Merge pull request #367 from shenlvkang-collab/pr/claude-conversation-first-hand

fix(session): learn the live Claude conversation from the CLI's own hook
This commit is contained in:
Ark0N
2026-09-06 23:02:33 +02:00
committed by GitHub
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();
+105 -5
View File
@@ -156,6 +156,11 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
/** Graceful shutdown delay when stopping session (100ms) */
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
// otherwise grow state.json without bound; 32 covers any real session's history
// and the oldest entries are the ones whose transcripts Claude Code has pruned.
const MAX_CLAUDE_SESSION_CHAIN = 32;
// Filter out terminal focus escape sequences (focus in/out reports)
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
// eslint-disable-next-line no-control-regex
@@ -438,6 +443,17 @@ export class Session extends EventEmitter {
private _wireActivityAt: number;
private _wireActivitySettleUntil: number;
private _claudeSessionId: string | null = null;
// Set only when the id came from the CLI's own UserPromptSubmit/Stop hook
// payload, keyed on this pane's $CODEMAN_SESSION_ID. That binding is a fact,
// not a correlation: it never consults cwd, so a sibling pane on the same
// folder cannot steal it. Runtime-only — a restart must re-earn it from the
// next hook rather than trust a persisted claim.
private _claudeSessionIdIsFirstHand = false;
// Conversations this pane has been on, oldest first, current last. Grows only
// through a first-hand adoption, so it can never splice in a foreign
// conversation. Persisted, because `/clear` is otherwise unrecoverable: the
// predecessor id exists nowhere else once the pane moves on.
private _claudeSessionChain: string[] = [];
private _totalCost: number = 0;
private _messages: ClaudeMessage[] = [];
private _lineBuffer: string = '';
@@ -660,6 +676,8 @@ export class Session extends EventEmitter {
attachmentHistory?: SessionAttachmentHistoryItem[];
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
lastSubmitAt?: number;
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
claudeSessionChain?: string[];
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
lastActivityAt?: number;
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
@@ -716,6 +734,13 @@ export class Session extends EventEmitter {
// response viewer re-derive the live conversation without waiting for the
// user to type again.
this._lastSubmitAt = config.lastSubmitAt ?? 0;
// Restored chain: its tail is the conversation the CLI was actually on when
// the server stopped, which outranks the launch id seeded just above. The
// FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a
// fact, so the pane re-earns the guess-free path from its next hook.
this._claudeSessionChain = Array.isArray(config.claudeSessionChain) ? [...config.claudeSessionChain] : [];
const restoredConversation = this._claudeSessionChain[this._claudeSessionChain.length - 1];
if (restoredConversation) this._claudeSessionId = restoredConversation;
this._mux = config.mux || null;
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
this._muxSession = config.muxSession || null;
@@ -914,6 +939,20 @@ export class Session extends EventEmitter {
return this._claudeSessionId;
}
/**
* True when `claudeSessionId` came from the CLI's own hook payload rather than
* from the launch config or a history correlation. The response viewer uses it
* to skip guessing entirely — see resolveActiveClaudeSessionIdFromHistory().
*/
get claudeSessionIdIsFirstHand(): boolean {
return this._claudeSessionIdIsFirstHand;
}
/** Conversations this pane has been on, oldest first, current last. */
get claudeSessionChain(): readonly string[] {
return this._claudeSessionChain;
}
/** Docker execution metadata when this session runs inside a container, else undefined. */
get docker(): SessionDocker | undefined {
return this._docker;
@@ -971,11 +1010,38 @@ export class Session extends EventEmitter {
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
// that conveys a post-/clear conversation switch.
adoptClaudeSessionId(newId: string): void {
if (!newId || newId === this._claudeSessionId) return;
//
// `firstHand` marks an id that came from the CLI process itself — a hook
// payload whose delivery was keyed on this pane's $CODEMAN_SESSION_ID. Only
// those extend the chain: a history-correlated guess must never be able to
// write a foreign conversation into this pane's permanent record.
adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
if (!newId) return;
if (options.firstHand) {
this._claudeSessionIdIsFirstHand = true;
this._recordClaudeSessionInChain(newId);
}
if (newId === this._claudeSessionId) return;
this._claudeSessionId = newId;
}
/**
* Append to the conversation chain, oldest first. A repeat of the current tail
* is a no-op (every prompt in a conversation reports the same id), and an id
* already in the chain moves to the tail rather than duplicating, which is
* what a `/resume` back to an earlier conversation does.
*/
private _recordClaudeSessionInChain(id: string): void {
if (this._claudeSessionChain[this._claudeSessionChain.length - 1] === id) return;
const existing = this._claudeSessionChain.indexOf(id);
if (existing !== -1) this._claudeSessionChain.splice(existing, 1);
this._claudeSessionChain.push(id);
// A pane that /clears in a loop must not grow this without bound.
if (this._claudeSessionChain.length > MAX_CLAUDE_SESSION_CHAIN) {
this._claudeSessionChain.splice(0, this._claudeSessionChain.length - MAX_CLAUDE_SESSION_CHAIN);
}
}
/** The tmux session name, if the session is running inside a mux */
get muxName(): string | null {
return this._muxSession?.muxName ?? null;
@@ -1409,6 +1475,12 @@ export class Session extends EventEmitter {
respawnBlocked: this._respawnBlocked || undefined,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
lastSubmitAt: this._lastSubmitAt || undefined,
// Only a chain the CLI's own hooks vouched for is persisted, and only when
// the pane actually moved conversation. Its LAST entry is the live one, so
// it is also what restores `claudeSessionId` across a restart — `start()`
// resets that field to the launch id at three separate points, which is
// why a recovered pane otherwise shows its pre-/clear transcript forever.
claudeSessionChain: this._claudeSessionChain.length > 0 ? [...this._claudeSessionChain] : 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
@@ -1948,6 +2020,11 @@ export class Session extends EventEmitter {
}, REMOTE_CLI_VERSION_PROBE_DELAY_MS);
}
// ⚠️ Hoisted, because the "third reset point" below runs unconditionally
// AFTER the mux branch and would otherwise stomp the restored conversation
// straight back to the launch id.
let restoredConversation: string | undefined;
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
@@ -1989,7 +2066,15 @@ export class Session extends EventEmitter {
// over the generic `this.id` fallback, or this line clobbers it back
// to the Codeman id
// on every single respawn.
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
// ⚠️ A RESTORED mux session is the one case where the launch id is a
// lie: the CLI never stopped, so a `/clear` before the Codeman restart
// already moved it to a conversation `this.id` knows nothing about. The
// persisted chain's tail is that conversation, reported first-hand by
// the CLI's own hook, so it outranks the fallback here. A NEW pane has
// an empty chain and falls through to exactly today's expression.
restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined;
this._claudeSessionId =
restoredConversation || this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
// For NEW mux sessions: wait for readiness then clean buffer
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
@@ -2092,8 +2177,13 @@ export class Session extends EventEmitter {
// unconditionally after both the mux and direct-PTY paths, so it also needs
// the ompConfig fallback or it stomps the mux branch's correctly-resolved
// OMP alias back to this.id on every mux/plain-reattach boot recovery
// (the "third reset point" — see DECISIONS.md).
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
// (the "third reset point" — see DECISIONS.md). For the same reason it needs
// `restoredConversation`: on a RESTORED mux attach the CLI never stopped and
// may have `/clear`ed before the restart, so the launch id is a lie and the
// chain's tail is the live conversation. Empty on every other path, which
// leaves this expression exactly as it was.
this._claudeSessionId =
restoredConversation || this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
this._pid = this.ptyProcess.pid;
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
@@ -3220,6 +3310,16 @@ export class Session extends EventEmitter {
}
}
/**
* A prompt was submitted, reported by the CLI's own UserPromptSubmit hook.
* `_trackSubmit` only sees input that flows through Codeman's write path, so
* a pane the user drives by attaching to tmux directly never stamped this and
* `lastSubmitAt` stayed 0 for its whole life.
*/
markPromptSubmitted(): void {
this._lastSubmitAt = Date.now();
}
/**
* Per-client highest-applied input sequence, for exactly-once input delivery.
* Keyed by the web client's stable `clientId`. Bounded so many devices over a
+4
View File
@@ -110,6 +110,10 @@ export type HookEventType =
| 'stop'
| 'teammate_idle'
| 'task_completed'
// Claude Code's UserPromptSubmit. The payload's `session_id` is the pane's
// LIVE conversation id, reported by the CLI process itself, so it survives a
// `/clear` without any cwd/timestamp correlation.
| 'prompt_submitted'
// No Claude Code hook behind this one: it is the DeepSeek status bridge's
// "a turn STARTED" report (see deepseek-status-shim.ts). Keep in step with
// HookEventSchema in web/schemas.ts.
+9
View File
@@ -688,6 +688,15 @@ export interface SessionState {
* again until the pane's own Enter is known.
*/
lastSubmitAt?: number;
/**
* Claude conversations this pane has been on, oldest first, current last.
* Written ONLY from a first-hand `UserPromptSubmit`/`Stop` hook payload —
* never from the history correlation — so it cannot record a sibling pane's
* conversation. Persisted because `/clear` is otherwise unrecoverable: once
* the pane moves on, the predecessor id exists nowhere else, and the last
* entry is what re-pins `claudeSessionId` past `start()`'s three resets.
*/
claudeSessionChain?: string[];
/**
* PTY-exit circuit breaker tripped — respawn blocked until an explicit restart
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
+5
View File
@@ -424,6 +424,11 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
'stop_hook_active',
'transcript_path',
'message',
// UserPromptSubmit identity fields. `prompt` is deliberately NOT here: the
// prompt text would land in the SSE broadcast, and Read My Mind already
// captures intent through transcript-watcher.
'prompt_id',
'source',
];
for (const key of allowedKeys) {
+23 -1
View File
@@ -129,7 +129,29 @@ export function registerHookEventRoutes(
if (data && typeof data.session_id === 'string' && data.session_id) {
const session = ctx.sessions.get(sessionId);
const prevClaudeSessionId = session?.claudeSessionId;
session?.adoptClaudeSessionId(data.session_id);
const prevChainLength = session?.claudeSessionChain.length ?? 0;
// FIRST-HAND: this payload came from the CLI process itself and reached us
// because the pane's own $CODEMAN_SESSION_ID addressed it. No cwd, no
// timestamp, nothing a sibling pane on the same folder could win — so the
// response viewer can stop guessing entirely (see
// resolveActiveClaudeSessionIdFromHistory).
session?.adoptClaudeSessionId(data.session_id, { firstHand: true });
if (event === 'prompt_submitted') {
// Repairs `lastSubmitAt` for a pane driven straight from tmux: it was
// bumped only by input that flowed through Codeman's own write path, so
// it read 0 forever for those panes and every consumer of "when did this
// pane last submit" silently degraded.
session?.markPromptSubmitted();
}
// Persist when the conversation actually moved: `/clear` emits no
// completion event, so without this the successor id is lost on restart
// and recovery falls back to the launch conversation.
if (
session &&
(session.claudeSessionId !== prevClaudeSessionId || session.claudeSessionChain.length !== prevChainLength)
) {
ctx.persistSessionState(session);
}
// Docker sessions: keep the case's resume seed following the LIVE
// conversation (post-/clear id switches), so a container stop/reboot
// relaunch resumes the right transcript.
+10 -1
View File
@@ -1840,8 +1840,17 @@ export function registerSessionRoutes(
session: Session,
projectsDir: string
): Promise<string | null> {
// A pane whose conversation id came from its OWN hook needs no correlation:
// $CODEMAN_SESSION_ID (the pane's env) -> data.session_id (the CLI's own
// stdin JSON) is a first-hand binding that never looks at cwd, so it cannot
// be stolen by a sibling pane, a closed tab, or a bare `claude` in a
// terminal. Guessing can only be worse than the fact. This is also what
// closes the hole below for a pane driven straight from tmux: it never
// reaches `if (!submitAt)`.
if (session.claudeSessionIdIsFirstHand) return null;
const submitAt = session.lastSubmitAt;
if (!submitAt) return null; // never typed through Codeman — nothing to credit
if (!submitAt) return null; // no anchor at all — nothing to credit
const cached = claudeHistoryPinCache.get(session.id);
if (cached && cached.submitAt === submitAt) return cached.claudeSessionId;
+3
View File
@@ -1043,6 +1043,9 @@ export const HookEventSchema = z.object({
'stop',
'teammate_idle',
'task_completed',
// Claude Code's UserPromptSubmit: a first-hand report of the pane's live
// conversation id. Keep in step with HookEventType in types/api.ts.
'prompt_submitted',
// A turn STARTED. Unlike the others this one has no Claude Code hook behind
// it: it is reported by the DeepSeek Harness status shim, and exists so a
// dialog answered in the terminal resolves its Approvals Inbox item at once
+4
View File
@@ -2794,6 +2794,10 @@ export class WebServer extends EventEmitter {
// the launch conversation until the user types again, even though
// the re-attached CLI is on a post-`/clear` one.
lastSubmitAt: savedState?.lastSubmitAt,
// Conversations this pane provably owned, oldest first. Its tail is
// the conversation the CLI was on when the server stopped, which is
// what a re-attach must point the viewer at instead of the launch id.
claudeSessionChain: savedState?.claudeSessionChain,
// The pane's last output, previous run's value. Without it every
// restart restamped all sessions "now" (constructor + the attach
// repaint within the same second), flattening the home screens'