Compare commits

...
Author SHA1 Message Date
Codeman maintainer 12a5f5919e chore: version packages 2026-08-05 22:36:51 +02:00
Codeman maintainer ecd3f3f32a harden(history): exclude automated transcripts by SDK shape, not by "not cli"
#215 filters non-interactive transcripts out of Past Sessions with
`entrypoint !== 'cli'`. That is an allowlist on a value, and the check
hides rows, so it fails CLOSED on anything Claude Code has not shipped
yet: the day it stamps a new interactive entrypoint (a rename, or a
second interactive host), no transcript matches 'cli' any more and the
entire Past Sessions list goes blank with nothing in the UI explaining
why.

Invert it to a blocklist on the SDK shape (`sdk`, `sdk-cli`, `sdk-py`).
An automated entrypoint we do not recognize yet now costs a few noisy
rows, which is the annoyance the filter set out to fix, rather than a
dead feature. Matches the fail-open reasoning #215 already applied to a
MISSING entrypoint field; only the unknown-VALUE case was inverted.

Test fails against the pre-fix line and passes after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:47:06 +02:00
Ark0N c19d884a51 Merge pull request #215 from timkjr/fix/past-sessions-history-quality
fix(history): three Past Sessions data-quality bugs (automated-session noise, cross-contaminated previews, blank restart-heavy rows)
2026-08-05 21:44:47 +02:00
Ark0N 22e77a1827 Merge pull request #214 from timkjr/fix/mobile-overview-run-gating
fix(mobile): gate the phone overview's run picker on CLI availability
2026-08-05 21:44:42 +02:00
Ark0N b641560040 Merge pull request #203 from shenlvkang-collab/contrib/claude-viewer-session-pin
fix(web): pin the Claude response viewer to the pane's own conversation
2026-08-05 21:44:37 +02:00
timkjr 8300c15cbd fix(history): entrypoint detection was first-field-wins, plus a two-tier head read
extractTranscriptEntrypoint returned the FIRST entrypoint-bearing message's
value instead of scanning for any 'cli' occurrence, so a transcript that
started under an older Claude Code build (no entrypoint field) and later
picked up a non-'cli' entrypoint on some later message was wrongly excluded
from history — the opposite of the fail-open behavior the function's own
comment claimed. Now returns 'cli' the moment any scanned message carries it,
and only falls back to a non-cli value when nothing else qualifies. Head/tail
entrypoints are merged the same way (either side being 'cli' wins).

Also restructures scanProjectDir's head read into two tiers: try 16KB first
and escalate to 128KB only when that wasn't enough, instead of reading 128KB
for every file unconditionally. Measured against a real ~/.claude/projects
tree, the unconditional-128KB version roughly quadrupled scan cost to fix a
problem only a minority of files actually have; the two-tier version cuts
bytes read by ~36% and wall time by ~17% while producing identical output.
Also fixes a fallback regression where a failed head read (e.g. EMFILE) on a
file at or under the head buffer size no longer got a shot at the tail-read
fallback, silently dropping the session from history.
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 09f5f28017 docs(test): correct an overclaiming comment in the tail-fallback regression test
The comment implied the fallback could be "silently skipped" by the
stale hardcoded threshold, which isn't actually true -- the old
smaller numbers were always more eager to trigger the fallback, never
less (same correction as the commit this test belongs to). What the
test actually protects against is the fallback logic itself breaking
(e.g. a copy-paste slip dropping the check entirely), not the exact
threshold value. Reworded to say that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 251706be3b harden: scope entrypoint detection to message lines, add fallback coverage
Two follow-ups from reviewing the entrypoint-filter and head-buffer
fixes before submitting them upstream:

1. extractTranscriptEntrypoint() scanned any line containing the
   substring "entrypoint", not specifically the first "type":"user"/
   "type":"assistant" message line (unlike its sibling
   extractFirstUserPrompt, which does scope to type). A transcript
   that started under an older Claude Code version (no entrypoint
   field) and got resumed under a newer one mid-conversation could
   pick up the field from a much later message than the true first
   one, misattributing the session's origin. Scoped it to match.

2. Added a regression test proving the tail-read fallback still
   engages correctly when bookkeeping accumulation exceeds even the
   new 128KB head window, not just the 16KB it previously blanked at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 18b473f0e4 fix(history): raise the transcript head-read window to fit restart bookkeeping
Blank firstPrompt rows weren't all oversized messages -- traced one
directly: a session restarted many times (mux deaths, redeploys)
accumulates a batch of small bookkeeping lines (mode/permission-mode/
last-prompt/queue-operation, one batch per restart) ahead of the real
first message. With enough restarts these alone crossed the old 16KB
head-read window, so extraction found nothing even though the actual
first message was tiny (measured case: ~17.5KB of bookkeeping pushed a
189-byte real message just past the boundary).

Raise the head buffer from 16KB to 128KB (matching the existing
precedent at the codex-history head-read a few hundred lines up) and
fix three now-stale `> 16384`/`> 65536` fallback thresholds to
reference headBuf.length instead of hardcoded numbers, so the tail-read
fallbacks stay correctly scoped to "beyond what head already covered."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 a2aed38073 fix(unified-sessions): stop the firstPrompt workingDir backfill from cross-contaminating history rows
COD-140's backfill was meant to cover live/persisted rows whose Codeman
id doesn't match an on-disk transcript UUID, guessing from the newest
transcript in the same workingDir as a last resort. It was also firing
for pure history rows whose OWN transcript scan already ran (and
genuinely found nothing, e.g. an oversized first message) -- those got
silently backfilled with the newest OTHER session's opening line from
the same directory. Not a blank row, but actively wrong: old sessions
displayed a completely unrelated (often today's live) conversation's
first prompt as if it were their own.

Skip the workingDir guess for any item that already has its own
'history' source -- it already had a real, direct attempt. Rows with
no history source at all (their transcript isn't linked/scanned under
their own id yet) still get the guess, matching the original intent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 e888c65c52 fix(history): exclude non-interactive (SDK-driven) transcripts from Past Sessions
Automated tools (CI review bots, etc.) invoke Claude Code via the SDK
and write their transcripts into the same ~/.claude/projects tree as
real interactive sessions, but were never something a user can resume
into -- no PTY, no running process. Their one-shot review prompts also
embed the full diff inline as a single message, often exceeding the
16KB head / 32KB tail windows this scanner reads, so they cluttered
Past Sessions two ways: as blank rows when the huge message couldn't
be parsed, or as N identical "Review this change for security
vulnerabilities..." rows when it could.

Claude Code stamps `entrypoint` on its own message records ('cli' for
a real interactive session, e.g. 'sdk-py' for an SDK invocation).
Exclude any transcript whose entrypoint isn't 'cli' from the history
list entirely, checked last so it reuses whatever head/tail the prompt
extraction already read. Missing entrypoint (older transcripts) reads
as interactive -- fail open, matching every other gating check in this
codebase. Shared by /api/history/sessions and /api/sessions/unified,
since both call the same scanProjectDir().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:18 -05:00
timkjrandClaude Sonnet 5 1ea39de650 fix(mobile): gate the phone overview's run picker on CLI availability
MOBILE_OVERVIEW_RUN_MODES / _buildMobileOverviewRunMenu is a separate,
hardcoded duplicate of the toolbar's #runModeMenu (mobile-overview.js
is a newer feature that mirrors the toolbar menu's look/behavior
rather than reusing its render), so it never picked up #201's
isCliAvailable() gating and offered every backend regardless of what
the server actually has installed.

Gate it the same way: skip an entry unless isCliAvailable(mode),
shell always exempt. Added functional + static regression tests
mirroring the toolbar menu's own test pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:11:15 -05:00
shenlvkang-collab ab7a703e90 fix(web): keep the viewer's conversation anchor across a Codeman restart
start() reassigns _claudeSessionId to `resumeSessionId || id` on every launch,
including the path that re-attaches to a mux session that outlived the restart.
A pane whose CLI had moved on via /clear therefore came back pointing the
response viewer at its pre-/clear transcript, and because Session.lastSubmitAt
lived only in memory, the history correlation had nothing to correct it with
until the user happened to type again — observed as hours of the eye showing a
conversation the pane had long since left.

Persist lastSubmitAt in SessionState, restore it in restoreMuxSessions(), and
flush it when the viewer adopts (a /clear emits no completion event, which is
the trigger that would otherwise have persisted it). Recovered panes now
re-derive their live conversation on the viewer's first poll.

Restoring a stale anchor is safe: the resolver already refuses a candidate
transcript older than the one the pane is currently on, which is the shape of a
respawn into a fresh conversation.
2026-08-03 21:22:33 +08:00
shenlvkang-collabandClaude Opus 5 73315bc351 fix(web): pin the Claude response viewer to the pane's own conversation
The viewer re-derived a pane's live conversation from the newest
~/.claude/history.jsonl entry for the pane's cwd. A cwd is shared with every
other Codeman tab on it, with tabs long since closed, and with any plain
`claude` the user runs in their own terminal, so the eye followed whichever of
those was typed into last — and since the match was written back through
adoptClaudeSessionId(), the mispin stuck.

Credit a history entry to a pane only when it lands within 10s of that pane's
own Enter and no other pane on the same cwd submitted closer, reusing the
last-submit correlation the Codex locator already relies on. Submit tracking
moves from _codexLastSubmitAt to a mode-agnostic Session.lastSubmitAt. With no
correlated entry the pane keeps the id it has: a viewer one turn behind beats a
viewer showing someone else's conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:53:42 +08:00
18 changed files with 917 additions and 104 deletions

No files matched your search

+64
View File
@@ -1,5 +1,69 @@
# aicodeman
## 1.11.1
### Patch Changes
- fix(history): Past Sessions data quality, and gate the phone run picker on CLI availability
**Past Sessions data quality (#215).** Three bugs in the transcript scanner behind
the Cmd+K Session Manager and the phone overview's PAST SESSIONS list:
- Automated/SDK-driven transcripts (CI review bots and other tooling, which Claude
Code stamps with a non-`cli` `entrypoint`) were listed alongside real interactive
sessions even though they were never resumable. They are now excluded. Detection
scans every entrypoint-bearing message rather than stopping at the first, so a
transcript that began under an older Claude Code build and only later picked up a
non-`cli` entrypoint is no longer wrongly hidden.
- A resumed session could show a same-directory sibling's preview text as its own.
The `workingDir` backfill in `mergeUnifiedSessions()` now only ever applies to rows
that have no history entry of their own, so it can no longer overwrite a row's real
content with another conversation's.
- Sessions restarted many times accumulated enough bookkeeping lines to push the real
first prompt past the scanner's 16KB head-read window, leaving a blank row. The read
is now two-tier: 16KB first, escalating to 128KB only when that was not enough, which
is both correct and cheaper than reading 128KB unconditionally (measured on a real
transcript tree: 36% fewer bytes read, roughly 17.5% faster than the unconditional
version). Also restores the tail-read fallback for a file whose head read failed
outright (for example `EMFILE` while scanning hundreds of files), which had been
silently dropping the session from history.
Follow-up hardening on top of the above: the automated-transcript exclusion now
blocklists the SDK entrypoint shape (`sdk`, `sdk-cli`, `sdk-py`) instead of allowlisting
the exact value `cli`. Because the check hides rows, an allowlist failed closed on any
value Claude Code has not shipped yet: a future rename of the interactive entrypoint,
or a second interactive host, would have blanked the entire Past Sessions list with
nothing in the UI to explain it. An unrecognized automated entrypoint now costs a few
noisy rows instead, which is the annoyance this filter set out to fix rather than a
broken feature.
**Phone overview run picker (#214).** The "C" logo home screen's Run picker listed all
six backends regardless of what was installed, so tapping an uninstalled one produced a
failed launch instead of the entry simply not being offered. It is now gated on
`isCliAvailable()` exactly like the desktop toolbar's run-mode dropdown (shell exempt,
since it has no external CLI dependency and keeps the menu from ever being empty). The
picker is a hardcoded duplicate of the toolbar menu rather than a shared render, which
is why it never picked up the earlier gating work; a test now asserts that every mode
the picker offers is gated, so a newly added backend cannot silently drift again.
- 73315bc: fix(web): stop the Claude response viewer from following another session's conversation
The viewer re-derived a pane's live conversation by taking the newest
`~/.claude/history.jsonl` entry for the pane's cwd. A cwd is shared with every
other Codeman tab on it, with tabs long since closed, and with any plain
`claude` run in the user's own terminal, so the eye followed whichever of those
was typed into last — and the adoption was written back to the session, so the
mispin persisted. Entries are now credited to a pane only when they land within
10s of that pane's own Enter and no other pane on the cwd submitted closer, the
same last-submit correlation the Codex locator already uses.
That correlation also has to survive a restart. `start()` resets
`claudeSessionId` to the launch id even when re-attaching to a mux session whose
CLI has since moved on via `/clear`, so a recovered pane pointed the viewer at
its pre-`/clear` transcript — and with the anchor itself living only in memory,
nothing corrected it until the user happened to type again. `lastSubmitAt` is
now persisted in `SessionState` and restored on boot recovery, so the viewer
re-derives the live conversation on its first poll.
## 1.11.0
### Minor Changes
+1 -1
View File
@@ -74,7 +74,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.11.0 (must match `package.json`)
**Version**: 1.11.1 (must match `package.json`)
## Project Overview
+4
View File
@@ -168,6 +168,10 @@ Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execComm
**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`.
⚠️ **A Claude pane's conversation is identified by the pane's own Enter, never by "newest entry for this cwd".** `~/.claude/history.jsonl` records every submitted prompt as `{project, sessionId, timestamp}`, and `/clear` moves the pane to a fresh `<uuid>.jsonl` that nothing on the PTY announces — so the viewer has to re-derive the live conversation. Keying that off `project` alone was the bug: a cwd is shared with every other Codeman tab on it, with tabs long since closed, and with any plain `claude` the user runs in their own terminal, so the eye followed whichever of those conversations was typed into last and showed a stranger's transcript. `resolveActiveClaudeSessionIdFromHistory()` instead credits an entry to a pane only when it lands within `CLAUDE_SUBMIT_MATCH_MS` of that pane's `Session.lastSubmitAt` **and** no other pane on the same cwd submitted closer — the same last-submit correlation the Codex locator uses. With no correlated entry the pane keeps the id it has: a viewer one turn behind beats a viewer showing someone else's conversation.
⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.
⚠️ **Claude transcripts are grouped at real human-turn boundaries, not per JSONL row.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. Rendering a card per row was the bug: it produced duplicate and truncated cards that looked like the viewer had lost the response. The grouping walks to the next genuine user turn and dedups replayed assistant snapshots while preserving the tool/task/skill/compact/team metadata filtering. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Display → Header Displays); cron jobs themselves are unaffected.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.11.0",
"version": "1.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.11.0",
"version": "1.11.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.11.0",
"version": "1.11.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+13 -2
View File
@@ -226,6 +226,16 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
// firstPrompt (so rows keyed to their own transcript are untouched).
//
// The workingDir guess is a last resort and MUST be skipped for any item that
// already has its own 'history' entry (step 1 above already gave it a real,
// direct scan of its own transcript). Without this guard, a history row whose
// OWN extraction genuinely failed (oversized first message, etc.) silently
// inherited the newest OTHER session's opening line from the same directory —
// not a blank, but actively wrong: old sessions displayed today's conversation
// as if it were their own. A row with no 'history' source at all (its
// transcript hasn't been linked/scanned under its own id yet) has no such
// direct attempt to prefer, so the guess remains a reasonable stand-in there.
const firstPromptByUuid = new Map<string, string>();
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
@@ -254,12 +264,13 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
}
}
for (const item of map.values()) {
const hasOwnHistoryEntry = item.sources.includes('history');
if (!item.firstPrompt) {
// never overwrite an existing non-empty prompt
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
if (byUuid) {
item.firstPrompt = byUuid;
} else if (item.workingDir) {
} else if (item.workingDir && !hasOwnHistoryEntry) {
const byDir = firstPromptByWorkingDir.get(item.workingDir);
if (byDir) item.firstPrompt = byDir.prompt;
}
@@ -268,7 +279,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
if (byUuid) {
item.lastPrompt = byUuid;
} else if (item.workingDir) {
} else if (item.workingDir && !hasOwnHistoryEntry) {
const byDir = lastPromptByWorkingDir.get(item.workingDir);
if (byDir) item.lastPrompt = byDir.prompt;
}
+24 -13
View File
@@ -509,6 +509,8 @@ export class Session extends EventEmitter {
tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */
attachmentHistory?: SessionAttachmentHistoryItem[];
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
lastSubmitAt?: number;
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
remote?: SessionRemote;
/** Docker execution metadata for sessions launched inside a container via local tmux. */
@@ -535,6 +537,12 @@ export class Session extends EventEmitter {
this._lastActivityAt = this.createdAt;
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = config.resumeSessionId || this.id;
// Restored from state.json on boot recovery. start() resets _claudeSessionId
// to the launch id even when re-attaching to a mux session whose CLI has
// moved on (a `/clear` before the restart), so this anchor is what lets the
// response viewer re-derive the live conversation without waiting for the
// user to type again.
this._lastSubmitAt = config.lastSubmitAt ?? 0;
this._mux = config.mux || null;
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
this._muxSession = config.muxSession || null;
@@ -1135,6 +1143,7 @@ export class Session extends EventEmitter {
// recovery can re-attach.
respawnBlocked: this._respawnBlocked || undefined,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
lastSubmitAt: this._lastSubmitAt || undefined,
// envOverrides intentionally NOT on the public SessionState type — they must not
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
// can carry secrets). For disk persistence, session-manager calls
@@ -2543,26 +2552,28 @@ export class Session extends EventEmitter {
* ```
*/
write(data: string): void {
this._trackCodexSubmit(data);
this._trackSubmit(data);
if (this.ptyProcess) {
this.ptyProcess.write(data);
}
}
// ── Codex thread tracking ─────────────────────────────────────────────
// When a codex pane last submitted a message (Enter). The response-viewer
// correlates this against ~/.codex/history.jsonl entry timestamps to find
// the thread the pane is ACTUALLY on — the only signal that survives
// /resume, /new and /fork typed inside the codex TUI itself.
private _codexLastSubmitAt = 0;
// ── Conversation tracking ─────────────────────────────────────────────
// When this pane last submitted a message (Enter). The response-viewer
// correlates this against the CLI's own history.jsonl entry timestamps to
// find the conversation the pane is ACTUALLY on — the only signal that
// survives /clear, /resume, /new and /fork typed inside the TUI itself,
// none of which announce themselves on the PTY's stdout.
private _lastSubmitAt = 0;
get codexLastSubmitAt(): number {
return this._codexLastSubmitAt;
/** Wall-clock ms of this pane's last Enter; 0 if it has never submitted. */
get lastSubmitAt(): number {
return this._lastSubmitAt;
}
private _trackCodexSubmit(data: string): void {
if (this.mode === 'codex' && (data.includes('\r') || data.includes('\n'))) {
this._codexLastSubmitAt = Date.now();
private _trackSubmit(data: string): void {
if (data.includes('\r') || data.includes('\n')) {
this._lastSubmitAt = Date.now();
}
}
@@ -2619,7 +2630,7 @@ export class Session extends EventEmitter {
* ```
*/
async writeViaMux(data: string): Promise<boolean> {
this._trackCodexSubmit(data);
this._trackSubmit(data);
if (this._mux && this._muxSession) {
return this._mux.sendInput(this.id, data);
}
+9
View File
@@ -480,6 +480,15 @@ export interface SessionState {
effort?: EffortLevel;
/** Sanitized per-session attachment history. */
attachmentHistory?: SessionAttachmentHistoryItem[];
/**
* Wall-clock ms of this pane's last Enter (Session.lastSubmitAt). Persisted
* because it is the response-viewer's only anchor for re-deriving the pane's
* live conversation after a Codeman restart: `start()` resets
* `claudeSessionId` to the launch id even when re-attaching to a mux session
* whose CLI has since moved on via `/clear`, and the correlation cannot run
* again until the pane's own Enter is known.
*/
lastSubmitAt?: number;
/**
* PTY-exit circuit breaker tripped — respawn blocked until an explicit restart
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
+6
View File
@@ -486,6 +486,11 @@ Object.assign(CodemanApp.prototype, {
* The Run picker: the same backends as the toolbar's run-mode menu, plus saved
* web tabs. Deliberately no "Recent Sessions" block, unlike the toolbar menu:
* past conversations have their own section further down this screen.
*
* Gated the same way as the toolbar's #runModeMenu (isCliAvailable(), shell
* exempt) — this list is a separate, hardcoded duplicate of the toolbar's menu
* rather than a shared render, so it never picked up #201's gating and offered
* every backend regardless of what's actually installed.
*/
_buildMobileOverviewRunMenu() {
const menu = document.createElement('div');
@@ -493,6 +498,7 @@ Object.assign(CodemanApp.prototype, {
const current = this.runMode || 'claude';
for (const entry of MOBILE_OVERVIEW_RUN_MODES) {
if (entry.mode !== 'shell' && !this.isCliAvailable(entry.mode)) continue;
const option = document.createElement('button');
option.type = 'button';
option.className = 'mobile-overview-run-option' + (entry.mode === current ? ' selected' : '');
+219 -81
View File
@@ -965,80 +965,97 @@ export function registerSessionRoutes(
// ========== Get Last Response (from transcript JSONL) ==========
// Resolves the most recent Claude conversation id for a session's cwd by
// tailing ~/.claude/history.jsonl. After `/clear`, Claude Code keeps writing
// to a new <uuid>.jsonl; history.jsonl is the only source-of-truth update
// that does not rely on project-local hooks (we intentionally don't install
// hooks in arbitrary user repos, see the POST /api/sessions comment).
// How far apart a ~/.claude/history.jsonl entry and a pane's Enter may be and
// still be the same submission. Claude appends to history as it accepts the
// prompt, so the true gap is milliseconds — this is slack for a loaded box,
// not a search radius.
const CLAUDE_SUBMIT_MATCH_MS = 10_000;
// history.jsonl grows forever; only the tail can hold entries near a submit.
const CLAUDE_HISTORY_TAIL_BYTES = 256 * 1024;
// Resolves the Claude conversation id THIS pane is currently on by matching
// ~/.claude/history.jsonl (which logs every submitted prompt as
// {project, sessionId, timestamp}) against the pane's last Enter. After
// `/clear` Claude keeps writing to a new <uuid>.jsonl, and history.jsonl is
// the only source-of-truth update that does not rely on project-local hooks
// (we intentionally don't install hooks in arbitrary user repos, see the
// POST /api/sessions comment).
//
// Entries from OTHER Codeman sessions in the same cwd are filtered out by
// their known claudeSessionIds so concurrent tabs don't shadow each other,
// as long as each has had its id resolved at least once.
// The pane's own Enter is what makes an entry OURS. `project` alone is not:
// a cwd is shared by every other Codeman tab on it, by tabs long since
// closed, and by any plain `claude` the user runs in their own terminal —
// adopting the newest entry for the cwd pinned the viewer to whichever of
// those conversations was typed in last, so the eye showed a stranger's
// transcript. With no correlated entry we keep the id we have; a viewer one
// turn behind beats a viewer showing someone else's conversation.
const claudeHistoryPinCache = new LRUMap<string, { submitAt: number; claudeSessionId: string }>({ maxSize: 1024 });
async function resolveActiveClaudeSessionIdFromHistory(
session: Session,
projectsDir: string
): Promise<string | null> {
const historyPath = join(homedir(), '.claude', 'history.jsonl');
const submitAt = session.lastSubmitAt;
if (!submitAt) return null; // never typed through Codeman — nothing to credit
const cached = claudeHistoryPinCache.get(session.id);
if (cached && cached.submitAt === submitAt) return cached.claudeSessionId;
// Ids another live pane is already pinned to can never be ours, and every
// pane sharing this cwd competes for the entry we are about to claim —
// including non-Claude panes, since a shell pane can run `claude` too.
const otherClaudeIds = new Set<string>();
const otherSubmits: number[] = [];
for (const s of ctx.sessions.values()) {
if (s.id !== session.id && s.workingDir === session.workingDir && s.claudeSessionId) {
otherClaudeIds.add(s.claudeSessionId);
}
if (s.id === session.id || s.workingDir !== session.workingDir) continue;
if (s.claudeSessionId) otherClaudeIds.add(s.claudeSessionId);
if (s.lastSubmitAt) otherSubmits.push(s.lastSubmitAt);
}
let candidateSid: string | null = null;
try {
const content = await fs.readFile(historyPath, 'utf8');
const lines = content.split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (!line) continue;
try {
const entry = JSON.parse(line) as { project?: string; sessionId?: string };
if (
entry.project === session.workingDir &&
typeof entry.sessionId === 'string' &&
!otherClaudeIds.has(entry.sessionId)
) {
candidateSid = entry.sessionId;
break;
}
} catch {
// Skip unparseable lines
}
}
} catch {
return null;
}
if (!candidateSid || candidateSid === session.id) return candidateSid;
const historyPath = join(homedir(), '.claude', 'history.jsonl');
const stat = await fs.stat(historyPath).catch(() => null);
if (!stat || stat.size === 0) return null;
const tail = await readFileTail(historyPath, Buffer.alloc(CLAUDE_HISTORY_TAIL_BYTES), stat.size);
if (!tail) return null;
// Safety: only adopt if the candidate's jsonl is more recently written
// than our initial conversation's jsonl. Blocks stale ids inherited from
// a prior Codeman session that happened to share this cwd.
try {
const projectDirs = await fs.readdir(projectsDir);
let best: { sessionId: string; dist: number } | undefined;
for (const line of tail.split('\n')) {
if (!line) continue;
let entry: { project?: string; sessionId?: string; timestamp?: number };
try {
entry = JSON.parse(line) as typeof entry;
} catch {
continue; // the first tail line is usually cut mid-JSON
}
const { sessionId, timestamp } = entry;
if (entry.project !== session.workingDir) continue;
if (typeof sessionId !== 'string' || !sessionId) continue;
if (typeof timestamp !== 'number') continue;
if (otherClaudeIds.has(sessionId)) continue;
const dist = Math.abs(timestamp - submitAt);
if (dist > CLAUDE_SUBMIT_MATCH_MS) continue;
if (otherSubmits.some((other) => Math.abs(timestamp - other) < dist)) continue; // another pane is closer
if (!best || dist <= best.dist) best = { sessionId, dist }; // ties: the newer entry wins
}
if (!best) return null;
// Sanity: the conversation we switch to must exist on disk and must not be
// staler than the one we are leaving. A `/clear` successor never is.
const currentSessionId = session.claudeSessionId || session.id;
if (best.sessionId !== currentSessionId) {
const projectDirs = await fs.readdir(projectsDir).catch(() => null);
if (!projectDirs) return null;
let candidateMtime = 0;
let initialMtime = 0;
let currentMtime = 0;
for (const projDir of projectDirs) {
try {
const cs = await fs.stat(join(projectsDir, projDir, `${candidateSid}.jsonl`));
if (cs.mtimeMs > candidateMtime) candidateMtime = cs.mtimeMs;
} catch {
/* not in this dir */
}
try {
const is = await fs.stat(join(projectsDir, projDir, `${session.id}.jsonl`));
if (is.mtimeMs > initialMtime) initialMtime = is.mtimeMs;
} catch {
/* not in this dir */
}
const candidateStat = await fs.stat(join(projectsDir, projDir, `${best.sessionId}.jsonl`)).catch(() => null);
if (candidateStat && candidateStat.mtimeMs > candidateMtime) candidateMtime = candidateStat.mtimeMs;
const currentStat = await fs.stat(join(projectsDir, projDir, `${currentSessionId}.jsonl`)).catch(() => null);
if (currentStat && currentStat.mtimeMs > currentMtime) currentMtime = currentStat.mtimeMs;
}
if (candidateMtime === 0) return null;
if (initialMtime > 0 && candidateMtime <= initialMtime) return null;
} catch {
return null;
if (candidateMtime === 0) return null; // transcript not written yet — retry next poll
if (currentMtime > 0 && candidateMtime < currentMtime) return null;
}
return candidateSid;
claudeHistoryPinCache.set(session.id, { submitAt, claudeSessionId: best.sessionId });
return best.sessionId;
}
interface ClaudeResponseMessage {
@@ -1236,6 +1253,11 @@ export function registerSessionRoutes(
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
if (activeId && activeId !== session.claudeSessionId) {
session.adoptClaudeSessionId(activeId);
// Flush the Enter that vouched for this adoption to state.json. A `/clear`
// emits no completion event, so without this the anchor could still be
// unpersisted when the server restarts — and recovery would fall back to
// the launch conversation.
ctx.persistSessionState(session);
// Docker sessions: keep the case's resume seed following the live conversation.
if (session.docker) {
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
@@ -1309,7 +1331,7 @@ export function registerSessionRoutes(
return { cwd, originator };
}
// The pane's last Enter (Session.codexLastSubmitAt) correlated against
// The pane's last Enter (Session.lastSubmitAt) correlated against
// ~/.codex/history.jsonl, which logs every submitted user message as
// {session_id, ts}. This identifies the thread the pane is ACTUALLY on and
// is the only signal that survives /resume, /new and /fork typed inside the
@@ -1318,10 +1340,10 @@ export function registerSessionRoutes(
// can't steal the attribution.
const codexHistoryPinCache = new LRUMap<string, { submitAt: number; threadId: string }>({ maxSize: 1024 });
async function resolveCodexThreadFromHistory(
session: { id: string; codexLastSubmitAt?: number },
session: { id: string; lastSubmitAt?: number },
codexHome: string
): Promise<string | null> {
const submitAt = session.codexLastSubmitAt || 0;
const submitAt = session.lastSubmitAt || 0;
if (!submitAt) return null;
const cached = codexHistoryPinCache.get(session.id);
if (cached && cached.submitAt === submitAt) return cached.threadId;
@@ -1335,8 +1357,8 @@ export function registerSessionRoutes(
const WINDOW_MS = 15_000;
const otherSubmits: number[] = [];
for (const s of ctx.sessions.values()) {
if (s.id !== session.id && s.mode === 'codex' && s.codexLastSubmitAt) {
otherSubmits.push(s.codexLastSubmitAt);
if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) {
otherSubmits.push(s.lastSubmitAt);
}
}
@@ -1379,7 +1401,7 @@ export function registerSessionRoutes(
async function findActiveCodexFile(session: {
id: string;
workingDir: string;
codexLastSubmitAt?: number;
lastSubmitAt?: number;
codexConfig?: { resumeSessionId?: string };
}): Promise<string | null> {
const codexHome = process.env.CODEX_HOME || join(process.env.HOME || '/tmp', '.codex');
@@ -2456,6 +2478,64 @@ export function registerSessionRoutes(
return undefined;
}
/**
* Is this `entrypoint` value an automated/SDK-driven invocation?
*
* ⚠️ Deliberately a BLOCKLIST on the SDK shape, not an allowlist on `'cli'`.
* The exclusion below hides rows, so an allowlist fails CLOSED on any value
* Claude Code has not shipped yet: the day it stamps a new interactive
* entrypoint (a rename, or a second interactive host), every transcript stops
* matching `'cli'` and the whole Past Sessions list goes blank with nothing in
* the UI to explain it. A blocklist fails OPEN instead — an automated
* entrypoint we do not recognize yet costs a few noisy rows, which is the
* annoyance this filter set out to fix rather than a broken feature.
*
* Observed values: `cli` (interactive), `sdk-cli` / `sdk-py` (automated).
*/
function isAutomatedEntrypoint(entrypoint: string): boolean {
return /^sdk(-|$)/.test(entrypoint);
}
/**
* The `entrypoint` field Claude Code stamps on its own message records:
* 'cli' for a real interactive session, something else (e.g. 'sdk-py') for
* an SDK/automated invocation. Used to exclude non-interactive transcripts
* (CI review bots, etc.) from the resumable history list — they were never
* something a user can resume into.
*
* Scans every `"type":"user"`/`"type":"assistant"` line with an entrypoint
* field — not just the first one — and returns 'cli' the moment ANY of them
* carries it. A transcript is excluded only when every entrypoint-bearing
* message says something else; "first field wins" would misattribute a
* transcript that started under an older Claude Code version (no entrypoint
* on its true first message) and later picked up a non-'cli' entrypoint on
* some later message, wrongly hiding a genuinely interactive session. This
* deliberately errs toward keeping a session visible: one real interactive
* message anywhere is enough. Returns undefined ("unknown", fail-open) only
* when nothing scanned carries the field at all.
*/
function extractTranscriptEntrypoint(text: string): string | undefined {
let start = 0;
let sawNonCli: string | undefined;
while (start < text.length) {
const end = text.indexOf('\n', start);
const line = end === -1 ? text.slice(start) : text.slice(start, end);
start = end === -1 ? text.length : end + 1;
if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue;
if (!line.includes('"entrypoint"')) continue;
try {
const entry = JSON.parse(line);
if ((entry.type === 'user' || entry.type === 'assistant') && typeof entry.entrypoint === 'string') {
if (entry.entrypoint === 'cli') return 'cli';
sawNonCli ??= entry.entrypoint;
}
} catch {
// Malformed/truncated line — skip
}
}
return sawNonCli;
}
/**
* Extract the text of the LAST user message from a JSONL transcript chunk
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
@@ -2668,7 +2748,7 @@ export function registerSessionRoutes(
return finalExists ? current : process.env.HOME || '/tmp';
}
/** Read the first 16KB of a file for content sniffing. */
/** Read the first `buf.length` bytes of a file for content sniffing. */
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
try {
const fd = await fs.open(path, 'r');
@@ -2711,7 +2791,12 @@ export function registerSessionRoutes(
// Scan a single project directory and return all valid history sessions in it.
// Reused by both the global overview and the single-folder drill-down.
async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise<HistorySession[]> {
async function scanProjectDir(
projPath: string,
projDir: string,
smallHeadBuf: Buffer,
headBuf: Buffer
): Promise<HistorySession[]> {
const out: HistorySession[] = [];
const stat = await fs.stat(projPath).catch(() => null);
if (!stat?.isDirectory()) return out;
@@ -2729,22 +2814,45 @@ export function registerSessionRoutes(
if (!fileStat) continue;
if (fileStat.size < 4000) continue;
let firstPrompt: string | undefined;
const head = await readFileHead(filePath, headBuf);
const hasConversation = (text: string) =>
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
// Two-tier head read: try the cheap smallHeadBuf (16KB) size first -- enough
// for the vast majority of transcripts -- and only escalate to the full
// headBuf (128KB) when that wasn't enough. Reading 128KB unconditionally for
// EVERY file in the directory roughly quadrupled the cost of a full scan
// (measured against a real ~/.claude/projects tree: ~4x both bytes read and
// wall time) to fix a problem only ~28% of files actually have. Escalating
// resolves the restart-bookkeeping case (the reason 128KB exists at all)
// without ever touching the tail-read fallback below for most of that 28%.
let head = await readFileHead(filePath, smallHeadBuf);
let foundContent = head ? hasConversation(head) : false;
let firstPrompt = head ? extractFirstUserPrompt(head) : undefined;
if ((!foundContent || !firstPrompt) && head !== null && fileStat.size > smallHeadBuf.length) {
const biggerHead = await readFileHead(filePath, headBuf);
if (biggerHead) {
head = biggerHead;
if (!foundContent) foundContent = hasConversation(head);
if (!firstPrompt) firstPrompt = extractFirstUserPrompt(head);
}
}
let tail: string | null = null;
if (!foundContent && fileStat.size > 16384) {
// `head === null` (a failed read -- e.g. EMFILE while scanning hundreds of
// files) must also get a shot at the tail, not just "file bigger than the
// head buffer". Losing this dropped the session from history entirely
// instead of giving it a second chance, for any file at or under the head
// buffer size whose head read happened to fail.
if (!foundContent && (head === null || fileStat.size > headBuf.length)) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (tail) foundContent = hasConversation(tail);
}
if (!foundContent) continue;
if (head) firstPrompt = extractFirstUserPrompt(head);
if (!firstPrompt && fileStat.size > 65536) {
// firstPrompt was already attempted from head (both tiers) above; this is
// purely the tail fallback for whatever's left unresolved.
if (!firstPrompt && (head === null || fileStat.size > headBuf.length)) {
if (!tail) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
@@ -2754,15 +2862,37 @@ export function registerSessionRoutes(
// COD-145: last (most recent) user prompt lives near the END of the file, so
// prefer the tail. For large files where no tail was read yet, read one
// (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
// then contains the whole transcript — scan it for the last match instead.
if (!tail && fileStat.size > 65536) {
// (mirrors the firstPrompt > headBuf.length block). Small files fit in `head`,
// which then contains the whole transcript — scan it for the last match instead.
if (!tail && fileStat.size > headBuf.length) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
const lastPrompt =
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
// Automated/SDK-driven invocations (CI review bots, etc.) write transcripts
// into the same ~/.claude/projects tree as interactive sessions but were
// never something a user can resume into — no PTY, no running process, and
// their "conversation" is typically a single one-shot prompt (often with a
// full diff embedded, which is exactly why it dwarfs this scanner's read
// windows and shows up above as blank or as an identical boilerplate
// sentence across many rows). Checked last, so it reuses whatever `head`/
// `tail` the prompt extraction above already read rather than triggering
// an extra file read. Missing entrypoint (older transcripts) reads as
// interactive — fail open, matching every other gating check in this
// codebase.
//
// head and tail are checked independently and merged with "cli wins" (not
// a first-truthy-value `??` chain): a large file's head might land on a
// non-'cli' message while a real interactive message sits in the tail (or
// vice versa), and either one being 'cli' is enough to keep the session.
const headEntrypoint = head ? extractTranscriptEntrypoint(head) : undefined;
const tailEntrypoint = tail ? extractTranscriptEntrypoint(tail) : undefined;
const entrypoint =
headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint);
if (entrypoint && isAutomatedEntrypoint(entrypoint)) continue;
out.push({
sessionId,
workingDir,
@@ -2779,7 +2909,13 @@ export function registerSessionRoutes(
app.get('/api/history/sessions', async (req) => {
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
const headBuf = Buffer.alloc(16384);
// scanProjectDir tries smallHeadBuf (16KB, the original size) first for every
// file and only escalates to headBuf (128KB) when that wasn't enough — see the
// comment at the escalation site in scanProjectDir for why unconditional 128KB
// reads were too expensive to keep. 128KB matches the existing precedent
// elsewhere in this file (line ~1431).
const smallHeadBuf = Buffer.alloc(16384);
const headBuf = Buffer.alloc(131072);
// Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
// must only see history whose decoded workingDir is inside their own case space.
// Do NOT trust the caller-supplied projectKey — confine on the decoded path.
@@ -2797,7 +2933,7 @@ export function registerSessionRoutes(
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
const projPath = join(projectsDir, query.projectKey);
let all = await scanProjectDir(projPath, query.projectKey, headBuf);
let all = await scanProjectDir(projPath, query.projectKey, smallHeadBuf, headBuf);
// Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
@@ -2810,7 +2946,7 @@ export function registerSessionRoutes(
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const projPath = join(projectsDir, projDir);
const list = await scanProjectDir(projPath, projDir, headBuf);
const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
results.push(...list);
}
} catch {
@@ -2886,11 +3022,13 @@ export function registerSessionRoutes(
const history: HistoryInput[] = [];
try {
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
const headBuf = Buffer.alloc(16384);
// See the sibling allocation above for why there are two sizes.
const smallHeadBuf = Buffer.alloc(16384);
const headBuf = Buffer.alloc(131072);
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const projPath = join(projectsDir, projDir);
const list = await scanProjectDir(projPath, projDir, headBuf);
const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
for (const h of list) {
history.push({
sessionId: h.sessionId,
+4
View File
@@ -2508,6 +2508,10 @@ export class WebServer extends EventEmitter {
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though
// the re-attached CLI is on a post-`/clear` one.
lastSubmitAt: savedState?.lastSubmitAt,
// Remote SSH metadata must round-trip on recovery: without it the
// attach cwd falls back to the (nonexistent-locally) remote path and
// respawn rebuilds a LOCAL command, breaking the pane and silently
+66 -1
View File
@@ -12,13 +12,34 @@ import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
/** Minimal fake DOM node — enough surface for mobile-overview.js's programmatic builders. */
function fakeElement(): any {
const el: any = {
className: '',
type: '',
dataset: {},
style: {},
children: [] as any[],
setAttribute() {},
appendChild(child: any) {
el.children.push(child);
return child;
},
};
return el;
}
function loadOverviewApp(overrides: Record<string, any> = {}) {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
console,
window: {},
document: { getElementById: () => null },
document: {
getElementById: () => null,
createElement: () => fakeElement(),
createElementNS: () => fakeElement(),
},
MobileDetection: { getDeviceType: () => 'mobile' },
});
vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, {
@@ -272,3 +293,47 @@ describe('mobile overview wiring', () => {
expect(hardcoded).toEqual([]);
});
});
describe('mobile overview run picker (CLI availability gating)', () => {
function modeButtons(menu: any): string[] {
return menu.children.filter((c: any) => c.dataset.moAction === 'run-mode').map((c: any) => c.dataset.moMode);
}
// #201 gated the toolbar's #runModeMenu on isCliAvailable(); this phone-only
// picker (MOBILE_OVERVIEW_RUN_MODES / _buildMobileOverviewRunMenu) is a
// separate, hardcoded duplicate of that menu rather than a shared render, so
// it silently offered every backend regardless of what the server reported.
it('hides run modes the server reports as unavailable, keeps shell always', () => {
const app = loadOverviewApp({
runMode: 'claude',
isCliAvailable: (tool: string) => tool === 'claude',
});
const menu = app._buildMobileOverviewRunMenu();
expect(modeButtons(menu)).toEqual(['claude', 'shell']);
});
it('shows every mode when every CLI is available', () => {
const app = loadOverviewApp({
runMode: 'claude',
isCliAvailable: () => true,
});
const menu = app._buildMobileOverviewRunMenu();
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']);
});
it('gates every mode the picker actually offers', () => {
// Catches a new backend being added to MOBILE_OVERVIEW_RUN_MODES without
// being gated — the same class of bug that let this list drift from the
// toolbar menu's gating in the first place.
const src = readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8');
const modesBlock = src.slice(
src.indexOf('const MOBILE_OVERVIEW_RUN_MODES'),
src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2
);
const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]);
expect(offered).toContain('antigravity');
const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {'));
const gate = fn.slice(0, fn.indexOf('const header'));
expect(gate).toContain('isCliAvailable');
});
});
+2
View File
@@ -19,6 +19,8 @@ export class MockSession extends EventEmitter {
ralphTracker: null = null;
writeBuffer: string[] = [];
terminalBuffer: string = '';
/** Mirrors Session.lastSubmitAt — the response viewer credits history entries by it. */
lastSubmitAt: number = 0;
private _muxName: string | null = null;
@@ -8,7 +8,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
@@ -176,3 +176,131 @@ describe('GET /api/sessions/:id/last-response (claude)', () => {
]);
});
});
/**
* Which conversation a pane is on is decided by the pane's own Enter, not by
* "newest entry for this cwd" — a cwd is shared with every other tab on it,
* with tabs long since closed, and with any plain `claude` the user runs in
* their own terminal.
*/
describe('GET /api/sessions/:id/last-response (claude conversation pinning)', () => {
let harness: LocalHarness;
let testHome: string;
let previousHome: string | undefined;
const WORKDIR = '/workspace';
const NOW = 1_770_000_000_000;
beforeEach(async () => {
testHome = mkdtempSync(join(tmpdir(), 'codeman-claude-pin-'));
previousHome = process.env.HOME;
process.env.HOME = testHome;
harness = await createEnvelopeHarness();
});
afterEach(async () => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
rmSync(testHome, { recursive: true, force: true });
await harness.app.close();
});
/** A transcript whose only assistant turn is `text`, stamped at `mtimeMs`. */
function writeTranscript(conversationId: string, text: string, mtimeMs: number): void {
const projectDir = join(testHome, '.claude', 'projects', '-workspace');
mkdirSync(projectDir, { recursive: true });
const path = join(projectDir, `${conversationId}.jsonl`);
writeFileSync(
path,
JSON.stringify({
type: 'assistant',
timestamp: new Date(mtimeMs).toISOString(),
message: { content: [{ type: 'text', text }] },
})
);
utimesSync(path, mtimeMs / 1000, mtimeMs / 1000);
}
function writeHistory(entries: Array<{ sessionId: string; timestamp: number; project?: string }>): void {
const claudeDir = join(testHome, '.claude');
mkdirSync(claudeDir, { recursive: true });
writeFileSync(
join(claudeDir, 'history.jsonl'),
entries
.map((entry) => JSON.stringify({ display: 'prompt', project: entry.project ?? WORKDIR, ...entry }))
.join('\n')
);
}
/** Replaces the pre-seeded mock session with a Claude pane in WORKDIR. */
function addPane(id: string, conversationId: string, lastSubmitAt: number) {
const base = harness.ctx._session;
const pane = Object.create(Object.getPrototypeOf(base)) as typeof base & {
claudeSessionId: string;
lastSubmitAt: number;
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
};
Object.assign(pane, base, { id, mode: 'claude', workingDir: WORKDIR, docker: undefined });
pane.claudeSessionId = conversationId;
pane.lastSubmitAt = lastSubmitAt;
pane.adoptClaudeSessionId = vi.fn((newId: string) => {
pane.claudeSessionId = newId;
});
harness.ctx.sessions.set(id, pane);
return pane;
}
async function getLastResponse(sessionId: string) {
const response = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/last-response` });
return JSON.parse(response.body).data as { text: string };
}
it('does not adopt a conversation from another claude process sharing the cwd', async () => {
// The pane typed hours ago; a `claude` running in the user's own terminal
// is the newest thing in this cwd. Before this fix the viewer followed it.
const pane = addPane('pane-1', 'pane-conversation', NOW - 6 * 3600_000);
writeTranscript('pane-conversation', 'my own answer', NOW - 6 * 3600_000);
writeTranscript('someone-elses-conversation', 'a stranger answer', NOW);
writeHistory([{ sessionId: 'someone-elses-conversation', timestamp: NOW }]);
expect(await getLastResponse('pane-1')).toEqual({ text: 'my own answer', timestamp: expect.any(String) });
expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled();
});
it('follows /clear onto the new conversation the pane submitted into', async () => {
const pane = addPane('pane-1', 'before-clear', NOW);
writeTranscript('before-clear', 'answer before clear', NOW - 60_000);
writeTranscript('after-clear', 'answer after clear', NOW + 500);
writeHistory([{ sessionId: 'after-clear', timestamp: NOW + 120 }]);
expect(await getLastResponse('pane-1')).toEqual({ text: 'answer after clear', timestamp: expect.any(String) });
expect(pane.adoptClaudeSessionId).toHaveBeenCalledWith('after-clear');
});
it('stays put when the pane has never submitted through Codeman', async () => {
const pane = addPane('pane-1', 'pane-conversation', 0);
writeTranscript('pane-conversation', 'my own answer', NOW - 60_000);
writeTranscript('unrelated-conversation', 'a stranger answer', NOW);
writeHistory([{ sessionId: 'unrelated-conversation', timestamp: NOW }]);
expect(await getLastResponse('pane-1')).toEqual({ text: 'my own answer', timestamp: expect.any(String) });
expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled();
});
it('credits a shared-cwd entry to the pane whose Enter is closest to it', async () => {
const near = addPane('pane-near', 'near-conversation', NOW);
const far = addPane('pane-far', 'far-conversation', NOW - 4_000);
writeTranscript('near-conversation', 'near answer', NOW - 60_000);
writeTranscript('far-conversation', 'far answer', NOW - 60_000);
writeTranscript('fresh-conversation', 'the freshly cleared answer', NOW + 500);
writeHistory([{ sessionId: 'fresh-conversation', timestamp: NOW + 100 }]);
// Both panes are inside the match window; only the closest may claim it.
expect(await getLastResponse('pane-far')).toEqual({ text: 'far answer', timestamp: expect.any(String) });
expect(far.adoptClaudeSessionId).not.toHaveBeenCalled();
expect(await getLastResponse('pane-near')).toEqual({
text: 'the freshly cleared answer',
timestamp: expect.any(String),
});
expect(near.adoptClaudeSessionId).toHaveBeenCalledWith('fresh-conversation');
});
});
@@ -104,7 +104,7 @@ describe('GET /api/sessions/:id/last-response (codex)', () => {
let codexHome: string;
let prevCodexHome: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any; // MockSession, loosened for codex-only fields (codexConfig, codexLastSubmitAt)
let session: any; // MockSession, loosened for codex-only fields (codexConfig, lastSubmitAt)
let workdir: string;
/** Write a rollout under CODEX_HOME/sessions/<date>/ with a controlled mtime. */
@@ -230,7 +230,7 @@ describe('GET /api/sessions/:id/last-response (codex)', () => {
it('history.jsonl pin (pane last-submit correlation) outranks the originator match', async () => {
const submitAtSec = BASE_MTIME + 500;
session.codexLastSubmitAt = submitAtSec * 1000;
session.lastSubmitAt = submitAtSec * 1000;
writeHistory([{ session_id: UUID_B, ts: submitAtSec }]);
// Originator-stamped rollout exists and is NEWER, but the pane /resume'd onto
+247
View File
@@ -1350,6 +1350,253 @@ describe('session-routes', () => {
expect(row.workingDir).toBe(dotDir);
expect(row.workingDir).not.toContain('//');
});
it('excludes non-interactive (SDK-driven) transcripts from the history list', async () => {
// CI review bots and other automated tools write transcripts into the same
// ~/.claude/projects tree as interactive sessions (entrypoint "sdk-py" etc.)
// but were never something a user can resume into — no PTY, no running
// process. They cluttered Past Sessions as blank rows or identical
// boilerplate ("Review this change for security vulnerabilities...").
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-test');
await mkdir(projPath, { recursive: true });
const cliId = '33333333-3333-3333-3333-333333333333';
const sdkId = '44444444-4444-4444-4444-444444444444';
const noEntrypointId = '55555555-5555-5555-5555-555555555555';
const cliLine =
JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real question' } }) +
'\n';
const sdkLine =
JSON.stringify({
type: 'user',
entrypoint: 'sdk-py',
message: { role: 'user', content: 'Review this change for security vulnerabilities.' },
}) + '\n';
// Older transcripts predate the entrypoint field entirely — must still show.
const noEntrypointLine =
JSON.stringify({ type: 'user', message: { role: 'user', content: 'a pre-entrypoint session' } }) + '\n';
await writeFile(join(projPath, `${cliId}.jsonl`), cliLine + '#'.repeat(4200 - cliLine.length));
await writeFile(join(projPath, `${sdkId}.jsonl`), sdkLine + '#'.repeat(4200 - sdkLine.length));
await writeFile(
join(projPath, `${noEntrypointId}.jsonl`),
noEntrypointLine + '#'.repeat(4200 - noEntrypointLine.length)
);
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-entrypoint-test',
});
expect(res.statusCode).toBe(200);
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).toContain(cliId);
expect(ids).toContain(noEntrypointId);
expect(ids).not.toContain(sdkId);
});
it('ignores a bookkeeping line that happens to mention "entrypoint" outside a real message record', async () => {
// Scanning must anchor on "type":"user"/"assistant" lines specifically,
// not any line that happens to contain the substring "entrypoint".
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-bookkeeping-test');
await mkdir(projPath, { recursive: true });
const sessionId = '77777777-7777-7777-7777-777777777777';
const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', entrypoint: 'sdk-py' }) + '\n';
const realLine =
JSON.stringify({ type: 'user', entrypoint: 'cli', message: { role: 'user', content: 'a real message' } }) +
'\n';
// scanProjectDir skips files under 4000 bytes.
const body = bookkeepingLine + realLine;
await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length));
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-entrypoint-bookkeeping-test',
});
expect(res.statusCode).toBe(200);
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).toContain(sessionId);
});
it('shows a session with ANY interactive (cli) message, even if an earlier message was automated', async () => {
// "First field wins" would have misattributed this: an old transcript
// whose true first message predates the entrypoint field, later resumed
// under something automated (entrypoint: 'sdk-py' on message 2), then
// continued interactively by a real person (entrypoint: 'cli' on message
// 3). Stopping at the first entrypoint-bearing line found ('sdk-py')
// would wrongly exclude a session a human genuinely used. One real
// interactive message anywhere is enough to keep it visible.
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-any-cli-test');
await mkdir(projPath, { recursive: true });
const sessionId = '99999999-9999-9999-9999-999999999999';
const firstLine =
JSON.stringify({ type: 'user', message: { role: 'user', content: 'pre-entrypoint-field message' } }) + '\n';
const automatedLine =
JSON.stringify({
type: 'user',
entrypoint: 'sdk-py',
message: { role: 'user', content: 'an automated follow-up' },
}) + '\n';
const interactiveLine =
JSON.stringify({
type: 'user',
entrypoint: 'cli',
message: { role: 'user', content: 'a real person continued this' },
}) + '\n';
const body = firstLine + automatedLine + interactiveLine;
await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length));
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-entrypoint-any-cli-test',
});
expect(res.statusCode).toBe(200);
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).toContain(sessionId);
});
it('still excludes a session where every entrypoint-bearing message is automated', async () => {
// Mirror of the previous test with no 'cli' message anywhere — proves the
// "any cli wins" fix isn't just failing open unconditionally.
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-all-automated-test');
await mkdir(projPath, { recursive: true });
const sessionId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const firstLine =
JSON.stringify({
type: 'user',
entrypoint: 'sdk-py',
message: { role: 'user', content: 'Review this change for security vulnerabilities.' },
}) + '\n';
const secondLine =
JSON.stringify({
type: 'assistant',
entrypoint: 'sdk-py',
message: { role: 'assistant', content: [{ type: 'text', text: 'Looking at the diff...' }] },
}) + '\n';
const body = firstLine + secondLine;
await writeFile(join(projPath, `${sessionId}.jsonl`), body + '#'.repeat(4200 - body.length));
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-entrypoint-all-automated-test',
});
expect(res.statusCode).toBe(200);
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).not.toContain(sessionId);
});
it('keeps a session whose entrypoint is an unrecognized non-SDK value (fail open)', async () => {
// The exclusion is a blocklist on the SDK shape, NOT an allowlist on 'cli'.
// An allowlist fails CLOSED on any value Claude Code has not shipped yet:
// the day it stamps a new interactive entrypoint, nothing matches 'cli' and
// the entire Past Sessions list silently goes blank. Excluding only what we
// positively recognize as automated fails open instead — a few noisy rows,
// not a dead feature.
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-entrypoint-unknown-test');
await mkdir(projPath, { recursive: true });
const sessionId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const line =
JSON.stringify({
type: 'user',
entrypoint: 'cli-next',
message: { role: 'user', content: 'a question from a future interactive host' },
}) + '\n';
await writeFile(join(projPath, `${sessionId}.jsonl`), line + '#'.repeat(4200 - line.length));
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-entrypoint-unknown-test',
});
expect(res.statusCode).toBe(200);
const ids = JSON.parse(res.body).data.sessions.map((s: { sessionId: string }) => s.sessionId);
expect(ids).toContain(sessionId);
});
it('finds the real first prompt past a large run of pre-message bookkeeping lines', async () => {
// A session restarted many times over a long conversation accumulates a batch
// of small bookkeeping lines (mode/permission-mode/last-prompt/queue-operation)
// per restart, ahead of the real first message. With enough restarts these can
// push the genuine first prompt past a 16KB head-read window even though the
// message itself is tiny — the row showed up blank despite having real content.
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-bookkeeping-test');
await mkdir(projPath, { recursive: true });
const sessionId = '66666666-6666-6666-6666-666666666666';
const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', sessionId }) + '\n';
// > 16KB (the old head-read size) but well under 128KB (the new one).
const prefix = bookkeepingLine.repeat(Math.ceil(20000 / bookkeepingLine.length));
const realLine =
JSON.stringify({
type: 'user',
entrypoint: 'cli',
message: { role: 'user', content: 'the real first message' },
}) + '\n';
expect(prefix.length).toBeGreaterThan(16384);
await writeFile(join(projPath, `${sessionId}.jsonl`), prefix + realLine);
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-bookkeeping-test',
});
expect(res.statusCode).toBe(200);
const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
expect(row).toBeDefined();
expect(row.firstPrompt).toBe('the real first message');
});
it('still falls back to the tail read when bookkeeping alone exceeds the new 128KB head window', async () => {
// Raising the head buffer to 128KB helps most restart-heavy sessions, but an
// even more extreme case (many more restarts) can still exceed it. This
// proves the tail-read fallback itself is intact after the threshold
// rewrite (`fileStat.size > headBuf.length` replacing the old hardcoded
// 16384/65536) — the fallback's own logic, not the exact threshold value,
// is what could have silently broken (e.g. a copy-paste slip that dropped
// the `> headBuf.length` check entirely). The real message sits near the
// end of the file, well inside the 32KB tail window, so a working fallback
// finds it; a broken one leaves the row blank exactly like the bug this
// whole fix addresses.
const home = process.env.HOME as string;
const projPath = join(home, '.claude', 'projects', 'proj-tail-fallback-test');
await mkdir(projPath, { recursive: true });
const sessionId = '88888888-8888-8888-8888-888888888888';
const bookkeepingLine = JSON.stringify({ type: 'mode', mode: 'normal', sessionId }) + '\n';
// Comfortably past the new 128KB head window (was 16KB), so the head read
// never reaches a single "type":"user"/"assistant"/"summary" line.
const prefix = bookkeepingLine.repeat(Math.ceil(140000 / bookkeepingLine.length));
const realLine =
JSON.stringify({
type: 'user',
entrypoint: 'cli',
message: { role: 'user', content: 'found via tail fallback' },
}) + '\n';
expect(prefix.length).toBeGreaterThan(131072);
await writeFile(join(projPath, `${sessionId}.jsonl`), prefix + realLine);
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions?projectKey=proj-tail-fallback-test',
});
expect(res.statusCode).toBe(200);
const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
expect(row).toBeDefined();
expect(row.firstPrompt).toBe('found via tail fallback');
});
});
// ========== POST /api/sessions (with resumeSessionId) ==========
@@ -270,6 +270,75 @@ describe('mergeUnifiedSessions', () => {
expect(live!.firstPrompt).toBeUndefined();
});
it('does NOT borrow a sibling transcript for a history-only row whose own extraction failed (no cross-contamination)', () => {
// A pure history row already got its own real scan (step 1 keys it under its
// OWN sessionId) — if that extraction genuinely failed (oversized first
// message, noise-filtered, etc.), the workingDir guess must not paper over
// it with an unrelated session's opening line. Regression: an old session
// in a shared workingDir was displaying TODAY's live session's firstPrompt
// as its own, because the guess didn't check whether this row already had
// its own (failed) attempt.
const merged = mergeUnifiedSessions({
history: [
// This session's own transcript scan found no usable prompt.
{
sessionId: 'old-uuid',
workingDir: '/shared',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: undefined,
},
// A much newer, unrelated session in the same directory.
{
sessionId: 'newer-uuid',
workingDir: '/shared',
sizeBytes: 6000,
lastModified: '2026-06-01T00:00:00.000Z',
firstPrompt: "today's real prompt",
},
],
});
const old = merged.find((m) => m.sessionId === 'old-uuid');
expect(old).toBeDefined();
expect(old!.firstPrompt).toBeUndefined();
});
it('leaves a RESUMED session blank rather than borrowing a sibling, once its own transcript is aliased in', () => {
// The exact scenario COD-140's own comment lists first: a live/persisted row
// whose claudeSessionId aliases to an on-disk transcript. Once that alias
// successfully folds the transcript's own (failed) extraction into this row
// (sources includes 'history'), it must NOT then fall through to the
// workingDir guess and borrow an unrelated sibling's prompt -- same bug as
// the plain history-only case above, but for the resumed-session path the
// backfill mechanism was actually built for.
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-resumed', status: 'working', claudeSessionId: 'resumed-uuid', workingDir: '/shared' }],
history: [
// The resumed session's OWN transcript -- aliased in via claudeSessionId,
// but its own extraction found nothing.
{
sessionId: 'resumed-uuid',
workingDir: '/shared',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: undefined,
},
// An unrelated, newer sibling in the same directory.
{
sessionId: 'sibling-uuid',
workingDir: '/shared',
sizeBytes: 6000,
lastModified: '2026-06-01T00:00:00.000Z',
firstPrompt: "unrelated sibling's prompt",
},
],
});
const resumed = merged.find((m) => m.sessionId === 'codeman-resumed');
expect(resumed).toBeDefined();
expect([...resumed!.sources].sort()).toEqual(['history', 'live']);
expect(resumed!.firstPrompt).toBeUndefined();
});
// COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
+55
View File
@@ -0,0 +1,55 @@
/**
* @fileoverview The pane's last-Enter timestamp must survive a Codeman restart.
*
* `start()` resets `claudeSessionId` to the launch id even when re-attaching to
* a mux session whose CLI has since moved on (a `/clear` before the restart), so
* `lastSubmitAt` is the response viewer's only anchor for re-deriving the live
* conversation. If it is not persisted, a recovered pane shows the pre-`/clear`
* transcript until the user happens to type again — hours, in practice.
*
* Port: N/A (no server needed)
*/
import { describe, it, expect } from 'vitest';
import { Session } from '../src/session.js';
describe('session submit anchor', () => {
it('records the pane Enter and carries it into persisted state', () => {
const session = new Session({ workingDir: '/tmp' });
expect(session.lastSubmitAt).toBe(0);
expect(session.toState().lastSubmitAt).toBeUndefined();
const before = Date.now();
session.write('hello\r');
const after = Date.now();
expect(session.lastSubmitAt).toBeGreaterThanOrEqual(before);
expect(session.lastSubmitAt).toBeLessThanOrEqual(after);
expect(session.toState().lastSubmitAt).toBe(session.lastSubmitAt);
});
it('leaves the anchor unset for keystrokes that never submit', () => {
const session = new Session({ workingDir: '/tmp' });
session.write('hello');
session.write('\x1b[A'); // arrow-up: history recall, not a submit
expect(session.lastSubmitAt).toBe(0);
expect(session.toState().lastSubmitAt).toBeUndefined();
});
it('restores the anchor from persisted state on boot recovery', () => {
const submitted = new Session({ workingDir: '/tmp' });
submitted.write('prompt\r');
const persisted = submitted.toState();
const recovered = new Session({ workingDir: '/tmp', lastSubmitAt: persisted.lastSubmitAt });
expect(recovered.lastSubmitAt).toBe(submitted.lastSubmitAt);
expect(recovered.toState().lastSubmitAt).toBe(submitted.lastSubmitAt);
});
it('starts a pane with no persisted anchor at zero rather than NaN', () => {
const recovered = new Session({ workingDir: '/tmp', lastSubmitAt: undefined });
expect(recovered.lastSubmitAt).toBe(0);
});
});