fix(session): learn the live Claude conversation from the CLI's own hook - #367
Open
shenlvkang-collab wants to merge 1 commit into
Open
fix(session): learn the live Claude conversation from the CLI's own hook#367shenlvkang-collab wants to merge 1 commit into
shenlvkang-collab wants to merge 1 commit into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jsonlagainst
Session.lastSubmitAt. That anchor is bumped only by_trackSubmit(), reachableonly from
Session.write()andSession.writeViaMux()— input that flows throughCodeman. 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 wholelife and the response viewer stays pinned to the launch conversation, showing a pre-
/cleartranscript 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.tsalready documents as made and reverted — keying off "newest history entryfor 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 is2.3 s, well inside
CLAUDE_SUBMIT_MATCH_MS = 10_000.The fix
A
UserPromptSubmithook reportssession_idfrom inside the CLI process, delivered underthe pane's own
$CODEMAN_SESSION_ID. That is a fact, not a correlation: it never consultsworkingDir, so no sibling pane, closed tab, or bareclaudein a terminal can claim it. Apane holding such an id skips the correlation entirely (
claudeSessionIdIsFirstHand), so thenumber 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 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 wouldmake 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 injectsa
UserPromptSubmithook'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/nulldoes not work:
curlCmdalready ends… 2>/dev/null || true, and inpipeline || true >/dev/nullthe shell binds the redirection totrue, which never runs onthe success path. An
endsWith('>/dev/null')assertion passes on exactly that broken form, sothe 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.jsonis rewritten for them.2.
start()resets_claudeSessionIdat 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:
hooksJsonisJSON.stringify'd, so a'"prompt_submitted"'needle can never match and would rewrite every workspace's settings onevery 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
mastercleanly on their own. They do overlap intest/routes/session-routes-claude-last-response.test.tsanddocs/architecture-invariants.md, so I will rebase whichever one lands second — merge them ineither 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.
sanitizeHookDatagainsprompt_id/sourcebut 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— theUserPromptSubmitcommand carriescurl -sk -o /dev/null -X POSTand does not end in a trailing redirect; the other events'command text is unchanged; a pre-
prompt_submittedblock heals; an already-current block isleft 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/clearwithout duplicating a repeat, persistence, and that the prompt text never reachesthe broadcast.
test/routes/session-routes-claude-last-response.test.ts— a first-hand pane ignores ahistory 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 extendthe chain,
/resumemoves an entry to the tail instead of duplicating, the chain round-tripsthrough
toState()and re-pins the conversation on restore while the first-hand flagdeliberately does not, and every reset point in
start()applies it.Verification
Isolated worktree, clean
npm ci:Running as my daily driver: after the restart the hook landed in the workspace's
settings.local.jsonon the boot sweep, and the pane's conversation id now comes from the CLIinstead of being guessed.