cc-viewer 1.9.3 → 1.9.4

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * sdk-manager.js — Agent SDK session lifecycle manager.
2
+ * sdk-manager.js — Agent SDK session lifecycle manager (per-cwd multi-session).
3
3
  *
4
4
  * Wraps @anthropic-ai/claude-agent-sdk query() to provide:
5
5
  * - User-message input over a RESIDENT streaming-input query (one CLI child process
@@ -9,6 +9,16 @@
9
9
  * - canUseTool callback for AskUserQuestion + permission approval
10
10
  * - Turn-end signaling (SDK 'result' message → Stop-hook equivalent)
11
11
  *
12
+ * Registry (2026-10-12, PTY parity): one Session per cwd, kept alive across
13
+ * workspace switches. `_activeKey` selects the session the composer/WS handlers
14
+ * act on (the SDK analogue of pty-manager's activePtyKey). Each Session is a
15
+ * factory closure — NO classes (avoids this-binding traps; the module's entire
16
+ * history is arrow functions). GLOBAL, deliberately kept module-level: `_query`
17
+ * (test injection must be settable before any init — 161+ test cases depend on
18
+ * set-then-init), `_onTurnEnd`/`_onQueryError`/`_broadcastWs`/`_runWaterfallHook`
19
+ * (cli.js registers ONE wiring for the whole process; every session reuses it),
20
+ * constants, and the pure helpers (_makePushQueue/_userMessage/_normalizeSdkTask).
21
+ *
12
22
  * Display/persistence is NOT synthesized here: the SDK child process talks to
13
23
  * the Anthropic API through cc-viewer's loopback proxy (ANTHROPIC_BASE_URL is
14
24
  * injected via options.env + options.settings), so request/response capture,
@@ -21,6 +31,7 @@ import { withDefaultThinkingDisplay, resolveLaunchSystemPrompt, launchArgsToExtr
21
31
  import { evaluateImDeny } from './im-deny.js';
22
32
  import { APPROVAL_TOOLS, isPublishCommand } from './approval-policy.js';
23
33
  import { reportSwallowed } from '@ccv/core/error-report';
34
+ import { resolve as _pathResolve } from 'node:path';
24
35
 
25
36
  let _query;
26
37
  try {
@@ -32,144 +43,111 @@ try {
32
43
 
33
44
  // Test seam — inject a fake query() (following im-bridge-core.js's __setFetchForTests
34
45
  // convention). Only lets unit tests inject a fake async-generator; no business logic changes.
46
+ // MUST stay module-level (outside any Session closure): tests set it BEFORE initSdkSession.
35
47
  export function __setQueryForTests(fn) { _query = fn; }
36
48
 
37
- // Session state
38
- let _sessionId = null;
39
- let _cwd = null;
40
- let _permissionMode = 'default';
41
- let _childEnv = null; // env handed to the SDK child (proxy injection etc.)
42
- let _settings = null; // merged Settings object for options.settings
43
- let _launchModel = null; // --model lifted from user args (options.model)
44
- let _launchResume = null; // startup continuation intent { continue, resumeId, forkSession } — first query only
45
- let _userArgs = []; // remaining user args → options.extraArgs passthrough
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.
49
- let _activeQuery = null;
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;
49
+ // ── Session registry ─────────────────────────────────────────────────────────
50
+ // Keyed by path.resolve(cwd) — normalize only, NO realpath (house exact-string
51
+ // convention, routes/resume.js, so `/tmp` vs `/private/tmp` stay distinct). The
52
+ // per-session state machine lives in each _createSession closure; `_activeKey`
53
+ // selects which session the SDK composer and WS handlers act on.
54
+ const _sessions = new Map();
55
+ let _activeKey = null;
56
+
57
+ function _normalizeCwd(cwd) {
58
+ if (typeof cwd !== 'string' || !cwd) return '';
59
+ try { return _pathResolve(cwd); } catch { return String(cwd); }
60
+ }
61
+
62
+ // Get-or-create the per-cwd Session. The record holds the launch config so a
63
+ // kept-alive background session can reconstruct with ITS OWN config.
64
+ function _getOrCreateSession(cwd) {
65
+ const key = _normalizeCwd(cwd);
66
+ let s = _sessions.get(key);
67
+ if (!s) { s = _createSession(key, cwd); _sessions.set(key, s); }
68
+ return s;
69
+ }
70
+
71
+ // The ACTIVE session (the one the composer talks to). May be null before any init.
72
+ function _active() { return _activeKey != null ? _sessions.get(_activeKey) : null; }
73
+
74
+ // Resolve an anchor { cwd|project } to a Session WITHOUT switching active (read ops).
75
+ // `cwd` is an absolute path (exact key match); `project` is the SANITIZED project name the
76
+ // web client sends (ChatView stamps `viewProject || projectName`, where projectName =
77
+ // projectKeyForCwd(cwd) — non [a-zA-Z0-9_-] chars become `_`, so a dir with spaces/CJK sends
78
+ // the sanitized name, NOT the raw basename). Match each session by projectKeyForCwd(s.cwd)
79
+ // (with a raw-basename fallback for exact-dir callers). Same-name collision → null (never
80
+ // guess). Returns null when the anchor resolves to no registered session.
81
+ function _sessionForAnchor(anchor) {
82
+ if (!anchor || typeof anchor !== 'object') return null;
83
+ const cwd = (typeof anchor.cwd === 'string' && anchor.cwd) || null;
84
+ const project = (typeof anchor.project === 'string' && anchor.project) || null;
85
+ if (cwd) return _sessions.get(_normalizeCwd(cwd)) || null;
86
+ if (!project) return null;
87
+ // exact-path form of a project (an absolute path was passed as `project`) — try key first.
88
+ const byKey = _sessions.get(_normalizeCwd(project));
89
+ if (byKey) return byKey;
90
+ let found = null;
91
+ for (const s of _sessions.values()) {
92
+ const rawBase = String(s.cwd || '').replace(/\\/g, '/').split('/').filter(Boolean).pop();
93
+ const sanitized = rawBase ? rawBase.replace(/[^a-zA-Z0-9_\-.]/g, '_') : null;
94
+ if (sanitized === project || rawBase === project) { if (found) return null; found = s; } // ambiguous → null
95
+ }
96
+ return found;
97
+ }
98
+
99
+ // Test seams (house `__…ForTests` convention) — assert registry behavior without a real SDK query.
100
+ export function __listSessionsForTests() { return [..._sessions.keys()]; }
101
+ export function __getSessionKeyForTests(cwd) { return _normalizeCwd(cwd); }
102
+
103
+ /** Enumerate the per-cwd SDK session registry for the header's live list. Each entry:
104
+ * { key, cwd, active }. Read-only. */
105
+ export function listSdkSessions() {
106
+ return [..._sessions.values()].map((s) => ({ key: s.key, cwd: s.cwd, active: s.key === _activeKey }));
107
+ }
108
+
65
109
  // Silence window for the watchdog. Must stay above the approval ceilings the turn can
66
110
  // legally wait on: perm/plan = 5min, AskUserQuestion = 24h ("GUI effectively has no
67
111
  // timeout" is a product contract). A pending approval exempts the turn from the
68
112
  // watchdog entirely; 25min covers long tool runs (big builds) with headroom.
69
113
  const TURN_WATCHDOG_MS = 25 * 60 * 1000;
70
- let _initAnnouncedSid = ''; // sessionId whose slash-command surface was already announced (reset on full reset)
71
- let _lastInitSnapshot = null; // last sdk-init payload for WS reconnect replay
72
-
73
- // Callbacks registered by cli.js
114
+ const INTERRUPT_GRACE_MS = 10 * 1000;
115
+ // getContextUsage control requests have no SDK-side timeout — bound the wait so a live-but-
116
+ // unresponsive child can't wedge the in-flight gate forever.
117
+ const CTX_USAGE_TIMEOUT_MS = 10 * 1000;
118
+
119
+ // Cap the pending backlog so a stuck/busy resident query can't grow it unbounded
120
+ // (chat-queue.js MAX_ITEMS / im-bridge-core MAX_QUEUE parity). On overflow the item
121
+ // is NOT pushed and a `queue-rejected` frame goes out instead of `queue-state` — the
122
+ // frontend's `queueFull` toast (ChatView.jsx) is the user-visible signal.
123
+ const MAX_QUEUE_ITEMS = 50;
124
+
125
+ // Callbacks registered by cli.js (one wiring for the whole process; every session
126
+ // reuses the same callbacks — they broadcast to the shared terminal WS).
74
127
  let _onTurnEnd = null; // SDK mode has no Stop hook (ensureHooks() skipped) — fire turnEnd directly when the SDK 'result' message arrives
75
128
  let _onQueryError = null; // query-level failure → (message: string) => void; cli.js forwards to a WS toast
76
129
  let _broadcastWs = null;
77
130
  let _runWaterfallHook = null;
78
131
 
79
- // Display/persistence flows through the SAME wire path as PTY mode: the SDK child's API
80
- // traffic reaches ccv's loopback proxy (verified — the injected ANTHROPIC_BASE_URL survives
81
- // in the spawn env), the main-process fetch hook captures it, and `_v2Writer` writes the v2
82
- // transcript that both panels + SSE read. The SDK channel here only carries what the wire
83
- // path can't see: turn-end (no Stop hook in SDK mode), turn-level error toasts, the
84
- // sdk-init/sdk-compact lifecycle metadata, and canUseTool approvals. It deliberately does
85
- // NOT persist conversation content or drive streaming/typewriter — those are wire-owned.
86
-
87
- // Pending canUseTool promises: id → { kind, resolve, replay, startedAt, timeoutMs }
88
- // replay is the exact broadcast payload that announced this approval — retained so a
89
- // fresh WS connection can re-announce it (server.js connection replay), otherwise a
90
- // reconnect mid-approval orphans the modal and the wait silently times out to deny.
91
- const _pendingApprovals = new Map();
92
-
93
- // Message queue for messages sent while a query is running. Items are { id, text, ts } —
94
- // the id lets web clients act on individual queued bubbles (send-now / remove) and the
95
- // queue-state WS broadcast keeps every client in sync (server is the source of truth).
96
- let _messageQueue = [];
97
- // Stop semantics (product decision): Stop parks the queue instead of dropping it —
98
- // interruptTurn sets this flag so sendUserMessage's drain loop exits without running the
99
- // parked items; cleared when a fresh user message / send-now starts a new turn.
100
- let _suppressDrain = false;
101
-
102
- export function isSdkAvailable() {
103
- return typeof _query === 'function';
104
- }
105
-
106
- /**
107
- * Initialize SDK session.
108
- * Does NOT start a query — waits for the first user message via sendUserMessage().
109
- *
110
- * @param {string} cwd
111
- * @param {string} projectName
112
- * @param {object} deps
113
- * @param {function} deps.onTurnEnd — ({sessionId, ts}) => void, fired on SDK 'result'
114
- * @param {function} [deps.onQueryError] — (message: string) => void, fired when the
115
- * query itself fails (spawn/protocol/iterator errors) — until this existed the only
116
- * surfacing was console.error, invisible in the Web UI
117
- * @param {function} deps.broadcastWs — (msg) => void, terminal-WS broadcast for approvals
118
- * @param {string} [deps.permissionMode]
119
- * @param {function} [deps.runWaterfallHook] — plugin waterfall (onPlanRequest/onAskRequest/onPermRequest)
120
- * @param {object} [deps.env] — child env (must already contain ANTHROPIC_BASE_URL → ccv proxy)
121
- * @param {object} [deps.settings] — merged Settings object (env.ANTHROPIC_BASE_URL double-injection)
122
- * @param {string} [deps.model] — --model lifted from user args (options.model)
123
- * @param {object} [deps.launchResume] — startup continuation { continue, resumeId, forkSession }
124
- * @param {string[]} [deps.userArgs] — remaining user args (extraArgs passthrough)
125
- * @param {string} [deps.claudeExecutable] — resolved claude binary (options.pathToClaudeCodeExecutable);
126
- * must come from the same selection as PTY mode, else the SDK spawns a PATH claude that
127
- * host security tooling (e.g. agent-security headless guards) may SIGKILL on spawn
128
- */
129
- export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broadcastWs, permissionMode, runWaterfallHook, env, settings, model, launchResume, userArgs, claudeExecutable }) {
130
- _cwd = cwd;
131
- void projectName; // reserved (display name comes from the wire path)
132
- _onTurnEnd = onTurnEnd;
133
- _onQueryError = onQueryError || null;
134
- _broadcastWs = broadcastWs;
135
- _permissionMode = permissionMode || 'default';
136
- _runWaterfallHook = runWaterfallHook || null;
137
- _childEnv = env || null;
138
- _settings = settings || null;
139
- _launchModel = model || null;
140
- _launchResume = launchResume || null;
141
- _userArgs = Array.isArray(userArgs) ? userArgs : [];
142
- _claudeExecutable = claudeExecutable || null;
143
- // Re-init (tests / hot reload) must not leave a zombie resident generation alive.
144
- _teardownResident();
145
- _resetFullState();
146
- }
147
-
148
- /**
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.
157
- */
158
- export async function sendUserMessage(text) {
159
- if (!_query) throw new Error('Agent SDK not available');
160
-
161
- // If a turn is already running, queue this message and return
162
- if (_queryBusy) {
163
- _messageQueue.push(_makeQueueItem(text));
164
- _broadcastQueueState();
165
- return;
166
- }
167
-
168
- // A fresh user-initiated turn re-arms automatic draining of parked (Stop-kept) items.
169
- _suppressDrain = false;
170
- _consecutiveDeaths = 0; // explicit user action re-arms the death-drain backstop
171
- await _startTurn(text);
132
+ // Process-level SDK LAUNCH-CONFIG template (2026-10-12). cli.js registers it once at boot
133
+ // (the env carries the loopback-proxy URL + the per-cwd header is added per session). The
134
+ // workspace-launch route uses it to register a session for a NEW cwd — without it the new
135
+ // session would inherit process.env (no ANTHROPIC_BASE_URL → bypass the ccv proxy → no
136
+ // transcript, no per-cwd routing, model/permission/executable lost). launchResume is
137
+ // excluded (it is a startup-only intent for the boot cwd, not for later workspaces).
138
+ let _launchConfigTemplate = null;
139
+ export function setSdkLaunchConfig(cfg) {
140
+ if (!cfg || typeof cfg !== 'object') return;
141
+ _launchConfigTemplate = {
142
+ permissionMode: cfg.permissionMode || 'default',
143
+ env: cfg.env || null,
144
+ settings: cfg.settings || null,
145
+ model: cfg.model || null,
146
+ userArgs: Array.isArray(cfg.userArgs) ? cfg.userArgs : [],
147
+ claudeExecutable: cfg.claudeExecutable || null,
148
+ };
172
149
  }
150
+ export function getSdkLaunchConfig() { return _launchConfigTemplate; }
173
151
 
174
152
  function _makeQueueItem(text) {
175
153
  return {
@@ -179,14 +157,6 @@ function _makeQueueItem(text) {
179
157
  };
180
158
  }
181
159
 
182
- /** Broadcast the queue snapshot to all terminal-WS clients (drives the floating bubbles). */
183
- function _broadcastQueueState() {
184
- if (!_broadcastWs) return;
185
- try {
186
- _broadcastWs({ type: 'queue-state', items: _messageQueue.map(({ id, text, ts }) => ({ id, text, ts })) });
187
- } catch (err) { console.warn('[sdk-manager] queue-state broadcast threw:', err?.message); }
188
- }
189
-
190
160
  /**
191
161
  * Never-ending push queue used as the resident query's streaming input.
192
162
  * HARD INVARIANT: with canUseTool mounted, the SDK ends the child's stdin (killing
@@ -233,367 +203,6 @@ function _userMessage(text) {
233
203
  };
234
204
  }
235
205
 
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
-
336
- const options = {
337
- cwd: _cwd,
338
- permissionMode: _permissionMode,
339
- // canUseTool is mounted in EVERY mode (including bypassPermissions) so the
340
- // npm-publish hard gate survives --d, mirroring perm-bridge.js's bypass
341
- // exemption. In bypass mode the callback early-allows everything except
342
- // publish commands and the two interactive tools (see _handleCanUseTool).
343
- canUseTool: _handleCanUseTool,
344
- ..._permissionMode === 'bypassPermissions' && { allowDangerouslySkipPermissions: true },
345
- };
346
- // env carries the loopback-proxy base URL — the SDK passes it through to the
347
- // spawned CLI verbatim (verified against sdk.mjs: env defaults to process.env
348
- // and is only ever added to, never filtered).
349
- if (_childEnv) options.env = _childEnv;
350
- if (_settings) options.settings = _settings;
351
- if (_launchModel) options.model = _launchModel;
352
- // Same executable priority as PTY mode — without this the SDK resolves claude from
353
- // PATH, which on guarded hosts picks a binary that gets SIGKILLed on headless spawn.
354
- if (_claudeExecutable) options.pathToClaudeCodeExecutable = _claudeExecutable;
355
-
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).
360
- let resumeIntent = null;
361
- if (_sessionId) {
362
- options.resume = _sessionId;
363
- resumeIntent = { resumeValue: _sessionId, picker: false, fork: false };
364
- } else if (_launchResume) {
365
- if (_launchResume.resumeId) options.resume = _launchResume.resumeId;
366
- else if (_launchResume.continue) options.continue = true;
367
- if (_launchResume.forkSession) options.forkSession = true;
368
- resumeIntent = { resumeValue: _launchResume.resumeId ?? null, picker: false, fork: !!_launchResume.forkSession };
369
- }
370
-
371
- // System-prompt injection, byte-identical to the PTY link (shared pipeline in
372
- // lib/launch-config.js): fresh launch → sentinel/model-matched files (rendered);
373
- // resume turn → pinned snapshot bytes (never re-rendered). Resume-turn pendings are
374
- // NOT persisted — their only consumer (SessionStart-hook Bind B) is dormant in SDK mode.
375
- // Injection failure must never block the query (PTY parity: PR#128 fallback).
376
- const launchArgs = process.env.CCV_SKIP_THINKING_DISPLAY === '1' ? _userArgs : withDefaultThinkingDisplay(_userArgs);
377
- try {
378
- const lc = resolveLaunchSystemPrompt({
379
- spawnDir: _cwd,
380
- extraArgs: launchArgs,
381
- env: _childEnv || process.env,
382
- launchSettings: _settings,
383
- resume: resumeIntent,
384
- persistPending: !resumeIntent,
385
- });
386
- options.extraArgs = launchArgsToExtraArgs([...launchArgs, ...lc.sysPrompt.args]);
387
- } catch (err) {
388
- console.warn('[SDK] launch system-prompt resolution failed, querying without injected prompt:', err?.message || err);
389
- options.extraArgs = launchArgsToExtraArgs(launchArgs);
390
- }
391
-
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
- }
400
-
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);
411
- }
412
- _armTurnWatchdog(); // silence watchdog: any message proves the session is alive
413
- }
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');
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));
496
- }
497
-
498
- /**
499
- * Process a single SDK message. Display/persistence flows through the wire path
500
- * (proxy → fetch hook → v2 transcript), same as PTY mode — here we only track session
501
- * continuity, fire turn-end on 'result', surface turn errors, and broadcast the
502
- * SDK-only lifecycle metadata (sdk-init / sdk-compact) the wire path never sees.
503
- */
504
- function _processMessage(msg) {
505
- switch (msg.type) {
506
- case 'system':
507
- if (msg.session_id) _sessionId = msg.session_id;
508
- // Init carries the session's slash-command surface (no TUI `/` help in a
509
- // headless session); compact_boundary is an SDK-only event (no wire delta)
510
- // — broadcast both so clients see the same lifecycle cues PTY users get.
511
- if (msg.subtype === 'init' && _broadcastWs
512
- && Array.isArray(msg.slash_commands) && msg.slash_commands.length > 0
513
- && msg.session_id !== _initAnnouncedSid) {
514
- _initAnnouncedSid = msg.session_id;
515
- _lastInitSnapshot = {
516
- type: 'sdk-init',
517
- sessionId: msg.session_id,
518
- model: msg.model,
519
- slashCommands: msg.slash_commands,
520
- tools: msg.tools,
521
- };
522
- _broadcastWs(_lastInitSnapshot);
523
- } else if (msg.subtype === 'compact_boundary' && _broadcastWs) {
524
- const meta = msg.compact_metadata;
525
- _broadcastWs({
526
- type: 'sdk-compact',
527
- trigger: meta && meta.trigger,
528
- preTokens: meta && meta.pre_tokens,
529
- postTokens: meta && meta.post_tokens,
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) });
541
- }
542
- break;
543
-
544
- case 'user':
545
- case 'assistant':
546
- // Conversation content is persisted by the wire path (proxy → fetch hook → v2),
547
- // not here — the SDK child's API traffic reaches ccv's proxy and is captured with
548
- // full fidelity (real user prompts + tool calls), so accumulating it again would
549
- // double-write. Here we only track session continuity.
550
- if (msg.session_id) _sessionId = msg.session_id;
551
- break;
552
-
553
- case 'result': {
554
- if (msg.session_id) _sessionId = msg.session_id;
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);
574
- // SDK turn-end signal. Equivalent to Claude Code's Stop hook
575
- // in CLI mode — fires once per user-prompt response when the whole chain
576
- // (assistant text + all tool calls + final reply) completes. SDK mode
577
- // doesn't go through ensureHooks() so this in-process callback is the
578
- // only way the renderer learns the turn is over.
579
- if (_onTurnEnd) {
580
- try { _onTurnEnd({ sessionId: _sessionId, ts: Date.now() }); }
581
- catch (err) { console.warn('[sdk-manager] onTurnEnd threw:', err?.message); }
582
- }
583
- if (rec) _settleTurn(rec);
584
- _clearGraceTimer();
585
- _queryBusy = false;
586
- _turnInFlight = false;
587
- if (_pendingTurns.length === 0) _clearTurnWatchdog();
588
- _maybeDrain();
589
- break;
590
- }
591
-
592
- default:
593
- break;
594
- }
595
- }
596
-
597
206
  /**
598
207
  * Normalize an SDK `task_*` system message into the camelCase relay payload
599
208
  * broadcast as `{ type: 'sdk-task', ... }`. Lossless pass-through (snake→camel
@@ -659,505 +268,1522 @@ function _normalizeSdkTask(msg) {
659
268
  return out;
660
269
  }
661
270
 
662
- /**
663
- * Surface a failed turn to the Web UI — the wire path persists the failed entry but
664
- * never pushes a toast, so a dead/errored turn would otherwise look like a hang.
665
- */
666
- function _notifyTurnError(resultMsg) {
667
- const isError = resultMsg.is_error === true || (resultMsg.subtype && resultMsg.subtype !== 'success');
668
- if (!isError) return;
669
- const errs = Array.isArray(resultMsg.errors) && resultMsg.errors.length
670
- ? resultMsg.errors.join('; ')
671
- : (typeof resultMsg.result === 'string' && resultMsg.result) || resultMsg.subtype || 'unknown error';
672
- if (_broadcastWs) {
673
- try { _broadcastWs({ type: 'sdk-error', message: String(errs) }); } catch (err) { console.warn('[sdk-manager] sdk-error broadcast threw:', err?.message); }
271
+ // ── Per-session factory ──────────────────────────────────────────────────────
272
+ // ONE closure = ONE workspace's resident SDK session. Every variable the current
273
+ // (single-session) module held in module-level `let` lives here EXCEPT the ones
274
+ // that are process-global by contract (see file header). Arrays stay closure
275
+ // `let` (rebinding, not a frozen property) so assignment paths like
276
+ // `_messageQueue = []` keep working.
277
+ function _createSession(key, cwd) {
278
+ // Launch config (per-session identity; adopted on lazy reconstruction)
279
+ let _cwd = cwd;
280
+ let _permissionMode = 'default';
281
+ let _childEnv = null; // env handed to the SDK child (proxy injection etc.)
282
+ let _settings = null; // merged Settings object for options.settings
283
+ let _launchModel = null; // --model lifted from user args (options.model)
284
+ let _launchResume = null; // startup continuation intent { continue, resumeId, forkSession } — first query only
285
+ let _userArgs = []; // remaining user args → options.extraArgs passthrough
286
+ 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
287
+ // Resident-query state. `_activeQuery` (the Query handle) and `_queryBusy` (a turn in
288
+ // flight) are deliberately decoupled: an idle resident query is alive but not busy.
289
+ let _activeQuery = null;
290
+ let _inputQueue = null; // push-queue AsyncIterable handed to query() as the prompt
291
+ let _queryBusy = false; // a turn is in flight (including ensure + interrupt unwind)
292
+ let _switching = false; // teardown window of switchToSession — rejects new turns/switches
293
+ let _pendingTurns = []; // FIFO [{ resolve, interrupted, settled }] — one per pushed message
294
+ let _turnInFlight = false; // sticky "a pushed turn has not produced its result" flag —
295
+ // interruptTurn keys off this, NOT _pendingTurns.length (the
296
+ // grace/watchdog force-settle paths drain the FIFO while the
297
+ // CLI-side turn may still be running; without the sticky flag a
298
+ // second Stop would silently no-op).
299
+ let _settleGraceTimer = null; // backstop after interrupt: force-settle if no result arrives
300
+ let _lateResultTombstones = 0; // results to discard (already force-settled via grace/watchdog)
301
+ let _consecutiveDeaths = 0; // backstop against respawn storms in a broken environment
302
+ let _turnWatchdog = null; // SILENCE watchdog: re-armed on every incoming message; fires only
303
+ // when the session produces nothing for the whole window
304
+ let _sessionId = null;
305
+ let _initAnnouncedSid = ''; // sessionId whose slash-command surface was already announced (reset on full reset)
306
+ let _lastInitSnapshot = null; // last sdk-init payload for WS reconnect replay
307
+ // Last announced session state ('idle' | 'running' | 'requires_action' | null) — the
308
+ // turn-level busy truth for the conversation spinner. Sources, most authoritative
309
+ // first: CLI-emitted session_state_changed (gated behind CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS,
310
+ // injected by cli.js) > push-running / result-idle fallbacks below. Retained for WS
311
+ // reconnect replay like _lastInitSnapshot; reset alongside it on switch/reset.
312
+ let _lastSessionState = null;
313
+ let _lastContextUsage = null; // last sdk-context-usage frame for WS reconnect replay
314
+ let _ctxUsageFetching = false; // one getContextUsage control request in flight
315
+ let _ctxUsageQueued = false; // a turn-end/compact arrived while in flight → rerun once (coalesce)
316
+ // Generation counter for the in-flight gate: _clearContextUsage bumps it so a stale
317
+ // request's .finally can't clear the flag of a NEWER in-flight request started after a
318
+ // session switch (the flag is only reset by the generation that set it).
319
+ let _ctxUsageSeq = 0;
320
+ // Last sdk-mcp-status frame for WS reconnect replay. mcpServerStatus() is on-demand only
321
+ // (HTTP pull + reconnect replay + clear-on-switch) — unlike context usage it is NOT
322
+ // turn-correlated, so it gets no turn-end/compact trigger and none of the in-flight gate
323
+ // (fetching/queued/seq) machinery above. It reuses the same CTX_USAGE_TIMEOUT_MS bound.
324
+ let _lastMcpStatus = null;
325
+ // Pending canUseTool promises: id → { kind, resolve, replay, startedAt, timeoutMs }
326
+ // replay is the exact broadcast payload that announced this approval — retained so a
327
+ // fresh WS connection can re-announce it (server.js connection replay), otherwise a
328
+ // reconnect mid-approval orphans the modal and the wait silently times out to deny.
329
+ const _pendingApprovals = new Map();
330
+ // Message queue for messages sent while a query is running. Items are { id, text, ts } —
331
+ // the id lets web clients act on individual queued bubbles (send-now / remove) and the
332
+ // queue-state WS broadcast keeps every client in sync (server is the source of truth).
333
+ let _messageQueue = [];
334
+ // Stop semantics (product decision): Stop parks the queue instead of dropping it —
335
+ // interruptTurn sets this flag so sendUserMessage's drain loop exits without running the
336
+ // parked items; cleared when a fresh user message / send-now starts a new turn.
337
+ let _suppressDrain = false;
338
+
339
+ // Wrap the process-global _broadcastWs so EVERY frame this session emits carries
340
+ // its routing identity (cwd + sessionId): with concurrent sessions on one WS, the
341
+ // frontend filters frames by cwd — without it every workspace's clients would see
342
+ // every other workspace's spinner/queue/modal frames.
343
+ function _broadcast(payload) {
344
+ if (!_broadcastWs) return;
345
+ try {
346
+ _broadcastWs({ cwd: _cwd, sessionId: _sessionId || null, ...payload });
347
+ } catch (err) { reportSwallowed('sdk-manager.broadcast', err); }
674
348
  }
675
- }
676
349
 
677
- /**
678
- * canUseTool callback — handles AskUserQuestion + permission approval.
679
- *
680
- * Check order mirrors perm-bridge.js precedence:
681
- * 1. IM hard deny (CCV_IM_DENY=1 only) — beats everything, including the
682
- * publish force-approval below (an IM worker gets a hard deny, not a modal);
683
- * 2. npm-publish hard gate — forced through the perm approval branch even in
684
- * bypassPermissions mode (perm-bridge.js's bypass exemption equivalent);
685
- * 3. bypass early-allow — bypass auto-approves everything else, but NEVER
686
- * short-circuits the ExitPlanMode/AskUserQuestion interactive branches;
687
- * 4. the three regular branches (plan / ask / perm).
688
- */
689
- async function _handleCanUseTool(toolName, input, options) {
690
- const id = options?.toolUseID || `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
691
-
692
- // 1. IM worker hard deny — must run before any auto-allow (perm-bridge.js parity).
693
- if (process.env.CCV_IM_DENY === '1') {
694
- const verdict = evaluateImDeny(toolName, input);
695
- if (verdict.deny) {
696
- return { behavior: 'deny', message: `cc-viewer IM guard: ${verdict.reason}` };
350
+ /** Adopt launch config (initSdkSession writes it here). */
351
+ function _init({ permissionMode, env, settings, model, launchResume, userArgs, claudeExecutable }) {
352
+ _permissionMode = permissionMode || 'default';
353
+ _childEnv = env || null;
354
+ _settings = settings || null;
355
+ _launchModel = model || null;
356
+ _launchResume = launchResume || null;
357
+ _userArgs = Array.isArray(userArgs) ? userArgs : [];
358
+ _claudeExecutable = claudeExecutable || null;
359
+ // Re-init (tests / hot reload) must not leave a zombie resident generation alive.
360
+ _teardownResident();
361
+ _resetFullState();
362
+ }
363
+
364
+ // ── Turn records (FIFO pairing of pushed messages ↔ result messages) ──
365
+ // Every pushed user message eventually yields exactly one result from the CLI
366
+ // (an interrupted turn still synthesizes an error_during_execution result). All
367
+ // settle paths (result / grace force-settle / teardown settleAll) go through
368
+ // _settleTurn, which is idempotent via rec.settled.
369
+ function _beginTurn() {
370
+ let resolve;
371
+ const promise = new Promise((r) => { resolve = r; });
372
+ const rec = { resolve, interrupted: false, settled: false };
373
+ _pendingTurns.push(rec);
374
+ return { rec, promise };
375
+ }
376
+
377
+ function _settleTurn(rec) {
378
+ if (!rec || rec.settled) return;
379
+ rec.settled = true;
380
+ _clearGraceTimer();
381
+ rec.resolve();
382
+ }
383
+
384
+ function _settleAllTurns() {
385
+ for (const rec of _pendingTurns) _settleTurn(rec);
386
+ _pendingTurns = [];
387
+ _queryBusy = false;
388
+ _turnInFlight = false;
389
+ _clearTurnWatchdog();
390
+ }
391
+
392
+ function _clearGraceTimer() {
393
+ if (_settleGraceTimer) { clearTimeout(_settleGraceTimer); _settleGraceTimer = null; }
394
+ }
395
+
396
+ /** Soft SILENCE watchdog: re-armed on every incoming SDK message, so it only fires
397
+ * when the session has produced nothing for the full window — a genuinely stuck/dead
398
+ * turn. Pending approvals prove the session is alive (the child is parked waiting for
399
+ * our control response), so the watchdog merely re-arms in that case. Force-settle is
400
+ * symmetric with the interrupt grace path: settle the head turn FIRST (its promise
401
+ * must resolve), then tombstone the result that may still arrive. */
402
+ function _armTurnWatchdog() {
403
+ _clearTurnWatchdog();
404
+ if (_pendingTurns.length === 0) return;
405
+ _turnWatchdog = setTimeout(() => {
406
+ _turnWatchdog = null;
407
+ if (_pendingTurns.length === 0) return;
408
+ if (_pendingApprovals.size > 0) {
409
+ _armTurnWatchdog(); // approval pending = alive; wait out the approval timeout instead
410
+ return;
411
+ }
412
+ console.warn('[sdk-manager] turn silent beyond watchdog window — force-settling (possible silent session death)');
413
+ _lateResultTombstones++;
414
+ _settleAllTurns();
415
+ _maybeDrain();
416
+ }, TURN_WATCHDOG_MS);
417
+ if (_turnWatchdog.unref) _turnWatchdog.unref();
418
+ }
419
+
420
+ function _clearTurnWatchdog() {
421
+ if (_turnWatchdog) { clearTimeout(_turnWatchdog); _turnWatchdog = null; }
422
+ }
423
+
424
+ /**
425
+ * Send a user message. Lazily constructs the resident query on the first message
426
+ * (a startup-continuation launchResume applies to that first construction only).
427
+ * Queues the message if a turn is already in flight (ccv's queue remains the single
428
+ * source of truth — only one message is pushed to the SDK per turn boundary, so the
429
+ * CLI's own command queue stays empty and Stop keeps its "park the bubbles" semantics).
430
+ *
431
+ * Never rejects for turn/session failures (those surface via onQueryError toasts);
432
+ * the only throw is the synchronous "SDK not available" guard cli.js relies on.
433
+ */
434
+ async function _sendUserMessage(text) {
435
+ if (!_query) throw new Error('Agent SDK not available');
436
+
437
+ // If a turn is already running, queue this message and return
438
+ if (_queryBusy) {
439
+ if (_messageQueue.length >= MAX_QUEUE_ITEMS) {
440
+ // Negative ack: the frontend cleared the composer optimistically — never fail
441
+ // silently. Only the reject frame goes out (no queue-state — nothing changed).
442
+ console.warn('[sdk-manager] queue full, rejecting message');
443
+ try { if (_broadcastWs) _broadcastWs({ type: 'queue-rejected', reason: 'full', cwd: _cwd, sessionId: _sessionId || null }); }
444
+ catch (err) { reportSwallowed('sdk-manager.reject-broadcast', err); }
445
+ return;
446
+ }
447
+ _messageQueue.push(_makeQueueItem(text));
448
+ _broadcastQueueState();
449
+ return;
697
450
  }
451
+
452
+ // A fresh user-initiated turn re-arms automatic draining of parked (Stop-kept) items.
453
+ _suppressDrain = false;
454
+ _consecutiveDeaths = 0; // explicit user action re-arms the death-drain backstop
455
+ await _startTurn(text);
698
456
  }
699
457
 
700
- // 2. npm publish is never auto-allowed, even under --d (safety floor).
701
- const isPublish = isPublishCommand(toolName, input);
458
+ /** Broadcast the queue snapshot to all terminal-WS clients (drives the floating bubbles). */
459
+ function _broadcastQueueState() {
460
+ if (!_broadcastWs) return;
461
+ try {
462
+ _broadcastWs({ type: 'queue-state', cwd: _cwd, sessionId: _sessionId || null, items: _messageQueue.map(({ id, text, ts }) => ({ id, text, ts })) });
463
+ } catch (err) { console.warn('[sdk-manager] queue-state broadcast threw:', err?.message); }
464
+ }
702
465
 
703
- // 3. Bypass mode auto-approves everything except publish and the two
704
- // interactive tools — those keep their UI channels so a forwarded
705
- // ExitPlanMode/AskUserQuestion still reaches the user.
706
- if (_permissionMode === 'bypassPermissions' && !isPublish
707
- && toolName !== 'ExitPlanMode' && toolName !== 'AskUserQuestion') {
708
- return { behavior: 'allow', updatedInput: input };
466
+ /**
467
+ * Start one turn: park a turn record, ensure the resident query exists, push the
468
+ * message. Resolves when the turn's result arrives (or on teardown/death settle).
469
+ */
470
+ async function _startTurn(text) {
471
+ const { rec, promise } = _beginTurn();
472
+ _queryBusy = true;
473
+ try {
474
+ await _ensureResidentQuery();
475
+ } catch (err) {
476
+ // Construction itself failed (e.g. options assembly) — settle immediately.
477
+ _pendingTurns = _pendingTurns.filter((r) => r !== rec);
478
+ _settleTurn(rec);
479
+ _queryBusy = false;
480
+ _notifyQueryError(err);
481
+ return promise;
482
+ }
483
+ // Teardown/stop raced with construction — the settle already happened; don't push.
484
+ if (rec.settled) return promise;
485
+ if (!_activeQuery || !_inputQueue) {
486
+ _pendingTurns = _pendingTurns.filter((r) => r !== rec);
487
+ _settleTurn(rec);
488
+ _queryBusy = false;
489
+ return promise;
490
+ }
491
+ _inputQueue.push(_userMessage(text));
492
+ _turnInFlight = true;
493
+ // Turn-level loading fallback: light the spinner the moment the turn is pushed.
494
+ // The CLI's own session_state_changed(running) arrives later and is idempotent
495
+ // against this — on CLIs that never emit it (no env gate / older build) this is
496
+ // the only "on" signal, paired with the result-idle broadcast in _processMessage.
497
+ _announceSessionState('running');
498
+ _armTurnWatchdog();
499
+ return promise;
709
500
  }
710
501
 
711
- if (toolName === 'ExitPlanMode') {
712
- if (_runWaterfallHook) {
713
- try {
714
- const hookResult = await _runWaterfallHook('onPlanRequest', { id, input, mode: 'sdk' });
715
- if (hookResult.approve !== undefined) {
716
- if (hookResult.approve === false) {
717
- return { behavior: 'deny', message: hookResult.feedback || 'Plugin rejected the plan' };
502
+ /**
503
+ * Construct the resident query exactly once (no-op while one is alive). The first
504
+ * construction consumes the startup continuation intent (_launchResume); later
505
+ * reconstructions (after a session-level death) resume the captured _sessionId.
506
+ */
507
+ async function _ensureResidentQuery() {
508
+ if (_activeQuery) return;
509
+ if (!_query) throw new Error('Agent SDK not available');
510
+
511
+ const options = {
512
+ cwd: _cwd,
513
+ permissionMode: _permissionMode,
514
+ // canUseTool is mounted in EVERY mode (including bypassPermissions) so the
515
+ // npm-publish hard gate survives --d, mirroring perm-bridge.js's bypass
516
+ // exemption. In bypass mode the callback early-allows everything except
517
+ // publish commands and the two interactive tools (see _handleCanUseTool).
518
+ canUseTool: _handleCanUseTool,
519
+ ..._permissionMode === 'bypassPermissions' && { allowDangerouslySkipPermissions: true },
520
+ };
521
+ // env carries the loopback-proxy base URL — the SDK passes it through to the
522
+ // spawned CLI verbatim (verified against sdk.mjs: env defaults to process.env
523
+ // and is only ever added to, never filtered).
524
+ if (_childEnv) options.env = _childEnv;
525
+ // Per-cwd transcript routing (multi-session, PTY parity): tag this session's requests with
526
+ // `x-ccv-project-dir` = ITS cwd, exactly like PTY spawn (pty-manager.js:397-405). Without
527
+ // it every session's requests fall back to the server's single bound project and concurrent
528
+ // workspaces would double-write one transcript. Mutates options.env in place (PTY parity:
529
+ // the same object is passed through — a copy would break reference-identity callers).
530
+ if (options.env && _cwd) {
531
+ const headerCwd = String(_cwd).replace(/[\r\n\x00-\x1f\x7f]/g, '');
532
+ if (headerCwd) {
533
+ const projectHeader = `x-ccv-project-dir: ${headerCwd}`;
534
+ options.env.ANTHROPIC_CUSTOM_HEADERS = options.env.ANTHROPIC_CUSTOM_HEADERS
535
+ ? `${options.env.ANTHROPIC_CUSTOM_HEADERS}\n${projectHeader}` : projectHeader;
536
+ }
537
+ }
538
+ if (_settings) options.settings = _settings;
539
+ if (_launchModel) options.model = _launchModel;
540
+ // Same executable priority as PTY mode — without this the SDK resolves claude from
541
+ // PATH, which on guarded hosts picks a binary that gets SIGKILLed on headless spawn.
542
+ if (_claudeExecutable) options.pathToClaudeCodeExecutable = _claudeExecutable;
543
+
544
+ // Resume semantics: a captured session id (mid-session reconstruction) resumes it;
545
+ // the FIRST construction of a startup-continuation launch (-c/-r/--fork-session)
546
+ // uses the launch intent. options.resume only takes effect at construction time —
547
+ // switching sessions therefore means teardown + reconstruct (see switchToSession).
548
+ let resumeIntent = null;
549
+ if (_sessionId) {
550
+ options.resume = _sessionId;
551
+ resumeIntent = { resumeValue: _sessionId, picker: false, fork: false };
552
+ } else if (_launchResume) {
553
+ if (_launchResume.resumeId) options.resume = _launchResume.resumeId;
554
+ else if (_launchResume.continue) options.continue = true;
555
+ if (_launchResume.forkSession) options.forkSession = true;
556
+ resumeIntent = { resumeValue: _launchResume.resumeId ?? null, picker: false, fork: !!_launchResume.forkSession };
557
+ }
558
+
559
+ // System-prompt injection, byte-identical to the PTY link (shared pipeline in
560
+ // lib/launch-config.js): fresh launch → sentinel/model-matched files (rendered);
561
+ // resume turn → pinned snapshot bytes (never re-rendered). Resume-turn pendings are
562
+ // NOT persisted — their only consumer (SessionStart-hook Bind B) is dormant in SDK mode.
563
+ // Injection failure must never block the query (PTY parity: PR#128 fallback).
564
+ const launchArgs = process.env.CCV_SKIP_THINKING_DISPLAY === '1' ? _userArgs : withDefaultThinkingDisplay(_userArgs);
565
+ try {
566
+ const lc = resolveLaunchSystemPrompt({
567
+ spawnDir: _cwd,
568
+ extraArgs: launchArgs,
569
+ env: _childEnv || process.env,
570
+ launchSettings: _settings,
571
+ resume: resumeIntent,
572
+ persistPending: !resumeIntent,
573
+ });
574
+ options.extraArgs = launchArgsToExtraArgs([...launchArgs, ...lc.sysPrompt.args]);
575
+ } catch (err) {
576
+ console.warn('[SDK] launch system-prompt resolution failed, querying without injected prompt:', err?.message || err);
577
+ options.extraArgs = launchArgsToExtraArgs(launchArgs);
578
+ }
579
+
580
+ const inputQueue = _makePushQueue();
581
+ const query = _query({ prompt: inputQueue, options });
582
+ _inputQueue = inputQueue;
583
+ _activeQuery = query;
584
+ // Background iteration — messages flow in for the life of the session. Its end
585
+ // (clean or thrown) means the child process is gone: session-level death.
586
+ _drainResidentIterator(query, inputQueue);
587
+ }
588
+
589
+ async function _drainResidentIterator(query, inputQueue) {
590
+ try {
591
+ for await (const msg of query) {
592
+ // A message-processing failure (e.g. a broadcast callback throwing) must NOT be
593
+ // mistaken for session death — the child is still alive; only the iterator's
594
+ // end/throw is a death signal.
595
+ try {
596
+ _processMessage(msg);
597
+ } catch (msgErr) {
598
+ reportSwallowed('sdk-manager.process-message', msgErr);
599
+ }
600
+ _armTurnWatchdog(); // silence watchdog: any message proves the session is alive
601
+ }
602
+ _onResidentDeath(query, inputQueue, null);
603
+ } catch (err) {
604
+ _onResidentDeath(query, inputQueue, err);
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Session-level death (iterator ended or threw). Stale generations (a query already
610
+ * replaced by teardown/switch) are ignored. The message queue PARKS instead of
611
+ * draining — the next explicit user message / send-now lazily reconstructs the
612
+ * resident query (resuming _sessionId when one was captured).
613
+ */
614
+ function _onResidentDeath(query, inputQueue, err) {
615
+ if (_activeQuery !== query) return; // stale generation — teardown already moved on
616
+ _activeQuery = null;
617
+ _inputQueue = null;
618
+ _clearGraceTimer();
619
+ _clearTurnWatchdog();
620
+ _lateResultTombstones = 0;
621
+ // Session died mid-turn: no result will arrive, so without this the spinner would
622
+ // hang until the 25min watchdog. The turn is unambiguously over.
623
+ _announceSessionState('idle');
624
+ if (err && err.name !== 'AbortError') {
625
+ console.error('[SDK] Resident query error:', err.message);
626
+ _notifyQueryError(err);
627
+ }
628
+ _settleAllTurns();
629
+ // Flush pending approvals with a dismiss broadcast, else their modals hang forever.
630
+ const pending = Array.from(_pendingApprovals, ([id, p]) => ({ id, kind: p.kind || null }));
631
+ for (const { id, kind } of pending) {
632
+ _pendingApprovals.get(id)?.resolve(null);
633
+ _broadcastApprovalDismiss(kind, id, 'session-ended');
634
+ }
635
+ _pendingApprovals.clear();
636
+ // Best-effort child cleanup: if the iterator died without the process exiting (rare
637
+ // but possible on transport errors), don't orphan the resident CLI.
638
+ try { if (typeof query.close === 'function') query.close(); }
639
+ catch (closeErr) { reportSwallowed('sdk-manager.death-close', closeErr); }
640
+ // Release the SDK's streamInput consumer parked on the old queue's next() waiter.
641
+ if (inputQueue && !inputQueue.isClosed()) inputQueue.close();
642
+ // Keep the pipeline moving: the queue is not suppressed here (only interruptTurn/Stop
643
+ // parks it), so a queued message drains into a lazily-reconstructed session. A broken
644
+ // environment stops that loop after two consecutive deaths (drain pauses parked;
645
+ // the next explicit user message re-arms it) instead of burning one spawn + error
646
+ // toast per queued message.
647
+ _consecutiveDeaths++;
648
+ if (_consecutiveDeaths <= 2) _maybeDrain();
649
+ }
650
+
651
+ /**
652
+ * Orderly teardown of the resident query (stopSession / session switch). Nulls the
653
+ * handles FIRST so the dying iterator's end is dismissed as stale, then ends the
654
+ * input queue (lets the SDK close stdin gracefully) and closes the query (terminates
655
+ * the child). Settles every in-flight turn promise so no caller hangs.
656
+ */
657
+ function _teardownResident() {
658
+ const query = _activeQuery;
659
+ const inputQueue = _inputQueue;
660
+ _activeQuery = null;
661
+ _inputQueue = null;
662
+ _clearGraceTimer();
663
+ _clearTurnWatchdog();
664
+ _lateResultTombstones = 0;
665
+ _settleAllTurns();
666
+ if (inputQueue) inputQueue.close();
667
+ if (query) {
668
+ try { if (typeof query.close === 'function') query.close(); }
669
+ catch (err) { reportSwallowed('sdk-manager.teardown-close', err); }
670
+ }
671
+ }
672
+
673
+ /** Turn-level error toast (callback throw must never break the caller). */
674
+ function _notifyQueryError(err) {
675
+ if (!_onQueryError) return;
676
+ try { _onQueryError(String(err?.message || err)); }
677
+ catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
678
+ }
679
+
680
+ /** Drain one queued message after a turn boundary (result-driven, not loop-driven). */
681
+ function _maybeDrain() {
682
+ if (_suppressDrain || _queryBusy || _switching) return;
683
+ if (_messageQueue.length === 0) return;
684
+ const next = _messageQueue.shift();
685
+ _broadcastQueueState();
686
+ _sendUserMessage(next.text).catch((err) => reportSwallowed('sdk-manager.drain', err));
687
+ }
688
+
689
+ /**
690
+ * Announce a turn-level session state to WS clients (spinner source in SDK mode).
691
+ * State-change guarded + retained for reconnect replay. Broadcast failures are
692
+ * swallowed inside this helper — the message loop must never die on a UI signal.
693
+ */
694
+ function _announceSessionState(state) {
695
+ const valid = state === 'idle' || state === 'running' || state === 'requires_action';
696
+ if (!valid || state === _lastSessionState) return;
697
+ _lastSessionState = state;
698
+ // _broadcast carries the routing envelope (cwd + sessionId) — every frame needs
699
+ // it so concurrent sessions' clients filter correctly.
700
+ _broadcast({ type: 'sdk-session-state', state });
701
+ }
702
+
703
+ /**
704
+ * Process a single SDK message. Display/persistence flows through the wire path
705
+ * (proxy → fetch hook → v2 transcript), same as PTY mode — here we only track session
706
+ * continuity, fire turn-end on 'result', surface turn errors, and broadcast the
707
+ * SDK-only lifecycle metadata (sdk-init / sdk-compact) the wire path never sees.
708
+ */
709
+ function _processMessage(msg) {
710
+ switch (msg.type) {
711
+ case 'system':
712
+ if (msg.session_id) _sessionId = msg.session_id;
713
+ // The CLI's authoritative turn-level state machine (idle / running /
714
+ // requires_action — 'idle' fires only after the whole turn, tools included,
715
+ // settles). Emission is gated behind CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS
716
+ // which cli.js injects into the child env; on CLIs that never emit it the
717
+ // push/result fallbacks keep the spinner correct at result granularity.
718
+ if (msg.subtype === 'session_state_changed') {
719
+ _announceSessionState(msg.state);
720
+ } else if (msg.subtype === 'init' && _broadcastWs
721
+ && Array.isArray(msg.slash_commands) && msg.slash_commands.length > 0
722
+ && msg.session_id !== _initAnnouncedSid) {
723
+ _initAnnouncedSid = msg.session_id;
724
+ _lastInitSnapshot = {
725
+ type: 'sdk-init',
726
+ sessionId: msg.session_id,
727
+ model: msg.model,
728
+ slashCommands: msg.slash_commands,
729
+ tools: msg.tools,
730
+ cwd: _cwd,
731
+ };
732
+ _broadcastWs(_lastInitSnapshot);
733
+ } else if (msg.subtype === 'compact_boundary' && _broadcastWs) {
734
+ const meta = msg.compact_metadata;
735
+ _broadcastWs({
736
+ type: 'sdk-compact',
737
+ trigger: meta && meta.trigger,
738
+ preTokens: meta && meta.pre_tokens,
739
+ postTokens: meta && meta.post_tokens,
740
+ cwd: _cwd,
741
+ sessionId: _sessionId || null,
742
+ });
743
+ // Compaction is the only non-turn event that materially shrinks context — refresh
744
+ // the usage panel. Coalesced with the turn-end refresh via the in-flight gate.
745
+ _refreshContextUsage('compact');
746
+ } else if (_broadcastWs
747
+ && (msg.subtype === 'task_started' || msg.subtype === 'task_progress'
748
+ || msg.subtype === 'task_notification' || msg.subtype === 'task_updated')) {
749
+ // SDK background-task / subagent progress (SDK 0.2.x system subtypes). The TODO
750
+ // HUD (TaskProgressHud) is hook-fed and never fires in SDK mode; this is a
751
+ // separate, lossless relay so clients can observe task lifecycle (incl.
752
+ // failed/stopped/killed terminal states the TODO HUD vocabulary lacks). The
753
+ // server keeps no task table here — pure forward, merge semantics left to the
754
+ // client (task_updated.patch is a wire-safe delta, not a full snapshot).
755
+ _broadcastWs({ type: 'sdk-task', cwd: _cwd, ..._normalizeSdkTask(msg) });
756
+ }
757
+ break;
758
+
759
+ case 'user':
760
+ case 'assistant':
761
+ // Conversation content is persisted by the wire path (proxy → fetch hook → v2),
762
+ // not here — the SDK child's API traffic reaches ccv's proxy and is captured with
763
+ // full fidelity (real user prompts + tool calls), so accumulating it again would
764
+ // double-write. Here we only track session continuity.
765
+ if (msg.session_id) _sessionId = msg.session_id;
766
+ break;
767
+
768
+ case 'result': {
769
+ if (msg.session_id) _sessionId = msg.session_id;
770
+ // Grace/watchdog tombstone: a result that arrives AFTER its turn was already
771
+ // force-settled belongs to that dead turn — discard it instead of letting it
772
+ // settle (and prematurely end) the NEXT turn. If the tombstone outlived its turn
773
+ // entirely (the owed result never comes) the count would swallow a FUTURE turn's
774
+ // result — the bookkeeping clear below keeps that mistake bounded to the
775
+ // turn-end signal rather than wedging _queryBusy.
776
+ if (_lateResultTombstones > 0) {
777
+ _lateResultTombstones--;
778
+ _clearGraceTimer();
779
+ _queryBusy = false;
780
+ _turnInFlight = false;
781
+ // The tombstoned turn is over — but a NEWER turn may already be in flight
782
+ // (Stop → grace settle → user re-sent → late result of the old turn arrives).
783
+ // Only announce idle when no turn is pending, else we'd kill turn 2's spinner.
784
+ if (_pendingTurns.length === 0) {
785
+ _announceSessionState('idle');
786
+ _clearTurnWatchdog();
718
787
  }
719
- return { behavior: 'allow', updatedInput: input };
788
+ _maybeDrain();
789
+ break;
720
790
  }
721
- } catch {}
791
+ const rec = _pendingTurns.shift();
792
+ // An interrupted turn's result is expected to be an error (turn aborted) — don't
793
+ // toast it. Turn-end still fires, matching the PTY Stop-hook semantics.
794
+ if (rec && !rec.interrupted) _notifyTurnError(msg);
795
+ // SDK turn-end signal. Equivalent to Claude Code's Stop hook
796
+ // in CLI mode — fires once per user-prompt response when the whole chain
797
+ // (assistant text + all tool calls + final reply) completes. SDK mode
798
+ // doesn't go through ensureHooks() so this in-process callback is the
799
+ // only way the renderer learns the turn is over.
800
+ if (_onTurnEnd) {
801
+ try { _onTurnEnd({ sessionId: _sessionId, ts: Date.now() }); }
802
+ catch (err) { console.warn('[sdk-manager] onTurnEnd threw:', err?.message); }
803
+ }
804
+ if (rec) _settleTurn(rec);
805
+ _clearGraceTimer();
806
+ _queryBusy = false;
807
+ _turnInFlight = false;
808
+ // Turn-level spinner clear at result time — immediate, unlike the debounced
809
+ // `turn_end` SSE (voice-pack semantics). Idempotent against a real
810
+ // session_state_changed(idle) arriving before/after.
811
+ _announceSessionState('idle');
812
+ if (_pendingTurns.length === 0) _clearTurnWatchdog();
813
+ _maybeDrain();
814
+ // Refresh the context-usage panel AFTER the turn fully settles (fire-and-forget,
815
+ // never awaited in this message loop). Placement after settle/drain guarantees a
816
+ // synchronous throw here can't truncate turn settlement.
817
+ _refreshContextUsage('turn-end');
818
+ break;
819
+ }
820
+
821
+ default:
822
+ break;
722
823
  }
723
- const planPayload = { type: 'sdk-plan-pending', id, input };
824
+ }
825
+
826
+ /**
827
+ * Surface a failed turn to the Web UI — the wire path persists the failed entry but
828
+ * never pushes a toast, so a dead/errored turn would otherwise look like a hang.
829
+ */
830
+ function _notifyTurnError(resultMsg) {
831
+ const isError = resultMsg.is_error === true || (resultMsg.subtype && resultMsg.subtype !== 'success');
832
+ if (!isError) return;
833
+ const errs = Array.isArray(resultMsg.errors) && resultMsg.errors.length
834
+ ? resultMsg.errors.join('; ')
835
+ : (typeof resultMsg.result === 'string' && resultMsg.result) || resultMsg.subtype || 'unknown error';
724
836
  if (_broadcastWs) {
725
- _broadcastWs(planPayload);
837
+ try { _broadcastWs({ type: 'sdk-error', message: String(errs), cwd: _cwd, sessionId: _sessionId || null }); } catch (err) { console.warn('[sdk-manager] sdk-error broadcast threw:', err?.message); }
726
838
  }
727
- const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal);
728
- if (result === null) {
729
- return { behavior: 'deny', message: 'Timeout waiting for plan approval' };
839
+ }
840
+
841
+ /**
842
+ * Collect a context-usage snapshot via the Query control surface (`get_context_usage`).
843
+ * Returns { ok:true, snapshot } | { ok:false, reason:'session-inactive'|'control-failed' }.
844
+ * Never throws — the panel is best-effort; failures must not surface as sdk-error toasts.
845
+ *
846
+ * Identity guard (not a null check): teardown nulls `_activeQuery`, but a subsequent user
847
+ * message lazily rebuilds it into a NEW query object. A late-resolving control promise from
848
+ * the dead generation must not broadcast that session's numbers into the new panel.
849
+ */
850
+ async function _collectContextUsage(trigger) {
851
+ const q = _activeQuery;
852
+ if (!q || typeof q.getContextUsage !== 'function') return { ok: false, reason: 'session-inactive' };
853
+ let timeoutId;
854
+ try {
855
+ // SDK request() has no built-in timeout — race a bound so a live-but-unresponsive
856
+ // child can't hold the in-flight gate forever. unref so the timer never delays process
857
+ // exit; cleared once the request settles so the handle doesn't linger.
858
+ const timeout = new Promise((_, reject) => {
859
+ timeoutId = setTimeout(() => reject(new Error('getContextUsage timeout')), CTX_USAGE_TIMEOUT_MS);
860
+ if (timeoutId.unref) timeoutId.unref();
861
+ });
862
+ const usage = await Promise.race([q.getContextUsage(), timeout]);
863
+ if (_activeQuery !== q) return { ok: false, reason: 'session-inactive' }; // stale generation
864
+ // Degenerate resolve (null/undefined/{}) — skip rather than broadcast a usage-less frame.
865
+ if (!usage || typeof usage !== 'object' || Array.isArray(usage) || Object.keys(usage).length === 0) {
866
+ return { ok: false, reason: 'control-failed' };
867
+ }
868
+ _lastContextUsage = { type: 'sdk-context-usage', sessionId: _sessionId, ts: Date.now(), trigger, usage, cwd: _cwd };
869
+ return { ok: true, snapshot: _lastContextUsage };
870
+ } catch (err) {
871
+ reportSwallowed('sdk-manager.context-usage', err);
872
+ return { ok: false, reason: 'control-failed' };
873
+ } finally {
874
+ if (timeoutId) clearTimeout(timeoutId);
730
875
  }
731
- // cancel sentinel guard: cancelApproval shares the _pendingApprovals Map; if an
732
- // ask-cancel collides with a plan id (the kind tag already guards this, but the sentinel
733
- // guard is kept as defensive depth), it must not fall through to allow.
734
- if (result && typeof result === 'object' && result.__cancelled__ === true) {
735
- return { behavior: 'deny', message: result.reason || 'User aborted' };
876
+ }
877
+
878
+ /**
879
+ * Fire-and-forget refresh for turn-boundary hooks (result / compact_boundary). Never
880
+ * awaited by the message loop. In-flight requests coalesce: a second trigger while one is
881
+ * running queues exactly one rerun. The fetching flag is ALWAYS reset in finally (incl.
882
+ * stale-generation early returns) so the gate can't wedge.
883
+ */
884
+ function _refreshContextUsage(trigger) {
885
+ if (_ctxUsageFetching) { _ctxUsageQueued = true; return; }
886
+ _ctxUsageFetching = true;
887
+ const seq = ++_ctxUsageSeq; // capture this generation; a session switch bumps it
888
+ _collectContextUsage(trigger).then((r) => {
889
+ if (r.ok && _broadcastWs) {
890
+ try { _broadcastWs(r.snapshot); }
891
+ catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
892
+ }
893
+ }).finally(() => {
894
+ // Only the generation that set the flag may clear it — a stale request's finally must
895
+ // not clobber a NEWER in-flight request started after a session switch.
896
+ if (seq !== _ctxUsageSeq) return;
897
+ _ctxUsageFetching = false;
898
+ if (_ctxUsageQueued) { _ctxUsageQueued = false; _refreshContextUsage('coalesced'); }
899
+ });
900
+ }
901
+
902
+ /**
903
+ * Clear the usage snapshot on a session boundary (switch / full reset). Broadcasts a
904
+ * usage:null clear frame ONLY when a snapshot was present (an empty-to-empty broadcast
905
+ * would just be noise). The death path deliberately does NOT clear (same-session resume
906
+ * keeps the last known value, matching _lastInitSnapshot's death behavior).
907
+ */
908
+ function _clearContextUsage(trigger) {
909
+ const had = _lastContextUsage !== null;
910
+ _lastContextUsage = null;
911
+ _ctxUsageSeq++; // invalidate any in-flight generation so its finally can't clear a newer gate
912
+ _ctxUsageFetching = false;
913
+ _ctxUsageQueued = false;
914
+ if (had && _broadcastWs) {
915
+ try { _broadcastWs({ type: 'sdk-context-usage', sessionId: _sessionId, ts: Date.now(), trigger, usage: null, cwd: _cwd }); }
916
+ catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
736
917
  }
737
- if (typeof result === 'object' && result.approve === false) {
738
- return { behavior: 'deny', message: result.feedback || 'User rejected the plan' };
918
+ }
919
+
920
+ /**
921
+ * Collect an MCP-server-status snapshot via the Query control surface (`mcp_status`).
922
+ * Returns { ok:true, snapshot } | { ok:false, reason:'session-inactive'|'control-failed' }.
923
+ * Never throws — the panel is best-effort; failures must not surface as sdk-error toasts.
924
+ *
925
+ * Mirrors _collectContextUsage with ONE inversion: mcpServerStatus() resolves to an ARRAY
926
+ * (McpServerStatus[]), so the degenerate guard accepts any array — an EMPTY array is a
927
+ * legitimate "no MCP servers configured" result that must be broadcast, not a failure —
928
+ * and rejects non-arrays. Same identity guard (stale-generation) and 10s timeout bound.
929
+ */
930
+ async function _collectMcpStatus(trigger) {
931
+ const q = _activeQuery;
932
+ if (!q || typeof q.mcpServerStatus !== 'function') return { ok: false, reason: 'session-inactive' };
933
+ let timeoutId;
934
+ try {
935
+ const timeout = new Promise((_, reject) => {
936
+ timeoutId = setTimeout(() => reject(new Error('mcpServerStatus timeout')), CTX_USAGE_TIMEOUT_MS);
937
+ if (timeoutId.unref) timeoutId.unref();
938
+ });
939
+ const servers = await Promise.race([q.mcpServerStatus(), timeout]);
940
+ if (_activeQuery !== q) return { ok: false, reason: 'session-inactive' }; // stale generation
941
+ // Degenerate resolve (non-array) — skip rather than broadcast a malformed frame.
942
+ if (!Array.isArray(servers)) return { ok: false, reason: 'control-failed' };
943
+ _lastMcpStatus = { type: 'sdk-mcp-status', sessionId: _sessionId, ts: Date.now(), trigger, servers, cwd: _cwd };
944
+ return { ok: true, snapshot: _lastMcpStatus };
945
+ } catch (err) {
946
+ reportSwallowed('sdk-manager.mcp-status', err);
947
+ return { ok: false, reason: 'control-failed' };
948
+ } finally {
949
+ if (timeoutId) clearTimeout(timeoutId);
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Clear the MCP-status snapshot on a session boundary (switch / full reset). Broadcasts a
955
+ * servers:null clear frame ONLY when a snapshot was present. No seq bump — there is no
956
+ * in-flight gate to invalidate; the identity guard already covers the stale case. The
957
+ * death path deliberately does NOT clear (same-session resume keeps the last known value).
958
+ */
959
+ function _clearMcpStatus(trigger) {
960
+ const had = _lastMcpStatus !== null;
961
+ _lastMcpStatus = null;
962
+ if (had && _broadcastWs) {
963
+ try { _broadcastWs({ type: 'sdk-mcp-status', sessionId: _sessionId, ts: Date.now(), trigger, servers: null, cwd: _cwd }); }
964
+ catch (err) { reportSwallowed('sdk-manager.mcp-status-broadcast', err); }
739
965
  }
740
- return { behavior: 'allow', updatedInput: input };
741
966
  }
742
967
 
743
- if (toolName === 'AskUserQuestion') {
968
+ /**
969
+ * canUseTool callback — handles AskUserQuestion + permission approval.
970
+ *
971
+ * Check order mirrors perm-bridge.js precedence:
972
+ * 1. IM hard deny (CCV_IM_DENY=1 only) — beats everything, including the
973
+ * publish force-approval below (an IM worker gets a hard deny, not a modal);
974
+ * 2. npm-publish hard gate — forced through the perm approval branch even in
975
+ * bypassPermissions mode (perm-bridge.js's bypass exemption equivalent);
976
+ * 3. bypass early-allow — bypass auto-approves everything else, but NEVER
977
+ * short-circuits the ExitPlanMode/AskUserQuestion interactive branches;
978
+ * 4. the three regular branches (plan / ask / perm).
979
+ */
980
+ async function _handleCanUseTool(toolName, input, options) {
981
+ const id = options?.toolUseID || `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
982
+
983
+ // 1. IM worker hard deny — must run before any auto-allow (perm-bridge.js parity).
984
+ if (process.env.CCV_IM_DENY === '1') {
985
+ const verdict = evaluateImDeny(toolName, input);
986
+ if (verdict.deny) {
987
+ return { behavior: 'deny', message: `cc-viewer IM guard: ${verdict.reason}` };
988
+ }
989
+ }
990
+
991
+ // 2. npm publish is never auto-allowed, even under --d (safety floor).
992
+ const isPublish = isPublishCommand(toolName, input);
993
+
994
+ // 3. Bypass mode auto-approves everything except publish and the two
995
+ // interactive tools — those keep their UI channels so a forwarded
996
+ // ExitPlanMode/AskUserQuestion still reaches the user.
997
+ if (_permissionMode === 'bypassPermissions' && !isPublish
998
+ && toolName !== 'ExitPlanMode' && toolName !== 'AskUserQuestion') {
999
+ return { behavior: 'allow', updatedInput: input };
1000
+ }
1001
+
1002
+ if (toolName === 'ExitPlanMode') {
1003
+ if (_runWaterfallHook) {
1004
+ try {
1005
+ const hookResult = await _runWaterfallHook('onPlanRequest', { id, input, mode: 'sdk' });
1006
+ if (hookResult.approve !== undefined) {
1007
+ if (hookResult.approve === false) {
1008
+ return { behavior: 'deny', message: hookResult.feedback || 'Plugin rejected the plan' };
1009
+ }
1010
+ return { behavior: 'allow', updatedInput: input };
1011
+ }
1012
+ } catch (err) { reportSwallowed('sdk-manager.waterfall.plan', err); }
1013
+ }
1014
+ const planPayload = { type: 'sdk-plan-pending', id, input, cwd: _cwd, sessionId: _sessionId || null };
1015
+ if (_broadcastWs) {
1016
+ _broadcastWs(planPayload);
1017
+ }
1018
+ const result = await _waitForApproval(id, 5 * 60 * 1000, 'plan', planPayload, options?.signal);
1019
+ if (result === null) {
1020
+ return { behavior: 'deny', message: 'Timeout waiting for plan approval' };
1021
+ }
1022
+ // cancel sentinel guard: cancelApproval shares the _pendingApprovals Map; if an
1023
+ // ask-cancel collides with a plan id (the kind tag already guards this, but the sentinel
1024
+ // guard is kept as defensive depth), it must not fall through to allow.
1025
+ if (result && typeof result === 'object' && result.__cancelled__ === true) {
1026
+ return { behavior: 'deny', message: result.reason || 'User aborted' };
1027
+ }
1028
+ if (typeof result === 'object' && result.approve === false) {
1029
+ return { behavior: 'deny', message: result.feedback || 'User rejected the plan' };
1030
+ }
1031
+ return { behavior: 'allow', updatedInput: input };
1032
+ }
1033
+
1034
+ if (toolName === 'AskUserQuestion') {
1035
+ if (_runWaterfallHook) {
1036
+ try {
1037
+ const hookResult = await _runWaterfallHook('onAskRequest', { id, questions: input.questions, mode: 'sdk' });
1038
+ if (hookResult.answers) {
1039
+ return { behavior: 'allow', updatedInput: { questions: input.questions, answers: hookResult.answers } };
1040
+ }
1041
+ } catch (err) { reportSwallowed('sdk-manager.waterfall.ask', err); }
1042
+ }
1043
+ // 24h — same source as the hook path (server.js ASK_HOOK_TIMEOUT_MS), honoring the
1044
+ // "GUI effectively has no timeout" promise. The actual constant lives in
1045
+ // server/lib/ask/ask-constants.js.
1046
+ const askTimeoutMs = ASK_TIMEOUT_MS;
1047
+ const askStartedAt = Date.now();
1048
+ const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs, cwd: _cwd, sessionId: _sessionId || null };
1049
+ if (_broadcastWs) {
1050
+ _broadcastWs(askPayload);
1051
+ }
1052
+ const answers = await _waitForApproval(id, askTimeoutMs, 'ask', askPayload, options?.signal);
1053
+ if (answers === null) {
1054
+ return { behavior: 'deny', message: 'Timeout waiting for user answer' };
1055
+ }
1056
+ // cancel sentinel: the { __cancelled__: true, reason } injected by cancelApproval via
1057
+ // _waitForApproval. Equivalent to terminal Claude Code's onAbort path — the SDK package
1058
+ // turns this deny into tool_result.is_error=true before injecting it into the
1059
+ // transcript, so the next request's transcript closes and the session does not wedge.
1060
+ // The [cc-viewer:cancel] prefix is a protocol-level sentinel — toolResultBuilder.js uses
1061
+ // prefix matching to tell cancelled vs rejected apart.
1062
+ if (answers && typeof answers === 'object' && answers.__cancelled__ === true) {
1063
+ return { behavior: 'deny', message: '[cc-viewer:cancel] ' + (answers.reason || 'User aborted') };
1064
+ }
1065
+ return { behavior: 'allow', updatedInput: { questions: input.questions, answers } };
1066
+ }
1067
+
1068
+ // Tools that need explicit user approval via Web UI (mutating or external access).
1069
+ // The six-tool set is shared with the PTY perm-bridge via approval-policy.js.
1070
+ if (!APPROVAL_TOOLS.has(toolName)) {
1071
+ return { behavior: 'allow', updatedInput: input };
1072
+ }
1073
+
1074
+ // Permission approval for mutating tools
1075
+ const suggestions = options?.suggestions;
744
1076
  if (_runWaterfallHook) {
745
1077
  try {
746
- const hookResult = await _runWaterfallHook('onAskRequest', { id, questions: input.questions, mode: 'sdk' });
747
- if (hookResult.answers) {
748
- return { behavior: 'allow', updatedInput: { questions: input.questions, answers: hookResult.answers } };
1078
+ const hookResult = await _runWaterfallHook('onPermRequest', { id, toolName, input, mode: 'sdk' });
1079
+ if (hookResult.decision === 'allow') {
1080
+ const response = { behavior: 'allow', updatedInput: input };
1081
+ if (hookResult.allowSession && Array.isArray(suggestions) && suggestions.length > 0) {
1082
+ response.updatedPermissions = suggestions;
1083
+ }
1084
+ return response;
749
1085
  }
750
- } catch {}
1086
+ if (hookResult.decision === 'deny') {
1087
+ return { behavior: 'deny', message: 'Plugin denied' };
1088
+ }
1089
+ // unknown decision → fall through to normal approval flow
1090
+ } catch (err) { reportSwallowed('sdk-manager.waterfall.perm', err); }
751
1091
  }
752
- // 24h — same source as the hook path (server.js ASK_HOOK_TIMEOUT_MS), honoring the
753
- // "GUI effectively has no timeout" promise. The actual constant lives in
754
- // server/lib/ask/ask-constants.js.
755
- const askTimeoutMs = ASK_TIMEOUT_MS;
756
- const askStartedAt = Date.now();
757
- const askPayload = { type: 'sdk-ask-pending', id, questions: input.questions, startedAt: askStartedAt, timeoutMs: askTimeoutMs };
1092
+ const permPayload = { type: 'perm-hook-pending', id, toolName, input, cwd: _cwd, sessionId: _sessionId || null };
758
1093
  if (_broadcastWs) {
759
- _broadcastWs(askPayload);
1094
+ _broadcastWs(permPayload);
760
1095
  }
761
- const answers = await _waitForApproval(id, askTimeoutMs, 'ask', askPayload, options?.signal);
762
- if (answers === null) {
763
- return { behavior: 'deny', message: 'Timeout waiting for user answer' };
1096
+
1097
+ const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal);
1098
+ if (result === null) {
1099
+ return { behavior: 'deny', message: 'Timeout waiting for user approval' };
764
1100
  }
765
- // cancel sentinel: the { __cancelled__: true, reason } injected by cancelApproval via
766
- // _waitForApproval. Equivalent to terminal Claude Code's onAbort path — the SDK package
767
- // turns this deny into tool_result.is_error=true before injecting it into the
768
- // transcript, so the next request's transcript closes and the session does not wedge.
769
- // The [cc-viewer:cancel] prefix is a protocol-level sentinel — toolResultBuilder.js uses
770
- // prefix matching to tell cancelled vs rejected apart.
771
- if (answers && typeof answers === 'object' && answers.__cancelled__ === true) {
772
- return { behavior: 'deny', message: '[cc-viewer:cancel] ' + (answers.reason || 'User aborted') };
1101
+ // cancel sentinel guard: same as the plan branch — prevents a cancelApproval colliding
1102
+ // with a perm id from wrongly allowing
1103
+ if (result && typeof result === 'object' && result.__cancelled__ === true) {
1104
+ return { behavior: 'deny', message: result.reason || 'User aborted' };
1105
+ }
1106
+ const decision = typeof result === 'object' ? result.decision : result;
1107
+ const allowSession = typeof result === 'object' && result.allowSession;
1108
+ if (decision === 'deny') {
1109
+ return { behavior: 'deny', message: 'User denied via cc-viewer' };
1110
+ }
1111
+ const response = { behavior: 'allow', updatedInput: input };
1112
+ if (allowSession && Array.isArray(suggestions) && suggestions.length > 0) {
1113
+ response.updatedPermissions = suggestions;
773
1114
  }
774
- return { behavior: 'allow', updatedInput: { questions: input.questions, answers } };
1115
+ return response;
775
1116
  }
776
1117
 
777
- // Tools that need explicit user approval via Web UI (mutating or external access).
778
- // The six-tool set is shared with the PTY perm-bridge via approval-policy.js.
779
- if (!APPROVAL_TOOLS.has(toolName)) {
780
- return { behavior: 'allow', updatedInput: input };
1118
+ /**
1119
+ * Broadcast a modal-dismiss for a pending approval that ended WITHOUT a user answer
1120
+ * (timeout / session teardown / CLI-side abort). Maps to the close types the web
1121
+ * clients already handle by id:
1122
+ * ask → 'sdk-ask-timeout' (askFlowController), plan → 'sdk-plan-resolved',
1123
+ * perm → 'perm-hook-timeout' (same type the PTY hook path emits).
1124
+ * Deliberately NOT routed through sdk-adapter's sdkApprovalCloseType: that maps ask →
1125
+ * 'ask-hook-cancelled', whose handler flushes the user message parked behind the ask —
1126
+ * wrong for a timeout/dismiss. Frontend handlers match on `id` only; extra fields are inert.
1127
+ */
1128
+ function _broadcastApprovalDismiss(kind, id, reason) {
1129
+ if (!_broadcastWs) return;
1130
+ const type = kind === 'ask' ? 'sdk-ask-timeout'
1131
+ : kind === 'plan' ? 'sdk-plan-resolved'
1132
+ : 'perm-hook-timeout';
1133
+ try {
1134
+ _broadcastWs({ type, id, reason, cwd: _cwd, sessionId: _sessionId || null });
1135
+ } catch (err) { console.warn('[sdk-manager] approval-dismiss broadcast threw:', err?.message); }
781
1136
  }
782
1137
 
783
- // Permission approval for mutating tools
784
- const suggestions = options?.suggestions;
785
- if (_runWaterfallHook) {
786
- try {
787
- const hookResult = await _runWaterfallHook('onPermRequest', { id, toolName, input, mode: 'sdk' });
788
- if (hookResult.decision === 'allow') {
789
- const response = { behavior: 'allow', updatedInput: input };
790
- if (hookResult.allowSession && Array.isArray(suggestions) && suggestions.length > 0) {
791
- response.updatedPermissions = suggestions;
1138
+ function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null) {
1139
+ return new Promise((resolve) => {
1140
+ const timer = setTimeout(() => {
1141
+ _pendingApprovals.delete(id);
1142
+ // Close the modal on every client — a silent deny would leave it hanging forever.
1143
+ _broadcastApprovalDismiss(kind, id, 'timeout');
1144
+ resolve(null);
1145
+ }, timeoutMs);
1146
+ _pendingApprovals.set(id, {
1147
+ kind, // 'ask' | 'plan' | 'perm' — lets cancelApproval distinguish types so an
1148
+ // ask-cancel does not collide with a plan/perm id
1149
+ replay, // exact broadcast payload announced to clients — replayed to a fresh WS connection
1150
+ startedAt: Date.now(),
1151
+ timeoutMs,
1152
+ resolve: (value) => {
1153
+ clearTimeout(timer);
1154
+ _pendingApprovals.delete(id);
1155
+ resolve(value);
1156
+ },
1157
+ });
1158
+ // CLI-side abort of the in-flight control request (e.g. interrupt while the modal is
1159
+ // open): settle through the same first-wins path as a timeout so the promise resolves
1160
+ // to deny and the modal closes everywhere. { once: true } prevents listener leaks.
1161
+ if (signal && typeof signal.addEventListener === 'function') {
1162
+ if (signal.aborted) {
1163
+ const pending = _pendingApprovals.get(id);
1164
+ if (pending) { _broadcastApprovalDismiss(kind, id, 'aborted'); pending.resolve(null); }
1165
+ } else {
1166
+ signal.addEventListener('abort', () => {
1167
+ const pending = _pendingApprovals.get(id);
1168
+ if (pending) { _broadcastApprovalDismiss(kind, id, 'aborted'); pending.resolve(null); }
1169
+ }, { once: true });
792
1170
  }
793
- return response;
794
1171
  }
795
- if (hookResult.decision === 'deny') {
796
- return { behavior: 'deny', message: 'Plugin denied' };
1172
+ });
1173
+ }
1174
+
1175
+ /**
1176
+ * Resolve a pending canUseTool approval.
1177
+ * Called by server.js when a WS message arrives.
1178
+ */
1179
+ function _resolveApproval(id, value) {
1180
+ const pending = _pendingApprovals.get(id);
1181
+ if (pending) {
1182
+ pending.resolve(value);
1183
+ return true;
1184
+ }
1185
+ return false;
1186
+ }
1187
+
1188
+ /**
1189
+ * Snapshot of pending approvals for WS-reconnect replay (server.js connection
1190
+ * handler). Returns [ { id, kind, replay, startedAt, timeoutMs } ] with remaining
1191
+ * time computed at call time; entries already past their timeout are excluded.
1192
+ * The resolve closures are deliberately not exposed.
1193
+ */
1194
+ function _getPendingApprovals() {
1195
+ const now = Date.now();
1196
+ const out = [];
1197
+ for (const [id, p] of _pendingApprovals) {
1198
+ const remaining = (p.timeoutMs ?? 0) - (now - (p.startedAt ?? now));
1199
+ if (remaining <= 0) continue;
1200
+ out.push({ id, kind: p.kind || null, replay: p.replay || null, remainingMs: remaining });
1201
+ }
1202
+ return out;
1203
+ }
1204
+
1205
+ /**
1206
+ * Cancel a pending canUseTool approval — used by ask-cancel WS handler
1207
+ * (user clicked Cancel button or typed-interrupt in input bar).
1208
+ *
1209
+ * Not equivalent to resolveApproval(id, null): null already occupies the timeout semantics
1210
+ * in _waitForApproval. Here we resolve a { __cancelled__: true, reason } sentinel so
1211
+ * canUseTool takes the deny branch rather than allow (see the _handleCanUseTool
1212
+ * AskUserQuestion block).
1213
+ *
1214
+ * kind check: the ask-cancel protocol only applies to ask-type approvals. Colliding with a
1215
+ * plan / perm id returns false and is not handled — so a cancel-ask signal cannot be
1216
+ * mistaken for "the user rejected the plan", which would write a wrong reason into the
1217
+ * model context.
1218
+ *
1219
+ * Shares the same _pendingApprovals Map and the same first-wins atomic guard
1220
+ * (pending.resolve clearTimeout + delete) as resolveApproval, so a cancel racing an answer
1221
+ * is a no-op for whichever arrives second.
1222
+ */
1223
+ function _cancelApproval(id, reason) {
1224
+ const pending = _pendingApprovals.get(id);
1225
+ if (!pending) return false;
1226
+ if (pending.kind && pending.kind !== 'ask') return false;
1227
+ pending.resolve({ __cancelled__: true, reason: typeof reason === 'string' ? reason : 'User aborted' });
1228
+ return true;
1229
+ }
1230
+
1231
+ /**
1232
+ * Cancel all in-flight approvals (else their canUseTool promises stay parked and the
1233
+ * timeout timers leak, and clients keep a ghost approval modal open).
1234
+ *
1235
+ * Returns the list of approvals that were pending (`[{ id, kind }]`) so the caller
1236
+ * (server.js) can broadcast modal-close messages to every client. Always an array.
1237
+ */
1238
+ function _cancelAllApprovals() {
1239
+ const cancelled = Array.from(_pendingApprovals, ([id, pending]) => ({ id, kind: pending.kind || null }));
1240
+ for (const { id } of cancelled) {
1241
+ _pendingApprovals.get(id)?.resolve(null);
1242
+ }
1243
+ _pendingApprovals.clear();
1244
+ return cancelled;
1245
+ }
1246
+
1247
+ /** Backstop: if no result shows up within the grace window after an interrupt, force-
1248
+ * settle the head turn (keeping _queryBusy from wedging forever) and mark a tombstone
1249
+ * so the result that eventually arrives is discarded instead of settling the next turn. */
1250
+ function _armSettleGrace() {
1251
+ _clearGraceTimer();
1252
+ _settleGraceTimer = setTimeout(() => {
1253
+ _settleGraceTimer = null;
1254
+ const rec = _pendingTurns[0];
1255
+ if (!rec || !rec.interrupted) return;
1256
+ console.warn('[sdk-manager] no result within interrupt grace — force-settling the turn');
1257
+ _lateResultTombstones++;
1258
+ _pendingTurns.shift();
1259
+ _settleTurn(rec);
1260
+ _queryBusy = false;
1261
+ // The CLI never answered the interrupt — no result is coming, so clear the
1262
+ // spinner here rather than waiting on a dead signal.
1263
+ _announceSessionState('idle');
1264
+ if (_pendingTurns.length === 0) _clearTurnWatchdog();
1265
+ _maybeDrain();
1266
+ }, INTERRUPT_GRACE_MS);
1267
+ if (_settleGraceTimer.unref) _settleGraceTimer.unref();
1268
+ }
1269
+
1270
+ /**
1271
+ * Interrupt the current turn (user clicked the Stop button) while KEEPING the
1272
+ * session alive: the resident query is a streaming-input session, so interrupt()
1273
+ * is a real control request — the CLI aborts the current turn and synthesizes an
1274
+ * error result for it, and the conversation (and child process) survives.
1275
+ *
1276
+ * Contrast with `stopSession()` below, which is the hard process-exit cleanup
1277
+ * that also nulls `_sessionId` (loses conversation continuity).
1278
+ *
1279
+ * Stop KEEPS queued messages (product decision, matching the web UX where queued bubbles
1280
+ * stay parked after Stop): `_suppressDrain` stops the result-driven drain from running
1281
+ * them right after the interrupt; the park lifts when a fresh user message / send-now
1282
+ * starts a new turn. The queue-state broadcast lets clients keep their bubbles.
1283
+ *
1284
+ * Returns the list of approvals that were pending at interrupt time
1285
+ * (`[{ id, kind }]`) so the caller (server.js) can broadcast modal-close
1286
+ * messages to every client. Always an array (empty when nothing was pending).
1287
+ */
1288
+ function _interruptTurn() {
1289
+ const cancelled = _cancelAllApprovals();
1290
+ const q = _activeQuery;
1291
+ // Key off the sticky in-flight flag, not the FIFO: the grace/watchdog force-settle
1292
+ // paths drain _pendingTurns while the CLI-side turn may still be running — keying on
1293
+ // the FIFO length would make a second Stop silently skip the interrupt request.
1294
+ if (q && _turnInFlight) {
1295
+ _turnInFlight = false; // don't send duplicate interrupts while the abort unwinds
1296
+ if (_pendingTurns.length > 0) _pendingTurns[0].interrupted = true;
1297
+ if (typeof q.interrupt === 'function') {
1298
+ try {
1299
+ // A reject here (e.g. the control request can't reach a dying child) is NOT a
1300
+ // death signal — only the iterator ending is. Report and move on.
1301
+ q.interrupt().catch((err) => reportSwallowed('sdk-manager.interrupt', err));
1302
+ } catch (err) { reportSwallowed('sdk-manager.interrupt', err); }
797
1303
  }
798
- // unknown decision → fall through to normal approval flow
799
- } catch {}
1304
+ _armSettleGrace();
1305
+ }
1306
+ _suppressDrain = true;
1307
+ _broadcastQueueState();
1308
+ return cancelled;
800
1309
  }
801
- const permPayload = { type: 'perm-hook-pending', id, toolName, input };
802
- if (_broadcastWs) {
803
- _broadcastWs(permPayload);
1310
+
1311
+ /**
1312
+ * Send-now on a queued bubble: interrupt the running turn and make this message the next
1313
+ * one executed. Unlike Stop, this does NOT suppress draining — the in-flight
1314
+ * sendUserMessage loop picks the prioritized item up as soon as the abort settles
1315
+ * (JS single-threading: the splice+unshift below is synchronous, so the drain's next
1316
+ * iteration always sees it at the head).
1317
+ *
1318
+ * When no turn is running (parked queue after Stop), the message is executed immediately
1319
+ * as a fresh turn.
1320
+ *
1321
+ * Known semantic: if the FIRST turn is interrupted before its system/init message was
1322
+ * processed, `_sessionId` is still null and the prioritized message starts a fresh session
1323
+ * (the aborted first turn is not resumable) — same as Stop-then-send on turn one.
1324
+ *
1325
+ * Returns the cancelled-approvals list (same shape as interruptTurn) so server.js can
1326
+ * close approval modals on all clients.
1327
+ */
1328
+ function _sendQueuedNow(id) {
1329
+ const idx = _messageQueue.findIndex((it) => it.id === id);
1330
+ if (idx < 0) return []; // unknown / already dispatched — no-op BEFORE any interrupt
1331
+ const [item] = _messageQueue.splice(idx, 1);
1332
+ // Send-now ALWAYS means "run this now" — lift a Stop-park even when the aborted turn's
1333
+ // unwind is still in flight (_queryBusy still true). Without this, the drain loop's
1334
+ // `!_suppressDrain` condition would exit with the item already spliced out → silent loss.
1335
+ _suppressDrain = false;
1336
+
1337
+ if (!_queryBusy) {
1338
+ // Parked queue, idle session: run it now (broadcast happens via the drain path —
1339
+ // here we broadcast explicitly since the item left the queue).
1340
+ _broadcastQueueState();
1341
+ // Fire-and-forget matches sendUserMessage's existing call sites (WS handler does not await).
1342
+ _sendUserMessage(item.text).catch((err) => console.warn('[sdk-manager] send-now query failed:', err?.message));
1343
+ return [];
1344
+ }
1345
+
1346
+ _messageQueue.unshift(item);
1347
+ const cancelled = _interruptTurn();
1348
+ _suppressDrain = false; // send-now overrides the Stop-park interruptTurn just set
1349
+ _broadcastQueueState();
1350
+ return cancelled;
804
1351
  }
805
1352
 
806
- const result = await _waitForApproval(id, 5 * 60 * 1000, 'perm', permPayload, options?.signal);
807
- if (result === null) {
808
- return { behavior: 'deny', message: 'Timeout waiting for user approval' };
1353
+ /** Remove one queued message (bubble ×). Returns true when found. */
1354
+ function _removeQueued(id) {
1355
+ const idx = _messageQueue.findIndex((it) => it.id === id);
1356
+ if (idx < 0) return false;
1357
+ _messageQueue.splice(idx, 1);
1358
+ _broadcastQueueState();
1359
+ return true;
809
1360
  }
810
- // cancel sentinel guard: same as the plan branch — prevents a cancelApproval colliding
811
- // with a perm id from wrongly allowing
812
- if (result && typeof result === 'object' && result.__cancelled__ === true) {
813
- return { behavior: 'deny', message: result.reason || 'User aborted' };
1361
+
1362
+ /** Drop all queued messages WITHOUT touching the running turn (session switch). */
1363
+ function _clearQueued() {
1364
+ if (_messageQueue.length === 0) return;
1365
+ _messageQueue = [];
1366
+ _broadcastQueueState();
814
1367
  }
815
- const decision = typeof result === 'object' ? result.decision : result;
816
- const allowSession = typeof result === 'object' && result.allowSession;
817
- if (decision === 'deny') {
818
- return { behavior: 'deny', message: 'User denied via cc-viewer' };
1368
+
1369
+ /** Queue snapshot for WS-reconnect replay (same pattern as getSdkInitSnapshot). */
1370
+ function _getQueueSnapshot() {
1371
+ return _messageQueue.map(({ id, text, ts }) => ({ id, text, ts }));
819
1372
  }
820
- const response = { behavior: 'allow', updatedInput: input };
821
- if (allowSession && Array.isArray(suggestions) && suggestions.length > 0) {
822
- response.updatedPermissions = suggestions;
1373
+
1374
+ /**
1375
+ * Switch the session to a different session id (web "resume session" entry — SDK-mode
1376
+ * counterpart of the PTY /resume injection). options.resume only applies at query
1377
+ * construction, so the switch tears down the resident query; the next user message
1378
+ * lazily reconstructs it with options.resume = sessionId.
1379
+ *
1380
+ * Returns { ok: true } on success; { ok: false, reason } with 'busy' (a turn or another
1381
+ * switch is in flight), 'unavailable' (SDK missing), or 'bad-id' otherwise.
1382
+ */
1383
+ function _switchToSession(sessionId) {
1384
+ if (!_query) return { ok: false, reason: 'unavailable' };
1385
+ if (typeof sessionId !== 'string' || !sessionId) return { ok: false, reason: 'bad-id' };
1386
+ if (_queryBusy || _switching) return { ok: false, reason: 'busy' };
1387
+ _switching = true;
1388
+ try {
1389
+ _teardownResident();
1390
+ // Defensive: no approvals can be pending while idle, but never leak one across
1391
+ // a session boundary (WS reconnect replay would show a ghost modal).
1392
+ for (const { id, kind } of _cancelAllApprovals()) {
1393
+ _broadcastApprovalDismiss(kind, id, 'session-switch');
1394
+ }
1395
+ _sessionId = sessionId;
1396
+ _launchResume = null;
1397
+ _initAnnouncedSid = '';
1398
+ _lastInitSnapshot = null; // don't replay the OLD session's sdk-init to the new one
1399
+ _lastSessionState = null; // same for the session-state snapshot — new session starts idle
1400
+ _clearContextUsage('session-switched');
1401
+ _clearMcpStatus('session-switched');
1402
+ _clearQueued(); // broadcasts the emptied queue-state
1403
+ _suppressDrain = false;
1404
+ return { ok: true };
1405
+ } finally {
1406
+ _switching = false;
1407
+ }
823
1408
  }
824
- return response;
1409
+
1410
+ /**
1411
+ * Reset all session state.
1412
+ */
1413
+ function _resetFullState() {
1414
+ _sessionId = null;
1415
+ _queryBusy = false;
1416
+ _switching = false;
1417
+ _turnInFlight = false;
1418
+ _initAnnouncedSid = '';
1419
+ _lastInitSnapshot = null;
1420
+ // Tell clients BEFORE nulling the snapshot: a re-init/launch mid-turn would
1421
+ // otherwise leave the spinner lit with no frame ever clearing it (the new
1422
+ // session starts idle and the state-change guard would swallow the first
1423
+ // legit idle). Skip the broadcast when nothing was ever announced.
1424
+ if (_lastSessionState && _lastSessionState !== 'idle') _announceSessionState('idle');
1425
+ _lastSessionState = null;
1426
+ _clearContextUsage('session-switched');
1427
+ _clearMcpStatus('session-switched');
1428
+ _pendingTurns = [];
1429
+ _lateResultTombstones = 0;
1430
+ _consecutiveDeaths = 0;
1431
+ _clearGraceTimer();
1432
+ _clearTurnWatchdog();
1433
+ const hadQueued = _messageQueue.length > 0;
1434
+ _messageQueue = [];
1435
+ _suppressDrain = false;
1436
+ // Only broadcast on an actual change — initSdkSession calls this before any client cares,
1437
+ // and an empty-to-empty broadcast would just be noise on the wire.
1438
+ if (hadQueued) _broadcastQueueState();
1439
+ // Reject all pending approvals
1440
+ for (const [, pending] of _pendingApprovals) {
1441
+ pending.resolve(null);
1442
+ }
1443
+ _pendingApprovals.clear();
1444
+ }
1445
+
1446
+ // ── Exposed Session surface ──────────────────────────────────────────────
1447
+ // `teardown` = _teardownResident + _resetFullState (the old module-level stopSession
1448
+ // body, now per-session). `init` = config adoption + that teardown/reset pair.
1449
+ return {
1450
+ key,
1451
+ get cwd() { return _cwd; },
1452
+ get config() {
1453
+ return {
1454
+ permissionMode: _permissionMode,
1455
+ env: _childEnv,
1456
+ settings: _settings,
1457
+ model: _launchModel,
1458
+ launchResume: _launchResume,
1459
+ userArgs: _userArgs,
1460
+ claudeExecutable: _claudeExecutable,
1461
+ };
1462
+ },
1463
+ init: _init,
1464
+ sendUserMessage: _sendUserMessage,
1465
+ interruptTurn: _interruptTurn,
1466
+ sendQueuedNow: _sendQueuedNow,
1467
+ removeQueued: _removeQueued,
1468
+ clearQueued: _clearQueued,
1469
+ getQueueSnapshot: _getQueueSnapshot,
1470
+ resolveApproval: _resolveApproval,
1471
+ cancelApproval: _cancelApproval,
1472
+ getPendingApprovals: _getPendingApprovals,
1473
+ getSdkInitSnapshot: () => _lastInitSnapshot,
1474
+ getSessionStateSnapshot: () => {
1475
+ if (!_lastSessionState) return null;
1476
+ return { type: 'sdk-session-state', state: _lastSessionState, sessionId: _sessionId || null, cwd: _cwd };
1477
+ },
1478
+ getContextUsageSnapshot: () => _lastContextUsage,
1479
+ getMcpStatusSnapshot: () => _lastMcpStatus,
1480
+ fetchContextUsage: async () => {
1481
+ // On-demand fetch for the HTTP route: refresh the snapshot, broadcast to all clients,
1482
+ // then reply. Unlike _refreshContextUsage this bypasses the in-flight gate (an explicit
1483
+ // user refresh should not be swallowed by a queued turn-end) and returns the outcome.
1484
+ const r = await _collectContextUsage('request');
1485
+ if (r.ok && _broadcastWs) {
1486
+ try { _broadcastWs(r.snapshot); }
1487
+ catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
1488
+ }
1489
+ return r;
1490
+ },
1491
+ fetchMcpStatus: async () => {
1492
+ // On-demand fetch for the HTTP route: refresh the snapshot, broadcast to all clients,
1493
+ // then reply. fetchContextUsage parity — bypasses any gate (explicit user refresh).
1494
+ const r = await _collectMcpStatus('request');
1495
+ if (r.ok && _broadcastWs) {
1496
+ try { _broadcastWs(r.snapshot); }
1497
+ catch (err) { reportSwallowed('sdk-manager.mcp-status-broadcast', err); }
1498
+ }
1499
+ return r;
1500
+ },
1501
+ switchToSession: _switchToSession,
1502
+ getSessionId: () => _sessionId,
1503
+ teardown: () => {
1504
+ _teardownResident();
1505
+ _resetFullState();
1506
+ },
1507
+ };
1508
+ }
1509
+
1510
+ export function isSdkAvailable() {
1511
+ return typeof _query === 'function';
825
1512
  }
826
1513
 
827
1514
  /**
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.
1515
+ * Initialize (or re-initialize) a workspace's SDK session.
1516
+ * Does NOT start a query — waits for the first user message via sendUserMessage().
1517
+ * Registering a DIFFERENT cwd does not touch other registered sessions (they stay
1518
+ * resident in the registry; concurrent workspaces keep their state).
1519
+ *
1520
+ * @param {string} cwd
1521
+ * @param {string} projectName
1522
+ * @param {object} deps
1523
+ * @param {function} deps.onTurnEnd — ({sessionId, ts}) => void, fired on SDK 'result'
1524
+ * @param {function} [deps.onQueryError] — (message: string) => void, fired when the
1525
+ * query itself fails (spawn/protocol/iterator errors) — until this existed the only
1526
+ * surfacing was console.error, invisible in the Web UI
1527
+ * @param {function} deps.broadcastWs — (msg) => void, terminal-WS broadcast for approvals
1528
+ * @param {string} [deps.permissionMode]
1529
+ * @param {function} [deps.runWaterfallHook] — plugin waterfall (onPlanRequest/onAskRequest/onPermRequest)
1530
+ * @param {object} [deps.env] — child env (must already contain ANTHROPIC_BASE_URL → ccv proxy)
1531
+ * @param {object} [deps.settings] — merged Settings object (env.ANTHROPIC_BASE_URL double-injection)
1532
+ * @param {string} [deps.model] — --model lifted from user args (options.model)
1533
+ * @param {object} [deps.launchResume] — startup continuation { continue, resumeId, forkSession }
1534
+ * @param {string[]} [deps.userArgs] — remaining user args (extraArgs passthrough)
1535
+ * @param {string} [deps.claudeExecutable] — resolved claude binary (options.pathToClaudeCodeExecutable);
1536
+ * must come from the same selection as PTY mode, else the SDK spawns a PATH claude that
1537
+ * host security tooling (e.g. agent-security headless guards) may SIGKILL on spawn
836
1538
  */
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); }
1539
+ export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broadcastWs, permissionMode, runWaterfallHook, env, settings, model, launchResume, userArgs, claudeExecutable } = {}) {
1540
+ void projectName; // reserved (display name comes from the wire path)
1541
+ // Process-global callbacks (cli.js registers one wiring; every session reuses it). Only
1542
+ // OVERWRITE when a callback is actually passed — a later per-workspace init (the launch
1543
+ // route registers a fresh session) may omit them, and blindly assigning undefined would
1544
+ // kill every workspace's broadcasts/turn-end/approvals process-wide (2026-10-12 regression).
1545
+ if (onTurnEnd !== undefined) _onTurnEnd = onTurnEnd;
1546
+ if (onQueryError !== undefined) _onQueryError = onQueryError || null;
1547
+ if (broadcastWs !== undefined) _broadcastWs = broadcastWs;
1548
+ if (runWaterfallHook !== undefined) _runWaterfallHook = runWaterfallHook || null;
1549
+ // Register this workspace's session (per-cwd, re-using the record on re-init) and make
1550
+ // it active. init() adopts the launch config and tears down ONLY this session's prior
1551
+ // generation (tests / hot reload) — other workspaces' sessions are untouched.
1552
+ const sess = _getOrCreateSession(cwd);
1553
+ sess.init({ permissionMode, env, settings, model, launchResume, userArgs, claudeExecutable });
1554
+ _activeKey = sess.key;
845
1555
  }
846
1556
 
847
- function _waitForApproval(id, timeoutMs, kind, replay = null, signal = null) {
848
- return new Promise((resolve) => {
849
- const timer = setTimeout(() => {
850
- _pendingApprovals.delete(id);
851
- // Close the modal on every client — a silent deny would leave it hanging forever.
852
- _broadcastApprovalDismiss(kind, id, 'timeout');
853
- resolve(null);
854
- }, timeoutMs);
855
- _pendingApprovals.set(id, {
856
- kind, // 'ask' | 'plan' | 'perm' — lets cancelApproval distinguish types so an
857
- // ask-cancel does not collide with a plan/perm id
858
- replay, // exact broadcast payload announced to clients — replayed to a fresh WS connection
859
- startedAt: Date.now(),
860
- timeoutMs,
861
- resolve: (value) => {
862
- clearTimeout(timer);
863
- _pendingApprovals.delete(id);
864
- resolve(value);
865
- },
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
- }
1557
+ /**
1558
+ * Send a user message to the ACTIVE session, or to the session an anchor
1559
+ * { cwd | project } resolves to (switching active to it first — a send is an ACTION).
1560
+ * An anchor that resolves to no registered session is reported and dropped.
1561
+ *
1562
+ * Never rejects for turn/session failures (those surface via onQueryError toasts);
1563
+ * the only throw is the synchronous "SDK not available" guard cli.js relies on.
1564
+ */
1565
+ export async function sendUserMessage(text, anchor) {
1566
+ if (!_query) throw new Error('Agent SDK not available');
1567
+ const sess = _active();
1568
+ if (!sess) {
1569
+ // Uninitialized — the old module's state machine only came alive after
1570
+ // initSdkSession; a pre-init send just resolves (the SDK-unavailable guard
1571
+ // above already covered the only documented throw).
1572
+ return;
1573
+ }
1574
+ if (anchor && (anchor.cwd || anchor.project)) {
1575
+ const target = _sessionForAnchor(anchor);
1576
+ if (!target) {
1577
+ // A mis-resolved anchor must not silently drop the user's message — log + report.
1578
+ const targetName = anchor.cwd || anchor.project;
1579
+ console.warn(`[sdk-manager] no SDK session registered for "${targetName}" — message dropped`);
1580
+ reportSwallowed('sdk-manager.route', new Error(`no SDK session for ${targetName}`));
1581
+ return;
880
1582
  }
881
- });
1583
+ if (target.key !== _activeKey) {
1584
+ _activeKey = target.key; // an action re-routes the active session (lazy: no teardown)
1585
+ }
1586
+ return target.sendUserMessage(text);
1587
+ }
1588
+ return sess.sendUserMessage(text);
882
1589
  }
883
1590
 
884
1591
  /**
885
- * Resolve a pending canUseTool approval.
886
- * Called by server.js when a WS message arrives.
1592
+ * Resolve a pending canUseTool approval by id, scanning EVERY session (ids are
1593
+ * globally unique — `sdk_<ts>_<rand>` or the CLI's own toolUseID). Called by
1594
+ * server.js when a WS message arrives. Does not switch active.
887
1595
  */
888
1596
  export function resolveApproval(id, value) {
889
- const pending = _pendingApprovals.get(id);
890
- if (pending) {
891
- pending.resolve(value);
892
- return true;
1597
+ for (const sess of _sessions.values()) {
1598
+ if (sess.resolveApproval(id, value)) return true;
893
1599
  }
894
1600
  return false;
895
1601
  }
896
1602
 
897
1603
  /**
898
- * Snapshot of pending approvals for WS-reconnect replay (server.js connection
899
- * handler). Returns [ { id, kind, replay, startedAt, timeoutMs } ] with remaining
900
- * time computed at call time; entries already past their timeout are excluded.
901
- * The resolve closures are deliberately not exposed.
1604
+ * Cancel a pending canUseTool approval by id, scanning EVERY session (ask-cancel
1605
+ * WS handler). Does not switch active.
902
1606
  */
903
- export function getPendingApprovals() {
904
- const now = Date.now();
905
- const out = [];
906
- for (const [id, p] of _pendingApprovals) {
907
- const remaining = (p.timeoutMs ?? 0) - (now - (p.startedAt ?? now));
908
- if (remaining <= 0) continue;
909
- out.push({ id, kind: p.kind || null, replay: p.replay || null, remainingMs: remaining });
1607
+ export function cancelApproval(id, reason) {
1608
+ for (const sess of _sessions.values()) {
1609
+ if (sess.cancelApproval(id, reason)) return true;
910
1610
  }
911
- return out;
1611
+ return false;
912
1612
  }
913
1613
 
914
1614
  /**
915
- * Cancel a pending canUseTool approval — used by ask-cancel WS handler
916
- * (user clicked Cancel button or typed-interrupt in input bar).
917
- *
918
- * Not equivalent to resolveApproval(id, null): null already occupies the timeout semantics
919
- * in _waitForApproval. Here we resolve a { __cancelled__: true, reason } sentinel so
920
- * canUseTool takes the deny branch rather than allow (see the _handleCanUseTool
921
- * AskUserQuestion block).
922
- *
923
- * kind check: the ask-cancel protocol only applies to ask-type approvals. Colliding with a
924
- * plan / perm id returns false and is not handled — so a cancel-ask signal cannot be
925
- * mistaken for "the user rejected the plan", which would write a wrong reason into the
926
- * model context.
927
- *
928
- * Shares the same _pendingApprovals Map and the same first-wins atomic guard
929
- * (pending.resolve clearTimeout + delete) as resolveApproval, so a cancel racing an answer
930
- * is a no-op for whichever arrives second.
1615
+ * Snapshot of pending approvals for WS-reconnect replay (server.js connection
1616
+ * handler). WITHOUT an anchor: aggregated across ALL sessions; WITH an anchor:
1617
+ * only that session's pendings.
931
1618
  */
932
- export function cancelApproval(id, reason) {
933
- const pending = _pendingApprovals.get(id);
934
- if (!pending) return false;
935
- if (pending.kind && pending.kind !== 'ask') return false;
936
- pending.resolve({ __cancelled__: true, reason: typeof reason === 'string' ? reason : 'User aborted' });
937
- return true;
1619
+ export function getPendingApprovals(anchor) {
1620
+ const target = anchor ? _sessionForAnchor(anchor) : null;
1621
+ if (target) return target.getPendingApprovals();
1622
+ const out = [];
1623
+ for (const sess of _sessions.values()) out.push(...sess.getPendingApprovals());
1624
+ return out;
938
1625
  }
939
1626
 
940
- /**
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).
943
- *
944
- * Returns the list of approvals that were pending (`[{ id, kind }]`) so the caller
945
- * (server.js) can broadcast modal-close messages to every client. Always an array.
946
- */
947
- function _cancelAllApprovals() {
948
- const cancelled = Array.from(_pendingApprovals, ([id, pending]) => ({ id, kind: pending.kind || null }));
949
- for (const { id } of cancelled) {
950
- _pendingApprovals.get(id)?.resolve(null);
1627
+ /** Interrupt the ACTIVE session's turn (Stop). An anchor targets another session
1628
+ * (switching active to it first — an interrupt is an ACTION). Returns the
1629
+ * cancelled-approval list (see the Session factory's interruptTurn). */
1630
+ export function interruptTurn(anchor) {
1631
+ if (anchor) {
1632
+ const target = _sessionForAnchor(anchor);
1633
+ if (target) {
1634
+ if (target.key !== _activeKey) _activeKey = target.key;
1635
+ return target.interruptTurn();
1636
+ }
1637
+ // Unknown anchor: nothing to interrupt — same empty array the idle path returns.
1638
+ return [];
951
1639
  }
952
- _pendingApprovals.clear();
953
- return cancelled;
1640
+ const sess = _active();
1641
+ return sess ? sess.interruptTurn() : [];
954
1642
  }
955
1643
 
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();
1644
+ /** Send-now on a queued bubble of the ACTIVE session (or an anchor's session —
1645
+ * an action, switches active). Returns the cancelled-approval list. */
1646
+ export function sendQueuedNow(id, anchor) {
1647
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1648
+ if (anchor && sess && sess.key !== _activeKey) _activeKey = sess.key;
1649
+ return sess ? sess.sendQueuedNow(id) : [];
1650
+ }
1651
+
1652
+ /** Remove one queued message of the ACTIVE session (or an anchor's session —
1653
+ * a targeted action; does not switch active). */
1654
+ export function removeQueued(id, anchor) {
1655
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1656
+ return sess ? sess.removeQueued(id) : false;
1657
+ }
1658
+
1659
+ /** Drop all queued messages of the ACTIVE session (or an anchor's session).
1660
+ * queue-clear is a targeted reset, not a send — it does NOT switch active. */
1661
+ export function clearQueued(anchor) {
1662
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1663
+ if (sess) sess.clearQueued();
1664
+ }
1665
+
1666
+ /** Queue snapshot for WS-reconnect replay: no anchor → active session; with an
1667
+ * anchor → that session. Empty array when there is nothing to replay. */
1668
+ export function getQueueSnapshot(anchor) {
1669
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1670
+ return sess ? sess.getQueueSnapshot() : [];
974
1671
  }
975
1672
 
976
1673
  /**
977
- * Interrupt the current turn (user clicked the Stop button) while KEEPING the
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.
981
- *
982
- * Contrast with `stopSession()` below, which is the hard process-exit cleanup
983
- * that also nulls `_sessionId` (loses conversation continuity).
984
- *
985
- * Stop KEEPS queued messages (product decision, matching the web UX where queued bubbles
986
- * stay parked after Stop): `_suppressDrain` stops the result-driven drain from running
987
- * them right after the interrupt; the park lifts when a fresh user message / send-now
988
- * starts a new turn. The queue-state broadcast lets clients keep their bubbles.
989
- *
990
- * Returns the list of approvals that were pending at interrupt time
991
- * (`[{ id, kind }]`) so the caller (server.js) can broadcast modal-close
992
- * messages to every client. Always an array (empty when nothing was pending).
1674
+ * Stop the active SDK session (hard cleanup): tear down the resident query and reset
1675
+ * all session state, losing conversation continuity. Tears down EVERY registered
1676
+ * session and clears the registry + the active pointer (process-exit cleanup path).
993
1677
  */
994
- export function interruptTurn() {
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();
1678
+ export function stopSession() {
1679
+ for (const sess of _sessions.values()) {
1680
+ try { sess.teardown(); }
1681
+ catch (err) { reportSwallowed('sdk-manager.stop', err); }
1011
1682
  }
1012
- _suppressDrain = true;
1013
- _broadcastQueueState();
1014
- return cancelled;
1683
+ _sessions.clear();
1684
+ _activeKey = null;
1685
+ // Process-global wiring is cleared on the HARD teardown boundary. initSdkSession only
1686
+ // overwrites a callback when one is actually passed (so a per-workspace launch init
1687
+ // cannot wipe cli.js's process-wide wiring mid-run); that guard means an init that
1688
+ // omits a callback KEEPS the previous one. stopSession is the process-exit / test-reset
1689
+ // boundary, so it must drop the wiring entirely — otherwise a prior registrant's
1690
+ // runWaterfallHook (e.g. a test's onPermRequest deny) would leak into the next init.
1691
+ _onTurnEnd = null;
1692
+ _onQueryError = null;
1693
+ _broadcastWs = null;
1694
+ _runWaterfallHook = null;
1015
1695
  }
1016
1696
 
1017
1697
  /**
1018
- * Send-now on a queued bubble: interrupt the running turn and make this message the next
1019
- * one executed. Unlike Stop, this does NOT suppress draining — the in-flight
1020
- * sendUserMessage loop picks the prioritized item up as soon as the abort settles
1021
- * (JS single-threading: the splice+unshift below is synchronous, so the drain's next
1022
- * iteration always sees it at the head).
1023
- *
1024
- * When no turn is running (parked queue after Stop), the message is executed immediately
1025
- * as a fresh turn.
1026
- *
1027
- * Known semantic: if the FIRST turn is interrupted before its system/init message was
1028
- * processed, `_sessionId` is still null and the prioritized message starts a fresh session
1029
- * (the aborted first turn is not resumable) — same as Stop-then-send on turn one.
1030
- *
1031
- * Returns the cancelled-approvals list (same shape as interruptTurn) so server.js can
1032
- * close approval modals on all clients.
1698
+ * Switch the ACTIVE session to a different workspace (cwd). Concurrent-keep semantics:
1699
+ * NO teardown of any session — the outgoing session stays resident in the registry
1700
+ * (its transcript is preserved on disk by the wire path), and the target session
1701
+ * reconstructs its resident query LAZILY on its next message (resuming its own
1702
+ * captured sessionId). Unknown cwd → false (no switch); already-active → true (no-op).
1033
1703
  */
1034
- export function sendQueuedNow(id) {
1035
- const idx = _messageQueue.findIndex((it) => it.id === id);
1036
- if (idx < 0) return []; // unknown / already dispatched — no-op BEFORE any interrupt
1037
- const [item] = _messageQueue.splice(idx, 1);
1038
- // Send-now ALWAYS means "run this now" — lift a Stop-park even when the aborted turn's
1039
- // unwind is still in flight (_queryBusy still true). Without this, the drain loop's
1040
- // `!_suppressDrain` condition would exit with the item already spliced out → silent loss.
1041
- _suppressDrain = false;
1042
-
1043
- if (!_queryBusy) {
1044
- // Parked queue, idle session: run it now (broadcast happens via the drain path —
1045
- // here we broadcast explicitly since the item left the queue).
1046
- _broadcastQueueState();
1047
- // Fire-and-forget matches sendUserMessage's existing call sites (WS handler does not await).
1048
- sendUserMessage(item.text).catch((err) => console.warn('[sdk-manager] send-now query failed:', err?.message));
1049
- return [];
1050
- }
1051
-
1052
- _messageQueue.unshift(item);
1053
- const cancelled = interruptTurn();
1054
- _suppressDrain = false; // send-now overrides the Stop-park interruptTurn just set
1055
- _broadcastQueueState();
1056
- return cancelled;
1704
+ export function setActiveSdkSession(cwd) {
1705
+ const key = _normalizeCwd(cwd);
1706
+ if (!key || !_sessions.has(key)) return false;
1707
+ _activeKey = key; // lazy switch: the target's state machine is intact by construction
1708
+ return true;
1057
1709
  }
1058
1710
 
1059
- /** Remove one queued message (bubble ×). Returns true when found. */
1060
- export function removeQueued(id) {
1061
- const idx = _messageQueue.findIndex((it) => it.id === id);
1062
- if (idx < 0) return false;
1063
- _messageQueue.splice(idx, 1);
1064
- _broadcastQueueState();
1065
- return true;
1711
+ /** The active session's cwd (header/switch addressing). '' when none. */
1712
+ export function getActiveSdkCwd() {
1713
+ const sess = _active();
1714
+ return sess ? sess.cwd : '';
1066
1715
  }
1067
1716
 
1068
- /** Drop all queued messages WITHOUT touching the running turn (session switch). */
1069
- export function clearQueued() {
1070
- if (_messageQueue.length === 0) return;
1071
- _messageQueue = [];
1072
- _broadcastQueueState();
1717
+ /**
1718
+ * Switch the ACTIVE session's session id (web "resume session" entry — SDK-mode
1719
+ * counterpart of the PTY /resume injection). An anchor targets another workspace's
1720
+ * session (switching active to it first — a resume is an ACTION); no anchor → the
1721
+ * active session. See the Session factory's switchToSession for the return contract.
1722
+ */
1723
+ export function switchToSession(sessionId, anchor) {
1724
+ if (!_query) return { ok: false, reason: 'unavailable' }; // route maps this to 409 — must fire BEFORE any session lookup
1725
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1726
+ if (!sess) return { ok: false, reason: 'bad-id' };
1727
+ if (anchor && sess.key !== _activeKey) _activeKey = sess.key;
1728
+ return sess.switchToSession(sessionId);
1073
1729
  }
1074
1730
 
1075
- /** Queue snapshot for WS-reconnect replay (same pattern as getSdkInitSnapshot). */
1076
- export function getQueueSnapshot() {
1077
- return _messageQueue.map(({ id, text, ts }) => ({ id, text, ts }));
1731
+ /** The active session's id (for resume); null before any init/turn. */
1732
+ export function getSessionId(anchor) {
1733
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1734
+ return sess ? sess.getSessionId() : null;
1078
1735
  }
1079
1736
 
1080
1737
  /**
1081
- * Stop the active SDK session (hard cleanup): tear down the resident query and reset
1082
- * all session state, losing conversation continuity.
1738
+ * The last sdk-init snapshot for WS reconnect replay. No anchor → active session;
1739
+ * with an anchor → that session. Returns null if no init has been announced yet
1740
+ * (e.g. session without slash commands).
1083
1741
  */
1084
- export function stopSession() {
1085
- _teardownResident();
1086
- _resetFullState();
1742
+ export function getSdkInitSnapshot(anchor) {
1743
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1744
+ return sess ? sess.getSdkInitSnapshot() : null;
1087
1745
  }
1088
1746
 
1089
1747
  /**
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.
1748
+ * Last announced session-state frame for WS reconnect replay (sdk-init snapshot
1749
+ * parity). Null until the first state transition — a fresh session is idle, and an
1750
+ * absent frame leaves the client at its own idle default. No anchor → active session.
1097
1751
  */
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
- }
1752
+ export function getSessionStateSnapshot(anchor) {
1753
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1754
+ return sess ? sess.getSessionStateSnapshot() : null;
1120
1755
  }
1121
1756
 
1122
- /**
1123
- * Reset all session state.
1124
- */
1125
- function _resetFullState() {
1126
- _sessionId = null;
1127
- _queryBusy = false;
1128
- _switching = false;
1129
- _turnInFlight = false;
1130
- _initAnnouncedSid = '';
1131
- _lastInitSnapshot = null;
1132
- _pendingTurns = [];
1133
- _lateResultTombstones = 0;
1134
- _consecutiveDeaths = 0;
1135
- _clearGraceTimer();
1136
- _clearTurnWatchdog();
1137
- const hadQueued = _messageQueue.length > 0;
1138
- _messageQueue = [];
1139
- _suppressDrain = false;
1140
- // Only broadcast on an actual change — initSdkSession calls this before any client cares,
1141
- // and an empty-to-empty broadcast would just be noise on the wire.
1142
- if (hadQueued) _broadcastQueueState();
1143
- // Reject all pending approvals
1144
- for (const [, pending] of _pendingApprovals) {
1145
- pending.resolve(null);
1146
- }
1147
- _pendingApprovals.clear();
1757
+ /** WS reconnect replay getter for the context-usage frame. No anchor → active session. */
1758
+ export function getContextUsageSnapshot(anchor) {
1759
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1760
+ return sess ? sess.getContextUsageSnapshot() : null;
1761
+ }
1762
+
1763
+ /** WS reconnect replay getter for the MCP-status frame. No anchor → active session. */
1764
+ export function getMcpStatusSnapshot(anchor) {
1765
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1766
+ return sess ? sess.getMcpStatusSnapshot() : null;
1148
1767
  }
1149
1768
 
1150
1769
  /**
1151
- * Get current session ID (for resume).
1770
+ * On-demand context-usage fetch for the HTTP route (see the Session factory's
1771
+ * fetchContextUsage). No anchor → the active session. Returns
1772
+ * { ok:false, reason:'session-inactive' } when there is no active session at all.
1152
1773
  */
1153
- export function getSessionId() {
1154
- return _sessionId;
1774
+ export async function fetchContextUsage(anchor) {
1775
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1776
+ if (!sess) return { ok: false, reason: 'session-inactive' };
1777
+ return sess.fetchContextUsage();
1155
1778
  }
1156
1779
 
1157
1780
  /**
1158
- * Get the last sdk-init snapshot for WS reconnect replay.
1159
- * Returns null if no init has been announced yet (e.g. session without slash commands).
1781
+ * On-demand MCP-status fetch for the HTTP route (see the Session factory's
1782
+ * fetchMcpStatus). No anchor → the active session. Returns
1783
+ * { ok:false, reason:'session-inactive' } when there is no active session at all.
1160
1784
  */
1161
- export function getSdkInitSnapshot() {
1162
- return _lastInitSnapshot;
1785
+ export async function fetchMcpStatus(anchor) {
1786
+ const sess = anchor ? _sessionForAnchor(anchor) : _active();
1787
+ if (!sess) return { ok: false, reason: 'session-inactive' };
1788
+ return sess.fetchMcpStatus();
1163
1789
  }