@sanqianx/claude-ai-workbench 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +129 -0
- package/apps/agent-terminal/app.css +567 -0
- package/apps/agent-terminal/app.js +2392 -0
- package/apps/agent-terminal/index.html +158 -0
- package/apps/ai-profile-settings-host/index.html +25 -0
- package/apps/ai-profile-settings-host/page.css +1 -0
- package/apps/ai-profile-settings-host/page.js +113 -0
- package/apps/minimal-test-host/README.md +15 -0
- package/apps/minimal-test-host/host.css +2 -0
- package/apps/minimal-test-host/host.js +35 -0
- package/apps/minimal-test-host/index.html +58 -0
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
- package/docs/sdk/FILE_LAYOUT.md +31 -0
- package/docs/sdk/INTEGRATION.md +126 -0
- package/docs/sdk/README.md +21 -0
- package/docs/sdk/openapi.yaml +760 -0
- package/package.json +51 -0
- package/packages/client/README.md +11 -0
- package/packages/client/index.d.ts +81 -0
- package/packages/client/index.js +198 -0
- package/packages/client/package.json +29 -0
- package/packages/contracts/README.md +9 -0
- package/packages/contracts/index.d.ts +252 -0
- package/packages/contracts/index.js +295 -0
- package/packages/contracts/package.json +24 -0
- package/packages/core/README.md +8 -0
- package/packages/core/index.d.ts +76 -0
- package/packages/core/index.js +214 -0
- package/packages/core/package.json +29 -0
- package/packages/server/README.md +17 -0
- package/packages/server/bin/agent-terminal-server.js +36 -0
- package/packages/server/bin/claude-workbench-server.js +46 -0
- package/packages/server/bin/claude-workbench.js +225 -0
- package/packages/server/index.d.ts +145 -0
- package/packages/server/index.js +183 -0
- package/packages/server/lib/agent-discovery.js +112 -0
- package/packages/server/lib/agent-registry.js +98 -0
- package/packages/server/lib/attachment-store.js +78 -0
- package/packages/server/lib/bridges/app-server-bridge.js +372 -0
- package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
- package/packages/server/lib/capture-settings-store.js +34 -0
- package/packages/server/lib/context-resolver.js +40 -0
- package/packages/server/lib/drivers/cli-json-runner.js +960 -0
- package/packages/server/lib/drivers/codex-driver.js +1413 -0
- package/packages/server/lib/drivers/opencode-driver.js +1527 -0
- package/packages/server/lib/drivers/zcode-driver.js +1167 -0
- package/packages/server/lib/event-stream.js +35 -0
- package/packages/server/lib/folder-picker.js +623 -0
- package/packages/server/lib/http-handler.js +254 -0
- package/packages/server/lib/json-file.js +19 -0
- package/packages/server/lib/model-detection.js +295 -0
- package/packages/server/lib/profile-store.js +149 -0
- package/packages/server/lib/project-store.js +102 -0
- package/packages/server/lib/session-archive-store.js +53 -0
- package/packages/server/lib/system-doctor.js +119 -0
- package/packages/server/lib/ui-preferences-store.js +48 -0
- package/packages/server/lib/workbench-service.js +620 -0
- package/packages/server/package.json +34 -0
- package/packages/ui/README.md +30 -0
- package/packages/ui/ai-profile-settings.d.ts +45 -0
- package/packages/ui/ai-profile-settings.js +283 -0
- package/packages/ui/claude-terminal.d.ts +31 -0
- package/packages/ui/claude-terminal.js +343 -0
- package/packages/ui/index.d.ts +18 -0
- package/packages/ui/index.js +51 -0
- package/packages/ui/package.json +42 -0
- package/packages/ui/theme.css +13 -0
- package/src/assets/claude-prompts.json +21 -0
- package/src/backend/http/ai-profile-routes.js +61 -0
- package/src/backend/http/claude-routes.js +170 -0
- package/src/backend/http/node-helpers.js +45 -0
- package/src/backend/index.js +21 -0
- package/src/backend/lib/ai-adapter.js +144 -0
- package/src/backend/lib/ai-profile-store.js +259 -0
- package/src/backend/lib/ai-vendor-presets.js +83 -0
- package/src/backend/lib/ai-workspace.js +140 -0
- package/src/backend/lib/automation-config.js +253 -0
- package/src/backend/lib/claude-cli-runner.js +1749 -0
- package/src/backend/lib/data-dir.js +169 -0
- package/src/backend/lib/llm-client.js +176 -0
- package/src/backend/lib/model-context-windows.js +82 -0
- package/src/backend/lib/prompt-registry.js +166 -0
- package/src/backend/lib/runtime-config.js +121 -0
- package/src/index.js +7 -0
- package/src/sdk-wave-a.ts +1 -0
- package/src/sdk-wave-b.ts +1 -0
- package/src/sdk-wave-c.ts +1 -0
- package/src/sdk-wave-d.ts +1 -0
- package/src/sdk-wave-e.ts +1 -0
|
@@ -0,0 +1,1749 @@
|
|
|
1
|
+
// Claude Session Runner — powers the embedded Claude Code terminal in the
|
|
2
|
+
// dashboard. Always uses the Claude Agent SDK (the `claude -p` subprocess
|
|
3
|
+
// path was removed; the SDK path applies the hardened claude_code system
|
|
4
|
+
// prompt and is the only supported mode).
|
|
5
|
+
//
|
|
6
|
+
// Used by server.js to stream Claude output as SSE events.
|
|
7
|
+
//
|
|
8
|
+
// Key design:
|
|
9
|
+
// - One session per "kickoff" (initial analyze or follow-up). Each session has a
|
|
10
|
+
// server-generated sessionId; claude itself has a separate session_id captured from
|
|
11
|
+
// the system/init event and used for --resume on follow-ups.
|
|
12
|
+
// - Listeners are SSE response objects. When an event arrives, it's emitted to all
|
|
13
|
+
// current listeners AND appended to outputBuffer so late subscribers can replay.
|
|
14
|
+
// - NDJSON parsing uses a per-process line buffer — stdout chunks may split mid-line.
|
|
15
|
+
// - The SDK emits messages via an async iterator (not a child stdout pipe), so the
|
|
16
|
+
// per-line buffer is fed stringified message objects one at a time.
|
|
17
|
+
|
|
18
|
+
const { execFileSync, spawn } = require('child_process');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { renderPrompt } = require('./prompt-registry');
|
|
23
|
+
const aiWorkspace = require('./ai-workspace');
|
|
24
|
+
const {
|
|
25
|
+
normalizePermissionMode,
|
|
26
|
+
evaluateAutomationToolUse,
|
|
27
|
+
} = require('./automation-config');
|
|
28
|
+
const { resolveContextWindow } = require('./model-context-windows');
|
|
29
|
+
const {
|
|
30
|
+
getWorkbenchConfig,
|
|
31
|
+
sessionDirNames,
|
|
32
|
+
sessionSchemas,
|
|
33
|
+
} = require('./runtime-config');
|
|
34
|
+
|
|
35
|
+
// ---- session store ----
|
|
36
|
+
// Map<sessionId, Session>
|
|
37
|
+
const sessions = new Map();
|
|
38
|
+
const { getDataDir } = require('./data-dir');
|
|
39
|
+
|
|
40
|
+
function kbRoot() {
|
|
41
|
+
return getDataDir();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sessionDirName() {
|
|
45
|
+
return getWorkbenchConfig().sessionDirName || 'claude-workbench';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sessionSchema() {
|
|
49
|
+
return getWorkbenchConfig().sessionSchema || 'claude-workbench-session/v1';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function projectsPath() {
|
|
53
|
+
const cfg = getWorkbenchConfig();
|
|
54
|
+
return path.join(kbRoot(), cfg.projectsFileName || 'projects.json');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function projectsRoot() {
|
|
58
|
+
return path.join(kbRoot(), 'projects');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function applyConfiguredProcessEnv(session) {
|
|
62
|
+
const cfg = getWorkbenchConfig();
|
|
63
|
+
if (!cfg.suppressHooksEnv) return;
|
|
64
|
+
session.claudeEnv = {
|
|
65
|
+
...(session.claudeEnv || {}),
|
|
66
|
+
[cfg.suppressHooksEnv]: cfg.suppressHooksValue || '1',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fakeClaudeEnabled() {
|
|
71
|
+
const cfg = getWorkbenchConfig();
|
|
72
|
+
const primary = cfg.fakeClaudeEnvVar || 'KB_AUTOMATION_FAKE_CLAUDE';
|
|
73
|
+
return process.env[primary] === '1' || process.env.KB_AUTOMATION_FAKE_CLAUDE === '1';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// End-of-session subscribers. Used by the automation queue to learn when an
|
|
77
|
+
// automation run finishes so it can promote the next queued run. Fires on
|
|
78
|
+
// idle/failed/aborted; non-automation sessions are filtered by subscribers
|
|
79
|
+
// themselves via session.metadata.automationRunId.
|
|
80
|
+
const sessionEndedCallbacks = new Set();
|
|
81
|
+
function onSessionEnded(cb) {
|
|
82
|
+
if (typeof cb !== 'function') return () => {};
|
|
83
|
+
sessionEndedCallbacks.add(cb);
|
|
84
|
+
return () => sessionEndedCallbacks.delete(cb);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const TERMINAL_STATES = new Set(['idle', 'failed', 'aborted']);
|
|
88
|
+
|
|
89
|
+
function sleep(ms) {
|
|
90
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeErrorMessage(err) {
|
|
94
|
+
if (!err) return '';
|
|
95
|
+
if (typeof err === 'string') return err;
|
|
96
|
+
if (typeof err.message === 'string' && err.message) return err.message;
|
|
97
|
+
try { return JSON.stringify(err); } catch { return String(err); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isTransientClaudeError(err) {
|
|
101
|
+
const msg = normalizeErrorMessage(err).toLowerCase();
|
|
102
|
+
if (!msg) return false;
|
|
103
|
+
return [
|
|
104
|
+
'429',
|
|
105
|
+
'502',
|
|
106
|
+
'503',
|
|
107
|
+
'504',
|
|
108
|
+
'529',
|
|
109
|
+
'rate limit',
|
|
110
|
+
'too many requests',
|
|
111
|
+
'overloaded',
|
|
112
|
+
'overload',
|
|
113
|
+
'busy',
|
|
114
|
+
'capacity',
|
|
115
|
+
'traffic',
|
|
116
|
+
'timeout',
|
|
117
|
+
'timed out',
|
|
118
|
+
'etimedout',
|
|
119
|
+
'econnreset',
|
|
120
|
+
'econnrefused',
|
|
121
|
+
'eai_again',
|
|
122
|
+
'\u8bbf\u95ee\u91cf',
|
|
123
|
+
'\u8bbf\u95ee\u592a\u5927',
|
|
124
|
+
'\u9650\u6d41',
|
|
125
|
+
'\u7e41\u5fd9',
|
|
126
|
+
'\u7a0d\u540e',
|
|
127
|
+
'\u8fc7\u8f7d',
|
|
128
|
+
].some(token => msg.includes(token));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function maxSdkRetries() {
|
|
132
|
+
const raw = process.env.KB_CLAUDE_SDK_MAX_RETRIES || process.env.CLAUDE_SDK_MAX_RETRIES;
|
|
133
|
+
const n = raw == null || raw === '' ? 2 : Number(raw);
|
|
134
|
+
if (!Number.isFinite(n)) return 2;
|
|
135
|
+
return Math.max(0, Math.min(5, Math.floor(n)));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sdkRetryDelayMs(attempt) {
|
|
139
|
+
const base = Number(process.env.KB_CLAUDE_SDK_RETRY_BASE_MS) || 1000;
|
|
140
|
+
const delay = base * Math.pow(2, Math.max(0, attempt - 1));
|
|
141
|
+
return Math.min(8000, Math.max(250, delay));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function newSessionId() {
|
|
145
|
+
return 'sess-' + crypto.randomBytes(6).toString('hex');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createSession({ projectSlug, projectPath, kbPath, promptKey, source = 'manual', metadata = null }) {
|
|
149
|
+
const sessionId = newSessionId();
|
|
150
|
+
const session = {
|
|
151
|
+
sessionId,
|
|
152
|
+
projectSlug,
|
|
153
|
+
projectPath,
|
|
154
|
+
kbPath: kbPath || path.join(projectsRoot(), projectSlug),
|
|
155
|
+
promptKey,
|
|
156
|
+
state: 'idle',
|
|
157
|
+
model: null,
|
|
158
|
+
claudeSessionId: null,
|
|
159
|
+
startedAt: new Date().toISOString(),
|
|
160
|
+
endedAt: null,
|
|
161
|
+
exitCode: null,
|
|
162
|
+
listeners: new Set(),
|
|
163
|
+
outputBuffer: [],
|
|
164
|
+
subprocess: null,
|
|
165
|
+
turns: 0,
|
|
166
|
+
error: null,
|
|
167
|
+
claudeEnv: {},
|
|
168
|
+
pendingPermission: null,
|
|
169
|
+
pendingTurn: null,
|
|
170
|
+
pendingToolApproval: null,
|
|
171
|
+
restored: false,
|
|
172
|
+
source,
|
|
173
|
+
metadata: metadata && typeof metadata === 'object' ? metadata : {},
|
|
174
|
+
automation: !!(metadata && metadata.automation),
|
|
175
|
+
automationRunId: metadata && metadata.automationRunId || null,
|
|
176
|
+
permissionMode: 'default',
|
|
177
|
+
commands: [],
|
|
178
|
+
commandDiscovery: null,
|
|
179
|
+
safetyPolicy: null,
|
|
180
|
+
};
|
|
181
|
+
sessions.set(sessionId, session);
|
|
182
|
+
persistSession(session);
|
|
183
|
+
return session;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sessionRecordPath(session) {
|
|
187
|
+
if (!session || !session.projectSlug) return null;
|
|
188
|
+
return path.join(aiWorkspace.ensureProjectAIPath(session.projectSlug), sessionDirName(), `${session.sessionId}.json`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function toPersistedSession(session) {
|
|
192
|
+
return {
|
|
193
|
+
schema: sessionSchema(),
|
|
194
|
+
sessionId: session.sessionId,
|
|
195
|
+
projectSlug: session.projectSlug,
|
|
196
|
+
projectPath: session.projectPath,
|
|
197
|
+
kbPath: session.kbPath,
|
|
198
|
+
promptKey: session.promptKey,
|
|
199
|
+
runner: session.runner || 'cli',
|
|
200
|
+
state: session.state,
|
|
201
|
+
model: session.model,
|
|
202
|
+
aiProfileId: session.aiProfileId || null,
|
|
203
|
+
selectedModel: session.selectedModel || null,
|
|
204
|
+
claudeSessionId: session.claudeSessionId,
|
|
205
|
+
source: session.source || 'manual',
|
|
206
|
+
automation: !!session.automation,
|
|
207
|
+
automationRunId: session.automationRunId || null,
|
|
208
|
+
metadata: session.metadata || {},
|
|
209
|
+
permissionMode: session.permissionMode || 'default',
|
|
210
|
+
commands: Array.isArray(session.commands) ? session.commands : [],
|
|
211
|
+
startedAt: session.startedAt,
|
|
212
|
+
endedAt: session.endedAt,
|
|
213
|
+
exitCode: session.exitCode,
|
|
214
|
+
turns: session.turns,
|
|
215
|
+
error: session.error,
|
|
216
|
+
pendingPermission: session.pendingPermission,
|
|
217
|
+
events: session.outputBuffer.slice(-5000),
|
|
218
|
+
updatedAt: new Date().toISOString(),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function persistSession(session) {
|
|
223
|
+
const file = sessionRecordPath(session);
|
|
224
|
+
if (!file) return;
|
|
225
|
+
try {
|
|
226
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
227
|
+
fs.writeFileSync(file, JSON.stringify(toPersistedSession(session), null, 2) + '\n', 'utf-8');
|
|
228
|
+
} catch (e) {
|
|
229
|
+
// Persistence should never interrupt the live Claude process.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readPersistedRecord(file) {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
236
|
+
if (parsed && sessionSchemas().includes(parsed.schema) && parsed.sessionId) return parsed;
|
|
237
|
+
} catch {}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function scanPersistedRecords(projectSlug = null) {
|
|
242
|
+
let projectDirs = [];
|
|
243
|
+
try {
|
|
244
|
+
const projects = JSON.parse(fs.readFileSync(projectsPath(), 'utf-8'));
|
|
245
|
+
projectDirs = Object.entries(projects || {})
|
|
246
|
+
.filter(([slug]) => !projectSlug || slug === projectSlug)
|
|
247
|
+
.map(([slug, cfg]) => ({ name: slug, kbPath: cfg.kbPath || path.join(projectsRoot(), slug) }));
|
|
248
|
+
} catch {
|
|
249
|
+
try {
|
|
250
|
+
const root = projectsRoot();
|
|
251
|
+
projectDirs = fs.readdirSync(root, { withFileTypes: true })
|
|
252
|
+
.filter(entry => entry.isDirectory())
|
|
253
|
+
.filter(entry => !projectSlug || entry.name === projectSlug)
|
|
254
|
+
.map(entry => ({ name: entry.name, kbPath: path.join(root, entry.name) }));
|
|
255
|
+
} catch { return []; }
|
|
256
|
+
}
|
|
257
|
+
const records = [];
|
|
258
|
+
for (const entry of projectDirs) {
|
|
259
|
+
const dirs = sessionDirNames().flatMap(dirName => [
|
|
260
|
+
path.join(aiWorkspace.projectAIPath(entry.name), dirName),
|
|
261
|
+
path.join(entry.kbPath, '_ai', dirName),
|
|
262
|
+
]);
|
|
263
|
+
for (const dir of dirs) {
|
|
264
|
+
let files = [];
|
|
265
|
+
try { files = fs.readdirSync(dir); } catch { continue; }
|
|
266
|
+
for (const file of files) {
|
|
267
|
+
if (!file.endsWith('.json')) continue;
|
|
268
|
+
const record = readPersistedRecord(path.join(dir, file));
|
|
269
|
+
if (record) records.push(record);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return records.sort((a, b) => String(b.updatedAt || b.startedAt || '').localeCompare(String(a.updatedAt || a.startedAt || '')));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function findPersistedRecord(sessionId) {
|
|
277
|
+
return scanPersistedRecords().find(record => record.sessionId === sessionId) || null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function currentProjectKbPath(projectSlug) {
|
|
281
|
+
try {
|
|
282
|
+
const projects = JSON.parse(fs.readFileSync(projectsPath(), 'utf-8'));
|
|
283
|
+
const cfg = projects && projects[projectSlug];
|
|
284
|
+
if (cfg && cfg.kbPath) return cfg.kbPath;
|
|
285
|
+
} catch {}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function restoreSessionFromDisk(sessionId) {
|
|
290
|
+
if (sessions.has(sessionId)) return sessions.get(sessionId);
|
|
291
|
+
const record = findPersistedRecord(sessionId);
|
|
292
|
+
if (!record) return null;
|
|
293
|
+
const liveState = ['running', 'spawning', 'pending-permission'].includes(record.state) ? 'idle' : (record.state || 'idle');
|
|
294
|
+
// Always prefer the project's CURRENT kbPath from projects.json over the
|
|
295
|
+
// value persisted when the session was first created. The KB root is
|
|
296
|
+
// user-configurable (see knowledge-store.json) and projects can move between
|
|
297
|
+
// roots; following the live config means restoring a session after a path
|
|
298
|
+
// change still lands in the right working directory. Falls back to the
|
|
299
|
+
// persisted value only when the project is no longer registered.
|
|
300
|
+
const liveKbPath = currentProjectKbPath(record.projectSlug);
|
|
301
|
+
const kbPath = liveKbPath || record.kbPath || path.join(projectsRoot(), record.projectSlug);
|
|
302
|
+
const kbPathChanged = !!liveKbPath && record.kbPath && liveKbPath !== record.kbPath;
|
|
303
|
+
// When the working directory moves, the previous Claude conversation is
|
|
304
|
+
// stored in the OLD cwd's session store and `--resume <id>` cannot find
|
|
305
|
+
// it from the new cwd. Carrying the stale claudeSessionId forward would
|
|
306
|
+
// just produce a "No conversation found" error on the next follow-up —
|
|
307
|
+
// drop it (and the chat history) so the user starts a fresh turn in the
|
|
308
|
+
// new directory.
|
|
309
|
+
const inheritConversation = !kbPathChanged;
|
|
310
|
+
const session = {
|
|
311
|
+
sessionId: record.sessionId,
|
|
312
|
+
projectSlug: record.projectSlug,
|
|
313
|
+
projectPath: record.projectPath,
|
|
314
|
+
kbPath,
|
|
315
|
+
promptKey: record.promptKey,
|
|
316
|
+
runner: record.runner || 'cli',
|
|
317
|
+
state: liveState,
|
|
318
|
+
model: record.model || null,
|
|
319
|
+
aiProfileId: record.aiProfileId || null,
|
|
320
|
+
selectedModel: record.selectedModel || record.model || null,
|
|
321
|
+
claudeSessionId: inheritConversation ? (record.claudeSessionId || null) : null,
|
|
322
|
+
startedAt: record.startedAt || new Date().toISOString(),
|
|
323
|
+
endedAt: liveState === record.state ? record.endedAt || null : null,
|
|
324
|
+
exitCode: record.exitCode ?? null,
|
|
325
|
+
listeners: new Set(),
|
|
326
|
+
outputBuffer: inheritConversation && Array.isArray(record.events) ? record.events : [],
|
|
327
|
+
subprocess: null,
|
|
328
|
+
turns: inheritConversation ? (record.turns || 0) : 0,
|
|
329
|
+
error: inheritConversation ? (record.error || null) : null,
|
|
330
|
+
claudeEnv: {},
|
|
331
|
+
pendingPermission: null,
|
|
332
|
+
pendingTurn: null,
|
|
333
|
+
pendingToolApproval: null,
|
|
334
|
+
restored: true,
|
|
335
|
+
historyCleared: !inheritConversation,
|
|
336
|
+
source: record.source || 'manual',
|
|
337
|
+
metadata: record.metadata || {},
|
|
338
|
+
automation: !!record.automation,
|
|
339
|
+
automationRunId: record.automationRunId || null,
|
|
340
|
+
permissionMode: record.permissionMode || 'default',
|
|
341
|
+
commands: inheritConversation && Array.isArray(record.commands) ? record.commands : [],
|
|
342
|
+
commandDiscovery: null,
|
|
343
|
+
safetyPolicy: null,
|
|
344
|
+
};
|
|
345
|
+
sessions.set(sessionId, session);
|
|
346
|
+
if (liveState !== record.state || kbPathChanged) {
|
|
347
|
+
emit(session, { type: 'claude/restored', fromState: record.state, state: liveState });
|
|
348
|
+
}
|
|
349
|
+
// Only broadcast active restorations so server boot doesn't flood the bus
|
|
350
|
+
// with every persisted idle record.
|
|
351
|
+
if (ACTIVE_STATES.has(record.state)) {
|
|
352
|
+
_broadcastSessionChange(session, 'restore');
|
|
353
|
+
}
|
|
354
|
+
if (kbPathChanged) {
|
|
355
|
+
emit(session, {
|
|
356
|
+
type: 'claude/kbpath-updated',
|
|
357
|
+
fromKbPath: record.kbPath,
|
|
358
|
+
toKbPath: kbPath,
|
|
359
|
+
historyCleared: true,
|
|
360
|
+
message: `working directory moved to ${kbPath}; previous conversation history was cleared — send a new message to start a fresh session`,
|
|
361
|
+
});
|
|
362
|
+
persistSession(session);
|
|
363
|
+
}
|
|
364
|
+
return session;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Terminal conversations are internal AI runtime by default: their hooks must
|
|
368
|
+
// never be captured by the Bridge into Development Conversation. The 会话捕获
|
|
369
|
+
// setting (terminal settings UI) flips this via setBridgeCaptureConversation —
|
|
370
|
+
// new sessions then carry AI_CODING_EVENT_BRIDGE_CAPTURE=1 and reach the
|
|
371
|
+
// Bridge journal like any CLI tool conversation.
|
|
372
|
+
let bridgeCaptureConversations = false;
|
|
373
|
+
|
|
374
|
+
function setBridgeCaptureConversation(enabled) {
|
|
375
|
+
bridgeCaptureConversations = enabled === true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function buildClaudeEnvFromProfile(profile) {
|
|
379
|
+
const env = {
|
|
380
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
381
|
+
AI_CODING_EVENT_BRIDGE_CAPTURE: bridgeCaptureConversations ? '1' : '0',
|
|
382
|
+
AI_CODING_EVENT_BRIDGE_ORIGIN: 'claude-ai-workbench-internal',
|
|
383
|
+
};
|
|
384
|
+
if (!profile || typeof profile !== 'object') return env;
|
|
385
|
+
|
|
386
|
+
const apiKey = profile.apiKey || profile.authToken || profile.anthropicAuthToken || '';
|
|
387
|
+
const baseUrl = profile.baseUrl || profile.apiBaseUrl || profile.anthropicBaseUrl || '';
|
|
388
|
+
// CC-Switch-style per-tier model fields. Each alias falls back to
|
|
389
|
+
// mainModel so an empty optional slot doesn't break the env.
|
|
390
|
+
const mainModel = profile.mainModel || profile.models && profile.models.default || profile.model || '';
|
|
391
|
+
const thinkingModel = profile.thinkingModel || mainModel;
|
|
392
|
+
const haikuModel = profile.haikuModel || mainModel;
|
|
393
|
+
const sonnetModel = profile.sonnetModel || mainModel;
|
|
394
|
+
const opusModel = profile.opusModel || mainModel;
|
|
395
|
+
const timeoutMs = profile.timeoutMs || process.env.API_TIMEOUT_MS || '';
|
|
396
|
+
|
|
397
|
+
if (apiKey) {
|
|
398
|
+
// Coding-plan keys (sk-cp-*) authenticate through ANTHROPIC_AUTH_TOKEN's
|
|
399
|
+
// Bearer header only; adding ANTHROPIC_API_KEY makes Claude Code send the
|
|
400
|
+
// x-api-key header too, which MiniMax-style gateways reject outright.
|
|
401
|
+
env.ANTHROPIC_AUTH_TOKEN = String(apiKey);
|
|
402
|
+
}
|
|
403
|
+
if (baseUrl) env.ANTHROPIC_BASE_URL = String(baseUrl);
|
|
404
|
+
if (mainModel) env.ANTHROPIC_MODEL = String(mainModel);
|
|
405
|
+
if (thinkingModel) env.ANTHROPIC_DEFAULT_THINKING_MODEL = String(thinkingModel);
|
|
406
|
+
if (haikuModel) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = String(haikuModel);
|
|
407
|
+
if (sonnetModel) env.ANTHROPIC_DEFAULT_SONNET_MODEL = String(sonnetModel);
|
|
408
|
+
if (opusModel) env.ANTHROPIC_DEFAULT_OPUS_MODEL = String(opusModel);
|
|
409
|
+
if (timeoutMs) env.API_TIMEOUT_MS = String(timeoutMs);
|
|
410
|
+
|
|
411
|
+
return env;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function profileMainModel(profile) {
|
|
415
|
+
if (!profile || typeof profile !== 'object') return '';
|
|
416
|
+
return String(profile.mainModel || profile.models && profile.models.default || profile.model || '').trim();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function applyAiProfileToSession(session, aiProfile) {
|
|
420
|
+
if (!session) {
|
|
421
|
+
return { resetConversation: false, model: session && session.model || null };
|
|
422
|
+
}
|
|
423
|
+
if (!aiProfile || typeof aiProfile !== 'object') {
|
|
424
|
+
session.claudeEnv = buildClaudeEnvFromProfile(null);
|
|
425
|
+
session.runner = 'sdk';
|
|
426
|
+
return { resetConversation: false, model: session.model || null };
|
|
427
|
+
}
|
|
428
|
+
const nextEnv = buildClaudeEnvFromProfile(aiProfile);
|
|
429
|
+
const nextModel = profileMainModel(aiProfile) || nextEnv.ANTHROPIC_MODEL || null;
|
|
430
|
+
|
|
431
|
+
session.aiProfileId = aiProfile.id || null;
|
|
432
|
+
session.selectedModel = nextModel;
|
|
433
|
+
session.claudeEnv = nextEnv;
|
|
434
|
+
if (nextModel) session.model = nextModel;
|
|
435
|
+
session.runner = 'sdk';
|
|
436
|
+
session.contextWindow = resolveContextWindow({
|
|
437
|
+
// The profile default (200000) must not shadow model auto-detection
|
|
438
|
+
// (e.g. MiniMax 1M); only an explicit non-default override wins.
|
|
439
|
+
profileContextWindow: aiProfile && Number(aiProfile.contextWindow) !== 200000 ? aiProfile.contextWindow : undefined,
|
|
440
|
+
model: session.model || profileMainModel(aiProfile),
|
|
441
|
+
});
|
|
442
|
+
// Model/provider changes keep claudeSessionId: env and model are per-query
|
|
443
|
+
// SDK options, so the conversation resumes under the new selection the
|
|
444
|
+
// same way the CLIs treat the model as a per-request parameter.
|
|
445
|
+
return { resetConversation: false, model: session.model || null };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Rebind a session to another AI profile/model (or back to the agent's own
|
|
449
|
+
// login with a null profile). The conversation continues: claudeSessionId is
|
|
450
|
+
// preserved and the next SDK query resumes it under the new env/model.
|
|
451
|
+
function updateSelection(sessionId, aiProfile, selectedModel) {
|
|
452
|
+
const session = getSession(sessionId);
|
|
453
|
+
if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
|
|
454
|
+
if (session.subprocess) {
|
|
455
|
+
throw Object.assign(new Error(`a subprocess is already running on session ${sessionId}`), { status: 409 });
|
|
456
|
+
}
|
|
457
|
+
const fromAiProfileId = session.aiProfileId || null;
|
|
458
|
+
const fromModel = session.model || null;
|
|
459
|
+
const profile = aiProfile && typeof aiProfile === 'object' ? aiProfile : null;
|
|
460
|
+
session.aiProfileId = profile && profile.id || null;
|
|
461
|
+
session.selectedModel = selectedModel || (profile && profileMainModel(profile)) || null;
|
|
462
|
+
// An agent-owned turn must not inherit the previous provider's model id;
|
|
463
|
+
// null lets the agent default apply until the next init re-syncs.
|
|
464
|
+
session.model = session.selectedModel || null;
|
|
465
|
+
session.claudeEnv = buildClaudeEnvFromProfile(profile);
|
|
466
|
+
session.runner = 'sdk';
|
|
467
|
+
session.contextWindow = resolveContextWindow({
|
|
468
|
+
profileContextWindow: profile && Number(profile.contextWindow) !== 200000 ? profile.contextWindow : undefined,
|
|
469
|
+
model: session.model || profileMainModel(profile),
|
|
470
|
+
});
|
|
471
|
+
applyConfiguredProcessEnv(session);
|
|
472
|
+
persistSession(session);
|
|
473
|
+
emit(session, {
|
|
474
|
+
type: 'claude/selection-changed',
|
|
475
|
+
fromAiProfileId,
|
|
476
|
+
toAiProfileId: session.aiProfileId,
|
|
477
|
+
fromModel,
|
|
478
|
+
toModel: session.selectedModel,
|
|
479
|
+
});
|
|
480
|
+
return sessionSummary(session);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Pull the per-call SDK overrides (systemPrompt) from an AI profile.
|
|
484
|
+
// The legacy temperature/maxTokens profile fields were removed in the
|
|
485
|
+
// CC-Switch migration — they were silently dropped by the CLI path and
|
|
486
|
+
// are no longer wired into the SDK path either. Callers that need to
|
|
487
|
+
// tune sampling pass temperature/maxTokens at the call site.
|
|
488
|
+
function buildSdkOverridesFromProfile(profile) {
|
|
489
|
+
if (!profile || typeof profile !== 'object') return {};
|
|
490
|
+
const out = {};
|
|
491
|
+
// Default to the hardened Claude Code system prompt when the profile
|
|
492
|
+
// doesn't override — that's the whole point of switching to SDK, and it
|
|
493
|
+
// matches what claudecodeui does. The user can override with an object
|
|
494
|
+
// (e.g. {type:'preset',preset:'claude_code'}) or a plain string.
|
|
495
|
+
if (profile.systemPrompt !== undefined) {
|
|
496
|
+
out.systemPrompt = profile.systemPrompt;
|
|
497
|
+
} else {
|
|
498
|
+
out.systemPrompt = { type: 'preset', preset: 'claude_code' };
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function getSession(sessionId) {
|
|
504
|
+
return sessions.get(sessionId) || restoreSessionFromDisk(sessionId);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function sessionSummary(s) {
|
|
508
|
+
return {
|
|
509
|
+
sessionId: s.sessionId,
|
|
510
|
+
projectSlug: s.projectSlug,
|
|
511
|
+
promptKey: s.promptKey,
|
|
512
|
+
runner: s.runner || 'cli',
|
|
513
|
+
state: s.state,
|
|
514
|
+
model: s.model,
|
|
515
|
+
aiProfileId: s.aiProfileId || null,
|
|
516
|
+
selectedModel: s.selectedModel || null,
|
|
517
|
+
claudeSessionId: s.claudeSessionId,
|
|
518
|
+
source: s.source || 'manual',
|
|
519
|
+
automation: !!s.automation,
|
|
520
|
+
automationRunId: s.automationRunId || null,
|
|
521
|
+
permissionMode: s.permissionMode || 'default',
|
|
522
|
+
startedAt: s.startedAt,
|
|
523
|
+
endedAt: s.endedAt,
|
|
524
|
+
exitCode: s.exitCode,
|
|
525
|
+
turns: s.turns,
|
|
526
|
+
pendingPermission: s.pendingPermission,
|
|
527
|
+
restored: !!s.restored,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function listSessions(filter = {}) {
|
|
532
|
+
const projectSlug = filter.projectSlug || null;
|
|
533
|
+
const byId = new Map();
|
|
534
|
+
for (const record of scanPersistedRecords(projectSlug)) {
|
|
535
|
+
byId.set(record.sessionId, sessionSummary({
|
|
536
|
+
...record,
|
|
537
|
+
listeners: new Set(),
|
|
538
|
+
outputBuffer: record.events || [],
|
|
539
|
+
subprocess: null,
|
|
540
|
+
pendingPermission: ['running', 'spawning'].includes(record.state) ? null : record.pendingPermission,
|
|
541
|
+
restored: true,
|
|
542
|
+
}));
|
|
543
|
+
}
|
|
544
|
+
for (const s of sessions.values()) {
|
|
545
|
+
if (projectSlug && s.projectSlug !== projectSlug) continue;
|
|
546
|
+
byId.set(s.sessionId, sessionSummary(s));
|
|
547
|
+
}
|
|
548
|
+
return [...byId.values()].sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function emit(session, event) {
|
|
552
|
+
session.outputBuffer.push(event);
|
|
553
|
+
if (session.outputBuffer.length > 5000) session.outputBuffer.shift();
|
|
554
|
+
for (const listener of session.listeners) {
|
|
555
|
+
try { listener(event); } catch { /* listener may be a dead SSE */ }
|
|
556
|
+
}
|
|
557
|
+
persistSession(session);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function setState(session, state, extra = {}) {
|
|
561
|
+
session.state = state;
|
|
562
|
+
if (state === 'ended' || state === 'failed' || state === 'aborted') {
|
|
563
|
+
session.endedAt = new Date().toISOString();
|
|
564
|
+
}
|
|
565
|
+
emit(session, { type: 'claude/state', state, ...extra });
|
|
566
|
+
_broadcastSessionChange(session, 'state');
|
|
567
|
+
if (TERMINAL_STATES.has(state) && sessionEndedCallbacks.size > 0) {
|
|
568
|
+
const snapshot = session;
|
|
569
|
+
setImmediate(() => {
|
|
570
|
+
for (const cb of sessionEndedCallbacks) {
|
|
571
|
+
try { cb(snapshot); } catch { /* subscriber errors must not corrupt session state */ }
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const ACTIVE_STATES = new Set(['spawning', 'running', 'pending-permission']);
|
|
578
|
+
const listSubscribers = new Set();
|
|
579
|
+
|
|
580
|
+
function _emitList(event) {
|
|
581
|
+
if (listSubscribers.size === 0) return;
|
|
582
|
+
for (const cb of listSubscribers) {
|
|
583
|
+
try { cb(event); } catch { /* dead subscriber; req.on('close') will clean up */ }
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function _broadcastSessionChange(session, kind) {
|
|
588
|
+
if (!session) return;
|
|
589
|
+
const summary = sessionSummary(session);
|
|
590
|
+
_emitList({
|
|
591
|
+
kind,
|
|
592
|
+
sessionId: summary.sessionId,
|
|
593
|
+
projectSlug: summary.projectSlug,
|
|
594
|
+
state: summary.state,
|
|
595
|
+
active: ACTIVE_STATES.has(summary.state),
|
|
596
|
+
restored: !!summary.restored,
|
|
597
|
+
startedAt: summary.startedAt,
|
|
598
|
+
endedAt: summary.endedAt,
|
|
599
|
+
exitCode: summary.exitCode,
|
|
600
|
+
promptKey: summary.promptKey,
|
|
601
|
+
source: summary.source,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function subscribeList(callback) {
|
|
606
|
+
if (typeof callback !== 'function') throw new Error('callback must be a function');
|
|
607
|
+
listSubscribers.add(callback);
|
|
608
|
+
return () => listSubscribers.delete(callback);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function newPermissionId() {
|
|
612
|
+
return 'perm-' + crypto.randomBytes(6).toString('hex');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function resolvePermission(sessionId, requestId, decision) {
|
|
616
|
+
const session = getSession(sessionId);
|
|
617
|
+
if (!session) throw new Error(`session not found: ${sessionId}`);
|
|
618
|
+
if (!session.pendingPermission || session.pendingPermission.requestId !== requestId) {
|
|
619
|
+
throw new Error('permission request not found or already resolved');
|
|
620
|
+
}
|
|
621
|
+
const allow = decision && decision.allow === true;
|
|
622
|
+
const resolvedAt = new Date().toISOString();
|
|
623
|
+
emit(session, {
|
|
624
|
+
type: 'claude/permission-resolved',
|
|
625
|
+
requestId,
|
|
626
|
+
allow,
|
|
627
|
+
message: decision && decision.message || '',
|
|
628
|
+
resolvedAt,
|
|
629
|
+
});
|
|
630
|
+
// SDK path: resolve the in-flight tool approval promise that was created
|
|
631
|
+
// inside runSdkTurn's canUseTool hook. The CLI `pendingTurn` mechanism
|
|
632
|
+
// is gone with the spawn path.
|
|
633
|
+
if (session.pendingToolApproval && session.pendingToolApproval.requestId === requestId) {
|
|
634
|
+
const approval = session.pendingToolApproval;
|
|
635
|
+
session.pendingToolApproval = null;
|
|
636
|
+
session.pendingPermission = null;
|
|
637
|
+
setState(session, 'running', { message: allow ? 'tool permission approved' : 'tool permission denied', requestId });
|
|
638
|
+
approval.resolve(allow
|
|
639
|
+
? { behavior: 'allow', updatedInput: approval.input, toolUseID: approval.toolUseID }
|
|
640
|
+
: { behavior: 'deny', message: decision && decision.message || 'User denied tool use', toolUseID: approval.toolUseID });
|
|
641
|
+
persistSession(session);
|
|
642
|
+
return { ok: true, started: true, toolPermission: true };
|
|
643
|
+
}
|
|
644
|
+
if (!allow) {
|
|
645
|
+
session.pendingPermission = { ...session.pendingPermission, status: 'denied', resolvedAt };
|
|
646
|
+
setState(session, 'idle', { message: 'permission denied', requestId });
|
|
647
|
+
session.pendingPermission = null;
|
|
648
|
+
persistSession(session);
|
|
649
|
+
return { ok: true, started: false };
|
|
650
|
+
}
|
|
651
|
+
// Legacy CLI fallback — kept so old session records on disk don't crash
|
|
652
|
+
// if they're restored after the migration. New sessions never set
|
|
653
|
+
// pendingTurn.
|
|
654
|
+
if (session.pendingTurn) {
|
|
655
|
+
const pending = session.pendingTurn;
|
|
656
|
+
session.pendingTurn = null;
|
|
657
|
+
setState(session, 'idle', { message: 'legacy CLI pending turn — please resend your message', requestId });
|
|
658
|
+
persistSession(session);
|
|
659
|
+
return { ok: true, started: false, legacy: true };
|
|
660
|
+
}
|
|
661
|
+
persistSession(session);
|
|
662
|
+
return { ok: true, started: true };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ---- NDJSON parsing (used by the SDK message stream) ----
|
|
666
|
+
function emitImageContent(session, block, name = 'image') {
|
|
667
|
+
if (!block || block.type !== 'image' || !block.source) return;
|
|
668
|
+
const source = block.source;
|
|
669
|
+
const mediaType = String(source.media_type || source.mediaType || 'image/png');
|
|
670
|
+
if (source.type === 'base64' && source.data) {
|
|
671
|
+
emit(session, { type: 'claude/image', name, mediaType, data: `data:${mediaType};base64,${source.data}` });
|
|
672
|
+
} else if (source.type === 'url' && source.url) {
|
|
673
|
+
emit(session, { type: 'claude/image', name, mediaType, url: source.url });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function emitNestedImages(session, content, name) {
|
|
678
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
679
|
+
for (const block of blocks) {
|
|
680
|
+
if (!block) continue;
|
|
681
|
+
if (block.type === 'image') emitImageContent(session, block, name);
|
|
682
|
+
else if (block.type === 'tool_result' && Array.isArray(block.content)) emitNestedImages(session, block.content, name || 'tool result');
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function toolResultText(content) {
|
|
687
|
+
if (typeof content === 'string') return content;
|
|
688
|
+
if (!Array.isArray(content)) return '';
|
|
689
|
+
return content.map(block => block && block.type === 'text' && typeof block.text === 'string' ? block.text : '').join('\n');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function handleNdjsonLine(session, line, toolInputBuffers) {
|
|
693
|
+
let parsed;
|
|
694
|
+
try {
|
|
695
|
+
parsed = JSON.parse(line);
|
|
696
|
+
} catch {
|
|
697
|
+
emit(session, { type: 'claude/raw', text: line });
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const t = parsed.type;
|
|
702
|
+
|
|
703
|
+
if (t === 'system' && parsed.subtype === 'init') {
|
|
704
|
+
if (parsed.session_id) session.claudeSessionId = parsed.session_id;
|
|
705
|
+
if (parsed.model) session.model = typeof parsed.model === 'string' ? parsed.model : (parsed.model.id || parsed.model.name || null);
|
|
706
|
+
emit(session, {
|
|
707
|
+
type: 'claude/init',
|
|
708
|
+
claudeSessionId: session.claudeSessionId,
|
|
709
|
+
model: session.model,
|
|
710
|
+
aiProfileId: session.aiProfileId || null,
|
|
711
|
+
tools: parsed.tools || [],
|
|
712
|
+
mcpServers: parsed.mcp_servers || [],
|
|
713
|
+
});
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (t === 'system' && parsed.subtype === 'local_command_output') {
|
|
718
|
+
emit(session, { type: 'claude/system-prompt', text: String(parsed.content || '') });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (t === 'stream_event' && parsed.event) {
|
|
723
|
+
const ev = parsed.event;
|
|
724
|
+
if (ev.type === 'message_start' && ev.message) {
|
|
725
|
+
emit(session, { type: 'claude/message-start', role: ev.message.role });
|
|
726
|
+
if (ev.message.usage) {
|
|
727
|
+
emit(session, { type: 'claude/usage', usage: ev.message.usage });
|
|
728
|
+
}
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (ev.type === 'content_block_start' && ev.content_block) {
|
|
732
|
+
const cb = ev.content_block;
|
|
733
|
+
if (cb.type === 'tool_use') {
|
|
734
|
+
// Initialize the per-block input accumulator; emit nothing yet (input is empty).
|
|
735
|
+
// The full input arrives via input_json_delta events and is emitted on content_block_stop.
|
|
736
|
+
if (cb.id) toolInputBuffers.set(cb.id, '');
|
|
737
|
+
emit(session, { type: 'claude/tool-use-start', id: cb.id, name: cb.name });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (cb.type === 'thinking') {
|
|
741
|
+
emit(session, { type: 'claude/thinking-start', id: cb.id });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (ev.type === 'content_block_delta' && ev.delta) {
|
|
747
|
+
const d = ev.delta;
|
|
748
|
+
if (d.type === 'text_delta') {
|
|
749
|
+
emit(session, { type: 'claude/text-delta', text: d.text });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (d.type === 'input_json_delta') {
|
|
753
|
+
// Accumulate into the tool-use block; also emit a low-level delta for live UIs.
|
|
754
|
+
if (ev.index != null && toolInputBuffers) {
|
|
755
|
+
// We don't know block id here; emit raw delta
|
|
756
|
+
}
|
|
757
|
+
emit(session, { type: 'claude/tool-input-delta', text: d.partial_json });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (d.type === 'thinking_delta') {
|
|
761
|
+
emit(session, { type: 'claude/thinking-delta', text: d.thinking });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (ev.type === 'content_block_stop') {
|
|
767
|
+
// Block ended — but we don't reliably know which block. Tool input emission is
|
|
768
|
+
// best-effort: the assistant message envelope at message_delta carries final tools.
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (ev.type === 'message_delta' && ev.delta) {
|
|
772
|
+
if (ev.delta.stop_reason) {
|
|
773
|
+
emit(session, { type: 'claude/message-stop', stopReason: ev.delta.stop_reason });
|
|
774
|
+
}
|
|
775
|
+
if (ev.usage) {
|
|
776
|
+
emit(session, { type: 'claude/usage', usage: ev.usage });
|
|
777
|
+
}
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (ev.type === 'message_stop') {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (t === 'user' || t === 'assistant') {
|
|
787
|
+
// On assistant turn boundaries, the full message (with completed tool_use blocks)
|
|
788
|
+
// appears here. Emit any tool-use blocks we haven't fully captured yet.
|
|
789
|
+
if (t === 'assistant' && parsed.message && Array.isArray(parsed.message.content)) {
|
|
790
|
+
for (const block of parsed.message.content) {
|
|
791
|
+
if (block && block.type === 'tool_use') {
|
|
792
|
+
emit(session, {
|
|
793
|
+
type: 'claude/tool-use',
|
|
794
|
+
id: block.id,
|
|
795
|
+
name: block.name,
|
|
796
|
+
input: block.input || {},
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
emitNestedImages(session, parsed.message.content, 'Claude image');
|
|
801
|
+
}
|
|
802
|
+
if (t === 'user' && parsed.message && Array.isArray(parsed.message.content)) {
|
|
803
|
+
for (const block of parsed.message.content) {
|
|
804
|
+
if (block && block.type === 'tool_result') {
|
|
805
|
+
// The SDK reports tool outcomes as user-message tool_result blocks;
|
|
806
|
+
// surface them as first-class tool-result events like the CLI drivers.
|
|
807
|
+
emit(session, {
|
|
808
|
+
type: 'claude/tool-result',
|
|
809
|
+
id: block.tool_use_id,
|
|
810
|
+
output: toolResultText(block.content).slice(0, 4000),
|
|
811
|
+
isError: block.is_error === true,
|
|
812
|
+
});
|
|
813
|
+
emitNestedImages(session, block.content, block.name || 'Tool image');
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (t === 'result') {
|
|
821
|
+
const event = {
|
|
822
|
+
type: 'claude/result',
|
|
823
|
+
subtype: parsed.subtype || null,
|
|
824
|
+
costUsd: parsed.total_cost_usd,
|
|
825
|
+
durationMs: parsed.duration_ms,
|
|
826
|
+
result: typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result),
|
|
827
|
+
isError: parsed.is_error === true,
|
|
828
|
+
usage: parsed.usage || null,
|
|
829
|
+
};
|
|
830
|
+
emit(session, event);
|
|
831
|
+
return event;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
emit(session, { type: 'claude/raw', json: parsed });
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ---- public API ----
|
|
838
|
+
|
|
839
|
+
// Start a Claude Code SDK session for the FIRST turn (initial analysis).
|
|
840
|
+
// The legacy `claude -p` subprocess path was removed — the SDK is the
|
|
841
|
+
// only supported mode and applies the hardened claude_code system prompt.
|
|
842
|
+
function startSession({ slug, projectPath, kbPath, promptKey, vars, aiProfile }) {
|
|
843
|
+
const rendered = renderPrompt(promptKey, vars || {});
|
|
844
|
+
if (!rendered) {
|
|
845
|
+
throw new Error(`unknown prompt key: ${promptKey}`);
|
|
846
|
+
}
|
|
847
|
+
const session = createSession({ projectSlug: slug, projectPath, kbPath, promptKey });
|
|
848
|
+
applyAiProfileToSession(session, aiProfile);
|
|
849
|
+
applyConfiguredProcessEnv(session);
|
|
850
|
+
_broadcastSessionChange(session, 'create');
|
|
851
|
+
session.permissionMode = normalizePermissionMode(rendered.permissionMode);
|
|
852
|
+
const sdkOverrides = buildSdkOverridesFromProfile(aiProfile);
|
|
853
|
+
emit(session, {
|
|
854
|
+
type: 'claude/system-prompt',
|
|
855
|
+
text: rendered.systemPrompt,
|
|
856
|
+
promptKey,
|
|
857
|
+
aiProfileId: session.aiProfileId,
|
|
858
|
+
});
|
|
859
|
+
emit(session, {
|
|
860
|
+
type: 'claude/user-prompt',
|
|
861
|
+
text: rendered.userPrompt,
|
|
862
|
+
promptKey,
|
|
863
|
+
isInitial: true,
|
|
864
|
+
});
|
|
865
|
+
const turnOpts = {
|
|
866
|
+
userPrompt: rendered.userPrompt,
|
|
867
|
+
systemPrompt: rendered.systemPrompt,
|
|
868
|
+
...sdkOverrides,
|
|
869
|
+
model: rendered.model,
|
|
870
|
+
permissionMode: session.permissionMode,
|
|
871
|
+
allowedTools: rendered.allowedTools,
|
|
872
|
+
cwd: session.kbPath,
|
|
873
|
+
env: session.claudeEnv,
|
|
874
|
+
resumeSessionId: null,
|
|
875
|
+
};
|
|
876
|
+
startSdkTurn(session, turnOpts, false);
|
|
877
|
+
return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk' };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const EFFORT_THINKING_BUDGETS = { low: 4096, medium: 8192, high: 16384, max: 31999 };
|
|
881
|
+
|
|
882
|
+
function normalizeEffortLevel(value) {
|
|
883
|
+
const level = String(value || '').trim().toLowerCase();
|
|
884
|
+
return Object.prototype.hasOwnProperty.call(EFFORT_THINKING_BUDGETS, level) ? level : null;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function startChatSession({ slug, projectPath, kbPath, aiProfile, permissionMode = 'default', effort }) {
|
|
888
|
+
if (!slug) throw new Error('slug required');
|
|
889
|
+
if (!projectPath) throw new Error('projectPath required');
|
|
890
|
+
const session = createSession({
|
|
891
|
+
projectSlug: slug,
|
|
892
|
+
projectPath,
|
|
893
|
+
kbPath,
|
|
894
|
+
promptKey: 'terminal-chat',
|
|
895
|
+
source: 'terminal',
|
|
896
|
+
});
|
|
897
|
+
applyAiProfileToSession(session, aiProfile);
|
|
898
|
+
applyConfiguredProcessEnv(session);
|
|
899
|
+
session.permissionMode = normalizePermissionMode(permissionMode);
|
|
900
|
+
session.effort = normalizeEffortLevel(effort);
|
|
901
|
+
emit(session, {
|
|
902
|
+
type: 'claude/session-ready',
|
|
903
|
+
promptKey: session.promptKey,
|
|
904
|
+
aiProfileId: session.aiProfileId,
|
|
905
|
+
permissionMode: session.permissionMode,
|
|
906
|
+
message: 'Claude terminal session is ready. Send a message to start the first turn.',
|
|
907
|
+
});
|
|
908
|
+
_broadcastSessionChange(session, 'create');
|
|
909
|
+
return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk' };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function startAutomationSession({
|
|
913
|
+
slug,
|
|
914
|
+
projectPath,
|
|
915
|
+
kbPath,
|
|
916
|
+
userPrompt,
|
|
917
|
+
systemPrompt,
|
|
918
|
+
aiProfile,
|
|
919
|
+
permissionMode = 'default',
|
|
920
|
+
allowedTools = ['Read', 'Grep', 'Glob'],
|
|
921
|
+
safetyPolicy = null,
|
|
922
|
+
metadata = {},
|
|
923
|
+
}) {
|
|
924
|
+
if (!slug) throw new Error('slug required');
|
|
925
|
+
if (typeof userPrompt !== 'string' || !userPrompt.trim()) throw new Error('userPrompt required');
|
|
926
|
+
const session = createSession({
|
|
927
|
+
projectSlug: slug,
|
|
928
|
+
projectPath,
|
|
929
|
+
kbPath,
|
|
930
|
+
promptKey: 'post-commit-automation',
|
|
931
|
+
source: metadata.source || 'git-hook',
|
|
932
|
+
metadata,
|
|
933
|
+
});
|
|
934
|
+
applyAiProfileToSession(session, aiProfile);
|
|
935
|
+
applyConfiguredProcessEnv(session);
|
|
936
|
+
_broadcastSessionChange(session, 'create');
|
|
937
|
+
session.permissionMode = normalizePermissionMode(permissionMode);
|
|
938
|
+
session.safetyPolicy = safetyPolicy || null;
|
|
939
|
+
session.automation = true;
|
|
940
|
+
session.automationRunId = metadata.automationRunId || null;
|
|
941
|
+
const sdkOverrides = buildSdkOverridesFromProfile(aiProfile);
|
|
942
|
+
const effectiveSystemPrompt = systemPrompt || sdkOverrides.systemPrompt || { type: 'preset', preset: 'claude_code' };
|
|
943
|
+
emit(session, {
|
|
944
|
+
type: 'claude/system-prompt',
|
|
945
|
+
text: effectiveSystemPrompt,
|
|
946
|
+
promptKey: session.promptKey,
|
|
947
|
+
aiProfileId: session.aiProfileId,
|
|
948
|
+
automation: true,
|
|
949
|
+
});
|
|
950
|
+
emit(session, {
|
|
951
|
+
type: 'claude/user-prompt',
|
|
952
|
+
text: userPrompt,
|
|
953
|
+
promptKey: session.promptKey,
|
|
954
|
+
isInitial: true,
|
|
955
|
+
automation: true,
|
|
956
|
+
});
|
|
957
|
+
if (fakeClaudeEnabled()) {
|
|
958
|
+
emit(session, { type: 'claude/result', result: 'fake automation completed', automation: true });
|
|
959
|
+
setState(session, 'idle', { exitCode: 0, message: 'fake automation complete' });
|
|
960
|
+
return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk', fake: true };
|
|
961
|
+
}
|
|
962
|
+
startSdkTurn(session, {
|
|
963
|
+
userPrompt,
|
|
964
|
+
systemPrompt: effectiveSystemPrompt,
|
|
965
|
+
model: null,
|
|
966
|
+
permissionMode: session.permissionMode,
|
|
967
|
+
allowedTools,
|
|
968
|
+
cwd: session.kbPath,
|
|
969
|
+
resumeSessionId: null,
|
|
970
|
+
env: session.claudeEnv,
|
|
971
|
+
safetyPolicy,
|
|
972
|
+
}, false);
|
|
973
|
+
return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk' };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Send a follow-up turn via the SDK. With a non-null claudeSessionId,
|
|
977
|
+
// runs as a resume. With a null claudeSessionId (e.g. after a kbPath
|
|
978
|
+
// change cleared the stale id), starts a fresh session while reusing
|
|
979
|
+
// the same session record.
|
|
980
|
+
async function sendInput(sessionId, text, aiProfile = null, opts = {}) {
|
|
981
|
+
const session = getSession(sessionId);
|
|
982
|
+
if (!session) throw new Error(`session not found: ${sessionId}`);
|
|
983
|
+
if (session.subprocess) {
|
|
984
|
+
throw new Error(`a subprocess is already running on session ${sessionId}`);
|
|
985
|
+
}
|
|
986
|
+
if (aiProfile) {
|
|
987
|
+
applyAiProfileToSession(session, aiProfile);
|
|
988
|
+
applyConfiguredProcessEnv(session);
|
|
989
|
+
}
|
|
990
|
+
const isFresh = !session.claudeSessionId;
|
|
991
|
+
emit(session, { type: 'claude/user-prompt', text, attachments: Array.isArray(opts.displayAttachments) ? opts.displayAttachments : [], isFollowUp: !isFresh, isFresh });
|
|
992
|
+
const sdkOverrides = buildSdkOverridesFromProfile(aiProfile);
|
|
993
|
+
session.permissionMode = normalizePermissionMode(opts.permissionMode || session.permissionMode || 'default');
|
|
994
|
+
const envModel = session.claudeEnv && session.claudeEnv.ANTHROPIC_MODEL
|
|
995
|
+
? String(session.claudeEnv.ANTHROPIC_MODEL)
|
|
996
|
+
: null;
|
|
997
|
+
const turnOpts = {
|
|
998
|
+
userPrompt: text,
|
|
999
|
+
model: envModel || session.model || null,
|
|
1000
|
+
permissionMode: session.permissionMode,
|
|
1001
|
+
allowedTools: opts.allowedTools || ['Read', 'Grep', 'Glob', 'Bash', 'Edit'],
|
|
1002
|
+
cwd: session.kbPath,
|
|
1003
|
+
resumeSessionId: session.claudeSessionId,
|
|
1004
|
+
systemPrompt: sdkOverrides.systemPrompt || null,
|
|
1005
|
+
env: session.claudeEnv,
|
|
1006
|
+
attachments: Array.isArray(opts.attachments) ? opts.attachments : [],
|
|
1007
|
+
effort: opts.effort || undefined, // per-message override beats the session-level default
|
|
1008
|
+
};
|
|
1009
|
+
startSdkTurn(session, turnOpts, !isFresh);
|
|
1010
|
+
return { started: true, pendingPermission: null, runner: 'sdk' };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Compute accumulated token usage for a session from its persisted
|
|
1014
|
+
// claude/usage events. Returns { used, total, hasUsage }.
|
|
1015
|
+
// Uses a max-not-sum strategy because the SDK emits usage on each
|
|
1016
|
+
// message_start (initial input_tokens) and each message_delta (output
|
|
1017
|
+
// deltas). Summing would double-count. hasUsage is false when the SDK
|
|
1018
|
+
// has not yet emitted any usage block — the caller renders "—" in that
|
|
1019
|
+
// case rather than guessing 0.0%.
|
|
1020
|
+
function getSessionTokenUsage(sessionId) {
|
|
1021
|
+
const session = getSession(sessionId);
|
|
1022
|
+
const fallbackTotal = Number(process.env.CLAUDE_CONTEXT_WINDOW) || 200000;
|
|
1023
|
+
if (!session) return { used: 0, total: fallbackTotal, hasUsage: false };
|
|
1024
|
+
const ctxWin = Number(session.contextWindow);
|
|
1025
|
+
const total = Number.isFinite(ctxWin) && ctxWin > 0 ? Math.floor(ctxWin) : fallbackTotal;
|
|
1026
|
+
let used = 0;
|
|
1027
|
+
let sawUsage = false;
|
|
1028
|
+
for (const ev of session.outputBuffer || []) {
|
|
1029
|
+
if (ev && ev.type === 'claude/usage' && ev.usage) {
|
|
1030
|
+
sawUsage = true;
|
|
1031
|
+
const turn = Number(ev.usage.input_tokens || 0)
|
|
1032
|
+
+ Number(ev.usage.output_tokens || 0)
|
|
1033
|
+
+ Number(ev.usage.cache_creation_input_tokens || 0)
|
|
1034
|
+
+ Number(ev.usage.cache_read_input_tokens || 0);
|
|
1035
|
+
if (turn > used) used = turn;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return { used, total, hasUsage: sawUsage };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const isShellShim = (file) => /\.(cmd|bat|ps1)$/i.test(String(file || ''));
|
|
1042
|
+
const sdkSpawnable = (file) => file && !isShellShim(file);
|
|
1043
|
+
|
|
1044
|
+
// Newer claude-code releases keep moving their entry file (cli.js ->
|
|
1045
|
+
// bin/claude.exe), so hardcoded layout guesses rot. The installed package's
|
|
1046
|
+
// own package.json bin field is the authoritative entry, whatever it is.
|
|
1047
|
+
function readClaudePackageEntry(packageDir) {
|
|
1048
|
+
try {
|
|
1049
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
|
|
1050
|
+
const bin = pkg && pkg.bin;
|
|
1051
|
+
const rel = typeof bin === 'string'
|
|
1052
|
+
? bin
|
|
1053
|
+
: (bin && typeof bin === 'object' && typeof bin.claude === 'string' ? bin.claude : null);
|
|
1054
|
+
if (!rel) return null;
|
|
1055
|
+
const entry = path.join(packageDir, ...String(rel).split(/[\\/]+/).filter(Boolean));
|
|
1056
|
+
return fs.existsSync(entry) ? entry : null;
|
|
1057
|
+
} catch {
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Resolves the real binary an npm/cmd/PowerShell shim wraps. npm generates
|
|
1063
|
+
// these shims in the global prefix at install time, so they stay correct for
|
|
1064
|
+
// any install root — mine them instead of guessing layouts. Never returns
|
|
1065
|
+
// the shim itself: the SDK spawns with shell:false.
|
|
1066
|
+
function resolveClaudeShimTarget(shimPath) {
|
|
1067
|
+
const dir = path.dirname(shimPath);
|
|
1068
|
+
const packageDir = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code');
|
|
1069
|
+
const direct = path.join(packageDir, 'bin', 'claude.exe');
|
|
1070
|
+
if (fs.existsSync(direct)) return direct;
|
|
1071
|
+
const declared = readClaudePackageEntry(packageDir);
|
|
1072
|
+
if (declared) return declared;
|
|
1073
|
+
let content;
|
|
1074
|
+
try {
|
|
1075
|
+
content = fs.readFileSync(shimPath, 'utf8');
|
|
1076
|
+
} catch {
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
const embedded = /["']([^"'\r\n]+claude\.exe)["']/gi;
|
|
1080
|
+
for (const match of content.matchAll(embedded)) {
|
|
1081
|
+
const raw = match[1]
|
|
1082
|
+
.replace(/^\$basedir[\\/]*/i, '')
|
|
1083
|
+
.replace(/^%~dp0%?[\\/]*/i, '')
|
|
1084
|
+
.replace(/^%dp0%[\\/]*/i, '');
|
|
1085
|
+
const candidate = path.isAbsolute(raw) ? raw : path.resolve(dir, raw);
|
|
1086
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
1087
|
+
}
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// "where claude" reports exactly what a shell would execute, in PATH order —
|
|
1092
|
+
// ground truth for install roots none of the layout guesses above covered.
|
|
1093
|
+
function listClaudePathHits() {
|
|
1094
|
+
if (process.platform !== 'win32') return [];
|
|
1095
|
+
const whereExe = process.env.WINDIR
|
|
1096
|
+
? path.join(process.env.WINDIR, 'System32', 'where.exe')
|
|
1097
|
+
: 'where.exe';
|
|
1098
|
+
try {
|
|
1099
|
+
const stdout = execFileSync(whereExe, ['claude'], {
|
|
1100
|
+
encoding: 'utf8',
|
|
1101
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1102
|
+
windowsHide: true,
|
|
1103
|
+
timeout: 5000,
|
|
1104
|
+
});
|
|
1105
|
+
return String(stdout).split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
|
|
1106
|
+
} catch {
|
|
1107
|
+
return [];
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function findClaudeExecutableForSdk() {
|
|
1112
|
+
if (process.env.CLAUDE_CODE_EXECPATH && sdkSpawnable(process.env.CLAUDE_CODE_EXECPATH)) {
|
|
1113
|
+
return { cmd: process.env.CLAUDE_CODE_EXECPATH, shell: false };
|
|
1114
|
+
}
|
|
1115
|
+
if (process.platform === 'win32') {
|
|
1116
|
+
const candidates = [];
|
|
1117
|
+
const seen = new Set();
|
|
1118
|
+
const push = (file) => {
|
|
1119
|
+
if (file && !seen.has(file)) {
|
|
1120
|
+
seen.add(file);
|
|
1121
|
+
candidates.push(file);
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
// npm global install: known layout first, then the entry the installed
|
|
1125
|
+
// package itself declares, then the pre-2.x literal.
|
|
1126
|
+
const npmRoot = process.env.APPDATA && path.join(process.env.APPDATA, 'npm');
|
|
1127
|
+
const npmPackageDir = npmRoot && path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code');
|
|
1128
|
+
if (npmPackageDir) {
|
|
1129
|
+
push(path.join(npmPackageDir, 'bin', 'claude.exe'));
|
|
1130
|
+
push(readClaudePackageEntry(npmPackageDir));
|
|
1131
|
+
push(path.join(npmPackageDir, 'cli.js'));
|
|
1132
|
+
}
|
|
1133
|
+
// Official native installer layout (PowerShell/CMD installers).
|
|
1134
|
+
if (process.env.USERPROFILE) {
|
|
1135
|
+
push(path.join(process.env.USERPROFILE, '.local', 'bin', 'claude.exe'));
|
|
1136
|
+
}
|
|
1137
|
+
if (process.env.LOCALAPPDATA) {
|
|
1138
|
+
push(
|
|
1139
|
+
path.join(process.env.LOCALAPPDATA, 'Programs', 'claude-code', 'claude.exe'),
|
|
1140
|
+
path.join(process.env.LOCALAPPDATA, 'claude-code', 'claude.exe'),
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
// Any claude.exe reachable from PATH (covers custom install prefixes);
|
|
1144
|
+
// .cmd/.bat shims stay excluded — the SDK spawns with shell:false.
|
|
1145
|
+
for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
|
|
1146
|
+
if (dir) push(path.join(dir, 'claude.exe'));
|
|
1147
|
+
}
|
|
1148
|
+
for (const candidate of candidates) {
|
|
1149
|
+
if (fs.existsSync(candidate) && sdkSpawnable(candidate)) return { cmd: candidate, shell: false };
|
|
1150
|
+
}
|
|
1151
|
+
// Nothing guessed panned out: fall back to the shims a shell would
|
|
1152
|
+
// actually run, resolving each to the binary it wraps.
|
|
1153
|
+
const pathHits = listClaudePathHits();
|
|
1154
|
+
for (const hit of pathHits) {
|
|
1155
|
+
if (/\.exe$/i.test(hit) && fs.existsSync(hit) && sdkSpawnable(hit)) return { cmd: hit, shell: false };
|
|
1156
|
+
}
|
|
1157
|
+
for (const hit of pathHits) {
|
|
1158
|
+
const target = resolveClaudeShimTarget(hit);
|
|
1159
|
+
if (target && fs.existsSync(target) && sdkSpawnable(target)) return { cmd: target, shell: false };
|
|
1160
|
+
}
|
|
1161
|
+
return { cmd: null, shell: false, reason: 'no SDK-compatible Claude Code executable found' };
|
|
1162
|
+
}
|
|
1163
|
+
return { cmd: 'claude', shell: false };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// A missing Claude Code executable used to surface as an opaque exit code -1
|
|
1167
|
+
// from inside the SDK spawn. Fail fast with actionable instructions instead.
|
|
1168
|
+
function ensureClaudeExecutable() {
|
|
1169
|
+
if (process.platform !== 'win32') return;
|
|
1170
|
+
if (findClaudeExecutableForSdk().cmd) return;
|
|
1171
|
+
throw new Error(
|
|
1172
|
+
'Claude Code executable not found. Install it first (npm install -g @anthropic-ai/claude-code, or the official installer), '
|
|
1173
|
+
+ 'or set the CLAUDE_CODE_EXECPATH environment variable to claude.exe.',
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function createSdkPrompt(text, attachments) {
|
|
1178
|
+
const items = Array.isArray(attachments) ? attachments : [];
|
|
1179
|
+
const images = items.filter(item => item && item.data && /^image\//.test(item.mediaType));
|
|
1180
|
+
const textFiles = items.filter(item => item && item.data && !/^image\//.test(item.mediaType));
|
|
1181
|
+
const prompt = [String(text || '').trim(), ...textFiles.map(item => {
|
|
1182
|
+
const name = String(item.name || 'attachment.txt').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
1183
|
+
const content = Buffer.from(item.data, 'base64').toString('utf8').replace(/<\/attachment>/gi, '</attachment>');
|
|
1184
|
+
return `<attachment name="${name}">\n${content}\n</attachment>`;
|
|
1185
|
+
})].filter(Boolean).join('\n\n');
|
|
1186
|
+
const content = images.map(item => ({
|
|
1187
|
+
type: 'image',
|
|
1188
|
+
source: { type: 'base64', media_type: item.mediaType, data: item.data },
|
|
1189
|
+
}));
|
|
1190
|
+
if (prompt) content.push({ type: 'text', text: prompt });
|
|
1191
|
+
return (async function* () {
|
|
1192
|
+
yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null };
|
|
1193
|
+
})();
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function listSupportedCommands(sessionId, aiProfile = null) {
|
|
1197
|
+
const session = getSession(sessionId);
|
|
1198
|
+
if (!session) throw new Error(`session not found: ${sessionId}`);
|
|
1199
|
+
if (Array.isArray(session.commands) && session.commands.length) return session.commands;
|
|
1200
|
+
if (session.commandDiscovery) return session.commandDiscovery;
|
|
1201
|
+
session.commandDiscovery = (async () => {
|
|
1202
|
+
ensureClaudeExecutable();
|
|
1203
|
+
if (aiProfile) {
|
|
1204
|
+
applyAiProfileToSession(session, aiProfile);
|
|
1205
|
+
applyConfiguredProcessEnv(session);
|
|
1206
|
+
}
|
|
1207
|
+
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
|
1208
|
+
const claudeBin = findClaudeExecutableForSdk();
|
|
1209
|
+
const envModel = session.claudeEnv && session.claudeEnv.ANTHROPIC_MODEL ? String(session.claudeEnv.ANTHROPIC_MODEL) : null;
|
|
1210
|
+
const overrides = buildSdkOverridesFromProfile(aiProfile);
|
|
1211
|
+
const sdkOptions = {
|
|
1212
|
+
cwd: session.kbPath,
|
|
1213
|
+
env: { ...process.env, ...(session.claudeEnv || {}), FORCE_COLOR: '0', NO_COLOR: '1' },
|
|
1214
|
+
model: envModel || session.model || null,
|
|
1215
|
+
tools: { type: 'preset', preset: 'claude_code' },
|
|
1216
|
+
settingSources: ['project', 'user', 'local'],
|
|
1217
|
+
systemPrompt: overrides.systemPrompt || { type: 'preset', preset: 'claude_code' },
|
|
1218
|
+
permissionMode: normalizePermissionMode(session.permissionMode || 'default'),
|
|
1219
|
+
};
|
|
1220
|
+
if (claudeBin.cmd) sdkOptions.pathToClaudeCodeExecutable = claudeBin.cmd;
|
|
1221
|
+
const idle = (async function* () { await new Promise(() => {}); })();
|
|
1222
|
+
const queryInstance = query({ prompt: idle, options: sdkOptions });
|
|
1223
|
+
let timer;
|
|
1224
|
+
try {
|
|
1225
|
+
const commands = await Promise.race([
|
|
1226
|
+
queryInstance.supportedCommands(),
|
|
1227
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Claude Code command discovery timed out')), 15000); }),
|
|
1228
|
+
]);
|
|
1229
|
+
session.commands = (Array.isArray(commands) ? commands : []).map(item => ({
|
|
1230
|
+
name: String(item.name || '').replace(/^\//, ''), description: String(item.description || ''),
|
|
1231
|
+
argumentHint: String(item.argumentHint || ''),
|
|
1232
|
+
aliases: Array.isArray(item.aliases) ? item.aliases.map(value => String(value).replace(/^\//, '')) : [], source: 'claude-code',
|
|
1233
|
+
})).filter(item => item.name);
|
|
1234
|
+
emit(session, { type: 'claude/commands', commands: session.commands });
|
|
1235
|
+
return session.commands;
|
|
1236
|
+
} finally {
|
|
1237
|
+
if (timer) clearTimeout(timer);
|
|
1238
|
+
try { queryInstance.close(); } catch {}
|
|
1239
|
+
session.commandDiscovery = null;
|
|
1240
|
+
}
|
|
1241
|
+
})();
|
|
1242
|
+
return session.commandDiscovery;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function runSdkTurn(session, opts, isResume) {
|
|
1246
|
+
ensureClaudeExecutable();
|
|
1247
|
+
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
|
1248
|
+
const toolInputBuffers = new Map();
|
|
1249
|
+
const sdkPermissionMode = normalizePermissionMode(opts.permissionMode || session.permissionMode || 'default');
|
|
1250
|
+
session.permissionMode = sdkPermissionMode;
|
|
1251
|
+
const effortLevel = normalizeEffortLevel(opts.effort || session.effort);
|
|
1252
|
+
const allowedTools = Array.isArray(opts.allowedTools) ? opts.allowedTools : [];
|
|
1253
|
+
const claudeBin = findClaudeExecutableForSdk();
|
|
1254
|
+
// Keep the CLI's own stderr tail: when the process dies with an opaque
|
|
1255
|
+
// "exited with code 1", these lines carry the actual reason (auth failure,
|
|
1256
|
+
// network block, config error) and belong in the surfaced message.
|
|
1257
|
+
const stderrTail = [];
|
|
1258
|
+
const sdkOptions = {
|
|
1259
|
+
cwd: opts.cwd,
|
|
1260
|
+
env: { ...process.env, ...(opts.env || {}), FORCE_COLOR: '0', NO_COLOR: '1' },
|
|
1261
|
+
stderr: (text) => {
|
|
1262
|
+
const line = String(text || '').trim();
|
|
1263
|
+
if (line) stderrTail.push(line);
|
|
1264
|
+
if (stderrTail.length > 30) stderrTail.shift();
|
|
1265
|
+
},
|
|
1266
|
+
model: opts.model,
|
|
1267
|
+
tools: { type: 'preset', preset: 'claude_code' },
|
|
1268
|
+
allowedTools,
|
|
1269
|
+
disallowedTools: [],
|
|
1270
|
+
settingSources: ['project', 'user', 'local'],
|
|
1271
|
+
includePartialMessages: true,
|
|
1272
|
+
systemPrompt: opts.systemPrompt || { type: 'preset', preset: 'claude_code' },
|
|
1273
|
+
permissionMode: sdkPermissionMode,
|
|
1274
|
+
...(effortLevel ? { maxThinkingTokens: EFFORT_THINKING_BUDGETS[effortLevel] } : {}),
|
|
1275
|
+
allowDangerouslySkipPermissions: sdkPermissionMode === 'bypassPermissions' && !opts.safetyPolicy,
|
|
1276
|
+
canUseTool: async (toolName, input, context = {}) => {
|
|
1277
|
+
const toolUseID = context.toolUseID || null;
|
|
1278
|
+
if (opts.safetyPolicy) {
|
|
1279
|
+
const decision = evaluateAutomationToolUse(opts.safetyPolicy, toolName, input);
|
|
1280
|
+
emit(session, {
|
|
1281
|
+
type: 'claude/tool-policy',
|
|
1282
|
+
toolName,
|
|
1283
|
+
decision: decision.behavior,
|
|
1284
|
+
reason: decision.reason,
|
|
1285
|
+
automation: !!session.automation,
|
|
1286
|
+
});
|
|
1287
|
+
if (decision.behavior === 'allow') {
|
|
1288
|
+
return { behavior: 'allow', updatedInput: input, toolUseID };
|
|
1289
|
+
}
|
|
1290
|
+
return { behavior: 'deny', message: decision.reason || 'Denied by automation safety policy', toolUseID };
|
|
1291
|
+
}
|
|
1292
|
+
if (sdkPermissionMode === 'bypassPermissions') {
|
|
1293
|
+
return { behavior: 'allow', updatedInput: input, toolUseID };
|
|
1294
|
+
}
|
|
1295
|
+
if (sdkPermissionMode === 'acceptEdits' && ['Edit', 'Write', 'MultiEdit'].includes(toolName)) {
|
|
1296
|
+
return { behavior: 'allow', updatedInput: input, toolUseID };
|
|
1297
|
+
}
|
|
1298
|
+
if (sdkPermissionMode === 'plan' && ['Edit', 'Write', 'MultiEdit', 'Bash'].includes(toolName)) {
|
|
1299
|
+
return { behavior: 'deny', message: 'Plan mode does not allow file edits or shell commands', toolUseID };
|
|
1300
|
+
}
|
|
1301
|
+
const requestId = newPermissionId();
|
|
1302
|
+
const summary = {
|
|
1303
|
+
turnKind: 'tool-use',
|
|
1304
|
+
isResume,
|
|
1305
|
+
cwd: opts.cwd || '',
|
|
1306
|
+
model: opts.model || null,
|
|
1307
|
+
permissionMode: sdkOptions.permissionMode,
|
|
1308
|
+
allowedTools: [toolName],
|
|
1309
|
+
toolName,
|
|
1310
|
+
toolUseID,
|
|
1311
|
+
title: context.title || context.displayName || toolName,
|
|
1312
|
+
description: context.description || context.decisionReason || '',
|
|
1313
|
+
promptPreview: JSON.stringify(input || {}).slice(0, 500),
|
|
1314
|
+
};
|
|
1315
|
+
session.pendingPermission = {
|
|
1316
|
+
requestId,
|
|
1317
|
+
status: 'pending',
|
|
1318
|
+
createdAt: new Date().toISOString(),
|
|
1319
|
+
summary,
|
|
1320
|
+
};
|
|
1321
|
+
setState(session, 'pending-permission', { requestId, toolName, toolUseID: context.toolUseID || null });
|
|
1322
|
+
emit(session, { type: 'claude/permission-request', ...session.pendingPermission });
|
|
1323
|
+
return await new Promise((resolve) => {
|
|
1324
|
+
const cancel = () => {
|
|
1325
|
+
if (session.pendingToolApproval && session.pendingToolApproval.requestId === requestId) {
|
|
1326
|
+
session.pendingToolApproval = null;
|
|
1327
|
+
session.pendingPermission = null;
|
|
1328
|
+
resolve({ behavior: 'deny', message: 'Permission request cancelled', toolUseID });
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
if (context.signal) {
|
|
1332
|
+
if (context.signal.aborted) return cancel();
|
|
1333
|
+
context.signal.addEventListener('abort', cancel, { once: true });
|
|
1334
|
+
}
|
|
1335
|
+
session.pendingToolApproval = {
|
|
1336
|
+
requestId,
|
|
1337
|
+
resolve,
|
|
1338
|
+
input,
|
|
1339
|
+
toolUseID,
|
|
1340
|
+
};
|
|
1341
|
+
});
|
|
1342
|
+
},
|
|
1343
|
+
};
|
|
1344
|
+
if (claudeBin.cmd) sdkOptions.pathToClaudeCodeExecutable = claudeBin.cmd;
|
|
1345
|
+
if (opts.resumeSessionId) sdkOptions.resume = opts.resumeSessionId;
|
|
1346
|
+
|
|
1347
|
+
try {
|
|
1348
|
+
const retries = maxSdkRetries();
|
|
1349
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
1350
|
+
let queryInstance;
|
|
1351
|
+
const attemptToolInputBuffers = attempt === 0 ? toolInputBuffers : new Map();
|
|
1352
|
+
try {
|
|
1353
|
+
queryInstance = query({
|
|
1354
|
+
prompt: createSdkPrompt(opts.userPrompt, opts.attachments),
|
|
1355
|
+
options: sdkOptions,
|
|
1356
|
+
});
|
|
1357
|
+
if ((!session.commands || !session.commands.length) && typeof queryInstance.supportedCommands === 'function') {
|
|
1358
|
+
queryInstance.supportedCommands().then(commands => {
|
|
1359
|
+
session.commands = (Array.isArray(commands) ? commands : []).map(item => ({
|
|
1360
|
+
name: String(item.name || '').replace(/^\//, ''), description: String(item.description || ''),
|
|
1361
|
+
argumentHint: String(item.argumentHint || ''),
|
|
1362
|
+
aliases: Array.isArray(item.aliases) ? item.aliases.map(value => String(value).replace(/^\//, '')) : [], source: 'claude-code',
|
|
1363
|
+
})).filter(item => item.name);
|
|
1364
|
+
emit(session, { type: 'claude/commands', commands: session.commands });
|
|
1365
|
+
}).catch(() => {});
|
|
1366
|
+
}
|
|
1367
|
+
session.subprocess = { kill: () => queryInstance.close && queryInstance.close() };
|
|
1368
|
+
let errorResult = null;
|
|
1369
|
+
for await (const message of queryInstance) {
|
|
1370
|
+
if (message.session_id) session.claudeSessionId = message.session_id;
|
|
1371
|
+
const event = handleNdjsonLine(session, JSON.stringify(message), attemptToolInputBuffers);
|
|
1372
|
+
if (event && event.type === 'claude/result' && event.isError) {
|
|
1373
|
+
errorResult = event.result || 'Claude Code returned an error result';
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
session.subprocess = null;
|
|
1377
|
+
if (errorResult) {
|
|
1378
|
+
throw new Error(`Claude Code returned an error result: ${errorResult}`);
|
|
1379
|
+
}
|
|
1380
|
+
if (session.state !== 'aborted' && session.state !== 'failed') {
|
|
1381
|
+
setState(session, 'idle', { exitCode: 0, message: isResume ? 'follow-up complete' : 'turn complete, awaiting input or new analysis' });
|
|
1382
|
+
emit(session, { type: 'claude/turn-end', exitCode: 0 });
|
|
1383
|
+
}
|
|
1384
|
+
return;
|
|
1385
|
+
} catch (e) {
|
|
1386
|
+
session.subprocess = null;
|
|
1387
|
+
if (session.state === 'aborted') return;
|
|
1388
|
+
if (attempt < retries && isTransientClaudeError(e)) {
|
|
1389
|
+
const delayMs = sdkRetryDelayMs(attempt + 1);
|
|
1390
|
+
const message = normalizeErrorMessage(e);
|
|
1391
|
+
emit(session, {
|
|
1392
|
+
type: 'claude/retry',
|
|
1393
|
+
attempt: attempt + 1,
|
|
1394
|
+
maxRetries: retries,
|
|
1395
|
+
delayMs,
|
|
1396
|
+
message,
|
|
1397
|
+
});
|
|
1398
|
+
setState(session, 'running', {
|
|
1399
|
+
turn: session.turns,
|
|
1400
|
+
runner: 'sdk',
|
|
1401
|
+
retryAttempt: attempt + 1,
|
|
1402
|
+
maxRetries: retries,
|
|
1403
|
+
});
|
|
1404
|
+
await sleep(delayMs);
|
|
1405
|
+
if (session.state === 'aborted') return;
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
throw e;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
} catch (e) {
|
|
1412
|
+
let message = String(e && e.message || e);
|
|
1413
|
+
// Opaque process deaths ("exited with code 1") get the CLI's own stderr
|
|
1414
|
+
// tail plus an actionable hint when the cause is clearly auth/network.
|
|
1415
|
+
if (/exited with code|Failed to authenticate|Request not allowed|401|403/i.test(message)) {
|
|
1416
|
+
const tail = stderrTail.filter(line => !/^\s*$/.test(line)).slice(-4).join(' | ');
|
|
1417
|
+
if (tail) message = `${message} — CLI 输出: ${tail.slice(0, 300)}`;
|
|
1418
|
+
if (!session.aiProfileId && /authenticate|401|403|Request not allowed/i.test(message)) {
|
|
1419
|
+
message += '。当前为 Agent 自身登录模式:本机 Claude 登录无法访问 Anthropic API(常见于网络/区域限制)。请在模型菜单改选一家供应商配置,或在 CLI 中配置可用的网络与登录。';
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
session.error = message;
|
|
1423
|
+
emit(session, { type: 'claude/error', message });
|
|
1424
|
+
setState(session, 'failed', { error: message });
|
|
1425
|
+
} finally {
|
|
1426
|
+
session.subprocess = null;
|
|
1427
|
+
session.pendingToolApproval = null;
|
|
1428
|
+
session.pendingPermission = null;
|
|
1429
|
+
persistSession(session);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function startSdkTurn(session, opts, isResume) {
|
|
1434
|
+
session.pendingPermission = null;
|
|
1435
|
+
session.pendingTurn = null;
|
|
1436
|
+
setState(session, 'spawning', { turn: session.turns + 1, runner: 'sdk' });
|
|
1437
|
+
session.turns += 1;
|
|
1438
|
+
setState(session, 'running', { turn: session.turns, runner: 'sdk' });
|
|
1439
|
+
runSdkTurn(session, opts, isResume).catch(e => {
|
|
1440
|
+
session.error = e.message;
|
|
1441
|
+
emit(session, { type: 'claude/error', message: e.message });
|
|
1442
|
+
setState(session, 'failed', { error: e.message });
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function abort(sessionId) {
|
|
1447
|
+
const session = getSession(sessionId);
|
|
1448
|
+
if (!session) throw new Error(`session not found: ${sessionId}`);
|
|
1449
|
+
if (session.subprocess) {
|
|
1450
|
+
try {
|
|
1451
|
+
session.subprocess.kill();
|
|
1452
|
+
} catch {}
|
|
1453
|
+
setState(session, 'aborted', { reason: 'user-abort' });
|
|
1454
|
+
emit(session, { type: 'claude/aborted', reason: 'user-abort' });
|
|
1455
|
+
} else {
|
|
1456
|
+
session.pendingPermission = null;
|
|
1457
|
+
session.pendingTurn = null;
|
|
1458
|
+
setState(session, 'aborted', { reason: 'no-active-subprocess' });
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// Subscribe to live events. Callback receives each event as it arrives.
|
|
1463
|
+
// Late subscribers also get replayed outputBuffer.
|
|
1464
|
+
function subscribe(sessionId, onEvent) {
|
|
1465
|
+
const session = getSession(sessionId);
|
|
1466
|
+
if (!session) throw new Error(`session not found: ${sessionId}`);
|
|
1467
|
+
// replay buffer first
|
|
1468
|
+
for (const ev of session.outputBuffer) {
|
|
1469
|
+
try { onEvent(ev); } catch {}
|
|
1470
|
+
}
|
|
1471
|
+
session.listeners.add(onEvent);
|
|
1472
|
+
return () => {
|
|
1473
|
+
session.listeners.delete(onEvent);
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function getState(sessionId) {
|
|
1478
|
+
const s = getSession(sessionId);
|
|
1479
|
+
if (!s) return null;
|
|
1480
|
+
return {
|
|
1481
|
+
sessionId: s.sessionId,
|
|
1482
|
+
projectSlug: s.projectSlug,
|
|
1483
|
+
promptKey: s.promptKey,
|
|
1484
|
+
runner: s.runner || 'cli',
|
|
1485
|
+
state: s.state,
|
|
1486
|
+
model: s.model,
|
|
1487
|
+
aiProfileId: s.aiProfileId || null,
|
|
1488
|
+
claudeSessionId: s.claudeSessionId,
|
|
1489
|
+
source: s.source || 'manual',
|
|
1490
|
+
automation: !!s.automation,
|
|
1491
|
+
automationRunId: s.automationRunId || null,
|
|
1492
|
+
permissionMode: s.permissionMode || 'default',
|
|
1493
|
+
startedAt: s.startedAt,
|
|
1494
|
+
endedAt: s.endedAt,
|
|
1495
|
+
exitCode: s.exitCode,
|
|
1496
|
+
turns: s.turns,
|
|
1497
|
+
error: s.error,
|
|
1498
|
+
listenerCount: s.listeners.size,
|
|
1499
|
+
bufferedEvents: s.outputBuffer.length,
|
|
1500
|
+
pendingPermission: s.pendingPermission,
|
|
1501
|
+
restored: !!s.restored,
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function deleteSession(sessionId) {
|
|
1506
|
+
const s = sessions.get(sessionId);
|
|
1507
|
+
if (!s) return false;
|
|
1508
|
+
if (s.subprocess) {
|
|
1509
|
+
try { s.subprocess.kill(); } catch {}
|
|
1510
|
+
}
|
|
1511
|
+
sessions.delete(sessionId);
|
|
1512
|
+
try {
|
|
1513
|
+
const file = sessionRecordPath(s);
|
|
1514
|
+
if (file) fs.rmSync(file, { force: true });
|
|
1515
|
+
} catch {}
|
|
1516
|
+
return true;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Periodically clean up old sessions with no listeners (memory hygiene)
|
|
1520
|
+
function pruneOldSessions(maxAgeMs = 30 * 60 * 1000) {
|
|
1521
|
+
const now = Date.now();
|
|
1522
|
+
for (const [id, s] of sessions) {
|
|
1523
|
+
if (s.listeners.size === 0 && s.subprocess === null && s.endedAt) {
|
|
1524
|
+
const age = now - new Date(s.endedAt).getTime();
|
|
1525
|
+
if (age > maxAgeMs) sessions.delete(id);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
setInterval(pruneOldSessions, 5 * 60 * 1000).unref();
|
|
1531
|
+
|
|
1532
|
+
// Sweep sessions that are still in an ACTIVE state but whose subprocess is
|
|
1533
|
+
// gone. Without this, three known failure modes strand sessions in
|
|
1534
|
+
// "running" forever:
|
|
1535
|
+
// 1. dashboard restart kills the SDK parent query → claude.exe is orphaned
|
|
1536
|
+
// and no one calls setState(failed)
|
|
1537
|
+
// 2. SIGKILL of the dashboard (or OOM) before the SDK's for-await loop
|
|
1538
|
+
// emitted its terminal message
|
|
1539
|
+
// 3. child Claude.exe dies under a permission denial or API outage but the
|
|
1540
|
+
// parent query's error path never reaches setState(failed) (e.g. IPC
|
|
1541
|
+
// connection drops silently after the dashboard process exits)
|
|
1542
|
+
//
|
|
1543
|
+
// Symptom in the UI: a project keeps showing the pulsing "running" badge,
|
|
1544
|
+
// and the embedded terminal view stays empty because no real subprocess is
|
|
1545
|
+
// streaming into it. The sweeper demotes these to `failed` with a reason
|
|
1546
|
+
// and emits a state-change event so subscribers (UI + automation queue)
|
|
1547
|
+
// stop waiting on a session that will never finish.
|
|
1548
|
+
//
|
|
1549
|
+
// Threshold: by default 5 minutes. Tunable via KB_STALE_ACTIVE_MS env var
|
|
1550
|
+
// for the test fixture to inject shorter windows.
|
|
1551
|
+
function readStaleThresholdMs() {
|
|
1552
|
+
const cfg = getWorkbenchConfig();
|
|
1553
|
+
const envName = cfg.staleActiveEnvVar || 'KB_STALE_ACTIVE_MS';
|
|
1554
|
+
const raw = process.env[envName] || process.env.KB_STALE_ACTIVE_MS;
|
|
1555
|
+
const n = raw == null || raw === '' ? 5 * 60 * 1000 : Number(raw);
|
|
1556
|
+
if (!Number.isFinite(n)) return 5 * 60 * 1000;
|
|
1557
|
+
return Math.max(1000, Math.floor(n));
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function lastActivityAt(record) {
|
|
1561
|
+
if (!record) return null;
|
|
1562
|
+
return record.updatedAt || record.startedAt || null;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function isStaleActive(record, thresholdMs, now) {
|
|
1566
|
+
if (!record) return false;
|
|
1567
|
+
if (!ACTIVE_STATES.has(record.state)) return false;
|
|
1568
|
+
const last = lastActivityAt(record);
|
|
1569
|
+
if (!last) return false;
|
|
1570
|
+
return now - new Date(last).getTime() > thresholdMs;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Demote a session to `failed` with a clear reason. Reuses emit +
|
|
1574
|
+
// _broadcastSessionChange so the UI sees the same lifecycle shape as a
|
|
1575
|
+
// normal SDK-error end. Also fires onSessionEnded callbacks because
|
|
1576
|
+
// automation queue (post-commit dispatcher) treats `failed` as a release
|
|
1577
|
+
// signal for the next queued run.
|
|
1578
|
+
function demoteSessionToFailed(session, reason, source) {
|
|
1579
|
+
session.state = 'failed';
|
|
1580
|
+
session.endedAt = new Date().toISOString();
|
|
1581
|
+
session.error = reason;
|
|
1582
|
+
session.subprocess = null;
|
|
1583
|
+
session.pendingToolApproval = null;
|
|
1584
|
+
session.pendingPermission = null;
|
|
1585
|
+
emit(session, {
|
|
1586
|
+
type: 'claude/state',
|
|
1587
|
+
state: 'failed',
|
|
1588
|
+
reason,
|
|
1589
|
+
source: source || 'stale-active-sweep',
|
|
1590
|
+
});
|
|
1591
|
+
_broadcastSessionChange(session, 'stale-active-sweep');
|
|
1592
|
+
if (sessionEndedCallbacks.size > 0) {
|
|
1593
|
+
const snapshot = session;
|
|
1594
|
+
setImmediate(() => {
|
|
1595
|
+
for (const cb of sessionEndedCallbacks) {
|
|
1596
|
+
try { cb(snapshot); } catch {}
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
persistSession(session);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Sweep stale ACTIVE sessions from two sources:
|
|
1604
|
+
// * In-memory `sessions` Map: subprocess is null AND state stayed ACTIVE
|
|
1605
|
+
// past the threshold (e.g. a runtime orphan; in-memory state cannot
|
|
1606
|
+
// progress to terminal because the async loop is gone).
|
|
1607
|
+
// * Persisted records on disk: state stayed ACTIVE through at least one
|
|
1608
|
+
// dashboard restart cycle, and the corresponding live entry is not in
|
|
1609
|
+
// the in-memory Map. We rehydrate a minimal session to drive the
|
|
1610
|
+
// demotion emit + broadcast so the UI and any post-commit queue
|
|
1611
|
+
// subscribers see the transition.
|
|
1612
|
+
//
|
|
1613
|
+
// Returns a list of demoted sessionIds and the source ('memory' or
|
|
1614
|
+
// 'persisted') for observability and for tests.
|
|
1615
|
+
function demoteStaleActiveSessions(opts = {}) {
|
|
1616
|
+
const thresholdMs = opts.thresholdMs || readStaleThresholdMs();
|
|
1617
|
+
const now = Date.now();
|
|
1618
|
+
const demoted = [];
|
|
1619
|
+
|
|
1620
|
+
// 1. In-memory sweep.
|
|
1621
|
+
for (const session of sessions.values()) {
|
|
1622
|
+
if (!ACTIVE_STATES.has(session.state)) continue;
|
|
1623
|
+
if (session.subprocess !== null) continue;
|
|
1624
|
+
const last = lastActivityAt(session);
|
|
1625
|
+
if (!last) continue;
|
|
1626
|
+
if (now - new Date(last).getTime() <= thresholdMs) continue;
|
|
1627
|
+
demoteSessionToFailed(
|
|
1628
|
+
session,
|
|
1629
|
+
'subprocess exited unexpectedly (subprocess=null while state=active)',
|
|
1630
|
+
'memory'
|
|
1631
|
+
);
|
|
1632
|
+
demoted.push({ sessionId: session.sessionId, source: 'memory' });
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// 2. Persisted records sweep (covers dashboard-restart orphans).
|
|
1636
|
+
for (const record of scanPersistedRecords()) {
|
|
1637
|
+
if (!isStaleActive(record, thresholdMs, now)) continue;
|
|
1638
|
+
if (sessions.has(record.sessionId)) continue;
|
|
1639
|
+
const rehydrated = {
|
|
1640
|
+
sessionId: record.sessionId,
|
|
1641
|
+
projectSlug: record.projectSlug,
|
|
1642
|
+
projectPath: record.projectPath || null,
|
|
1643
|
+
kbPath: record.kbPath || null,
|
|
1644
|
+
promptKey: record.promptKey || null,
|
|
1645
|
+
runner: record.runner || 'cli',
|
|
1646
|
+
state: record.state,
|
|
1647
|
+
model: record.model || null,
|
|
1648
|
+
aiProfileId: record.aiProfileId || null,
|
|
1649
|
+
claudeSessionId: record.claudeSessionId || null,
|
|
1650
|
+
startedAt: record.startedAt || new Date().toISOString(),
|
|
1651
|
+
endedAt: null,
|
|
1652
|
+
exitCode: null,
|
|
1653
|
+
listeners: new Set(),
|
|
1654
|
+
outputBuffer: [],
|
|
1655
|
+
subprocess: null,
|
|
1656
|
+
turns: record.turns || 0,
|
|
1657
|
+
error: null,
|
|
1658
|
+
claudeEnv: {},
|
|
1659
|
+
pendingPermission: null,
|
|
1660
|
+
pendingTurn: null,
|
|
1661
|
+
pendingToolApproval: null,
|
|
1662
|
+
restored: true,
|
|
1663
|
+
historyCleared: true,
|
|
1664
|
+
source: record.source || 'manual',
|
|
1665
|
+
metadata: record.metadata || {},
|
|
1666
|
+
automation: !!record.automation,
|
|
1667
|
+
automationRunId: record.automationRunId || null,
|
|
1668
|
+
permissionMode: record.permissionMode || 'default',
|
|
1669
|
+
safetyPolicy: null,
|
|
1670
|
+
};
|
|
1671
|
+
sessions.set(record.sessionId, rehydrated);
|
|
1672
|
+
demoteSessionToFailed(
|
|
1673
|
+
rehydrated,
|
|
1674
|
+
'subprocess lost (server restart while session active)',
|
|
1675
|
+
'persisted'
|
|
1676
|
+
);
|
|
1677
|
+
demoted.push({ sessionId: record.sessionId, source: 'persisted' });
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
if (demoted.length) {
|
|
1681
|
+
if (typeof process !== 'undefined' && process.stderr) {
|
|
1682
|
+
const summary = demoted
|
|
1683
|
+
.map(d => `${d.sessionId}(${d.source})`)
|
|
1684
|
+
.slice(0, 20)
|
|
1685
|
+
.join(', ');
|
|
1686
|
+
process.stderr.write(
|
|
1687
|
+
`[claude-cli-runner] stale-active-sweep demoted ${demoted.length} session(s): ${summary}\n`
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
return demoted;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Run once at module load (covers boot-time dashboard-restart orphans).
|
|
1695
|
+
// Don't crash if it throws — a sweeper that takes down the dashboard is
|
|
1696
|
+
// worse than the orphans it cleans up.
|
|
1697
|
+
try {
|
|
1698
|
+
const bootDemoted = demoteStaleActiveSessions();
|
|
1699
|
+
if (bootDemoted.length && typeof process !== 'undefined' && process.stderr) {
|
|
1700
|
+
const summary = bootDemoted
|
|
1701
|
+
.map(d => `${d.sessionId}(${d.source})`)
|
|
1702
|
+
.slice(0, 10)
|
|
1703
|
+
.join(', ');
|
|
1704
|
+
process.stderr.write(
|
|
1705
|
+
`[claude-cli-runner] boot sweep demoted ${bootDemoted.length} stale active session(s): ${summary}\n`
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
} catch (e) {
|
|
1709
|
+
if (typeof process !== 'undefined' && process.stderr) {
|
|
1710
|
+
process.stderr.write(`[claude-cli-runner] boot sweep failed: ${e.message}\n`);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Periodic sweep (covers runtime orphans and new starts). 30s gives a
|
|
1715
|
+
// reasonable tradeoff between responsiveness and noise.
|
|
1716
|
+
setInterval(() => {
|
|
1717
|
+
try { demoteStaleActiveSessions(); }
|
|
1718
|
+
catch {}
|
|
1719
|
+
}, 30 * 1000).unref();
|
|
1720
|
+
|
|
1721
|
+
module.exports = {
|
|
1722
|
+
createSession,
|
|
1723
|
+
startSession,
|
|
1724
|
+
startChatSession,
|
|
1725
|
+
startAutomationSession,
|
|
1726
|
+
buildClaudeEnvFromProfile,
|
|
1727
|
+
setBridgeCaptureConversation,
|
|
1728
|
+
applyAiProfileToSession,
|
|
1729
|
+
updateSelection,
|
|
1730
|
+
buildSdkOverridesFromProfile,
|
|
1731
|
+
sendInput,
|
|
1732
|
+
listSupportedCommands,
|
|
1733
|
+
resolvePermission,
|
|
1734
|
+
abort,
|
|
1735
|
+
subscribe,
|
|
1736
|
+
subscribeList,
|
|
1737
|
+
onSessionEnded,
|
|
1738
|
+
getSession,
|
|
1739
|
+
getState,
|
|
1740
|
+
listSessions,
|
|
1741
|
+
deleteSession,
|
|
1742
|
+
getSessionTokenUsage,
|
|
1743
|
+
findClaudeExecutableForSdk,
|
|
1744
|
+
isTransientClaudeError,
|
|
1745
|
+
demoteStaleActiveSessions,
|
|
1746
|
+
readStaleThresholdMs,
|
|
1747
|
+
ACTIVE_STATES,
|
|
1748
|
+
TERMINAL_STATES,
|
|
1749
|
+
};
|