// Long-running bridge that runs inside a sandbox alongside the `claude` CLI. // The generic transport — WebSocket server, token auth, single-flight // reconnect, the in-memory event log + `seq`, resume replay, and the // lifecycle/meta files — lives in the shared `@ai-sdk/harness/bridge` runtime. // This file supplies only the Claude-specific turn driver. import { runBridge, type BridgeEvent, type BridgeTurn, type Experimental_BridgeUserMessage, type Experimental_BridgeUserMessageQueue, } from '@ai-sdk/harness/bridge'; import { createCompactionLatch } from './compaction-latch'; import type { StartMessage } from '../claude-code-bridge-protocol'; import { randomUUID } from 'node:crypto'; import { argv, env as procEnv, stdout } from 'node:process'; /* * CONSTRAINT — the third-party imports below are NEVER bundled into the * compiled `bridge/index.mjs`. They are declared `external` in * tsup.config.ts and resolved at runtime from the node_modules that this * bridge installs *inside the sandbox* from `src/bridge/package.json` (and * its pinned `pnpm-lock.yaml`). That bridge package.json — NOT this host * package — is the single source of truth for these packages and their * versions; the published `@ai-sdk/harness-claude-code` package does not * provide them at runtime. * * When adding or changing a third-party import here you MUST keep all three * in sync, or the bridge will either get the dependency bundled in or fail * to resolve it in the sandbox: * 1. the import statement below, * 2. the `external` array in tsup.config.ts, and * 3. the dependency entry in `src/bridge/package.json`. */ import * as claudeAgentSdk from '@anthropic-ai/claude-agent-sdk'; import * as mcpServerModule from '@modelcontextprotocol/sdk/server/mcp.js'; import { createClaudeCodeSystemPrompt } from './claude-code-system-prompt'; import { toClaudeSkillsOption } from './claude-skills-option'; import { createClaudeStreamEventState, createEmitStreamEvent, defaultUsage, emitFinishStep, finishApprovalStep, mapUsage, type ClaudeMessage, } from './create-emit-stream-event'; import { jsonSchemaToZodShape } from './json-schema-to-zod'; import { resolveInactiveNativeTools, resolveNativeTools, } from './tool-filtering'; /* * Native Claude Code tool name → cross-harness common name. Tools outside this * map (e.g. `WebFetch`, `NotebookEdit`) have no common equivalent; their * native name is forwarded as-is on `tool-call` events. */ type CommonBuiltinToolName = | 'read' | 'write' | 'edit' | 'bash' | 'glob' | 'grep' | 'webSearch'; const NATIVE_TO_COMMON: Readonly> = { Read: 'read', Write: 'write', Edit: 'edit', Bash: 'bash', Glob: 'glob', Grep: 'grep', WebSearch: 'webSearch', }; const NATIVE_TOOL_KINDS: Readonly< Record > = { Read: 'readonly', Glob: 'readonly', Grep: 'readonly', WebSearch: 'readonly', WebFetch: 'readonly', TaskGet: 'readonly', TaskList: 'readonly', TaskOutput: 'readonly', ListMcpResources: 'readonly', ReadMcpResource: 'readonly', Write: 'edit', Edit: 'edit', NotebookEdit: 'edit', TodoWrite: 'edit', TaskCreate: 'edit', TaskUpdate: 'edit', TaskStop: 'edit', EnterWorktree: 'edit', ExitWorktree: 'edit', ExitPlanMode: 'edit', Skill: 'readonly', AskUserQuestion: 'readonly', ToolSearch: 'readonly', Bash: 'bash', Monitor: 'bash', }; function toCommonName(nativeName: string): CommonBuiltinToolName | string { return NATIVE_TO_COMMON[nativeName] ?? nativeName; } const args = parseArgs(argv.slice(2)); const workdir = args.workdir; const bridgeStateDir = args.bridgeStateDir; if (!workdir) { emitFatal('Missing --workdir argument.'); } if (!bridgeStateDir) { emitFatal('Missing --bridge-state-dir argument.'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const claudeSdk = claudeAgentSdk as any; // eslint-disable-next-line @typescript-eslint/no-explicit-any const mcpModule = mcpServerModule as any; await runBridge({ bridgeType: 'claude-code', bridgeStateDir, onStart: runTurn, // Claude Code's session state lives in the workdir on the sandbox filesystem // (captured by the sandbox snapshot on stop); the resume payload is empty. onStop: () => ({}), }); type Emit = (msg: Record) => void; function createPermissionOptions(input: { start: StartMessage; inactiveNativeTools: readonly string[]; turn: BridgeTurn; emit: Emit; finishApprovalStep: (approvalId: string) => void; nativeToolCallNames: Map; approvalRequestedToolUseIds: Set; }): Record { const permissionMode = input.start.permissionMode ?? 'allow-all'; const inactiveNativeTools = new Set(input.inactiveNativeTools); const permissionSettings = createPermissionSettings({ permissionMode, inactiveNativeTools, }); if (permissionMode === 'allow-all' && inactiveNativeTools.size === 0) { return { permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, }; } return { permissionMode: permissionMode === 'allow-edits' ? 'acceptEdits' : 'default', allowDangerouslySkipPermissions: false, ...(permissionSettings ? { settings: permissionSettings } : {}), canUseTool: async ( toolName: string, toolInput: Record, options: { toolUseID: string }, ) => { if (toolName.startsWith('mcp__harness-tools__')) { return { behavior: 'allow', updatedInput: toolInput }; } if ( !inactiveNativeTools.has(toolName) && !nativeToolRequiresApproval({ nativeName: toolName, permissionMode, }) ) { return { behavior: 'allow', updatedInput: toolInput }; } const approvalId = options.toolUseID; input.approvalRequestedToolUseIds.add(approvalId); input.nativeToolCallNames.set(approvalId, toolName); input.emit({ type: 'tool-call', toolCallId: approvalId, toolName: toCommonName(toolName), nativeName: toolName, input: JSON.stringify(toolInput ?? {}), providerExecuted: true, }); input.emit({ type: 'tool-approval-request', approvalId, toolCallId: approvalId, }); input.finishApprovalStep(approvalId); const decision = await input.turn.requestToolApproval(approvalId); return decision.approved ? { behavior: 'allow', updatedInput: toolInput, toolUseID: approvalId } : { behavior: 'deny', message: decision.reason ?? 'Denied', toolUseID: approvalId, }; }, }; } function createPermissionSettings(input: { permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all'; inactiveNativeTools: ReadonlySet; }): Record | undefined { const askRules = new Set(); for (const [nativeName, kind] of Object.entries(NATIVE_TOOL_KINDS)) { if ( input.inactiveNativeTools.has(nativeName) || (input.permissionMode === 'allow-reads' ? kind === 'edit' || kind === 'bash' : input.permissionMode === 'allow-edits' ? kind === 'bash' : false) ) { askRules.add(`${nativeName}(*)`); } } if (askRules.size === 0) return undefined; return { permissions: { ask: [...askRules] }, sandbox: { autoAllowBashIfSandboxed: false }, }; } function nativeToolRequiresApproval(input: { nativeName: string; permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all'; }): boolean { if (input.permissionMode === 'allow-all') return false; const kind = NATIVE_TOOL_KINDS[input.nativeName] ?? 'edit'; if (input.permissionMode === 'allow-edits') return kind === 'bash'; return kind === 'edit' || kind === 'bash'; } async function runTurn(start: StartMessage, turn: BridgeTurn): Promise { const emit: Emit = msg => turn.emit(msg as BridgeEvent); // Local controller for the Claude query. Aborted either by the host (via the // shared runtime's `turn.abortSignal`) or by us on a terminal error. const abortCtl = new AbortController(); if (turn.abortSignal.aborted) { abortCtl.abort(); } else { turn.abortSignal.addEventListener('abort', () => abortCtl.abort(), { once: true, }); } const streamEventState = createClaudeStreamEventState(); const mcpServers: Record = { ...(start.mcpServers ?? {}) }; if (start.tools && start.tools.length > 0) { const server = new mcpModule.McpServer({ name: 'harness-tools', version: '1.0.0', }); for (const tool of start.tools) { const shape = jsonSchemaToZodShape(tool.inputSchema); server.tool( tool.name, tool.description ?? '', shape, async (input: Record) => { const toolCallId = randomUUID(); emit({ type: 'tool-call', toolCallId, toolName: tool.name, input: JSON.stringify(input), providerExecuted: false, }); const { output, isError } = await turn.requestToolResult(toolCallId); emit({ type: 'tool-result', toolCallId, toolName: tool.name, result: output ?? null, isError: !!isError, }); return { content: [{ type: 'text', text: JSON.stringify(output ?? null) }], isError, }; }, ); } mcpServers['harness-tools'] = { type: 'sdk', name: 'harness-tools', instance: server, }; } // Compaction observation: merge Claude's `compact_boundary` message and // `PostCompact` hook (which arrive in either order) into one `compaction` // event. See `createCompactionLatch`. const compaction = createCompactionLatch(event => emit(event)); // `stream-start` is emitted lazily on the first SDK message (below) so it can // carry the model the CLI resolved to, reported on the `system`/`init` message. const queryInput = createQueryInput({ initialUserMessage: start.prompt, userMessages: turn.experimental_userMessages, abortSignal: abortCtl.signal, }); const skillsOption = toClaudeSkillsOption(start.skills); const nativeTools = resolveNativeTools(start.builtinToolFiltering); const inactiveNativeTools = resolveInactiveNativeTools( start.builtinToolFiltering, ); const permissionOptions = createPermissionOptions({ start, inactiveNativeTools, turn, emit, finishApprovalStep: approvalId => { finishApprovalStep({ state: streamEventState, emit, approvalId }); }, nativeToolCallNames: streamEventState.nativeToolCallNames, approvalRequestedToolUseIds: streamEventState.approvalRequestedToolUseIds, }); const q = claudeSdk.query({ prompt: queryInput.input, options: { ...(start.model ? { model: start.model } : {}), ...(start.maxTurns !== undefined ? { maxTurns: start.maxTurns } : {}), ...(start.env !== undefined ? { env: { ...procEnv, ...start.env } } : {}), ...(skillsOption ? { skills: skillsOption } : {}), ...(nativeTools !== undefined ? { tools: nativeTools } : {}), ...(inactiveNativeTools.length > 0 ? { disallowedTools: inactiveNativeTools } : {}), systemPrompt: createClaudeCodeSystemPrompt(start.instructions), thinking: start.thinking, ...(start.effort !== undefined ? { effort: start.effort } : {}), ...(start.responseFormat?.type === 'json' && start.responseFormat.schema != null ? { outputFormat: { type: 'json_schema' as const, schema: start.responseFormat.schema, }, } : {}), includePartialMessages: true, // The `PostCompact` hook carries the compaction summary, which the // `compact_boundary` system message does not. Latch it for the unified // `compaction` event; return an empty output so compaction proceeds. hooks: { PostCompact: [ { hooks: [ async (input: { compact_summary?: unknown }) => { if (typeof input?.compact_summary === 'string') { compaction.onSummary(input.compact_summary); } return {}; }, ], }, ], }, // Continuation rule: the host can force-continue (resume after a // cross-process detach) by setting `start.continue: true`; otherwise // we continue every subsequent turn after the first one in this // bridge process. ...(start.continue === true || !turn.firstTurn ? { continue: true } : {}), ...permissionOptions, mcpServers, cwd: workdir, abortSignal: abortCtl.signal, }, }); let turnUsage: Record | undefined; let totalCostUsd: number | undefined; let emittedTerminalError = false; let emittedTerminalFinish = false; const emitTerminalError = (message: string | undefined): void => { const normalized = message?.trim(); if (!normalized || emittedTerminalError || emittedTerminalFinish) return; streamEventState.observedTerminalError = normalized; emittedTerminalError = true; turn.emitError({ error: normalized, message: 'claude-code terminal error', }); queryInput.close(); abortCtl.abort(); }; const emitStreamEvent = createEmitStreamEvent({ state: streamEventState, emit, emitWarning: turn.emitWarning, emitTerminalError, onCompactionBoundary: boundary => compaction.onBoundary(boundary), toCommonName, }); try { for await (const msg of q as AsyncIterable) { if (abortCtl.signal.aborted) break; const type = msg.type; if (type === 'command_lifecycle') { queryInput.handleLifecycle(msg); } emitStreamEvent(msg); if (type === 'result') { if (msg.subtype === 'success') { const emptyResult = !msg.result?.trim?.(); if (emptyResult && streamEventState.observedTerminalError) { emitTerminalError(streamEventState.observedTerminalError); continue; } const usage = msg.usage ?? msg.message?.usage; const harnessUsage = mapUsage(usage); if (harnessUsage) turnUsage = addUsage(turnUsage, harnessUsage); if (typeof msg.total_cost_usd === 'number') { totalCostUsd = (totalCostUsd ?? 0) + msg.total_cost_usd; } if ( start.responseFormat?.type === 'json' && msg.structured_output !== undefined ) { const id = randomUUID(); emit({ type: 'text-start', id }); emit({ type: 'text-delta', id, delta: JSON.stringify(msg.structured_output), }); emit({ type: 'text-end', id }); streamEventState.stepOpen = true; } if (streamEventState.stepOpen) { emitFinishStep({ state: streamEventState, emit, usage: streamEventState.pendingStepUsage ?? harnessUsage, }); } queryInput.observeResult(); if (!queryInput.hasActiveUserMessages()) { queryInput.close(); break; } } else { emitTerminalError( (Array.isArray(msg.errors) ? msg.errors.join('\n') : undefined) || streamEventState.observedTerminalError || msg.result || 'Unknown error', ); } continue; } if (queryInput.hasObservedResult && !queryInput.hasActiveUserMessages()) { queryInput.close(); break; } } } catch (err) { if (!(abortCtl.signal.aborted && emittedTerminalError)) { turn.emitError({ error: err, message: 'claude-code turn failed' }); } return; } finally { queryInput.close(); } if (emittedTerminalError) return; emittedTerminalFinish = true; void emittedTerminalFinish; emit({ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, totalUsage: turnUsage ?? streamEventState.stepUsage ?? defaultUsage(), ...(totalCostUsd !== undefined ? { harnessMetadata: { 'claude-code': { costUsd: totalCostUsd } } } : {}), }); } function createQueryInput({ initialUserMessage, userMessages, abortSignal, }: { initialUserMessage: string; userMessages: Experimental_BridgeUserMessageQueue; abortSignal: AbortSignal; }): { input: AsyncIterable; close(error?: unknown): void; handleLifecycle(message: ClaudeMessage): void; hasActiveUserMessages(): boolean; observeResult(): void; readonly hasObservedResult: boolean; } { let closed = false; let observedResult = false; const submittedMessages = new Map(); const close = (error?: unknown): void => { if (closed) return; closed = true; userMessages.close(error); }; if (abortSignal.aborted) { close(abortSignal.reason); } else { abortSignal.addEventListener('abort', () => close(abortSignal.reason), { once: true, }); } const toUserMessage = (options: { text: string; messageId: string; priority?: 'next'; }): unknown => ({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: options.text }], }, parent_tool_use_id: null, uuid: options.messageId, ...(options.priority == null ? {} : { priority: options.priority }), }); const messageIterator = userMessages[Symbol.asyncIterator](); return { close, handleLifecycle: message => { const lifecycle = message as ClaudeMessage & { command_uuid?: string; state?: 'queued' | 'started' | 'completed' | 'cancelled' | 'discarded'; }; if (lifecycle.command_uuid == null || lifecycle.state == null) return; const submitted = submittedMessages.get(lifecycle.command_uuid); if (submitted == null) return; if (lifecycle.state === 'queued' || lifecycle.state === 'started') { submitted.accept(); return; } if (lifecycle.state === 'cancelled' || lifecycle.state === 'discarded') { submitted.reject( new Error(`Claude Code ${lifecycle.state} the user message.`), ); } submittedMessages.delete(lifecycle.command_uuid); }, hasActiveUserMessages: () => submittedMessages.size > 0 || userMessages.pendingCount > 0, observeResult: () => { observedResult = true; }, get hasObservedResult() { return observedResult; }, input: { [Symbol.asyncIterator]() { let sentInitial = false; return { async next() { if (closed || abortSignal.aborted) { return { value: undefined, done: true, } as IteratorResult; } if (!sentInitial) { sentInitial = true; return { value: toUserMessage({ text: initialUserMessage, messageId: randomUUID(), }), done: false, }; } const nextMessage = await messageIterator.next(); if (nextMessage.done) { return { value: undefined, done: true, } as IteratorResult; } submittedMessages.set( nextMessage.value.messageId, nextMessage.value, ); return { value: toUserMessage({ text: nextMessage.value.text, messageId: nextMessage.value.messageId, priority: 'next', }), done: false, }; }, }; }, }, }; } function addUsage( total: Record | undefined, usage: Record, ): Record { if (total == null) return usage; const result: Record = { ...total }; for (const [key, value] of Object.entries(usage)) { const previous = result[key]; if (typeof value === 'number' && typeof previous === 'number') { result[key] = previous + value; } else if ( value != null && previous != null && typeof value === 'object' && typeof previous === 'object' && !Array.isArray(value) && !Array.isArray(previous) ) { result[key] = addUsage( previous as Record, value as Record, ); } else { result[key] = value; } } return result; } function parseArgs(args: string[]): { workdir?: string; bridgeStateDir?: string; } { const out: { workdir?: string; bridgeStateDir?: string } = {}; for (let i = 0; i < args.length; i++) { if (args[i] === '--workdir' && i + 1 < args.length) { out.workdir = args[++i]; } else if (args[i] === '--bridge-state-dir' && i + 1 < args.length) { out.bridgeStateDir = args[++i]; } } return out; } function emitFatal(message: string): never { stdout.write(JSON.stringify({ type: 'bridge-fatal', message }) + '\n'); process.exit(1); }