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
+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