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.
@@ -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 and multi-turn conversation via session resume
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 _queryBusy = false; // concurrency guard
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. Starts a new query (or resumes existing session).
123
- * Queues the message if a query is already running.
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 query is already running, queue this message and return
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
- try {
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
- * Execute a single query for one user message.
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
- async function _executeQuery(text) {
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: mid-session turns resume the captured session id; the FIRST
194
- // query of a startup-continuation launch (-c/-r/--fork-session) uses the launch intent.
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
- try {
228
- _activeQuery = _query({ prompt: text, options });
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
- for await (const msg of _activeQuery) {
231
- _processMessage(msg);
232
- }
233
- } catch (err) {
234
- if (err.name !== 'AbortError') {
235
- console.error('[SDK] Query error:', err.message);
236
- // Surface query-level failures to the Web UI — console.error is invisible there
237
- // (a dead query otherwise looks exactly like a hung one). Callback throw must
238
- // never mask the original error or break the finally cleanup.
239
- if (_onQueryError) {
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
- } finally {
245
- _activeQuery = null;
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
- _notifyTurnError(msg);
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
- function _waitForApproval(id, timeoutMs, kind, replay = null) {
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
- * Shared interrupt core: cancel in-flight approvals (else their canUseTool promises stay
557
- * parked and the timeout timers leak, and clients keep a ghost approval modal open) and
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 _interruptActiveQuery() {
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 so the next message resumes the same conversation.
583
- *
584
- * Crucially we do NOT call `_resetFullState()`, so `_sessionId` is preserved
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 sendUserMessage's drain loop from running
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 = _interruptActiveQuery();
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 = _interruptActiveQuery();
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
- // Use close() instead of interrupt() — works in all modes
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;