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.
- package/cli.js +1 -0
- package/concepts/ar/UltraPlan.md +9 -9
- package/concepts/da/UltraPlan.md +9 -9
- package/concepts/de/UltraPlan.md +9 -9
- package/concepts/en/UltraPlan.md +9 -9
- package/concepts/es/UltraPlan.md +9 -9
- package/concepts/fr/UltraPlan.md +9 -9
- package/concepts/it/UltraPlan.md +9 -9
- package/concepts/ja/UltraPlan.md +9 -9
- package/concepts/ko/UltraPlan.md +9 -9
- package/concepts/no/UltraPlan.md +9 -9
- package/concepts/pl/UltraPlan.md +9 -9
- package/concepts/pt-BR/UltraPlan.md +9 -9
- package/concepts/ru/UltraPlan.md +9 -9
- package/concepts/th/UltraPlan.md +9 -9
- package/concepts/tr/UltraPlan.md +9 -9
- package/concepts/uk/UltraPlan.md +9 -9
- package/concepts/zh/UltraPlan.md +9 -9
- package/concepts/zh-TW/UltraPlan.md +9 -9
- package/dist/assets/App-BvVdj099.css +1 -0
- package/dist/assets/App-CYdOioAz.js +2 -0
- package/dist/assets/{MdxEditorPanel-DI3B4gsM.js → MdxEditorPanel-CuCAe1OG.js} +1 -1
- package/dist/assets/Mobile-CfNYOgRa.js +1 -0
- package/dist/assets/{ProxyStatsModal-B8K0AtAq.js → ProxyStatsModal-Dhj4EVnh.js} +1 -1
- package/dist/assets/index-sgW8MlD3.js +2 -0
- package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
- package/dist/assets/seqResourceLoaders-Cj0pdz-l.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/ask/perm-bridge.js +4 -1
- package/server/lib/auto-approve.js +160 -0
- package/server/lib/chat-queue.js +350 -120
- package/server/lib/file-access-policy.js +24 -2
- package/server/lib/scheduled-task-executor.js +3 -1
- package/server/lib/sdk-manager.js +377 -76
- 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/ask-perm.js +54 -3
- package/server/routes/files-content.js +352 -16
- 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/routes/skills.js +19 -8
- package/server/server.js +205 -34
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
- package/ultraAgents/code-expert.json +1 -1
- 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/index-CU0gt6m1.js +0 -2
- package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
- 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)
|
|
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
|
|
361
|
-
// request's .finally can't clear the flag of a
|
|
362
|
-
//
|
|
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:
|
|
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.
|
|
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)
|
|
530
|
-
|
|
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
|
-
|
|
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 (
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
|
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
|
|
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
|
|
1140
|
-
* usage:null clear frame
|
|
1141
|
-
*
|
|
1142
|
-
* keeps the last known value,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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:
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
* (
|
|
1651
|
-
*
|
|
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
|
-
*
|
|
1654
|
-
*
|
|
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)
|
|
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
|
|
1669
|
-
// `!_suppressDrain` condition would exit with the item
|
|
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 (
|
|
1673
|
-
//
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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
|
-
|
|
1682
|
-
|
|
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
|
-
|
|
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:
|