cc-viewer 1.9.13 → 1.9.15

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 (33) hide show
  1. package/dist/assets/App-BCDnm2Lh.js +2 -0
  2. package/dist/assets/App-CCnPkf2_.css +1 -0
  3. package/dist/assets/{MdxEditorPanel-Db1nmcJV.js → MdxEditorPanel-DRLFmdU5.js} +1 -1
  4. package/dist/assets/Mobile-C8VJqBwa.js +1 -0
  5. package/dist/assets/{ProxyStatsModal-Ch1BH0Kp.js → ProxyStatsModal-Ba1E1lu4.js} +1 -1
  6. package/dist/assets/{index-CAiC6cw-.js → index-Sp3JYYKo.js} +2 -2
  7. package/dist/assets/seqResourceLoaders-BUfjTaCV.css +41 -0
  8. package/dist/assets/seqResourceLoaders-CHmtPDFO.js +2 -0
  9. package/dist/index.html +1 -1
  10. package/node_modules/@ccv/core/src/contentFilter.js +3 -0
  11. package/node_modules/@ccv/core/src/context-rules.js +6 -2
  12. package/node_modules/@ccv/core/src/queued-prompt.js +66 -0
  13. package/node_modules/@ccv/core/src/v2-transcript-normalizer.js +78 -14
  14. package/package.json +1 -1
  15. package/server/lib/chat-queue.js +350 -120
  16. package/server/lib/model-system-prompts.js +4 -1
  17. package/server/lib/scheduled-task-executor.js +3 -1
  18. package/server/lib/sdk-manager.js +282 -68
  19. package/server/lib/v2/conversation-store.js +15 -11
  20. package/server/lib/v2/v2-writer.js +1 -1
  21. package/server/lib/view-root.js +4 -4
  22. package/server/pty-manager.js +8 -8
  23. package/server/routes/files-content.js +108 -14
  24. package/server/routes/files-fs.js +122 -36
  25. package/server/routes/resume.js +1 -1
  26. package/server/routes/sdk-context.js +13 -2
  27. package/server/server.js +128 -28
  28. package/server/system-prompt-templates/presets/kimi-k3.md +4 -0
  29. package/dist/assets/App-BzOM1_y-.js +0 -2
  30. package/dist/assets/App-DU5jmGrx.css +0 -1
  31. package/dist/assets/Mobile-rjfABa-U.js +0 -1
  32. package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
  33. package/dist/assets/seqResourceLoaders-Ds8-_QA3.js +0 -2
@@ -35,6 +35,7 @@ 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';
38
39
 
39
40
  let _query;
40
41
  try {
@@ -94,6 +95,12 @@ function _matchesProject(sess, project) {
94
95
  return sanitized === project || rawBase === project;
95
96
  }
96
97
 
98
+ // A project stamp matches a session when it names the session's exact normalized cwd
99
+ // (absolute-path form) or its sanitized/raw basename (the web client's usual form).
100
+ function _sessionMatchesProject(sess, project) {
101
+ return sess.key === _normalizeCwd(project) || _matchesProject(sess, project);
102
+ }
103
+
97
104
  // Resolve an anchor { instanceKey|cwd|project } to a Session WITHOUT switching active (read ops).
98
105
  // `cwd` is an absolute path (exact key match); `project` is the SANITIZED project name the
99
106
  // web client sends (ChatView stamps `viewProject || projectName`, where projectName =
@@ -107,13 +114,20 @@ function _sessionForAnchor(anchor) {
107
114
  const cwd = (typeof anchor.cwd === 'string' && anchor.cwd) || null;
108
115
  const project = (typeof anchor.project === 'string' && anchor.project) || null;
109
116
  if (instanceKey) {
117
+ let byInstance = null;
110
118
  for (const s of _sessions.values()) {
111
- if (s.instanceKey === instanceKey) return s;
119
+ if (s.instanceKey === instanceKey) { byInstance = s; break; }
112
120
  }
113
121
  // An exact instance that no longer exists must never fall back to a
114
122
  // same-basename sibling — that would execute the user's action in the
115
123
  // wrong workspace. The caller's unknown-anchor path drops + reports it.
116
- return null;
124
+ if (!byInstance) return null;
125
+ // The web client stamps project and instance TOGETHER. A stale/mismatched pair
126
+ // must not let instance priority execute/read in a project other than the one
127
+ // the viewer named — resolve the pair to null instead (never fall through to
128
+ // project-only resolution, which could pick an unrelated sibling).
129
+ if (project && !_sessionMatchesProject(byInstance, project)) return null;
130
+ return byInstance;
117
131
  }
118
132
  if (cwd) return _sessions.get(_normalizeCwd(cwd)) || null;
119
133
  if (!project) return null;
@@ -239,6 +253,12 @@ function _userMessage(text) {
239
253
  };
240
254
  }
241
255
 
256
+ // Bare "/clear" is the CLI's context-reset command; arguments/case variants are not the
257
+ // reset command. Trim only surrounding whitespace (composer newlines/padding).
258
+ function _isBareClearCommand(text) {
259
+ return typeof text === 'string' && text.trim() === '/clear';
260
+ }
261
+
242
262
  /**
243
263
  * Normalize an SDK `task_*` system message into the camelCase relay payload
244
264
  * broadcast as `{ type: 'sdk-task', ... }`. Lossless pass-through (snake→camel
@@ -357,10 +377,15 @@ function _createSession(key, cwd) {
357
377
  let _lastContextUsage = null; // last sdk-context-usage frame for WS reconnect replay
358
378
  let _ctxUsageFetching = false; // one getContextUsage control request in flight
359
379
  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).
380
+ // Generation counter for the in-flight gate AND stale-refresh suppression:
381
+ // _clearContextUsage bumps it so (a) a stale request's .finally can't clear the flag of a
382
+ // NEWER in-flight request started after a clear/switch (the flag is only reset by the
383
+ // generation that set it) and (b) a request that resolves after a clear cannot retain or
384
+ // broadcast its pre-clear snapshot.
363
385
  let _ctxUsageSeq = 0;
386
+ let _ctxUsageClearEpoch = 0;
387
+ let _ctxUsageClearPending = false;
388
+ let _ctxUsageStartSeq = 0;
364
389
  // Last sdk-mcp-status frame for WS reconnect replay. mcpServerStatus() is on-demand only
365
390
  // (HTTP pull + reconnect replay + clear-on-switch) — unlike context usage it is NOT
366
391
  // turn-correlated, so it gets no turn-end/compact trigger and none of the in-flight gate
@@ -375,6 +400,14 @@ function _createSession(key, cwd) {
375
400
  // the id lets web clients act on individual queued bubbles (send-now / remove) and the
376
401
  // queue-state WS broadcast keeps every client in sync (server is the source of truth).
377
402
  let _messageQueue = [];
403
+ // Single in-transit slot between the queued FIFO and an ACCEPTED turn. An item is moved
404
+ // here before the interrupt (busy send-now) or before the drain start, so it is no longer
405
+ // actionable but stays visible in the snapshot as status: 'dispatching' (disabled bubble)
406
+ // until _startTurn commits it with a successful inputQueue.push. A pre-push failure puts
407
+ // it back at the queue head; a pushed message is never requeued, even if its turn fails.
408
+ let _dispatching = null;
409
+ let _dispatchingStarting = false; // _startTurn is running for _dispatching (pre-push)
410
+ let _dispatchAttempts = 0;
378
411
  // Stop semantics (product decision): Stop parks the queue instead of dropping it —
379
412
  // interruptTurn sets this flag so sendUserMessage's drain loop exits without running the
380
413
  // parked items; cleared when a fresh user message / send-now starts a new turn.
@@ -481,11 +514,11 @@ function _createSession(key, cwd) {
481
514
 
482
515
  // If a turn is already running, queue this message and return
483
516
  if (_queryBusy) {
484
- if (_messageQueue.length >= MAX_QUEUE_ITEMS) {
517
+ if (_messageQueue.length + (_dispatching ? 1 : 0) >= MAX_QUEUE_ITEMS) {
485
518
  // Negative ack: the frontend cleared the composer optimistically — never fail
486
519
  // silently. Only the reject frame goes out (no queue-state — nothing changed).
487
520
  console.warn('[sdk-manager] queue full, rejecting message');
488
- try { if (_broadcastWs) _broadcastWs({ type: 'queue-rejected', reason: 'full', cwd: _cwd, sessionId: _sessionId || null, instanceKey }); }
521
+ try { if (_broadcastWs) _broadcastWs({ type: 'queue-rejected', reason: 'full', cwd: _cwd, project: projectKeyForCwd(_cwd) || undefined, sessionId: _sessionId || null, instanceKey }); }
489
522
  catch (err) { reportSwallowed('sdk-manager.reject-broadcast', err); }
490
523
  return;
491
524
  }
@@ -497,23 +530,41 @@ function _createSession(key, cwd) {
497
530
  // A fresh user-initiated turn re-arms automatic draining of parked (Stop-kept) items.
498
531
  _suppressDrain = false;
499
532
  _consecutiveDeaths = 0; // explicit user action re-arms the death-drain backstop
500
- await _startTurn(text);
533
+ const { promise } = await _startTurn(text);
534
+ return promise;
535
+ }
536
+
537
+ /** Queue snapshot. The optional dispatching item is listed first (it has been selected
538
+ * as the next turn) and carries status 'dispatching' until _startTurn accepts it; queued
539
+ * items carry status 'queued'. Both the broadcast and the reconnect snapshot use this
540
+ * one shape so every client converges on the same actionable set. */
541
+ function _getQueueSnapshot() {
542
+ const items = [];
543
+ if (_dispatching) items.push({ id: _dispatching.id, text: _dispatching.text, ts: _dispatching.ts, status: 'dispatching' });
544
+ for (const item of _messageQueue) items.push({ id: item.id, text: item.text, ts: item.ts, status: 'queued' });
545
+ return items;
501
546
  }
502
547
 
503
548
  /** Broadcast the queue snapshot to all terminal-WS clients (drives the floating bubbles). */
504
549
  function _broadcastQueueState() {
505
550
  if (!_broadcastWs) return;
506
551
  try {
507
- _broadcastWs({ type: 'queue-state', cwd: _cwd, sessionId: _sessionId || null, instanceKey, items: _messageQueue.map(({ id, text, ts }) => ({ id, text, ts })) });
552
+ _broadcastWs({ type: 'queue-state', cwd: _cwd, project: projectKeyForCwd(_cwd) || undefined, sessionId: _sessionId || null, instanceKey, items: _getQueueSnapshot() });
508
553
  } catch (err) { console.warn('[sdk-manager] queue-state broadcast threw:', err?.message); }
509
554
  }
510
555
 
511
556
  /**
512
557
  * 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).
558
+ * message. Returns { started, promise }:
559
+ * - started: true only once the message was ACCEPTED by inputQueue.push (the commit
560
+ * point). False means construction/teardown/push failed before delivery, so a
561
+ * dispatching caller restores its queued item instead of treating it as sent.
562
+ * - promise: resolves when the turn settles (result / teardown / death). It never
563
+ * rejects; a later turn failure must NOT put an already-pushed message back.
514
564
  */
515
- async function _startTurn(text) {
565
+ async function _startTurn(text, dispatch = null) {
516
566
  const { rec, promise } = _beginTurn();
567
+ if (dispatch) rec.dispatchItem = dispatch;
517
568
  _queryBusy = true;
518
569
  try {
519
570
  await _ensureResidentQuery();
@@ -523,17 +574,41 @@ function _createSession(key, cwd) {
523
574
  _settleTurn(rec);
524
575
  _queryBusy = false;
525
576
  _notifyQueryError(err);
526
- return promise;
577
+ return { started: false, promise };
527
578
  }
528
579
  // Teardown/stop raced with construction — the settle already happened; don't push.
529
- if (rec.settled) return promise;
530
- if (!_activeQuery || !_inputQueue) {
580
+ if (rec.settled || (dispatch && _dispatching !== dispatch)) {
581
+ if (!rec.settled) {
582
+ _pendingTurns = _pendingTurns.filter((turn) => turn !== rec);
583
+ _settleTurn(rec);
584
+ if (_dispatching !== dispatch) _queryBusy = false;
585
+ }
586
+ return { started: false, promise };
587
+ }
588
+ if (!_activeQuery || !_inputQueue || (typeof _inputQueue.isClosed === 'function' && _inputQueue.isClosed()) || (dispatch && _dispatching !== dispatch)) {
589
+ _pendingTurns = _pendingTurns.filter((turn) => turn !== rec);
590
+ _settleTurn(rec);
591
+ _queryBusy = false;
592
+ return { started: false, promise };
593
+ }
594
+ // Typed /clear is detected at EXECUTION time (never at enqueue time): a clear queued
595
+ // behind a busy turn must not wipe the running turn's usage panel. Invalidate the
596
+ // retained snapshot before the CLI sees the command; the /clear turn's own result then
597
+ // refreshes it with the post-clear numbers.
598
+ if (_isBareClearCommand(text)) _clearContextUsage('context-cleared');
599
+ try {
600
+ // The push below is the acceptance point watched by the dispatching slot. The push
601
+ // queue itself never throws (a closed queue silently drops), and teardown nulls the
602
+ // handles synchronously before closing, so the checks above plus this call are the
603
+ // complete pre-push failure surface.
604
+ _inputQueue.push(_userMessage(text));
605
+ } catch (err) {
531
606
  _pendingTurns = _pendingTurns.filter((r) => r !== rec);
532
607
  _settleTurn(rec);
533
608
  _queryBusy = false;
534
- return promise;
609
+ reportSwallowed('sdk-manager.queue-push', err);
610
+ return { started: false, promise };
535
611
  }
536
- _inputQueue.push(_userMessage(text));
537
612
  _turnInFlight = true;
538
613
  // Turn-level loading fallback: light the spinner the moment the turn is pushed.
539
614
  // The CLI's own session_state_changed(running) arrives later and is idempotent
@@ -541,7 +616,7 @@ function _createSession(key, cwd) {
541
616
  // the only "on" signal, paired with the result-idle broadcast in _processMessage.
542
617
  _announceSessionState('running');
543
618
  _armTurnWatchdog();
544
- return promise;
619
+ return { started: true, promise };
545
620
  }
546
621
 
547
622
  /**
@@ -828,6 +903,14 @@ function _createSession(key, cwd) {
828
903
  if (_activeQuery !== query) return; // stale generation — teardown already moved on
829
904
  _activeQuery = null;
830
905
  _inputQueue = null;
906
+ if (_ctxUsageClearPending) {
907
+ // A child that exits while applying /clear must not resume the cleared session on the
908
+ // next message. Start fresh, allow the next init/read, and invalidate older controls.
909
+ _ctxUsageClearPending = false;
910
+ _ctxUsageClearEpoch++;
911
+ _sessionId = null;
912
+ _launchResume = null;
913
+ }
831
914
  _clearGraceTimer();
832
915
  _clearTurnWatchdog();
833
916
  _lateResultTombstones = 0;
@@ -839,6 +922,15 @@ function _createSession(key, cwd) {
839
922
  _notifyQueryError(err);
840
923
  }
841
924
  _settleAllTurns();
925
+ // A dispatching item waiting on an interrupt unwind never reached the SDK. Death
926
+ // parks the backlog, so put it back at the queue head (actionable again) instead of
927
+ // leaving it locked in the slot; the drain below may immediately re-select it. Once
928
+ // _startTurn is running the item is mid-commit and must not be resurrected here.
929
+ if (_dispatching && !_dispatchingStarting) {
930
+ _messageQueue.unshift(_dispatching);
931
+ _dispatching = null;
932
+ _broadcastQueueState();
933
+ }
842
934
  // Flush pending approvals with a dismiss broadcast, else their modals hang forever.
843
935
  const pending = Array.from(_pendingApprovals, ([id, p]) => ({ id, kind: p.kind || null }));
844
936
  for (const { id, kind } of pending) {
@@ -894,13 +986,78 @@ function _createSession(key, cwd) {
894
986
  catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
895
987
  }
896
988
 
989
+ /**
990
+ * Start the dispatching item. The slot was claimed synchronously by the caller (auto
991
+ * drain or idle send-now), so repeated drains cannot start it twice. The item stays in
992
+ * the snapshot as 'dispatching' until _startTurn reports acceptance; if construction or
993
+ * the push fails before delivery it returns to the queue head, enabled again.
994
+ */
995
+ function _startDispatch() {
996
+ const item = _dispatching;
997
+ if (!item || _dispatchingStarting) return;
998
+ _dispatchingStarting = true;
999
+ _dispatchAttempts += 1;
1000
+ _startTurn(item.text, item).then(({ started, promise }) => {
1001
+ // A reset/clear that superseded this dispatch already swept the slot — never
1002
+ // resurrect its item across that boundary.
1003
+ if (_dispatching !== item) {
1004
+ // A superseded start may have cleared _queryBusy while a newer dispatch
1005
+ // is waiting. Resume only the current item, never the cancelled one.
1006
+ _maybeDrain();
1007
+ return;
1008
+ }
1009
+ _dispatchingStarting = false;
1010
+ if (!started) {
1011
+ // A pre-push failure has not committed this item. Retry the SAME slot once
1012
+ // without depending on an unrelated legacy turn's busy flag or a result.
1013
+ if (_dispatchAttempts < 2 && !_suppressDrain && !_switching) {
1014
+ _broadcastQueueState();
1015
+ _startDispatch();
1016
+ return;
1017
+ }
1018
+ _dispatching = null;
1019
+ _dispatchAttempts = 0;
1020
+ _messageQueue.unshift(item);
1021
+ _broadcastQueueState();
1022
+ return;
1023
+ }
1024
+ _dispatching = null;
1025
+ // Push accepted: the item is now the running turn, not a queued bubble. Clearing
1026
+ // the slot here also means a later turn failure (error result / interrupt / death)
1027
+ // can never requeue it.
1028
+ _broadcastQueueState();
1029
+ _dispatchAttempts = 0;
1030
+ _consecutiveDeaths = 0; // only an accepted push re-arms the death-drain backstop
1031
+ // The turn may already have settled inside the same microtask window (fast fake or
1032
+ // CLI); continue the queue now when the normal result-driven drain could not.
1033
+ _maybeDrain();
1034
+ promise.catch((err) => reportSwallowed('sdk-manager.dispatch-turn', err));
1035
+ }).catch((err) => {
1036
+ if (_dispatching !== item) {
1037
+ // A superseded start may have cleared _queryBusy while a newer dispatch
1038
+ // is waiting. Resume only the current item, never the cancelled one.
1039
+ _maybeDrain();
1040
+ return;
1041
+ }
1042
+ _dispatchingStarting = false;
1043
+ _dispatching = null;
1044
+ _dispatchAttempts = 0;
1045
+ _messageQueue.unshift(item);
1046
+ _broadcastQueueState();
1047
+ reportSwallowed('sdk-manager.dispatch', err);
1048
+ });
1049
+ }
1050
+
897
1051
  /** Drain one queued message after a turn boundary (result-driven, not loop-driven). */
898
1052
  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));
1053
+ if (_suppressDrain || _queryBusy || _switching || _dispatchingStarting) return;
1054
+ if (!_dispatching) {
1055
+ if (_messageQueue.length === 0) return;
1056
+ _dispatching = _messageQueue.shift();
1057
+ // Make the bubble visible-but-disabled while the turn is constructed/pushed.
1058
+ _broadcastQueueState();
1059
+ }
1060
+ _startDispatch();
904
1061
  }
905
1062
 
906
1063
  /**
@@ -935,6 +1092,9 @@ function _createSession(key, cwd) {
935
1092
  if (msg.subtype === 'session_state_changed') {
936
1093
  _announceSessionState(msg.state);
937
1094
  } else if (msg.subtype === 'init') {
1095
+ // /clear may reinitialize instead of returning a normal result. The new session is
1096
+ // now authoritative, so context reads may resume (without resuming the old session).
1097
+ if (_ctxUsageClearPending && msg.session_id) _ctxUsageClearPending = false;
938
1098
  if (_broadcastWs && Array.isArray(msg.slash_commands) && msg.slash_commands.length > 0
939
1099
  && msg.session_id !== _initAnnouncedSid) {
940
1100
  _initAnnouncedSid = msg.session_id;
@@ -1048,7 +1208,9 @@ function _createSession(key, cwd) {
1048
1208
  _maybeDrain();
1049
1209
  // Refresh the context-usage panel AFTER the turn fully settles (fire-and-forget,
1050
1210
  // never awaited in this message loop). Placement after settle/drain guarantees a
1051
- // synchronous throw here can't truncate turn settlement.
1211
+ // synchronous throw here can't truncate turn settlement. The CLI has now completed
1212
+ // the /clear turn, so a fresh read is allowed again.
1213
+ _ctxUsageClearPending = false;
1052
1214
  _refreshContextUsage('turn-end');
1053
1215
  break;
1054
1216
  }
@@ -1079,13 +1241,19 @@ function _createSession(key, cwd) {
1079
1241
  * Returns { ok:true, snapshot } | { ok:false, reason:'session-inactive'|'control-failed' }.
1080
1242
  * Never throws — the panel is best-effort; failures must not surface as sdk-error toasts.
1081
1243
  *
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.
1244
+ * Identity/generation guards (not just a null check): teardown nulls `_activeQuery`, but a
1245
+ * subsequent user message lazily rebuilds it into a NEW query object. A late-resolving
1246
+ * control promise from the dead generation must not broadcast that session's numbers into
1247
+ * the new panel. `generation` additionally rejects a request that outlived a context clear
1248
+ * on the SAME query (typed /clear), so its pre-clear numbers are neither retained nor
1249
+ * returned. Manual pulls capture the same epoch, and every pull is newest-start-wins so
1250
+ * an older response cannot overwrite a newer turn-end snapshot.
1085
1251
  */
1086
- async function _collectContextUsage(trigger) {
1252
+ async function _collectContextUsage(trigger, generation, clearEpoch = _ctxUsageClearEpoch) {
1253
+ if (_ctxUsageClearPending) return { ok: false, reason: 'session-inactive' };
1087
1254
  const q = _activeQuery;
1088
1255
  if (!q || typeof q.getContextUsage !== 'function') return { ok: false, reason: 'session-inactive' };
1256
+ const startSeq = ++_ctxUsageStartSeq;
1089
1257
  let timeoutId;
1090
1258
  try {
1091
1259
  // SDK request() has no built-in timeout — race a bound so a live-but-unresponsive
@@ -1097,11 +1265,15 @@ function _createSession(key, cwd) {
1097
1265
  });
1098
1266
  const usage = await Promise.race([q.getContextUsage(), timeout]);
1099
1267
  if (_activeQuery !== q) return { ok: false, reason: 'session-inactive' }; // stale generation
1268
+ // Cleared/superseded while this request was in flight — the numbers describe the
1269
+ // PRE-clear context and must not land in the retained snapshot.
1270
+ if (generation != null && generation !== _ctxUsageSeq) return { ok: false, reason: 'session-inactive' };
1271
+ if (clearEpoch !== _ctxUsageClearEpoch || _ctxUsageClearPending || startSeq !== _ctxUsageStartSeq) return { ok: false, reason: 'session-inactive' };
1100
1272
  // Degenerate resolve (null/undefined/{}) — skip rather than broadcast a usage-less frame.
1101
1273
  if (!usage || typeof usage !== 'object' || Array.isArray(usage) || Object.keys(usage).length === 0) {
1102
1274
  return { ok: false, reason: 'control-failed' };
1103
1275
  }
1104
- _lastContextUsage = { type: 'sdk-context-usage', sessionId: _sessionId, ts: Date.now(), trigger, usage, cwd: _cwd };
1276
+ _lastContextUsage = { type: 'sdk-context-usage', sessionId: _sessionId, ts: Date.now(), trigger, usage, cwd: _cwd, instanceKey };
1105
1277
  return { ok: true, snapshot: _lastContextUsage };
1106
1278
  } catch (err) {
1107
1279
  reportSwallowed('sdk-manager.context-usage', err);
@@ -1120,8 +1292,12 @@ function _createSession(key, cwd) {
1120
1292
  function _refreshContextUsage(trigger) {
1121
1293
  if (_ctxUsageFetching) { _ctxUsageQueued = true; return; }
1122
1294
  _ctxUsageFetching = true;
1123
- const seq = ++_ctxUsageSeq; // capture this generation; a session switch bumps it
1124
- _collectContextUsage(trigger).then((r) => {
1295
+ const seq = ++_ctxUsageSeq; // capture this generation; a clear/session switch bumps it
1296
+ _collectContextUsage(trigger, seq).then((r) => {
1297
+ // A clear/switch landed while this request was in flight: its numbers are from the
1298
+ // old generation and must not reach clients (the snapshot write is guarded inside
1299
+ // _collectContextUsage for the same reason).
1300
+ if (seq !== _ctxUsageSeq) return;
1125
1301
  if (r.ok && _broadcastWs) {
1126
1302
  try { _broadcastWs(r.snapshot); }
1127
1303
  catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
@@ -1136,18 +1312,21 @@ function _createSession(key, cwd) {
1136
1312
  }
1137
1313
 
1138
1314
  /**
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).
1315
+ * Clear the usage snapshot on a context boundary (session switch / full reset / typed
1316
+ * /clear). A usage:null clear frame is emitted when a snapshot was present, and always
1317
+ * for an explicit /clear (a synchronization signal even when no snapshot existed).
1318
+ * The death path deliberately does NOT clear (same-session resume keeps the last known value,
1319
+ * matching _lastInitSnapshot's death behavior).
1143
1320
  */
1144
1321
  function _clearContextUsage(trigger) {
1145
1322
  const had = _lastContextUsage !== null;
1146
1323
  _lastContextUsage = null;
1147
1324
  _ctxUsageSeq++; // invalidate any in-flight generation so its finally can't clear a newer gate
1325
+ _ctxUsageClearEpoch++;
1326
+ _ctxUsageClearPending = trigger === 'context-cleared';
1148
1327
  _ctxUsageFetching = false;
1149
1328
  _ctxUsageQueued = false;
1150
- if (had && _broadcastWs) {
1329
+ if ((had || trigger === 'context-cleared') && _broadcastWs) {
1151
1330
  try { _broadcastWs({ type: 'sdk-context-usage', sessionId: _sessionId, ts: Date.now(), trigger, usage: null, cwd: _cwd, instanceKey }); }
1152
1331
  catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
1153
1332
  }
@@ -1310,7 +1489,7 @@ function _createSession(key, cwd) {
1310
1489
  }
1311
1490
  } catch (err) { reportSwallowed('sdk-manager.waterfall.plan', err); }
1312
1491
  }
1313
- const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), ...origin };
1492
+ const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: effSessionId(), instanceKey, ...origin };
1314
1493
  if (_broadcastWs) {
1315
1494
  _broadcastWs(planPayload);
1316
1495
  }
@@ -1619,8 +1798,14 @@ function _createSession(key, cwd) {
1619
1798
  * Returns the list of approvals that were pending at interrupt time
1620
1799
  * (`[{ id, kind }]`) so the caller (server.js) can broadcast modal-close
1621
1800
  * messages to every client. Always an array (empty when nothing was pending).
1801
+ *
1802
+ * `keepDispatch` is set only by send-now's own preemption: the dispatching item it
1803
+ * just selected must survive this interrupt. A user Stop (no options) parks the whole
1804
+ * backlog, so a selected-but-not-yet-started item returns to the queue head and its
1805
+ * bubble becomes actionable again. An item whose _startTurn is already running cannot
1806
+ * be recalled (the push is synchronous once construction resolves), so it stays put.
1622
1807
  */
1623
- function _interruptTurn() {
1808
+ function _interruptTurn({ keepDispatch = false } = {}) {
1624
1809
  const cancelled = _cancelAllApprovals();
1625
1810
  const q = _activeQuery;
1626
1811
  // Key off the sticky in-flight flag, not the FIFO: the grace/watchdog force-settle
@@ -1638,20 +1823,28 @@ function _createSession(key, cwd) {
1638
1823
  }
1639
1824
  _armSettleGrace();
1640
1825
  }
1826
+ if (_dispatching && !_dispatchingStarting && !keepDispatch) {
1827
+ _messageQueue.unshift(_dispatching);
1828
+ _dispatching = null;
1829
+ }
1641
1830
  _suppressDrain = true;
1642
1831
  _broadcastQueueState();
1643
1832
  return cancelled;
1644
1833
  }
1645
1834
 
1646
1835
  /**
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).
1836
+ * Send-now on a queued bubble: move this message into the single dispatching slot and
1837
+ * make it the next turn. While busy it interrupts the running turn first, then the
1838
+ * result-driven drain starts the dispatching item as soon as the abort settles. While
1839
+ * idle (parked queue after Stop) it runs immediately. Both paths share _startDispatch's
1840
+ * lifecycle: the bubble stays in the snapshot as 'dispatching' (disabled) until
1841
+ * _startTurn accepts it; a pre-push failure restores it to the queue.
1652
1842
  *
1653
- * When no turn is running (parked queue after Stop), the message is executed immediately
1654
- * as a fresh turn.
1843
+ * Only ONE item may be in flight through that lifecycle: a second send-now (including a
1844
+ * repeat click on the same bubble) is rejected — it must not fire a second interrupt or
1845
+ * reorder the selected item. The current snapshot is re-broadcast so stale clients
1846
+ * converge on the disabled state. Removing the dispatching bubble is rejected for the
1847
+ * same reason; other queued bubbles remain removable.
1655
1848
  *
1656
1849
  * Known semantic: if the FIRST turn is interrupted before its system/init message was
1657
1850
  * processed, `_sessionId` is still null and the prioritized message starts a fresh session
@@ -1661,32 +1854,46 @@ function _createSession(key, cwd) {
1661
1854
  * close approval modals on all clients.
1662
1855
  */
1663
1856
  function _sendQueuedNow(id) {
1857
+ if (_dispatching) {
1858
+ // The slot already owns the interrupt/drain window. Re-broadcast for convergence
1859
+ // (a client may still render the bubble as actionable), but do not act twice.
1860
+ _broadcastQueueState();
1861
+ return [];
1862
+ }
1664
1863
  const idx = _messageQueue.findIndex((it) => it.id === id);
1665
- if (idx < 0) return []; // unknown / already dispatched — no-op BEFORE any interrupt
1864
+ if (idx < 0) {
1865
+ _broadcastQueueState();
1866
+ return []; // unknown / already dispatched — converge stale clients, never interrupt
1867
+ }
1666
1868
  const [item] = _messageQueue.splice(idx, 1);
1869
+ _dispatching = item;
1667
1870
  // 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.
1871
+ // unwind is still in flight (_queryBusy still true). Without this, the drain's
1872
+ // `!_suppressDrain` condition would exit with the item parked in the slot → silent loss.
1670
1873
  _suppressDrain = false;
1671
1874
 
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 [];
1875
+ if (_queryBusy) {
1876
+ // The interrupt broadcasts the snapshot itself (including this dispatching item).
1877
+ const cancelled = _interruptTurn({ keepDispatch: true });
1878
+ _suppressDrain = false; // send-now overrides the Stop-park interruptTurn just set
1879
+ return cancelled;
1679
1880
  }
1680
1881
 
1681
- _messageQueue.unshift(item);
1682
- const cancelled = _interruptTurn();
1683
- _suppressDrain = false; // send-now overrides the Stop-park interruptTurn just set
1882
+ // Parked queue, idle session: same lifecycle as automatic drain — announce the
1883
+ // dispatching state before starting, then let _startDispatch commit/restore it.
1684
1884
  _broadcastQueueState();
1685
- return cancelled;
1885
+ _startDispatch();
1886
+ return [];
1686
1887
  }
1687
1888
 
1688
- /** Remove one queued message (bubble ×). Returns true when found. */
1889
+ /** Remove one queued message (bubble ×). Returns true when found. The dispatching
1890
+ * item is locked until _startTurn accepts it: reject and re-broadcast so a stale
1891
+ * enabled bubble converges instead of silently dropping a message mid-commit. */
1689
1892
  function _removeQueued(id) {
1893
+ if (_dispatching && _dispatching.id === id) {
1894
+ _broadcastQueueState();
1895
+ return false;
1896
+ }
1690
1897
  const idx = _messageQueue.findIndex((it) => it.id === id);
1691
1898
  if (idx < 0) return false;
1692
1899
  _messageQueue.splice(idx, 1);
@@ -1694,18 +1901,20 @@ function _createSession(key, cwd) {
1694
1901
  return true;
1695
1902
  }
1696
1903
 
1697
- /** Drop all queued messages WITHOUT touching the running turn (session switch). */
1904
+ /** Drop all queued messages WITHOUT touching the running turn (session switch).
1905
+ * A dispatching item is still part of the backlog until its push starts; once
1906
+ * _startTurn is running it is already being accepted and cannot be recalled. */
1698
1907
  function _clearQueued() {
1699
- if (_messageQueue.length === 0) return;
1908
+ if (_messageQueue.length === 0 && !_dispatching) return;
1700
1909
  _messageQueue = [];
1910
+ // Selection is not delivery: even while _startTurn is awaiting construction,
1911
+ // a scope clear must invalidate the queued input before it can be accepted.
1912
+ // The completion checks this identity and will not resurrect the item.
1913
+ _dispatching = null;
1914
+ _dispatchingStarting = false;
1701
1915
  _broadcastQueueState();
1702
1916
  }
1703
1917
 
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
1918
  /**
1710
1919
  * Switch the session to a different session id (web "resume session" entry — SDK-mode
1711
1920
  * counterpart of the PTY /resume injection). options.resume only applies at query
@@ -1767,8 +1976,13 @@ function _createSession(key, cwd) {
1767
1976
  _consecutiveDeaths = 0;
1768
1977
  _clearGraceTimer();
1769
1978
  _clearTurnWatchdog();
1770
- const hadQueued = _messageQueue.length > 0;
1979
+ const hadQueued = _messageQueue.length > 0 || !!_dispatching;
1771
1980
  _messageQueue = [];
1981
+ // Hard reset sweeps the dispatching slot too; a pending _startTurn callback checks
1982
+ // slot identity and therefore will not resurrect the item into the cleared queue.
1983
+ _dispatching = null;
1984
+ _dispatchingStarting = false;
1985
+ _dispatchAttempts = 0;
1772
1986
  _suppressDrain = false;
1773
1987
  // Only broadcast on an actual change — initSdkSession calls this before any client cares,
1774
1988
  // and an empty-to-empty broadcast would just be noise on the wire.
@@ -1820,7 +2034,7 @@ function _createSession(key, cwd) {
1820
2034
  // On-demand fetch for the HTTP route: refresh the snapshot, broadcast to all clients,
1821
2035
  // then reply. Unlike _refreshContextUsage this bypasses the in-flight gate (an explicit
1822
2036
  // user refresh should not be swallowed by a queued turn-end) and returns the outcome.
1823
- const r = await _collectContextUsage('request');
2037
+ const r = await _collectContextUsage('request', null, _ctxUsageClearEpoch);
1824
2038
  if (r.ok && _broadcastWs) {
1825
2039
  try { _broadcastWs(r.snapshot); }
1826
2040
  catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
@@ -35,13 +35,13 @@ export class ConversationStore {
35
35
  /**
36
36
  * @param {object} paths - sessionPaths() object
37
37
  * @param {{appendTo: Function}} queue - AsyncWriteQueue (explicit-path API)
38
- * @param {{exactFps?: boolean}} [opts] - exact mode: judgement fingerprints
39
- * additionally hash the FULL message JSON. The offline converter needs
40
- * byte-grade faithfulness (its golden gate replays byte-identical wire);
41
- * fingerprintMsg's 80-char first-block snip cannot distinguish wires that
42
- * share a long prefix (real data 2026-07-14: sub prompts identical in the
43
- * first 80 chars, differing later, judged "unchanged" → replay stuck on
44
- * the first wire). The live request path keeps the cheap default.
38
+ * @param {{exactFps?: boolean|'main'}} [opts] - exact mode: judgement
39
+ * fingerprints additionally hash the FULL normalized message JSON. Default
40
+ * 'main' covers the user-facing conversation; sub/misc retain cheap fps.
41
+ * The offline converter passes true for byte-grade faithfulness across all
42
+ * conversations. fingerprintMsg's 80-char first-block snip cannot see an
43
+ * appended prompt in a later content block, which would otherwise be
44
+ * judged "unchanged" and vanish from the replayed main conversation.
45
45
  */
46
46
  constructor(paths, queue, opts = {}) {
47
47
  this._paths = paths;
@@ -54,9 +54,12 @@ export class ConversationStore {
54
54
  // per request on affected conversations (real-data storm, ~62% of corpus
55
55
  // bytes). Storage still records the wire verbatim; only the judgement
56
56
  // compares modulo cache_control/form.
57
- this._fpOf = opts.exactFps
58
- ? (m) => { const n = normalizeMsgForEquality(m); return `${fingerprintMsg(n)}:${fnv1a(JSON.stringify(n))}`; }
59
- : fingerprintMsg;
57
+ this._exactFps = opts.exactFps === undefined ? 'main' : opts.exactFps;
58
+ this._exactFpOf = (m) => {
59
+ const n = normalizeMsgForEquality(m);
60
+ return `${fingerprintMsg(n)}:${fnv1a(JSON.stringify(n))}`;
61
+ };
62
+ this._fpOf = this._exactFps === true ? this._exactFpOf : fingerprintMsg;
60
63
  }
61
64
 
62
65
  _state(convKey) {
@@ -123,7 +126,8 @@ export class ConversationStore {
123
126
  // exactly like v1's Plan C does, or the two dual-write sides legitimately
124
127
  // diverge in a way the verify digest (built on messageFingerprint) is
125
128
  // structurally blind to (review P2).
126
- const fpsIn = msgs.map(this._fpOf);
129
+ const fpOf = this._exactFps === 'main' && convKey === 'main' ? this._exactFpOf : this._fpOf;
130
+ const fpsIn = msgs.map(fpOf);
127
131
  const prev = st.count;
128
132
  const prevFps = st.fps;
129
133
 
@@ -73,7 +73,7 @@ export class V2Writer {
73
73
  // writers pass none of these.
74
74
  this._sessionsDirName = opts.sessionsDirName || 'sessions';
75
75
  this._metaExtra = opts.metaExtra || null;
76
- this._exactConvFps = !!opts.exactConvFps;
76
+ this._exactConvFps = opts.exactConvFps;
77
77
  // S6b live feed: called with the session dir after every ingest so the
78
78
  // in-process live cursor can read the fresh appends with zero fs-watch
79
79
  // latency. Purely a nudge — the feed's data source stays the files.