@sanqianx/claude-ai-workbench 0.11.0

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.
Files changed (90) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +129 -0
  3. package/apps/agent-terminal/app.css +567 -0
  4. package/apps/agent-terminal/app.js +2392 -0
  5. package/apps/agent-terminal/index.html +158 -0
  6. package/apps/ai-profile-settings-host/index.html +25 -0
  7. package/apps/ai-profile-settings-host/page.css +1 -0
  8. package/apps/ai-profile-settings-host/page.js +113 -0
  9. package/apps/minimal-test-host/README.md +15 -0
  10. package/apps/minimal-test-host/host.css +2 -0
  11. package/apps/minimal-test-host/host.js +35 -0
  12. package/apps/minimal-test-host/index.html +58 -0
  13. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
  14. package/docs/sdk/FILE_LAYOUT.md +31 -0
  15. package/docs/sdk/INTEGRATION.md +126 -0
  16. package/docs/sdk/README.md +21 -0
  17. package/docs/sdk/openapi.yaml +760 -0
  18. package/package.json +51 -0
  19. package/packages/client/README.md +11 -0
  20. package/packages/client/index.d.ts +81 -0
  21. package/packages/client/index.js +198 -0
  22. package/packages/client/package.json +29 -0
  23. package/packages/contracts/README.md +9 -0
  24. package/packages/contracts/index.d.ts +252 -0
  25. package/packages/contracts/index.js +295 -0
  26. package/packages/contracts/package.json +24 -0
  27. package/packages/core/README.md +8 -0
  28. package/packages/core/index.d.ts +76 -0
  29. package/packages/core/index.js +214 -0
  30. package/packages/core/package.json +29 -0
  31. package/packages/server/README.md +17 -0
  32. package/packages/server/bin/agent-terminal-server.js +36 -0
  33. package/packages/server/bin/claude-workbench-server.js +46 -0
  34. package/packages/server/bin/claude-workbench.js +225 -0
  35. package/packages/server/index.d.ts +145 -0
  36. package/packages/server/index.js +183 -0
  37. package/packages/server/lib/agent-discovery.js +112 -0
  38. package/packages/server/lib/agent-registry.js +98 -0
  39. package/packages/server/lib/attachment-store.js +78 -0
  40. package/packages/server/lib/bridges/app-server-bridge.js +372 -0
  41. package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
  42. package/packages/server/lib/capture-settings-store.js +34 -0
  43. package/packages/server/lib/context-resolver.js +40 -0
  44. package/packages/server/lib/drivers/cli-json-runner.js +960 -0
  45. package/packages/server/lib/drivers/codex-driver.js +1413 -0
  46. package/packages/server/lib/drivers/opencode-driver.js +1527 -0
  47. package/packages/server/lib/drivers/zcode-driver.js +1167 -0
  48. package/packages/server/lib/event-stream.js +35 -0
  49. package/packages/server/lib/folder-picker.js +623 -0
  50. package/packages/server/lib/http-handler.js +254 -0
  51. package/packages/server/lib/json-file.js +19 -0
  52. package/packages/server/lib/model-detection.js +295 -0
  53. package/packages/server/lib/profile-store.js +149 -0
  54. package/packages/server/lib/project-store.js +102 -0
  55. package/packages/server/lib/session-archive-store.js +53 -0
  56. package/packages/server/lib/system-doctor.js +119 -0
  57. package/packages/server/lib/ui-preferences-store.js +48 -0
  58. package/packages/server/lib/workbench-service.js +620 -0
  59. package/packages/server/package.json +34 -0
  60. package/packages/ui/README.md +30 -0
  61. package/packages/ui/ai-profile-settings.d.ts +45 -0
  62. package/packages/ui/ai-profile-settings.js +283 -0
  63. package/packages/ui/claude-terminal.d.ts +31 -0
  64. package/packages/ui/claude-terminal.js +343 -0
  65. package/packages/ui/index.d.ts +18 -0
  66. package/packages/ui/index.js +51 -0
  67. package/packages/ui/package.json +42 -0
  68. package/packages/ui/theme.css +13 -0
  69. package/src/assets/claude-prompts.json +21 -0
  70. package/src/backend/http/ai-profile-routes.js +61 -0
  71. package/src/backend/http/claude-routes.js +170 -0
  72. package/src/backend/http/node-helpers.js +45 -0
  73. package/src/backend/index.js +21 -0
  74. package/src/backend/lib/ai-adapter.js +144 -0
  75. package/src/backend/lib/ai-profile-store.js +259 -0
  76. package/src/backend/lib/ai-vendor-presets.js +83 -0
  77. package/src/backend/lib/ai-workspace.js +140 -0
  78. package/src/backend/lib/automation-config.js +253 -0
  79. package/src/backend/lib/claude-cli-runner.js +1749 -0
  80. package/src/backend/lib/data-dir.js +169 -0
  81. package/src/backend/lib/llm-client.js +176 -0
  82. package/src/backend/lib/model-context-windows.js +82 -0
  83. package/src/backend/lib/prompt-registry.js +166 -0
  84. package/src/backend/lib/runtime-config.js +121 -0
  85. package/src/index.js +7 -0
  86. package/src/sdk-wave-a.ts +1 -0
  87. package/src/sdk-wave-b.ts +1 -0
  88. package/src/sdk-wave-c.ts +1 -0
  89. package/src/sdk-wave-d.ts +1 -0
  90. package/src/sdk-wave-e.ts +1 -0
@@ -0,0 +1,960 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const crypto = require('crypto');
8
+ const { appendTextAttachments, normalizeAttachments } = require('../../../contracts');
9
+
10
+ // Shared subprocess runner for CLI agents that can stream normalized JSON
11
+ // events (codex exec --json, opencode run --format json). Implements the same
12
+ // WorkbenchRunner surface as the Claude Code SDK runner so AgentRegistry can
13
+ // route to it transparently.
14
+ //
15
+ // config:
16
+ // agentId contracts agent id
17
+ // command executable name on PATH (spawned with shell on Windows)
18
+ // promptViaStdin true -> prompt is written to stdin (codex "-"); false ->
19
+ // prompt comes back from spawnArgs as a normal argv element
20
+ // spawnArgs ({ projectPath, model, sandbox, threadId, prompt }) -> string[]
21
+ // parseLine (parsedJson, session, helpers) -> normalized events[]
22
+ // sandboxFor (permissionMode) -> string|undefined
23
+ // modelFor (runtimeProfile) -> string|null
24
+ // envFor (runtimeProfile) -> object (profile-injected agents only)
25
+ const ACTIVE_STATES = new Set(['running', 'thinking', 'tool-running']);
26
+
27
+ // ---- session disk persistence ----
28
+ // Session records survive server restarts the same way the claude-code
29
+ // runner's per-project records do: every completed turn is flushed to a JSON
30
+ // sidecar file and lazily restored when a lookup misses the live map. Without
31
+ // this, a server restart wiped every codex/opencode session from the sidebar.
32
+ const PERSIST_SCHEMA = 'cli-json-runner-session/v1';
33
+ const MAX_PERSISTED_EVENTS = 5000;
34
+ const PERSIST_DEBOUNCE_MS = 250;
35
+ // Consecutive failed write rounds (atomic rename attempts included) before
36
+ // persistence degrades to memory-only. One-off failures — a Windows AV/EBUSY
37
+ // lock, a transient indexer handle — must not disable the feature.
38
+ const PERSIST_FAILURE_LIMIT = 3;
39
+ // Listing only needs the scalar header of a record; every header field is
40
+ // written before "events" (see toPersistedSession), so a bounded head read
41
+ // plus an early-terminated JSON parse suffices. A restart's first
42
+ // listSessions therefore never parses every session's full (up to
43
+ // 5000-event) payload.
44
+ const PERSIST_HEAD_BYTES = 8192;
45
+
46
+ // Default on-disk home for CLI driver sessions. Sits under the agent-terminal
47
+ // data root (~/.agent-terminal, beside profiles/projects/attachments) so the
48
+ // directory's lifetime matches the rest of the app's persisted state.
49
+ function defaultPersistDir() {
50
+ return path.join(os.homedir(), '.agent-terminal', 'cli-sessions');
51
+ }
52
+
53
+ function safeFileStem(value) {
54
+ return String(value).replace(/[^A-Za-z0-9._-]/g, '_');
55
+ }
56
+
57
+ function resolvePersistenceConfig(config) {
58
+ // config.persistence: { dir?, enabled? } — the preferred shape (tests inject
59
+ // an isolated temp dir here). config.persistDir is accepted as a thin alias:
60
+ // a string selects the directory, null opts out entirely. Default: enabled
61
+ // with ~/.agent-terminal/cli-sessions, so the production wiring (which just
62
+ // calls createCodexDriver()/createOpenCodeDriver()) persists for free.
63
+ // The default is suppressed in test contexts — an injected spawn double or
64
+ // the node:test runner env — so test runs never write to (or read) the
65
+ // user's real session store; an explicit persistence config always wins.
66
+ const options = config.persistence && typeof config.persistence === 'object' ? config.persistence : {};
67
+ const explicitlyConfigured = config.persistence != null || config.persistDir !== undefined;
68
+ const ephemeralTestContext = Boolean(config.spawn) || Boolean(process.env.NODE_TEST_CONTEXT);
69
+ const enabled = config.persistence !== null
70
+ && options.enabled !== false
71
+ && config.persistDir !== null
72
+ && (explicitlyConfigured || !ephemeralTestContext);
73
+ const dir = options.dir
74
+ || (typeof config.persistDir === 'string' && config.persistDir)
75
+ || defaultPersistDir();
76
+ return { enabled, dir: path.resolve(dir) };
77
+ }
78
+
79
+ // Restored sessions can never be mid-subprocess: a restart killed it. ACTIVE
80
+ // persisted states therefore come back idle instead of dangling forever.
81
+ function restoredState(state) {
82
+ return ACTIVE_STATES.has(state) ? 'idle' : (state || 'idle');
83
+ }
84
+
85
+ function persistedSummary(record) {
86
+ return {
87
+ sessionId: record.sessionId,
88
+ projectSlug: record.projectSlug,
89
+ state: restoredState(record.state),
90
+ model: record.model || null,
91
+ selectedModel: record.selectedModel || null,
92
+ aiProfileId: record.aiProfileId || null,
93
+ permissionMode: record.permissionMode || 'default',
94
+ turns: Number(record.turns) || 0,
95
+ startedAt: record.startedAt || '',
96
+ endedAt: record.endedAt || null,
97
+ pendingPermission: null,
98
+ };
99
+ }
100
+
101
+ function validPersistedHead(parsed, agentId) {
102
+ return Boolean(parsed && parsed.schema === PERSIST_SCHEMA && parsed.agentId === agentId
103
+ && typeof parsed.sessionId === 'string' && parsed.sessionId);
104
+ }
105
+
106
+ function readPersistedRecord(file, agentId) {
107
+ try {
108
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
109
+ // The persist directory is shared by every CLI driver (codex, opencode,
110
+ // ...): a runner must only ever claim records it wrote itself, otherwise
111
+ // it would restore another agent's thread under the wrong parser/flags.
112
+ if (parsed && parsed.schema === PERSIST_SCHEMA && typeof parsed.sessionId === 'string' && parsed.sessionId
113
+ && (agentId === undefined || parsed.agentId === agentId)) return parsed;
114
+ } catch { /* corrupt or partially written records are skipped, never fatal */ }
115
+ return null;
116
+ }
117
+
118
+ // Summary-only read for the scan paths: header fields (validated shape) +
119
+ // a cheap completeness check that the record terminates like a finished
120
+ // write (every record ends with a closing brace), so a truncated/foreign
121
+ // file is skipped the same way a full parse would skip it.
122
+ function readPersistedHead(file) {
123
+ let headText = '';
124
+ let tailText = '';
125
+ let size = 0;
126
+ let headRead = 0;
127
+ try {
128
+ const fd = fs.openSync(file, 'r');
129
+ try {
130
+ const head = Buffer.alloc(PERSIST_HEAD_BYTES);
131
+ headRead = fs.readSync(fd, head, 0, PERSIST_HEAD_BYTES, 0);
132
+ headText = head.toString('utf8', 0, headRead);
133
+ ({ size } = fs.fstatSync(fd));
134
+ if (size > headRead) {
135
+ const tail = Buffer.alloc(16);
136
+ const tailRead = fs.readSync(fd, tail, 0, tail.length, size - tail.length);
137
+ tailText = tail.toString('utf8', 0, tailRead);
138
+ }
139
+ } finally { fs.closeSync(fd); }
140
+ } catch { return null; }
141
+ // A half-written (in-place truncated) record must be skipped, not listed.
142
+ const complete = size > headRead ? /\}\s*$/.test(tailText) : /\}\s*$/.test(headText);
143
+ if (!complete) return null;
144
+ const marker = headText.indexOf('"events":');
145
+ if (marker < 0) return readPersistedRecord(file); // tiny or oversized header: full read
146
+ try {
147
+ const parsed = JSON.parse(`${headText.slice(0, marker).replace(/,\s*$/, '')}}`);
148
+ if (parsed && parsed.schema === PERSIST_SCHEMA) return parsed;
149
+ } catch { /* header cut mid-token */ }
150
+ return readPersistedRecord(file);
151
+ }
152
+
153
+ function indexRecordFromFile(file, agentId) {
154
+ const head = readPersistedHead(file);
155
+ if (!head || !validPersistedHead(head, agentId)) return null;
156
+ return { file, summary: persistedSummary(head) };
157
+ }
158
+
159
+ function scanPersistDir(dir, agentId) {
160
+ const index = new Map();
161
+ let names = [];
162
+ try { names = fs.readdirSync(dir).filter(name => name.endsWith('.json')); } catch { return index; }
163
+ for (const name of names) {
164
+ const file = path.join(dir, name);
165
+ const entry = indexRecordFromFile(file, agentId);
166
+ if (entry) index.set(entry.summary.sessionId, entry);
167
+ }
168
+ return index;
169
+ }
170
+
171
+ // Placeholder written the instant a session id is claimed (exclusive create)
172
+ // so a concurrently-running sibling runner can never take the same id.
173
+ function reservedRecord(agentId, sessionId, slug, projectPath) {
174
+ return {
175
+ schema: PERSIST_SCHEMA,
176
+ agentId,
177
+ sessionId,
178
+ projectSlug: slug,
179
+ projectPath,
180
+ state: 'idle',
181
+ model: null,
182
+ selectedModel: null,
183
+ aiProfileId: null,
184
+ permissionMode: 'default',
185
+ effort: null,
186
+ title: null,
187
+ turns: 0,
188
+ startedAt: new Date().toISOString(),
189
+ endedAt: null,
190
+ threadId: null,
191
+ lastResult: '',
192
+ usage: { inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
193
+ peakTurnTokens: 0,
194
+ updatedAt: new Date().toISOString(),
195
+ events: [],
196
+ };
197
+ }
198
+
199
+ function createCliJsonRunner(config) {
200
+ const sessions = new Map();
201
+ let nextId = 1;
202
+ const persistence = resolvePersistenceConfig(config);
203
+ // sessionId -> { file, summary } for every readable record in the persist
204
+ // directory. Built lazily on first use and refreshed with a cheap readdir
205
+ // diff, so records written by a previous process become visible without
206
+ // loading their event streams into memory.
207
+ let diskIndex = null;
208
+ const persistTimers = new Map();
209
+
210
+ function sessionFile(sessionId) {
211
+ return path.join(persistence.dir, `${safeFileStem(sessionId)}.json`);
212
+ }
213
+
214
+ function ensureDiskIndex() {
215
+ if (!persistence.enabled) return null;
216
+ if (!diskIndex) diskIndex = scanPersistDir(persistence.dir, config.agentId);
217
+ return diskIndex;
218
+ }
219
+
220
+ function syncDiskIndex() {
221
+ const index = ensureDiskIndex();
222
+ if (!index) return null;
223
+ let names = [];
224
+ try { names = fs.readdirSync(persistence.dir).filter(name => name.endsWith('.json')); } catch { return index; }
225
+ const present = new Set(names);
226
+ const known = new Set();
227
+ for (const entry of index.values()) known.add(path.basename(entry.file));
228
+ for (const name of names) {
229
+ if (known.has(name)) continue;
230
+ const entry = indexRecordFromFile(path.join(persistence.dir, name), config.agentId);
231
+ if (entry) index.set(entry.summary.sessionId, entry);
232
+ }
233
+ for (const [sessionId, entry] of index) {
234
+ if (!present.has(path.basename(entry.file))) index.delete(sessionId);
235
+ }
236
+ return index;
237
+ }
238
+
239
+ function toPersistedSession(session) {
240
+ // Field order matters: every scalar the listing/summary paths need is
241
+ // written BEFORE "events" so scanPersistDir can read a bounded head
242
+ // instead of parsing the whole (up to 5000-event) payload.
243
+ return {
244
+ schema: PERSIST_SCHEMA,
245
+ agentId: config.agentId,
246
+ sessionId: session.sessionId,
247
+ projectSlug: session.projectSlug,
248
+ projectPath: session.projectPath,
249
+ state: session.state,
250
+ model: session.model,
251
+ selectedModel: session.selectedModel,
252
+ aiProfileId: session.aiProfileId,
253
+ permissionMode: session.permissionMode,
254
+ effort: session.effort || null,
255
+ title: session.title || null,
256
+ turns: session.turns,
257
+ startedAt: session.startedAt,
258
+ endedAt: session.endedAt,
259
+ // The underlying agent conversation id: codex `resume <id>` /
260
+ // opencode `--session <id>` need it to continue the thread after a
261
+ // restart.
262
+ threadId: session.threadId || null,
263
+ lastResult: session.lastResult || '',
264
+ usage: { ...session.usage },
265
+ peakTurnTokens: session.peakTurnTokens || 0,
266
+ updatedAt: new Date().toISOString(),
267
+ events: session.outputBuffer.slice(-MAX_PERSISTED_EVENTS),
268
+ };
269
+ }
270
+
271
+ let persistFailures = 0;
272
+ let tmpFileCounter = 0;
273
+
274
+ function notePersistFailure() {
275
+ // Transient write failures (AV locks, EBUSY, a yanked directory) must not
276
+ // kill persistence for the whole process; only a run of consecutive
277
+ // failed write rounds degrades to the memory-only behavior.
278
+ persistFailures += 1;
279
+ if (persistFailures >= PERSIST_FAILURE_LIMIT) disablePersistence();
280
+ }
281
+
282
+ function persistSession(session) {
283
+ if (!persistence.enabled) return;
284
+ const file = sessionFile(session.sessionId);
285
+ const payload = `${JSON.stringify(toPersistedSession(session))}\n`;
286
+ // Atomic replace: the complete new record is written to a sibling temp
287
+ // file first and then renamed over the destination, so a crash mid-write
288
+ // can only ever leave an unread .tmp leftover — the previous complete
289
+ // record on disk is never destroyed by a half write.
290
+ for (let attempt = 0; attempt < 2; attempt += 1) {
291
+ const tmp = `${file}.${process.pid}-${++tmpFileCounter}.tmp`;
292
+ try {
293
+ fs.mkdirSync(path.dirname(file), { recursive: true });
294
+ fs.writeFileSync(tmp, payload, 'utf8');
295
+ try {
296
+ fs.renameSync(tmp, file); // same-volume atomic replace
297
+ } catch {
298
+ // Windows can refuse to replace a destination an AV scanner or
299
+ // indexer still holds open: drop the old copy and retry the move.
300
+ // The temp file always carries the complete new version, so the
301
+ // destination is only ever missing for one rename call.
302
+ try { fs.unlinkSync(file); } catch { /* nothing to drop */ }
303
+ fs.renameSync(tmp, file);
304
+ }
305
+ persistFailures = 0;
306
+ if (diskIndex) diskIndex.set(session.sessionId, { file, summary: summary(session) });
307
+ return;
308
+ } catch {
309
+ try { fs.unlinkSync(tmp); } catch { /* nothing to clean up */ }
310
+ }
311
+ }
312
+ notePersistFailure();
313
+ }
314
+
315
+ function disablePersistence() {
316
+ persistence.enabled = false;
317
+ diskIndex = null;
318
+ for (const timer of persistTimers.values()) clearTimeout(timer);
319
+ persistTimers.clear();
320
+ }
321
+
322
+ // Events stream fast during a turn; coalesce disk writes instead of
323
+ // rewriting the record on every emit. Turn boundaries (finish, abort,
324
+ // selection changes, creation) always flush synchronously, so a process
325
+ // exit can only ever lose the still-running turn.
326
+ function schedulePersist(session) {
327
+ if (!persistence.enabled) return;
328
+ if (persistTimers.has(session.sessionId)) return;
329
+ const timer = setTimeout(() => {
330
+ persistTimers.delete(session.sessionId);
331
+ persistSession(session);
332
+ }, PERSIST_DEBOUNCE_MS);
333
+ if (timer.unref) timer.unref();
334
+ persistTimers.set(session.sessionId, timer);
335
+ }
336
+
337
+ function flushPersist(session) {
338
+ if (!persistence.enabled) return;
339
+ const timer = persistTimers.get(session.sessionId);
340
+ if (timer) { clearTimeout(timer); persistTimers.delete(session.sessionId); }
341
+ persistSession(session);
342
+ }
343
+
344
+ function removePersistedRecord(sessionId, file) {
345
+ const timer = persistTimers.get(sessionId);
346
+ if (timer) { clearTimeout(timer); persistTimers.delete(sessionId); }
347
+ if (diskIndex) diskIndex.delete(sessionId);
348
+ try { fs.rmSync(file || sessionFile(sessionId), { force: true }); } catch { /* already gone */ }
349
+ }
350
+
351
+ function restoreSessionFromDisk(sessionId) {
352
+ const index = syncDiskIndex();
353
+ const entry = index && index.get(sessionId);
354
+ if (!entry) return null;
355
+ const record = readPersistedRecord(entry.file, config.agentId);
356
+ if (!record || record.sessionId !== sessionId) {
357
+ index.delete(sessionId);
358
+ return null;
359
+ }
360
+ const session = {
361
+ sessionId: record.sessionId,
362
+ projectSlug: record.projectSlug,
363
+ projectPath: record.projectPath,
364
+ state: restoredState(record.state),
365
+ model: record.model || null,
366
+ selectedModel: record.selectedModel || null,
367
+ aiProfileId: record.aiProfileId || null,
368
+ permissionMode: record.permissionMode || 'default',
369
+ effort: record.effort || null,
370
+ title: record.title || null,
371
+ turns: Number(record.turns) || 0,
372
+ startedAt: record.startedAt || new Date().toISOString(),
373
+ endedAt: ACTIVE_STATES.has(record.state) ? null : (record.endedAt || null),
374
+ pendingPermission: null,
375
+ outputBuffer: Array.isArray(record.events) ? record.events.slice(-MAX_PERSISTED_EVENTS) : [],
376
+ listeners: new Set(),
377
+ child: null,
378
+ threadId: record.threadId || null,
379
+ lastResult: record.lastResult || '',
380
+ usage: {
381
+ inputTokens: Number(record.usage && record.usage.inputTokens) || 0,
382
+ outputTokens: Number(record.usage && record.usage.outputTokens) || 0,
383
+ cachedTokens: Number(record.usage && record.usage.cachedTokens) || 0,
384
+ },
385
+ peakTurnTokens: Number(record.peakTurnTokens) || 0,
386
+ restored: true,
387
+ };
388
+ sessions.set(sessionId, session);
389
+ return session;
390
+ }
391
+
392
+ function resolveSession(id) {
393
+ return sessions.get(id) || restoreSessionFromDisk(id);
394
+ }
395
+
396
+ function reserveSessionId({ slug, projectPath }) {
397
+ let index = ensureDiskIndex();
398
+ for (let attempt = 0; attempt < 200; attempt += 1) {
399
+ let id = `${config.agentId}-${nextId++}`;
400
+ while (sessions.has(id) || (index && index.has(id))) id = `${config.agentId}-${nextId++}`;
401
+ if (!persistence.enabled) return id;
402
+ // Claim the record file exclusively the moment the id is chosen: a
403
+ // sibling runner instance sharing this directory (whose cached index
404
+ // predates our write — e.g. a sidebar poll at startup) cannot take the
405
+ // same id, so its flush can never overwrite our session's record.
406
+ // Keeping the plain `${agentId}-${n}` shape also preserves external
407
+ // assertions on the id format.
408
+ const file = sessionFile(id);
409
+ const record = reservedRecord(config.agentId, id, slug, projectPath);
410
+ try {
411
+ fs.mkdirSync(persistence.dir, { recursive: true });
412
+ } catch {
413
+ notePersistFailure(); // unusable directory: session stays memory-only
414
+ return id;
415
+ }
416
+ try {
417
+ fs.writeFileSync(file, `${JSON.stringify(record)}\n`, { flag: 'wx' });
418
+ if (diskIndex) diskIndex.set(id, { file, summary: persistedSummary(record) });
419
+ return id;
420
+ } catch (error) {
421
+ if (error && error.code === 'EEXIST') {
422
+ index = syncDiskIndex() || index; // another instance won the race
423
+ continue;
424
+ }
425
+ notePersistFailure();
426
+ return id;
427
+ }
428
+ }
429
+ return `${config.agentId}-${nextId++}`;
430
+ }
431
+
432
+ function summary(session) {
433
+ return {
434
+ sessionId: session.sessionId,
435
+ projectSlug: session.projectSlug,
436
+ state: session.state,
437
+ model: session.model,
438
+ selectedModel: session.selectedModel,
439
+ aiProfileId: session.aiProfileId,
440
+ permissionMode: session.permissionMode,
441
+ turns: session.turns,
442
+ startedAt: session.startedAt,
443
+ endedAt: session.endedAt,
444
+ pendingPermission: session.pendingPermission,
445
+ };
446
+ }
447
+
448
+ function emit(session, event) {
449
+ session.outputBuffer.push(event);
450
+ if (session.outputBuffer.length > MAX_PERSISTED_EVENTS) session.outputBuffer.shift();
451
+ for (const listener of session.listeners) {
452
+ try { listener(event); } catch { /* listener errors never break the turn */ }
453
+ }
454
+ schedulePersist(session);
455
+ }
456
+
457
+ function setState(session, state) {
458
+ session.state = state;
459
+ emit(session, { type: 'claude/state', state, turn: session.turns });
460
+ }
461
+
462
+ const runner = {
463
+ agentId: config.agentId,
464
+
465
+ startChatSession({ slug, projectPath, aiProfile, permissionMode, effort, title }) {
466
+ if (!slug) throw new Error('slug required');
467
+ if (!projectPath) throw new Error('projectPath required');
468
+ const sessionId = reserveSessionId({ slug, projectPath });
469
+ const session = {
470
+ sessionId,
471
+ projectSlug: slug,
472
+ projectPath,
473
+ state: 'idle',
474
+ model: config.modelFor ? config.modelFor(aiProfile) : null,
475
+ selectedModel: aiProfile && (aiProfile.mainModel || aiProfile.models && aiProfile.models.default) || '',
476
+ aiProfileId: aiProfile && aiProfile.id,
477
+ permissionMode: permissionMode || 'default',
478
+ effort: effort || null,
479
+ title: title || null,
480
+ turns: 0,
481
+ startedAt: new Date().toISOString(),
482
+ endedAt: null,
483
+ pendingPermission: null,
484
+ outputBuffer: [],
485
+ listeners: new Set(),
486
+ child: null,
487
+ threadId: null,
488
+ lastResult: '',
489
+ usage: { inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
490
+ };
491
+ sessions.set(sessionId, session);
492
+ emit(session, {
493
+ type: 'claude/session-ready',
494
+ model: session.model,
495
+ aiProfileId: session.aiProfileId,
496
+ permissionMode: session.permissionMode,
497
+ message: `${config.agentId} session is ready. Send a message to start the first turn.`,
498
+ });
499
+ // Write the record immediately so a session with no turns yet still
500
+ // shows up in the sidebar after a restart.
501
+ flushPersist(session);
502
+ return { sessionId };
503
+ },
504
+
505
+ listSessions(filter = {}) {
506
+ const byId = new Map();
507
+ const index = syncDiskIndex();
508
+ if (index) {
509
+ // Lightweight summaries (no events) for sessions a previous process
510
+ // persisted, newest first; live sessions below override by id.
511
+ const restored = [...index.values()]
512
+ .map(entry => entry.summary)
513
+ .filter(item => !filter.projectSlug || item.projectSlug === filter.projectSlug)
514
+ .sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
515
+ for (const item of restored) byId.set(item.sessionId, item);
516
+ }
517
+ for (const session of sessions.values()) {
518
+ if (filter.projectSlug && session.projectSlug !== filter.projectSlug) continue;
519
+ byId.set(session.sessionId, summary(session));
520
+ }
521
+ return [...byId.values()];
522
+ },
523
+
524
+ getState(id) {
525
+ const session = resolveSession(id);
526
+ if (!session) return null;
527
+ return {
528
+ ...summary(session),
529
+ listenerCount: session.listeners.size,
530
+ bufferedEvents: session.outputBuffer.length,
531
+ };
532
+ },
533
+
534
+ getSession(id) { return resolveSession(id); },
535
+
536
+ getSessionTokenUsage(id) {
537
+ const session = resolveSession(id);
538
+ if (!session) return { used: 0, hasUsage: false };
539
+ const { inputTokens, outputTokens } = session.usage;
540
+ // Context occupancy ≈ the largest single turn, not the running sum:
541
+ // resumed turns re-send the whole history each time (same semantics as
542
+ // the claude-cli-runner's max-not-sum strategy). peakTurnTokens is
543
+ // persisted, so restored sessions keep their occupancy metric.
544
+ const used = session.peakTurnTokens || 0;
545
+ return {
546
+ used,
547
+ inputTokens,
548
+ outputTokens,
549
+ hasUsage: Boolean(used || inputTokens || outputTokens),
550
+ };
551
+ },
552
+
553
+ subscribe(id, listener) {
554
+ const session = resolveSession(id);
555
+ if (!session) throw new Error('session not found');
556
+ for (const event of session.outputBuffer) listener(event);
557
+ session.listeners.add(listener);
558
+ return () => session.listeners.delete(listener);
559
+ },
560
+
561
+ async sendInput(id, text, profile, options = {}) {
562
+ const session = resolveSession(id);
563
+ if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
564
+ if (ACTIVE_STATES.has(session.state) || session.child) {
565
+ throw Object.assign(new Error('session already has an active turn'), { status: 409 });
566
+ }
567
+ const value = String(text || '').trim();
568
+ const attachments = normalizeAttachments(options.attachments || []);
569
+ if (!value && !attachments.length) throw Object.assign(new Error('text or attachments are required'), { status: 400 });
570
+ if (config.modelFor && profile) {
571
+ session.model = config.modelFor(profile);
572
+ session.selectedModel = profile.mainModel || profile.models && profile.models.default || '';
573
+ }
574
+ if (options.permissionMode) session.permissionMode = options.permissionMode;
575
+ if (options.effort) session.effort = options.effort;
576
+ emit(session, {
577
+ type: 'claude/user-prompt',
578
+ text: value,
579
+ attachments: (options.displayAttachments || []).map(({ id, name, mediaType, size, width, height, url }) => ({ id, name, mediaType, size, width, height, url })),
580
+ });
581
+ // runTurn marks running synchronously; HTTP callers get 202 while the
582
+ // subprocess runs and observe completion through the event stream.
583
+ // Restored sessions carry their threadId, so the spawn below resumes
584
+ // the persisted agent conversation (codex resume / opencode --session).
585
+ session.turnPromise = runTurn(session, appendTextAttachments(value, attachments), profile, attachments).catch(error => {
586
+ emit(session, { type: 'claude/error', message: `${config.agentId} turn cleanup failed: ${error.message}` });
587
+ setState(session, 'idle');
588
+ flushPersist(session);
589
+ });
590
+ return { started: true };
591
+ },
592
+
593
+ resolvePermission() {
594
+ throw Object.assign(new Error(`${config.agentId} driver does not support interactive permissions`), { status: 501 });
595
+ },
596
+
597
+ // Rebind the session to another profile/model (or agent-owned auth with
598
+ // a null profile). Each turn spawns fresh with `-m` + the thread id, so
599
+ // only the per-turn model source needs updating; history continues.
600
+ updateSelection(id, profile, selectedModel) {
601
+ const session = resolveSession(id);
602
+ if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
603
+ if (ACTIVE_STATES.has(session.state) || session.child) {
604
+ throw Object.assign(new Error('session already has an active turn'), { status: 409 });
605
+ }
606
+ const fromAiProfileId = session.aiProfileId || null;
607
+ const fromModel = session.selectedModel || session.model || null;
608
+ session.aiProfileId = profile && profile.id || null;
609
+ session.selectedModel = selectedModel || (profile && (profile.mainModel || profile.models && profile.models.default)) || null;
610
+ session.model = config.modelFor ? config.modelFor(profile) : null;
611
+ emit(session, {
612
+ type: 'claude/selection-changed',
613
+ fromAiProfileId,
614
+ toAiProfileId: session.aiProfileId,
615
+ fromModel,
616
+ toModel: session.selectedModel,
617
+ });
618
+ flushPersist(session);
619
+ return summary(session);
620
+ },
621
+
622
+ abort(id) {
623
+ const session = resolveSession(id);
624
+ if (!session) return;
625
+ // A user-initiated kill is not a failure: the nonzero exit code it
626
+ // produces must not surface as an error event.
627
+ session.abortedByUser = true;
628
+ killChild(session);
629
+ if (!['idle', 'ended'].includes(session.state)) {
630
+ session.state = 'aborted';
631
+ emit(session, { type: 'claude/aborted' });
632
+ setState(session, 'idle');
633
+ }
634
+ flushPersist(session);
635
+ },
636
+
637
+ deleteSession(id) {
638
+ const session = sessions.get(id);
639
+ if (session) {
640
+ killChild(session);
641
+ sessions.delete(id);
642
+ removePersistedRecord(id);
643
+ return true;
644
+ }
645
+ // The session may exist only on disk (created by a previous process).
646
+ const index = syncDiskIndex();
647
+ const entry = index && index.get(id);
648
+ if (entry) {
649
+ removePersistedRecord(id, entry.file);
650
+ return true;
651
+ }
652
+ return false;
653
+ },
654
+
655
+ async listSupportedCommands() { return []; },
656
+
657
+ findClaudeExecutableForSdk() { return null; },
658
+ };
659
+
660
+ function killChild(session) {
661
+ if (!session.child) return;
662
+ try {
663
+ if (process.platform === 'win32') {
664
+ // npm shims spawn a node child; killing the shell alone orphans it.
665
+ spawn('taskkill', ['/pid', String(session.child.pid), '/T', '/F'], { windowsHide: true });
666
+ // taskkill cannot reach injected test doubles or already-orphaned
667
+ // shells; the direct kill covers them. Exactly one kill per platform
668
+ // — a double kill schedules two close events and the second would
669
+ // surface as a spurious error after the abort flag resets.
670
+ try { session.child.kill(); } catch { /* already gone */ }
671
+ } else {
672
+ session.child.kill('SIGTERM');
673
+ }
674
+ } catch { /* already gone */ }
675
+ // Retain the handle until close so a new turn cannot race the dying child.
676
+ }
677
+
678
+ async function runTurn(session, text, profile, attachments) {
679
+ setState(session, 'running');
680
+ session.lastResult = '';
681
+ session.closeHandled = false; // per-child guard; each turn spawns a new child
682
+ session.turnUsage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
683
+ let prepared;
684
+ try {
685
+ prepared = prepareImages(session.projectPath, attachments);
686
+ return await new Promise(resolve => {
687
+ const model = session.model;
688
+ const sandbox = config.sandboxFor ? config.sandboxFor(session.permissionMode) : undefined;
689
+ let args = config.spawnArgs({
690
+ projectPath: session.projectPath,
691
+ model,
692
+ sandbox,
693
+ threadId: session.threadId,
694
+ prompt: text,
695
+ profile,
696
+ permissionMode: session.permissionMode,
697
+ effort: session.effort,
698
+ imagePaths: prepared.imagePaths,
699
+ });
700
+ const spawnOptions = {
701
+ cwd: session.projectPath,
702
+ windowsHide: true,
703
+ env: { ...process.env, ...(config.envFor ? config.envFor(profile, session) : {}) },
704
+ };
705
+ let child;
706
+ try {
707
+ // Launch executables/npm entry points directly. shell:true corrupts
708
+ // TOML quotes and lets prompt/file metacharacters become cmd syntax.
709
+ const invocation = config.spawn ? { command: config.command, args } : resolveCliInvocation(config.command, args);
710
+ child = (config.spawn || spawn)(invocation.command, invocation.args, spawnOptions);
711
+ } catch (error) {
712
+ emit(session, { type: 'claude/error', message: `failed to start ${config.agentId}: ${error.message}` });
713
+ prepared.cleanup();
714
+ setState(session, 'idle');
715
+ resolve();
716
+ return;
717
+ }
718
+ session.child = child;
719
+ let buffer = '';
720
+ let settled = false;
721
+ let sawResult = false;
722
+ let emittedResult = false;
723
+ let emittedError = false;
724
+ // Until the CLI's first JSON event lands the UI would show dead air
725
+ // (reasoning models think for tens of seconds first) — announce it.
726
+ setState(session, 'thinking');
727
+ const helpers = {
728
+ setResult(value) { session.lastResult = String(value || ''); sawResult = true; },
729
+ setThreadId(threadId) { if (threadId && !session.threadId) session.threadId = String(threadId); },
730
+ setModel(model2) { if (model2) session.model = String(model2); },
731
+ addUsage(usage) {
732
+ if (!usage) return;
733
+ const input = Number(usage.inputTokens || usage.input_tokens || 0) || 0;
734
+ const output = Number(usage.outputTokens || usage.output_tokens || 0) || 0;
735
+ const cached = Number(usage.cachedTokens || usage.cached_input_tokens || 0) || 0;
736
+ session.usage.inputTokens += input;
737
+ session.usage.outputTokens += output;
738
+ session.usage.cachedTokens += cached;
739
+ if (session.turnUsage) {
740
+ session.turnUsage.inputTokens += input;
741
+ session.turnUsage.outputTokens += output;
742
+ session.turnUsage.cachedTokens += cached;
743
+ const turnTotal = session.turnUsage.inputTokens + session.turnUsage.outputTokens + session.turnUsage.cachedTokens;
744
+ session.peakTurnTokens = Math.max(session.peakTurnTokens || 0, turnTotal);
745
+ }
746
+ },
747
+ setState,
748
+ };
749
+ // The same upstream failure often arrives twice (stderr chunk + error
750
+ // item + nonzero exit); collapse consecutive identical diagnostics so
751
+ // the UI never stacks duplicate warnings.
752
+ let lastDiag = '';
753
+ const emitDiag = event => {
754
+ const text = event.text != null ? event.text : event.message;
755
+ if (text === lastDiag) return;
756
+ lastDiag = String(text == null ? '' : text);
757
+ emit(session, event);
758
+ };
759
+ const parse = line => {
760
+ if (!line.trim()) return;
761
+ let parsed;
762
+ try { parsed = JSON.parse(line); } catch {
763
+ emitDiag({ type: 'claude/stderr', text: line.slice(0, 2000) });
764
+ return;
765
+ }
766
+ try {
767
+ if (session.state === 'thinking') setState(session, 'running');
768
+ const events = config.parseLine(parsed, session, helpers);
769
+ for (const event of events) {
770
+ if (event.type === 'claude/result') emittedResult = true;
771
+ if (event.type === 'claude/error') emittedError = true;
772
+ if (event.type === 'claude/error' || event.type === 'claude/stderr') emitDiag(event);
773
+ else emit(session, event);
774
+ }
775
+ } catch (error) {
776
+ emitDiag({ type: 'claude/stderr', text: `parser: ${error.message}`.slice(0, 2000) });
777
+ }
778
+ };
779
+ const finish = () => {
780
+ if (settled) return;
781
+ settled = true;
782
+ session.child = null;
783
+ session.turns += 1;
784
+ prepared.cleanup();
785
+ if (!session.endedAt) session.state = 'idle';
786
+ // Drivers that stream step-level records (opencode) never know which
787
+ // step is last; the turn result is synthesized once at process close
788
+ // unless the driver already emitted one or failed.
789
+ if (!emittedResult && !emittedError && sawResult) {
790
+ emit(session, { type: 'claude/result', result: session.lastResult, model: session.model, isError: false });
791
+ }
792
+ emit(session, { type: 'claude/state', state: 'idle', turn: session.turns });
793
+ // The turn is complete: write the record now so a process exit after
794
+ // this point can never lose the finished conversation.
795
+ flushPersist(session);
796
+ resolve();
797
+ };
798
+ child.stdout.on('data', chunk => {
799
+ buffer += chunk.toString('utf8');
800
+ let index;
801
+ while ((index = buffer.indexOf('\n')) >= 0) {
802
+ const line = buffer.slice(0, index).trim();
803
+ buffer = buffer.slice(index + 1);
804
+ parse(line);
805
+ }
806
+ });
807
+ child.stderr.on('data', chunk => {
808
+ const text = chunk.toString('utf8');
809
+ if (text.trim()) emitDiag({ type: 'claude/stderr', text: text.slice(0, 2000) });
810
+ });
811
+ child.on('error', error => {
812
+ emit(session, { type: 'claude/error', message: `${config.agentId} process error: ${error.message}` });
813
+ finish();
814
+ });
815
+ child.on('close', code => {
816
+ // Real children close once; the guard keeps repeated closes (test
817
+ // doubles, double kill) from re-running the tail after the abort
818
+ // flag has already reset.
819
+ if (session.closeHandled) return;
820
+ session.closeHandled = true;
821
+ if (buffer.trim()) parse(buffer);
822
+ if (code !== 0 && !sawResult && !session.abortedByUser && !emittedError) {
823
+ emitDiag({
824
+ type: 'claude/error',
825
+ message: `${config.agentId} exited with code ${code}`.slice(0, 500),
826
+ });
827
+ }
828
+ session.abortedByUser = false;
829
+ finish();
830
+ });
831
+ try {
832
+ if (config.promptViaStdin) {
833
+ child.stdin.write(text);
834
+ }
835
+ child.stdin.end();
836
+ } catch { /* stdin closed early; close handler will settle the turn */ }
837
+ });
838
+ } catch (error) {
839
+ emit(session, { type: 'claude/error', message: `failed to prepare ${config.agentId} turn: ${error.message}` });
840
+ setState(session, 'idle');
841
+ flushPersist(session);
842
+ } finally {
843
+ if (prepared) prepared.cleanup();
844
+ }
845
+ }
846
+
847
+ return runner;
848
+ }
849
+
850
+ function prepareImages(projectPath, attachments = []) {
851
+ const images = attachments.filter(item => item.mediaType.startsWith('image/'));
852
+ if (!images.length) return { imagePaths: [], cleanup() {} };
853
+ const root = path.join(path.resolve(projectPath), '.agent-terminal-attachments');
854
+ fs.mkdirSync(root, { recursive: true });
855
+ // Refuse a workspace symlink/junction that would write attachments elsewhere.
856
+ if (fs.lstatSync(root).isSymbolicLink()) throw new Error('attachment directory must not be a symbolic link');
857
+ const turnDir = path.join(root, crypto.randomUUID());
858
+ fs.mkdirSync(turnDir);
859
+ let cleaned = false;
860
+ const cleanup = () => {
861
+ if (cleaned) return;
862
+ fs.rmSync(turnDir, { recursive: true, force: true });
863
+ try { fs.rmdirSync(root); } catch (error) {
864
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
865
+ }
866
+ cleaned = true;
867
+ };
868
+ try {
869
+ const imagePaths = images.map((item, index) => {
870
+ const extension = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }[item.mediaType];
871
+ const file = path.join(turnDir, `${index}${extension}`);
872
+ fs.writeFileSync(file, Buffer.from(item.data, 'base64'), { mode: 0o600 });
873
+ return file;
874
+ });
875
+ return { imagePaths, cleanup };
876
+ } catch (error) { cleanup(); throw error; }
877
+ }
878
+
879
+ // npm/cmd shims embed the real install target at generation time; mine it
880
+ // instead of spawning the shim through a shell. The variable prefixes npm and
881
+ // hand-written wrappers use ($basedir, %~dp0%, %dp0%) are stripped, and when
882
+ // the embedded file no longer exists (upstream moved its entry — the claude
883
+ // cli.js -> bin/claude.exe class of breakage) the package's own package.json
884
+ // bin field is consulted: the authoritative entry whatever the layout.
885
+ function readPackageBinEntry(packageDir, commandName) {
886
+ if (!packageDir) return null;
887
+ try {
888
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
889
+ const bin = pkg && pkg.bin;
890
+ if (!bin) return null;
891
+ const rel = typeof bin === 'string'
892
+ ? bin
893
+ : (typeof bin[commandName] === 'string' && bin[commandName])
894
+ || (Object.keys(bin).length === 1 && typeof bin[Object.keys(bin)[0]] === 'string' && bin[Object.keys(bin)[0]]);
895
+ if (!rel) return null;
896
+ const entry = path.join(packageDir, ...String(rel).split(/[\\/]+/).filter(Boolean));
897
+ return fs.existsSync(entry) ? entry : null;
898
+ } catch {
899
+ return null;
900
+ }
901
+ }
902
+
903
+ function packageDirFromEntry(raw, shimDir) {
904
+ // Relative npm shim targets start bare ("node_modules\\pkg\\...") because
905
+ // the %dp0% strip consumed the separator, so locate the last segment
906
+ // wherever it sits — innermost node_modules wins on nested layouts.
907
+ const idx = raw.toLowerCase().lastIndexOf('node_modules');
908
+ if (idx === -1) return null;
909
+ const after = raw.slice(idx + 'node_modules'.length);
910
+ if (!/^[\\/]/.test(after)) return null;
911
+ const rest = after.split(/[\\/]+/).filter(Boolean);
912
+ if (!rest.length) return null;
913
+ const scope = rest[0].startsWith('@') ? 2 : 1;
914
+ if (rest.length < scope) return null;
915
+ const base = path.isAbsolute(raw) ? raw.slice(0, idx) : shimDir;
916
+ return path.join(base, 'node_modules', ...rest.slice(0, scope));
917
+ }
918
+
919
+ function resolveShimEntry(shimPath, commandName) {
920
+ let content;
921
+ try {
922
+ content = fs.readFileSync(shimPath, 'utf8');
923
+ } catch {
924
+ return null;
925
+ }
926
+ const shimDir = path.dirname(shimPath);
927
+ for (const match of content.matchAll(/["']([^"'\r\n]+\.(?:exe|[cm]?js))["']/gi)) {
928
+ const raw = match[1]
929
+ .replace(/^\$basedir[\\/]*/i, '')
930
+ .replace(/^%~dp0%?[\\/]*/i, '')
931
+ .replace(/^%dp0%[\\/]*/i, '');
932
+ const entry = path.isAbsolute(raw) ? raw : path.resolve(shimDir, raw);
933
+ if (fs.existsSync(entry)) return entry;
934
+ const declared = readPackageBinEntry(packageDirFromEntry(raw, shimDir), commandName);
935
+ if (declared) return declared;
936
+ }
937
+ return null;
938
+ }
939
+
940
+ function resolveCliInvocation(command, args) {
941
+ if (process.platform !== 'win32') return { command, args };
942
+ const commandName = path.basename(command, path.extname(command));
943
+ const searchDirs = path.isAbsolute(command) ? [''] : (process.env.PATH || '').split(path.delimiter);
944
+ const extensions = path.extname(command) ? [''] : ['.exe', '.com', '.cmd', '.bat'];
945
+ for (const dir of searchDirs) {
946
+ for (const extension of extensions) {
947
+ const file = path.resolve(dir, `${command}${extension}`);
948
+ if (!fs.existsSync(file)) continue;
949
+ if (!/\.(cmd|bat)$/i.test(file)) return { command: file, args };
950
+ const entry = resolveShimEntry(file, commandName);
951
+ if (entry) return /\.exe$/i.test(entry)
952
+ ? { command: entry, args }
953
+ : { command: process.execPath, args: [entry, ...args] };
954
+ throw new Error(`cannot safely launch ${path.basename(file)}; configure its executable or Node entry point`);
955
+ }
956
+ }
957
+ return { command, args }; // spawn reports the normal ENOENT diagnostic.
958
+ }
959
+
960
+ module.exports = { createCliJsonRunner, resolveCliInvocation };