feat: implement mervelas cli engine, built-in tools, and reactive UI
Swadhin Biswas committed 6 months ago
0358986
1 parent 1c96a14
commit 0358986
1943 files changed
+516761 -0
Showing 20 changed files+10057-0
This diff is too large and has been truncated. Displaying first 20 files.
bin/mervelas.jsscripts/build.tsscripts/perf.tssrc/QueryEngine.tssrc/Task.tssrc/Tool.tssrc/assistant/sessionHistory.tssrc/bootstrap/state.tssrc/bridge/bridgeApi.tssrc/bridge/bridgeConfig.tssrc/bridge/bridgeDebug.tssrc/bridge/bridgeEnabled.tssrc/bridge/bridgeMain.tssrc/bridge/bridgeMessaging.tssrc/bridge/bridgePermissionCallbacks.tssrc/bridge/bridgePointer.tssrc/bridge/bridgeStatusUtil.tssrc/bridge/bridgeUI.tssrc/bridge/capacityWake.tssrc/bridge/codeSessionApi.ts
▼bin/mervelas.js
+2-0
| @@ -0,0 +1,2 @@ | ||
1 | +#!/usr/bin/env node | |
2 | +import '../dist/cli.mjs' | |
0 | 3 | |
▼scripts/build.ts
+281-0
| @@ -0,0 +1,281 @@ | ||
1 | +import { readFileSync } from "fs"; | |
2 | + | |
3 | +const pkg = JSON.parse(readFileSync("./package.json", "utf-8")); | |
4 | +const version = pkg.version; | |
5 | + | |
6 | +const result = await Bun.build({ | |
7 | + entrypoints: ["./src/entrypoints/cli.tsx"], | |
8 | + outdir: "./dist", | |
9 | + target: "node", | |
10 | + format: "esm", | |
11 | + splitting: false, | |
12 | + sourcemap: "external", | |
13 | + minify: false, | |
14 | + naming: "cli.mjs", | |
15 | + define: { | |
16 | + "MACRO.VERSION": JSON.stringify(version), | |
17 | + "MACRO.DISPLAY_VERSION": JSON.stringify(version), | |
18 | + "MACRO.BUILD_TIME": JSON.stringify(new Date().toISOString()), | |
19 | + "MACRO.ISSUES_EXPLAINER": JSON.stringify( | |
20 | + "report the issue at https://github.com/swadhinbiswas/Mervelas/issues", | |
21 | + ), | |
22 | + }, | |
23 | + plugins: [ | |
24 | + { | |
25 | + name: "mervelas-build-shim", | |
26 | + setup(build) { | |
27 | + const internalFeatureStubModules = new Map([ | |
28 | + [ | |
29 | + "../daemon/workerRegistry.js", | |
30 | + 'export async function runDaemonWorker() { throw new Error("Daemon worker is unavailable in this Mervelas build."); }', | |
31 | + ], | |
32 | + [ | |
33 | + "../daemon/main.js", | |
34 | + 'export async function daemonMain() { throw new Error("Daemon mode is unavailable in this Mervelas build."); }', | |
35 | + ], | |
36 | + [ | |
37 | + "../cli/bg.js", | |
38 | + ` | |
39 | +export async function psHandler() { throw new Error("Background sessions are unavailable in this Mervelas build."); } | |
40 | +export async function logsHandler() { throw new Error("Background sessions are unavailable in this Mervelas build."); } | |
41 | +export async function attachHandler() { throw new Error("Background sessions are unavailable in this Mervelas build."); } | |
42 | +export async function killHandler() { throw new Error("Background sessions are unavailable in this Mervelas build."); } | |
43 | +export async function handleBgFlag() { throw new Error("Background sessions are unavailable in this Mervelas build."); } | |
44 | +`, | |
45 | + ], | |
46 | + [ | |
47 | + "../cli/handlers/templateJobs.js", | |
48 | + 'export async function templatesMain() { throw new Error("Template jobs are unavailable in this Mervelas build."); }', | |
49 | + ], | |
50 | + [ | |
51 | + "../environment-runner/main.js", | |
52 | + 'export async function environmentRunnerMain() { throw new Error("Environment runner is unavailable in this Mervelas build."); }', | |
53 | + ], | |
54 | + [ | |
55 | + "../self-hosted-runner/main.js", | |
56 | + 'export async function selfHostedRunnerMain() { throw new Error("Self-hosted runner is unavailable in this Mervelas build."); }', | |
57 | + ], | |
58 | + [ | |
59 | + "./components/agents/SnapshotUpdateDialog.js", | |
60 | + "export function SnapshotUpdateDialog() { return null; }", | |
61 | + ], | |
62 | + [ | |
63 | + "./assistant/AssistantSessionChooser.js", | |
64 | + "export function AssistantSessionChooser() { return null; }", | |
65 | + ], | |
66 | + [ | |
67 | + "./commands/assistant/assistant.js", | |
68 | + "export function NewInstallWizard() { return null; } export async function computeDefaultInstallDir() { return '.mervelas-assistant'; }", | |
69 | + ], | |
70 | + [ | |
71 | + "./commands/agents-platform/index.js", | |
72 | + 'export default async function agentsPlatformCommand() { throw new Error("Agents platform is unavailable in this Mervelas build."); }', | |
73 | + ], | |
74 | + [ | |
75 | + "./tools/TungstenTool/TungstenTool.js", | |
76 | + "export const TungstenTool = { name: 'TungstenTool' };", | |
77 | + ], | |
78 | + [ | |
79 | + "../tools/TungstenTool/TungstenTool.js", | |
80 | + "export const TungstenTool = { name: 'TungstenTool' };", | |
81 | + ], | |
82 | + [ | |
83 | + "../../tools/TungstenTool/TungstenTool.js", | |
84 | + "export const TungstenTool = { name: 'TungstenTool' };", | |
85 | + ], | |
86 | + [ | |
87 | + "../tools/TungstenTool/TungstenLiveMonitor.js", | |
88 | + "export function TungstenLiveMonitor() { return null; }", | |
89 | + ], | |
90 | + [ | |
91 | + "./tools/REPLTool/REPLTool.js", | |
92 | + "export const REPLTool = { name: 'REPLTool' };", | |
93 | + ], | |
94 | + [ | |
95 | + "./tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.js", | |
96 | + "export const SuggestBackgroundPRTool = { name: 'SuggestBackgroundPRTool' };", | |
97 | + ], | |
98 | + [ | |
99 | + "./tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.js", | |
100 | + "export const VerifyPlanExecutionTool = { name: 'VerifyPlanExecutionTool' };", | |
101 | + ], | |
102 | + ] as const); | |
103 | + | |
104 | + build.onResolve({ filter: /^bun:bundle$/ }, () => ({ | |
105 | + path: "bun:bundle", | |
106 | + namespace: "bun-bundle-shim", | |
107 | + })); | |
108 | + build.onLoad({ filter: /.*/, namespace: "bun-bundle-shim" }, () => ({ | |
109 | + contents: `export function feature() { return false; }`, | |
110 | + loader: "js", | |
111 | + })); | |
112 | + | |
113 | + build.onResolve( | |
114 | + { | |
115 | + filter: | |
116 | + /^(\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)|\.\/(components\/agents\/SnapshotUpdateDialog|assistant\/AssistantSessionChooser|commands\/assistant\/assistant|commands\/agents-platform\/index|tools\/REPLTool\/REPLTool|tools\/SuggestBackgroundPRTool\/SuggestBackgroundPRTool|tools\/VerifyPlanExecutionTool\/VerifyPlanExecutionTool|tools\/TungstenTool\/TungstenTool)|\.\.\/tools\/TungstenTool\/Tungsten(LiveMonitor|Tool)|\.\.\/\.\.\/tools\/TungstenTool\/TungstenTool)\.js$/, | |
117 | + }, | |
118 | + (args) => { | |
119 | + if (!internalFeatureStubModules.has(args.path)) return null; | |
120 | + return { | |
121 | + path: args.path, | |
122 | + namespace: "internal-feature-stub", | |
123 | + }; | |
124 | + }, | |
125 | + ); | |
126 | + build.onLoad( | |
127 | + { filter: /.*/, namespace: "internal-feature-stub" }, | |
128 | + (args) => ({ | |
129 | + contents: internalFeatureStubModules.get(args.path) ?? "export {}", | |
130 | + loader: "js", | |
131 | + }), | |
132 | + ); | |
133 | + | |
134 | + build.onResolve({ filter: /^react\/compiler-runtime$/ }, () => ({ | |
135 | + path: "react/compiler-runtime", | |
136 | + namespace: "react-compiler-shim", | |
137 | + })); | |
138 | + build.onLoad( | |
139 | + { filter: /.*/, namespace: "react-compiler-shim" }, | |
140 | + () => ({ | |
141 | + contents: | |
142 | + "export function c(size) { return new Array(size).fill(Symbol.for('react.memo_cache_sentinel')); }", | |
143 | + loader: "js", | |
144 | + }), | |
145 | + ); | |
146 | + | |
147 | + for (const mod of [ | |
148 | + "audio-capture-napi", | |
149 | + "audio-capture.node", | |
150 | + "image-processor-napi", | |
151 | + "modifiers-napi", | |
152 | + "url-handler-napi", | |
153 | + "color-diff-napi", | |
154 | + "sharp", | |
155 | + "@anthropic-ai/mcpb", | |
156 | + "@ant/claude-for-chrome-mcp", | |
157 | + "@ant/computer-use-mcp", | |
158 | + "@anthropic-ai/sandbox-runtime", | |
159 | + "asciichart", | |
160 | + "plist", | |
161 | + "cacache", | |
162 | + "fuse", | |
163 | + "code-excerpt", | |
164 | + "stack-utils", | |
165 | + ]) { | |
166 | + build.onResolve({ filter: new RegExp(`^${mod}$`) }, () => ({ | |
167 | + path: mod, | |
168 | + namespace: "native-stub", | |
169 | + })); | |
170 | + } | |
171 | + build.onLoad({ filter: /.*/, namespace: "native-stub" }, () => ({ | |
172 | + contents: ` | |
173 | +const noop = () => null; | |
174 | +const noopClass = class {}; | |
175 | +const handler = { | |
176 | + get(_, prop) { | |
177 | + if (prop === '__esModule') return true; | |
178 | + if (prop === 'default') return new Proxy({}, handler); | |
179 | + if (prop === 'ExportResultCode') return { SUCCESS: 0, FAILED: 1 }; | |
180 | + if (prop === 'resourceFromAttributes') return () => ({}); | |
181 | + if (prop === 'SandboxRuntimeConfigSchema') return { parse: () => ({}) }; | |
182 | + return noop; | |
183 | + } | |
184 | +}; | |
185 | +const stub = new Proxy(noop, handler); | |
186 | +export default stub; | |
187 | +export const __stub = true; | |
188 | +export const SandboxViolationStore = null; | |
189 | +export const SandboxManager = new Proxy({}, { get: () => noop }); | |
190 | +export const SandboxRuntimeConfigSchema = { parse: () => ({}) }; | |
191 | +export const BROWSER_TOOLS = []; | |
192 | +export const getMcpConfigForManifest = noop; | |
193 | +export const ColorDiff = null; | |
194 | +export const ColorFile = null; | |
195 | +export const getSyntaxTheme = noop; | |
196 | +export const plot = noop; | |
197 | +export const createClaudeForChromeMcpServer = noop; | |
198 | +export const createComputerUseMcpServer = noop; | |
199 | +export const ExportResultCode = { SUCCESS: 0, FAILED: 1 }; | |
200 | +export const resourceFromAttributes = noop; | |
201 | +export const Resource = noopClass; | |
202 | +export const SimpleSpanProcessor = noopClass; | |
203 | +export const BatchSpanProcessor = noopClass; | |
204 | +export const NodeTracerProvider = noopClass; | |
205 | +export const BasicTracerProvider = noopClass; | |
206 | +export const OTLPTraceExporter = noopClass; | |
207 | +export const OTLPLogExporter = noopClass; | |
208 | +export const OTLPMetricExporter = noopClass; | |
209 | +export const PrometheusExporter = noopClass; | |
210 | +export const LoggerProvider = noopClass; | |
211 | +export const SimpleLogRecordProcessor = noopClass; | |
212 | +export const BatchLogRecordProcessor = noopClass; | |
213 | +export const MeterProvider = noopClass; | |
214 | +export const PeriodicExportingMetricReader = noopClass; | |
215 | +export const trace = { getTracer: () => ({ startSpan: () => ({ end: noop, setAttribute: noop, setStatus: noop, recordException: noop }) }) }; | |
216 | +export const context = { active: noop, with: (_, fn) => fn() }; | |
217 | +export const SpanStatusCode = { OK: 0, ERROR: 1, UNSET: 2 }; | |
218 | +export const ATTR_SERVICE_NAME = 'service.name'; | |
219 | +export const ATTR_SERVICE_VERSION = 'service.version'; | |
220 | +export const SEMRESATTRS_SERVICE_NAME = 'service.name'; | |
221 | +export const SEMRESATTRS_SERVICE_VERSION = 'service.version'; | |
222 | +export const AggregationTemporality = { CUMULATIVE: 0, DELTA: 1 }; | |
223 | +export const DataPointType = { HISTOGRAM: 0, SUM: 1, GAUGE: 2 }; | |
224 | +export const InstrumentType = { COUNTER: 0, HISTOGRAM: 1, UP_DOWN_COUNTER: 2 }; | |
225 | +export const PushMetricExporter = noopClass; | |
226 | +export const SeverityNumber = {}; | |
227 | +`, | |
228 | + loader: "js", | |
229 | + })); | |
230 | + | |
231 | + build.onResolve({ filter: /\.(md|txt)$/ }, (args) => ({ | |
232 | + path: args.path, | |
233 | + namespace: "text-stub", | |
234 | + })); | |
235 | + build.onLoad({ filter: /.*/, namespace: "text-stub" }, () => ({ | |
236 | + contents: `export default '';`, | |
237 | + loader: "js", | |
238 | + })); | |
239 | + }, | |
240 | + }, | |
241 | + ], | |
242 | + external: [ | |
243 | + "@opentelemetry/api", | |
244 | + "@opentelemetry/api-logs", | |
245 | + "@opentelemetry/core", | |
246 | + "@opentelemetry/exporter-trace-otlp-grpc", | |
247 | + "@opentelemetry/exporter-trace-otlp-http", | |
248 | + "@opentelemetry/exporter-trace-otlp-proto", | |
249 | + "@opentelemetry/exporter-logs-otlp-http", | |
250 | + "@opentelemetry/exporter-logs-otlp-proto", | |
251 | + "@opentelemetry/exporter-logs-otlp-grpc", | |
252 | + "@opentelemetry/exporter-metrics-otlp-proto", | |
253 | + "@opentelemetry/exporter-metrics-otlp-grpc", | |
254 | + "@opentelemetry/exporter-metrics-otlp-http", | |
255 | + "@opentelemetry/exporter-prometheus", | |
256 | + "@opentelemetry/resources", | |
257 | + "@opentelemetry/sdk-trace-base", | |
258 | + "@opentelemetry/sdk-trace-node", | |
259 | + "@opentelemetry/sdk-logs", | |
260 | + "@opentelemetry/sdk-metrics", | |
261 | + "@opentelemetry/semantic-conventions", | |
262 | + "@aws-sdk/client-bedrock", | |
263 | + "@aws-sdk/client-bedrock-runtime", | |
264 | + "@aws-sdk/client-sts", | |
265 | + "@aws-sdk/credential-providers", | |
266 | + "@azure/identity", | |
267 | + "google-auth-library", | |
268 | + "https-proxy-agent", | |
269 | + "debug", | |
270 | + ], | |
271 | +}); | |
272 | + | |
273 | +if (!result.success) { | |
274 | + console.error("Build failed:"); | |
275 | + for (const log of result.logs) { | |
276 | + console.error(log); | |
277 | + } | |
278 | + process.exit(1); | |
279 | +} | |
280 | + | |
281 | +console.log(`Built Mervelas v${version} -> dist/cli.mjs`); | |
0 | 282 | |
▼scripts/perf.ts
+163-0
| @@ -0,0 +1,163 @@ | ||
1 | +import { spawn } from 'child_process' | |
2 | +import { | |
3 | + existsSync, | |
4 | + mkdirSync, | |
5 | + readdirSync, | |
6 | + readFileSync, | |
7 | + statSync, | |
8 | + writeFileSync, | |
9 | +} from 'fs' | |
10 | +import { join, resolve } from 'path' | |
11 | + | |
12 | +type Mode = 'startup' | 'query' | |
13 | + | |
14 | +type RunSummary = { | |
15 | + mode: Mode | |
16 | + command: string[] | |
17 | + artifactDir: string | |
18 | + startupReportPath: string | null | |
19 | + queryReportPath: string | null | |
20 | + wallTimeMs: number | |
21 | + exitCode: number | |
22 | + startupTotalMs: number | null | |
23 | + queryTtftMs: number | null | |
24 | + queryTotalTimeMs: number | null | |
25 | + queryPreApiOverheadMs: number | null | |
26 | +} | |
27 | + | |
28 | +function fail(message: string): never { | |
29 | + process.stderr.write(`${message}\n`) | |
30 | + process.exit(1) | |
31 | +} | |
32 | + | |
33 | +function parseArgs(argv: string[]): { mode: Mode; cliArgs: string[] } { | |
34 | + const [mode, ...rest] = argv | |
35 | + if (mode !== 'startup' && mode !== 'query') { | |
36 | + fail( | |
37 | + 'Usage: bun run scripts/perf.ts <startup|query> -- <mervelas args...>', | |
38 | + ) | |
39 | + } | |
40 | + | |
41 | + const separatorIdx = rest.indexOf('--') | |
42 | + const cliArgs = separatorIdx === -1 ? rest : rest.slice(separatorIdx + 1) | |
43 | + return { mode, cliArgs } | |
44 | +} | |
45 | + | |
46 | +function ensureBuilt(): void { | |
47 | + if (!existsSync(resolve('dist/cli.mjs'))) { | |
48 | + fail('Missing dist/cli.mjs. Run `bun run build` before running perf scripts.') | |
49 | + } | |
50 | +} | |
51 | + | |
52 | +function getRunId(): string { | |
53 | + return new Date().toISOString().replace(/[:.]/g, '-') | |
54 | +} | |
55 | + | |
56 | +function getLatestFile(dir: string): string | null { | |
57 | + if (!existsSync(dir)) return null | |
58 | + const files = readdirSync(dir) | |
59 | + .map(name => join(dir, name)) | |
60 | + .filter(path => statSync(path).isFile()) | |
61 | + .sort( | |
62 | + (a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs, | |
63 | + ) | |
64 | + return files[0] ?? null | |
65 | +} | |
66 | + | |
67 | +function extractMs(report: string, regex: RegExp): number | null { | |
68 | + const match = report.match(regex) | |
69 | + if (!match?.[1]) return null | |
70 | + const value = Number.parseFloat(match[1]) | |
71 | + return Number.isFinite(value) ? value : null | |
72 | +} | |
73 | + | |
74 | +function readMaybe(path: string | null): string | null { | |
75 | + if (!path) return null | |
76 | + return readFileSync(path, 'utf8') | |
77 | +} | |
78 | + | |
79 | +async function runCommand( | |
80 | + mode: Mode, | |
81 | + cliArgs: string[], | |
82 | + artifactDir: string, | |
83 | +): Promise<RunSummary> { | |
84 | + const configDir = join(artifactDir, 'config') | |
85 | + const debugDir = join(artifactDir, 'debug') | |
86 | + mkdirSync(configDir, { recursive: true }) | |
87 | + mkdirSync(debugDir, { recursive: true }) | |
88 | + | |
89 | + const childArgs = ['dist/cli.mjs', ...cliArgs] | |
90 | + if ( | |
91 | + mode === 'query' && | |
92 | + !cliArgs.some( | |
93 | + arg => arg === '--debug-file' || arg.startsWith('--debug-file='), | |
94 | + ) | |
95 | + ) { | |
96 | + childArgs.splice(1, 0, '--debug-file', join(debugDir, 'query-debug.txt')) | |
97 | + } | |
98 | + | |
99 | + const env = { | |
100 | + ...process.env, | |
101 | + MERVELAS_CONFIG_DIR: configDir, | |
102 | + CLAUDE_CODE_DEBUG_LOGS_DIR: debugDir, | |
103 | + CLAUDE_CODE_PROFILE_STARTUP: mode === 'startup' ? '1' : '0', | |
104 | + CLAUDE_CODE_PROFILE_QUERY: mode === 'query' ? '1' : '0', | |
105 | + } | |
106 | + | |
107 | + const startedAt = Date.now() | |
108 | + const exitCode = await new Promise<number>((resolveExit, reject) => { | |
109 | + const child = spawn('node', childArgs, { | |
110 | + cwd: resolve('.'), | |
111 | + env, | |
112 | + stdio: 'inherit', | |
113 | + }) | |
114 | + | |
115 | + child.on('error', reject) | |
116 | + child.on('exit', code => resolveExit(code ?? 1)) | |
117 | + }) | |
118 | + const wallTimeMs = Date.now() - startedAt | |
119 | + | |
120 | + const startupReportPath = getLatestFile(join(configDir, 'startup-perf')) | |
121 | + const queryReportPath = getLatestFile(join(configDir, 'query-perf')) | |
122 | + const startupReport = readMaybe(startupReportPath) | |
123 | + const queryReport = readMaybe(queryReportPath) | |
124 | + | |
125 | + return { | |
126 | + mode, | |
127 | + command: ['node', ...childArgs], | |
128 | + artifactDir, | |
129 | + startupReportPath, | |
130 | + queryReportPath, | |
131 | + wallTimeMs, | |
132 | + exitCode, | |
133 | + startupTotalMs: startupReport | |
134 | + ? extractMs(startupReport, /Total startup time:\s+([0-9.]+)ms/) | |
135 | + : null, | |
136 | + queryTtftMs: queryReport | |
137 | + ? extractMs(queryReport, /Total TTFT:\s+([0-9.]+)ms/) | |
138 | + : null, | |
139 | + queryTotalTimeMs: queryReport | |
140 | + ? extractMs(queryReport, /Total time:\s+([0-9.]+)ms/) | |
141 | + : null, | |
142 | + queryPreApiOverheadMs: queryReport | |
143 | + ? extractMs(queryReport, /Total pre-API overhead\s+([0-9.]+)ms/) | |
144 | + : null, | |
145 | + } | |
146 | +} | |
147 | + | |
148 | +const { mode, cliArgs } = parseArgs(process.argv.slice(2)) | |
149 | +ensureBuilt() | |
150 | + | |
151 | +const artifactDir = resolve('.perf-artifacts', `${mode}-${getRunId()}`) | |
152 | +mkdirSync(artifactDir, { recursive: true }) | |
153 | + | |
154 | +const summary = await runCommand(mode, cliArgs, artifactDir) | |
155 | +const summaryPath = join(artifactDir, 'summary.json') | |
156 | +writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') | |
157 | + | |
158 | +process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`) | |
159 | +process.stdout.write(`Summary written to ${summaryPath}\n`) | |
160 | + | |
161 | +if (summary.exitCode !== 0) { | |
162 | + process.exit(summary.exitCode) | |
163 | +} | |
0 | 164 | |
▼src/QueryEngine.ts
+1295-0
| @@ -0,0 +1,1295 @@ | ||
1 | +import { feature } from 'bun:bundle' | |
2 | +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' | |
3 | +import { randomUUID } from 'crypto' | |
4 | +import last from 'lodash-es/last.js' | |
5 | +import { | |
6 | + getSessionId, | |
7 | + isSessionPersistenceDisabled, | |
8 | +} from 'src/bootstrap/state.js' | |
9 | +import type { | |
10 | + PermissionMode, | |
11 | + SDKCompactBoundaryMessage, | |
12 | + SDKMessage, | |
13 | + SDKPermissionDenial, | |
14 | + SDKStatus, | |
15 | + SDKUserMessageReplay, | |
16 | +} from 'src/entrypoints/agentSdkTypes.js' | |
17 | +import { accumulateUsage, updateUsage } from 'src/services/api/mervelas.js' | |
18 | +import type { NonNullableUsage } from 'src/services/api/logging.js' | |
19 | +import { EMPTY_USAGE } from 'src/services/api/logging.js' | |
20 | +import stripAnsi from 'strip-ansi' | |
21 | +import type { Command } from './commands.js' | |
22 | +import { getSlashCommandToolSkills } from './commands.js' | |
23 | +import { | |
24 | + LOCAL_COMMAND_STDERR_TAG, | |
25 | + LOCAL_COMMAND_STDOUT_TAG, | |
26 | +} from './constants/xml.js' | |
27 | +import { | |
28 | + getModelUsage, | |
29 | + getTotalAPIDuration, | |
30 | + getTotalCost, | |
31 | +} from './cost-tracker.js' | |
32 | +import type { CanUseToolFn } from './hooks/useCanUseTool.js' | |
33 | +import { loadMemoryPrompt } from './memdir/memdir.js' | |
34 | +import { hasAutoMemPathOverride } from './memdir/paths.js' | |
35 | +import { query } from './query.js' | |
36 | +import { categorizeRetryableAPIError } from './services/api/errors.js' | |
37 | +import type { MCPServerConnection } from './services/mcp/types.js' | |
38 | +import type { AppState } from './state/AppState.js' | |
39 | +import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js' | |
40 | +import type { AgentDefinition } from './tools/AgentTool/loadAgentsDir.js' | |
41 | +import { SYNTHETIC_OUTPUT_TOOL_NAME } from './tools/SyntheticOutputTool/SyntheticOutputTool.js' | |
42 | +import type { Message } from './types/message.js' | |
43 | +import type { OrphanedPermission } from './types/textInputTypes.js' | |
44 | +import { createAbortController } from './utils/abortController.js' | |
45 | +import type { AttributionState } from './utils/commitAttribution.js' | |
46 | +import { getGlobalConfig } from './utils/config.js' | |
47 | +import { getCwd } from './utils/cwd.js' | |
48 | +import { isBareMode, isEnvTruthy } from './utils/envUtils.js' | |
49 | +import { getFastModeState } from './utils/fastMode.js' | |
50 | +import { | |
51 | + type FileHistoryState, | |
52 | + fileHistoryEnabled, | |
53 | + fileHistoryMakeSnapshot, | |
54 | +} from './utils/fileHistory.js' | |
55 | +import { | |
56 | + cloneFileStateCache, | |
57 | + type FileStateCache, | |
58 | +} from './utils/fileStateCache.js' | |
59 | +import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js' | |
60 | +import { registerStructuredOutputEnforcement } from './utils/hooks/hookHelpers.js' | |
61 | +import { getInMemoryErrors } from './utils/log.js' | |
62 | +import { countToolCalls, SYNTHETIC_MESSAGES } from './utils/messages.js' | |
63 | +import { | |
64 | + getMainLoopModel, | |
65 | + parseUserSpecifiedModel, | |
66 | +} from './utils/model/model.js' | |
67 | +import { loadAllPluginsCacheOnly } from './utils/plugins/pluginLoader.js' | |
68 | +import { | |
69 | + type ProcessUserInputContext, | |
70 | + processUserInput, | |
71 | +} from './utils/processUserInput/processUserInput.js' | |
72 | +import { fetchSystemPromptParts } from './utils/queryContext.js' | |
73 | +import { setCwd } from './utils/Shell.js' | |
74 | +import { | |
75 | + flushSessionStorage, | |
76 | + recordTranscript, | |
77 | +} from './utils/sessionStorage.js' | |
78 | +import { asSystemPrompt } from './utils/systemPromptType.js' | |
79 | +import { resolveThemeSetting } from './utils/systemTheme.js' | |
80 | +import { | |
81 | + shouldEnableThinkingByDefault, | |
82 | + type ThinkingConfig, | |
83 | +} from './utils/thinking.js' | |
84 | + | |
85 | +// Lazy: MessageSelector.tsx pulls React/ink; only needed for message filtering at query time | |
86 | +/* eslint-disable @typescript-eslint/no-require-imports */ | |
87 | +const messageSelector = | |
88 | + (): typeof import('src/components/MessageSelector.js') => | |
89 | + require('src/components/MessageSelector.js') | |
90 | + | |
91 | +import { | |
92 | + localCommandOutputToSDKAssistantMessage, | |
93 | + toSDKCompactMetadata, | |
94 | +} from './utils/messages/mappers.js' | |
95 | +import { | |
96 | + buildSystemInitMessage, | |
97 | + sdkCompatToolName, | |
98 | +} from './utils/messages/systemInit.js' | |
99 | +import { | |
100 | + getScratchpadDir, | |
101 | + isScratchpadEnabled, | |
102 | +} from './utils/permissions/filesystem.js' | |
103 | +/* eslint-enable @typescript-eslint/no-require-imports */ | |
104 | +import { | |
105 | + handleOrphanedPermission, | |
106 | + isResultSuccessful, | |
107 | + normalizeMessage, | |
108 | +} from './utils/queryHelpers.js' | |
109 | + | |
110 | +// Dead code elimination: conditional import for coordinator mode | |
111 | +/* eslint-disable @typescript-eslint/no-require-imports */ | |
112 | +const getCoordinatorUserContext: ( | |
113 | + mcpClients: ReadonlyArray<{ name: string }>, | |
114 | + scratchpadDir?: string, | |
115 | +) => { [k: string]: string } = feature('COORDINATOR_MODE') | |
116 | + ? require('./coordinator/coordinatorMode.js').getCoordinatorUserContext | |
117 | + : () => ({}) | |
118 | +/* eslint-enable @typescript-eslint/no-require-imports */ | |
119 | + | |
120 | +// Dead code elimination: conditional import for snip compaction | |
121 | +/* eslint-disable @typescript-eslint/no-require-imports */ | |
122 | +const snipModule = feature('HISTORY_SNIP') | |
123 | + ? (require('./services/compact/snipCompact.js') as typeof import('./services/compact/snipCompact.js')) | |
124 | + : null | |
125 | +const snipProjection = feature('HISTORY_SNIP') | |
126 | + ? (require('./services/compact/snipProjection.js') as typeof import('./services/compact/snipProjection.js')) | |
127 | + : null | |
128 | +/* eslint-enable @typescript-eslint/no-require-imports */ | |
129 | + | |
130 | +export type QueryEngineConfig = { | |
131 | + cwd: string | |
132 | + tools: Tools | |
133 | + commands: Command[] | |
134 | + mcpClients: MCPServerConnection[] | |
135 | + agents: AgentDefinition[] | |
136 | + canUseTool: CanUseToolFn | |
137 | + getAppState: () => AppState | |
138 | + setAppState: (f: (prev: AppState) => AppState) => void | |
139 | + initialMessages?: Message[] | |
140 | + readFileCache: FileStateCache | |
141 | + customSystemPrompt?: string | |
142 | + appendSystemPrompt?: string | |
143 | + userSpecifiedModel?: string | |
144 | + fallbackModel?: string | |
145 | + thinkingConfig?: ThinkingConfig | |
146 | + maxTurns?: number | |
147 | + maxBudgetUsd?: number | |
148 | + taskBudget?: { total: number } | |
149 | + jsonSchema?: Record<string, unknown> | |
150 | + verbose?: boolean | |
151 | + replayUserMessages?: boolean | |
152 | + /** Handler for URL elicitations triggered by MCP tool -32042 errors. */ | |
153 | + handleElicitation?: ToolUseContext['handleElicitation'] | |
154 | + includePartialMessages?: boolean | |
155 | + setSDKStatus?: (status: SDKStatus) => void | |
156 | + abortController?: AbortController | |
157 | + orphanedPermission?: OrphanedPermission | |
158 | + /** | |
159 | + * Snip-boundary handler: receives each yielded system message plus the | |
160 | + * current mutableMessages store. Returns undefined if the message is not a | |
161 | + * snip boundary; otherwise returns the replayed snip result. Injected by | |
162 | + * ask() when HISTORY_SNIP is enabled so feature-gated strings stay inside | |
163 | + * the gated module (keeps QueryEngine free of excluded strings and testable | |
164 | + * despite feature() returning false under bun test). SDK-only: the REPL | |
165 | + * keeps full history for UI scrollback and projects on demand via | |
166 | + * projectSnippedView; QueryEngine truncates here to bound memory in long | |
167 | + * headless sessions (no UI to preserve). | |
168 | + */ | |
169 | + snipReplay?: ( | |
170 | + yieldedSystemMsg: Message, | |
171 | + store: Message[], | |
172 | + ) => { messages: Message[]; executed: boolean } | undefined | |
173 | +} | |
174 | + | |
175 | +/** | |
176 | + * QueryEngine owns the query lifecycle and session state for a conversation. | |
177 | + * It extracts the core logic from ask() into a standalone class that can be | |
178 | + * used by both the headless/SDK path and (in a future phase) the REPL. | |
179 | + * | |
180 | + * One QueryEngine per conversation. Each submitMessage() call starts a new | |
181 | + * turn within the same conversation. State (messages, file cache, usage, etc.) | |
182 | + * persists across turns. | |
183 | + */ | |
184 | +export class QueryEngine { | |
185 | + private config: QueryEngineConfig | |
186 | + private mutableMessages: Message[] | |
187 | + private abortController: AbortController | |
188 | + private permissionDenials: SDKPermissionDenial[] | |
189 | + private totalUsage: NonNullableUsage | |
190 | + private hasHandledOrphanedPermission = false | |
191 | + private readFileState: FileStateCache | |
192 | + // Turn-scoped skill discovery tracking (feeds was_discovered on | |
193 | + // tengu_skill_tool_invocation). Must persist across the two | |
194 | + // processUserInputContext rebuilds inside submitMessage, but is cleared | |
195 | + // at the start of each submitMessage to avoid unbounded growth across | |
196 | + // many turns in SDK mode. | |
197 | + private discoveredSkillNames = new Set<string>() | |
198 | + private loadedNestedMemoryPaths = new Set<string>() | |
199 | + | |
200 | + constructor(config: QueryEngineConfig) { | |
201 | + this.config = config | |
202 | + this.mutableMessages = config.initialMessages ?? [] | |
203 | + this.abortController = config.abortController ?? createAbortController() | |
204 | + this.permissionDenials = [] | |
205 | + this.readFileState = config.readFileCache | |
206 | + this.totalUsage = EMPTY_USAGE | |
207 | + } | |
208 | + | |
209 | + async *submitMessage( | |
210 | + prompt: string | ContentBlockParam[], | |
211 | + options?: { uuid?: string; isMeta?: boolean }, | |
212 | + ): AsyncGenerator<SDKMessage, void, unknown> { | |
213 | + const { | |
214 | + cwd, | |
215 | + commands, | |
216 | + tools, | |
217 | + mcpClients, | |
218 | + verbose = false, | |
219 | + thinkingConfig, | |
220 | + maxTurns, | |
221 | + maxBudgetUsd, | |
222 | + taskBudget, | |
223 | + canUseTool, | |
224 | + customSystemPrompt, | |
225 | + appendSystemPrompt, | |
226 | + userSpecifiedModel, | |
227 | + fallbackModel, | |
228 | + jsonSchema, | |
229 | + getAppState, | |
230 | + setAppState, | |
231 | + replayUserMessages = false, | |
232 | + includePartialMessages = false, | |
233 | + agents = [], | |
234 | + setSDKStatus, | |
235 | + orphanedPermission, | |
236 | + } = this.config | |
237 | + | |
238 | + this.discoveredSkillNames.clear() | |
239 | + setCwd(cwd) | |
240 | + const persistSession = !isSessionPersistenceDisabled() | |
241 | + const startTime = Date.now() | |
242 | + | |
243 | + // Wrap canUseTool to track permission denials | |
244 | + const wrappedCanUseTool: CanUseToolFn = async ( | |
245 | + tool, | |
246 | + input, | |
247 | + toolUseContext, | |
248 | + assistantMessage, | |
249 | + toolUseID, | |
250 | + forceDecision, | |
251 | + ) => { | |
252 | + const result = await canUseTool( | |
253 | + tool, | |
254 | + input, | |
255 | + toolUseContext, | |
256 | + assistantMessage, | |
257 | + toolUseID, | |
258 | + forceDecision, | |
259 | + ) | |
260 | + | |
261 | + // Track denials for SDK reporting | |
262 | + if (result.behavior !== 'allow') { | |
263 | + this.permissionDenials.push({ | |
264 | + tool_name: sdkCompatToolName(tool.name), | |
265 | + tool_use_id: toolUseID, | |
266 | + tool_input: input, | |
267 | + }) | |
268 | + } | |
269 | + | |
270 | + return result | |
271 | + } | |
272 | + | |
273 | + const initialAppState = getAppState() | |
274 | + const initialMainLoopModel = userSpecifiedModel | |
275 | + ? parseUserSpecifiedModel(userSpecifiedModel) | |
276 | + : getMainLoopModel() | |
277 | + | |
278 | + const initialThinkingConfig: ThinkingConfig = thinkingConfig | |
279 | + ? thinkingConfig | |
280 | + : shouldEnableThinkingByDefault() !== false | |
281 | + ? { type: 'adaptive' } | |
282 | + : { type: 'disabled' } | |
283 | + | |
284 | + headlessProfilerCheckpoint('before_getSystemPrompt') | |
285 | + // Narrow once so TS tracks the type through the conditionals below. | |
286 | + const customPrompt = | |
287 | + typeof customSystemPrompt === 'string' ? customSystemPrompt : undefined | |
288 | + const { | |
289 | + defaultSystemPrompt, | |
290 | + userContext: baseUserContext, | |
291 | + systemContext, | |
292 | + } = await fetchSystemPromptParts({ | |
293 | + tools, | |
294 | + mainLoopModel: initialMainLoopModel, | |
295 | + additionalWorkingDirectories: Array.from( | |
296 | + initialAppState.toolPermissionContext.additionalWorkingDirectories.keys(), | |
297 | + ), | |
298 | + mcpClients, | |
299 | + customSystemPrompt: customPrompt, | |
300 | + }) | |
301 | + headlessProfilerCheckpoint('after_getSystemPrompt') | |
302 | + const userContext = { | |
303 | + ...baseUserContext, | |
304 | + ...getCoordinatorUserContext( | |
305 | + mcpClients, | |
306 | + isScratchpadEnabled() ? getScratchpadDir() : undefined, | |
307 | + ), | |
308 | + } | |
309 | + | |
310 | + // When an SDK caller provides a custom system prompt AND has set | |
311 | + // CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, inject the memory-mechanics prompt. | |
312 | + // The env var is an explicit opt-in signal — the caller has wired up | |
313 | + // a memory directory and needs Claude to know how to use it (which | |
314 | + // Write/Edit tools to call, MEMORY.md filename, loading semantics). | |
315 | + // The caller can layer their own policy text via appendSystemPrompt. | |
316 | + const memoryMechanicsPrompt = | |
317 | + customPrompt !== undefined && hasAutoMemPathOverride() | |
318 | + ? await loadMemoryPrompt() | |
319 | + : null | |
320 | + | |
321 | + const systemPrompt = asSystemPrompt([ | |
322 | + ...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt), | |
323 | + ...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []), | |
324 | + ...(appendSystemPrompt ? [appendSystemPrompt] : []), | |
325 | + ]) | |
326 | + | |
327 | + // Register function hook for structured output enforcement | |
328 | + const hasStructuredOutputTool = tools.some(t => | |
329 | + toolMatchesName(t, SYNTHETIC_OUTPUT_TOOL_NAME), | |
330 | + ) | |
331 | + if (jsonSchema && hasStructuredOutputTool) { | |
332 | + registerStructuredOutputEnforcement(setAppState, getSessionId()) | |
333 | + } | |
334 | + | |
335 | + let processUserInputContext: ProcessUserInputContext = { | |
336 | + messages: this.mutableMessages, | |
337 | + // Slash commands that mutate the message array (e.g. /force-snip) | |
338 | + // call setMessages(fn). In interactive mode this writes back to | |
339 | + // AppState; in print mode we write back to mutableMessages so the | |
340 | + // rest of the query loop (push at :389, snapshot at :392) sees | |
341 | + // the result. The second processUserInputContext below (after | |
342 | + // slash-command processing) keeps the no-op — nothing else calls | |
343 | + // setMessages past that point. | |
344 | + setMessages: fn => { | |
345 | + this.mutableMessages = fn(this.mutableMessages) | |
346 | + }, | |
347 | + onChangeAPIKey: () => {}, | |
348 | + handleElicitation: this.config.handleElicitation, | |
349 | + options: { | |
350 | + commands, | |
351 | + debug: false, // we use stdout, so don't want to clobber it | |
352 | + tools, | |
353 | + verbose, | |
354 | + mainLoopModel: initialMainLoopModel, | |
355 | + thinkingConfig: initialThinkingConfig, | |
356 | + mcpClients, | |
357 | + mcpResources: {}, | |
358 | + ideInstallationStatus: null, | |
359 | + isNonInteractiveSession: true, | |
360 | + customSystemPrompt, | |
361 | + appendSystemPrompt, | |
362 | + agentDefinitions: { activeAgents: agents, allAgents: [] }, | |
363 | + theme: resolveThemeSetting(getGlobalConfig().theme), | |
364 | + maxBudgetUsd, | |
365 | + }, | |
366 | + getAppState, | |
367 | + setAppState, | |
368 | + abortController: this.abortController, | |
369 | + readFileState: this.readFileState, | |
370 | + nestedMemoryAttachmentTriggers: new Set<string>(), | |
371 | + loadedNestedMemoryPaths: this.loadedNestedMemoryPaths, | |
372 | + dynamicSkillDirTriggers: new Set<string>(), | |
373 | + discoveredSkillNames: this.discoveredSkillNames, | |
374 | + setInProgressToolUseIDs: () => {}, | |
375 | + setResponseLength: () => {}, | |
376 | + updateFileHistoryState: ( | |
377 | + updater: (prev: FileHistoryState) => FileHistoryState, | |
378 | + ) => { | |
379 | + setAppState(prev => { | |
380 | + const updated = updater(prev.fileHistory) | |
381 | + if (updated === prev.fileHistory) return prev | |
382 | + return { ...prev, fileHistory: updated } | |
383 | + }) | |
384 | + }, | |
385 | + updateAttributionState: ( | |
386 | + updater: (prev: AttributionState) => AttributionState, | |
387 | + ) => { | |
388 | + setAppState(prev => { | |
389 | + const updated = updater(prev.attribution) | |
390 | + if (updated === prev.attribution) return prev | |
391 | + return { ...prev, attribution: updated } | |
392 | + }) | |
393 | + }, | |
394 | + setSDKStatus, | |
395 | + } | |
396 | + | |
397 | + // Handle orphaned permission (only once per engine lifetime) | |
398 | + if (orphanedPermission && !this.hasHandledOrphanedPermission) { | |
399 | + this.hasHandledOrphanedPermission = true | |
400 | + for await (const message of handleOrphanedPermission( | |
401 | + orphanedPermission, | |
402 | + tools, | |
403 | + this.mutableMessages, | |
404 | + processUserInputContext, | |
405 | + )) { | |
406 | + yield message | |
407 | + } | |
408 | + } | |
409 | + | |
410 | + const { | |
411 | + messages: messagesFromUserInput, | |
412 | + shouldQuery, | |
413 | + allowedTools, | |
414 | + model: modelFromUserInput, | |
415 | + resultText, | |
416 | + } = await processUserInput({ | |
417 | + input: prompt, | |
418 | + mode: 'prompt', | |
419 | + setToolJSX: () => {}, | |
420 | + context: { | |
421 | + ...processUserInputContext, | |
422 | + messages: this.mutableMessages, | |
423 | + }, | |
424 | + messages: this.mutableMessages, | |
425 | + uuid: options?.uuid, | |
426 | + isMeta: options?.isMeta, | |
427 | + querySource: 'sdk', | |
428 | + }) | |
429 | + | |
430 | + // Push new messages, including user input and any attachments | |
431 | + this.mutableMessages.push(...messagesFromUserInput) | |
432 | + | |
433 | + // Update params to reflect updates from processing /slash commands | |
434 | + const messages = [...this.mutableMessages] | |
435 | + | |
436 | + // Persist the user's message(s) to transcript BEFORE entering the query | |
437 | + // loop. The for-await below only calls recordTranscript when ask() yields | |
438 | + // an assistant/user/compact_boundary message — which doesn't happen until | |
439 | + // the API responds. If the process is killed before that (e.g. user clicks | |
440 | + // Stop in cowork seconds after send), the transcript is left with only | |
441 | + // queue-operation entries; getLastSessionLog filters those out, returns | |
442 | + // null, and --resume fails with "No conversation found". Writing now makes | |
443 | + // the transcript resumable from the point the user message was accepted, | |
444 | + // even if no API response ever arrives. | |
445 | + // | |
446 | + // --bare / SIMPLE: fire-and-forget. Scripted calls don't --resume after | |
447 | + // kill-mid-request. The await is ~4ms on SSD, ~30ms under disk contention | |
448 | + // — the single largest controllable critical-path cost after module eval. | |
449 | + // Transcript is still written (for post-hoc debugging); just not blocking. | |
450 | + if (persistSession && messagesFromUserInput.length > 0) { | |
451 | + const transcriptPromise = recordTranscript(messages) | |
452 | + if (isBareMode()) { | |
453 | + void transcriptPromise | |
454 | + } else { | |
455 | + await transcriptPromise | |
456 | + if ( | |
457 | + isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) || | |
458 | + isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK) | |
459 | + ) { | |
460 | + await flushSessionStorage() | |
461 | + } | |
462 | + } | |
463 | + } | |
464 | + | |
465 | + // Filter messages that should be acknowledged after transcript | |
466 | + const replayableMessages = messagesFromUserInput.filter( | |
467 | + msg => | |
468 | + (msg.type === 'user' && | |
469 | + !msg.isMeta && // Skip synthetic caveat messages | |
470 | + !msg.toolUseResult && // Skip tool results (they'll be acked from query) | |
471 | + messageSelector().selectableUserMessagesFilter(msg)) || // Skip non-user-authored messages (task notifications, etc.) | |
472 | + (msg.type === 'system' && msg.subtype === 'compact_boundary'), // Always ack compact boundaries | |
473 | + ) | |
474 | + const messagesToAck = replayUserMessages ? replayableMessages : [] | |
475 | + | |
476 | + // Update the ToolPermissionContext based on user input processing (as necessary) | |
477 | + setAppState(prev => ({ | |
478 | + ...prev, | |
479 | + toolPermissionContext: { | |
480 | + ...prev.toolPermissionContext, | |
481 | + alwaysAllowRules: { | |
482 | + ...prev.toolPermissionContext.alwaysAllowRules, | |
483 | + command: allowedTools, | |
484 | + }, | |
485 | + }, | |
486 | + })) | |
487 | + | |
488 | + const mainLoopModel = modelFromUserInput ?? initialMainLoopModel | |
489 | + | |
490 | + // Recreate after processing the prompt to pick up updated messages and | |
491 | + // model (from slash commands). | |
492 | + processUserInputContext = { | |
493 | + messages, | |
494 | + setMessages: () => {}, | |
495 | + onChangeAPIKey: () => {}, | |
496 | + handleElicitation: this.config.handleElicitation, | |
497 | + options: { | |
498 | + commands, | |
499 | + debug: false, | |
500 | + tools, | |
501 | + verbose, | |
502 | + mainLoopModel, | |
503 | + thinkingConfig: initialThinkingConfig, | |
504 | + mcpClients, | |
505 | + mcpResources: {}, | |
506 | + ideInstallationStatus: null, | |
507 | + isNonInteractiveSession: true, | |
508 | + customSystemPrompt, | |
509 | + appendSystemPrompt, | |
510 | + theme: resolveThemeSetting(getGlobalConfig().theme), | |
511 | + agentDefinitions: { activeAgents: agents, allAgents: [] }, | |
512 | + maxBudgetUsd, | |
513 | + }, | |
514 | + getAppState, | |
515 | + setAppState, | |
516 | + abortController: this.abortController, | |
517 | + readFileState: this.readFileState, | |
518 | + nestedMemoryAttachmentTriggers: new Set<string>(), | |
519 | + loadedNestedMemoryPaths: this.loadedNestedMemoryPaths, | |
520 | + dynamicSkillDirTriggers: new Set<string>(), | |
521 | + discoveredSkillNames: this.discoveredSkillNames, | |
522 | + setInProgressToolUseIDs: () => {}, | |
523 | + setResponseLength: () => {}, | |
524 | + updateFileHistoryState: processUserInputContext.updateFileHistoryState, | |
525 | + updateAttributionState: processUserInputContext.updateAttributionState, | |
526 | + setSDKStatus, | |
527 | + } | |
528 | + | |
529 | + headlessProfilerCheckpoint('before_skills_plugins') | |
530 | + // Cache-only: headless/SDK/CCR startup must not block on network for | |
531 | + // ref-tracked plugins. CCR populates the cache via CLAUDE_CODE_SYNC_PLUGIN_INSTALL | |
532 | + // (headlessPluginInstall) or CLAUDE_CODE_PLUGIN_SEED_DIR before this runs; | |
533 | + // SDK callers that need fresh source can call /reload-plugins. | |
534 | + const [skills, { enabled: enabledPlugins }] = await Promise.all([ | |
535 | + getSlashCommandToolSkills(getCwd()), | |
536 | + loadAllPluginsCacheOnly(), | |
537 | + ]) | |
538 | + headlessProfilerCheckpoint('after_skills_plugins') | |
539 | + | |
540 | + yield buildSystemInitMessage({ | |
541 | + tools, | |
542 | + mcpClients, | |
543 | + model: mainLoopModel, | |
544 | + permissionMode: initialAppState.toolPermissionContext | |
545 | + .mode as PermissionMode, // TODO: avoid the cast | |
546 | + commands, | |
547 | + agents, | |
548 | + skills, | |
549 | + plugins: enabledPlugins, | |
550 | + fastMode: initialAppState.fastMode, | |
551 | + }) | |
552 | + | |
553 | + // Record when system message is yielded for headless latency tracking | |
554 | + headlessProfilerCheckpoint('system_message_yielded') | |
555 | + | |
556 | + if (!shouldQuery) { | |
557 | + // Return the results of local slash commands. | |
558 | + // Use messagesFromUserInput (not replayableMessages) for command output | |
559 | + // because selectableUserMessagesFilter excludes local-command-stdout tags. | |
560 | + for (const msg of messagesFromUserInput) { | |
561 | + if ( | |
562 | + msg.type === 'user' && | |
563 | + typeof msg.message.content === 'string' && | |
564 | + (msg.message.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) || | |
565 | + msg.message.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`) || | |
566 | + msg.isCompactSummary) | |
567 | + ) { | |
568 | + yield { | |
569 | + type: 'user', | |
570 | + message: { | |
571 | + ...msg.message, | |
572 | + content: stripAnsi(msg.message.content), | |
573 | + }, | |
574 | + session_id: getSessionId(), | |
575 | + parent_tool_use_id: null, | |
576 | + uuid: msg.uuid, | |
577 | + timestamp: msg.timestamp, | |
578 | + isReplay: !msg.isCompactSummary, | |
579 | + isSynthetic: msg.isMeta || msg.isVisibleInTranscriptOnly, | |
580 | + } as SDKUserMessageReplay | |
581 | + } | |
582 | + | |
583 | + // Local command output — yield as a synthetic assistant message so | |
584 | + // RC renders it as assistant-style text rather than a user bubble. | |
585 | + // Emitted as assistant (not the dedicated SDKLocalCommandOutputMessage | |
586 | + // system subtype) so mobile clients + session-ingress can parse it. | |
587 | + if ( | |
588 | + msg.type === 'system' && | |
589 | + msg.subtype === 'local_command' && | |
590 | + typeof msg.content === 'string' && | |
591 | + (msg.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) || | |
592 | + msg.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`)) | |
593 | + ) { | |
594 | + yield localCommandOutputToSDKAssistantMessage(msg.content, msg.uuid) | |
595 | + } | |
596 | + | |
597 | + if (msg.type === 'system' && msg.subtype === 'compact_boundary') { | |
598 | + yield { | |
599 | + type: 'system', | |
600 | + subtype: 'compact_boundary' as const, | |
601 | + session_id: getSessionId(), | |
602 | + uuid: msg.uuid, | |
603 | + compact_metadata: toSDKCompactMetadata(msg.compactMetadata), | |
604 | + } as SDKCompactBoundaryMessage | |
605 | + } | |
606 | + } | |
607 | + | |
608 | + if (persistSession) { | |
609 | + await recordTranscript(messages) | |
610 | + if ( | |
611 | + isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) || | |
612 | + isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK) | |
613 | + ) { | |
614 | + await flushSessionStorage() | |
615 | + } | |
616 | + } | |
617 | + | |
618 | + yield { | |
619 | + type: 'result', | |
620 | + subtype: 'success', | |
621 | + is_error: false, | |
622 | + duration_ms: Date.now() - startTime, | |
623 | + duration_api_ms: getTotalAPIDuration(), | |
624 | + num_turns: messages.length - 1, | |
625 | + result: resultText ?? '', | |
626 | + stop_reason: null, | |
627 | + session_id: getSessionId(), | |
628 | + total_cost_usd: getTotalCost(), | |
629 | + usage: this.totalUsage, | |
630 | + modelUsage: getModelUsage(), | |
631 | + permission_denials: this.permissionDenials, | |
632 | + fast_mode_state: getFastModeState( | |
633 | + mainLoopModel, | |
634 | + initialAppState.fastMode, | |
635 | + ), | |
636 | + uuid: randomUUID(), | |
637 | + } | |
638 | + return | |
639 | + } | |
640 | + | |
641 | + if (fileHistoryEnabled() && persistSession) { | |
642 | + messagesFromUserInput | |
643 | + .filter(messageSelector().selectableUserMessagesFilter) | |
644 | + .forEach(message => { | |
645 | + void fileHistoryMakeSnapshot( | |
646 | + (updater: (prev: FileHistoryState) => FileHistoryState) => { | |
647 | + setAppState(prev => ({ | |
648 | + ...prev, | |
649 | + fileHistory: updater(prev.fileHistory), | |
650 | + })) | |
651 | + }, | |
652 | + message.uuid, | |
653 | + ) | |
654 | + }) | |
655 | + } | |
656 | + | |
657 | + // Track current message usage (reset on each message_start) | |
658 | + let currentMessageUsage: NonNullableUsage = EMPTY_USAGE | |
659 | + let turnCount = 1 | |
660 | + let hasAcknowledgedInitialMessages = false | |
661 | + // Track structured output from StructuredOutput tool calls | |
662 | + let structuredOutputFromTool: unknown | |
663 | + // Track the last stop_reason from assistant messages | |
664 | + let lastStopReason: string | null = null | |
665 | + // Reference-based watermark so error_during_execution's errors[] is | |
666 | + // turn-scoped. A length-based index breaks when the 100-entry ring buffer | |
667 | + // shift()s during the turn — the index slides. If this entry is rotated | |
668 | + // out, lastIndexOf returns -1 and we include everything (safe fallback). | |
669 | + const errorLogWatermark = getInMemoryErrors().at(-1) | |
670 | + // Snapshot count before this query for delta-based retry limiting | |
671 | + const initialStructuredOutputCalls = jsonSchema | |
672 | + ? countToolCalls(this.mutableMessages, SYNTHETIC_OUTPUT_TOOL_NAME) | |
673 | + : 0 | |
674 | + | |
675 | + for await (const message of query({ | |
676 | + messages, | |
677 | + systemPrompt, | |
678 | + userContext, | |
679 | + systemContext, | |
680 | + canUseTool: wrappedCanUseTool, | |
681 | + toolUseContext: processUserInputContext, | |
682 | + fallbackModel, | |
683 | + querySource: 'sdk', | |
684 | + maxTurns, | |
685 | + taskBudget, | |
686 | + })) { | |
687 | + // Record assistant, user, and compact boundary messages | |
688 | + if ( | |
689 | + message.type === 'assistant' || | |
690 | + message.type === 'user' || | |
691 | + (message.type === 'system' && message.subtype === 'compact_boundary') | |
692 | + ) { | |
693 | + // Before writing a compact boundary, flush any in-memory-only | |
694 | + // messages up through the preservedSegment tail. Attachments and | |
695 | + // progress are now recorded inline (their switch cases below), but | |
696 | + // this flush still matters for the preservedSegment tail walk. | |
697 | + // If the SDK subprocess restarts before then (claude-desktop kills | |
698 | + // between turns), tailUuid points to a never-written message → | |
699 | + // applyPreservedSegmentRelinks fails its tail→head walk → returns | |
700 | + // without pruning → resume loads full pre-compact history. | |
701 | + if ( | |
702 | + persistSession && | |
703 | + message.type === 'system' && | |
704 | + message.subtype === 'compact_boundary' | |
705 | + ) { | |
706 | + const tailUuid = message.compactMetadata?.preservedSegment?.tailUuid | |
707 | + if (tailUuid) { | |
708 | + const tailIdx = this.mutableMessages.findLastIndex( | |
709 | + m => m.uuid === tailUuid, | |
710 | + ) | |
711 | + if (tailIdx !== -1) { | |
712 | + await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1)) | |
713 | + } | |
714 | + } | |
715 | + } | |
716 | + messages.push(message) | |
717 | + if (persistSession) { | |
718 | + // Fire-and-forget for assistant messages. claude.ts yields one | |
719 | + // assistant message per content block, then mutates the last | |
720 | + // one's message.usage/stop_reason on message_delta — relying on | |
721 | + // the write queue's 100ms lazy jsonStringify. Awaiting here | |
722 | + // blocks ask()'s generator, so message_delta can't run until | |
723 | + // every block is consumed; the drain timer (started at block 1) | |
724 | + // elapses first. Interactive CC doesn't hit this because | |
725 | + // useLogMessages.ts fire-and-forgets. enqueueWrite is | |
726 | + // order-preserving so fire-and-forget here is safe. | |
727 | + if (message.type === 'assistant') { | |
728 | + void recordTranscript(messages) | |
729 | + } else { | |
730 | + await recordTranscript(messages) | |
731 | + } | |
732 | + } | |
733 | + | |
734 | + // Acknowledge initial user messages after first transcript recording | |
735 | + if (!hasAcknowledgedInitialMessages && messagesToAck.length > 0) { | |
736 | + hasAcknowledgedInitialMessages = true | |
737 | + for (const msgToAck of messagesToAck) { | |
738 | + if (msgToAck.type === 'user') { | |
739 | + yield { | |
740 | + type: 'user', | |
741 | + message: msgToAck.message, | |
742 | + session_id: getSessionId(), | |
743 | + parent_tool_use_id: null, | |
744 | + uuid: msgToAck.uuid, | |
745 | + timestamp: msgToAck.timestamp, | |
746 | + isReplay: true, | |
747 | + } as SDKUserMessageReplay | |
748 | + } | |
749 | + } | |
750 | + } | |
751 | + } | |
752 | + | |
753 | + if (message.type === 'user') { | |
754 | + turnCount++ | |
755 | + } | |
756 | + | |
757 | + switch (message.type) { | |
758 | + case 'tombstone': | |
759 | + // Tombstone messages are control signals for removing messages, skip them | |
760 | + break | |
761 | + case 'assistant': | |
762 | + // Capture stop_reason if already set (synthetic messages). For | |
763 | + // streamed responses, this is null at content_block_stop time; | |
764 | + // the real value arrives via message_delta (handled below). | |
765 | + if (message.message.stop_reason != null) { | |
766 | + lastStopReason = message.message.stop_reason | |
767 | + } | |
768 | + this.mutableMessages.push(message) | |
769 | + yield* normalizeMessage(message) | |
770 | + break | |
771 | + case 'progress': | |
772 | + this.mutableMessages.push(message) | |
773 | + // Record inline so the dedup loop in the next ask() call sees it | |
774 | + // as already-recorded. Without this, deferred progress interleaves | |
775 | + // with already-recorded tool_results in mutableMessages, and the | |
776 | + // dedup walk freezes startingParentUuid at the wrong message — | |
777 | + // forking the chain and orphaning the conversation on resume. | |
778 | + if (persistSession) { | |
779 | + messages.push(message) | |
780 | + void recordTranscript(messages) | |
781 | + } | |
782 | + yield* normalizeMessage(message) | |
783 | + break | |
784 | + case 'user': | |
785 | + this.mutableMessages.push(message) | |
786 | + yield* normalizeMessage(message) | |
787 | + break | |
788 | + case 'stream_event': | |
789 | + if (message.event.type === 'message_start') { | |
790 | + // Reset current message usage for new message | |
791 | + currentMessageUsage = EMPTY_USAGE | |
792 | + currentMessageUsage = updateUsage( | |
793 | + currentMessageUsage, | |
794 | + message.event.message.usage, | |
795 | + ) | |
796 | + } | |
797 | + if (message.event.type === 'message_delta') { | |
798 | + currentMessageUsage = updateUsage( | |
799 | + currentMessageUsage, | |
800 | + message.event.usage, | |
801 | + ) | |
802 | + // Capture stop_reason from message_delta. The assistant message | |
803 | + // is yielded at content_block_stop with stop_reason=null; the | |
804 | + // real value only arrives here (see claude.ts message_delta | |
805 | + // handler). Without this, result.stop_reason is always null. | |
806 | + if (message.event.delta.stop_reason != null) { | |
807 | + lastStopReason = message.event.delta.stop_reason | |
808 | + } | |
809 | + } | |
810 | + if (message.event.type === 'message_stop') { | |
811 | + // Accumulate current message usage into total | |
812 | + this.totalUsage = accumulateUsage( | |
813 | + this.totalUsage, | |
814 | + currentMessageUsage, | |
815 | + ) | |
816 | + } | |
817 | + | |
818 | + if (includePartialMessages) { | |
819 | + yield { | |
820 | + type: 'stream_event' as const, | |
821 | + event: message.event, | |
822 | + session_id: getSessionId(), | |
823 | + parent_tool_use_id: null, | |
824 | + uuid: randomUUID(), | |
825 | + } | |
826 | + } | |
827 | + | |
828 | + break | |
829 | + case 'attachment': | |
830 | + this.mutableMessages.push(message) | |
831 | + // Record inline (same reason as progress above). | |
832 | + if (persistSession) { | |
833 | + messages.push(message) | |
834 | + void recordTranscript(messages) | |
835 | + } | |
836 | + | |
837 | + // Extract structured output from StructuredOutput tool calls | |
838 | + if (message.attachment.type === 'structured_output') { | |
839 | + structuredOutputFromTool = message.attachment.data | |
840 | + } | |
841 | + // Handle max turns reached signal from query.ts | |
842 | + else if (message.attachment.type === 'max_turns_reached') { | |
843 | + if (persistSession) { | |
844 | + if ( | |
845 | + isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) || | |
846 | + isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK) | |
847 | + ) { | |
848 | + await flushSessionStorage() | |
849 | + } | |
850 | + } | |
851 | + yield { | |
852 | + type: 'result', | |
853 | + subtype: 'error_max_turns', | |
854 | + duration_ms: Date.now() - startTime, | |
855 | + duration_api_ms: getTotalAPIDuration(), | |
856 | + is_error: true, | |
857 | + num_turns: message.attachment.turnCount, | |
858 | + stop_reason: lastStopReason, | |
859 | + session_id: getSessionId(), | |
860 | + total_cost_usd: getTotalCost(), | |
861 | + usage: this.totalUsage, | |
862 | + modelUsage: getModelUsage(), | |
863 | + permission_denials: this.permissionDenials, | |
864 | + fast_mode_state: getFastModeState( | |
865 | + mainLoopModel, | |
866 | + initialAppState.fastMode, | |
867 | + ), | |
868 | + uuid: randomUUID(), | |
869 | + errors: [ | |
870 | + `Reached maximum number of turns (${message.attachment.maxTurns})`, | |
871 | + ], | |
872 | + } | |
873 | + return | |
874 | + } | |
875 | + // Yield queued_command attachments as SDK user message replays | |
876 | + else if ( | |
877 | + replayUserMessages && | |
878 | + message.attachment.type === 'queued_command' | |
879 | + ) { | |
880 | + yield { | |
881 | + type: 'user', | |
882 | + message: { | |
883 | + role: 'user' as const, | |
884 | + content: message.attachment.prompt, | |
885 | + }, | |
886 | + session_id: getSessionId(), | |
887 | + parent_tool_use_id: null, | |
888 | + uuid: message.attachment.source_uuid || message.uuid, | |
889 | + timestamp: message.timestamp, | |
890 | + isReplay: true, | |
891 | + } as SDKUserMessageReplay | |
892 | + } | |
893 | + break | |
894 | + case 'stream_request_start': | |
895 | + // Don't yield stream request start messages | |
896 | + break | |
897 | + case 'system': { | |
898 | + // Snip boundary: replay on our store to remove zombie messages and | |
899 | + // stale markers. The yielded boundary is a signal, not data to push — | |
900 | + // the replay produces its own equivalent boundary. Without this, | |
901 | + // markers persist and re-trigger on every turn, and mutableMessages | |
902 | + // never shrinks (memory leak in long SDK sessions). The subtype | |
903 | + // check lives inside the injected callback so feature-gated strings | |
904 | + // stay out of this file (excluded-strings check). | |
905 | + const snipResult = this.config.snipReplay?.( | |
906 | + message, | |
907 | + this.mutableMessages, | |
908 | + ) | |
909 | + if (snipResult !== undefined) { | |
910 | + if (snipResult.executed) { | |
911 | + this.mutableMessages.length = 0 | |
912 | + this.mutableMessages.push(...snipResult.messages) | |
913 | + } | |
914 | + break | |
915 | + } | |
916 | + this.mutableMessages.push(message) | |
917 | + // Yield compact boundary messages to SDK | |
918 | + if ( | |
919 | + message.subtype === 'compact_boundary' && | |
920 | + message.compactMetadata | |
921 | + ) { | |
922 | + // Release pre-compaction messages for GC. The boundary was just | |
923 | + // pushed so it's the last element. query.ts already uses | |
924 | + // getMessagesAfterCompactBoundary() internally, so only | |
925 | + // post-boundary messages are needed going forward. | |
926 | + const mutableBoundaryIdx = this.mutableMessages.length - 1 | |
927 | + if (mutableBoundaryIdx > 0) { | |
928 | + this.mutableMessages.splice(0, mutableBoundaryIdx) | |
929 | + } | |
930 | + const localBoundaryIdx = messages.length - 1 | |
931 | + if (localBoundaryIdx > 0) { | |
932 | + messages.splice(0, localBoundaryIdx) | |
933 | + } | |
934 | + | |
935 | + yield { | |
936 | + type: 'system', | |
937 | + subtype: 'compact_boundary' as const, | |
938 | + session_id: getSessionId(), | |
939 | + uuid: message.uuid, | |
940 | + compact_metadata: toSDKCompactMetadata(message.compactMetadata), | |
941 | + } | |
942 | + } | |
943 | + if (message.subtype === 'api_error') { | |
944 | + yield { | |
945 | + type: 'system', | |
946 | + subtype: 'api_retry' as const, | |
947 | + attempt: message.retryAttempt, | |
948 | + max_retries: message.maxRetries, | |
949 | + retry_delay_ms: message.retryInMs, | |
950 | + error_status: message.error.status ?? null, | |
951 | + error: categorizeRetryableAPIError(message.error), | |
952 | + session_id: getSessionId(), | |
953 | + uuid: message.uuid, | |
954 | + } | |
955 | + } | |
956 | + // Don't yield other system messages in headless mode | |
957 | + break | |
958 | + } | |
959 | + case 'tool_use_summary': | |
960 | + // Yield tool use summary messages to SDK | |
961 | + yield { | |
962 | + type: 'tool_use_summary' as const, | |
963 | + summary: message.summary, | |
964 | + preceding_tool_use_ids: message.precedingToolUseIds, | |
965 | + session_id: getSessionId(), | |
966 | + uuid: message.uuid, | |
967 | + } | |
968 | + break | |
969 | + } | |
970 | + | |
971 | + // Check if USD budget has been exceeded | |
972 | + if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) { | |
973 | + if (persistSession) { | |
974 | + if ( | |
975 | + isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) || | |
976 | + isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK) | |
977 | + ) { | |
978 | + await flushSessionStorage() | |
979 | + } | |
980 | + } | |
981 | + yield { | |
982 | + type: 'result', | |
983 | + subtype: 'error_max_budget_usd', | |
984 | + duration_ms: Date.now() - startTime, | |
985 | + duration_api_ms: getTotalAPIDuration(), | |
986 | + is_error: true, | |
987 | + num_turns: turnCount, | |
988 | + stop_reason: lastStopReason, | |
989 | + session_id: getSessionId(), | |
990 | + total_cost_usd: getTotalCost(), | |
991 | + usage: this.totalUsage, | |
992 | + modelUsage: getModelUsage(), | |
993 | + permission_denials: this.permissionDenials, | |
994 | + fast_mode_state: getFastModeState( | |
995 | + mainLoopModel, | |
996 | + initialAppState.fastMode, | |
997 | + ), | |
998 | + uuid: randomUUID(), | |
999 | + errors: [`Reached maximum budget ($${maxBudgetUsd})`], | |
1000 | + } | |
1001 | + return | |
1002 | + } | |
1003 | + | |
1004 | + // Check if structured output retry limit exceeded (only on user messages) | |
1005 | + if (message.type === 'user' && jsonSchema) { | |
1006 | + const currentCalls = countToolCalls( | |
1007 | + this.mutableMessages, | |
1008 | + SYNTHETIC_OUTPUT_TOOL_NAME, | |
1009 | + ) | |
1010 | + const callsThisQuery = currentCalls - initialStructuredOutputCalls | |
1011 | + const maxRetries = parseInt( | |
1012 | + process.env.MAX_STRUCTURED_OUTPUT_RETRIES || '5', | |
1013 | + 10, | |
1014 | + ) | |
1015 | + if (callsThisQuery >= maxRetries) { | |
1016 | + if (persistSession) { | |
1017 | + if ( | |
1018 | + isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) || | |
1019 | + isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK) | |
1020 | + ) { | |
1021 | + await flushSessionStorage() | |
1022 | + } | |
1023 | + } | |
1024 | + yield { | |
1025 | + type: 'result', | |
1026 | + subtype: 'error_max_structured_output_retries', | |
1027 | + duration_ms: Date.now() - startTime, | |
1028 | + duration_api_ms: getTotalAPIDuration(), | |
1029 | + is_error: true, | |
1030 | + num_turns: turnCount, | |
1031 | + stop_reason: lastStopReason, | |
1032 | + session_id: getSessionId(), | |
1033 | + total_cost_usd: getTotalCost(), | |
1034 | + usage: this.totalUsage, | |
1035 | + modelUsage: getModelUsage(), | |
1036 | + permission_denials: this.permissionDenials, | |
1037 | + fast_mode_state: getFastModeState( | |
1038 | + mainLoopModel, | |
1039 | + initialAppState.fastMode, | |
1040 | + ), | |
1041 | + uuid: randomUUID(), | |
1042 | + errors: [ | |
1043 | + `Failed to provide valid structured output after ${maxRetries} attempts`, | |
1044 | + ], | |
1045 | + } | |
1046 | + return | |
1047 | + } | |
1048 | + } | |
1049 | + } | |
1050 | + | |
1051 | + // Stop hooks yield progress/attachment messages AFTER the assistant | |
1052 | + // response (via yield* handleStopHooks in query.ts). Since #23537 pushes | |
1053 | + // those to `messages` inline, last(messages) can be a progress/attachment | |
1054 | + // instead of the assistant — which makes textResult extraction below | |
1055 | + // return '' and -p mode emit a blank line. Allowlist to assistant|user: | |
1056 | + // isResultSuccessful handles both (user with all tool_result blocks is a | |
1057 | + // valid successful terminal state). | |
1058 | + const result = messages.findLast( | |
1059 | + m => m.type === 'assistant' || m.type === 'user', | |
1060 | + ) | |
1061 | + // Capture for the error_during_execution diagnostic — isResultSuccessful | |
1062 | + // is a type predicate (message is Message), so inside the false branch | |
1063 | + // `result` narrows to never and these accesses don't typecheck. | |
1064 | + const edeResultType = result?.type ?? 'undefined' | |
1065 | + const edeLastContentType = | |
1066 | + result?.type === 'assistant' | |
1067 | + ? (last(result.message.content)?.type ?? 'none') | |
1068 | + : 'n/a' | |
1069 | + | |
1070 | + // Flush buffered transcript writes before yielding result. | |
1071 | + // The desktop app kills the CLI process immediately after receiving the | |
1072 | + // result message, so any unflushed writes would be lost. | |
1073 | + if (persistSession) { | |
1074 | + if ( | |
1075 | + isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) || | |
1076 | + isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK) | |
1077 | + ) { | |
1078 | + await flushSessionStorage() | |
1079 | + } | |
1080 | + } | |
1081 | + | |
1082 | + if (!isResultSuccessful(result, lastStopReason)) { | |
1083 | + yield { | |
1084 | + type: 'result', | |
1085 | + subtype: 'error_during_execution', | |
1086 | + duration_ms: Date.now() - startTime, | |
1087 | + duration_api_ms: getTotalAPIDuration(), | |
1088 | + is_error: true, | |
1089 | + num_turns: turnCount, | |
1090 | + stop_reason: lastStopReason, | |
1091 | + session_id: getSessionId(), | |
1092 | + total_cost_usd: getTotalCost(), | |
1093 | + usage: this.totalUsage, | |
1094 | + modelUsage: getModelUsage(), | |
1095 | + permission_denials: this.permissionDenials, | |
1096 | + fast_mode_state: getFastModeState( | |
1097 | + mainLoopModel, | |
1098 | + initialAppState.fastMode, | |
1099 | + ), | |
1100 | + uuid: randomUUID(), | |
1101 | + // Diagnostic prefix: these are what isResultSuccessful() checks — if | |
1102 | + // the result type isn't assistant-with-text/thinking or user-with- | |
1103 | + // tool_result, and stop_reason isn't end_turn, that's why this fired. | |
1104 | + // errors[] is turn-scoped via the watermark; previously it dumped the | |
1105 | + // entire process's logError buffer (ripgrep timeouts, ENOENT, etc). | |
1106 | + errors: (() => { | |
1107 | + const all = getInMemoryErrors() | |
1108 | + const start = errorLogWatermark | |
1109 | + ? all.lastIndexOf(errorLogWatermark) + 1 | |
1110 | + : 0 | |
1111 | + return [ | |
1112 | + `[ede_diagnostic] result_type=${edeResultType} last_content_type=${edeLastContentType} stop_reason=${lastStopReason}`, | |
1113 | + ...all.slice(start).map(_ => _.error), | |
1114 | + ] | |
1115 | + })(), | |
1116 | + } | |
1117 | + return | |
1118 | + } | |
1119 | + | |
1120 | + // Extract the text result based on message type | |
1121 | + let textResult = '' | |
1122 | + let isApiError = false | |
1123 | + | |
1124 | + if (result.type === 'assistant') { | |
1125 | + const lastContent = last(result.message.content) | |
1126 | + if ( | |
1127 | + lastContent?.type === 'text' && | |
1128 | + !SYNTHETIC_MESSAGES.has(lastContent.text) | |
1129 | + ) { | |
1130 | + textResult = lastContent.text | |
1131 | + } | |
1132 | + isApiError = Boolean(result.isApiErrorMessage) | |
1133 | + } | |
1134 | + | |
1135 | + yield { | |
1136 | + type: 'result', | |
1137 | + subtype: 'success', | |
1138 | + is_error: isApiError, | |
1139 | + duration_ms: Date.now() - startTime, | |
1140 | + duration_api_ms: getTotalAPIDuration(), | |
1141 | + num_turns: turnCount, | |
1142 | + result: textResult, | |
1143 | + stop_reason: lastStopReason, | |
1144 | + session_id: getSessionId(), | |
1145 | + total_cost_usd: getTotalCost(), | |
1146 | + usage: this.totalUsage, | |
1147 | + modelUsage: getModelUsage(), | |
1148 | + permission_denials: this.permissionDenials, | |
1149 | + structured_output: structuredOutputFromTool, | |
1150 | + fast_mode_state: getFastModeState( | |
1151 | + mainLoopModel, | |
1152 | + initialAppState.fastMode, | |
1153 | + ), | |
1154 | + uuid: randomUUID(), | |
1155 | + } | |
1156 | + } | |
1157 | + | |
1158 | + interrupt(): void { | |
1159 | + this.abortController.abort() | |
1160 | + } | |
1161 | + | |
1162 | + getMessages(): readonly Message[] { | |
1163 | + return this.mutableMessages | |
1164 | + } | |
1165 | + | |
1166 | + getReadFileState(): FileStateCache { | |
1167 | + return this.readFileState | |
1168 | + } | |
1169 | + | |
1170 | + getSessionId(): string { | |
1171 | + return getSessionId() | |
1172 | + } | |
1173 | + | |
1174 | + setModel(model: string): void { | |
1175 | + this.config.userSpecifiedModel = model | |
1176 | + } | |
1177 | +} | |
1178 | + | |
1179 | +/** | |
1180 | + * Sends a single prompt to the Claude API and returns the response. | |
1181 | + * Assumes that claude is being used non-interactively -- will not | |
1182 | + * ask the user for permissions or further input. | |
1183 | + * | |
1184 | + * Convenience wrapper around QueryEngine for one-shot usage. | |
1185 | + */ | |
1186 | +export async function* ask({ | |
1187 | + commands, | |
1188 | + prompt, | |
1189 | + promptUuid, | |
1190 | + isMeta, | |
1191 | + cwd, | |
1192 | + tools, | |
1193 | + mcpClients, | |
1194 | + verbose = false, | |
1195 | + thinkingConfig, | |
1196 | + maxTurns, | |
1197 | + maxBudgetUsd, | |
1198 | + taskBudget, | |
1199 | + canUseTool, | |
1200 | + mutableMessages = [], | |
1201 | + getReadFileCache, | |
1202 | + setReadFileCache, | |
1203 | + customSystemPrompt, | |
1204 | + appendSystemPrompt, | |
1205 | + userSpecifiedModel, | |
1206 | + fallbackModel, | |
1207 | + jsonSchema, | |
1208 | + getAppState, | |
1209 | + setAppState, | |
1210 | + abortController, | |
1211 | + replayUserMessages = false, | |
1212 | + includePartialMessages = false, | |
1213 | + handleElicitation, | |
1214 | + agents = [], | |
1215 | + setSDKStatus, | |
1216 | + orphanedPermission, | |
1217 | +}: { | |
1218 | + commands: Command[] | |
1219 | + prompt: string | Array<ContentBlockParam> | |
1220 | + promptUuid?: string | |
1221 | + isMeta?: boolean | |
1222 | + cwd: string | |
1223 | + tools: Tools | |
1224 | + verbose?: boolean | |
1225 | + mcpClients: MCPServerConnection[] | |
1226 | + thinkingConfig?: ThinkingConfig | |
1227 | + maxTurns?: number | |
1228 | + maxBudgetUsd?: number | |
1229 | + taskBudget?: { total: number } | |
1230 | + canUseTool: CanUseToolFn | |
1231 | + mutableMessages?: Message[] | |
1232 | + customSystemPrompt?: string | |
1233 | + appendSystemPrompt?: string | |
1234 | + userSpecifiedModel?: string | |
1235 | + fallbackModel?: string | |
1236 | + jsonSchema?: Record<string, unknown> | |
1237 | + getAppState: () => AppState | |
1238 | + setAppState: (f: (prev: AppState) => AppState) => void | |
1239 | + getReadFileCache: () => FileStateCache | |
1240 | + setReadFileCache: (cache: FileStateCache) => void | |
1241 | + abortController?: AbortController | |
1242 | + replayUserMessages?: boolean | |
1243 | + includePartialMessages?: boolean | |
1244 | + handleElicitation?: ToolUseContext['handleElicitation'] | |
1245 | + agents?: AgentDefinition[] | |
1246 | + setSDKStatus?: (status: SDKStatus) => void | |
1247 | + orphanedPermission?: OrphanedPermission | |
1248 | +}): AsyncGenerator<SDKMessage, void, unknown> { | |
1249 | + const engine = new QueryEngine({ | |
1250 | + cwd, | |
1251 | + tools, | |
1252 | + commands, | |
1253 | + mcpClients, | |
1254 | + agents, | |
1255 | + canUseTool, | |
1256 | + getAppState, | |
1257 | + setAppState, | |
1258 | + initialMessages: mutableMessages, | |
1259 | + readFileCache: cloneFileStateCache(getReadFileCache()), | |
1260 | + customSystemPrompt, | |
1261 | + appendSystemPrompt, | |
1262 | + userSpecifiedModel, | |
1263 | + fallbackModel, | |
1264 | + thinkingConfig, | |
1265 | + maxTurns, | |
1266 | + maxBudgetUsd, | |
1267 | + taskBudget, | |
1268 | + jsonSchema, | |
1269 | + verbose, | |
1270 | + handleElicitation, | |
1271 | + replayUserMessages, | |
1272 | + includePartialMessages, | |
1273 | + setSDKStatus, | |
1274 | + abortController, | |
1275 | + orphanedPermission, | |
1276 | + ...(feature('HISTORY_SNIP') | |
1277 | + ? { | |
1278 | + snipReplay: (yielded: Message, store: Message[]) => { | |
1279 | + if (!snipProjection!.isSnipBoundaryMessage(yielded)) | |
1280 | + return undefined | |
1281 | + return snipModule!.snipCompactIfNeeded(store, { force: true }) | |
1282 | + }, | |
1283 | + } | |
1284 | + : {}), | |
1285 | + }) | |
1286 | + | |
1287 | + try { | |
1288 | + yield* engine.submitMessage(prompt, { | |
1289 | + uuid: promptUuid, | |
1290 | + isMeta, | |
1291 | + }) | |
1292 | + } finally { | |
1293 | + setReadFileCache(engine.getReadFileState()) | |
1294 | + } | |
1295 | +} | |
0 | 1296 | |
▼src/Task.ts
+125-0
| @@ -0,0 +1,125 @@ | ||
1 | +import { randomBytes } from 'crypto' | |
2 | +import type { AppState } from './state/AppState.js' | |
3 | +import type { AgentId } from './types/ids.js' | |
4 | +import { getTaskOutputPath } from './utils/task/diskOutput.js' | |
5 | + | |
6 | +export type TaskType = | |
7 | + | 'local_bash' | |
8 | + | 'local_agent' | |
9 | + | 'remote_agent' | |
10 | + | 'in_process_teammate' | |
11 | + | 'local_workflow' | |
12 | + | 'monitor_mcp' | |
13 | + | 'dream' | |
14 | + | |
15 | +export type TaskStatus = | |
16 | + | 'pending' | |
17 | + | 'running' | |
18 | + | 'completed' | |
19 | + | 'failed' | |
20 | + | 'killed' | |
21 | + | |
22 | +/** | |
23 | + * True when a task is in a terminal state and will not transition further. | |
24 | + * Used to guard against injecting messages into dead teammates, evicting | |
25 | + * finished tasks from AppState, and orphan-cleanup paths. | |
26 | + */ | |
27 | +export function isTerminalTaskStatus(status: TaskStatus): boolean { | |
28 | + return status === 'completed' || status === 'failed' || status === 'killed' | |
29 | +} | |
30 | + | |
31 | +export type TaskHandle = { | |
32 | + taskId: string | |
33 | + cleanup?: () => void | |
34 | +} | |
35 | + | |
36 | +export type SetAppState = (f: (prev: AppState) => AppState) => void | |
37 | + | |
38 | +export type TaskContext = { | |
39 | + abortController: AbortController | |
40 | + getAppState: () => AppState | |
41 | + setAppState: SetAppState | |
42 | +} | |
43 | + | |
44 | +// Base fields shared by all task states | |
45 | +export type TaskStateBase = { | |
46 | + id: string | |
47 | + type: TaskType | |
48 | + status: TaskStatus | |
49 | + description: string | |
50 | + toolUseId?: string | |
51 | + startTime: number | |
52 | + endTime?: number | |
53 | + totalPausedMs?: number | |
54 | + outputFile: string | |
55 | + outputOffset: number | |
56 | + notified: boolean | |
57 | +} | |
58 | + | |
59 | +export type LocalShellSpawnInput = { | |
60 | + command: string | |
61 | + description: string | |
62 | + timeout?: number | |
63 | + toolUseId?: string | |
64 | + agentId?: AgentId | |
65 | + /** UI display variant: description-as-label, dialog title, status bar pill. */ | |
66 | + kind?: 'bash' | 'monitor' | |
67 | +} | |
68 | + | |
69 | +// What getTaskByType dispatches for: kill. spawn/render were never | |
70 | +// called polymorphically (removed in #22546). All six kill implementations | |
71 | +// use only setAppState — getAppState/abortController were dead weight. | |
72 | +export type Task = { | |
73 | + name: string | |
74 | + type: TaskType | |
75 | + kill(taskId: string, setAppState: SetAppState): Promise<void> | |
76 | +} | |
77 | + | |
78 | +// Task ID prefixes | |
79 | +const TASK_ID_PREFIXES: Record<string, string> = { | |
80 | + local_bash: 'b', // Keep as 'b' for backward compatibility | |
81 | + local_agent: 'a', | |
82 | + remote_agent: 'r', | |
83 | + in_process_teammate: 't', | |
84 | + local_workflow: 'w', | |
85 | + monitor_mcp: 'm', | |
86 | + dream: 'd', | |
87 | +} | |
88 | + | |
89 | +// Get task ID prefix | |
90 | +function getTaskIdPrefix(type: TaskType): string { | |
91 | + return TASK_ID_PREFIXES[type] ?? 'x' | |
92 | +} | |
93 | + | |
94 | +// Case-insensitive-safe alphabet (digits + lowercase) for task IDs. | |
95 | +// 36^8 ≈ 2.8 trillion combinations, sufficient to resist brute-force symlink attacks. | |
96 | +const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz' | |
97 | + | |
98 | +export function generateTaskId(type: TaskType): string { | |
99 | + const prefix = getTaskIdPrefix(type) | |
100 | + const bytes = randomBytes(8) | |
101 | + let id = prefix | |
102 | + for (let i = 0; i < 8; i++) { | |
103 | + id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length] | |
104 | + } | |
105 | + return id | |
106 | +} | |
107 | + | |
108 | +export function createTaskStateBase( | |
109 | + id: string, | |
110 | + type: TaskType, | |
111 | + description: string, | |
112 | + toolUseId?: string, | |
113 | +): TaskStateBase { | |
114 | + return { | |
115 | + id, | |
116 | + type, | |
117 | + status: 'pending', | |
118 | + description, | |
119 | + toolUseId, | |
120 | + startTime: Date.now(), | |
121 | + outputFile: getTaskOutputPath(id), | |
122 | + outputOffset: 0, | |
123 | + notified: false, | |
124 | + } | |
125 | +} | |
0 | 126 | |
▼src/Tool.ts
+792-0
| @@ -0,0 +1,792 @@ | ||
1 | +import type { | |
2 | + ToolResultBlockParam, | |
3 | + ToolUseBlockParam, | |
4 | +} from '@anthropic-ai/sdk/resources/index.mjs' | |
5 | +import type { | |
6 | + ElicitRequestURLParams, | |
7 | + ElicitResult, | |
8 | +} from '@modelcontextprotocol/sdk/types.js' | |
9 | +import type { UUID } from 'crypto' | |
10 | +import type { z } from 'zod/v4' | |
11 | +import type { Command } from './commands.js' | |
12 | +import type { CanUseToolFn } from './hooks/useCanUseTool.js' | |
13 | +import type { ThinkingConfig } from './utils/thinking.js' | |
14 | + | |
15 | +export type ToolInputJSONSchema = { | |
16 | + [x: string]: unknown | |
17 | + type: 'object' | |
18 | + properties?: { | |
19 | + [x: string]: unknown | |
20 | + } | |
21 | +} | |
22 | + | |
23 | +import type { Notification } from './context/notifications.js' | |
24 | +import type { | |
25 | + MCPServerConnection, | |
26 | + ServerResource, | |
27 | +} from './services/mcp/types.js' | |
28 | +import type { | |
29 | + AgentDefinition, | |
30 | + AgentDefinitionsResult, | |
31 | +} from './tools/AgentTool/loadAgentsDir.js' | |
32 | +import type { | |
33 | + AssistantMessage, | |
34 | + AttachmentMessage, | |
35 | + Message, | |
36 | + ProgressMessage, | |
37 | + SystemLocalCommandMessage, | |
38 | + SystemMessage, | |
39 | + UserMessage, | |
40 | +} from './types/message.js' | |
41 | +// Import permission types from centralized location to break import cycles | |
42 | +// Import PermissionResult from centralized location to break import cycles | |
43 | +import type { | |
44 | + AdditionalWorkingDirectory, | |
45 | + PermissionMode, | |
46 | + PermissionResult, | |
47 | +} from './types/permissions.js' | |
48 | +// Import tool progress types from centralized location to break import cycles | |
49 | +import type { | |
50 | + AgentToolProgress, | |
51 | + BashProgress, | |
52 | + MCPProgress, | |
53 | + REPLToolProgress, | |
54 | + SkillToolProgress, | |
55 | + TaskOutputProgress, | |
56 | + ToolProgressData, | |
57 | + WebSearchProgress, | |
58 | +} from './types/tools.js' | |
59 | +import type { FileStateCache } from './utils/fileStateCache.js' | |
60 | +import type { DenialTrackingState } from './utils/permissions/denialTracking.js' | |
61 | +import type { SystemPrompt } from './utils/systemPromptType.js' | |
62 | +import type { ContentReplacementState } from './utils/toolResultStorage.js' | |
63 | + | |
64 | +// Re-export progress types for backwards compatibility | |
65 | +export type { | |
66 | + AgentToolProgress, | |
67 | + BashProgress, | |
68 | + MCPProgress, | |
69 | + REPLToolProgress, | |
70 | + SkillToolProgress, | |
71 | + TaskOutputProgress, | |
72 | + WebSearchProgress, | |
73 | +} | |
74 | + | |
75 | +import type { SpinnerMode } from './components/Spinner.js' | |
76 | +import type { QuerySource } from './constants/querySource.js' | |
77 | +import type { SDKStatus } from './entrypoints/agentSdkTypes.js' | |
78 | +import type { AppState } from './state/AppState.js' | |
79 | +import type { | |
80 | + HookProgress, | |
81 | + PromptRequest, | |
82 | + PromptResponse, | |
83 | +} from './types/hooks.js' | |
84 | +import type { AgentId } from './types/ids.js' | |
85 | +import type { DeepImmutable } from './types/utils.js' | |
86 | +import type { AttributionState } from './utils/commitAttribution.js' | |
87 | +import type { FileHistoryState } from './utils/fileHistory.js' | |
88 | +import type { Theme, ThemeName } from './utils/theme.js' | |
89 | + | |
90 | +export type QueryChainTracking = { | |
91 | + chainId: string | |
92 | + depth: number | |
93 | +} | |
94 | + | |
95 | +export type ValidationResult = | |
96 | + | { result: true } | |
97 | + | { | |
98 | + result: false | |
99 | + message: string | |
100 | + errorCode: number | |
101 | + } | |
102 | + | |
103 | +export type SetToolJSXFn = ( | |
104 | + args: { | |
105 | + jsx: React.ReactNode | null | |
106 | + shouldHidePromptInput: boolean | |
107 | + shouldContinueAnimation?: true | |
108 | + showSpinner?: boolean | |
109 | + isLocalJSXCommand?: boolean | |
110 | + isImmediate?: boolean | |
111 | + /** Set to true to clear a local JSX command (e.g., from its onDone callback) */ | |
112 | + clearLocalJSX?: boolean | |
113 | + } | null, | |
114 | +) => void | |
115 | + | |
116 | +// Import tool permission types from centralized location to break import cycles | |
117 | +import type { ToolPermissionRulesBySource } from './types/permissions.js' | |
118 | + | |
119 | +// Re-export for backwards compatibility | |
120 | +export type { ToolPermissionRulesBySource } | |
121 | + | |
122 | +// Apply DeepImmutable to the imported type | |
123 | +export type ToolPermissionContext = DeepImmutable<{ | |
124 | + mode: PermissionMode | |
125 | + additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory> | |
126 | + alwaysAllowRules: ToolPermissionRulesBySource | |
127 | + alwaysDenyRules: ToolPermissionRulesBySource | |
128 | + alwaysAskRules: ToolPermissionRulesBySource | |
129 | + isBypassPermissionsModeAvailable: boolean | |
130 | + isAutoModeAvailable?: boolean | |
131 | + strippedDangerousRules?: ToolPermissionRulesBySource | |
132 | + /** When true, permission prompts are auto-denied (e.g., background agents that can't show UI) */ | |
133 | + shouldAvoidPermissionPrompts?: boolean | |
134 | + /** When true, automated checks (classifier, hooks) are awaited before showing the permission dialog (coordinator workers) */ | |
135 | + awaitAutomatedChecksBeforeDialog?: boolean | |
136 | + /** Stores the permission mode before model-initiated plan mode entry, so it can be restored on exit */ | |
137 | + prePlanMode?: PermissionMode | |
138 | +}> | |
139 | + | |
140 | +export const getEmptyToolPermissionContext: () => ToolPermissionContext = | |
141 | + () => ({ | |
142 | + mode: 'default', | |
143 | + additionalWorkingDirectories: new Map(), | |
144 | + alwaysAllowRules: {}, | |
145 | + alwaysDenyRules: {}, | |
146 | + alwaysAskRules: {}, | |
147 | + isBypassPermissionsModeAvailable: false, | |
148 | + }) | |
149 | + | |
150 | +export type CompactProgressEvent = | |
151 | + | { | |
152 | + type: 'hooks_start' | |
153 | + hookType: 'pre_compact' | 'post_compact' | 'session_start' | |
154 | + } | |
155 | + | { type: 'compact_start' } | |
156 | + | { type: 'compact_end' } | |
157 | + | |
158 | +export type ToolUseContext = { | |
159 | + options: { | |
160 | + commands: Command[] | |
161 | + debug: boolean | |
162 | + mainLoopModel: string | |
163 | + tools: Tools | |
164 | + verbose: boolean | |
165 | + thinkingConfig: ThinkingConfig | |
166 | + mcpClients: MCPServerConnection[] | |
167 | + mcpResources: Record<string, ServerResource[]> | |
168 | + isNonInteractiveSession: boolean | |
169 | + agentDefinitions: AgentDefinitionsResult | |
170 | + maxBudgetUsd?: number | |
171 | + /** Custom system prompt that replaces the default system prompt */ | |
172 | + customSystemPrompt?: string | |
173 | + /** Additional system prompt appended after the main system prompt */ | |
174 | + appendSystemPrompt?: string | |
175 | + /** Override querySource for analytics tracking */ | |
176 | + querySource?: QuerySource | |
177 | + /** Optional callback to get the latest tools (e.g., after MCP servers connect mid-query) */ | |
178 | + refreshTools?: () => Tools | |
179 | + } | |
180 | + abortController: AbortController | |
181 | + readFileState: FileStateCache | |
182 | + getAppState(): AppState | |
183 | + setAppState(f: (prev: AppState) => AppState): void | |
184 | + /** | |
185 | + * Always-shared setAppState for session-scoped infrastructure (background | |
186 | + * tasks, session hooks). Unlike setAppState, which is no-op for async agents | |
187 | + * (see createSubagentContext), this always reaches the root store so agents | |
188 | + * at any nesting depth can register/clean up infrastructure that outlives | |
189 | + * a single turn. Only set by createSubagentContext; main-thread contexts | |
190 | + * fall back to setAppState. | |
191 | + */ | |
192 | + setAppStateForTasks?: (f: (prev: AppState) => AppState) => void | |
193 | + /** | |
194 | + * Optional handler for URL elicitations triggered by tool call errors (-32042). | |
195 | + * In print/SDK mode, this delegates to structuredIO.handleElicitation. | |
196 | + * In REPL mode, this is undefined and the queue-based UI path is used. | |
197 | + */ | |
198 | + handleElicitation?: ( | |
199 | + serverName: string, | |
200 | + params: ElicitRequestURLParams, | |
201 | + signal: AbortSignal, | |
202 | + ) => Promise<ElicitResult> | |
203 | + setToolJSX?: SetToolJSXFn | |
204 | + addNotification?: (notif: Notification) => void | |
205 | + /** Append a UI-only system message to the REPL message list. Stripped at the | |
206 | + * normalizeMessagesForAPI boundary — the Exclude<> makes that type-enforced. */ | |
207 | + appendSystemMessage?: ( | |
208 | + msg: Exclude<SystemMessage, SystemLocalCommandMessage>, | |
209 | + ) => void | |
210 | + /** Send an OS-level notification (iTerm2, Kitty, Ghostty, bell, etc.) */ | |
211 | + sendOSNotification?: (opts: { | |
212 | + message: string | |
213 | + notificationType: string | |
214 | + }) => void | |
215 | + nestedMemoryAttachmentTriggers?: Set<string> | |
216 | + /** | |
217 | + * MERVELAS.md paths already injected as nested_memory attachments this | |
218 | + * session. Dedup for memoryFilesToAttachments — readFileState is an LRU | |
219 | + * that evicts entries in busy sessions, so its .has() check alone can | |
220 | + * re-inject the same MERVELAS.md dozens of times. | |
221 | + */ | |
222 | + loadedNestedMemoryPaths?: Set<string> | |
223 | + dynamicSkillDirTriggers?: Set<string> | |
224 | + /** Skill names surfaced via skill_discovery this session. Telemetry only (feeds was_discovered). */ | |
225 | + discoveredSkillNames?: Set<string> | |
226 | + userModified?: boolean | |
227 | + setInProgressToolUseIDs: (f: (prev: Set<string>) => Set<string>) => void | |
228 | + /** Only wired in interactive (REPL) contexts; SDK/QueryEngine don't set this. */ | |
229 | + setHasInterruptibleToolInProgress?: (v: boolean) => void | |
230 | + setResponseLength: (f: (prev: number) => number) => void | |
231 | + /** Ant-only: push a new API metrics entry for OTPS tracking. | |
232 | + * Called by subagent streaming when a new API request starts. */ | |
233 | + pushApiMetricsEntry?: (ttftMs: number) => void | |
234 | + setStreamMode?: (mode: SpinnerMode) => void | |
235 | + onCompactProgress?: (event: CompactProgressEvent) => void | |
236 | + setSDKStatus?: (status: SDKStatus) => void | |
237 | + openMessageSelector?: () => void | |
238 | + updateFileHistoryState: ( | |
239 | + updater: (prev: FileHistoryState) => FileHistoryState, | |
240 | + ) => void | |
241 | + updateAttributionState: ( | |
242 | + updater: (prev: AttributionState) => AttributionState, | |
243 | + ) => void | |
244 | + setConversationId?: (id: UUID) => void | |
245 | + agentId?: AgentId // Only set for subagents; use getSessionId() for session ID. Hooks use this to distinguish subagent calls. | |
246 | + agentType?: string // Subagent type name. For the main thread's --agent type, hooks fall back to getMainThreadAgentType(). | |
247 | + /** When true, canUseTool must always be called even when hooks auto-approve. | |
248 | + * Used by speculation for overlay file path rewriting. */ | |
249 | + requireCanUseTool?: boolean | |
250 | + messages: Message[] | |
251 | + fileReadingLimits?: { | |
252 | + maxTokens?: number | |
253 | + maxSizeBytes?: number | |
254 | + } | |
255 | + globLimits?: { | |
256 | + maxResults?: number | |
257 | + } | |
258 | + toolDecisions?: Map< | |
259 | + string, | |
260 | + { | |
261 | + source: string | |
262 | + decision: 'accept' | 'reject' | |
263 | + timestamp: number | |
264 | + } | |
265 | + > | |
266 | + queryTracking?: QueryChainTracking | |
267 | + /** Callback factory for requesting interactive prompts from the user. | |
268 | + * Returns a prompt callback bound to the given source name. | |
269 | + * Only available in interactive (REPL) contexts. */ | |
270 | + requestPrompt?: ( | |
271 | + sourceName: string, | |
272 | + toolInputSummary?: string | null, | |
273 | + ) => (request: PromptRequest) => Promise<PromptResponse> | |
274 | + toolUseId?: string | |
275 | + criticalSystemReminder_EXPERIMENTAL?: string | |
276 | + /** When true, preserve toolUseResult on messages even for subagents. | |
277 | + * Used by in-process teammates whose transcripts are viewable by the user. */ | |
278 | + preserveToolUseResults?: boolean | |
279 | + /** Local denial tracking state for async subagents whose setAppState is a | |
280 | + * no-op. Without this, the denial counter never accumulates and the | |
281 | + * fallback-to-prompting threshold is never reached. Mutable — the | |
282 | + * permissions code updates it in place. */ | |
283 | + localDenialTracking?: DenialTrackingState | |
284 | + /** | |
285 | + * Per-conversation-thread content replacement state for the tool result | |
286 | + * budget. When present, query.ts applies the aggregate tool result budget. | |
287 | + * Main thread: REPL provisions once (never resets — stale UUID keys | |
288 | + * are inert). Subagents: createSubagentContext clones the parent's state | |
289 | + * by default (cache-sharing forks need identical decisions), or | |
290 | + * resumeAgentBackground threads one reconstructed from sidechain records. | |
291 | + */ | |
292 | + contentReplacementState?: ContentReplacementState | |
293 | + /** | |
294 | + * Parent's rendered system prompt bytes, frozen at turn start. | |
295 | + * Used by fork subagents to share the parent's prompt cache — re-calling | |
296 | + * getSystemPrompt() at fork-spawn time can diverge (GrowthBook cold→warm) | |
297 | + * and bust the cache. See forkSubagent.ts. | |
298 | + */ | |
299 | + renderedSystemPrompt?: SystemPrompt | |
300 | +} | |
301 | + | |
302 | +// Re-export ToolProgressData from centralized location | |
303 | +export type { ToolProgressData } | |
304 | + | |
305 | +export type Progress = ToolProgressData | HookProgress | |
306 | + | |
307 | +export type ToolProgress<P extends ToolProgressData> = { | |
308 | + toolUseID: string | |
309 | + data: P | |
310 | +} | |
311 | + | |
312 | +export function filterToolProgressMessages( | |
313 | + progressMessagesForMessage: ProgressMessage[], | |
314 | +): ProgressMessage<ToolProgressData>[] { | |
315 | + return progressMessagesForMessage.filter( | |
316 | + (msg): msg is ProgressMessage<ToolProgressData> => | |
317 | + msg.data?.type !== 'hook_progress', | |
318 | + ) | |
319 | +} | |
320 | + | |
321 | +export type ToolResult<T> = { | |
322 | + data: T | |
323 | + newMessages?: ( | |
324 | + | UserMessage | |
325 | + | AssistantMessage | |
326 | + | AttachmentMessage | |
327 | + | SystemMessage | |
328 | + )[] | |
329 | + // contextModifier is only honored for tools that aren't concurrency safe. | |
330 | + contextModifier?: (context: ToolUseContext) => ToolUseContext | |
331 | + /** MCP protocol metadata (structuredContent, _meta) to pass through to SDK consumers */ | |
332 | + mcpMeta?: { | |
333 | + _meta?: Record<string, unknown> | |
334 | + structuredContent?: Record<string, unknown> | |
335 | + } | |
336 | +} | |
337 | + | |
338 | +export type ToolCallProgress<P extends ToolProgressData = ToolProgressData> = ( | |
339 | + progress: ToolProgress<P>, | |
340 | +) => void | |
341 | + | |
342 | +// Type for any schema that outputs an object with string keys | |
343 | +export type AnyObject = z.ZodType<{ [key: string]: unknown }> | |
344 | + | |
345 | +/** | |
346 | + * Checks if a tool matches the given name (primary name or alias). | |
347 | + */ | |
348 | +export function toolMatchesName( | |
349 | + tool: { name: string; aliases?: string[] }, | |
350 | + name: string, | |
351 | +): boolean { | |
352 | + return tool.name === name || (tool.aliases?.includes(name) ?? false) | |
353 | +} | |
354 | + | |
355 | +/** | |
356 | + * Finds a tool by name or alias from a list of tools. | |
357 | + */ | |
358 | +export function findToolByName(tools: Tools, name: string): Tool | undefined { | |
359 | + return tools.find(t => toolMatchesName(t, name)) | |
360 | +} | |
361 | + | |
362 | +export type Tool< | |
363 | + Input extends AnyObject = AnyObject, | |
364 | + Output = unknown, | |
365 | + P extends ToolProgressData = ToolProgressData, | |
366 | +> = { | |
367 | + /** | |
368 | + * Optional aliases for backwards compatibility when a tool is renamed. | |
369 | + * The tool can be looked up by any of these names in addition to its primary name. | |
370 | + */ | |
371 | + aliases?: string[] | |
372 | + /** | |
373 | + * One-line capability phrase used by ToolSearch for keyword matching. | |
374 | + * Helps the model find this tool via keyword search when it's deferred. | |
375 | + * 3–10 words, no trailing period. | |
376 | + * Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit). | |
377 | + */ | |
378 | + searchHint?: string | |
379 | + call( | |
380 | + args: z.infer<Input>, | |
381 | + context: ToolUseContext, | |
382 | + canUseTool: CanUseToolFn, | |
383 | + parentMessage: AssistantMessage, | |
384 | + onProgress?: ToolCallProgress<P>, | |
385 | + ): Promise<ToolResult<Output>> | |
386 | + description( | |
387 | + input: z.infer<Input>, | |
388 | + options: { | |
389 | + isNonInteractiveSession: boolean | |
390 | + toolPermissionContext: ToolPermissionContext | |
391 | + tools: Tools | |
392 | + }, | |
393 | + ): Promise<string> | |
394 | + readonly inputSchema: Input | |
395 | + // Type for MCP tools that can specify their input schema directly in JSON Schema format | |
396 | + // rather than converting from Zod schema | |
397 | + readonly inputJSONSchema?: ToolInputJSONSchema | |
398 | + // Optional because TungstenTool doesn't define this. TODO: Make it required. | |
399 | + // When we do that, we can also go through and make this a bit more type-safe. | |
400 | + outputSchema?: z.ZodType<unknown> | |
401 | + inputsEquivalent?(a: z.infer<Input>, b: z.infer<Input>): boolean | |
402 | + isConcurrencySafe(input: z.infer<Input>): boolean | |
403 | + isEnabled(): boolean | |
404 | + isReadOnly(input: z.infer<Input>): boolean | |
405 | + /** Defaults to false. Only set when the tool performs irreversible operations (delete, overwrite, send). */ | |
406 | + isDestructive?(input: z.infer<Input>): boolean | |
407 | + /** | |
408 | + * What should happen when the user submits a new message while this tool | |
409 | + * is running. | |
410 | + * | |
411 | + * - `'cancel'` — stop the tool and discard its result | |
412 | + * - `'block'` — keep running; the new message waits | |
413 | + * | |
414 | + * Defaults to `'block'` when not implemented. | |
415 | + */ | |
416 | + interruptBehavior?(): 'cancel' | 'block' | |
417 | + /** | |
418 | + * Returns information about whether this tool use is a search or read operation | |
419 | + * that should be collapsed into a condensed display in the UI. Examples include | |
420 | + * file searching (Grep, Glob), file reading (Read), and bash commands like find, | |
421 | + * grep, wc, etc. | |
422 | + * | |
423 | + * Returns an object indicating whether the operation is a search or read operation: | |
424 | + * - `isSearch: true` for search operations (grep, find, glob patterns) | |
425 | + * - `isRead: true` for read operations (cat, head, tail, file read) | |
426 | + * - `isList: true` for directory-listing operations (ls, tree, du) | |
427 | + * - All can be false if the operation shouldn't be collapsed | |
428 | + */ | |
429 | + isSearchOrReadCommand?(input: z.infer<Input>): { | |
430 | + isSearch: boolean | |
431 | + isRead: boolean | |
432 | + isList?: boolean | |
433 | + } | |
434 | + isOpenWorld?(input: z.infer<Input>): boolean | |
435 | + requiresUserInteraction?(): boolean | |
436 | + isMcp?: boolean | |
437 | + isLsp?: boolean | |
438 | + /** | |
439 | + * When true, this tool is deferred (sent with defer_loading: true) and requires | |
440 | + * ToolSearch to be used before it can be called. | |
441 | + */ | |
442 | + readonly shouldDefer?: boolean | |
443 | + /** | |
444 | + * When true, this tool is never deferred — its full schema appears in the | |
445 | + * initial prompt even when ToolSearch is enabled. For MCP tools, set via | |
446 | + * `_meta['anthropic/alwaysLoad']`. Use for tools the model must see on | |
447 | + * turn 1 without a ToolSearch round-trip. | |
448 | + */ | |
449 | + readonly alwaysLoad?: boolean | |
450 | + /** | |
451 | + * For MCP tools: the server and tool names as received from the MCP server (unnormalized). | |
452 | + * Present on all MCP tools regardless of whether `name` is prefixed (mcp__server__tool) | |
453 | + * or unprefixed (CLAUDE_AGENT_SDK_MCP_NO_PREFIX mode). | |
454 | + */ | |
455 | + mcpInfo?: { serverName: string; toolName: string } | |
456 | + readonly name: string | |
457 | + /** | |
458 | + * Maximum size in characters for tool result before it gets persisted to disk. | |
459 | + * When exceeded, the result is saved to a file and Claude receives a preview | |
460 | + * with the file path instead of the full content. | |
461 | + * | |
462 | + * Set to Infinity for tools whose output must never be persisted (e.g. Read, | |
463 | + * where persisting creates a circular Read→file→Read loop and the tool | |
464 | + * already self-bounds via its own limits). | |
465 | + */ | |
466 | + maxResultSizeChars: number | |
467 | + /** | |
468 | + * When true, enables strict mode for this tool, which causes the API to | |
469 | + * more strictly adhere to tool instructions and parameter schemas. | |
470 | + * Only applied when the tengu_tool_pear is enabled. | |
471 | + */ | |
472 | + readonly strict?: boolean | |
473 | + | |
474 | + /** | |
475 | + * Called on copies of tool_use input before observers see it (SDK stream, | |
476 | + * transcript, canUseTool, PreToolUse/PostToolUse hooks). Mutate in place | |
477 | + * to add legacy/derived fields. Must be idempotent. The original API-bound | |
478 | + * input is never mutated (preserves prompt cache). Not re-applied when a | |
479 | + * hook/permission returns a fresh updatedInput — those own their shape. | |
480 | + */ | |
481 | + backfillObservableInput?(input: Record<string, unknown>): void | |
482 | + | |
483 | + /** | |
484 | + * Determines if this tool is allowed to run with this input in the current context. | |
485 | + * It informs the model of why the tool use failed, and does not directly display any UI. | |
486 | + * @param input | |
487 | + * @param context | |
488 | + */ | |
489 | + validateInput?( | |
490 | + input: z.infer<Input>, | |
491 | + context: ToolUseContext, | |
492 | + ): Promise<ValidationResult> | |
493 | + | |
494 | + /** | |
495 | + * Determines if the user is asked for permission. Only called after validateInput() passes. | |
496 | + * General permission logic is in permissions.ts. This method contains tool-specific logic. | |
497 | + * @param input | |
498 | + * @param context | |
499 | + */ | |
500 | + checkPermissions( | |
501 | + input: z.infer<Input>, | |
502 | + context: ToolUseContext, | |
503 | + ): Promise<PermissionResult> | |
504 | + | |
505 | + // Optional method for tools that operate on a file path | |
506 | + getPath?(input: z.infer<Input>): string | |
507 | + | |
508 | + /** | |
509 | + * Prepare a matcher for hook `if` conditions (permission-rule patterns like | |
510 | + * "git *" from "Bash(git *)"). Called once per hook-input pair; any | |
511 | + * expensive parsing happens here. Returns a closure that is called per | |
512 | + * hook pattern. If not implemented, only tool-name-level matching works. | |
513 | + */ | |
514 | + preparePermissionMatcher?( | |
515 | + input: z.infer<Input>, | |
516 | + ): Promise<(pattern: string) => boolean> | |
517 | + | |
518 | + prompt(options: { | |
519 | + getToolPermissionContext: () => Promise<ToolPermissionContext> | |
520 | + tools: Tools | |
521 | + agents: AgentDefinition[] | |
522 | + allowedAgentTypes?: string[] | |
523 | + }): Promise<string> | |
524 | + userFacingName(input: Partial<z.infer<Input>> | undefined): string | |
525 | + userFacingNameBackgroundColor?( | |
526 | + input: Partial<z.infer<Input>> | undefined, | |
527 | + ): keyof Theme | undefined | |
528 | + /** | |
529 | + * Transparent wrappers (e.g. REPL) delegate all rendering to their progress | |
530 | + * handler, which emits native-looking blocks for each inner tool call. | |
531 | + * The wrapper itself shows nothing. | |
532 | + */ | |
533 | + isTransparentWrapper?(): boolean | |
534 | + /** | |
535 | + * Returns a short string summary of this tool use for display in compact views. | |
536 | + * @param input The tool input | |
537 | + * @returns A short string summary, or null to not display | |
538 | + */ | |
539 | + getToolUseSummary?(input: Partial<z.infer<Input>> | undefined): string | null | |
540 | + /** | |
541 | + * Returns a human-readable present-tense activity description for spinner display. | |
542 | + * Example: "Reading src/foo.ts", "Running bun test", "Searching for pattern" | |
543 | + * @param input The tool input | |
544 | + * @returns Activity description string, or null to fall back to tool name | |
545 | + */ | |
546 | + getActivityDescription?( | |
547 | + input: Partial<z.infer<Input>> | undefined, | |
548 | + ): string | null | |
549 | + /** | |
550 | + * Returns a compact representation of this tool use for the auto-mode | |
551 | + * security classifier. Examples: `ls -la` for Bash, `/tmp/x: new content` | |
552 | + * for Edit. Return '' to skip this tool in the classifier transcript | |
553 | + * (e.g. tools with no security relevance). May return an object to avoid | |
554 | + * double-encoding when the caller JSON-wraps the value. | |
555 | + */ | |
556 | + toAutoClassifierInput(input: z.infer<Input>): unknown | |
557 | + mapToolResultToToolResultBlockParam( | |
558 | + content: Output, | |
559 | + toolUseID: string, | |
560 | + ): ToolResultBlockParam | |
561 | + /** | |
562 | + * Optional. When omitted, the tool result renders nothing (same as returning | |
563 | + * null). Omit for tools whose results are surfaced elsewhere (e.g., TodoWrite | |
564 | + * updates the todo panel, not the transcript). | |
565 | + */ | |
566 | + renderToolResultMessage?( | |
567 | + content: Output, | |
568 | + progressMessagesForMessage: ProgressMessage<P>[], | |
569 | + options: { | |
570 | + style?: 'condensed' | |
571 | + theme: ThemeName | |
572 | + tools: Tools | |
573 | + verbose: boolean | |
574 | + isTranscriptMode?: boolean | |
575 | + isBriefOnly?: boolean | |
576 | + /** Original tool_use input, when available. Useful for compact result | |
577 | + * summaries that reference what was requested (e.g. "Sent to #foo"). */ | |
578 | + input?: unknown | |
579 | + }, | |
580 | + ): React.ReactNode | |
581 | + /** | |
582 | + * Flattened text of what renderToolResultMessage shows IN TRANSCRIPT | |
583 | + * MODE (verbose=true, isTranscriptMode=true). For transcript search | |
584 | + * indexing: the index counts occurrences in this string, the highlight | |
585 | + * overlay scans the actual screen buffer. For count ≡ highlight, this | |
586 | + * must return the text that ends up visible — not the model-facing | |
587 | + * serialization from mapToolResultToToolResultBlockParam (which adds | |
588 | + * system-reminders, persisted-output wrappers). | |
589 | + * | |
590 | + * Chrome can be skipped (under-count is fine). "Found 3 files in 12ms" | |
591 | + * isn't worth indexing. Phantoms are not fine — text that's claimed | |
592 | + * here but doesn't render is a count≠highlight bug. | |
593 | + * | |
594 | + * Optional: omitted → field-name heuristic in transcriptSearch.ts. | |
595 | + * Drift caught by test/utils/transcriptSearch.renderFidelity.test.tsx | |
596 | + * which renders sample outputs and flags text that's indexed-but-not- | |
597 | + * rendered (phantom) or rendered-but-not-indexed (under-count warning). | |
598 | + */ | |
599 | + extractSearchText?(out: Output): string | |
600 | + /** | |
601 | + * Render the tool use message. Note that `input` is partial because we render | |
602 | + * the message as soon as possible, possibly before tool parameters have fully | |
603 | + * streamed in. | |
604 | + */ | |
605 | + renderToolUseMessage( | |
606 | + input: Partial<z.infer<Input>>, | |
607 | + options: { theme: ThemeName; verbose: boolean; commands?: Command[] }, | |
608 | + ): React.ReactNode | |
609 | + /** | |
610 | + * Returns true when the non-verbose rendering of this output is truncated | |
611 | + * (i.e., clicking to expand would reveal more content). Gates | |
612 | + * click-to-expand in fullscreen — only messages where verbose actually | |
613 | + * shows more get a hover/click affordance. Unset means never truncated. | |
614 | + */ | |
615 | + isResultTruncated?(output: Output): boolean | |
616 | + /** | |
617 | + * Renders an optional tag to display after the tool use message. | |
618 | + * Used for additional metadata like timeout, model, resume ID, etc. | |
619 | + * Returns null to not display anything. | |
620 | + */ | |
621 | + renderToolUseTag?(input: Partial<z.infer<Input>>): React.ReactNode | |
622 | + /** | |
623 | + * Optional. When omitted, no progress UI is shown while the tool runs. | |
624 | + */ | |
625 | + renderToolUseProgressMessage?( | |
626 | + progressMessagesForMessage: ProgressMessage<P>[], | |
627 | + options: { | |
628 | + tools: Tools | |
629 | + verbose: boolean | |
630 | + terminalSize?: { columns: number; rows: number } | |
631 | + inProgressToolCallCount?: number | |
632 | + isTranscriptMode?: boolean | |
633 | + }, | |
634 | + ): React.ReactNode | |
635 | + renderToolUseQueuedMessage?(): React.ReactNode | |
636 | + /** | |
637 | + * Optional. When omitted, falls back to <FallbackToolUseRejectedMessage />. | |
638 | + * Only define this for tools that need custom rejection UI (e.g., file edits | |
639 | + * that show the rejected diff). | |
640 | + */ | |
641 | + renderToolUseRejectedMessage?( | |
642 | + input: z.infer<Input>, | |
643 | + options: { | |
644 | + columns: number | |
645 | + messages: Message[] | |
646 | + style?: 'condensed' | |
647 | + theme: ThemeName | |
648 | + tools: Tools | |
649 | + verbose: boolean | |
650 | + progressMessagesForMessage: ProgressMessage<P>[] | |
651 | + isTranscriptMode?: boolean | |
652 | + }, | |
653 | + ): React.ReactNode | |
654 | + /** | |
655 | + * Optional. When omitted, falls back to <FallbackToolUseErrorMessage />. | |
656 | + * Only define this for tools that need custom error UI (e.g., search tools | |
657 | + * that show "File not found" instead of the raw error). | |
658 | + */ | |
659 | + renderToolUseErrorMessage?( | |
660 | + result: ToolResultBlockParam['content'], | |
661 | + options: { | |
662 | + progressMessagesForMessage: ProgressMessage<P>[] | |
663 | + tools: Tools | |
664 | + verbose: boolean | |
665 | + isTranscriptMode?: boolean | |
666 | + }, | |
667 | + ): React.ReactNode | |
668 | + | |
669 | + /** | |
670 | + * Renders multiple parallel instances of this tool as a group. | |
671 | + * @returns React node to render, or null to fall back to individual rendering | |
672 | + */ | |
673 | + /** | |
674 | + * Renders multiple tool uses as a group (non-verbose mode only). | |
675 | + * In verbose mode, individual tool uses render at their original positions. | |
676 | + * @returns React node to render, or null to fall back to individual rendering | |
677 | + */ | |
678 | + renderGroupedToolUse?( | |
679 | + toolUses: Array<{ | |
680 | + param: ToolUseBlockParam | |
681 | + isResolved: boolean | |
682 | + isError: boolean | |
683 | + isInProgress: boolean | |
684 | + progressMessages: ProgressMessage<P>[] | |
685 | + result?: { | |
686 | + param: ToolResultBlockParam | |
687 | + output: unknown | |
688 | + } | |
689 | + }>, | |
690 | + options: { | |
691 | + shouldAnimate: boolean | |
692 | + tools: Tools | |
693 | + }, | |
694 | + ): React.ReactNode | null | |
695 | +} | |
696 | + | |
697 | +/** | |
698 | + * A collection of tools. Use this type instead of `Tool[]` to make it easier | |
699 | + * to track where tool sets are assembled, passed, and filtered across the codebase. | |
700 | + */ | |
701 | +export type Tools = readonly Tool[] | |
702 | + | |
703 | +/** | |
704 | + * Methods that `buildTool` supplies a default for. A `ToolDef` may omit these; | |
705 | + * the resulting `Tool` always has them. | |
706 | + */ | |
707 | +type DefaultableToolKeys = | |
708 | + | 'isEnabled' | |
709 | + | 'isConcurrencySafe' | |
710 | + | 'isReadOnly' | |
711 | + | 'isDestructive' | |
712 | + | 'checkPermissions' | |
713 | + | 'toAutoClassifierInput' | |
714 | + | 'userFacingName' | |
715 | + | |
716 | +/** | |
717 | + * Tool definition accepted by `buildTool`. Same shape as `Tool` but with the | |
718 | + * defaultable methods optional — `buildTool` fills them in so callers always | |
719 | + * see a complete `Tool`. | |
720 | + */ | |
721 | +export type ToolDef< | |
722 | + Input extends AnyObject = AnyObject, | |
723 | + Output = unknown, | |
724 | + P extends ToolProgressData = ToolProgressData, | |
725 | +> = Omit<Tool<Input, Output, P>, DefaultableToolKeys> & | |
726 | + Partial<Pick<Tool<Input, Output, P>, DefaultableToolKeys>> | |
727 | + | |
728 | +/** | |
729 | + * Type-level spread mirroring `{ ...TOOL_DEFAULTS, ...def }`. For each | |
730 | + * defaultable key: if D provides it (required), D's type wins; if D omits | |
731 | + * it or has it optional (inherited from Partial<> in the constraint), the | |
732 | + * default fills in. All other keys come from D verbatim — preserving arity, | |
733 | + * optional presence, and literal types exactly as `satisfies Tool` did. | |
734 | + */ | |
735 | +type BuiltTool<D> = Omit<D, DefaultableToolKeys> & { | |
736 | + [K in DefaultableToolKeys]-?: K extends keyof D | |
737 | + ? undefined extends D[K] | |
738 | + ? ToolDefaults[K] | |
739 | + : D[K] | |
740 | + : ToolDefaults[K] | |
741 | +} | |
742 | + | |
743 | +/** | |
744 | + * Build a complete `Tool` from a partial definition, filling in safe defaults | |
745 | + * for the commonly-stubbed methods. All tool exports should go through this so | |
746 | + * that defaults live in one place and callers never need `?.() ?? default`. | |
747 | + * | |
748 | + * Defaults (fail-closed where it matters): | |
749 | + * - `isEnabled` → `true` | |
750 | + * - `isConcurrencySafe` → `false` (assume not safe) | |
751 | + * - `isReadOnly` → `false` (assume writes) | |
752 | + * - `isDestructive` → `false` | |
753 | + * - `checkPermissions` → `{ behavior: 'allow', updatedInput }` (defer to general permission system) | |
754 | + * - `toAutoClassifierInput` → `''` (skip classifier — security-relevant tools must override) | |
755 | + * - `userFacingName` → `name` | |
756 | + */ | |
757 | +const TOOL_DEFAULTS = { | |
758 | + isEnabled: () => true, | |
759 | + isConcurrencySafe: (_input?: unknown) => false, | |
760 | + isReadOnly: (_input?: unknown) => false, | |
761 | + isDestructive: (_input?: unknown) => false, | |
762 | + checkPermissions: ( | |
763 | + input: { [key: string]: unknown }, | |
764 | + _ctx?: ToolUseContext, | |
765 | + ): Promise<PermissionResult> => | |
766 | + Promise.resolve({ behavior: 'allow', updatedInput: input }), | |
767 | + toAutoClassifierInput: (_input?: unknown) => '', | |
768 | + userFacingName: (_input?: unknown) => '', | |
769 | +} | |
770 | + | |
771 | +// The defaults type is the ACTUAL shape of TOOL_DEFAULTS (optional params so | |
772 | +// both 0-arg and full-arg call sites type-check — stubs varied in arity and | |
773 | +// tests relied on that), not the interface's strict signatures. | |
774 | +type ToolDefaults = typeof TOOL_DEFAULTS | |
775 | + | |
776 | +// D infers the concrete object-literal type from the call site. The | |
777 | +// constraint provides contextual typing for method parameters; `any` in | |
778 | +// constraint position is structural and never leaks into the return type. | |
779 | +// BuiltTool<D> mirrors runtime `{...TOOL_DEFAULTS, ...def}` at the type level. | |
780 | +// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
781 | +type AnyToolDef = ToolDef<any, any, any> | |
782 | + | |
783 | +export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> { | |
784 | + // The runtime spread is straightforward; the `as` bridges the gap between | |
785 | + // the structural-any constraint and the precise BuiltTool<D> return. The | |
786 | + // type semantics are proven by the 0-error typecheck across all 60+ tools. | |
787 | + return { | |
788 | + ...TOOL_DEFAULTS, | |
789 | + userFacingName: () => def.name, | |
790 | + ...def, | |
791 | + } as BuiltTool<D> | |
792 | +} | |
0 | 793 | |
▼src/assistant/sessionHistory.ts
+87-0
| @@ -0,0 +1,87 @@ | ||
1 | +import axios from 'axios' | |
2 | +import { getOauthConfig } from '../constants/oauth.js' | |
3 | +import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' | |
4 | +import { logForDebugging } from '../utils/debug.js' | |
5 | +import { getOAuthHeaders, prepareApiRequest } from '../utils/teleport/api.js' | |
6 | + | |
7 | +export const HISTORY_PAGE_SIZE = 100 | |
8 | + | |
9 | +export type HistoryPage = { | |
10 | + /** Chronological order within the page. */ | |
11 | + events: SDKMessage[] | |
12 | + /** Oldest event ID in this page → before_id cursor for next-older page. */ | |
13 | + firstId: string | null | |
14 | + /** true = older events exist. */ | |
15 | + hasMore: boolean | |
16 | +} | |
17 | + | |
18 | +type SessionEventsResponse = { | |
19 | + data: SDKMessage[] | |
20 | + has_more: boolean | |
21 | + first_id: string | null | |
22 | + last_id: string | null | |
23 | +} | |
24 | + | |
25 | +export type HistoryAuthCtx = { | |
26 | + baseUrl: string | |
27 | + headers: Record<string, string> | |
28 | +} | |
29 | + | |
30 | +/** Prepare auth + headers + base URL once, reuse across pages. */ | |
31 | +export async function createHistoryAuthCtx( | |
32 | + sessionId: string, | |
33 | +): Promise<HistoryAuthCtx> { | |
34 | + const { accessToken, orgUUID } = await prepareApiRequest() | |
35 | + return { | |
36 | + baseUrl: `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`, | |
37 | + headers: { | |
38 | + ...getOAuthHeaders(accessToken), | |
39 | + 'anthropic-beta': 'ccr-byoc-2025-07-29', | |
40 | + 'x-organization-uuid': orgUUID, | |
41 | + }, | |
42 | + } | |
43 | +} | |
44 | + | |
45 | +async function fetchPage( | |
46 | + ctx: HistoryAuthCtx, | |
47 | + params: Record<string, string | number | boolean>, | |
48 | + label: string, | |
49 | +): Promise<HistoryPage | null> { | |
50 | + const resp = await axios | |
51 | + .get<SessionEventsResponse>(ctx.baseUrl, { | |
52 | + headers: ctx.headers, | |
53 | + params, | |
54 | + timeout: 15000, | |
55 | + validateStatus: () => true, | |
56 | + }) | |
57 | + .catch(() => null) | |
58 | + if (!resp || resp.status !== 200) { | |
59 | + logForDebugging(`[${label}] HTTP ${resp?.status ?? 'error'}`) | |
60 | + return null | |
61 | + } | |
62 | + return { | |
63 | + events: Array.isArray(resp.data.data) ? resp.data.data : [], | |
64 | + firstId: resp.data.first_id, | |
65 | + hasMore: resp.data.has_more, | |
66 | + } | |
67 | +} | |
68 | + | |
69 | +/** | |
70 | + * Newest page: last `limit` events, chronological, via anchor_to_latest. | |
71 | + * has_more=true means older events exist. | |
72 | + */ | |
73 | +export async function fetchLatestEvents( | |
74 | + ctx: HistoryAuthCtx, | |
75 | + limit = HISTORY_PAGE_SIZE, | |
76 | +): Promise<HistoryPage | null> { | |
77 | + return fetchPage(ctx, { limit, anchor_to_latest: true }, 'fetchLatestEvents') | |
78 | +} | |
79 | + | |
80 | +/** Older page: events immediately before `beforeId` cursor. */ | |
81 | +export async function fetchOlderEvents( | |
82 | + ctx: HistoryAuthCtx, | |
83 | + beforeId: string, | |
84 | + limit = HISTORY_PAGE_SIZE, | |
85 | +): Promise<HistoryPage | null> { | |
86 | + return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents') | |
87 | +} | |
0 | 88 | |
▼src/bootstrap/state.ts
+1758-0
| @@ -0,0 +1,1758 @@ | ||
1 | +import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' | |
2 | +import type { Attributes, Meter, MetricOptions } from '@opentelemetry/api' | |
3 | +import type { logs } from '@opentelemetry/api-logs' | |
4 | +import type { LoggerProvider } from '@opentelemetry/sdk-logs' | |
5 | +import type { MeterProvider } from '@opentelemetry/sdk-metrics' | |
6 | +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' | |
7 | +import { realpathSync } from 'fs' | |
8 | +import sumBy from 'lodash-es/sumBy.js' | |
9 | +import { cwd } from 'process' | |
10 | +import type { HookEvent, ModelUsage } from 'src/entrypoints/agentSdkTypes.js' | |
11 | +import type { AgentColorName } from 'src/tools/AgentTool/agentColorManager.js' | |
12 | +import type { HookCallbackMatcher } from 'src/types/hooks.js' | |
13 | +// Indirection for browser-sdk build (package.json "browser" field swaps | |
14 | +// crypto.ts for crypto.browser.ts). Pure leaf re-export of node:crypto — | |
15 | +// zero circular-dep risk. Path-alias import bypasses bootstrap-isolation | |
16 | +// (rule only checks ./ and / prefixes); explicit disable documents intent. | |
17 | +// eslint-disable-next-line custom-rules/bootstrap-isolation | |
18 | +import { randomUUID } from 'src/utils/crypto.js' | |
19 | +import type { ModelSetting } from 'src/utils/model/model.js' | |
20 | +import type { ModelStrings } from 'src/utils/model/modelStrings.js' | |
21 | +import type { SettingSource } from 'src/utils/settings/constants.js' | |
22 | +import { resetSettingsCache } from 'src/utils/settings/settingsCache.js' | |
23 | +import type { PluginHookMatcher } from 'src/utils/settings/types.js' | |
24 | +import { createSignal } from 'src/utils/signal.js' | |
25 | + | |
26 | +// Union type for registered hooks - can be SDK callbacks or native plugin hooks | |
27 | +type RegisteredHookMatcher = HookCallbackMatcher | PluginHookMatcher | |
28 | + | |
29 | +import type { SessionId } from 'src/types/ids.js' | |
30 | + | |
31 | +// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE | |
32 | + | |
33 | +// dev: true on entries that came via --dangerously-load-development-channels. | |
34 | +// The allowlist gate checks this per-entry (not the session-wide | |
35 | +// hasDevChannels bit) so passing both flags doesn't let the dev dialog's | |
36 | +// acceptance leak allowlist-bypass to the --channels entries. | |
37 | +export type ChannelEntry = | |
38 | + | { kind: 'plugin'; name: string; marketplace: string; dev?: boolean } | |
39 | + | { kind: 'server'; name: string; dev?: boolean } | |
40 | + | |
41 | +export type AttributedCounter = { | |
42 | + add(value: number, additionalAttributes?: Attributes): void | |
43 | +} | |
44 | + | |
45 | +type State = { | |
46 | + originalCwd: string | |
47 | + // Stable project root - set once at startup (including by --worktree flag), | |
48 | + // never updated by mid-session EnterWorktreeTool. | |
49 | + // Use for project identity (history, skills, sessions) not file operations. | |
50 | + projectRoot: string | |
51 | + totalCostUSD: number | |
52 | + totalAPIDuration: number | |
53 | + totalAPIDurationWithoutRetries: number | |
54 | + totalToolDuration: number | |
55 | + turnHookDurationMs: number | |
56 | + turnToolDurationMs: number | |
57 | + turnClassifierDurationMs: number | |
58 | + turnToolCount: number | |
59 | + turnHookCount: number | |
60 | + turnClassifierCount: number | |
61 | + startTime: number | |
62 | + lastInteractionTime: number | |
63 | + totalLinesAdded: number | |
64 | + totalLinesRemoved: number | |
65 | + hasUnknownModelCost: boolean | |
66 | + cwd: string | |
67 | + modelUsage: { [modelName: string]: ModelUsage } | |
68 | + mainLoopModelOverride: ModelSetting | undefined | |
69 | + initialMainLoopModel: ModelSetting | |
70 | + modelStrings: ModelStrings | null | |
71 | + isInteractive: boolean | |
72 | + kairosActive: boolean | |
73 | + // When true, ensureToolResultPairing throws on mismatch instead of | |
74 | + // repairing with synthetic placeholders. HFI opts in at startup so | |
75 | + // trajectories fail fast rather than conditioning the model on fake | |
76 | + // tool_results. | |
77 | + strictToolResultPairing: boolean | |
78 | + sdkAgentProgressSummariesEnabled: boolean | |
79 | + userMsgOptIn: boolean | |
80 | + clientType: string | |
81 | + sessionSource: string | undefined | |
82 | + questionPreviewFormat: 'markdown' | 'html' | undefined | |
83 | + flagSettingsPath: string | undefined | |
84 | + flagSettingsInline: Record<string, unknown> | null | |
85 | + allowedSettingSources: SettingSource[] | |
86 | + sessionIngressToken: string | null | undefined | |
87 | + oauthTokenFromFd: string | null | undefined | |
88 | + apiKeyFromFd: string | null | undefined | |
89 | + // Telemetry state | |
90 | + meter: Meter | null | |
91 | + sessionCounter: AttributedCounter | null | |
92 | + locCounter: AttributedCounter | null | |
93 | + prCounter: AttributedCounter | null | |
94 | + commitCounter: AttributedCounter | null | |
95 | + costCounter: AttributedCounter | null | |
96 | + tokenCounter: AttributedCounter | null | |
97 | + codeEditToolDecisionCounter: AttributedCounter | null | |
98 | + activeTimeCounter: AttributedCounter | null | |
99 | + statsStore: { observe(name: string, value: number): void } | null | |
100 | + sessionId: SessionId | |
101 | + // Parent session ID for tracking session lineage (e.g., plan mode -> implementation) | |
102 | + parentSessionId: SessionId | undefined | |
103 | + // Logger state | |
104 | + loggerProvider: LoggerProvider | null | |
105 | + eventLogger: ReturnType<typeof logs.getLogger> | null | |
106 | + // Meter provider state | |
107 | + meterProvider: MeterProvider | null | |
108 | + // Tracer provider state | |
109 | + tracerProvider: BasicTracerProvider | null | |
110 | + // Agent color state | |
111 | + agentColorMap: Map<string, AgentColorName> | |
112 | + agentColorIndex: number | |
113 | + // Last API request for bug reports | |
114 | + lastAPIRequest: Omit<BetaMessageStreamParams, 'messages'> | null | |
115 | + // Messages from the last API request (ant-only; reference, not clone). | |
116 | + // Captures the exact post-compaction, MERVELAS.md-injected message set sent | |
117 | + // to the API so /share's serialized_conversation.json reflects reality. | |
118 | + lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null | |
119 | + // Last auto-mode classifier request(s) for /share transcript | |
120 | + lastClassifierRequests: unknown[] | null | |
121 | + // MERVELAS.md content cached by context.ts for the auto-mode classifier. | |
122 | + // Breaks the yoloClassifier → claudemd → filesystem → permissions cycle. | |
123 | + cachedClaudeMdContent: string | null | |
124 | + // In-memory error log for recent errors | |
125 | + inMemoryErrorLog: Array<{ error: string; timestamp: string }> | |
126 | + // Session-only plugins from --plugin-dir flag | |
127 | + inlinePlugins: Array<string> | |
128 | + // Explicit --chrome / --no-chrome flag value (undefined = not set on CLI) | |
129 | + chromeFlagOverride: boolean | undefined | |
130 | + // Use cowork_plugins directory instead of plugins (--cowork flag or env var) | |
131 | + useCoworkPlugins: boolean | |
132 | + // Session-only bypass permissions mode flag (not persisted) | |
133 | + sessionBypassPermissionsMode: boolean | |
134 | + // Session-only flag gating the .mervelas/scheduled_tasks.json watcher | |
135 | + // (useScheduledTasks). Set by cronScheduler.start() when the JSON has | |
136 | + // entries, or by CronCreateTool. Not persisted. | |
137 | + scheduledTasksEnabled: boolean | |
138 | + // Session-only cron tasks created via CronCreate with durable: false. | |
139 | + // Fire on schedule like file-backed tasks but are never written to | |
140 | + // .mervelas/scheduled_tasks.json — they die with the process. Typed via | |
141 | + // SessionCronTask below (not importing from cronTasks.ts keeps | |
142 | + // bootstrap a leaf of the import DAG). | |
143 | + sessionCronTasks: SessionCronTask[] | |
144 | + // Teams created this session via TeamCreate. cleanupSessionTeams() | |
145 | + // removes these on gracefulShutdown so subagent-created teams don't | |
146 | + // persist on disk forever (gh-32730). TeamDelete removes entries to | |
147 | + // avoid double-cleanup. Lives here (not teamHelpers.ts) so | |
148 | + // resetStateForTests() clears it between tests. | |
149 | + sessionCreatedTeams: Set<string> | |
150 | + // Session-only trust flag for home directory (not persisted to disk) | |
151 | + // When running from home dir, trust dialog is shown but not saved to disk. | |
152 | + // This flag allows features requiring trust to work during the session. | |
153 | + sessionTrustAccepted: boolean | |
154 | + // Session-only flag to disable session persistence to disk | |
155 | + sessionPersistenceDisabled: boolean | |
156 | + // Track if user has exited plan mode in this session (for re-entry guidance) | |
157 | + hasExitedPlanMode: boolean | |
158 | + // Track if we need to show the plan mode exit attachment (one-time notification) | |
159 | + needsPlanModeExitAttachment: boolean | |
160 | + // Track if we need to show the auto mode exit attachment (one-time notification) | |
161 | + needsAutoModeExitAttachment: boolean | |
162 | + // Track if LSP plugin recommendation has been shown this session (only show once) | |
163 | + lspRecommendationShownThisSession: boolean | |
164 | + // SDK init event state - jsonSchema for structured output | |
165 | + initJsonSchema: Record<string, unknown> | null | |
166 | + // Registered hooks - SDK callbacks and plugin native hooks | |
167 | + registeredHooks: Partial<Record<HookEvent, RegisteredHookMatcher[]>> | null | |
168 | + // Cache for plan slugs: sessionId -> wordSlug | |
169 | + planSlugCache: Map<string, string> | |
170 | + // Track teleported session for reliability logging | |
171 | + teleportedSessionInfo: { | |
172 | + isTeleported: boolean | |
173 | + hasLoggedFirstMessage: boolean | |
174 | + sessionId: string | null | |
175 | + } | null | |
176 | + // Track invoked skills for preservation across compaction | |
177 | + // Keys are composite: `${agentId ?? ''}:${skillName}` to prevent cross-agent overwrites | |
178 | + invokedSkills: Map< | |
179 | + string, | |
180 | + { | |
181 | + skillName: string | |
182 | + skillPath: string | |
183 | + content: string | |
184 | + invokedAt: number | |
185 | + agentId: string | null | |
186 | + } | |
187 | + > | |
188 | + // Track slow operations for dev bar display (ant-only) | |
189 | + slowOperations: Array<{ | |
190 | + operation: string | |
191 | + durationMs: number | |
192 | + timestamp: number | |
193 | + }> | |
194 | + // SDK-provided betas (e.g., context-1m-2025-08-07) | |
195 | + sdkBetas: string[] | undefined | |
196 | + // Main thread agent type (from --agent flag or settings) | |
197 | + mainThreadAgentType: string | undefined | |
198 | + // Remote mode (--remote flag) | |
199 | + isRemoteMode: boolean | |
200 | + // Direct connect server URL (for display in header) | |
201 | + directConnectServerUrl: string | undefined | |
202 | + // System prompt section cache state | |
203 | + systemPromptSectionCache: Map<string, string | null> | |
204 | + // Last date emitted to the model (for detecting midnight date changes) | |
205 | + lastEmittedDate: string | null | |
206 | + // Additional directories from --add-dir flag (for MERVELAS.md loading) | |
207 | + additionalDirectoriesForClaudeMd: string[] | |
208 | + // Channel server allowlist from --channels flag (servers whose channel | |
209 | + // notifications should register this session). Parsed once in main.tsx — | |
210 | + // the tag decides trust model: 'plugin' → marketplace verification + | |
211 | + // allowlist, 'server' → allowlist always fails (schema is plugin-only). | |
212 | + // Either kind needs entry.dev to bypass allowlist. | |
213 | + allowedChannels: ChannelEntry[] | |
214 | + // True if any entry in allowedChannels came from | |
215 | + // --dangerously-load-development-channels (so ChannelsNotice can name the | |
216 | + // right flag in policy-blocked messages) | |
217 | + hasDevChannels: boolean | |
218 | + // Dir containing the session's `.jsonl`; null = derive from originalCwd. | |
219 | + sessionProjectDir: string | null | |
220 | + // Cached prompt cache 1h TTL allowlist from GrowthBook (session-stable) | |
221 | + promptCache1hAllowlist: string[] | null | |
222 | + // Cached 1h TTL user eligibility (session-stable). Latched on first | |
223 | + // evaluation so mid-session overage flips don't change the cache_control | |
224 | + // TTL, which would bust the server-side prompt cache. | |
225 | + promptCache1hEligible: boolean | null | |
226 | + // Sticky-on latch for AFK_MODE_BETA_HEADER. Once auto mode is first | |
227 | + // activated, keep sending the header for the rest of the session so | |
228 | + // Shift+Tab toggles don't bust the ~50-70K token prompt cache. | |
229 | + afkModeHeaderLatched: boolean | null | |
230 | + // Sticky-on latch for FAST_MODE_BETA_HEADER. Once fast mode is first | |
231 | + // enabled, keep sending the header so cooldown enter/exit doesn't | |
232 | + // double-bust the prompt cache. The `speed` body param stays dynamic. | |
233 | + fastModeHeaderLatched: boolean | null | |
234 | + // Sticky-on latch for the cache-editing beta header. Once cached | |
235 | + // microcompact is first enabled, keep sending the header so mid-session | |
236 | + // GrowthBook/settings toggles don't bust the prompt cache. | |
237 | + cacheEditingHeaderLatched: boolean | null | |
238 | + // Sticky-on latch for clearing thinking from prior tool loops. Triggered | |
239 | + // when >1h since last API call (confirmed cache miss — no cache-hit | |
240 | + // benefit to keeping thinking). Once latched, stays on so the newly-warmed | |
241 | + // thinking-cleared cache isn't busted by flipping back to keep:'all'. | |
242 | + thinkingClearLatched: boolean | null | |
243 | + // Current prompt ID (UUID) correlating a user prompt with subsequent OTel events | |
244 | + promptId: string | null | |
245 | + // Last API requestId for the main conversation chain (not subagents). | |
246 | + // Updated after each successful API response for main-session queries. | |
247 | + // Read at shutdown to send cache eviction hints to inference. | |
248 | + lastMainRequestId: string | undefined | |
249 | + // Timestamp (Date.now()) of the last successful API call completion. | |
250 | + // Used to compute timeSinceLastApiCallMs in tengu_api_success for | |
251 | + // correlating cache misses with idle time (cache TTL is ~5min). | |
252 | + lastApiCompletionTimestamp: number | null | |
253 | + // Set to true after compaction (auto or manual /compact). Consumed by | |
254 | + // logAPISuccess to tag the first post-compaction API call so we can | |
255 | + // distinguish compaction-induced cache misses from TTL expiry. | |
256 | + pendingPostCompaction: boolean | |
257 | +} | |
258 | + | |
259 | +// ALSO HERE - THINK THRICE BEFORE MODIFYING | |
260 | +function getInitialState(): State { | |
261 | + // Resolve symlinks in cwd to match behavior of shell.ts setCwd | |
262 | + // This ensures consistency with how paths are sanitized for session storage | |
263 | + let resolvedCwd = '' | |
264 | + if ( | |
265 | + typeof process !== 'undefined' && | |
266 | + typeof process.cwd === 'function' && | |
267 | + typeof realpathSync === 'function' | |
268 | + ) { | |
269 | + const rawCwd = cwd() | |
270 | + try { | |
271 | + resolvedCwd = realpathSync(rawCwd).normalize('NFC') | |
272 | + } catch { | |
273 | + // File Provider EPERM on CloudStorage mounts (lstat per path component). | |
274 | + resolvedCwd = rawCwd.normalize('NFC') | |
275 | + } | |
276 | + } | |
277 | + const state: State = { | |
278 | + originalCwd: resolvedCwd, | |
279 | + projectRoot: resolvedCwd, | |
280 | + totalCostUSD: 0, | |
281 | + totalAPIDuration: 0, | |
282 | + totalAPIDurationWithoutRetries: 0, | |
283 | + totalToolDuration: 0, | |
284 | + turnHookDurationMs: 0, | |
285 | + turnToolDurationMs: 0, | |
286 | + turnClassifierDurationMs: 0, | |
287 | + turnToolCount: 0, | |
288 | + turnHookCount: 0, | |
289 | + turnClassifierCount: 0, | |
290 | + startTime: Date.now(), | |
291 | + lastInteractionTime: Date.now(), | |
292 | + totalLinesAdded: 0, | |
293 | + totalLinesRemoved: 0, | |
294 | + hasUnknownModelCost: false, | |
295 | + cwd: resolvedCwd, | |
296 | + modelUsage: {}, | |
297 | + mainLoopModelOverride: undefined, | |
298 | + initialMainLoopModel: null, | |
299 | + modelStrings: null, | |
300 | + isInteractive: false, | |
301 | + kairosActive: false, | |
302 | + strictToolResultPairing: false, | |
303 | + sdkAgentProgressSummariesEnabled: false, | |
304 | + userMsgOptIn: false, | |
305 | + clientType: 'cli', | |
306 | + sessionSource: undefined, | |
307 | + questionPreviewFormat: undefined, | |
308 | + sessionIngressToken: undefined, | |
309 | + oauthTokenFromFd: undefined, | |
310 | + apiKeyFromFd: undefined, | |
311 | + flagSettingsPath: undefined, | |
312 | + flagSettingsInline: null, | |
313 | + allowedSettingSources: [ | |
314 | + 'userSettings', | |
315 | + 'projectSettings', | |
316 | + 'localSettings', | |
317 | + 'flagSettings', | |
318 | + 'policySettings', | |
319 | + ], | |
320 | + // Telemetry state | |
321 | + meter: null, | |
322 | + sessionCounter: null, | |
323 | + locCounter: null, | |
324 | + prCounter: null, | |
325 | + commitCounter: null, | |
326 | + costCounter: null, | |
327 | + tokenCounter: null, | |
328 | + codeEditToolDecisionCounter: null, | |
329 | + activeTimeCounter: null, | |
330 | + statsStore: null, | |
331 | + sessionId: randomUUID() as SessionId, | |
332 | + parentSessionId: undefined, | |
333 | + // Logger state | |
334 | + loggerProvider: null, | |
335 | + eventLogger: null, | |
336 | + // Meter provider state | |
337 | + meterProvider: null, | |
338 | + tracerProvider: null, | |
339 | + // Agent color state | |
340 | + agentColorMap: new Map(), | |
341 | + agentColorIndex: 0, | |
342 | + // Last API request for bug reports | |
343 | + lastAPIRequest: null, | |
344 | + lastAPIRequestMessages: null, | |
345 | + // Last auto-mode classifier request(s) for /share transcript | |
346 | + lastClassifierRequests: null, | |
347 | + cachedClaudeMdContent: null, | |
348 | + // In-memory error log for recent errors | |
349 | + inMemoryErrorLog: [], | |
350 | + // Session-only plugins from --plugin-dir flag | |
351 | + inlinePlugins: [], | |
352 | + // Explicit --chrome / --no-chrome flag value (undefined = not set on CLI) | |
353 | + chromeFlagOverride: undefined, | |
354 | + // Use cowork_plugins directory instead of plugins | |
355 | + useCoworkPlugins: false, | |
356 | + // Session-only bypass permissions mode flag (not persisted) | |
357 | + sessionBypassPermissionsMode: false, | |
358 | + // Scheduled tasks disabled until flag or dialog enables them | |
359 | + scheduledTasksEnabled: false, | |
360 | + sessionCronTasks: [], | |
361 | + sessionCreatedTeams: new Set(), | |
362 | + // Session-only trust flag (not persisted to disk) | |
363 | + sessionTrustAccepted: false, | |
364 | + // Session-only flag to disable session persistence to disk | |
365 | + sessionPersistenceDisabled: false, | |
366 | + // Track if user has exited plan mode in this session | |
367 | + hasExitedPlanMode: false, | |
368 | + // Track if we need to show the plan mode exit attachment | |
369 | + needsPlanModeExitAttachment: false, | |
370 | + // Track if we need to show the auto mode exit attachment | |
371 | + needsAutoModeExitAttachment: false, | |
372 | + // Track if LSP plugin recommendation has been shown this session | |
373 | + lspRecommendationShownThisSession: false, | |
374 | + // SDK init event state | |
375 | + initJsonSchema: null, | |
376 | + registeredHooks: null, | |
377 | + // Cache for plan slugs | |
378 | + planSlugCache: new Map(), | |
379 | + // Track teleported session for reliability logging | |
380 | + teleportedSessionInfo: null, | |
381 | + // Track invoked skills for preservation across compaction | |
382 | + invokedSkills: new Map(), | |
383 | + // Track slow operations for dev bar display | |
384 | + slowOperations: [], | |
385 | + // SDK-provided betas | |
386 | + sdkBetas: undefined, | |
387 | + // Main thread agent type | |
388 | + mainThreadAgentType: undefined, | |
389 | + // Remote mode | |
390 | + isRemoteMode: false, | |
391 | + ...(process.env.USER_TYPE === 'ant' | |
392 | + ? { | |
393 | + replBridgeActive: false, | |
394 | + } | |
395 | + : {}), | |
396 | + // Direct connect server URL | |
397 | + directConnectServerUrl: undefined, | |
398 | + // System prompt section cache state | |
399 | + systemPromptSectionCache: new Map(), | |
400 | + // Last date emitted to the model | |
401 | + lastEmittedDate: null, | |
402 | + // Additional directories from --add-dir flag (for MERVELAS.md loading) | |
403 | + additionalDirectoriesForClaudeMd: [], | |
404 | + // Channel server allowlist from --channels flag | |
405 | + allowedChannels: [], | |
406 | + hasDevChannels: false, | |
407 | + // Session project dir (null = derive from originalCwd) | |
408 | + sessionProjectDir: null, | |
409 | + // Prompt cache 1h allowlist (null = not yet fetched from GrowthBook) | |
410 | + promptCache1hAllowlist: null, | |
411 | + // Prompt cache 1h eligibility (null = not yet evaluated) | |
412 | + promptCache1hEligible: null, | |
413 | + // Beta header latches (null = not yet triggered) | |
414 | + afkModeHeaderLatched: null, | |
415 | + fastModeHeaderLatched: null, | |
416 | + cacheEditingHeaderLatched: null, | |
417 | + thinkingClearLatched: null, | |
418 | + // Current prompt ID | |
419 | + promptId: null, | |
420 | + lastMainRequestId: undefined, | |
421 | + lastApiCompletionTimestamp: null, | |
422 | + pendingPostCompaction: false, | |
423 | + } | |
424 | + | |
425 | + return state | |
426 | +} | |
427 | + | |
428 | +// AND ESPECIALLY HERE | |
429 | +const STATE: State = getInitialState() | |
430 | + | |
431 | +export function getSessionId(): SessionId { | |
432 | + return STATE.sessionId | |
433 | +} | |
434 | + | |
435 | +export function regenerateSessionId( | |
436 | + options: { setCurrentAsParent?: boolean } = {}, | |
437 | +): SessionId { | |
438 | + if (options.setCurrentAsParent) { | |
439 | + STATE.parentSessionId = STATE.sessionId | |
440 | + } | |
441 | + // Drop the outgoing session's plan-slug entry so the Map doesn't | |
442 | + // accumulate stale keys. Callers that need to carry the slug across | |
443 | + // (REPL.tsx clearContext) read it before calling clearConversation. | |
444 | + STATE.planSlugCache.delete(STATE.sessionId) | |
445 | + // Regenerated sessions live in the current project: reset projectDir to | |
446 | + // null so getTranscriptPath() derives from originalCwd. | |
447 | + STATE.sessionId = randomUUID() as SessionId | |
448 | + STATE.sessionProjectDir = null | |
449 | + return STATE.sessionId | |
450 | +} | |
451 | + | |
452 | +export function getParentSessionId(): SessionId | undefined { | |
453 | + return STATE.parentSessionId | |
454 | +} | |
455 | + | |
456 | +/** | |
457 | + * Atomically switch the active session. `sessionId` and `sessionProjectDir` | |
458 | + * always change together — there is no separate setter for either, so they | |
459 | + * cannot drift out of sync (CC-34). | |
460 | + * | |
461 | + * @param projectDir — directory containing `<sessionId>.jsonl`. Omit (or | |
462 | + * pass `null`) for sessions in the current project — the path will derive | |
463 | + * from originalCwd at read time. Pass `dirname(transcriptPath)` when the | |
464 | + * session lives in a different project directory (git worktrees, | |
465 | + * cross-project resume). Every call resets the project dir; it never | |
466 | + * carries over from the previous session. | |
467 | + */ | |
468 | +export function switchSession( | |
469 | + sessionId: SessionId, | |
470 | + projectDir: string | null = null, | |
471 | +): void { | |
472 | + // Drop the outgoing session's plan-slug entry so the Map stays bounded | |
473 | + // across repeated /resume. Only the current session's slug is ever read | |
474 | + // (plans.ts getPlanSlug defaults to getSessionId()). | |
475 | + STATE.planSlugCache.delete(STATE.sessionId) | |
476 | + STATE.sessionId = sessionId | |
477 | + STATE.sessionProjectDir = projectDir | |
478 | + sessionSwitched.emit(sessionId) | |
479 | +} | |
480 | + | |
481 | +const sessionSwitched = createSignal<[id: SessionId]>() | |
482 | + | |
483 | +/** | |
484 | + * Register a callback that fires when switchSession changes the active | |
485 | + * sessionId. bootstrap can't import listeners directly (DAG leaf), so | |
486 | + * callers register themselves. concurrentSessions.ts uses this to keep the | |
487 | + * PID file's sessionId in sync with --resume. | |
488 | + */ | |
489 | +export const onSessionSwitch = sessionSwitched.subscribe | |
490 | + | |
491 | +/** | |
492 | + * Project directory the current session's transcript lives in, or `null` if | |
493 | + * the session was created in the current project (common case — derive from | |
494 | + * originalCwd). See `switchSession()`. | |
495 | + */ | |
496 | +export function getSessionProjectDir(): string | null { | |
497 | + return STATE.sessionProjectDir | |
498 | +} | |
499 | + | |
500 | +export function getOriginalCwd(): string { | |
501 | + return STATE.originalCwd | |
502 | +} | |
503 | + | |
504 | +/** | |
505 | + * Get the stable project root directory. | |
506 | + * Unlike getOriginalCwd(), this is never updated by mid-session EnterWorktreeTool | |
507 | + * (so skills/history stay stable when entering a throwaway worktree). | |
508 | + * It IS set at startup by --worktree, since that worktree is the session's project. | |
509 | + * Use for project identity (history, skills, sessions) not file operations. | |
510 | + */ | |
511 | +export function getProjectRoot(): string { | |
512 | + return STATE.projectRoot | |
513 | +} | |
514 | + | |
515 | +export function setOriginalCwd(cwd: string): void { | |
516 | + STATE.originalCwd = cwd.normalize('NFC') | |
517 | +} | |
518 | + | |
519 | +/** | |
520 | + * Only for --worktree startup flag. Mid-session EnterWorktreeTool must NOT | |
521 | + * call this — skills/history should stay anchored to where the session started. | |
522 | + */ | |
523 | +export function setProjectRoot(cwd: string): void { | |
524 | + STATE.projectRoot = cwd.normalize('NFC') | |
525 | +} | |
526 | + | |
527 | +export function getCwdState(): string { | |
528 | + return STATE.cwd | |
529 | +} | |
530 | + | |
531 | +export function setCwdState(cwd: string): void { | |
532 | + STATE.cwd = cwd.normalize('NFC') | |
533 | +} | |
534 | + | |
535 | +export function getDirectConnectServerUrl(): string | undefined { | |
536 | + return STATE.directConnectServerUrl | |
537 | +} | |
538 | + | |
539 | +export function setDirectConnectServerUrl(url: string): void { | |
540 | + STATE.directConnectServerUrl = url | |
541 | +} | |
542 | + | |
543 | +export function addToTotalDurationState( | |
544 | + duration: number, | |
545 | + durationWithoutRetries: number, | |
546 | +): void { | |
547 | + STATE.totalAPIDuration += duration | |
548 | + STATE.totalAPIDurationWithoutRetries += durationWithoutRetries | |
549 | +} | |
550 | + | |
551 | +export function resetTotalDurationStateAndCost_FOR_TESTS_ONLY(): void { | |
552 | + STATE.totalAPIDuration = 0 | |
553 | + STATE.totalAPIDurationWithoutRetries = 0 | |
554 | + STATE.totalCostUSD = 0 | |
555 | +} | |
556 | + | |
557 | +export function addToTotalCostState( | |
558 | + cost: number, | |
559 | + modelUsage: ModelUsage, | |
560 | + model: string, | |
561 | +): void { | |
562 | + STATE.modelUsage[model] = modelUsage | |
563 | + STATE.totalCostUSD += cost | |
564 | +} | |
565 | + | |
566 | +export function getTotalCostUSD(): number { | |
567 | + return STATE.totalCostUSD | |
568 | +} | |
569 | + | |
570 | +export function getTotalAPIDuration(): number { | |
571 | + return STATE.totalAPIDuration | |
572 | +} | |
573 | + | |
574 | +export function getTotalDuration(): number { | |
575 | + return Date.now() - STATE.startTime | |
576 | +} | |
577 | + | |
578 | +export function getTotalAPIDurationWithoutRetries(): number { | |
579 | + return STATE.totalAPIDurationWithoutRetries | |
580 | +} | |
581 | + | |
582 | +export function getTotalToolDuration(): number { | |
583 | + return STATE.totalToolDuration | |
584 | +} | |
585 | + | |
586 | +export function addToToolDuration(duration: number): void { | |
587 | + STATE.totalToolDuration += duration | |
588 | + STATE.turnToolDurationMs += duration | |
589 | + STATE.turnToolCount++ | |
590 | +} | |
591 | + | |
592 | +export function getTurnHookDurationMs(): number { | |
593 | + return STATE.turnHookDurationMs | |
594 | +} | |
595 | + | |
596 | +export function addToTurnHookDuration(duration: number): void { | |
597 | + STATE.turnHookDurationMs += duration | |
598 | + STATE.turnHookCount++ | |
599 | +} | |
600 | + | |
601 | +export function resetTurnHookDuration(): void { | |
602 | + STATE.turnHookDurationMs = 0 | |
603 | + STATE.turnHookCount = 0 | |
604 | +} | |
605 | + | |
606 | +export function getTurnHookCount(): number { | |
607 | + return STATE.turnHookCount | |
608 | +} | |
609 | + | |
610 | +export function getTurnToolDurationMs(): number { | |
611 | + return STATE.turnToolDurationMs | |
612 | +} | |
613 | + | |
614 | +export function resetTurnToolDuration(): void { | |
615 | + STATE.turnToolDurationMs = 0 | |
616 | + STATE.turnToolCount = 0 | |
617 | +} | |
618 | + | |
619 | +export function getTurnToolCount(): number { | |
620 | + return STATE.turnToolCount | |
621 | +} | |
622 | + | |
623 | +export function getTurnClassifierDurationMs(): number { | |
624 | + return STATE.turnClassifierDurationMs | |
625 | +} | |
626 | + | |
627 | +export function addToTurnClassifierDuration(duration: number): void { | |
628 | + STATE.turnClassifierDurationMs += duration | |
629 | + STATE.turnClassifierCount++ | |
630 | +} | |
631 | + | |
632 | +export function resetTurnClassifierDuration(): void { | |
633 | + STATE.turnClassifierDurationMs = 0 | |
634 | + STATE.turnClassifierCount = 0 | |
635 | +} | |
636 | + | |
637 | +export function getTurnClassifierCount(): number { | |
638 | + return STATE.turnClassifierCount | |
639 | +} | |
640 | + | |
641 | +export function getStatsStore(): { | |
642 | + observe(name: string, value: number): void | |
643 | +} | null { | |
644 | + return STATE.statsStore | |
645 | +} | |
646 | + | |
647 | +export function setStatsStore( | |
648 | + store: { observe(name: string, value: number): void } | null, | |
649 | +): void { | |
650 | + STATE.statsStore = store | |
651 | +} | |
652 | + | |
653 | +/** | |
654 | + * Marks that an interaction occurred. | |
655 | + * | |
656 | + * By default the actual Date.now() call is deferred until the next Ink render | |
657 | + * frame (via flushInteractionTime()) so we avoid calling Date.now() on every | |
658 | + * single keypress. | |
659 | + * | |
660 | + * Pass `immediate = true` when calling from React useEffect callbacks or | |
661 | + * other code that runs *after* the Ink render cycle has already flushed. | |
662 | + * Without it the timestamp stays stale until the next render, which may never | |
663 | + * come if the user is idle (e.g. permission dialog waiting for input). | |
664 | + */ | |
665 | +let interactionTimeDirty = false | |
666 | + | |
667 | +export function updateLastInteractionTime(immediate?: boolean): void { | |
668 | + if (immediate) { | |
669 | + flushInteractionTime_inner() | |
670 | + } else { | |
671 | + interactionTimeDirty = true | |
672 | + } | |
673 | +} | |
674 | + | |
675 | +/** | |
676 | + * If an interaction was recorded since the last flush, update the timestamp | |
677 | + * now. Called by Ink before each render cycle so we batch many keypresses into | |
678 | + * a single Date.now() call. | |
679 | + */ | |
680 | +export function flushInteractionTime(): void { | |
681 | + if (interactionTimeDirty) { | |
682 | + flushInteractionTime_inner() | |
683 | + } | |
684 | +} | |
685 | + | |
686 | +function flushInteractionTime_inner(): void { | |
687 | + STATE.lastInteractionTime = Date.now() | |
688 | + interactionTimeDirty = false | |
689 | +} | |
690 | + | |
691 | +export function addToTotalLinesChanged(added: number, removed: number): void { | |
692 | + STATE.totalLinesAdded += added | |
693 | + STATE.totalLinesRemoved += removed | |
694 | +} | |
695 | + | |
696 | +export function getTotalLinesAdded(): number { | |
697 | + return STATE.totalLinesAdded | |
698 | +} | |
699 | + | |
700 | +export function getTotalLinesRemoved(): number { | |
701 | + return STATE.totalLinesRemoved | |
702 | +} | |
703 | + | |
704 | +export function getTotalInputTokens(): number { | |
705 | + return sumBy(Object.values(STATE.modelUsage), 'inputTokens') | |
706 | +} | |
707 | + | |
708 | +export function getTotalOutputTokens(): number { | |
709 | + return sumBy(Object.values(STATE.modelUsage), 'outputTokens') | |
710 | +} | |
711 | + | |
712 | +export function getTotalCacheReadInputTokens(): number { | |
713 | + return sumBy(Object.values(STATE.modelUsage), 'cacheReadInputTokens') | |
714 | +} | |
715 | + | |
716 | +export function getTotalCacheCreationInputTokens(): number { | |
717 | + return sumBy(Object.values(STATE.modelUsage), 'cacheCreationInputTokens') | |
718 | +} | |
719 | + | |
720 | +export function getTotalWebSearchRequests(): number { | |
721 | + return sumBy(Object.values(STATE.modelUsage), 'webSearchRequests') | |
722 | +} | |
723 | + | |
724 | +let outputTokensAtTurnStart = 0 | |
725 | +let currentTurnTokenBudget: number | null = null | |
726 | +export function getTurnOutputTokens(): number { | |
727 | + return getTotalOutputTokens() - outputTokensAtTurnStart | |
728 | +} | |
729 | +export function getCurrentTurnTokenBudget(): number | null { | |
730 | + return currentTurnTokenBudget | |
731 | +} | |
732 | +let budgetContinuationCount = 0 | |
733 | +export function snapshotOutputTokensForTurn(budget: number | null): void { | |
734 | + outputTokensAtTurnStart = getTotalOutputTokens() | |
735 | + currentTurnTokenBudget = budget | |
736 | + budgetContinuationCount = 0 | |
737 | +} | |
738 | +export function getBudgetContinuationCount(): number { | |
739 | + return budgetContinuationCount | |
740 | +} | |
741 | +export function incrementBudgetContinuationCount(): void { | |
742 | + budgetContinuationCount++ | |
743 | +} | |
744 | + | |
745 | +export function setHasUnknownModelCost(): void { | |
746 | + STATE.hasUnknownModelCost = true | |
747 | +} | |
748 | + | |
749 | +export function hasUnknownModelCost(): boolean { | |
750 | + return STATE.hasUnknownModelCost | |
751 | +} | |
752 | + | |
753 | +export function getLastMainRequestId(): string | undefined { | |
754 | + return STATE.lastMainRequestId | |
755 | +} | |
756 | + | |
757 | +export function setLastMainRequestId(requestId: string): void { | |
758 | + STATE.lastMainRequestId = requestId | |
759 | +} | |
760 | + | |
761 | +export function getLastApiCompletionTimestamp(): number | null { | |
762 | + return STATE.lastApiCompletionTimestamp | |
763 | +} | |
764 | + | |
765 | +export function setLastApiCompletionTimestamp(timestamp: number): void { | |
766 | + STATE.lastApiCompletionTimestamp = timestamp | |
767 | +} | |
768 | + | |
769 | +/** Mark that a compaction just occurred. The next API success event will | |
770 | + * include isPostCompaction=true, then the flag auto-resets. */ | |
771 | +export function markPostCompaction(): void { | |
772 | + STATE.pendingPostCompaction = true | |
773 | +} | |
774 | + | |
775 | +/** Consume the post-compaction flag. Returns true once after compaction, | |
776 | + * then returns false until the next compaction. */ | |
777 | +export function consumePostCompaction(): boolean { | |
778 | + const was = STATE.pendingPostCompaction | |
779 | + STATE.pendingPostCompaction = false | |
780 | + return was | |
781 | +} | |
782 | + | |
783 | +export function getLastInteractionTime(): number { | |
784 | + return STATE.lastInteractionTime | |
785 | +} | |
786 | + | |
787 | +// Scroll drain suspension — background intervals check this before doing work | |
788 | +// so they don't compete with scroll frames for the event loop. Set by | |
789 | +// ScrollBox scrollBy/scrollTo, cleared SCROLL_DRAIN_IDLE_MS after the last | |
790 | +// scroll event. Module-scope (not in STATE) — ephemeral hot-path flag, no | |
791 | +// test-reset needed since the debounce timer self-clears. | |
792 | +let scrollDraining = false | |
793 | +let scrollDrainTimer: ReturnType<typeof setTimeout> | undefined | |
794 | +const SCROLL_DRAIN_IDLE_MS = 150 | |
795 | + | |
796 | +/** Mark that a scroll event just happened. Background intervals gate on | |
797 | + * getIsScrollDraining() and skip their work until the debounce clears. */ | |
798 | +export function markScrollActivity(): void { | |
799 | + scrollDraining = true | |
800 | + if (scrollDrainTimer) clearTimeout(scrollDrainTimer) | |
801 | + scrollDrainTimer = setTimeout(() => { | |
802 | + scrollDraining = false | |
803 | + scrollDrainTimer = undefined | |
804 | + }, SCROLL_DRAIN_IDLE_MS) | |
805 | + scrollDrainTimer.unref?.() | |
806 | +} | |
807 | + | |
808 | +/** True while scroll is actively draining (within 150ms of last event). | |
809 | + * Intervals should early-return when this is set — the work picks up next | |
810 | + * tick after scroll settles. */ | |
811 | +export function getIsScrollDraining(): boolean { | |
812 | + return scrollDraining | |
813 | +} | |
814 | + | |
815 | +/** Await this before expensive one-shot work (network, subprocess) that could | |
816 | + * coincide with scroll. Resolves immediately if not scrolling; otherwise | |
817 | + * polls at the idle interval until the flag clears. */ | |
818 | +export async function waitForScrollIdle(): Promise<void> { | |
819 | + while (scrollDraining) { | |
820 | + // bootstrap-isolation forbids importing sleep() from src/utils/ | |
821 | + // eslint-disable-next-line no-restricted-syntax | |
822 | + await new Promise(r => setTimeout(r, SCROLL_DRAIN_IDLE_MS).unref?.()) | |
823 | + } | |
824 | +} | |
825 | + | |
826 | +export function getModelUsage(): { [modelName: string]: ModelUsage } { | |
827 | + return STATE.modelUsage | |
828 | +} | |
829 | + | |
830 | +export function getUsageForModel(model: string): ModelUsage | undefined { | |
831 | + return STATE.modelUsage[model] | |
832 | +} | |
833 | + | |
834 | +/** | |
835 | + * Gets the model override set from the --model CLI flag or after the user | |
836 | + * updates their configured model. | |
837 | + */ | |
838 | +export function getMainLoopModelOverride(): ModelSetting | undefined { | |
839 | + return STATE.mainLoopModelOverride | |
840 | +} | |
841 | + | |
842 | +export function getInitialMainLoopModel(): ModelSetting { | |
843 | + return STATE.initialMainLoopModel | |
844 | +} | |
845 | + | |
846 | +export function setMainLoopModelOverride( | |
847 | + model: ModelSetting | undefined, | |
848 | +): void { | |
849 | + STATE.mainLoopModelOverride = model | |
850 | +} | |
851 | + | |
852 | +export function setInitialMainLoopModel(model: ModelSetting): void { | |
853 | + STATE.initialMainLoopModel = model | |
854 | +} | |
855 | + | |
856 | +export function getSdkBetas(): string[] | undefined { | |
857 | + return STATE.sdkBetas | |
858 | +} | |
859 | + | |
860 | +export function setSdkBetas(betas: string[] | undefined): void { | |
861 | + STATE.sdkBetas = betas | |
862 | +} | |
863 | + | |
864 | +export function resetCostState(): void { | |
865 | + STATE.totalCostUSD = 0 | |
866 | + STATE.totalAPIDuration = 0 | |
867 | + STATE.totalAPIDurationWithoutRetries = 0 | |
868 | + STATE.totalToolDuration = 0 | |
869 | + STATE.startTime = Date.now() | |
870 | + STATE.totalLinesAdded = 0 | |
871 | + STATE.totalLinesRemoved = 0 | |
872 | + STATE.hasUnknownModelCost = false | |
873 | + STATE.modelUsage = {} | |
874 | + STATE.promptId = null | |
875 | +} | |
876 | + | |
877 | +/** | |
878 | + * Sets cost state values for session restore. | |
879 | + * Called by restoreCostStateForSession in cost-tracker.ts. | |
880 | + */ | |
881 | +export function setCostStateForRestore({ | |
882 | + totalCostUSD, | |
883 | + totalAPIDuration, | |
884 | + totalAPIDurationWithoutRetries, | |
885 | + totalToolDuration, | |
886 | + totalLinesAdded, | |
887 | + totalLinesRemoved, | |
888 | + lastDuration, | |
889 | + modelUsage, | |
890 | +}: { | |
891 | + totalCostUSD: number | |
892 | + totalAPIDuration: number | |
893 | + totalAPIDurationWithoutRetries: number | |
894 | + totalToolDuration: number | |
895 | + totalLinesAdded: number | |
896 | + totalLinesRemoved: number | |
897 | + lastDuration: number | undefined | |
898 | + modelUsage: { [modelName: string]: ModelUsage } | undefined | |
899 | +}): void { | |
900 | + STATE.totalCostUSD = totalCostUSD | |
901 | + STATE.totalAPIDuration = totalAPIDuration | |
902 | + STATE.totalAPIDurationWithoutRetries = totalAPIDurationWithoutRetries | |
903 | + STATE.totalToolDuration = totalToolDuration | |
904 | + STATE.totalLinesAdded = totalLinesAdded | |
905 | + STATE.totalLinesRemoved = totalLinesRemoved | |
906 | + | |
907 | + // Restore per-model usage breakdown | |
908 | + if (modelUsage) { | |
909 | + STATE.modelUsage = modelUsage | |
910 | + } | |
911 | + | |
912 | + // Adjust startTime to make wall duration accumulate | |
913 | + if (lastDuration) { | |
914 | + STATE.startTime = Date.now() - lastDuration | |
915 | + } | |
916 | +} | |
917 | + | |
918 | +// Only used in tests | |
919 | +export function resetStateForTests(): void { | |
920 | + if (process.env.NODE_ENV !== 'test') { | |
921 | + throw new Error('resetStateForTests can only be called in tests') | |
922 | + } | |
923 | + Object.entries(getInitialState()).forEach(([key, value]) => { | |
924 | + STATE[key as keyof State] = value as never | |
925 | + }) | |
926 | + outputTokensAtTurnStart = 0 | |
927 | + currentTurnTokenBudget = null | |
928 | + budgetContinuationCount = 0 | |
929 | + sessionSwitched.clear() | |
930 | +} | |
931 | + | |
932 | +// You shouldn't use this directly. See src/utils/model/modelStrings.ts::getModelStrings() | |
933 | +export function getModelStrings(): ModelStrings | null { | |
934 | + return STATE.modelStrings | |
935 | +} | |
936 | + | |
937 | +// You shouldn't use this directly. See src/utils/model/modelStrings.ts | |
938 | +export function setModelStrings(modelStrings: ModelStrings): void { | |
939 | + STATE.modelStrings = modelStrings | |
940 | +} | |
941 | + | |
942 | +// Test utility function to reset model strings for re-initialization. | |
943 | +// Separate from setModelStrings because we only want to accept 'null' in tests. | |
944 | +export function resetModelStringsForTestingOnly() { | |
945 | + STATE.modelStrings = null | |
946 | +} | |
947 | + | |
948 | +export function setMeter( | |
949 | + meter: Meter, | |
950 | + createCounter: (name: string, options: MetricOptions) => AttributedCounter, | |
951 | +): void { | |
952 | + STATE.meter = meter | |
953 | + | |
954 | + // Initialize all counters using the provided factory | |
955 | + STATE.sessionCounter = createCounter('claude_code.session.count', { | |
956 | + description: 'Count of CLI sessions started', | |
957 | + }) | |
958 | + STATE.locCounter = createCounter('claude_code.lines_of_code.count', { | |
959 | + description: | |
960 | + "Count of lines of code modified, with the 'type' attribute indicating whether lines were added or removed", | |
961 | + }) | |
962 | + STATE.prCounter = createCounter('claude_code.pull_request.count', { | |
963 | + description: 'Number of pull requests created', | |
964 | + }) | |
965 | + STATE.commitCounter = createCounter('claude_code.commit.count', { | |
966 | + description: 'Number of git commits created', | |
967 | + }) | |
968 | + STATE.costCounter = createCounter('claude_code.cost.usage', { | |
969 | + description: 'Cost of the Mervelas session', | |
970 | + unit: 'USD', | |
971 | + }) | |
972 | + STATE.tokenCounter = createCounter('claude_code.token.usage', { | |
973 | + description: 'Number of tokens used', | |
974 | + unit: 'tokens', | |
975 | + }) | |
976 | + STATE.codeEditToolDecisionCounter = createCounter( | |
977 | + 'claude_code.code_edit_tool.decision', | |
978 | + { | |
979 | + description: | |
980 | + 'Count of code editing tool permission decisions (accept/reject) for Edit, Write, and NotebookEdit tools', | |
981 | + }, | |
982 | + ) | |
983 | + STATE.activeTimeCounter = createCounter('claude_code.active_time.total', { | |
984 | + description: 'Total active time in seconds', | |
985 | + unit: 's', | |
986 | + }) | |
987 | +} | |
988 | + | |
989 | +export function getMeter(): Meter | null { | |
990 | + return STATE.meter | |
991 | +} | |
992 | + | |
993 | +export function getSessionCounter(): AttributedCounter | null { | |
994 | + return STATE.sessionCounter | |
995 | +} | |
996 | + | |
997 | +export function getLocCounter(): AttributedCounter | null { | |
998 | + return STATE.locCounter | |
999 | +} | |
1000 | + | |
1001 | +export function getPrCounter(): AttributedCounter | null { | |
1002 | + return STATE.prCounter | |
1003 | +} | |
1004 | + | |
1005 | +export function getCommitCounter(): AttributedCounter | null { | |
1006 | + return STATE.commitCounter | |
1007 | +} | |
1008 | + | |
1009 | +export function getCostCounter(): AttributedCounter | null { | |
1010 | + return STATE.costCounter | |
1011 | +} | |
1012 | + | |
1013 | +export function getTokenCounter(): AttributedCounter | null { | |
1014 | + return STATE.tokenCounter | |
1015 | +} | |
1016 | + | |
1017 | +export function getCodeEditToolDecisionCounter(): AttributedCounter | null { | |
1018 | + return STATE.codeEditToolDecisionCounter | |
1019 | +} | |
1020 | + | |
1021 | +export function getActiveTimeCounter(): AttributedCounter | null { | |
1022 | + return STATE.activeTimeCounter | |
1023 | +} | |
1024 | + | |
1025 | +export function getLoggerProvider(): LoggerProvider | null { | |
1026 | + return STATE.loggerProvider | |
1027 | +} | |
1028 | + | |
1029 | +export function setLoggerProvider(provider: LoggerProvider | null): void { | |
1030 | + STATE.loggerProvider = provider | |
1031 | +} | |
1032 | + | |
1033 | +export function getEventLogger(): ReturnType<typeof logs.getLogger> | null { | |
1034 | + return STATE.eventLogger | |
1035 | +} | |
1036 | + | |
1037 | +export function setEventLogger( | |
1038 | + logger: ReturnType<typeof logs.getLogger> | null, | |
1039 | +): void { | |
1040 | + STATE.eventLogger = logger | |
1041 | +} | |
1042 | + | |
1043 | +export function getMeterProvider(): MeterProvider | null { | |
1044 | + return STATE.meterProvider | |
1045 | +} | |
1046 | + | |
1047 | +export function setMeterProvider(provider: MeterProvider | null): void { | |
1048 | + STATE.meterProvider = provider | |
1049 | +} | |
1050 | +export function getTracerProvider(): BasicTracerProvider | null { | |
1051 | + return STATE.tracerProvider | |
1052 | +} | |
1053 | +export function setTracerProvider(provider: BasicTracerProvider | null): void { | |
1054 | + STATE.tracerProvider = provider | |
1055 | +} | |
1056 | + | |
1057 | +export function getIsNonInteractiveSession(): boolean { | |
1058 | + return !STATE.isInteractive | |
1059 | +} | |
1060 | + | |
1061 | +export function getIsInteractive(): boolean { | |
1062 | + return STATE.isInteractive | |
1063 | +} | |
1064 | + | |
1065 | +export function setIsInteractive(value: boolean): void { | |
1066 | + STATE.isInteractive = value | |
1067 | +} | |
1068 | + | |
1069 | +export function getClientType(): string { | |
1070 | + return STATE.clientType | |
1071 | +} | |
1072 | + | |
1073 | +export function setClientType(type: string): void { | |
1074 | + STATE.clientType = type | |
1075 | +} | |
1076 | + | |
1077 | +export function getSdkAgentProgressSummariesEnabled(): boolean { | |
1078 | + return STATE.sdkAgentProgressSummariesEnabled | |
1079 | +} | |
1080 | + | |
1081 | +export function setSdkAgentProgressSummariesEnabled(value: boolean): void { | |
1082 | + STATE.sdkAgentProgressSummariesEnabled = value | |
1083 | +} | |
1084 | + | |
1085 | +export function getKairosActive(): boolean { | |
1086 | + return STATE.kairosActive | |
1087 | +} | |
1088 | + | |
1089 | +export function setKairosActive(value: boolean): void { | |
1090 | + STATE.kairosActive = value | |
1091 | +} | |
1092 | + | |
1093 | +export function getStrictToolResultPairing(): boolean { | |
1094 | + return STATE.strictToolResultPairing | |
1095 | +} | |
1096 | + | |
1097 | +export function setStrictToolResultPairing(value: boolean): void { | |
1098 | + STATE.strictToolResultPairing = value | |
1099 | +} | |
1100 | + | |
1101 | +// Field name 'userMsgOptIn' avoids excluded-string substrings ('BriefTool', | |
1102 | +// 'SendUserMessage' — case-insensitive). All callers are inside feature() | |
1103 | +// guards so these accessors don't need their own (matches getKairosActive). | |
1104 | +export function getUserMsgOptIn(): boolean { | |
1105 | + return STATE.userMsgOptIn | |
1106 | +} | |
1107 | + | |
1108 | +export function setUserMsgOptIn(value: boolean): void { | |
1109 | + STATE.userMsgOptIn = value | |
1110 | +} | |
1111 | + | |
1112 | +export function getSessionSource(): string | undefined { | |
1113 | + return STATE.sessionSource | |
1114 | +} | |
1115 | + | |
1116 | +export function setSessionSource(source: string): void { | |
1117 | + STATE.sessionSource = source | |
1118 | +} | |
1119 | + | |
1120 | +export function getQuestionPreviewFormat(): 'markdown' | 'html' | undefined { | |
1121 | + return STATE.questionPreviewFormat | |
1122 | +} | |
1123 | + | |
1124 | +export function setQuestionPreviewFormat(format: 'markdown' | 'html'): void { | |
1125 | + STATE.questionPreviewFormat = format | |
1126 | +} | |
1127 | + | |
1128 | +export function getAgentColorMap(): Map<string, AgentColorName> { | |
1129 | + return STATE.agentColorMap | |
1130 | +} | |
1131 | + | |
1132 | +export function getFlagSettingsPath(): string | undefined { | |
1133 | + return STATE.flagSettingsPath | |
1134 | +} | |
1135 | + | |
1136 | +export function setFlagSettingsPath(path: string | undefined): void { | |
1137 | + STATE.flagSettingsPath = path | |
1138 | +} | |
1139 | + | |
1140 | +export function getFlagSettingsInline(): Record<string, unknown> | null { | |
1141 | + return STATE.flagSettingsInline | |
1142 | +} | |
1143 | + | |
1144 | +export function setFlagSettingsInline( | |
1145 | + settings: Record<string, unknown> | null, | |
1146 | +): void { | |
1147 | + STATE.flagSettingsInline = settings | |
1148 | +} | |
1149 | + | |
1150 | +export function getSessionIngressToken(): string | null | undefined { | |
1151 | + return STATE.sessionIngressToken | |
1152 | +} | |
1153 | + | |
1154 | +export function setSessionIngressToken(token: string | null): void { | |
1155 | + STATE.sessionIngressToken = token | |
1156 | +} | |
1157 | + | |
1158 | +export function getOauthTokenFromFd(): string | null | undefined { | |
1159 | + return STATE.oauthTokenFromFd | |
1160 | +} | |
1161 | + | |
1162 | +export function setOauthTokenFromFd(token: string | null): void { | |
1163 | + STATE.oauthTokenFromFd = token | |
1164 | +} | |
1165 | + | |
1166 | +export function getApiKeyFromFd(): string | null | undefined { | |
1167 | + return STATE.apiKeyFromFd | |
1168 | +} | |
1169 | + | |
1170 | +export function setApiKeyFromFd(key: string | null): void { | |
1171 | + STATE.apiKeyFromFd = key | |
1172 | +} | |
1173 | + | |
1174 | +export function setLastAPIRequest( | |
1175 | + params: Omit<BetaMessageStreamParams, 'messages'> | null, | |
1176 | +): void { | |
1177 | + STATE.lastAPIRequest = params | |
1178 | +} | |
1179 | + | |
1180 | +export function getLastAPIRequest(): Omit< | |
1181 | + BetaMessageStreamParams, | |
1182 | + 'messages' | |
1183 | +> | null { | |
1184 | + return STATE.lastAPIRequest | |
1185 | +} | |
1186 | + | |
1187 | +export function setLastAPIRequestMessages( | |
1188 | + messages: BetaMessageStreamParams['messages'] | null, | |
1189 | +): void { | |
1190 | + STATE.lastAPIRequestMessages = messages | |
1191 | +} | |
1192 | + | |
1193 | +export function getLastAPIRequestMessages(): | |
1194 | + | BetaMessageStreamParams['messages'] | |
1195 | + | null { | |
1196 | + return STATE.lastAPIRequestMessages | |
1197 | +} | |
1198 | + | |
1199 | +export function setLastClassifierRequests(requests: unknown[] | null): void { | |
1200 | + STATE.lastClassifierRequests = requests | |
1201 | +} | |
1202 | + | |
1203 | +export function getLastClassifierRequests(): unknown[] | null { | |
1204 | + return STATE.lastClassifierRequests | |
1205 | +} | |
1206 | + | |
1207 | +export function setCachedClaudeMdContent(content: string | null): void { | |
1208 | + STATE.cachedClaudeMdContent = content | |
1209 | +} | |
1210 | + | |
1211 | +export function getCachedClaudeMdContent(): string | null { | |
1212 | + return STATE.cachedClaudeMdContent | |
1213 | +} | |
1214 | + | |
1215 | +export function addToInMemoryErrorLog(errorInfo: { | |
1216 | + error: string | |
1217 | + timestamp: string | |
1218 | +}): void { | |
1219 | + const MAX_IN_MEMORY_ERRORS = 100 | |
1220 | + if (STATE.inMemoryErrorLog.length >= MAX_IN_MEMORY_ERRORS) { | |
1221 | + STATE.inMemoryErrorLog.shift() // Remove oldest error | |
1222 | + } | |
1223 | + STATE.inMemoryErrorLog.push(errorInfo) | |
1224 | +} | |
1225 | + | |
1226 | +export function getAllowedSettingSources(): SettingSource[] { | |
1227 | + return STATE.allowedSettingSources | |
1228 | +} | |
1229 | + | |
1230 | +export function setAllowedSettingSources(sources: SettingSource[]): void { | |
1231 | + STATE.allowedSettingSources = sources | |
1232 | +} | |
1233 | + | |
1234 | +export function preferThirdPartyAuthentication(): boolean { | |
1235 | + // IDE extension should behave as 1P for authentication reasons. | |
1236 | + return getIsNonInteractiveSession() && STATE.clientType !== 'claude-vscode' | |
1237 | +} | |
1238 | + | |
1239 | +export function setInlinePlugins(plugins: Array<string>): void { | |
1240 | + STATE.inlinePlugins = plugins | |
1241 | +} | |
1242 | + | |
1243 | +export function getInlinePlugins(): Array<string> { | |
1244 | + return STATE.inlinePlugins | |
1245 | +} | |
1246 | + | |
1247 | +export function setChromeFlagOverride(value: boolean | undefined): void { | |
1248 | + STATE.chromeFlagOverride = value | |
1249 | +} | |
1250 | + | |
1251 | +export function getChromeFlagOverride(): boolean | undefined { | |
1252 | + return STATE.chromeFlagOverride | |
1253 | +} | |
1254 | + | |
1255 | +export function setUseCoworkPlugins(value: boolean): void { | |
1256 | + STATE.useCoworkPlugins = value | |
1257 | + resetSettingsCache() | |
1258 | +} | |
1259 | + | |
1260 | +export function getUseCoworkPlugins(): boolean { | |
1261 | + return STATE.useCoworkPlugins | |
1262 | +} | |
1263 | + | |
1264 | +export function setSessionBypassPermissionsMode(enabled: boolean): void { | |
1265 | + STATE.sessionBypassPermissionsMode = enabled | |
1266 | +} | |
1267 | + | |
1268 | +export function getSessionBypassPermissionsMode(): boolean { | |
1269 | + return STATE.sessionBypassPermissionsMode | |
1270 | +} | |
1271 | + | |
1272 | +export function setScheduledTasksEnabled(enabled: boolean): void { | |
1273 | + STATE.scheduledTasksEnabled = enabled | |
1274 | +} | |
1275 | + | |
1276 | +export function getScheduledTasksEnabled(): boolean { | |
1277 | + return STATE.scheduledTasksEnabled | |
1278 | +} | |
1279 | + | |
1280 | +export type SessionCronTask = { | |
1281 | + id: string | |
1282 | + cron: string | |
1283 | + prompt: string | |
1284 | + createdAt: number | |
1285 | + recurring?: boolean | |
1286 | + /** | |
1287 | + * When set, the task was created by an in-process teammate (not the team lead). | |
1288 | + * The scheduler routes fires to that teammate's pendingUserMessages queue | |
1289 | + * instead of the main REPL command queue. Session-only — never written to disk. | |
1290 | + */ | |
1291 | + agentId?: string | |
1292 | +} | |
1293 | + | |
1294 | +export function getSessionCronTasks(): SessionCronTask[] { | |
1295 | + return STATE.sessionCronTasks | |
1296 | +} | |
1297 | + | |
1298 | +export function addSessionCronTask(task: SessionCronTask): void { | |
1299 | + STATE.sessionCronTasks.push(task) | |
1300 | +} | |
1301 | + | |
1302 | +/** | |
1303 | + * Returns the number of tasks actually removed. Callers use this to skip | |
1304 | + * downstream work (e.g. the disk read in removeCronTasks) when all ids | |
1305 | + * were accounted for here. | |
1306 | + */ | |
1307 | +export function removeSessionCronTasks(ids: readonly string[]): number { | |
1308 | + if (ids.length === 0) return 0 | |
1309 | + const idSet = new Set(ids) | |
1310 | + const remaining = STATE.sessionCronTasks.filter(t => !idSet.has(t.id)) | |
1311 | + const removed = STATE.sessionCronTasks.length - remaining.length | |
1312 | + if (removed === 0) return 0 | |
1313 | + STATE.sessionCronTasks = remaining | |
1314 | + return removed | |
1315 | +} | |
1316 | + | |
1317 | +export function setSessionTrustAccepted(accepted: boolean): void { | |
1318 | + STATE.sessionTrustAccepted = accepted | |
1319 | +} | |
1320 | + | |
1321 | +export function getSessionTrustAccepted(): boolean { | |
1322 | + return STATE.sessionTrustAccepted | |
1323 | +} | |
1324 | + | |
1325 | +export function setSessionPersistenceDisabled(disabled: boolean): void { | |
1326 | + STATE.sessionPersistenceDisabled = disabled | |
1327 | +} | |
1328 | + | |
1329 | +export function isSessionPersistenceDisabled(): boolean { | |
1330 | + return STATE.sessionPersistenceDisabled | |
1331 | +} | |
1332 | + | |
1333 | +export function hasExitedPlanModeInSession(): boolean { | |
1334 | + return STATE.hasExitedPlanMode | |
1335 | +} | |
1336 | + | |
1337 | +export function setHasExitedPlanMode(value: boolean): void { | |
1338 | + STATE.hasExitedPlanMode = value | |
1339 | +} | |
1340 | + | |
1341 | +export function needsPlanModeExitAttachment(): boolean { | |
1342 | + return STATE.needsPlanModeExitAttachment | |
1343 | +} | |
1344 | + | |
1345 | +export function setNeedsPlanModeExitAttachment(value: boolean): void { | |
1346 | + STATE.needsPlanModeExitAttachment = value | |
1347 | +} | |
1348 | + | |
1349 | +export function handlePlanModeTransition( | |
1350 | + fromMode: string, | |
1351 | + toMode: string, | |
1352 | +): void { | |
1353 | + // If switching TO plan mode, clear any pending exit attachment | |
1354 | + // This prevents sending both plan_mode and plan_mode_exit when user toggles quickly | |
1355 | + if (toMode === 'plan' && fromMode !== 'plan') { | |
1356 | + STATE.needsPlanModeExitAttachment = false | |
1357 | + } | |
1358 | + | |
1359 | + // If switching out of plan mode, trigger the plan_mode_exit attachment | |
1360 | + if (fromMode === 'plan' && toMode !== 'plan') { | |
1361 | + STATE.needsPlanModeExitAttachment = true | |
1362 | + } | |
1363 | +} | |
1364 | + | |
1365 | +export function needsAutoModeExitAttachment(): boolean { | |
1366 | + return STATE.needsAutoModeExitAttachment | |
1367 | +} | |
1368 | + | |
1369 | +export function setNeedsAutoModeExitAttachment(value: boolean): void { | |
1370 | + STATE.needsAutoModeExitAttachment = value | |
1371 | +} | |
1372 | + | |
1373 | +export function handleAutoModeTransition( | |
1374 | + fromMode: string, | |
1375 | + toMode: string, | |
1376 | +): void { | |
1377 | + // Auto↔plan transitions are handled by prepareContextForPlanMode (auto may | |
1378 | + // stay active through plan if opted in) and ExitPlanMode (restores mode). | |
1379 | + // Skip both directions so this function only handles direct auto transitions. | |
1380 | + if ( | |
1381 | + (fromMode === 'auto' && toMode === 'plan') || | |
1382 | + (fromMode === 'plan' && toMode === 'auto') | |
1383 | + ) { | |
1384 | + return | |
1385 | + } | |
1386 | + const fromIsAuto = fromMode === 'auto' | |
1387 | + const toIsAuto = toMode === 'auto' | |
1388 | + | |
1389 | + // If switching TO auto mode, clear any pending exit attachment | |
1390 | + // This prevents sending both auto_mode and auto_mode_exit when user toggles quickly | |
1391 | + if (toIsAuto && !fromIsAuto) { | |
1392 | + STATE.needsAutoModeExitAttachment = false | |
1393 | + } | |
1394 | + | |
1395 | + // If switching out of auto mode, trigger the auto_mode_exit attachment | |
1396 | + if (fromIsAuto && !toIsAuto) { | |
1397 | + STATE.needsAutoModeExitAttachment = true | |
1398 | + } | |
1399 | +} | |
1400 | + | |
1401 | +// LSP plugin recommendation session tracking | |
1402 | +export function hasShownLspRecommendationThisSession(): boolean { | |
1403 | + return STATE.lspRecommendationShownThisSession | |
1404 | +} | |
1405 | + | |
1406 | +export function setLspRecommendationShownThisSession(value: boolean): void { | |
1407 | + STATE.lspRecommendationShownThisSession = value | |
1408 | +} | |
1409 | + | |
1410 | +// SDK init event state | |
1411 | +export function setInitJsonSchema(schema: Record<string, unknown>): void { | |
1412 | + STATE.initJsonSchema = schema | |
1413 | +} | |
1414 | + | |
1415 | +export function getInitJsonSchema(): Record<string, unknown> | null { | |
1416 | + return STATE.initJsonSchema | |
1417 | +} | |
1418 | + | |
1419 | +export function registerHookCallbacks( | |
1420 | + hooks: Partial<Record<HookEvent, RegisteredHookMatcher[]>>, | |
1421 | +): void { | |
1422 | + if (!STATE.registeredHooks) { | |
1423 | + STATE.registeredHooks = {} | |
1424 | + } | |
1425 | + | |
1426 | + // `registerHookCallbacks` may be called multiple times, so we need to merge (not overwrite) | |
1427 | + for (const [event, matchers] of Object.entries(hooks)) { | |
1428 | + const eventKey = event as HookEvent | |
1429 | + if (!STATE.registeredHooks[eventKey]) { | |
1430 | + STATE.registeredHooks[eventKey] = [] | |
1431 | + } | |
1432 | + STATE.registeredHooks[eventKey]!.push(...matchers) | |
1433 | + } | |
1434 | +} | |
1435 | + | |
1436 | +export function getRegisteredHooks(): Partial< | |
1437 | + Record<HookEvent, RegisteredHookMatcher[]> | |
1438 | +> | null { | |
1439 | + return STATE.registeredHooks | |
1440 | +} | |
1441 | + | |
1442 | +export function clearRegisteredHooks(): void { | |
1443 | + STATE.registeredHooks = null | |
1444 | +} | |
1445 | + | |
1446 | +export function clearRegisteredPluginHooks(): void { | |
1447 | + if (!STATE.registeredHooks) { | |
1448 | + return | |
1449 | + } | |
1450 | + | |
1451 | + const filtered: Partial<Record<HookEvent, RegisteredHookMatcher[]>> = {} | |
1452 | + for (const [event, matchers] of Object.entries(STATE.registeredHooks)) { | |
1453 | + // Keep only callback hooks (those without pluginRoot) | |
1454 | + const callbackHooks = matchers.filter(m => !('pluginRoot' in m)) | |
1455 | + if (callbackHooks.length > 0) { | |
1456 | + filtered[event as HookEvent] = callbackHooks | |
1457 | + } | |
1458 | + } | |
1459 | + | |
1460 | + STATE.registeredHooks = Object.keys(filtered).length > 0 ? filtered : null | |
1461 | +} | |
1462 | + | |
1463 | +export function resetSdkInitState(): void { | |
1464 | + STATE.initJsonSchema = null | |
1465 | + STATE.registeredHooks = null | |
1466 | +} | |
1467 | + | |
1468 | +export function getPlanSlugCache(): Map<string, string> { | |
1469 | + return STATE.planSlugCache | |
1470 | +} | |
1471 | + | |
1472 | +export function getSessionCreatedTeams(): Set<string> { | |
1473 | + return STATE.sessionCreatedTeams | |
1474 | +} | |
1475 | + | |
1476 | +// Teleported session tracking for reliability logging | |
1477 | +export function setTeleportedSessionInfo(info: { | |
1478 | + sessionId: string | null | |
1479 | +}): void { | |
1480 | + STATE.teleportedSessionInfo = { | |
1481 | + isTeleported: true, | |
1482 | + hasLoggedFirstMessage: false, | |
1483 | + sessionId: info.sessionId, | |
1484 | + } | |
1485 | +} | |
1486 | + | |
1487 | +export function getTeleportedSessionInfo(): { | |
1488 | + isTeleported: boolean | |
1489 | + hasLoggedFirstMessage: boolean | |
1490 | + sessionId: string | null | |
1491 | +} | null { | |
1492 | + return STATE.teleportedSessionInfo | |
1493 | +} | |
1494 | + | |
1495 | +export function markFirstTeleportMessageLogged(): void { | |
1496 | + if (STATE.teleportedSessionInfo) { | |
1497 | + STATE.teleportedSessionInfo.hasLoggedFirstMessage = true | |
1498 | + } | |
1499 | +} | |
1500 | + | |
1501 | +// Invoked skills tracking for preservation across compaction | |
1502 | +export type InvokedSkillInfo = { | |
1503 | + skillName: string | |
1504 | + skillPath: string | |
1505 | + content: string | |
1506 | + invokedAt: number | |
1507 | + agentId: string | null | |
1508 | +} | |
1509 | + | |
1510 | +export function addInvokedSkill( | |
1511 | + skillName: string, | |
1512 | + skillPath: string, | |
1513 | + content: string, | |
1514 | + agentId: string | null = null, | |
1515 | +): void { | |
1516 | + const key = `${agentId ?? ''}:${skillName}` | |
1517 | + STATE.invokedSkills.set(key, { | |
1518 | + skillName, | |
1519 | + skillPath, | |
1520 | + content, | |
1521 | + invokedAt: Date.now(), | |
1522 | + agentId, | |
1523 | + }) | |
1524 | +} | |
1525 | + | |
1526 | +export function getInvokedSkills(): Map<string, InvokedSkillInfo> { | |
1527 | + return STATE.invokedSkills | |
1528 | +} | |
1529 | + | |
1530 | +export function getInvokedSkillsForAgent( | |
1531 | + agentId: string | undefined | null, | |
1532 | +): Map<string, InvokedSkillInfo> { | |
1533 | + const normalizedId = agentId ?? null | |
1534 | + const filtered = new Map<string, InvokedSkillInfo>() | |
1535 | + for (const [key, skill] of STATE.invokedSkills) { | |
1536 | + if (skill.agentId === normalizedId) { | |
1537 | + filtered.set(key, skill) | |
1538 | + } | |
1539 | + } | |
1540 | + return filtered | |
1541 | +} | |
1542 | + | |
1543 | +export function clearInvokedSkills( | |
1544 | + preservedAgentIds?: ReadonlySet<string>, | |
1545 | +): void { | |
1546 | + if (!preservedAgentIds || preservedAgentIds.size === 0) { | |
1547 | + STATE.invokedSkills.clear() | |
1548 | + return | |
1549 | + } | |
1550 | + for (const [key, skill] of STATE.invokedSkills) { | |
1551 | + if (skill.agentId === null || !preservedAgentIds.has(skill.agentId)) { | |
1552 | + STATE.invokedSkills.delete(key) | |
1553 | + } | |
1554 | + } | |
1555 | +} | |
1556 | + | |
1557 | +export function clearInvokedSkillsForAgent(agentId: string): void { | |
1558 | + for (const [key, skill] of STATE.invokedSkills) { | |
1559 | + if (skill.agentId === agentId) { | |
1560 | + STATE.invokedSkills.delete(key) | |
1561 | + } | |
1562 | + } | |
1563 | +} | |
1564 | + | |
1565 | +// Slow operations tracking for dev bar | |
1566 | +const MAX_SLOW_OPERATIONS = 10 | |
1567 | +const SLOW_OPERATION_TTL_MS = 10000 | |
1568 | + | |
1569 | +export function addSlowOperation(operation: string, durationMs: number): void { | |
1570 | + if (process.env.USER_TYPE !== 'ant') return | |
1571 | + // Skip tracking for editor sessions (user editing a prompt file in $EDITOR) | |
1572 | + // These are intentionally slow since the user is drafting text | |
1573 | + if (operation.includes('exec') && operation.includes('claude-prompt-')) { | |
1574 | + return | |
1575 | + } | |
1576 | + const now = Date.now() | |
1577 | + // Remove stale operations | |
1578 | + STATE.slowOperations = STATE.slowOperations.filter( | |
1579 | + op => now - op.timestamp < SLOW_OPERATION_TTL_MS, | |
1580 | + ) | |
1581 | + // Add new operation | |
1582 | + STATE.slowOperations.push({ operation, durationMs, timestamp: now }) | |
1583 | + // Keep only the most recent operations | |
1584 | + if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) { | |
1585 | + STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS) | |
1586 | + } | |
1587 | +} | |
1588 | + | |
1589 | +const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{ | |
1590 | + operation: string | |
1591 | + durationMs: number | |
1592 | + timestamp: number | |
1593 | +}> = [] | |
1594 | + | |
1595 | +export function getSlowOperations(): ReadonlyArray<{ | |
1596 | + operation: string | |
1597 | + durationMs: number | |
1598 | + timestamp: number | |
1599 | +}> { | |
1600 | + // Most common case: nothing tracked. Return a stable reference so the | |
1601 | + // caller's setState() can bail via Object.is instead of re-rendering at 2fps. | |
1602 | + if (STATE.slowOperations.length === 0) { | |
1603 | + return EMPTY_SLOW_OPERATIONS | |
1604 | + } | |
1605 | + const now = Date.now() | |
1606 | + // Only allocate a new array when something actually expired; otherwise keep | |
1607 | + // the reference stable across polls while ops are still fresh. | |
1608 | + if ( | |
1609 | + STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS) | |
1610 | + ) { | |
1611 | + STATE.slowOperations = STATE.slowOperations.filter( | |
1612 | + op => now - op.timestamp < SLOW_OPERATION_TTL_MS, | |
1613 | + ) | |
1614 | + if (STATE.slowOperations.length === 0) { | |
1615 | + return EMPTY_SLOW_OPERATIONS | |
1616 | + } | |
1617 | + } | |
1618 | + // Safe to return directly: addSlowOperation() reassigns STATE.slowOperations | |
1619 | + // before pushing, so the array held in React state is never mutated. | |
1620 | + return STATE.slowOperations | |
1621 | +} | |
1622 | + | |
1623 | +export function getMainThreadAgentType(): string | undefined { | |
1624 | + return STATE.mainThreadAgentType | |
1625 | +} | |
1626 | + | |
1627 | +export function setMainThreadAgentType(agentType: string | undefined): void { | |
1628 | + STATE.mainThreadAgentType = agentType | |
1629 | +} | |
1630 | + | |
1631 | +export function getIsRemoteMode(): boolean { | |
1632 | + return STATE.isRemoteMode | |
1633 | +} | |
1634 | + | |
1635 | +export function setIsRemoteMode(value: boolean): void { | |
1636 | + STATE.isRemoteMode = value | |
1637 | +} | |
1638 | + | |
1639 | +// System prompt section accessors | |
1640 | + | |
1641 | +export function getSystemPromptSectionCache(): Map<string, string | null> { | |
1642 | + return STATE.systemPromptSectionCache | |
1643 | +} | |
1644 | + | |
1645 | +export function setSystemPromptSectionCacheEntry( | |
1646 | + name: string, | |
1647 | + value: string | null, | |
1648 | +): void { | |
1649 | + STATE.systemPromptSectionCache.set(name, value) | |
1650 | +} | |
1651 | + | |
1652 | +export function clearSystemPromptSectionState(): void { | |
1653 | + STATE.systemPromptSectionCache.clear() | |
1654 | +} | |
1655 | + | |
1656 | +// Last emitted date accessors (for detecting midnight date changes) | |
1657 | + | |
1658 | +export function getLastEmittedDate(): string | null { | |
1659 | + return STATE.lastEmittedDate | |
1660 | +} | |
1661 | + | |
1662 | +export function setLastEmittedDate(date: string | null): void { | |
1663 | + STATE.lastEmittedDate = date | |
1664 | +} | |
1665 | + | |
1666 | +export function getAdditionalDirectoriesForClaudeMd(): string[] { | |
1667 | + return STATE.additionalDirectoriesForClaudeMd | |
1668 | +} | |
1669 | + | |
1670 | +export function setAdditionalDirectoriesForClaudeMd( | |
1671 | + directories: string[], | |
1672 | +): void { | |
1673 | + STATE.additionalDirectoriesForClaudeMd = directories | |
1674 | +} | |
1675 | + | |
1676 | +export function getAllowedChannels(): ChannelEntry[] { | |
1677 | + return STATE.allowedChannels | |
1678 | +} | |
1679 | + | |
1680 | +export function setAllowedChannels(entries: ChannelEntry[]): void { | |
1681 | + STATE.allowedChannels = entries | |
1682 | +} | |
1683 | + | |
1684 | +export function getHasDevChannels(): boolean { | |
1685 | + return STATE.hasDevChannels | |
1686 | +} | |
1687 | + | |
1688 | +export function setHasDevChannels(value: boolean): void { | |
1689 | + STATE.hasDevChannels = value | |
1690 | +} | |
1691 | + | |
1692 | +export function getPromptCache1hAllowlist(): string[] | null { | |
1693 | + return STATE.promptCache1hAllowlist | |
1694 | +} | |
1695 | + | |
1696 | +export function setPromptCache1hAllowlist(allowlist: string[] | null): void { | |
1697 | + STATE.promptCache1hAllowlist = allowlist | |
1698 | +} | |
1699 | + | |
1700 | +export function getPromptCache1hEligible(): boolean | null { | |
1701 | + return STATE.promptCache1hEligible | |
1702 | +} | |
1703 | + | |
1704 | +export function setPromptCache1hEligible(eligible: boolean | null): void { | |
1705 | + STATE.promptCache1hEligible = eligible | |
1706 | +} | |
1707 | + | |
1708 | +export function getAfkModeHeaderLatched(): boolean | null { | |
1709 | + return STATE.afkModeHeaderLatched | |
1710 | +} | |
1711 | + | |
1712 | +export function setAfkModeHeaderLatched(v: boolean): void { | |
1713 | + STATE.afkModeHeaderLatched = v | |
1714 | +} | |
1715 | + | |
1716 | +export function getFastModeHeaderLatched(): boolean | null { | |
1717 | + return STATE.fastModeHeaderLatched | |
1718 | +} | |
1719 | + | |
1720 | +export function setFastModeHeaderLatched(v: boolean): void { | |
1721 | + STATE.fastModeHeaderLatched = v | |
1722 | +} | |
1723 | + | |
1724 | +export function getCacheEditingHeaderLatched(): boolean | null { | |
1725 | + return STATE.cacheEditingHeaderLatched | |
1726 | +} | |
1727 | + | |
1728 | +export function setCacheEditingHeaderLatched(v: boolean): void { | |
1729 | + STATE.cacheEditingHeaderLatched = v | |
1730 | +} | |
1731 | + | |
1732 | +export function getThinkingClearLatched(): boolean | null { | |
1733 | + return STATE.thinkingClearLatched | |
1734 | +} | |
1735 | + | |
1736 | +export function setThinkingClearLatched(v: boolean): void { | |
1737 | + STATE.thinkingClearLatched = v | |
1738 | +} | |
1739 | + | |
1740 | +/** | |
1741 | + * Reset beta header latches to null. Called on /clear and /compact so a | |
1742 | + * fresh conversation gets fresh header evaluation. | |
1743 | + */ | |
1744 | +export function clearBetaHeaderLatches(): void { | |
1745 | + STATE.afkModeHeaderLatched = null | |
1746 | + STATE.fastModeHeaderLatched = null | |
1747 | + STATE.cacheEditingHeaderLatched = null | |
1748 | + STATE.thinkingClearLatched = null | |
1749 | +} | |
1750 | + | |
1751 | +export function getPromptId(): string | null { | |
1752 | + return STATE.promptId | |
1753 | +} | |
1754 | + | |
1755 | +export function setPromptId(id: string | null): void { | |
1756 | + STATE.promptId = id | |
1757 | +} | |
1758 | + | |
0 | 1759 | |
▼src/bridge/bridgeApi.ts
+539-0
| @@ -0,0 +1,539 @@ | ||
1 | +import axios from 'axios' | |
2 | + | |
3 | +import { debugBody, extractErrorDetail } from './debugUtils.js' | |
4 | +import { | |
5 | + BRIDGE_LOGIN_INSTRUCTION, | |
6 | + type BridgeApiClient, | |
7 | + type BridgeConfig, | |
8 | + type PermissionResponseEvent, | |
9 | + type WorkResponse, | |
10 | +} from './types.js' | |
11 | + | |
12 | +type BridgeApiDeps = { | |
13 | + baseUrl: string | |
14 | + getAccessToken: () => string | undefined | |
15 | + runnerVersion: string | |
16 | + onDebug?: (msg: string) => void | |
17 | + /** | |
18 | + * Called on 401 to attempt OAuth token refresh. Returns true if refreshed, | |
19 | + * in which case the request is retried once. Injected because | |
20 | + * handleOAuth401Error from utils/auth.ts transitively pulls in config.ts → | |
21 | + * file.ts → permissions/filesystem.ts → sessionStorage.ts → commands.ts | |
22 | + * (~1300 modules). Daemon callers using env-var tokens omit this — their | |
23 | + * tokens don't refresh, so 401 goes straight to BridgeFatalError. | |
24 | + */ | |
25 | + onAuth401?: (staleAccessToken: string) => Promise<boolean> | |
26 | + /** | |
27 | + * Returns the trusted device token to send as X-Trusted-Device-Token on | |
28 | + * bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the | |
29 | + * server (CCR v2); when the server's enforcement flag is on, | |
30 | + * ConnectBridgeWorker requires a trusted device at JWT-issuance. | |
31 | + * Optional — when absent or returning undefined, the header is omitted | |
32 | + * and the server falls through to its flag-off/no-op path. The CLI-side | |
33 | + * gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts). | |
34 | + */ | |
35 | + getTrustedDeviceToken?: () => string | undefined | |
36 | +} | |
37 | + | |
38 | +const BETA_HEADER = 'environments-2025-11-01' | |
39 | + | |
40 | +/** Allowlist pattern for server-provided IDs used in URL path segments. */ | |
41 | +const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/ | |
42 | + | |
43 | +/** | |
44 | + * Validate that a server-provided ID is safe to interpolate into a URL path. | |
45 | + * Prevents path traversal (e.g. `../../admin`) and injection via IDs that | |
46 | + * contain slashes, dots, or other special characters. | |
47 | + */ | |
48 | +export function validateBridgeId(id: string, label: string): string { | |
49 | + if (!id || !SAFE_ID_PATTERN.test(id)) { | |
50 | + throw new Error(`Invalid ${label}: contains unsafe characters`) | |
51 | + } | |
52 | + return id | |
53 | +} | |
54 | + | |
55 | +/** Fatal bridge errors that should not be retried (e.g. auth failures). */ | |
56 | +export class BridgeFatalError extends Error { | |
57 | + readonly status: number | |
58 | + /** Server-provided error type, e.g. "environment_expired". */ | |
59 | + readonly errorType: string | undefined | |
60 | + constructor(message: string, status: number, errorType?: string) { | |
61 | + super(message) | |
62 | + this.name = 'BridgeFatalError' | |
63 | + this.status = status | |
64 | + this.errorType = errorType | |
65 | + } | |
66 | +} | |
67 | + | |
68 | +export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient { | |
69 | + function debug(msg: string): void { | |
70 | + deps.onDebug?.(msg) | |
71 | + } | |
72 | + | |
73 | + let consecutiveEmptyPolls = 0 | |
74 | + const EMPTY_POLL_LOG_INTERVAL = 100 | |
75 | + | |
76 | + function getHeaders(accessToken: string): Record<string, string> { | |
77 | + const headers: Record<string, string> = { | |
78 | + Authorization: `Bearer ${accessToken}`, | |
79 | + 'Content-Type': 'application/json', | |
80 | + 'anthropic-version': '2023-06-01', | |
81 | + 'anthropic-beta': BETA_HEADER, | |
82 | + 'x-environment-runner-version': deps.runnerVersion, | |
83 | + } | |
84 | + const deviceToken = deps.getTrustedDeviceToken?.() | |
85 | + if (deviceToken) { | |
86 | + headers['X-Trusted-Device-Token'] = deviceToken | |
87 | + } | |
88 | + return headers | |
89 | + } | |
90 | + | |
91 | + function resolveAuth(): string { | |
92 | + const accessToken = deps.getAccessToken() | |
93 | + if (!accessToken) { | |
94 | + throw new Error(BRIDGE_LOGIN_INSTRUCTION) | |
95 | + } | |
96 | + return accessToken | |
97 | + } | |
98 | + | |
99 | + /** | |
100 | + * Execute an OAuth-authenticated request with a single retry on 401. | |
101 | + * On 401, attempts token refresh via handleOAuth401Error (same pattern as | |
102 | + * withRetry.ts for v1/messages). If refresh succeeds, retries the request | |
103 | + * once with the new token. If refresh fails or the retry also returns 401, | |
104 | + * the 401 response is returned for handleErrorStatus to throw BridgeFatalError. | |
105 | + */ | |
106 | + async function withOAuthRetry<T>( | |
107 | + fn: (accessToken: string) => Promise<{ status: number; data: T }>, | |
108 | + context: string, | |
109 | + ): Promise<{ status: number; data: T }> { | |
110 | + const accessToken = resolveAuth() | |
111 | + const response = await fn(accessToken) | |
112 | + | |
113 | + if (response.status !== 401) { | |
114 | + return response | |
115 | + } | |
116 | + | |
117 | + if (!deps.onAuth401) { | |
118 | + debug(`[bridge:api] ${context}: 401 received, no refresh handler`) | |
119 | + return response | |
120 | + } | |
121 | + | |
122 | + // Attempt token refresh — matches the pattern in withRetry.ts | |
123 | + debug(`[bridge:api] ${context}: 401 received, attempting token refresh`) | |
124 | + const refreshed = await deps.onAuth401(accessToken) | |
125 | + if (refreshed) { | |
126 | + debug(`[bridge:api] ${context}: Token refreshed, retrying request`) | |
127 | + const newToken = resolveAuth() | |
128 | + const retryResponse = await fn(newToken) | |
129 | + if (retryResponse.status !== 401) { | |
130 | + return retryResponse | |
131 | + } | |
132 | + debug(`[bridge:api] ${context}: Retry after refresh also got 401`) | |
133 | + } else { | |
134 | + debug(`[bridge:api] ${context}: Token refresh failed`) | |
135 | + } | |
136 | + | |
137 | + // Refresh failed — return 401 for handleErrorStatus to throw | |
138 | + return response | |
139 | + } | |
140 | + | |
141 | + return { | |
142 | + async registerBridgeEnvironment( | |
143 | + config: BridgeConfig, | |
144 | + ): Promise<{ environment_id: string; environment_secret: string }> { | |
145 | + debug( | |
146 | + `[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`, | |
147 | + ) | |
148 | + | |
149 | + const response = await withOAuthRetry( | |
150 | + (token: string) => | |
151 | + axios.post<{ | |
152 | + environment_id: string | |
153 | + environment_secret: string | |
154 | + }>( | |
155 | + `${deps.baseUrl}/v1/environments/bridge`, | |
156 | + { | |
157 | + machine_name: config.machineName, | |
158 | + directory: config.dir, | |
159 | + branch: config.branch, | |
160 | + git_repo_url: config.gitRepoUrl, | |
161 | + // Advertise session capacity so claude.ai/code can show | |
162 | + // "2/4 sessions" badges and only block the picker when | |
163 | + // actually at capacity. Backends that don't yet accept | |
164 | + // this field will silently ignore it. | |
165 | + max_sessions: config.maxSessions, | |
166 | + // worker_type lets claude.ai filter environments by origin | |
167 | + // (e.g. assistant picker only shows assistant-mode workers). | |
168 | + // Desktop cowork app sends "cowork"; we send a distinct value. | |
169 | + metadata: { worker_type: config.workerType }, | |
170 | + // Idempotent re-registration: if we have a backend-issued | |
171 | + // environment_id from a prior session (--session-id resume), | |
172 | + // send it back so the backend reattaches instead of creating | |
173 | + // a new env. The backend may still hand back a fresh ID if | |
174 | + // the old one expired — callers must compare the response. | |
175 | + ...(config.reuseEnvironmentId && { | |
176 | + environment_id: config.reuseEnvironmentId, | |
177 | + }), | |
178 | + }, | |
179 | + { | |
180 | + headers: getHeaders(token), | |
181 | + timeout: 15_000, | |
182 | + validateStatus: status => status < 500, | |
183 | + }, | |
184 | + ), | |
185 | + 'Registration', | |
186 | + ) | |
187 | + | |
188 | + handleErrorStatus(response.status, response.data, 'Registration') | |
189 | + debug( | |
190 | + `[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`, | |
191 | + ) | |
192 | + debug( | |
193 | + `[bridge:api] >>> ${debugBody({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`, | |
194 | + ) | |
195 | + debug(`[bridge:api] <<< ${debugBody(response.data)}`) | |
196 | + return response.data | |
197 | + }, | |
198 | + | |
199 | + async pollForWork( | |
200 | + environmentId: string, | |
201 | + environmentSecret: string, | |
202 | + signal?: AbortSignal, | |
203 | + reclaimOlderThanMs?: number, | |
204 | + ): Promise<WorkResponse | null> { | |
205 | + validateBridgeId(environmentId, 'environmentId') | |
206 | + | |
207 | + // Save and reset so errors break the "consecutive empty" streak. | |
208 | + // Restored below when the response is truly empty. | |
209 | + const prevEmptyPolls = consecutiveEmptyPolls | |
210 | + consecutiveEmptyPolls = 0 | |
211 | + | |
212 | + const response = await axios.get<WorkResponse | null>( | |
213 | + `${deps.baseUrl}/v1/environments/${environmentId}/work/poll`, | |
214 | + { | |
215 | + headers: getHeaders(environmentSecret), | |
216 | + params: | |
217 | + reclaimOlderThanMs !== undefined | |
218 | + ? { reclaim_older_than_ms: reclaimOlderThanMs } | |
219 | + : undefined, | |
220 | + timeout: 10_000, | |
221 | + signal, | |
222 | + validateStatus: status => status < 500, | |
223 | + }, | |
224 | + ) | |
225 | + | |
226 | + handleErrorStatus(response.status, response.data, 'Poll') | |
227 | + | |
228 | + // Empty body or null = no work available | |
229 | + if (!response.data) { | |
230 | + consecutiveEmptyPolls = prevEmptyPolls + 1 | |
231 | + if ( | |
232 | + consecutiveEmptyPolls === 1 || | |
233 | + consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0 | |
234 | + ) { | |
235 | + debug( | |
236 | + `[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`, | |
237 | + ) | |
238 | + } | |
239 | + return null | |
240 | + } | |
241 | + | |
242 | + debug( | |
243 | + `[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`, | |
244 | + ) | |
245 | + debug(`[bridge:api] <<< ${debugBody(response.data)}`) | |
246 | + return response.data | |
247 | + }, | |
248 | + | |
249 | + async acknowledgeWork( | |
250 | + environmentId: string, | |
251 | + workId: string, | |
252 | + sessionToken: string, | |
253 | + ): Promise<void> { | |
254 | + validateBridgeId(environmentId, 'environmentId') | |
255 | + validateBridgeId(workId, 'workId') | |
256 | + | |
257 | + debug(`[bridge:api] POST .../work/${workId}/ack`) | |
258 | + | |
259 | + const response = await axios.post( | |
260 | + `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`, | |
261 | + {}, | |
262 | + { | |
263 | + headers: getHeaders(sessionToken), | |
264 | + timeout: 10_000, | |
265 | + validateStatus: s => s < 500, | |
266 | + }, | |
267 | + ) | |
268 | + | |
269 | + handleErrorStatus(response.status, response.data, 'Acknowledge') | |
270 | + debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`) | |
271 | + }, | |
272 | + | |
273 | + async stopWork( | |
274 | + environmentId: string, | |
275 | + workId: string, | |
276 | + force: boolean, | |
277 | + ): Promise<void> { | |
278 | + validateBridgeId(environmentId, 'environmentId') | |
279 | + validateBridgeId(workId, 'workId') | |
280 | + | |
281 | + debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`) | |
282 | + | |
283 | + const response = await withOAuthRetry( | |
284 | + (token: string) => | |
285 | + axios.post( | |
286 | + `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`, | |
287 | + { force }, | |
288 | + { | |
289 | + headers: getHeaders(token), | |
290 | + timeout: 10_000, | |
291 | + validateStatus: s => s < 500, | |
292 | + }, | |
293 | + ), | |
294 | + 'StopWork', | |
295 | + ) | |
296 | + | |
297 | + handleErrorStatus(response.status, response.data, 'StopWork') | |
298 | + debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`) | |
299 | + }, | |
300 | + | |
301 | + async deregisterEnvironment(environmentId: string): Promise<void> { | |
302 | + validateBridgeId(environmentId, 'environmentId') | |
303 | + | |
304 | + debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`) | |
305 | + | |
306 | + const response = await withOAuthRetry( | |
307 | + (token: string) => | |
308 | + axios.delete( | |
309 | + `${deps.baseUrl}/v1/environments/bridge/${environmentId}`, | |
310 | + { | |
311 | + headers: getHeaders(token), | |
312 | + timeout: 10_000, | |
313 | + validateStatus: s => s < 500, | |
314 | + }, | |
315 | + ), | |
316 | + 'Deregister', | |
317 | + ) | |
318 | + | |
319 | + handleErrorStatus(response.status, response.data, 'Deregister') | |
320 | + debug( | |
321 | + `[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`, | |
322 | + ) | |
323 | + }, | |
324 | + | |
325 | + async archiveSession(sessionId: string): Promise<void> { | |
326 | + validateBridgeId(sessionId, 'sessionId') | |
327 | + | |
328 | + debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`) | |
329 | + | |
330 | + const response = await withOAuthRetry( | |
331 | + (token: string) => | |
332 | + axios.post( | |
333 | + `${deps.baseUrl}/v1/sessions/${sessionId}/archive`, | |
334 | + {}, | |
335 | + { | |
336 | + headers: getHeaders(token), | |
337 | + timeout: 10_000, | |
338 | + validateStatus: s => s < 500, | |
339 | + }, | |
340 | + ), | |
341 | + 'ArchiveSession', | |
342 | + ) | |
343 | + | |
344 | + // 409 = already archived (idempotent, not an error) | |
345 | + if (response.status === 409) { | |
346 | + debug( | |
347 | + `[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`, | |
348 | + ) | |
349 | + return | |
350 | + } | |
351 | + | |
352 | + handleErrorStatus(response.status, response.data, 'ArchiveSession') | |
353 | + debug( | |
354 | + `[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`, | |
355 | + ) | |
356 | + }, | |
357 | + | |
358 | + async reconnectSession( | |
359 | + environmentId: string, | |
360 | + sessionId: string, | |
361 | + ): Promise<void> { | |
362 | + validateBridgeId(environmentId, 'environmentId') | |
363 | + validateBridgeId(sessionId, 'sessionId') | |
364 | + | |
365 | + debug( | |
366 | + `[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`, | |
367 | + ) | |
368 | + | |
369 | + const response = await withOAuthRetry( | |
370 | + (token: string) => | |
371 | + axios.post( | |
372 | + `${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`, | |
373 | + { session_id: sessionId }, | |
374 | + { | |
375 | + headers: getHeaders(token), | |
376 | + timeout: 10_000, | |
377 | + validateStatus: s => s < 500, | |
378 | + }, | |
379 | + ), | |
380 | + 'ReconnectSession', | |
381 | + ) | |
382 | + | |
383 | + handleErrorStatus(response.status, response.data, 'ReconnectSession') | |
384 | + debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`) | |
385 | + }, | |
386 | + | |
387 | + async heartbeatWork( | |
388 | + environmentId: string, | |
389 | + workId: string, | |
390 | + sessionToken: string, | |
391 | + ): Promise<{ lease_extended: boolean; state: string }> { | |
392 | + validateBridgeId(environmentId, 'environmentId') | |
393 | + validateBridgeId(workId, 'workId') | |
394 | + | |
395 | + debug(`[bridge:api] POST .../work/${workId}/heartbeat`) | |
396 | + | |
397 | + const response = await axios.post<{ | |
398 | + lease_extended: boolean | |
399 | + state: string | |
400 | + last_heartbeat: string | |
401 | + ttl_seconds: number | |
402 | + }>( | |
403 | + `${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`, | |
404 | + {}, | |
405 | + { | |
406 | + headers: getHeaders(sessionToken), | |
407 | + timeout: 10_000, | |
408 | + validateStatus: s => s < 500, | |
409 | + }, | |
410 | + ) | |
411 | + | |
412 | + handleErrorStatus(response.status, response.data, 'Heartbeat') | |
413 | + debug( | |
414 | + `[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`, | |
415 | + ) | |
416 | + return response.data | |
417 | + }, | |
418 | + | |
419 | + async sendPermissionResponseEvent( | |
420 | + sessionId: string, | |
421 | + event: PermissionResponseEvent, | |
422 | + sessionToken: string, | |
423 | + ): Promise<void> { | |
424 | + validateBridgeId(sessionId, 'sessionId') | |
425 | + | |
426 | + debug( | |
427 | + `[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`, | |
428 | + ) | |
429 | + | |
430 | + const response = await axios.post( | |
431 | + `${deps.baseUrl}/v1/sessions/${sessionId}/events`, | |
432 | + { events: [event] }, | |
433 | + { | |
434 | + headers: getHeaders(sessionToken), | |
435 | + timeout: 10_000, | |
436 | + validateStatus: s => s < 500, | |
437 | + }, | |
438 | + ) | |
439 | + | |
440 | + handleErrorStatus( | |
441 | + response.status, | |
442 | + response.data, | |
443 | + 'SendPermissionResponseEvent', | |
444 | + ) | |
445 | + debug( | |
446 | + `[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`, | |
447 | + ) | |
448 | + debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`) | |
449 | + debug(`[bridge:api] <<< ${debugBody(response.data)}`) | |
450 | + }, | |
451 | + } | |
452 | +} | |
453 | + | |
454 | +function handleErrorStatus( | |
455 | + status: number, | |
456 | + data: unknown, | |
457 | + context: string, | |
458 | +): void { | |
459 | + if (status === 200 || status === 204) { | |
460 | + return | |
461 | + } | |
462 | + const detail = extractErrorDetail(data) | |
463 | + const errorType = extractErrorTypeFromData(data) | |
464 | + switch (status) { | |
465 | + case 401: | |
466 | + throw new BridgeFatalError( | |
467 | + `${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`, | |
468 | + 401, | |
469 | + errorType, | |
470 | + ) | |
471 | + case 403: | |
472 | + throw new BridgeFatalError( | |
473 | + isExpiredErrorType(errorType) | |
474 | + ? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.' | |
475 | + : `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`, | |
476 | + 403, | |
477 | + errorType, | |
478 | + ) | |
479 | + case 404: | |
480 | + throw new BridgeFatalError( | |
481 | + detail ?? | |
482 | + `${context}: Not found (404). Remote Control may not be available for this organization.`, | |
483 | + 404, | |
484 | + errorType, | |
485 | + ) | |
486 | + case 410: | |
487 | + throw new BridgeFatalError( | |
488 | + detail ?? | |
489 | + 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.', | |
490 | + 410, | |
491 | + errorType ?? 'environment_expired', | |
492 | + ) | |
493 | + case 429: | |
494 | + throw new Error(`${context}: Rate limited (429). Polling too frequently.`) | |
495 | + default: | |
496 | + throw new Error( | |
497 | + `${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`, | |
498 | + ) | |
499 | + } | |
500 | +} | |
501 | + | |
502 | +/** Check whether an error type string indicates a session/environment expiry. */ | |
503 | +export function isExpiredErrorType(errorType: string | undefined): boolean { | |
504 | + if (!errorType) { | |
505 | + return false | |
506 | + } | |
507 | + return errorType.includes('expired') || errorType.includes('lifetime') | |
508 | +} | |
509 | + | |
510 | +/** | |
511 | + * Check whether a BridgeFatalError is a suppressible 403 permission error. | |
512 | + * These are 403 errors for scopes like 'external_poll_sessions' or operations | |
513 | + * like StopWork that fail because the user's role lacks 'environments:manage'. | |
514 | + * They don't affect core functionality and shouldn't be shown to users. | |
515 | + */ | |
516 | +export function isSuppressible403(err: BridgeFatalError): boolean { | |
517 | + if (err.status !== 403) { | |
518 | + return false | |
519 | + } | |
520 | + return ( | |
521 | + err.message.includes('external_poll_sessions') || | |
522 | + err.message.includes('environments:manage') | |
523 | + ) | |
524 | +} | |
525 | + | |
526 | +function extractErrorTypeFromData(data: unknown): string | undefined { | |
527 | + if (data && typeof data === 'object') { | |
528 | + if ( | |
529 | + 'error' in data && | |
530 | + data.error && | |
531 | + typeof data.error === 'object' && | |
532 | + 'type' in data.error && | |
533 | + typeof data.error.type === 'string' | |
534 | + ) { | |
535 | + return data.error.type | |
536 | + } | |
537 | + } | |
538 | + return undefined | |
539 | +} | |
0 | 540 | |
▼src/bridge/bridgeConfig.ts
+48-0
| @@ -0,0 +1,48 @@ | ||
1 | +/** | |
2 | + * Shared bridge auth/URL resolution. Consolidates the ant-only | |
3 | + * CLAUDE_BRIDGE_* dev overrides that were previously copy-pasted across | |
4 | + * a dozen files — inboundAttachments, BriefTool/upload, bridgeMain, | |
5 | + * initReplBridge, remoteBridgeCore, daemon workers, /rename, | |
6 | + * /remote-control. | |
7 | + * | |
8 | + * Two layers: *Override() returns the ant-only env var (or undefined); | |
9 | + * the non-Override versions fall through to the real OAuth store/config. | |
10 | + * Callers that compose with a different auth source (e.g. daemon workers | |
11 | + * using IPC auth) use the Override getters directly. | |
12 | + */ | |
13 | + | |
14 | +import { getOauthConfig } from '../constants/oauth.js' | |
15 | +import { getClaudeAIOAuthTokens } from '../utils/auth.js' | |
16 | + | |
17 | +/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */ | |
18 | +export function getBridgeTokenOverride(): string | undefined { | |
19 | + return ( | |
20 | + (process.env.USER_TYPE === 'ant' && | |
21 | + process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) || | |
22 | + undefined | |
23 | + ) | |
24 | +} | |
25 | + | |
26 | +/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */ | |
27 | +export function getBridgeBaseUrlOverride(): string | undefined { | |
28 | + return ( | |
29 | + (process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) || | |
30 | + undefined | |
31 | + ) | |
32 | +} | |
33 | + | |
34 | +/** | |
35 | + * Access token for bridge API calls: dev override first, then the OAuth | |
36 | + * keychain. Undefined means "not logged in". | |
37 | + */ | |
38 | +export function getBridgeAccessToken(): string | undefined { | |
39 | + return getBridgeTokenOverride() ?? getClaudeAIOAuthTokens()?.accessToken | |
40 | +} | |
41 | + | |
42 | +/** | |
43 | + * Base URL for bridge API calls: dev override first, then the production | |
44 | + * OAuth config. Always returns a URL. | |
45 | + */ | |
46 | +export function getBridgeBaseUrl(): string { | |
47 | + return getBridgeBaseUrlOverride() ?? getOauthConfig().BASE_API_URL | |
48 | +} | |
0 | 49 | |
▼src/bridge/bridgeDebug.ts
+135-0
| @@ -0,0 +1,135 @@ | ||
1 | +import { logForDebugging } from '../utils/debug.js' | |
2 | +import { BridgeFatalError } from './bridgeApi.js' | |
3 | +import type { BridgeApiClient } from './types.js' | |
4 | + | |
5 | +/** | |
6 | + * Ant-only fault injection for manually testing bridge recovery paths. | |
7 | + * | |
8 | + * Real failure modes this targets (BQ 2026-03-12, 7-day window): | |
9 | + * poll 404 not_found_error — 147K sessions/week, dead onEnvironmentLost gate | |
10 | + * ws_closed 1002/1006 — 22K sessions/week, zombie poll after close | |
11 | + * register transient failure — residual: network blips during doReconnect | |
12 | + * | |
13 | + * Usage: /bridge-kick <subcommand> from the REPL while Remote Control is | |
14 | + * connected, then tail debug.log to watch the recovery machinery react. | |
15 | + * | |
16 | + * Module-level state is intentional here: one bridge per REPL process, the | |
17 | + * /bridge-kick slash command has no other way to reach into initBridgeCore's | |
18 | + * closures, and teardown clears the slot. | |
19 | + */ | |
20 | + | |
21 | +/** One-shot fault to inject on the next matching api call. */ | |
22 | +type BridgeFault = { | |
23 | + method: | |
24 | + | 'pollForWork' | |
25 | + | 'registerBridgeEnvironment' | |
26 | + | 'reconnectSession' | |
27 | + | 'heartbeatWork' | |
28 | + /** Fatal errors go through handleErrorStatus → BridgeFatalError. Transient | |
29 | + * errors surface as plain axios rejections (5xx / network). Recovery code | |
30 | + * distinguishes the two: fatal → teardown, transient → retry/backoff. */ | |
31 | + kind: 'fatal' | 'transient' | |
32 | + status: number | |
33 | + errorType?: string | |
34 | + /** Remaining injections. Decremented on consume; removed at 0. */ | |
35 | + count: number | |
36 | +} | |
37 | + | |
38 | +export type BridgeDebugHandle = { | |
39 | + /** Invoke the transport's permanent-close handler directly. Tests the | |
40 | + * ws_closed → reconnectEnvironmentWithSession escalation (#22148). */ | |
41 | + fireClose: (code: number) => void | |
42 | + /** Call reconnectEnvironmentWithSession() — same as SIGUSR2 but | |
43 | + * reachable from the slash command. */ | |
44 | + forceReconnect: () => void | |
45 | + /** Queue a fault for the next N calls to the named api method. */ | |
46 | + injectFault: (fault: BridgeFault) => void | |
47 | + /** Abort the at-capacity sleep so an injected poll fault lands | |
48 | + * immediately instead of up to 10min later. */ | |
49 | + wakePollLoop: () => void | |
50 | + /** env/session IDs for the debug.log grep. */ | |
51 | + describe: () => string | |
52 | +} | |
53 | + | |
54 | +let debugHandle: BridgeDebugHandle | null = null | |
55 | +const faultQueue: BridgeFault[] = [] | |
56 | + | |
57 | +export function registerBridgeDebugHandle(h: BridgeDebugHandle): void { | |
58 | + debugHandle = h | |
59 | +} | |
60 | + | |
61 | +export function clearBridgeDebugHandle(): void { | |
62 | + debugHandle = null | |
63 | + faultQueue.length = 0 | |
64 | +} | |
65 | + | |
66 | +export function getBridgeDebugHandle(): BridgeDebugHandle | null { | |
67 | + return debugHandle | |
68 | +} | |
69 | + | |
70 | +export function injectBridgeFault(fault: BridgeFault): void { | |
71 | + faultQueue.push(fault) | |
72 | + logForDebugging( | |
73 | + `[bridge:debug] Queued fault: ${fault.method} ${fault.kind}/${fault.status}${fault.errorType ? `/${fault.errorType}` : ''} ×${fault.count}`, | |
74 | + ) | |
75 | +} | |
76 | + | |
77 | +/** | |
78 | + * Wrap a BridgeApiClient so each call first checks the fault queue. If a | |
79 | + * matching fault is queued, throw the specified error instead of calling | |
80 | + * through. Delegates everything else to the real client. | |
81 | + * | |
82 | + * Only called when USER_TYPE === 'ant' — zero overhead in external builds. | |
83 | + */ | |
84 | +export function wrapApiForFaultInjection( | |
85 | + api: BridgeApiClient, | |
86 | +): BridgeApiClient { | |
87 | + function consume(method: BridgeFault['method']): BridgeFault | null { | |
88 | + const idx = faultQueue.findIndex(f => f.method === method) | |
89 | + if (idx === -1) return null | |
90 | + const fault = faultQueue[idx]! | |
91 | + fault.count-- | |
92 | + if (fault.count <= 0) faultQueue.splice(idx, 1) | |
93 | + return fault | |
94 | + } | |
95 | + | |
96 | + function throwFault(fault: BridgeFault, context: string): never { | |
97 | + logForDebugging( | |
98 | + `[bridge:debug] Injecting ${fault.kind} fault into ${context}: status=${fault.status} errorType=${fault.errorType ?? 'none'}`, | |
99 | + ) | |
100 | + if (fault.kind === 'fatal') { | |
101 | + throw new BridgeFatalError( | |
102 | + `[injected] ${context} ${fault.status}`, | |
103 | + fault.status, | |
104 | + fault.errorType, | |
105 | + ) | |
106 | + } | |
107 | + // Transient: mimic an axios rejection (5xx / network). No .status on | |
108 | + // the error itself — that's how the catch blocks distinguish. | |
109 | + throw new Error(`[injected transient] ${context} ${fault.status}`) | |
110 | + } | |
111 | + | |
112 | + return { | |
113 | + ...api, | |
114 | + async pollForWork(envId, secret, signal, reclaimMs) { | |
115 | + const f = consume('pollForWork') | |
116 | + if (f) throwFault(f, 'Poll') | |
117 | + return api.pollForWork(envId, secret, signal, reclaimMs) | |
118 | + }, | |
119 | + async registerBridgeEnvironment(config) { | |
120 | + const f = consume('registerBridgeEnvironment') | |
121 | + if (f) throwFault(f, 'Registration') | |
122 | + return api.registerBridgeEnvironment(config) | |
123 | + }, | |
124 | + async reconnectSession(envId, sessionId) { | |
125 | + const f = consume('reconnectSession') | |
126 | + if (f) throwFault(f, 'ReconnectSession') | |
127 | + return api.reconnectSession(envId, sessionId) | |
128 | + }, | |
129 | + async heartbeatWork(envId, workId, token) { | |
130 | + const f = consume('heartbeatWork') | |
131 | + if (f) throwFault(f, 'Heartbeat') | |
132 | + return api.heartbeatWork(envId, workId, token) | |
133 | + }, | |
134 | + } | |
135 | +} | |
0 | 136 | |
▼src/bridge/bridgeEnabled.ts
+202-0
| @@ -0,0 +1,202 @@ | ||
1 | +import { feature } from 'bun:bundle' | |
2 | +import { | |
3 | + checkGate_CACHED_OR_BLOCKING, | |
4 | + getDynamicConfig_CACHED_MAY_BE_STALE, | |
5 | + getFeatureValue_CACHED_MAY_BE_STALE, | |
6 | +} from '../services/analytics/growthbook.js' | |
7 | +// Namespace import breaks the bridgeEnabled → auth → config → bridgeEnabled | |
8 | +// cycle — authModule.foo is a live binding, so by the time the helpers below | |
9 | +// call it, auth.js is fully loaded. Previously used require() for the same | |
10 | +// deferral, but require() hits a CJS cache that diverges from the ESM | |
11 | +// namespace after mock.module() (daemon/auth.test.ts), breaking spyOn. | |
12 | +import * as authModule from '../utils/auth.js' | |
13 | +import { isEnvTruthy } from '../utils/envUtils.js' | |
14 | +import { lt } from '../utils/semver.js' | |
15 | + | |
16 | +/** | |
17 | + * Runtime check for bridge mode entitlement. | |
18 | + * | |
19 | + * Remote Control requires a claude.ai subscription (the bridge auths to CCR | |
20 | + * with the claude.ai OAuth token). isClaudeAISubscriber() excludes | |
21 | + * Bedrock/Vertex/Foundry, apiKeyHelper/gateway deployments, env-var API keys, | |
22 | + * and Console API logins — none of which have the OAuth token CCR needs. | |
23 | + * See github.com/deshaw/anthropic-issues/issues/24. | |
24 | + * | |
25 | + * The `feature('BRIDGE_MODE')` guard ensures the GrowthBook string literal | |
26 | + * is only referenced when bridge mode is enabled at build time. | |
27 | + */ | |
28 | +export function isBridgeEnabled(): boolean { | |
29 | + // Positive ternary pattern — see docs/feature-gating.md. | |
30 | + // Negative pattern (if (!feature(...)) return) does not eliminate | |
31 | + // inline string literals from external builds. | |
32 | + return feature('BRIDGE_MODE') | |
33 | + ? isClaudeAISubscriber() && | |
34 | + getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_bridge', false) | |
35 | + : false | |
36 | +} | |
37 | + | |
38 | +/** | |
39 | + * Blocking entitlement check for Remote Control. | |
40 | + * | |
41 | + * Returns cached `true` immediately (fast path). If the disk cache says | |
42 | + * `false` or is missing, awaits GrowthBook init and fetches the fresh | |
43 | + * server value (slow path, max ~5s), then writes it to disk. | |
44 | + * | |
45 | + * Use at entitlement gates where a stale `false` would unfairly block access. | |
46 | + * For user-facing error paths, prefer `getBridgeDisabledReason()` which gives | |
47 | + * a specific diagnostic. For render-body UI visibility checks, use | |
48 | + * `isBridgeEnabled()` instead. | |
49 | + */ | |
50 | +export async function isBridgeEnabledBlocking(): Promise<boolean> { | |
51 | + return feature('BRIDGE_MODE') | |
52 | + ? isClaudeAISubscriber() && | |
53 | + (await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge')) | |
54 | + : false | |
55 | +} | |
56 | + | |
57 | +/** | |
58 | + * Diagnostic message for why Remote Control is unavailable, or null if | |
59 | + * it's enabled. Call this instead of a bare `isBridgeEnabledBlocking()` | |
60 | + * check when you need to show the user an actionable error. | |
61 | + * | |
62 | + * The GrowthBook gate targets on organizationUUID, which comes from | |
63 | + * config.oauthAccount — populated by /api/oauth/profile during login. | |
64 | + * That endpoint requires the user:profile scope. Tokens without it | |
65 | + * (setup-token, CLAUDE_CODE_OAUTH_TOKEN env var, or pre-scope-expansion | |
66 | + * logins) leave oauthAccount unpopulated, so the gate falls back to | |
67 | + * false and users see a dead-end "not enabled" message with no hint | |
68 | + * that re-login would fix it. See CC-1165 / gh-33105. | |
69 | + */ | |
70 | +export async function getBridgeDisabledReason(): Promise<string | null> { | |
71 | + if (feature('BRIDGE_MODE')) { | |
72 | + if (!isClaudeAISubscriber()) { | |
73 | + return 'Remote Control requires browser-based remote-session authentication. Run `/login` to sign in before using Remote Control.' | |
74 | + } | |
75 | + if (!hasProfileScope()) { | |
76 | + return 'Remote Control requires a full-scope browser login token. Long-lived tokens are limited to inference-only for security reasons. Run `/login` to use Remote Control.' | |
77 | + } | |
78 | + if (!getOauthAccountInfo()?.organizationUuid) { | |
79 | + return 'Unable to determine your organization for Remote Control eligibility. Run `/login` to refresh your account information.' | |
80 | + } | |
81 | + if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) { | |
82 | + return 'Remote Control is not yet enabled for your account.' | |
83 | + } | |
84 | + return null | |
85 | + } | |
86 | + return 'Remote Control is not available in this build.' | |
87 | +} | |
88 | + | |
89 | +// try/catch: main.tsx:5698 calls isBridgeEnabled() while defining the Commander | |
90 | +// program, before enableConfigs() runs. isClaudeAISubscriber() → getGlobalConfig() | |
91 | +// throws "Config accessed before allowed" there. Pre-config, no OAuth token can | |
92 | +// exist anyway — false is correct. Same swallow getFeatureValue_CACHED_MAY_BE_STALE | |
93 | +// already does at growthbook.ts:775-780. | |
94 | +function isClaudeAISubscriber(): boolean { | |
95 | + try { | |
96 | + return authModule.isClaudeAISubscriber() | |
97 | + } catch { | |
98 | + return false | |
99 | + } | |
100 | +} | |
101 | +function hasProfileScope(): boolean { | |
102 | + try { | |
103 | + return authModule.hasProfileScope() | |
104 | + } catch { | |
105 | + return false | |
106 | + } | |
107 | +} | |
108 | +function getOauthAccountInfo(): ReturnType< | |
109 | + typeof authModule.getOauthAccountInfo | |
110 | +> { | |
111 | + try { | |
112 | + return authModule.getOauthAccountInfo() | |
113 | + } catch { | |
114 | + return undefined | |
115 | + } | |
116 | +} | |
117 | + | |
118 | +/** | |
119 | + * Runtime check for the env-less (v2) REPL bridge path. | |
120 | + * Returns true when the GrowthBook flag `tengu_bridge_repl_v2` is enabled. | |
121 | + * | |
122 | + * This gates which implementation initReplBridge uses — NOT whether bridge | |
123 | + * is available at all (see isBridgeEnabled above). Daemon/print paths stay | |
124 | + * on the env-based implementation regardless of this gate. | |
125 | + */ | |
126 | +export function isEnvLessBridgeEnabled(): boolean { | |
127 | + return feature('BRIDGE_MODE') | |
128 | + ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_repl_v2', false) | |
129 | + : false | |
130 | +} | |
131 | + | |
132 | +/** | |
133 | + * Kill-switch for the `cse_*` → `session_*` client-side retag shim. | |
134 | + * | |
135 | + * The shim exists because compat/convert.go:27 validates TagSession and the | |
136 | + * claude.ai frontend routes on `session_*`, while v2 worker endpoints hand out | |
137 | + * `cse_*`. Once the server tags by environment_kind and the frontend accepts | |
138 | + * `cse_*` directly, flip this to false to make toCompatSessionId a no-op. | |
139 | + * Defaults to true — the shim stays active until explicitly disabled. | |
140 | + */ | |
141 | +export function isCseShimEnabled(): boolean { | |
142 | + return feature('BRIDGE_MODE') | |
143 | + ? getFeatureValue_CACHED_MAY_BE_STALE( | |
144 | + 'tengu_bridge_repl_v2_cse_shim_enabled', | |
145 | + true, | |
146 | + ) | |
147 | + : true | |
148 | +} | |
149 | + | |
150 | +/** | |
151 | + * Returns an error message if the current CLI version is below the | |
152 | + * minimum required for the v1 (env-based) Remote Control path, or null if the | |
153 | + * version is fine. The v2 (env-less) path uses checkEnvLessBridgeMinVersion() | |
154 | + * in envLessBridgeConfig.ts instead — the two implementations have independent | |
155 | + * version floors. | |
156 | + * | |
157 | + * Uses cached (non-blocking) GrowthBook config. If GrowthBook hasn't | |
158 | + * loaded yet, the default '0.0.0' means the check passes — a safe fallback. | |
159 | + */ | |
160 | +export function checkBridgeMinVersion(): string | null { | |
161 | + // Positive pattern — see docs/feature-gating.md. | |
162 | + // Negative pattern (if (!feature(...)) return) does not eliminate | |
163 | + // inline string literals from external builds. | |
164 | + if (feature('BRIDGE_MODE')) { | |
165 | + const config = getDynamicConfig_CACHED_MAY_BE_STALE<{ | |
166 | + minVersion: string | |
167 | + }>('tengu_bridge_min_version', { minVersion: '0.0.0' }) | |
168 | + if (config.minVersion && lt(MACRO.VERSION, config.minVersion)) { | |
169 | + return `Your version of Mervelas (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${config.minVersion} or higher is required. Run \`claude update\` to update.` | |
170 | + } | |
171 | + } | |
172 | + return null | |
173 | +} | |
174 | + | |
175 | +/** | |
176 | + * Default for remoteControlAtStartup when the user hasn't explicitly set it. | |
177 | + * When the CCR_AUTO_CONNECT build flag is present (ant-only) and the | |
178 | + * tengu_cobalt_harbor GrowthBook gate is on, all sessions connect to CCR by | |
179 | + * default — the user can still opt out by setting remoteControlAtStartup=false | |
180 | + * in config (explicit settings always win over this default). | |
181 | + * | |
182 | + * Defined here rather than in config.ts to avoid a direct | |
183 | + * config.ts → growthbook.ts import cycle (growthbook.ts → user.ts → config.ts). | |
184 | + */ | |
185 | +export function getCcrAutoConnectDefault(): boolean { | |
186 | + return feature('CCR_AUTO_CONNECT') | |
187 | + ? getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_harbor', false) | |
188 | + : false | |
189 | +} | |
190 | + | |
191 | +/** | |
192 | + * Opt-in CCR mirror mode — every local session spawns an outbound-only | |
193 | + * Remote Control session that receives forwarded events. Separate from | |
194 | + * getCcrAutoConnectDefault (bidirectional Remote Control). Env var wins for | |
195 | + * local opt-in; GrowthBook controls rollout. | |
196 | + */ | |
197 | +export function isCcrMirrorEnabled(): boolean { | |
198 | + return feature('CCR_MIRROR') | |
199 | + ? isEnvTruthy(process.env.CLAUDE_CODE_CCR_MIRROR) || | |
200 | + getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_mirror', false) | |
201 | + : false | |
202 | +} | |
0 | 203 | |
▼src/bridge/bridgeMain.ts
+2999-0
| @@ -0,0 +1,2999 @@ | ||
1 | +import { feature } from 'bun:bundle' | |
2 | +import { randomUUID } from 'crypto' | |
3 | +import { hostname, tmpdir } from 'os' | |
4 | +import { basename, join, resolve } from 'path' | |
5 | +import { getRemoteSessionUrl } from '../constants/product.js' | |
6 | +import { shutdownDatadog } from '../services/analytics/datadog.js' | |
7 | +import { shutdown1PEventLogging } from '../services/analytics/firstPartyEventLogger.js' | |
8 | +import { checkGate_CACHED_OR_BLOCKING } from '../services/analytics/growthbook.js' | |
9 | +import { | |
10 | + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
11 | + logEvent, | |
12 | + logEventAsync, | |
13 | +} from '../services/analytics/index.js' | |
14 | +import { isInBundledMode } from '../utils/bundledMode.js' | |
15 | +import { logForDebugging } from '../utils/debug.js' | |
16 | +import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' | |
17 | +import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js' | |
18 | +import { errorMessage } from '../utils/errors.js' | |
19 | +import { truncateToWidth } from '../utils/format.js' | |
20 | +import { logError } from '../utils/log.js' | |
21 | +import { sleep } from '../utils/sleep.js' | |
22 | +import { createAgentWorktree, removeAgentWorktree } from '../utils/worktree.js' | |
23 | +import { | |
24 | + BridgeFatalError, | |
25 | + createBridgeApiClient, | |
26 | + isExpiredErrorType, | |
27 | + isSuppressible403, | |
28 | + validateBridgeId, | |
29 | +} from './bridgeApi.js' | |
30 | +import { formatDuration } from './bridgeStatusUtil.js' | |
31 | +import { createBridgeLogger } from './bridgeUI.js' | |
32 | +import { createCapacityWake } from './capacityWake.js' | |
33 | +import { describeAxiosError } from './debugUtils.js' | |
34 | +import { createTokenRefreshScheduler } from './jwtUtils.js' | |
35 | +import { getPollIntervalConfig } from './pollConfig.js' | |
36 | +import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js' | |
37 | +import { createSessionSpawner, safeFilenameId } from './sessionRunner.js' | |
38 | +import { getTrustedDeviceToken } from './trustedDevice.js' | |
39 | +import { | |
40 | + BRIDGE_LOGIN_ERROR, | |
41 | + type BridgeApiClient, | |
42 | + type BridgeConfig, | |
43 | + type BridgeLogger, | |
44 | + DEFAULT_SESSION_TIMEOUT_MS, | |
45 | + type SessionDoneStatus, | |
46 | + type SessionHandle, | |
47 | + type SessionSpawner, | |
48 | + type SessionSpawnOpts, | |
49 | + type SpawnMode, | |
50 | +} from './types.js' | |
51 | +import { | |
52 | + buildCCRv2SdkUrl, | |
53 | + buildSdkUrl, | |
54 | + decodeWorkSecret, | |
55 | + registerWorker, | |
56 | + sameSessionId, | |
57 | +} from './workSecret.js' | |
58 | + | |
59 | +export type BackoffConfig = { | |
60 | + connInitialMs: number | |
61 | + connCapMs: number | |
62 | + connGiveUpMs: number | |
63 | + generalInitialMs: number | |
64 | + generalCapMs: number | |
65 | + generalGiveUpMs: number | |
66 | + /** SIGTERM→SIGKILL grace period on shutdown. Default 30s. */ | |
67 | + shutdownGraceMs?: number | |
68 | + /** stopWorkWithRetry base delay (1s/2s/4s backoff). Default 1000ms. */ | |
69 | + stopWorkBaseDelayMs?: number | |
70 | +} | |
71 | + | |
72 | +const DEFAULT_BACKOFF: BackoffConfig = { | |
73 | + connInitialMs: 2_000, | |
74 | + connCapMs: 120_000, // 2 minutes | |
75 | + connGiveUpMs: 600_000, // 10 minutes | |
76 | + generalInitialMs: 500, | |
77 | + generalCapMs: 30_000, | |
78 | + generalGiveUpMs: 600_000, // 10 minutes | |
79 | +} | |
80 | + | |
81 | +/** Status update interval for the live display (ms). */ | |
82 | +const STATUS_UPDATE_INTERVAL_MS = 1_000 | |
83 | +const SPAWN_SESSIONS_DEFAULT = 32 | |
84 | + | |
85 | +/** | |
86 | + * GrowthBook gate for multi-session spawn modes (--spawn / --capacity / --create-session-in-dir). | |
87 | + * Sibling of tengu_ccr_bridge_multi_environment (multiple envs per host:dir) — | |
88 | + * this one enables multiple sessions per environment. | |
89 | + * Rollout staged via targeting rules: ants first, then gradual external. | |
90 | + * | |
91 | + * Uses the blocking gate check so a stale disk-cache miss doesn't unfairly | |
92 | + * deny access. The fast path (cache has true) is still instant; only the | |
93 | + * cold-start path awaits the server fetch, and that fetch also seeds the | |
94 | + * disk cache for next time. | |
95 | + */ | |
96 | +async function isMultiSessionSpawnEnabled(): Promise<boolean> { | |
97 | + return checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge_multi_session') | |
98 | +} | |
99 | + | |
100 | +/** | |
101 | + * Returns the threshold for detecting system sleep/wake in the poll loop. | |
102 | + * Must exceed the max backoff cap — otherwise normal backoff delays trigger | |
103 | + * false sleep detection (resetting the error budget indefinitely). Using | |
104 | + * 2× the connection backoff cap, matching the pattern in WebSocketTransport | |
105 | + * and replBridge. | |
106 | + */ | |
107 | +function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number { | |
108 | + return backoff.connCapMs * 2 | |
109 | +} | |
110 | + | |
111 | +/** | |
112 | + * Returns the args that must precede CLI flags when spawning a child claude | |
113 | + * process. In compiled binaries, process.execPath is the claude binary itself | |
114 | + * and args go directly to it. In npm installs (node running cli.js), | |
115 | + * process.execPath is the node runtime — the child spawn must pass the script | |
116 | + * path as the first arg, otherwise node interprets --sdk-url as a node option | |
117 | + * and exits with "bad option: --sdk-url". See anthropics/claude-code#28334. | |
118 | + */ | |
119 | +function spawnScriptArgs(): string[] { | |
120 | + if (isInBundledMode() || !process.argv[1]) { | |
121 | + return [] | |
122 | + } | |
123 | + return [process.argv[1]] | |
124 | +} | |
125 | + | |
126 | +/** Attempt to spawn a session; returns error string if spawn throws. */ | |
127 | +function safeSpawn( | |
128 | + spawner: SessionSpawner, | |
129 | + opts: SessionSpawnOpts, | |
130 | + dir: string, | |
131 | +): SessionHandle | string { | |
132 | + try { | |
133 | + return spawner.spawn(opts, dir) | |
134 | + } catch (err) { | |
135 | + const errMsg = errorMessage(err) | |
136 | + logError(new Error(`Session spawn failed: ${errMsg}`)) | |
137 | + return errMsg | |
138 | + } | |
139 | +} | |
140 | + | |
141 | +export async function runBridgeLoop( | |
142 | + config: BridgeConfig, | |
143 | + environmentId: string, | |
144 | + environmentSecret: string, | |
145 | + api: BridgeApiClient, | |
146 | + spawner: SessionSpawner, | |
147 | + logger: BridgeLogger, | |
148 | + signal: AbortSignal, | |
149 | + backoffConfig: BackoffConfig = DEFAULT_BACKOFF, | |
150 | + initialSessionId?: string, | |
151 | + getAccessToken?: () => string | undefined | Promise<string | undefined>, | |
152 | +): Promise<void> { | |
153 | + // Local abort controller so that onSessionDone can stop the poll loop. | |
154 | + // Linked to the incoming signal so external aborts also work. | |
155 | + const controller = new AbortController() | |
156 | + if (signal.aborted) { | |
157 | + controller.abort() | |
158 | + } else { | |
159 | + signal.addEventListener('abort', () => controller.abort(), { once: true }) | |
160 | + } | |
161 | + const loopSignal = controller.signal | |
162 | + | |
163 | + const activeSessions = new Map<string, SessionHandle>() | |
164 | + const sessionStartTimes = new Map<string, number>() | |
165 | + const sessionWorkIds = new Map<string, string>() | |
166 | + // Compat-surface ID (session_*) computed once at spawn and cached so | |
167 | + // cleanup and status-update ticks use the same key regardless of whether | |
168 | + // the tengu_bridge_repl_v2_cse_shim_enabled gate flips mid-session. | |
169 | + const sessionCompatIds = new Map<string, string>() | |
170 | + // Session ingress JWTs for heartbeat auth, keyed by sessionId. | |
171 | + // Stored separately from handle.accessToken because the token refresh | |
172 | + // scheduler overwrites that field with the OAuth token (~3h55m in). | |
173 | + const sessionIngressTokens = new Map<string, string>() | |
174 | + const sessionTimers = new Map<string, ReturnType<typeof setTimeout>>() | |
175 | + const completedWorkIds = new Set<string>() | |
176 | + const sessionWorktrees = new Map< | |
177 | + string, | |
178 | + { | |
179 | + worktreePath: string | |
180 | + worktreeBranch?: string | |
181 | + gitRoot?: string | |
182 | + hookBased?: boolean | |
183 | + } | |
184 | + >() | |
185 | + // Track sessions killed by the timeout watchdog so onSessionDone can | |
186 | + // distinguish them from server-initiated or shutdown interrupts. | |
187 | + const timedOutSessions = new Set<string>() | |
188 | + // Sessions that already have a title (server-set or bridge-derived) so | |
189 | + // onFirstUserMessage doesn't clobber a user-assigned --name / web rename. | |
190 | + // Keyed by compatSessionId to match logger.setSessionTitle's key. | |
191 | + const titledSessions = new Set<string>() | |
192 | + // Signal to wake the at-capacity sleep early when a session completes, | |
193 | + // so the bridge can immediately accept new work. | |
194 | + const capacityWake = createCapacityWake(loopSignal) | |
195 | + | |
196 | + /** | |
197 | + * Heartbeat all active work items. | |
198 | + * Returns 'ok' if at least one heartbeat succeeded, 'auth_failed' if any | |
199 | + * got a 401/403 (JWT expired — re-queued via reconnectSession so the next | |
200 | + * poll delivers fresh work), or 'failed' if all failed for other reasons. | |
201 | + */ | |
202 | + async function heartbeatActiveWorkItems(): Promise< | |
203 | + 'ok' | 'auth_failed' | 'fatal' | 'failed' | |
204 | + > { | |
205 | + let anySuccess = false | |
206 | + let anyFatal = false | |
207 | + const authFailedSessions: string[] = [] | |
208 | + for (const [sessionId] of activeSessions) { | |
209 | + const workId = sessionWorkIds.get(sessionId) | |
210 | + const ingressToken = sessionIngressTokens.get(sessionId) | |
211 | + if (!workId || !ingressToken) { | |
212 | + continue | |
213 | + } | |
214 | + try { | |
215 | + await api.heartbeatWork(environmentId, workId, ingressToken) | |
216 | + anySuccess = true | |
217 | + } catch (err) { | |
218 | + logForDebugging( | |
219 | + `[bridge:heartbeat] Failed for sessionId=${sessionId} workId=${workId}: ${errorMessage(err)}`, | |
220 | + ) | |
221 | + if (err instanceof BridgeFatalError) { | |
222 | + logEvent('tengu_bridge_heartbeat_error', { | |
223 | + status: | |
224 | + err.status as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
225 | + error_type: (err.status === 401 || err.status === 403 | |
226 | + ? 'auth_failed' | |
227 | + : 'fatal') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
228 | + }) | |
229 | + if (err.status === 401 || err.status === 403) { | |
230 | + authFailedSessions.push(sessionId) | |
231 | + } else { | |
232 | + // 404/410 = environment expired or deleted — no point retrying | |
233 | + anyFatal = true | |
234 | + } | |
235 | + } | |
236 | + } | |
237 | + } | |
238 | + // JWT expired → trigger server-side re-dispatch. Without this, work stays | |
239 | + // ACK'd out of the Redis PEL and poll returns empty forever (CC-1263). | |
240 | + // The existingHandle path below delivers the fresh token to the child. | |
241 | + // sessionId is already in the format /bridge/reconnect expects: it comes | |
242 | + // from work.data.id, which matches the server's EnvironmentInstance store | |
243 | + // (cse_* under the compat gate, session_* otherwise). | |
244 | + for (const sessionId of authFailedSessions) { | |
245 | + logger.logVerbose( | |
246 | + `Session ${sessionId} token expired — re-queuing via bridge/reconnect`, | |
247 | + ) | |
248 | + try { | |
249 | + await api.reconnectSession(environmentId, sessionId) | |
250 | + logForDebugging( | |
251 | + `[bridge:heartbeat] Re-queued sessionId=${sessionId} via bridge/reconnect`, | |
252 | + ) | |
253 | + } catch (err) { | |
254 | + logger.logError( | |
255 | + `Failed to refresh session ${sessionId} token: ${errorMessage(err)}`, | |
256 | + ) | |
257 | + logForDebugging( | |
258 | + `[bridge:heartbeat] reconnectSession(${sessionId}) failed: ${errorMessage(err)}`, | |
259 | + { level: 'error' }, | |
260 | + ) | |
261 | + } | |
262 | + } | |
263 | + if (anyFatal) { | |
264 | + return 'fatal' | |
265 | + } | |
266 | + if (authFailedSessions.length > 0) { | |
267 | + return 'auth_failed' | |
268 | + } | |
269 | + return anySuccess ? 'ok' : 'failed' | |
270 | + } | |
271 | + | |
272 | + // Sessions spawned with CCR v2 env vars. v2 children cannot use OAuth | |
273 | + // tokens (CCR worker endpoints validate the JWT's session_id claim, | |
274 | + // register_worker.go:32), so onRefresh triggers server re-dispatch | |
275 | + // instead — the next poll delivers fresh work with a new JWT via the | |
276 | + // existingHandle path below. | |
277 | + const v2Sessions = new Set<string>() | |
278 | + | |
279 | + // Proactive token refresh: schedules a timer 5min before the session | |
280 | + // ingress JWT expires. v1 delivers OAuth directly; v2 calls | |
281 | + // reconnectSession to trigger server re-dispatch (CC-1263: without | |
282 | + // this, v2 daemon sessions silently die at ~5h since the server does | |
283 | + // not auto-re-dispatch ACK'd work on lease expiry). | |
284 | + const tokenRefresh = getAccessToken | |
285 | + ? createTokenRefreshScheduler({ | |
286 | + getAccessToken, | |
287 | + onRefresh: (sessionId, oauthToken) => { | |
288 | + const handle = activeSessions.get(sessionId) | |
289 | + if (!handle) { | |
290 | + return | |
291 | + } | |
292 | + if (v2Sessions.has(sessionId)) { | |
293 | + logger.logVerbose( | |
294 | + `Refreshing session ${sessionId} token via bridge/reconnect`, | |
295 | + ) | |
296 | + void api | |
297 | + .reconnectSession(environmentId, sessionId) | |
298 | + .catch((err: unknown) => { | |
299 | + logger.logError( | |
300 | + `Failed to refresh session ${sessionId} token: ${errorMessage(err)}`, | |
301 | + ) | |
302 | + logForDebugging( | |
303 | + `[bridge:token] reconnectSession(${sessionId}) failed: ${errorMessage(err)}`, | |
304 | + { level: 'error' }, | |
305 | + ) | |
306 | + }) | |
307 | + } else { | |
308 | + handle.updateAccessToken(oauthToken) | |
309 | + } | |
310 | + }, | |
311 | + label: 'bridge', | |
312 | + }) | |
313 | + : null | |
314 | + const loopStartTime = Date.now() | |
315 | + // Track all in-flight cleanup promises (stopWork, worktree removal) so | |
316 | + // the shutdown sequence can await them before process.exit(). | |
317 | + const pendingCleanups = new Set<Promise<unknown>>() | |
318 | + function trackCleanup(p: Promise<unknown>): void { | |
319 | + pendingCleanups.add(p) | |
320 | + void p.finally(() => pendingCleanups.delete(p)) | |
321 | + } | |
322 | + let connBackoff = 0 | |
323 | + let generalBackoff = 0 | |
324 | + let connErrorStart: number | null = null | |
325 | + let generalErrorStart: number | null = null | |
326 | + let lastPollErrorTime: number | null = null | |
327 | + let statusUpdateTimer: ReturnType<typeof setInterval> | null = null | |
328 | + // Set by BridgeFatalError and give-up paths so the shutdown block can | |
329 | + // skip the resume message (resume is impossible after env expiry/auth | |
330 | + // failure/sustained connection errors). | |
331 | + let fatalExit = false | |
332 | + | |
333 | + logForDebugging( | |
334 | + `[bridge:work] Starting poll loop spawnMode=${config.spawnMode} maxSessions=${config.maxSessions} environmentId=${environmentId}`, | |
335 | + ) | |
336 | + logForDiagnosticsNoPII('info', 'bridge_loop_started', { | |
337 | + max_sessions: config.maxSessions, | |
338 | + spawn_mode: config.spawnMode, | |
339 | + }) | |
340 | + | |
341 | + // For ant users, show where session debug logs will land so they can tail them. | |
342 | + // sessionRunner.ts uses the same base path. File appears once a session spawns. | |
343 | + if (process.env.USER_TYPE === 'ant') { | |
344 | + let debugGlob: string | |
345 | + if (config.debugFile) { | |
346 | + const ext = config.debugFile.lastIndexOf('.') | |
347 | + debugGlob = | |
348 | + ext > 0 | |
349 | + ? `${config.debugFile.slice(0, ext)}-*${config.debugFile.slice(ext)}` | |
350 | + : `${config.debugFile}-*` | |
351 | + } else { | |
352 | + debugGlob = join(tmpdir(), 'claude', 'bridge-session-*.log') | |
353 | + } | |
354 | + logger.setDebugLogPath(debugGlob) | |
355 | + } | |
356 | + | |
357 | + logger.printBanner(config, environmentId) | |
358 | + | |
359 | + // Seed the logger's session count + spawn mode before any render. Without | |
360 | + // this, setAttached() below renders with the logger's default sessionMax=1, | |
361 | + // showing "Capacity: 0/1" until the status ticker kicks in (which is gated | |
362 | + // by !initialSessionId and only starts after the poll loop picks up work). | |
363 | + logger.updateSessionCount(0, config.maxSessions, config.spawnMode) | |
364 | + | |
365 | + // If an initial session was pre-created, show its URL from the start so | |
366 | + // the user can click through immediately (matching /remote-control behavior). | |
367 | + if (initialSessionId) { | |
368 | + logger.setAttached(initialSessionId) | |
369 | + } | |
370 | + | |
371 | + /** Refresh the inline status display. Shows idle or active depending on state. */ | |
372 | + function updateStatusDisplay(): void { | |
373 | + // Push the session count (no-op when maxSessions === 1) so the | |
374 | + // next renderStatusLine tick shows the current count. | |
375 | + logger.updateSessionCount( | |
376 | + activeSessions.size, | |
377 | + config.maxSessions, | |
378 | + config.spawnMode, | |
379 | + ) | |
380 | + | |
381 | + // Push per-session activity into the multi-session display. | |
382 | + for (const [sid, handle] of activeSessions) { | |
383 | + const act = handle.currentActivity | |
384 | + if (act) { | |
385 | + logger.updateSessionActivity(sessionCompatIds.get(sid) ?? sid, act) | |
386 | + } | |
387 | + } | |
388 | + | |
389 | + if (activeSessions.size === 0) { | |
390 | + logger.updateIdleStatus() | |
391 | + return | |
392 | + } | |
393 | + | |
394 | + // Show the most recently started session that is still actively working. | |
395 | + // Sessions whose current activity is 'result' or 'error' are between | |
396 | + // turns — the CLI emitted its result but the process stays alive waiting | |
397 | + // for the next user message. Skip updating so the status line keeps | |
398 | + // whatever state it had (Attached / session title). | |
399 | + const [sessionId, handle] = [...activeSessions.entries()].pop()! | |
400 | + const startTime = sessionStartTimes.get(sessionId) | |
401 | + if (!startTime) return | |
402 | + | |
403 | + const activity = handle.currentActivity | |
404 | + if (!activity || activity.type === 'result' || activity.type === 'error') { | |
405 | + // Session is between turns — keep current status (Attached/titled). | |
406 | + // In multi-session mode, still refresh so bullet-list activities stay current. | |
407 | + if (config.maxSessions > 1) logger.refreshDisplay() | |
408 | + return | |
409 | + } | |
410 | + | |
411 | + const elapsed = formatDuration(Date.now() - startTime) | |
412 | + | |
413 | + // Build trail from recent tool activities (last 5) | |
414 | + const trail = handle.activities | |
415 | + .filter(a => a.type === 'tool_start') | |
416 | + .slice(-5) | |
417 | + .map(a => a.summary) | |
418 | + | |
419 | + logger.updateSessionStatus(sessionId, elapsed, activity, trail) | |
420 | + } | |
421 | + | |
422 | + /** Start the status display update ticker. */ | |
423 | + function startStatusUpdates(): void { | |
424 | + stopStatusUpdates() | |
425 | + // Call immediately so the first transition (e.g. Connecting → Ready) | |
426 | + // happens without delay, avoiding concurrent timer races. | |
427 | + updateStatusDisplay() | |
428 | + statusUpdateTimer = setInterval( | |
429 | + updateStatusDisplay, | |
430 | + STATUS_UPDATE_INTERVAL_MS, | |
431 | + ) | |
432 | + } | |
433 | + | |
434 | + /** Stop the status display update ticker. */ | |
435 | + function stopStatusUpdates(): void { | |
436 | + if (statusUpdateTimer) { | |
437 | + clearInterval(statusUpdateTimer) | |
438 | + statusUpdateTimer = null | |
439 | + } | |
440 | + } | |
441 | + | |
442 | + function onSessionDone( | |
443 | + sessionId: string, | |
444 | + startTime: number, | |
445 | + handle: SessionHandle, | |
446 | + ): (status: SessionDoneStatus) => void { | |
447 | + return (rawStatus: SessionDoneStatus): void => { | |
448 | + const workId = sessionWorkIds.get(sessionId) | |
449 | + activeSessions.delete(sessionId) | |
450 | + sessionStartTimes.delete(sessionId) | |
451 | + sessionWorkIds.delete(sessionId) | |
452 | + sessionIngressTokens.delete(sessionId) | |
453 | + const compatId = sessionCompatIds.get(sessionId) ?? sessionId | |
454 | + sessionCompatIds.delete(sessionId) | |
455 | + logger.removeSession(compatId) | |
456 | + titledSessions.delete(compatId) | |
457 | + v2Sessions.delete(sessionId) | |
458 | + // Clear per-session timeout timer | |
459 | + const timer = sessionTimers.get(sessionId) | |
460 | + if (timer) { | |
461 | + clearTimeout(timer) | |
462 | + sessionTimers.delete(sessionId) | |
463 | + } | |
464 | + // Clear token refresh timer | |
465 | + tokenRefresh?.cancel(sessionId) | |
466 | + // Wake the at-capacity sleep so the bridge can accept new work immediately | |
467 | + capacityWake.wake() | |
468 | + | |
469 | + // If the session was killed by the timeout watchdog, treat it as a | |
470 | + // failed session (not a server/shutdown interrupt) so we still call | |
471 | + // stopWork and archiveSession below. | |
472 | + const wasTimedOut = timedOutSessions.delete(sessionId) | |
473 | + const status: SessionDoneStatus = | |
474 | + wasTimedOut && rawStatus === 'interrupted' ? 'failed' : rawStatus | |
475 | + const durationMs = Date.now() - startTime | |
476 | + | |
477 | + logForDebugging( | |
478 | + `[bridge:session] sessionId=${sessionId} workId=${workId ?? 'unknown'} exited status=${status} duration=${formatDuration(durationMs)}`, | |
479 | + ) | |
480 | + logEvent('tengu_bridge_session_done', { | |
481 | + status: | |
482 | + status as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
483 | + duration_ms: durationMs, | |
484 | + }) | |
485 | + logForDiagnosticsNoPII('info', 'bridge_session_done', { | |
486 | + status, | |
487 | + duration_ms: durationMs, | |
488 | + }) | |
489 | + | |
490 | + // Clear the status display before printing final log | |
491 | + logger.clearStatus() | |
492 | + stopStatusUpdates() | |
493 | + | |
494 | + // Build error message from stderr if available | |
495 | + const stderrSummary = | |
496 | + handle.lastStderr.length > 0 ? handle.lastStderr.join('\n') : undefined | |
497 | + let failureMessage: string | undefined | |
498 | + | |
499 | + switch (status) { | |
500 | + case 'completed': | |
501 | + logger.logSessionComplete(sessionId, durationMs) | |
502 | + break | |
503 | + case 'failed': | |
504 | + // Skip failure log during shutdown — the child exits non-zero when | |
505 | + // killed, which is expected and not a real failure. | |
506 | + // Also skip for timeout-killed sessions — the timeout watchdog | |
507 | + // already logged a clear timeout message. | |
508 | + if (!wasTimedOut && !loopSignal.aborted) { | |
509 | + failureMessage = stderrSummary ?? 'Process exited with error' | |
510 | + logger.logSessionFailed(sessionId, failureMessage) | |
511 | + logError(new Error(`Bridge session failed: ${failureMessage}`)) | |
512 | + } | |
513 | + break | |
514 | + case 'interrupted': | |
515 | + logger.logVerbose(`Session ${sessionId} interrupted`) | |
516 | + break | |
517 | + } | |
518 | + | |
519 | + // Notify the server that this work item is done. Skip for interrupted | |
520 | + // sessions — interrupts are either server-initiated (the server already | |
521 | + // knows) or caused by bridge shutdown (which calls stopWork() separately). | |
522 | + if (status !== 'interrupted' && workId) { | |
523 | + trackCleanup( | |
524 | + stopWorkWithRetry( | |
525 | + api, | |
526 | + environmentId, | |
527 | + workId, | |
528 | + logger, | |
529 | + backoffConfig.stopWorkBaseDelayMs, | |
530 | + ), | |
531 | + ) | |
532 | + completedWorkIds.add(workId) | |
533 | + } | |
534 | + | |
535 | + // Clean up worktree if one was created for this session | |
536 | + const wt = sessionWorktrees.get(sessionId) | |
537 | + if (wt) { | |
538 | + sessionWorktrees.delete(sessionId) | |
539 | + trackCleanup( | |
540 | + removeAgentWorktree( | |
541 | + wt.worktreePath, | |
542 | + wt.worktreeBranch, | |
543 | + wt.gitRoot, | |
544 | + wt.hookBased, | |
545 | + ).catch((err: unknown) => | |
546 | + logger.logVerbose( | |
547 | + `Failed to remove worktree ${wt.worktreePath}: ${errorMessage(err)}`, | |
548 | + ), | |
549 | + ), | |
550 | + ) | |
551 | + } | |
552 | + | |
553 | + // Lifecycle decision: in multi-session mode, keep the bridge running | |
554 | + // after a session completes. In single-session mode, abort the poll | |
555 | + // loop so the bridge exits cleanly. | |
556 | + if (status !== 'interrupted' && !loopSignal.aborted) { | |
557 | + if (config.spawnMode !== 'single-session') { | |
558 | + // Multi-session: archive the completed session so it doesn't linger | |
559 | + // as stale in the web UI. archiveSession is idempotent (409 if already | |
560 | + // archived), so double-archiving at shutdown is safe. | |
561 | + // sessionId arrived as cse_* from the work poll (infrastructure-layer | |
562 | + // tag). archiveSession hits /v1/sessions/{id}/archive which is the | |
563 | + // compat surface and validates TagSession (session_*). Re-tag — same | |
564 | + // UUID underneath. | |
565 | + trackCleanup( | |
566 | + api | |
567 | + .archiveSession(compatId) | |
568 | + .catch((err: unknown) => | |
569 | + logger.logVerbose( | |
570 | + `Failed to archive session ${sessionId}: ${errorMessage(err)}`, | |
571 | + ), | |
572 | + ), | |
573 | + ) | |
574 | + logForDebugging( | |
575 | + `[bridge:session] Session ${status}, returning to idle (multi-session mode)`, | |
576 | + ) | |
577 | + } else { | |
578 | + // Single-session: coupled lifecycle — tear down environment | |
579 | + logForDebugging( | |
580 | + `[bridge:session] Session ${status}, aborting poll loop to tear down environment`, | |
581 | + ) | |
582 | + controller.abort() | |
583 | + return | |
584 | + } | |
585 | + } | |
586 | + | |
587 | + if (!loopSignal.aborted) { | |
588 | + startStatusUpdates() | |
589 | + } | |
590 | + } | |
591 | + } | |
592 | + | |
593 | + // Start the idle status display immediately — unless we have a pre-created | |
594 | + // session, in which case setAttached() already set up the display and the | |
595 | + // poll loop will start status updates when it picks up the session. | |
596 | + if (!initialSessionId) { | |
597 | + startStatusUpdates() | |
598 | + } | |
599 | + | |
600 | + while (!loopSignal.aborted) { | |
601 | + // Fetched once per iteration — the GrowthBook cache refreshes every | |
602 | + // 5 min, so a loop running at the at-capacity rate picks up config | |
603 | + // changes within one sleep cycle. | |
604 | + const pollConfig = getPollIntervalConfig() | |
605 | + | |
606 | + try { | |
607 | + const work = await api.pollForWork( | |
608 | + environmentId, | |
609 | + environmentSecret, | |
610 | + loopSignal, | |
611 | + pollConfig.reclaim_older_than_ms, | |
612 | + ) | |
613 | + | |
614 | + // Log reconnection if we were previously disconnected | |
615 | + const wasDisconnected = | |
616 | + connErrorStart !== null || generalErrorStart !== null | |
617 | + if (wasDisconnected) { | |
618 | + const disconnectedMs = | |
619 | + Date.now() - (connErrorStart ?? generalErrorStart ?? Date.now()) | |
620 | + logger.logReconnected(disconnectedMs) | |
621 | + logForDebugging( | |
622 | + `[bridge:poll] Reconnected after ${formatDuration(disconnectedMs)}`, | |
623 | + ) | |
624 | + logEvent('tengu_bridge_reconnected', { | |
625 | + disconnected_ms: disconnectedMs, | |
626 | + }) | |
627 | + } | |
628 | + | |
629 | + connBackoff = 0 | |
630 | + generalBackoff = 0 | |
631 | + connErrorStart = null | |
632 | + generalErrorStart = null | |
633 | + lastPollErrorTime = null | |
634 | + | |
635 | + // Null response = no work available in the queue. | |
636 | + // Add a minimum delay to avoid hammering the server. | |
637 | + if (!work) { | |
638 | + // Use live check (not a snapshot) since sessions can end during poll. | |
639 | + const atCap = activeSessions.size >= config.maxSessions | |
640 | + if (atCap) { | |
641 | + const atCapMs = pollConfig.multisession_poll_interval_ms_at_capacity | |
642 | + // Heartbeat loops WITHOUT polling. When at-capacity polling is also | |
643 | + // enabled (atCapMs > 0), the loop tracks a deadline and breaks out | |
644 | + // to poll at that interval — heartbeat and poll compose instead of | |
645 | + // one suppressing the other. We break out to poll when: | |
646 | + // - Poll deadline reached (atCapMs > 0 only) | |
647 | + // - Auth fails (JWT expired → poll refreshes tokens) | |
648 | + // - Capacity wake fires (session ended → poll for new work) | |
649 | + // - Loop aborted (shutdown) | |
650 | + if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) { | |
651 | + logEvent('tengu_bridge_heartbeat_mode_entered', { | |
652 | + active_sessions: activeSessions.size, | |
653 | + heartbeat_interval_ms: | |
654 | + pollConfig.non_exclusive_heartbeat_interval_ms, | |
655 | + }) | |
656 | + // Deadline computed once at entry — GB updates to atCapMs don't | |
657 | + // shift an in-flight deadline (next entry picks up the new value). | |
658 | + const pollDeadline = atCapMs > 0 ? Date.now() + atCapMs : null | |
659 | + let hbResult: 'ok' | 'auth_failed' | 'fatal' | 'failed' = 'ok' | |
660 | + let hbCycles = 0 | |
661 | + while ( | |
662 | + !loopSignal.aborted && | |
663 | + activeSessions.size >= config.maxSessions && | |
664 | + (pollDeadline === null || Date.now() < pollDeadline) | |
665 | + ) { | |
666 | + // Re-read config each cycle so GrowthBook updates take effect | |
667 | + const hbConfig = getPollIntervalConfig() | |
668 | + if (hbConfig.non_exclusive_heartbeat_interval_ms <= 0) break | |
669 | + | |
670 | + // Capture capacity signal BEFORE the async heartbeat call so | |
671 | + // a session ending during the HTTP request is caught by the | |
672 | + // subsequent sleep (instead of being lost to a replaced controller). | |
673 | + const cap = capacityWake.signal() | |
674 | + | |
675 | + hbResult = await heartbeatActiveWorkItems() | |
676 | + if (hbResult === 'auth_failed' || hbResult === 'fatal') { | |
677 | + cap.cleanup() | |
678 | + break | |
679 | + } | |
680 | + | |
681 | + hbCycles++ | |
682 | + await sleep( | |
683 | + hbConfig.non_exclusive_heartbeat_interval_ms, | |
684 | + cap.signal, | |
685 | + ) | |
686 | + cap.cleanup() | |
687 | + } | |
688 | + | |
689 | + // Determine exit reason for telemetry | |
690 | + const exitReason = | |
691 | + hbResult === 'auth_failed' || hbResult === 'fatal' | |
692 | + ? hbResult | |
693 | + : loopSignal.aborted | |
694 | + ? 'shutdown' | |
695 | + : activeSessions.size < config.maxSessions | |
696 | + ? 'capacity_changed' | |
697 | + : pollDeadline !== null && Date.now() >= pollDeadline | |
698 | + ? 'poll_due' | |
699 | + : 'config_disabled' | |
700 | + logEvent('tengu_bridge_heartbeat_mode_exited', { | |
701 | + reason: | |
702 | + exitReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
703 | + heartbeat_cycles: hbCycles, | |
704 | + active_sessions: activeSessions.size, | |
705 | + }) | |
706 | + if (exitReason === 'poll_due') { | |
707 | + // bridgeApi throttles empty-poll logs (EMPTY_POLL_LOG_INTERVAL=100) | |
708 | + // so the once-per-10min poll_due poll is invisible at counter=2. | |
709 | + // Log it here so verification runs see both endpoints in the debug log. | |
710 | + logForDebugging( | |
711 | + `[bridge:poll] Heartbeat poll_due after ${hbCycles} cycles — falling through to pollForWork`, | |
712 | + ) | |
713 | + } | |
714 | + | |
715 | + // On auth_failed or fatal, sleep before polling to avoid a tight | |
716 | + // poll+heartbeat loop. Auth_failed: heartbeatActiveWorkItems | |
717 | + // already called reconnectSession — the sleep gives the server | |
718 | + // time to propagate the re-queue. Fatal (404/410): may be a | |
719 | + // single work item GCd while the environment is still valid. | |
720 | + // Use atCapMs if enabled, else the heartbeat interval as a floor | |
721 | + // (guaranteed > 0 here) so heartbeat-only configs don't tight-loop. | |
722 | + if (hbResult === 'auth_failed' || hbResult === 'fatal') { | |
723 | + const cap = capacityWake.signal() | |
724 | + await sleep( | |
725 | + atCapMs > 0 | |
726 | + ? atCapMs | |
727 | + : pollConfig.non_exclusive_heartbeat_interval_ms, | |
728 | + cap.signal, | |
729 | + ) | |
730 | + cap.cleanup() | |
731 | + } | |
732 | + } else if (atCapMs > 0) { | |
733 | + // Heartbeat disabled: slow poll as liveness signal. | |
734 | + const cap = capacityWake.signal() | |
735 | + await sleep(atCapMs, cap.signal) | |
736 | + cap.cleanup() | |
737 | + } | |
738 | + } else { | |
739 | + const interval = | |
740 | + activeSessions.size > 0 | |
741 | + ? pollConfig.multisession_poll_interval_ms_partial_capacity | |
742 | + : pollConfig.multisession_poll_interval_ms_not_at_capacity | |
743 | + await sleep(interval, loopSignal) | |
744 | + } | |
745 | + continue | |
746 | + } | |
747 | + | |
748 | + // At capacity — we polled to keep the heartbeat alive, but cannot | |
749 | + // accept new work right now. We still enter the switch below so that | |
750 | + // token refreshes for existing sessions are processed (the case | |
751 | + // 'session' handler checks for existing sessions before the inner | |
752 | + // capacity guard). | |
753 | + const atCapacityBeforeSwitch = activeSessions.size >= config.maxSessions | |
754 | + | |
755 | + // Skip work items that have already been completed and stopped. | |
756 | + // The server may re-deliver stale work before processing our stop | |
757 | + // request, which would otherwise cause a duplicate session spawn. | |
758 | + if (completedWorkIds.has(work.id)) { | |
759 | + logForDebugging( | |
760 | + `[bridge:work] Skipping already-completed workId=${work.id}`, | |
761 | + ) | |
762 | + // Respect capacity throttle — without a sleep here, persistent stale | |
763 | + // redeliveries would tight-loop at poll-request speed (the !work | |
764 | + // branch above is the only sleep, and work != null skips it). | |
765 | + if (atCapacityBeforeSwitch) { | |
766 | + const cap = capacityWake.signal() | |
767 | + if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) { | |
768 | + await heartbeatActiveWorkItems() | |
769 | + await sleep( | |
770 | + pollConfig.non_exclusive_heartbeat_interval_ms, | |
771 | + cap.signal, | |
772 | + ) | |
773 | + } else if (pollConfig.multisession_poll_interval_ms_at_capacity > 0) { | |
774 | + await sleep( | |
775 | + pollConfig.multisession_poll_interval_ms_at_capacity, | |
776 | + cap.signal, | |
777 | + ) | |
778 | + } | |
779 | + cap.cleanup() | |
780 | + } else { | |
781 | + await sleep(1000, loopSignal) | |
782 | + } | |
783 | + continue | |
784 | + } | |
785 | + | |
786 | + // Decode the work secret for session spawning and to extract the JWT | |
787 | + // used for the ack call below. | |
788 | + let secret | |
789 | + try { | |
790 | + secret = decodeWorkSecret(work.secret) | |
791 | + } catch (err) { | |
792 | + const errMsg = errorMessage(err) | |
793 | + logger.logError( | |
794 | + `Failed to decode work secret for workId=${work.id}: ${errMsg}`, | |
795 | + ) | |
796 | + logEvent('tengu_bridge_work_secret_failed', {}) | |
797 | + // Can't ack (needs the JWT we failed to decode). stopWork uses OAuth, | |
798 | + // so it's callable here — prevents XAUTOCLAIM from re-delivering this | |
799 | + // poisoned item every reclaim_older_than_ms cycle. | |
800 | + completedWorkIds.add(work.id) | |
801 | + trackCleanup( | |
802 | + stopWorkWithRetry( | |
803 | + api, | |
804 | + environmentId, | |
805 | + work.id, | |
806 | + logger, | |
807 | + backoffConfig.stopWorkBaseDelayMs, | |
808 | + ), | |
809 | + ) | |
810 | + // Respect capacity throttle before retrying — without a sleep here, | |
811 | + // repeated decode failures at capacity would tight-loop at | |
812 | + // poll-request speed (work != null skips the !work sleep above). | |
813 | + if (atCapacityBeforeSwitch) { | |
814 | + const cap = capacityWake.signal() | |
815 | + if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) { | |
816 | + await heartbeatActiveWorkItems() | |
817 | + await sleep( | |
818 | + pollConfig.non_exclusive_heartbeat_interval_ms, | |
819 | + cap.signal, | |
820 | + ) | |
821 | + } else if (pollConfig.multisession_poll_interval_ms_at_capacity > 0) { | |
822 | + await sleep( | |
823 | + pollConfig.multisession_poll_interval_ms_at_capacity, | |
824 | + cap.signal, | |
825 | + ) | |
826 | + } | |
827 | + cap.cleanup() | |
828 | + } | |
829 | + continue | |
830 | + } | |
831 | + | |
832 | + // Explicitly acknowledge after committing to handle the work — NOT | |
833 | + // before. The at-capacity guard inside case 'session' can break | |
834 | + // without spawning; acking there would permanently lose the work. | |
835 | + // Ack failures are non-fatal: server re-delivers, and existingHandle | |
836 | + // / completedWorkIds paths handle the dedup. | |
837 | + const ackWork = async (): Promise<void> => { | |
838 | + logForDebugging(`[bridge:work] Acknowledging workId=${work.id}`) | |
839 | + try { | |
840 | + await api.acknowledgeWork( | |
841 | + environmentId, | |
842 | + work.id, | |
843 | + secret.session_ingress_token, | |
844 | + ) | |
845 | + } catch (err) { | |
846 | + logForDebugging( | |
847 | + `[bridge:work] Acknowledge failed workId=${work.id}: ${errorMessage(err)}`, | |
848 | + ) | |
849 | + } | |
850 | + } | |
851 | + | |
852 | + const workType: string = work.data.type | |
853 | + switch (work.data.type) { | |
854 | + case 'healthcheck': | |
855 | + await ackWork() | |
856 | + logForDebugging('[bridge:work] Healthcheck received') | |
857 | + logger.logVerbose('Healthcheck received') | |
858 | + break | |
859 | + case 'session': { | |
860 | + const sessionId = work.data.id | |
861 | + try { | |
862 | + validateBridgeId(sessionId, 'session_id') | |
863 | + } catch { | |
864 | + await ackWork() | |
865 | + logger.logError(`Invalid session_id received: ${sessionId}`) | |
866 | + break | |
867 | + } | |
868 | + | |
869 | + // If the session is already running, deliver the fresh token so | |
870 | + // the child process can reconnect its WebSocket with the new | |
871 | + // session ingress token. This handles the case where the server | |
872 | + // re-dispatches work for an existing session after the WS drops. | |
873 | + const existingHandle = activeSessions.get(sessionId) | |
874 | + if (existingHandle) { | |
875 | + existingHandle.updateAccessToken(secret.session_ingress_token) | |
876 | + sessionIngressTokens.set(sessionId, secret.session_ingress_token) | |
877 | + sessionWorkIds.set(sessionId, work.id) | |
878 | + // Re-schedule next refresh from the fresh JWT's expiry. onRefresh | |
879 | + // branches on v2Sessions so both v1 and v2 are safe here. | |
880 | + tokenRefresh?.schedule(sessionId, secret.session_ingress_token) | |
881 | + logForDebugging( | |
882 | + `[bridge:work] Updated access token for existing sessionId=${sessionId} workId=${work.id}`, | |
883 | + ) | |
884 | + await ackWork() | |
885 | + break | |
886 | + } | |
887 | + | |
888 | + // At capacity — token refresh for existing sessions is handled | |
889 | + // above, but we cannot spawn new ones. The post-switch capacity | |
890 | + // sleep will throttle the loop; just break here. | |
891 | + if (activeSessions.size >= config.maxSessions) { | |
892 | + logForDebugging( | |
893 | + `[bridge:work] At capacity (${activeSessions.size}/${config.maxSessions}), cannot spawn new session for workId=${work.id}`, | |
894 | + ) | |
895 | + break | |
896 | + } | |
897 | + | |
898 | + await ackWork() | |
899 | + const spawnStartTime = Date.now() | |
900 | + | |
901 | + // CCR v2 path: register this bridge as the session worker, get the | |
902 | + // epoch, and point the child at /v1/code/sessions/{id}. The child | |
903 | + // already has the full v2 client (SSETransport + CCRClient) — same | |
904 | + // code path environment-manager launches in containers. | |
905 | + // | |
906 | + // v1 path: Session-Ingress WebSocket. Uses config.sessionIngressUrl | |
907 | + // (not secret.api_base_url, which may point to a remote proxy tunnel | |
908 | + // that doesn't know about locally-created sessions). | |
909 | + let sdkUrl: string | |
910 | + let useCcrV2 = false | |
911 | + let workerEpoch: number | undefined | |
912 | + // Server decides per-session via the work secret; env var is the | |
913 | + // ant-dev override (e.g. forcing v2 before the server flag is on). | |
914 | + if ( | |
915 | + secret.use_code_sessions === true || | |
916 | + isEnvTruthy(process.env.CLAUDE_BRIDGE_USE_CCR_V2) | |
917 | + ) { | |
918 | + sdkUrl = buildCCRv2SdkUrl(config.apiBaseUrl, sessionId) | |
919 | + // Retry once on transient failure (network blip, 500) before | |
920 | + // permanently giving up and killing the session. | |
921 | + for (let attempt = 1; attempt <= 2; attempt++) { | |
922 | + try { | |
923 | + workerEpoch = await registerWorker( | |
924 | + sdkUrl, | |
925 | + secret.session_ingress_token, | |
926 | + ) | |
927 | + useCcrV2 = true | |
928 | + logForDebugging( | |
929 | + `[bridge:session] CCR v2: registered worker sessionId=${sessionId} epoch=${workerEpoch} attempt=${attempt}`, | |
930 | + ) | |
931 | + break | |
932 | + } catch (err) { | |
933 | + const errMsg = errorMessage(err) | |
934 | + if (attempt < 2) { | |
935 | + logForDebugging( | |
936 | + `[bridge:session] CCR v2: registerWorker attempt ${attempt} failed, retrying: ${errMsg}`, | |
937 | + ) | |
938 | + await sleep(2_000, loopSignal) | |
939 | + if (loopSignal.aborted) break | |
940 | + continue | |
941 | + } | |
942 | + logger.logError( | |
943 | + `CCR v2 worker registration failed for session ${sessionId}: ${errMsg}`, | |
944 | + ) | |
945 | + logError(new Error(`registerWorker failed: ${errMsg}`)) | |
946 | + completedWorkIds.add(work.id) | |
947 | + trackCleanup( | |
948 | + stopWorkWithRetry( | |
949 | + api, | |
950 | + environmentId, | |
951 | + work.id, | |
952 | + logger, | |
953 | + backoffConfig.stopWorkBaseDelayMs, | |
954 | + ), | |
955 | + ) | |
956 | + } | |
957 | + } | |
958 | + if (!useCcrV2) break | |
959 | + } else { | |
960 | + sdkUrl = buildSdkUrl(config.sessionIngressUrl, sessionId) | |
961 | + } | |
962 | + | |
963 | + // In worktree mode, on-demand sessions get an isolated git worktree | |
964 | + // so concurrent sessions don't interfere with each other's file | |
965 | + // changes. The pre-created initial session (if any) runs in | |
966 | + // config.dir so the user's first session lands in the directory they | |
967 | + // invoked `rc` from — matching the old single-session UX. | |
968 | + // In same-dir and single-session modes, all sessions share config.dir. | |
969 | + // Capture spawnMode before the await below — the `w` key handler | |
970 | + // mutates config.spawnMode directly, and createAgentWorktree can | |
971 | + // take 1-2s, so reading config.spawnMode after the await can | |
972 | + // produce contradictory analytics (spawn_mode:'same-dir', in_worktree:true). | |
973 | + const spawnModeAtDecision = config.spawnMode | |
974 | + let sessionDir = config.dir | |
975 | + let worktreeCreateMs = 0 | |
976 | + if ( | |
977 | + spawnModeAtDecision === 'worktree' && | |
978 | + (initialSessionId === undefined || | |
979 | + !sameSessionId(sessionId, initialSessionId)) | |
980 | + ) { | |
981 | + const wtStart = Date.now() | |
982 | + try { | |
983 | + const wt = await createAgentWorktree( | |
984 | + `bridge-${safeFilenameId(sessionId)}`, | |
985 | + ) | |
986 | + worktreeCreateMs = Date.now() - wtStart | |
987 | + sessionWorktrees.set(sessionId, { | |
988 | + worktreePath: wt.worktreePath, | |
989 | + worktreeBranch: wt.worktreeBranch, | |
990 | + gitRoot: wt.gitRoot, | |
991 | + hookBased: wt.hookBased, | |
992 | + }) | |
993 | + sessionDir = wt.worktreePath | |
994 | + logForDebugging( | |
995 | + `[bridge:session] Created worktree for sessionId=${sessionId} at ${wt.worktreePath}`, | |
996 | + ) | |
997 | + } catch (err) { | |
998 | + const errMsg = errorMessage(err) | |
999 | + logger.logError( | |
1000 | + `Failed to create worktree for session ${sessionId}: ${errMsg}`, | |
1001 | + ) | |
1002 | + logError(new Error(`Worktree creation failed: ${errMsg}`)) | |
1003 | + completedWorkIds.add(work.id) | |
1004 | + trackCleanup( | |
1005 | + stopWorkWithRetry( | |
1006 | + api, | |
1007 | + environmentId, | |
1008 | + work.id, | |
1009 | + logger, | |
1010 | + backoffConfig.stopWorkBaseDelayMs, | |
1011 | + ), | |
1012 | + ) | |
1013 | + break | |
1014 | + } | |
1015 | + } | |
1016 | + | |
1017 | + logForDebugging( | |
1018 | + `[bridge:session] Spawning sessionId=${sessionId} sdkUrl=${sdkUrl}`, | |
1019 | + ) | |
1020 | + | |
1021 | + // compat-surface session_* form for logger/Sessions-API calls. | |
1022 | + // Work poll returns cse_* under v2 compat; convert before spawn so | |
1023 | + // the onFirstUserMessage callback can close over it. | |
1024 | + const compatSessionId = toCompatSessionId(sessionId) | |
1025 | + | |
1026 | + const spawnResult = safeSpawn( | |
1027 | + spawner, | |
1028 | + { | |
1029 | + sessionId, | |
1030 | + sdkUrl, | |
1031 | + accessToken: secret.session_ingress_token, | |
1032 | + useCcrV2, | |
1033 | + workerEpoch, | |
1034 | + onFirstUserMessage: text => { | |
1035 | + // Server-set titles (--name, web rename) win. fetchSessionTitle | |
1036 | + // runs concurrently; if it already populated titledSessions, | |
1037 | + // skip. If it hasn't resolved yet, the derived title sticks — | |
1038 | + // acceptable since the server had no title at spawn time. | |
1039 | + if (titledSessions.has(compatSessionId)) return | |
1040 | + titledSessions.add(compatSessionId) | |
1041 | + const title = deriveSessionTitle(text) | |
1042 | + logger.setSessionTitle(compatSessionId, title) | |
1043 | + logForDebugging( | |
1044 | + `[bridge:title] derived title for ${compatSessionId}: ${title}`, | |
1045 | + ) | |
1046 | + void import('./createSession.js') | |
1047 | + .then(({ updateBridgeSessionTitle }) => | |
1048 | + updateBridgeSessionTitle(compatSessionId, title, { | |
1049 | + baseUrl: config.apiBaseUrl, | |
1050 | + }), | |
1051 | + ) | |
1052 | + .catch(err => | |
1053 | + logForDebugging( | |
1054 | + `[bridge:title] failed to update title for ${compatSessionId}: ${err}`, | |
1055 | + { level: 'error' }, | |
1056 | + ), | |
1057 | + ) | |
1058 | + }, | |
1059 | + }, | |
1060 | + sessionDir, | |
1061 | + ) | |
1062 | + if (typeof spawnResult === 'string') { | |
1063 | + logger.logError( | |
1064 | + `Failed to spawn session ${sessionId}: ${spawnResult}`, | |
1065 | + ) | |
1066 | + // Clean up worktree if one was created for this session | |
1067 | + const wt = sessionWorktrees.get(sessionId) | |
1068 | + if (wt) { | |
1069 | + sessionWorktrees.delete(sessionId) | |
1070 | + trackCleanup( | |
1071 | + removeAgentWorktree( | |
1072 | + wt.worktreePath, | |
1073 | + wt.worktreeBranch, | |
1074 | + wt.gitRoot, | |
1075 | + wt.hookBased, | |
1076 | + ).catch((err: unknown) => | |
1077 | + logger.logVerbose( | |
1078 | + `Failed to remove worktree ${wt.worktreePath}: ${errorMessage(err)}`, | |
1079 | + ), | |
1080 | + ), | |
1081 | + ) | |
1082 | + } | |
1083 | + completedWorkIds.add(work.id) | |
1084 | + trackCleanup( | |
1085 | + stopWorkWithRetry( | |
1086 | + api, | |
1087 | + environmentId, | |
1088 | + work.id, | |
1089 | + logger, | |
1090 | + backoffConfig.stopWorkBaseDelayMs, | |
1091 | + ), | |
1092 | + ) | |
1093 | + break | |
1094 | + } | |
1095 | + const handle = spawnResult | |
1096 | + | |
1097 | + const spawnDurationMs = Date.now() - spawnStartTime | |
1098 | + logEvent('tengu_bridge_session_started', { | |
1099 | + active_sessions: activeSessions.size, | |
1100 | + spawn_mode: | |
1101 | + spawnModeAtDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
1102 | + in_worktree: sessionWorktrees.has(sessionId), | |
1103 | + spawn_duration_ms: spawnDurationMs, | |
1104 | + worktree_create_ms: worktreeCreateMs, | |
1105 | + inProtectedNamespace: isInProtectedNamespace(), | |
1106 | + }) | |
1107 | + logForDiagnosticsNoPII('info', 'bridge_session_started', { | |
1108 | + spawn_mode: spawnModeAtDecision, | |
1109 | + in_worktree: sessionWorktrees.has(sessionId), | |
1110 | + spawn_duration_ms: spawnDurationMs, | |
1111 | + worktree_create_ms: worktreeCreateMs, | |
1112 | + }) | |
1113 | + | |
1114 | + activeSessions.set(sessionId, handle) | |
1115 | + sessionWorkIds.set(sessionId, work.id) | |
1116 | + sessionIngressTokens.set(sessionId, secret.session_ingress_token) | |
1117 | + sessionCompatIds.set(sessionId, compatSessionId) | |
1118 | + | |
1119 | + const startTime = Date.now() | |
1120 | + sessionStartTimes.set(sessionId, startTime) | |
1121 | + | |
1122 | + // Use a generic prompt description since we no longer get startup_context | |
1123 | + logger.logSessionStart(sessionId, `Session ${sessionId}`) | |
1124 | + | |
1125 | + // Compute the actual debug file path (mirrors sessionRunner.ts logic) | |
1126 | + const safeId = safeFilenameId(sessionId) | |
1127 | + let sessionDebugFile: string | undefined | |
1128 | + if (config.debugFile) { | |
1129 | + const ext = config.debugFile.lastIndexOf('.') | |
1130 | + if (ext > 0) { | |
1131 | + sessionDebugFile = `${config.debugFile.slice(0, ext)}-${safeId}${config.debugFile.slice(ext)}` | |
1132 | + } else { | |
1133 | + sessionDebugFile = `${config.debugFile}-${safeId}` | |
1134 | + } | |
1135 | + } else if (config.verbose || process.env.USER_TYPE === 'ant') { | |
1136 | + sessionDebugFile = join( | |
1137 | + tmpdir(), | |
1138 | + 'claude', | |
1139 | + `bridge-session-${safeId}.log`, | |
1140 | + ) | |
1141 | + } | |
1142 | + | |
1143 | + if (sessionDebugFile) { | |
1144 | + logger.logVerbose(`Debug log: ${sessionDebugFile}`) | |
1145 | + } | |
1146 | + | |
1147 | + // Register in the sessions Map before starting status updates so the | |
1148 | + // first render tick shows the correct count and bullet list in sync. | |
1149 | + logger.addSession( | |
1150 | + compatSessionId, | |
1151 | + getRemoteSessionUrl(compatSessionId, config.sessionIngressUrl), | |
1152 | + ) | |
1153 | + | |
1154 | + // Start live status updates and transition to "Attached" state. | |
1155 | + startStatusUpdates() | |
1156 | + logger.setAttached(compatSessionId) | |
1157 | + | |
1158 | + // One-shot title fetch. If the session already has a title (set via | |
1159 | + // --name, web rename, or /remote-control), display it and mark as | |
1160 | + // titled so the first-user-message fallback doesn't overwrite it. | |
1161 | + // Otherwise onFirstUserMessage derives one from the first prompt. | |
1162 | + void fetchSessionTitle(compatSessionId, config.apiBaseUrl) | |
1163 | + .then(title => { | |
1164 | + if (title && activeSessions.has(sessionId)) { | |
1165 | + titledSessions.add(compatSessionId) | |
1166 | + logger.setSessionTitle(compatSessionId, title) | |
1167 | + logForDebugging( | |
1168 | + `[bridge:title] server title for ${compatSessionId}: ${title}`, | |
1169 | + ) | |
1170 | + } | |
1171 | + }) | |
1172 | + .catch(err => | |
1173 | + logForDebugging( | |
1174 | + `[bridge:title] failed to fetch title for ${compatSessionId}: ${err}`, | |
1175 | + { level: 'error' }, | |
1176 | + ), | |
1177 | + ) | |
1178 | + | |
1179 | + // Start per-session timeout watchdog | |
1180 | + const timeoutMs = | |
1181 | + config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS | |
1182 | + if (timeoutMs > 0) { | |
1183 | + const timer = setTimeout( | |
1184 | + onSessionTimeout, | |
1185 | + timeoutMs, | |
1186 | + sessionId, | |
1187 | + timeoutMs, | |
1188 | + logger, | |
1189 | + timedOutSessions, | |
1190 | + handle, | |
1191 | + ) | |
1192 | + sessionTimers.set(sessionId, timer) | |
1193 | + } | |
1194 | + | |
1195 | + // Schedule proactive token refresh before the JWT expires. | |
1196 | + // onRefresh branches on v2Sessions: v1 delivers OAuth to the | |
1197 | + // child, v2 triggers server re-dispatch via reconnectSession. | |
1198 | + if (useCcrV2) { | |
1199 | + v2Sessions.add(sessionId) | |
1200 | + } | |
1201 | + tokenRefresh?.schedule(sessionId, secret.session_ingress_token) | |
1202 | + | |
1203 | + void handle.done.then(onSessionDone(sessionId, startTime, handle)) | |
1204 | + break | |
1205 | + } | |
1206 | + default: | |
1207 | + await ackWork() | |
1208 | + // Gracefully ignore unknown work types. The backend may send new | |
1209 | + // types before the bridge client is updated. | |
1210 | + logForDebugging( | |
1211 | + `[bridge:work] Unknown work type: ${workType}, skipping`, | |
1212 | + ) | |
1213 | + break | |
1214 | + } | |
1215 | + | |
1216 | + // When at capacity, throttle the loop. The switch above still runs so | |
1217 | + // existing-session token refreshes are processed, but we sleep here | |
1218 | + // to avoid busy-looping. Include the capacity wake signal so the | |
1219 | + // sleep is interrupted immediately when a session completes. | |
1220 | + if (atCapacityBeforeSwitch) { | |
1221 | + const cap = capacityWake.signal() | |
1222 | + if (pollConfig.non_exclusive_heartbeat_interval_ms > 0) { | |
1223 | + await heartbeatActiveWorkItems() | |
1224 | + await sleep( | |
1225 | + pollConfig.non_exclusive_heartbeat_interval_ms, | |
1226 | + cap.signal, | |
1227 | + ) | |
1228 | + } else if (pollConfig.multisession_poll_interval_ms_at_capacity > 0) { | |
1229 | + await sleep( | |
1230 | + pollConfig.multisession_poll_interval_ms_at_capacity, | |
1231 | + cap.signal, | |
1232 | + ) | |
1233 | + } | |
1234 | + cap.cleanup() | |
1235 | + } | |
1236 | + } catch (err) { | |
1237 | + if (loopSignal.aborted) { | |
1238 | + break | |
1239 | + } | |
1240 | + | |
1241 | + // Fatal errors (401/403) — no point retrying, auth won't fix itself | |
1242 | + if (err instanceof BridgeFatalError) { | |
1243 | + fatalExit = true | |
1244 | + // Server-enforced expiry gets a clean status message, not an error | |
1245 | + if (isExpiredErrorType(err.errorType)) { | |
1246 | + logger.logStatus(err.message) | |
1247 | + } else if (isSuppressible403(err)) { | |
1248 | + // Cosmetic 403 errors (e.g., external_poll_sessions scope, | |
1249 | + // environments:manage permission) — don't show to user | |
1250 | + logForDebugging(`[bridge:work] Suppressed 403 error: ${err.message}`) | |
1251 | + } else { | |
1252 | + logger.logError(err.message) | |
1253 | + logError(err) | |
1254 | + } | |
1255 | + logEvent('tengu_bridge_fatal_error', { | |
1256 | + status: err.status, | |
1257 | + error_type: | |
1258 | + err.errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
1259 | + }) | |
1260 | + logForDiagnosticsNoPII( | |
1261 | + isExpiredErrorType(err.errorType) ? 'info' : 'error', | |
1262 | + 'bridge_fatal_error', | |
1263 | + { status: err.status, error_type: err.errorType }, | |
1264 | + ) | |
1265 | + break | |
1266 | + } | |
1267 | + | |
1268 | + const errMsg = describeAxiosError(err) | |
1269 | + | |
1270 | + if (isConnectionError(err) || isServerError(err)) { | |
1271 | + const now = Date.now() | |
1272 | + | |
1273 | + // Detect system sleep/wake: if the gap since the last poll error | |
1274 | + // greatly exceeds the expected backoff, the machine likely slept. | |
1275 | + // Reset error tracking so the bridge retries with a fresh budget. | |
1276 | + if ( | |
1277 | + lastPollErrorTime !== null && | |
1278 | + now - lastPollErrorTime > pollSleepDetectionThresholdMs(backoffConfig) | |
1279 | + ) { | |
1280 | + logForDebugging( | |
1281 | + `[bridge:work] Detected system sleep (${Math.round((now - lastPollErrorTime) / 1000)}s gap), resetting error budget`, | |
1282 | + ) | |
1283 | + logForDiagnosticsNoPII('info', 'bridge_poll_sleep_detected', { | |
1284 | + gapMs: now - lastPollErrorTime, | |
1285 | + }) | |
1286 | + connErrorStart = null | |
1287 | + connBackoff = 0 | |
1288 | + generalErrorStart = null | |
1289 | + generalBackoff = 0 | |
1290 | + } | |
1291 | + lastPollErrorTime = now | |
1292 | + | |
1293 | + if (!connErrorStart) { | |
1294 | + connErrorStart = now | |
1295 | + } | |
1296 | + const elapsed = now - connErrorStart | |
1297 | + if (elapsed >= backoffConfig.connGiveUpMs) { | |
1298 | + logger.logError( | |
1299 | + `Server unreachable for ${Math.round(elapsed / 60_000)} minutes, giving up.`, | |
1300 | + ) | |
1301 | + logEvent('tengu_bridge_poll_give_up', { | |
1302 | + error_type: | |
1303 | + 'connection' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
1304 | + elapsed_ms: elapsed, | |
1305 | + }) | |
1306 | + logForDiagnosticsNoPII('error', 'bridge_poll_give_up', { | |
1307 | + error_type: 'connection', | |
1308 | + elapsed_ms: elapsed, | |
1309 | + }) | |
1310 | + fatalExit = true | |
1311 | + break | |
1312 | + } | |
1313 | + | |
1314 | + // Reset the other track when switching error types | |
1315 | + generalErrorStart = null | |
1316 | + generalBackoff = 0 | |
1317 | + | |
1318 | + connBackoff = connBackoff | |
1319 | + ? Math.min(connBackoff * 2, backoffConfig.connCapMs) | |
1320 | + : backoffConfig.connInitialMs | |
1321 | + const delay = addJitter(connBackoff) | |
1322 | + logger.logVerbose( | |
1323 | + `Connection error, retrying in ${formatDelay(delay)} (${Math.round(elapsed / 1000)}s elapsed): ${errMsg}`, | |
1324 | + ) | |
1325 | + logger.updateReconnectingStatus( | |
1326 | + formatDelay(delay), | |
1327 | + formatDuration(elapsed), | |
1328 | + ) | |
1329 | + // The poll_due heartbeat-loop exit leaves a healthy lease exposed to | |
1330 | + // this backoff path. Heartbeat before each sleep so /poll outages | |
1331 | + // (the VerifyEnvironmentSecretAuth DB path heartbeat was introduced | |
1332 | + // to avoid) don't kill the 300s lease TTL. No-op when activeSessions | |
1333 | + // is empty or heartbeat is disabled. | |
1334 | + if (getPollIntervalConfig().non_exclusive_heartbeat_interval_ms > 0) { | |
1335 | + await heartbeatActiveWorkItems() | |
1336 | + } | |
1337 | + await sleep(delay, loopSignal) | |
1338 | + } else { | |
1339 | + const now = Date.now() | |
1340 | + | |
1341 | + // Sleep detection for general errors (same logic as connection errors) | |
1342 | + if ( | |
1343 | + lastPollErrorTime !== null && | |
1344 | + now - lastPollErrorTime > pollSleepDetectionThresholdMs(backoffConfig) | |
1345 | + ) { | |
1346 | + logForDebugging( | |
1347 | + `[bridge:work] Detected system sleep (${Math.round((now - lastPollErrorTime) / 1000)}s gap), resetting error budget`, | |
1348 | + ) | |
1349 | + logForDiagnosticsNoPII('info', 'bridge_poll_sleep_detected', { | |
1350 | + gapMs: now - lastPollErrorTime, | |
1351 | + }) | |
1352 | + connErrorStart = null | |
1353 | + connBackoff = 0 | |
1354 | + generalErrorStart = null | |
1355 | + generalBackoff = 0 | |
1356 | + } | |
1357 | + lastPollErrorTime = now | |
1358 | + | |
1359 | + if (!generalErrorStart) { | |
1360 | + generalErrorStart = now | |
1361 | + } | |
1362 | + const elapsed = now - generalErrorStart | |
1363 | + if (elapsed >= backoffConfig.generalGiveUpMs) { | |
1364 | + logger.logError( | |
1365 | + `Persistent errors for ${Math.round(elapsed / 60_000)} minutes, giving up.`, | |
1366 | + ) | |
1367 | + logEvent('tengu_bridge_poll_give_up', { | |
1368 | + error_type: | |
1369 | + 'general' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
1370 | + elapsed_ms: elapsed, | |
1371 | + }) | |
1372 | + logForDiagnosticsNoPII('error', 'bridge_poll_give_up', { | |
1373 | + error_type: 'general', | |
1374 | + elapsed_ms: elapsed, | |
1375 | + }) | |
1376 | + fatalExit = true | |
1377 | + break | |
1378 | + } | |
1379 | + | |
1380 | + // Reset the other track when switching error types | |
1381 | + connErrorStart = null | |
1382 | + connBackoff = 0 | |
1383 | + | |
1384 | + generalBackoff = generalBackoff | |
1385 | + ? Math.min(generalBackoff * 2, backoffConfig.generalCapMs) | |
1386 | + : backoffConfig.generalInitialMs | |
1387 | + const delay = addJitter(generalBackoff) | |
1388 | + logger.logVerbose( | |
1389 | + `Poll failed, retrying in ${formatDelay(delay)} (${Math.round(elapsed / 1000)}s elapsed): ${errMsg}`, | |
1390 | + ) | |
1391 | + logger.updateReconnectingStatus( | |
1392 | + formatDelay(delay), | |
1393 | + formatDuration(elapsed), | |
1394 | + ) | |
1395 | + if (getPollIntervalConfig().non_exclusive_heartbeat_interval_ms > 0) { | |
1396 | + await heartbeatActiveWorkItems() | |
1397 | + } | |
1398 | + await sleep(delay, loopSignal) | |
1399 | + } | |
1400 | + } | |
1401 | + } | |
1402 | + | |
1403 | + // Clean up | |
1404 | + stopStatusUpdates() | |
1405 | + logger.clearStatus() | |
1406 | + | |
1407 | + const loopDurationMs = Date.now() - loopStartTime | |
1408 | + logEvent('tengu_bridge_shutdown', { | |
1409 | + active_sessions: activeSessions.size, | |
1410 | + loop_duration_ms: loopDurationMs, | |
1411 | + }) | |
1412 | + logForDiagnosticsNoPII('info', 'bridge_shutdown', { | |
1413 | + active_sessions: activeSessions.size, | |
1414 | + loop_duration_ms: loopDurationMs, | |
1415 | + }) | |
1416 | + | |
1417 | + // Graceful shutdown: kill active sessions, report them as interrupted, | |
1418 | + // archive sessions, then deregister the environment so the web UI shows | |
1419 | + // the bridge as offline. | |
1420 | + | |
1421 | + // Collect all session IDs to archive on exit. This includes: | |
1422 | + // 1. Active sessions (snapshot before killing — onSessionDone clears maps) | |
1423 | + // 2. The initial auto-created session (may never have had work dispatched) | |
1424 | + // api.archiveSession is idempotent (409 if already archived), so | |
1425 | + // double-archiving is safe. | |
1426 | + const sessionsToArchive = new Set(activeSessions.keys()) | |
1427 | + if (initialSessionId) { | |
1428 | + sessionsToArchive.add(initialSessionId) | |
1429 | + } | |
1430 | + // Snapshot before killing — onSessionDone clears sessionCompatIds. | |
1431 | + const compatIdSnapshot = new Map(sessionCompatIds) | |
1432 | + | |
1433 | + if (activeSessions.size > 0) { | |
1434 | + logForDebugging( | |
1435 | + `[bridge:shutdown] Shutting down ${activeSessions.size} active session(s)`, | |
1436 | + ) | |
1437 | + logger.logStatus( | |
1438 | + `Shutting down ${activeSessions.size} active session(s)\u2026`, | |
1439 | + ) | |
1440 | + | |
1441 | + // Snapshot work IDs before killing — onSessionDone clears the maps when | |
1442 | + // each child exits, so we need a copy for the stopWork calls below. | |
1443 | + const shutdownWorkIds = new Map(sessionWorkIds) | |
1444 | + | |
1445 | + for (const [sessionId, handle] of activeSessions.entries()) { | |
1446 | + logForDebugging( | |
1447 | + `[bridge:shutdown] Sending SIGTERM to sessionId=${sessionId}`, | |
1448 | + ) | |
1449 | + handle.kill() | |
1450 | + } | |
1451 | + | |
1452 | + const timeout = new AbortController() | |
1453 | + await Promise.race([ | |
1454 | + Promise.allSettled([...activeSessions.values()].map(h => h.done)), | |
1455 | + sleep(backoffConfig.shutdownGraceMs ?? 30_000, timeout.signal), | |
1456 | + ]) | |
1457 | + timeout.abort() | |
1458 | + | |
1459 | + // SIGKILL any processes that didn't respond to SIGTERM within the grace window | |
1460 | + for (const [sid, handle] of activeSessions.entries()) { | |
1461 | + logForDebugging(`[bridge:shutdown] Force-killing stuck sessionId=${sid}`) | |
1462 | + handle.forceKill() | |
1463 | + } | |
1464 | + | |
1465 | + // Clear any remaining session timeout and refresh timers | |
1466 | + for (const timer of sessionTimers.values()) { | |
1467 | + clearTimeout(timer) | |
1468 | + } | |
1469 | + sessionTimers.clear() | |
1470 | + tokenRefresh?.cancelAll() | |
1471 | + | |
1472 | + // Clean up any remaining worktrees from active sessions. | |
1473 | + // Snapshot and clear the map first so onSessionDone (which may fire | |
1474 | + // during the await below when handle.done resolves) won't try to | |
1475 | + // remove the same worktrees again. | |
1476 | + if (sessionWorktrees.size > 0) { | |
1477 | + const remainingWorktrees = [...sessionWorktrees.values()] | |
1478 | + sessionWorktrees.clear() | |
1479 | + logForDebugging( | |
1480 | + `[bridge:shutdown] Cleaning up ${remainingWorktrees.length} worktree(s)`, | |
1481 | + ) | |
1482 | + await Promise.allSettled( | |
1483 | + remainingWorktrees.map(wt => | |
1484 | + removeAgentWorktree( | |
1485 | + wt.worktreePath, | |
1486 | + wt.worktreeBranch, | |
1487 | + wt.gitRoot, | |
1488 | + wt.hookBased, | |
1489 | + ), | |
1490 | + ), | |
1491 | + ) | |
1492 | + } | |
1493 | + | |
1494 | + // Stop all active work items so the server knows they're done | |
1495 | + await Promise.allSettled( | |
1496 | + [...shutdownWorkIds.entries()].map(([sessionId, workId]) => { | |
1497 | + return api | |
1498 | + .stopWork(environmentId, workId, true) | |
1499 | + .catch(err => | |
1500 | + logger.logVerbose( | |
1501 | + `Failed to stop work ${workId} for session ${sessionId}: ${errorMessage(err)}`, | |
1502 | + ), | |
1503 | + ) | |
1504 | + }), | |
1505 | + ) | |
1506 | + } | |
1507 | + | |
1508 | + // Ensure all in-flight cleanup (stopWork, worktree removal) from | |
1509 | + // onSessionDone completes before deregistering — otherwise | |
1510 | + // process.exit() can kill them mid-flight. | |
1511 | + if (pendingCleanups.size > 0) { | |
1512 | + await Promise.allSettled([...pendingCleanups]) | |
1513 | + } | |
1514 | + | |
1515 | + // In single-session mode with a known session, leave the session and | |
1516 | + // environment alive so `claude remote-control --session-id=<id>` can resume. | |
1517 | + // The backend GCs stale environments via a 4h TTL (BRIDGE_LAST_POLL_TTL). | |
1518 | + // Archiving the session or deregistering the environment would make the | |
1519 | + // printed resume command a lie — deregister deletes Firestore + Redis stream. | |
1520 | + // Skip when the loop exited fatally (env expired, auth failed, give-up) — | |
1521 | + // resume is impossible in those cases and the message would contradict the | |
1522 | + // error already printed. | |
1523 | + // feature('KAIROS') gate: --session-id is ant-only; without the gate, | |
1524 | + // revert to the pre-PR behavior (archive + deregister on every shutdown). | |
1525 | + if ( | |
1526 | + feature('KAIROS') && | |
1527 | + config.spawnMode === 'single-session' && | |
1528 | + initialSessionId && | |
1529 | + !fatalExit | |
1530 | + ) { | |
1531 | + logger.logStatus( | |
1532 | + `Resume this session by running \`claude remote-control --continue\``, | |
1533 | + ) | |
1534 | + logForDebugging( | |
1535 | + `[bridge:shutdown] Skipping archive+deregister to allow resume of session ${initialSessionId}`, | |
1536 | + ) | |
1537 | + return | |
1538 | + } | |
1539 | + | |
1540 | + // Archive all known sessions so they don't linger as idle/running on the | |
1541 | + // server after the bridge goes offline. | |
1542 | + if (sessionsToArchive.size > 0) { | |
1543 | + logForDebugging( | |
1544 | + `[bridge:shutdown] Archiving ${sessionsToArchive.size} session(s)`, | |
1545 | + ) | |
1546 | + await Promise.allSettled( | |
1547 | + [...sessionsToArchive].map(sessionId => | |
1548 | + api | |
1549 | + .archiveSession( | |
1550 | + compatIdSnapshot.get(sessionId) ?? toCompatSessionId(sessionId), | |
1551 | + ) | |
1552 | + .catch(err => | |
1553 | + logger.logVerbose( | |
1554 | + `Failed to archive session ${sessionId}: ${errorMessage(err)}`, | |
1555 | + ), | |
1556 | + ), | |
1557 | + ), | |
1558 | + ) | |
1559 | + } | |
1560 | + | |
1561 | + // Deregister the environment so the web UI shows the bridge as offline | |
1562 | + // and the Redis stream is cleaned up. | |
1563 | + try { | |
1564 | + await api.deregisterEnvironment(environmentId) | |
1565 | + logForDebugging( | |
1566 | + `[bridge:shutdown] Environment deregistered, bridge offline`, | |
1567 | + ) | |
1568 | + logger.logVerbose('Environment deregistered.') | |
1569 | + } catch (err) { | |
1570 | + logger.logVerbose(`Failed to deregister environment: ${errorMessage(err)}`) | |
1571 | + } | |
1572 | + | |
1573 | + // Clear the crash-recovery pointer — the env is gone, pointer would be | |
1574 | + // stale. The early return above (resumable SIGINT shutdown) skips this, | |
1575 | + // leaving the pointer as a backup for the printed --session-id hint. | |
1576 | + const { clearBridgePointer } = await import('./bridgePointer.js') | |
1577 | + await clearBridgePointer(config.dir) | |
1578 | + | |
1579 | + logger.logVerbose('Environment offline.') | |
1580 | +} | |
1581 | + | |
1582 | +const CONNECTION_ERROR_CODES = new Set([ | |
1583 | + 'ECONNREFUSED', | |
1584 | + 'ECONNRESET', | |
1585 | + 'ETIMEDOUT', | |
1586 | + 'ENETUNREACH', | |
1587 | + 'EHOSTUNREACH', | |
1588 | +]) | |
1589 | + | |
1590 | +export function isConnectionError(err: unknown): boolean { | |
1591 | + if ( | |
1592 | + err && | |
1593 | + typeof err === 'object' && | |
1594 | + 'code' in err && | |
1595 | + typeof err.code === 'string' && | |
1596 | + CONNECTION_ERROR_CODES.has(err.code) | |
1597 | + ) { | |
1598 | + return true | |
1599 | + } | |
1600 | + return false | |
1601 | +} | |
1602 | + | |
1603 | +/** Detect HTTP 5xx errors from axios (code: 'ERR_BAD_RESPONSE'). */ | |
1604 | +export function isServerError(err: unknown): boolean { | |
1605 | + return ( | |
1606 | + !!err && | |
1607 | + typeof err === 'object' && | |
1608 | + 'code' in err && | |
1609 | + typeof err.code === 'string' && | |
1610 | + err.code === 'ERR_BAD_RESPONSE' | |
1611 | + ) | |
1612 | +} | |
1613 | + | |
1614 | +/** Add ±25% jitter to a delay value. */ | |
1615 | +function addJitter(ms: number): number { | |
1616 | + return Math.max(0, ms + ms * 0.25 * (2 * Math.random() - 1)) | |
1617 | +} | |
1618 | + | |
1619 | +function formatDelay(ms: number): string { | |
1620 | + return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms` | |
1621 | +} | |
1622 | + | |
1623 | +/** | |
1624 | + * Retry stopWork with exponential backoff (3 attempts, 1s/2s/4s). | |
1625 | + * Ensures the server learns the work item ended, preventing server-side zombies. | |
1626 | + */ | |
1627 | +async function stopWorkWithRetry( | |
1628 | + api: BridgeApiClient, | |
1629 | + environmentId: string, | |
1630 | + workId: string, | |
1631 | + logger: BridgeLogger, | |
1632 | + baseDelayMs = 1000, | |
1633 | +): Promise<void> { | |
1634 | + const MAX_ATTEMPTS = 3 | |
1635 | + | |
1636 | + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | |
1637 | + try { | |
1638 | + await api.stopWork(environmentId, workId, false) | |
1639 | + logForDebugging( | |
1640 | + `[bridge:work] stopWork succeeded for workId=${workId} on attempt ${attempt}/${MAX_ATTEMPTS}`, | |
1641 | + ) | |
1642 | + return | |
1643 | + } catch (err) { | |
1644 | + // Auth/permission errors won't be fixed by retrying | |
1645 | + if (err instanceof BridgeFatalError) { | |
1646 | + if (isSuppressible403(err)) { | |
1647 | + logForDebugging( | |
1648 | + `[bridge:work] Suppressed stopWork 403 for ${workId}: ${err.message}`, | |
1649 | + ) | |
1650 | + } else { | |
1651 | + logger.logError(`Failed to stop work ${workId}: ${err.message}`) | |
1652 | + } | |
1653 | + logForDiagnosticsNoPII('error', 'bridge_stop_work_failed', { | |
1654 | + attempts: attempt, | |
1655 | + fatal: true, | |
1656 | + }) | |
1657 | + return | |
1658 | + } | |
1659 | + const errMsg = errorMessage(err) | |
1660 | + if (attempt < MAX_ATTEMPTS) { | |
1661 | + const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1)) | |
1662 | + logger.logVerbose( | |
1663 | + `Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`, | |
1664 | + ) | |
1665 | + await sleep(delay) | |
1666 | + } else { | |
1667 | + logger.logError( | |
1668 | + `Failed to stop work ${workId} after ${MAX_ATTEMPTS} attempts: ${errMsg}`, | |
1669 | + ) | |
1670 | + logForDiagnosticsNoPII('error', 'bridge_stop_work_failed', { | |
1671 | + attempts: MAX_ATTEMPTS, | |
1672 | + }) | |
1673 | + } | |
1674 | + } | |
1675 | + } | |
1676 | +} | |
1677 | + | |
1678 | +function onSessionTimeout( | |
1679 | + sessionId: string, | |
1680 | + timeoutMs: number, | |
1681 | + logger: BridgeLogger, | |
1682 | + timedOutSessions: Set<string>, | |
1683 | + handle: SessionHandle, | |
1684 | +): void { | |
1685 | + logForDebugging( | |
1686 | + `[bridge:session] sessionId=${sessionId} timed out after ${formatDuration(timeoutMs)}`, | |
1687 | + ) | |
1688 | + logEvent('tengu_bridge_session_timeout', { | |
1689 | + timeout_ms: timeoutMs, | |
1690 | + }) | |
1691 | + logger.logSessionFailed( | |
1692 | + sessionId, | |
1693 | + `Session timed out after ${formatDuration(timeoutMs)}`, | |
1694 | + ) | |
1695 | + timedOutSessions.add(sessionId) | |
1696 | + handle.kill() | |
1697 | +} | |
1698 | + | |
1699 | +export type ParsedArgs = { | |
1700 | + verbose: boolean | |
1701 | + sandbox: boolean | |
1702 | + debugFile?: string | |
1703 | + sessionTimeoutMs?: number | |
1704 | + permissionMode?: string | |
1705 | + name?: string | |
1706 | + /** Value passed to --spawn (if any); undefined if no --spawn flag was given. */ | |
1707 | + spawnMode: SpawnMode | undefined | |
1708 | + /** Value passed to --capacity (if any); undefined if no --capacity flag was given. */ | |
1709 | + capacity: number | undefined | |
1710 | + /** --[no-]create-session-in-dir override; undefined = use default (on). */ | |
1711 | + createSessionInDir: boolean | undefined | |
1712 | + /** Resume an existing session instead of creating a new one. */ | |
1713 | + sessionId?: string | |
1714 | + /** Resume the last session in this directory (reads bridge-pointer.json). */ | |
1715 | + continueSession: boolean | |
1716 | + help: boolean | |
1717 | + error?: string | |
1718 | +} | |
1719 | + | |
1720 | +const SPAWN_FLAG_VALUES = ['session', 'same-dir', 'worktree'] as const | |
1721 | + | |
1722 | +function parseSpawnValue(raw: string | undefined): SpawnMode | string { | |
1723 | + if (raw === 'session') return 'single-session' | |
1724 | + if (raw === 'same-dir') return 'same-dir' | |
1725 | + if (raw === 'worktree') return 'worktree' | |
1726 | + return `--spawn requires one of: ${SPAWN_FLAG_VALUES.join(', ')} (got: ${raw ?? '<missing>'})` | |
1727 | +} | |
1728 | + | |
1729 | +function parseCapacityValue(raw: string | undefined): number | string { | |
1730 | + const n = raw === undefined ? NaN : parseInt(raw, 10) | |
1731 | + if (isNaN(n) || n < 1) { | |
1732 | + return `--capacity requires a positive integer (got: ${raw ?? '<missing>'})` | |
1733 | + } | |
1734 | + return n | |
1735 | +} | |
1736 | + | |
1737 | +export function parseArgs(args: string[]): ParsedArgs { | |
1738 | + let verbose = false | |
1739 | + let sandbox = false | |
1740 | + let debugFile: string | undefined | |
1741 | + let sessionTimeoutMs: number | undefined | |
1742 | + let permissionMode: string | undefined | |
1743 | + let name: string | undefined | |
1744 | + let help = false | |
1745 | + let spawnMode: SpawnMode | undefined | |
1746 | + let capacity: number | undefined | |
1747 | + let createSessionInDir: boolean | undefined | |
1748 | + let sessionId: string | undefined | |
1749 | + let continueSession = false | |
1750 | + | |
1751 | + for (let i = 0; i < args.length; i++) { | |
1752 | + const arg = args[i]! | |
1753 | + if (arg === '--help' || arg === '-h') { | |
1754 | + help = true | |
1755 | + } else if (arg === '--verbose' || arg === '-v') { | |
1756 | + verbose = true | |
1757 | + } else if (arg === '--sandbox') { | |
1758 | + sandbox = true | |
1759 | + } else if (arg === '--no-sandbox') { | |
1760 | + sandbox = false | |
1761 | + } else if (arg === '--debug-file' && i + 1 < args.length) { | |
1762 | + debugFile = resolve(args[++i]!) | |
1763 | + } else if (arg.startsWith('--debug-file=')) { | |
1764 | + debugFile = resolve(arg.slice('--debug-file='.length)) | |
1765 | + } else if (arg === '--session-timeout' && i + 1 < args.length) { | |
1766 | + sessionTimeoutMs = parseInt(args[++i]!, 10) * 1000 | |
1767 | + } else if (arg.startsWith('--session-timeout=')) { | |
1768 | + sessionTimeoutMs = | |
1769 | + parseInt(arg.slice('--session-timeout='.length), 10) * 1000 | |
1770 | + } else if (arg === '--permission-mode' && i + 1 < args.length) { | |
1771 | + permissionMode = args[++i]! | |
1772 | + } else if (arg.startsWith('--permission-mode=')) { | |
1773 | + permissionMode = arg.slice('--permission-mode='.length) | |
1774 | + } else if (arg === '--name' && i + 1 < args.length) { | |
1775 | + name = args[++i]! | |
1776 | + } else if (arg.startsWith('--name=')) { | |
1777 | + name = arg.slice('--name='.length) | |
1778 | + } else if ( | |
1779 | + feature('KAIROS') && | |
1780 | + arg === '--session-id' && | |
1781 | + i + 1 < args.length | |
1782 | + ) { | |
1783 | + sessionId = args[++i]! | |
1784 | + if (!sessionId) { | |
1785 | + return makeError('--session-id requires a value') | |
1786 | + } | |
1787 | + } else if (feature('KAIROS') && arg.startsWith('--session-id=')) { | |
1788 | + sessionId = arg.slice('--session-id='.length) | |
1789 | + if (!sessionId) { | |
1790 | + return makeError('--session-id requires a value') | |
1791 | + } | |
1792 | + } else if (feature('KAIROS') && (arg === '--continue' || arg === '-c')) { | |
1793 | + continueSession = true | |
1794 | + } else if (arg === '--spawn' || arg.startsWith('--spawn=')) { | |
1795 | + if (spawnMode !== undefined) { | |
1796 | + return makeError('--spawn may only be specified once') | |
1797 | + } | |
1798 | + const raw = arg.startsWith('--spawn=') | |
1799 | + ? arg.slice('--spawn='.length) | |
1800 | + : args[++i] | |
1801 | + const v = parseSpawnValue(raw) | |
1802 | + if (v === 'single-session' || v === 'same-dir' || v === 'worktree') { | |
1803 | + spawnMode = v | |
1804 | + } else { | |
1805 | + return makeError(v) | |
1806 | + } | |
1807 | + } else if (arg === '--capacity' || arg.startsWith('--capacity=')) { | |
1808 | + if (capacity !== undefined) { | |
1809 | + return makeError('--capacity may only be specified once') | |
1810 | + } | |
1811 | + const raw = arg.startsWith('--capacity=') | |
1812 | + ? arg.slice('--capacity='.length) | |
1813 | + : args[++i] | |
1814 | + const v = parseCapacityValue(raw) | |
1815 | + if (typeof v === 'number') capacity = v | |
1816 | + else return makeError(v) | |
1817 | + } else if (arg === '--create-session-in-dir') { | |
1818 | + createSessionInDir = true | |
1819 | + } else if (arg === '--no-create-session-in-dir') { | |
1820 | + createSessionInDir = false | |
1821 | + } else { | |
1822 | + return makeError( | |
1823 | + `Unknown argument: ${arg}\nRun 'claude remote-control --help' for usage.`, | |
1824 | + ) | |
1825 | + } | |
1826 | + } | |
1827 | + | |
1828 | + // Note: gate check for --spawn/--capacity/--create-session-in-dir is in bridgeMain | |
1829 | + // (gate-aware error). Flag cross-validation happens here. | |
1830 | + | |
1831 | + // --capacity only makes sense for multi-session modes. | |
1832 | + if (spawnMode === 'single-session' && capacity !== undefined) { | |
1833 | + return makeError( | |
1834 | + `--capacity cannot be used with --spawn=session (single-session mode has fixed capacity 1).`, | |
1835 | + ) | |
1836 | + } | |
1837 | + | |
1838 | + // --session-id / --continue resume a specific session on its original | |
1839 | + // environment; incompatible with spawn-related flags (which configure | |
1840 | + // fresh session creation), and mutually exclusive with each other. | |
1841 | + if ( | |
1842 | + (sessionId || continueSession) && | |
1843 | + (spawnMode !== undefined || | |
1844 | + capacity !== undefined || | |
1845 | + createSessionInDir !== undefined) | |
1846 | + ) { | |
1847 | + return makeError( | |
1848 | + `--session-id and --continue cannot be used with --spawn, --capacity, or --create-session-in-dir.`, | |
1849 | + ) | |
1850 | + } | |
1851 | + if (sessionId && continueSession) { | |
1852 | + return makeError(`--session-id and --continue cannot be used together.`) | |
1853 | + } | |
1854 | + | |
1855 | + return { | |
1856 | + verbose, | |
1857 | + sandbox, | |
1858 | + debugFile, | |
1859 | + sessionTimeoutMs, | |
1860 | + permissionMode, | |
1861 | + name, | |
1862 | + spawnMode, | |
1863 | + capacity, | |
1864 | + createSessionInDir, | |
1865 | + sessionId, | |
1866 | + continueSession, | |
1867 | + help, | |
1868 | + } | |
1869 | + | |
1870 | + function makeError(error: string): ParsedArgs { | |
1871 | + return { | |
1872 | + verbose, | |
1873 | + sandbox, | |
1874 | + debugFile, | |
1875 | + sessionTimeoutMs, | |
1876 | + permissionMode, | |
1877 | + name, | |
1878 | + spawnMode, | |
1879 | + capacity, | |
1880 | + createSessionInDir, | |
1881 | + sessionId, | |
1882 | + continueSession, | |
1883 | + help, | |
1884 | + error, | |
1885 | + } | |
1886 | + } | |
1887 | +} | |
1888 | + | |
1889 | +async function printHelp(): Promise<void> { | |
1890 | + // Use EXTERNAL_PERMISSION_MODES for help text — internal modes (bubble) | |
1891 | + // are ant-only and auto is feature-gated; they're still accepted by validation. | |
1892 | + const { EXTERNAL_PERMISSION_MODES } = await import('../types/permissions.js') | |
1893 | + const modes = EXTERNAL_PERMISSION_MODES.join(', ') | |
1894 | + const showServer = await isMultiSessionSpawnEnabled() | |
1895 | + const serverOptions = showServer | |
1896 | + ? ` --spawn <mode> Spawn mode: same-dir, worktree, session | |
1897 | + (default: same-dir) | |
1898 | + --capacity <N> Max concurrent sessions in worktree or | |
1899 | + same-dir mode (default: ${SPAWN_SESSIONS_DEFAULT}) | |
1900 | + --[no-]create-session-in-dir Pre-create a session in the current | |
1901 | + directory; in worktree mode this session | |
1902 | + stays in cwd while on-demand sessions get | |
1903 | + isolated worktrees (default: on) | |
1904 | +` | |
1905 | + : '' | |
1906 | + const serverDescription = showServer | |
1907 | + ? ` | |
1908 | + Remote Control runs as a persistent server that accepts multiple concurrent | |
1909 | + sessions in the current directory. One session is pre-created on start so | |
1910 | + you have somewhere to type immediately. Use --spawn=worktree to isolate | |
1911 | + each on-demand session in its own git worktree, or --spawn=session for | |
1912 | + the classic single-session mode (exits when that session ends). Press 'w' | |
1913 | + during runtime to toggle between same-dir and worktree. | |
1914 | +` | |
1915 | + : '' | |
1916 | + const serverNote = showServer | |
1917 | + ? ` - Worktree mode requires a git repository or WorktreeCreate/WorktreeRemove hooks | |
1918 | +` | |
1919 | + : '' | |
1920 | + const help = ` | |
1921 | +Remote Control - Connect your local environment to a remote bridge | |
1922 | + | |
1923 | +USAGE | |
1924 | + claude remote-control [options] | |
1925 | +OPTIONS | |
1926 | + --name <name> Name for the session shown by the remote bridge | |
1927 | +${ | |
1928 | + feature('KAIROS') | |
1929 | + ? ` -c, --continue Resume the last session in this directory | |
1930 | + --session-id <id> Resume a specific session by ID (cannot be | |
1931 | + used with spawn flags or --continue) | |
1932 | +` | |
1933 | + : '' | |
1934 | +} --permission-mode <mode> Permission mode for spawned sessions | |
1935 | + (${modes}) | |
1936 | + --debug-file <path> Write debug logs to file | |
1937 | + -v, --verbose Enable verbose output | |
1938 | + -h, --help Show this help | |
1939 | +${serverOptions} | |
1940 | +DESCRIPTION | |
1941 | + Remote Control allows you to control sessions on your local device from a | |
1942 | + compatible remote bridge. Run this command in the directory you want to | |
1943 | + work in, then connect from your configured bridge client. | |
1944 | +${serverDescription} | |
1945 | +NOTES | |
1946 | + - You must be logged in with an account that supports your configured bridge | |
1947 | + - Run \`mervelas\` first in the directory to accept the workspace trust dialog | |
1948 | +${serverNote}` | |
1949 | + // biome-ignore lint/suspicious/noConsole: intentional help output | |
1950 | + console.log(help) | |
1951 | +} | |
1952 | + | |
1953 | +const TITLE_MAX_LEN = 80 | |
1954 | + | |
1955 | +/** Derive a session title from a user message: first line, truncated. */ | |
1956 | +function deriveSessionTitle(text: string): string { | |
1957 | + // Collapse whitespace — newlines/tabs would break the single-line status display. | |
1958 | + const flat = text.replace(/\s+/g, ' ').trim() | |
1959 | + return truncateToWidth(flat, TITLE_MAX_LEN) | |
1960 | +} | |
1961 | + | |
1962 | +/** | |
1963 | + * One-shot fetch of a session's title via GET /v1/sessions/{id}. | |
1964 | + * | |
1965 | + * Uses `getBridgeSession` from createSession.ts (ccr-byoc headers + org UUID) | |
1966 | + * rather than the environments-level bridgeApi client, whose headers make the | |
1967 | + * Sessions API return 404. Returns undefined if the session has no title yet | |
1968 | + * or the fetch fails — the caller falls back to deriving a title from the | |
1969 | + * first user message. | |
1970 | + */ | |
1971 | +async function fetchSessionTitle( | |
1972 | + compatSessionId: string, | |
1973 | + baseUrl: string, | |
1974 | +): Promise<string | undefined> { | |
1975 | + const { getBridgeSession } = await import('./createSession.js') | |
1976 | + const session = await getBridgeSession(compatSessionId, { baseUrl }) | |
1977 | + return session?.title || undefined | |
1978 | +} | |
1979 | + | |
1980 | +export async function bridgeMain(args: string[]): Promise<void> { | |
1981 | + const parsed = parseArgs(args) | |
1982 | + | |
1983 | + if (parsed.help) { | |
1984 | + await printHelp() | |
1985 | + return | |
1986 | + } | |
1987 | + if (parsed.error) { | |
1988 | + // biome-ignore lint/suspicious/noConsole: intentional error output | |
1989 | + console.error(`Error: ${parsed.error}`) | |
1990 | + // eslint-disable-next-line custom-rules/no-process-exit | |
1991 | + process.exit(1) | |
1992 | + } | |
1993 | + | |
1994 | + const { | |
1995 | + verbose, | |
1996 | + sandbox, | |
1997 | + debugFile, | |
1998 | + sessionTimeoutMs, | |
1999 | + permissionMode, | |
2000 | + name, | |
2001 | + spawnMode: parsedSpawnMode, | |
2002 | + capacity: parsedCapacity, | |
2003 | + createSessionInDir: parsedCreateSessionInDir, | |
2004 | + sessionId: parsedSessionId, | |
2005 | + continueSession, | |
2006 | + } = parsed | |
2007 | + // Mutable so --continue can set it from the pointer file. The #20460 | |
2008 | + // resume flow below then treats it the same as an explicit --session-id. | |
2009 | + let resumeSessionId = parsedSessionId | |
2010 | + // When --continue found a pointer, this is the directory it came from | |
2011 | + // (may be a worktree sibling, not `dir`). On resume-flow deterministic | |
2012 | + // failure, clear THIS file so --continue doesn't keep hitting the same | |
2013 | + // dead session. Undefined for explicit --session-id (leaves pointer alone). | |
2014 | + let resumePointerDir: string | undefined | |
2015 | + | |
2016 | + const usedMultiSessionFeature = | |
2017 | + parsedSpawnMode !== undefined || | |
2018 | + parsedCapacity !== undefined || | |
2019 | + parsedCreateSessionInDir !== undefined | |
2020 | + | |
2021 | + // Validate permission mode early so the user gets an error before | |
2022 | + // the bridge starts polling for work. | |
2023 | + if (permissionMode !== undefined) { | |
2024 | + const { PERMISSION_MODES } = await import('../types/permissions.js') | |
2025 | + const valid: readonly string[] = PERMISSION_MODES | |
2026 | + if (!valid.includes(permissionMode)) { | |
2027 | + // biome-ignore lint/suspicious/noConsole: intentional error output | |
2028 | + console.error( | |
2029 | + `Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`, | |
2030 | + ) | |
2031 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2032 | + process.exit(1) | |
2033 | + } | |
2034 | + } | |
2035 | + | |
2036 | + const dir = resolve('.') | |
2037 | + | |
2038 | + // The bridge fast-path bypasses init.ts, so we must enable config reading | |
2039 | + // before any code that transitively calls getGlobalConfig() | |
2040 | + const { enableConfigs, checkHasTrustDialogAccepted } = await import( | |
2041 | + '../utils/config.js' | |
2042 | + ) | |
2043 | + enableConfigs() | |
2044 | + | |
2045 | + // Initialize analytics and error reporting sinks. The bridge bypasses the | |
2046 | + // setup() init flow, so we call initSinks() directly to attach sinks here. | |
2047 | + const { initSinks } = await import('../utils/sinks.js') | |
2048 | + initSinks() | |
2049 | + | |
2050 | + // Gate-aware validation: --spawn / --capacity / --create-session-in-dir require | |
2051 | + // the multi-session gate. parseArgs has already validated flag combinations; | |
2052 | + // here we only check the gate since that requires an async GrowthBook call. | |
2053 | + // Runs after enableConfigs() (GrowthBook cache reads global config) and after | |
2054 | + // initSinks() so the denial event can be enqueued. | |
2055 | + const multiSessionEnabled = await isMultiSessionSpawnEnabled() | |
2056 | + if (usedMultiSessionFeature && !multiSessionEnabled) { | |
2057 | + await logEventAsync('tengu_bridge_multi_session_denied', { | |
2058 | + used_spawn: parsedSpawnMode !== undefined, | |
2059 | + used_capacity: parsedCapacity !== undefined, | |
2060 | + used_create_session_in_dir: parsedCreateSessionInDir !== undefined, | |
2061 | + }) | |
2062 | + // logEventAsync only enqueues — process.exit() discards buffered events. | |
2063 | + // Flush explicitly, capped at 500ms to match gracefulShutdown.ts. | |
2064 | + // (sleep() doesn't unref its timer, but process.exit() follows immediately | |
2065 | + // so the ref'd timer can't delay shutdown.) | |
2066 | + await Promise.race([ | |
2067 | + Promise.all([shutdown1PEventLogging(), shutdownDatadog()]), | |
2068 | + sleep(500, undefined, { unref: true }), | |
2069 | + ]).catch(() => {}) | |
2070 | + // biome-ignore lint/suspicious/noConsole: intentional error output | |
2071 | + console.error( | |
2072 | + 'Error: Multi-session Remote Control is not enabled for your account yet.', | |
2073 | + ) | |
2074 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2075 | + process.exit(1) | |
2076 | + } | |
2077 | + | |
2078 | + // Set the bootstrap CWD so that trust checks, project config lookups, and | |
2079 | + // git utilities (getBranch, getRemoteUrl) resolve against the correct path. | |
2080 | + const { setOriginalCwd, setCwdState } = await import('../bootstrap/state.js') | |
2081 | + setOriginalCwd(dir) | |
2082 | + setCwdState(dir) | |
2083 | + | |
2084 | + // The bridge bypasses main.tsx (which renders the interactive TrustDialog via showSetupScreens), | |
2085 | + // so we must verify trust was previously established by a normal `claude` session. | |
2086 | + if (!checkHasTrustDialogAccepted()) { | |
2087 | + // biome-ignore lint/suspicious/noConsole:: intentional console output | |
2088 | + console.error( | |
2089 | + `Error: Workspace not trusted. Please run \`claude\` in ${dir} first to review and accept the workspace trust dialog.`, | |
2090 | + ) | |
2091 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2092 | + process.exit(1) | |
2093 | + } | |
2094 | + | |
2095 | + // Resolve auth | |
2096 | + const { clearOAuthTokenCache, checkAndRefreshOAuthTokenIfNeeded } = | |
2097 | + await import('../utils/auth.js') | |
2098 | + const { getBridgeAccessToken, getBridgeBaseUrl } = await import( | |
2099 | + './bridgeConfig.js' | |
2100 | + ) | |
2101 | + | |
2102 | + const bridgeToken = getBridgeAccessToken() | |
2103 | + if (!bridgeToken) { | |
2104 | + // biome-ignore lint/suspicious/noConsole:: intentional console output | |
2105 | + console.error(BRIDGE_LOGIN_ERROR) | |
2106 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2107 | + process.exit(1) | |
2108 | + } | |
2109 | + | |
2110 | + // First-time remote dialog — explain what bridge does and get consent | |
2111 | + const { | |
2112 | + getGlobalConfig, | |
2113 | + saveGlobalConfig, | |
2114 | + getCurrentProjectConfig, | |
2115 | + saveCurrentProjectConfig, | |
2116 | + } = await import('../utils/config.js') | |
2117 | + if (!getGlobalConfig().remoteDialogSeen) { | |
2118 | + const readline = await import('readline') | |
2119 | + const rl = readline.createInterface({ | |
2120 | + input: process.stdin, | |
2121 | + output: process.stdout, | |
2122 | + }) | |
2123 | + // biome-ignore lint/suspicious/noConsole:: intentional console output | |
2124 | + console.log( | |
2125 | + '\nRemote Control lets you access this CLI session from a compatible remote bridge, so you can pick up where you left off on another device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n', | |
2126 | + ) | |
2127 | + const answer = await new Promise<string>(resolve => { | |
2128 | + rl.question('Enable Remote Control? (y/n) ', resolve) | |
2129 | + }) | |
2130 | + rl.close() | |
2131 | + saveGlobalConfig(current => { | |
2132 | + if (current.remoteDialogSeen) return current | |
2133 | + return { ...current, remoteDialogSeen: true } | |
2134 | + }) | |
2135 | + if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') { | |
2136 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2137 | + process.exit(0) | |
2138 | + } | |
2139 | + } | |
2140 | + | |
2141 | + // --continue: resolve the most recent session from the crash-recovery | |
2142 | + // pointer and chain into the #20460 --session-id flow. Worktree-aware: | |
2143 | + // checks current dir first (fast path, zero exec), then fans out to git | |
2144 | + // worktree siblings if that misses — the REPL bridge writes to | |
2145 | + // getOriginalCwd() which EnterWorktreeTool/activeWorktreeSession can | |
2146 | + // point at a worktree while the user's shell is at the repo root. | |
2147 | + // KAIROS-gated at parseArgs — continueSession is always false in external | |
2148 | + // builds, so this block tree-shakes. | |
2149 | + if (feature('KAIROS') && continueSession) { | |
2150 | + const { readBridgePointerAcrossWorktrees } = await import( | |
2151 | + './bridgePointer.js' | |
2152 | + ) | |
2153 | + const found = await readBridgePointerAcrossWorktrees(dir) | |
2154 | + if (!found) { | |
2155 | + // biome-ignore lint/suspicious/noConsole: intentional error output | |
2156 | + console.error( | |
2157 | + `Error: No recent session found in this directory or its worktrees. Run \`claude remote-control\` to start a new one.`, | |
2158 | + ) | |
2159 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2160 | + process.exit(1) | |
2161 | + } | |
2162 | + const { pointer, dir: pointerDir } = found | |
2163 | + const ageMin = Math.round(pointer.ageMs / 60_000) | |
2164 | + const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h` | |
2165 | + const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : '' | |
2166 | + // biome-ignore lint/suspicious/noConsole: intentional info output | |
2167 | + console.error( | |
2168 | + `Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`, | |
2169 | + ) | |
2170 | + resumeSessionId = pointer.sessionId | |
2171 | + // Track where the pointer came from so the #20460 exit(1) paths below | |
2172 | + // clear the RIGHT file on deterministic failure — otherwise --continue | |
2173 | + // would keep hitting the same dead session. May be a worktree sibling. | |
2174 | + resumePointerDir = pointerDir | |
2175 | + } | |
2176 | + | |
2177 | + // In production, baseUrl is the Anthropic API (from OAuth config). | |
2178 | + // CLAUDE_BRIDGE_BASE_URL overrides this for ant local dev only. | |
2179 | + const baseUrl = getBridgeBaseUrl() | |
2180 | + | |
2181 | + // For non-localhost targets, require HTTPS to protect credentials. | |
2182 | + if ( | |
2183 | + baseUrl.startsWith('http://') && | |
2184 | + !baseUrl.includes('localhost') && | |
2185 | + !baseUrl.includes('127.0.0.1') | |
2186 | + ) { | |
2187 | + // biome-ignore lint/suspicious/noConsole:: intentional console output | |
2188 | + console.error( | |
2189 | + 'Error: Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.', | |
2190 | + ) | |
2191 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2192 | + process.exit(1) | |
2193 | + } | |
2194 | + | |
2195 | + // Session ingress URL for WebSocket connections. In production this is the | |
2196 | + // same as baseUrl (Envoy routes /v1/session_ingress/* to session-ingress). | |
2197 | + // Locally, session-ingress runs on a different port (9413) than the | |
2198 | + // contain-provide-api (8211), so CLAUDE_BRIDGE_SESSION_INGRESS_URL must be | |
2199 | + // set explicitly. Ant-only, matching CLAUDE_BRIDGE_BASE_URL. | |
2200 | + const sessionIngressUrl = | |
2201 | + process.env.USER_TYPE === 'ant' && | |
2202 | + process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL | |
2203 | + ? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL | |
2204 | + : baseUrl | |
2205 | + | |
2206 | + const { getBranch, getRemoteUrl, findGitRoot } = await import( | |
2207 | + '../utils/git.js' | |
2208 | + ) | |
2209 | + | |
2210 | + // Precheck worktree availability for the first-run dialog and the `w` | |
2211 | + // toggle. Unconditional so we know upfront whether worktree is an option. | |
2212 | + const { hasWorktreeCreateHook } = await import('../utils/hooks.js') | |
2213 | + const worktreeAvailable = hasWorktreeCreateHook() || findGitRoot(dir) !== null | |
2214 | + | |
2215 | + // Load saved per-project spawn-mode preference. Gated by multiSessionEnabled | |
2216 | + // so a GrowthBook rollback cleanly reverts users to single-session — | |
2217 | + // otherwise a saved pref would silently re-enable multi-session behavior | |
2218 | + // (worktree isolation, 32 max sessions, w toggle) despite the gate being off. | |
2219 | + // Also guard against a stale worktree pref left over from when this dir WAS | |
2220 | + // a git repo (or the user copied config) — clear it on disk so the warning | |
2221 | + // doesn't repeat on every launch. | |
2222 | + let savedSpawnMode = multiSessionEnabled | |
2223 | + ? getCurrentProjectConfig().remoteControlSpawnMode | |
2224 | + : undefined | |
2225 | + if (savedSpawnMode === 'worktree' && !worktreeAvailable) { | |
2226 | + // biome-ignore lint/suspicious/noConsole: intentional warning output | |
2227 | + console.error( | |
2228 | + 'Warning: Saved spawn mode is worktree but this directory is not a git repository. Falling back to same-dir.', | |
2229 | + ) | |
2230 | + savedSpawnMode = undefined | |
2231 | + saveCurrentProjectConfig(current => { | |
2232 | + if (current.remoteControlSpawnMode === undefined) return current | |
2233 | + return { ...current, remoteControlSpawnMode: undefined } | |
2234 | + }) | |
2235 | + } | |
2236 | + | |
2237 | + // First-run spawn-mode choice: ask once per project when the choice is | |
2238 | + // meaningful (gate on, both modes available, no explicit override, not | |
2239 | + // resuming). Saves to ProjectConfig so subsequent runs skip this. | |
2240 | + if ( | |
2241 | + multiSessionEnabled && | |
2242 | + !savedSpawnMode && | |
2243 | + worktreeAvailable && | |
2244 | + parsedSpawnMode === undefined && | |
2245 | + !resumeSessionId && | |
2246 | + process.stdin.isTTY | |
2247 | + ) { | |
2248 | + const readline = await import('readline') | |
2249 | + const rl = readline.createInterface({ | |
2250 | + input: process.stdin, | |
2251 | + output: process.stdout, | |
2252 | + }) | |
2253 | + // biome-ignore lint/suspicious/noConsole: intentional dialog output | |
2254 | + console.log( | |
2255 | + `\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Mervelas on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` + | |
2256 | + `Spawn mode for this project:\n` + | |
2257 | + ` [1] same-dir \u2014 sessions share the current directory (default)\n` + | |
2258 | + ` [2] worktree \u2014 each session gets an isolated git worktree\n\n` + | |
2259 | + `This can be changed later or explicitly set with --spawn=same-dir or --spawn=worktree.\n`, | |
2260 | + ) | |
2261 | + const answer = await new Promise<string>(resolve => { | |
2262 | + rl.question('Choose [1/2] (default: 1): ', resolve) | |
2263 | + }) | |
2264 | + rl.close() | |
2265 | + const chosen: 'same-dir' | 'worktree' = | |
2266 | + answer.trim() === '2' ? 'worktree' : 'same-dir' | |
2267 | + savedSpawnMode = chosen | |
2268 | + logEvent('tengu_bridge_spawn_mode_chosen', { | |
2269 | + spawn_mode: | |
2270 | + chosen as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
2271 | + }) | |
2272 | + saveCurrentProjectConfig(current => { | |
2273 | + if (current.remoteControlSpawnMode === chosen) return current | |
2274 | + return { ...current, remoteControlSpawnMode: chosen } | |
2275 | + }) | |
2276 | + } | |
2277 | + | |
2278 | + // Determine effective spawn mode. | |
2279 | + // Precedence: resume > explicit --spawn > saved project pref > gate default | |
2280 | + // - resuming via --continue / --session-id: always single-session (resume | |
2281 | + // targets one specific session in its original directory) | |
2282 | + // - explicit --spawn flag: use that value directly (does not persist) | |
2283 | + // - saved ProjectConfig.remoteControlSpawnMode: set by first-run dialog or `w` | |
2284 | + // - default with gate on: same-dir (persistent multi-session, shared cwd) | |
2285 | + // - default with gate off: single-session (unchanged legacy behavior) | |
2286 | + // Track how spawn mode was determined, for rollout analytics. | |
2287 | + type SpawnModeSource = 'resume' | 'flag' | 'saved' | 'gate_default' | |
2288 | + let spawnModeSource: SpawnModeSource | |
2289 | + let spawnMode: SpawnMode | |
2290 | + if (resumeSessionId) { | |
2291 | + spawnMode = 'single-session' | |
2292 | + spawnModeSource = 'resume' | |
2293 | + } else if (parsedSpawnMode !== undefined) { | |
2294 | + spawnMode = parsedSpawnMode | |
2295 | + spawnModeSource = 'flag' | |
2296 | + } else if (savedSpawnMode !== undefined) { | |
2297 | + spawnMode = savedSpawnMode | |
2298 | + spawnModeSource = 'saved' | |
2299 | + } else { | |
2300 | + spawnMode = multiSessionEnabled ? 'same-dir' : 'single-session' | |
2301 | + spawnModeSource = 'gate_default' | |
2302 | + } | |
2303 | + const maxSessions = | |
2304 | + spawnMode === 'single-session' | |
2305 | + ? 1 | |
2306 | + : (parsedCapacity ?? SPAWN_SESSIONS_DEFAULT) | |
2307 | + // Pre-create an empty session on start so the user has somewhere to type | |
2308 | + // immediately, running in the current directory (exempted from worktree | |
2309 | + // creation in the spawn loop). On by default; --no-create-session-in-dir | |
2310 | + // opts out for a pure on-demand server where every session is isolated. | |
2311 | + // The effectiveResumeSessionId guard at the creation site handles the | |
2312 | + // resume case (skip creation when resume succeeded; fall through to | |
2313 | + // fresh creation on env-mismatch fallback). | |
2314 | + const preCreateSession = parsedCreateSessionInDir ?? true | |
2315 | + | |
2316 | + // Without --continue: a leftover pointer means the previous run didn't | |
2317 | + // shut down cleanly (crash, kill -9, terminal closed). Clear it so the | |
2318 | + // stale env doesn't linger past its relevance. Runs in all modes | |
2319 | + // (clearBridgePointer is a no-op when no file exists) — covers the | |
2320 | + // gate-transition case where a user crashed in single-session mode then | |
2321 | + // starts fresh in worktree mode. Only single-session mode writes new | |
2322 | + // pointers. | |
2323 | + if (!resumeSessionId) { | |
2324 | + const { clearBridgePointer } = await import('./bridgePointer.js') | |
2325 | + await clearBridgePointer(dir) | |
2326 | + } | |
2327 | + | |
2328 | + // Worktree mode requires either git or WorktreeCreate/WorktreeRemove hooks. | |
2329 | + // Only reachable via explicit --spawn=worktree (default is same-dir); | |
2330 | + // saved worktree pref was already guarded above. | |
2331 | + if (spawnMode === 'worktree' && !worktreeAvailable) { | |
2332 | + // biome-ignore lint/suspicious/noConsole: intentional error output | |
2333 | + console.error( | |
2334 | + `Error: Worktree mode requires a git repository or WorktreeCreate hooks configured. Use --spawn=session for single-session mode.`, | |
2335 | + ) | |
2336 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2337 | + process.exit(1) | |
2338 | + } | |
2339 | + | |
2340 | + const branch = await getBranch() | |
2341 | + const gitRepoUrl = await getRemoteUrl() | |
2342 | + const machineName = hostname() | |
2343 | + const bridgeId = randomUUID() | |
2344 | + | |
2345 | + const { handleOAuth401Error } = await import('../utils/auth.js') | |
2346 | + const api = createBridgeApiClient({ | |
2347 | + baseUrl, | |
2348 | + getAccessToken: getBridgeAccessToken, | |
2349 | + runnerVersion: MACRO.VERSION, | |
2350 | + onDebug: logForDebugging, | |
2351 | + onAuth401: handleOAuth401Error, | |
2352 | + getTrustedDeviceToken, | |
2353 | + }) | |
2354 | + | |
2355 | + // When resuming a session via --session-id, fetch it to learn its | |
2356 | + // environment_id and reuse that for registration (idempotent on the | |
2357 | + // backend). Left undefined otherwise — the backend rejects | |
2358 | + // client-generated UUIDs and will allocate a fresh environment. | |
2359 | + // feature('KAIROS') gate: --session-id is ant-only; parseArgs already | |
2360 | + // rejects the flag when the gate is off, so resumeSessionId is always | |
2361 | + // undefined here in external builds — this guard is for tree-shaking. | |
2362 | + let reuseEnvironmentId: string | undefined | |
2363 | + if (feature('KAIROS') && resumeSessionId) { | |
2364 | + try { | |
2365 | + validateBridgeId(resumeSessionId, 'sessionId') | |
2366 | + } catch { | |
2367 | + // biome-ignore lint/suspicious/noConsole: intentional error output | |
2368 | + console.error( | |
2369 | + `Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`, | |
2370 | + ) | |
2371 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2372 | + process.exit(1) | |
2373 | + } | |
2374 | + // Proactively refresh the OAuth token — getBridgeSession uses raw axios | |
2375 | + // without the withOAuthRetry 401-refresh logic. An expired-but-present | |
2376 | + // token would otherwise produce a misleading "not found" error. | |
2377 | + await checkAndRefreshOAuthTokenIfNeeded() | |
2378 | + clearOAuthTokenCache() | |
2379 | + const { getBridgeSession } = await import('./createSession.js') | |
2380 | + const session = await getBridgeSession(resumeSessionId, { | |
2381 | + baseUrl, | |
2382 | + getAccessToken: getBridgeAccessToken, | |
2383 | + }) | |
2384 | + if (!session) { | |
2385 | + // Session gone on server → pointer is stale. Clear it so the user | |
2386 | + // isn't re-prompted next launch. (Explicit --session-id leaves the | |
2387 | + // pointer alone — it's an independent file they may not even have.) | |
2388 | + // resumePointerDir may be a worktree sibling — clear THAT file. | |
2389 | + if (resumePointerDir) { | |
2390 | + const { clearBridgePointer } = await import('./bridgePointer.js') | |
2391 | + await clearBridgePointer(resumePointerDir) | |
2392 | + } | |
2393 | + // biome-ignore lint/suspicious/noConsole: intentional error output | |
2394 | + console.error( | |
2395 | + `Error: Session ${resumeSessionId} not found. It may have been archived or expired, or your login may have lapsed (run \`claude /login\`).`, | |
2396 | + ) | |
2397 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2398 | + process.exit(1) | |
2399 | + } | |
2400 | + if (!session.environment_id) { | |
2401 | + if (resumePointerDir) { | |
2402 | + const { clearBridgePointer } = await import('./bridgePointer.js') | |
2403 | + await clearBridgePointer(resumePointerDir) | |
2404 | + } | |
2405 | + // biome-ignore lint/suspicious/noConsole: intentional error output | |
2406 | + console.error( | |
2407 | + `Error: Session ${resumeSessionId} has no environment_id. It may never have been attached to a bridge.`, | |
2408 | + ) | |
2409 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2410 | + process.exit(1) | |
2411 | + } | |
2412 | + reuseEnvironmentId = session.environment_id | |
2413 | + logForDebugging( | |
2414 | + `[bridge:init] Resuming session ${resumeSessionId} on environment ${reuseEnvironmentId}`, | |
2415 | + ) | |
2416 | + } | |
2417 | + | |
2418 | + const config: BridgeConfig = { | |
2419 | + dir, | |
2420 | + machineName, | |
2421 | + branch, | |
2422 | + gitRepoUrl, | |
2423 | + maxSessions, | |
2424 | + spawnMode, | |
2425 | + verbose, | |
2426 | + sandbox, | |
2427 | + bridgeId, | |
2428 | + workerType: 'claude_code', | |
2429 | + environmentId: randomUUID(), | |
2430 | + reuseEnvironmentId, | |
2431 | + apiBaseUrl: baseUrl, | |
2432 | + sessionIngressUrl, | |
2433 | + debugFile, | |
2434 | + sessionTimeoutMs, | |
2435 | + } | |
2436 | + | |
2437 | + logForDebugging( | |
2438 | + `[bridge:init] bridgeId=${bridgeId}${reuseEnvironmentId ? ` reuseEnvironmentId=${reuseEnvironmentId}` : ''} dir=${dir} branch=${branch} gitRepoUrl=${gitRepoUrl} machine=${machineName}`, | |
2439 | + ) | |
2440 | + logForDebugging( | |
2441 | + `[bridge:init] apiBaseUrl=${baseUrl} sessionIngressUrl=${sessionIngressUrl}`, | |
2442 | + ) | |
2443 | + logForDebugging( | |
2444 | + `[bridge:init] sandbox=${sandbox}${debugFile ? ` debugFile=${debugFile}` : ''}`, | |
2445 | + ) | |
2446 | + | |
2447 | + // Register the bridge environment before entering the poll loop. | |
2448 | + let environmentId: string | |
2449 | + let environmentSecret: string | |
2450 | + try { | |
2451 | + const reg = await api.registerBridgeEnvironment(config) | |
2452 | + environmentId = reg.environment_id | |
2453 | + environmentSecret = reg.environment_secret | |
2454 | + } catch (err) { | |
2455 | + logEvent('tengu_bridge_registration_failed', { | |
2456 | + status: err instanceof BridgeFatalError ? err.status : undefined, | |
2457 | + }) | |
2458 | + // Registration failures are fatal — print a clean message instead of a stack trace. | |
2459 | + // biome-ignore lint/suspicious/noConsole:: intentional console output | |
2460 | + console.error( | |
2461 | + err instanceof BridgeFatalError && err.status === 404 | |
2462 | + ? 'Remote Control environments are not available for your account.' | |
2463 | + : `Error: ${errorMessage(err)}`, | |
2464 | + ) | |
2465 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2466 | + process.exit(1) | |
2467 | + } | |
2468 | + | |
2469 | + // Tracks whether the --session-id resume flow completed successfully. | |
2470 | + // Used below to skip fresh session creation and seed initialSessionId. | |
2471 | + // Cleared on env mismatch so we gracefully fall back to a new session. | |
2472 | + let effectiveResumeSessionId: string | undefined | |
2473 | + if (feature('KAIROS') && resumeSessionId) { | |
2474 | + if (reuseEnvironmentId && environmentId !== reuseEnvironmentId) { | |
2475 | + // Backend returned a different environment_id — the original env | |
2476 | + // expired or was reaped. Reconnect won't work against the new env | |
2477 | + // (session is bound to the old one). Log to sentry for visibility | |
2478 | + // and fall through to fresh session creation on the new env. | |
2479 | + logError( | |
2480 | + new Error( | |
2481 | + `Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`, | |
2482 | + ), | |
2483 | + ) | |
2484 | + // biome-ignore lint/suspicious/noConsole: intentional warning output | |
2485 | + console.warn( | |
2486 | + `Warning: Could not resume session ${resumeSessionId} — its environment has expired. Creating a fresh session instead.`, | |
2487 | + ) | |
2488 | + // Don't deregister — we're going to use this new environment. | |
2489 | + // effectiveResumeSessionId stays undefined → fresh session path below. | |
2490 | + } else { | |
2491 | + // Force-stop any stale worker instances for this session and re-queue | |
2492 | + // it so our poll loop picks it up. Must happen after registration so | |
2493 | + // the backend knows a live worker exists for the environment. | |
2494 | + // | |
2495 | + // The pointer stores a session_* ID but /bridge/reconnect looks | |
2496 | + // sessions up by their infra tag (cse_*) when ccr_v2_compat_enabled | |
2497 | + // is on. Try both; the conversion is a no-op if already cse_*. | |
2498 | + const infraResumeId = toInfraSessionId(resumeSessionId) | |
2499 | + const reconnectCandidates = | |
2500 | + infraResumeId === resumeSessionId | |
2501 | + ? [resumeSessionId] | |
2502 | + : [resumeSessionId, infraResumeId] | |
2503 | + let reconnected = false | |
2504 | + let lastReconnectErr: unknown | |
2505 | + for (const candidateId of reconnectCandidates) { | |
2506 | + try { | |
2507 | + await api.reconnectSession(environmentId, candidateId) | |
2508 | + logForDebugging( | |
2509 | + `[bridge:init] Session ${candidateId} re-queued via bridge/reconnect`, | |
2510 | + ) | |
2511 | + effectiveResumeSessionId = resumeSessionId | |
2512 | + reconnected = true | |
2513 | + break | |
2514 | + } catch (err) { | |
2515 | + lastReconnectErr = err | |
2516 | + logForDebugging( | |
2517 | + `[bridge:init] reconnectSession(${candidateId}) failed: ${errorMessage(err)}`, | |
2518 | + ) | |
2519 | + } | |
2520 | + } | |
2521 | + if (!reconnected) { | |
2522 | + const err = lastReconnectErr | |
2523 | + | |
2524 | + // Do NOT deregister on transient reconnect failure — at this point | |
2525 | + // environmentId IS the session's own environment. Deregistering | |
2526 | + // would make retry impossible. The backend's 4h TTL cleans up. | |
2527 | + const isFatal = err instanceof BridgeFatalError | |
2528 | + // Clear pointer only on fatal reconnect failure. Transient failures | |
2529 | + // ("try running the same command again") should keep the pointer so | |
2530 | + // next launch re-prompts — that IS the retry mechanism. | |
2531 | + if (resumePointerDir && isFatal) { | |
2532 | + const { clearBridgePointer } = await import('./bridgePointer.js') | |
2533 | + await clearBridgePointer(resumePointerDir) | |
2534 | + } | |
2535 | + // biome-ignore lint/suspicious/noConsole: intentional error output | |
2536 | + console.error( | |
2537 | + isFatal | |
2538 | + ? `Error: ${errorMessage(err)}` | |
2539 | + : `Error: Failed to reconnect session ${resumeSessionId}: ${errorMessage(err)}\nThe session may still be resumable — try running the same command again.`, | |
2540 | + ) | |
2541 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2542 | + process.exit(1) | |
2543 | + } | |
2544 | + } | |
2545 | + } | |
2546 | + | |
2547 | + logForDebugging( | |
2548 | + `[bridge:init] Registered, server environmentId=${environmentId}`, | |
2549 | + ) | |
2550 | + const startupPollConfig = getPollIntervalConfig() | |
2551 | + logEvent('tengu_bridge_started', { | |
2552 | + max_sessions: config.maxSessions, | |
2553 | + has_debug_file: !!config.debugFile, | |
2554 | + sandbox: config.sandbox, | |
2555 | + verbose: config.verbose, | |
2556 | + heartbeat_interval_ms: | |
2557 | + startupPollConfig.non_exclusive_heartbeat_interval_ms, | |
2558 | + spawn_mode: | |
2559 | + config.spawnMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
2560 | + spawn_mode_source: | |
2561 | + spawnModeSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
2562 | + multi_session_gate: multiSessionEnabled, | |
2563 | + pre_create_session: preCreateSession, | |
2564 | + worktree_available: worktreeAvailable, | |
2565 | + }) | |
2566 | + logForDiagnosticsNoPII('info', 'bridge_started', { | |
2567 | + max_sessions: config.maxSessions, | |
2568 | + sandbox: config.sandbox, | |
2569 | + spawn_mode: config.spawnMode, | |
2570 | + }) | |
2571 | + | |
2572 | + const spawner = createSessionSpawner({ | |
2573 | + execPath: process.execPath, | |
2574 | + scriptArgs: spawnScriptArgs(), | |
2575 | + env: process.env, | |
2576 | + verbose, | |
2577 | + sandbox, | |
2578 | + debugFile, | |
2579 | + permissionMode, | |
2580 | + onDebug: logForDebugging, | |
2581 | + onActivity: (sessionId, activity) => { | |
2582 | + logForDebugging( | |
2583 | + `[bridge:activity] sessionId=${sessionId} ${activity.type} ${activity.summary}`, | |
2584 | + ) | |
2585 | + }, | |
2586 | + onPermissionRequest: (sessionId, request, _accessToken) => { | |
2587 | + logForDebugging( | |
2588 | + `[bridge:perm] sessionId=${sessionId} tool=${request.request.tool_name} request_id=${request.request_id} (not auto-approving)`, | |
2589 | + ) | |
2590 | + }, | |
2591 | + }) | |
2592 | + | |
2593 | + const logger = createBridgeLogger({ verbose }) | |
2594 | + const { parseGitHubRepository } = await import('../utils/detectRepository.js') | |
2595 | + const ownerRepo = gitRepoUrl ? parseGitHubRepository(gitRepoUrl) : null | |
2596 | + // Use the repo name from the parsed owner/repo, or fall back to the dir basename | |
2597 | + const repoName = ownerRepo ? ownerRepo.split('/').pop()! : basename(dir) | |
2598 | + logger.setRepoInfo(repoName, branch) | |
2599 | + | |
2600 | + // `w` toggle is available iff we're in a multi-session mode AND worktree | |
2601 | + // is a valid option. When unavailable, the mode suffix and hint are hidden. | |
2602 | + const toggleAvailable = spawnMode !== 'single-session' && worktreeAvailable | |
2603 | + if (toggleAvailable) { | |
2604 | + // Safe cast: spawnMode is not single-session (checked above), and the | |
2605 | + // saved-worktree-in-non-git guard + exit check above ensure worktree | |
2606 | + // is only reached when available. | |
2607 | + logger.setSpawnModeDisplay(spawnMode as 'same-dir' | 'worktree') | |
2608 | + } | |
2609 | + | |
2610 | + // Listen for keys: space toggles QR code, w toggles spawn mode | |
2611 | + const onStdinData = (data: Buffer): void => { | |
2612 | + if (data[0] === 0x03 || data[0] === 0x04) { | |
2613 | + // Ctrl+C / Ctrl+D — trigger graceful shutdown | |
2614 | + process.emit('SIGINT') | |
2615 | + return | |
2616 | + } | |
2617 | + if (data[0] === 0x20 /* space */) { | |
2618 | + logger.toggleQr() | |
2619 | + return | |
2620 | + } | |
2621 | + if (data[0] === 0x77 /* 'w' */) { | |
2622 | + if (!toggleAvailable) return | |
2623 | + const newMode: 'same-dir' | 'worktree' = | |
2624 | + config.spawnMode === 'same-dir' ? 'worktree' : 'same-dir' | |
2625 | + config.spawnMode = newMode | |
2626 | + logEvent('tengu_bridge_spawn_mode_toggled', { | |
2627 | + spawn_mode: | |
2628 | + newMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
2629 | + }) | |
2630 | + logger.logStatus( | |
2631 | + newMode === 'worktree' | |
2632 | + ? 'Spawn mode: worktree (new sessions get isolated git worktrees)' | |
2633 | + : 'Spawn mode: same-dir (new sessions share the current directory)', | |
2634 | + ) | |
2635 | + logger.setSpawnModeDisplay(newMode) | |
2636 | + logger.refreshDisplay() | |
2637 | + saveCurrentProjectConfig(current => { | |
2638 | + if (current.remoteControlSpawnMode === newMode) return current | |
2639 | + return { ...current, remoteControlSpawnMode: newMode } | |
2640 | + }) | |
2641 | + return | |
2642 | + } | |
2643 | + } | |
2644 | + if (process.stdin.isTTY) { | |
2645 | + process.stdin.setRawMode(true) | |
2646 | + process.stdin.resume() | |
2647 | + process.stdin.on('data', onStdinData) | |
2648 | + } | |
2649 | + | |
2650 | + const controller = new AbortController() | |
2651 | + const onSigint = (): void => { | |
2652 | + logForDebugging('[bridge:shutdown] SIGINT received, shutting down') | |
2653 | + controller.abort() | |
2654 | + } | |
2655 | + const onSigterm = (): void => { | |
2656 | + logForDebugging('[bridge:shutdown] SIGTERM received, shutting down') | |
2657 | + controller.abort() | |
2658 | + } | |
2659 | + process.on('SIGINT', onSigint) | |
2660 | + process.on('SIGTERM', onSigterm) | |
2661 | + | |
2662 | + // Auto-create an empty session so the user has somewhere to type | |
2663 | + // immediately (matching /remote-control behavior). Controlled by | |
2664 | + // preCreateSession: on by default; --no-create-session-in-dir opts out. | |
2665 | + // When a --session-id resume succeeded, skip creation entirely — the | |
2666 | + // session already exists and bridge/reconnect has re-queued it. | |
2667 | + // When resume was requested but failed on env mismatch, effectiveResumeSessionId | |
2668 | + // is undefined, so we fall through to fresh session creation (honoring the | |
2669 | + // "Creating a fresh session instead" warning printed above). | |
2670 | + let initialSessionId: string | null = | |
2671 | + feature('KAIROS') && effectiveResumeSessionId | |
2672 | + ? effectiveResumeSessionId | |
2673 | + : null | |
2674 | + if (preCreateSession && !(feature('KAIROS') && effectiveResumeSessionId)) { | |
2675 | + const { createBridgeSession } = await import('./createSession.js') | |
2676 | + try { | |
2677 | + initialSessionId = await createBridgeSession({ | |
2678 | + environmentId, | |
2679 | + title: name, | |
2680 | + events: [], | |
2681 | + gitRepoUrl, | |
2682 | + branch, | |
2683 | + signal: controller.signal, | |
2684 | + baseUrl, | |
2685 | + getAccessToken: getBridgeAccessToken, | |
2686 | + permissionMode, | |
2687 | + }) | |
2688 | + if (initialSessionId) { | |
2689 | + logForDebugging( | |
2690 | + `[bridge:init] Created initial session ${initialSessionId}`, | |
2691 | + ) | |
2692 | + } | |
2693 | + } catch (err) { | |
2694 | + logForDebugging( | |
2695 | + `[bridge:init] Session creation failed (non-fatal): ${errorMessage(err)}`, | |
2696 | + ) | |
2697 | + } | |
2698 | + } | |
2699 | + | |
2700 | + // Crash-recovery pointer: write immediately so kill -9 at any point | |
2701 | + // after this leaves a recoverable trail. Covers both fresh sessions and | |
2702 | + // resumed ones (so a second crash after resume is still recoverable). | |
2703 | + // Cleared when runBridgeLoop falls through to archive+deregister; left in | |
2704 | + // place on the SIGINT resumable-shutdown return (backup for when the user | |
2705 | + // closes the terminal before copying the printed --session-id hint). | |
2706 | + // Refreshed hourly so a 5h+ session that crashes still has a fresh | |
2707 | + // pointer (staleness checks file mtime, backend TTL is rolling-from-poll). | |
2708 | + let pointerRefreshTimer: ReturnType<typeof setInterval> | null = null | |
2709 | + // Single-session only: --continue forces single-session mode on resume, | |
2710 | + // so a pointer written in multi-session mode would contradict the user's | |
2711 | + // config when they try to resume. The resumable-shutdown path is also | |
2712 | + // gated to single-session (line ~1254) so the pointer would be orphaned. | |
2713 | + if (initialSessionId && spawnMode === 'single-session') { | |
2714 | + const { writeBridgePointer } = await import('./bridgePointer.js') | |
2715 | + const pointerPayload = { | |
2716 | + sessionId: initialSessionId, | |
2717 | + environmentId, | |
2718 | + source: 'standalone' as const, | |
2719 | + } | |
2720 | + await writeBridgePointer(config.dir, pointerPayload) | |
2721 | + pointerRefreshTimer = setInterval( | |
2722 | + writeBridgePointer, | |
2723 | + 60 * 60 * 1000, | |
2724 | + config.dir, | |
2725 | + pointerPayload, | |
2726 | + ) | |
2727 | + // Don't let the interval keep the process alive on its own. | |
2728 | + pointerRefreshTimer.unref?.() | |
2729 | + } | |
2730 | + | |
2731 | + try { | |
2732 | + await runBridgeLoop( | |
2733 | + config, | |
2734 | + environmentId, | |
2735 | + environmentSecret, | |
2736 | + api, | |
2737 | + spawner, | |
2738 | + logger, | |
2739 | + controller.signal, | |
2740 | + undefined, | |
2741 | + initialSessionId ?? undefined, | |
2742 | + async () => { | |
2743 | + // Clear the memoized OAuth token cache so we re-read from secure | |
2744 | + // storage, picking up tokens refreshed by child processes. | |
2745 | + clearOAuthTokenCache() | |
2746 | + // Proactively refresh the token if it's expired on disk too. | |
2747 | + await checkAndRefreshOAuthTokenIfNeeded() | |
2748 | + return getBridgeAccessToken() | |
2749 | + }, | |
2750 | + ) | |
2751 | + } finally { | |
2752 | + if (pointerRefreshTimer !== null) { | |
2753 | + clearInterval(pointerRefreshTimer) | |
2754 | + } | |
2755 | + process.off('SIGINT', onSigint) | |
2756 | + process.off('SIGTERM', onSigterm) | |
2757 | + process.stdin.off('data', onStdinData) | |
2758 | + if (process.stdin.isTTY) { | |
2759 | + process.stdin.setRawMode(false) | |
2760 | + } | |
2761 | + process.stdin.pause() | |
2762 | + } | |
2763 | + | |
2764 | + // The bridge bypasses init.ts (and its graceful shutdown handler), so we | |
2765 | + // must exit explicitly. | |
2766 | + // eslint-disable-next-line custom-rules/no-process-exit | |
2767 | + process.exit(0) | |
2768 | +} | |
2769 | + | |
2770 | +// ─── Headless bridge (daemon worker) ──────────────────────────────────────── | |
2771 | + | |
2772 | +/** | |
2773 | + * Thrown by runBridgeHeadless for configuration issues the supervisor should | |
2774 | + * NOT retry (trust not accepted, worktree unavailable, http-not-https). The | |
2775 | + * daemon worker catches this and exits with EXIT_CODE_PERMANENT so the | |
2776 | + * supervisor parks the worker instead of respawning it on backoff. | |
2777 | + */ | |
2778 | +export class BridgeHeadlessPermanentError extends Error { | |
2779 | + constructor(message: string) { | |
2780 | + super(message) | |
2781 | + this.name = 'BridgeHeadlessPermanentError' | |
2782 | + } | |
2783 | +} | |
2784 | + | |
2785 | +export type HeadlessBridgeOpts = { | |
2786 | + dir: string | |
2787 | + name?: string | |
2788 | + spawnMode: 'same-dir' | 'worktree' | |
2789 | + capacity: number | |
2790 | + permissionMode?: string | |
2791 | + sandbox: boolean | |
2792 | + sessionTimeoutMs?: number | |
2793 | + createSessionOnStart: boolean | |
2794 | + getAccessToken: () => string | undefined | |
2795 | + onAuth401: (failedToken: string) => Promise<boolean> | |
2796 | + log: (s: string) => void | |
2797 | +} | |
2798 | + | |
2799 | +/** | |
2800 | + * Non-interactive bridge entrypoint for the `remoteControl` daemon worker. | |
2801 | + * | |
2802 | + * Linear subset of bridgeMain(): no readline dialogs, no stdin key handlers, | |
2803 | + * no TUI, no process.exit(). Config comes from the caller (daemon.json), auth | |
2804 | + * comes via IPC (supervisor's AuthManager), logs go to the worker's stdout | |
2805 | + * pipe. Throws on fatal errors — the worker catches and maps permanent vs | |
2806 | + * transient to the right exit code. | |
2807 | + * | |
2808 | + * Resolves cleanly when `signal` aborts and the poll loop tears down. | |
2809 | + */ | |
2810 | +export async function runBridgeHeadless( | |
2811 | + opts: HeadlessBridgeOpts, | |
2812 | + signal: AbortSignal, | |
2813 | +): Promise<void> { | |
2814 | + const { dir, log } = opts | |
2815 | + | |
2816 | + // Worker inherits the supervisor's CWD. chdir first so git utilities | |
2817 | + // (getBranch/getRemoteUrl) — which read from bootstrap CWD state set | |
2818 | + // below — resolve against the right repo. | |
2819 | + process.chdir(dir) | |
2820 | + const { setOriginalCwd, setCwdState } = await import('../bootstrap/state.js') | |
2821 | + setOriginalCwd(dir) | |
2822 | + setCwdState(dir) | |
2823 | + | |
2824 | + const { enableConfigs, checkHasTrustDialogAccepted } = await import( | |
2825 | + '../utils/config.js' | |
2826 | + ) | |
2827 | + enableConfigs() | |
2828 | + const { initSinks } = await import('../utils/sinks.js') | |
2829 | + initSinks() | |
2830 | + | |
2831 | + if (!checkHasTrustDialogAccepted()) { | |
2832 | + throw new BridgeHeadlessPermanentError( | |
2833 | + `Workspace not trusted: ${dir}. Run \`claude\` in that directory first to accept the trust dialog.`, | |
2834 | + ) | |
2835 | + } | |
2836 | + | |
2837 | + if (!opts.getAccessToken()) { | |
2838 | + // Transient — supervisor's AuthManager may pick up a token on next cycle. | |
2839 | + throw new Error(BRIDGE_LOGIN_ERROR) | |
2840 | + } | |
2841 | + | |
2842 | + const { getBridgeBaseUrl } = await import('./bridgeConfig.js') | |
2843 | + const baseUrl = getBridgeBaseUrl() | |
2844 | + if ( | |
2845 | + baseUrl.startsWith('http://') && | |
2846 | + !baseUrl.includes('localhost') && | |
2847 | + !baseUrl.includes('127.0.0.1') | |
2848 | + ) { | |
2849 | + throw new BridgeHeadlessPermanentError( | |
2850 | + 'Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.', | |
2851 | + ) | |
2852 | + } | |
2853 | + const sessionIngressUrl = | |
2854 | + process.env.USER_TYPE === 'ant' && | |
2855 | + process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL | |
2856 | + ? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL | |
2857 | + : baseUrl | |
2858 | + | |
2859 | + const { getBranch, getRemoteUrl, findGitRoot } = await import( | |
2860 | + '../utils/git.js' | |
2861 | + ) | |
2862 | + const { hasWorktreeCreateHook } = await import('../utils/hooks.js') | |
2863 | + | |
2864 | + if (opts.spawnMode === 'worktree') { | |
2865 | + const worktreeAvailable = | |
2866 | + hasWorktreeCreateHook() || findGitRoot(dir) !== null | |
2867 | + if (!worktreeAvailable) { | |
2868 | + throw new BridgeHeadlessPermanentError( | |
2869 | + `Worktree mode requires a git repository or WorktreeCreate hooks. Directory ${dir} has neither.`, | |
2870 | + ) | |
2871 | + } | |
2872 | + } | |
2873 | + | |
2874 | + const branch = await getBranch() | |
2875 | + const gitRepoUrl = await getRemoteUrl() | |
2876 | + const machineName = hostname() | |
2877 | + const bridgeId = randomUUID() | |
2878 | + | |
2879 | + const config: BridgeConfig = { | |
2880 | + dir, | |
2881 | + machineName, | |
2882 | + branch, | |
2883 | + gitRepoUrl, | |
2884 | + maxSessions: opts.capacity, | |
2885 | + spawnMode: opts.spawnMode, | |
2886 | + verbose: false, | |
2887 | + sandbox: opts.sandbox, | |
2888 | + bridgeId, | |
2889 | + workerType: 'claude_code', | |
2890 | + environmentId: randomUUID(), | |
2891 | + apiBaseUrl: baseUrl, | |
2892 | + sessionIngressUrl, | |
2893 | + sessionTimeoutMs: opts.sessionTimeoutMs, | |
2894 | + } | |
2895 | + | |
2896 | + const api = createBridgeApiClient({ | |
2897 | + baseUrl, | |
2898 | + getAccessToken: opts.getAccessToken, | |
2899 | + runnerVersion: MACRO.VERSION, | |
2900 | + onDebug: log, | |
2901 | + onAuth401: opts.onAuth401, | |
2902 | + getTrustedDeviceToken, | |
2903 | + }) | |
2904 | + | |
2905 | + let environmentId: string | |
2906 | + let environmentSecret: string | |
2907 | + try { | |
2908 | + const reg = await api.registerBridgeEnvironment(config) | |
2909 | + environmentId = reg.environment_id | |
2910 | + environmentSecret = reg.environment_secret | |
2911 | + } catch (err) { | |
2912 | + // Transient — let supervisor backoff-retry. | |
2913 | + throw new Error(`Bridge registration failed: ${errorMessage(err)}`) | |
2914 | + } | |
2915 | + | |
2916 | + const spawner = createSessionSpawner({ | |
2917 | + execPath: process.execPath, | |
2918 | + scriptArgs: spawnScriptArgs(), | |
2919 | + env: process.env, | |
2920 | + verbose: false, | |
2921 | + sandbox: opts.sandbox, | |
2922 | + permissionMode: opts.permissionMode, | |
2923 | + onDebug: log, | |
2924 | + }) | |
2925 | + | |
2926 | + const logger = createHeadlessBridgeLogger(log) | |
2927 | + logger.printBanner(config, environmentId) | |
2928 | + | |
2929 | + let initialSessionId: string | undefined | |
2930 | + if (opts.createSessionOnStart) { | |
2931 | + const { createBridgeSession } = await import('./createSession.js') | |
2932 | + try { | |
2933 | + const sid = await createBridgeSession({ | |
2934 | + environmentId, | |
2935 | + title: opts.name, | |
2936 | + events: [], | |
2937 | + gitRepoUrl, | |
2938 | + branch, | |
2939 | + signal, | |
2940 | + baseUrl, | |
2941 | + getAccessToken: opts.getAccessToken, | |
2942 | + permissionMode: opts.permissionMode, | |
2943 | + }) | |
2944 | + if (sid) { | |
2945 | + initialSessionId = sid | |
2946 | + log(`created initial session ${sid}`) | |
2947 | + } | |
2948 | + } catch (err) { | |
2949 | + log(`session pre-creation failed (non-fatal): ${errorMessage(err)}`) | |
2950 | + } | |
2951 | + } | |
2952 | + | |
2953 | + await runBridgeLoop( | |
2954 | + config, | |
2955 | + environmentId, | |
2956 | + environmentSecret, | |
2957 | + api, | |
2958 | + spawner, | |
2959 | + logger, | |
2960 | + signal, | |
2961 | + undefined, | |
2962 | + initialSessionId, | |
2963 | + async () => opts.getAccessToken(), | |
2964 | + ) | |
2965 | +} | |
2966 | + | |
2967 | +/** BridgeLogger adapter that routes everything to a single line-log fn. */ | |
2968 | +function createHeadlessBridgeLogger(log: (s: string) => void): BridgeLogger { | |
2969 | + const noop = (): void => {} | |
2970 | + return { | |
2971 | + printBanner: (cfg, envId) => | |
2972 | + log( | |
2973 | + `registered environmentId=${envId} dir=${cfg.dir} spawnMode=${cfg.spawnMode} capacity=${cfg.maxSessions}`, | |
2974 | + ), | |
2975 | + logSessionStart: (id, _prompt) => log(`session start ${id}`), | |
2976 | + logSessionComplete: (id, ms) => log(`session complete ${id} (${ms}ms)`), | |
2977 | + logSessionFailed: (id, err) => log(`session failed ${id}: ${err}`), | |
2978 | + logStatus: log, | |
2979 | + logVerbose: log, | |
2980 | + logError: s => log(`error: ${s}`), | |
2981 | + logReconnected: ms => log(`reconnected after ${ms}ms`), | |
2982 | + addSession: (id, _url) => log(`session attached ${id}`), | |
2983 | + removeSession: id => log(`session detached ${id}`), | |
2984 | + updateIdleStatus: noop, | |
2985 | + updateReconnectingStatus: noop, | |
2986 | + updateSessionStatus: noop, | |
2987 | + updateSessionActivity: noop, | |
2988 | + updateSessionCount: noop, | |
2989 | + updateFailedStatus: noop, | |
2990 | + setSpawnModeDisplay: noop, | |
2991 | + setRepoInfo: noop, | |
2992 | + setDebugLogPath: noop, | |
2993 | + setAttached: noop, | |
2994 | + setSessionTitle: noop, | |
2995 | + clearStatus: noop, | |
2996 | + toggleQr: noop, | |
2997 | + refreshDisplay: noop, | |
2998 | + } | |
2999 | +} | |
0 | 3000 | |
▼src/bridge/bridgeMessaging.ts
+461-0
| @@ -0,0 +1,461 @@ | ||
1 | +/** | |
2 | + * Shared transport-layer helpers for bridge message handling. | |
3 | + * | |
4 | + * Extracted from replBridge.ts so both the env-based core (initBridgeCore) | |
5 | + * and the env-less core (initEnvLessBridgeCore) can use the same ingress | |
6 | + * parsing, control-request handling, and echo-dedup machinery. | |
7 | + * | |
8 | + * Everything here is pure — no closure over bridge-specific state. All | |
9 | + * collaborators (transport, sessionId, UUID sets, callbacks) are passed | |
10 | + * as params. | |
11 | + */ | |
12 | + | |
13 | +import { randomUUID } from 'crypto' | |
14 | +import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' | |
15 | +import type { | |
16 | + SDKControlRequest, | |
17 | + SDKControlResponse, | |
18 | +} from '../entrypoints/sdk/controlTypes.js' | |
19 | +import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js' | |
20 | +import { logEvent } from '../services/analytics/index.js' | |
21 | +import { EMPTY_USAGE } from '../services/api/emptyUsage.js' | |
22 | +import type { Message } from '../types/message.js' | |
23 | +import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js' | |
24 | +import { logForDebugging } from '../utils/debug.js' | |
25 | +import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js' | |
26 | +import { errorMessage } from '../utils/errors.js' | |
27 | +import type { PermissionMode } from '../utils/permissions/PermissionMode.js' | |
28 | +import { jsonParse } from '../utils/slowOperations.js' | |
29 | +import type { ReplBridgeTransport } from './replBridgeTransport.js' | |
30 | + | |
31 | +// ─── Type guards ───────────────────────────────────────────────────────────── | |
32 | + | |
33 | +/** Type predicate for parsed WebSocket messages. SDKMessage is a | |
34 | + * discriminated union on `type` — validating the discriminant is | |
35 | + * sufficient for the predicate; callers narrow further via the union. */ | |
36 | +export function isSDKMessage(value: unknown): value is SDKMessage { | |
37 | + return ( | |
38 | + value !== null && | |
39 | + typeof value === 'object' && | |
40 | + 'type' in value && | |
41 | + typeof value.type === 'string' | |
42 | + ) | |
43 | +} | |
44 | + | |
45 | +/** Type predicate for control_response messages from the server. */ | |
46 | +export function isSDKControlResponse( | |
47 | + value: unknown, | |
48 | +): value is SDKControlResponse { | |
49 | + return ( | |
50 | + value !== null && | |
51 | + typeof value === 'object' && | |
52 | + 'type' in value && | |
53 | + value.type === 'control_response' && | |
54 | + 'response' in value | |
55 | + ) | |
56 | +} | |
57 | + | |
58 | +/** Type predicate for control_request messages from the server. */ | |
59 | +export function isSDKControlRequest( | |
60 | + value: unknown, | |
61 | +): value is SDKControlRequest { | |
62 | + return ( | |
63 | + value !== null && | |
64 | + typeof value === 'object' && | |
65 | + 'type' in value && | |
66 | + value.type === 'control_request' && | |
67 | + 'request_id' in value && | |
68 | + 'request' in value | |
69 | + ) | |
70 | +} | |
71 | + | |
72 | +/** | |
73 | + * True for message types that should be forwarded to the bridge transport. | |
74 | + * The server only wants user/assistant turns and slash-command system events; | |
75 | + * everything else (tool_result, progress, etc.) is internal REPL chatter. | |
76 | + */ | |
77 | +export function isEligibleBridgeMessage(m: Message): boolean { | |
78 | + // Virtual messages (REPL inner calls) are display-only — bridge/SDK | |
79 | + // consumers see the REPL tool_use/result which summarizes the work. | |
80 | + if ((m.type === 'user' || m.type === 'assistant') && m.isVirtual) { | |
81 | + return false | |
82 | + } | |
83 | + return ( | |
84 | + m.type === 'user' || | |
85 | + m.type === 'assistant' || | |
86 | + (m.type === 'system' && m.subtype === 'local_command') | |
87 | + ) | |
88 | +} | |
89 | + | |
90 | +/** | |
91 | + * Extract title-worthy text from a Message for onUserMessage. Returns | |
92 | + * undefined for messages that shouldn't title the session: non-user, meta | |
93 | + * (nudges), tool results, compact summaries, non-human origins (task | |
94 | + * notifications, channel messages), or pure display-tag content | |
95 | + * (<ide_opened_file>, <session-start-hook>, etc.). | |
96 | + * | |
97 | + * Synthetic interrupts ([Request interrupted by user]) are NOT filtered here — | |
98 | + * isSyntheticMessage lives in messages.ts (heavy import, pulls command | |
99 | + * registry). The initialMessages path in initReplBridge checks it; the | |
100 | + * writeMessages path reaching an interrupt as the *first* message is | |
101 | + * implausible (an interrupt implies a prior prompt already flowed through). | |
102 | + */ | |
103 | +export function extractTitleText(m: Message): string | undefined { | |
104 | + if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary) | |
105 | + return undefined | |
106 | + if (m.origin && m.origin.kind !== 'human') return undefined | |
107 | + const content = m.message.content | |
108 | + let raw: string | undefined | |
109 | + if (typeof content === 'string') { | |
110 | + raw = content | |
111 | + } else { | |
112 | + for (const block of content) { | |
113 | + if (block.type === 'text') { | |
114 | + raw = block.text | |
115 | + break | |
116 | + } | |
117 | + } | |
118 | + } | |
119 | + if (!raw) return undefined | |
120 | + const clean = stripDisplayTagsAllowEmpty(raw) | |
121 | + return clean || undefined | |
122 | +} | |
123 | + | |
124 | +// ─── Ingress routing ───────────────────────────────────────────────────────── | |
125 | + | |
126 | +/** | |
127 | + * Parse an ingress WebSocket message and route it to the appropriate handler. | |
128 | + * Ignores messages whose UUID is in recentPostedUUIDs (echoes of what we sent) | |
129 | + * or in recentInboundUUIDs (re-deliveries we've already forwarded — e.g. | |
130 | + * server replayed history after a transport swap lost the seq-num cursor). | |
131 | + */ | |
132 | +export function handleIngressMessage( | |
133 | + data: string, | |
134 | + recentPostedUUIDs: BoundedUUIDSet, | |
135 | + recentInboundUUIDs: BoundedUUIDSet, | |
136 | + onInboundMessage: ((msg: SDKMessage) => void | Promise<void>) | undefined, | |
137 | + onPermissionResponse?: ((response: SDKControlResponse) => void) | undefined, | |
138 | + onControlRequest?: ((request: SDKControlRequest) => void) | undefined, | |
139 | +): void { | |
140 | + try { | |
141 | + const parsed: unknown = normalizeControlMessageKeys(jsonParse(data)) | |
142 | + | |
143 | + // control_response is not an SDKMessage — check before the type guard | |
144 | + if (isSDKControlResponse(parsed)) { | |
145 | + logForDebugging('[bridge:repl] Ingress message type=control_response') | |
146 | + onPermissionResponse?.(parsed) | |
147 | + return | |
148 | + } | |
149 | + | |
150 | + // control_request from the server (initialize, set_model, can_use_tool). | |
151 | + // Must respond promptly or the server kills the WS (~10-14s timeout). | |
152 | + if (isSDKControlRequest(parsed)) { | |
153 | + logForDebugging( | |
154 | + `[bridge:repl] Inbound control_request subtype=${parsed.request.subtype}`, | |
155 | + ) | |
156 | + onControlRequest?.(parsed) | |
157 | + return | |
158 | + } | |
159 | + | |
160 | + if (!isSDKMessage(parsed)) return | |
161 | + | |
162 | + // Check for UUID to detect echoes of our own messages | |
163 | + const uuid = | |
164 | + 'uuid' in parsed && typeof parsed.uuid === 'string' | |
165 | + ? parsed.uuid | |
166 | + : undefined | |
167 | + | |
168 | + if (uuid && recentPostedUUIDs.has(uuid)) { | |
169 | + logForDebugging( | |
170 | + `[bridge:repl] Ignoring echo: type=${parsed.type} uuid=${uuid}`, | |
171 | + ) | |
172 | + return | |
173 | + } | |
174 | + | |
175 | + // Defensive dedup: drop inbound prompts we've already forwarded. The | |
176 | + // SSE seq-num carryover (lastTransportSequenceNum) is the primary fix | |
177 | + // for history-replay; this catches edge cases where that negotiation | |
178 | + // fails (server ignores from_sequence_num, transport died before | |
179 | + // receiving any frames, etc). | |
180 | + if (uuid && recentInboundUUIDs.has(uuid)) { | |
181 | + logForDebugging( | |
182 | + `[bridge:repl] Ignoring re-delivered inbound: type=${parsed.type} uuid=${uuid}`, | |
183 | + ) | |
184 | + return | |
185 | + } | |
186 | + | |
187 | + logForDebugging( | |
188 | + `[bridge:repl] Ingress message type=${parsed.type}${uuid ? ` uuid=${uuid}` : ''}`, | |
189 | + ) | |
190 | + | |
191 | + if (parsed.type === 'user') { | |
192 | + if (uuid) recentInboundUUIDs.add(uuid) | |
193 | + logEvent('tengu_bridge_message_received', { | |
194 | + is_repl: true, | |
195 | + }) | |
196 | + // Fire-and-forget — handler may be async (attachment resolution). | |
197 | + void onInboundMessage?.(parsed) | |
198 | + } else { | |
199 | + logForDebugging( | |
200 | + `[bridge:repl] Ignoring non-user inbound message: type=${parsed.type}`, | |
201 | + ) | |
202 | + } | |
203 | + } catch (err) { | |
204 | + logForDebugging( | |
205 | + `[bridge:repl] Failed to parse ingress message: ${errorMessage(err)}`, | |
206 | + ) | |
207 | + } | |
208 | +} | |
209 | + | |
210 | +// ─── Server-initiated control requests ─────────────────────────────────────── | |
211 | + | |
212 | +export type ServerControlRequestHandlers = { | |
213 | + transport: ReplBridgeTransport | null | |
214 | + sessionId: string | |
215 | + /** | |
216 | + * When true, all mutable requests (interrupt, set_model, set_permission_mode, | |
217 | + * set_max_thinking_tokens) reply with an error instead of false-success. | |
218 | + * initialize still replies success — the server kills the connection otherwise. | |
219 | + * Used by the outbound-only bridge mode and the SDK's /bridge subpath so claude.ai sees a | |
220 | + * proper error instead of "action succeeded but nothing happened locally". | |
221 | + */ | |
222 | + outboundOnly?: boolean | |
223 | + onInterrupt?: () => void | |
224 | + onSetModel?: (model: string | undefined) => void | |
225 | + onSetMaxThinkingTokens?: (maxTokens: number | null) => void | |
226 | + onSetPermissionMode?: ( | |
227 | + mode: PermissionMode, | |
228 | + ) => { ok: true } | { ok: false; error: string } | |
229 | +} | |
230 | + | |
231 | +const OUTBOUND_ONLY_ERROR = | |
232 | + 'This session is outbound-only. Enable Remote Control locally to allow inbound control.' | |
233 | + | |
234 | +/** | |
235 | + * Respond to inbound control_request messages from the server. The server | |
236 | + * sends these for session lifecycle events (initialize, set_model) and | |
237 | + * for turn-level coordination (interrupt, set_max_thinking_tokens). If we | |
238 | + * don't respond, the server hangs and kills the WS after ~10-14s. | |
239 | + * | |
240 | + * Previously a closure inside initBridgeCore's onWorkReceived; now takes | |
241 | + * collaborators as params so both cores can use it. | |
242 | + */ | |
243 | +export function handleServerControlRequest( | |
244 | + request: SDKControlRequest, | |
245 | + handlers: ServerControlRequestHandlers, | |
246 | +): void { | |
247 | + const { | |
248 | + transport, | |
249 | + sessionId, | |
250 | + outboundOnly, | |
251 | + onInterrupt, | |
252 | + onSetModel, | |
253 | + onSetMaxThinkingTokens, | |
254 | + onSetPermissionMode, | |
255 | + } = handlers | |
256 | + if (!transport) { | |
257 | + logForDebugging( | |
258 | + '[bridge:repl] Cannot respond to control_request: transport not configured', | |
259 | + ) | |
260 | + return | |
261 | + } | |
262 | + | |
263 | + let response: SDKControlResponse | |
264 | + | |
265 | + // Outbound-only: reply error for mutable requests so claude.ai doesn't show | |
266 | + // false success. initialize must still succeed (server kills the connection | |
267 | + // if it doesn't — see comment above). | |
268 | + if (outboundOnly && request.request.subtype !== 'initialize') { | |
269 | + response = { | |
270 | + type: 'control_response', | |
271 | + response: { | |
272 | + subtype: 'error', | |
273 | + request_id: request.request_id, | |
274 | + error: OUTBOUND_ONLY_ERROR, | |
275 | + }, | |
276 | + } | |
277 | + const event = { ...response, session_id: sessionId } | |
278 | + void transport.write(event) | |
279 | + logForDebugging( | |
280 | + `[bridge:repl] Rejected ${request.request.subtype} (outbound-only) request_id=${request.request_id}`, | |
281 | + ) | |
282 | + return | |
283 | + } | |
284 | + | |
285 | + switch (request.request.subtype) { | |
286 | + case 'initialize': | |
287 | + // Respond with minimal capabilities — the REPL handles | |
288 | + // commands, models, and account info itself. | |
289 | + response = { | |
290 | + type: 'control_response', | |
291 | + response: { | |
292 | + subtype: 'success', | |
293 | + request_id: request.request_id, | |
294 | + response: { | |
295 | + commands: [], | |
296 | + output_style: 'normal', | |
297 | + available_output_styles: ['normal'], | |
298 | + models: [], | |
299 | + account: {}, | |
300 | + pid: process.pid, | |
301 | + }, | |
302 | + }, | |
303 | + } | |
304 | + break | |
305 | + | |
306 | + case 'set_model': | |
307 | + onSetModel?.(request.request.model) | |
308 | + response = { | |
309 | + type: 'control_response', | |
310 | + response: { | |
311 | + subtype: 'success', | |
312 | + request_id: request.request_id, | |
313 | + }, | |
314 | + } | |
315 | + break | |
316 | + | |
317 | + case 'set_max_thinking_tokens': | |
318 | + onSetMaxThinkingTokens?.(request.request.max_thinking_tokens) | |
319 | + response = { | |
320 | + type: 'control_response', | |
321 | + response: { | |
322 | + subtype: 'success', | |
323 | + request_id: request.request_id, | |
324 | + }, | |
325 | + } | |
326 | + break | |
327 | + | |
328 | + case 'set_permission_mode': { | |
329 | + // The callback returns a policy verdict so we can send an error | |
330 | + // control_response without importing isAutoModeGateEnabled / | |
331 | + // isBypassPermissionsModeDisabled here (bootstrap-isolation). If no | |
332 | + // callback is registered (daemon context, which doesn't wire this — | |
333 | + // see daemonBridge.ts), return an error verdict rather than a silent | |
334 | + // false-success: the mode is never actually applied in that context, | |
335 | + // so success would lie to the client. | |
336 | + const verdict = onSetPermissionMode?.(request.request.mode) ?? { | |
337 | + ok: false, | |
338 | + error: | |
339 | + 'set_permission_mode is not supported in this context (onSetPermissionMode callback not registered)', | |
340 | + } | |
341 | + if (verdict.ok) { | |
342 | + response = { | |
343 | + type: 'control_response', | |
344 | + response: { | |
345 | + subtype: 'success', | |
346 | + request_id: request.request_id, | |
347 | + }, | |
348 | + } | |
349 | + } else { | |
350 | + response = { | |
351 | + type: 'control_response', | |
352 | + response: { | |
353 | + subtype: 'error', | |
354 | + request_id: request.request_id, | |
355 | + error: verdict.error, | |
356 | + }, | |
357 | + } | |
358 | + } | |
359 | + break | |
360 | + } | |
361 | + | |
362 | + case 'interrupt': | |
363 | + onInterrupt?.() | |
364 | + response = { | |
365 | + type: 'control_response', | |
366 | + response: { | |
367 | + subtype: 'success', | |
368 | + request_id: request.request_id, | |
369 | + }, | |
370 | + } | |
371 | + break | |
372 | + | |
373 | + default: | |
374 | + // Unknown subtype — respond with error so the server doesn't | |
375 | + // hang waiting for a reply that never comes. | |
376 | + response = { | |
377 | + type: 'control_response', | |
378 | + response: { | |
379 | + subtype: 'error', | |
380 | + request_id: request.request_id, | |
381 | + error: `REPL bridge does not handle control_request subtype: ${request.request.subtype}`, | |
382 | + }, | |
383 | + } | |
384 | + } | |
385 | + | |
386 | + const event = { ...response, session_id: sessionId } | |
387 | + void transport.write(event) | |
388 | + logForDebugging( | |
389 | + `[bridge:repl] Sent control_response for ${request.request.subtype} request_id=${request.request_id} result=${response.response.subtype}`, | |
390 | + ) | |
391 | +} | |
392 | + | |
393 | +// ─── Result message (for session archival on teardown) ─────────────────────── | |
394 | + | |
395 | +/** | |
396 | + * Build a minimal `SDKResultSuccess` message for session archival. | |
397 | + * The server needs this event before a WS close to trigger archival. | |
398 | + */ | |
399 | +export function makeResultMessage(sessionId: string): SDKResultSuccess { | |
400 | + return { | |
401 | + type: 'result', | |
402 | + subtype: 'success', | |
403 | + duration_ms: 0, | |
404 | + duration_api_ms: 0, | |
405 | + is_error: false, | |
406 | + num_turns: 0, | |
407 | + result: '', | |
408 | + stop_reason: null, | |
409 | + total_cost_usd: 0, | |
410 | + usage: { ...EMPTY_USAGE }, | |
411 | + modelUsage: {}, | |
412 | + permission_denials: [], | |
413 | + session_id: sessionId, | |
414 | + uuid: randomUUID(), | |
415 | + } | |
416 | +} | |
417 | + | |
418 | +// ─── BoundedUUIDSet (echo-dedup ring buffer) ───────────────────────────────── | |
419 | + | |
420 | +/** | |
421 | + * FIFO-bounded set backed by a circular buffer. Evicts the oldest entry | |
422 | + * when capacity is reached, keeping memory usage constant at O(capacity). | |
423 | + * | |
424 | + * Messages are added in chronological order, so evicted entries are always | |
425 | + * the oldest. The caller relies on external ordering (the hook's | |
426 | + * lastWrittenIndexRef) as the primary dedup — this set is a secondary | |
427 | + * safety net for echo filtering and race-condition dedup. | |
428 | + */ | |
429 | +export class BoundedUUIDSet { | |
430 | + private readonly capacity: number | |
431 | + private readonly ring: (string | undefined)[] | |
432 | + private readonly set = new Set<string>() | |
433 | + private writeIdx = 0 | |
434 | + | |
435 | + constructor(capacity: number) { | |
436 | + this.capacity = capacity | |
437 | + this.ring = new Array<string | undefined>(capacity) | |
438 | + } | |
439 | + | |
440 | + add(uuid: string): void { | |
441 | + if (this.set.has(uuid)) return | |
442 | + // Evict the entry at the current write position (if occupied) | |
443 | + const evicted = this.ring[this.writeIdx] | |
444 | + if (evicted !== undefined) { | |
445 | + this.set.delete(evicted) | |
446 | + } | |
447 | + this.ring[this.writeIdx] = uuid | |
448 | + this.set.add(uuid) | |
449 | + this.writeIdx = (this.writeIdx + 1) % this.capacity | |
450 | + } | |
451 | + | |
452 | + has(uuid: string): boolean { | |
453 | + return this.set.has(uuid) | |
454 | + } | |
455 | + | |
456 | + clear(): void { | |
457 | + this.set.clear() | |
458 | + this.ring.fill(undefined) | |
459 | + this.writeIdx = 0 | |
460 | + } | |
461 | +} | |
0 | 462 | |
▼src/bridge/bridgePermissionCallbacks.ts
+43-0
| @@ -0,0 +1,43 @@ | ||
1 | +import type { PermissionUpdate } from '../utils/permissions/PermissionUpdateSchema.js' | |
2 | + | |
3 | +type BridgePermissionResponse = { | |
4 | + behavior: 'allow' | 'deny' | |
5 | + updatedInput?: Record<string, unknown> | |
6 | + updatedPermissions?: PermissionUpdate[] | |
7 | + message?: string | |
8 | +} | |
9 | + | |
10 | +type BridgePermissionCallbacks = { | |
11 | + sendRequest( | |
12 | + requestId: string, | |
13 | + toolName: string, | |
14 | + input: Record<string, unknown>, | |
15 | + toolUseId: string, | |
16 | + description: string, | |
17 | + permissionSuggestions?: PermissionUpdate[], | |
18 | + blockedPath?: string, | |
19 | + ): void | |
20 | + sendResponse(requestId: string, response: BridgePermissionResponse): void | |
21 | + /** Cancel a pending control_request so the web app can dismiss its prompt. */ | |
22 | + cancelRequest(requestId: string): void | |
23 | + onResponse( | |
24 | + requestId: string, | |
25 | + handler: (response: BridgePermissionResponse) => void, | |
26 | + ): () => void // returns unsubscribe | |
27 | +} | |
28 | + | |
29 | +/** Type predicate for validating a parsed control_response payload | |
30 | + * as a BridgePermissionResponse. Checks the required `behavior` | |
31 | + * discriminant rather than using an unsafe `as` cast. */ | |
32 | +function isBridgePermissionResponse( | |
33 | + value: unknown, | |
34 | +): value is BridgePermissionResponse { | |
35 | + if (!value || typeof value !== 'object') return false | |
36 | + return ( | |
37 | + 'behavior' in value && | |
38 | + (value.behavior === 'allow' || value.behavior === 'deny') | |
39 | + ) | |
40 | +} | |
41 | + | |
42 | +export { isBridgePermissionResponse } | |
43 | +export type { BridgePermissionCallbacks, BridgePermissionResponse } | |
0 | 44 | |
▼src/bridge/bridgePointer.ts
+210-0
| @@ -0,0 +1,210 @@ | ||
1 | +import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises' | |
2 | +import { dirname, join } from 'path' | |
3 | +import { z } from 'zod/v4' | |
4 | +import { logForDebugging } from '../utils/debug.js' | |
5 | +import { isENOENT } from '../utils/errors.js' | |
6 | +import { getWorktreePathsPortable } from '../utils/getWorktreePathsPortable.js' | |
7 | +import { lazySchema } from '../utils/lazySchema.js' | |
8 | +import { | |
9 | + getProjectsDir, | |
10 | + sanitizePath, | |
11 | +} from '../utils/sessionStoragePortable.js' | |
12 | +import { jsonParse, jsonStringify } from '../utils/slowOperations.js' | |
13 | + | |
14 | +/** | |
15 | + * Upper bound on worktree fanout. git worktree list is naturally bounded | |
16 | + * (50 is a LOT), but this caps the parallel stat() burst and guards against | |
17 | + * pathological setups. Above this, --continue falls back to current-dir-only. | |
18 | + */ | |
19 | +const MAX_WORKTREE_FANOUT = 50 | |
20 | + | |
21 | +/** | |
22 | + * Crash-recovery pointer for Remote Control sessions. | |
23 | + * | |
24 | + * Written immediately after a bridge session is created, periodically | |
25 | + * refreshed during the session, and cleared on clean shutdown. If the | |
26 | + * process dies unclean (crash, kill -9, terminal closed), the pointer | |
27 | + * persists. On next startup, `claude remote-control` detects it and offers | |
28 | + * to resume via the --session-id flow from #20460. | |
29 | + * | |
30 | + * Staleness is checked against the file's mtime (not an embedded timestamp) | |
31 | + * so that a periodic re-write with the same content serves as a refresh — | |
32 | + * matches the backend's rolling BRIDGE_LAST_POLL_TTL (4h) semantics. A | |
33 | + * bridge that's been polling for 5+ hours and then crashes still has a | |
34 | + * fresh pointer as long as the refresh ran within the window. | |
35 | + * | |
36 | + * Scoped per working directory (alongside transcript JSONL files) so two | |
37 | + * concurrent bridges in different repos don't clobber each other. | |
38 | + */ | |
39 | + | |
40 | +export const BRIDGE_POINTER_TTL_MS = 4 * 60 * 60 * 1000 | |
41 | + | |
42 | +const BridgePointerSchema = lazySchema(() => | |
43 | + z.object({ | |
44 | + sessionId: z.string(), | |
45 | + environmentId: z.string(), | |
46 | + source: z.enum(['standalone', 'repl']), | |
47 | + }), | |
48 | +) | |
49 | + | |
50 | +export type BridgePointer = z.infer<ReturnType<typeof BridgePointerSchema>> | |
51 | + | |
52 | +export function getBridgePointerPath(dir: string): string { | |
53 | + return join(getProjectsDir(), sanitizePath(dir), 'bridge-pointer.json') | |
54 | +} | |
55 | + | |
56 | +/** | |
57 | + * Write the pointer. Also used to refresh mtime during long sessions — | |
58 | + * calling with the same IDs is a cheap no-content-change write that bumps | |
59 | + * the staleness clock. Best-effort — a crash-recovery file must never | |
60 | + * itself cause a crash. Logs and swallows on error. | |
61 | + */ | |
62 | +export async function writeBridgePointer( | |
63 | + dir: string, | |
64 | + pointer: BridgePointer, | |
65 | +): Promise<void> { | |
66 | + const path = getBridgePointerPath(dir) | |
67 | + try { | |
68 | + await mkdir(dirname(path), { recursive: true }) | |
69 | + await writeFile(path, jsonStringify(pointer), 'utf8') | |
70 | + logForDebugging(`[bridge:pointer] wrote ${path}`) | |
71 | + } catch (err: unknown) { | |
72 | + logForDebugging(`[bridge:pointer] write failed: ${err}`, { level: 'warn' }) | |
73 | + } | |
74 | +} | |
75 | + | |
76 | +/** | |
77 | + * Read the pointer and its age (ms since last write). Operates directly | |
78 | + * and handles errors — no existence check (MERVELAS.md TOCTOU rule). Returns | |
79 | + * null on any failure: missing file, corrupted JSON, schema mismatch, or | |
80 | + * stale (mtime > 4h ago). Stale/invalid pointers are deleted so they don't | |
81 | + * keep re-prompting after the backend has already GC'd the env. | |
82 | + */ | |
83 | +export async function readBridgePointer( | |
84 | + dir: string, | |
85 | +): Promise<(BridgePointer & { ageMs: number }) | null> { | |
86 | + const path = getBridgePointerPath(dir) | |
87 | + let raw: string | |
88 | + let mtimeMs: number | |
89 | + try { | |
90 | + // stat for mtime (staleness anchor), then read. Two syscalls, but both | |
91 | + // are needed — mtime IS the data we return, not a TOCTOU guard. | |
92 | + mtimeMs = (await stat(path)).mtimeMs | |
93 | + raw = await readFile(path, 'utf8') | |
94 | + } catch { | |
95 | + return null | |
96 | + } | |
97 | + | |
98 | + const parsed = BridgePointerSchema().safeParse(safeJsonParse(raw)) | |
99 | + if (!parsed.success) { | |
100 | + logForDebugging(`[bridge:pointer] invalid schema, clearing: ${path}`) | |
101 | + await clearBridgePointer(dir) | |
102 | + return null | |
103 | + } | |
104 | + | |
105 | + const ageMs = Math.max(0, Date.now() - mtimeMs) | |
106 | + if (ageMs > BRIDGE_POINTER_TTL_MS) { | |
107 | + logForDebugging(`[bridge:pointer] stale (>4h mtime), clearing: ${path}`) | |
108 | + await clearBridgePointer(dir) | |
109 | + return null | |
110 | + } | |
111 | + | |
112 | + return { ...parsed.data, ageMs } | |
113 | +} | |
114 | + | |
115 | +/** | |
116 | + * Worktree-aware read for `--continue`. The REPL bridge writes its pointer | |
117 | + * to `getOriginalCwd()` which EnterWorktreeTool/activeWorktreeSession can | |
118 | + * mutate to a worktree path — but `claude remote-control --continue` runs | |
119 | + * with `resolve('.')` = shell CWD. This fans out across git worktree | |
120 | + * siblings to find the freshest pointer, matching /resume's semantics. | |
121 | + * | |
122 | + * Fast path: checks `dir` first. Only shells out to `git worktree list` if | |
123 | + * that misses — the common case (pointer in launch dir) is one stat, zero | |
124 | + * exec. Fanout reads run in parallel; capped at MAX_WORKTREE_FANOUT. | |
125 | + * | |
126 | + * Returns the pointer AND the dir it was found in, so the caller can clear | |
127 | + * the right file on resume failure. | |
128 | + */ | |
129 | +export async function readBridgePointerAcrossWorktrees( | |
130 | + dir: string, | |
131 | +): Promise<{ pointer: BridgePointer & { ageMs: number }; dir: string } | null> { | |
132 | + // Fast path: current dir. Covers standalone bridge (always matches) and | |
133 | + // REPL bridge when no worktree mutation happened. | |
134 | + const here = await readBridgePointer(dir) | |
135 | + if (here) { | |
136 | + return { pointer: here, dir } | |
137 | + } | |
138 | + | |
139 | + // Fanout: scan worktree siblings. getWorktreePathsPortable has a 5s | |
140 | + // timeout and returns [] on any error (not a git repo, git not installed). | |
141 | + const worktrees = await getWorktreePathsPortable(dir) | |
142 | + if (worktrees.length <= 1) return null | |
143 | + if (worktrees.length > MAX_WORKTREE_FANOUT) { | |
144 | + logForDebugging( | |
145 | + `[bridge:pointer] ${worktrees.length} worktrees exceeds fanout cap ${MAX_WORKTREE_FANOUT}, skipping`, | |
146 | + ) | |
147 | + return null | |
148 | + } | |
149 | + | |
150 | + // Dedupe against `dir` so we don't re-stat it. sanitizePath normalizes | |
151 | + // case/separators so worktree-list output matches our fast-path key even | |
152 | + // on Windows where git may emit C:/ vs stored c:/. | |
153 | + const dirKey = sanitizePath(dir) | |
154 | + const candidates = worktrees.filter(wt => sanitizePath(wt) !== dirKey) | |
155 | + | |
156 | + // Parallel stat+read. Each readBridgePointer is a stat() that ENOENTs | |
157 | + // for worktrees with no pointer (cheap) plus a ~100-byte read for the | |
158 | + // rare ones that have one. Promise.all → latency ≈ slowest single stat. | |
159 | + const results = await Promise.all( | |
160 | + candidates.map(async wt => { | |
161 | + const p = await readBridgePointer(wt) | |
162 | + return p ? { pointer: p, dir: wt } : null | |
163 | + }), | |
164 | + ) | |
165 | + | |
166 | + // Pick freshest (lowest ageMs). The pointer stores environmentId so | |
167 | + // resume reconnects to the right env regardless of which worktree | |
168 | + // --continue was invoked from. | |
169 | + let freshest: { | |
170 | + pointer: BridgePointer & { ageMs: number } | |
171 | + dir: string | |
172 | + } | null = null | |
173 | + for (const r of results) { | |
174 | + if (r && (!freshest || r.pointer.ageMs < freshest.pointer.ageMs)) { | |
175 | + freshest = r | |
176 | + } | |
177 | + } | |
178 | + if (freshest) { | |
179 | + logForDebugging( | |
180 | + `[bridge:pointer] fanout found pointer in worktree ${freshest.dir} (ageMs=${freshest.pointer.ageMs})`, | |
181 | + ) | |
182 | + } | |
183 | + return freshest | |
184 | +} | |
185 | + | |
186 | +/** | |
187 | + * Delete the pointer. Idempotent — ENOENT is expected when the process | |
188 | + * shut down clean previously. | |
189 | + */ | |
190 | +export async function clearBridgePointer(dir: string): Promise<void> { | |
191 | + const path = getBridgePointerPath(dir) | |
192 | + try { | |
193 | + await unlink(path) | |
194 | + logForDebugging(`[bridge:pointer] cleared ${path}`) | |
195 | + } catch (err: unknown) { | |
196 | + if (!isENOENT(err)) { | |
197 | + logForDebugging(`[bridge:pointer] clear failed: ${err}`, { | |
198 | + level: 'warn', | |
199 | + }) | |
200 | + } | |
201 | + } | |
202 | +} | |
203 | + | |
204 | +function safeJsonParse(raw: string): unknown { | |
205 | + try { | |
206 | + return jsonParse(raw) | |
207 | + } catch { | |
208 | + return null | |
209 | + } | |
210 | +} | |
0 | 211 | |
▼src/bridge/bridgeStatusUtil.ts
+163-0
| @@ -0,0 +1,163 @@ | ||
1 | +import { | |
2 | + getClaudeAiBaseUrl, | |
3 | + getRemoteSessionUrl, | |
4 | +} from '../constants/product.js' | |
5 | +import { stringWidth } from '../ink/stringWidth.js' | |
6 | +import { formatDuration, truncateToWidth } from '../utils/format.js' | |
7 | +import { getGraphemeSegmenter } from '../utils/intl.js' | |
8 | + | |
9 | +/** Bridge status state machine states. */ | |
10 | +export type StatusState = | |
11 | + | 'idle' | |
12 | + | 'attached' | |
13 | + | 'titled' | |
14 | + | 'reconnecting' | |
15 | + | 'failed' | |
16 | + | |
17 | +/** How long a tool activity line stays visible after last tool_start (ms). */ | |
18 | +export const TOOL_DISPLAY_EXPIRY_MS = 30_000 | |
19 | + | |
20 | +/** Interval for the shimmer animation tick (ms). */ | |
21 | +export const SHIMMER_INTERVAL_MS = 150 | |
22 | + | |
23 | +export function timestamp(): string { | |
24 | + const now = new Date() | |
25 | + const h = String(now.getHours()).padStart(2, '0') | |
26 | + const m = String(now.getMinutes()).padStart(2, '0') | |
27 | + const s = String(now.getSeconds()).padStart(2, '0') | |
28 | + return `${h}:${m}:${s}` | |
29 | +} | |
30 | + | |
31 | +export { formatDuration, truncateToWidth as truncatePrompt } | |
32 | + | |
33 | +/** Abbreviate a tool activity summary for the trail display. */ | |
34 | +export function abbreviateActivity(summary: string): string { | |
35 | + return truncateToWidth(summary, 30) | |
36 | +} | |
37 | + | |
38 | +/** Build the connect URL shown when the bridge is idle. */ | |
39 | +export function buildBridgeConnectUrl( | |
40 | + environmentId: string, | |
41 | + ingressUrl?: string, | |
42 | +): string { | |
43 | + const baseUrl = getClaudeAiBaseUrl(undefined, ingressUrl) | |
44 | + return `${baseUrl}/code?bridge=${environmentId}` | |
45 | +} | |
46 | + | |
47 | +/** | |
48 | + * Build the session URL shown when a session is attached. Delegates to | |
49 | + * getRemoteSessionUrl for the cse_→session_ prefix translation, then appends | |
50 | + * the v1-specific ?bridge={environmentId} query. | |
51 | + */ | |
52 | +export function buildBridgeSessionUrl( | |
53 | + sessionId: string, | |
54 | + environmentId: string, | |
55 | + ingressUrl?: string, | |
56 | +): string { | |
57 | + return `${getRemoteSessionUrl(sessionId, ingressUrl)}?bridge=${environmentId}` | |
58 | +} | |
59 | + | |
60 | +/** Compute the glimmer index for a reverse-sweep shimmer animation. */ | |
61 | +export function computeGlimmerIndex( | |
62 | + tick: number, | |
63 | + messageWidth: number, | |
64 | +): number { | |
65 | + const cycleLength = messageWidth + 20 | |
66 | + return messageWidth + 10 - (tick % cycleLength) | |
67 | +} | |
68 | + | |
69 | +/** | |
70 | + * Split text into three segments by visual column position for shimmer rendering. | |
71 | + * | |
72 | + * Uses grapheme segmentation and `stringWidth` so the split is correct for | |
73 | + * multi-byte characters, emoji, and CJK glyphs. | |
74 | + * | |
75 | + * Returns `{ before, shimmer, after }` strings. Both renderers (chalk in | |
76 | + * bridgeUI.ts and React/Ink in bridge.tsx) apply their own coloring to | |
77 | + * these segments. | |
78 | + */ | |
79 | +export function computeShimmerSegments( | |
80 | + text: string, | |
81 | + glimmerIndex: number, | |
82 | +): { before: string; shimmer: string; after: string } { | |
83 | + const messageWidth = stringWidth(text) | |
84 | + const shimmerStart = glimmerIndex - 1 | |
85 | + const shimmerEnd = glimmerIndex + 1 | |
86 | + | |
87 | + // When shimmer is offscreen, return all text as "before" | |
88 | + if (shimmerStart >= messageWidth || shimmerEnd < 0) { | |
89 | + return { before: text, shimmer: '', after: '' } | |
90 | + } | |
91 | + | |
92 | + // Split into at most 3 segments by visual column position | |
93 | + const clampedStart = Math.max(0, shimmerStart) | |
94 | + let colPos = 0 | |
95 | + let before = '' | |
96 | + let shimmer = '' | |
97 | + let after = '' | |
98 | + for (const { segment } of getGraphemeSegmenter().segment(text)) { | |
99 | + const segWidth = stringWidth(segment) | |
100 | + if (colPos + segWidth <= clampedStart) { | |
101 | + before += segment | |
102 | + } else if (colPos > shimmerEnd) { | |
103 | + after += segment | |
104 | + } else { | |
105 | + shimmer += segment | |
106 | + } | |
107 | + colPos += segWidth | |
108 | + } | |
109 | + | |
110 | + return { before, shimmer, after } | |
111 | +} | |
112 | + | |
113 | +/** Computed bridge status label and color from connection state. */ | |
114 | +export type BridgeStatusInfo = { | |
115 | + label: | |
116 | + | 'Remote Control failed' | |
117 | + | 'Remote Control reconnecting' | |
118 | + | 'Remote Control active' | |
119 | + | 'Remote Control connecting\u2026' | |
120 | + color: 'error' | 'warning' | 'success' | |
121 | +} | |
122 | + | |
123 | +/** Derive a status label and color from the bridge connection state. */ | |
124 | +export function getBridgeStatus({ | |
125 | + error, | |
126 | + connected, | |
127 | + sessionActive, | |
128 | + reconnecting, | |
129 | +}: { | |
130 | + error: string | undefined | |
131 | + connected: boolean | |
132 | + sessionActive: boolean | |
133 | + reconnecting: boolean | |
134 | +}): BridgeStatusInfo { | |
135 | + if (error) return { label: 'Remote Control failed', color: 'error' } | |
136 | + if (reconnecting) | |
137 | + return { label: 'Remote Control reconnecting', color: 'warning' } | |
138 | + if (sessionActive || connected) | |
139 | + return { label: 'Remote Control active', color: 'success' } | |
140 | + return { label: 'Remote Control connecting\u2026', color: 'warning' } | |
141 | +} | |
142 | + | |
143 | +/** Footer text shown when bridge is idle (Ready state). */ | |
144 | +export function buildIdleFooterText(url: string): string { | |
145 | + return `Code everywhere with the Claude app or ${url}` | |
146 | +} | |
147 | + | |
148 | +/** Footer text shown when a session is active (Connected state). */ | |
149 | +export function buildActiveFooterText(url: string): string { | |
150 | + return `Continue coding in the Claude app or ${url}` | |
151 | +} | |
152 | + | |
153 | +/** Footer text shown when the bridge has failed. */ | |
154 | +export const FAILED_FOOTER_TEXT = 'Something went wrong, please try again' | |
155 | + | |
156 | +/** | |
157 | + * Wrap text in an OSC 8 terminal hyperlink. Zero visual width for layout purposes. | |
158 | + * strip-ansi (used by stringWidth) correctly strips these sequences, so | |
159 | + * countVisualLines in bridgeUI.ts remains accurate. | |
160 | + */ | |
161 | +export function wrapWithOsc8Link(text: string, url: string): string { | |
162 | + return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07` | |
163 | +} | |
0 | 164 | |
▼src/bridge/bridgeUI.ts
+530-0
| @@ -0,0 +1,530 @@ | ||
1 | +import chalk from 'chalk' | |
2 | +import { toString as qrToString } from 'qrcode' | |
3 | +import { | |
4 | + BRIDGE_FAILED_INDICATOR, | |
5 | + BRIDGE_READY_INDICATOR, | |
6 | + BRIDGE_SPINNER_FRAMES, | |
7 | +} from '../constants/figures.js' | |
8 | +import { stringWidth } from '../ink/stringWidth.js' | |
9 | +import { logForDebugging } from '../utils/debug.js' | |
10 | +import { | |
11 | + buildActiveFooterText, | |
12 | + buildBridgeConnectUrl, | |
13 | + buildBridgeSessionUrl, | |
14 | + buildIdleFooterText, | |
15 | + FAILED_FOOTER_TEXT, | |
16 | + formatDuration, | |
17 | + type StatusState, | |
18 | + TOOL_DISPLAY_EXPIRY_MS, | |
19 | + timestamp, | |
20 | + truncatePrompt, | |
21 | + wrapWithOsc8Link, | |
22 | +} from './bridgeStatusUtil.js' | |
23 | +import type { | |
24 | + BridgeConfig, | |
25 | + BridgeLogger, | |
26 | + SessionActivity, | |
27 | + SpawnMode, | |
28 | +} from './types.js' | |
29 | + | |
30 | +const QR_OPTIONS = { | |
31 | + type: 'utf8' as const, | |
32 | + errorCorrectionLevel: 'L' as const, | |
33 | + small: true, | |
34 | +} | |
35 | + | |
36 | +/** Generate a QR code and return its lines. */ | |
37 | +async function generateQr(url: string): Promise<string[]> { | |
38 | + const qr = await qrToString(url, QR_OPTIONS) | |
39 | + return qr.split('\n').filter((line: string) => line.length > 0) | |
40 | +} | |
41 | + | |
42 | +export function createBridgeLogger(options: { | |
43 | + verbose: boolean | |
44 | + write?: (s: string) => void | |
45 | +}): BridgeLogger { | |
46 | + const write = options.write ?? ((s: string) => process.stdout.write(s)) | |
47 | + const verbose = options.verbose | |
48 | + | |
49 | + // Track how many status lines are currently displayed at the bottom | |
50 | + let statusLineCount = 0 | |
51 | + | |
52 | + // Status state machine | |
53 | + let currentState: StatusState = 'idle' | |
54 | + let currentStateText = 'Ready' | |
55 | + let repoName = '' | |
56 | + let branch = '' | |
57 | + let debugLogPath = '' | |
58 | + | |
59 | + // Connect URL (built in printBanner with correct base for staging/prod) | |
60 | + let connectUrl = '' | |
61 | + let cachedIngressUrl = '' | |
62 | + let cachedEnvironmentId = '' | |
63 | + let activeSessionUrl: string | null = null | |
64 | + | |
65 | + // QR code lines for the current URL | |
66 | + let qrLines: string[] = [] | |
67 | + let qrVisible = false | |
68 | + | |
69 | + // Tool activity for the second status line | |
70 | + let lastToolSummary: string | null = null | |
71 | + let lastToolTime = 0 | |
72 | + | |
73 | + // Session count indicator (shown when multi-session mode is enabled) | |
74 | + let sessionActive = 0 | |
75 | + let sessionMax = 1 | |
76 | + // Spawn mode shown in the session-count line + gates the `w` hint | |
77 | + let spawnModeDisplay: 'same-dir' | 'worktree' | null = null | |
78 | + let spawnMode: SpawnMode = 'single-session' | |
79 | + | |
80 | + // Per-session display info for the multi-session bullet list (keyed by compat sessionId) | |
81 | + const sessionDisplayInfo = new Map< | |
82 | + string, | |
83 | + { title?: string; url: string; activity?: SessionActivity } | |
84 | + >() | |
85 | + | |
86 | + // Connecting spinner state | |
87 | + let connectingTimer: ReturnType<typeof setInterval> | null = null | |
88 | + let connectingTick = 0 | |
89 | + | |
90 | + /** | |
91 | + * Count how many visual terminal rows a string occupies, accounting for | |
92 | + * line wrapping. Each `\n` is one row, and content wider than the terminal | |
93 | + * wraps to additional rows. | |
94 | + */ | |
95 | + function countVisualLines(text: string): number { | |
96 | + // eslint-disable-next-line custom-rules/prefer-use-terminal-size | |
97 | + const cols = process.stdout.columns || 80 // non-React CLI context | |
98 | + let count = 0 | |
99 | + // Split on newlines to get logical lines | |
100 | + for (const logical of text.split('\n')) { | |
101 | + if (logical.length === 0) { | |
102 | + // Empty segment between consecutive \n — counts as 1 row | |
103 | + count++ | |
104 | + continue | |
105 | + } | |
106 | + const width = stringWidth(logical) | |
107 | + count += Math.max(1, Math.ceil(width / cols)) | |
108 | + } | |
109 | + // The trailing \n in "line\n" produces an empty last element — don't count it | |
110 | + // because the cursor sits at the start of the next line, not a new visual row. | |
111 | + if (text.endsWith('\n')) { | |
112 | + count-- | |
113 | + } | |
114 | + return count | |
115 | + } | |
116 | + | |
117 | + /** Write a status line and track its visual line count. */ | |
118 | + function writeStatus(text: string): void { | |
119 | + write(text) | |
120 | + statusLineCount += countVisualLines(text) | |
121 | + } | |
122 | + | |
123 | + /** Clear any currently displayed status lines. */ | |
124 | + function clearStatusLines(): void { | |
125 | + if (statusLineCount <= 0) return | |
126 | + logForDebugging(`[bridge:ui] clearStatusLines count=${statusLineCount}`) | |
127 | + // Move cursor up to the start of the status block, then erase everything below | |
128 | + write(`\x1b[${statusLineCount}A`) // cursor up N lines | |
129 | + write('\x1b[J') // erase from cursor to end of screen | |
130 | + statusLineCount = 0 | |
131 | + } | |
132 | + | |
133 | + /** Print a permanent log line, clearing status first and restoring after. */ | |
134 | + function printLog(line: string): void { | |
135 | + clearStatusLines() | |
136 | + write(line) | |
137 | + } | |
138 | + | |
139 | + /** Regenerate the QR code with the given URL. */ | |
140 | + function regenerateQr(url: string): void { | |
141 | + generateQr(url) | |
142 | + .then(lines => { | |
143 | + qrLines = lines | |
144 | + renderStatusLine() | |
145 | + }) | |
146 | + .catch(e => { | |
147 | + logForDebugging(`QR code generation failed: ${e}`, { level: 'error' }) | |
148 | + }) | |
149 | + } | |
150 | + | |
151 | + /** Render the connecting spinner line (shown before first updateIdleStatus). */ | |
152 | + function renderConnectingLine(): void { | |
153 | + clearStatusLines() | |
154 | + | |
155 | + const frame = | |
156 | + BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]! | |
157 | + let suffix = '' | |
158 | + if (repoName) { | |
159 | + suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName) | |
160 | + } | |
161 | + if (branch) { | |
162 | + suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch) | |
163 | + } | |
164 | + writeStatus( | |
165 | + `${chalk.yellow(frame)} ${chalk.yellow('Connecting')}${suffix}\n`, | |
166 | + ) | |
167 | + } | |
168 | + | |
169 | + /** Start the connecting spinner. Stopped by first updateIdleStatus(). */ | |
170 | + function startConnecting(): void { | |
171 | + stopConnecting() | |
172 | + renderConnectingLine() | |
173 | + connectingTimer = setInterval(() => { | |
174 | + connectingTick++ | |
175 | + renderConnectingLine() | |
176 | + }, 150) | |
177 | + } | |
178 | + | |
179 | + /** Stop the connecting spinner. */ | |
180 | + function stopConnecting(): void { | |
181 | + if (connectingTimer) { | |
182 | + clearInterval(connectingTimer) | |
183 | + connectingTimer = null | |
184 | + } | |
185 | + } | |
186 | + | |
187 | + /** Render and write the current status lines based on state. */ | |
188 | + function renderStatusLine(): void { | |
189 | + if (currentState === 'reconnecting' || currentState === 'failed') { | |
190 | + // These states are handled separately (updateReconnectingStatus / | |
191 | + // updateFailedStatus). Return before clearing so callers like toggleQr | |
192 | + // and setSpawnModeDisplay don't blank the display during these states. | |
193 | + return | |
194 | + } | |
195 | + | |
196 | + clearStatusLines() | |
197 | + | |
198 | + const isIdle = currentState === 'idle' | |
199 | + | |
200 | + // QR code above the status line | |
201 | + if (qrVisible) { | |
202 | + for (const line of qrLines) { | |
203 | + writeStatus(`${chalk.dim(line)}\n`) | |
204 | + } | |
205 | + } | |
206 | + | |
207 | + // Determine indicator and colors based on state | |
208 | + const indicator = BRIDGE_READY_INDICATOR | |
209 | + const indicatorColor = isIdle ? chalk.green : chalk.cyan | |
210 | + const baseColor = isIdle ? chalk.green : chalk.cyan | |
211 | + const stateText = baseColor(currentStateText) | |
212 | + | |
213 | + // Build the suffix with repo and branch | |
214 | + let suffix = '' | |
215 | + if (repoName) { | |
216 | + suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName) | |
217 | + } | |
218 | + // In worktree mode each session gets its own branch, so showing the | |
219 | + // bridge's branch would be misleading. | |
220 | + if (branch && spawnMode !== 'worktree') { | |
221 | + suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch) | |
222 | + } | |
223 | + | |
224 | + if (process.env.USER_TYPE === 'ant' && debugLogPath) { | |
225 | + writeStatus( | |
226 | + `${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`, | |
227 | + ) | |
228 | + } | |
229 | + writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`) | |
230 | + | |
231 | + // Session count and per-session list (multi-session mode only) | |
232 | + if (sessionMax > 1) { | |
233 | + const modeHint = | |
234 | + spawnMode === 'worktree' | |
235 | + ? 'New sessions will be created in an isolated worktree' | |
236 | + : 'New sessions will be created in the current directory' | |
237 | + writeStatus( | |
238 | + ` ${chalk.dim(`Capacity: ${sessionActive}/${sessionMax} \u00b7 ${modeHint}`)}\n`, | |
239 | + ) | |
240 | + for (const [, info] of sessionDisplayInfo) { | |
241 | + const titleText = info.title | |
242 | + ? truncatePrompt(info.title, 35) | |
243 | + : chalk.dim('Attached') | |
244 | + const titleLinked = wrapWithOsc8Link(titleText, info.url) | |
245 | + const act = info.activity | |
246 | + const showAct = act && act.type !== 'result' && act.type !== 'error' | |
247 | + const actText = showAct | |
248 | + ? chalk.dim(` ${truncatePrompt(act.summary, 40)}`) | |
249 | + : '' | |
250 | + writeStatus(` ${titleLinked}${actText} | |
251 | +`) | |
252 | + } | |
253 | + } | |
254 | + | |
255 | + // Mode line for spawn modes with a single slot (or true single-session mode) | |
256 | + if (sessionMax === 1) { | |
257 | + const modeText = | |
258 | + spawnMode === 'single-session' | |
259 | + ? 'Single session \u00b7 exits when complete' | |
260 | + : spawnMode === 'worktree' | |
261 | + ? `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in an isolated worktree` | |
262 | + : `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in the current directory` | |
263 | + writeStatus(` ${chalk.dim(modeText)}\n`) | |
264 | + } | |
265 | + | |
266 | + // Tool activity line for single-session mode | |
267 | + if ( | |
268 | + sessionMax === 1 && | |
269 | + !isIdle && | |
270 | + lastToolSummary && | |
271 | + Date.now() - lastToolTime < TOOL_DISPLAY_EXPIRY_MS | |
272 | + ) { | |
273 | + writeStatus(` ${chalk.dim(truncatePrompt(lastToolSummary, 60))}\n`) | |
274 | + } | |
275 | + | |
276 | + // Blank line separator before footer | |
277 | + const url = activeSessionUrl ?? connectUrl | |
278 | + if (url) { | |
279 | + writeStatus('\n') | |
280 | + const footerText = isIdle | |
281 | + ? buildIdleFooterText(url) | |
282 | + : buildActiveFooterText(url) | |
283 | + const qrHint = qrVisible | |
284 | + ? chalk.dim.italic('space to hide QR code') | |
285 | + : chalk.dim.italic('space to show QR code') | |
286 | + const toggleHint = spawnModeDisplay | |
287 | + ? chalk.dim.italic(' \u00b7 w to toggle spawn mode') | |
288 | + : '' | |
289 | + writeStatus(`${chalk.dim(footerText)}\n`) | |
290 | + writeStatus(`${qrHint}${toggleHint}\n`) | |
291 | + } | |
292 | + } | |
293 | + | |
294 | + return { | |
295 | + printBanner(config: BridgeConfig, environmentId: string): void { | |
296 | + cachedIngressUrl = config.sessionIngressUrl | |
297 | + cachedEnvironmentId = environmentId | |
298 | + connectUrl = buildBridgeConnectUrl(environmentId, cachedIngressUrl) | |
299 | + regenerateQr(connectUrl) | |
300 | + | |
301 | + if (verbose) { | |
302 | + write(chalk.dim(`Remote Control`) + ` v${MACRO.VERSION}\n`) | |
303 | + } | |
304 | + if (verbose) { | |
305 | + if (config.spawnMode !== 'single-session') { | |
306 | + write(chalk.dim(`Spawn mode: `) + `${config.spawnMode}\n`) | |
307 | + write( | |
308 | + chalk.dim(`Max concurrent sessions: `) + `${config.maxSessions}\n`, | |
309 | + ) | |
310 | + } | |
311 | + write(chalk.dim(`Environment ID: `) + `${environmentId}\n`) | |
312 | + } | |
313 | + if (config.sandbox) { | |
314 | + write(chalk.dim(`Sandbox: `) + `${chalk.green('Enabled')}\n`) | |
315 | + } | |
316 | + write('\n') | |
317 | + | |
318 | + // Start connecting spinner — first updateIdleStatus() will stop it | |
319 | + startConnecting() | |
320 | + }, | |
321 | + | |
322 | + logSessionStart(sessionId: string, prompt: string): void { | |
323 | + if (verbose) { | |
324 | + const short = truncatePrompt(prompt, 80) | |
325 | + printLog( | |
326 | + chalk.dim(`[${timestamp()}]`) + | |
327 | + ` Session started: ${chalk.white(`"${short}"`)} (${chalk.dim(sessionId)})\n`, | |
328 | + ) | |
329 | + } | |
330 | + }, | |
331 | + | |
332 | + logSessionComplete(sessionId: string, durationMs: number): void { | |
333 | + printLog( | |
334 | + chalk.dim(`[${timestamp()}]`) + | |
335 | + ` Session ${chalk.green('completed')} (${formatDuration(durationMs)}) ${chalk.dim(sessionId)}\n`, | |
336 | + ) | |
337 | + }, | |
338 | + | |
339 | + logSessionFailed(sessionId: string, error: string): void { | |
340 | + printLog( | |
341 | + chalk.dim(`[${timestamp()}]`) + | |
342 | + ` Session ${chalk.red('failed')}: ${error} ${chalk.dim(sessionId)}\n`, | |
343 | + ) | |
344 | + }, | |
345 | + | |
346 | + logStatus(message: string): void { | |
347 | + printLog(chalk.dim(`[${timestamp()}]`) + ` ${message}\n`) | |
348 | + }, | |
349 | + | |
350 | + logVerbose(message: string): void { | |
351 | + if (verbose) { | |
352 | + printLog(chalk.dim(`[${timestamp()}] ${message}`) + '\n') | |
353 | + } | |
354 | + }, | |
355 | + | |
356 | + logError(message: string): void { | |
357 | + printLog(chalk.red(`[${timestamp()}] Error: ${message}`) + '\n') | |
358 | + }, | |
359 | + | |
360 | + logReconnected(disconnectedMs: number): void { | |
361 | + printLog( | |
362 | + chalk.dim(`[${timestamp()}]`) + | |
363 | + ` ${chalk.green('Reconnected')} after ${formatDuration(disconnectedMs)}\n`, | |
364 | + ) | |
365 | + }, | |
366 | + | |
367 | + setRepoInfo(repo: string, branchName: string): void { | |
368 | + repoName = repo | |
369 | + branch = branchName | |
370 | + }, | |
371 | + | |
372 | + setDebugLogPath(path: string): void { | |
373 | + debugLogPath = path | |
374 | + }, | |
375 | + | |
376 | + updateIdleStatus(): void { | |
377 | + stopConnecting() | |
378 | + | |
379 | + currentState = 'idle' | |
380 | + currentStateText = 'Ready' | |
381 | + lastToolSummary = null | |
382 | + lastToolTime = 0 | |
383 | + activeSessionUrl = null | |
384 | + regenerateQr(connectUrl) | |
385 | + renderStatusLine() | |
386 | + }, | |
387 | + | |
388 | + setAttached(sessionId: string): void { | |
389 | + stopConnecting() | |
390 | + currentState = 'attached' | |
391 | + currentStateText = 'Connected' | |
392 | + lastToolSummary = null | |
393 | + lastToolTime = 0 | |
394 | + // Multi-session: keep footer/QR on the environment connect URL so users | |
395 | + // can spawn more sessions. Per-session links are in the bullet list. | |
396 | + if (sessionMax <= 1) { | |
397 | + activeSessionUrl = buildBridgeSessionUrl( | |
398 | + sessionId, | |
399 | + cachedEnvironmentId, | |
400 | + cachedIngressUrl, | |
401 | + ) | |
402 | + regenerateQr(activeSessionUrl) | |
403 | + } | |
404 | + renderStatusLine() | |
405 | + }, | |
406 | + | |
407 | + updateReconnectingStatus(delayStr: string, elapsedStr: string): void { | |
408 | + stopConnecting() | |
409 | + clearStatusLines() | |
410 | + currentState = 'reconnecting' | |
411 | + | |
412 | + // QR code above the status line | |
413 | + if (qrVisible) { | |
414 | + for (const line of qrLines) { | |
415 | + writeStatus(`${chalk.dim(line)}\n`) | |
416 | + } | |
417 | + } | |
418 | + | |
419 | + const frame = | |
420 | + BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]! | |
421 | + connectingTick++ | |
422 | + writeStatus( | |
423 | + `${chalk.yellow(frame)} ${chalk.yellow('Reconnecting')} ${chalk.dim('\u00b7')} ${chalk.dim(`retrying in ${delayStr}`)} ${chalk.dim('\u00b7')} ${chalk.dim(`disconnected ${elapsedStr}`)}\n`, | |
424 | + ) | |
425 | + }, | |
426 | + | |
427 | + updateFailedStatus(error: string): void { | |
428 | + stopConnecting() | |
429 | + clearStatusLines() | |
430 | + currentState = 'failed' | |
431 | + | |
432 | + let suffix = '' | |
433 | + if (repoName) { | |
434 | + suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName) | |
435 | + } | |
436 | + if (branch) { | |
437 | + suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch) | |
438 | + } | |
439 | + | |
440 | + writeStatus( | |
441 | + `${chalk.red(BRIDGE_FAILED_INDICATOR)} ${chalk.red('Remote Control Failed')}${suffix}\n`, | |
442 | + ) | |
443 | + writeStatus(`${chalk.dim(FAILED_FOOTER_TEXT)}\n`) | |
444 | + | |
445 | + if (error) { | |
446 | + writeStatus(`${chalk.red(error)}\n`) | |
447 | + } | |
448 | + }, | |
449 | + | |
450 | + updateSessionStatus( | |
451 | + _sessionId: string, | |
452 | + _elapsed: string, | |
453 | + activity: SessionActivity, | |
454 | + _trail: string[], | |
455 | + ): void { | |
456 | + // Cache tool activity for the second status line | |
457 | + if (activity.type === 'tool_start') { | |
458 | + lastToolSummary = activity.summary | |
459 | + lastToolTime = Date.now() | |
460 | + } | |
461 | + renderStatusLine() | |
462 | + }, | |
463 | + | |
464 | + clearStatus(): void { | |
465 | + stopConnecting() | |
466 | + clearStatusLines() | |
467 | + }, | |
468 | + | |
469 | + toggleQr(): void { | |
470 | + qrVisible = !qrVisible | |
471 | + renderStatusLine() | |
472 | + }, | |
473 | + | |
474 | + updateSessionCount(active: number, max: number, mode: SpawnMode): void { | |
475 | + if (sessionActive === active && sessionMax === max && spawnMode === mode) | |
476 | + return | |
477 | + sessionActive = active | |
478 | + sessionMax = max | |
479 | + spawnMode = mode | |
480 | + // Don't re-render here — the status ticker calls renderStatusLine | |
481 | + // on its own cadence, and the next tick will pick up the new values. | |
482 | + }, | |
483 | + | |
484 | + setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void { | |
485 | + if (spawnModeDisplay === mode) return | |
486 | + spawnModeDisplay = mode | |
487 | + // Also sync the #21118-added spawnMode so the next render shows correct | |
488 | + // mode hint + branch visibility. Don't render here — matches | |
489 | + // updateSessionCount: called before printBanner (initial setup) and | |
490 | + // again from the `w` handler (which follows with refreshDisplay). | |
491 | + if (mode) spawnMode = mode | |
492 | + }, | |
493 | + | |
494 | + addSession(sessionId: string, url: string): void { | |
495 | + sessionDisplayInfo.set(sessionId, { url }) | |
496 | + }, | |
497 | + | |
498 | + updateSessionActivity(sessionId: string, activity: SessionActivity): void { | |
499 | + const info = sessionDisplayInfo.get(sessionId) | |
500 | + if (!info) return | |
501 | + info.activity = activity | |
502 | + }, | |
503 | + | |
504 | + setSessionTitle(sessionId: string, title: string): void { | |
505 | + const info = sessionDisplayInfo.get(sessionId) | |
506 | + if (!info) return | |
507 | + info.title = title | |
508 | + // Guard against reconnecting/failed — renderStatusLine clears then returns | |
509 | + // early for those states, which would erase the spinner/error. | |
510 | + if (currentState === 'reconnecting' || currentState === 'failed') return | |
511 | + if (sessionMax === 1) { | |
512 | + // Single-session: show title in the main status line too. | |
513 | + currentState = 'titled' | |
514 | + currentStateText = truncatePrompt(title, 40) | |
515 | + } | |
516 | + renderStatusLine() | |
517 | + }, | |
518 | + | |
519 | + removeSession(sessionId: string): void { | |
520 | + sessionDisplayInfo.delete(sessionId) | |
521 | + }, | |
522 | + | |
523 | + refreshDisplay(): void { | |
524 | + // Skip during reconnecting/failed — renderStatusLine clears then returns | |
525 | + // early for those states, which would erase the spinner/error. | |
526 | + if (currentState === 'reconnecting' || currentState === 'failed') return | |
527 | + renderStatusLine() | |
528 | + }, | |
529 | + } | |
530 | +} | |
0 | 531 | |
▼src/bridge/capacityWake.ts
+56-0
| @@ -0,0 +1,56 @@ | ||
1 | +/** | |
2 | + * Shared capacity-wake primitive for bridge poll loops. | |
3 | + * | |
4 | + * Both replBridge.ts and bridgeMain.ts need to sleep while "at capacity" | |
5 | + * but wake early when either (a) the outer loop signal aborts (shutdown), | |
6 | + * or (b) capacity frees up (session done / transport lost). This module | |
7 | + * encapsulates the mutable wake-controller + two-signal merger that both | |
8 | + * poll loops previously duplicated byte-for-byte. | |
9 | + */ | |
10 | + | |
11 | +export type CapacitySignal = { signal: AbortSignal; cleanup: () => void } | |
12 | + | |
13 | +export type CapacityWake = { | |
14 | + /** | |
15 | + * Create a signal that aborts when either the outer loop signal or the | |
16 | + * capacity-wake controller fires. Returns the merged signal and a cleanup | |
17 | + * function that removes listeners when the sleep resolves normally | |
18 | + * (without abort). | |
19 | + */ | |
20 | + signal(): CapacitySignal | |
21 | + /** | |
22 | + * Abort the current at-capacity sleep and arm a fresh controller so the | |
23 | + * poll loop immediately re-checks for new work. | |
24 | + */ | |
25 | + wake(): void | |
26 | +} | |
27 | + | |
28 | +export function createCapacityWake(outerSignal: AbortSignal): CapacityWake { | |
29 | + let wakeController = new AbortController() | |
30 | + | |
31 | + function wake(): void { | |
32 | + wakeController.abort() | |
33 | + wakeController = new AbortController() | |
34 | + } | |
35 | + | |
36 | + function signal(): CapacitySignal { | |
37 | + const merged = new AbortController() | |
38 | + const abort = (): void => merged.abort() | |
39 | + if (outerSignal.aborted || wakeController.signal.aborted) { | |
40 | + merged.abort() | |
41 | + return { signal: merged.signal, cleanup: () => {} } | |
42 | + } | |
43 | + outerSignal.addEventListener('abort', abort, { once: true }) | |
44 | + const capSig = wakeController.signal | |
45 | + capSig.addEventListener('abort', abort, { once: true }) | |
46 | + return { | |
47 | + signal: merged.signal, | |
48 | + cleanup: () => { | |
49 | + outerSignal.removeEventListener('abort', abort) | |
50 | + capSig.removeEventListener('abort', abort) | |
51 | + }, | |
52 | + } | |
53 | + } | |
54 | + | |
55 | + return { signal, wake } | |
56 | +} | |
0 | 57 | |
▼src/bridge/codeSessionApi.ts
+168-0
| @@ -0,0 +1,168 @@ | ||
1 | +/** | |
2 | + * Thin HTTP wrappers for the CCR v2 code-session API. | |
3 | + * | |
4 | + * Separate file from remoteBridgeCore.ts so the SDK /bridge subpath can | |
5 | + * export createCodeSession + fetchRemoteCredentials without bundling the | |
6 | + * heavy CLI tree (analytics, transport, etc.). Callers supply explicit | |
7 | + * accessToken + baseUrl — no implicit auth or config reads. | |
8 | + */ | |
9 | + | |
10 | +import axios from 'axios' | |
11 | +import { logForDebugging } from '../utils/debug.js' | |
12 | +import { errorMessage } from '../utils/errors.js' | |
13 | +import { jsonStringify } from '../utils/slowOperations.js' | |
14 | +import { extractErrorDetail } from './debugUtils.js' | |
15 | + | |
16 | +const ANTHROPIC_VERSION = '2023-06-01' | |
17 | + | |
18 | +function oauthHeaders(accessToken: string): Record<string, string> { | |
19 | + return { | |
20 | + Authorization: `Bearer ${accessToken}`, | |
21 | + 'Content-Type': 'application/json', | |
22 | + 'anthropic-version': ANTHROPIC_VERSION, | |
23 | + } | |
24 | +} | |
25 | + | |
26 | +export async function createCodeSession( | |
27 | + baseUrl: string, | |
28 | + accessToken: string, | |
29 | + title: string, | |
30 | + timeoutMs: number, | |
31 | + tags?: string[], | |
32 | +): Promise<string | null> { | |
33 | + const url = `${baseUrl}/v1/code/sessions` | |
34 | + let response | |
35 | + try { | |
36 | + response = await axios.post( | |
37 | + url, | |
38 | + // bridge: {} is the positive signal for the oneof runner — omitting it | |
39 | + // (or sending environment_id: "") now 400s. BridgeRunner is an empty | |
40 | + // message today; it's a placeholder for future bridge-specific options. | |
41 | + { title, bridge: {}, ...(tags?.length ? { tags } : {}) }, | |
42 | + { | |
43 | + headers: oauthHeaders(accessToken), | |
44 | + timeout: timeoutMs, | |
45 | + validateStatus: s => s < 500, | |
46 | + }, | |
47 | + ) | |
48 | + } catch (err: unknown) { | |
49 | + logForDebugging( | |
50 | + `[code-session] Session create request failed: ${errorMessage(err)}`, | |
51 | + ) | |
52 | + return null | |
53 | + } | |
54 | + | |
55 | + if (response.status !== 200 && response.status !== 201) { | |
56 | + const detail = extractErrorDetail(response.data) | |
57 | + logForDebugging( | |
58 | + `[code-session] Session create failed ${response.status}${detail ? `: ${detail}` : ''}`, | |
59 | + ) | |
60 | + return null | |
61 | + } | |
62 | + | |
63 | + const data: unknown = response.data | |
64 | + if ( | |
65 | + !data || | |
66 | + typeof data !== 'object' || | |
67 | + !('session' in data) || | |
68 | + !data.session || | |
69 | + typeof data.session !== 'object' || | |
70 | + !('id' in data.session) || | |
71 | + typeof data.session.id !== 'string' || | |
72 | + !data.session.id.startsWith('cse_') | |
73 | + ) { | |
74 | + logForDebugging( | |
75 | + `[code-session] No session.id (cse_*) in response: ${jsonStringify(data).slice(0, 200)}`, | |
76 | + ) | |
77 | + return null | |
78 | + } | |
79 | + return data.session.id | |
80 | +} | |
81 | + | |
82 | +/** | |
83 | + * Credentials from POST /bridge. JWT is opaque — do not decode. | |
84 | + * Each /bridge call bumps worker_epoch server-side (it IS the register). | |
85 | + */ | |
86 | +export type RemoteCredentials = { | |
87 | + worker_jwt: string | |
88 | + api_base_url: string | |
89 | + expires_in: number | |
90 | + worker_epoch: number | |
91 | +} | |
92 | + | |
93 | +export async function fetchRemoteCredentials( | |
94 | + sessionId: string, | |
95 | + baseUrl: string, | |
96 | + accessToken: string, | |
97 | + timeoutMs: number, | |
98 | + trustedDeviceToken?: string, | |
99 | +): Promise<RemoteCredentials | null> { | |
100 | + const url = `${baseUrl}/v1/code/sessions/${sessionId}/bridge` | |
101 | + const headers = oauthHeaders(accessToken) | |
102 | + if (trustedDeviceToken) { | |
103 | + headers['X-Trusted-Device-Token'] = trustedDeviceToken | |
104 | + } | |
105 | + let response | |
106 | + try { | |
107 | + response = await axios.post( | |
108 | + url, | |
109 | + {}, | |
110 | + { | |
111 | + headers, | |
112 | + timeout: timeoutMs, | |
113 | + validateStatus: s => s < 500, | |
114 | + }, | |
115 | + ) | |
116 | + } catch (err: unknown) { | |
117 | + logForDebugging( | |
118 | + `[code-session] /bridge request failed: ${errorMessage(err)}`, | |
119 | + ) | |
120 | + return null | |
121 | + } | |
122 | + | |
123 | + if (response.status !== 200) { | |
124 | + const detail = extractErrorDetail(response.data) | |
125 | + logForDebugging( | |
126 | + `[code-session] /bridge failed ${response.status}${detail ? `: ${detail}` : ''}`, | |
127 | + ) | |
128 | + return null | |
129 | + } | |
130 | + | |
131 | + const data: unknown = response.data | |
132 | + if ( | |
133 | + data === null || | |
134 | + typeof data !== 'object' || | |
135 | + !('worker_jwt' in data) || | |
136 | + typeof data.worker_jwt !== 'string' || | |
137 | + !('expires_in' in data) || | |
138 | + typeof data.expires_in !== 'number' || | |
139 | + !('api_base_url' in data) || | |
140 | + typeof data.api_base_url !== 'string' || | |
141 | + !('worker_epoch' in data) | |
142 | + ) { | |
143 | + logForDebugging( | |
144 | + `[code-session] /bridge response malformed (need worker_jwt, expires_in, api_base_url, worker_epoch): ${jsonStringify(data).slice(0, 200)}`, | |
145 | + ) | |
146 | + return null | |
147 | + } | |
148 | + // protojson serializes int64 as a string to avoid JS precision loss; | |
149 | + // Go may also return a number depending on encoder settings. | |
150 | + const rawEpoch = data.worker_epoch | |
151 | + const epoch = typeof rawEpoch === 'string' ? Number(rawEpoch) : rawEpoch | |
152 | + if ( | |
153 | + typeof epoch !== 'number' || | |
154 | + !Number.isFinite(epoch) || | |
155 | + !Number.isSafeInteger(epoch) | |
156 | + ) { | |
157 | + logForDebugging( | |
158 | + `[code-session] /bridge worker_epoch invalid: ${jsonStringify(rawEpoch)}`, | |
159 | + ) | |
160 | + return null | |
161 | + } | |
162 | + return { | |
163 | + worker_jwt: data.worker_jwt, | |
164 | + api_base_url: data.api_base_url, | |
165 | + expires_in: data.expires_in, | |
166 | + worker_epoch: epoch, | |
167 | + } | |
168 | +} | |
0 | 169 | |