diff --git a/.changeset/fix-claude-viewer-session-pin.md b/.changeset/fix-claude-viewer-session-pin.md index 4a7e4371..caf457b1 100644 --- a/.changeset/fix-claude-viewer-session-pin.md +++ b/.changeset/fix-claude-viewer-session-pin.md @@ -12,3 +12,11 @@ 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. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 71fdf0e1..94084fd8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -147,7 +147,9 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in **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 `.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. `lastSubmitAt` is in-memory, so after a server restart a pane re-pins on its next Enter. +⚠️ **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 `.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-` 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. diff --git a/src/session.ts b/src/session.ts index a80fd831..f59787df 100644 --- a/src/session.ts +++ b/src/session.ts @@ -499,6 +499,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. */ @@ -525,6 +527,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; @@ -1119,6 +1127,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 diff --git a/src/types/session.ts b/src/types/session.ts index 8305759c..eb8c116e 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -459,6 +459,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). diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index a442dd9a..ca1c828b 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1228,6 +1228,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( diff --git a/src/web/server.ts b/src/web/server.ts index 6a200aba..28532d54 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2464,6 +2464,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 diff --git a/test/session-submit-anchor.test.ts b/test/session-submit-anchor.test.ts new file mode 100644 index 00000000..a1298fb9 --- /dev/null +++ b/test/session-submit-anchor.test.ts @@ -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); + }); +});