@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.
- package/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +636 -613
- package/apps/agent-terminal/app.js +125 -65
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +171 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +187 -159
- package/apps/agent-terminal/right-workspace-dock.js +623 -519
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +13 -13
- package/docs/sdk/openapi.yaml +8 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1442 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2392 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- 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 };
|