@pikiloom/kernel 0.3.15 → 0.3.17

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.
@@ -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,23 +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
+ };
323
410
  // Codex emits no explicit compaction event, so track peak occupancy: a sharp
324
411
  // mid-turn drop is an auto-compaction, surfaced as a live `compaction` signal.
325
412
  let compactPeakTokens = 0;
326
413
  srv.onNotification((method, params) => {
327
414
  if (params?.threadId && params.threadId !== state.sessionId && method !== 'turn/started')
328
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
+ }
329
424
  switch (method) {
330
425
  case 'turn/started':
331
426
  state.turnId = params?.turn?.id ?? null;
332
427
  if (!steerRegistered && state.turnId) {
333
428
  steerRegistered = true;
334
429
  ctx.registerSteer(async (prompt, attachments = []) => {
335
- if (!state.sessionId || !state.turnId)
430
+ if (settled || !state.sessionId || !state.turnId)
336
431
  return false;
337
- const r = await srv.request('turn/steer', { threadId: state.sessionId, expectedTurnId: state.turnId, input: buildTurnInput(prompt, attachments) }, 30_000);
338
- 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
339
443
  return false;
444
+ }
340
445
  state.turnId = r.result?.turnId ?? state.turnId;
341
446
  return true;
342
447
  });
@@ -466,11 +571,21 @@ export class CodexDriver {
466
571
  }
467
572
  case 'turn/completed': {
468
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
+ }
469
586
  state.status = turn.status ?? 'completed';
470
587
  if (turn.error)
471
588
  state.error = turn.error.message || turn.error.code || 'turn error';
472
- applyCodexTokenUsage(state, params?.tokenUsage || turn.tokenUsage || turn.usage);
473
- ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
474
589
  settle();
475
590
  break;
476
591
  }
@@ -480,6 +595,7 @@ export class CodexDriver {
480
595
  // accept approvals by default (parity with the legacy codex driver). Never throw —
481
596
  // an unanswerable request degrades to an empty response, not a JSON-RPC error.
482
597
  srv.onRequest(async (method, params, id) => {
598
+ pendingServerRequests++; // a request awaiting a human legitimately silences the turn
483
599
  try {
484
600
  if (method === 'item/tool/requestUserInput') {
485
601
  const interaction = codexUserInputToInteraction(params, `codex-input-${id}`);
@@ -497,6 +613,9 @@ export class CodexDriver {
497
613
  catch {
498
614
  return {};
499
615
  }
616
+ finally {
617
+ pendingServerRequests--;
618
+ }
500
619
  });
501
620
  const turnResp = await srv.request('turn/start', {
502
621
  threadId: state.sessionId,
@@ -506,6 +625,35 @@ export class CodexDriver {
506
625
  });
507
626
  if (turnResp.error)
508
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?.();
509
657
  await turnDone;
510
658
  const usage = codexUsageOf(state);
511
659
  const ok2 = (state.status === 'completed' || state.status == null) && !state.error && !ctx.signal.aborted;
@@ -515,7 +663,7 @@ export class CodexDriver {
515
663
  text: codexFinalText(state),
516
664
  reasoning: finalReasoning || undefined,
517
665
  error: state.error || (ctx.signal.aborted ? 'Interrupted by user.' : null),
518
- stopReason: ctx.signal.aborted ? 'interrupted' : (state.status || 'end_turn'),
666
+ stopReason: ctx.signal.aborted ? 'interrupted' : stalled ? 'stalled' : (state.status || 'end_turn'),
519
667
  sessionId: state.sessionId,
520
668
  // Fork anchor: the turn id is the inclusive keep-boundary thread/fork's lastTurnId takes.
521
669
  anchor: state.turnId,
@@ -523,6 +671,8 @@ export class CodexDriver {
523
671
  };
524
672
  }
525
673
  finally {
674
+ if (livenessTimer)
675
+ clearInterval(livenessTimer);
526
676
  srv.kill();
527
677
  }
528
678
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikiloom/kernel",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Heterogeneous coding agents (Claude / Codex / Gemini / ACP) -> an interaction-friendly, accumulating session snapshot + control handle, over pluggable surfaces (IM, Web, tunnel, TUI). The reusable core pikiloom itself is built on.",
5
5
  "type": "module",
6
6
  "license": "MIT",