// GENERATED — do not edit here. // // Source: https://github.com/sharpshooter90/agent-native.git // branch privatecloud/claude-agent-sdk-harness @ db5f84e9c596549d73164cb1c5f4aae009af1465 // packages/core/src/agent/harness/claude-agent-sdk-adapter.ts // Regenerate: node scripts/vendor-claude-harness.mjs // // Why this file exists: apps install @agent-native/core from npm, which does not // carry this harness yet. Registering it here through the framework's public // registry gives the app the same capability without forking the installed // package. Delete this file once a published core ships `claude-agent-sdk`. /** * Claude Agent SDK harness adapter. * * Runs Claude Code through `@anthropic-ai/claude-agent-sdk` as an embedded * harness: the SDK owns its loop, native file/shell tools, compaction, and * session state, while Agent-Native drives it as a session and streams its * events into the normal transcript. * * Two things distinguish this from `acp:claude-code`, which reaches the same * runtime through the Zed ACP bridge: * * - The SDK is the first-party surface, so session resume is the SDK's own * `session_id` (not an ACP `loadSession` capability that may be absent), * and host tools are bridged as an in-process MCP server rather than being * unavailable. * - Auth is whatever the spawned `claude` binary already has. The child * inherits the host environment, so a host that runs on a Claude * subscription token (`CLAUDE_CODE_OAUTH_TOKEN`) — rather than a metered * `ANTHROPIC_API_KEY` — can reuse it here. This adapter never reads those * variables itself; a host that needs to inject credentials passes them * through `env`, which is layered onto the inherited environment. * * The SDK package is an optional dependency loaded lazily through * `installPackage`, so apps that never use this harness do not pay for it. * Everything beyond the thin query/session glue is pure mapping logic between * SDK messages and {@link AgentHarnessEvent}s. */ import { randomUUID } from "node:crypto"; import { registerAgentHarness, type AgentHarnessAdapter, type AgentHarnessApproval, type AgentHarnessCapabilities, type AgentHarnessCreateSessionOptions, type AgentHarnessEvent, type AgentHarnessMessage, type AgentHarnessPermissionMode, type AgentHarnessSession, type AgentHarnessTurnInput, } from "@agent-native/core/agent/harness"; export const CLAUDE_AGENT_SDK_PACKAGE = "@anthropic-ai/claude-agent-sdk"; /** MCP server name the host tools from `createSession.tools` are mounted under. */ export const HOST_TOOL_MCP_SERVER = "agent-native"; /** Claude Code tools that only observe; safe under every permission mode. */ const READ_ONLY_TOOLS = new Set([ "Glob", "Grep", "NotebookRead", "Read", "Task", "TodoWrite", "WebFetch", "WebSearch", ]); /** Claude Code tools that mutate workspace files; gated by `allow-edits`. */ const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "NotebookEdit", "Write"]); /** Input keys the edit tools use to name their target file. */ const FILE_PATH_KEYS = ["file_path", "notebook_path", "path"] as const; export interface ClaudeAgentSdkHarnessAdapterOptions { label?: string; description?: string; /** Model id passed to the SDK. Omit to use the CLI's configured default. */ model?: string; /** Default workspace directory when a session does not supply `cwd`. */ cwd?: string; permissionMode?: AgentHarnessPermissionMode; /** * Extra environment for the spawned `claude` process, layered onto the * inherited host environment. Use this to hand the child host-resolved * credentials instead of reading them in this module. */ env?: Record; /** Path to the `claude` executable when it is not the packaged one. */ pathToClaudeCodeExecutable?: string; /** * Which on-disk settings the SDK loads (CLAUDE.md, slash commands, MCP * config). The SDK default is none; pass `["project"]` to let an app's own * repo settings shape the agent. */ settingSources?: Array<"user" | "project" | "local">; /** Extra `query()` options, merged last except where noted below. */ queryOptions?: Record; /** * @internal Test seam: supply the SDK module instead of importing it. */ loadClaudeAgentSdk?: () => Promise; } /** Options this adapter owns; a host overriding them would break the contract. */ const RESERVED_QUERY_OPTIONS = [ "abortController", "canUseTool", "includePartialMessages", "resume", ] as const; export interface ClaudeAgentSdkResumeState { sessionId: string; cwd?: string; } /** Per-turn correlation between tool_use ids and the call they started. */ export interface ClaudeAgentSdkTurnState { toolCalls: Map; } export function createClaudeAgentSdkTurnState(): ClaudeAgentSdkTurnState { return { toolCalls: new Map() }; } const dynamicImport = new Function("specifier", "return import(specifier)") as ( specifier: string, ) => Promise; export function createClaudeAgentSdkHarnessAdapter( options: ClaudeAgentSdkHarnessAdapterOptions = {}, ): AgentHarnessAdapter { assertNoReservedQueryOptions(options.queryOptions); const capabilities: AgentHarnessCapabilities = { // The SDK runs the agent in a real working directory with its own file and // shell tools; it is not sandboxed by Agent-Native. sandbox: false, resumable: true, approvals: true, hostTools: true, fileEvents: true, }; return { name: "claude-agent-sdk", label: options.label ?? "Claude Code (Agent SDK)", description: options.description ?? "Runs Claude Code through the first-party Claude Agent SDK, reusing the host's existing Claude credentials.", installPackage: CLAUDE_AGENT_SDK_PACKAGE, capabilities, async createSession(sessionOptions) { const sdk = (await (options.loadClaudeAgentSdk ? options.loadClaudeAgentSdk() : dynamicImport(CLAUDE_AGENT_SDK_PACKAGE))) as any; if (typeof sdk?.query !== "function") { throw new Error( `[agent-harness] ${CLAUDE_AGENT_SDK_PACKAGE} did not expose query().`, ); } return new ClaudeAgentSdkHarnessSession(sdk, options, sessionOptions); }, }; } function assertNoReservedQueryOptions( queryOptions: Record | undefined, ): void { if (!queryOptions) return; const reserved = RESERVED_QUERY_OPTIONS.filter((key) => key in queryOptions); if (reserved.length === 0) return; throw new Error( `[agent-harness] queryOptions.${reserved.join(", queryOptions.")} ${ reserved.length === 1 ? "is" : "are" } owned by the Claude Agent SDK harness (streaming, approvals, resume, and cancellation depend on them). Remove ${reserved.length === 1 ? "it" : "them"}.`, ); } class ClaudeAgentSdkHarnessSession implements AgentHarnessSession { readonly id: string; private readonly cwd?: string; private readonly permissionMode: AgentHarnessPermissionMode; private readonly pendingPermissions = new Map< string, { resolve: ( result: { behavior: "allow" } | { behavior: "deny"; message: string }, ) => void; } >(); private queue: AsyncEventQueue | null = null; private activeQuery: { interrupt?: () => Promise; return?: (value?: unknown) => Promise; } | null = null; private approvalCounter = 0; private sessionId: string; private stopped = false; constructor( private readonly sdk: any, private readonly adapterOptions: ClaudeAgentSdkHarnessAdapterOptions, private readonly sessionOptions: AgentHarnessCreateSessionOptions, ) { const resume = sessionOptions.resumeState as | ClaudeAgentSdkResumeState | undefined | null; this.cwd = sessionOptions.cwd ?? resume?.cwd ?? adapterOptions.cwd; this.permissionMode = sessionOptions.permissionMode ?? adapterOptions.permissionMode ?? "allow-reads"; // Empty until the SDK reports its own session id on the first turn. The // resume state is the only id that can actually resume a conversation, so // never substitute a synthetic one for it — and never let two fresh // sessions share a harness id, which would collide in the session store. this.sessionId = resume?.sessionId ?? ""; this.id = this.sessionId || sessionOptions.sessionId || `claude-agent-sdk-${randomUUID()}`; } async *streamTurn( input: AgentHarnessTurnInput, ): AsyncIterable { if (this.stopped) { yield { type: "error", error: "[agent-harness] This Claude Agent SDK session was stopped.", }; return; } const queue = new AsyncEventQueue(); this.queue = queue; const state = createClaudeAgentSdkTurnState(); const abortController = new AbortController(); const abort = input.abortSignal ?? this.sessionOptions.signal; const onAbort = () => abortController.abort(); if (abort) { if (abort.aborted) onAbort(); else abort.addEventListener("abort", onAbort, { once: true }); } const query = this.sdk.query({ prompt: promptFromTurnInput(input), options: this.buildQueryOptions(abortController), }); this.activeQuery = query; void (async () => { try { for await (const message of query) { if (typeof message?.session_id === "string" && message.session_id) { this.sessionId = message.session_id; } for (const event of claudeAgentSdkMessageToEvents(message, state)) { queue.push(event); } } } catch (error) { queue.push({ type: "error", error: `[agent-harness] ${CLAUDE_AGENT_SDK_PACKAGE}: ${ error instanceof Error ? error.message : String(error) }`, }); } finally { if (abort) abort.removeEventListener("abort", onAbort); this.denyAllPending( "The Claude Agent SDK turn ended before this approval was answered.", ); this.activeQuery = null; this.queue = null; queue.close(); } })(); yield* queue; } async approve(approval: AgentHarnessApproval): Promise { const pending = this.pendingPermissions.get(approval.id); if (!pending) return; this.pendingPermissions.delete(approval.id); pending.resolve( approval.approved ? { behavior: "allow" } : { behavior: "deny", message: approval.message ?? "Denied by the user.", }, ); } async detach(): Promise { // A detached session is resumed by id on the next turn, so there is no // process to keep alive between turns. await this.destroy(); if (!this.sessionId) return undefined; const state: ClaudeAgentSdkResumeState = { sessionId: this.sessionId }; if (this.cwd) state.cwd = this.cwd; return state; } async stop(): Promise { this.stopped = true; return this.detach(); } async destroy(): Promise { const query = this.activeQuery; this.activeQuery = null; this.denyAllPending("The Claude Agent SDK session was stopped."); if (!query) return; try { await query.interrupt?.(); } catch { // The turn may already be finished; fall through to closing the stream. } try { await query.return?.(undefined); } catch { // Same: an already-closed generator is not an error here. } } private buildQueryOptions( abortController: AbortController, ): Record { const { env, model, pathToClaudeCodeExecutable, queryOptions, settingSources, } = this.adapterOptions; return { ...(queryOptions ?? {}), ...(this.cwd ? { cwd: this.cwd } : {}), ...(model ? { model } : {}), ...(settingSources ? { settingSources } : {}), ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}), ...(env ? { env: { ...process.env, ...env } } : {}), ...(this.sessionOptions.instructions ? { systemPrompt: { type: "preset", preset: "claude_code", append: this.sessionOptions.instructions, }, } : {}), ...(this.sessionId ? { resume: this.sessionId } : {}), ...this.hostToolOptions(queryOptions), // Text and thinking reach the transcript as deltas; without partial // messages a turn would stream nothing until it finished. includePartialMessages: true, abortController, canUseTool: this.createCanUseTool(), }; } /** * Bridge the host tools handed to `createSession` into the SDK as an * in-process MCP server. The SDK exposes them to the agent as * `mcp__agent-native__`, which the permission mapping treats as risky * — a host action is app state, not a workspace read. */ private hostToolOptions( queryOptions: Record | undefined, ): Record { const tools = this.sessionOptions.tools; if (!tools || Object.keys(tools).length === 0) return {}; const server = createHostToolMcpServer(this.sdk, tools); const existing = queryOptions?.mcpServers; if (existing !== undefined && !isPlainRecord(existing)) { throw new Error( "[agent-harness] queryOptions.mcpServers must be a record of server configs when host tools are supplied.", ); } if (isPlainRecord(existing) && HOST_TOOL_MCP_SERVER in existing) { throw new Error( `[agent-harness] queryOptions.mcpServers.${HOST_TOOL_MCP_SERVER} collides with the host-tool server this harness mounts. Rename it.`, ); } return { mcpServers: { ...(isPlainRecord(existing) ? existing : {}), [HOST_TOOL_MCP_SERVER]: server, }, }; } private createCanUseTool() { return async ( toolName: string, input: Record, ): Promise< | { behavior: "allow"; updatedInput: Record } | { behavior: "deny"; message: string } > => { if ( claudeAgentSdkPermissionDecision(toolName, this.permissionMode) === "allow" ) { return { behavior: "allow", updatedInput: input }; } const queue = this.queue; if (!queue) { // No live turn to prompt on: deny rather than hang the agent. return { behavior: "deny", message: "No approval surface is attached to this session.", }; } const id = `claude-agent-sdk-approval-${++this.approvalCounter}`; const decision = new Promise< { behavior: "allow" } | { behavior: "deny"; message: string } >((resolve) => { this.pendingPermissions.set(id, { resolve }); }); queue.push({ type: "approval-request", id, tool: toolName, message: `Approve ${toolName}?`, input, }); const resolved = await decision; return resolved.behavior === "allow" ? { behavior: "allow", updatedInput: input } : resolved; }; } private denyAllPending(message: string): void { for (const [id, pending] of this.pendingPermissions) { this.pendingPermissions.delete(id); pending.resolve({ behavior: "deny", message }); } } } /** * A host tool as `createSession.tools` carries it: the AI SDK tool shape, whose * `inputSchema` is a Zod object (what `defineAction` already holds). */ interface HostTool { description?: string; inputSchema: unknown; execute: (input: Record) => unknown; } function createHostToolMcpServer( sdk: any, tools: Record, ): unknown { if ( typeof sdk?.createSdkMcpServer !== "function" || typeof sdk?.tool !== "function" ) { throw new Error( `[agent-harness] ${CLAUDE_AGENT_SDK_PACKAGE} did not expose createSdkMcpServer()/tool(), so host tools cannot be bridged.`, ); } const definitions = Object.entries(tools).map(([name, definition]) => sdk.tool( name, hostToolDescription(name, definition), hostToolInputShape(name, definition), async (args: Record) => { const result = await (definition as HostTool).execute(args); return { content: [{ type: "text", text: stringifyHostToolResult(result) }], }; }, ), ); return sdk.createSdkMcpServer({ name: HOST_TOOL_MCP_SERVER, tools: definitions, }); } function hostToolDescription(name: string, definition: unknown): string { const description = (definition as HostTool)?.description; return typeof description === "string" && description ? description : `Agent-Native host tool: ${name}`; } /** * The SDK builds its tool schema from a Zod raw shape. `defineAction` inputs * are Zod objects, so unwrap one level; anything else is rejected by name * rather than silently registered with an empty schema, which would leave the * agent calling the tool with no arguments. */ function hostToolInputShape( name: string, definition: unknown, ): Record { const tool = definition as HostTool | undefined; if (typeof tool?.execute !== "function") { throw new Error( `[agent-harness] Host tool "${name}" has no execute() function.`, ); } const schema = tool.inputSchema as { shape?: unknown } | undefined; if (isPlainRecord(schema?.shape)) return schema.shape; if (isPlainRecord(schema)) return schema; throw new Error( `[agent-harness] Host tool "${name}" needs a Zod object inputSchema (or a raw Zod shape); the Claude Agent SDK cannot register a tool without one.`, ); } function stringifyHostToolResult(result: unknown): string { if (typeof result === "string") return result; if (result === undefined) return ""; try { return JSON.stringify(result); } catch { return String(result); } } function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function promptFromTurnInput(input: AgentHarnessTurnInput): string { if (input.prompt) return input.prompt; const messages = input.messages ?? []; const text = messages .map((message) => harnessMessageToText(message)) .filter(Boolean) .join("\n\n"); if (!text) { throw new Error( "[agent-harness] The Claude Agent SDK harness needs a prompt or at least one message with text content.", ); } return text; } function harnessMessageToText(message: AgentHarnessMessage): string { if (typeof message.content === "string") return message.content; return message.content .map((part) => { if (typeof part === "string") return part; const record = part as { type?: unknown; text?: unknown }; return record?.type === "text" && typeof record.text === "string" ? record.text : ""; }) .filter(Boolean) .join("\n"); } /** * Map an Agent-Native permission mode onto a decision for a Claude Code tool * call. Reads always run; workspace edits run under `allow-edits`; shell, MCP, * and unknown tools prompt unless `allow-all`. */ export function claudeAgentSdkPermissionDecision( toolName: string, mode: AgentHarnessPermissionMode, ): "allow" | "prompt" { if (mode === "allow-all") return "allow"; if (READ_ONLY_TOOLS.has(toolName)) return "allow"; if (mode === "allow-edits" && EDIT_TOOLS.has(toolName)) return "allow"; return "prompt"; } /** * Translate one SDK message into harness events. Tool results arrive on a * later message than the call that produced them, so `state` carries the * tool_use id → name/input correlation across a turn. */ export function claudeAgentSdkMessageToEvents( message: any, state: ClaudeAgentSdkTurnState, ): AgentHarnessEvent[] { switch (message?.type) { case "stream_event": return streamEventToEvents(message.event); case "assistant": return assistantMessageToEvents(message, state); case "user": return userMessageToEvents(message, state); case "result": return resultMessageToEvents(message); case "tool_progress": return typeof message.tool_name === "string" ? [ { type: "activity", label: `${message.tool_name} is still running`, tool: message.tool_name, }, ] : []; case "system": return systemMessageToEvents(message); default: return []; } } function streamEventToEvents(event: any): AgentHarnessEvent[] { if (event?.type !== "content_block_delta") return []; const delta = event.delta; if (delta?.type === "text_delta" && typeof delta.text === "string") { return delta.text ? [{ type: "text-delta", text: delta.text }] : []; } if (delta?.type === "thinking_delta" && typeof delta.thinking === "string") { return delta.thinking ? [{ type: "thinking-delta", text: delta.thinking }] : []; } return []; } function assistantMessageToEvents( message: any, state: ClaudeAgentSdkTurnState, ): AgentHarnessEvent[] { // Text and thinking already streamed as deltas; only tool calls are new here. const blocks = Array.isArray(message?.message?.content) ? message.message.content : []; const events: AgentHarnessEvent[] = []; for (const block of blocks) { if (block?.type !== "tool_use" || typeof block.id !== "string") continue; const name = typeof block.name === "string" ? block.name : "tool"; state.toolCalls.set(block.id, { name, input: block.input }); events.push({ type: "tool-start", id: block.id, name, input: block.input, }); } return events; } function userMessageToEvents( message: any, state: ClaudeAgentSdkTurnState, ): AgentHarnessEvent[] { const blocks = Array.isArray(message?.message?.content) ? message.message.content : []; const events: AgentHarnessEvent[] = []; for (const block of blocks) { if (block?.type !== "tool_result") continue; const id = typeof block.tool_use_id === "string" ? block.tool_use_id : undefined; const call = id ? state.toolCalls.get(id) : undefined; if (id) state.toolCalls.delete(id); const name = call?.name ?? "tool"; events.push({ type: "tool-done", ...(id ? { id } : {}), name, ...(call ? { input: call.input } : {}), result: block.content, }); if (block.is_error) continue; const fileChange = fileChangeEvent(name, call?.input); if (fileChange) events.push(fileChange); } return events; } function fileChangeEvent( toolName: string, input: unknown, ): AgentHarnessEvent | null { if (!EDIT_TOOLS.has(toolName)) return null; const record = (input ?? {}) as Record; const key = FILE_PATH_KEYS.find( (candidate) => typeof record[candidate] === "string", ); if (!key) return null; return { type: "file-change", path: record[key] as string, // Write creates or overwrites and the result does not say which; the edit // tools always target an existing file. operation: toolName === "Write" ? "unknown" : "update", }; } function resultMessageToEvents(message: any): AgentHarnessEvent[] { const events: AgentHarnessEvent[] = []; const usage = message?.usage; const inputTokens = numberOrUndefined(usage?.input_tokens); const outputTokens = numberOrUndefined(usage?.output_tokens); const costUsd = numberOrUndefined(message?.total_cost_usd); if ( inputTokens !== undefined || outputTokens !== undefined || costUsd !== undefined ) { events.push({ type: "usage", ...(inputTokens !== undefined ? { inputTokens } : {}), ...(outputTokens !== undefined ? { outputTokens } : {}), ...(inputTokens !== undefined && outputTokens !== undefined ? { totalTokens: inputTokens + outputTokens } : {}), ...(costUsd !== undefined ? { costCents: costUsd * 100 } : {}), }); } if (message?.is_error || message?.subtype !== "success") { const errors = Array.isArray(message?.errors) ? message.errors : []; events.push({ type: "error", error: errors .filter((entry: unknown) => typeof entry === "string") .join("; ") || `Claude Agent SDK run ended: ${message?.subtype ?? "unknown error"}`, ...(typeof message?.subtype === "string" ? { code: message.subtype } : {}), }); return events; } events.push({ type: "done", ...(typeof message?.stop_reason === "string" ? { reason: message.stop_reason } : {}), }); return events; } function systemMessageToEvents(message: any): AgentHarnessEvent[] { if (message?.subtype === "compact_boundary") { const trigger = message?.compact_metadata?.trigger; return [ { type: "compaction", ...(typeof trigger === "string" ? { summary: `Compacted conversation (${trigger})` } : {}), }, ]; } if (message?.subtype === "permission_denied") { const tool = typeof message?.tool_name === "string" ? message.tool_name : "tool"; return [{ type: "activity", label: `Denied ${tool}`, tool }]; } return []; } function numberOrUndefined(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } // --- inlined from packages/core/src/agent/harness/async-event-queue.ts ------- /** Minimal async queue bridging callback-driven harness updates to an async iterable. */ class AsyncEventQueue implements AsyncIterable { private readonly values: T[] = []; private readonly resolvers: Array<(result: IteratorResult) => void> = []; private closed = false; push(value: T): void { if (this.closed) return; const resolver = this.resolvers.shift(); if (resolver) resolver({ value, done: false }); else this.values.push(value); } close(): void { if (this.closed) return; this.closed = true; let resolver: ((result: IteratorResult) => void) | undefined; while ((resolver = this.resolvers.shift())) { resolver({ value: undefined as never, done: true }); } } [Symbol.asyncIterator](): AsyncIterator { return { next: (): Promise> => { if (this.values.length > 0) { return Promise.resolve({ value: this.values.shift() as T, done: false, }); } if (this.closed) { return Promise.resolve({ value: undefined as never, done: true }); } return new Promise>((resolve) => { this.resolvers.push(resolve); }); }, }; } } // --- registration ----------------------------------------------------------- /** * Startup plugin: make `claude-agent-sdk` resolvable by name, so anything that * calls `resolveAgentHarness("claude-agent-sdk")` gets this adapter. * * Auth comes from the process environment the app runs under. On a privatecloud * VM that is CLAUDE_CODE_OAUTH_TOKEN, delivered to the systemd unit through * EnvironmentFile=%h/.config/privatecloud/agent.env — a login shell's .bashrc * export does NOT reach a service, so without that line the harness starts and * then fails on its first turn. */ export default function claudeAgentSdkHarnessPlugin(): void { registerAgentHarness({ name: "claude-agent-sdk", label: "Claude Code (Agent SDK)", description: "Runs Claude Code through the first-party Claude Agent SDK on this app's own Claude credentials.", installPackage: CLAUDE_AGENT_SDK_PACKAGE, capabilities: { sandbox: false, resumable: true, approvals: true, hostTools: true, fileEvents: true, }, create: (config) => createClaudeAgentSdkHarnessAdapter( (config ?? {}) as ClaudeAgentSdkHarnessAdapterOptions, ), }); }