@sanqianx/claude-ai-workbench 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +129 -0
  3. package/apps/agent-terminal/app.css +567 -0
  4. package/apps/agent-terminal/app.js +2392 -0
  5. package/apps/agent-terminal/index.html +158 -0
  6. package/apps/ai-profile-settings-host/index.html +25 -0
  7. package/apps/ai-profile-settings-host/page.css +1 -0
  8. package/apps/ai-profile-settings-host/page.js +113 -0
  9. package/apps/minimal-test-host/README.md +15 -0
  10. package/apps/minimal-test-host/host.css +2 -0
  11. package/apps/minimal-test-host/host.js +35 -0
  12. package/apps/minimal-test-host/index.html +58 -0
  13. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
  14. package/docs/sdk/FILE_LAYOUT.md +31 -0
  15. package/docs/sdk/INTEGRATION.md +126 -0
  16. package/docs/sdk/README.md +21 -0
  17. package/docs/sdk/openapi.yaml +760 -0
  18. package/package.json +51 -0
  19. package/packages/client/README.md +11 -0
  20. package/packages/client/index.d.ts +81 -0
  21. package/packages/client/index.js +198 -0
  22. package/packages/client/package.json +29 -0
  23. package/packages/contracts/README.md +9 -0
  24. package/packages/contracts/index.d.ts +252 -0
  25. package/packages/contracts/index.js +295 -0
  26. package/packages/contracts/package.json +24 -0
  27. package/packages/core/README.md +8 -0
  28. package/packages/core/index.d.ts +76 -0
  29. package/packages/core/index.js +214 -0
  30. package/packages/core/package.json +29 -0
  31. package/packages/server/README.md +17 -0
  32. package/packages/server/bin/agent-terminal-server.js +36 -0
  33. package/packages/server/bin/claude-workbench-server.js +46 -0
  34. package/packages/server/bin/claude-workbench.js +225 -0
  35. package/packages/server/index.d.ts +145 -0
  36. package/packages/server/index.js +183 -0
  37. package/packages/server/lib/agent-discovery.js +112 -0
  38. package/packages/server/lib/agent-registry.js +98 -0
  39. package/packages/server/lib/attachment-store.js +78 -0
  40. package/packages/server/lib/bridges/app-server-bridge.js +372 -0
  41. package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
  42. package/packages/server/lib/capture-settings-store.js +34 -0
  43. package/packages/server/lib/context-resolver.js +40 -0
  44. package/packages/server/lib/drivers/cli-json-runner.js +960 -0
  45. package/packages/server/lib/drivers/codex-driver.js +1413 -0
  46. package/packages/server/lib/drivers/opencode-driver.js +1527 -0
  47. package/packages/server/lib/drivers/zcode-driver.js +1167 -0
  48. package/packages/server/lib/event-stream.js +35 -0
  49. package/packages/server/lib/folder-picker.js +623 -0
  50. package/packages/server/lib/http-handler.js +254 -0
  51. package/packages/server/lib/json-file.js +19 -0
  52. package/packages/server/lib/model-detection.js +295 -0
  53. package/packages/server/lib/profile-store.js +149 -0
  54. package/packages/server/lib/project-store.js +102 -0
  55. package/packages/server/lib/session-archive-store.js +53 -0
  56. package/packages/server/lib/system-doctor.js +119 -0
  57. package/packages/server/lib/ui-preferences-store.js +48 -0
  58. package/packages/server/lib/workbench-service.js +620 -0
  59. package/packages/server/package.json +34 -0
  60. package/packages/ui/README.md +30 -0
  61. package/packages/ui/ai-profile-settings.d.ts +45 -0
  62. package/packages/ui/ai-profile-settings.js +283 -0
  63. package/packages/ui/claude-terminal.d.ts +31 -0
  64. package/packages/ui/claude-terminal.js +343 -0
  65. package/packages/ui/index.d.ts +18 -0
  66. package/packages/ui/index.js +51 -0
  67. package/packages/ui/package.json +42 -0
  68. package/packages/ui/theme.css +13 -0
  69. package/src/assets/claude-prompts.json +21 -0
  70. package/src/backend/http/ai-profile-routes.js +61 -0
  71. package/src/backend/http/claude-routes.js +170 -0
  72. package/src/backend/http/node-helpers.js +45 -0
  73. package/src/backend/index.js +21 -0
  74. package/src/backend/lib/ai-adapter.js +144 -0
  75. package/src/backend/lib/ai-profile-store.js +259 -0
  76. package/src/backend/lib/ai-vendor-presets.js +83 -0
  77. package/src/backend/lib/ai-workspace.js +140 -0
  78. package/src/backend/lib/automation-config.js +253 -0
  79. package/src/backend/lib/claude-cli-runner.js +1749 -0
  80. package/src/backend/lib/data-dir.js +169 -0
  81. package/src/backend/lib/llm-client.js +176 -0
  82. package/src/backend/lib/model-context-windows.js +82 -0
  83. package/src/backend/lib/prompt-registry.js +166 -0
  84. package/src/backend/lib/runtime-config.js +121 -0
  85. package/src/index.js +7 -0
  86. package/src/sdk-wave-a.ts +1 -0
  87. package/src/sdk-wave-b.ts +1 -0
  88. package/src/sdk-wave-c.ts +1 -0
  89. package/src/sdk-wave-d.ts +1 -0
  90. package/src/sdk-wave-e.ts +1 -0
@@ -0,0 +1,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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1183
+ const content = Buffer.from(item.data, 'base64').toString('utf8').replace(/<\/attachment>/gi, '&lt;/attachment&gt;');
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
+ };