@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3

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