mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
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:
+40
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user