cc-viewer 1.9.14 → 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.
- package/dist/assets/App-BCDnm2Lh.js +2 -0
- package/dist/assets/App-CCnPkf2_.css +1 -0
- package/dist/assets/{MdxEditorPanel-DI3B4gsM.js → MdxEditorPanel-DRLFmdU5.js} +1 -1
- package/dist/assets/Mobile-C8VJqBwa.js +1 -0
- package/dist/assets/{ProxyStatsModal-B8K0AtAq.js → ProxyStatsModal-Ba1E1lu4.js} +1 -1
- package/dist/assets/{index-CU0gt6m1.js → index-Sp3JYYKo.js} +2 -2
- package/dist/assets/seqResourceLoaders-BUfjTaCV.css +41 -0
- package/dist/assets/seqResourceLoaders-CHmtPDFO.js +2 -0
- package/dist/index.html +1 -1
- package/node_modules/@ccv/core/src/contentFilter.js +3 -0
- package/node_modules/@ccv/core/src/queued-prompt.js +66 -0
- package/node_modules/@ccv/core/src/v2-transcript-normalizer.js +78 -14
- package/package.json +1 -1
- package/server/lib/chat-queue.js +350 -120
- package/server/lib/scheduled-task-executor.js +3 -1
- package/server/lib/sdk-manager.js +282 -68
- package/server/lib/v2/conversation-store.js +15 -11
- package/server/lib/v2/v2-writer.js +1 -1
- package/server/lib/view-root.js +4 -4
- package/server/pty-manager.js +8 -8
- package/server/routes/files-content.js +108 -14
- package/server/routes/files-fs.js +122 -36
- package/server/routes/resume.js +1 -1
- package/server/routes/sdk-context.js +13 -2
- package/server/server.js +128 -28
- package/dist/assets/App-BzV1ieDH.js +0 -2
- package/dist/assets/App-DU5jmGrx.css +0 -1
- package/dist/assets/Mobile-qBkAnXy3.js +0 -1
- package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
- package/dist/assets/seqResourceLoaders-nGCs11zu.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)
|
|
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
|
|
361
|
-
// request's .finally can't clear the flag of a
|
|
362
|
-
//
|
|
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:
|
|
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.
|
|
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)
|
|
530
|
-
|
|
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
|
-
|
|
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 (
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
|
1083
|
-
* message lazily rebuilds it into a NEW query object. A late-resolving
|
|
1084
|
-
* the dead generation must not broadcast that session's numbers into
|
|
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
|
|
1140
|
-
* usage:null clear frame
|
|
1141
|
-
*
|
|
1142
|
-
* keeps the last known value,
|
|
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:
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
* (
|
|
1651
|
-
*
|
|
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
|
-
*
|
|
1654
|
-
*
|
|
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)
|
|
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
|
|
1669
|
-
// `!_suppressDrain` condition would exit with the item
|
|
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 (
|
|
1673
|
-
//
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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
|
-
|
|
1682
|
-
|
|
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
|
-
|
|
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
|
|
39
|
-
* additionally hash the FULL message JSON.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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.
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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 =
|
|
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.
|