@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.
- package/.agents/plugins/marketplace.json +17 -17
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +817 -799
- package/README.md +48 -36
- package/_modules/knowledge-engine/README.md +88 -0
- package/_modules/knowledge-engine/package.json +38 -0
- package/_modules/knowledge-engine/src/analysis-contract.js +25 -0
- package/_modules/knowledge-engine/src/analysis-worker.js +54 -0
- package/_modules/knowledge-engine/src/bin.js +52 -0
- package/_modules/knowledge-engine/src/bridge-client.js +24 -0
- package/_modules/knowledge-engine/src/coordinator.js +255 -0
- package/_modules/knowledge-engine/src/headless-analyzer.js +75 -0
- package/_modules/knowledge-engine/src/memories.js +85 -0
- package/_modules/knowledge-engine/src/migration.js +88 -0
- package/_modules/knowledge-engine/src/retrieval.js +116 -0
- package/_modules/knowledge-engine/src/runtime/README.md +7 -0
- package/_modules/knowledge-engine/src/runtime/atomic-file.js +204 -0
- package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -0
- package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +249 -0
- package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -0
- package/_modules/knowledge-engine/src/runtime/commit-prompt.js +202 -0
- package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +432 -0
- package/_modules/knowledge-engine/src/runtime/content-hash.js +4 -0
- package/_modules/knowledge-engine/src/runtime/contracts.js +279 -0
- package/_modules/knowledge-engine/src/runtime/conversation-exclusions.js +204 -0
- package/_modules/knowledge-engine/src/runtime/conversation-store.js +387 -0
- package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -0
- package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -0
- package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +444 -0
- package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +402 -0
- package/_modules/knowledge-engine/src/runtime/layout.js +44 -0
- package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +230 -0
- package/_modules/knowledge-engine/src/runtime/scanner.js +298 -0
- package/_modules/knowledge-engine/src/server.js +155 -0
- package/_modules/knowledge-engine/src/store.js +239 -0
- package/_modules/knowledge-engine/ui/app.css +172 -0
- package/_modules/knowledge-engine/ui/app.js +297 -0
- package/_modules/knowledge-engine/ui/index.html +95 -0
- package/_site/README.md +30 -30
- package/_site/_test/ai-profile-resolver-test.js +137 -137
- package/_site/_test/automation-queue-test.js +14 -14
- package/_site/_test/automation-ui-test.js +40 -40
- package/_site/_test/background-task-registry-test.js +43 -43
- package/_site/_test/baseline-schema-test.js +91 -91
- package/_site/_test/chat-claudecodeui-match-test.js +92 -92
- package/_site/_test/claude-executable-discovery-test.js +62 -62
- package/_site/_test/claude-workbench-test.js +170 -170
- package/_site/_test/codex-conversation-projection-test.js +2 -1
- package/_site/_test/commit-conversation-binding-test.js +95 -95
- package/_site/_test/commit-evidence-test.js +110 -110
- package/_site/_test/conversation-api-test.js +109 -109
- package/_site/_test/conversation-store-test.js +136 -136
- package/_site/_test/data-dir-migration-test.js +57 -57
- package/_site/_test/desktop-browser-compat-test.js +28 -28
- package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
- package/_site/_test/explicit-commit-processor-test.js +37 -37
- package/_site/_test/fixtures/make-git-repos.js +12 -1
- package/_site/_test/folder-picker-output-test.js +19 -19
- package/_site/_test/full-integration-e2e-test.js +165 -165
- package/_site/_test/git-validation-test.js +2 -2
- package/_site/_test/hook-runtime-endpoint-test.js +88 -88
- package/_site/_test/hook-status-repair-api-test.js +152 -152
- package/_site/_test/hook-trigger-test.js +85 -85
- package/_site/_test/import-preflight-api-test.js +234 -233
- package/_site/_test/index-writer-concurrency-test.js +135 -135
- package/_site/_test/integration-adapters-test.js +171 -171
- package/_site/_test/integration-surface-coverage-test.js +164 -164
- package/_site/_test/knowledge-language-control-test.js +170 -170
- package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
- package/_site/_test/knowledge-query-test.js +54 -54
- package/_site/_test/knowledge-retrieval-service-test.js +77 -77
- package/_site/_test/knowledge-storage-startup-test.js +52 -52
- package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
- package/_site/_test/legacy-forward-compat-test.js +257 -257
- package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -363
- package/_site/_test/logging-api-test.js +89 -89
- package/_site/_test/logging-sse-no-gap-test.js +88 -88
- package/_site/_test/logging-ui-test.js +106 -104
- package/_site/_test/markdown-delta-overlay-test.js +85 -85
- package/_site/_test/markdown-maintenance-api-test.js +75 -75
- package/_site/_test/mcp-server-test.js +149 -149
- package/_site/_test/module-artifact-integrity-test.js +27 -0
- package/_site/_test/module-boundary-test.js +30 -0
- package/_site/_test/module-bridge-eventbridge-test.js +7 -0
- package/_site/_test/module-model-configuration-test.js +43 -0
- package/_site/_test/module-process-lifecycle-test.js +24 -0
- package/_site/_test/module-stream-proxy-test.js +34 -0
- package/_site/_test/non-release-ci-test.js +34 -34
- package/_site/_test/offline-boundary-isolation-test.js +32 -32
- package/_site/_test/p0-data-migration-characterization-test.js +37 -37
- package/_site/_test/p0-e2e-gate-test.js +347 -347
- package/_site/_test/packaged-ui-smoke-test.js +11 -9
- package/_site/_test/path-consistency-test.js +151 -151
- package/_site/_test/pending-sweep-test.js +9 -9
- package/_site/_test/post-commit-automation-test.js +87 -87
- package/_site/_test/product-diagnostics-ui-test.js +75 -0
- package/_site/_test/product-import-ui-test.js +92 -0
- package/_site/_test/project-delete-recovery-test.js +64 -64
- package/_site/_test/project-goal-editor-test.js +144 -144
- package/_site/_test/project-layout-v2-migration-test.js +127 -127
- package/_site/_test/project-lifecycle-transaction-test.js +106 -106
- package/_site/_test/prompt-settings-test.js +115 -115
- package/_site/_test/protected-architecture-gate-test.js +122 -122
- package/_site/_test/refactor-characterization-test.js +36 -36
- package/_site/_test/release-version-sync-test.js +1 -1
- package/_site/_test/requirement-recorder-test.js +173 -173
- package/_site/_test/run-all-tests.js +159 -156
- package/_site/_test/server-runtime-migration-safety-test.js +27 -27
- package/_site/_test/shared-contracts-test.js +58 -58
- package/_site/_test/startup-analysis-disabled-test.js +10 -10
- package/_site/_test/storage-foundation-test.js +78 -78
- package/_site/_test/structured-logger-test.js +101 -101
- package/_site/_test/tracking-start-test.js +108 -108
- package/_site/_test/v4122-upgrade-data-contract-test.js +56 -56
- package/_site/_test/workbench-permission-test.js +117 -117
- package/_site/_test/workspace-ui-contract-test.js +28 -43
- package/_site/lib/ai-profile-resolver.js +78 -78
- package/_site/lib/ai-workspace.js +101 -101
- package/_site/lib/automation-config.js +140 -140
- package/_site/lib/bridge-adapter.js +1 -0
- package/_site/lib/bridge-consumer-service.js +402 -402
- package/_site/lib/claude-cli-runner.js +1510 -1510
- package/_site/lib/commit-conversation-binder.js +234 -234
- package/_site/lib/commit-processing-ledger.js +27 -27
- package/_site/lib/commit-prompt.js +202 -202
- package/_site/lib/commit-reconciler.js +406 -406
- package/_site/lib/contracts.js +284 -279
- package/_site/lib/conversation-query-service.js +144 -144
- package/_site/lib/conversation-store.js +385 -385
- package/_site/lib/data-dir.js +74 -74
- package/_site/lib/data-state-classifier.js +40 -40
- package/_site/lib/engine-cutover.js +97 -0
- package/_site/lib/evidence-bundle.js +228 -228
- package/_site/lib/folder-picker-output.js +21 -21
- package/_site/lib/git-runner.js +62 -62
- package/_site/lib/github-team-store.js +1172 -1172
- package/_site/lib/hook-manager.js +203 -202
- package/_site/lib/index-service.js +173 -173
- package/_site/lib/integration-installer.js +907 -907
- package/_site/lib/kb-framework.js +176 -176
- package/_site/lib/kb-validator.js +124 -124
- package/_site/lib/knowledge-promotion.js +435 -435
- package/_site/lib/knowledge-retrieval-service.js +401 -401
- package/_site/lib/knowledge-tool-runtime.js +359 -359
- package/_site/lib/legacy-data-manifest.js +26 -26
- package/_site/lib/llm-client.js +161 -161
- package/_site/lib/markdown-knowledge-indexer.js +230 -230
- package/_site/lib/migration-service.js +442 -442
- package/_site/lib/module-bridge.js +198 -33
- package/_site/lib/post-commit-automation.js +87 -87
- package/_site/lib/project-lifecycle-service.js +519 -497
- package/_site/lib/project-store.js +268 -262
- package/_site/lib/requirement-recorder.js +278 -278
- package/_site/lib/runtime-endpoint.js +155 -155
- package/_site/lib/scanner.js +298 -298
- package/_site/lib/server-app.js +1590 -1520
- package/_site/lib/settings-store.js +133 -113
- package/_site/lib/storage-layout.js +162 -162
- package/_site/lib/structured-logger.js +574 -574
- package/_site/scripts/folder-picker.ps1 +156 -156
- package/_site/scripts/hook-trigger.js +157 -156
- package/_site/scripts/install-module-candidates.js +69 -0
- package/_site/scripts/pack-module-candidates.js +72 -0
- package/_site/scripts/sync-release-version.js +2 -2
- package/_site/scripts/vendor-modules.js +47 -0
- package/_site/scripts/verify-module-runtime.js +45 -0
- package/_site/scripts/verify-product-runtime.js +34 -0
- package/bin/project-knowledge-mcp.js +194 -194
- package/docs/README.zh-CN.md +34 -25
- package/docs/project-registry-schema.md +22 -22
- package/docs/testing-strategy.md +33 -33
- package/module-runtime-manifest.json +220 -0
- package/package.json +13 -9
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
- package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
- package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
- package/templates/project-readme.md +34 -34
- package/ui/favicon.svg +38 -38
- package/ui/index.html +51 -149
- package/ui/product.css +9 -0
- package/ui/product.js +386 -0
- package/vendor-manifest.json +46 -0
- package/_site/_test/import-ui-flow-test.js +0 -171
- package/_site/_test/project-control-panel-task14-test.js +0 -63
- package/_site/_test/task15-20-ui-flow-test.js +0 -148
- package/_site/_test/ui-i18n-toggle-test.js +0 -114
- package/_site/_test/ui-smoke-test.js +0 -73
- package/ui/app.css +0 -58
- package/ui/app.js +0 -607
- 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
|
+
};
|