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
(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 commit is contained in:
shenlvkang-collab
2026-09-01 12:33:24 +08:00
parent e5c5d890aa
commit ccfda623fe
16 changed files with 540 additions and 11 deletions
+40 -3
View File
@@ -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: [
@@ -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: [
@@ -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();