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
▼bin/mervelas.js
+2-0
1
+#!/usr/bin/env node
2
+import '../dist/cli.mjs'
0
3
▼scripts/build.ts
+281-0
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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