mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
Merge branch 'master' into feat/codex-resume
master and this branch both rewrote the two `_claudeSessionId` resets inside
`start()`, so `src/session.ts` conflicted at both of them.
master's commit ccfda623 puts `restoredConversation` at the head of each
fallback chain. A restored mux attach means the CLI never stopped, so a
`/clear` before the Codeman restart may already have moved it to a
conversation the launch id knows nothing about. The persisted chain's tail is
that conversation, and the CLI's own hook reported it first-hand.
This branch adds `this._codexConfig?.resumeSessionId` to the same two chains,
so a resumed codex session keeps its thread-id alias across every mux reattach
and boot recovery.
Both fixes belong. Each chain now reads restoredConversation, then
_resumeSessionId, then omp's alias, then codex's alias, then the launch id.
The comments from both sides are kept.
test/session-claude-conversation-chain.test.ts pins the shape of those two
assignments by matching the source text, and its pattern named omp's alias as
the last term before `this.id`. Codex's alias now sits between the two, so the
pattern widens to pin the ends of the chain and let the middle grow. A `[^;]`
run cannot cross a statement boundary, so each match is still one assignment.
Checked on the merged tree: typecheck, lint, prettier and the frontend syntax
check all pass, and the CI suite runs 6721 tests green across 349 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+110
-4
@@ -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. */
|
||||
@@ -723,6 +741,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;
|
||||
@@ -921,6 +946,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;
|
||||
@@ -978,11 +1017,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;
|
||||
@@ -1416,6 +1482,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
|
||||
@@ -1955,6 +2027,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 {
|
||||
@@ -1999,8 +2076,19 @@ export class Session extends EventEmitter {
|
||||
// reason: its thread id lives in `_codexConfig`, so without it every
|
||||
// respawn drops a resumed codex session's alias and its Past-Sessions
|
||||
// row springs back as a duplicate that still resumes.
|
||||
// ⚠️ 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 every fallback here. A NEW pane has
|
||||
// an empty chain and falls through to the resume/alias fallbacks.
|
||||
restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined;
|
||||
this._claudeSessionId =
|
||||
this._resumeSessionId || this._ompConfig?.resumeSessionId || this._codexConfig?.resumeSessionId || this.id;
|
||||
restoredConversation ||
|
||||
this._resumeSessionId ||
|
||||
this._ompConfig?.resumeSessionId ||
|
||||
this._codexConfig?.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
|
||||
@@ -2104,8 +2192,16 @@ export class Session extends EventEmitter {
|
||||
// the ompConfig and codexConfig fallbacks or it stomps the mux branch's
|
||||
// correctly-resolved OMP/codex alias back to this.id on every mux/plain-
|
||||
// reattach boot recovery (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.
|
||||
// It is empty on every other path, so those paths keep the alias chain.
|
||||
this._claudeSessionId =
|
||||
this._resumeSessionId || this._ompConfig?.resumeSessionId || this._codexConfig?.resumeSessionId || this.id;
|
||||
restoredConversation ||
|
||||
this._resumeSessionId ||
|
||||
this._ompConfig?.resumeSessionId ||
|
||||
this._codexConfig?.resumeSessionId ||
|
||||
this.id;
|
||||
|
||||
this._pid = this.ptyProcess.pid;
|
||||
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
||||
@@ -3232,6 +3328,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