cc-viewer 1.9.18 → 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.
@@ -189,6 +189,12 @@ const RETRY_CONTINUE_PROMPT =
189
189
  // How often to re-broadcast sdk-session-state while a turn is active (keeps the client's
190
190
  // Stop button/spinner alive past its 30s stale sweep and re-seeds it after a view switch).
191
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;
192
198
  // getContextUsage control requests have no SDK-side timeout — bound the wait so a live-but-
193
199
  // unresponsive child can't wedge the in-flight gate forever.
194
200
  const CTX_USAGE_TIMEOUT_MS = 10 * 1000;
@@ -205,6 +211,11 @@ let _onTurnEnd = null; // SDK mode has no Stop hook (ensureHooks() skipped) —
205
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)
206
212
  let _broadcastWs = null;
207
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;
208
219
  // Durable process-level wiring registered ONCE by cli.js. Unlike the mutable callbacks
209
220
  // above (which stopSession clears), this survives a workspace stop→relaunch so a launch
210
221
  // route that omits the callbacks still restores them (see _adoptProcessWiring). Tests
@@ -216,6 +227,8 @@ export function setSdkProcessWiring(w) {
216
227
  onQueryError: w.onQueryError || null,
217
228
  broadcastWs: w.broadcastWs || null,
218
229
  runWaterfallHook: w.runWaterfallHook || null,
230
+ onAgentLoopStart: w.onAgentLoopStart || null,
231
+ onAgentLoopEnd: w.onAgentLoopEnd || null,
219
232
  };
220
233
  }
221
234
  function _adoptProcessWiring() {
@@ -224,6 +237,8 @@ function _adoptProcessWiring() {
224
237
  if (!_onQueryError) _onQueryError = _processWiring.onQueryError;
225
238
  if (!_broadcastWs) _broadcastWs = _processWiring.broadcastWs;
226
239
  if (!_runWaterfallHook) _runWaterfallHook = _processWiring.runWaterfallHook;
240
+ if (!_onAgentLoopStart) _onAgentLoopStart = _processWiring.onAgentLoopStart;
241
+ if (!_onAgentLoopEnd) _onAgentLoopEnd = _processWiring.onAgentLoopEnd;
227
242
  }
228
243
 
229
244
  // Process-level SDK LAUNCH-CONFIG template (2026-10-12). cli.js registers it once at boot
@@ -402,7 +417,18 @@ function _createSession(key, cwd) {
402
417
  // second Stop would silently no-op).
403
418
  let _settleGraceTimer = null; // backstop after interrupt: force-settle if no result arrives
404
419
  let _lostResultTimer = null; // lost-result backstop handle (cleared on turn settle/teardown)
405
- let _lateResultTombstones = 0; // results to discard (already force-settled via grace/watchdog)
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)
406
432
  let _consecutiveDeaths = 0; // backstop against respawn storms in a broken environment
407
433
  // Activity probe (replaces the old fixed 25min silence watchdog): re-armed on every
408
434
  // incoming SDK message. When a turn stays silent past ACTIVITY_PROBE_IDLE_MS it pings
@@ -414,6 +440,11 @@ function _createSession(key, cwd) {
414
440
  let _activityProbe = null;
415
441
  let _probeFailures = 0;
416
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;
417
448
  // Suspect generation: set when an interrupt grace expires with no result (the CLI never
418
449
  // answered our Stop) or a probe convicts the child. The wedged query is torn down LAZILY
419
450
  // — on the next push rather than at conviction time — so we never SIGTERM a child that
@@ -437,6 +468,12 @@ function _createSession(key, cwd) {
437
468
  // reconnect replay like _lastInitSnapshot; reset alongside it on switch/reset.
438
469
  let _lastSessionState = null;
439
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)
440
477
  let _lastContextUsage = null; // last sdk-context-usage frame for WS reconnect replay
441
478
  let _ctxUsageFetching = false; // one getContextUsage control request in flight
442
479
  let _ctxUsageQueued = false; // a turn-end/compact arrived while in flight → rerun once (coalesce)
@@ -514,9 +551,13 @@ function _createSession(key, cwd) {
514
551
  const rec = {
515
552
  id: 'turn_' + (++_turnSeq).toString(36) + '_' + Math.random().toString(36).slice(2, 8),
516
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
517
557
  apiRetry: { count: 0, attempt: 0, maxRetries: 0, lastStatus: null, lastError: null },
518
558
  };
519
559
  _pendingTurns.push(rec);
560
+ _lastTurnStartedAt = rec.startedAt;
520
561
  return { rec, promise };
521
562
  }
522
563
 
@@ -552,17 +593,38 @@ function _createSession(key, cwd) {
552
593
  * pending timer, and the counter is reset by a successful/answered probe (or a full
553
594
  * teardown/reset) — otherwise conviction at MAX_FAILURES would be unreachable. */
554
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.
555
598
  _clearTurnWatchdog();
556
599
  if (_pendingTurns.length === 0) return;
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
+ }
557
606
  _activityProbe = setTimeout(() => {
558
607
  _activityProbe = null;
559
608
  if (_pendingTurns.length === 0) return;
560
- if (_pendingApprovals.size > 0) { _armTurnWatchdog(); return; } // approval pending = alive
609
+ if (_pendingApprovals.size > 0) { _armTurnWatchdog(); return; } // approval parked mid-wait — stand down
561
610
  _runActivityProbe();
562
611
  }, _probeFailures === 0 ? ACTIVITY_PROBE_IDLE_MS : ACTIVITY_PROBE_INTERVAL_MS);
563
612
  if (_activityProbe.unref) _activityProbe.unref();
564
613
  }
565
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
+
566
628
  function _clearTurnWatchdog() {
567
629
  if (_activityProbe) { clearTimeout(_activityProbe); _activityProbe = null; }
568
630
  // Any arm/clear is triggered by observed activity (a message re-arms on every frame,
@@ -642,11 +704,15 @@ function _createSession(key, cwd) {
642
704
  // and leaving them would replay a ghost modal on attach AND suppress probing on the
643
705
  // rebuilt generation (size > 0 = "alive"). They time out / deny as their own path.
644
706
  _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++;
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];
648
712
  _settleAllTurns();
649
- _announceSessionState('idle');
713
+ for (const r of stuckRecs) _emitAgentLoopEnd(r, 'turn-stuck');
714
+ _announceSessionState('idle', stalledRec || null);
715
+ _clearActivity();
650
716
  // Retry is only possible if a session id was captured to resume — a first turn that
651
717
  // wedges before any init frame has nothing to continue, so don't offer [retry].
652
718
  const retryable = !!(_sessionId || _launchResume);
@@ -799,7 +865,10 @@ function _createSession(key, cwd) {
799
865
  // The CLI's own session_state_changed(running) arrives later and is idempotent
800
866
  // against this — on CLIs that never emit it (no env gate / older build) this is
801
867
  // the only "on" signal, paired with the result-idle broadcast in _processMessage.
802
- _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);
803
872
  _armTurnWatchdog();
804
873
  return { started: true, promise, turnId: rec.id };
805
874
  }
@@ -1078,7 +1147,9 @@ function _createSession(key, cwd) {
1078
1147
  // so a probe that timed out on the PREVIOUS quiet stretch cannot convict a turn
1079
1148
  // that has since demonstrably produced output.
1080
1149
  _probeFailures = 0;
1150
+ _inboundMsgSeq++;
1081
1151
  _armTurnWatchdog(); // reset the activity probe on observed activity
1152
+ _noteActivity(msg); // coalesced UI liveness (≤1/s sdk-session-state activity frame)
1082
1153
  }
1083
1154
  _onResidentDeath(query, inputQueue, null);
1084
1155
  } catch (err) {
@@ -1111,12 +1182,20 @@ function _createSession(key, cwd) {
1111
1182
  _clearGraceTimer();
1112
1183
  _clearTurnWatchdog();
1113
1184
  _clearStateHeartbeat();
1114
- _lateResultTombstones = 0;
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;
1115
1190
  const hadTurns = _pendingTurns.length > 0;
1116
1191
  const headTurn = _pendingTurns[0] || null;
1117
1192
  // Session died mid-turn: no result will arrive, so without this the spinner would
1118
- // hang forever. The turn is unambiguously over.
1119
- _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'); } }
1120
1199
  if (err && err.name !== 'AbortError') {
1121
1200
  console.error('[SDK] Resident query error:', err.message);
1122
1201
  _notifyQueryError(err);
@@ -1184,11 +1263,19 @@ function _createSession(key, cwd) {
1184
1263
  _clearTurnWatchdog();
1185
1264
  _probeFailures = 0;
1186
1265
  _clearStateHeartbeat();
1187
- _lateResultTombstones = 0;
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;
1188
1271
  // NOTE: _lastFailedTurn deliberately survives teardown — the lazy rebuild path
1189
1272
  // (_startTurn's suspect-generation teardown) must keep the recorded failure so the
1190
1273
  // manual [retry] affordance stays available. User-facing session boundaries clear it
1191
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'); }
1192
1279
  _settleAllTurns();
1193
1280
  if (inputQueue) inputQueue.close();
1194
1281
  if (query) {
@@ -1285,17 +1372,87 @@ function _createSession(key, cwd) {
1285
1372
  * Announce a turn-level session state to WS clients (spinner source in SDK mode).
1286
1373
  * State-change guarded + retained for reconnect replay. Broadcast failures are
1287
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.
1288
1381
  */
1289
- function _announceSessionState(state) {
1382
+ function _announceSessionState(state, rec = null) {
1290
1383
  const valid = state === 'idle' || state === 'running' || state === 'requires_action';
1291
1384
  if (!valid || state === _lastSessionState) return;
1292
1385
  _lastSessionState = state;
1386
+ const owner = rec || _pendingTurns[0] || null;
1293
1387
  // _broadcast carries the routing envelope (cwd + sessionId + instanceKey) — every
1294
1388
  // frame needs it so concurrent sessions' clients filter correctly.
1295
- _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
+ });
1296
1394
  _syncStateHeartbeat();
1297
1395
  }
1298
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
+
1299
1456
  // The in-flight turn is the FIFO HEAD (the result branch shifts from the head).
1300
1457
  const _currentTurnId = () => (_pendingTurns.length ? _pendingTurns[0].id : null);
1301
1458
 
@@ -1309,9 +1466,11 @@ function _createSession(key, cwd) {
1309
1466
  if (active && !_stateHeartbeat) {
1310
1467
  _stateHeartbeat = setInterval(() => {
1311
1468
  if (_lastSessionState !== 'running' && _lastSessionState !== 'requires_action') return;
1469
+ const head = _pendingTurns[0] || null;
1312
1470
  _broadcast({
1313
1471
  type: 'sdk-session-state', state: _lastSessionState, heartbeat: true,
1314
- turnId: _currentTurnId(), pendingApprovals: _pendingApprovals.size,
1472
+ turnId: _currentTurnId(), turnStartedAt: head ? head.startedAt : _lastTurnStartedAt,
1473
+ pendingApprovals: _pendingApprovals.size,
1315
1474
  });
1316
1475
  }, STATE_HEARTBEAT_MS);
1317
1476
  if (_stateHeartbeat.unref) _stateHeartbeat.unref();
@@ -1341,24 +1500,42 @@ function _createSession(key, cwd) {
1341
1500
  // which cli.js injects into the child env; on CLIs that never emit it the
1342
1501
  // push/result fallbacks keep the spinner correct at result granularity.
1343
1502
  if (msg.subtype === 'session_state_changed') {
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();
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
+ }
1356
1530
  } else if (msg.subtype === 'api_retry') {
1357
1531
  // The CLI's own retry of a retryable API error (502/429/5xx/network). It carries
1358
1532
  // 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) {
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) {
1362
1539
  const rec = _pendingTurns.length ? _pendingTurns[0] : null;
1363
1540
  if (rec) {
1364
1541
  rec.apiRetry.count += 1;
@@ -1443,26 +1620,49 @@ function _createSession(key, cwd) {
1443
1620
 
1444
1621
  case 'result': {
1445
1622
  if (msg.session_id) _sessionId = msg.session_id;
1446
- // Grace/watchdog tombstone: a result that arrives AFTER its turn was already
1447
- // force-settled belongs to that dead turn — discard it instead of letting it
1448
- // settle (and prematurely end) the NEXT turn. If the tombstone outlived its turn
1449
- // entirely (the owed result never comes) the count would swallow a FUTURE turn's
1450
- // result — the bookkeeping clear below keeps that mistake bounded to the
1451
- // turn-end signal rather than wedging _queryBusy.
1452
- if (_lateResultTombstones > 0) {
1453
- _lateResultTombstones--;
1454
- _clearGraceTimer();
1455
- _queryBusy = false;
1456
- _turnInFlight = false;
1457
- // The tombstoned turn is over — but a NEWER turn may already be in flight
1458
- // (Stop → grace settle → user re-sent → late result of the old turn arrives).
1459
- // Only announce idle when no turn is pending, else we'd kill turn 2's spinner.
1460
- if (_pendingTurns.length === 0) {
1461
- _announceSessionState('idle');
1462
- _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;
1463
1662
  }
1464
- _maybeDrain();
1465
- 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;
1466
1666
  }
1467
1667
  const rec = _pendingTurns.shift();
1468
1668
  // 502/retry state machine. An error result after observed CLI retries records a
@@ -1496,7 +1696,12 @@ function _createSession(key, cwd) {
1496
1696
  // in CLI mode — fires once per user-prompt response when the whole chain
1497
1697
  // (assistant text + all tool calls + final reply) completes. SDK mode
1498
1698
  // doesn't go through ensureHooks() so this in-process callback is the
1499
- // 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.
1500
1705
  if (_onTurnEnd) {
1501
1706
  // Per-workspace (2026-10): carry the session's own cwd so the server
1502
1707
  // can route turn_end to THIS workspace's SSE scope (SDK mode has no
@@ -1505,13 +1710,23 @@ function _createSession(key, cwd) {
1505
1710
  catch (err) { console.warn('[sdk-manager] onTurnEnd threw:', err?.message); }
1506
1711
  }
1507
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
+ }
1508
1720
  _clearGraceTimer();
1721
+ _clearActivity();
1509
1722
  _queryBusy = false;
1510
1723
  _turnInFlight = false;
1511
1724
  // Turn-level spinner clear at result time — immediate, unlike the debounced
1512
1725
  // `turn_end` SSE (voice-pack semantics). Idempotent against a real
1513
- // session_state_changed(idle) arriving before/after.
1514
- _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);
1515
1730
  if (_pendingTurns.length === 0) _clearTurnWatchdog();
1516
1731
  _maybeDrain();
1517
1732
  // Refresh the context-usage panel AFTER the turn fully settles (fire-and-forget,
@@ -1988,6 +2203,7 @@ function _createSession(key, cwd) {
1988
2203
  // Close the modal on every client — a silent deny would leave it hanging forever.
1989
2204
  _broadcastApprovalDismiss(kind, id, 'timeout');
1990
2205
  resolve(null);
2206
+ _rearmWatchdogAfterApprovalSettled();
1991
2207
  }, timeoutMs);
1992
2208
  const entry = {
1993
2209
  kind, // 'ask' | 'plan' | 'perm' — lets cancelApproval distinguish types so an
@@ -2006,6 +2222,7 @@ function _createSession(key, cwd) {
2006
2222
  if (cur?.auto) cancelAutoApproveState(cur.auto);
2007
2223
  _pendingApprovals.delete(id);
2008
2224
  resolve(value);
2225
+ _rearmWatchdogAfterApprovalSettled();
2009
2226
  },
2010
2227
  };
2011
2228
  _pendingApprovals.set(id, entry);
@@ -2149,14 +2366,16 @@ function _createSession(key, cwd) {
2149
2366
  const rec = _pendingTurns[0];
2150
2367
  if (!rec || !rec.interrupted) return;
2151
2368
  console.warn('[sdk-manager] no result within interrupt grace — settling the turn and marking the generation suspect');
2152
- _lateResultTombstones++;
2369
+ _lateResultEpoch = rec.epoch; // discard this generation's late result, never a later turn's
2153
2370
  _pendingTurns.shift();
2154
2371
  _settleTurn(rec);
2372
+ _emitAgentLoopEnd(rec, 'interrupt-timeout');
2155
2373
  _queryBusy = false;
2156
2374
  _suspectGeneration = true;
2157
2375
  // The CLI never answered the interrupt — no result is coming, so clear the
2158
2376
  // spinner here rather than waiting on a dead signal.
2159
- _announceSessionState('idle');
2377
+ _announceSessionState('idle', rec);
2378
+ _clearActivity();
2160
2379
  if (_pendingTurns.length === 0) _clearTurnWatchdog();
2161
2380
  _maybeDrain();
2162
2381
  }, INTERRUPT_GRACE_MS);
@@ -2174,17 +2393,32 @@ function _createSession(key, cwd) {
2174
2393
  // backstop from turn A must never settle turn B (which may be parked on the SAME idle
2175
2394
  // state). The interrupt-grace path owns user-cancelled turns — skip those here.
2176
2395
  const armedRec = _pendingTurns[0] || null;
2396
+ const armedSeq = _inboundMsgSeq; // any message arriving after arming bumps this
2177
2397
  _lostResultTimer = setTimeout(() => {
2178
2398
  _lostResultTimer = null;
2179
2399
  const rec = _pendingTurns[0];
2180
2400
  if (!rec || rec !== armedRec || rec.interrupted) return; // stale arm / different turn / user-cancelled
2181
2401
  if (_queryBusy === false) return;
2182
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;
2183
2412
  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
2413
+ // Mark the head turn's epoch as owing a result: without it a late result would
2185
2414
  // consume the NEXT turn's FIFO record (settling it early and mispairing turn-end).
2186
- _lateResultTombstones++;
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];
2187
2419
  _settleAllTurns();
2420
+ _clearActivity();
2421
+ for (const r of lostRecs) _emitAgentLoopEnd(r, 'lost-result');
2188
2422
  const retryable = !!(_sessionId || _launchResume);
2189
2423
  _lastFailedTurn = {
2190
2424
  turnId: rec.id || null, retryable, apiStatus: rec.apiRetry?.lastStatus ?? null,
@@ -2424,10 +2658,12 @@ function _createSession(key, cwd) {
2424
2658
  if (_lastSessionState && _lastSessionState !== 'idle') _announceSessionState('idle');
2425
2659
  _lastSessionState = null;
2426
2660
  _clearStateHeartbeat();
2661
+ _clearActivity();
2662
+ _lastTurnStartedAt = 0;
2427
2663
  _clearContextUsage('session-switched');
2428
2664
  _clearMcpStatus('session-switched');
2429
2665
  _pendingTurns = [];
2430
- _lateResultTombstones = 0;
2666
+ _lateResultEpoch = 0; // session boundary: a stale debt must not follow into the new session
2431
2667
  _consecutiveDeaths = 0;
2432
2668
  _suspectGeneration = false;
2433
2669
  _lastFailedTurn = null;
@@ -2483,7 +2719,8 @@ function _createSession(key, cwd) {
2483
2719
  getSdkInitSnapshot: () => _lastInitSnapshot,
2484
2720
  getSessionStateSnapshot: () => {
2485
2721
  if (!_lastSessionState) return null;
2486
- return { type: 'sdk-session-state', state: _lastSessionState, sessionId: _sessionId || null, cwd: _cwd, instanceKey, turnId: _currentTurnId(), pendingApprovals: _pendingApprovals.size };
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 };
2487
2724
  },
2488
2725
  getContextUsageSnapshot: () => _lastContextUsage,
2489
2726
  getMcpStatusSnapshot: () => _lastMcpStatus,
@@ -2619,6 +2856,8 @@ export function __isEphemeralHostForTests(cwd) {
2619
2856
  * @param {function} deps.broadcastWs — (msg) => void, terminal-WS broadcast for approvals
2620
2857
  * @param {string} [deps.permissionMode]
2621
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
2622
2861
  * @param {object} [deps.env] — child env (must already contain ANTHROPIC_BASE_URL → ccv proxy)
2623
2862
  * @param {object} [deps.settings] — merged Settings object (env.ANTHROPIC_BASE_URL double-injection)
2624
2863
  * @param {string} [deps.model] — --model lifted from user args (options.model)
@@ -2628,7 +2867,7 @@ export function __isEphemeralHostForTests(cwd) {
2628
2867
  * must come from the same selection as PTY mode, else the SDK spawns a PATH claude that
2629
2868
  * host security tooling (e.g. agent-security headless guards) may SIGKILL on spawn
2630
2869
  */
2631
- 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 } = {}) {
2632
2871
  void projectName; // reserved (display name comes from the wire path)
2633
2872
  // Process-global callbacks (cli.js registers one wiring; every session reuses it). Only
2634
2873
  // OVERWRITE when a callback is actually passed — a later per-workspace init (the launch
@@ -2638,6 +2877,8 @@ export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broa
2638
2877
  if (onQueryError !== undefined) _onQueryError = onQueryError || null;
2639
2878
  if (broadcastWs !== undefined) _broadcastWs = broadcastWs;
2640
2879
  if (runWaterfallHook !== undefined) _runWaterfallHook = runWaterfallHook || null;
2880
+ if (onAgentLoopStart !== undefined) _onAgentLoopStart = onAgentLoopStart || null;
2881
+ if (onAgentLoopEnd !== undefined) _onAgentLoopEnd = onAgentLoopEnd || null;
2641
2882
  // Restore any callback stopSession() cleared from the durable process wiring (cli.js's
2642
2883
  // registration). A workspace stop→relaunch omits these callbacks, so without this every
2643
2884
  // sdk-* frame would silently stop broadcasting after returning to the workspace list.
@@ -2819,6 +3060,8 @@ export function stopSession() {
2819
3060
  _onQueryError = null;
2820
3061
  _broadcastWs = null;
2821
3062
  _runWaterfallHook = null;
3063
+ _onAgentLoopStart = null;
3064
+ _onAgentLoopEnd = null;
2822
3065
  }
2823
3066
 
2824
3067
  /**