Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/fix-claude-conversation-first-hand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"aicodeman": patch
---

fix(session): learn the live Claude conversation from the CLI's own hook

Which conversation a pane is on was re-derived by correlating `~/.claude/history.jsonl`
against `Session.lastSubmitAt` — and `lastSubmitAt` is bumped only by input that flows
through Codeman's own write path. A user who attaches to the pane's tmux session directly
never set it, so the resolver returned at its first line for that pane's whole life and the
response viewer stayed pinned to the launch conversation, showing a pre-`/clear` transcript
indefinitely. A new `UserPromptSubmit` hook reports the live conversation id from inside the
CLI process, addressed by the pane's own `$CODEMAN_SESSION_ID`, so the id is a fact rather
than a correlation: it never consults the working directory and cannot be claimed by a
sibling pane on the same folder. A pane with such an id now skips the correlation entirely,
which strictly reduces the number of prompts eligible for cwd-based guessing. The hook also
stamps `lastSubmitAt`, so it finally means "a prompt was submitted" rather than "typed into
Codeman's web terminal". Conversations vouched for this way are persisted as a chain, whose
tail re-pins the conversation when a surviving tmux session is re-attached after a restart —
`start()` otherwise resets the id back to the launch conversation. Existing workspaces heal
on their next Claude spawn via the hooks staleness sweep. The hook's stdout is discarded with curl's own `-o /dev/null`:
Claude Code injects `UserPromptSubmit` output into the model's context, so an undiscarded
curl would paste the API envelope into the user's own prompt on every turn.
2 changes: 2 additions & 0 deletions docs/architecture-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se

⚠️ **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.

⚠️ **A first-hand conversation id outranks every correlation, and the correlation must never run when one exists.** `UserPromptSubmit` and `Stop` hook payloads carry `session_id` — the pane's LIVE conversation, reported from inside the CLI process — and reach Codeman addressed by that pane's own `$CODEMAN_SESSION_ID`. That binding is a fact: it never consults `workingDir`, so it cannot be claimed by a sibling pane on the same folder, by a closed tab, or by a bare `claude` in the user's terminal. `Session.claudeSessionIdIsFirstHand` gates `resolveActiveClaudeSessionIdFromHistory()` at its first line, so the number of prompts eligible for cwd-based guessing goes DOWN, never up. There is no TTL: if hooks stop arriving the last hook-supplied id is kept forever rather than falling back to guessing, which is the same rule as the paragraph below. ⚠️ **This is also the only fix for a pane driven by attaching to its tmux session directly.** `lastSubmitAt` was bumped only by `Session.write()`/`writeViaMux()`, i.e. input that flows through Codeman, so such a pane's anchor stayed 0 and the resolver returned at `if (!submitAt)` for the pane's entire life. The hook stamps it too (`markPromptSubmitted()`), so it finally means "a prompt was submitted". ⚠️ **Only a first-hand adoption may extend `Session.claudeSessionChain`** — a correlated guess writing into the pane's permanent record is precisely the bug the paragraph above describes, made durable. The chain is persisted because `/clear` is otherwise unrecoverable: once the pane moves on, the predecessor id exists nowhere else. Its tail re-pins the conversation on a RESTORED mux attach, where the launch id is a lie (the CLI never stopped and may have `/clear`ed before the restart); a NEW pane has an empty chain and keeps the launch id unchanged. ⚠️ **The hook's stdout must stay discarded, using curl's own `-o /dev/null`** (`curlCmdSilent`): Claude Code injects a `UserPromptSubmit` hook's stdout into the model's context — the CLI's own hook reference says "Exit code 0 - stdout shown to Claude" — so an undiscarded curl pastes Codeman's `{"success":true,…}` envelope into the user's own prompt on every turn. ⚠️ **A trailing `>/dev/null` does NOT work and looks like it does**: `curlCmd` already ends `… 2>/dev/null || true`, and in `pipeline || true >/dev/null` the shell binds the redirection to `true`, which never runs on the success path. An `endsWith('>/dev/null')` assertion passes on exactly that broken form, so the test asserts the `-o` flag instead. The other events feed SSE, where their stdout is harmless — hence a separate builder rather than a change to `curlCmd`. Tests: `test/hooks-config.test.ts`, `test/routes/hook-event-routes.test.ts`, `test/routes/session-routes-claude-last-response.test.ts`.

⚠️ **`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.
Expand Down
43 changes: 40 additions & 3 deletions src/hooks-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,19 +366,33 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
// never lands in this config and rotation needs no respawn. If the var/file is
// missing the header is empty — the middleware then allows the request only on
// the plain loopback bypass (tunnel down), same as pre-secret behavior.
const curlCmd = (event: HookEventType) =>
const curlCmd = (event: HookEventType, options: { discardStdout?: boolean } = {}) =>
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
// `-k`, same as the statusline exporter: CODEMAN_API_URL is loopback HTTPS with
// a self-signed cert on --https/tailscale installs. Without it curl exits 60,
// the `|| true` swallows it, and ALL SIX hook events die silently: respawn loses
// its definitive idle signals and the wait endpoints lose stop/blocked.
`curl -sk -X POST "$CODEMAN_API_URL/api/hook-event" ` +
`curl -sk ${options.discardStdout ? '-o /dev/null ' : ''}-X POST "$CODEMAN_API_URL/api/hook-event" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- ` +
`2>/dev/null || true`;

// The same POST with stdout DISCARDED, via curl's own `-o`. UserPromptSubmit is
// one of the hook events whose stdout Claude Code injects into the model's
// context (the CLI's own hook reference: "Exit code 0 - stdout shown to
// Claude"), so an undiscarded curl pastes Codeman's `{"success":true,…}`
// envelope into the user's prompt on every single turn.
// ⚠️ It MUST be curl's flag, not a trailing redirect. `curlCmd` already ends
// `… 2>/dev/null || true`, and in `pipeline || true >/dev/null` the shell binds
// the redirection to `true` — which never runs on the success path — so the
// envelope still reaches stdout. Verified in dash and bash.
// ⚠️ The flag is opt-in so the other events' command text stays byte-identical:
// their stdout feeds the SSE stream harmlessly, and changing it would rewrite
// every workspace's settings file for no gain.
const curlCmdSilent = (event: HookEventType) => curlCmd(event, { discardStdout: true });

return {
hooks: {
Notification: [
Expand Down Expand Up @@ -410,6 +424,16 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_SECONDS }],
},
],
// The pane's LIVE conversation id, reported by the CLI process itself.
// Without it the response viewer has to guess which `<uuid>.jsonl` a pane
// is on after a `/clear`, and the only anchor it can guess from is an
// Enter that went THROUGH Codeman — so a user who attaches to tmux
// directly never gets one and stays pinned to the launch conversation.
UserPromptSubmit: [
{
hooks: [{ type: 'command', command: curlCmdSilent('prompt_submitted'), timeout: HOOK_TIMEOUT_SECONDS }],
},
],
SubagentStop: [
{
hooks: [
Expand Down Expand Up @@ -735,9 +759,22 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
// Approvals Inbox needs the elicitation_complete/elicitation_response
// matchers; their absence marks a pre-inbox hooks block.
const hasElicitationComplete = hooksJson.includes('elicitation_complete');
// The UserPromptSubmit event is what gives a tmux-driven pane a first-hand
// conversation id; its absence marks a pre-prompt_submitted hooks block.
// ⚠️ No surrounding quotes: `hooksJson` is JSON.stringify'd, so the marker
// inside the command reads \"prompt_submitted\" and a quoted needle never
// matches — which would make this gate permanently false and rewrite every
// workspace's settings file on every Claude spawn. The sibling markers are
// quote-free for the same reason.
const hasPromptSubmit = hooksJson.includes('prompt_submitted');
if (
!isOurs ||
(hasSecret && hasBackgroundWake && hasSubagentStopGuard && hasElicitationComplete && !hasTlsFlaglessCurl)
(hasSecret &&
hasBackgroundWake &&
hasSubagentStopGuard &&
hasElicitationComplete &&
hasPromptSubmit &&
!hasTlsFlaglessCurl)
)
return;
const generated = generateHooksConfig();
Expand Down
Loading