mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +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:
+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
|
||||
|
||||
Reference in New Issue
Block a user