cc-viewer 1.9.14 → 1.9.16

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.
Files changed (57) hide show
  1. package/cli.js +1 -0
  2. package/concepts/ar/UltraPlan.md +9 -9
  3. package/concepts/da/UltraPlan.md +9 -9
  4. package/concepts/de/UltraPlan.md +9 -9
  5. package/concepts/en/UltraPlan.md +9 -9
  6. package/concepts/es/UltraPlan.md +9 -9
  7. package/concepts/fr/UltraPlan.md +9 -9
  8. package/concepts/it/UltraPlan.md +9 -9
  9. package/concepts/ja/UltraPlan.md +9 -9
  10. package/concepts/ko/UltraPlan.md +9 -9
  11. package/concepts/no/UltraPlan.md +9 -9
  12. package/concepts/pl/UltraPlan.md +9 -9
  13. package/concepts/pt-BR/UltraPlan.md +9 -9
  14. package/concepts/ru/UltraPlan.md +9 -9
  15. package/concepts/th/UltraPlan.md +9 -9
  16. package/concepts/tr/UltraPlan.md +9 -9
  17. package/concepts/uk/UltraPlan.md +9 -9
  18. package/concepts/zh/UltraPlan.md +9 -9
  19. package/concepts/zh-TW/UltraPlan.md +9 -9
  20. package/dist/assets/App-BvVdj099.css +1 -0
  21. package/dist/assets/App-CYdOioAz.js +2 -0
  22. package/dist/assets/{MdxEditorPanel-DI3B4gsM.js → MdxEditorPanel-CuCAe1OG.js} +1 -1
  23. package/dist/assets/Mobile-CfNYOgRa.js +1 -0
  24. package/dist/assets/{ProxyStatsModal-B8K0AtAq.js → ProxyStatsModal-Dhj4EVnh.js} +1 -1
  25. package/dist/assets/index-sgW8MlD3.js +2 -0
  26. package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
  27. package/dist/assets/seqResourceLoaders-Cj0pdz-l.js +2 -0
  28. package/dist/index.html +1 -1
  29. package/node_modules/@ccv/core/src/contentFilter.js +3 -0
  30. package/node_modules/@ccv/core/src/queued-prompt.js +66 -0
  31. package/node_modules/@ccv/core/src/v2-transcript-normalizer.js +78 -14
  32. package/package.json +1 -1
  33. package/server/lib/ask/perm-bridge.js +4 -1
  34. package/server/lib/auto-approve.js +160 -0
  35. package/server/lib/chat-queue.js +350 -120
  36. package/server/lib/file-access-policy.js +24 -2
  37. package/server/lib/scheduled-task-executor.js +3 -1
  38. package/server/lib/sdk-manager.js +377 -76
  39. package/server/lib/v2/conversation-store.js +15 -11
  40. package/server/lib/v2/v2-writer.js +1 -1
  41. package/server/lib/view-root.js +4 -4
  42. package/server/pty-manager.js +8 -8
  43. package/server/routes/ask-perm.js +54 -3
  44. package/server/routes/files-content.js +352 -16
  45. package/server/routes/files-fs.js +122 -36
  46. package/server/routes/resume.js +1 -1
  47. package/server/routes/sdk-context.js +13 -2
  48. package/server/routes/skills.js +19 -8
  49. package/server/server.js +205 -34
  50. package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
  51. package/ultraAgents/code-expert.json +1 -1
  52. package/dist/assets/App-BzV1ieDH.js +0 -2
  53. package/dist/assets/App-DU5jmGrx.css +0 -1
  54. package/dist/assets/Mobile-qBkAnXy3.js +0 -1
  55. package/dist/assets/index-CU0gt6m1.js +0 -2
  56. package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
  57. package/dist/assets/seqResourceLoaders-nGCs11zu.js +0 -2
@@ -35,6 +35,13 @@ import { resolve as _pathResolve, dirname as _pathDirname, sep as _pathSep } fro
35
35
  import { createHash, randomBytes } from 'node:crypto';
36
36
  import { registerSdkSessionLister } from './sdk-session-index.js';
37
37
  import { readPrefsRaw } from './prefs-store.js';
38
+ import { projectKeyForCwd } from './system-prompt-snapshots.js';
39
+ import {
40
+ computeAutoApprove,
41
+ createAutoApproveState,
42
+ cancelAutoApproveState,
43
+ replayAutoApproveFields,
44
+ } from './auto-approve.js';
38
45
 
39
46
  let _query;
40
47
  try {
@@ -94,6 +101,12 @@ function _matchesProject(sess, project) {
94
101
  return sanitized === project || rawBase === project;
95
102
  }
96
103
 
104
+ // A project stamp matches a session when it names the session's exact normalized cwd
105
+ // (absolute-path form) or its sanitized/raw basename (the web client's usual form).
106
+ function _sessionMatchesProject(sess, project) {
107
+ return sess.key === _normalizeCwd(project) || _matchesProject(sess, project);
108
+ }
109
+
97
110
  // Resolve an anchor { instanceKey|cwd|project } to a Session WITHOUT switching active (read ops).
98
111
  // `cwd` is an absolute path (exact key match); `project` is the SANITIZED project name the
99
112
  // web client sends (ChatView stamps `viewProject || projectName`, where projectName =
@@ -107,13 +120,20 @@ function _sessionForAnchor(anchor) {
107
120
  const cwd = (typeof anchor.cwd === 'string' && anchor.cwd) || null;
108
121
  const project = (typeof anchor.project === 'string' && anchor.project) || null;
109
122
  if (instanceKey) {
123
+ let byInstance = null;
110
124
  for (const s of _sessions.values()) {
111
- if (s.instanceKey === instanceKey) return s;
125
+ if (s.instanceKey === instanceKey) { byInstance = s; break; }
112
126
  }
113
127
  // An exact instance that no longer exists must never fall back to a
114
128
  // same-basename sibling — that would execute the user's action in the
115
129
  // wrong workspace. The caller's unknown-anchor path drops + reports it.
116
- return null;
130
+ if (!byInstance) return null;
131
+ // The web client stamps project and instance TOGETHER. A stale/mismatched pair
132
+ // must not let instance priority execute/read in a project other than the one
133
+ // the viewer named — resolve the pair to null instead (never fall through to
134
+ // project-only resolution, which could pick an unrelated sibling).
135
+ if (project && !_sessionMatchesProject(byInstance, project)) return null;
136
+ return byInstance;
117
137
  }
118
138
  if (cwd) return _sessions.get(_normalizeCwd(cwd)) || null;
119
139
  if (!project) return null;
@@ -239,6 +259,12 @@ function _userMessage(text) {
239
259
  };
240
260
  }
241
261
 
262
+ // Bare "/clear" is the CLI's context-reset command; arguments/case variants are not the
263
+ // reset command. Trim only surrounding whitespace (composer newlines/padding).
264
+ function _isBareClearCommand(text) {
265
+ return typeof text === 'string' && text.trim() === '/clear';
266
+ }
267
+
242
268
  /**
243
269
  * Normalize an SDK `task_*` system message into the camelCase relay payload
244
270
  * broadcast as `{ type: 'sdk-task', ... }`. Lossless pass-through (snake→camel
@@ -357,10 +383,15 @@ function _createSession(key, cwd) {
357
383
  let _lastContextUsage = null; // last sdk-context-usage frame for WS reconnect replay
358
384
  let _ctxUsageFetching = false; // one getContextUsage control request in flight
359
385
  let _ctxUsageQueued = false; // a turn-end/compact arrived while in flight → rerun once (coalesce)
360
- // Generation counter for the in-flight gate: _clearContextUsage bumps it so a stale
361
- // request's .finally can't clear the flag of a NEWER in-flight request started after a
362
- // session switch (the flag is only reset by the generation that set it).
386
+ // Generation counter for the in-flight gate AND stale-refresh suppression:
387
+ // _clearContextUsage bumps it so (a) a stale request's .finally can't clear the flag of a
388
+ // NEWER in-flight request started after a clear/switch (the flag is only reset by the
389
+ // generation that set it) and (b) a request that resolves after a clear cannot retain or
390
+ // broadcast its pre-clear snapshot.
363
391
  let _ctxUsageSeq = 0;
392
+ let _ctxUsageClearEpoch = 0;
393
+ let _ctxUsageClearPending = false;
394
+ let _ctxUsageStartSeq = 0;
364
395
  // Last sdk-mcp-status frame for WS reconnect replay. mcpServerStatus() is on-demand only
365
396
  // (HTTP pull + reconnect replay + clear-on-switch) — unlike context usage it is NOT
366
397
  // turn-correlated, so it gets no turn-end/compact trigger and none of the in-flight gate
@@ -375,6 +406,14 @@ function _createSession(key, cwd) {
375
406
  // the id lets web clients act on individual queued bubbles (send-now / remove) and the
376
407
  // queue-state WS broadcast keeps every client in sync (server is the source of truth).
377
408
  let _messageQueue = [];
409
+ // Single in-transit slot between the queued FIFO and an ACCEPTED turn. An item is moved
410
+ // here before the interrupt (busy send-now) or before the drain start, so it is no longer
411
+ // actionable but stays visible in the snapshot as status: 'dispatching' (disabled bubble)
412
+ // until _startTurn commits it with a successful inputQueue.push. A pre-push failure puts
413
+ // it back at the queue head; a pushed message is never requeued, even if its turn fails.
414
+ let _dispatching = null;
415
+ let _dispatchingStarting = false; // _startTurn is running for _dispatching (pre-push)
416
+ let _dispatchAttempts = 0;
378
417
  // Stop semantics (product decision): Stop parks the queue instead of dropping it —
379
418
  // interruptTurn sets this flag so sendUserMessage's drain loop exits without running the
380
419
  // parked items; cleared when a fresh user message / send-now starts a new turn.
@@ -481,11 +520,11 @@ function _createSession(key, cwd) {
481
520
 
482
521
  // If a turn is already running, queue this message and return
483
522
  if (_queryBusy) {
484
- if (_messageQueue.length >= MAX_QUEUE_ITEMS) {
523
+ if (_messageQueue.length + (_dispatching ? 1 : 0) >= MAX_QUEUE_ITEMS) {
485
524
  // Negative ack: the frontend cleared the composer optimistically — never fail
486
525
  // silently. Only the reject frame goes out (no queue-state — nothing changed).
487
526
  console.warn('[sdk-manager] queue full, rejecting message');
488
- try { if (_broadcastWs) _broadcastWs({ type: 'queue-rejected', reason: 'full', cwd: _cwd, sessionId: _sessionId || null, instanceKey }); }
527
+ try { if (_broadcastWs) _broadcastWs({ type: 'queue-rejected', reason: 'full', cwd: _cwd, project: projectKeyForCwd(_cwd) || undefined, sessionId: _sessionId || null, instanceKey }); }
489
528
  catch (err) { reportSwallowed('sdk-manager.reject-broadcast', err); }
490
529
  return;
491
530
  }
@@ -497,23 +536,41 @@ function _createSession(key, cwd) {
497
536
  // A fresh user-initiated turn re-arms automatic draining of parked (Stop-kept) items.
498
537
  _suppressDrain = false;
499
538
  _consecutiveDeaths = 0; // explicit user action re-arms the death-drain backstop
500
- await _startTurn(text);
539
+ const { promise } = await _startTurn(text);
540
+ return promise;
541
+ }
542
+
543
+ /** Queue snapshot. The optional dispatching item is listed first (it has been selected
544
+ * as the next turn) and carries status 'dispatching' until _startTurn accepts it; queued
545
+ * items carry status 'queued'. Both the broadcast and the reconnect snapshot use this
546
+ * one shape so every client converges on the same actionable set. */
547
+ function _getQueueSnapshot() {
548
+ const items = [];
549
+ if (_dispatching) items.push({ id: _dispatching.id, text: _dispatching.text, ts: _dispatching.ts, status: 'dispatching' });
550
+ for (const item of _messageQueue) items.push({ id: item.id, text: item.text, ts: item.ts, status: 'queued' });
551
+ return items;
501
552
  }
502
553
 
503
554
  /** Broadcast the queue snapshot to all terminal-WS clients (drives the floating bubbles). */
504
555
  function _broadcastQueueState() {
505
556
  if (!_broadcastWs) return;
506
557
  try {
507
- _broadcastWs({ type: 'queue-state', cwd: _cwd, sessionId: _sessionId || null, instanceKey, items: _messageQueue.map(({ id, text, ts }) => ({ id, text, ts })) });
558
+ _broadcastWs({ type: 'queue-state', cwd: _cwd, project: projectKeyForCwd(_cwd) || undefined, sessionId: _sessionId || null, instanceKey, items: _getQueueSnapshot() });
508
559
  } catch (err) { console.warn('[sdk-manager] queue-state broadcast threw:', err?.message); }
509
560
  }
510
561
 
511
562
  /**
512
563
  * Start one turn: park a turn record, ensure the resident query exists, push the
513
- * message. Resolves when the turn's result arrives (or on teardown/death settle).
564
+ * message. Returns { started, promise }:
565
+ * - started: true only once the message was ACCEPTED by inputQueue.push (the commit
566
+ * point). False means construction/teardown/push failed before delivery, so a
567
+ * dispatching caller restores its queued item instead of treating it as sent.
568
+ * - promise: resolves when the turn settles (result / teardown / death). It never
569
+ * rejects; a later turn failure must NOT put an already-pushed message back.
514
570
  */
515
- async function _startTurn(text) {
571
+ async function _startTurn(text, dispatch = null) {
516
572
  const { rec, promise } = _beginTurn();
573
+ if (dispatch) rec.dispatchItem = dispatch;
517
574
  _queryBusy = true;
518
575
  try {
519
576
  await _ensureResidentQuery();
@@ -523,17 +580,41 @@ function _createSession(key, cwd) {
523
580
  _settleTurn(rec);
524
581
  _queryBusy = false;
525
582
  _notifyQueryError(err);
526
- return promise;
583
+ return { started: false, promise };
527
584
  }
528
585
  // Teardown/stop raced with construction — the settle already happened; don't push.
529
- if (rec.settled) return promise;
530
- if (!_activeQuery || !_inputQueue) {
586
+ if (rec.settled || (dispatch && _dispatching !== dispatch)) {
587
+ if (!rec.settled) {
588
+ _pendingTurns = _pendingTurns.filter((turn) => turn !== rec);
589
+ _settleTurn(rec);
590
+ if (_dispatching !== dispatch) _queryBusy = false;
591
+ }
592
+ return { started: false, promise };
593
+ }
594
+ if (!_activeQuery || !_inputQueue || (typeof _inputQueue.isClosed === 'function' && _inputQueue.isClosed()) || (dispatch && _dispatching !== dispatch)) {
595
+ _pendingTurns = _pendingTurns.filter((turn) => turn !== rec);
596
+ _settleTurn(rec);
597
+ _queryBusy = false;
598
+ return { started: false, promise };
599
+ }
600
+ // Typed /clear is detected at EXECUTION time (never at enqueue time): a clear queued
601
+ // behind a busy turn must not wipe the running turn's usage panel. Invalidate the
602
+ // retained snapshot before the CLI sees the command; the /clear turn's own result then
603
+ // refreshes it with the post-clear numbers.
604
+ if (_isBareClearCommand(text)) _clearContextUsage('context-cleared');
605
+ try {
606
+ // The push below is the acceptance point watched by the dispatching slot. The push
607
+ // queue itself never throws (a closed queue silently drops), and teardown nulls the
608
+ // handles synchronously before closing, so the checks above plus this call are the
609
+ // complete pre-push failure surface.
610
+ _inputQueue.push(_userMessage(text));
611
+ } catch (err) {
531
612
  _pendingTurns = _pendingTurns.filter((r) => r !== rec);
532
613
  _settleTurn(rec);
533
614
  _queryBusy = false;
534
- return promise;
615
+ reportSwallowed('sdk-manager.queue-push', err);
616
+ return { started: false, promise };
535
617
  }
536
- _inputQueue.push(_userMessage(text));
537
618
  _turnInFlight = true;
538
619
  // Turn-level loading fallback: light the spinner the moment the turn is pushed.
539
620
  // The CLI's own session_state_changed(running) arrives later and is idempotent
@@ -541,7 +622,7 @@ function _createSession(key, cwd) {
541
622
  // the only "on" signal, paired with the result-idle broadcast in _processMessage.
542
623
  _announceSessionState('running');
543
624
  _armTurnWatchdog();
544
- return promise;
625
+ return { started: true, promise };
545
626
  }
546
627
 
547
628
  /**
@@ -828,6 +909,14 @@ function _createSession(key, cwd) {
828
909
  if (_activeQuery !== query) return; // stale generation — teardown already moved on
829
910
  _activeQuery = null;
830
911
  _inputQueue = null;
912
+ if (_ctxUsageClearPending) {
913
+ // A child that exits while applying /clear must not resume the cleared session on the
914
+ // next message. Start fresh, allow the next init/read, and invalidate older controls.
915
+ _ctxUsageClearPending = false;
916
+ _ctxUsageClearEpoch++;
917
+ _sessionId = null;
918
+ _launchResume = null;
919
+ }
831
920
  _clearGraceTimer();
832
921
  _clearTurnWatchdog();
833
922
  _lateResultTombstones = 0;
@@ -839,6 +928,15 @@ function _createSession(key, cwd) {
839
928
  _notifyQueryError(err);
840
929
  }
841
930
  _settleAllTurns();
931
+ // A dispatching item waiting on an interrupt unwind never reached the SDK. Death
932
+ // parks the backlog, so put it back at the queue head (actionable again) instead of
933
+ // leaving it locked in the slot; the drain below may immediately re-select it. Once
934
+ // _startTurn is running the item is mid-commit and must not be resurrected here.
935
+ if (_dispatching && !_dispatchingStarting) {
936
+ _messageQueue.unshift(_dispatching);
937
+ _dispatching = null;
938
+ _broadcastQueueState();
939
+ }
842
940
  // Flush pending approvals with a dismiss broadcast, else their modals hang forever.
843
941
  const pending = Array.from(_pendingApprovals, ([id, p]) => ({ id, kind: p.kind || null }));
844
942
  for (const { id, kind } of pending) {
@@ -894,13 +992,78 @@ function _createSession(key, cwd) {
894
992
  catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
895
993
  }
896
994
 
995
+ /**
996
+ * Start the dispatching item. The slot was claimed synchronously by the caller (auto
997
+ * drain or idle send-now), so repeated drains cannot start it twice. The item stays in
998
+ * the snapshot as 'dispatching' until _startTurn reports acceptance; if construction or
999
+ * the push fails before delivery it returns to the queue head, enabled again.
1000
+ */
1001
+ function _startDispatch() {
1002
+ const item = _dispatching;
1003
+ if (!item || _dispatchingStarting) return;
1004
+ _dispatchingStarting = true;
1005
+ _dispatchAttempts += 1;
1006
+ _startTurn(item.text, item).then(({ started, promise }) => {
1007
+ // A reset/clear that superseded this dispatch already swept the slot — never
1008
+ // resurrect its item across that boundary.
1009
+ if (_dispatching !== item) {
1010
+ // A superseded start may have cleared _queryBusy while a newer dispatch
1011
+ // is waiting. Resume only the current item, never the cancelled one.
1012
+ _maybeDrain();
1013
+ return;
1014
+ }
1015
+ _dispatchingStarting = false;
1016
+ if (!started) {
1017
+ // A pre-push failure has not committed this item. Retry the SAME slot once
1018
+ // without depending on an unrelated legacy turn's busy flag or a result.
1019
+ if (_dispatchAttempts < 2 && !_suppressDrain && !_switching) {
1020
+ _broadcastQueueState();
1021
+ _startDispatch();
1022
+ return;
1023
+ }
1024
+ _dispatching = null;
1025
+ _dispatchAttempts = 0;
1026
+ _messageQueue.unshift(item);
1027
+ _broadcastQueueState();
1028
+ return;
1029
+ }
1030
+ _dispatching = null;
1031
+ // Push accepted: the item is now the running turn, not a queued bubble. Clearing
1032
+ // the slot here also means a later turn failure (error result / interrupt / death)
1033
+ // can never requeue it.
1034
+ _broadcastQueueState();
1035
+ _dispatchAttempts = 0;
1036
+ _consecutiveDeaths = 0; // only an accepted push re-arms the death-drain backstop
1037
+ // The turn may already have settled inside the same microtask window (fast fake or
1038
+ // CLI); continue the queue now when the normal result-driven drain could not.
1039
+ _maybeDrain();
1040
+ promise.catch((err) => reportSwallowed('sdk-manager.dispatch-turn', err));
1041
+ }).catch((err) => {
1042
+ if (_dispatching !== item) {
1043
+ // A superseded start may have cleared _queryBusy while a newer dispatch
1044
+ // is waiting. Resume only the current item, never the cancelled one.
1045
+ _maybeDrain();
1046
+ return;
1047
+ }
1048
+ _dispatchingStarting = false;
1049
+ _dispatching = null;
1050
+ _dispatchAttempts = 0;
1051
+ _messageQueue.unshift(item);
1052
+ _broadcastQueueState();
1053
+ reportSwallowed('sdk-manager.dispatch', err);
1054
+ });
1055
+ }
1056
+
897
1057
  /** Drain one queued message after a turn boundary (result-driven, not loop-driven). */
898
1058
  function _maybeDrain() {
899
- if (_suppressDrain || _queryBusy || _switching) return;
900
- if (_messageQueue.length === 0) return;
901
- const next = _messageQueue.shift();
902
- _broadcastQueueState();
903
- _sendUserMessage(next.text).catch((err) => reportSwallowed('sdk-manager.drain', err));
1059
+ if (_suppressDrain || _queryBusy || _switching || _dispatchingStarting) return;
1060
+ if (!_dispatching) {
1061
+ if (_messageQueue.length === 0) return;
1062
+ _dispatching = _messageQueue.shift();
1063
+ // Make the bubble visible-but-disabled while the turn is constructed/pushed.
1064
+ _broadcastQueueState();
1065
+ }
1066
+ _startDispatch();
904
1067
  }
905
1068
 
906
1069
  /**
@@ -935,6 +1098,9 @@ function _createSession(key, cwd) {
935
1098
  if (msg.subtype === 'session_state_changed') {
936
1099
  _announceSessionState(msg.state);
937
1100
  } else if (msg.subtype === 'init') {
1101
+ // /clear may reinitialize instead of returning a normal result. The new session is
1102
+ // now authoritative, so context reads may resume (without resuming the old session).
1103
+ if (_ctxUsageClearPending && msg.session_id) _ctxUsageClearPending = false;
938
1104
  if (_broadcastWs && Array.isArray(msg.slash_commands) && msg.slash_commands.length > 0
939
1105
  && msg.session_id !== _initAnnouncedSid) {
940
1106
  _initAnnouncedSid = msg.session_id;
@@ -1048,7 +1214,9 @@ function _createSession(key, cwd) {
1048
1214
  _maybeDrain();
1049
1215
  // Refresh the context-usage panel AFTER the turn fully settles (fire-and-forget,
1050
1216
  // never awaited in this message loop). Placement after settle/drain guarantees a
1051
- // synchronous throw here can't truncate turn settlement.
1217
+ // synchronous throw here can't truncate turn settlement. The CLI has now completed
1218
+ // the /clear turn, so a fresh read is allowed again.
1219
+ _ctxUsageClearPending = false;
1052
1220
  _refreshContextUsage('turn-end');
1053
1221
  break;
1054
1222
  }
@@ -1079,13 +1247,19 @@ function _createSession(key, cwd) {
1079
1247
  * Returns { ok:true, snapshot } | { ok:false, reason:'session-inactive'|'control-failed' }.
1080
1248
  * Never throws — the panel is best-effort; failures must not surface as sdk-error toasts.
1081
1249
  *
1082
- * Identity guard (not a null check): teardown nulls `_activeQuery`, but a subsequent user
1083
- * message lazily rebuilds it into a NEW query object. A late-resolving control promise from
1084
- * the dead generation must not broadcast that session's numbers into the new panel.
1250
+ * Identity/generation guards (not just a null check): teardown nulls `_activeQuery`, but a
1251
+ * subsequent user message lazily rebuilds it into a NEW query object. A late-resolving
1252
+ * control promise from the dead generation must not broadcast that session's numbers into
1253
+ * the new panel. `generation` additionally rejects a request that outlived a context clear
1254
+ * on the SAME query (typed /clear), so its pre-clear numbers are neither retained nor
1255
+ * returned. Manual pulls capture the same epoch, and every pull is newest-start-wins so
1256
+ * an older response cannot overwrite a newer turn-end snapshot.
1085
1257
  */
1086
- async function _collectContextUsage(trigger) {
1258
+ async function _collectContextUsage(trigger, generation, clearEpoch = _ctxUsageClearEpoch) {
1259
+ if (_ctxUsageClearPending) return { ok: false, reason: 'session-inactive' };
1087
1260
  const q = _activeQuery;
1088
1261
  if (!q || typeof q.getContextUsage !== 'function') return { ok: false, reason: 'session-inactive' };
1262
+ const startSeq = ++_ctxUsageStartSeq;
1089
1263
  let timeoutId;
1090
1264
  try {
1091
1265
  // SDK request() has no built-in timeout — race a bound so a live-but-unresponsive
@@ -1097,11 +1271,15 @@ function _createSession(key, cwd) {
1097
1271
  });
1098
1272
  const usage = await Promise.race([q.getContextUsage(), timeout]);
1099
1273
  if (_activeQuery !== q) return { ok: false, reason: 'session-inactive' }; // stale generation
1274
+ // Cleared/superseded while this request was in flight — the numbers describe the
1275
+ // PRE-clear context and must not land in the retained snapshot.
1276
+ if (generation != null && generation !== _ctxUsageSeq) return { ok: false, reason: 'session-inactive' };
1277
+ if (clearEpoch !== _ctxUsageClearEpoch || _ctxUsageClearPending || startSeq !== _ctxUsageStartSeq) return { ok: false, reason: 'session-inactive' };
1100
1278
  // Degenerate resolve (null/undefined/{}) — skip rather than broadcast a usage-less frame.
1101
1279
  if (!usage || typeof usage !== 'object' || Array.isArray(usage) || Object.keys(usage).length === 0) {
1102
1280
  return { ok: false, reason: 'control-failed' };
1103
1281
  }
1104
- _lastContextUsage = { type: 'sdk-context-usage', sessionId: _sessionId, ts: Date.now(), trigger, usage, cwd: _cwd };
1282
+ _lastContextUsage = { type: 'sdk-context-usage', sessionId: _sessionId, ts: Date.now(), trigger, usage, cwd: _cwd, instanceKey };
1105
1283
  return { ok: true, snapshot: _lastContextUsage };
1106
1284
  } catch (err) {
1107
1285
  reportSwallowed('sdk-manager.context-usage', err);
@@ -1120,8 +1298,12 @@ function _createSession(key, cwd) {
1120
1298
  function _refreshContextUsage(trigger) {
1121
1299
  if (_ctxUsageFetching) { _ctxUsageQueued = true; return; }
1122
1300
  _ctxUsageFetching = true;
1123
- const seq = ++_ctxUsageSeq; // capture this generation; a session switch bumps it
1124
- _collectContextUsage(trigger).then((r) => {
1301
+ const seq = ++_ctxUsageSeq; // capture this generation; a clear/session switch bumps it
1302
+ _collectContextUsage(trigger, seq).then((r) => {
1303
+ // A clear/switch landed while this request was in flight: its numbers are from the
1304
+ // old generation and must not reach clients (the snapshot write is guarded inside
1305
+ // _collectContextUsage for the same reason).
1306
+ if (seq !== _ctxUsageSeq) return;
1125
1307
  if (r.ok && _broadcastWs) {
1126
1308
  try { _broadcastWs(r.snapshot); }
1127
1309
  catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
@@ -1136,18 +1318,21 @@ function _createSession(key, cwd) {
1136
1318
  }
1137
1319
 
1138
1320
  /**
1139
- * Clear the usage snapshot on a session boundary (switch / full reset). Broadcasts a
1140
- * usage:null clear frame ONLY when a snapshot was present (an empty-to-empty broadcast
1141
- * would just be noise). The death path deliberately does NOT clear (same-session resume
1142
- * keeps the last known value, matching _lastInitSnapshot's death behavior).
1321
+ * Clear the usage snapshot on a context boundary (session switch / full reset / typed
1322
+ * /clear). A usage:null clear frame is emitted when a snapshot was present, and always
1323
+ * for an explicit /clear (a synchronization signal even when no snapshot existed).
1324
+ * The death path deliberately does NOT clear (same-session resume keeps the last known value,
1325
+ * matching _lastInitSnapshot's death behavior).
1143
1326
  */
1144
1327
  function _clearContextUsage(trigger) {
1145
1328
  const had = _lastContextUsage !== null;
1146
1329
  _lastContextUsage = null;
1147
1330
  _ctxUsageSeq++; // invalidate any in-flight generation so its finally can't clear a newer gate
1331
+ _ctxUsageClearEpoch++;
1332
+ _ctxUsageClearPending = trigger === 'context-cleared';
1148
1333
  _ctxUsageFetching = false;
1149
1334
  _ctxUsageQueued = false;
1150
- if (had && _broadcastWs) {
1335
+ if ((had || trigger === 'context-cleared') && _broadcastWs) {
1151
1336
  try { _broadcastWs({ type: 'sdk-context-usage', sessionId: _sessionId, ts: Date.now(), trigger, usage: null, cwd: _cwd, instanceKey }); }
1152
1337
  catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
1153
1338
  }
@@ -1310,11 +1495,24 @@ function _createSession(key, cwd) {
1310
1495
  }
1311
1496
  } catch (err) { reportSwallowed('sdk-manager.waterfall.plan', err); }
1312
1497
  }
1313
- const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), ...origin };
1498
+ // Server-side auto-approve countdown (armed from request arrival, wall-clock):
1499
+ // the pending frame carries the absolute deadline so clients render the remaining
1500
+ // time instead of arming their own full-length timer; the server resolves the
1501
+ // approval itself at deadline + 1s buffer when no client answered.
1502
+ // Unattended scheduled-task runs (ctx != null → origin 'scheduled-task') are exempt
1503
+ // from the server-side auto-approve fallback — with no human watching, an ephemeral
1504
+ // run must wait for a decision (5-min deny), symmetric to the npm-publish floor below.
1505
+ const planAuto = ctx ? { enabled: false } : computeAutoApprove('plan', _cwd);
1506
+ // Always stamp autoApproveSeconds (0 = server says OFF): the client's local pref
1507
+ // can disagree with the fork-resolved server value, and absence would make the
1508
+ // client fall back to its own countdown and defeat a fork-level off.
1509
+ const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin,
1510
+ autoApproveSeconds: planAuto.enabled ? planAuto.seconds : 0,
1511
+ ...(planAuto.enabled ? { autoApproveAt: planAuto.autoApproveAt } : {}) };
1314
1512
  if (_broadcastWs) {
1315
1513
  _broadcastWs(planPayload);
1316
1514
  }
1317
- const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal, ctx);
1515
+ const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal, ctx, planAuto);
1318
1516
  if (result === null) {
1319
1517
  return { behavior: 'deny', message: 'Timeout waiting for plan approval' };
1320
1518
  }
@@ -1344,7 +1542,7 @@ function _createSession(key, cwd) {
1344
1542
  // server/lib/ask/ask-constants.js.
1345
1543
  const askTimeoutMs = ASK_TIMEOUT_MS;
1346
1544
  const askStartedAt = Date.now();
1347
- const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: effSessionId(), ...origin };
1545
+ const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin };
1348
1546
  if (_broadcastWs) {
1349
1547
  _broadcastWs(askPayload);
1350
1548
  }
@@ -1420,12 +1618,18 @@ function _createSession(key, cwd) {
1420
1618
  // unknown decision → fall through to normal approval flow
1421
1619
  } catch (err) { reportSwallowed('sdk-manager.waterfall.perm', err); }
1422
1620
  }
1423
- const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: effSessionId(), ...origin };
1621
+ // npm publish is a hard safety floor: it must always wait for a HUMAN decision
1622
+ // (the 5-minute deny timeout applies), never the server-side auto-approve fallback.
1623
+ // Same exemption for unattended scheduled-task runs (ctx != null).
1624
+ const permAuto = (isPublish || ctx) ? { enabled: false } : computeAutoApprove('perm', _cwd);
1625
+ const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin,
1626
+ autoApproveSeconds: permAuto.enabled ? permAuto.seconds : 0,
1627
+ ...(permAuto.enabled ? { autoApproveAt: permAuto.autoApproveAt } : {}) };
1424
1628
  if (_broadcastWs) {
1425
1629
  _broadcastWs(permPayload);
1426
1630
  }
1427
1631
 
1428
- const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal, ctx);
1632
+ const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal, ctx, permAuto);
1429
1633
  if (result === null) {
1430
1634
  return { behavior: 'deny', message: 'Timeout waiting for user approval' };
1431
1635
  }
@@ -1466,30 +1670,52 @@ function _createSession(key, cwd) {
1466
1670
  } catch (err) { console.warn('[sdk-manager] approval-dismiss broadcast threw:', err?.message); }
1467
1671
  }
1468
1672
 
1469
- function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null, ctx = null) {
1673
+ function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null, ctx = null, autoMeta = null) {
1470
1674
  // Record the id on the ephemeral run's ctx ONLY when it actually parks (the three
1471
1675
  // callers below sit after every auto-allow/hard-deny early-return, so an auto-allowed
1472
1676
  // tool never lands here) — keeps ctx.approvalIds bounded (review P3: no unbounded growth).
1473
1677
  if (ctx && Array.isArray(ctx.approvalIds)) ctx.approvalIds.push(id);
1474
1678
  return new Promise((resolve) => {
1475
1679
  const timer = setTimeout(() => {
1680
+ const cur = _pendingApprovals.get(id);
1681
+ if (cur?.auto) cancelAutoApproveState(cur.auto);
1476
1682
  _pendingApprovals.delete(id);
1477
1683
  // Close the modal on every client — a silent deny would leave it hanging forever.
1478
1684
  _broadcastApprovalDismiss(kind, id, 'timeout');
1479
1685
  resolve(null);
1480
1686
  }, timeoutMs);
1481
- _pendingApprovals.set(id, {
1687
+ const entry = {
1482
1688
  kind, // 'ask' | 'plan' | 'perm' — lets cancelApproval distinguish types so an
1483
1689
  // ask-cancel does not collide with a plan/perm id
1484
1690
  replay, // exact broadcast payload announced to clients — replayed to a fresh WS connection
1485
1691
  startedAt: Date.now(),
1486
1692
  timeoutMs,
1693
+ // Durable auto-approve cancellation: a client cancel marks this and clears the
1694
+ // server fallback timer, the approval stays pending for a manual decision, and
1695
+ // the flag is replayed to fresh clients so they never re-arm the countdown.
1696
+ autoCancelled: false,
1697
+ auto: null,
1487
1698
  resolve: (value) => {
1488
1699
  clearTimeout(timer);
1700
+ const cur = _pendingApprovals.get(id);
1701
+ if (cur?.auto) cancelAutoApproveState(cur.auto);
1489
1702
  _pendingApprovals.delete(id);
1490
1703
  resolve(value);
1491
1704
  },
1492
- });
1705
+ };
1706
+ _pendingApprovals.set(id, entry);
1707
+ // Arm the server-side fallback AFTER the entry is in the map: instant (-1) fires
1708
+ // synchronously and resolves through the same first-wins wrapper.
1709
+ if (autoMeta?.enabled) {
1710
+ entry.auto = createAutoApproveState(autoMeta, () => {
1711
+ const p = _pendingApprovals.get(id);
1712
+ if (!p || p.autoCancelled) return;
1713
+ p.resolve(kind === 'plan' ? { approve: true, feedback: '' } : { decision: 'allow' });
1714
+ try {
1715
+ _broadcastWs?.({ type: kind === 'plan' ? 'sdk-plan-resolved' : 'perm-hook-resolved', id, reason: 'auto-approved', cwd: _cwd, sessionId: _sessionId || null, instanceKey });
1716
+ } catch (err) { reportSwallowed('sdk-manager.auto-approve-broadcast', err); }
1717
+ });
1718
+ }
1493
1719
  // CLI-side abort of the in-flight control request (e.g. interrupt while the modal is
1494
1720
  // open): settle through the same first-wins path as a timeout so the promise resolves
1495
1721
  // to deny and the modal closes everywhere. { once: true } prevents listener leaks.
@@ -1532,11 +1758,37 @@ function _createSession(key, cwd) {
1532
1758
  for (const [id, p] of _pendingApprovals) {
1533
1759
  const remaining = (p.timeoutMs ?? 0) - (now - (p.startedAt ?? now));
1534
1760
  if (remaining <= 0) continue;
1535
- out.push({ id, kind: p.kind || null, replay: p.replay || null, remainingMs: remaining });
1761
+ out.push({
1762
+ id, kind: p.kind || null, replay: p.replay || null, remainingMs: remaining,
1763
+ ...replayAutoApproveFields(p.auto, p.autoCancelled),
1764
+ });
1536
1765
  }
1537
1766
  return out;
1538
1767
  }
1539
1768
 
1769
+ /**
1770
+ * Cancel the SERVER-SIDE auto-approve countdown for a pending plan/perm approval
1771
+ * (client clicked the countdown-cancel button). Unlike cancelApproval this does NOT
1772
+ * resolve the approval — it stays pending for a manual decision; only the fallback
1773
+ * timer is destroyed and the cancelled flag is replayed to fresh clients so they
1774
+ * never re-arm. Returns true when the id matched a pending approval of that kind
1775
+ * (even on a repeat cancel — the state change is durable while pending); false when
1776
+ * nothing matched (caller falls back to the legacy relay-only behavior).
1777
+ */
1778
+ function _autoCancelApproval(id, kind) {
1779
+ // plan/perm only — ask approvals never arm a server auto-approve timer.
1780
+ if (kind !== 'plan' && kind !== 'perm') return false;
1781
+ const pending = _pendingApprovals.get(id);
1782
+ if (!pending || pending.kind !== kind) return false;
1783
+ const first = cancelAutoApproveState(pending);
1784
+ if (first && _broadcastWs) {
1785
+ try {
1786
+ _broadcastWs({ type: kind === 'plan' ? 'sdk-plan-auto-cancel' : 'perm-hook-auto-cancel', id, cwd: _cwd, sessionId: _sessionId || null, instanceKey });
1787
+ } catch (err) { reportSwallowed('sdk-manager.auto-cancel-broadcast', err); }
1788
+ }
1789
+ return true;
1790
+ }
1791
+
1540
1792
  /**
1541
1793
  * Cancel a pending canUseTool approval — used by ask-cancel WS handler
1542
1794
  * (user clicked Cancel button or typed-interrupt in input bar).
@@ -1619,8 +1871,14 @@ function _createSession(key, cwd) {
1619
1871
  * Returns the list of approvals that were pending at interrupt time
1620
1872
  * (`[{ id, kind }]`) so the caller (server.js) can broadcast modal-close
1621
1873
  * messages to every client. Always an array (empty when nothing was pending).
1874
+ *
1875
+ * `keepDispatch` is set only by send-now's own preemption: the dispatching item it
1876
+ * just selected must survive this interrupt. A user Stop (no options) parks the whole
1877
+ * backlog, so a selected-but-not-yet-started item returns to the queue head and its
1878
+ * bubble becomes actionable again. An item whose _startTurn is already running cannot
1879
+ * be recalled (the push is synchronous once construction resolves), so it stays put.
1622
1880
  */
1623
- function _interruptTurn() {
1881
+ function _interruptTurn({ keepDispatch = false } = {}) {
1624
1882
  const cancelled = _cancelAllApprovals();
1625
1883
  const q = _activeQuery;
1626
1884
  // Key off the sticky in-flight flag, not the FIFO: the grace/watchdog force-settle
@@ -1638,20 +1896,28 @@ function _createSession(key, cwd) {
1638
1896
  }
1639
1897
  _armSettleGrace();
1640
1898
  }
1899
+ if (_dispatching && !_dispatchingStarting && !keepDispatch) {
1900
+ _messageQueue.unshift(_dispatching);
1901
+ _dispatching = null;
1902
+ }
1641
1903
  _suppressDrain = true;
1642
1904
  _broadcastQueueState();
1643
1905
  return cancelled;
1644
1906
  }
1645
1907
 
1646
1908
  /**
1647
- * Send-now on a queued bubble: interrupt the running turn and make this message the next
1648
- * one executed. Unlike Stop, this does NOT suppress draining — the in-flight
1649
- * sendUserMessage loop picks the prioritized item up as soon as the abort settles
1650
- * (JS single-threading: the splice+unshift below is synchronous, so the drain's next
1651
- * iteration always sees it at the head).
1909
+ * Send-now on a queued bubble: move this message into the single dispatching slot and
1910
+ * make it the next turn. While busy it interrupts the running turn first, then the
1911
+ * result-driven drain starts the dispatching item as soon as the abort settles. While
1912
+ * idle (parked queue after Stop) it runs immediately. Both paths share _startDispatch's
1913
+ * lifecycle: the bubble stays in the snapshot as 'dispatching' (disabled) until
1914
+ * _startTurn accepts it; a pre-push failure restores it to the queue.
1652
1915
  *
1653
- * When no turn is running (parked queue after Stop), the message is executed immediately
1654
- * as a fresh turn.
1916
+ * Only ONE item may be in flight through that lifecycle: a second send-now (including a
1917
+ * repeat click on the same bubble) is rejected — it must not fire a second interrupt or
1918
+ * reorder the selected item. The current snapshot is re-broadcast so stale clients
1919
+ * converge on the disabled state. Removing the dispatching bubble is rejected for the
1920
+ * same reason; other queued bubbles remain removable.
1655
1921
  *
1656
1922
  * Known semantic: if the FIRST turn is interrupted before its system/init message was
1657
1923
  * processed, `_sessionId` is still null and the prioritized message starts a fresh session
@@ -1661,32 +1927,46 @@ function _createSession(key, cwd) {
1661
1927
  * close approval modals on all clients.
1662
1928
  */
1663
1929
  function _sendQueuedNow(id) {
1930
+ if (_dispatching) {
1931
+ // The slot already owns the interrupt/drain window. Re-broadcast for convergence
1932
+ // (a client may still render the bubble as actionable), but do not act twice.
1933
+ _broadcastQueueState();
1934
+ return [];
1935
+ }
1664
1936
  const idx = _messageQueue.findIndex((it) => it.id === id);
1665
- if (idx < 0) return []; // unknown / already dispatched — no-op BEFORE any interrupt
1937
+ if (idx < 0) {
1938
+ _broadcastQueueState();
1939
+ return []; // unknown / already dispatched — converge stale clients, never interrupt
1940
+ }
1666
1941
  const [item] = _messageQueue.splice(idx, 1);
1942
+ _dispatching = item;
1667
1943
  // Send-now ALWAYS means "run this now" — lift a Stop-park even when the aborted turn's
1668
- // unwind is still in flight (_queryBusy still true). Without this, the drain loop's
1669
- // `!_suppressDrain` condition would exit with the item already spliced out → silent loss.
1944
+ // unwind is still in flight (_queryBusy still true). Without this, the drain's
1945
+ // `!_suppressDrain` condition would exit with the item parked in the slot → silent loss.
1670
1946
  _suppressDrain = false;
1671
1947
 
1672
- if (!_queryBusy) {
1673
- // Parked queue, idle session: run it now (broadcast happens via the drain path —
1674
- // here we broadcast explicitly since the item left the queue).
1675
- _broadcastQueueState();
1676
- // Fire-and-forget matches sendUserMessage's existing call sites (WS handler does not await).
1677
- _sendUserMessage(item.text).catch((err) => console.warn('[sdk-manager] send-now query failed:', err?.message));
1678
- return [];
1948
+ if (_queryBusy) {
1949
+ // The interrupt broadcasts the snapshot itself (including this dispatching item).
1950
+ const cancelled = _interruptTurn({ keepDispatch: true });
1951
+ _suppressDrain = false; // send-now overrides the Stop-park interruptTurn just set
1952
+ return cancelled;
1679
1953
  }
1680
1954
 
1681
- _messageQueue.unshift(item);
1682
- const cancelled = _interruptTurn();
1683
- _suppressDrain = false; // send-now overrides the Stop-park interruptTurn just set
1955
+ // Parked queue, idle session: same lifecycle as automatic drain — announce the
1956
+ // dispatching state before starting, then let _startDispatch commit/restore it.
1684
1957
  _broadcastQueueState();
1685
- return cancelled;
1958
+ _startDispatch();
1959
+ return [];
1686
1960
  }
1687
1961
 
1688
- /** Remove one queued message (bubble ×). Returns true when found. */
1962
+ /** Remove one queued message (bubble ×). Returns true when found. The dispatching
1963
+ * item is locked until _startTurn accepts it: reject and re-broadcast so a stale
1964
+ * enabled bubble converges instead of silently dropping a message mid-commit. */
1689
1965
  function _removeQueued(id) {
1966
+ if (_dispatching && _dispatching.id === id) {
1967
+ _broadcastQueueState();
1968
+ return false;
1969
+ }
1690
1970
  const idx = _messageQueue.findIndex((it) => it.id === id);
1691
1971
  if (idx < 0) return false;
1692
1972
  _messageQueue.splice(idx, 1);
@@ -1694,18 +1974,20 @@ function _createSession(key, cwd) {
1694
1974
  return true;
1695
1975
  }
1696
1976
 
1697
- /** Drop all queued messages WITHOUT touching the running turn (session switch). */
1977
+ /** Drop all queued messages WITHOUT touching the running turn (session switch).
1978
+ * A dispatching item is still part of the backlog until its push starts; once
1979
+ * _startTurn is running it is already being accepted and cannot be recalled. */
1698
1980
  function _clearQueued() {
1699
- if (_messageQueue.length === 0) return;
1981
+ if (_messageQueue.length === 0 && !_dispatching) return;
1700
1982
  _messageQueue = [];
1983
+ // Selection is not delivery: even while _startTurn is awaiting construction,
1984
+ // a scope clear must invalidate the queued input before it can be accepted.
1985
+ // The completion checks this identity and will not resurrect the item.
1986
+ _dispatching = null;
1987
+ _dispatchingStarting = false;
1701
1988
  _broadcastQueueState();
1702
1989
  }
1703
1990
 
1704
- /** Queue snapshot for WS-reconnect replay (same pattern as getSdkInitSnapshot). */
1705
- function _getQueueSnapshot() {
1706
- return _messageQueue.map(({ id, text, ts }) => ({ id, text, ts }));
1707
- }
1708
-
1709
1991
  /**
1710
1992
  * Switch the session to a different session id (web "resume session" entry — SDK-mode
1711
1993
  * counterpart of the PTY /resume injection). options.resume only applies at query
@@ -1767,8 +2049,13 @@ function _createSession(key, cwd) {
1767
2049
  _consecutiveDeaths = 0;
1768
2050
  _clearGraceTimer();
1769
2051
  _clearTurnWatchdog();
1770
- const hadQueued = _messageQueue.length > 0;
2052
+ const hadQueued = _messageQueue.length > 0 || !!_dispatching;
1771
2053
  _messageQueue = [];
2054
+ // Hard reset sweeps the dispatching slot too; a pending _startTurn callback checks
2055
+ // slot identity and therefore will not resurrect the item into the cleared queue.
2056
+ _dispatching = null;
2057
+ _dispatchingStarting = false;
2058
+ _dispatchAttempts = 0;
1772
2059
  _suppressDrain = false;
1773
2060
  // Only broadcast on an actual change — initSdkSession calls this before any client cares,
1774
2061
  // and an empty-to-empty broadcast would just be noise on the wire.
@@ -1807,6 +2094,7 @@ function _createSession(key, cwd) {
1807
2094
  getQueueSnapshot: _getQueueSnapshot,
1808
2095
  resolveApproval: _resolveApproval,
1809
2096
  cancelApproval: _cancelApproval,
2097
+ autoCancelApproval: _autoCancelApproval,
1810
2098
  getPendingApprovals: _getPendingApprovals,
1811
2099
  getSdkInitSnapshot: () => _lastInitSnapshot,
1812
2100
  getSessionStateSnapshot: () => {
@@ -1820,7 +2108,7 @@ function _createSession(key, cwd) {
1820
2108
  // On-demand fetch for the HTTP route: refresh the snapshot, broadcast to all clients,
1821
2109
  // then reply. Unlike _refreshContextUsage this bypasses the in-flight gate (an explicit
1822
2110
  // user refresh should not be swallowed by a queued turn-end) and returns the outcome.
1823
- const r = await _collectContextUsage('request');
2111
+ const r = await _collectContextUsage('request', null, _ctxUsageClearEpoch);
1824
2112
  if (r.ok && _broadcastWs) {
1825
2113
  try { _broadcastWs(r.snapshot); }
1826
2114
  catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
@@ -2042,6 +2330,19 @@ export function cancelApproval(id, reason) {
2042
2330
  return false;
2043
2331
  }
2044
2332
 
2333
+ /**
2334
+ * Cancel the server-side auto-approve countdown for a plan/perm approval,
2335
+ * scanning EVERY session (auto-cancel WS handlers). The approval stays pending
2336
+ * for a manual decision. Returns false when no pending approval matched, so the
2337
+ * caller can fall back to the legacy relay-only broadcast.
2338
+ */
2339
+ export function autoCancelApproval(id, kind) {
2340
+ for (const sess of new Set([..._sessions.values(), ..._ephemeralHosts])) {
2341
+ if (sess.autoCancelApproval(id, kind)) return true;
2342
+ }
2343
+ return false;
2344
+ }
2345
+
2045
2346
  /**
2046
2347
  * Snapshot of pending approvals for WS-reconnect replay (server.js connection
2047
2348
  * handler). WITHOUT an anchor: aggregated across ALL sessions; WITH an anchor: