@sanqianx/project-knowledge 4.7.0 → 4.8.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 (192) hide show
  1. package/.agents/plugins/marketplace.json +17 -17
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CHANGELOG.md +817 -799
  4. package/README.md +48 -36
  5. package/_modules/knowledge-engine/README.md +88 -0
  6. package/_modules/knowledge-engine/package.json +38 -0
  7. package/_modules/knowledge-engine/src/analysis-contract.js +25 -0
  8. package/_modules/knowledge-engine/src/analysis-worker.js +54 -0
  9. package/_modules/knowledge-engine/src/bin.js +52 -0
  10. package/_modules/knowledge-engine/src/bridge-client.js +24 -0
  11. package/_modules/knowledge-engine/src/coordinator.js +255 -0
  12. package/_modules/knowledge-engine/src/headless-analyzer.js +75 -0
  13. package/_modules/knowledge-engine/src/memories.js +85 -0
  14. package/_modules/knowledge-engine/src/migration.js +88 -0
  15. package/_modules/knowledge-engine/src/retrieval.js +116 -0
  16. package/_modules/knowledge-engine/src/runtime/README.md +7 -0
  17. package/_modules/knowledge-engine/src/runtime/atomic-file.js +204 -0
  18. package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -0
  19. package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +249 -0
  20. package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -0
  21. package/_modules/knowledge-engine/src/runtime/commit-prompt.js +202 -0
  22. package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +432 -0
  23. package/_modules/knowledge-engine/src/runtime/content-hash.js +4 -0
  24. package/_modules/knowledge-engine/src/runtime/contracts.js +279 -0
  25. package/_modules/knowledge-engine/src/runtime/conversation-exclusions.js +204 -0
  26. package/_modules/knowledge-engine/src/runtime/conversation-store.js +387 -0
  27. package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -0
  28. package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -0
  29. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +444 -0
  30. package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +402 -0
  31. package/_modules/knowledge-engine/src/runtime/layout.js +44 -0
  32. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +230 -0
  33. package/_modules/knowledge-engine/src/runtime/scanner.js +298 -0
  34. package/_modules/knowledge-engine/src/server.js +155 -0
  35. package/_modules/knowledge-engine/src/store.js +239 -0
  36. package/_modules/knowledge-engine/ui/app.css +172 -0
  37. package/_modules/knowledge-engine/ui/app.js +297 -0
  38. package/_modules/knowledge-engine/ui/index.html +95 -0
  39. package/_site/README.md +30 -30
  40. package/_site/_test/ai-profile-resolver-test.js +137 -137
  41. package/_site/_test/automation-queue-test.js +14 -14
  42. package/_site/_test/automation-ui-test.js +40 -40
  43. package/_site/_test/background-task-registry-test.js +43 -43
  44. package/_site/_test/baseline-schema-test.js +91 -91
  45. package/_site/_test/chat-claudecodeui-match-test.js +92 -92
  46. package/_site/_test/claude-executable-discovery-test.js +62 -62
  47. package/_site/_test/claude-workbench-test.js +170 -170
  48. package/_site/_test/codex-conversation-projection-test.js +2 -1
  49. package/_site/_test/commit-conversation-binding-test.js +95 -95
  50. package/_site/_test/commit-evidence-test.js +110 -110
  51. package/_site/_test/conversation-api-test.js +109 -109
  52. package/_site/_test/conversation-store-test.js +136 -136
  53. package/_site/_test/data-dir-migration-test.js +57 -57
  54. package/_site/_test/desktop-browser-compat-test.js +28 -28
  55. package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
  56. package/_site/_test/explicit-commit-processor-test.js +37 -37
  57. package/_site/_test/fixtures/make-git-repos.js +12 -1
  58. package/_site/_test/folder-picker-output-test.js +19 -19
  59. package/_site/_test/full-integration-e2e-test.js +165 -165
  60. package/_site/_test/git-validation-test.js +2 -2
  61. package/_site/_test/hook-runtime-endpoint-test.js +88 -88
  62. package/_site/_test/hook-status-repair-api-test.js +152 -152
  63. package/_site/_test/hook-trigger-test.js +85 -85
  64. package/_site/_test/import-preflight-api-test.js +234 -233
  65. package/_site/_test/index-writer-concurrency-test.js +135 -135
  66. package/_site/_test/integration-adapters-test.js +171 -171
  67. package/_site/_test/integration-surface-coverage-test.js +164 -164
  68. package/_site/_test/knowledge-language-control-test.js +170 -170
  69. package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
  70. package/_site/_test/knowledge-query-test.js +54 -54
  71. package/_site/_test/knowledge-retrieval-service-test.js +77 -77
  72. package/_site/_test/knowledge-storage-startup-test.js +52 -52
  73. package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
  74. package/_site/_test/legacy-forward-compat-test.js +257 -257
  75. package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -363
  76. package/_site/_test/logging-api-test.js +89 -89
  77. package/_site/_test/logging-sse-no-gap-test.js +88 -88
  78. package/_site/_test/logging-ui-test.js +106 -104
  79. package/_site/_test/markdown-delta-overlay-test.js +85 -85
  80. package/_site/_test/markdown-maintenance-api-test.js +75 -75
  81. package/_site/_test/mcp-server-test.js +149 -149
  82. package/_site/_test/module-artifact-integrity-test.js +27 -0
  83. package/_site/_test/module-boundary-test.js +30 -0
  84. package/_site/_test/module-bridge-eventbridge-test.js +7 -0
  85. package/_site/_test/module-model-configuration-test.js +43 -0
  86. package/_site/_test/module-process-lifecycle-test.js +24 -0
  87. package/_site/_test/module-stream-proxy-test.js +34 -0
  88. package/_site/_test/non-release-ci-test.js +34 -34
  89. package/_site/_test/offline-boundary-isolation-test.js +32 -32
  90. package/_site/_test/p0-data-migration-characterization-test.js +37 -37
  91. package/_site/_test/p0-e2e-gate-test.js +347 -347
  92. package/_site/_test/packaged-ui-smoke-test.js +11 -9
  93. package/_site/_test/path-consistency-test.js +151 -151
  94. package/_site/_test/pending-sweep-test.js +9 -9
  95. package/_site/_test/post-commit-automation-test.js +87 -87
  96. package/_site/_test/product-diagnostics-ui-test.js +75 -0
  97. package/_site/_test/product-import-ui-test.js +92 -0
  98. package/_site/_test/project-delete-recovery-test.js +64 -64
  99. package/_site/_test/project-goal-editor-test.js +144 -144
  100. package/_site/_test/project-layout-v2-migration-test.js +127 -127
  101. package/_site/_test/project-lifecycle-transaction-test.js +106 -106
  102. package/_site/_test/prompt-settings-test.js +115 -115
  103. package/_site/_test/protected-architecture-gate-test.js +122 -122
  104. package/_site/_test/refactor-characterization-test.js +36 -36
  105. package/_site/_test/release-version-sync-test.js +1 -1
  106. package/_site/_test/requirement-recorder-test.js +173 -173
  107. package/_site/_test/run-all-tests.js +159 -156
  108. package/_site/_test/server-runtime-migration-safety-test.js +27 -27
  109. package/_site/_test/shared-contracts-test.js +58 -58
  110. package/_site/_test/startup-analysis-disabled-test.js +10 -10
  111. package/_site/_test/storage-foundation-test.js +78 -78
  112. package/_site/_test/structured-logger-test.js +101 -101
  113. package/_site/_test/tracking-start-test.js +108 -108
  114. package/_site/_test/v4122-upgrade-data-contract-test.js +56 -56
  115. package/_site/_test/workbench-permission-test.js +117 -117
  116. package/_site/_test/workspace-ui-contract-test.js +28 -43
  117. package/_site/lib/ai-profile-resolver.js +78 -78
  118. package/_site/lib/ai-workspace.js +101 -101
  119. package/_site/lib/automation-config.js +140 -140
  120. package/_site/lib/bridge-adapter.js +1 -0
  121. package/_site/lib/bridge-consumer-service.js +402 -402
  122. package/_site/lib/claude-cli-runner.js +1510 -1510
  123. package/_site/lib/commit-conversation-binder.js +234 -234
  124. package/_site/lib/commit-processing-ledger.js +27 -27
  125. package/_site/lib/commit-prompt.js +202 -202
  126. package/_site/lib/commit-reconciler.js +406 -406
  127. package/_site/lib/contracts.js +284 -279
  128. package/_site/lib/conversation-query-service.js +144 -144
  129. package/_site/lib/conversation-store.js +385 -385
  130. package/_site/lib/data-dir.js +74 -74
  131. package/_site/lib/data-state-classifier.js +40 -40
  132. package/_site/lib/engine-cutover.js +97 -0
  133. package/_site/lib/evidence-bundle.js +228 -228
  134. package/_site/lib/folder-picker-output.js +21 -21
  135. package/_site/lib/git-runner.js +62 -62
  136. package/_site/lib/github-team-store.js +1172 -1172
  137. package/_site/lib/hook-manager.js +203 -202
  138. package/_site/lib/index-service.js +173 -173
  139. package/_site/lib/integration-installer.js +907 -907
  140. package/_site/lib/kb-framework.js +176 -176
  141. package/_site/lib/kb-validator.js +124 -124
  142. package/_site/lib/knowledge-promotion.js +435 -435
  143. package/_site/lib/knowledge-retrieval-service.js +401 -401
  144. package/_site/lib/knowledge-tool-runtime.js +359 -359
  145. package/_site/lib/legacy-data-manifest.js +26 -26
  146. package/_site/lib/llm-client.js +161 -161
  147. package/_site/lib/markdown-knowledge-indexer.js +230 -230
  148. package/_site/lib/migration-service.js +442 -442
  149. package/_site/lib/module-bridge.js +198 -33
  150. package/_site/lib/post-commit-automation.js +87 -87
  151. package/_site/lib/project-lifecycle-service.js +519 -497
  152. package/_site/lib/project-store.js +268 -262
  153. package/_site/lib/requirement-recorder.js +278 -278
  154. package/_site/lib/runtime-endpoint.js +155 -155
  155. package/_site/lib/scanner.js +298 -298
  156. package/_site/lib/server-app.js +1590 -1520
  157. package/_site/lib/settings-store.js +133 -113
  158. package/_site/lib/storage-layout.js +162 -162
  159. package/_site/lib/structured-logger.js +574 -574
  160. package/_site/scripts/folder-picker.ps1 +156 -156
  161. package/_site/scripts/hook-trigger.js +157 -156
  162. package/_site/scripts/install-module-candidates.js +69 -0
  163. package/_site/scripts/pack-module-candidates.js +72 -0
  164. package/_site/scripts/sync-release-version.js +2 -2
  165. package/_site/scripts/vendor-modules.js +47 -0
  166. package/_site/scripts/verify-module-runtime.js +45 -0
  167. package/_site/scripts/verify-product-runtime.js +34 -0
  168. package/bin/project-knowledge-mcp.js +194 -194
  169. package/docs/README.zh-CN.md +34 -25
  170. package/docs/project-registry-schema.md +22 -22
  171. package/docs/testing-strategy.md +33 -33
  172. package/module-runtime-manifest.json +220 -0
  173. package/package.json +13 -9
  174. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  175. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  176. package/plugins/project-knowledge/.mcp.json +1 -1
  177. package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
  178. package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
  179. package/templates/project-readme.md +34 -34
  180. package/ui/favicon.svg +38 -38
  181. package/ui/index.html +51 -149
  182. package/ui/product.css +9 -0
  183. package/ui/product.js +386 -0
  184. package/vendor-manifest.json +46 -0
  185. package/_site/_test/import-ui-flow-test.js +0 -171
  186. package/_site/_test/project-control-panel-task14-test.js +0 -63
  187. package/_site/_test/task15-20-ui-flow-test.js +0 -148
  188. package/_site/_test/ui-i18n-toggle-test.js +0 -114
  189. package/_site/_test/ui-smoke-test.js +0 -73
  190. package/ui/app.css +0 -58
  191. package/ui/app.js +0 -607
  192. package/ui/i18n.js +0 -146
@@ -1,234 +1,234 @@
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 { spawn, spawnSync } = 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 AtomicFile = require('./atomic-file');
30
-
31
- let structuredLogger = null;
32
- function setLogger(logger) { structuredLogger = logger || null; }
33
- function logSession(level, event, message, session, context = {}, error = null) {
34
- if (!structuredLogger || typeof structuredLogger[level] !== 'function') return;
35
- const payload = {
36
- component: 'claude-cli-runner',
37
- projectId: session && session.projectSlug || '',
38
- runId: session && session.automationRunId || '',
39
- phase: session && session.state || '',
40
- context: { sessionId: session && session.sessionId || '', ...context },
41
- };
42
- if (error) payload.error = error;
43
- Promise.resolve(structuredLogger[level](event, message, payload)).catch(() => {
44
- try { process.stderr.write(`[claude-cli-runner logger fallback] ${event}\n`); } catch {
45
- // stderr is the final non-recursive observer fallback.
46
- }
47
- });
48
- }
49
-
50
- // ---- session store ----
51
- // Map<sessionId, Session>
52
- const sessions = new Map();
53
- const { getDataDir } = require('./data-dir');
54
- const { StorageLayout } = require('./storage-layout');
55
- const { ProjectRegistryStore } = require('./project-registry-store');
56
- const { ProjectStore } = require('./project-store');
57
- const KB_ROOT = getDataDir();
58
- const storageLayout = new StorageLayout({ dataDir: KB_ROOT });
59
- const projectRegistryStore = new ProjectRegistryStore({ layout: storageLayout });
60
- const projectStore = new ProjectStore({ layout: storageLayout });
61
- const WORKBENCH_DIR = 'claude-workbench';
62
-
63
- // End-of-session subscribers. Used by the automation queue to learn when an
64
- // automation run finishes so it can promote the next queued run. Fires on
65
- // idle/failed/aborted; non-automation sessions are filtered by subscribers
66
- // themselves via session.metadata.automationRunId.
67
- const sessionEndedCallbacks = new Set();
68
- function onSessionEnded(cb) {
69
- if (typeof cb !== 'function') return () => {};
70
- sessionEndedCallbacks.add(cb);
71
- return () => sessionEndedCallbacks.delete(cb);
72
- }
73
-
74
- const TERMINAL_STATES = new Set(['idle', 'failed', 'aborted']);
75
-
76
- function sleep(ms) {
77
- return new Promise(resolve => setTimeout(resolve, ms));
78
- }
79
-
80
- function normalizeErrorMessage(err) {
81
- if (!err) return '';
82
- if (typeof err === 'string') return err;
83
- if (typeof err.message === 'string' && err.message) return err.message;
84
- try { return JSON.stringify(err); } catch { return String(err); }
85
- }
86
-
87
- function isTransientClaudeError(err) {
88
- const msg = normalizeErrorMessage(err).toLowerCase();
89
- if (!msg) return false;
90
- return [
91
- '429',
92
- '502',
93
- '503',
94
- '504',
95
- '529',
96
- 'rate limit',
97
- 'too many requests',
98
- 'overloaded',
99
- 'overload',
100
- 'busy',
101
- 'capacity',
102
- 'traffic',
103
- 'timeout',
104
- 'timed out',
105
- 'etimedout',
106
- 'econnreset',
107
- 'econnrefused',
108
- 'eai_again',
109
- '\u8bbf\u95ee\u91cf',
110
- '\u8bbf\u95ee\u592a\u5927',
111
- '\u9650\u6d41',
112
- '\u7e41\u5fd9',
113
- '\u7a0d\u540e',
114
- '\u8fc7\u8f7d',
115
- ].some(token => msg.includes(token));
116
- }
117
-
118
- function maxSdkRetries() {
119
- const raw = process.env.KB_CLAUDE_SDK_MAX_RETRIES || process.env.CLAUDE_SDK_MAX_RETRIES;
120
- const n = raw == null || raw === '' ? 2 : Number(raw);
121
- if (!Number.isFinite(n)) return 2;
122
- return Math.max(0, Math.min(5, Math.floor(n)));
123
- }
124
-
125
- function sdkRetryDelayMs(attempt) {
126
- const base = Number(process.env.KB_CLAUDE_SDK_RETRY_BASE_MS) || 1000;
127
- const delay = base * Math.pow(2, Math.max(0, attempt - 1));
128
- return Math.min(8000, Math.max(250, delay));
129
- }
130
-
131
- function newSessionId() {
132
- return 'sess-' + crypto.randomBytes(6).toString('hex');
133
- }
134
-
135
- function createSession({ projectSlug, projectPath, kbPath, promptKey, source = 'manual', metadata = null }) {
136
- const sessionId = newSessionId();
137
- const session = {
138
- sessionId,
139
- projectSlug,
140
- projectPath,
141
- kbPath: kbPath || path.join(KB_ROOT, 'projects', projectSlug),
142
- promptKey,
143
- state: 'idle',
144
- model: null,
145
- claudeSessionId: null,
146
- startedAt: new Date().toISOString(),
147
- endedAt: null,
148
- exitCode: null,
149
- listeners: new Set(),
150
- outputBuffer: [],
151
- subprocess: null,
152
- turns: 0,
153
- error: null,
154
- claudeEnv: {},
155
- pendingPermission: null,
156
- pendingTurn: null,
157
- pendingToolApproval: null,
158
- restored: false,
159
- source,
160
- metadata: metadata && typeof metadata === 'object' ? metadata : {},
161
- automation: !!(metadata && metadata.automation),
162
- automationRunId: metadata && metadata.automationRunId || null,
163
- permissionMode: 'default',
164
- safetyPolicy: null,
165
- };
166
- sessions.set(sessionId, session);
167
- persistSession(session);
168
- logSession('info', 'claude.session_created', 'Claude Workbench session created.', session, { promptKey, source });
169
- return session;
170
- }
171
-
172
- function sessionRecordPath(session) {
173
- if (!session || !session.projectSlug) return null;
174
- return storageLayout.getRuntimePath('claude-sessions', session.projectSlug, `${session.sessionId}.json`);
175
- }
176
-
177
- function toPersistedSession(session) {
178
- return {
179
- schema: 'claude-workbench-session/v1',
180
- sessionId: session.sessionId,
181
- projectSlug: session.projectSlug,
182
- projectPath: session.projectPath,
183
- kbPath: session.kbPath,
184
- promptKey: session.promptKey,
185
- runner: session.runner || 'cli',
186
- state: session.state,
187
- model: session.model,
188
- aiProfileId: session.aiProfileId || null,
189
- claudeSessionId: session.claudeSessionId,
190
- source: session.source || 'manual',
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 { spawn, spawnSync } = 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 AtomicFile = require('./atomic-file');
30
+
31
+ let structuredLogger = null;
32
+ function setLogger(logger) { structuredLogger = logger || null; }
33
+ function logSession(level, event, message, session, context = {}, error = null) {
34
+ if (!structuredLogger || typeof structuredLogger[level] !== 'function') return;
35
+ const payload = {
36
+ component: 'claude-cli-runner',
37
+ projectId: session && session.projectSlug || '',
38
+ runId: session && session.automationRunId || '',
39
+ phase: session && session.state || '',
40
+ context: { sessionId: session && session.sessionId || '', ...context },
41
+ };
42
+ if (error) payload.error = error;
43
+ Promise.resolve(structuredLogger[level](event, message, payload)).catch(() => {
44
+ try { process.stderr.write(`[claude-cli-runner logger fallback] ${event}\n`); } catch {
45
+ // stderr is the final non-recursive observer fallback.
46
+ }
47
+ });
48
+ }
49
+
50
+ // ---- session store ----
51
+ // Map<sessionId, Session>
52
+ const sessions = new Map();
53
+ const { getDataDir } = require('./data-dir');
54
+ const { StorageLayout } = require('./storage-layout');
55
+ const { ProjectRegistryStore } = require('./project-registry-store');
56
+ const { ProjectStore } = require('./project-store');
57
+ const KB_ROOT = getDataDir();
58
+ const storageLayout = new StorageLayout({ dataDir: KB_ROOT });
59
+ const projectRegistryStore = new ProjectRegistryStore({ layout: storageLayout });
60
+ const projectStore = new ProjectStore({ layout: storageLayout });
61
+ const WORKBENCH_DIR = 'claude-workbench';
62
+
63
+ // End-of-session subscribers. Used by the automation queue to learn when an
64
+ // automation run finishes so it can promote the next queued run. Fires on
65
+ // idle/failed/aborted; non-automation sessions are filtered by subscribers
66
+ // themselves via session.metadata.automationRunId.
67
+ const sessionEndedCallbacks = new Set();
68
+ function onSessionEnded(cb) {
69
+ if (typeof cb !== 'function') return () => {};
70
+ sessionEndedCallbacks.add(cb);
71
+ return () => sessionEndedCallbacks.delete(cb);
72
+ }
73
+
74
+ const TERMINAL_STATES = new Set(['idle', 'failed', 'aborted']);
75
+
76
+ function sleep(ms) {
77
+ return new Promise(resolve => setTimeout(resolve, ms));
78
+ }
79
+
80
+ function normalizeErrorMessage(err) {
81
+ if (!err) return '';
82
+ if (typeof err === 'string') return err;
83
+ if (typeof err.message === 'string' && err.message) return err.message;
84
+ try { return JSON.stringify(err); } catch { return String(err); }
85
+ }
86
+
87
+ function isTransientClaudeError(err) {
88
+ const msg = normalizeErrorMessage(err).toLowerCase();
89
+ if (!msg) return false;
90
+ return [
91
+ '429',
92
+ '502',
93
+ '503',
94
+ '504',
95
+ '529',
96
+ 'rate limit',
97
+ 'too many requests',
98
+ 'overloaded',
99
+ 'overload',
100
+ 'busy',
101
+ 'capacity',
102
+ 'traffic',
103
+ 'timeout',
104
+ 'timed out',
105
+ 'etimedout',
106
+ 'econnreset',
107
+ 'econnrefused',
108
+ 'eai_again',
109
+ '\u8bbf\u95ee\u91cf',
110
+ '\u8bbf\u95ee\u592a\u5927',
111
+ '\u9650\u6d41',
112
+ '\u7e41\u5fd9',
113
+ '\u7a0d\u540e',
114
+ '\u8fc7\u8f7d',
115
+ ].some(token => msg.includes(token));
116
+ }
117
+
118
+ function maxSdkRetries() {
119
+ const raw = process.env.KB_CLAUDE_SDK_MAX_RETRIES || process.env.CLAUDE_SDK_MAX_RETRIES;
120
+ const n = raw == null || raw === '' ? 2 : Number(raw);
121
+ if (!Number.isFinite(n)) return 2;
122
+ return Math.max(0, Math.min(5, Math.floor(n)));
123
+ }
124
+
125
+ function sdkRetryDelayMs(attempt) {
126
+ const base = Number(process.env.KB_CLAUDE_SDK_RETRY_BASE_MS) || 1000;
127
+ const delay = base * Math.pow(2, Math.max(0, attempt - 1));
128
+ return Math.min(8000, Math.max(250, delay));
129
+ }
130
+
131
+ function newSessionId() {
132
+ return 'sess-' + crypto.randomBytes(6).toString('hex');
133
+ }
134
+
135
+ function createSession({ projectSlug, projectPath, kbPath, promptKey, source = 'manual', metadata = null }) {
136
+ const sessionId = newSessionId();
137
+ const session = {
138
+ sessionId,
139
+ projectSlug,
140
+ projectPath,
141
+ kbPath: kbPath || path.join(KB_ROOT, 'projects', projectSlug),
142
+ promptKey,
143
+ state: 'idle',
144
+ model: null,
145
+ claudeSessionId: null,
146
+ startedAt: new Date().toISOString(),
147
+ endedAt: null,
148
+ exitCode: null,
149
+ listeners: new Set(),
150
+ outputBuffer: [],
151
+ subprocess: null,
152
+ turns: 0,
153
+ error: null,
154
+ claudeEnv: {},
155
+ pendingPermission: null,
156
+ pendingTurn: null,
157
+ pendingToolApproval: null,
158
+ restored: false,
159
+ source,
160
+ metadata: metadata && typeof metadata === 'object' ? metadata : {},
161
+ automation: !!(metadata && metadata.automation),
162
+ automationRunId: metadata && metadata.automationRunId || null,
163
+ permissionMode: 'default',
164
+ safetyPolicy: null,
165
+ };
166
+ sessions.set(sessionId, session);
167
+ persistSession(session);
168
+ logSession('info', 'claude.session_created', 'Claude Workbench session created.', session, { promptKey, source });
169
+ return session;
170
+ }
171
+
172
+ function sessionRecordPath(session) {
173
+ if (!session || !session.projectSlug) return null;
174
+ return storageLayout.getRuntimePath('claude-sessions', session.projectSlug, `${session.sessionId}.json`);
175
+ }
176
+
177
+ function toPersistedSession(session) {
178
+ return {
179
+ schema: 'claude-workbench-session/v1',
180
+ sessionId: session.sessionId,
181
+ projectSlug: session.projectSlug,
182
+ projectPath: session.projectPath,
183
+ kbPath: session.kbPath,
184
+ promptKey: session.promptKey,
185
+ runner: session.runner || 'cli',
186
+ state: session.state,
187
+ model: session.model,
188
+ aiProfileId: session.aiProfileId || null,
189
+ claudeSessionId: session.claudeSessionId,
190
+ source: session.source || 'manual',
191
191
  automation: !!session.automation,
192
192
  automationRunId: session.automationRunId || null,
193
193
  metadata: session.metadata || {},
194
194
  legacyProjectSlug: session.legacyProjectSlug || null,
195
195
  legacySource: session.legacySource === true,
196
196
  permissionMode: session.permissionMode || 'default',
197
- startedAt: session.startedAt,
198
- endedAt: session.endedAt,
199
- exitCode: session.exitCode,
200
- turns: session.turns,
201
- error: session.error,
202
- pendingPermission: session.pendingPermission,
203
- events: session.outputBuffer.slice(-5000),
204
- updatedAt: new Date().toISOString(),
205
- };
206
- }
207
-
208
- function persistSession(session) {
209
- const file = sessionRecordPath(session);
210
- if (!file) return false;
211
- try {
212
- AtomicFile.writeJsonAtomic(file, toPersistedSession(session));
213
- return true;
214
- } catch (error) {
215
- // Persistence should never interrupt the live Claude process.
216
- logSession('error', 'claude.session_persist_failed', 'Claude Workbench session metadata could not be persisted.', session, { recordId: path.basename(file) }, error);
217
- return false;
218
- }
219
- }
220
-
221
- function readPersistedRecord(file) {
222
- try {
223
- const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
224
- if (parsed && parsed.schema === 'claude-workbench-session/v1' && parsed.sessionId) return parsed;
225
- } catch (error) {
226
- // Corrupt or partially written historical session records are not resumable.
227
- logSession('warn', 'claude.session_restore_skipped', 'A Claude Workbench session record was not resumable.', null, { recordId: path.basename(file), errorCode: error && error.code || 'DATA_CORRUPT' });
228
- }
229
- return null;
230
- }
231
-
197
+ startedAt: session.startedAt,
198
+ endedAt: session.endedAt,
199
+ exitCode: session.exitCode,
200
+ turns: session.turns,
201
+ error: session.error,
202
+ pendingPermission: session.pendingPermission,
203
+ events: session.outputBuffer.slice(-5000),
204
+ updatedAt: new Date().toISOString(),
205
+ };
206
+ }
207
+
208
+ function persistSession(session) {
209
+ const file = sessionRecordPath(session);
210
+ if (!file) return false;
211
+ try {
212
+ AtomicFile.writeJsonAtomic(file, toPersistedSession(session));
213
+ return true;
214
+ } catch (error) {
215
+ // Persistence should never interrupt the live Claude process.
216
+ logSession('error', 'claude.session_persist_failed', 'Claude Workbench session metadata could not be persisted.', session, { recordId: path.basename(file) }, error);
217
+ return false;
218
+ }
219
+ }
220
+
221
+ function readPersistedRecord(file) {
222
+ try {
223
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
224
+ if (parsed && parsed.schema === 'claude-workbench-session/v1' && parsed.sessionId) return parsed;
225
+ } catch (error) {
226
+ // Corrupt or partially written historical session records are not resumable.
227
+ logSession('warn', 'claude.session_restore_skipped', 'A Claude Workbench session record was not resumable.', null, { recordId: path.basename(file), errorCode: error && error.code || 'DATA_CORRUPT' });
228
+ }
229
+ return null;
230
+ }
231
+
232
232
  function scanPersistedRecords(projectSlug = null) {
233
233
  let projectDirs = [];
234
234
  try {
@@ -286,63 +286,63 @@ function scanPersistedRecords(projectSlug = null) {
286
286
  }
287
287
  return [...records.values()].sort((a, b) => String(b.updatedAt || b.startedAt || '').localeCompare(String(a.updatedAt || a.startedAt || '')));
288
288
  }
289
-
290
- function findPersistedRecord(sessionId) {
291
- return scanPersistedRecords().find(record => record.sessionId === sessionId) || null;
292
- }
293
-
294
- function currentProjectKbPath(projectSlug) {
295
- try {
296
- if (projectRegistryStore.readDisplaySnapshot(projectSlug)) return projectStore.readConfig(projectSlug).knowledgePath;
297
- } catch {
298
- // Missing project metadata means there is no knowledge path for this legacy caller.
299
- }
300
- return null;
301
- }
302
-
303
- function restoreSessionFromDisk(sessionId) {
304
- if (sessions.has(sessionId)) return sessions.get(sessionId);
305
- const record = findPersistedRecord(sessionId);
306
- if (!record) return null;
307
- const liveState = ['running', 'spawning', 'pending-permission'].includes(record.state) ? 'idle' : (record.state || 'idle');
308
- // Always prefer the project's current v2 config knowledgePath over the
309
- // value persisted when the session was created. A controlled project move
310
- // therefore restores the session in the new working directory.
311
- const liveKbPath = currentProjectKbPath(record.projectSlug);
312
- const kbPath = liveKbPath || record.kbPath || path.join(KB_ROOT, 'projects', record.projectSlug);
313
- const kbPathChanged = !!liveKbPath && record.kbPath && liveKbPath !== record.kbPath;
314
- // When the working directory moves, the previous Claude conversation is
315
- // stored in the OLD cwd's session store and `--resume <id>` cannot find
316
- // it from the new cwd. Carrying the stale claudeSessionId forward would
317
- // just produce a "No conversation found" error on the next follow-up —
318
- // drop it (and the chat history) so the user starts a fresh turn in the
319
- // new directory.
320
- const inheritConversation = !kbPathChanged;
321
- const session = {
322
- sessionId: record.sessionId,
323
- projectSlug: record.projectSlug,
324
- projectPath: record.projectPath,
325
- kbPath,
326
- promptKey: record.promptKey,
327
- runner: record.runner || 'cli',
328
- state: liveState,
329
- model: record.model || null,
330
- aiProfileId: record.aiProfileId || null,
331
- claudeSessionId: inheritConversation ? (record.claudeSessionId || null) : null,
332
- startedAt: record.startedAt || new Date().toISOString(),
333
- endedAt: liveState === record.state ? record.endedAt || null : null,
334
- exitCode: record.exitCode ?? null,
335
- listeners: new Set(),
336
- outputBuffer: inheritConversation && Array.isArray(record.events) ? record.events : [],
337
- subprocess: null,
338
- turns: inheritConversation ? (record.turns || 0) : 0,
339
- error: inheritConversation ? (record.error || null) : null,
340
- claudeEnv: {},
341
- pendingPermission: null,
342
- pendingTurn: null,
343
- pendingToolApproval: null,
344
- restored: true,
345
- historyCleared: !inheritConversation,
289
+
290
+ function findPersistedRecord(sessionId) {
291
+ return scanPersistedRecords().find(record => record.sessionId === sessionId) || null;
292
+ }
293
+
294
+ function currentProjectKbPath(projectSlug) {
295
+ try {
296
+ if (projectRegistryStore.readDisplaySnapshot(projectSlug)) return projectStore.readConfig(projectSlug).knowledgePath;
297
+ } catch {
298
+ // Missing project metadata means there is no knowledge path for this legacy caller.
299
+ }
300
+ return null;
301
+ }
302
+
303
+ function restoreSessionFromDisk(sessionId) {
304
+ if (sessions.has(sessionId)) return sessions.get(sessionId);
305
+ const record = findPersistedRecord(sessionId);
306
+ if (!record) return null;
307
+ const liveState = ['running', 'spawning', 'pending-permission'].includes(record.state) ? 'idle' : (record.state || 'idle');
308
+ // Always prefer the project's current v2 config knowledgePath over the
309
+ // value persisted when the session was created. A controlled project move
310
+ // therefore restores the session in the new working directory.
311
+ const liveKbPath = currentProjectKbPath(record.projectSlug);
312
+ const kbPath = liveKbPath || record.kbPath || path.join(KB_ROOT, 'projects', record.projectSlug);
313
+ const kbPathChanged = !!liveKbPath && record.kbPath && liveKbPath !== record.kbPath;
314
+ // When the working directory moves, the previous Claude conversation is
315
+ // stored in the OLD cwd's session store and `--resume <id>` cannot find
316
+ // it from the new cwd. Carrying the stale claudeSessionId forward would
317
+ // just produce a "No conversation found" error on the next follow-up —
318
+ // drop it (and the chat history) so the user starts a fresh turn in the
319
+ // new directory.
320
+ const inheritConversation = !kbPathChanged;
321
+ const session = {
322
+ sessionId: record.sessionId,
323
+ projectSlug: record.projectSlug,
324
+ projectPath: record.projectPath,
325
+ kbPath,
326
+ promptKey: record.promptKey,
327
+ runner: record.runner || 'cli',
328
+ state: liveState,
329
+ model: record.model || null,
330
+ aiProfileId: record.aiProfileId || null,
331
+ claudeSessionId: inheritConversation ? (record.claudeSessionId || null) : null,
332
+ startedAt: record.startedAt || new Date().toISOString(),
333
+ endedAt: liveState === record.state ? record.endedAt || null : null,
334
+ exitCode: record.exitCode ?? null,
335
+ listeners: new Set(),
336
+ outputBuffer: inheritConversation && Array.isArray(record.events) ? record.events : [],
337
+ subprocess: null,
338
+ turns: inheritConversation ? (record.turns || 0) : 0,
339
+ error: inheritConversation ? (record.error || null) : null,
340
+ claudeEnv: {},
341
+ pendingPermission: null,
342
+ pendingTurn: null,
343
+ pendingToolApproval: null,
344
+ restored: true,
345
+ historyCleared: !inheritConversation,
346
346
  source: record.source || 'manual',
347
347
  metadata: record.metadata || {},
348
348
  automation: !!record.automation,
@@ -350,1162 +350,1162 @@ function restoreSessionFromDisk(sessionId) {
350
350
  legacyProjectSlug: record.legacyProjectSlug || null,
351
351
  legacySource: record.legacySource === true,
352
352
  permissionMode: record.permissionMode || 'default',
353
- safetyPolicy: null,
354
- };
355
- sessions.set(sessionId, session);
356
- if (liveState !== record.state || kbPathChanged) {
357
- emit(session, { type: 'claude/restored', fromState: record.state, state: liveState });
358
- }
359
- // Only broadcast active restorations so server boot doesn't flood the bus
360
- // with every persisted idle record.
361
- if (ACTIVE_STATES.has(record.state)) {
362
- _broadcastSessionChange(session, 'restore');
363
- }
364
- if (kbPathChanged) {
365
- emit(session, {
366
- type: 'claude/kbpath-updated',
367
- fromKbPath: record.kbPath,
368
- toKbPath: kbPath,
369
- historyCleared: true,
370
- message: `working directory moved to ${kbPath}; previous conversation history was cleared — send a new message to start a fresh session`,
371
- });
372
- persistSession(session);
373
- }
374
- return session;
375
- }
376
-
377
- function buildClaudeEnvFromProfile(profile) {
378
- // Every Project-Knowledge-owned Claude Agent SDK session is internal AI
379
- // runtime: its hooks must never be captured by the Bridge into Development
380
- // Conversation (I-02/I-03). The markers are inherited by every child of the
381
- // SDK session and checked by the Bridge before any Git/journal work.
382
- const env = {
383
- CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
384
- AI_CODING_EVENT_BRIDGE_CAPTURE: '0',
385
- AI_CODING_EVENT_ORIGIN: 'project-knowledge-internal',
386
- };
387
- if (!profile || typeof profile !== 'object') return env;
388
-
389
- const apiKey = profile.apiKey || profile.authToken || profile.anthropicAuthToken || '';
390
- const baseUrl = profile.baseUrl || profile.apiBaseUrl || profile.anthropicBaseUrl || '';
391
- // CC-Switch-style per-tier model fields. Each alias falls back to
392
- // mainModel so an empty optional slot doesn't break the env.
393
- const mainModel = profile.mainModel || profile.model || '';
394
- const thinkingModel = profile.thinkingModel || mainModel;
395
- const haikuModel = profile.haikuModel || mainModel;
396
- const sonnetModel = profile.sonnetModel || mainModel;
397
- const opusModel = profile.opusModel || mainModel;
398
- const timeoutMs = profile.timeoutMs || process.env.API_TIMEOUT_MS || '';
399
-
400
- if (apiKey) env.ANTHROPIC_AUTH_TOKEN = String(apiKey);
401
- if (baseUrl) env.ANTHROPIC_BASE_URL = String(baseUrl);
402
- if (mainModel) env.ANTHROPIC_MODEL = String(mainModel);
403
- if (thinkingModel) env.ANTHROPIC_DEFAULT_THINKING_MODEL = String(thinkingModel);
404
- if (haikuModel) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = String(haikuModel);
405
- if (sonnetModel) env.ANTHROPIC_DEFAULT_SONNET_MODEL = String(sonnetModel);
406
- if (opusModel) env.ANTHROPIC_DEFAULT_OPUS_MODEL = String(opusModel);
407
- if (timeoutMs) env.API_TIMEOUT_MS = String(timeoutMs);
408
-
409
- return env;
410
- }
411
-
412
- function profileMainModel(profile) {
413
- if (!profile || typeof profile !== 'object') return '';
414
- return String(profile.mainModel || profile.model || '').trim();
415
- }
416
-
417
- function applyAiProfileToSession(session, aiProfile) {
418
- if (!session) {
419
- return { resetConversation: false, model: session && session.model || null };
420
- }
421
- if (!aiProfile || typeof aiProfile !== 'object') {
422
- session.claudeEnv = buildClaudeEnvFromProfile(null);
423
- session.runner = 'sdk';
424
- return { resetConversation: false, model: session.model || null };
425
- }
426
- const previousProfileId = session.aiProfileId || null;
427
- const previousModel = session.model || null;
428
- const nextProfileId = aiProfile.id || previousProfileId || null;
429
- const nextEnv = buildClaudeEnvFromProfile(aiProfile);
430
- const nextModel = profileMainModel(aiProfile) || nextEnv.ANTHROPIC_MODEL || null;
431
-
432
- const profileChanged = !!(previousProfileId && nextProfileId && previousProfileId !== nextProfileId);
433
- const modelChanged = !!(previousModel && nextModel && previousModel !== nextModel);
434
- const resetConversation = !!(session.claudeSessionId && (profileChanged || modelChanged));
435
-
436
- if (resetConversation) {
437
- emit(session, {
438
- type: 'claude/conversation-reset',
439
- reason: profileChanged ? 'ai-profile-changed' : 'model-changed',
440
- fromAiProfileId: previousProfileId,
441
- toAiProfileId: nextProfileId,
442
- fromModel: previousModel,
443
- toModel: nextModel,
444
- message: 'AI profile or model changed; previous Claude conversation was cleared before the next turn.',
445
- });
446
- session.claudeSessionId = null;
447
- }
448
-
449
- session.aiProfileId = nextProfileId;
450
- session.claudeEnv = nextEnv;
451
- if (nextModel) session.model = nextModel;
452
- session.runner = 'sdk';
453
- session.contextWindow = resolveContextWindow({
454
- profileContextWindow: aiProfile && aiProfile.contextWindow,
455
- model: session.model || profileMainModel(aiProfile),
456
- });
457
- return { resetConversation, model: session.model || null };
458
- }
459
-
460
- // Pull the per-call SDK overrides (systemPrompt) from an AI profile.
461
- // The legacy temperature/maxTokens profile fields were removed in the
462
- // CC-Switch migration — they were silently dropped by the CLI path and
463
- // are no longer wired into the SDK path either. Callers that need to
464
- // tune sampling pass temperature/maxTokens at the call site.
465
- function buildSdkOverridesFromProfile(profile) {
466
- if (!profile || typeof profile !== 'object') return {};
467
- const out = {};
468
- // Default to the hardened Claude Code system prompt when the profile
469
- // doesn't override — that's the whole point of switching to SDK, and it
470
- // matches what claudecodeui does. The user can override with an object
471
- // (e.g. {type:'preset',preset:'claude_code'}) or a plain string.
472
- if (profile.systemPrompt !== undefined) {
473
- out.systemPrompt = profile.systemPrompt;
474
- } else {
475
- out.systemPrompt = { type: 'preset', preset: 'claude_code' };
476
- }
477
- return out;
478
- }
479
-
480
- function getSession(sessionId) {
481
- return sessions.get(sessionId) || restoreSessionFromDisk(sessionId);
482
- }
483
-
484
- function sessionSummary(s) {
485
- return {
486
- sessionId: s.sessionId,
487
- projectSlug: s.projectSlug,
488
- promptKey: s.promptKey,
489
- runner: s.runner || 'cli',
490
- state: s.state,
491
- model: s.model,
492
- aiProfileId: s.aiProfileId || null,
493
- claudeSessionId: s.claudeSessionId,
353
+ safetyPolicy: null,
354
+ };
355
+ sessions.set(sessionId, session);
356
+ if (liveState !== record.state || kbPathChanged) {
357
+ emit(session, { type: 'claude/restored', fromState: record.state, state: liveState });
358
+ }
359
+ // Only broadcast active restorations so server boot doesn't flood the bus
360
+ // with every persisted idle record.
361
+ if (ACTIVE_STATES.has(record.state)) {
362
+ _broadcastSessionChange(session, 'restore');
363
+ }
364
+ if (kbPathChanged) {
365
+ emit(session, {
366
+ type: 'claude/kbpath-updated',
367
+ fromKbPath: record.kbPath,
368
+ toKbPath: kbPath,
369
+ historyCleared: true,
370
+ message: `working directory moved to ${kbPath}; previous conversation history was cleared — send a new message to start a fresh session`,
371
+ });
372
+ persistSession(session);
373
+ }
374
+ return session;
375
+ }
376
+
377
+ function buildClaudeEnvFromProfile(profile) {
378
+ // Every Project-Knowledge-owned Claude Agent SDK session is internal AI
379
+ // runtime: its hooks must never be captured by the Bridge into Development
380
+ // Conversation (I-02/I-03). The markers are inherited by every child of the
381
+ // SDK session and checked by the Bridge before any Git/journal work.
382
+ const env = {
383
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
384
+ AI_CODING_EVENT_BRIDGE_CAPTURE: '0',
385
+ AI_CODING_EVENT_ORIGIN: 'project-knowledge-internal',
386
+ };
387
+ if (!profile || typeof profile !== 'object') return env;
388
+
389
+ const apiKey = profile.apiKey || profile.authToken || profile.anthropicAuthToken || '';
390
+ const baseUrl = profile.baseUrl || profile.apiBaseUrl || profile.anthropicBaseUrl || '';
391
+ // CC-Switch-style per-tier model fields. Each alias falls back to
392
+ // mainModel so an empty optional slot doesn't break the env.
393
+ const mainModel = profile.mainModel || profile.model || '';
394
+ const thinkingModel = profile.thinkingModel || mainModel;
395
+ const haikuModel = profile.haikuModel || mainModel;
396
+ const sonnetModel = profile.sonnetModel || mainModel;
397
+ const opusModel = profile.opusModel || mainModel;
398
+ const timeoutMs = profile.timeoutMs || process.env.API_TIMEOUT_MS || '';
399
+
400
+ if (apiKey) env.ANTHROPIC_AUTH_TOKEN = String(apiKey);
401
+ if (baseUrl) env.ANTHROPIC_BASE_URL = String(baseUrl);
402
+ if (mainModel) env.ANTHROPIC_MODEL = String(mainModel);
403
+ if (thinkingModel) env.ANTHROPIC_DEFAULT_THINKING_MODEL = String(thinkingModel);
404
+ if (haikuModel) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = String(haikuModel);
405
+ if (sonnetModel) env.ANTHROPIC_DEFAULT_SONNET_MODEL = String(sonnetModel);
406
+ if (opusModel) env.ANTHROPIC_DEFAULT_OPUS_MODEL = String(opusModel);
407
+ if (timeoutMs) env.API_TIMEOUT_MS = String(timeoutMs);
408
+
409
+ return env;
410
+ }
411
+
412
+ function profileMainModel(profile) {
413
+ if (!profile || typeof profile !== 'object') return '';
414
+ return String(profile.mainModel || profile.model || '').trim();
415
+ }
416
+
417
+ function applyAiProfileToSession(session, aiProfile) {
418
+ if (!session) {
419
+ return { resetConversation: false, model: session && session.model || null };
420
+ }
421
+ if (!aiProfile || typeof aiProfile !== 'object') {
422
+ session.claudeEnv = buildClaudeEnvFromProfile(null);
423
+ session.runner = 'sdk';
424
+ return { resetConversation: false, model: session.model || null };
425
+ }
426
+ const previousProfileId = session.aiProfileId || null;
427
+ const previousModel = session.model || null;
428
+ const nextProfileId = aiProfile.id || previousProfileId || null;
429
+ const nextEnv = buildClaudeEnvFromProfile(aiProfile);
430
+ const nextModel = profileMainModel(aiProfile) || nextEnv.ANTHROPIC_MODEL || null;
431
+
432
+ const profileChanged = !!(previousProfileId && nextProfileId && previousProfileId !== nextProfileId);
433
+ const modelChanged = !!(previousModel && nextModel && previousModel !== nextModel);
434
+ const resetConversation = !!(session.claudeSessionId && (profileChanged || modelChanged));
435
+
436
+ if (resetConversation) {
437
+ emit(session, {
438
+ type: 'claude/conversation-reset',
439
+ reason: profileChanged ? 'ai-profile-changed' : 'model-changed',
440
+ fromAiProfileId: previousProfileId,
441
+ toAiProfileId: nextProfileId,
442
+ fromModel: previousModel,
443
+ toModel: nextModel,
444
+ message: 'AI profile or model changed; previous Claude conversation was cleared before the next turn.',
445
+ });
446
+ session.claudeSessionId = null;
447
+ }
448
+
449
+ session.aiProfileId = nextProfileId;
450
+ session.claudeEnv = nextEnv;
451
+ if (nextModel) session.model = nextModel;
452
+ session.runner = 'sdk';
453
+ session.contextWindow = resolveContextWindow({
454
+ profileContextWindow: aiProfile && aiProfile.contextWindow,
455
+ model: session.model || profileMainModel(aiProfile),
456
+ });
457
+ return { resetConversation, model: session.model || null };
458
+ }
459
+
460
+ // Pull the per-call SDK overrides (systemPrompt) from an AI profile.
461
+ // The legacy temperature/maxTokens profile fields were removed in the
462
+ // CC-Switch migration — they were silently dropped by the CLI path and
463
+ // are no longer wired into the SDK path either. Callers that need to
464
+ // tune sampling pass temperature/maxTokens at the call site.
465
+ function buildSdkOverridesFromProfile(profile) {
466
+ if (!profile || typeof profile !== 'object') return {};
467
+ const out = {};
468
+ // Default to the hardened Claude Code system prompt when the profile
469
+ // doesn't override — that's the whole point of switching to SDK, and it
470
+ // matches what claudecodeui does. The user can override with an object
471
+ // (e.g. {type:'preset',preset:'claude_code'}) or a plain string.
472
+ if (profile.systemPrompt !== undefined) {
473
+ out.systemPrompt = profile.systemPrompt;
474
+ } else {
475
+ out.systemPrompt = { type: 'preset', preset: 'claude_code' };
476
+ }
477
+ return out;
478
+ }
479
+
480
+ function getSession(sessionId) {
481
+ return sessions.get(sessionId) || restoreSessionFromDisk(sessionId);
482
+ }
483
+
484
+ function sessionSummary(s) {
485
+ return {
486
+ sessionId: s.sessionId,
487
+ projectSlug: s.projectSlug,
488
+ promptKey: s.promptKey,
489
+ runner: s.runner || 'cli',
490
+ state: s.state,
491
+ model: s.model,
492
+ aiProfileId: s.aiProfileId || null,
493
+ claudeSessionId: s.claudeSessionId,
494
494
  source: s.source || 'manual',
495
495
  automation: !!s.automation,
496
496
  automationRunId: s.automationRunId || null,
497
497
  legacyProjectSlug: s.legacyProjectSlug || null,
498
498
  legacySource: s.legacySource === true,
499
499
  permissionMode: s.permissionMode || 'default',
500
- startedAt: s.startedAt,
501
- endedAt: s.endedAt,
502
- exitCode: s.exitCode,
503
- turns: s.turns,
504
- pendingPermission: s.pendingPermission,
505
- restored: !!s.restored,
506
- };
507
- }
508
-
509
- function listSessions(filter = {}) {
510
- const projectSlug = filter.projectSlug || null;
511
- const byId = new Map();
512
- for (const record of scanPersistedRecords(projectSlug)) {
513
- byId.set(record.sessionId, sessionSummary({
514
- ...record,
515
- listeners: new Set(),
516
- outputBuffer: record.events || [],
517
- subprocess: null,
518
- pendingPermission: ['running', 'spawning'].includes(record.state) ? null : record.pendingPermission,
519
- restored: true,
520
- }));
521
- }
522
- for (const s of sessions.values()) {
523
- if (projectSlug && s.projectSlug !== projectSlug) continue;
524
- byId.set(s.sessionId, sessionSummary(s));
525
- }
526
- return [...byId.values()].sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
527
- }
528
-
529
- function emit(session, event) {
530
- session.outputBuffer.push(event);
531
- if (session.outputBuffer.length > 5000) session.outputBuffer.shift();
532
- for (const listener of session.listeners) {
533
- try { listener(event); } catch { /* listener may be a dead SSE */ }
534
- }
535
- persistSession(session);
536
- }
537
-
538
- function setState(session, state, extra = {}) {
539
- session.state = state;
540
- if (state === 'ended' || state === 'failed' || state === 'aborted') {
541
- session.endedAt = new Date().toISOString();
542
- }
543
- emit(session, { type: 'claude/state', state, ...extra });
544
- logSession(state === 'failed' ? 'error' : 'info', 'claude.session_state_changed', 'Claude Workbench session state changed.', session, { state, turn: session.turns, runner: session.runner || 'sdk' });
545
- _broadcastSessionChange(session, 'state');
546
- if (TERMINAL_STATES.has(state) && sessionEndedCallbacks.size > 0) {
547
- const snapshot = session;
548
- setImmediate(() => {
549
- for (const cb of sessionEndedCallbacks) {
550
- try { cb(snapshot); } catch { /* subscriber errors must not corrupt session state */ }
551
- }
552
- });
553
- }
554
- }
555
-
556
- const ACTIVE_STATES = new Set(['spawning', 'running', 'pending-permission']);
557
- const listSubscribers = new Set();
558
-
559
- function _emitList(event) {
560
- if (listSubscribers.size === 0) return;
561
- for (const cb of listSubscribers) {
562
- try { cb(event); } catch { /* dead subscriber; req.on('close') will clean up */ }
563
- }
564
- }
565
-
566
- function _broadcastSessionChange(session, kind) {
567
- if (!session) return;
568
- const summary = sessionSummary(session);
569
- _emitList({
570
- kind,
571
- sessionId: summary.sessionId,
572
- projectSlug: summary.projectSlug,
573
- state: summary.state,
574
- active: ACTIVE_STATES.has(summary.state),
575
- restored: !!summary.restored,
576
- startedAt: summary.startedAt,
577
- endedAt: summary.endedAt,
578
- exitCode: summary.exitCode,
579
- promptKey: summary.promptKey,
580
- source: summary.source,
581
- });
582
- }
583
-
584
- function subscribeList(callback) {
585
- if (typeof callback !== 'function') throw new Error('callback must be a function');
586
- listSubscribers.add(callback);
587
- return () => listSubscribers.delete(callback);
588
- }
589
-
590
- function newPermissionId() {
591
- return 'perm-' + crypto.randomBytes(6).toString('hex');
592
- }
593
-
594
- function resolvePermission(sessionId, requestId, decision) {
595
- const session = getSession(sessionId);
596
- if (!session) throw new Error(`session not found: ${sessionId}`);
597
- if (!session.pendingPermission || session.pendingPermission.requestId !== requestId) {
598
- throw new Error('permission request not found or already resolved');
599
- }
600
- const allow = decision && decision.allow === true;
601
- const resolvedAt = new Date().toISOString();
602
- emit(session, {
603
- type: 'claude/permission-resolved',
604
- requestId,
605
- allow,
606
- message: decision && decision.message || '',
607
- resolvedAt,
608
- });
609
- // SDK path: resolve the in-flight tool approval promise that was created
610
- // inside runSdkTurn's canUseTool hook. The CLI `pendingTurn` mechanism
611
- // is gone with the spawn path.
612
- if (session.pendingToolApproval && session.pendingToolApproval.requestId === requestId) {
613
- const approval = session.pendingToolApproval;
614
- session.pendingToolApproval = null;
615
- session.pendingPermission = null;
616
- setState(session, 'running', { message: allow ? 'tool permission approved' : 'tool permission denied', requestId });
617
- approval.resolve(allow
618
- ? { behavior: 'allow', updatedInput: approval.input, toolUseID: approval.toolUseID }
619
- : { behavior: 'deny', message: decision && decision.message || 'User denied tool use', toolUseID: approval.toolUseID });
620
- persistSession(session);
621
- return { ok: true, started: true, toolPermission: true };
622
- }
623
- if (!allow) {
624
- session.pendingPermission = { ...session.pendingPermission, status: 'denied', resolvedAt };
625
- setState(session, 'idle', { message: 'permission denied', requestId });
626
- session.pendingPermission = null;
627
- persistSession(session);
628
- return { ok: true, started: false };
629
- }
630
- // Legacy CLI fallback — kept so old session records on disk don't crash
631
- // if they're restored after the migration. New sessions never set
632
- // pendingTurn.
633
- if (session.pendingTurn) {
634
- const pending = session.pendingTurn;
635
- session.pendingTurn = null;
636
- setState(session, 'idle', { message: 'legacy CLI pending turn — please resend your message', requestId });
637
- persistSession(session);
638
- return { ok: true, started: false, legacy: true };
639
- }
640
- persistSession(session);
641
- return { ok: true, started: true };
642
- }
643
-
644
- // ---- NDJSON parsing (used by the SDK message stream) ----
645
- function handleNdjsonLine(session, line, toolInputBuffers) {
646
- let parsed;
647
- try {
648
- parsed = JSON.parse(line);
649
- } catch {
650
- emit(session, { type: 'claude/raw', text: line });
651
- return;
652
- }
653
-
654
- const t = parsed.type;
655
-
656
- if (t === 'system' && parsed.subtype === 'init') {
657
- if (parsed.session_id) session.claudeSessionId = parsed.session_id;
658
- if (parsed.model) session.model = typeof parsed.model === 'string' ? parsed.model : (parsed.model.id || parsed.model.name || null);
659
- emit(session, {
660
- type: 'claude/init',
661
- claudeSessionId: session.claudeSessionId,
662
- model: session.model,
663
- aiProfileId: session.aiProfileId || null,
664
- tools: parsed.tools || [],
665
- mcpServers: parsed.mcp_servers || [],
666
- });
667
- return;
668
- }
669
-
670
- if (t === 'stream_event' && parsed.event) {
671
- const ev = parsed.event;
672
- if (ev.type === 'message_start' && ev.message) {
673
- emit(session, { type: 'claude/message-start', role: ev.message.role });
674
- if (ev.message.usage) {
675
- emit(session, { type: 'claude/usage', usage: ev.message.usage });
676
- }
677
- return;
678
- }
679
- if (ev.type === 'content_block_start' && ev.content_block) {
680
- const cb = ev.content_block;
681
- if (cb.type === 'tool_use') {
682
- // Initialize the per-block input accumulator; emit nothing yet (input is empty).
683
- // The full input arrives via input_json_delta events and is emitted on content_block_stop.
684
- if (cb.id) toolInputBuffers.set(cb.id, '');
685
- emit(session, { type: 'claude/tool-use-start', id: cb.id, name: cb.name });
686
- return;
687
- }
688
- if (cb.type === 'thinking') {
689
- emit(session, { type: 'claude/thinking-start', id: cb.id });
690
- return;
691
- }
692
- return;
693
- }
694
- if (ev.type === 'content_block_delta' && ev.delta) {
695
- const d = ev.delta;
696
- if (d.type === 'text_delta') {
697
- emit(session, { type: 'claude/text-delta', text: d.text });
698
- return;
699
- }
700
- if (d.type === 'input_json_delta') {
701
- // Accumulate into the tool-use block; also emit a low-level delta for live UIs.
702
- if (ev.index != null && toolInputBuffers) {
703
- // We don't know block id here; emit raw delta
704
- }
705
- emit(session, { type: 'claude/tool-input-delta', text: d.partial_json });
706
- return;
707
- }
708
- if (d.type === 'thinking_delta') {
709
- emit(session, { type: 'claude/thinking-delta', text: d.thinking });
710
- return;
711
- }
712
- return;
713
- }
714
- if (ev.type === 'content_block_stop') {
715
- // Block ended — but we don't reliably know which block. Tool input emission is
716
- // best-effort: the assistant message envelope at message_delta carries final tools.
717
- return;
718
- }
719
- if (ev.type === 'message_delta' && ev.delta) {
720
- if (ev.delta.stop_reason) {
721
- emit(session, { type: 'claude/message-stop', stopReason: ev.delta.stop_reason });
722
- }
723
- if (ev.usage) {
724
- emit(session, { type: 'claude/usage', usage: ev.usage });
725
- }
726
- return;
727
- }
728
- if (ev.type === 'message_stop') {
729
- return;
730
- }
731
- return;
732
- }
733
-
734
- if (t === 'user' || t === 'assistant') {
735
- // On assistant turn boundaries, the full message (with completed tool_use blocks)
736
- // appears here. Emit any tool-use blocks we haven't fully captured yet.
737
- if (t === 'assistant' && parsed.message && Array.isArray(parsed.message.content)) {
738
- for (const block of parsed.message.content) {
739
- if (block && block.type === 'tool_use') {
740
- emit(session, {
741
- type: 'claude/tool-use',
742
- id: block.id,
743
- name: block.name,
744
- input: block.input || {},
745
- });
746
- }
747
- }
748
- }
749
- return;
750
- }
751
-
752
- if (t === 'result') {
753
- const event = {
754
- type: 'claude/result',
755
- subtype: parsed.subtype || null,
756
- costUsd: parsed.total_cost_usd,
757
- durationMs: parsed.duration_ms,
758
- result: typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result),
759
- isError: parsed.is_error === true,
760
- usage: parsed.usage || null,
761
- };
762
- emit(session, event);
763
- return event;
764
- }
765
-
766
- emit(session, { type: 'claude/raw', json: parsed });
767
- }
768
-
769
- // ---- public API ----
770
-
771
- // Start a Claude Code SDK session for the FIRST turn (initial analysis).
772
- // The legacy `claude -p` subprocess path was removed — the SDK is the
773
- // only supported mode and applies the hardened claude_code system prompt.
774
- function startSession({ slug, projectPath, kbPath, promptKey, vars, aiProfile }) {
775
- const rendered = renderPrompt(promptKey, vars || {});
776
- if (!rendered) {
777
- throw new Error(`unknown prompt key: ${promptKey}`);
778
- }
779
- const session = createSession({ projectSlug: slug, projectPath, kbPath, promptKey });
780
- applyAiProfileToSession(session, aiProfile);
781
- _broadcastSessionChange(session, 'create');
782
- session.permissionMode = normalizePermissionMode(rendered.permissionMode);
783
- const sdkOverrides = buildSdkOverridesFromProfile(aiProfile);
784
- emit(session, {
785
- type: 'claude/system-prompt',
786
- text: rendered.systemPrompt,
787
- promptKey,
788
- aiProfileId: session.aiProfileId,
789
- });
790
- emit(session, {
791
- type: 'claude/user-prompt',
792
- text: rendered.userPrompt,
793
- promptKey,
794
- isInitial: true,
795
- });
796
- const turnOpts = {
797
- userPrompt: rendered.userPrompt,
798
- systemPrompt: rendered.systemPrompt,
799
- ...sdkOverrides,
800
- model: rendered.model,
801
- permissionMode: session.permissionMode,
802
- allowedTools: rendered.allowedTools,
803
- cwd: session.kbPath,
804
- env: session.claudeEnv,
805
- resumeSessionId: null,
806
- };
807
- startSdkTurn(session, turnOpts, false);
808
- return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk' };
809
- }
810
-
811
- function startChatSession({ slug, projectPath, kbPath, aiProfile, permissionMode = 'default' }) {
812
- if (!slug) throw new Error('slug required');
813
- if (!projectPath) throw new Error('projectPath required');
814
- const session = createSession({
815
- projectSlug: slug,
816
- projectPath,
817
- kbPath,
818
- promptKey: 'terminal-chat',
819
- source: 'terminal',
820
- });
821
- applyAiProfileToSession(session, aiProfile);
822
- session.permissionMode = normalizePermissionMode(permissionMode);
823
- emit(session, {
824
- type: 'claude/session-ready',
825
- promptKey: session.promptKey,
826
- aiProfileId: session.aiProfileId,
827
- permissionMode: session.permissionMode,
828
- message: 'Claude terminal session is ready. Send a message to start the first turn.',
829
- });
830
- _broadcastSessionChange(session, 'create');
831
- return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk' };
832
- }
833
-
834
- function startAutomationSession({
835
- slug,
836
- projectPath,
837
- kbPath,
838
- userPrompt,
839
- systemPrompt,
840
- aiProfile,
841
- permissionMode = 'default',
842
- allowedTools = ['Read', 'Grep', 'Glob'],
843
- safetyPolicy = null,
844
- metadata = {},
845
- }) {
846
- if (!slug) throw new Error('slug required');
847
- if (typeof userPrompt !== 'string' || !userPrompt.trim()) throw new Error('userPrompt required');
848
- const session = createSession({
849
- projectSlug: slug,
850
- projectPath,
851
- kbPath,
852
- promptKey: 'post-commit-automation',
853
- source: metadata.source || 'git-hook',
854
- metadata,
855
- });
856
- applyAiProfileToSession(session, aiProfile);
857
- _broadcastSessionChange(session, 'create');
858
- session.permissionMode = normalizePermissionMode(permissionMode);
859
- session.safetyPolicy = safetyPolicy || null;
860
- session.automation = true;
861
- session.automationRunId = metadata.automationRunId || null;
862
- const sdkOverrides = buildSdkOverridesFromProfile(aiProfile);
863
- const effectiveSystemPrompt = systemPrompt || sdkOverrides.systemPrompt || { type: 'preset', preset: 'claude_code' };
864
- emit(session, {
865
- type: 'claude/system-prompt',
866
- text: effectiveSystemPrompt,
867
- promptKey: session.promptKey,
868
- aiProfileId: session.aiProfileId,
869
- automation: true,
870
- });
871
- emit(session, {
872
- type: 'claude/user-prompt',
873
- text: userPrompt,
874
- promptKey: session.promptKey,
875
- isInitial: true,
876
- automation: true,
877
- });
878
- if (process.env.KB_AUTOMATION_FAKE_CLAUDE === '1') {
879
- const commitHash = String(metadata.commitHash || '');
880
- if (commitHash && kbPath) {
881
- const changesDir = path.join(kbPath, 'changes');
882
- fs.mkdirSync(changesDir, { recursive: true });
883
- fs.writeFileSync(path.join(changesDir, `fake-${commitHash.slice(0, 7)}.md`),
884
- `---\ncommit: ${commitHash}\n---\n\n# Fake automation ${commitHash.slice(0, 7)}\n`, 'utf8');
885
- }
886
- emit(session, { type: 'claude/result', result: 'fake automation completed', automation: true });
887
- setState(session, 'idle', { exitCode: 0, message: 'fake automation complete' });
888
- return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk', fake: true };
889
- }
890
- startSdkTurn(session, {
891
- userPrompt,
892
- systemPrompt: effectiveSystemPrompt,
893
- model: null,
894
- permissionMode: session.permissionMode,
895
- allowedTools,
896
- cwd: session.kbPath,
897
- resumeSessionId: null,
898
- env: session.claudeEnv,
899
- safetyPolicy,
900
- }, false);
901
- return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk' };
902
- }
903
-
904
- // Send a follow-up turn via the SDK. With a non-null claudeSessionId,
905
- // runs as a resume. With a null claudeSessionId (e.g. after a kbPath
906
- // change cleared the stale id), starts a fresh session while reusing
907
- // the same session record.
908
- async function sendInput(sessionId, text, aiProfile = null, opts = {}) {
909
- const session = getSession(sessionId);
910
- if (!session) throw new Error(`session not found: ${sessionId}`);
911
- if (session.subprocess) {
912
- throw new Error(`a subprocess is already running on session ${sessionId}`);
913
- }
914
- if (aiProfile) {
915
- applyAiProfileToSession(session, aiProfile);
916
- }
917
- const isFresh = !session.claudeSessionId;
918
- logSession('info', 'claude.input_accepted', 'Claude Workbench input accepted.', session, {
919
- inputHash: `sha256:${crypto.createHash('sha256').update(String(text), 'utf8').digest('hex')}`,
920
- inputLength: String(text).length,
921
- isFollowUp: !isFresh,
922
- });
923
- emit(session, { type: 'claude/user-prompt', text, isFollowUp: !isFresh, isFresh });
924
- const sdkOverrides = buildSdkOverridesFromProfile(aiProfile);
925
- session.permissionMode = normalizePermissionMode(opts.permissionMode || session.permissionMode || 'default');
926
- const envModel = session.claudeEnv && session.claudeEnv.ANTHROPIC_MODEL
927
- ? String(session.claudeEnv.ANTHROPIC_MODEL)
928
- : null;
929
- const turnOpts = {
930
- userPrompt: text,
931
- model: envModel || session.model || null,
932
- permissionMode: session.permissionMode,
933
- allowedTools: opts.allowedTools || ['Read', 'Grep', 'Glob', 'Bash', 'Edit'],
934
- cwd: session.kbPath,
935
- resumeSessionId: session.claudeSessionId,
936
- systemPrompt: sdkOverrides.systemPrompt || null,
937
- env: session.claudeEnv,
938
- };
939
- startSdkTurn(session, turnOpts, !isFresh);
940
- return { started: true, pendingPermission: null, runner: 'sdk' };
941
- }
942
-
943
- // Compute accumulated token usage for a session from its persisted
944
- // claude/usage events. Returns { used, total, hasUsage }.
945
- // Uses a max-not-sum strategy because the SDK emits usage on each
946
- // message_start (initial input_tokens) and each message_delta (output
947
- // deltas). Summing would double-count. hasUsage is false when the SDK
948
- // has not yet emitted any usage block — the caller renders "—" in that
949
- // case rather than guessing 0.0%.
950
- function getSessionTokenUsage(sessionId) {
951
- const session = getSession(sessionId);
952
- const fallbackTotal = Number(process.env.CLAUDE_CONTEXT_WINDOW) || 200000;
953
- if (!session) return { used: 0, total: fallbackTotal, hasUsage: false };
954
- const ctxWin = Number(session.contextWindow);
955
- const total = Number.isFinite(ctxWin) && ctxWin > 0 ? Math.floor(ctxWin) : fallbackTotal;
956
- let used = 0;
957
- let sawUsage = false;
958
- for (const ev of session.outputBuffer || []) {
959
- if (ev && ev.type === 'claude/usage' && ev.usage) {
960
- sawUsage = true;
961
- const turn = Number(ev.usage.input_tokens || 0)
962
- + Number(ev.usage.output_tokens || 0)
963
- + Number(ev.usage.cache_creation_input_tokens || 0)
964
- + Number(ev.usage.cache_read_input_tokens || 0);
965
- if (turn > used) used = turn;
966
- }
967
- }
968
- return { used, total, hasUsage: sawUsage };
969
- }
970
-
971
- function findClaudeExecutable() {
972
- // Prefer the .exe path the running Claude Code sets in the env — bypasses cmd.exe shim
973
- // entirely and avoids Windows shell quoting pitfalls on long prompts.
974
- if (process.env.CLAUDE_CODE_EXECPATH) {
975
- return { cmd: process.env.CLAUDE_CODE_EXECPATH, shell: false };
976
- }
977
- // Fallbacks
978
- if (process.platform === 'win32') {
979
- const npmRoot = process.env.APPDATA && require('path').join(process.env.APPDATA, 'npm');
980
- if (npmRoot) {
981
- const candidates = [
982
- require('path').join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
983
- require('path').join(npmRoot, 'claude.cmd'),
984
- ];
985
- const fs = require('fs');
986
- for (const c of candidates) {
987
- if (fs.existsSync(c)) return { cmd: c, shell: c.endsWith('.cmd') };
988
- }
989
- }
990
- return { cmd: 'claude', shell: true }; // last resort
991
- }
992
- return { cmd: 'claude', shell: false };
993
- }
994
-
995
- async function findClaudeExecutableForSdk(options = {}) {
996
- const env = options.env || process.env;
997
- const platform = options.platform || process.platform;
998
- const runtimeExecutable = String(options.runtimeExecutable || process.execPath || '').trim();
999
- const exists = options.exists || fs.existsSync;
1000
- const runCommand = options.runCommand || ((command, args) => new Promise((resolve) => {
1001
- const child = spawn(command, args, { encoding: 'utf8', windowsHide: true });
1002
- let stdout = '', stderr = '';
1003
- const killer = setTimeout(() => { child.kill(); resolve({ status: null, stdout, stderr, error: 'timeout' }); }, 3000);
1004
- child.stdout.on('data', d => { stdout += d.toString('utf8'); });
1005
- child.stderr.on('data', d => { stderr += d.toString('utf8'); });
1006
- child.on('error', e => { clearTimeout(killer); resolve({ status: null, stdout, stderr, error: e.message }); });
1007
- child.on('close', code => { clearTimeout(killer); resolve({ status: code, stdout, stderr, error: null }); });
1008
- }));
1009
- const clean = file => String(file || '').trim().replace(/^"|"$/g, '');
1010
- const isWindowsShellShim = file => {
1011
- const candidate = clean(file);
1012
- if (!candidate) return false;
1013
- const extension = path.extname(candidate).toLowerCase();
1014
- return !extension || ['.cmd', '.bat', '.ps1'].includes(extension);
1015
- };
1016
- const isSdkCompatibleWindowsFile = file => {
1017
- const extension = path.extname(clean(file)).toLowerCase();
1018
- return ['.exe', '.js', '.mjs', '.ts', '.tsx', '.jsx'].includes(extension);
1019
- };
1020
- const usable = file => {
1021
- const candidate = clean(file);
1022
- if (!candidate || !exists(candidate)) return null;
1023
- if (platform === 'win32' && !isSdkCompatibleWindowsFile(candidate)) return null;
1024
- return candidate;
1025
- };
1026
- const npmCliForShim = file => {
1027
- const npmRoot = path.dirname(clean(file));
1028
- const candidates = [
1029
- path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
1030
- path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
1031
- ];
1032
- return candidates.map(usable).find(Boolean) || null;
1033
- };
1034
- const resolveCandidate = file => {
1035
- const candidate = clean(file);
1036
- if (!candidate) return null;
1037
- if (platform === 'win32' && isWindowsShellShim(candidate)) return npmCliForShim(candidate);
1038
- return usable(candidate);
1039
- };
1040
- const launchInfo = (candidate, source) => {
1041
- const extension = path.extname(clean(candidate)).toLowerCase();
1042
- const needsRuntime = ['.js', '.mjs', '.cjs'].includes(extension);
1043
- return {
1044
- cmd: candidate,
1045
- shell: false,
1046
- source,
1047
- runtimeExecutable: needsRuntime ? runtimeExecutable : null,
1048
- };
1049
- };
1050
-
1051
- const configured = resolveCandidate(env.CLAUDE_CODE_EXECPATH);
1052
- if (configured) return launchInfo(configured, 'CLAUDE_CODE_EXECPATH');
1053
-
1054
- if (platform === 'win32') {
1055
- let discovered = [];
1056
- try {
1057
- const result = runCommand('where.exe', ['claude']);
1058
- if (result && result.status === 0) discovered = String(result.stdout || '').split(/\r?\n/).filter(Boolean);
1059
- } catch {
1060
- // PATH discovery falls through to the remaining supported executable locations.
1061
- }
1062
- for (const found of discovered) {
1063
- const candidate = resolveCandidate(found);
1064
- if (candidate) return launchInfo(candidate, 'PATH');
1065
- }
1066
-
1067
- const npmRoot = env.APPDATA && path.join(env.APPDATA, 'npm');
1068
- if (npmRoot) {
1069
- const candidates = [
1070
- path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
1071
- path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
1072
- ];
1073
- for (const file of candidates) {
1074
- const candidate = usable(file);
1075
- if (candidate) return launchInfo(candidate, 'npm-global');
1076
- }
1077
- }
1078
- return {
1079
- cmd: null,
1080
- shell: false,
1081
- reason: 'Claude Code was not found. Install Claude Code on this computer or set CLAUDE_CODE_EXECPATH.',
1082
- };
1083
- }
1084
- return { cmd: clean(env.CLAUDE_CODE_EXECPATH) || 'claude', shell: false, source: 'PATH' };
1085
- }
1086
-
1087
- function spawnClaude(session, opts) {
1088
- // Legacy `claude -p` subprocess path was removed in the CC-Switch
1089
- // migration; the SDK is the only supported entry point. This stub is
1090
- // kept only so any stale caller still gets a clear error rather than a
1091
- // silent no-op. New code uses startSdkTurn directly.
1092
- throw new Error('spawnClaude is no longer supported — use the Claude Agent SDK');
1093
- }
1094
-
1095
- async function runSdkTurn(session, opts, isResume) {
1096
- const { query } = await import('@anthropic-ai/claude-agent-sdk');
1097
- const toolInputBuffers = new Map();
1098
- const sdkPermissionMode = normalizePermissionMode(opts.permissionMode || session.permissionMode || 'default');
1099
- session.permissionMode = sdkPermissionMode;
1100
- const allowedTools = Array.isArray(opts.allowedTools) ? opts.allowedTools : [];
1101
- const claudeBin = await findClaudeExecutableForSdk();
1102
- if (!claudeBin.cmd) throw new Error(claudeBin.reason);
1103
- const sdkOptions = {
1104
- cwd: opts.cwd,
1105
- env: { ...process.env, ...(opts.env || {}), FORCE_COLOR: '0', NO_COLOR: '1' },
1106
- model: opts.model,
1107
- tools: { type: 'preset', preset: 'claude_code' },
1108
- allowedTools,
1109
- disallowedTools: [],
1110
- settingSources: ['project', 'user', 'local'],
1111
- includePartialMessages: true,
1112
- systemPrompt: opts.systemPrompt || { type: 'preset', preset: 'claude_code' },
1113
- permissionMode: sdkPermissionMode,
1114
- allowDangerouslySkipPermissions: sdkPermissionMode === 'bypassPermissions' && !opts.safetyPolicy,
1115
- canUseTool: async (toolName, input, context = {}) => {
1116
- const toolUseID = context.toolUseID || null;
1117
- if (opts.safetyPolicy) {
1118
- const decision = evaluateAutomationToolUse(opts.safetyPolicy, toolName, input);
1119
- emit(session, {
1120
- type: 'claude/tool-policy',
1121
- toolName,
1122
- decision: decision.behavior,
1123
- reason: decision.reason,
1124
- automation: !!session.automation,
1125
- });
1126
- if (decision.behavior === 'allow') {
1127
- return { behavior: 'allow', updatedInput: input, toolUseID };
1128
- }
1129
- return { behavior: 'deny', message: decision.reason || 'Denied by automation safety policy', toolUseID };
1130
- }
1131
- if (sdkPermissionMode === 'bypassPermissions') {
1132
- return { behavior: 'allow', updatedInput: input, toolUseID };
1133
- }
1134
- if (sdkPermissionMode === 'acceptEdits' && ['Edit', 'Write', 'MultiEdit'].includes(toolName)) {
1135
- return { behavior: 'allow', updatedInput: input, toolUseID };
1136
- }
1137
- if (sdkPermissionMode === 'plan' && ['Edit', 'Write', 'MultiEdit', 'Bash'].includes(toolName)) {
1138
- return { behavior: 'deny', message: 'Plan mode does not allow file edits or shell commands', toolUseID };
1139
- }
1140
- const requestId = newPermissionId();
1141
- const summary = {
1142
- turnKind: 'tool-use',
1143
- isResume,
1144
- cwd: opts.cwd || '',
1145
- model: opts.model || null,
1146
- permissionMode: sdkOptions.permissionMode,
1147
- allowedTools: [toolName],
1148
- toolName,
1149
- toolUseID,
1150
- title: context.title || context.displayName || toolName,
1151
- description: context.description || context.decisionReason || '',
1152
- promptPreview: JSON.stringify(input || {}).slice(0, 500),
1153
- };
1154
- session.pendingPermission = {
1155
- requestId,
1156
- status: 'pending',
1157
- createdAt: new Date().toISOString(),
1158
- summary,
1159
- };
1160
- setState(session, 'pending-permission', { requestId, toolName, toolUseID: context.toolUseID || null });
1161
- emit(session, { type: 'claude/permission-request', ...session.pendingPermission });
1162
- return await new Promise((resolve) => {
1163
- const cancel = () => {
1164
- if (session.pendingToolApproval && session.pendingToolApproval.requestId === requestId) {
1165
- session.pendingToolApproval = null;
1166
- session.pendingPermission = null;
1167
- resolve({ behavior: 'deny', message: 'Permission request cancelled', toolUseID });
1168
- }
1169
- };
1170
- if (context.signal) {
1171
- if (context.signal.aborted) return cancel();
1172
- context.signal.addEventListener('abort', cancel, { once: true });
1173
- }
1174
- session.pendingToolApproval = {
1175
- requestId,
1176
- resolve,
1177
- input,
1178
- toolUseID,
1179
- };
1180
- });
1181
- },
1182
- };
1183
- sdkOptions.pathToClaudeCodeExecutable = claudeBin.cmd;
1184
- // The desktop backend runs through Electron with ELECTRON_RUN_AS_NODE=1.
1185
- // Use that exact executable (or the current node binary in CLI mode) for a
1186
- // JavaScript Claude entry point instead of relying on `node` being present
1187
- // in the desktop process PATH.
1188
- if (claudeBin.runtimeExecutable) sdkOptions.executable = claudeBin.runtimeExecutable;
1189
- if (opts.resumeSessionId) sdkOptions.resume = opts.resumeSessionId;
1190
-
1191
- try {
1192
- const retries = maxSdkRetries();
1193
- for (let attempt = 0; attempt <= retries; attempt += 1) {
1194
- const attemptStartedAt = Date.now();
1195
- let queryInstance;
1196
- const attemptToolInputBuffers = attempt === 0 ? toolInputBuffers : new Map();
1197
- try {
1198
- logSession('info', 'claude.sdk_attempt_started', 'Claude SDK attempt started.', session, { attempt: attempt + 1, maxAttempts: retries + 1, isResume });
1199
- queryInstance = query({ prompt: opts.userPrompt, options: sdkOptions });
1200
- session.subprocess = { kill: () => queryInstance.close && queryInstance.close() };
1201
- let errorResult = null;
1202
- for await (const message of queryInstance) {
1203
- if (message.session_id) session.claudeSessionId = message.session_id;
1204
- const event = handleNdjsonLine(session, JSON.stringify(message), attemptToolInputBuffers);
1205
- if (event && event.type === 'claude/result' && event.isError) {
1206
- errorResult = event.result || 'Claude Code returned an error result';
1207
- }
1208
- }
1209
- session.subprocess = null;
1210
- if (errorResult) {
1211
- throw new Error(`Claude Code returned an error result: ${errorResult}`);
1212
- }
1213
- if (session.state !== 'aborted' && session.state !== 'failed') {
1214
- setState(session, 'idle', { exitCode: 0, message: isResume ? 'follow-up complete' : 'turn complete, awaiting input or new analysis' });
1215
- emit(session, { type: 'claude/turn-end', exitCode: 0 });
1216
- }
1217
- logSession('info', 'claude.sdk_attempt_completed', 'Claude SDK attempt completed.', session, { attempt: attempt + 1, maxAttempts: retries + 1, durationMs: Date.now() - attemptStartedAt });
1218
- return;
1219
- } catch (e) {
1220
- session.subprocess = null;
1221
- if (session.state === 'aborted') return;
1222
- if (attempt < retries && isTransientClaudeError(e)) {
1223
- const delayMs = sdkRetryDelayMs(attempt + 1);
1224
- const message = normalizeErrorMessage(e);
1225
- emit(session, {
1226
- type: 'claude/retry',
1227
- attempt: attempt + 1,
1228
- maxRetries: retries,
1229
- delayMs,
1230
- message,
1231
- });
1232
- logSession('warn', 'claude.sdk_attempt_retrying', 'Claude SDK attempt will retry after a transient failure.', session, {
1233
- attempt: attempt + 1,
1234
- maxAttempts: retries + 1,
1235
- delayMs,
1236
- durationMs: Date.now() - attemptStartedAt,
1237
- failureHash: `sha256:${crypto.createHash('sha256').update(message, 'utf8').digest('hex')}`,
1238
- });
1239
- setState(session, 'running', {
1240
- turn: session.turns,
1241
- runner: 'sdk',
1242
- retryAttempt: attempt + 1,
1243
- maxRetries: retries,
1244
- });
1245
- await sleep(delayMs);
1246
- if (session.state === 'aborted') return;
1247
- continue;
1248
- }
1249
- throw e;
1250
- }
1251
- }
1252
- } catch (e) {
1253
- session.error = e.message;
1254
- emit(session, { type: 'claude/error', message: e.message });
1255
- setState(session, 'failed', { error: e.message });
1256
- logSession('error', 'claude.sdk_turn_failed', 'Claude SDK turn failed.', session, {
1257
- failureHash: `sha256:${crypto.createHash('sha256').update(normalizeErrorMessage(e), 'utf8').digest('hex')}`,
1258
- }, Object.assign(new Error('Claude SDK turn failed.'), { code: 'CLAUDE_SDK_FAILED' }));
1259
- } finally {
1260
- session.subprocess = null;
1261
- session.pendingToolApproval = null;
1262
- session.pendingPermission = null;
1263
- persistSession(session);
1264
- }
1265
- }
1266
-
1267
- function startSdkTurn(session, opts, isResume) {
1268
- session.pendingPermission = null;
1269
- session.pendingTurn = null;
1270
- setState(session, 'spawning', { turn: session.turns + 1, runner: 'sdk' });
1271
- session.turns += 1;
1272
- setState(session, 'running', { turn: session.turns, runner: 'sdk' });
1273
- runSdkTurn(session, opts, isResume).catch(e => {
1274
- session.error = e.message;
1275
- emit(session, { type: 'claude/error', message: e.message });
1276
- setState(session, 'failed', { error: e.message });
1277
- });
1278
- }
1279
-
1280
- function abort(sessionId) {
1281
- const session = getSession(sessionId);
1282
- if (!session) throw new Error(`session not found: ${sessionId}`);
1283
- logSession('warn', 'claude.session_abort_requested', 'Claude Workbench session abort requested.', session, { state: session.state });
1284
- if (session.subprocess) {
1285
- try {
1286
- session.subprocess.kill();
1287
- } catch {
1288
- // The subprocess may already have exited; state is still transitioned to aborted below.
1289
- }
1290
- setState(session, 'aborted', { reason: 'user-abort' });
1291
- emit(session, { type: 'claude/aborted', reason: 'user-abort' });
1292
- } else {
1293
- session.pendingPermission = null;
1294
- session.pendingTurn = null;
1295
- setState(session, 'aborted', { reason: 'no-active-subprocess' });
1296
- }
1297
- }
1298
-
1299
- // Subscribe to live events. Callback receives each event as it arrives.
1300
- // Late subscribers also get replayed outputBuffer.
1301
- function subscribe(sessionId, onEvent) {
1302
- const session = getSession(sessionId);
1303
- if (!session) throw new Error(`session not found: ${sessionId}`);
1304
- // replay buffer first
1305
- for (const ev of session.outputBuffer) {
1306
- try { onEvent(ev); } catch {
1307
- // A disconnected subscriber must not corrupt the persisted session.
1308
- }
1309
- }
1310
- session.listeners.add(onEvent);
1311
- return () => {
1312
- session.listeners.delete(onEvent);
1313
- };
1314
- }
1315
-
1316
- function getState(sessionId) {
1317
- const s = getSession(sessionId);
1318
- if (!s) return null;
1319
- return {
1320
- sessionId: s.sessionId,
1321
- projectSlug: s.projectSlug,
1322
- promptKey: s.promptKey,
1323
- runner: s.runner || 'cli',
1324
- state: s.state,
1325
- model: s.model,
1326
- aiProfileId: s.aiProfileId || null,
1327
- claudeSessionId: s.claudeSessionId,
500
+ startedAt: s.startedAt,
501
+ endedAt: s.endedAt,
502
+ exitCode: s.exitCode,
503
+ turns: s.turns,
504
+ pendingPermission: s.pendingPermission,
505
+ restored: !!s.restored,
506
+ };
507
+ }
508
+
509
+ function listSessions(filter = {}) {
510
+ const projectSlug = filter.projectSlug || null;
511
+ const byId = new Map();
512
+ for (const record of scanPersistedRecords(projectSlug)) {
513
+ byId.set(record.sessionId, sessionSummary({
514
+ ...record,
515
+ listeners: new Set(),
516
+ outputBuffer: record.events || [],
517
+ subprocess: null,
518
+ pendingPermission: ['running', 'spawning'].includes(record.state) ? null : record.pendingPermission,
519
+ restored: true,
520
+ }));
521
+ }
522
+ for (const s of sessions.values()) {
523
+ if (projectSlug && s.projectSlug !== projectSlug) continue;
524
+ byId.set(s.sessionId, sessionSummary(s));
525
+ }
526
+ return [...byId.values()].sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
527
+ }
528
+
529
+ function emit(session, event) {
530
+ session.outputBuffer.push(event);
531
+ if (session.outputBuffer.length > 5000) session.outputBuffer.shift();
532
+ for (const listener of session.listeners) {
533
+ try { listener(event); } catch { /* listener may be a dead SSE */ }
534
+ }
535
+ persistSession(session);
536
+ }
537
+
538
+ function setState(session, state, extra = {}) {
539
+ session.state = state;
540
+ if (state === 'ended' || state === 'failed' || state === 'aborted') {
541
+ session.endedAt = new Date().toISOString();
542
+ }
543
+ emit(session, { type: 'claude/state', state, ...extra });
544
+ logSession(state === 'failed' ? 'error' : 'info', 'claude.session_state_changed', 'Claude Workbench session state changed.', session, { state, turn: session.turns, runner: session.runner || 'sdk' });
545
+ _broadcastSessionChange(session, 'state');
546
+ if (TERMINAL_STATES.has(state) && sessionEndedCallbacks.size > 0) {
547
+ const snapshot = session;
548
+ setImmediate(() => {
549
+ for (const cb of sessionEndedCallbacks) {
550
+ try { cb(snapshot); } catch { /* subscriber errors must not corrupt session state */ }
551
+ }
552
+ });
553
+ }
554
+ }
555
+
556
+ const ACTIVE_STATES = new Set(['spawning', 'running', 'pending-permission']);
557
+ const listSubscribers = new Set();
558
+
559
+ function _emitList(event) {
560
+ if (listSubscribers.size === 0) return;
561
+ for (const cb of listSubscribers) {
562
+ try { cb(event); } catch { /* dead subscriber; req.on('close') will clean up */ }
563
+ }
564
+ }
565
+
566
+ function _broadcastSessionChange(session, kind) {
567
+ if (!session) return;
568
+ const summary = sessionSummary(session);
569
+ _emitList({
570
+ kind,
571
+ sessionId: summary.sessionId,
572
+ projectSlug: summary.projectSlug,
573
+ state: summary.state,
574
+ active: ACTIVE_STATES.has(summary.state),
575
+ restored: !!summary.restored,
576
+ startedAt: summary.startedAt,
577
+ endedAt: summary.endedAt,
578
+ exitCode: summary.exitCode,
579
+ promptKey: summary.promptKey,
580
+ source: summary.source,
581
+ });
582
+ }
583
+
584
+ function subscribeList(callback) {
585
+ if (typeof callback !== 'function') throw new Error('callback must be a function');
586
+ listSubscribers.add(callback);
587
+ return () => listSubscribers.delete(callback);
588
+ }
589
+
590
+ function newPermissionId() {
591
+ return 'perm-' + crypto.randomBytes(6).toString('hex');
592
+ }
593
+
594
+ function resolvePermission(sessionId, requestId, decision) {
595
+ const session = getSession(sessionId);
596
+ if (!session) throw new Error(`session not found: ${sessionId}`);
597
+ if (!session.pendingPermission || session.pendingPermission.requestId !== requestId) {
598
+ throw new Error('permission request not found or already resolved');
599
+ }
600
+ const allow = decision && decision.allow === true;
601
+ const resolvedAt = new Date().toISOString();
602
+ emit(session, {
603
+ type: 'claude/permission-resolved',
604
+ requestId,
605
+ allow,
606
+ message: decision && decision.message || '',
607
+ resolvedAt,
608
+ });
609
+ // SDK path: resolve the in-flight tool approval promise that was created
610
+ // inside runSdkTurn's canUseTool hook. The CLI `pendingTurn` mechanism
611
+ // is gone with the spawn path.
612
+ if (session.pendingToolApproval && session.pendingToolApproval.requestId === requestId) {
613
+ const approval = session.pendingToolApproval;
614
+ session.pendingToolApproval = null;
615
+ session.pendingPermission = null;
616
+ setState(session, 'running', { message: allow ? 'tool permission approved' : 'tool permission denied', requestId });
617
+ approval.resolve(allow
618
+ ? { behavior: 'allow', updatedInput: approval.input, toolUseID: approval.toolUseID }
619
+ : { behavior: 'deny', message: decision && decision.message || 'User denied tool use', toolUseID: approval.toolUseID });
620
+ persistSession(session);
621
+ return { ok: true, started: true, toolPermission: true };
622
+ }
623
+ if (!allow) {
624
+ session.pendingPermission = { ...session.pendingPermission, status: 'denied', resolvedAt };
625
+ setState(session, 'idle', { message: 'permission denied', requestId });
626
+ session.pendingPermission = null;
627
+ persistSession(session);
628
+ return { ok: true, started: false };
629
+ }
630
+ // Legacy CLI fallback — kept so old session records on disk don't crash
631
+ // if they're restored after the migration. New sessions never set
632
+ // pendingTurn.
633
+ if (session.pendingTurn) {
634
+ const pending = session.pendingTurn;
635
+ session.pendingTurn = null;
636
+ setState(session, 'idle', { message: 'legacy CLI pending turn — please resend your message', requestId });
637
+ persistSession(session);
638
+ return { ok: true, started: false, legacy: true };
639
+ }
640
+ persistSession(session);
641
+ return { ok: true, started: true };
642
+ }
643
+
644
+ // ---- NDJSON parsing (used by the SDK message stream) ----
645
+ function handleNdjsonLine(session, line, toolInputBuffers) {
646
+ let parsed;
647
+ try {
648
+ parsed = JSON.parse(line);
649
+ } catch {
650
+ emit(session, { type: 'claude/raw', text: line });
651
+ return;
652
+ }
653
+
654
+ const t = parsed.type;
655
+
656
+ if (t === 'system' && parsed.subtype === 'init') {
657
+ if (parsed.session_id) session.claudeSessionId = parsed.session_id;
658
+ if (parsed.model) session.model = typeof parsed.model === 'string' ? parsed.model : (parsed.model.id || parsed.model.name || null);
659
+ emit(session, {
660
+ type: 'claude/init',
661
+ claudeSessionId: session.claudeSessionId,
662
+ model: session.model,
663
+ aiProfileId: session.aiProfileId || null,
664
+ tools: parsed.tools || [],
665
+ mcpServers: parsed.mcp_servers || [],
666
+ });
667
+ return;
668
+ }
669
+
670
+ if (t === 'stream_event' && parsed.event) {
671
+ const ev = parsed.event;
672
+ if (ev.type === 'message_start' && ev.message) {
673
+ emit(session, { type: 'claude/message-start', role: ev.message.role });
674
+ if (ev.message.usage) {
675
+ emit(session, { type: 'claude/usage', usage: ev.message.usage });
676
+ }
677
+ return;
678
+ }
679
+ if (ev.type === 'content_block_start' && ev.content_block) {
680
+ const cb = ev.content_block;
681
+ if (cb.type === 'tool_use') {
682
+ // Initialize the per-block input accumulator; emit nothing yet (input is empty).
683
+ // The full input arrives via input_json_delta events and is emitted on content_block_stop.
684
+ if (cb.id) toolInputBuffers.set(cb.id, '');
685
+ emit(session, { type: 'claude/tool-use-start', id: cb.id, name: cb.name });
686
+ return;
687
+ }
688
+ if (cb.type === 'thinking') {
689
+ emit(session, { type: 'claude/thinking-start', id: cb.id });
690
+ return;
691
+ }
692
+ return;
693
+ }
694
+ if (ev.type === 'content_block_delta' && ev.delta) {
695
+ const d = ev.delta;
696
+ if (d.type === 'text_delta') {
697
+ emit(session, { type: 'claude/text-delta', text: d.text });
698
+ return;
699
+ }
700
+ if (d.type === 'input_json_delta') {
701
+ // Accumulate into the tool-use block; also emit a low-level delta for live UIs.
702
+ if (ev.index != null && toolInputBuffers) {
703
+ // We don't know block id here; emit raw delta
704
+ }
705
+ emit(session, { type: 'claude/tool-input-delta', text: d.partial_json });
706
+ return;
707
+ }
708
+ if (d.type === 'thinking_delta') {
709
+ emit(session, { type: 'claude/thinking-delta', text: d.thinking });
710
+ return;
711
+ }
712
+ return;
713
+ }
714
+ if (ev.type === 'content_block_stop') {
715
+ // Block ended — but we don't reliably know which block. Tool input emission is
716
+ // best-effort: the assistant message envelope at message_delta carries final tools.
717
+ return;
718
+ }
719
+ if (ev.type === 'message_delta' && ev.delta) {
720
+ if (ev.delta.stop_reason) {
721
+ emit(session, { type: 'claude/message-stop', stopReason: ev.delta.stop_reason });
722
+ }
723
+ if (ev.usage) {
724
+ emit(session, { type: 'claude/usage', usage: ev.usage });
725
+ }
726
+ return;
727
+ }
728
+ if (ev.type === 'message_stop') {
729
+ return;
730
+ }
731
+ return;
732
+ }
733
+
734
+ if (t === 'user' || t === 'assistant') {
735
+ // On assistant turn boundaries, the full message (with completed tool_use blocks)
736
+ // appears here. Emit any tool-use blocks we haven't fully captured yet.
737
+ if (t === 'assistant' && parsed.message && Array.isArray(parsed.message.content)) {
738
+ for (const block of parsed.message.content) {
739
+ if (block && block.type === 'tool_use') {
740
+ emit(session, {
741
+ type: 'claude/tool-use',
742
+ id: block.id,
743
+ name: block.name,
744
+ input: block.input || {},
745
+ });
746
+ }
747
+ }
748
+ }
749
+ return;
750
+ }
751
+
752
+ if (t === 'result') {
753
+ const event = {
754
+ type: 'claude/result',
755
+ subtype: parsed.subtype || null,
756
+ costUsd: parsed.total_cost_usd,
757
+ durationMs: parsed.duration_ms,
758
+ result: typeof parsed.result === 'string' ? parsed.result : JSON.stringify(parsed.result),
759
+ isError: parsed.is_error === true,
760
+ usage: parsed.usage || null,
761
+ };
762
+ emit(session, event);
763
+ return event;
764
+ }
765
+
766
+ emit(session, { type: 'claude/raw', json: parsed });
767
+ }
768
+
769
+ // ---- public API ----
770
+
771
+ // Start a Claude Code SDK session for the FIRST turn (initial analysis).
772
+ // The legacy `claude -p` subprocess path was removed — the SDK is the
773
+ // only supported mode and applies the hardened claude_code system prompt.
774
+ function startSession({ slug, projectPath, kbPath, promptKey, vars, aiProfile }) {
775
+ const rendered = renderPrompt(promptKey, vars || {});
776
+ if (!rendered) {
777
+ throw new Error(`unknown prompt key: ${promptKey}`);
778
+ }
779
+ const session = createSession({ projectSlug: slug, projectPath, kbPath, promptKey });
780
+ applyAiProfileToSession(session, aiProfile);
781
+ _broadcastSessionChange(session, 'create');
782
+ session.permissionMode = normalizePermissionMode(rendered.permissionMode);
783
+ const sdkOverrides = buildSdkOverridesFromProfile(aiProfile);
784
+ emit(session, {
785
+ type: 'claude/system-prompt',
786
+ text: rendered.systemPrompt,
787
+ promptKey,
788
+ aiProfileId: session.aiProfileId,
789
+ });
790
+ emit(session, {
791
+ type: 'claude/user-prompt',
792
+ text: rendered.userPrompt,
793
+ promptKey,
794
+ isInitial: true,
795
+ });
796
+ const turnOpts = {
797
+ userPrompt: rendered.userPrompt,
798
+ systemPrompt: rendered.systemPrompt,
799
+ ...sdkOverrides,
800
+ model: rendered.model,
801
+ permissionMode: session.permissionMode,
802
+ allowedTools: rendered.allowedTools,
803
+ cwd: session.kbPath,
804
+ env: session.claudeEnv,
805
+ resumeSessionId: null,
806
+ };
807
+ startSdkTurn(session, turnOpts, false);
808
+ return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk' };
809
+ }
810
+
811
+ function startChatSession({ slug, projectPath, kbPath, aiProfile, permissionMode = 'default' }) {
812
+ if (!slug) throw new Error('slug required');
813
+ if (!projectPath) throw new Error('projectPath required');
814
+ const session = createSession({
815
+ projectSlug: slug,
816
+ projectPath,
817
+ kbPath,
818
+ promptKey: 'terminal-chat',
819
+ source: 'terminal',
820
+ });
821
+ applyAiProfileToSession(session, aiProfile);
822
+ session.permissionMode = normalizePermissionMode(permissionMode);
823
+ emit(session, {
824
+ type: 'claude/session-ready',
825
+ promptKey: session.promptKey,
826
+ aiProfileId: session.aiProfileId,
827
+ permissionMode: session.permissionMode,
828
+ message: 'Claude terminal session is ready. Send a message to start the first turn.',
829
+ });
830
+ _broadcastSessionChange(session, 'create');
831
+ return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk' };
832
+ }
833
+
834
+ function startAutomationSession({
835
+ slug,
836
+ projectPath,
837
+ kbPath,
838
+ userPrompt,
839
+ systemPrompt,
840
+ aiProfile,
841
+ permissionMode = 'default',
842
+ allowedTools = ['Read', 'Grep', 'Glob'],
843
+ safetyPolicy = null,
844
+ metadata = {},
845
+ }) {
846
+ if (!slug) throw new Error('slug required');
847
+ if (typeof userPrompt !== 'string' || !userPrompt.trim()) throw new Error('userPrompt required');
848
+ const session = createSession({
849
+ projectSlug: slug,
850
+ projectPath,
851
+ kbPath,
852
+ promptKey: 'post-commit-automation',
853
+ source: metadata.source || 'git-hook',
854
+ metadata,
855
+ });
856
+ applyAiProfileToSession(session, aiProfile);
857
+ _broadcastSessionChange(session, 'create');
858
+ session.permissionMode = normalizePermissionMode(permissionMode);
859
+ session.safetyPolicy = safetyPolicy || null;
860
+ session.automation = true;
861
+ session.automationRunId = metadata.automationRunId || null;
862
+ const sdkOverrides = buildSdkOverridesFromProfile(aiProfile);
863
+ const effectiveSystemPrompt = systemPrompt || sdkOverrides.systemPrompt || { type: 'preset', preset: 'claude_code' };
864
+ emit(session, {
865
+ type: 'claude/system-prompt',
866
+ text: effectiveSystemPrompt,
867
+ promptKey: session.promptKey,
868
+ aiProfileId: session.aiProfileId,
869
+ automation: true,
870
+ });
871
+ emit(session, {
872
+ type: 'claude/user-prompt',
873
+ text: userPrompt,
874
+ promptKey: session.promptKey,
875
+ isInitial: true,
876
+ automation: true,
877
+ });
878
+ if (process.env.KB_AUTOMATION_FAKE_CLAUDE === '1') {
879
+ const commitHash = String(metadata.commitHash || '');
880
+ if (commitHash && kbPath) {
881
+ const changesDir = path.join(kbPath, 'changes');
882
+ fs.mkdirSync(changesDir, { recursive: true });
883
+ fs.writeFileSync(path.join(changesDir, `fake-${commitHash.slice(0, 7)}.md`),
884
+ `---\ncommit: ${commitHash}\n---\n\n# Fake automation ${commitHash.slice(0, 7)}\n`, 'utf8');
885
+ }
886
+ emit(session, { type: 'claude/result', result: 'fake automation completed', automation: true });
887
+ setState(session, 'idle', { exitCode: 0, message: 'fake automation complete' });
888
+ return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk', fake: true };
889
+ }
890
+ startSdkTurn(session, {
891
+ userPrompt,
892
+ systemPrompt: effectiveSystemPrompt,
893
+ model: null,
894
+ permissionMode: session.permissionMode,
895
+ allowedTools,
896
+ cwd: session.kbPath,
897
+ resumeSessionId: null,
898
+ env: session.claudeEnv,
899
+ safetyPolicy,
900
+ }, false);
901
+ return { sessionId: session.sessionId, pendingPermission: null, runner: 'sdk' };
902
+ }
903
+
904
+ // Send a follow-up turn via the SDK. With a non-null claudeSessionId,
905
+ // runs as a resume. With a null claudeSessionId (e.g. after a kbPath
906
+ // change cleared the stale id), starts a fresh session while reusing
907
+ // the same session record.
908
+ async function sendInput(sessionId, text, aiProfile = null, opts = {}) {
909
+ const session = getSession(sessionId);
910
+ if (!session) throw new Error(`session not found: ${sessionId}`);
911
+ if (session.subprocess) {
912
+ throw new Error(`a subprocess is already running on session ${sessionId}`);
913
+ }
914
+ if (aiProfile) {
915
+ applyAiProfileToSession(session, aiProfile);
916
+ }
917
+ const isFresh = !session.claudeSessionId;
918
+ logSession('info', 'claude.input_accepted', 'Claude Workbench input accepted.', session, {
919
+ inputHash: `sha256:${crypto.createHash('sha256').update(String(text), 'utf8').digest('hex')}`,
920
+ inputLength: String(text).length,
921
+ isFollowUp: !isFresh,
922
+ });
923
+ emit(session, { type: 'claude/user-prompt', text, isFollowUp: !isFresh, isFresh });
924
+ const sdkOverrides = buildSdkOverridesFromProfile(aiProfile);
925
+ session.permissionMode = normalizePermissionMode(opts.permissionMode || session.permissionMode || 'default');
926
+ const envModel = session.claudeEnv && session.claudeEnv.ANTHROPIC_MODEL
927
+ ? String(session.claudeEnv.ANTHROPIC_MODEL)
928
+ : null;
929
+ const turnOpts = {
930
+ userPrompt: text,
931
+ model: envModel || session.model || null,
932
+ permissionMode: session.permissionMode,
933
+ allowedTools: opts.allowedTools || ['Read', 'Grep', 'Glob', 'Bash', 'Edit'],
934
+ cwd: session.kbPath,
935
+ resumeSessionId: session.claudeSessionId,
936
+ systemPrompt: sdkOverrides.systemPrompt || null,
937
+ env: session.claudeEnv,
938
+ };
939
+ startSdkTurn(session, turnOpts, !isFresh);
940
+ return { started: true, pendingPermission: null, runner: 'sdk' };
941
+ }
942
+
943
+ // Compute accumulated token usage for a session from its persisted
944
+ // claude/usage events. Returns { used, total, hasUsage }.
945
+ // Uses a max-not-sum strategy because the SDK emits usage on each
946
+ // message_start (initial input_tokens) and each message_delta (output
947
+ // deltas). Summing would double-count. hasUsage is false when the SDK
948
+ // has not yet emitted any usage block — the caller renders "—" in that
949
+ // case rather than guessing 0.0%.
950
+ function getSessionTokenUsage(sessionId) {
951
+ const session = getSession(sessionId);
952
+ const fallbackTotal = Number(process.env.CLAUDE_CONTEXT_WINDOW) || 200000;
953
+ if (!session) return { used: 0, total: fallbackTotal, hasUsage: false };
954
+ const ctxWin = Number(session.contextWindow);
955
+ const total = Number.isFinite(ctxWin) && ctxWin > 0 ? Math.floor(ctxWin) : fallbackTotal;
956
+ let used = 0;
957
+ let sawUsage = false;
958
+ for (const ev of session.outputBuffer || []) {
959
+ if (ev && ev.type === 'claude/usage' && ev.usage) {
960
+ sawUsage = true;
961
+ const turn = Number(ev.usage.input_tokens || 0)
962
+ + Number(ev.usage.output_tokens || 0)
963
+ + Number(ev.usage.cache_creation_input_tokens || 0)
964
+ + Number(ev.usage.cache_read_input_tokens || 0);
965
+ if (turn > used) used = turn;
966
+ }
967
+ }
968
+ return { used, total, hasUsage: sawUsage };
969
+ }
970
+
971
+ function findClaudeExecutable() {
972
+ // Prefer the .exe path the running Claude Code sets in the env — bypasses cmd.exe shim
973
+ // entirely and avoids Windows shell quoting pitfalls on long prompts.
974
+ if (process.env.CLAUDE_CODE_EXECPATH) {
975
+ return { cmd: process.env.CLAUDE_CODE_EXECPATH, shell: false };
976
+ }
977
+ // Fallbacks
978
+ if (process.platform === 'win32') {
979
+ const npmRoot = process.env.APPDATA && require('path').join(process.env.APPDATA, 'npm');
980
+ if (npmRoot) {
981
+ const candidates = [
982
+ require('path').join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
983
+ require('path').join(npmRoot, 'claude.cmd'),
984
+ ];
985
+ const fs = require('fs');
986
+ for (const c of candidates) {
987
+ if (fs.existsSync(c)) return { cmd: c, shell: c.endsWith('.cmd') };
988
+ }
989
+ }
990
+ return { cmd: 'claude', shell: true }; // last resort
991
+ }
992
+ return { cmd: 'claude', shell: false };
993
+ }
994
+
995
+ async function findClaudeExecutableForSdk(options = {}) {
996
+ const env = options.env || process.env;
997
+ const platform = options.platform || process.platform;
998
+ const runtimeExecutable = String(options.runtimeExecutable || process.execPath || '').trim();
999
+ const exists = options.exists || fs.existsSync;
1000
+ const runCommand = options.runCommand || ((command, args) => new Promise((resolve) => {
1001
+ const child = spawn(command, args, { encoding: 'utf8', windowsHide: true });
1002
+ let stdout = '', stderr = '';
1003
+ const killer = setTimeout(() => { child.kill(); resolve({ status: null, stdout, stderr, error: 'timeout' }); }, 3000);
1004
+ child.stdout.on('data', d => { stdout += d.toString('utf8'); });
1005
+ child.stderr.on('data', d => { stderr += d.toString('utf8'); });
1006
+ child.on('error', e => { clearTimeout(killer); resolve({ status: null, stdout, stderr, error: e.message }); });
1007
+ child.on('close', code => { clearTimeout(killer); resolve({ status: code, stdout, stderr, error: null }); });
1008
+ }));
1009
+ const clean = file => String(file || '').trim().replace(/^"|"$/g, '');
1010
+ const isWindowsShellShim = file => {
1011
+ const candidate = clean(file);
1012
+ if (!candidate) return false;
1013
+ const extension = path.extname(candidate).toLowerCase();
1014
+ return !extension || ['.cmd', '.bat', '.ps1'].includes(extension);
1015
+ };
1016
+ const isSdkCompatibleWindowsFile = file => {
1017
+ const extension = path.extname(clean(file)).toLowerCase();
1018
+ return ['.exe', '.js', '.mjs', '.ts', '.tsx', '.jsx'].includes(extension);
1019
+ };
1020
+ const usable = file => {
1021
+ const candidate = clean(file);
1022
+ if (!candidate || !exists(candidate)) return null;
1023
+ if (platform === 'win32' && !isSdkCompatibleWindowsFile(candidate)) return null;
1024
+ return candidate;
1025
+ };
1026
+ const npmCliForShim = file => {
1027
+ const npmRoot = path.dirname(clean(file));
1028
+ const candidates = [
1029
+ path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
1030
+ path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
1031
+ ];
1032
+ return candidates.map(usable).find(Boolean) || null;
1033
+ };
1034
+ const resolveCandidate = file => {
1035
+ const candidate = clean(file);
1036
+ if (!candidate) return null;
1037
+ if (platform === 'win32' && isWindowsShellShim(candidate)) return npmCliForShim(candidate);
1038
+ return usable(candidate);
1039
+ };
1040
+ const launchInfo = (candidate, source) => {
1041
+ const extension = path.extname(clean(candidate)).toLowerCase();
1042
+ const needsRuntime = ['.js', '.mjs', '.cjs'].includes(extension);
1043
+ return {
1044
+ cmd: candidate,
1045
+ shell: false,
1046
+ source,
1047
+ runtimeExecutable: needsRuntime ? runtimeExecutable : null,
1048
+ };
1049
+ };
1050
+
1051
+ const configured = resolveCandidate(env.CLAUDE_CODE_EXECPATH);
1052
+ if (configured) return launchInfo(configured, 'CLAUDE_CODE_EXECPATH');
1053
+
1054
+ if (platform === 'win32') {
1055
+ let discovered = [];
1056
+ try {
1057
+ const result = runCommand('where.exe', ['claude']);
1058
+ if (result && result.status === 0) discovered = String(result.stdout || '').split(/\r?\n/).filter(Boolean);
1059
+ } catch {
1060
+ // PATH discovery falls through to the remaining supported executable locations.
1061
+ }
1062
+ for (const found of discovered) {
1063
+ const candidate = resolveCandidate(found);
1064
+ if (candidate) return launchInfo(candidate, 'PATH');
1065
+ }
1066
+
1067
+ const npmRoot = env.APPDATA && path.join(env.APPDATA, 'npm');
1068
+ if (npmRoot) {
1069
+ const candidates = [
1070
+ path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
1071
+ path.join(npmRoot, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
1072
+ ];
1073
+ for (const file of candidates) {
1074
+ const candidate = usable(file);
1075
+ if (candidate) return launchInfo(candidate, 'npm-global');
1076
+ }
1077
+ }
1078
+ return {
1079
+ cmd: null,
1080
+ shell: false,
1081
+ reason: 'Claude Code was not found. Install Claude Code on this computer or set CLAUDE_CODE_EXECPATH.',
1082
+ };
1083
+ }
1084
+ return { cmd: clean(env.CLAUDE_CODE_EXECPATH) || 'claude', shell: false, source: 'PATH' };
1085
+ }
1086
+
1087
+ function spawnClaude(session, opts) {
1088
+ // Legacy `claude -p` subprocess path was removed in the CC-Switch
1089
+ // migration; the SDK is the only supported entry point. This stub is
1090
+ // kept only so any stale caller still gets a clear error rather than a
1091
+ // silent no-op. New code uses startSdkTurn directly.
1092
+ throw new Error('spawnClaude is no longer supported — use the Claude Agent SDK');
1093
+ }
1094
+
1095
+ async function runSdkTurn(session, opts, isResume) {
1096
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
1097
+ const toolInputBuffers = new Map();
1098
+ const sdkPermissionMode = normalizePermissionMode(opts.permissionMode || session.permissionMode || 'default');
1099
+ session.permissionMode = sdkPermissionMode;
1100
+ const allowedTools = Array.isArray(opts.allowedTools) ? opts.allowedTools : [];
1101
+ const claudeBin = await findClaudeExecutableForSdk();
1102
+ if (!claudeBin.cmd) throw new Error(claudeBin.reason);
1103
+ const sdkOptions = {
1104
+ cwd: opts.cwd,
1105
+ env: { ...process.env, ...(opts.env || {}), FORCE_COLOR: '0', NO_COLOR: '1' },
1106
+ model: opts.model,
1107
+ tools: { type: 'preset', preset: 'claude_code' },
1108
+ allowedTools,
1109
+ disallowedTools: [],
1110
+ settingSources: ['project', 'user', 'local'],
1111
+ includePartialMessages: true,
1112
+ systemPrompt: opts.systemPrompt || { type: 'preset', preset: 'claude_code' },
1113
+ permissionMode: sdkPermissionMode,
1114
+ allowDangerouslySkipPermissions: sdkPermissionMode === 'bypassPermissions' && !opts.safetyPolicy,
1115
+ canUseTool: async (toolName, input, context = {}) => {
1116
+ const toolUseID = context.toolUseID || null;
1117
+ if (opts.safetyPolicy) {
1118
+ const decision = evaluateAutomationToolUse(opts.safetyPolicy, toolName, input);
1119
+ emit(session, {
1120
+ type: 'claude/tool-policy',
1121
+ toolName,
1122
+ decision: decision.behavior,
1123
+ reason: decision.reason,
1124
+ automation: !!session.automation,
1125
+ });
1126
+ if (decision.behavior === 'allow') {
1127
+ return { behavior: 'allow', updatedInput: input, toolUseID };
1128
+ }
1129
+ return { behavior: 'deny', message: decision.reason || 'Denied by automation safety policy', toolUseID };
1130
+ }
1131
+ if (sdkPermissionMode === 'bypassPermissions') {
1132
+ return { behavior: 'allow', updatedInput: input, toolUseID };
1133
+ }
1134
+ if (sdkPermissionMode === 'acceptEdits' && ['Edit', 'Write', 'MultiEdit'].includes(toolName)) {
1135
+ return { behavior: 'allow', updatedInput: input, toolUseID };
1136
+ }
1137
+ if (sdkPermissionMode === 'plan' && ['Edit', 'Write', 'MultiEdit', 'Bash'].includes(toolName)) {
1138
+ return { behavior: 'deny', message: 'Plan mode does not allow file edits or shell commands', toolUseID };
1139
+ }
1140
+ const requestId = newPermissionId();
1141
+ const summary = {
1142
+ turnKind: 'tool-use',
1143
+ isResume,
1144
+ cwd: opts.cwd || '',
1145
+ model: opts.model || null,
1146
+ permissionMode: sdkOptions.permissionMode,
1147
+ allowedTools: [toolName],
1148
+ toolName,
1149
+ toolUseID,
1150
+ title: context.title || context.displayName || toolName,
1151
+ description: context.description || context.decisionReason || '',
1152
+ promptPreview: JSON.stringify(input || {}).slice(0, 500),
1153
+ };
1154
+ session.pendingPermission = {
1155
+ requestId,
1156
+ status: 'pending',
1157
+ createdAt: new Date().toISOString(),
1158
+ summary,
1159
+ };
1160
+ setState(session, 'pending-permission', { requestId, toolName, toolUseID: context.toolUseID || null });
1161
+ emit(session, { type: 'claude/permission-request', ...session.pendingPermission });
1162
+ return await new Promise((resolve) => {
1163
+ const cancel = () => {
1164
+ if (session.pendingToolApproval && session.pendingToolApproval.requestId === requestId) {
1165
+ session.pendingToolApproval = null;
1166
+ session.pendingPermission = null;
1167
+ resolve({ behavior: 'deny', message: 'Permission request cancelled', toolUseID });
1168
+ }
1169
+ };
1170
+ if (context.signal) {
1171
+ if (context.signal.aborted) return cancel();
1172
+ context.signal.addEventListener('abort', cancel, { once: true });
1173
+ }
1174
+ session.pendingToolApproval = {
1175
+ requestId,
1176
+ resolve,
1177
+ input,
1178
+ toolUseID,
1179
+ };
1180
+ });
1181
+ },
1182
+ };
1183
+ sdkOptions.pathToClaudeCodeExecutable = claudeBin.cmd;
1184
+ // The desktop backend runs through Electron with ELECTRON_RUN_AS_NODE=1.
1185
+ // Use that exact executable (or the current node binary in CLI mode) for a
1186
+ // JavaScript Claude entry point instead of relying on `node` being present
1187
+ // in the desktop process PATH.
1188
+ if (claudeBin.runtimeExecutable) sdkOptions.executable = claudeBin.runtimeExecutable;
1189
+ if (opts.resumeSessionId) sdkOptions.resume = opts.resumeSessionId;
1190
+
1191
+ try {
1192
+ const retries = maxSdkRetries();
1193
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
1194
+ const attemptStartedAt = Date.now();
1195
+ let queryInstance;
1196
+ const attemptToolInputBuffers = attempt === 0 ? toolInputBuffers : new Map();
1197
+ try {
1198
+ logSession('info', 'claude.sdk_attempt_started', 'Claude SDK attempt started.', session, { attempt: attempt + 1, maxAttempts: retries + 1, isResume });
1199
+ queryInstance = query({ prompt: opts.userPrompt, options: sdkOptions });
1200
+ session.subprocess = { kill: () => queryInstance.close && queryInstance.close() };
1201
+ let errorResult = null;
1202
+ for await (const message of queryInstance) {
1203
+ if (message.session_id) session.claudeSessionId = message.session_id;
1204
+ const event = handleNdjsonLine(session, JSON.stringify(message), attemptToolInputBuffers);
1205
+ if (event && event.type === 'claude/result' && event.isError) {
1206
+ errorResult = event.result || 'Claude Code returned an error result';
1207
+ }
1208
+ }
1209
+ session.subprocess = null;
1210
+ if (errorResult) {
1211
+ throw new Error(`Claude Code returned an error result: ${errorResult}`);
1212
+ }
1213
+ if (session.state !== 'aborted' && session.state !== 'failed') {
1214
+ setState(session, 'idle', { exitCode: 0, message: isResume ? 'follow-up complete' : 'turn complete, awaiting input or new analysis' });
1215
+ emit(session, { type: 'claude/turn-end', exitCode: 0 });
1216
+ }
1217
+ logSession('info', 'claude.sdk_attempt_completed', 'Claude SDK attempt completed.', session, { attempt: attempt + 1, maxAttempts: retries + 1, durationMs: Date.now() - attemptStartedAt });
1218
+ return;
1219
+ } catch (e) {
1220
+ session.subprocess = null;
1221
+ if (session.state === 'aborted') return;
1222
+ if (attempt < retries && isTransientClaudeError(e)) {
1223
+ const delayMs = sdkRetryDelayMs(attempt + 1);
1224
+ const message = normalizeErrorMessage(e);
1225
+ emit(session, {
1226
+ type: 'claude/retry',
1227
+ attempt: attempt + 1,
1228
+ maxRetries: retries,
1229
+ delayMs,
1230
+ message,
1231
+ });
1232
+ logSession('warn', 'claude.sdk_attempt_retrying', 'Claude SDK attempt will retry after a transient failure.', session, {
1233
+ attempt: attempt + 1,
1234
+ maxAttempts: retries + 1,
1235
+ delayMs,
1236
+ durationMs: Date.now() - attemptStartedAt,
1237
+ failureHash: `sha256:${crypto.createHash('sha256').update(message, 'utf8').digest('hex')}`,
1238
+ });
1239
+ setState(session, 'running', {
1240
+ turn: session.turns,
1241
+ runner: 'sdk',
1242
+ retryAttempt: attempt + 1,
1243
+ maxRetries: retries,
1244
+ });
1245
+ await sleep(delayMs);
1246
+ if (session.state === 'aborted') return;
1247
+ continue;
1248
+ }
1249
+ throw e;
1250
+ }
1251
+ }
1252
+ } catch (e) {
1253
+ session.error = e.message;
1254
+ emit(session, { type: 'claude/error', message: e.message });
1255
+ setState(session, 'failed', { error: e.message });
1256
+ logSession('error', 'claude.sdk_turn_failed', 'Claude SDK turn failed.', session, {
1257
+ failureHash: `sha256:${crypto.createHash('sha256').update(normalizeErrorMessage(e), 'utf8').digest('hex')}`,
1258
+ }, Object.assign(new Error('Claude SDK turn failed.'), { code: 'CLAUDE_SDK_FAILED' }));
1259
+ } finally {
1260
+ session.subprocess = null;
1261
+ session.pendingToolApproval = null;
1262
+ session.pendingPermission = null;
1263
+ persistSession(session);
1264
+ }
1265
+ }
1266
+
1267
+ function startSdkTurn(session, opts, isResume) {
1268
+ session.pendingPermission = null;
1269
+ session.pendingTurn = null;
1270
+ setState(session, 'spawning', { turn: session.turns + 1, runner: 'sdk' });
1271
+ session.turns += 1;
1272
+ setState(session, 'running', { turn: session.turns, runner: 'sdk' });
1273
+ runSdkTurn(session, opts, isResume).catch(e => {
1274
+ session.error = e.message;
1275
+ emit(session, { type: 'claude/error', message: e.message });
1276
+ setState(session, 'failed', { error: e.message });
1277
+ });
1278
+ }
1279
+
1280
+ function abort(sessionId) {
1281
+ const session = getSession(sessionId);
1282
+ if (!session) throw new Error(`session not found: ${sessionId}`);
1283
+ logSession('warn', 'claude.session_abort_requested', 'Claude Workbench session abort requested.', session, { state: session.state });
1284
+ if (session.subprocess) {
1285
+ try {
1286
+ session.subprocess.kill();
1287
+ } catch {
1288
+ // The subprocess may already have exited; state is still transitioned to aborted below.
1289
+ }
1290
+ setState(session, 'aborted', { reason: 'user-abort' });
1291
+ emit(session, { type: 'claude/aborted', reason: 'user-abort' });
1292
+ } else {
1293
+ session.pendingPermission = null;
1294
+ session.pendingTurn = null;
1295
+ setState(session, 'aborted', { reason: 'no-active-subprocess' });
1296
+ }
1297
+ }
1298
+
1299
+ // Subscribe to live events. Callback receives each event as it arrives.
1300
+ // Late subscribers also get replayed outputBuffer.
1301
+ function subscribe(sessionId, onEvent) {
1302
+ const session = getSession(sessionId);
1303
+ if (!session) throw new Error(`session not found: ${sessionId}`);
1304
+ // replay buffer first
1305
+ for (const ev of session.outputBuffer) {
1306
+ try { onEvent(ev); } catch {
1307
+ // A disconnected subscriber must not corrupt the persisted session.
1308
+ }
1309
+ }
1310
+ session.listeners.add(onEvent);
1311
+ return () => {
1312
+ session.listeners.delete(onEvent);
1313
+ };
1314
+ }
1315
+
1316
+ function getState(sessionId) {
1317
+ const s = getSession(sessionId);
1318
+ if (!s) return null;
1319
+ return {
1320
+ sessionId: s.sessionId,
1321
+ projectSlug: s.projectSlug,
1322
+ promptKey: s.promptKey,
1323
+ runner: s.runner || 'cli',
1324
+ state: s.state,
1325
+ model: s.model,
1326
+ aiProfileId: s.aiProfileId || null,
1327
+ claudeSessionId: s.claudeSessionId,
1328
1328
  source: s.source || 'manual',
1329
1329
  automation: !!s.automation,
1330
1330
  automationRunId: s.automationRunId || null,
1331
1331
  legacyProjectSlug: s.legacyProjectSlug || null,
1332
1332
  legacySource: s.legacySource === true,
1333
1333
  permissionMode: s.permissionMode || 'default',
1334
- startedAt: s.startedAt,
1335
- endedAt: s.endedAt,
1336
- exitCode: s.exitCode,
1337
- turns: s.turns,
1338
- error: s.error,
1339
- listenerCount: s.listeners.size,
1340
- bufferedEvents: s.outputBuffer.length,
1341
- pendingPermission: s.pendingPermission,
1342
- restored: !!s.restored,
1343
- };
1344
- }
1345
-
1346
- function deleteSession(sessionId) {
1347
- const s = sessions.get(sessionId);
1348
- if (!s) return false;
1349
- if (s.subprocess) {
1350
- try { s.subprocess.kill(); } catch {
1351
- // Session deletion proceeds when the child has already exited.
1352
- }
1353
- }
1354
- sessions.delete(sessionId);
1355
- try {
1356
- const file = sessionRecordPath(s);
1357
- if (file) fs.rmSync(file, { force: true });
1358
- } catch {
1359
- // Metadata cleanup is best-effort after the in-memory session is removed.
1360
- }
1361
- return true;
1362
- }
1363
-
1364
- // Periodically clean up old sessions with no listeners (memory hygiene)
1365
- function pruneOldSessions(maxAgeMs = 30 * 60 * 1000) {
1366
- const now = Date.now();
1367
- for (const [id, s] of sessions) {
1368
- if (s.listeners.size === 0 && s.subprocess === null && s.endedAt) {
1369
- const age = now - new Date(s.endedAt).getTime();
1370
- if (age > maxAgeMs) sessions.delete(id);
1371
- }
1372
- }
1373
- }
1374
-
1375
- setInterval(pruneOldSessions, 5 * 60 * 1000).unref();
1376
-
1377
- // Sweep sessions that are still in an ACTIVE state but whose subprocess is
1378
- // gone. Without this, three known failure modes strand sessions in
1379
- // "running" forever:
1380
- // 1. dashboard restart kills the SDK parent query → claude.exe is orphaned
1381
- // and no one calls setState(failed)
1382
- // 2. SIGKILL of the dashboard (or OOM) before the SDK's for-await loop
1383
- // emitted its terminal message
1384
- // 3. child Claude.exe dies under a permission denial or API outage but the
1385
- // parent query's error path never reaches setState(failed) (e.g. IPC
1386
- // connection drops silently after the dashboard process exits)
1387
- //
1388
- // Symptom in the UI: a project keeps showing the pulsing "running" badge,
1389
- // and the embedded terminal view stays empty because no real subprocess is
1390
- // streaming into it. The sweeper demotes these to `failed` with a reason
1391
- // and emits a state-change event so subscribers (UI + automation queue)
1392
- // stop waiting on a session that will never finish.
1393
- //
1394
- // Threshold: by default 5 minutes. Tunable via KB_STALE_ACTIVE_MS env var
1395
- // for the test fixture to inject shorter windows.
1396
- function readStaleThresholdMs() {
1397
- const raw = process.env.KB_STALE_ACTIVE_MS;
1398
- const n = raw == null || raw === '' ? 5 * 60 * 1000 : Number(raw);
1399
- if (!Number.isFinite(n)) return 5 * 60 * 1000;
1400
- return Math.max(1000, Math.floor(n));
1401
- }
1402
-
1403
- function lastActivityAt(record) {
1404
- if (!record) return null;
1405
- return record.updatedAt || record.startedAt || null;
1406
- }
1407
-
1408
- function isStaleActive(record, thresholdMs, now) {
1409
- if (!record) return false;
1410
- if (!ACTIVE_STATES.has(record.state)) return false;
1411
- const last = lastActivityAt(record);
1412
- if (!last) return false;
1413
- return now - new Date(last).getTime() > thresholdMs;
1414
- }
1415
-
1416
- // Demote a session to `failed` with a clear reason. Reuses emit +
1417
- // _broadcastSessionChange so the UI sees the same lifecycle shape as a
1418
- // normal SDK-error end. Also fires onSessionEnded callbacks because
1419
- // automation queue (post-commit dispatcher) treats `failed` as a release
1420
- // signal for the next queued run.
1421
- function demoteSessionToFailed(session, reason, source) {
1422
- session.state = 'failed';
1423
- session.endedAt = new Date().toISOString();
1424
- session.error = reason;
1425
- session.subprocess = null;
1426
- session.pendingToolApproval = null;
1427
- session.pendingPermission = null;
1428
- emit(session, {
1429
- type: 'claude/state',
1430
- state: 'failed',
1431
- reason,
1432
- source: source || 'stale-active-sweep',
1433
- });
1434
- _broadcastSessionChange(session, 'stale-active-sweep');
1435
- if (sessionEndedCallbacks.size > 0) {
1436
- const snapshot = session;
1437
- setImmediate(() => {
1438
- for (const cb of sessionEndedCallbacks) {
1439
- try { cb(snapshot); } catch {
1440
- // Subscriber failures must not alter the terminal session snapshot.
1441
- }
1442
- }
1443
- });
1444
- }
1445
- persistSession(session);
1446
- }
1447
-
1448
- // Sweep stale ACTIVE sessions from two sources:
1449
- // * In-memory `sessions` Map: subprocess is null AND state stayed ACTIVE
1450
- // past the threshold (e.g. a runtime orphan; in-memory state cannot
1451
- // progress to terminal because the async loop is gone).
1452
- // * Persisted records on disk: state stayed ACTIVE through at least one
1453
- // dashboard restart cycle, and the corresponding live entry is not in
1454
- // the in-memory Map. We rehydrate a minimal session to drive the
1455
- // demotion emit + broadcast so the UI and any post-commit queue
1456
- // subscribers see the transition.
1457
- //
1458
- // Returns a list of demoted sessionIds and the source ('memory' or
1459
- // 'persisted') for observability and for tests.
1460
- function demoteStaleActiveSessions(opts = {}) {
1461
- const thresholdMs = opts.thresholdMs || readStaleThresholdMs();
1462
- const now = Date.now();
1463
- const demoted = [];
1464
-
1465
- // 1. In-memory sweep.
1466
- for (const session of sessions.values()) {
1467
- if (!ACTIVE_STATES.has(session.state)) continue;
1468
- if (session.subprocess !== null) continue;
1469
- const last = lastActivityAt(session);
1470
- if (!last) continue;
1471
- if (now - new Date(last).getTime() <= thresholdMs) continue;
1472
- demoteSessionToFailed(
1473
- session,
1474
- 'subprocess exited unexpectedly (subprocess=null while state=active)',
1475
- 'memory'
1476
- );
1477
- demoted.push({ sessionId: session.sessionId, source: 'memory' });
1478
- }
1479
-
1480
- // 2. Persisted records sweep (covers dashboard-restart orphans).
1481
- for (const record of scanPersistedRecords()) {
1482
- if (!isStaleActive(record, thresholdMs, now)) continue;
1483
- if (sessions.has(record.sessionId)) continue;
1484
- const rehydrated = {
1485
- sessionId: record.sessionId,
1486
- projectSlug: record.projectSlug,
1487
- projectPath: record.projectPath || null,
1488
- kbPath: record.kbPath || null,
1489
- promptKey: record.promptKey || null,
1490
- runner: record.runner || 'cli',
1491
- state: record.state,
1492
- model: record.model || null,
1493
- aiProfileId: record.aiProfileId || null,
1494
- claudeSessionId: record.claudeSessionId || null,
1495
- startedAt: record.startedAt || new Date().toISOString(),
1496
- endedAt: null,
1497
- exitCode: null,
1498
- listeners: new Set(),
1499
- outputBuffer: [],
1500
- subprocess: null,
1501
- turns: record.turns || 0,
1502
- error: null,
1503
- claudeEnv: {},
1504
- pendingPermission: null,
1505
- pendingTurn: null,
1506
- pendingToolApproval: null,
1507
- restored: true,
1508
- historyCleared: true,
1334
+ startedAt: s.startedAt,
1335
+ endedAt: s.endedAt,
1336
+ exitCode: s.exitCode,
1337
+ turns: s.turns,
1338
+ error: s.error,
1339
+ listenerCount: s.listeners.size,
1340
+ bufferedEvents: s.outputBuffer.length,
1341
+ pendingPermission: s.pendingPermission,
1342
+ restored: !!s.restored,
1343
+ };
1344
+ }
1345
+
1346
+ function deleteSession(sessionId) {
1347
+ const s = sessions.get(sessionId);
1348
+ if (!s) return false;
1349
+ if (s.subprocess) {
1350
+ try { s.subprocess.kill(); } catch {
1351
+ // Session deletion proceeds when the child has already exited.
1352
+ }
1353
+ }
1354
+ sessions.delete(sessionId);
1355
+ try {
1356
+ const file = sessionRecordPath(s);
1357
+ if (file) fs.rmSync(file, { force: true });
1358
+ } catch {
1359
+ // Metadata cleanup is best-effort after the in-memory session is removed.
1360
+ }
1361
+ return true;
1362
+ }
1363
+
1364
+ // Periodically clean up old sessions with no listeners (memory hygiene)
1365
+ function pruneOldSessions(maxAgeMs = 30 * 60 * 1000) {
1366
+ const now = Date.now();
1367
+ for (const [id, s] of sessions) {
1368
+ if (s.listeners.size === 0 && s.subprocess === null && s.endedAt) {
1369
+ const age = now - new Date(s.endedAt).getTime();
1370
+ if (age > maxAgeMs) sessions.delete(id);
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ setInterval(pruneOldSessions, 5 * 60 * 1000).unref();
1376
+
1377
+ // Sweep sessions that are still in an ACTIVE state but whose subprocess is
1378
+ // gone. Without this, three known failure modes strand sessions in
1379
+ // "running" forever:
1380
+ // 1. dashboard restart kills the SDK parent query → claude.exe is orphaned
1381
+ // and no one calls setState(failed)
1382
+ // 2. SIGKILL of the dashboard (or OOM) before the SDK's for-await loop
1383
+ // emitted its terminal message
1384
+ // 3. child Claude.exe dies under a permission denial or API outage but the
1385
+ // parent query's error path never reaches setState(failed) (e.g. IPC
1386
+ // connection drops silently after the dashboard process exits)
1387
+ //
1388
+ // Symptom in the UI: a project keeps showing the pulsing "running" badge,
1389
+ // and the embedded terminal view stays empty because no real subprocess is
1390
+ // streaming into it. The sweeper demotes these to `failed` with a reason
1391
+ // and emits a state-change event so subscribers (UI + automation queue)
1392
+ // stop waiting on a session that will never finish.
1393
+ //
1394
+ // Threshold: by default 5 minutes. Tunable via KB_STALE_ACTIVE_MS env var
1395
+ // for the test fixture to inject shorter windows.
1396
+ function readStaleThresholdMs() {
1397
+ const raw = process.env.KB_STALE_ACTIVE_MS;
1398
+ const n = raw == null || raw === '' ? 5 * 60 * 1000 : Number(raw);
1399
+ if (!Number.isFinite(n)) return 5 * 60 * 1000;
1400
+ return Math.max(1000, Math.floor(n));
1401
+ }
1402
+
1403
+ function lastActivityAt(record) {
1404
+ if (!record) return null;
1405
+ return record.updatedAt || record.startedAt || null;
1406
+ }
1407
+
1408
+ function isStaleActive(record, thresholdMs, now) {
1409
+ if (!record) return false;
1410
+ if (!ACTIVE_STATES.has(record.state)) return false;
1411
+ const last = lastActivityAt(record);
1412
+ if (!last) return false;
1413
+ return now - new Date(last).getTime() > thresholdMs;
1414
+ }
1415
+
1416
+ // Demote a session to `failed` with a clear reason. Reuses emit +
1417
+ // _broadcastSessionChange so the UI sees the same lifecycle shape as a
1418
+ // normal SDK-error end. Also fires onSessionEnded callbacks because
1419
+ // automation queue (post-commit dispatcher) treats `failed` as a release
1420
+ // signal for the next queued run.
1421
+ function demoteSessionToFailed(session, reason, source) {
1422
+ session.state = 'failed';
1423
+ session.endedAt = new Date().toISOString();
1424
+ session.error = reason;
1425
+ session.subprocess = null;
1426
+ session.pendingToolApproval = null;
1427
+ session.pendingPermission = null;
1428
+ emit(session, {
1429
+ type: 'claude/state',
1430
+ state: 'failed',
1431
+ reason,
1432
+ source: source || 'stale-active-sweep',
1433
+ });
1434
+ _broadcastSessionChange(session, 'stale-active-sweep');
1435
+ if (sessionEndedCallbacks.size > 0) {
1436
+ const snapshot = session;
1437
+ setImmediate(() => {
1438
+ for (const cb of sessionEndedCallbacks) {
1439
+ try { cb(snapshot); } catch {
1440
+ // Subscriber failures must not alter the terminal session snapshot.
1441
+ }
1442
+ }
1443
+ });
1444
+ }
1445
+ persistSession(session);
1446
+ }
1447
+
1448
+ // Sweep stale ACTIVE sessions from two sources:
1449
+ // * In-memory `sessions` Map: subprocess is null AND state stayed ACTIVE
1450
+ // past the threshold (e.g. a runtime orphan; in-memory state cannot
1451
+ // progress to terminal because the async loop is gone).
1452
+ // * Persisted records on disk: state stayed ACTIVE through at least one
1453
+ // dashboard restart cycle, and the corresponding live entry is not in
1454
+ // the in-memory Map. We rehydrate a minimal session to drive the
1455
+ // demotion emit + broadcast so the UI and any post-commit queue
1456
+ // subscribers see the transition.
1457
+ //
1458
+ // Returns a list of demoted sessionIds and the source ('memory' or
1459
+ // 'persisted') for observability and for tests.
1460
+ function demoteStaleActiveSessions(opts = {}) {
1461
+ const thresholdMs = opts.thresholdMs || readStaleThresholdMs();
1462
+ const now = Date.now();
1463
+ const demoted = [];
1464
+
1465
+ // 1. In-memory sweep.
1466
+ for (const session of sessions.values()) {
1467
+ if (!ACTIVE_STATES.has(session.state)) continue;
1468
+ if (session.subprocess !== null) continue;
1469
+ const last = lastActivityAt(session);
1470
+ if (!last) continue;
1471
+ if (now - new Date(last).getTime() <= thresholdMs) continue;
1472
+ demoteSessionToFailed(
1473
+ session,
1474
+ 'subprocess exited unexpectedly (subprocess=null while state=active)',
1475
+ 'memory'
1476
+ );
1477
+ demoted.push({ sessionId: session.sessionId, source: 'memory' });
1478
+ }
1479
+
1480
+ // 2. Persisted records sweep (covers dashboard-restart orphans).
1481
+ for (const record of scanPersistedRecords()) {
1482
+ if (!isStaleActive(record, thresholdMs, now)) continue;
1483
+ if (sessions.has(record.sessionId)) continue;
1484
+ const rehydrated = {
1485
+ sessionId: record.sessionId,
1486
+ projectSlug: record.projectSlug,
1487
+ projectPath: record.projectPath || null,
1488
+ kbPath: record.kbPath || null,
1489
+ promptKey: record.promptKey || null,
1490
+ runner: record.runner || 'cli',
1491
+ state: record.state,
1492
+ model: record.model || null,
1493
+ aiProfileId: record.aiProfileId || null,
1494
+ claudeSessionId: record.claudeSessionId || null,
1495
+ startedAt: record.startedAt || new Date().toISOString(),
1496
+ endedAt: null,
1497
+ exitCode: null,
1498
+ listeners: new Set(),
1499
+ outputBuffer: [],
1500
+ subprocess: null,
1501
+ turns: record.turns || 0,
1502
+ error: null,
1503
+ claudeEnv: {},
1504
+ pendingPermission: null,
1505
+ pendingTurn: null,
1506
+ pendingToolApproval: null,
1507
+ restored: true,
1508
+ historyCleared: true,
1509
1509
  source: record.source || 'manual',
1510
1510
  metadata: record.metadata || {},
1511
1511
  automation: !!record.automation,
@@ -1513,87 +1513,87 @@ function demoteStaleActiveSessions(opts = {}) {
1513
1513
  legacyProjectSlug: record.legacyProjectSlug || null,
1514
1514
  legacySource: record.legacySource === true,
1515
1515
  permissionMode: record.permissionMode || 'default',
1516
- safetyPolicy: null,
1517
- };
1518
- sessions.set(record.sessionId, rehydrated);
1519
- demoteSessionToFailed(
1520
- rehydrated,
1521
- 'subprocess lost (server restart while session active)',
1522
- 'persisted'
1523
- );
1524
- demoted.push({ sessionId: record.sessionId, source: 'persisted' });
1525
- }
1526
-
1527
- if (demoted.length) {
1528
- if (typeof process !== 'undefined' && process.stderr) {
1529
- const summary = demoted
1530
- .map(d => `${d.sessionId}(${d.source})`)
1531
- .slice(0, 20)
1532
- .join(', ');
1533
- process.stderr.write(
1534
- `[claude-cli-runner] stale-active-sweep demoted ${demoted.length} session(s): ${summary}\n`
1535
- );
1536
- }
1537
- }
1538
- return demoted;
1539
- }
1540
-
1541
- // Run once at module load (covers boot-time dashboard-restart orphans).
1542
- // Don't crash if it throws — a sweeper that takes down the dashboard is
1543
- // worse than the orphans it cleans up.
1544
- try {
1545
- const bootDemoted = demoteStaleActiveSessions();
1546
- if (bootDemoted.length && typeof process !== 'undefined' && process.stderr) {
1547
- const summary = bootDemoted
1548
- .map(d => `${d.sessionId}(${d.source})`)
1549
- .slice(0, 10)
1550
- .join(', ');
1551
- process.stderr.write(
1552
- `[claude-cli-runner] boot sweep demoted ${bootDemoted.length} stale active session(s): ${summary}\n`
1553
- );
1554
- }
1555
- } catch (e) {
1556
- if (typeof process !== 'undefined' && process.stderr) {
1557
- process.stderr.write(`[claude-cli-runner] boot sweep failed: ${e.message}\n`);
1558
- }
1559
- }
1560
-
1561
- // Periodic sweep (covers runtime orphans and new starts). 30s gives a
1562
- // reasonable tradeoff between responsiveness and noise.
1563
- setInterval(() => {
1564
- try { demoteStaleActiveSessions(); }
1565
- catch (error) {
1566
- try { process.stderr.write(`[claude-cli-runner] periodic sweep failed: ${normalizeErrorMessage(error)}\n`); } catch {
1567
- // stderr is the final fallback for this legacy workbench maintenance path.
1568
- }
1569
- }
1570
- }, 30 * 1000).unref();
1571
-
1572
- module.exports = {
1573
- setLogger,
1574
- persistSession,
1575
- createSession,
1576
- startSession,
1577
- startChatSession,
1578
- startAutomationSession,
1579
- buildClaudeEnvFromProfile,
1580
- applyAiProfileToSession,
1581
- buildSdkOverridesFromProfile,
1582
- sendInput,
1583
- resolvePermission,
1584
- abort,
1585
- subscribe,
1586
- subscribeList,
1587
- onSessionEnded,
1588
- getSession,
1589
- getState,
1590
- listSessions,
1591
- deleteSession,
1592
- getSessionTokenUsage,
1593
- findClaudeExecutableForSdk,
1594
- isTransientClaudeError,
1595
- demoteStaleActiveSessions,
1596
- readStaleThresholdMs,
1597
- ACTIVE_STATES,
1598
- TERMINAL_STATES,
1599
- };
1516
+ safetyPolicy: null,
1517
+ };
1518
+ sessions.set(record.sessionId, rehydrated);
1519
+ demoteSessionToFailed(
1520
+ rehydrated,
1521
+ 'subprocess lost (server restart while session active)',
1522
+ 'persisted'
1523
+ );
1524
+ demoted.push({ sessionId: record.sessionId, source: 'persisted' });
1525
+ }
1526
+
1527
+ if (demoted.length) {
1528
+ if (typeof process !== 'undefined' && process.stderr) {
1529
+ const summary = demoted
1530
+ .map(d => `${d.sessionId}(${d.source})`)
1531
+ .slice(0, 20)
1532
+ .join(', ');
1533
+ process.stderr.write(
1534
+ `[claude-cli-runner] stale-active-sweep demoted ${demoted.length} session(s): ${summary}\n`
1535
+ );
1536
+ }
1537
+ }
1538
+ return demoted;
1539
+ }
1540
+
1541
+ // Run once at module load (covers boot-time dashboard-restart orphans).
1542
+ // Don't crash if it throws — a sweeper that takes down the dashboard is
1543
+ // worse than the orphans it cleans up.
1544
+ try {
1545
+ const bootDemoted = demoteStaleActiveSessions();
1546
+ if (bootDemoted.length && typeof process !== 'undefined' && process.stderr) {
1547
+ const summary = bootDemoted
1548
+ .map(d => `${d.sessionId}(${d.source})`)
1549
+ .slice(0, 10)
1550
+ .join(', ');
1551
+ process.stderr.write(
1552
+ `[claude-cli-runner] boot sweep demoted ${bootDemoted.length} stale active session(s): ${summary}\n`
1553
+ );
1554
+ }
1555
+ } catch (e) {
1556
+ if (typeof process !== 'undefined' && process.stderr) {
1557
+ process.stderr.write(`[claude-cli-runner] boot sweep failed: ${e.message}\n`);
1558
+ }
1559
+ }
1560
+
1561
+ // Periodic sweep (covers runtime orphans and new starts). 30s gives a
1562
+ // reasonable tradeoff between responsiveness and noise.
1563
+ setInterval(() => {
1564
+ try { demoteStaleActiveSessions(); }
1565
+ catch (error) {
1566
+ try { process.stderr.write(`[claude-cli-runner] periodic sweep failed: ${normalizeErrorMessage(error)}\n`); } catch {
1567
+ // stderr is the final fallback for this legacy workbench maintenance path.
1568
+ }
1569
+ }
1570
+ }, 30 * 1000).unref();
1571
+
1572
+ module.exports = {
1573
+ setLogger,
1574
+ persistSession,
1575
+ createSession,
1576
+ startSession,
1577
+ startChatSession,
1578
+ startAutomationSession,
1579
+ buildClaudeEnvFromProfile,
1580
+ applyAiProfileToSession,
1581
+ buildSdkOverridesFromProfile,
1582
+ sendInput,
1583
+ resolvePermission,
1584
+ abort,
1585
+ subscribe,
1586
+ subscribeList,
1587
+ onSessionEnded,
1588
+ getSession,
1589
+ getState,
1590
+ listSessions,
1591
+ deleteSession,
1592
+ getSessionTokenUsage,
1593
+ findClaudeExecutableForSdk,
1594
+ isTransientClaudeError,
1595
+ demoteStaleActiveSessions,
1596
+ readStaleThresholdMs,
1597
+ ACTIVE_STATES,
1598
+ TERMINAL_STATES,
1599
+ };