mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
fix(session): learn the live Claude conversation from the CLI's own hook
Which conversation a pane is on was re-derived by correlating ~/.claude/history.jsonl against Session.lastSubmitAt — and lastSubmitAt is bumped only by input that flows through Codeman's own write path (Session.write / writeViaMux). A user who attaches to the pane's tmux session directly never set it, so resolveActiveClaudeSessionIdFromHistory() returned at its first line for that pane's whole life and the response viewer stayed pinned to the launch conversation, showing a pre-/clear transcript indefinitely. A UserPromptSubmit hook reports the live conversation id from inside the CLI process, delivered under the pane's own $CODEMAN_SESSION_ID. That binding is a fact rather than a correlation: it never consults workingDir, so it cannot be claimed by a sibling pane on the same folder, a closed tab, or a bare `claude` in the user's terminal. A pane holding such an id skips the correlation entirely, so the number of prompts eligible for cwd-based guessing goes DOWN, never up — the naive alternative (relax the guard, or synthesize an anchor from PTY activity) is the reverted bug the resolver's own comment describes. The hook also stamps lastSubmitAt, so it finally means "a prompt was submitted" rather than "typed into Codeman's web terminal". Conversations vouched for first-hand — and only those — extend a persisted claudeSessionChain, whose tail re-pins the conversation when a surviving tmux session is re-attached after a restart. ⚠️ start() resets the id at THREE points and the last one runs unconditionally after the mux branch, so the tail is applied there too; patching only the mux branch looks right and silently does nothing. ⚠️ The hook's stdout is discarded with curl's own -o /dev/null. Claude Code injects a UserPromptSubmit hook's stdout into the model's context ("Exit code 0 - stdout shown to Claude"), and a trailing >/dev/null does NOT work: 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. The discard is opt-in so the five SSE-fed events keep byte-identical command text and no workspace's settings file is rewritten for them. The staleness marker is quote-free for the matching reason: hooksJson is JSON.stringify'd, so a quoted needle never matches and the gate would rewrite every workspace on every spawn. Existing workspaces heal on their next Claude spawn through the staleness sweep.
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
@@ -152,6 +152,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
|
||||
@@ -427,6 +432,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 = '';
|
||||
@@ -648,6 +664,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. */
|
||||
@@ -704,6 +722,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;
|
||||
@@ -902,6 +927,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;
|
||||
@@ -959,11 +998,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;
|
||||
@@ -1397,6 +1463,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
|
||||
@@ -1931,6 +2003,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 {
|
||||
@@ -1972,7 +2049,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
|
||||
@@ -2094,8 +2179,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);
|
||||
@@ -3185,6 +3275,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.
|
||||
|
||||
@@ -648,6 +648,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).
|
||||
|
||||
@@ -422,6 +422,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.
|
||||
|
||||
@@ -1860,8 +1860,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;
|
||||
|
||||
|
||||
@@ -924,6 +924,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