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.
- package/README.md +5 -5
- package/cli.js +24 -0
- package/dist/assets/App-DtBVNYE7.js +2 -0
- package/dist/assets/App-DuDeDOuX.css +1 -0
- package/dist/assets/{MdxEditorPanel-Ck8B5a5v.js → MdxEditorPanel-C8Cij93y.js} +1 -1
- package/dist/assets/{Mobile-B8AkKb0z.js → Mobile-OKCVi8un.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DsDVMf-c.js → ProxyStatsModal-DGlngtM9.js} +1 -1
- package/dist/assets/index-Clt4dw8Y.js +2 -0
- package/dist/assets/{seqResourceLoaders-BsMFNfvR.css → seqResourceLoaders-Bg9Lz8pH.css} +1 -1
- package/dist/assets/seqResourceLoaders-zlIVjapi.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +11 -0
- package/server/lib/sdk-manager.js +1521 -895
- package/server/routes/events.js +20 -0
- package/server/routes/resume.js +62 -1
- package/server/routes/sdk-context.js +24 -0
- package/server/routes/sdk-mcp-status.js +24 -0
- package/server/routes/workspaces.js +24 -1
- package/server/scratch-pty-manager.js +127 -46
- package/server/server.js +326 -41
- package/dist/assets/App-BzXO6ATr.js +0 -2
- package/dist/assets/App-DJ4yNvwz.css +0 -1
- package/dist/assets/index-Dq5UPr6v.js +0 -2
- package/dist/assets/seqResourceLoaders-BsQ6KXpV.js +0 -2
|
@@ -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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
let
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
let
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
//
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
788
|
+
_maybeDrain();
|
|
789
|
+
break;
|
|
720
790
|
}
|
|
721
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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('
|
|
747
|
-
if (hookResult.
|
|
748
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1094
|
+
_broadcastWs(permPayload);
|
|
760
1095
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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:
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
|
1115
|
+
return response;
|
|
775
1116
|
}
|
|
776
1117
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
796
|
-
|
|
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
|
-
|
|
799
|
-
}
|
|
1304
|
+
_armSettleGrace();
|
|
1305
|
+
}
|
|
1306
|
+
_suppressDrain = true;
|
|
1307
|
+
_broadcastQueueState();
|
|
1308
|
+
return cancelled;
|
|
800
1309
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
return {
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
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
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
*
|
|
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
|
|
890
|
-
|
|
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
|
-
*
|
|
899
|
-
* handler).
|
|
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
|
|
904
|
-
const
|
|
905
|
-
|
|
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
|
|
1611
|
+
return false;
|
|
912
1612
|
}
|
|
913
1613
|
|
|
914
1614
|
/**
|
|
915
|
-
*
|
|
916
|
-
*
|
|
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
|
|
933
|
-
const
|
|
934
|
-
if (
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
return
|
|
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
|
-
*
|
|
942
|
-
*
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
953
|
-
return
|
|
1640
|
+
const sess = _active();
|
|
1641
|
+
return sess ? sess.interruptTurn() : [];
|
|
954
1642
|
}
|
|
955
1643
|
|
|
956
|
-
/**
|
|
957
|
-
*
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
*
|
|
978
|
-
* session
|
|
979
|
-
*
|
|
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
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
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
|
|
1035
|
-
const
|
|
1036
|
-
if (
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
/**
|
|
1060
|
-
export function
|
|
1061
|
-
const
|
|
1062
|
-
|
|
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
|
-
/**
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
-
/**
|
|
1076
|
-
export function
|
|
1077
|
-
|
|
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
|
-
*
|
|
1082
|
-
*
|
|
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
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
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
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
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
|
-
*
|
|
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
|
|
1154
|
-
|
|
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
|
-
*
|
|
1159
|
-
*
|
|
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
|
|
1162
|
-
|
|
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
|
}
|