cc-viewer 1.9.2 → 1.9.3
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/README.md +0 -4
- package/dist/assets/{App-DPRn6oGn.js → App-BzXO6ATr.js} +2 -2
- package/dist/assets/{MdxEditorPanel-Ci4ldhd7.js → MdxEditorPanel-Ck8B5a5v.js} +1 -1
- package/dist/assets/{Mobile-Dq-KcO1s.js → Mobile-B8AkKb0z.js} +1 -1
- package/dist/assets/{ProxyStatsModal-CUFpqt1s.js → ProxyStatsModal-DsDVMf-c.js} +1 -1
- package/dist/assets/{index-Ci38xZiX.js → index-Dq5UPr6v.js} +2 -2
- package/dist/assets/seqResourceLoaders-BsQ6KXpV.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/sdk-manager.js +519 -70
- package/server/routes/events.js +47 -6
- package/server/routes/resume.js +12 -0
- package/dist/assets/seqResourceLoaders-B0p7e_lM.js +0 -2
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* sdk-manager.js — Agent SDK session lifecycle manager.
|
|
3
3
|
*
|
|
4
4
|
* Wraps @anthropic-ai/claude-agent-sdk query() to provide:
|
|
5
|
-
* - User-message input
|
|
5
|
+
* - User-message input over a RESIDENT streaming-input query (one CLI child process
|
|
6
|
+
* per ccv session; turns are pushed as SDKUserMessages into a never-ending input
|
|
7
|
+
* queue). Resident mode unlocks the real control surface (interrupt() etc.) —
|
|
8
|
+
* single-prompt mode cannot interrupt and had to kill the child on every Stop.
|
|
6
9
|
* - canUseTool callback for AskUserQuestion + permission approval
|
|
7
10
|
* - Turn-end signaling (SDK 'result' message → Stop-hook equivalent)
|
|
8
11
|
*
|
|
@@ -17,6 +20,7 @@ import { ASK_TIMEOUT_MS } from './ask/ask-constants.js';
|
|
|
17
20
|
import { withDefaultThinkingDisplay, resolveLaunchSystemPrompt, launchArgsToExtraArgs } from './launch-config.js';
|
|
18
21
|
import { evaluateImDeny } from './im-deny.js';
|
|
19
22
|
import { APPROVAL_TOOLS, isPublishCommand } from './approval-policy.js';
|
|
23
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
20
24
|
|
|
21
25
|
let _query;
|
|
22
26
|
try {
|
|
@@ -40,8 +44,29 @@ let _launchModel = null; // --model lifted from user args (options.model)
|
|
|
40
44
|
let _launchResume = null; // startup continuation intent { continue, resumeId, forkSession } — first query only
|
|
41
45
|
let _userArgs = []; // remaining user args → options.extraArgs passthrough
|
|
42
46
|
let _claudeExecutable = null; // pathToClaudeCodeExecutable — resolved with the same priority as PTY mode (configured → codefuse → native → PATH → npm) so SDK sessions don't fall back to a PATH claude that host security policy may kill on headless spawn
|
|
47
|
+
// Resident-query state. `_activeQuery` (the Query handle) and `_queryBusy` (a turn in
|
|
48
|
+
// flight) are deliberately decoupled: an idle resident query is alive but not busy.
|
|
43
49
|
let _activeQuery = null;
|
|
44
|
-
let
|
|
50
|
+
let _inputQueue = null; // push-queue AsyncIterable handed to query() as the prompt
|
|
51
|
+
let _queryBusy = false; // a turn is in flight (including ensure + interrupt unwind)
|
|
52
|
+
let _switching = false; // teardown window of switchToSession — rejects new turns/switches
|
|
53
|
+
let _pendingTurns = []; // FIFO [{ resolve, interrupted, settled }] — one per pushed message
|
|
54
|
+
let _turnInFlight = false; // sticky "a pushed turn has not produced its result" flag —
|
|
55
|
+
// interruptTurn keys off this, NOT _pendingTurns.length (the
|
|
56
|
+
// grace/watchdog force-settle paths drain the FIFO while the
|
|
57
|
+
// CLI-side turn may still be running; without the sticky flag a
|
|
58
|
+
// second Stop would silently no-op).
|
|
59
|
+
let _settleGraceTimer = null; // backstop after interrupt: force-settle if no result arrives
|
|
60
|
+
let _lateResultTombstones = 0; // results to discard (already force-settled via grace/watchdog)
|
|
61
|
+
let _consecutiveDeaths = 0; // backstop against respawn storms in a broken environment
|
|
62
|
+
let _turnWatchdog = null; // SILENCE watchdog: re-armed on every incoming message; fires only
|
|
63
|
+
// when the session produces nothing for the whole window
|
|
64
|
+
const INTERRUPT_GRACE_MS = 10 * 1000;
|
|
65
|
+
// Silence window for the watchdog. Must stay above the approval ceilings the turn can
|
|
66
|
+
// legally wait on: perm/plan = 5min, AskUserQuestion = 24h ("GUI effectively has no
|
|
67
|
+
// timeout" is a product contract). A pending approval exempts the turn from the
|
|
68
|
+
// watchdog entirely; 25min covers long tool runs (big builds) with headroom.
|
|
69
|
+
const TURN_WATCHDOG_MS = 25 * 60 * 1000;
|
|
45
70
|
let _initAnnouncedSid = ''; // sessionId whose slash-command surface was already announced (reset on full reset)
|
|
46
71
|
let _lastInitSnapshot = null; // last sdk-init payload for WS reconnect replay
|
|
47
72
|
|
|
@@ -115,39 +140,35 @@ export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broa
|
|
|
115
140
|
_launchResume = launchResume || null;
|
|
116
141
|
_userArgs = Array.isArray(userArgs) ? userArgs : [];
|
|
117
142
|
_claudeExecutable = claudeExecutable || null;
|
|
143
|
+
// Re-init (tests / hot reload) must not leave a zombie resident generation alive.
|
|
144
|
+
_teardownResident();
|
|
118
145
|
_resetFullState();
|
|
119
146
|
}
|
|
120
147
|
|
|
121
148
|
/**
|
|
122
|
-
* Send a user message.
|
|
123
|
-
*
|
|
149
|
+
* Send a user message. Lazily constructs the resident query on the first message
|
|
150
|
+
* (a startup-continuation launchResume applies to that first construction only).
|
|
151
|
+
* Queues the message if a turn is already in flight (ccv's queue remains the single
|
|
152
|
+
* source of truth — only one message is pushed to the SDK per turn boundary, so the
|
|
153
|
+
* CLI's own command queue stays empty and Stop keeps its "park the bubbles" semantics).
|
|
154
|
+
*
|
|
155
|
+
* Never rejects for turn/session failures (those surface via onQueryError toasts);
|
|
156
|
+
* the only throw is the synchronous "SDK not available" guard cli.js relies on.
|
|
124
157
|
*/
|
|
125
158
|
export async function sendUserMessage(text) {
|
|
126
159
|
if (!_query) throw new Error('Agent SDK not available');
|
|
127
160
|
|
|
128
|
-
// If a
|
|
161
|
+
// If a turn is already running, queue this message and return
|
|
129
162
|
if (_queryBusy) {
|
|
130
163
|
_messageQueue.push(_makeQueueItem(text));
|
|
131
164
|
_broadcastQueueState();
|
|
132
165
|
return;
|
|
133
166
|
}
|
|
134
167
|
|
|
135
|
-
_queryBusy = true;
|
|
136
168
|
// A fresh user-initiated turn re-arms automatic draining of parked (Stop-kept) items.
|
|
137
169
|
_suppressDrain = false;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
await _executeQuery(text);
|
|
141
|
-
|
|
142
|
-
// Process any queued messages
|
|
143
|
-
while (_messageQueue.length > 0 && !_suppressDrain) {
|
|
144
|
-
const next = _messageQueue.shift();
|
|
145
|
-
_broadcastQueueState();
|
|
146
|
-
await _executeQuery(next.text);
|
|
147
|
-
}
|
|
148
|
-
} finally {
|
|
149
|
-
_queryBusy = false;
|
|
150
|
-
}
|
|
170
|
+
_consecutiveDeaths = 0; // explicit user action re-arms the death-drain backstop
|
|
171
|
+
await _startTurn(text);
|
|
151
172
|
}
|
|
152
173
|
|
|
153
174
|
function _makeQueueItem(text) {
|
|
@@ -167,9 +188,151 @@ function _broadcastQueueState() {
|
|
|
167
188
|
}
|
|
168
189
|
|
|
169
190
|
/**
|
|
170
|
-
*
|
|
191
|
+
* Never-ending push queue used as the resident query's streaming input.
|
|
192
|
+
* HARD INVARIANT: with canUseTool mounted, the SDK ends the child's stdin (killing
|
|
193
|
+
* the session) when this iterable completes — close() is reserved for teardown, and
|
|
194
|
+
* push() after close must silently drop (never throw: a throw would propagate into
|
|
195
|
+
* the SDK's streamInput catch and abort the whole query).
|
|
171
196
|
*/
|
|
172
|
-
|
|
197
|
+
function _makePushQueue() {
|
|
198
|
+
const items = [];
|
|
199
|
+
let waiter = null;
|
|
200
|
+
let closed = false;
|
|
201
|
+
const q = {
|
|
202
|
+
push(item) {
|
|
203
|
+
if (closed) return; // teardown race — drop, never throw (see header comment)
|
|
204
|
+
if (waiter) { const w = waiter; waiter = null; w({ value: item, done: false }); }
|
|
205
|
+
else items.push(item);
|
|
206
|
+
},
|
|
207
|
+
close() {
|
|
208
|
+
if (closed) return;
|
|
209
|
+
closed = true;
|
|
210
|
+
items.length = 0; // a dying generation must not leak its items into the next one
|
|
211
|
+
if (waiter) { const w = waiter; waiter = null; w({ value: undefined, done: true }); }
|
|
212
|
+
},
|
|
213
|
+
isClosed() { return closed; },
|
|
214
|
+
next() {
|
|
215
|
+
if (items.length) return Promise.resolve({ value: items.shift(), done: false });
|
|
216
|
+
// A closed queue keeps resolving done on EVERY call — the SDK's consumer stops
|
|
217
|
+
// iterating after the first done, and a stale waiter must never capture a
|
|
218
|
+
// future generation's message.
|
|
219
|
+
if (closed) return Promise.resolve({ value: undefined, done: true });
|
|
220
|
+
return new Promise((resolve) => { waiter = resolve; });
|
|
221
|
+
},
|
|
222
|
+
async return() { q.close(); return { value: undefined, done: true }; },
|
|
223
|
+
[Symbol.asyncIterator]() { return this; },
|
|
224
|
+
};
|
|
225
|
+
return q;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function _userMessage(text) {
|
|
229
|
+
return {
|
|
230
|
+
type: 'user',
|
|
231
|
+
message: { role: 'user', content: [{ type: 'text', text }] },
|
|
232
|
+
parent_tool_use_id: null,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Turn records (FIFO pairing of pushed messages ↔ result messages) ──
|
|
237
|
+
// Every pushed user message eventually yields exactly one result from the CLI
|
|
238
|
+
// (an interrupted turn still synthesizes an error_during_execution result). All
|
|
239
|
+
// settle paths (result / grace force-settle / teardown settleAll) go through
|
|
240
|
+
// _settleTurn, which is idempotent via rec.settled.
|
|
241
|
+
function _beginTurn() {
|
|
242
|
+
let resolve;
|
|
243
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
244
|
+
const rec = { resolve, interrupted: false, settled: false };
|
|
245
|
+
_pendingTurns.push(rec);
|
|
246
|
+
return { rec, promise };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _settleTurn(rec) {
|
|
250
|
+
if (!rec || rec.settled) return;
|
|
251
|
+
rec.settled = true;
|
|
252
|
+
_clearGraceTimer();
|
|
253
|
+
rec.resolve();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function _settleAllTurns() {
|
|
257
|
+
for (const rec of _pendingTurns) _settleTurn(rec);
|
|
258
|
+
_pendingTurns = [];
|
|
259
|
+
_queryBusy = false;
|
|
260
|
+
_turnInFlight = false;
|
|
261
|
+
_clearTurnWatchdog();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function _clearGraceTimer() {
|
|
265
|
+
if (_settleGraceTimer) { clearTimeout(_settleGraceTimer); _settleGraceTimer = null; }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Soft SILENCE watchdog: re-armed on every incoming SDK message, so it only fires
|
|
269
|
+
* when the session has produced nothing for the full window — a genuinely stuck/dead
|
|
270
|
+
* turn. Pending approvals prove the session is alive (the child is parked waiting for
|
|
271
|
+
* our control response), so the watchdog merely re-arms in that case. Force-settle is
|
|
272
|
+
* symmetric with the interrupt grace path: settle the head turn FIRST (its promise
|
|
273
|
+
* must resolve), then tombstone the result that may still arrive. */
|
|
274
|
+
function _armTurnWatchdog() {
|
|
275
|
+
_clearTurnWatchdog();
|
|
276
|
+
if (_pendingTurns.length === 0) return;
|
|
277
|
+
_turnWatchdog = setTimeout(() => {
|
|
278
|
+
_turnWatchdog = null;
|
|
279
|
+
if (_pendingTurns.length === 0) return;
|
|
280
|
+
if (_pendingApprovals.size > 0) {
|
|
281
|
+
_armTurnWatchdog(); // approval pending = alive; wait out the approval timeout instead
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
console.warn('[sdk-manager] turn silent beyond watchdog window — force-settling (possible silent session death)');
|
|
285
|
+
_lateResultTombstones++;
|
|
286
|
+
_settleAllTurns();
|
|
287
|
+
_maybeDrain();
|
|
288
|
+
}, TURN_WATCHDOG_MS);
|
|
289
|
+
if (_turnWatchdog.unref) _turnWatchdog.unref();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function _clearTurnWatchdog() {
|
|
293
|
+
if (_turnWatchdog) { clearTimeout(_turnWatchdog); _turnWatchdog = null; }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Start one turn: park a turn record, ensure the resident query exists, push the
|
|
298
|
+
* message. Resolves when the turn's result arrives (or on teardown/death settle).
|
|
299
|
+
*/
|
|
300
|
+
async function _startTurn(text) {
|
|
301
|
+
const { rec, promise } = _beginTurn();
|
|
302
|
+
_queryBusy = true;
|
|
303
|
+
try {
|
|
304
|
+
await _ensureResidentQuery();
|
|
305
|
+
} catch (err) {
|
|
306
|
+
// Construction itself failed (e.g. options assembly) — settle immediately.
|
|
307
|
+
_pendingTurns = _pendingTurns.filter((r) => r !== rec);
|
|
308
|
+
_settleTurn(rec);
|
|
309
|
+
_queryBusy = false;
|
|
310
|
+
_notifyQueryError(err);
|
|
311
|
+
return promise;
|
|
312
|
+
}
|
|
313
|
+
// Teardown/stop raced with construction — the settle already happened; don't push.
|
|
314
|
+
if (rec.settled) return promise;
|
|
315
|
+
if (!_activeQuery || !_inputQueue) {
|
|
316
|
+
_pendingTurns = _pendingTurns.filter((r) => r !== rec);
|
|
317
|
+
_settleTurn(rec);
|
|
318
|
+
_queryBusy = false;
|
|
319
|
+
return promise;
|
|
320
|
+
}
|
|
321
|
+
_inputQueue.push(_userMessage(text));
|
|
322
|
+
_turnInFlight = true;
|
|
323
|
+
_armTurnWatchdog();
|
|
324
|
+
return promise;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Construct the resident query exactly once (no-op while one is alive). The first
|
|
329
|
+
* construction consumes the startup continuation intent (_launchResume); later
|
|
330
|
+
* reconstructions (after a session-level death) resume the captured _sessionId.
|
|
331
|
+
*/
|
|
332
|
+
async function _ensureResidentQuery() {
|
|
333
|
+
if (_activeQuery) return;
|
|
334
|
+
if (!_query) throw new Error('Agent SDK not available');
|
|
335
|
+
|
|
173
336
|
const options = {
|
|
174
337
|
cwd: _cwd,
|
|
175
338
|
permissionMode: _permissionMode,
|
|
@@ -190,8 +353,10 @@ async function _executeQuery(text) {
|
|
|
190
353
|
// PATH, which on guarded hosts picks a binary that gets SIGKILLed on headless spawn.
|
|
191
354
|
if (_claudeExecutable) options.pathToClaudeCodeExecutable = _claudeExecutable;
|
|
192
355
|
|
|
193
|
-
// Resume semantics:
|
|
194
|
-
//
|
|
356
|
+
// Resume semantics: a captured session id (mid-session reconstruction) resumes it;
|
|
357
|
+
// the FIRST construction of a startup-continuation launch (-c/-r/--fork-session)
|
|
358
|
+
// uses the launch intent. options.resume only takes effect at construction time —
|
|
359
|
+
// switching sessions therefore means teardown + reconstruct (see switchToSession).
|
|
195
360
|
let resumeIntent = null;
|
|
196
361
|
if (_sessionId) {
|
|
197
362
|
options.resume = _sessionId;
|
|
@@ -224,26 +389,110 @@ async function _executeQuery(text) {
|
|
|
224
389
|
options.extraArgs = launchArgsToExtraArgs(launchArgs);
|
|
225
390
|
}
|
|
226
391
|
|
|
227
|
-
|
|
228
|
-
|
|
392
|
+
const inputQueue = _makePushQueue();
|
|
393
|
+
const query = _query({ prompt: inputQueue, options });
|
|
394
|
+
_inputQueue = inputQueue;
|
|
395
|
+
_activeQuery = query;
|
|
396
|
+
// Background iteration — messages flow in for the life of the session. Its end
|
|
397
|
+
// (clean or thrown) means the child process is gone: session-level death.
|
|
398
|
+
_drainResidentIterator(query, inputQueue);
|
|
399
|
+
}
|
|
229
400
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
try { _onQueryError(String(err?.message || err)); }
|
|
241
|
-
catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
|
|
401
|
+
async function _drainResidentIterator(query, inputQueue) {
|
|
402
|
+
try {
|
|
403
|
+
for await (const msg of query) {
|
|
404
|
+
// A message-processing failure (e.g. a broadcast callback throwing) must NOT be
|
|
405
|
+
// mistaken for session death — the child is still alive; only the iterator's
|
|
406
|
+
// end/throw is a death signal.
|
|
407
|
+
try {
|
|
408
|
+
_processMessage(msg);
|
|
409
|
+
} catch (msgErr) {
|
|
410
|
+
reportSwallowed('sdk-manager.process-message', msgErr);
|
|
242
411
|
}
|
|
412
|
+
_armTurnWatchdog(); // silence watchdog: any message proves the session is alive
|
|
243
413
|
}
|
|
244
|
-
|
|
245
|
-
|
|
414
|
+
_onResidentDeath(query, inputQueue, null);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
_onResidentDeath(query, inputQueue, err);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Session-level death (iterator ended or threw). Stale generations (a query already
|
|
422
|
+
* replaced by teardown/switch) are ignored. The message queue PARKS instead of
|
|
423
|
+
* draining — the next explicit user message / send-now lazily reconstructs the
|
|
424
|
+
* resident query (resuming _sessionId when one was captured).
|
|
425
|
+
*/
|
|
426
|
+
function _onResidentDeath(query, inputQueue, err) {
|
|
427
|
+
if (_activeQuery !== query) return; // stale generation — teardown already moved on
|
|
428
|
+
_activeQuery = null;
|
|
429
|
+
_inputQueue = null;
|
|
430
|
+
_clearGraceTimer();
|
|
431
|
+
_clearTurnWatchdog();
|
|
432
|
+
_lateResultTombstones = 0;
|
|
433
|
+
if (err && err.name !== 'AbortError') {
|
|
434
|
+
console.error('[SDK] Resident query error:', err.message);
|
|
435
|
+
_notifyQueryError(err);
|
|
436
|
+
}
|
|
437
|
+
_settleAllTurns();
|
|
438
|
+
// Flush pending approvals with a dismiss broadcast, else their modals hang forever.
|
|
439
|
+
const pending = Array.from(_pendingApprovals, ([id, p]) => ({ id, kind: p.kind || null }));
|
|
440
|
+
for (const { id, kind } of pending) {
|
|
441
|
+
_pendingApprovals.get(id)?.resolve(null);
|
|
442
|
+
_broadcastApprovalDismiss(kind, id, 'session-ended');
|
|
246
443
|
}
|
|
444
|
+
_pendingApprovals.clear();
|
|
445
|
+
// Best-effort child cleanup: if the iterator died without the process exiting (rare
|
|
446
|
+
// but possible on transport errors), don't orphan the resident CLI.
|
|
447
|
+
try { if (typeof query.close === 'function') query.close(); }
|
|
448
|
+
catch (closeErr) { reportSwallowed('sdk-manager.death-close', closeErr); }
|
|
449
|
+
// Release the SDK's streamInput consumer parked on the old queue's next() waiter.
|
|
450
|
+
if (inputQueue && !inputQueue.isClosed()) inputQueue.close();
|
|
451
|
+
// Keep the pipeline moving: the queue is not suppressed here (only interruptTurn/Stop
|
|
452
|
+
// parks it), so a queued message drains into a lazily-reconstructed session. A broken
|
|
453
|
+
// environment stops that loop after two consecutive deaths (drain pauses parked;
|
|
454
|
+
// the next explicit user message re-arms it) instead of burning one spawn + error
|
|
455
|
+
// toast per queued message.
|
|
456
|
+
_consecutiveDeaths++;
|
|
457
|
+
if (_consecutiveDeaths <= 2) _maybeDrain();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Orderly teardown of the resident query (stopSession / session switch). Nulls the
|
|
462
|
+
* handles FIRST so the dying iterator's end is dismissed as stale, then ends the
|
|
463
|
+
* input queue (lets the SDK close stdin gracefully) and closes the query (terminates
|
|
464
|
+
* the child). Settles every in-flight turn promise so no caller hangs.
|
|
465
|
+
*/
|
|
466
|
+
function _teardownResident() {
|
|
467
|
+
const query = _activeQuery;
|
|
468
|
+
const inputQueue = _inputQueue;
|
|
469
|
+
_activeQuery = null;
|
|
470
|
+
_inputQueue = null;
|
|
471
|
+
_clearGraceTimer();
|
|
472
|
+
_clearTurnWatchdog();
|
|
473
|
+
_lateResultTombstones = 0;
|
|
474
|
+
_settleAllTurns();
|
|
475
|
+
if (inputQueue) inputQueue.close();
|
|
476
|
+
if (query) {
|
|
477
|
+
try { if (typeof query.close === 'function') query.close(); }
|
|
478
|
+
catch (err) { reportSwallowed('sdk-manager.teardown-close', err); }
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Turn-level error toast (callback throw must never break the caller). */
|
|
483
|
+
function _notifyQueryError(err) {
|
|
484
|
+
if (!_onQueryError) return;
|
|
485
|
+
try { _onQueryError(String(err?.message || err)); }
|
|
486
|
+
catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Drain one queued message after a turn boundary (result-driven, not loop-driven). */
|
|
490
|
+
function _maybeDrain() {
|
|
491
|
+
if (_suppressDrain || _queryBusy || _switching) return;
|
|
492
|
+
if (_messageQueue.length === 0) return;
|
|
493
|
+
const next = _messageQueue.shift();
|
|
494
|
+
_broadcastQueueState();
|
|
495
|
+
sendUserMessage(next.text).catch((err) => reportSwallowed('sdk-manager.drain', err));
|
|
247
496
|
}
|
|
248
497
|
|
|
249
498
|
/**
|
|
@@ -279,6 +528,16 @@ function _processMessage(msg) {
|
|
|
279
528
|
preTokens: meta && meta.pre_tokens,
|
|
280
529
|
postTokens: meta && meta.post_tokens,
|
|
281
530
|
});
|
|
531
|
+
} else if (_broadcastWs
|
|
532
|
+
&& (msg.subtype === 'task_started' || msg.subtype === 'task_progress'
|
|
533
|
+
|| msg.subtype === 'task_notification' || msg.subtype === 'task_updated')) {
|
|
534
|
+
// SDK background-task / subagent progress (SDK 0.2.x system subtypes). The TODO
|
|
535
|
+
// HUD (TaskProgressHud) is hook-fed and never fires in SDK mode; this is a
|
|
536
|
+
// separate, lossless relay so clients can observe task lifecycle (incl.
|
|
537
|
+
// failed/stopped/killed terminal states the TODO HUD vocabulary lacks). The
|
|
538
|
+
// server keeps no task table here — pure forward, merge semantics left to the
|
|
539
|
+
// client (task_updated.patch is a wire-safe delta, not a full snapshot).
|
|
540
|
+
_broadcastWs({ type: 'sdk-task', ..._normalizeSdkTask(msg) });
|
|
282
541
|
}
|
|
283
542
|
break;
|
|
284
543
|
|
|
@@ -291,9 +550,27 @@ function _processMessage(msg) {
|
|
|
291
550
|
if (msg.session_id) _sessionId = msg.session_id;
|
|
292
551
|
break;
|
|
293
552
|
|
|
294
|
-
case 'result':
|
|
553
|
+
case 'result': {
|
|
295
554
|
if (msg.session_id) _sessionId = msg.session_id;
|
|
296
|
-
|
|
555
|
+
// Grace/watchdog tombstone: a result that arrives AFTER its turn was already
|
|
556
|
+
// force-settled belongs to that dead turn — discard it instead of letting it
|
|
557
|
+
// settle (and prematurely end) the NEXT turn. If the tombstone outlived its turn
|
|
558
|
+
// entirely (the owed result never comes) the count would swallow a FUTURE turn's
|
|
559
|
+
// result — the bookkeeping clear below keeps that mistake bounded to the
|
|
560
|
+
// turn-end signal rather than wedging _queryBusy.
|
|
561
|
+
if (_lateResultTombstones > 0) {
|
|
562
|
+
_lateResultTombstones--;
|
|
563
|
+
_clearGraceTimer();
|
|
564
|
+
_queryBusy = false;
|
|
565
|
+
_turnInFlight = false;
|
|
566
|
+
if (_pendingTurns.length === 0) _clearTurnWatchdog();
|
|
567
|
+
_maybeDrain();
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
const rec = _pendingTurns.shift();
|
|
571
|
+
// An interrupted turn's result is expected to be an error (turn aborted) — don't
|
|
572
|
+
// toast it. Turn-end still fires, matching the PTY Stop-hook semantics.
|
|
573
|
+
if (rec && !rec.interrupted) _notifyTurnError(msg);
|
|
297
574
|
// SDK turn-end signal. Equivalent to Claude Code's Stop hook
|
|
298
575
|
// in CLI mode — fires once per user-prompt response when the whole chain
|
|
299
576
|
// (assistant text + all tool calls + final reply) completes. SDK mode
|
|
@@ -303,13 +580,85 @@ function _processMessage(msg) {
|
|
|
303
580
|
try { _onTurnEnd({ sessionId: _sessionId, ts: Date.now() }); }
|
|
304
581
|
catch (err) { console.warn('[sdk-manager] onTurnEnd threw:', err?.message); }
|
|
305
582
|
}
|
|
583
|
+
if (rec) _settleTurn(rec);
|
|
584
|
+
_clearGraceTimer();
|
|
585
|
+
_queryBusy = false;
|
|
586
|
+
_turnInFlight = false;
|
|
587
|
+
if (_pendingTurns.length === 0) _clearTurnWatchdog();
|
|
588
|
+
_maybeDrain();
|
|
306
589
|
break;
|
|
590
|
+
}
|
|
307
591
|
|
|
308
592
|
default:
|
|
309
593
|
break;
|
|
310
594
|
}
|
|
311
595
|
}
|
|
312
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Normalize an SDK `task_*` system message into the camelCase relay payload
|
|
599
|
+
* broadcast as `{ type: 'sdk-task', ... }`. Lossless pass-through (snake→camel
|
|
600
|
+
* only) — no status mapping, no server-side merge: `task_updated.patch` stays a
|
|
601
|
+
* delta for the client to apply, and `skip_transcript` is forwarded as
|
|
602
|
+
* `skipTranscript` so the UI stage can decide whether to hide ambient tasks.
|
|
603
|
+
*
|
|
604
|
+
* Two constraints the UI stage MUST respect (server-side, this round is pure relay):
|
|
605
|
+
* - TWO status vocabularies share the wire: `task_notification.status` is
|
|
606
|
+
* `completed|failed|stopped`, while `task_updated.patch.status` is
|
|
607
|
+
* `pending|running|completed|failed|killed` — adjacent but disjoint domains.
|
|
608
|
+
* A client that naively merges `{...task, ...frame}` then `{...frame.patch}`
|
|
609
|
+
* will mis-render; discriminate by `subtype` before reading `status`.
|
|
610
|
+
* - NO server-side task table → NOT reconnect-replayed (unlike sdk-init's
|
|
611
|
+
* `_lastInitSnapshot`). A frame missed during a WS gap (page refresh / 2s
|
|
612
|
+
* reconnect) is NOT recoverable; an in-flight task is invisible to a late
|
|
613
|
+
* client. Accept-as-ephemeral vs add a bounded last-state map is a UI-stage
|
|
614
|
+
* decision (see sdk-mode-todo-list; if replay is wanted, reuse task-state.js's
|
|
615
|
+
* reducer semantics rather than growing a second table here).
|
|
616
|
+
*
|
|
617
|
+
* Note: the 4 subtypes relayed here are the PUBLIC contract (sdk.d.ts SDKMessage
|
|
618
|
+
* union). The SDK additionally emits @internal `task_summary` / `post_turn_summary`
|
|
619
|
+
* system subtypes which are deliberately NOT relayed this round — pending real-device
|
|
620
|
+
* observation (smoke item ⑧) of whether they carry signal worth a sniff-relay.
|
|
621
|
+
*/
|
|
622
|
+
function _normalizeSdkTask(msg) {
|
|
623
|
+
const out = {
|
|
624
|
+
subtype: msg.subtype,
|
|
625
|
+
sessionId: msg.session_id || null,
|
|
626
|
+
taskId: msg.task_id != null ? String(msg.task_id) : null,
|
|
627
|
+
};
|
|
628
|
+
if (msg.tool_use_id != null) out.toolUseId = msg.tool_use_id;
|
|
629
|
+
if (msg.description != null) out.description = msg.description;
|
|
630
|
+
if (msg.summary != null) out.summary = msg.summary;
|
|
631
|
+
if (msg.status != null) out.status = msg.status;
|
|
632
|
+
if (msg.task_type != null) out.taskType = msg.task_type;
|
|
633
|
+
if (msg.workflow_name != null) out.workflowName = msg.workflow_name;
|
|
634
|
+
if (msg.last_tool_name != null) out.lastToolName = msg.last_tool_name;
|
|
635
|
+
if (msg.output_file != null) out.outputFile = msg.output_file;
|
|
636
|
+
if (msg.prompt != null) out.prompt = msg.prompt;
|
|
637
|
+
if (msg.skip_transcript != null) out.skipTranscript = msg.skip_transcript === true;
|
|
638
|
+
if (msg.usage && typeof msg.usage === 'object') {
|
|
639
|
+
out.usage = {
|
|
640
|
+
totalTokens: msg.usage.total_tokens ?? null,
|
|
641
|
+
toolUses: msg.usage.tool_uses ?? null,
|
|
642
|
+
durationMs: msg.usage.duration_ms ?? null,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
// task_updated: patch is a merge delta (wire-safe subset of TaskState). Forward
|
|
646
|
+
// verbatim-in-shape (camelCased) so the client merges into its own task map.
|
|
647
|
+
// An all-empty patch ({}) is a harmless no-op frame — skip it to spare a broadcast.
|
|
648
|
+
if (msg.patch && typeof msg.patch === 'object') {
|
|
649
|
+
const p = msg.patch;
|
|
650
|
+
const patch = {};
|
|
651
|
+
if (p.status != null) patch.status = p.status;
|
|
652
|
+
if (p.description != null) patch.description = p.description;
|
|
653
|
+
if (p.end_time != null) patch.endTime = p.end_time;
|
|
654
|
+
if (p.total_paused_ms != null) patch.totalPausedMs = p.total_paused_ms;
|
|
655
|
+
if (p.error != null) patch.error = p.error;
|
|
656
|
+
if (p.is_backgrounded != null) patch.isBackgrounded = p.is_backgrounded;
|
|
657
|
+
if (Object.keys(patch).length > 0) out.patch = patch;
|
|
658
|
+
}
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
661
|
+
|
|
313
662
|
/**
|
|
314
663
|
* Surface a failed turn to the Web UI — the wire path persists the failed entry but
|
|
315
664
|
* never pushes a toast, so a dead/errored turn would otherwise look like a hang.
|
|
@@ -375,7 +724,7 @@ async function _handleCanUseTool(toolName, input, options) {
|
|
|
375
724
|
if (_broadcastWs) {
|
|
376
725
|
_broadcastWs(planPayload);
|
|
377
726
|
}
|
|
378
|
-
const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload);
|
|
727
|
+
const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal);
|
|
379
728
|
if (result === null) {
|
|
380
729
|
return { behavior: 'deny', message: 'Timeout waiting for plan approval' };
|
|
381
730
|
}
|
|
@@ -409,7 +758,7 @@ async function _handleCanUseTool(toolName, input, options) {
|
|
|
409
758
|
if (_broadcastWs) {
|
|
410
759
|
_broadcastWs(askPayload);
|
|
411
760
|
}
|
|
412
|
-
const answers = await _waitForApproval(id, askTimeoutMs, 'ask', askPayload);
|
|
761
|
+
const answers = await _waitForApproval(id, askTimeoutMs, 'ask', askPayload, options?.signal);
|
|
413
762
|
if (answers === null) {
|
|
414
763
|
return { behavior: 'deny', message: 'Timeout waiting for user answer' };
|
|
415
764
|
}
|
|
@@ -454,7 +803,7 @@ async function _handleCanUseTool(toolName, input, options) {
|
|
|
454
803
|
_broadcastWs(permPayload);
|
|
455
804
|
}
|
|
456
805
|
|
|
457
|
-
const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload);
|
|
806
|
+
const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal);
|
|
458
807
|
if (result === null) {
|
|
459
808
|
return { behavior: 'deny', message: 'Timeout waiting for user approval' };
|
|
460
809
|
}
|
|
@@ -475,10 +824,32 @@ async function _handleCanUseTool(toolName, input, options) {
|
|
|
475
824
|
return response;
|
|
476
825
|
}
|
|
477
826
|
|
|
478
|
-
|
|
827
|
+
/**
|
|
828
|
+
* Broadcast a modal-dismiss for a pending approval that ended WITHOUT a user answer
|
|
829
|
+
* (timeout / session teardown / CLI-side abort). Maps to the close types the web
|
|
830
|
+
* clients already handle by id:
|
|
831
|
+
* ask → 'sdk-ask-timeout' (askFlowController), plan → 'sdk-plan-resolved',
|
|
832
|
+
* perm → 'perm-hook-timeout' (same type the PTY hook path emits).
|
|
833
|
+
* Deliberately NOT routed through sdk-adapter's sdkApprovalCloseType: that maps ask →
|
|
834
|
+
* 'ask-hook-cancelled', whose handler flushes the user message parked behind the ask —
|
|
835
|
+
* wrong for a timeout/dismiss. Frontend handlers match on `id` only; extra fields are inert.
|
|
836
|
+
*/
|
|
837
|
+
function _broadcastApprovalDismiss(kind, id, reason) {
|
|
838
|
+
if (!_broadcastWs) return;
|
|
839
|
+
const type = kind === 'ask' ? 'sdk-ask-timeout'
|
|
840
|
+
: kind === 'plan' ? 'sdk-plan-resolved'
|
|
841
|
+
: 'perm-hook-timeout';
|
|
842
|
+
try {
|
|
843
|
+
_broadcastWs({ type, id, reason });
|
|
844
|
+
} catch (err) { console.warn('[sdk-manager] approval-dismiss broadcast threw:', err?.message); }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null) {
|
|
479
848
|
return new Promise((resolve) => {
|
|
480
849
|
const timer = setTimeout(() => {
|
|
481
850
|
_pendingApprovals.delete(id);
|
|
851
|
+
// Close the modal on every client — a silent deny would leave it hanging forever.
|
|
852
|
+
_broadcastApprovalDismiss(kind, id, 'timeout');
|
|
482
853
|
resolve(null);
|
|
483
854
|
}, timeoutMs);
|
|
484
855
|
_pendingApprovals.set(id, {
|
|
@@ -493,6 +864,20 @@ function _waitForApproval(id, timeoutMs, kind, replay = null) {
|
|
|
493
864
|
resolve(value);
|
|
494
865
|
},
|
|
495
866
|
});
|
|
867
|
+
// CLI-side abort of the in-flight control request (e.g. interrupt while the modal is
|
|
868
|
+
// open): settle through the same first-wins path as a timeout so the promise resolves
|
|
869
|
+
// to deny and the modal closes everywhere. { once: true } prevents listener leaks.
|
|
870
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
871
|
+
if (signal.aborted) {
|
|
872
|
+
const pending = _pendingApprovals.get(id);
|
|
873
|
+
if (pending) { _broadcastApprovalDismiss(kind, id, 'aborted'); pending.resolve(null); }
|
|
874
|
+
} else {
|
|
875
|
+
signal.addEventListener('abort', () => {
|
|
876
|
+
const pending = _pendingApprovals.get(id);
|
|
877
|
+
if (pending) { _broadcastApprovalDismiss(kind, id, 'aborted'); pending.resolve(null); }
|
|
878
|
+
}, { once: true });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
496
881
|
});
|
|
497
882
|
}
|
|
498
883
|
|
|
@@ -553,42 +938,52 @@ export function cancelApproval(id, reason) {
|
|
|
553
938
|
}
|
|
554
939
|
|
|
555
940
|
/**
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
* close the active query iterator. `_executeQuery`'s finally block then nulls
|
|
559
|
-
* `_activeQuery`; `sendUserMessage`'s finally clears `_queryBusy`.
|
|
941
|
+
* Cancel all in-flight approvals (else their canUseTool promises stay parked and the
|
|
942
|
+
* timeout timers leak, and clients keep a ghost approval modal open).
|
|
560
943
|
*
|
|
561
944
|
* Returns the list of approvals that were pending (`[{ id, kind }]`) so the caller
|
|
562
945
|
* (server.js) can broadcast modal-close messages to every client. Always an array.
|
|
563
946
|
*/
|
|
564
|
-
function
|
|
947
|
+
function _cancelAllApprovals() {
|
|
565
948
|
const cancelled = Array.from(_pendingApprovals, ([id, pending]) => ({ id, kind: pending.kind || null }));
|
|
566
949
|
for (const { id } of cancelled) {
|
|
567
950
|
_pendingApprovals.get(id)?.resolve(null);
|
|
568
951
|
}
|
|
569
952
|
_pendingApprovals.clear();
|
|
570
|
-
|
|
571
|
-
if (_activeQuery) {
|
|
572
|
-
// Best-effort streaming-mode control request (no-op / rejects in single-prompt mode → swallow).
|
|
573
|
-
try { _activeQuery.interrupt?.().catch(() => {}); } catch {}
|
|
574
|
-
// Forcefully terminate the underlying CLI subprocess — aborts an in-flight query.
|
|
575
|
-
try { if (typeof _activeQuery.close === 'function') _activeQuery.close(); } catch {}
|
|
576
|
-
}
|
|
577
953
|
return cancelled;
|
|
578
954
|
}
|
|
579
955
|
|
|
956
|
+
/** Backstop: if no result shows up within the grace window after an interrupt, force-
|
|
957
|
+
* settle the head turn (keeping _queryBusy from wedging forever) and mark a tombstone
|
|
958
|
+
* so the result that eventually arrives is discarded instead of settling the next turn. */
|
|
959
|
+
function _armSettleGrace() {
|
|
960
|
+
_clearGraceTimer();
|
|
961
|
+
_settleGraceTimer = setTimeout(() => {
|
|
962
|
+
_settleGraceTimer = null;
|
|
963
|
+
const rec = _pendingTurns[0];
|
|
964
|
+
if (!rec || !rec.interrupted) return;
|
|
965
|
+
console.warn('[sdk-manager] no result within interrupt grace — force-settling the turn');
|
|
966
|
+
_lateResultTombstones++;
|
|
967
|
+
_pendingTurns.shift();
|
|
968
|
+
_settleTurn(rec);
|
|
969
|
+
_queryBusy = false;
|
|
970
|
+
if (_pendingTurns.length === 0) _clearTurnWatchdog();
|
|
971
|
+
_maybeDrain();
|
|
972
|
+
}, INTERRUPT_GRACE_MS);
|
|
973
|
+
if (_settleGraceTimer.unref) _settleGraceTimer.unref();
|
|
974
|
+
}
|
|
975
|
+
|
|
580
976
|
/**
|
|
581
977
|
* Interrupt the current turn (user clicked the Stop button) while KEEPING the
|
|
582
|
-
* session alive
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
* and the next sendUserMessage resumes via `options.resume`.
|
|
978
|
+
* session alive: the resident query is a streaming-input session, so interrupt()
|
|
979
|
+
* is a real control request — the CLI aborts the current turn and synthesizes an
|
|
980
|
+
* error result for it, and the conversation (and child process) survives.
|
|
586
981
|
*
|
|
587
982
|
* Contrast with `stopSession()` below, which is the hard process-exit cleanup
|
|
588
983
|
* that also nulls `_sessionId` (loses conversation continuity).
|
|
589
984
|
*
|
|
590
985
|
* Stop KEEPS queued messages (product decision, matching the web UX where queued bubbles
|
|
591
|
-
* stay parked after Stop): `_suppressDrain` stops
|
|
986
|
+
* stay parked after Stop): `_suppressDrain` stops the result-driven drain from running
|
|
592
987
|
* them right after the interrupt; the park lifts when a fresh user message / send-now
|
|
593
988
|
* starts a new turn. The queue-state broadcast lets clients keep their bubbles.
|
|
594
989
|
*
|
|
@@ -597,7 +992,23 @@ function _interruptActiveQuery() {
|
|
|
597
992
|
* messages to every client. Always an array (empty when nothing was pending).
|
|
598
993
|
*/
|
|
599
994
|
export function interruptTurn() {
|
|
600
|
-
const cancelled =
|
|
995
|
+
const cancelled = _cancelAllApprovals();
|
|
996
|
+
const q = _activeQuery;
|
|
997
|
+
// Key off the sticky in-flight flag, not the FIFO: the grace/watchdog force-settle
|
|
998
|
+
// paths drain _pendingTurns while the CLI-side turn may still be running — keying on
|
|
999
|
+
// the FIFO length would make a second Stop silently skip the interrupt request.
|
|
1000
|
+
if (q && _turnInFlight) {
|
|
1001
|
+
_turnInFlight = false; // don't send duplicate interrupts while the abort unwinds
|
|
1002
|
+
if (_pendingTurns.length > 0) _pendingTurns[0].interrupted = true;
|
|
1003
|
+
if (typeof q.interrupt === 'function') {
|
|
1004
|
+
try {
|
|
1005
|
+
// A reject here (e.g. the control request can't reach a dying child) is NOT a
|
|
1006
|
+
// death signal — only the iterator ending is. Report and move on.
|
|
1007
|
+
q.interrupt().catch((err) => reportSwallowed('sdk-manager.interrupt', err));
|
|
1008
|
+
} catch (err) { reportSwallowed('sdk-manager.interrupt', err); }
|
|
1009
|
+
}
|
|
1010
|
+
_armSettleGrace();
|
|
1011
|
+
}
|
|
601
1012
|
_suppressDrain = true;
|
|
602
1013
|
_broadcastQueueState();
|
|
603
1014
|
return cancelled;
|
|
@@ -639,7 +1050,8 @@ export function sendQueuedNow(id) {
|
|
|
639
1050
|
}
|
|
640
1051
|
|
|
641
1052
|
_messageQueue.unshift(item);
|
|
642
|
-
const cancelled =
|
|
1053
|
+
const cancelled = interruptTurn();
|
|
1054
|
+
_suppressDrain = false; // send-now overrides the Stop-park interruptTurn just set
|
|
643
1055
|
_broadcastQueueState();
|
|
644
1056
|
return cancelled;
|
|
645
1057
|
}
|
|
@@ -666,25 +1078,62 @@ export function getQueueSnapshot() {
|
|
|
666
1078
|
}
|
|
667
1079
|
|
|
668
1080
|
/**
|
|
669
|
-
* Stop the active SDK session
|
|
1081
|
+
* Stop the active SDK session (hard cleanup): tear down the resident query and reset
|
|
1082
|
+
* all session state, losing conversation continuity.
|
|
670
1083
|
*/
|
|
671
1084
|
export function stopSession() {
|
|
672
|
-
|
|
673
|
-
if (_activeQuery && typeof _activeQuery.close === 'function') {
|
|
674
|
-
_activeQuery.close();
|
|
675
|
-
}
|
|
1085
|
+
_teardownResident();
|
|
676
1086
|
_resetFullState();
|
|
677
1087
|
}
|
|
678
1088
|
|
|
1089
|
+
/**
|
|
1090
|
+
* Switch the session to a different session id (web "resume session" entry — SDK-mode
|
|
1091
|
+
* counterpart of the PTY /resume injection). options.resume only applies at query
|
|
1092
|
+
* construction, so the switch tears down the resident query; the next user message
|
|
1093
|
+
* lazily reconstructs it with options.resume = sessionId.
|
|
1094
|
+
*
|
|
1095
|
+
* Returns { ok: true } on success; { ok: false, reason } with 'busy' (a turn or another
|
|
1096
|
+
* switch is in flight), 'unavailable' (SDK missing), or 'bad-id' otherwise.
|
|
1097
|
+
*/
|
|
1098
|
+
export function switchToSession(sessionId) {
|
|
1099
|
+
if (!_query) return { ok: false, reason: 'unavailable' };
|
|
1100
|
+
if (typeof sessionId !== 'string' || !sessionId) return { ok: false, reason: 'bad-id' };
|
|
1101
|
+
if (_queryBusy || _switching) return { ok: false, reason: 'busy' };
|
|
1102
|
+
_switching = true;
|
|
1103
|
+
try {
|
|
1104
|
+
_teardownResident();
|
|
1105
|
+
// Defensive: no approvals can be pending while idle, but never leak one across
|
|
1106
|
+
// a session boundary (WS reconnect replay would show a ghost modal).
|
|
1107
|
+
for (const { id, kind } of _cancelAllApprovals()) {
|
|
1108
|
+
_broadcastApprovalDismiss(kind, id, 'session-switch');
|
|
1109
|
+
}
|
|
1110
|
+
_sessionId = sessionId;
|
|
1111
|
+
_launchResume = null;
|
|
1112
|
+
_initAnnouncedSid = '';
|
|
1113
|
+
_lastInitSnapshot = null; // don't replay the OLD session's sdk-init to the new one
|
|
1114
|
+
clearQueued(); // broadcasts the emptied queue-state
|
|
1115
|
+
_suppressDrain = false;
|
|
1116
|
+
return { ok: true };
|
|
1117
|
+
} finally {
|
|
1118
|
+
_switching = false;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
679
1122
|
/**
|
|
680
1123
|
* Reset all session state.
|
|
681
1124
|
*/
|
|
682
1125
|
function _resetFullState() {
|
|
683
|
-
_activeQuery = null;
|
|
684
1126
|
_sessionId = null;
|
|
685
1127
|
_queryBusy = false;
|
|
1128
|
+
_switching = false;
|
|
1129
|
+
_turnInFlight = false;
|
|
686
1130
|
_initAnnouncedSid = '';
|
|
687
1131
|
_lastInitSnapshot = null;
|
|
1132
|
+
_pendingTurns = [];
|
|
1133
|
+
_lateResultTombstones = 0;
|
|
1134
|
+
_consecutiveDeaths = 0;
|
|
1135
|
+
_clearGraceTimer();
|
|
1136
|
+
_clearTurnWatchdog();
|
|
688
1137
|
const hadQueued = _messageQueue.length > 0;
|
|
689
1138
|
_messageQueue = [];
|
|
690
1139
|
_suppressDrain = false;
|