pikiloom 0.4.71 → 0.4.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,24 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
+ import { ClaudeWarmPool } from './claude-pool.js';
3
4
  import { claudeTranscriptTailAnchor, discoverClaudeNativeSessions } from './native.js';
4
5
  import { attachedFileNote, contextPercent, createLineBuffer, imageMimeForFile, parseJsonLine, sigterm, wireAbort } from './shared.js';
5
- // Real driver: shells the local `claude` CLI in stream-json mode and normalizes its
6
- // events into kernel DriverEvents. Faithful to pikiloom's claude.ts event shapes
7
- // (system / stream_event{message_start,content_block_delta,message_delta} / assistant / result),
8
- // but fully self-contained. Proves "下层 Claude 不变".
9
6
  export class ClaudeDriver {
10
7
  bin;
11
8
  id = 'claude';
12
- capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true };
13
- constructor(bin = 'claude') {
9
+ capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true, rewind: true };
10
+ pool;
11
+ constructor(bin = 'claude', opts = {}) {
14
12
  this.bin = bin;
13
+ this.pool = opts.warmPool ? new ClaudeWarmPool() : null;
14
+ }
15
+ /** Destroy every parked warm process. Long-lived hosts call this on shutdown. */
16
+ dispose() {
17
+ this.pool?.dispose();
18
+ }
19
+ /** Parked warm processes right now (tests + telemetry). */
20
+ warmPoolSize() {
21
+ return this.pool?.size() ?? 0;
15
22
  }
16
23
  // Interactive Claude Code TUI (no -p): the kernel spawns this in a PTY and passes
17
24
  // the terminal through. Model/resume/BYOK-env come from the kernel's resolution.
@@ -43,7 +50,7 @@ export class ClaudeDriver {
43
50
  if (input.effort)
44
51
  args.push('--effort', input.effort === 'ultra' ? 'max' : input.effort); // request extended thinking (ultra is a display-only alias for max)
45
52
  if (input.sessionId)
46
- args.push(...claudeResumeArgs(input.sessionId, input.fork));
53
+ args.push(...claudeResumeArgs(input.sessionId, input.fork, input.rewind));
47
54
  if (input.systemPrompt)
48
55
  args.push('--append-system-prompt', input.systemPrompt);
49
56
  if (input.mcpConfigPath)
@@ -54,6 +61,17 @@ export class ClaudeDriver {
54
61
  args.push('--replay-user-messages'); // parity: mid-turn steer
55
62
  if (input.extraArgs?.length)
56
63
  args.push(...input.extraArgs);
64
+ // Warm reuse: a rewind rebranches the session's transcript, so a parked process's
65
+ // in-memory conversation is stale — destroy it. A fork never touches the parent's
66
+ // transcript (the parked parent stays valid); the fork turn itself must cold-spawn
67
+ // for its --fork-session flags. Only a plain continuation may reclaim a process, and
68
+ // only when the spawn fingerprint still matches what a cold spawn would use now.
69
+ const fingerprint = claudeProcessFingerprint(this.bin, input);
70
+ if (input.rewind && input.sessionId)
71
+ this.pool?.evictSession(input.sessionId);
72
+ const pooled = (!input.fork && !input.rewind && input.sessionId && this.pool)
73
+ ? this.pool.take(input.sessionId, fingerprint)
74
+ : null;
57
75
  const state = {
58
76
  text: '', reasoning: '', streamedText: false, streamedReasoning: false,
59
77
  // Dangling-tool-loop tracking: sawToolResult flips on the first tool_result; textSinceToolResult
@@ -82,6 +100,8 @@ export class ClaudeDriver {
82
100
  };
83
101
  return new Promise((resolve) => {
84
102
  let child;
103
+ let transport = 'cold';
104
+ let promptDelivered = false;
85
105
  let settled = false;
86
106
  // One-shot guard for the truncated-turn recovery injection (see the result handler).
87
107
  let truncatedRecoveryAttempted = false;
@@ -101,6 +121,8 @@ export class ClaudeDriver {
101
121
  const usageOf = () => this.usage(state);
102
122
  const unref = (tm) => { if (tm && typeof tm.unref === 'function')
103
123
  tm.unref(); };
124
+ let disposeAbort = null;
125
+ let detachTurnListeners = () => { };
104
126
  const clearHoldCap = () => { if (holdCapTimer) {
105
127
  clearTimeout(holdCapTimer);
106
128
  holdCapTimer = null;
@@ -118,18 +140,29 @@ export class ClaudeDriver {
118
140
  // kill=false only ends stdin and lets Claude shut down on its own, so any still-running
119
141
  // detached background work survives a clean exit (a hard kill mid-flight is exactly what
120
142
  // tore the background — and the wake-up — down before). A leak-guard SIGTERM is the backstop.
121
- const finish = (r, kill = true) => {
143
+ const finish = (r, kill = true, park = false) => {
122
144
  if (settled)
123
145
  return;
124
146
  settled = true;
125
147
  clearHoldCap();
126
148
  clearQuiet();
127
149
  clearModelStall();
150
+ disposeAbort?.();
151
+ // Park instead of kill: only a CLEAN settle qualifies (ok, no error, no abort, the
152
+ // process still healthy, session known) — every other exit keeps today's semantics,
153
+ // so pooling can never leak a wedged or errored process.
154
+ if (park && this.pool && state.sessionId && r.ok && !r.error && !ctx.signal.aborted
155
+ && child && !child.killed && child.exitCode == null) {
156
+ detachTurnListeners();
157
+ this.pool.put(state.sessionId, fingerprint, child);
158
+ resolve({ ...r, transport });
159
+ return;
160
+ }
128
161
  try {
129
162
  child?.stdin?.end();
130
163
  }
131
164
  catch { /* ignore */ }
132
- if (!child.killed && child.exitCode == null) {
165
+ if (child && !child.killed && child.exitCode == null) {
133
166
  if (kill)
134
167
  sigterm(child);
135
168
  else {
@@ -137,12 +170,12 @@ export class ClaudeDriver {
137
170
  unref(guard);
138
171
  }
139
172
  }
140
- resolve(r);
173
+ resolve({ ...r, transport });
141
174
  };
142
175
  const settleResult = (opts = {}) => finish({
143
176
  ok: opts.ok ?? !state.error, text: state.text, reasoning: state.reasoning || undefined,
144
177
  error: state.error, stopReason: opts.stopReason ?? state.stopReason, sessionId: state.sessionId, anchor: state.anchor, usage: usageOf(),
145
- }, opts.kill ?? true);
178
+ }, opts.kill ?? true, opts.park ?? false);
146
179
  // Cap while holding for a still-running background task (stopReason marks it as
147
180
  // "still running in the background" so the terminal presentation reads right). Idempotent —
148
181
  // the countdown is absolute from the first arm. Sub-agent-backed holds use the longer
@@ -199,14 +232,32 @@ export class ClaudeDriver {
199
232
  }, claudeModelStallMs(input.effort));
200
233
  unref(modelStallTimer);
201
234
  };
202
- try {
203
- child = spawn(this.bin, args, { cwd: input.workdir, env: { ...process.env, ...(input.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
235
+ // Acquire the process: a reclaimed warm process gets the prompt as its acquisition
236
+ // probe a dead pipe throws synchronously here and the turn transparently falls
237
+ // back to a cold spawn (whose --resume flags are already in `args`).
238
+ let acquired = null;
239
+ if (pooled) {
240
+ try {
241
+ pooled.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
242
+ acquired = pooled;
243
+ transport = 'warm';
244
+ promptDelivered = true;
245
+ }
246
+ catch {
247
+ sigterm(pooled);
248
+ }
204
249
  }
205
- catch (err) {
206
- finish({ ok: false, text: '', error: `spawn failed: ${err?.message || err}`, stopReason: 'error' });
207
- return;
250
+ if (!acquired) {
251
+ try {
252
+ acquired = spawn(this.bin, args, { cwd: input.workdir, env: { ...process.env, ...(input.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
253
+ }
254
+ catch (err) {
255
+ finish({ ok: false, text: '', error: `spawn failed: ${err?.message || err}`, stopReason: 'error' });
256
+ return;
257
+ }
208
258
  }
209
- wireAbort(ctx.signal, () => sigterm(child));
259
+ child = acquired;
260
+ disposeAbort = wireAbort(ctx.signal, () => sigterm(child));
210
261
  if (steerable) {
211
262
  ctx.registerSteer(async (prompt, attachments) => {
212
263
  try {
@@ -220,7 +271,7 @@ export class ClaudeDriver {
220
271
  }
221
272
  const nextLines = createLineBuffer();
222
273
  let stderr = '';
223
- child.stdout.on('data', (chunk) => {
274
+ const onStdout = (chunk) => {
224
275
  if (settled)
225
276
  return; // ignore the process's post-settle shutdown chatter
226
277
  for (const line of nextLines(chunk)) {
@@ -299,8 +350,10 @@ export class ClaudeDriver {
299
350
  // DELIVERED live but never lands in the transcript, so the next re-render erases
300
351
  // it (the "对话结束之后就被吞了" shape, caught by the turn audit). Ending stdin
301
352
  // lets the CLI finish writing and exit on its own; the leak-guard SIGTERM is the
302
- // backstop.
303
- settleResult({ kill: false });
353
+ // backstop. park: this clean settle is the ONE warm-pool-eligible exit — the
354
+ // process (stdin open, transcript flushing in its own time) is parked for the
355
+ // session's next turn instead of being shut down; finish() re-checks health.
356
+ settleResult({ kill: false, park: true });
304
357
  return;
305
358
  }
306
359
  if (decision === 'hold') {
@@ -332,10 +385,10 @@ export class ClaudeDriver {
332
385
  if (ev.type === 'user' && pending === 0 && claudeUserEventHasToolResult(ev))
333
386
  armModelStall();
334
387
  }
335
- });
336
- child.stderr.on('data', (c) => { stderr += c.toString('utf8'); });
337
- child.on('error', (err) => finish({ ok: false, text: state.text, error: `claude spawn error: ${err.message}`, stopReason: 'error' }));
338
- child.on('close', (code) => {
388
+ };
389
+ const onStderr = (c) => { stderr += c.toString('utf8'); };
390
+ const onError = (err) => finish({ ok: false, text: state.text, error: `claude spawn error: ${err.message}`, stopReason: 'error' });
391
+ const onClose = (code) => {
339
392
  if (settled)
340
393
  return;
341
394
  if (ctx.signal.aborted) {
@@ -349,13 +402,27 @@ export class ClaudeDriver {
349
402
  // dangling check must win over it.)
350
403
  const stopReason = (ok && claudeTurnEndedDangling(state)) ? 'truncated' : state.stopReason;
351
404
  finish({ ok, text: state.text, reasoning: state.reasoning || undefined, error: state.error || (ok ? null : `claude exited ${code}${stderr ? `: ${stderr.slice(0, 300)}` : ''}`), stopReason, sessionId: state.sessionId, anchor: state.anchor, usage: usageOf() }, false);
352
- });
353
- try {
354
- // Send the prompt as a stream-json user message and keep stdin OPEN (do not end it here):
355
- // closing it makes Claude exit at the first `result`, before any background task finishes.
356
- child.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
405
+ };
406
+ // Parking hands the process to the pool — these turn-scoped listeners must not
407
+ // outlive the turn (the pool installs its own drain + close bookkeeping).
408
+ detachTurnListeners = () => {
409
+ child.stdout?.off('data', onStdout);
410
+ child.stderr?.off('data', onStderr);
411
+ child.off('error', onError);
412
+ child.off('close', onClose);
413
+ };
414
+ child.stdout.on('data', onStdout);
415
+ child.stderr.on('data', onStderr);
416
+ child.on('error', onError);
417
+ child.on('close', onClose);
418
+ if (!promptDelivered) {
419
+ try {
420
+ // Send the prompt as a stream-json user message and keep stdin OPEN (do not end it here):
421
+ // closing it makes Claude exit at the first `result`, before any background task finishes.
422
+ child.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
423
+ }
424
+ catch { /* ignore */ }
357
425
  }
358
- catch { /* ignore */ }
359
426
  });
360
427
  }
361
428
  usage(s) {
@@ -367,16 +434,53 @@ export class ClaudeDriver {
367
434
  return claudeTranscriptTailAnchor(opts.workdir, opts.sessionId);
368
435
  }
369
436
  }
437
+ // The spawn-time facts that must still match for a parked warm process to serve a
438
+ // continuation turn as if it were a fresh cold `--resume` — any drift (model switch,
439
+ // effort change, new MCP config, different BYOK env, …) destroys the parked process so
440
+ // the new configuration actually applies. `sessionId` is the pool key, not fingerprint
441
+ // material, and `systemPrompt` is deliberately EXCLUDED: it is a first-turn-only input
442
+ // (the parked process already carries it applied; a cold --resume would not re-send it
443
+ // either), so including it would make every continuation miss the pool. Pure + exported
444
+ // for hermetic testing.
445
+ export function claudeProcessFingerprint(bin, input) {
446
+ return JSON.stringify([
447
+ bin, input.workdir, input.model ?? null, input.effort ?? null,
448
+ input.mcpConfigPath ?? null, input.permissionMode ?? null, !!input.steerable,
449
+ fingerprintExtraArgs(input.extraArgs),
450
+ Object.entries(input.env ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
451
+ ]);
452
+ }
453
+ // `--session-id <uuid>` pins a FRESH session's native id and is contributed on the first
454
+ // turn only (continuations resume instead) — it names the session, it doesn't configure
455
+ // the process. Like sessionId itself it must not be fingerprint material, or every
456
+ // continuation of a session born with it would miss the pool forever.
457
+ function fingerprintExtraArgs(extraArgs) {
458
+ const args = extraArgs ?? [];
459
+ const out = [];
460
+ for (let i = 0; i < args.length; i++) {
461
+ if (args[i] === '--session-id') {
462
+ i++;
463
+ continue;
464
+ }
465
+ out.push(args[i]);
466
+ }
467
+ return out;
468
+ }
370
469
  // The --resume flag family for one turn: plain resume appends to the session; a fork branches
371
470
  // a NEW session off it (--fork-session), optionally cut at an inclusive keep-boundary record
372
- // uuid (--resume-session-at). Pure so tests can pin the arg contract without spawning.
373
- export function claudeResumeArgs(sessionId, fork) {
471
+ // uuid (--resume-session-at); a rewind cuts at that boundary WITHOUT --fork-session, so claude
472
+ // rebranches the SAME session in place (the transcript is a parentUuid tree — the dropped tip
473
+ // becomes a dead sibling and leaves the active path). Pure so tests pin the arg contract.
474
+ export function claudeResumeArgs(sessionId, fork, rewind) {
374
475
  const args = ['--resume', sessionId];
375
476
  if (fork) {
376
477
  args.push('--fork-session');
377
478
  if (fork.anchor)
378
479
  args.push('--resume-session-at', fork.anchor);
379
480
  }
481
+ else if (rewind?.anchor) {
482
+ args.push('--resume-session-at', rewind.anchor);
483
+ }
380
484
  return args;
381
485
  }
382
486
  function claudeUsageOf(s) {
@@ -468,6 +572,15 @@ export function handleClaudeEvent(ev, s, emit) {
468
572
  }
469
573
  s.model = ev.model ?? s.model;
470
574
  s.contextWindow = claudeEffectiveContextWindow(claudeContextWindowFromModel(s.model)) ?? s.contextWindow;
575
+ // Claude compacted the running context (subtype `compact_boundary`): `trigger` is
576
+ // `auto` (the context filled up) or `manual` (a `/compact` command). Surface it live
577
+ // so a terminal can show a "compacting" affordance; the compacted summary itself lands
578
+ // in the native transcript and settles into a divider separately.
579
+ if (ev.subtype === 'compact_boundary') {
580
+ const meta = (ev.compact_metadata ?? {});
581
+ emit({ type: 'compaction', trigger: meta.trigger === 'manual' ? 'manual' : 'auto', atTokens: typeof meta.pre_tokens === 'number' ? meta.pre_tokens : null });
582
+ return;
583
+ }
471
584
  // Live thinking progress (system/thinking_tokens, ~every 1.4s of sustained thinking): during
472
585
  // extended thinking a subscription account streams no plaintext (signature_delta only) and no
473
586
  // usage until the message settles, so without projecting these the terminal shows a dead
@@ -19,8 +19,15 @@ export declare function captureCodexAgentMessage(item: any, s: CodexContentState
19
19
  export declare function captureCodexReasoning(text: string, s: CodexContentState, emit: (e: DriverEvent) => void): void;
20
20
  export declare function codexFinalText(s: CodexContentState): string;
21
21
  export declare function codexFinalReasoning(s: CodexContentState): string;
22
+ export interface CodexLivenessOptions {
23
+ /** Silence after an accepted steer before the turn is healed (interrupt + replay). */
24
+ steerStallMs?: number;
25
+ /** Idle silence (no running tool, no pending HITL request) before the turn is force-closed. */
26
+ turnStallMs?: number;
27
+ }
22
28
  export declare class CodexDriver implements AgentDriver {
23
29
  private readonly bin;
30
+ private readonly liveness;
24
31
  readonly id = "codex";
25
32
  readonly capabilities: {
26
33
  steer: boolean;
@@ -29,7 +36,7 @@ export declare class CodexDriver implements AgentDriver {
29
36
  tui: boolean;
30
37
  fork: boolean;
31
38
  };
32
- constructor(bin?: string);
39
+ constructor(bin?: string, liveness?: CodexLivenessOptions);
33
40
  run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
34
41
  tui(input: TuiInput): TuiSpec;
35
42
  listNativeSessions(opts: {
@@ -234,12 +234,53 @@ export function codexFinalText(s) {
234
234
  export function codexFinalReasoning(s) {
235
235
  return s.reasoning.trim() ? s.reasoning : s.thinkParts.join('\n\n');
236
236
  }
237
+ // ── Turn liveness (steer race + silent-stall recovery) ──────────────────────────
238
+ // codex app-server has a turn-boundary race: a `turn/steer` that lands in the instant an
239
+ // item completes is ACCEPTED (recorded into the rollout) but never dispatched — the agent
240
+ // loop goes idle, no further notifications arrive, and `turn/completed` never fires, so the
241
+ // turn hangs forever while the process sits at 0% CPU (observed on codex-cli 0.144.x;
242
+ // same family as openai/codex#15714 / #23807). Two defenses below:
243
+ //
244
+ // 1. A successful steer is treated as ACCEPTED, NOT CONSUMED: it stays pending until a
245
+ // progress notification proves the loop picked it up. If the turn instead goes silent
246
+ // (or completes without consuming it), the driver heals in place — interrupt the wedged
247
+ // turn and restart it with the same input. The steered text is already in the thread
248
+ // history, so the worst false-positive cost is one duplicated user message; the turn
249
+ // keeps running under the same run()/task, invisible to upper layers.
250
+ // 2. A generic silence backstop: a turn with no notifications for a long stretch while
251
+ // nothing is visibly in flight (no running tool call, no server->client request awaiting
252
+ // a human) is declared stalled and force-closed, so the session ends as a visible error
253
+ // instead of spinning forever.
254
+ // Thresholds are calibrated against measured rollouts: with reasoning summaries off, a
255
+ // HEALTHY turn shows fully silent thinking stretches of up to ~320s (clustered just above
256
+ // codex core's own 300s stream watchdog, whose retry produces the next event). 600s has
257
+ // never been reached intra-turn in days of observed sessions, so a heal at that point is
258
+ // near-certainly a real wedge — and even a misfire only costs one duplicated user message
259
+ // plus the in-flight thinking (completed items live in the thread history).
260
+ const CODEX_STEER_STALL_MS = 600_000;
261
+ const CODEX_TURN_STALL_MS = 900_000;
262
+ // Notifications that prove the agent loop is making forward progress AFTER a steer.
263
+ // item/completed and tokenUsage are deliberately excluded — they can be the trailing edge
264
+ // of work that finished before the steer landed (the exact race signature). Progress that
265
+ // arrives within CODEX_STEER_CONSUMED_MS of the steer only refreshes the pending marker's
266
+ // clock rather than clearing it: the app-server can flush pre-acceptance stream output in
267
+ // the same write as the steer response, so near-simultaneous events are not proof the
268
+ // loop survived the injection. Progress beyond that window is.
269
+ const CODEX_STEER_CONSUMED_MS = 2_000;
270
+ const CODEX_PROGRESS_METHODS = new Set([
271
+ 'turn/started', 'item/started', 'item/agentMessage/delta',
272
+ 'item/reasoning/textDelta', 'item/reasoning/summaryTextDelta',
273
+ 'item/commandExecution/outputDelta', 'item/fileChange/outputDelta',
274
+ 'item/fileChange/patchUpdated', 'turn/plan/updated', 'rawResponseItem/completed',
275
+ ]);
237
276
  export class CodexDriver {
238
277
  bin;
278
+ liveness;
239
279
  id = 'codex';
240
280
  capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true };
241
- constructor(bin = 'codex') {
281
+ constructor(bin = 'codex', liveness = {}) {
242
282
  this.bin = bin;
283
+ this.liveness = liveness;
243
284
  }
244
285
  async run(input, ctx) {
245
286
  // BYOK provider routing arrives as `-c key=value` overrides; pass them through so the
@@ -261,6 +302,15 @@ export class CodexDriver {
261
302
  const deltaItems = new Set();
262
303
  let lastTextItemId = null;
263
304
  let steerRegistered = false;
305
+ // Liveness state (see the CODEX_STEER_STALL_MS block comment above).
306
+ const steerStallMs = this.liveness.steerStallMs ?? CODEX_STEER_STALL_MS;
307
+ const turnStallMs = this.liveness.turnStallMs ?? CODEX_TURN_STALL_MS;
308
+ let lastEventAt = Date.now();
309
+ let pendingSteer = null;
310
+ let pendingServerRequests = 0;
311
+ let healing = false;
312
+ let stalled = false;
313
+ let livenessTimer = null;
264
314
  if (!srv.start())
265
315
  return { ok: false, text: '', error: 'failed to start codex app-server', stopReason: 'error' };
266
316
  let settled = false;
@@ -320,20 +370,78 @@ export class CodexDriver {
320
370
  state.sessionId = threadId;
321
371
  ctx.emit({ type: 'session', sessionId: threadId });
322
372
  }
373
+ // Heal a wedged/lost steer in place: (optionally) interrupt the dead turn, then restart
374
+ // it with the same input. Runs under the SAME run()/turnDone, so upper layers just see
375
+ // the turn continue. `healing` suppresses the interrupt's own turn/completed echo.
376
+ const heal = async (opts) => {
377
+ if (settled || healing || !pendingSteer)
378
+ return;
379
+ healing = true;
380
+ const replayInput = pendingSteer.input;
381
+ pendingSteer = null;
382
+ if (opts.interrupt && state.sessionId && state.turnId) {
383
+ await srv.request('turn/interrupt', { threadId: state.sessionId, turnId: state.turnId }, 5_000);
384
+ }
385
+ if (settled) {
386
+ healing = false;
387
+ return;
388
+ } // raced with abort / process death
389
+ const resp = await srv.request('turn/start', {
390
+ threadId: state.sessionId,
391
+ input: replayInput,
392
+ model: input.model || undefined,
393
+ effort: input.effort || undefined,
394
+ });
395
+ if (settled) {
396
+ healing = false;
397
+ return;
398
+ }
399
+ if (resp.error) {
400
+ state.status = 'error';
401
+ state.error = `steer recovery failed: ${resp.error.message || 'turn/start failed'}`;
402
+ healing = false;
403
+ settle();
404
+ return;
405
+ }
406
+ state.turnId = resp.result?.turn?.id ?? state.turnId;
407
+ lastEventAt = Date.now();
408
+ healing = false;
409
+ };
410
+ // Codex emits no explicit compaction event, so track peak occupancy: a sharp
411
+ // mid-turn drop is an auto-compaction, surfaced as a live `compaction` signal.
412
+ let compactPeakTokens = 0;
323
413
  srv.onNotification((method, params) => {
324
414
  if (params?.threadId && params.threadId !== state.sessionId && method !== 'turn/started')
325
415
  return;
416
+ lastEventAt = Date.now();
417
+ if (pendingSteer && CODEX_PROGRESS_METHODS.has(method)) {
418
+ const now = Date.now();
419
+ if (now - pendingSteer.at >= CODEX_STEER_CONSUMED_MS)
420
+ pendingSteer = null; // loop provably alive post-steer
421
+ else
422
+ pendingSteer.progressAt = now; // maybe pre-acceptance flush — keep watching
423
+ }
326
424
  switch (method) {
327
425
  case 'turn/started':
328
426
  state.turnId = params?.turn?.id ?? null;
329
427
  if (!steerRegistered && state.turnId) {
330
428
  steerRegistered = true;
331
429
  ctx.registerSteer(async (prompt, attachments = []) => {
332
- if (!state.sessionId || !state.turnId)
430
+ if (settled || !state.sessionId || !state.turnId)
333
431
  return false;
334
- const r = await srv.request('turn/steer', { threadId: state.sessionId, expectedTurnId: state.turnId, input: buildTurnInput(prompt, attachments) }, 30_000);
335
- if (r.error)
432
+ const steerInput = buildTurnInput(prompt, attachments);
433
+ // Arm BEFORE sending — accepted, not yet consumed. Arming after the response
434
+ // would race the response's own microtask against progress notifications the
435
+ // server flushed in the same write, mis-arming against already-consumed steers.
436
+ // Back-to-back steers accumulate so a heal replays everything swallowed.
437
+ const armed = { input: [...(pendingSteer?.input ?? []), ...steerInput], at: Date.now(), progressAt: null };
438
+ pendingSteer = armed;
439
+ const r = await srv.request('turn/steer', { threadId: state.sessionId, expectedTurnId: state.turnId, input: steerInput }, 30_000);
440
+ if (r.error) {
441
+ if (pendingSteer === armed)
442
+ pendingSteer = null; // rejected — nothing to watch
336
443
  return false;
444
+ }
337
445
  state.turnId = r.result?.turnId ?? state.turnId;
338
446
  return true;
339
447
  });
@@ -444,15 +552,40 @@ export class CodexDriver {
444
552
  case 'thread/tokenUsage/updated': {
445
553
  applyCodexTokenUsage(state, params?.tokenUsage || params?.usage);
446
554
  ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
555
+ // No explicit boundary event: a sharp drop from a high peak IS a compaction.
556
+ // Conservative thresholds (peak ≥ 50% of window, drop ≥ 25% of window) so
557
+ // ordinary turn churn never trips it; re-baseline so it fires once per drop.
558
+ {
559
+ const cw = state.contextWindow ?? 0;
560
+ const used = state.contextUsed ?? 0;
561
+ if (cw > 0 && used >= 0) {
562
+ if (used > compactPeakTokens)
563
+ compactPeakTokens = used;
564
+ else if (compactPeakTokens / cw >= 0.5 && (compactPeakTokens - used) / cw >= 0.25) {
565
+ ctx.emit({ type: 'compaction', trigger: 'auto', atTokens: compactPeakTokens });
566
+ compactPeakTokens = used;
567
+ }
568
+ }
569
+ }
447
570
  break;
448
571
  }
449
572
  case 'turn/completed': {
450
573
  const turn = params?.turn || {};
574
+ applyCodexTokenUsage(state, params?.tokenUsage || turn.tokenUsage || turn.usage);
575
+ ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
576
+ if (healing)
577
+ break; // completion echo of the turn heal() just interrupted
578
+ if (pendingSteer && !pendingSteer.progressAt && !ctx.signal.aborted && (turn.status ?? 'completed') === 'completed') {
579
+ // The other face of the steer race: the turn finished without ever consuming
580
+ // the injected input. Replay it as a fresh turn instead of settling — the
581
+ // upper layers already dequeued the message on steer-ok, so settling here
582
+ // would silently drop it.
583
+ void heal({ interrupt: false });
584
+ break;
585
+ }
451
586
  state.status = turn.status ?? 'completed';
452
587
  if (turn.error)
453
588
  state.error = turn.error.message || turn.error.code || 'turn error';
454
- applyCodexTokenUsage(state, params?.tokenUsage || turn.tokenUsage || turn.usage);
455
- ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
456
589
  settle();
457
590
  break;
458
591
  }
@@ -462,6 +595,7 @@ export class CodexDriver {
462
595
  // accept approvals by default (parity with the legacy codex driver). Never throw —
463
596
  // an unanswerable request degrades to an empty response, not a JSON-RPC error.
464
597
  srv.onRequest(async (method, params, id) => {
598
+ pendingServerRequests++; // a request awaiting a human legitimately silences the turn
465
599
  try {
466
600
  if (method === 'item/tool/requestUserInput') {
467
601
  const interaction = codexUserInputToInteraction(params, `codex-input-${id}`);
@@ -479,6 +613,9 @@ export class CodexDriver {
479
613
  catch {
480
614
  return {};
481
615
  }
616
+ finally {
617
+ pendingServerRequests--;
618
+ }
482
619
  });
483
620
  const turnResp = await srv.request('turn/start', {
484
621
  threadId: state.sessionId,
@@ -488,6 +625,35 @@ export class CodexDriver {
488
625
  });
489
626
  if (turnResp.error)
490
627
  return { ok: false, text: state.text, error: turnResp.error.message || 'turn/start failed', stopReason: 'error', sessionId: state.sessionId };
628
+ // Liveness checker: heals a stalled steer, force-closes a silently dead turn. The
629
+ // silence clock only accumulates while nothing is visibly in flight — a running tool
630
+ // call or a server->client request parked on a human keeps the turn alive forever.
631
+ const checkEveryMs = Math.max(25, Math.min(5_000, Math.floor(Math.min(steerStallMs, turnStallMs) / 4)));
632
+ livenessTimer = setInterval(() => {
633
+ if (settled || healing)
634
+ return;
635
+ const now = Date.now();
636
+ if (pendingSteer) {
637
+ if (now - (pendingSteer.progressAt ?? pendingSteer.at) >= steerStallMs)
638
+ void heal({ interrupt: true });
639
+ return;
640
+ }
641
+ const busy = pendingServerRequests > 0 || [...toolCalls.values()].some(c => c.status === 'running');
642
+ if (busy) {
643
+ lastEventAt = now;
644
+ return;
645
+ }
646
+ if (now - lastEventAt < turnStallMs)
647
+ return;
648
+ stalled = true;
649
+ state.error = state.error || `codex app-server went silent mid-turn (no events for ${Math.round(turnStallMs / 1000)}s); closing the turn`;
650
+ if (state.sessionId && state.turnId) {
651
+ srv.request('turn/interrupt', { threadId: state.sessionId, turnId: state.turnId }, 5_000).finally(() => settle());
652
+ }
653
+ else
654
+ settle();
655
+ }, checkEveryMs);
656
+ livenessTimer.unref?.();
491
657
  await turnDone;
492
658
  const usage = codexUsageOf(state);
493
659
  const ok2 = (state.status === 'completed' || state.status == null) && !state.error && !ctx.signal.aborted;
@@ -497,7 +663,7 @@ export class CodexDriver {
497
663
  text: codexFinalText(state),
498
664
  reasoning: finalReasoning || undefined,
499
665
  error: state.error || (ctx.signal.aborted ? 'Interrupted by user.' : null),
500
- stopReason: ctx.signal.aborted ? 'interrupted' : (state.status || 'end_turn'),
666
+ stopReason: ctx.signal.aborted ? 'interrupted' : stalled ? 'stalled' : (state.status || 'end_turn'),
501
667
  sessionId: state.sessionId,
502
668
  // Fork anchor: the turn id is the inclusive keep-boundary thread/fork's lastTurnId takes.
503
669
  anchor: state.turnId,
@@ -505,6 +671,8 @@ export class CodexDriver {
505
671
  };
506
672
  }
507
673
  finally {
674
+ if (livenessTimer)
675
+ clearInterval(livenessTimer);
508
676
  srv.kill();
509
677
  }
510
678
  }
@@ -10,10 +10,7 @@ export declare function parseJsonLine(line: string): any | undefined;
10
10
  export declare function wireAbort(signal: AbortSignal, fn: () => void): () => void;
11
11
  /** SIGTERM a child, swallowing the already-dead race. */
12
12
  export declare function sigterm(proc: ChildProcess | null | undefined): void;
13
- /** Mime type when the file is an inlineable image, else null. */
14
- export declare function imageMimeForFile(filePath: string): string | null;
15
- /** The text note substituted for a non-image attachment. */
16
- export declare function attachedFileNote(filePath: string): string;
13
+ export { imageMimeForFile, attachedFileNote } from '../attachments.js';
17
14
  /**
18
15
  * Context-window occupancy as a display percent (one decimal, capped at 99.9).
19
16
  * Pass `used` as null when the caller wants "no data" rather than 0%.
@@ -1,4 +1,3 @@
1
- import { extname } from 'node:path';
2
1
  // Driver-internal helpers shared by the concrete drivers (claude/codex/gemini/acp).
3
2
  // NOT part of the public API — nothing here is re-exported by any barrel. Each helper
4
3
  // exists because the same code appeared verbatim in 3+ drivers.
@@ -43,20 +42,9 @@ export function sigterm(proc) {
43
42
  }
44
43
  catch { /* ignore */ }
45
44
  }
46
- // Attachment vocabulary: every driver inlines the same image formats (the Anthropic
47
- // vision set, which the others accept too) and notes non-image files the same way.
48
- const IMAGE_MIME_BY_EXT = {
49
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
50
- '.gif': 'image/gif', '.webp': 'image/webp',
51
- };
52
- /** Mime type when the file is an inlineable image, else null. */
53
- export function imageMimeForFile(filePath) {
54
- return IMAGE_MIME_BY_EXT[extname(filePath).toLowerCase()] ?? null;
55
- }
56
- /** The text note substituted for a non-image attachment. */
57
- export function attachedFileNote(filePath) {
58
- return `[Attached file: ${filePath}]`;
59
- }
45
+ // Attachment vocabulary lives in ../attachments.ts (the Hub also normalizes oversized
46
+ // images there); re-exported so drivers keep one import site for driver-internal helpers.
47
+ export { imageMimeForFile, attachedFileNote } from '../attachments.js';
60
48
  /**
61
49
  * Context-window occupancy as a display percent (one decimal, capped at 99.9).
62
50
  * Pass `used` as null when the caller wants "no data" rather than 0%.
@@ -9,7 +9,7 @@ export type { SessionStore, CoreSessionRecord, ModelResolver, ModelInjection, To
9
9
  export type { LoomIO, PromptInput, Surface, SurfaceCapabilities, Plugin, SpawnContribution, } from './contracts/surface.js';
10
10
  export { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, AutoCancelInteractionHandler, DeferToTerminalInteractionHandler, NoopCatalog, defaultBaseDir, } from './ports/defaults.js';
11
11
  export { EchoDriver } from './drivers/echo.js';
12
- export { ClaudeDriver } from './drivers/claude.js';
12
+ export { ClaudeDriver, type ClaudeDriverOptions } from './drivers/claude.js';
13
13
  export { CodexDriver } from './drivers/codex.js';
14
14
  export { GeminiDriver } from './drivers/gemini.js';
15
15
  export { AcpDriver, type AcpDriverConfig } from './drivers/acp.js';