Skip to content

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

Open
shenlvkang-collab wants to merge 1 commit into
Ark0N:masterfrom
shenlvkang-collab:pr/claude-conversation-first-hand
Open

fix(session): learn the live Claude conversation from the CLI's own hook#367
shenlvkang-collab wants to merge 1 commit into
Ark0N:masterfrom
shenlvkang-collab:pr/claude-conversation-first-hand

Conversation

@shenlvkang-collab

Copy link
Copy Markdown
Contributor

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

Closes #366.

The bug

Which conversation a pane is on is re-derived by correlating ~/.claude/history.jsonl
against Session.lastSubmitAt. That anchor is bumped only by _trackSubmit(), reachable
only from Session.write() and Session.writeViaMux() — input that flows through
Codeman
. A user who attaches to the pane's tmux session directly never sets it, so
resolveActiveClaudeSessionIdFromHistory() returns at its first line for that pane's whole
life and the response viewer stays pinned to the launch conversation, showing a pre-/clear
transcript indefinitely. On my machine neither live Claude pane has the key in
~/.codeman/state.json, so 100% of calls exit there.

Why not just relax the guard

Dropping it, or synthesising an anchor from PTY activity, recreates the bug
session-routes.ts already documents as made and reverted — keying off "newest history entry
for this cwd" made the eye follow a stranger's conversation. Three live panes here share a
workingDir, and the minimum gap between adjacent prompts of two different conversations is
2.3 s, well inside CLAUDE_SUBMIT_MATCH_MS = 10_000.

The fix

A UserPromptSubmit hook reports session_id from inside the CLI process, delivered under
the pane's own $CODEMAN_SESSION_ID. That is a fact, not a correlation: it never consults
workingDir, so no sibling pane, closed tab, or bare claude in a terminal can claim it. A
pane holding such an id skips the correlation entirely (claudeSessionIdIsFirstHand), so the
number of prompts eligible for cwd-based guessing goes down, never up:

pane A hooks pane B hooks cross-talk
impossible — neither runs the correlator
impossible — A is first-hand, B has no anchor
strictly fewer false positives than today (B's real id is now excluded, not its stale launch id)
unchanged, byte for byte

There is no TTL: if hooks stop arriving the last hook-supplied id is kept rather than falling
back to guessing — the same rule the existing comment already endorses.

The hook also stamps lastSubmitAt, so it finally means "a prompt was submitted" rather than
"typed into Codeman's web terminal". Conversations vouched for first-hand — and only those
— extend a persisted claudeSessionChain; a correlated guess writing into that record would
make the wrong-conversation bug permanent instead of transient.

Two things that are easy to get wrong, and are now pinned by tests

1. The hook's stdout must be discarded with curl's own -o /dev/null. Claude Code injects
a UserPromptSubmit hook's stdout 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 turn. A trailing >/dev/null
does not work: 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 flag instead. The discard is opt-in so the five SSE-fed events keep
byte-identical command text and no workspace's settings.local.json is rewritten for them.

2. start() resets _claudeSessionId at three points, and the last one is unconditional.
The chain's tail has to be applied at the third point too, or a restored mux attach silently
falls back to the launch id — patching only the mux branch looks correct and does nothing. A
structural test asserts every launch-id fallback assignment carries restoredConversation.

The staleness marker is likewise quote-free: hooksJson is JSON.stringify'd, so a
'"prompt_submitted"' needle can never match and would rewrite every workspace's settings on
every Claude spawn. A test now pins the no-op on an already-current block.

Relationship to the other viewer PR

Independent of the response-viewer PR for #365: the two touch different functions and both
cherry-pick onto master cleanly on their own. They do overlap in
test/routes/session-routes-claude-last-response.test.ts and
docs/architecture-invariants.md, so I will rebase whichever one lands second — merge them in
either order.

Scope

Server and state only — no API shape change, no UI. Existing workspaces heal on their next
Claude spawn through the hooks staleness sweep. sanitizeHookData gains prompt_id/source
but deliberately not prompt: the prompt text would otherwise land in the SSE broadcast.

This does not make earlier conversations reachable in the viewer — that needs a
conversation picker and is a separate change. It only ensures the pane is reading the right
one from now on.

Tests

  • test/hooks-config.test.ts — the UserPromptSubmit command carries
    curl -sk -o /dev/null -X POST and does not end in a trailing redirect; the other events'
    command text is unchanged; a pre-prompt_submitted block heals; an already-current block is
    left byte-identical. The first and last fail on the pre-fix code.
  • test/routes/hook-event-routes.test.ts — first-hand adoption, chain recording across a
    /clear without duplicating a repeat, persistence, and that the prompt text never reaches
    the broadcast.
  • test/routes/session-routes-claude-last-response.test.ts — a first-hand pane ignores a
    history entry the correlation would otherwise have claimed, including one anchored well
    enough that the pre-fix code does adopt it. Fails on master.
  • test/session-claude-conversation-chain.test.ts (new) — only first-hand adoptions extend
    the chain, /resume moves an entry to the tail instead of duplicating, the chain round-trips
    through toState() and re-pins the conversation on restore while the first-hand flag
    deliberately does not, and every reset point in start() applies it.

Verification

Isolated worktree, clean npm ci:

npm run typecheck              clean
npm run lint                   clean
npm run format:check           All matched files use Prettier code style!
npm run check:frontend-syntax  ✓ 34 frontend JS files parse cleanly
npm test                       Test Files 326 passed | 1 skipped (327), Tests 6356 passed | 12 skipped (6368)

Running as my daily driver: after the restart the hook landed in the workspace's
settings.local.json on the boot sweep, and the pane's conversation id now comes from the CLI
instead of being guessed.

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
(Session.write / writeViaMux). A user who attaches to the pane's tmux session
directly never set it, so resolveActiveClaudeSessionIdFromHistory() 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 UserPromptSubmit hook reports the live conversation id from inside the CLI
process, delivered under the pane's own $CODEMAN_SESSION_ID. That binding is a
fact rather than a correlation: it never consults workingDir, so it cannot be
claimed by a sibling pane on the same folder, a closed tab, or a bare `claude`
in the user's terminal. A pane holding such an id skips the correlation
entirely, so the number of prompts eligible for cwd-based guessing goes DOWN,
never up — the naive alternative (relax the guard, or synthesize an anchor from
PTY activity) is the reverted bug the resolver's own comment describes.

The hook also stamps lastSubmitAt, so it finally means "a prompt was submitted"
rather than "typed into Codeman's web terminal". Conversations vouched for
first-hand — and only those — extend a persisted claudeSessionChain, whose tail
re-pins the conversation when a surviving tmux session is re-attached after a
restart. ⚠️ start() resets the id at THREE points and the last one runs
unconditionally after the mux branch, so the tail is applied there too; patching
only the mux branch looks right and silently does nothing.

⚠️ The hook's stdout is discarded with curl's own -o /dev/null. Claude Code
injects a UserPromptSubmit hook's stdout into the model's context ("Exit code 0
- stdout shown to Claude"), and a trailing >/dev/null does NOT work: 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. The
discard is opt-in so the five SSE-fed events keep byte-identical command text
and no workspace's settings file is rewritten for them. The staleness marker is
quote-free for the matching reason: hooksJson is JSON.stringify'd, so a quoted
needle never matches and the gate would rewrite every workspace on every spawn.

Existing workspaces heal on their next Claude spawn through the staleness sweep.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Response viewer stays pinned to the launch conversation for a pane driven from tmux

1 participant