cc-viewer 1.9.17 → 1.9.19

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.
@@ -162,12 +162,39 @@ export function listSdkSessions() {
162
162
 
163
163
  registerSdkSessionLister(listSdkSessions);
164
164
 
165
- // Silence window for the watchdog. Must stay above the approval ceilings the turn can
166
- // legally wait on: perm/plan = 5min, AskUserQuestion = 24h ("GUI effectively has no
167
- // timeout" is a product contract). A pending approval exempts the turn from the
168
- // watchdog entirely; 25min covers long tool runs (big builds) with headroom.
169
- const TURN_WATCHDOG_MS = 25 * 60 * 1000;
165
+ // The turn can legally wait on approval ceilings: perm/plan = 5min, AskUserQuestion =
166
+ // 24h ("GUI effectively has no timeout" is a product contract). A pending approval
167
+ // exempts the turn from probing entirely (the child is parked awaiting our response).
170
168
  const INTERRUPT_GRACE_MS = 10 * 1000;
169
+ // Activity-probe tuning: how long a turn may produce NOTHING before we start pinging
170
+ // the child's control plane, how often we re-ping, and how many consecutive unanswered
171
+ // probes convict the child as wedged. A legal long turn (multi-hour tool, user away) is
172
+ // silent yet answers probes, so it is never convicted on silence alone. The probe must
173
+ // be a REAL control round-trip (getContextUsage — supportedCommands/initializationResult
174
+ // are cached init promises in the SDK and resolve even against a wedged event loop).
175
+ const ACTIVITY_PROBE_IDLE_MS = 10 * 60 * 1000;
176
+ const ACTIVITY_PROBE_INTERVAL_MS = 3 * 60 * 1000;
177
+ const ACTIVITY_PROBE_MAX_FAILURES = 2;
178
+ // HTTP statuses the CLI/ccv treat as retryable API errors (drives the manual [retry]
179
+ // affordance). error_status === null (a network error with no HTTP response) is also
180
+ // retryable; auth (401/403) and billing failures are not — retrying them is pointless.
181
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504, 529]);
182
+ // Synthetic continue instruction pushed by the manual [retry] affordance. Marked with a
183
+ // sentinel the web UI hides from the chat bubble (the model still reads the instruction).
184
+ // It does NOT resend the user's original message — that is already in the history.
185
+ const RETRY_CONTINUE_PROMPT =
186
+ '[cc-viewer:retry] The previous response was interrupted by an API error. ' +
187
+ 'Continue the same task from where it stopped. Do not repeat completed work; ' +
188
+ 'finish the remaining steps and reply concisely.';
189
+ // How often to re-broadcast sdk-session-state while a turn is active (keeps the client's
190
+ // Stop button/spinner alive past its 30s stale sweep and re-seeds it after a view switch).
191
+ const STATE_HEARTBEAT_MS = 10 * 1000;
192
+ // Message-level activity coalescing: every non-boundary SDK message (assistant/user/
193
+ // tool_progress/system task_*/status/api_retry/compact) refreshes turn liveness, but
194
+ // rebroadcasting per message would flood the terminal WS on busy turns. Activity frames
195
+ // reuse the sdk-session-state shape (heartbeat+activity flags) so clients need no new
196
+ // handler, and are capped at one per ACTIVITY_MIN_INTERVAL_MS per turn.
197
+ const ACTIVITY_MIN_INTERVAL_MS = 1000;
171
198
  // getContextUsage control requests have no SDK-side timeout — bound the wait so a live-but-
172
199
  // unresponsive child can't wedge the in-flight gate forever.
173
200
  const CTX_USAGE_TIMEOUT_MS = 10 * 1000;
@@ -184,6 +211,35 @@ let _onTurnEnd = null; // SDK mode has no Stop hook (ensureHooks() skipped) —
184
211
  let _onQueryError = null; // query-level failure → (message: string, meta: {cwd, sessionId, instanceKey}) => void; cli.js forwards it as a scoped sdk-error WS frame (dialogue row + toast)
185
212
  let _broadcastWs = null;
186
213
  let _runWaterfallHook = null;
214
+ // Authoritative per-turn loop boundaries (voice/plugin/SSE agent_loop_*). Unlike the
215
+ // debounced turn_end, these fire exactly once per accepted turn at push time (start) and
216
+ // at the turn's real settlement (end) — see _emitAgentLoopStart/_emitAgentLoopEnd.
217
+ let _onAgentLoopStart = null;
218
+ let _onAgentLoopEnd = null;
219
+ // Durable process-level wiring registered ONCE by cli.js. Unlike the mutable callbacks
220
+ // above (which stopSession clears), this survives a workspace stop→relaunch so a launch
221
+ // route that omits the callbacks still restores them (see _adoptProcessWiring). Tests
222
+ // never register it, so stopSession's "clear the wiring" contract for tests is unchanged.
223
+ let _processWiring = null;
224
+ export function setSdkProcessWiring(w) {
225
+ _processWiring = (w == null) ? null : {
226
+ onTurnEnd: w.onTurnEnd || null,
227
+ onQueryError: w.onQueryError || null,
228
+ broadcastWs: w.broadcastWs || null,
229
+ runWaterfallHook: w.runWaterfallHook || null,
230
+ onAgentLoopStart: w.onAgentLoopStart || null,
231
+ onAgentLoopEnd: w.onAgentLoopEnd || null,
232
+ };
233
+ }
234
+ function _adoptProcessWiring() {
235
+ if (!_processWiring) return;
236
+ if (!_onTurnEnd) _onTurnEnd = _processWiring.onTurnEnd;
237
+ if (!_onQueryError) _onQueryError = _processWiring.onQueryError;
238
+ if (!_broadcastWs) _broadcastWs = _processWiring.broadcastWs;
239
+ if (!_runWaterfallHook) _runWaterfallHook = _processWiring.runWaterfallHook;
240
+ if (!_onAgentLoopStart) _onAgentLoopStart = _processWiring.onAgentLoopStart;
241
+ if (!_onAgentLoopEnd) _onAgentLoopEnd = _processWiring.onAgentLoopEnd;
242
+ }
187
243
 
188
244
  // Process-level SDK LAUNCH-CONFIG template (2026-10-12). cli.js registers it once at boot
189
245
  // (the env carries the loopback-proxy URL + the per-cwd header is added per session). The
@@ -360,10 +416,41 @@ function _createSession(key, cwd) {
360
416
  // CLI-side turn may still be running; without the sticky flag a
361
417
  // second Stop would silently no-op).
362
418
  let _settleGraceTimer = null; // backstop after interrupt: force-settle if no result arrives
363
- let _lateResultTombstones = 0; // results to discard (already force-settled via grace/watchdog)
419
+ let _lostResultTimer = null; // lost-result backstop handle (cleared on turn settle/teardown)
420
+ // Generation guard for late results (replaces the old _lateResultTombstones counter).
421
+ // Result frames carry no turn id, so a force-settled turn's late result is
422
+ // indistinguishable from the NEXT turn's result on arrival order alone. The counter
423
+ // solved this by counting "results still owed", but it only reset on teardown/reset —
424
+ // so when the owed result NEVER arrived (the lost-result case), the count outlived its
425
+ // turn and swallowed the NEXT turn's result (no shift, no turn_end → that turn then
426
+ // force-settled as turn-stuck too). The guard instead binds the debt to the force-settled
427
+ // turn's epoch: a result is discarded only while it provably belongs to the dead
428
+ // generation. Once a NEWER turn is in flight, its own result pairs normally — the stale
429
+ // debt is dropped (the old result may never come, and must not tax the live turn).
430
+ let _turnEpoch = 0; // advanced per _beginTurn; rec.epoch snapshots it
431
+ let _lateResultEpoch = 0; // epoch of the force-settled turn awaiting a late result (0 = none)
364
432
  let _consecutiveDeaths = 0; // backstop against respawn storms in a broken environment
365
- let _turnWatchdog = null; // SILENCE watchdog: re-armed on every incoming message; fires only
366
- // when the session produces nothing for the whole window
433
+ // Activity probe (replaces the old fixed 25min silence watchdog): re-armed on every
434
+ // incoming SDK message. When a turn stays silent past ACTIVITY_PROBE_IDLE_MS it pings
435
+ // the CLI's control plane (getContextUsage — a REAL round-trip; the init-cached
436
+ // supportedCommands/initializationResult would resolve even against a wedged child).
437
+ // Only consecutive unanswered probes mean the child is genuinely wedged (a legal
438
+ // multi-hour tool turn keeps its event loop responsive and answers). This never kills
439
+ // a long turn for being merely quiet.
440
+ let _activityProbe = null;
441
+ let _probeFailures = 0;
442
+ let _probeEpoch = 0; // bumped on any message/interrupt → invalidates an in-flight probe
443
+ // Monotonic inbound-message counter, bumped ONCE per received SDK message (not by
444
+ // watchdog arms/clears like _probeEpoch). The lost-result backstop snapshots it at
445
+ // arm time: any message arriving afterwards proves the head turn is producing work,
446
+ // so a mis-armed conviction (uncorrelatable-idle hazard) disarms instead of firing.
447
+ let _inboundMsgSeq = 0;
448
+ // Suspect generation: set when an interrupt grace expires with no result (the CLI never
449
+ // answered our Stop) or a probe convicts the child. The wedged query is torn down LAZILY
450
+ // — on the next push rather than at conviction time — so we never SIGTERM a child that
451
+ // may just be slow, and the rebuild resumes _sessionId for continuity.
452
+ let _suspectGeneration = false;
453
+ let _lastFailedTurn = null; // { turnId, retryable, apiStatus, attempt, maxRetries, code, ts } — drives the manual [retry] affordance
367
454
  let _sessionId = null;
368
455
  let _initAnnouncedSid = ''; // sessionId whose slash-command surface was already announced (reset on full reset)
369
456
  let _lastInitSnapshot = null; // last sdk-init payload for WS reconnect replay
@@ -380,6 +467,13 @@ function _createSession(key, cwd) {
380
467
  // injected by cli.js) > push-running / result-idle fallbacks below. Retained for WS
381
468
  // reconnect replay like _lastInitSnapshot; reset alongside it on switch/reset.
382
469
  let _lastSessionState = null;
470
+ let _stateHeartbeat = null; // setInterval re-broadcasting sdk-session-state while a turn is active
471
+ // Message-activity coalescer state (see ACTIVITY_MIN_INTERVAL_MS): pending throttled
472
+ // activity frame plus the wall-clock turn stamp used as the wire-level turnStartedAt.
473
+ let _lastActivityAt = 0;
474
+ let _activityPending = null; // { kind, rec }
475
+ let _activityTimer = null;
476
+ let _lastTurnStartedAt = 0; // wire turnStartedAt for frames emitted with no head turn (idle settles)
383
477
  let _lastContextUsage = null; // last sdk-context-usage frame for WS reconnect replay
384
478
  let _ctxUsageFetching = false; // one getContextUsage control request in flight
385
479
  let _ctxUsageQueued = false; // a turn-end/compact arrived while in flight → rerun once (coalesce)
@@ -450,11 +544,20 @@ function _createSession(key, cwd) {
450
544
  // (an interrupted turn still synthesizes an error_during_execution result). All
451
545
  // settle paths (result / grace force-settle / teardown settleAll) go through
452
546
  // _settleTurn, which is idempotent via rec.settled.
547
+ let _turnSeq = 0;
453
548
  function _beginTurn() {
454
549
  let resolve;
455
550
  const promise = new Promise((r) => { resolve = r; });
456
- const rec = { resolve, interrupted: false, settled: false };
551
+ const rec = {
552
+ id: 'turn_' + (++_turnSeq).toString(36) + '_' + Math.random().toString(36).slice(2, 8),
553
+ resolve, interrupted: false, settled: false,
554
+ epoch: ++_turnEpoch, // generation this turn belongs to — the late-result guard keys off it
555
+ startedAt: Date.now(), // wall-clock turn stamp — the wire-level turnStartedAt (restart-safe, unlike the counter epoch)
556
+ loopStarted: false, loopEnded: false, // idempotence for the agent_loop boundary callbacks
557
+ apiRetry: { count: 0, attempt: 0, maxRetries: 0, lastStatus: null, lastError: null },
558
+ };
457
559
  _pendingTurns.push(rec);
560
+ _lastTurnStartedAt = rec.startedAt;
458
561
  return { rec, promise };
459
562
  }
460
563
 
@@ -471,38 +574,157 @@ function _createSession(key, cwd) {
471
574
  _queryBusy = false;
472
575
  _turnInFlight = false;
473
576
  _clearTurnWatchdog();
577
+ _clearLostResultBackstop();
474
578
  }
475
579
 
476
580
  function _clearGraceTimer() {
477
581
  if (_settleGraceTimer) { clearTimeout(_settleGraceTimer); _settleGraceTimer = null; }
478
582
  }
479
583
 
480
- /** Soft SILENCE watchdog: re-armed on every incoming SDK message, so it only fires
481
- * when the session has produced nothing for the full window — a genuinely stuck/dead
482
- * turn. Pending approvals prove the session is alive (the child is parked waiting for
483
- * our control response), so the watchdog merely re-arms in that case. Force-settle is
484
- * symmetric with the interrupt grace path: settle the head turn FIRST (its promise
485
- * must resolve), then tombstone the result that may still arrive. */
584
+ /** Activity probe (replaces the old fixed 25min silence watchdog). Re-armed on every
585
+ * incoming SDK message, so it only fires after ACTIVITY_PROBE_IDLE_MS of total silence.
586
+ * Instead of force-killing the turn for being quiet (which would murder a legal
587
+ * multi-hour tool run), it pings the CLI's control plane: a live child answers, a wedged
588
+ * one does not. Only ACTIVITY_PROBE_MAX_FAILURES consecutive unanswered probes convict
589
+ * the child and trigger recovery. Pending approvals also prove liveness (the child is
590
+ * parked awaiting our control response), so probes are not even sent then.
591
+ *
592
+ * `_probeFailures` deliberately survives re-arms: _clearTurnWatchdog only cancels the
593
+ * pending timer, and the counter is reset by a successful/answered probe (or a full
594
+ * teardown/reset) — otherwise conviction at MAX_FAILURES would be unreachable. */
486
595
  function _armTurnWatchdog() {
596
+ // NOTE: cancel + bump the epoch FIRST (any arm is triggered by observed activity
597
+ // and must invalidate an in-flight probe), then decide whether a timer is needed.
487
598
  _clearTurnWatchdog();
488
599
  if (_pendingTurns.length === 0) return;
489
- _turnWatchdog = setTimeout(() => {
490
- _turnWatchdog = null;
600
+ if (_pendingApprovals.size > 0) {
601
+ // Approval pending = the child is parked awaiting our control response, which
602
+ // itself proves liveness — do not even probe. No timer is armed here; probing
603
+ // resumes via _rearmWatchdogAfterApprovalSettled when the last approval settles.
604
+ return;
605
+ }
606
+ _activityProbe = setTimeout(() => {
607
+ _activityProbe = null;
491
608
  if (_pendingTurns.length === 0) return;
492
- if (_pendingApprovals.size > 0) {
493
- _armTurnWatchdog(); // approval pending = alive; wait out the approval timeout instead
609
+ if (_pendingApprovals.size > 0) { _armTurnWatchdog(); return; } // approval parked mid-wait — stand down
610
+ _runActivityProbe();
611
+ }, _probeFailures === 0 ? ACTIVITY_PROBE_IDLE_MS : ACTIVITY_PROBE_INTERVAL_MS);
612
+ if (_activityProbe.unref) _activityProbe.unref();
613
+ }
614
+
615
+ /** Re-arm the activity probe after the LAST pending approval settles (answered,
616
+ * cancelled, or timed out). _armTurnWatchdog deliberately arms NO timer while an
617
+ * approval is parked (the park itself proves liveness), so without this hook a
618
+ * child that wedges AFTER the approval clears would never be probed again — the
619
+ * watchdog would be permanently disarmed for the rest of the turn. Only re-arm
620
+ * when the approval map just emptied; a still-pending sibling approval keeps the
621
+ * park (and its liveness guarantee) intact. */
622
+ function _rearmWatchdogAfterApprovalSettled() {
623
+ if (_pendingTurns.length === 0) return;
624
+ if (_pendingApprovals.size > 0) return;
625
+ _armTurnWatchdog();
626
+ }
627
+
628
+ function _clearTurnWatchdog() {
629
+ if (_activityProbe) { clearTimeout(_activityProbe); _activityProbe = null; }
630
+ // Any arm/clear is triggered by observed activity (a message re-arms on every frame,
631
+ // a settle clears) — invalidate an in-flight probe and reset consecutive-failure
632
+ // counting so a slow probe on the PREVIOUS quiet stretch cannot convict a live turn.
633
+ _probeEpoch++;
634
+ if (_pendingTurns.length === 0) _probeFailures = 0;
635
+ }
636
+
637
+ /** Ping the child's control plane once with a REAL control round-trip (getContextUsage;
638
+ * supportedCommands/initializationResult resolve from the cached init promise and prove
639
+ * nothing). An answer — resolve OR reject — means the event loop is responsive: reset
640
+ * the failure count and keep waiting silently. No answer within the timeout counts one
641
+ * failure; ACTIVITY_PROBE_MAX_FAILURES consecutive failures convict the child as wedged.
642
+ * Both outcomes re-check the generation + FIFO: a probe started on query A must never
643
+ * convict generation B, and a turn that completed mid-probe must not be "recovered".
644
+ * A probe that never resolves leaks one in-flight control request per conviction —
645
+ * bounded, and the pending reject on teardown (Query.cleanup) is swallowed by `done`. */
646
+ function _runActivityProbe() {
647
+ const q = _activeQuery;
648
+ if (!q || typeof q.getContextUsage !== 'function') { _armTurnWatchdog(); return; }
649
+ // Bind the verdict to the TURN probed AND the probe epoch: any message/interrupt
650
+ // observed since this probe started proves the child is alive, so its timeout must be
651
+ // discarded rather than charged. (a new turn as head also invalidates the verdict.)
652
+ const probeRec = _pendingTurns[0] || null;
653
+ const epoch = _probeEpoch;
654
+ let timer = null;
655
+ let done = false;
656
+ // NOTE: own .then/.catch handlers instead of Promise.race — under node:test mocked
657
+ // timers a Promise.race([neverSettlingPromise, mockedTimerPromise]) never runs its
658
+ // reactions (race subscribes to the pending promise first; the mocked timer's later
659
+ // settle does not flush the race's capability). Plain handlers are immune.
660
+ timer = setTimeout(() => {
661
+ if (done) return;
662
+ done = true;
663
+ timer = null;
664
+ finish('timeout');
665
+ }, CTX_USAGE_TIMEOUT_MS);
666
+ if (timer.unref) timer.unref();
667
+ let probeCall;
668
+ try { probeCall = q.getContextUsage(); }
669
+ catch { finish('answered'); return; } // a sync throw still proves the event loop runs
670
+ if (!probeCall || typeof probeCall.then !== 'function') { finish('answered'); return; } // non-Promise impl = responsive
671
+ probeCall.then(
672
+ () => { if (done) return; done = true; if (timer) { clearTimeout(timer); timer = null; } finish('answered'); },
673
+ () => { if (done) return; done = true; if (timer) { clearTimeout(timer); timer = null; } finish('answered'); },
674
+ );
675
+ function finish(outcome) {
676
+ // Stale generation / settled-or-switched turn / activity since the probe started /
677
+ // a user interrupt / a parked approval: the probe's verdict no longer applies.
678
+ if (_activeQuery !== q || _pendingTurns.length === 0 || _pendingTurns[0] !== probeRec) return;
679
+ if (epoch !== _probeEpoch) return; // activity observed mid-probe — the child is alive
680
+ if (probeRec && probeRec.interrupted) return;
681
+ if (_pendingApprovals.size > 0) return;
682
+ if (outcome === 'timeout') {
683
+ _probeFailures += 1;
684
+ if (_probeFailures < ACTIVITY_PROBE_MAX_FAILURES) { _armTurnWatchdog(); return; }
685
+ console.warn('[sdk-manager] turn unresponsive to control probes — recovering the wedged query');
686
+ _recoverFromStuckTurn(_pendingTurns[0] || null);
494
687
  return;
495
688
  }
496
- console.warn('[sdk-manager] turn silent beyond watchdog window — force-settling (possible silent session death)');
497
- _lateResultTombstones++;
498
- _settleAllTurns();
499
- _maybeDrain();
500
- }, TURN_WATCHDOG_MS);
501
- if (_turnWatchdog.unref) _turnWatchdog.unref();
689
+ // resolve OR reject both prove the control plane (event loop) is responsive.
690
+ _probeFailures = 0;
691
+ _armTurnWatchdog(); // still no turn output — schedule the next idle probe
692
+ }
502
693
  }
503
694
 
504
- function _clearTurnWatchdog() {
505
- if (_turnWatchdog) { clearTimeout(_turnWatchdog); _turnWatchdog = null; }
695
+ /** Recover from a genuinely wedged query (proven by control probes, or an interrupt the
696
+ * CLI never answered). Settles turn promises, announces idle, records a retryable failure
697
+ * so the UI can offer a manual [retry], and marks the generation suspect. The teardown
698
+ * itself is LAZY — deferred to the next push (_startTurn consults _suspectGeneration) —
699
+ * so we never SIGTERM a merely-slow child mid-side-effect; the rebuild resumes
700
+ * _sessionId for continuity. */
701
+ function _recoverFromStuckTurn(stalledRec) {
702
+ _suspectGeneration = true;
703
+ // Cancel any parked approvals: they belong to the (now suspect) generation's canUseTool,
704
+ // and leaving them would replay a ghost modal on attach AND suppress probing on the
705
+ // rebuilt generation (size > 0 = "alive"). They time out / deny as their own path.
706
+ _cancelAllApprovals();
707
+ // Mark the result that a merely-slow child may still emit before the lazy teardown —
708
+ // otherwise it would fire a spurious turn_end for an already-failed turn. Bound to the
709
+ // head turn's epoch so it cannot tax a later turn if the result never arrives.
710
+ if (_pendingTurns[0]) _lateResultEpoch = _pendingTurns[0].epoch;
711
+ const stuckRecs = [..._pendingTurns];
712
+ _settleAllTurns();
713
+ for (const r of stuckRecs) _emitAgentLoopEnd(r, 'turn-stuck');
714
+ _announceSessionState('idle', stalledRec || null);
715
+ _clearActivity();
716
+ // Retry is only possible if a session id was captured to resume — a first turn that
717
+ // wedges before any init frame has nothing to continue, so don't offer [retry].
718
+ const retryable = !!(_sessionId || _launchResume);
719
+ _lastFailedTurn = {
720
+ turnId: stalledRec?.id || null, retryable, apiStatus: null,
721
+ attempt: stalledRec?.apiRetry?.attempt || 0, maxRetries: stalledRec?.apiRetry?.maxRetries || 0,
722
+ code: 'turn-stuck', ts: Date.now(),
723
+ };
724
+ _notifyQueryError(new Error('The turn became unresponsive; the SDK session will restart on the next message.'), {
725
+ code: 'turn-stuck', retryable, turnId: stalledRec?.id || null,
726
+ });
727
+ _maybeDrain(); // a queued backlog must not stall behind the recovered turn
506
728
  }
507
729
 
508
730
  /**
@@ -536,7 +758,18 @@ function _createSession(key, cwd) {
536
758
  // A fresh user-initiated turn re-arms automatic draining of parked (Stop-kept) items.
537
759
  _suppressDrain = false;
538
760
  _consecutiveDeaths = 0; // explicit user action re-arms the death-drain backstop
539
- const { promise } = await _startTurn(text);
761
+ const { started, promise, notified } = await _startTurn(text);
762
+ // A pre-push delivery failure (teardown raced / query closed) would otherwise leave
763
+ // the user with a cleared composer and no signal. _startTurn already settled the turn
764
+ // promise (so `promise` won't hang); reject HERE with a code so server.js can route a
765
+ // directed sdk-send-failed to the initiator. Construction failures are excluded
766
+ // (notified:true) — they already fired _notifyQueryError (a second toast would
767
+ // double-report).
768
+ if (!started && !notified) {
769
+ const err = new Error('message not delivered to the SDK session');
770
+ err.code = 'not-delivered';
771
+ throw err;
772
+ }
540
773
  return promise;
541
774
  }
542
775
 
@@ -569,6 +802,16 @@ function _createSession(key, cwd) {
569
802
  * rejects; a later turn failure must NOT put an already-pushed message back.
570
803
  */
571
804
  async function _startTurn(text, dispatch = null) {
805
+ // A convicted/suspect generation is torn down HERE — lazily, just before the next
806
+ // push — rather than at conviction time. Deferring avoids SIGTERM-ing a merely-slow
807
+ // child mid-side-effect; rebuilding now lets _ensureResidentQuery resume _sessionId.
808
+ // This MUST run before _beginTurn(): teardown settles every pending turn record, so a
809
+ // record created first would be settled (and its message silently dropped) by its own
810
+ // teardown, leaving _queryBusy wedged with an empty FIFO.
811
+ if (_suspectGeneration) {
812
+ _suspectGeneration = false;
813
+ _teardownResident();
814
+ }
572
815
  const { rec, promise } = _beginTurn();
573
816
  if (dispatch) rec.dispatchItem = dispatch;
574
817
  _queryBusy = true;
@@ -580,7 +823,9 @@ function _createSession(key, cwd) {
580
823
  _settleTurn(rec);
581
824
  _queryBusy = false;
582
825
  _notifyQueryError(err);
583
- return { started: false, promise };
826
+ // notified:true tells _sendUserMessage NOT to also reject not-delivered — the user
827
+ // already got the query-error toast; a second rejection would double-report.
828
+ return { started: false, promise, turnId: rec.id, notified: true };
584
829
  }
585
830
  // Teardown/stop raced with construction — the settle already happened; don't push.
586
831
  if (rec.settled || (dispatch && _dispatching !== dispatch)) {
@@ -589,13 +834,13 @@ function _createSession(key, cwd) {
589
834
  _settleTurn(rec);
590
835
  if (_dispatching !== dispatch) _queryBusy = false;
591
836
  }
592
- return { started: false, promise };
837
+ return { started: false, promise, turnId: rec.id };
593
838
  }
594
839
  if (!_activeQuery || !_inputQueue || (typeof _inputQueue.isClosed === 'function' && _inputQueue.isClosed()) || (dispatch && _dispatching !== dispatch)) {
595
840
  _pendingTurns = _pendingTurns.filter((turn) => turn !== rec);
596
841
  _settleTurn(rec);
597
842
  _queryBusy = false;
598
- return { started: false, promise };
843
+ return { started: false, promise, turnId: rec.id };
599
844
  }
600
845
  // Typed /clear is detected at EXECUTION time (never at enqueue time): a clear queued
601
846
  // behind a busy turn must not wipe the running turn's usage panel. Invalidate the
@@ -613,16 +858,19 @@ function _createSession(key, cwd) {
613
858
  _settleTurn(rec);
614
859
  _queryBusy = false;
615
860
  reportSwallowed('sdk-manager.queue-push', err);
616
- return { started: false, promise };
861
+ return { started: false, promise, turnId: rec.id };
617
862
  }
618
863
  _turnInFlight = true;
619
864
  // Turn-level loading fallback: light the spinner the moment the turn is pushed.
620
865
  // The CLI's own session_state_changed(running) arrives later and is idempotent
621
866
  // against this — on CLIs that never emit it (no env gate / older build) this is
622
867
  // the only "on" signal, paired with the result-idle broadcast in _processMessage.
623
- _announceSessionState('running');
868
+ _announceSessionState('running', rec);
869
+ // Authoritative loop start (voice/plugin/SSE agent_loop_start) — fires once, only
870
+ // after the push is accepted (queued/undelivered messages never ring).
871
+ _emitAgentLoopStart(rec);
624
872
  _armTurnWatchdog();
625
- return { started: true, promise };
873
+ return { started: true, promise, turnId: rec.id };
626
874
  }
627
875
 
628
876
  /**
@@ -883,6 +1131,10 @@ function _createSession(key, cwd) {
883
1131
  async function _drainResidentIterator(query, inputQueue) {
884
1132
  try {
885
1133
  for await (const msg of query) {
1134
+ // Generation guard: after a teardown/switch, a stale generation's late frames must
1135
+ // NOT be processed — they belong to the dead child and would mis-settle the NEW
1136
+ // generation's pending turns. Bail out and let _onResidentDeath dismiss it as stale.
1137
+ if (_activeQuery !== query) break;
886
1138
  // A message-processing failure (e.g. a broadcast callback throwing) must NOT be
887
1139
  // mistaken for session death — the child is still alive; only the iterator's
888
1140
  // end/throw is a death signal.
@@ -891,7 +1143,13 @@ function _createSession(key, cwd) {
891
1143
  } catch (msgErr) {
892
1144
  reportSwallowed('sdk-manager.process-message', msgErr);
893
1145
  }
894
- _armTurnWatchdog(); // silence watchdog: any message proves the session is alive
1146
+ // Any message proves the child is alive: reset consecutive-failure counting too,
1147
+ // so a probe that timed out on the PREVIOUS quiet stretch cannot convict a turn
1148
+ // that has since demonstrably produced output.
1149
+ _probeFailures = 0;
1150
+ _inboundMsgSeq++;
1151
+ _armTurnWatchdog(); // reset the activity probe on observed activity
1152
+ _noteActivity(msg); // coalesced UI liveness (≤1/s sdk-session-state activity frame)
895
1153
  }
896
1154
  _onResidentDeath(query, inputQueue, null);
897
1155
  } catch (err) {
@@ -909,6 +1167,10 @@ function _createSession(key, cwd) {
909
1167
  if (_activeQuery !== query) return; // stale generation — teardown already moved on
910
1168
  _activeQuery = null;
911
1169
  _inputQueue = null;
1170
+ // The (possibly wedged) child exited on its own — the suspicion dies with it, so the
1171
+ // next push must not run a pointless teardown against an already-dead generation.
1172
+ _suspectGeneration = false;
1173
+ _probeFailures = 0;
912
1174
  if (_ctxUsageClearPending) {
913
1175
  // A child that exits while applying /clear must not resume the cleared session on the
914
1176
  // next message. Start fresh, allow the next init/read, and invalidate older controls.
@@ -919,13 +1181,38 @@ function _createSession(key, cwd) {
919
1181
  }
920
1182
  _clearGraceTimer();
921
1183
  _clearTurnWatchdog();
922
- _lateResultTombstones = 0;
1184
+ _clearStateHeartbeat();
1185
+ _clearActivity();
1186
+ // Clear any stale late-result debt: the dead generation's frames are filtered by the
1187
+ // _activeQuery guard, so the owed result can never arrive — leaving the debt would
1188
+ // suppress the next generation's api_retry frames (and its retry affordance).
1189
+ _lateResultEpoch = 0;
1190
+ const hadTurns = _pendingTurns.length > 0;
1191
+ const headTurn = _pendingTurns[0] || null;
923
1192
  // Session died mid-turn: no result will arrive, so without this the spinner would
924
- // hang until the 25min watchdog. The turn is unambiguously over.
925
- _announceSessionState('idle');
1193
+ // hang forever. The turn is unambiguously over. `headTurn` stamps the idle with the
1194
+ // dead turn's id, and every pending turn reports its loop end (session-ended).
1195
+ _announceSessionState('idle', headTurn);
1196
+ // Only a turn that actually STARTED reports an end — a record that never reached the
1197
+ // push (construction raced with this death) must not emit an end with no matching start.
1198
+ if (hadTurns) { for (const rec of _pendingTurns) { if (rec.loopStarted) _emitAgentLoopEnd(rec, 'session-ended'); } }
926
1199
  if (err && err.name !== 'AbortError') {
927
1200
  console.error('[SDK] Resident query error:', err.message);
928
1201
  _notifyQueryError(err);
1202
+ } else if (!err && hadTurns) {
1203
+ // Clean exit with a turn still in flight is itself a failure — the user would
1204
+ // otherwise see only "idle" with no error. If the CLI exhausted API retries
1205
+ // before dying, keep it retryable so the manual [retry] affordance appears.
1206
+ const retried = (headTurn?.apiRetry?.count || 0) > 0;
1207
+ if (retried) {
1208
+ const status = headTurn.apiRetry.lastStatus;
1209
+ _lastFailedTurn = {
1210
+ turnId: headTurn.id, retryable: (status == null || RETRYABLE_STATUS.has(status)) && !!(_sessionId || _launchResume),
1211
+ apiStatus: status, attempt: headTurn.apiRetry.attempt, maxRetries: headTurn.apiRetry.maxRetries,
1212
+ code: 'session-ended', ts: Date.now(),
1213
+ };
1214
+ }
1215
+ _notifyQueryError(new Error('Agent SDK session ended before the turn completed'), { code: 'session-ended', retryable: retried && !!(_sessionId || _launchResume), turnId: headTurn?.id || null });
929
1216
  }
930
1217
  _settleAllTurns();
931
1218
  // A dispatching item waiting on an interrupt unwind never reached the SDK. Death
@@ -974,7 +1261,21 @@ function _createSession(key, cwd) {
974
1261
  _initSkills = null; // re-captured from the next init frame
975
1262
  _clearGraceTimer();
976
1263
  _clearTurnWatchdog();
977
- _lateResultTombstones = 0;
1264
+ _probeFailures = 0;
1265
+ _clearStateHeartbeat();
1266
+ _clearActivity();
1267
+ // Teardown filters the dead generation's late frames via _activeQuery, so its owed
1268
+ // result can never arrive — clear the debt or it would suppress the rebuilt turn's
1269
+ // api_retry frames. (_lastFailedTurn survives on purpose; the debt must not.)
1270
+ _lateResultEpoch = 0;
1271
+ // NOTE: _lastFailedTurn deliberately survives teardown — the lazy rebuild path
1272
+ // (_startTurn's suspect-generation teardown) must keep the recorded failure so the
1273
+ // manual [retry] affordance stays available. User-facing session boundaries clear it
1274
+ // instead: _resetFullState / _switchToSession (which funnel close/teardown/stop).
1275
+ // Teardown is also a loop boundary: any turn that STARTED must report its end, or
1276
+ // SSE clients / onAgentLoopEnd plugins see a start with no end (killed workspace,
1277
+ // session switch mid-turn). Guard on loopStarted so a never-pushed record stays silent.
1278
+ for (const rec of _pendingTurns) { if (rec.loopStarted) _emitAgentLoopEnd(rec, 'session-ended'); }
978
1279
  _settleAllTurns();
979
1280
  if (inputQueue) inputQueue.close();
980
1281
  if (query) {
@@ -984,10 +1285,11 @@ function _createSession(key, cwd) {
984
1285
  }
985
1286
 
986
1287
  /** Query-level error signal; the routing envelope always comes from this
987
- * session's closure (callback throw must never break the caller). */
988
- function _notifyQueryError(err) {
1288
+ * session's closure (callback throw must never break the caller). `extra` carries
1289
+ * failure metadata (code/retryable/turnId/apiStatus) through to the sdk-error frame. */
1290
+ function _notifyQueryError(err, extra = {}) {
989
1291
  if (!_onQueryError) return;
990
- const context = { cwd: _cwd, sessionId: _sessionId || null, instanceKey };
1292
+ const context = { cwd: _cwd, sessionId: _sessionId || null, instanceKey, ...extra };
991
1293
  try { _onQueryError(String(err?.message || err), context); }
992
1294
  catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
993
1295
  }
@@ -1070,14 +1372,116 @@ function _createSession(key, cwd) {
1070
1372
  * Announce a turn-level session state to WS clients (spinner source in SDK mode).
1071
1373
  * State-change guarded + retained for reconnect replay. Broadcast failures are
1072
1374
  * swallowed inside this helper — the message loop must never die on a UI signal.
1375
+ *
1376
+ * Every frame carries turn correlation (turnId + turnStartedAt): the CLI's
1377
+ * session_state_changed has no turn id of its own, so a late frame from the PREVIOUS
1378
+ * turn (e.g. its idle arriving after a fresh push) would otherwise clear the new
1379
+ * turn's spinner. `rec` is the turn this state belongs to — internal settle paths
1380
+ * pass it explicitly because the FIFO head may already have shifted.
1073
1381
  */
1074
- function _announceSessionState(state) {
1382
+ function _announceSessionState(state, rec = null) {
1075
1383
  const valid = state === 'idle' || state === 'running' || state === 'requires_action';
1076
1384
  if (!valid || state === _lastSessionState) return;
1077
1385
  _lastSessionState = state;
1386
+ const owner = rec || _pendingTurns[0] || null;
1078
1387
  // _broadcast carries the routing envelope (cwd + sessionId + instanceKey) — every
1079
1388
  // frame needs it so concurrent sessions' clients filter correctly.
1080
- _broadcast({ type: 'sdk-session-state', state });
1389
+ _broadcast({
1390
+ type: 'sdk-session-state', state,
1391
+ turnId: owner ? owner.id : null,
1392
+ turnStartedAt: owner ? owner.startedAt : _lastTurnStartedAt,
1393
+ });
1394
+ _syncStateHeartbeat();
1395
+ }
1396
+
1397
+ /** One-shot agent-loop boundary callbacks (voice/plugin/SSE agent_loop_*). Idempotent
1398
+ * per turn record — every settle path (result, interrupt grace, lost result, stuck
1399
+ * recovery, death) funnels through _emitAgentLoopEnd exactly once. Callback throws are
1400
+ * isolated so a broken host can never wedge the message loop. */
1401
+ function _emitAgentLoopStart(rec) {
1402
+ if (!rec || rec.loopStarted) return;
1403
+ rec.loopStarted = true;
1404
+ if (!_onAgentLoopStart) return;
1405
+ try { _onAgentLoopStart({ sessionId: _sessionId || null, ts: Date.now(), cwd: _cwd, instanceKey, turnId: rec.id, turnStartedAt: rec.startedAt }); }
1406
+ catch (err) { reportSwallowed('sdk-manager.agent-loop-start', err); }
1407
+ }
1408
+ function _emitAgentLoopEnd(rec, reason) {
1409
+ if (!rec || rec.loopEnded) return;
1410
+ rec.loopEnded = true;
1411
+ if (!_onAgentLoopEnd) return;
1412
+ try { _onAgentLoopEnd({ sessionId: _sessionId || null, ts: Date.now(), cwd: _cwd, instanceKey, turnId: rec.id, turnStartedAt: rec.startedAt, reason }); }
1413
+ catch (err) { reportSwallowed('sdk-manager.agent-loop-end', err); }
1414
+ }
1415
+
1416
+ /** Coalesced message-level liveness: any non-boundary SDK message (assistant/user/
1417
+ * tool_progress / system task_* frames / status / api_retry / compact …) proves the
1418
+ * loop is alive mid-turn — including tool/compaction/hook phases the proxy's per-API
1419
+ * streaming_status cannot see. Frames reuse the sdk-session-state shape with
1420
+ * heartbeat+activity flags (no new client handler) and are capped at one per
1421
+ * ACTIVITY_MIN_INTERVAL_MS. */
1422
+ function _noteActivity(msg) {
1423
+ if (_pendingTurns.length === 0 || !msg) return;
1424
+ const t = msg.type;
1425
+ if (t === 'result') return; // boundary, not progress
1426
+ if (t === 'system' && msg.subtype === 'session_state_changed') return; // state frame, not activity
1427
+ const rec = _pendingTurns[0];
1428
+ const kind = t === 'system' ? `system:${msg.subtype || ''}`
1429
+ : t === 'stream_event' ? `stream:${(msg.event && msg.event.type) || ''}` : t;
1430
+ _activityPending = { kind, rec };
1431
+ const wait = Math.max(0, ACTIVITY_MIN_INTERVAL_MS - (Date.now() - _lastActivityAt));
1432
+ if (wait === 0) _flushActivity();
1433
+ else if (!_activityTimer) {
1434
+ _activityTimer = setTimeout(_flushActivity, wait);
1435
+ if (_activityTimer.unref) _activityTimer.unref();
1436
+ }
1437
+ }
1438
+ function _flushActivity() {
1439
+ if (_activityTimer) { clearTimeout(_activityTimer); _activityTimer = null; }
1440
+ const p = _activityPending; _activityPending = null;
1441
+ if (!p || p.rec.settled || _pendingTurns[0] !== p.rec) return;
1442
+ if (_lastSessionState !== 'running' && _lastSessionState !== 'requires_action') return;
1443
+ _lastActivityAt = Date.now();
1444
+ _broadcast({
1445
+ type: 'sdk-session-state', state: _lastSessionState, heartbeat: true, activity: true,
1446
+ kind: p.kind, turnId: p.rec.id, turnStartedAt: p.rec.startedAt,
1447
+ pendingApprovals: _pendingApprovals.size,
1448
+ });
1449
+ }
1450
+ function _clearActivity() {
1451
+ if (_activityTimer) { clearTimeout(_activityTimer); _activityTimer = null; }
1452
+ _activityPending = null;
1453
+ _lastActivityAt = 0;
1454
+ }
1455
+
1456
+ // The in-flight turn is the FIFO HEAD (the result branch shifts from the head).
1457
+ const _currentTurnId = () => (_pendingTurns.length ? _pendingTurns[0].id : null);
1458
+
1459
+ /** While a turn is running/requires_action, re-broadcast the session state every
1460
+ * STATE_HEARTBEAT_MS so clients (a) keep their Stop button / spinner alive past the
1461
+ * frontend's 30s no-signal stale sweep, and (b) can re-derive it after a view switch.
1462
+ * The heartbeat does NOT re-arm the activity probe (silence semantics unchanged) and
1463
+ * is cleared on idle/teardown/reset/switch/death. */
1464
+ function _syncStateHeartbeat() {
1465
+ const active = _lastSessionState === 'running' || _lastSessionState === 'requires_action';
1466
+ if (active && !_stateHeartbeat) {
1467
+ _stateHeartbeat = setInterval(() => {
1468
+ if (_lastSessionState !== 'running' && _lastSessionState !== 'requires_action') return;
1469
+ const head = _pendingTurns[0] || null;
1470
+ _broadcast({
1471
+ type: 'sdk-session-state', state: _lastSessionState, heartbeat: true,
1472
+ turnId: _currentTurnId(), turnStartedAt: head ? head.startedAt : _lastTurnStartedAt,
1473
+ pendingApprovals: _pendingApprovals.size,
1474
+ });
1475
+ }, STATE_HEARTBEAT_MS);
1476
+ if (_stateHeartbeat.unref) _stateHeartbeat.unref();
1477
+ } else if (!active && _stateHeartbeat) {
1478
+ clearInterval(_stateHeartbeat);
1479
+ _stateHeartbeat = null;
1480
+ }
1481
+ }
1482
+
1483
+ function _clearStateHeartbeat() {
1484
+ if (_stateHeartbeat) { clearInterval(_stateHeartbeat); _stateHeartbeat = null; }
1081
1485
  }
1082
1486
 
1083
1487
  /**
@@ -1096,7 +1500,58 @@ function _createSession(key, cwd) {
1096
1500
  // which cli.js injects into the child env; on CLIs that never emit it the
1097
1501
  // push/result fallbacks keep the spinner correct at result granularity.
1098
1502
  if (msg.subtype === 'session_state_changed') {
1099
- _announceSessionState(msg.state);
1503
+ // Late-idle suppression (LOAD-BEARING gate): session_state_changed carries no
1504
+ // turn id, so an idle that settles the PREVIOUS turn can arrive after a fresh
1505
+ // push. If the head turn has never been seen running/requires_action by the CLI,
1506
+ // this idle cannot belong to it — drop it, or it would kill the new turn's
1507
+ // spinner. The frontend's turnStartedAt guard is NOT a substitute here: a stale
1508
+ // idle processed AFTER the new push is stamped with the NEW head's epoch, so the
1509
+ // client cannot tell it apart — only this server-side cliStateSeen gate can (the
1510
+ // client guard only helps against transport-level reordering of older-stamped
1511
+ // frames). Residual hazard (new turn's running lands before the old idle): the
1512
+ // gate passes it; the lost-result backstop's armedSeq check below disarms the
1513
+ // resulting mis-armed conviction when the head turn is still producing messages.
1514
+ const head = _pendingTurns[0] || null;
1515
+ const staleIdle = msg.state === 'idle' && head && !head.cliStateSeen;
1516
+ if (!staleIdle) {
1517
+ _announceSessionState(msg.state, head);
1518
+ // A CLI-emitted running/requires_action proves the head turn is genuinely
1519
+ // in-flight on the child (vs a freshly-drained turn whose own CLI frames have
1520
+ // not arrived yet). Lost-result only arms for such a turn — a LATE idle frame
1521
+ // from the previous turn must not convict the just-pushed one.
1522
+ if ((msg.state === 'running' || msg.state === 'requires_action') && head) head.cliStateSeen = true;
1523
+ // Lost-result backstop: the CLI says the turn settled (idle) but no result
1524
+ // message arrived for our pending FIFO. Without this _queryBusy would wedge
1525
+ // forever now that the 25min silence watchdog is gone. Give the result a short
1526
+ // grace to arrive first (frame ordering within a turn end is not guaranteed),
1527
+ // then settle as a retryable failure.
1528
+ if (msg.state === 'idle' && head && head.cliStateSeen) _armLostResultBackstop();
1529
+ }
1530
+ } else if (msg.subtype === 'api_retry') {
1531
+ // The CLI's own retry of a retryable API error (502/429/5xx/network). It carries
1532
+ // no turn id, so we attribute it to the head pending turn — approximate but
1533
+ // correct in the common single-turn case. Skipped only while a late-result debt
1534
+ // is pending AND no live turn could own the frame (a late retry of an
1535
+ // already-interrupted turn must not relight the bar). The gate mirrors the
1536
+ // result branch's orphan rule: a stale debt must never suppress a live turn's
1537
+ // own retry frames, and must not outlive the teardown/reset that orphaned it.
1538
+ if (_lateResultEpoch === 0 || _pendingTurns.length > 0) {
1539
+ const rec = _pendingTurns.length ? _pendingTurns[0] : null;
1540
+ if (rec) {
1541
+ rec.apiRetry.count += 1;
1542
+ rec.apiRetry.attempt = Number(msg.attempt) || rec.apiRetry.count;
1543
+ rec.apiRetry.maxRetries = Number(msg.max_retries) || 0;
1544
+ rec.apiRetry.lastStatus = (typeof msg.error_status === 'number') ? msg.error_status : null;
1545
+ rec.apiRetry.lastError = msg.error || null;
1546
+ _broadcast({
1547
+ type: 'sdk-retry-state', phase: 'retrying', turnId: rec.id,
1548
+ attempt: rec.apiRetry.attempt, maxRetries: rec.apiRetry.maxRetries,
1549
+ retryDelayMs: Number(msg.retry_delay_ms) || 0,
1550
+ errorStatus: rec.apiRetry.lastStatus, error: rec.apiRetry.lastError || null,
1551
+ ts: Date.now(),
1552
+ });
1553
+ }
1554
+ }
1100
1555
  } else if (msg.subtype === 'init') {
1101
1556
  // /clear may reinitialize instead of returning a normal result. The new session is
1102
1557
  // now authoritative, so context reads may resume (without resuming the old session).
@@ -1165,36 +1620,88 @@ function _createSession(key, cwd) {
1165
1620
 
1166
1621
  case 'result': {
1167
1622
  if (msg.session_id) _sessionId = msg.session_id;
1168
- // Grace/watchdog tombstone: a result that arrives AFTER its turn was already
1169
- // force-settled belongs to that dead turn — discard it instead of letting it
1170
- // settle (and prematurely end) the NEXT turn. If the tombstone outlived its turn
1171
- // entirely (the owed result never comes) the count would swallow a FUTURE turn's
1172
- // result — the bookkeeping clear below keeps that mistake bounded to the
1173
- // turn-end signal rather than wedging _queryBusy.
1174
- if (_lateResultTombstones > 0) {
1175
- _lateResultTombstones--;
1176
- _clearGraceTimer();
1177
- _queryBusy = false;
1178
- _turnInFlight = false;
1179
- // The tombstoned turn is over — but a NEWER turn may already be in flight
1180
- // (Stop → grace settle → user re-sent → late result of the old turn arrives).
1181
- // Only announce idle when no turn is pending, else we'd kill turn 2's spinner.
1182
- if (_pendingTurns.length === 0) {
1183
- _announceSessionState('idle');
1184
- _clearTurnWatchdog();
1623
+ // Background-task wake results are NOT turn boundaries: while local_agent /
1624
+ // local_workflow tasks are active the CLI holds the turn's real result back, and
1625
+ // each answered task notification can yield a synthetic result (num_turns: 0,
1626
+ // empty result). Treating one as end-of-turn fires a phantom turn_end (and would
1627
+ // ring the voice pack / clear the spinner mid-loop), so skip it entirely.
1628
+ if (msg.origin && msg.origin.kind === 'task-notification') break;
1629
+ // Generation guard for late results. Result frames carry no turn id, so a result
1630
+ // that arrives AFTER its turn was force-settled is indistinguishable from the NEXT
1631
+ // turn's result on arrival order alone. The guard resolves this by generation:
1632
+ // a force-settled turn is REMOVED from the FIFO (shift / settleAll), so when a
1633
+ // result arrives and the FIFO is EMPTY, nothing live could own it — it is the dead
1634
+ // generation's late result and is discarded (no turn_end, no error attribution).
1635
+ //
1636
+ // Deliberate trade-off (NOT a guarantee): when a NEWER turn is already in flight,
1637
+ // an arriving result is paired with it (`head.epoch > _lateResultEpoch`), because
1638
+ // swallowing the live turn's own result is the worse failure (that was the original
1639
+ // bug — the stuck turn got mis-settled as turn-stuck and re-ran). The cost is that
1640
+ // a genuinely-late result of the dead generation CAN be mis-paired to the live turn
1641
+ // here (an early/extra turn_end, or a wrong error toast attributed to it) — accepted
1642
+ // because the live turn must not lose its own result. This window is narrow: the
1643
+ // late result must arrive after the backstop AND after a newer turn has started.
1644
+ if (_lateResultEpoch !== 0) {
1645
+ const head = _pendingTurns[0] || null;
1646
+ if (!head) {
1647
+ // No live turn could own this result — it is the dead generation's late result.
1648
+ _lateResultEpoch = 0;
1649
+ _clearGraceTimer();
1650
+ _queryBusy = false;
1651
+ _turnInFlight = false;
1652
+ // The dead generation is over — but a NEWER turn may already be in flight
1653
+ // (Stop → grace settle → user re-sent → late result of the old turn arrives).
1654
+ // Only announce idle when no turn is pending, else we'd kill turn 2's spinner.
1655
+ if (_pendingTurns.length === 0) {
1656
+ _announceSessionState('idle');
1657
+ _clearTurnWatchdog();
1658
+ _clearActivity();
1659
+ }
1660
+ _maybeDrain();
1661
+ break;
1185
1662
  }
1186
- _maybeDrain();
1187
- break;
1663
+ // A NEWER generation is in flight: this result is that live turn's own, so fall
1664
+ // through to pair it normally — and drop the never-arriving late result's debt.
1665
+ _lateResultEpoch = 0;
1188
1666
  }
1189
1667
  const rec = _pendingTurns.shift();
1668
+ // 502/retry state machine. An error result after observed CLI retries records a
1669
+ // retryable _lastFailedTurn (drives the manual [retry] affordance). ANY success
1670
+ // unconditionally closes a recorded failure — including a manual retry that
1671
+ // succeeds on its first API attempt (apiRetry.count === 0) — and clears
1672
+ // _lastFailedTurn; `retryOf` carries the FAILED turn's id (rec.retryOf when this
1673
+ // turn is itself a manual retry) because the frontend matches cleanup by it.
1674
+ if (rec) {
1675
+ const isError = msg.is_error === true || (msg.subtype && msg.subtype !== 'success');
1676
+ if (isError && !rec.interrupted) {
1677
+ const status = rec.apiRetry.lastStatus;
1678
+ const retryable = rec.apiRetry.count > 0 && (status == null || RETRYABLE_STATUS.has(status));
1679
+ _lastFailedTurn = {
1680
+ turnId: rec.id, retryable, apiStatus: status,
1681
+ attempt: rec.apiRetry.attempt, maxRetries: rec.apiRetry.maxRetries, code: null, ts: Date.now(),
1682
+ };
1683
+ } else if (!isError && _lastFailedTurn && (rec.apiRetry.count > 0 || rec.retryOf === _lastFailedTurn.turnId)) {
1684
+ // recovered only when there IS a recorded failure AND this turn closes it
1685
+ // (a manual retry of it — even on its first attempt — or a turn that itself
1686
+ // saw CLI retries). A clean success (no _lastFailedTurn) emits nothing; an
1687
+ // unrelated later success neither clears the affordance nor spams the frame.
1688
+ _broadcast({ type: 'sdk-retry-state', phase: 'recovered', turnId: rec.id, retryOf: rec.retryOf || _lastFailedTurn.turnId, ts: Date.now() });
1689
+ _lastFailedTurn = null;
1690
+ }
1691
+ }
1190
1692
  // An interrupted turn's result is expected to be an error (turn aborted) — don't
1191
1693
  // toast it. Turn-end still fires, matching the PTY Stop-hook semantics.
1192
- if (rec && !rec.interrupted) _notifyTurnError(msg);
1694
+ if (rec && !rec.interrupted) _notifyTurnError(msg, rec);
1193
1695
  // SDK turn-end signal. Equivalent to Claude Code's Stop hook
1194
1696
  // in CLI mode — fires once per user-prompt response when the whole chain
1195
1697
  // (assistant text + all tool calls + final reply) completes. SDK mode
1196
1698
  // doesn't go through ensureHooks() so this in-process callback is the
1197
- // only way the renderer learns the turn is over.
1699
+ // only way the renderer learns the turn is over. NOTE: deliberately NOT
1700
+ // gated on `rec` — the late-result trade-off (see the generation-guard
1701
+ // comment above) pairs a dead turn's late result with the live turn, and
1702
+ // the live turn's OWN result can then arrive with an empty FIFO; phantom
1703
+ // ringing from background tasks is prevented by the task-notification
1704
+ // filter above, not by requiring a FIFO record.
1198
1705
  if (_onTurnEnd) {
1199
1706
  // Per-workspace (2026-10): carry the session's own cwd so the server
1200
1707
  // can route turn_end to THIS workspace's SSE scope (SDK mode has no
@@ -1203,13 +1710,23 @@ function _createSession(key, cwd) {
1203
1710
  catch (err) { console.warn('[sdk-manager] onTurnEnd threw:', err?.message); }
1204
1711
  }
1205
1712
  if (rec) _settleTurn(rec);
1713
+ // Authoritative loop end for THIS turn — immediate, per-turn (turnId/turnStartedAt
1714
+ // correlated), unlike the debounced turn_end above. `is_error`/non-success
1715
+ // subtypes distinguish error ends; interrupted turns report their own reason.
1716
+ if (rec) {
1717
+ const errored = msg.is_error === true || (msg.subtype && msg.subtype !== 'success');
1718
+ _emitAgentLoopEnd(rec, rec.interrupted ? 'interrupted' : (errored ? 'error' : 'success'));
1719
+ }
1206
1720
  _clearGraceTimer();
1721
+ _clearActivity();
1207
1722
  _queryBusy = false;
1208
1723
  _turnInFlight = false;
1209
1724
  // Turn-level spinner clear at result time — immediate, unlike the debounced
1210
1725
  // `turn_end` SSE (voice-pack semantics). Idempotent against a real
1211
- // session_state_changed(idle) arriving before/after.
1212
- _announceSessionState('idle');
1726
+ // session_state_changed(idle) arriving before/after. `rec` is passed explicitly:
1727
+ // the FIFO head has already shifted, so the default head lookup would stamp this
1728
+ // idle with the NEXT turn's id.
1729
+ _announceSessionState('idle', rec);
1213
1730
  if (_pendingTurns.length === 0) _clearTurnWatchdog();
1214
1731
  _maybeDrain();
1215
1732
  // Refresh the context-usage panel AFTER the turn fully settles (fire-and-forget,
@@ -1231,14 +1748,17 @@ function _createSession(key, cwd) {
1231
1748
  * never signals it, so a dead/errored turn would otherwise look like a hang. The
1232
1749
  * sdk-error frame renders as a persistent dialogue row plus a toast client-side.
1233
1750
  */
1234
- function _notifyTurnError(resultMsg) {
1751
+ function _notifyTurnError(resultMsg, rec = null) {
1235
1752
  const isError = resultMsg.is_error === true || (resultMsg.subtype && resultMsg.subtype !== 'success');
1236
1753
  if (!isError) return;
1237
1754
  const errs = Array.isArray(resultMsg.errors) && resultMsg.errors.length
1238
1755
  ? resultMsg.errors.join('; ')
1239
1756
  : (typeof resultMsg.result === 'string' && resultMsg.result) || resultMsg.subtype || 'unknown error';
1757
+ const retry = rec?.apiRetry || null;
1758
+ const status = retry?.lastStatus ?? null;
1759
+ const retryable = !!retry?.count && (status == null || RETRYABLE_STATUS.has(status));
1240
1760
  if (_broadcastWs) {
1241
- try { _broadcastWs({ type: 'sdk-error', message: String(errs), cwd: _cwd, sessionId: _sessionId || null, instanceKey }); } catch (err) { console.warn('[sdk-manager] sdk-error broadcast threw:', err?.message); }
1761
+ try { _broadcastWs({ type: 'sdk-error', message: String(errs), turnId: rec?.id || null, retryable, apiStatus: status, attempt: retry?.attempt || 0, maxRetries: retry?.maxRetries || 0, cwd: _cwd, sessionId: _sessionId || null, instanceKey }); } catch (err) { console.warn('[sdk-manager] sdk-error broadcast threw:', err?.message); }
1242
1762
  }
1243
1763
  }
1244
1764
 
@@ -1683,6 +2203,7 @@ function _createSession(key, cwd) {
1683
2203
  // Close the modal on every client — a silent deny would leave it hanging forever.
1684
2204
  _broadcastApprovalDismiss(kind, id, 'timeout');
1685
2205
  resolve(null);
2206
+ _rearmWatchdogAfterApprovalSettled();
1686
2207
  }, timeoutMs);
1687
2208
  const entry = {
1688
2209
  kind, // 'ask' | 'plan' | 'perm' — lets cancelApproval distinguish types so an
@@ -1701,6 +2222,7 @@ function _createSession(key, cwd) {
1701
2222
  if (cur?.auto) cancelAutoApproveState(cur.auto);
1702
2223
  _pendingApprovals.delete(id);
1703
2224
  resolve(value);
2225
+ _rearmWatchdogAfterApprovalSettled();
1704
2226
  },
1705
2227
  };
1706
2228
  _pendingApprovals.set(id, entry);
@@ -1831,29 +2353,90 @@ function _createSession(key, cwd) {
1831
2353
  return cancelled;
1832
2354
  }
1833
2355
 
1834
- /** Backstop: if no result shows up within the grace window after an interrupt, force-
1835
- * settle the head turn (keeping _queryBusy from wedging forever) and mark a tombstone
1836
- * so the result that eventually arrives is discarded instead of settling the next turn. */
2356
+ /** Backstop: if no result shows up within the grace window after an interrupt, settle
2357
+ * the head turn (keeping _queryBusy from wedging forever) and mark the generation
2358
+ * suspect — the CLI never answered our Stop, so the child may be wedged. The actual
2359
+ * teardown is deferred to the next push (_startTurn) so we never SIGTERM a child that
2360
+ * was merely slow to unwind; the rebuild resumes _sessionId. A tombstone discards the
2361
+ * late result if it ever arrives. */
1837
2362
  function _armSettleGrace() {
1838
2363
  _clearGraceTimer();
1839
2364
  _settleGraceTimer = setTimeout(() => {
1840
2365
  _settleGraceTimer = null;
1841
2366
  const rec = _pendingTurns[0];
1842
2367
  if (!rec || !rec.interrupted) return;
1843
- console.warn('[sdk-manager] no result within interrupt grace — force-settling the turn');
1844
- _lateResultTombstones++;
2368
+ console.warn('[sdk-manager] no result within interrupt grace — settling the turn and marking the generation suspect');
2369
+ _lateResultEpoch = rec.epoch; // discard this generation's late result, never a later turn's
1845
2370
  _pendingTurns.shift();
1846
2371
  _settleTurn(rec);
2372
+ _emitAgentLoopEnd(rec, 'interrupt-timeout');
1847
2373
  _queryBusy = false;
2374
+ _suspectGeneration = true;
1848
2375
  // The CLI never answered the interrupt — no result is coming, so clear the
1849
2376
  // spinner here rather than waiting on a dead signal.
1850
- _announceSessionState('idle');
2377
+ _announceSessionState('idle', rec);
2378
+ _clearActivity();
1851
2379
  if (_pendingTurns.length === 0) _clearTurnWatchdog();
1852
2380
  _maybeDrain();
1853
2381
  }, INTERRUPT_GRACE_MS);
1854
2382
  if (_settleGraceTimer.unref) _settleGraceTimer.unref();
1855
2383
  }
1856
2384
 
2385
+ /** Lost-result backstop: the CLI announced session_state_changed('idle') (turn over,
2386
+ * tools included) but never produced a result message for our pending FIFO. Give the
2387
+ * result a short grace to arrive (frame ordering at turn end is not guaranteed), then
2388
+ * settle as a retryable failure — without this _queryBusy would wedge forever now that
2389
+ * the fixed silence watchdog is gone. */
2390
+ function _armLostResultBackstop() {
2391
+ const GRACE_MS = 5000;
2392
+ // Capture the pending turn at arm time and re-verify identity at fire time: a stale
2393
+ // backstop from turn A must never settle turn B (which may be parked on the SAME idle
2394
+ // state). The interrupt-grace path owns user-cancelled turns — skip those here.
2395
+ const armedRec = _pendingTurns[0] || null;
2396
+ const armedSeq = _inboundMsgSeq; // any message arriving after arming bumps this
2397
+ _lostResultTimer = setTimeout(() => {
2398
+ _lostResultTimer = null;
2399
+ const rec = _pendingTurns[0];
2400
+ if (!rec || rec !== armedRec || rec.interrupted) return; // stale arm / different turn / user-cancelled
2401
+ if (_queryBusy === false) return;
2402
+ if (_lastSessionState !== 'idle') return; // a newer turn started running — not lost
2403
+ // Mitigation for the uncorrelatable-idle hazard: if the accepted idle actually
2404
+ // belonged to the PREVIOUS turn (the new turn's `running` landed first, so
2405
+ // cliStateSeen was already true and the gate passed it), the head turn may still
2406
+ // be alive. A message arriving after arming proves the head turn is producing
2407
+ // work — not lost. Disarm rather than convict it as turn-stuck. (armedSeq is read
2408
+ // inside _processMessage, BEFORE the drain loop increments the seq for the very
2409
+ // idle that armed us — so exactly ONE bump is expected from the arming message;
2410
+ // anything beyond it is post-arm activity.)
2411
+ if (_inboundMsgSeq > armedSeq + 1) return;
2412
+ console.warn('[sdk-manager] session idle but a turn result never arrived — settling as lost result');
2413
+ // Mark the head turn's epoch as owing a result: without it a late result would
2414
+ // consume the NEXT turn's FIFO record (settling it early and mispairing turn-end).
2415
+ // Binding to the epoch (not a count) is the fix for the swallowed-next-result bug —
2416
+ // this result never arriving must not let the debt outlive its generation.
2417
+ if (_pendingTurns[0]) _lateResultEpoch = _pendingTurns[0].epoch;
2418
+ const lostRecs = [..._pendingTurns];
2419
+ _settleAllTurns();
2420
+ _clearActivity();
2421
+ for (const r of lostRecs) _emitAgentLoopEnd(r, 'lost-result');
2422
+ const retryable = !!(_sessionId || _launchResume);
2423
+ _lastFailedTurn = {
2424
+ turnId: rec.id || null, retryable, apiStatus: rec.apiRetry?.lastStatus ?? null,
2425
+ attempt: rec.apiRetry?.attempt || 0, maxRetries: rec.apiRetry?.maxRetries || 0,
2426
+ code: 'turn-stuck', ts: Date.now(),
2427
+ };
2428
+ _notifyQueryError(new Error('The turn ended without a result; you can retry it.'), {
2429
+ code: 'turn-stuck', retryable, turnId: rec.id || null,
2430
+ });
2431
+ _maybeDrain();
2432
+ }, GRACE_MS);
2433
+ if (_lostResultTimer.unref) _lostResultTimer.unref();
2434
+ }
2435
+
2436
+ function _clearLostResultBackstop() {
2437
+ if (_lostResultTimer) { clearTimeout(_lostResultTimer); _lostResultTimer = null; }
2438
+ }
2439
+
1857
2440
  /**
1858
2441
  * Interrupt the current turn (user clicked the Stop button) while KEEPING the
1859
2442
  * session alive: the resident query is a streaming-input session, so interrupt()
@@ -1988,6 +2571,35 @@ function _createSession(key, cwd) {
1988
2571
  _broadcastQueueState();
1989
2572
  }
1990
2573
 
2574
+ /**
2575
+ * Manual [retry] of the last failed turn. The original user message is still in the
2576
+ * session history (the CLI closed the failed turn with a synthetic API-error entry and
2577
+ * stayed alive), so we do NOT resend it — we push a tiny synthetic continue instruction
2578
+ * that makes the model redo the interrupted step with full context. Refuses with a
2579
+ * reason unless there is a retryable recorded failure and the session is idle.
2580
+ */
2581
+ async function _retryInterruptedTurn(turnId = null) {
2582
+ if (_queryBusy || _dispatchingStarting || _dispatching) return { ok: false, reason: 'busy' };
2583
+ if (_switching) return { ok: false, reason: 'busy' }; // a session switch must not race a retry push
2584
+ if (!_lastFailedTurn) return { ok: false, reason: 'nothing-to-retry' };
2585
+ if (turnId && _lastFailedTurn.turnId && turnId !== _lastFailedTurn.turnId) return { ok: false, reason: 'stale' };
2586
+ if (_lastFailedTurn.retryable === false) return { ok: false, reason: 'not-retryable' };
2587
+ if (!_sessionId && !_launchResume) return { ok: false, reason: 'no-session' };
2588
+ const retryOf = _lastFailedTurn.turnId; // snapshot BEFORE the await (a new turn mutates state)
2589
+ _suppressDrain = false;
2590
+ const { started, promise, turnId: newTurnId } = await _startTurn(RETRY_CONTINUE_PROMPT);
2591
+ if (!started) return { ok: false, reason: 'not-delivered' };
2592
+ // Stamp the lineage on the new turn's record so the result branch's `recovered`
2593
+ // frame can name the FAILED turn (the frontend matches cleanup by retryOf).
2594
+ // Look the new turn up by id (not index) — a fast result could have shifted the FIFO
2595
+ // in the microtask gap, and the retryOf stamp must land on the retry turn itself.
2596
+ const newRec = _pendingTurns.find(r => r.id === newTurnId) || null;
2597
+ if (newRec) newRec.retryOf = retryOf;
2598
+ _broadcast({ type: 'sdk-retry-state', phase: 'recovering', turnId: newTurnId, retryOf, ts: Date.now() });
2599
+ promise.catch((err) => reportSwallowed('sdk-manager.retry-turn', err));
2600
+ return { ok: true, turnId: newTurnId };
2601
+ }
2602
+
1991
2603
  /**
1992
2604
  * Switch the session to a different session id (web "resume session" entry — SDK-mode
1993
2605
  * counterpart of the PTY /resume injection). options.resume only applies at query
@@ -2015,6 +2627,9 @@ function _createSession(key, cwd) {
2015
2627
  _lastInitSnapshot = null; // don't replay the OLD session's sdk-init to the new one
2016
2628
  _lastSkillsSnapshot = null; // same for the skill surface
2017
2629
  _lastSessionState = null; // same for the session-state snapshot — new session starts idle
2630
+ _clearStateHeartbeat();
2631
+ _suspectGeneration = false;
2632
+ _lastFailedTurn = null; // the failure belongs to the outgoing session — never retry it into this one
2018
2633
  _clearContextUsage('session-switched');
2019
2634
  _clearMcpStatus('session-switched');
2020
2635
  _clearQueued(); // broadcasts the emptied queue-state
@@ -2042,11 +2657,16 @@ function _createSession(key, cwd) {
2042
2657
  // legit idle). Skip the broadcast when nothing was ever announced.
2043
2658
  if (_lastSessionState && _lastSessionState !== 'idle') _announceSessionState('idle');
2044
2659
  _lastSessionState = null;
2660
+ _clearStateHeartbeat();
2661
+ _clearActivity();
2662
+ _lastTurnStartedAt = 0;
2045
2663
  _clearContextUsage('session-switched');
2046
2664
  _clearMcpStatus('session-switched');
2047
2665
  _pendingTurns = [];
2048
- _lateResultTombstones = 0;
2666
+ _lateResultEpoch = 0; // session boundary: a stale debt must not follow into the new session
2049
2667
  _consecutiveDeaths = 0;
2668
+ _suspectGeneration = false;
2669
+ _lastFailedTurn = null;
2050
2670
  _clearGraceTimer();
2051
2671
  _clearTurnWatchdog();
2052
2672
  const hadQueued = _messageQueue.length > 0 || !!_dispatching;
@@ -2099,7 +2719,8 @@ function _createSession(key, cwd) {
2099
2719
  getSdkInitSnapshot: () => _lastInitSnapshot,
2100
2720
  getSessionStateSnapshot: () => {
2101
2721
  if (!_lastSessionState) return null;
2102
- return { type: 'sdk-session-state', state: _lastSessionState, sessionId: _sessionId || null, cwd: _cwd };
2722
+ const head = _pendingTurns[0] || null;
2723
+ return { type: 'sdk-session-state', state: _lastSessionState, sessionId: _sessionId || null, cwd: _cwd, instanceKey, turnId: _currentTurnId(), turnStartedAt: head ? head.startedAt : _lastTurnStartedAt, pendingApprovals: _pendingApprovals.size };
2103
2724
  },
2104
2725
  getContextUsageSnapshot: () => _lastContextUsage,
2105
2726
  getMcpStatusSnapshot: () => _lastMcpStatus,
@@ -2127,6 +2748,7 @@ function _createSession(key, cwd) {
2127
2748
  },
2128
2749
  switchToSession: _switchToSession,
2129
2750
  getSessionId: () => _sessionId,
2751
+ retryInterruptedTurn: _retryInterruptedTurn,
2130
2752
  runEphemeralTurn: _runEphemeralTurn,
2131
2753
  close: () => {
2132
2754
  for (const { id, kind } of _cancelAllApprovals()) {
@@ -2234,6 +2856,8 @@ export function __isEphemeralHostForTests(cwd) {
2234
2856
  * @param {function} deps.broadcastWs — (msg) => void, terminal-WS broadcast for approvals
2235
2857
  * @param {string} [deps.permissionMode]
2236
2858
  * @param {function} [deps.runWaterfallHook] — plugin waterfall (onPlanRequest/onAskRequest/onPermRequest)
2859
+ * @param {function} [deps.onAgentLoopStart] — ({sessionId, ts, cwd, instanceKey, turnId, turnStartedAt}) => void, fired once per accepted turn
2860
+ * @param {function} [deps.onAgentLoopEnd] — (same + reason) => void, fired once at the turn's real settlement
2237
2861
  * @param {object} [deps.env] — child env (must already contain ANTHROPIC_BASE_URL → ccv proxy)
2238
2862
  * @param {object} [deps.settings] — merged Settings object (env.ANTHROPIC_BASE_URL double-injection)
2239
2863
  * @param {string} [deps.model] — --model lifted from user args (options.model)
@@ -2243,7 +2867,7 @@ export function __isEphemeralHostForTests(cwd) {
2243
2867
  * must come from the same selection as PTY mode, else the SDK spawns a PATH claude that
2244
2868
  * host security tooling (e.g. agent-security headless guards) may SIGKILL on spawn
2245
2869
  */
2246
- export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broadcastWs, permissionMode, runWaterfallHook, env, settings, model, launchResume, userArgs, claudeExecutable } = {}) {
2870
+ export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broadcastWs, permissionMode, runWaterfallHook, onAgentLoopStart, onAgentLoopEnd, env, settings, model, launchResume, userArgs, claudeExecutable } = {}) {
2247
2871
  void projectName; // reserved (display name comes from the wire path)
2248
2872
  // Process-global callbacks (cli.js registers one wiring; every session reuses it). Only
2249
2873
  // OVERWRITE when a callback is actually passed — a later per-workspace init (the launch
@@ -2253,6 +2877,12 @@ export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broa
2253
2877
  if (onQueryError !== undefined) _onQueryError = onQueryError || null;
2254
2878
  if (broadcastWs !== undefined) _broadcastWs = broadcastWs;
2255
2879
  if (runWaterfallHook !== undefined) _runWaterfallHook = runWaterfallHook || null;
2880
+ if (onAgentLoopStart !== undefined) _onAgentLoopStart = onAgentLoopStart || null;
2881
+ if (onAgentLoopEnd !== undefined) _onAgentLoopEnd = onAgentLoopEnd || null;
2882
+ // Restore any callback stopSession() cleared from the durable process wiring (cli.js's
2883
+ // registration). A workspace stop→relaunch omits these callbacks, so without this every
2884
+ // sdk-* frame would silently stop broadcasting after returning to the workspace list.
2885
+ _adoptProcessWiring();
2256
2886
  // Register this workspace's session (per-cwd, re-using the record on re-init) and make
2257
2887
  // it active. init() adopts the launch config and tears down ONLY this session's prior
2258
2888
  // generation (tests / hot reload) — other workspaces' sessions are untouched.
@@ -2430,6 +3060,8 @@ export function stopSession() {
2430
3060
  _onQueryError = null;
2431
3061
  _broadcastWs = null;
2432
3062
  _runWaterfallHook = null;
3063
+ _onAgentLoopStart = null;
3064
+ _onAgentLoopEnd = null;
2433
3065
  }
2434
3066
 
2435
3067
  /**
@@ -2546,6 +3178,22 @@ export function getSessionId(anchor) {
2546
3178
  return sess ? sess.getSessionId() : null;
2547
3179
  }
2548
3180
 
3181
+ /**
3182
+ * Manual [retry] of the last failed turn on the active (or anchored) session. Pushes a
3183
+ * synthetic continue instruction instead of resending the user's original message.
3184
+ * Returns the session's { ok, reason? , turnId? } verdict.
3185
+ */
3186
+ export async function retryInterruptedTurn(turnId, anchor) {
3187
+ if (!_query) return { ok: false, reason: 'unavailable' };
3188
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
3189
+ if (!sess) return { ok: false, reason: 'not-found' };
3190
+ const r = await sess.retryInterruptedTurn(turnId);
3191
+ // Only a SUCCESSFUL retry re-routes the process-wide active session — a rejected retry
3192
+ // (busy/stale/no-session) must not steal the active anchor from the foreground view.
3193
+ if (r && r.ok && anchor && sess.key !== _activeKey) _activeKey = sess.key;
3194
+ return r;
3195
+ }
3196
+
2549
3197
  /**
2550
3198
  * The last sdk-init snapshot for WS reconnect replay. No anchor → active session;
2551
3199
  * with an anchor → that session. Returns null if no init has been announced yet