import type { AgentHistoryEntry } from "./agent-history.js"; import { compactAgentHistory } from "./agent-history.js"; import { type CliProcessResult, type CliSpawnOptions, isCliProcessAbortedError, parseJsonl, runCliProcess, } from "./cli-harness.js"; import { classifyProviderLimit, WorkflowError, WorkflowErrorCode } from "./errors.js"; import { type AgentExecutor, EXECUTOR_CAPABILITY_DESCRIPTORS, type ExecutorRunRequest, type ExecutorRunResult, type ExecutorUsage, } from "./executor.js"; import { assertTopLevelObjectSchema, resolveStructuredOutput } from "./structured-output.js"; export interface ClaudeCodeHarnessOptions extends Omit { command?: string; /** Keep ANTHROPIC_BASE_URL in the child environment (default removes it). */ preserveAnthropicBaseUrl?: boolean; /** Disable user/project customizations, plugins, hooks, MCP, and workflows. Default: true. */ safeMode?: boolean; /** Built-in coding tools exposed to the child. null keeps Claude Code's default set. */ tools?: readonly string[] | null; /** Claude Code permission policy. Default: auto; bypassPermissions is intentionally unsupported. */ permissionMode?: "acceptEdits" | "auto" | "dontAsk" | "plan"; } interface ClaudeFinal { value?: unknown; text?: string; } /** Adapter for Claude Code's non-interactive stream-json CLI protocol. */ export class ClaudeCodeHarness implements AgentExecutor { readonly executor = "claude-code" as const; readonly descriptor = EXECUTOR_CAPABILITY_DESCRIPTORS["claude-code"]; private readonly options: ClaudeCodeHarnessOptions; constructor(options: ClaudeCodeHarnessOptions = {}) { this.options = options; } async run(request: ExecutorRunRequest): Promise> { const cwd = request.cwd ?? process.cwd(); const command = this.options.command ?? "claude"; if (request.schema) assertTopLevelObjectSchema(request.schema, "Claude structured output"); const args = [ "-p", "--output-format", "stream-json", "--verbose", "--no-session-persistence", "--permission-mode", this.options.permissionMode ?? "auto", ]; if (this.options.safeMode ?? true) args.push("--safe-mode"); const tools = this.options.tools === undefined ? ["Read", "Write", "Edit", "Bash", "LSP"] : this.options.tools; if (tools) args.push("--tools", tools.join(",")); if (request.model) args.push("--model", request.model); if (request.schema) args.push("--json-schema", JSON.stringify(request.schema)); // Never mutate process.env: this object is owned by this child only. The // default removal avoids accidentally routing CLI traffic through a host // API proxy that is incompatible with Claude Code's auth flow. const env: NodeJS.ProcessEnv = { ...process.env, ...(request.env ?? {}), ...(this.options.env ?? {}), }; if (!this.options.preserveAnthropicBaseUrl) delete env.ANTHROPIC_BASE_URL; let result: CliProcessResult; try { result = await runCliProcess(command, args, { cwd, prompt: request.prompt, signal: request.signal, env, stderrLimit: this.options.stderrLimit, terminateGraceMs: this.options.terminateGraceMs, killGraceMs: this.options.killGraceMs, spawn: this.options.spawn, }); } catch (error) { if (isCliProcessAbortedError(error)) { const partial = claudeProtocol(error.partialResult); reportUsage(request, usageFromEvents(partial.events)); } throw error; } const { events, jsonlErrors } = claudeProtocol(result); const usage = usageFromEvents(events); reportUsage(request, usage); if (jsonlErrors.length > 0 && events.length === 0) { throw protocolError("Claude Code returned malformed JSONL", { errors: jsonlErrors, stderr: result.stderr, }); } if (result.exitCode !== 0) throw classifyCliFailure(command, result.exitCode, result.stderr, events); const final = findFinal(events); if (!final) { throw protocolError("Claude Code completed without a final result", { events, stderr: result.stderr, }); } const text = final.text; const value = request.schema ? resolveStructuredOutput(final.value ?? text, request.schema, "Claude structured output") : ((final.value ?? text) as T); return { result: value, text, usage, history: historyFromEvents(events), events, }; } } export function createClaudeCodeHarness(options: ClaudeCodeHarnessOptions = {}): ClaudeCodeHarness { return new ClaudeCodeHarness(options); } /** Prefer stdout JSONL, while retaining compatibility with builds that emit it on stderr. */ function claudeProtocol(result: CliProcessResult): { events: unknown[]; jsonlErrors: CliProcessResult["jsonlErrors"]; } { if (result.events.length > 0 || !result.stderr.trim()) { return { events: result.events, jsonlErrors: result.jsonlErrors }; } const fallback = parseJsonl(result.stderr); return fallback.events.length > 0 ? { events: fallback.events, jsonlErrors: fallback.errors } : { events: result.events, jsonlErrors: result.jsonlErrors }; } function findFinal(events: readonly unknown[]): ClaudeFinal | undefined { let final: ClaudeFinal | undefined; for (const raw of events) { const event = asRecord(raw); if (!event) continue; const type = stringValue(event.type); if (type === "result") { const isError = event.is_error === true || event.subtype === "error" || event.subtype === "failure"; if (isError) { const message = stringValue(event.result) ?? stringValue(event.error) ?? "Claude Code returned an error"; throw providerOrProtocolError(message, event); } const structured = event.structured_output ?? event.structuredOutput; const value = structured !== undefined ? structured : (event.result ?? event.output); final = { value, text: typeof event.result === "string" ? event.result : value === undefined ? undefined : JSON.stringify(value), }; continue; } if (type === "assistant") { const message = asRecord(event.message) ?? event; const content = Array.isArray(message.content) ? message.content : []; const structured = content .map(asRecord) .find((part) => part && (part.type === "tool_use" || part.type === "structured_output")); const structuredValue = structured?.input ?? structured?.value; const text = textFromContent(message.content); if (structuredValue !== undefined || text) final = { value: structuredValue, text }; } } return final; } function usageFromEvents(events: readonly unknown[]): ExecutorUsage | undefined { let terminalUsage: Record | undefined; const assistantUsage = new Map>(); let anonymousMessage = 0; let cost = 0; for (const raw of events) { const event = asRecord(raw); if (!event) continue; cost = Math.max(cost, numberValue(event.total_cost_usd ?? event.totalCostUsd)); if (event.type === "result") { const candidate = asRecord(event.usage); if (candidate) terminalUsage = candidate; continue; } if (event.type !== "assistant") continue; const message = asRecord(event.message); const candidate = asRecord(message?.usage); if (!candidate) continue; const id = stringValue(message?.id) ?? `anonymous:${anonymousMessage++}`; // Claude may repeat cumulative snapshots for one streamed assistant message. // Replacing by ID retains the last snapshot without double-counting it. assistantUsage.set(id, candidate); } // A terminal result is the provider's authoritative cumulative usage for the // whole invocation. Nested assistant snapshots are only a failure-path fallback. if (terminalUsage) return usageFromRecord(terminalUsage, cost); let aggregate: ExecutorUsage | undefined; for (const candidate of assistantUsage.values()) { const usage = usageFromRecord(candidate, 0); if (!usage) continue; aggregate ??= { input: 0, output: 0, total: 0, cost: 0, cacheRead: 0, cacheWrite: 0 }; aggregate.input += usage.input; aggregate.output += usage.output; aggregate.total += usage.total; aggregate.cacheRead += usage.cacheRead; aggregate.cacheWrite += usage.cacheWrite; } if (!aggregate && cost <= 0) return undefined; return aggregate ? { ...aggregate, cost } : { input: 0, output: 0, total: 0, cost, cacheRead: 0, cacheWrite: 0 }; } function usageFromRecord(usage: Record, cost: number): ExecutorUsage | undefined { const input = numberValue(usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens); const output = numberValue(usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens); const cacheRead = numberValue(usage.cache_read_input_tokens ?? usage.cached_input_tokens ?? usage.cacheRead); const cacheWrite = numberValue(usage.cache_creation_input_tokens ?? usage.cacheWrite); const total = numberValue(usage.total_tokens) || input + output; if (input <= 0 && output <= 0 && total <= 0 && cost <= 0 && cacheRead <= 0 && cacheWrite <= 0) return undefined; return { input, output, total, cost, cacheRead, cacheWrite }; } function reportUsage(request: ExecutorRunRequest, usage: ExecutorUsage | undefined): void { if (!usage || !request.onUsage) return; try { request.onUsage(usage); } catch { // Usage is telemetry only; never let it mask the execution result/error. } } function historyFromEvents(events: readonly unknown[]): AgentHistoryEntry[] { const messages: unknown[] = []; const toolNames = new Map(); for (const raw of events) { const event = asRecord(raw); if (!event) continue; if (event.type === "assistant") { const message = asRecord(event.message) ?? event; const content = Array.isArray(message.content) ? message.content.map((rawPart) => { const part = asRecord(rawPart); if (part?.type !== "tool_use" || typeof part.name !== "string") return rawPart; if (typeof part.id === "string") toolNames.set(part.id, part.name); return { type: "toolCall", name: part.name.toLowerCase(), arguments: part.input ?? {} }; }) : message.content; messages.push({ role: "assistant", content }); } else if (event.type === "user") { const message = asRecord(event.message) ?? event; const content = Array.isArray(message.content) ? message.content : []; for (const rawPart of content) { const part = asRecord(rawPart); if (part?.type !== "tool_result") continue; const toolUseId = stringValue(part.tool_use_id); messages.push({ role: "toolResult", toolName: toolUseId ? toolNames.get(toolUseId)?.toLowerCase() : undefined, content: [{ type: "text", text: toolResultText(part.content) }], isError: part.is_error === true, }); } } } return compactAgentHistory(messages); } function classifyCliFailure( command: string, exitCode: number | null, stderr: string, events: readonly unknown[], ): WorkflowError { const text = `${stderr}\n${events.map((event) => JSON.stringify(event)).join("\n")}`; const limit = classifyProviderLimit(text); if (limit.matched) { return new WorkflowError(stderr || "Claude provider usage limit reached", WorkflowErrorCode.PROVIDER_USAGE_LIMIT, { recoverable: false, resetHint: limit.resetHint, }); } return new WorkflowError( `${command} exited with code ${exitCode ?? "unknown"}${stderr ? `: ${stderr}` : ""}`, WorkflowErrorCode.EXECUTOR_PROTOCOL_ERROR, { recoverable: false, details: { exitCode, stderr }, }, ); } function providerOrProtocolError(message: string, details: unknown): WorkflowError { const limit = classifyProviderLimit(message); if (limit.matched) { return new WorkflowError(message, WorkflowErrorCode.PROVIDER_USAGE_LIMIT, { recoverable: false, resetHint: limit.resetHint, details, }); } return protocolError(message, details); } function protocolError(message: string, details: unknown): WorkflowError { return new WorkflowError(message, WorkflowErrorCode.EXECUTOR_PROTOCOL_ERROR, { recoverable: false, details }); } function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? (value as Record) : undefined; } function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function numberValue(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } function toolResultText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return content === undefined ? "" : JSON.stringify(content); return content .map((rawPart) => { const part = asRecord(rawPart); return typeof part?.text === "string" ? part.text : ""; }) .filter(Boolean) .join("\n"); } function textFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((part) => { const item = asRecord(part); return item && typeof item.text === "string" ? item.text : ""; }) .join(""); }