cc-viewer 1.9.17 → 1.9.18

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,33 @@ 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;
171
192
  // getContextUsage control requests have no SDK-side timeout — bound the wait so a live-but-
172
193
  // unresponsive child can't wedge the in-flight gate forever.
173
194
  const CTX_USAGE_TIMEOUT_MS = 10 * 1000;
@@ -184,6 +205,26 @@ let _onTurnEnd = null; // SDK mode has no Stop hook (ensureHooks() skipped) —
184
205
  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
206
  let _broadcastWs = null;
186
207
  let _runWaterfallHook = null;
208
+ // Durable process-level wiring registered ONCE by cli.js. Unlike the mutable callbacks
209
+ // above (which stopSession clears), this survives a workspace stop→relaunch so a launch
210
+ // route that omits the callbacks still restores them (see _adoptProcessWiring). Tests
211
+ // never register it, so stopSession's "clear the wiring" contract for tests is unchanged.
212
+ let _processWiring = null;
213
+ export function setSdkProcessWiring(w) {
214
+ _processWiring = (w == null) ? null : {
215
+ onTurnEnd: w.onTurnEnd || null,
216
+ onQueryError: w.onQueryError || null,
217
+ broadcastWs: w.broadcastWs || null,
218
+ runWaterfallHook: w.runWaterfallHook || null,
219
+ };
220
+ }
221
+ function _adoptProcessWiring() {
222
+ if (!_processWiring) return;
223
+ if (!_onTurnEnd) _onTurnEnd = _processWiring.onTurnEnd;
224
+ if (!_onQueryError) _onQueryError = _processWiring.onQueryError;
225
+ if (!_broadcastWs) _broadcastWs = _processWiring.broadcastWs;
226
+ if (!_runWaterfallHook) _runWaterfallHook = _processWiring.runWaterfallHook;
227
+ }
187
228
 
188
229
  // Process-level SDK LAUNCH-CONFIG template (2026-10-12). cli.js registers it once at boot
189
230
  // (the env carries the loopback-proxy URL + the per-cwd header is added per session). The
@@ -360,10 +401,25 @@ function _createSession(key, cwd) {
360
401
  // CLI-side turn may still be running; without the sticky flag a
361
402
  // second Stop would silently no-op).
362
403
  let _settleGraceTimer = null; // backstop after interrupt: force-settle if no result arrives
404
+ let _lostResultTimer = null; // lost-result backstop handle (cleared on turn settle/teardown)
363
405
  let _lateResultTombstones = 0; // results to discard (already force-settled via grace/watchdog)
364
406
  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
407
+ // Activity probe (replaces the old fixed 25min silence watchdog): re-armed on every
408
+ // incoming SDK message. When a turn stays silent past ACTIVITY_PROBE_IDLE_MS it pings
409
+ // the CLI's control plane (getContextUsage — a REAL round-trip; the init-cached
410
+ // supportedCommands/initializationResult would resolve even against a wedged child).
411
+ // Only consecutive unanswered probes mean the child is genuinely wedged (a legal
412
+ // multi-hour tool turn keeps its event loop responsive and answers). This never kills
413
+ // a long turn for being merely quiet.
414
+ let _activityProbe = null;
415
+ let _probeFailures = 0;
416
+ let _probeEpoch = 0; // bumped on any message/interrupt → invalidates an in-flight probe
417
+ // Suspect generation: set when an interrupt grace expires with no result (the CLI never
418
+ // answered our Stop) or a probe convicts the child. The wedged query is torn down LAZILY
419
+ // — on the next push rather than at conviction time — so we never SIGTERM a child that
420
+ // may just be slow, and the rebuild resumes _sessionId for continuity.
421
+ let _suspectGeneration = false;
422
+ let _lastFailedTurn = null; // { turnId, retryable, apiStatus, attempt, maxRetries, code, ts } — drives the manual [retry] affordance
367
423
  let _sessionId = null;
368
424
  let _initAnnouncedSid = ''; // sessionId whose slash-command surface was already announced (reset on full reset)
369
425
  let _lastInitSnapshot = null; // last sdk-init payload for WS reconnect replay
@@ -380,6 +436,7 @@ function _createSession(key, cwd) {
380
436
  // injected by cli.js) > push-running / result-idle fallbacks below. Retained for WS
381
437
  // reconnect replay like _lastInitSnapshot; reset alongside it on switch/reset.
382
438
  let _lastSessionState = null;
439
+ let _stateHeartbeat = null; // setInterval re-broadcasting sdk-session-state while a turn is active
383
440
  let _lastContextUsage = null; // last sdk-context-usage frame for WS reconnect replay
384
441
  let _ctxUsageFetching = false; // one getContextUsage control request in flight
385
442
  let _ctxUsageQueued = false; // a turn-end/compact arrived while in flight → rerun once (coalesce)
@@ -450,10 +507,15 @@ function _createSession(key, cwd) {
450
507
  // (an interrupted turn still synthesizes an error_during_execution result). All
451
508
  // settle paths (result / grace force-settle / teardown settleAll) go through
452
509
  // _settleTurn, which is idempotent via rec.settled.
510
+ let _turnSeq = 0;
453
511
  function _beginTurn() {
454
512
  let resolve;
455
513
  const promise = new Promise((r) => { resolve = r; });
456
- const rec = { resolve, interrupted: false, settled: false };
514
+ const rec = {
515
+ id: 'turn_' + (++_turnSeq).toString(36) + '_' + Math.random().toString(36).slice(2, 8),
516
+ resolve, interrupted: false, settled: false,
517
+ apiRetry: { count: 0, attempt: 0, maxRetries: 0, lastStatus: null, lastError: null },
518
+ };
457
519
  _pendingTurns.push(rec);
458
520
  return { rec, promise };
459
521
  }
@@ -471,38 +533,132 @@ function _createSession(key, cwd) {
471
533
  _queryBusy = false;
472
534
  _turnInFlight = false;
473
535
  _clearTurnWatchdog();
536
+ _clearLostResultBackstop();
474
537
  }
475
538
 
476
539
  function _clearGraceTimer() {
477
540
  if (_settleGraceTimer) { clearTimeout(_settleGraceTimer); _settleGraceTimer = null; }
478
541
  }
479
542
 
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. */
543
+ /** Activity probe (replaces the old fixed 25min silence watchdog). Re-armed on every
544
+ * incoming SDK message, so it only fires after ACTIVITY_PROBE_IDLE_MS of total silence.
545
+ * Instead of force-killing the turn for being quiet (which would murder a legal
546
+ * multi-hour tool run), it pings the CLI's control plane: a live child answers, a wedged
547
+ * one does not. Only ACTIVITY_PROBE_MAX_FAILURES consecutive unanswered probes convict
548
+ * the child and trigger recovery. Pending approvals also prove liveness (the child is
549
+ * parked awaiting our control response), so probes are not even sent then.
550
+ *
551
+ * `_probeFailures` deliberately survives re-arms: _clearTurnWatchdog only cancels the
552
+ * pending timer, and the counter is reset by a successful/answered probe (or a full
553
+ * teardown/reset) — otherwise conviction at MAX_FAILURES would be unreachable. */
486
554
  function _armTurnWatchdog() {
487
555
  _clearTurnWatchdog();
488
556
  if (_pendingTurns.length === 0) return;
489
- _turnWatchdog = setTimeout(() => {
490
- _turnWatchdog = null;
557
+ _activityProbe = setTimeout(() => {
558
+ _activityProbe = null;
491
559
  if (_pendingTurns.length === 0) return;
492
- if (_pendingApprovals.size > 0) {
493
- _armTurnWatchdog(); // approval pending = alive; wait out the approval timeout instead
560
+ if (_pendingApprovals.size > 0) { _armTurnWatchdog(); return; } // approval pending = alive
561
+ _runActivityProbe();
562
+ }, _probeFailures === 0 ? ACTIVITY_PROBE_IDLE_MS : ACTIVITY_PROBE_INTERVAL_MS);
563
+ if (_activityProbe.unref) _activityProbe.unref();
564
+ }
565
+
566
+ function _clearTurnWatchdog() {
567
+ if (_activityProbe) { clearTimeout(_activityProbe); _activityProbe = null; }
568
+ // Any arm/clear is triggered by observed activity (a message re-arms on every frame,
569
+ // a settle clears) — invalidate an in-flight probe and reset consecutive-failure
570
+ // counting so a slow probe on the PREVIOUS quiet stretch cannot convict a live turn.
571
+ _probeEpoch++;
572
+ if (_pendingTurns.length === 0) _probeFailures = 0;
573
+ }
574
+
575
+ /** Ping the child's control plane once with a REAL control round-trip (getContextUsage;
576
+ * supportedCommands/initializationResult resolve from the cached init promise and prove
577
+ * nothing). An answer — resolve OR reject — means the event loop is responsive: reset
578
+ * the failure count and keep waiting silently. No answer within the timeout counts one
579
+ * failure; ACTIVITY_PROBE_MAX_FAILURES consecutive failures convict the child as wedged.
580
+ * Both outcomes re-check the generation + FIFO: a probe started on query A must never
581
+ * convict generation B, and a turn that completed mid-probe must not be "recovered".
582
+ * A probe that never resolves leaks one in-flight control request per conviction —
583
+ * bounded, and the pending reject on teardown (Query.cleanup) is swallowed by `done`. */
584
+ function _runActivityProbe() {
585
+ const q = _activeQuery;
586
+ if (!q || typeof q.getContextUsage !== 'function') { _armTurnWatchdog(); return; }
587
+ // Bind the verdict to the TURN probed AND the probe epoch: any message/interrupt
588
+ // observed since this probe started proves the child is alive, so its timeout must be
589
+ // discarded rather than charged. (a new turn as head also invalidates the verdict.)
590
+ const probeRec = _pendingTurns[0] || null;
591
+ const epoch = _probeEpoch;
592
+ let timer = null;
593
+ let done = false;
594
+ // NOTE: own .then/.catch handlers instead of Promise.race — under node:test mocked
595
+ // timers a Promise.race([neverSettlingPromise, mockedTimerPromise]) never runs its
596
+ // reactions (race subscribes to the pending promise first; the mocked timer's later
597
+ // settle does not flush the race's capability). Plain handlers are immune.
598
+ timer = setTimeout(() => {
599
+ if (done) return;
600
+ done = true;
601
+ timer = null;
602
+ finish('timeout');
603
+ }, CTX_USAGE_TIMEOUT_MS);
604
+ if (timer.unref) timer.unref();
605
+ let probeCall;
606
+ try { probeCall = q.getContextUsage(); }
607
+ catch { finish('answered'); return; } // a sync throw still proves the event loop runs
608
+ if (!probeCall || typeof probeCall.then !== 'function') { finish('answered'); return; } // non-Promise impl = responsive
609
+ probeCall.then(
610
+ () => { if (done) return; done = true; if (timer) { clearTimeout(timer); timer = null; } finish('answered'); },
611
+ () => { if (done) return; done = true; if (timer) { clearTimeout(timer); timer = null; } finish('answered'); },
612
+ );
613
+ function finish(outcome) {
614
+ // Stale generation / settled-or-switched turn / activity since the probe started /
615
+ // a user interrupt / a parked approval: the probe's verdict no longer applies.
616
+ if (_activeQuery !== q || _pendingTurns.length === 0 || _pendingTurns[0] !== probeRec) return;
617
+ if (epoch !== _probeEpoch) return; // activity observed mid-probe — the child is alive
618
+ if (probeRec && probeRec.interrupted) return;
619
+ if (_pendingApprovals.size > 0) return;
620
+ if (outcome === 'timeout') {
621
+ _probeFailures += 1;
622
+ if (_probeFailures < ACTIVITY_PROBE_MAX_FAILURES) { _armTurnWatchdog(); return; }
623
+ console.warn('[sdk-manager] turn unresponsive to control probes — recovering the wedged query');
624
+ _recoverFromStuckTurn(_pendingTurns[0] || null);
494
625
  return;
495
626
  }
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();
627
+ // resolve OR reject both prove the control plane (event loop) is responsive.
628
+ _probeFailures = 0;
629
+ _armTurnWatchdog(); // still no turn output — schedule the next idle probe
630
+ }
502
631
  }
503
632
 
504
- function _clearTurnWatchdog() {
505
- if (_turnWatchdog) { clearTimeout(_turnWatchdog); _turnWatchdog = null; }
633
+ /** Recover from a genuinely wedged query (proven by control probes, or an interrupt the
634
+ * CLI never answered). Settles turn promises, announces idle, records a retryable failure
635
+ * so the UI can offer a manual [retry], and marks the generation suspect. The teardown
636
+ * itself is LAZY — deferred to the next push (_startTurn consults _suspectGeneration) —
637
+ * so we never SIGTERM a merely-slow child mid-side-effect; the rebuild resumes
638
+ * _sessionId for continuity. */
639
+ function _recoverFromStuckTurn(stalledRec) {
640
+ _suspectGeneration = true;
641
+ // Cancel any parked approvals: they belong to the (now suspect) generation's canUseTool,
642
+ // and leaving them would replay a ghost modal on attach AND suppress probing on the
643
+ // rebuilt generation (size > 0 = "alive"). They time out / deny as their own path.
644
+ _cancelAllApprovals();
645
+ // Tombstone the result that a merely-slow child may still emit before the lazy
646
+ // teardown — otherwise it would fire a spurious turn_end for an already-failed turn.
647
+ _lateResultTombstones++;
648
+ _settleAllTurns();
649
+ _announceSessionState('idle');
650
+ // Retry is only possible if a session id was captured to resume — a first turn that
651
+ // wedges before any init frame has nothing to continue, so don't offer [retry].
652
+ const retryable = !!(_sessionId || _launchResume);
653
+ _lastFailedTurn = {
654
+ turnId: stalledRec?.id || null, retryable, apiStatus: null,
655
+ attempt: stalledRec?.apiRetry?.attempt || 0, maxRetries: stalledRec?.apiRetry?.maxRetries || 0,
656
+ code: 'turn-stuck', ts: Date.now(),
657
+ };
658
+ _notifyQueryError(new Error('The turn became unresponsive; the SDK session will restart on the next message.'), {
659
+ code: 'turn-stuck', retryable, turnId: stalledRec?.id || null,
660
+ });
661
+ _maybeDrain(); // a queued backlog must not stall behind the recovered turn
506
662
  }
507
663
 
508
664
  /**
@@ -536,7 +692,18 @@ function _createSession(key, cwd) {
536
692
  // A fresh user-initiated turn re-arms automatic draining of parked (Stop-kept) items.
537
693
  _suppressDrain = false;
538
694
  _consecutiveDeaths = 0; // explicit user action re-arms the death-drain backstop
539
- const { promise } = await _startTurn(text);
695
+ const { started, promise, notified } = await _startTurn(text);
696
+ // A pre-push delivery failure (teardown raced / query closed) would otherwise leave
697
+ // the user with a cleared composer and no signal. _startTurn already settled the turn
698
+ // promise (so `promise` won't hang); reject HERE with a code so server.js can route a
699
+ // directed sdk-send-failed to the initiator. Construction failures are excluded
700
+ // (notified:true) — they already fired _notifyQueryError (a second toast would
701
+ // double-report).
702
+ if (!started && !notified) {
703
+ const err = new Error('message not delivered to the SDK session');
704
+ err.code = 'not-delivered';
705
+ throw err;
706
+ }
540
707
  return promise;
541
708
  }
542
709
 
@@ -569,6 +736,16 @@ function _createSession(key, cwd) {
569
736
  * rejects; a later turn failure must NOT put an already-pushed message back.
570
737
  */
571
738
  async function _startTurn(text, dispatch = null) {
739
+ // A convicted/suspect generation is torn down HERE — lazily, just before the next
740
+ // push — rather than at conviction time. Deferring avoids SIGTERM-ing a merely-slow
741
+ // child mid-side-effect; rebuilding now lets _ensureResidentQuery resume _sessionId.
742
+ // This MUST run before _beginTurn(): teardown settles every pending turn record, so a
743
+ // record created first would be settled (and its message silently dropped) by its own
744
+ // teardown, leaving _queryBusy wedged with an empty FIFO.
745
+ if (_suspectGeneration) {
746
+ _suspectGeneration = false;
747
+ _teardownResident();
748
+ }
572
749
  const { rec, promise } = _beginTurn();
573
750
  if (dispatch) rec.dispatchItem = dispatch;
574
751
  _queryBusy = true;
@@ -580,7 +757,9 @@ function _createSession(key, cwd) {
580
757
  _settleTurn(rec);
581
758
  _queryBusy = false;
582
759
  _notifyQueryError(err);
583
- return { started: false, promise };
760
+ // notified:true tells _sendUserMessage NOT to also reject not-delivered — the user
761
+ // already got the query-error toast; a second rejection would double-report.
762
+ return { started: false, promise, turnId: rec.id, notified: true };
584
763
  }
585
764
  // Teardown/stop raced with construction — the settle already happened; don't push.
586
765
  if (rec.settled || (dispatch && _dispatching !== dispatch)) {
@@ -589,13 +768,13 @@ function _createSession(key, cwd) {
589
768
  _settleTurn(rec);
590
769
  if (_dispatching !== dispatch) _queryBusy = false;
591
770
  }
592
- return { started: false, promise };
771
+ return { started: false, promise, turnId: rec.id };
593
772
  }
594
773
  if (!_activeQuery || !_inputQueue || (typeof _inputQueue.isClosed === 'function' && _inputQueue.isClosed()) || (dispatch && _dispatching !== dispatch)) {
595
774
  _pendingTurns = _pendingTurns.filter((turn) => turn !== rec);
596
775
  _settleTurn(rec);
597
776
  _queryBusy = false;
598
- return { started: false, promise };
777
+ return { started: false, promise, turnId: rec.id };
599
778
  }
600
779
  // Typed /clear is detected at EXECUTION time (never at enqueue time): a clear queued
601
780
  // behind a busy turn must not wipe the running turn's usage panel. Invalidate the
@@ -613,7 +792,7 @@ function _createSession(key, cwd) {
613
792
  _settleTurn(rec);
614
793
  _queryBusy = false;
615
794
  reportSwallowed('sdk-manager.queue-push', err);
616
- return { started: false, promise };
795
+ return { started: false, promise, turnId: rec.id };
617
796
  }
618
797
  _turnInFlight = true;
619
798
  // Turn-level loading fallback: light the spinner the moment the turn is pushed.
@@ -622,7 +801,7 @@ function _createSession(key, cwd) {
622
801
  // the only "on" signal, paired with the result-idle broadcast in _processMessage.
623
802
  _announceSessionState('running');
624
803
  _armTurnWatchdog();
625
- return { started: true, promise };
804
+ return { started: true, promise, turnId: rec.id };
626
805
  }
627
806
 
628
807
  /**
@@ -883,6 +1062,10 @@ function _createSession(key, cwd) {
883
1062
  async function _drainResidentIterator(query, inputQueue) {
884
1063
  try {
885
1064
  for await (const msg of query) {
1065
+ // Generation guard: after a teardown/switch, a stale generation's late frames must
1066
+ // NOT be processed — they belong to the dead child and would mis-settle the NEW
1067
+ // generation's pending turns. Bail out and let _onResidentDeath dismiss it as stale.
1068
+ if (_activeQuery !== query) break;
886
1069
  // A message-processing failure (e.g. a broadcast callback throwing) must NOT be
887
1070
  // mistaken for session death — the child is still alive; only the iterator's
888
1071
  // end/throw is a death signal.
@@ -891,7 +1074,11 @@ function _createSession(key, cwd) {
891
1074
  } catch (msgErr) {
892
1075
  reportSwallowed('sdk-manager.process-message', msgErr);
893
1076
  }
894
- _armTurnWatchdog(); // silence watchdog: any message proves the session is alive
1077
+ // Any message proves the child is alive: reset consecutive-failure counting too,
1078
+ // so a probe that timed out on the PREVIOUS quiet stretch cannot convict a turn
1079
+ // that has since demonstrably produced output.
1080
+ _probeFailures = 0;
1081
+ _armTurnWatchdog(); // reset the activity probe on observed activity
895
1082
  }
896
1083
  _onResidentDeath(query, inputQueue, null);
897
1084
  } catch (err) {
@@ -909,6 +1096,10 @@ function _createSession(key, cwd) {
909
1096
  if (_activeQuery !== query) return; // stale generation — teardown already moved on
910
1097
  _activeQuery = null;
911
1098
  _inputQueue = null;
1099
+ // The (possibly wedged) child exited on its own — the suspicion dies with it, so the
1100
+ // next push must not run a pointless teardown against an already-dead generation.
1101
+ _suspectGeneration = false;
1102
+ _probeFailures = 0;
912
1103
  if (_ctxUsageClearPending) {
913
1104
  // A child that exits while applying /clear must not resume the cleared session on the
914
1105
  // next message. Start fresh, allow the next init/read, and invalidate older controls.
@@ -919,13 +1110,30 @@ function _createSession(key, cwd) {
919
1110
  }
920
1111
  _clearGraceTimer();
921
1112
  _clearTurnWatchdog();
1113
+ _clearStateHeartbeat();
922
1114
  _lateResultTombstones = 0;
1115
+ const hadTurns = _pendingTurns.length > 0;
1116
+ const headTurn = _pendingTurns[0] || null;
923
1117
  // 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.
1118
+ // hang forever. The turn is unambiguously over.
925
1119
  _announceSessionState('idle');
926
1120
  if (err && err.name !== 'AbortError') {
927
1121
  console.error('[SDK] Resident query error:', err.message);
928
1122
  _notifyQueryError(err);
1123
+ } else if (!err && hadTurns) {
1124
+ // Clean exit with a turn still in flight is itself a failure — the user would
1125
+ // otherwise see only "idle" with no error. If the CLI exhausted API retries
1126
+ // before dying, keep it retryable so the manual [retry] affordance appears.
1127
+ const retried = (headTurn?.apiRetry?.count || 0) > 0;
1128
+ if (retried) {
1129
+ const status = headTurn.apiRetry.lastStatus;
1130
+ _lastFailedTurn = {
1131
+ turnId: headTurn.id, retryable: (status == null || RETRYABLE_STATUS.has(status)) && !!(_sessionId || _launchResume),
1132
+ apiStatus: status, attempt: headTurn.apiRetry.attempt, maxRetries: headTurn.apiRetry.maxRetries,
1133
+ code: 'session-ended', ts: Date.now(),
1134
+ };
1135
+ }
1136
+ _notifyQueryError(new Error('Agent SDK session ended before the turn completed'), { code: 'session-ended', retryable: retried && !!(_sessionId || _launchResume), turnId: headTurn?.id || null });
929
1137
  }
930
1138
  _settleAllTurns();
931
1139
  // A dispatching item waiting on an interrupt unwind never reached the SDK. Death
@@ -974,7 +1182,13 @@ function _createSession(key, cwd) {
974
1182
  _initSkills = null; // re-captured from the next init frame
975
1183
  _clearGraceTimer();
976
1184
  _clearTurnWatchdog();
1185
+ _probeFailures = 0;
1186
+ _clearStateHeartbeat();
977
1187
  _lateResultTombstones = 0;
1188
+ // NOTE: _lastFailedTurn deliberately survives teardown — the lazy rebuild path
1189
+ // (_startTurn's suspect-generation teardown) must keep the recorded failure so the
1190
+ // manual [retry] affordance stays available. User-facing session boundaries clear it
1191
+ // instead: _resetFullState / _switchToSession (which funnel close/teardown/stop).
978
1192
  _settleAllTurns();
979
1193
  if (inputQueue) inputQueue.close();
980
1194
  if (query) {
@@ -984,10 +1198,11 @@ function _createSession(key, cwd) {
984
1198
  }
985
1199
 
986
1200
  /** 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) {
1201
+ * session's closure (callback throw must never break the caller). `extra` carries
1202
+ * failure metadata (code/retryable/turnId/apiStatus) through to the sdk-error frame. */
1203
+ function _notifyQueryError(err, extra = {}) {
989
1204
  if (!_onQueryError) return;
990
- const context = { cwd: _cwd, sessionId: _sessionId || null, instanceKey };
1205
+ const context = { cwd: _cwd, sessionId: _sessionId || null, instanceKey, ...extra };
991
1206
  try { _onQueryError(String(err?.message || err), context); }
992
1207
  catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
993
1208
  }
@@ -1078,6 +1293,36 @@ function _createSession(key, cwd) {
1078
1293
  // _broadcast carries the routing envelope (cwd + sessionId + instanceKey) — every
1079
1294
  // frame needs it so concurrent sessions' clients filter correctly.
1080
1295
  _broadcast({ type: 'sdk-session-state', state });
1296
+ _syncStateHeartbeat();
1297
+ }
1298
+
1299
+ // The in-flight turn is the FIFO HEAD (the result branch shifts from the head).
1300
+ const _currentTurnId = () => (_pendingTurns.length ? _pendingTurns[0].id : null);
1301
+
1302
+ /** While a turn is running/requires_action, re-broadcast the session state every
1303
+ * STATE_HEARTBEAT_MS so clients (a) keep their Stop button / spinner alive past the
1304
+ * frontend's 30s no-signal stale sweep, and (b) can re-derive it after a view switch.
1305
+ * The heartbeat does NOT re-arm the activity probe (silence semantics unchanged) and
1306
+ * is cleared on idle/teardown/reset/switch/death. */
1307
+ function _syncStateHeartbeat() {
1308
+ const active = _lastSessionState === 'running' || _lastSessionState === 'requires_action';
1309
+ if (active && !_stateHeartbeat) {
1310
+ _stateHeartbeat = setInterval(() => {
1311
+ if (_lastSessionState !== 'running' && _lastSessionState !== 'requires_action') return;
1312
+ _broadcast({
1313
+ type: 'sdk-session-state', state: _lastSessionState, heartbeat: true,
1314
+ turnId: _currentTurnId(), pendingApprovals: _pendingApprovals.size,
1315
+ });
1316
+ }, STATE_HEARTBEAT_MS);
1317
+ if (_stateHeartbeat.unref) _stateHeartbeat.unref();
1318
+ } else if (!active && _stateHeartbeat) {
1319
+ clearInterval(_stateHeartbeat);
1320
+ _stateHeartbeat = null;
1321
+ }
1322
+ }
1323
+
1324
+ function _clearStateHeartbeat() {
1325
+ if (_stateHeartbeat) { clearInterval(_stateHeartbeat); _stateHeartbeat = null; }
1081
1326
  }
1082
1327
 
1083
1328
  /**
@@ -1097,6 +1342,39 @@ function _createSession(key, cwd) {
1097
1342
  // push/result fallbacks keep the spinner correct at result granularity.
1098
1343
  if (msg.subtype === 'session_state_changed') {
1099
1344
  _announceSessionState(msg.state);
1345
+ // A CLI-emitted running/requires_action proves the head turn is genuinely
1346
+ // in-flight on the child (vs a freshly-drained turn whose own CLI frames have
1347
+ // not arrived yet). Lost-result only arms for such a turn — a LATE idle frame
1348
+ // from the previous turn must not convict the just-pushed one.
1349
+ if ((msg.state === 'running' || msg.state === 'requires_action') && _pendingTurns[0]) _pendingTurns[0].cliStateSeen = true;
1350
+ // Lost-result backstop: the CLI says the turn settled (idle) but no result
1351
+ // message arrived for our pending FIFO. Without this _queryBusy would wedge
1352
+ // forever now that the 25min silence watchdog is gone. Give the result a short
1353
+ // grace to arrive first (frame ordering within a turn end is not guaranteed),
1354
+ // then settle as a retryable failure.
1355
+ if (msg.state === 'idle' && _pendingTurns.length > 0 && _pendingTurns[0].cliStateSeen) _armLostResultBackstop();
1356
+ } else if (msg.subtype === 'api_retry') {
1357
+ // The CLI's own retry of a retryable API error (502/429/5xx/network). It carries
1358
+ // no turn id, so we attribute it to the head pending turn — approximate but
1359
+ // correct in the common single-turn case. Skipped while a tombstone is pending
1360
+ // (a late retry of an already-interrupted turn must not relight the bar).
1361
+ if (_lateResultTombstones === 0) {
1362
+ const rec = _pendingTurns.length ? _pendingTurns[0] : null;
1363
+ if (rec) {
1364
+ rec.apiRetry.count += 1;
1365
+ rec.apiRetry.attempt = Number(msg.attempt) || rec.apiRetry.count;
1366
+ rec.apiRetry.maxRetries = Number(msg.max_retries) || 0;
1367
+ rec.apiRetry.lastStatus = (typeof msg.error_status === 'number') ? msg.error_status : null;
1368
+ rec.apiRetry.lastError = msg.error || null;
1369
+ _broadcast({
1370
+ type: 'sdk-retry-state', phase: 'retrying', turnId: rec.id,
1371
+ attempt: rec.apiRetry.attempt, maxRetries: rec.apiRetry.maxRetries,
1372
+ retryDelayMs: Number(msg.retry_delay_ms) || 0,
1373
+ errorStatus: rec.apiRetry.lastStatus, error: rec.apiRetry.lastError || null,
1374
+ ts: Date.now(),
1375
+ });
1376
+ }
1377
+ }
1100
1378
  } else if (msg.subtype === 'init') {
1101
1379
  // /clear may reinitialize instead of returning a normal result. The new session is
1102
1380
  // now authoritative, so context reads may resume (without resuming the old session).
@@ -1187,9 +1465,33 @@ function _createSession(key, cwd) {
1187
1465
  break;
1188
1466
  }
1189
1467
  const rec = _pendingTurns.shift();
1468
+ // 502/retry state machine. An error result after observed CLI retries records a
1469
+ // retryable _lastFailedTurn (drives the manual [retry] affordance). ANY success
1470
+ // unconditionally closes a recorded failure — including a manual retry that
1471
+ // succeeds on its first API attempt (apiRetry.count === 0) — and clears
1472
+ // _lastFailedTurn; `retryOf` carries the FAILED turn's id (rec.retryOf when this
1473
+ // turn is itself a manual retry) because the frontend matches cleanup by it.
1474
+ if (rec) {
1475
+ const isError = msg.is_error === true || (msg.subtype && msg.subtype !== 'success');
1476
+ if (isError && !rec.interrupted) {
1477
+ const status = rec.apiRetry.lastStatus;
1478
+ const retryable = rec.apiRetry.count > 0 && (status == null || RETRYABLE_STATUS.has(status));
1479
+ _lastFailedTurn = {
1480
+ turnId: rec.id, retryable, apiStatus: status,
1481
+ attempt: rec.apiRetry.attempt, maxRetries: rec.apiRetry.maxRetries, code: null, ts: Date.now(),
1482
+ };
1483
+ } else if (!isError && _lastFailedTurn && (rec.apiRetry.count > 0 || rec.retryOf === _lastFailedTurn.turnId)) {
1484
+ // recovered only when there IS a recorded failure AND this turn closes it
1485
+ // (a manual retry of it — even on its first attempt — or a turn that itself
1486
+ // saw CLI retries). A clean success (no _lastFailedTurn) emits nothing; an
1487
+ // unrelated later success neither clears the affordance nor spams the frame.
1488
+ _broadcast({ type: 'sdk-retry-state', phase: 'recovered', turnId: rec.id, retryOf: rec.retryOf || _lastFailedTurn.turnId, ts: Date.now() });
1489
+ _lastFailedTurn = null;
1490
+ }
1491
+ }
1190
1492
  // An interrupted turn's result is expected to be an error (turn aborted) — don't
1191
1493
  // toast it. Turn-end still fires, matching the PTY Stop-hook semantics.
1192
- if (rec && !rec.interrupted) _notifyTurnError(msg);
1494
+ if (rec && !rec.interrupted) _notifyTurnError(msg, rec);
1193
1495
  // SDK turn-end signal. Equivalent to Claude Code's Stop hook
1194
1496
  // in CLI mode — fires once per user-prompt response when the whole chain
1195
1497
  // (assistant text + all tool calls + final reply) completes. SDK mode
@@ -1231,14 +1533,17 @@ function _createSession(key, cwd) {
1231
1533
  * never signals it, so a dead/errored turn would otherwise look like a hang. The
1232
1534
  * sdk-error frame renders as a persistent dialogue row plus a toast client-side.
1233
1535
  */
1234
- function _notifyTurnError(resultMsg) {
1536
+ function _notifyTurnError(resultMsg, rec = null) {
1235
1537
  const isError = resultMsg.is_error === true || (resultMsg.subtype && resultMsg.subtype !== 'success');
1236
1538
  if (!isError) return;
1237
1539
  const errs = Array.isArray(resultMsg.errors) && resultMsg.errors.length
1238
1540
  ? resultMsg.errors.join('; ')
1239
1541
  : (typeof resultMsg.result === 'string' && resultMsg.result) || resultMsg.subtype || 'unknown error';
1542
+ const retry = rec?.apiRetry || null;
1543
+ const status = retry?.lastStatus ?? null;
1544
+ const retryable = !!retry?.count && (status == null || RETRYABLE_STATUS.has(status));
1240
1545
  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); }
1546
+ 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
1547
  }
1243
1548
  }
1244
1549
 
@@ -1831,20 +2136,24 @@ function _createSession(key, cwd) {
1831
2136
  return cancelled;
1832
2137
  }
1833
2138
 
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. */
2139
+ /** Backstop: if no result shows up within the grace window after an interrupt, settle
2140
+ * the head turn (keeping _queryBusy from wedging forever) and mark the generation
2141
+ * suspect — the CLI never answered our Stop, so the child may be wedged. The actual
2142
+ * teardown is deferred to the next push (_startTurn) so we never SIGTERM a child that
2143
+ * was merely slow to unwind; the rebuild resumes _sessionId. A tombstone discards the
2144
+ * late result if it ever arrives. */
1837
2145
  function _armSettleGrace() {
1838
2146
  _clearGraceTimer();
1839
2147
  _settleGraceTimer = setTimeout(() => {
1840
2148
  _settleGraceTimer = null;
1841
2149
  const rec = _pendingTurns[0];
1842
2150
  if (!rec || !rec.interrupted) return;
1843
- console.warn('[sdk-manager] no result within interrupt grace — force-settling the turn');
2151
+ console.warn('[sdk-manager] no result within interrupt grace — settling the turn and marking the generation suspect');
1844
2152
  _lateResultTombstones++;
1845
2153
  _pendingTurns.shift();
1846
2154
  _settleTurn(rec);
1847
2155
  _queryBusy = false;
2156
+ _suspectGeneration = true;
1848
2157
  // The CLI never answered the interrupt — no result is coming, so clear the
1849
2158
  // spinner here rather than waiting on a dead signal.
1850
2159
  _announceSessionState('idle');
@@ -1854,6 +2163,46 @@ function _createSession(key, cwd) {
1854
2163
  if (_settleGraceTimer.unref) _settleGraceTimer.unref();
1855
2164
  }
1856
2165
 
2166
+ /** Lost-result backstop: the CLI announced session_state_changed('idle') (turn over,
2167
+ * tools included) but never produced a result message for our pending FIFO. Give the
2168
+ * result a short grace to arrive (frame ordering at turn end is not guaranteed), then
2169
+ * settle as a retryable failure — without this _queryBusy would wedge forever now that
2170
+ * the fixed silence watchdog is gone. */
2171
+ function _armLostResultBackstop() {
2172
+ const GRACE_MS = 5000;
2173
+ // Capture the pending turn at arm time and re-verify identity at fire time: a stale
2174
+ // backstop from turn A must never settle turn B (which may be parked on the SAME idle
2175
+ // state). The interrupt-grace path owns user-cancelled turns — skip those here.
2176
+ const armedRec = _pendingTurns[0] || null;
2177
+ _lostResultTimer = setTimeout(() => {
2178
+ _lostResultTimer = null;
2179
+ const rec = _pendingTurns[0];
2180
+ if (!rec || rec !== armedRec || rec.interrupted) return; // stale arm / different turn / user-cancelled
2181
+ if (_queryBusy === false) return;
2182
+ if (_lastSessionState !== 'idle') return; // a newer turn started running — not lost
2183
+ console.warn('[sdk-manager] session idle but a turn result never arrived — settling as lost result');
2184
+ // Tombstone the result that may still arrive: without it the late result would
2185
+ // consume the NEXT turn's FIFO record (settling it early and mispairing turn-end).
2186
+ _lateResultTombstones++;
2187
+ _settleAllTurns();
2188
+ const retryable = !!(_sessionId || _launchResume);
2189
+ _lastFailedTurn = {
2190
+ turnId: rec.id || null, retryable, apiStatus: rec.apiRetry?.lastStatus ?? null,
2191
+ attempt: rec.apiRetry?.attempt || 0, maxRetries: rec.apiRetry?.maxRetries || 0,
2192
+ code: 'turn-stuck', ts: Date.now(),
2193
+ };
2194
+ _notifyQueryError(new Error('The turn ended without a result; you can retry it.'), {
2195
+ code: 'turn-stuck', retryable, turnId: rec.id || null,
2196
+ });
2197
+ _maybeDrain();
2198
+ }, GRACE_MS);
2199
+ if (_lostResultTimer.unref) _lostResultTimer.unref();
2200
+ }
2201
+
2202
+ function _clearLostResultBackstop() {
2203
+ if (_lostResultTimer) { clearTimeout(_lostResultTimer); _lostResultTimer = null; }
2204
+ }
2205
+
1857
2206
  /**
1858
2207
  * Interrupt the current turn (user clicked the Stop button) while KEEPING the
1859
2208
  * session alive: the resident query is a streaming-input session, so interrupt()
@@ -1988,6 +2337,35 @@ function _createSession(key, cwd) {
1988
2337
  _broadcastQueueState();
1989
2338
  }
1990
2339
 
2340
+ /**
2341
+ * Manual [retry] of the last failed turn. The original user message is still in the
2342
+ * session history (the CLI closed the failed turn with a synthetic API-error entry and
2343
+ * stayed alive), so we do NOT resend it — we push a tiny synthetic continue instruction
2344
+ * that makes the model redo the interrupted step with full context. Refuses with a
2345
+ * reason unless there is a retryable recorded failure and the session is idle.
2346
+ */
2347
+ async function _retryInterruptedTurn(turnId = null) {
2348
+ if (_queryBusy || _dispatchingStarting || _dispatching) return { ok: false, reason: 'busy' };
2349
+ if (_switching) return { ok: false, reason: 'busy' }; // a session switch must not race a retry push
2350
+ if (!_lastFailedTurn) return { ok: false, reason: 'nothing-to-retry' };
2351
+ if (turnId && _lastFailedTurn.turnId && turnId !== _lastFailedTurn.turnId) return { ok: false, reason: 'stale' };
2352
+ if (_lastFailedTurn.retryable === false) return { ok: false, reason: 'not-retryable' };
2353
+ if (!_sessionId && !_launchResume) return { ok: false, reason: 'no-session' };
2354
+ const retryOf = _lastFailedTurn.turnId; // snapshot BEFORE the await (a new turn mutates state)
2355
+ _suppressDrain = false;
2356
+ const { started, promise, turnId: newTurnId } = await _startTurn(RETRY_CONTINUE_PROMPT);
2357
+ if (!started) return { ok: false, reason: 'not-delivered' };
2358
+ // Stamp the lineage on the new turn's record so the result branch's `recovered`
2359
+ // frame can name the FAILED turn (the frontend matches cleanup by retryOf).
2360
+ // Look the new turn up by id (not index) — a fast result could have shifted the FIFO
2361
+ // in the microtask gap, and the retryOf stamp must land on the retry turn itself.
2362
+ const newRec = _pendingTurns.find(r => r.id === newTurnId) || null;
2363
+ if (newRec) newRec.retryOf = retryOf;
2364
+ _broadcast({ type: 'sdk-retry-state', phase: 'recovering', turnId: newTurnId, retryOf, ts: Date.now() });
2365
+ promise.catch((err) => reportSwallowed('sdk-manager.retry-turn', err));
2366
+ return { ok: true, turnId: newTurnId };
2367
+ }
2368
+
1991
2369
  /**
1992
2370
  * Switch the session to a different session id (web "resume session" entry — SDK-mode
1993
2371
  * counterpart of the PTY /resume injection). options.resume only applies at query
@@ -2015,6 +2393,9 @@ function _createSession(key, cwd) {
2015
2393
  _lastInitSnapshot = null; // don't replay the OLD session's sdk-init to the new one
2016
2394
  _lastSkillsSnapshot = null; // same for the skill surface
2017
2395
  _lastSessionState = null; // same for the session-state snapshot — new session starts idle
2396
+ _clearStateHeartbeat();
2397
+ _suspectGeneration = false;
2398
+ _lastFailedTurn = null; // the failure belongs to the outgoing session — never retry it into this one
2018
2399
  _clearContextUsage('session-switched');
2019
2400
  _clearMcpStatus('session-switched');
2020
2401
  _clearQueued(); // broadcasts the emptied queue-state
@@ -2042,11 +2423,14 @@ function _createSession(key, cwd) {
2042
2423
  // legit idle). Skip the broadcast when nothing was ever announced.
2043
2424
  if (_lastSessionState && _lastSessionState !== 'idle') _announceSessionState('idle');
2044
2425
  _lastSessionState = null;
2426
+ _clearStateHeartbeat();
2045
2427
  _clearContextUsage('session-switched');
2046
2428
  _clearMcpStatus('session-switched');
2047
2429
  _pendingTurns = [];
2048
2430
  _lateResultTombstones = 0;
2049
2431
  _consecutiveDeaths = 0;
2432
+ _suspectGeneration = false;
2433
+ _lastFailedTurn = null;
2050
2434
  _clearGraceTimer();
2051
2435
  _clearTurnWatchdog();
2052
2436
  const hadQueued = _messageQueue.length > 0 || !!_dispatching;
@@ -2099,7 +2483,7 @@ function _createSession(key, cwd) {
2099
2483
  getSdkInitSnapshot: () => _lastInitSnapshot,
2100
2484
  getSessionStateSnapshot: () => {
2101
2485
  if (!_lastSessionState) return null;
2102
- return { type: 'sdk-session-state', state: _lastSessionState, sessionId: _sessionId || null, cwd: _cwd };
2486
+ return { type: 'sdk-session-state', state: _lastSessionState, sessionId: _sessionId || null, cwd: _cwd, instanceKey, turnId: _currentTurnId(), pendingApprovals: _pendingApprovals.size };
2103
2487
  },
2104
2488
  getContextUsageSnapshot: () => _lastContextUsage,
2105
2489
  getMcpStatusSnapshot: () => _lastMcpStatus,
@@ -2127,6 +2511,7 @@ function _createSession(key, cwd) {
2127
2511
  },
2128
2512
  switchToSession: _switchToSession,
2129
2513
  getSessionId: () => _sessionId,
2514
+ retryInterruptedTurn: _retryInterruptedTurn,
2130
2515
  runEphemeralTurn: _runEphemeralTurn,
2131
2516
  close: () => {
2132
2517
  for (const { id, kind } of _cancelAllApprovals()) {
@@ -2253,6 +2638,10 @@ export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broa
2253
2638
  if (onQueryError !== undefined) _onQueryError = onQueryError || null;
2254
2639
  if (broadcastWs !== undefined) _broadcastWs = broadcastWs;
2255
2640
  if (runWaterfallHook !== undefined) _runWaterfallHook = runWaterfallHook || null;
2641
+ // Restore any callback stopSession() cleared from the durable process wiring (cli.js's
2642
+ // registration). A workspace stop→relaunch omits these callbacks, so without this every
2643
+ // sdk-* frame would silently stop broadcasting after returning to the workspace list.
2644
+ _adoptProcessWiring();
2256
2645
  // Register this workspace's session (per-cwd, re-using the record on re-init) and make
2257
2646
  // it active. init() adopts the launch config and tears down ONLY this session's prior
2258
2647
  // generation (tests / hot reload) — other workspaces' sessions are untouched.
@@ -2546,6 +2935,22 @@ export function getSessionId(anchor) {
2546
2935
  return sess ? sess.getSessionId() : null;
2547
2936
  }
2548
2937
 
2938
+ /**
2939
+ * Manual [retry] of the last failed turn on the active (or anchored) session. Pushes a
2940
+ * synthetic continue instruction instead of resending the user's original message.
2941
+ * Returns the session's { ok, reason? , turnId? } verdict.
2942
+ */
2943
+ export async function retryInterruptedTurn(turnId, anchor) {
2944
+ if (!_query) return { ok: false, reason: 'unavailable' };
2945
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
2946
+ if (!sess) return { ok: false, reason: 'not-found' };
2947
+ const r = await sess.retryInterruptedTurn(turnId);
2948
+ // Only a SUCCESSFUL retry re-routes the process-wide active session — a rejected retry
2949
+ // (busy/stale/no-session) must not steal the active anchor from the foreground view.
2950
+ if (r && r.ok && anchor && sess.key !== _activeKey) _activeKey = sess.key;
2951
+ return r;
2952
+ }
2953
+
2549
2954
  /**
2550
2955
  * The last sdk-init snapshot for WS reconnect replay. No anchor → active session;
2551
2956
  * with an anchor → that session. Returns null if no init has been announced yet