import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { spawnProcess, terminateProcess } from "../process.js"; import { Readable, Writable } from "node:stream"; import { ClientSideConnection, CLIENT_METHODS, PROTOCOL_VERSION, ndJsonStream, type Client, type AgentCapabilities, type ContentBlock, type NewSessionResponse, type RequestPermissionRequest, type RequestPermissionResponse, type SessionConfigOption, type SessionNotification, type SessionUpdate, type ToolCall, type ToolCallUpdate, type Stream, } from "@agentclientprotocol/sdk"; import { z } from "zod"; import type { AcpConfigAccess, AcpConfigChange, AcpToolCallSnapshot, AcpTransformer, AcpVendorUpdate, RunAcpProviderOptions, } from "../acp.js"; import { PROVIDER_PROTOCOL_VERSION, ProviderEventSchema, ProviderInputSchema, negotiateProviderCapabilities, requireProviderCapabilities, type ProviderCatalog, type ProviderCapability, type ProviderConfigState, type ProviderConnectRequest, type ProviderConnection, type ProviderEvent, type ProviderInput, type ProviderLaunch, type ProviderPermissionResponse, type ProviderPersistence, type ProviderSessionConfig, type ProviderSetting, type ProviderTimelineItem, } from "../provider.js"; const ADAPTER_CAPABILITIES = [ "prompt.message", "prompt.command", "session.configure", "permission", ] as const; interface AcpBoundarySession { runtime: AcpRuntime; capabilities: readonly ProviderCapability[]; mutationLane: Promise; } export async function createAcpProviderConnection( options: RunAcpProviderOptions, request: ProviderConnectRequest, ): Promise { if (!request.versions.includes(PROVIDER_PROTOCOL_VERSION)) { throw new Error(`ACP adapter requires provider protocol ${PROVIDER_PROTOCOL_VERSION}`); } const probe = await AcpRuntime.start({ options, launch: request.launch, boundarySessionId: "capability-probe", env: {}, emit: () => undefined, }); const supportedCapabilities: ProviderCapability[] = [...ADAPTER_CAPABILITIES]; if (probe.agentCapabilities.promptCapabilities?.image) { supportedCapabilities.push("prompt.image"); } if (probe.agentCapabilities.sessionCapabilities?.list) { supportedCapabilities.push("session.list"); } if (probe.agentCapabilities.loadSession) { supportedCapabilities.push("session.persistence"); } await probe.close(); const capabilities = negotiateProviderCapabilities(request.capabilities, supportedCapabilities); const listeners = new Set<(event: ProviderEvent) => void>(); const sessions = new Map(); const inFlight = new Set>(); let closed = false; let closePromise: Promise | null = null; const emit = (event: ProviderEvent) => { if (closed) return; const parsed = ProviderEventSchema.parse(event); for (const listener of listeners) listener(parsed); }; const state: AcpConnectionState = { options, launch: request.launch, capabilities, sessions, emit, isClosed: () => closed, }; return { version: PROVIDER_PROTOCOL_VERSION, capabilities, async send(input) { if (closed) throw new Error("ACP provider connection is closed"); input = ProviderInputSchema.parse(input); validateAdmission(input, { capabilities, sessions }); const operation = Promise.resolve().then(async () => { if (closed) return undefined; await dispatch(input, state); return undefined; }); const settled = operation.catch((error) => { emitOperationFailure(input, emit, error); }); inFlight.add(settled); void settled.finally(() => inFlight.delete(settled)); }, onEvent(listener) { listeners.add(listener); return () => listeners.delete(listener); }, async close() { if (closePromise) return closePromise; closed = true; closePromise = (async () => { await Promise.all(inFlight); const active = [...sessions.values()]; sessions.clear(); await Promise.all(active.map(({ runtime }) => runtime.closeSession())); listeners.clear(); })(); return closePromise; }, }; } interface AcpConnectionState { launch?: ProviderLaunch; options: RunAcpProviderOptions; capabilities: readonly ProviderCapability[]; sessions: Map; emit(event: ProviderEvent): void; isClosed(): boolean; } function validateAdmission( input: ProviderInput, state: Pick, ): void { if (input.type === "session.open") { if (state.sessions.has(input.sessionId)) throw new Error(`Session already exists: ${input.sessionId}`); requireProviderCapabilities(state.capabilities, input); return; } if (!("sessionId" in input)) { requireProviderCapabilities(state.capabilities, input); return; } const session = requireSession(state as AcpConnectionState, input.sessionId); requireProviderCapabilities(session.capabilities, input); } function emitOperationFailure( input: ProviderInput, emit: (event: ProviderEvent) => void, error: unknown, ): void { const failure = { message: describeError(error) }; if (input.type === "session.prompt") { emit({ type: "session.prompt_result", sessionId: input.sessionId, clientMessageId: input.prompt.clientMessageId, result: { type: "failed", error: failure }, }); return; } if ("requestId" in input) { emit({ type: "request.failed", requestId: input.requestId, error: failure }); return; } if ("sessionId" in input) { emit({ type: "session.runtime_failed", sessionId: input.sessionId, error: failure }); } } async function dispatch(input: ProviderInput, state: AcpConnectionState): Promise { switch (input.type) { case "catalog": await discover(input, state); return; case "sessions": await listSessions(input, state); return; case "session.open": await openSession(input, state); return; case "session.prompt": await requireSession(state, input.sessionId).runtime.prompt(input); return; case "session.interrupt": await requireSession(state, input.sessionId).runtime.interrupt(); state.emit({ type: "request.completed", requestId: input.requestId }); return; case "session.permission": requireSession(state, input.sessionId).runtime.respondToPermission( input.permissionId, input.response, ); return; case "session.configure": { const session = requireSession(state, input.sessionId); await mutateSession(session, () => session.runtime.configure(input.changes)); state.emit({ type: "request.completed", requestId: input.requestId }); return; } case "session.close": { const session = requireSession(state, input.sessionId); state.sessions.delete(input.sessionId); await session.runtime.closeSession(); state.emit({ type: "session.closed", sessionId: input.sessionId }); state.emit({ type: "request.completed", requestId: input.requestId }); return; } case "session.archive": case "session.unarchive": case "session.revert": state.emit({ type: "request.failed", requestId: input.requestId, error: { message: `${input.type} is not supported by this ACP provider` }, }); } } async function discover( input: Extract, state: AcpConnectionState, ): Promise { const runtime = await AcpRuntime.start({ options: state.options, launch: state.launch, boundarySessionId: "catalog", env: {}, emit: state.emit, }); try { let catalog = await runtime.discover(input.cwd); const config = runtime.configAccess(); for (const transformer of state.options.transformers ?? []) { if (transformer.discover) { catalog = await transformer.discover(catalog, { sessionId: "catalog", config }); } } state.emit({ type: "catalog", requestId: input.requestId, catalog }); } finally { await runtime.closeSession(); } } async function listSessions( input: Extract, state: AcpConnectionState, ): Promise { const runtime = await AcpRuntime.start({ options: state.options, launch: state.launch, boundarySessionId: "sessions", env: {}, emit: state.emit, }); try { const response = await runtime.listSessions(input.cwd); const selected = input.limit ? response.sessions.slice(0, input.limit) : response.sessions; state.emit({ type: "sessions", requestId: input.requestId, sessions: selected.map((session) => ({ persistence: nativePersistence(session.sessionId), cwd: session.cwd, title: session.title ?? undefined, updatedAt: session.updatedAt ?? undefined, })), }); } finally { await runtime.close(); } } async function openSession( input: Extract, state: AcpConnectionState, ): Promise { if (state.sessions.has(input.sessionId)) throw new Error(`Session already exists: ${input.sessionId}`); const runtime = await AcpRuntime.start({ options: state.options, launch: state.launch, boundarySessionId: input.sessionId, env: input.config.env, emit: state.emit, }); try { const capabilities = await runtime.open(input, state.capabilities); if (state.isClosed()) { await runtime.closeSession(); return; } state.sessions.set(input.sessionId, { runtime, capabilities, mutationLane: Promise.resolve(), }); } catch (error) { await runtime.close(); throw error; } } async function mutateSession( session: AcpBoundarySession, operation: () => Promise, ): Promise { const result = session.mutationLane.then(operation); session.mutationLane = result.catch(() => undefined); return result; } function requireSession(state: AcpConnectionState, sessionId: string): AcpBoundarySession { const session = state.sessions.get(sessionId); if (!session) throw new Error(`Unknown session: ${sessionId}`); return session; } interface StartRuntimeOptions { launch?: ProviderLaunch; options: RunAcpProviderOptions; boundarySessionId: string; env: Readonly>; emit(event: ProviderEvent): void; } class AcpRuntime { readonly connection: ClientSideConnection; agentCapabilities: AgentCapabilities = {}; nativeSessionId = ""; private readonly child: ChildProcessWithoutNullStreams | null; private emit: (event: ProviderEvent) => void; private readonly messages = new Map(); private readonly toolCalls = new Map(); private readonly pendingCompactions = new Set(); private readonly permissions = new Map< string, { request: RequestPermissionRequest; resolve(response: RequestPermissionResponse): void } >(); private readonly transformers: readonly AcpTransformer[]; private configOptions: SessionConfigOption[] = []; private modes: NewSessionResponse["modes"] = null; private commandWaiter: (() => void) | null = null; private messageSequence = 0; /** * Fallback ids for streamed chunks that carry no `messageId`, one per * chunk kind. Consecutive chunks of a kind continue the same timeline * item; a chunk of the other kind, a tool call, a plan, a user message, * or a turn boundary ends it (#4699). */ private readonly fallbackChunkIds = new Map< "agent_message_chunk" | "agent_thought_chunk", string >(); private closing = false; private processFailed = false; private configTransaction = false; private stagedTransformerConfig: ProviderConfigState | null = null; private readonly closeConnector: () => Promise; private notificationLane: Promise = Promise.resolve(); private promptLane: Promise = Promise.resolve(); private activePrompt: { turnId: string; settled: Promise } | null = null; static async start(options: StartRuntimeOptions): Promise { let child: ChildProcessWithoutNullStreams | null = null; let spawnFailure: Promise | null = null; let stream: Stream; let closeConnector = async () => {}; if (options.options.command) { // COMPAT(pluginProviderLaunch): added in v0.10.0, remove after 2027-03-29 once plugin host floor >= v0.10.0. // Standalone callers and older hosts do not send a daemon-resolved launch. const launch = options.launch ?? { command: options.options.command[0], args: options.options.command.slice(1), env: process.env, }; child = spawnProcess(launch.command, launch.args, { env: { ...launch.env, ...options.env }, stdio: "pipe", }); spawnFailure = new Promise((_resolve, reject) => child!.once("error", reject)); child.stderr.on("data", () => undefined); const output = Writable.toWeb(child.stdin) as unknown as Parameters[0]; const input = Readable.toWeb(child.stdout) as unknown as Parameters[1]; stream = ndJsonStream(output, input); } else { const owned = ownConnectorStream(await options.options.connector()); stream = owned.stream; closeConnector = owned.close; } const runtime = new AcpRuntime(stream, child, closeConnector, options); try { const initialize = withTimeout( runtime.call( runtime.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {}, clientInfo: { name: "paseo", version: "1" }, }), ), options.options.acpOptions?.startupTimeoutMs ?? 10_000, `ACP provider ${options.options.id} did not initialize`, ); const initialized = spawnFailure ? await Promise.race([initialize, spawnFailure]) : await initialize; runtime.agentCapabilities = initialized.agentCapabilities ?? {}; return runtime; } catch (error) { await runtime.close(); throw error; } } private constructor( stream: Stream, child: ChildProcessWithoutNullStreams | null, closeConnector: () => Promise, private readonly options: StartRuntimeOptions, ) { this.child = child; this.closeConnector = closeConnector; this.emit = options.emit; this.transformers = options.options.transformers ?? []; const client: Client = { requestPermission: (request) => this.requestPermission(request), sessionUpdate: (notification) => this.enqueueNotification(() => this.sessionUpdate(notification)), }; this.connection = new ClientSideConnection( () => client, routeVendorNotifications(stream, (method, params) => this.vendorNotification(method, params)), ); if (!child) void this.connection.closed.then(() => this.handleUnexpectedTransportClose()); child?.on("error", (error) => { this.processFailed = true; this.settlePendingWork(); if (this.closing || !this.nativeSessionId) return; this.emit({ type: "session.runtime_failed", sessionId: this.options.boundarySessionId, error: { message: describeError(error) }, }); }); child?.once("close", (code, signal) => { if (this.closing || this.processFailed || !this.nativeSessionId) return; this.processFailed = true; this.settlePendingWork(); this.emit({ type: "session.runtime_failed", sessionId: this.options.boundarySessionId, error: { message: `ACP process exited (${signal ?? code ?? "unknown"})` }, }); }); } async open( input: Extract, connectionCapabilities: readonly ProviderCapability[], ): Promise { const mcpServers = toAcpMcpServers(input.config); const nativeSessionId = readNativeSessionId(input.persistence); const metadata = { _paseo: { systemPrompt: input.config.systemPrompt, providerOptions: input.config.providerOptions, toolPolicy: input.config.toolPolicy, persist: input.config.persist, }, }; let response: Pick; if (nativeSessionId) { response = await this.call( this.connection.loadSession({ sessionId: nativeSessionId, cwd: input.config.cwd, mcpServers, _meta: metadata, }), ); this.nativeSessionId = nativeSessionId; } else { const newSession = await this.call( this.connection.newSession({ cwd: input.config.cwd, mcpServers, _meta: metadata, }), ); response = newSession; this.nativeSessionId = newSession.sessionId; } this.modes = response.modes; this.configOptions = response.configOptions ?? []; await this.applyInitialConfig(input.config); const sessionCapabilities = connectionCapabilities.filter( (capability) => capability !== "session.configure" || this.modes !== null || this.configOptions.length > 0 || this.transformers.some((transformer) => transformer.configure !== undefined), ); this.emit({ type: "session.opened", requestId: input.requestId, sessionId: input.sessionId, capabilities: sessionCapabilities, restoration: "core", persistence: nativePersistence(this.nativeSessionId), title: input.config.title, cwd: input.config.cwd, }); this.emitConfig(); if (this.options.options.acpOptions?.waitForInitialCommands) { await this.waitForInitialCommands(); } this.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); return sessionCapabilities; } async discover(cwd = process.cwd()): Promise { const response = await this.call(this.connection.newSession({ cwd, mcpServers: [] })); this.nativeSessionId = response.sessionId; this.modes = response.modes; this.configOptions = response.configOptions ?? []; return toProviderCatalog(response.modes, response.configOptions ?? []); } prompt(input: Extract): Promise { const admission = this.promptLane.then(() => this.admitPrompt(input)); this.promptLane = admission.catch(() => undefined); return admission; } private async admitPrompt( input: Extract, ): Promise { const active = this.activePrompt; if (active) { await this.interrupt(); await active.settled; } const prompt = toAcpPrompt(input.prompt); const turnId = `acp:${input.prompt.clientMessageId}`; this.fallbackChunkIds.clear(); this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, item: { type: "user_message", id: input.prompt.clientMessageId, text: prompt .filter((part) => part.type === "text") .map((part) => part.text) .join("\n"), clientMessageId: input.prompt.clientMessageId, }, }); this.emit({ type: "session.prompt_result", sessionId: this.options.boundarySessionId, clientMessageId: input.prompt.clientMessageId, result: { type: "turn", turnId }, }); this.emit({ type: "session.turn", sessionId: this.options.boundarySessionId, turnId, state: "started", }); const settled = this.call( this.connection.prompt({ sessionId: this.nativeSessionId, prompt }), ).then( (response): void => { const state = response.stopReason === "cancelled" ? "canceled" : "completed"; this.fallbackChunkIds.clear(); this.terminalizeTransientItems(state); this.emit({ type: "session.turn", sessionId: this.options.boundarySessionId, turnId, state, }); return undefined; }, (error): void => { this.fallbackChunkIds.clear(); this.terminalizeTransientItems("failed"); this.emit({ type: "session.turn", sessionId: this.options.boundarySessionId, turnId, state: "failed", error: { message: describeError(error) }, }); return undefined; }, ); this.activePrompt = { turnId, settled }; void settled.finally(() => { if (this.activePrompt?.turnId === turnId) this.activePrompt = null; }); } interrupt(): Promise { return this.call(this.connection.cancel({ sessionId: this.nativeSessionId })); } listSessions(cwd?: string) { return this.call(this.connection.listSessions({ cwd })); } async drainNotifications(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); await this.notificationLane; } respondToPermission( permissionId: string, response: Extract["response"], ): void { const pending = this.permissions.get(permissionId); if (!pending) throw new Error(`Unknown ACP permission: ${permissionId}`); const selected = selectPermissionOption(pending.request.options, response); if (response.selectedActionId !== undefined && !selected) { throw new Error( `ACP permission action '${response.selectedActionId}' does not exist or does not match '${response.behavior}' behavior`, ); } this.permissions.delete(permissionId); pending.resolve({ outcome: selected ? { outcome: "selected", optionId: selected.optionId } : { outcome: "cancelled" }, }); this.emit({ type: "session.permission_resolved", sessionId: this.options.boundarySessionId, permissionId, }); } async configure( changes: Extract["changes"], ): Promise { const ordered: AcpConfigChange[] = []; if (changes.model !== undefined) ordered.push({ target: "model", value: changes.model }); if (changes.mode !== undefined) ordered.push({ target: "mode", value: changes.mode }); if (changes.thinkingOption !== undefined) { ordered.push({ target: "thinking", value: changes.thinkingOption }); } for (const [id, value] of Object.entries(changes.settings ?? {})) { ordered.push({ target: "setting", id, value }); } const previousModes = this.modes ? structuredClone(this.modes) : null; const previousOptions = structuredClone(this.configOptions); this.configTransaction = true; this.stagedTransformerConfig = null; let committedConfig: ProviderConfigState | null = null; try { for (const change of ordered) await this.applyConfigChange(change); committedConfig = this.stagedTransformerConfig ?? toProviderConfigState(this.modes, this.configOptions); } catch (error) { try { await this.restoreConfig(previousModes, previousOptions); } catch (rollbackError) { const failure = new AggregateError( [error, rollbackError], `ACP configuration failed and rollback could not restore the previous state`, ); this.emit({ type: "session.runtime_failed", sessionId: this.options.boundarySessionId, error: { message: describeError(failure) }, }); await this.close(); throw failure; } this.modes = previousModes; this.configOptions = previousOptions; this.stagedTransformerConfig = null; throw error; } finally { this.configTransaction = false; } this.stagedTransformerConfig = null; this.emitConfigSnapshot(committedConfig); } async closeSession(): Promise { if (this.nativeSessionId) { await withTimeout( this.call(this.connection.closeSession({ sessionId: this.nativeSessionId })), 1_000, `ACP session ${this.nativeSessionId} did not close`, ).catch(() => undefined); } await this.close(); } async close(): Promise { if (this.closing) return; this.closing = true; this.settlePendingWork(); await this.closeConnector(); const child = this.child; if (!child || this.processFailed) return; if (child.exitCode !== null || child.signalCode !== null) return; const closed = new Promise((resolve) => child.once("close", () => resolve())); await terminateProcess(child, "SIGTERM"); if (await settlesWithin(closed, 1_000)) return; await terminateProcess(child); if (!(await settlesWithin(closed, 1_000))) { throw new Error(`ACP provider ${this.options.options.id} did not terminate after SIGKILL`); } } private async applyInitialConfig(config: ProviderSessionConfig): Promise { if (config.model) await this.applyConfigChange({ target: "model", value: config.model }); if (config.mode) await this.applyConfigChange({ target: "mode", value: config.mode }); if (config.thinkingOption) { await this.applyConfigChange({ target: "thinking", value: config.thinkingOption }); } for (const [id, value] of Object.entries(config.settings)) { await this.applyConfigChange({ target: "setting", id, value }); } } private async applyConfigChange(change: AcpConfigChange): Promise { const access = this.configAccess(); for (const transformer of this.transformers) { if ( (await transformer.configure?.(change, { sessionId: this.options.boundarySessionId, config: access, })) === "handled" ) { return; } } if (change.target === "mode") { if (!change.value || !this.modes) { throw new Error("ACP session does not expose mode configuration"); } await this.call( this.connection.setSessionMode({ sessionId: this.nativeSessionId, modeId: change.value, }), ); this.modes = { ...this.modes, currentModeId: change.value }; return; } let category: "model" | "thought_level" | null = null; if (change.target === "model") category = "model"; if (change.target === "thinking") category = "thought_level"; const configId = change.target === "setting" ? change.id : this.configOptions.find((option) => option.category === category)?.id; if (!configId) { throw new Error(`ACP session does not expose ${change.target} configuration`); } if (change.value === null) { throw new Error(`ACP configuration ${configId} cannot be cleared`); } const response = await this.call( this.connection.setSessionConfigOption({ sessionId: this.nativeSessionId, configId, ...(typeof change.value === "boolean" ? { type: "boolean" as const, value: change.value } : { value: String(change.value) }), }), ); this.configOptions = response.configOptions; } configAccess(): AcpConfigAccess { return { read: async () => configValues(this.configOptions), set: async (id, value) => { if (typeof value !== "string" && typeof value !== "boolean") { throw new Error(`ACP configuration ${id} accepts only string or boolean values`); } const response = await this.call( this.connection.setSessionConfigOption({ sessionId: this.nativeSessionId, configId: id, ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }), }), ); this.configOptions = response.configOptions; }, }; } private emitConfig(): void { if (this.configTransaction) return; this.emitConfigSnapshot(toProviderConfigState(this.modes, this.configOptions)); } private emitConfigSnapshot(config: ProviderConfigState): void { this.emit({ type: "session.config", sessionId: this.options.boundarySessionId, config, }); } private async restoreConfig( modes: NewSessionResponse["modes"], options: readonly SessionConfigOption[], ): Promise { if (modes?.currentModeId && this.modes?.currentModeId !== modes.currentModeId) { await this.call( this.connection.setSessionMode({ sessionId: this.nativeSessionId, modeId: modes.currentModeId, }), ); } const currentValues = configValues(this.configOptions); for (const option of options) { const value = option.currentValue; if (currentValues[option.id] === value) continue; await this.call( this.connection.setSessionConfigOption({ sessionId: this.nativeSessionId, configId: option.id, ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }), }), ); } } private requestPermission(request: RequestPermissionRequest): Promise { const permissionId = `permission:${request.toolCall.toolCallId}`; return new Promise((resolve) => { this.permissions.set(permissionId, { request, resolve }); this.emit({ type: "session.permission", sessionId: this.options.boundarySessionId, request: { id: permissionId, name: request.toolCall.name ?? request.toolCall.title ?? "Tool", kind: "tool", title: request.toolCall.title ?? undefined, input: jsonRecord(request.toolCall.rawInput), actions: request.options.map((option) => ({ id: option.optionId, label: option.name, behavior: option.kind.startsWith("allow") ? "allow" : "deny", })), }, }); }); } private sessionUpdate(notification: SessionNotification): void { if (notification.sessionId !== this.nativeSessionId) return; this.reduceUpdate(notification.update); } private reduceUpdate(update: SessionUpdate): void { if ( update.sessionUpdate === "agent_message_chunk" || update.sessionUpdate === "agent_thought_chunk" || update.sessionUpdate === "user_message_chunk" ) { if (update.sessionUpdate === "user_message_chunk") { this.fallbackChunkIds.clear(); return; } // A chunk of one kind ends the other kind's item, whatever its content // or id: reasoning, then text, then reasoning again are three items. this.fallbackChunkIds.delete( update.sessionUpdate === "agent_message_chunk" ? "agent_thought_chunk" : "agent_message_chunk", ); if (update.content.type !== "text") return; const id = this.resolveChunkId(update.sessionUpdate, update.messageId); const text = `${this.messages.get(id) ?? ""}${update.content.text}`; this.messages.set(id, text); this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, item: { type: update.sessionUpdate === "agent_message_chunk" ? "assistant_message" : "reasoning", id, text, ...(update.sessionUpdate === "agent_message_chunk" ? { messageId: update.messageId ?? undefined } : {}), }, }); return; } if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { if (update.sessionUpdate === "tool_call") this.fallbackChunkIds.clear(); this.reduceToolCall(update); return; } if (update.sessionUpdate === "plan") this.fallbackChunkIds.clear(); this.reduceStateUpdate(update); } private resolveChunkId( kind: "agent_message_chunk" | "agent_thought_chunk", messageId: string | null | undefined, ): string { if (messageId) { this.fallbackChunkIds.delete(kind); return messageId; } let id = this.fallbackChunkIds.get(kind); if (!id) { id = `${kind}:${++this.messageSequence}`; this.fallbackChunkIds.set(kind, id); } return id; } private reduceStateUpdate(update: SessionUpdate): void { if (update.sessionUpdate === "plan") { this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, item: { type: "todo", id: "acp-plan", items: update.entries.map((entry, index) => ({ id: String(index), text: entry.content, completed: entry.status === "completed", status: entry.status, })), }, }); return; } if (update.sessionUpdate === "available_commands_update") { const commands = update.availableCommands.map((command) => ({ name: command.name, description: command.description, argumentHint: command.input?.hint, })); this.emit({ type: "session.commands", sessionId: this.options.boundarySessionId, commands }); this.commandWaiter?.(); this.commandWaiter = null; return; } if (update.sessionUpdate === "current_mode_update") { if (this.modes) this.modes = { ...this.modes, currentModeId: update.currentModeId }; this.emitConfig(); return; } if (update.sessionUpdate === "config_option_update") { this.configOptions = update.configOptions; this.emitConfig(); return; } if (update.sessionUpdate === "usage_update") { this.emit({ type: "session.usage", sessionId: this.options.boundarySessionId, usage: { contextWindowUsedTokens: update.used, contextWindowMaxTokens: update.size, totalCostUsd: update.cost?.currency === "USD" ? update.cost.amount : undefined, }, }); return; } if (update.sessionUpdate === "compaction_update") { if (update.status === "completed") this.pendingCompactions.delete(update.compactionId); else this.pendingCompactions.add(update.compactionId); this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, item: { type: "compaction", id: update.compactionId, status: update.status === "completed" ? "completed" : "loading", }, }); } } private terminalizeTransientItems(state: "completed" | "failed" | "canceled"): void { for (const [id, snapshot] of this.toolCalls) { if (snapshot.status !== "pending" && snapshot.status !== "in_progress") continue; const completed: AcpToolCallSnapshot = { ...snapshot, status: state === "completed" ? "completed" : "failed", }; this.toolCalls.set(id, completed); this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, item: toolTimelineItem(completed), }); } for (const id of this.pendingCompactions) { this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, item: { type: "compaction", id, status: "completed" }, }); } this.pendingCompactions.clear(); } private reduceToolCall(update: ToolCall | ToolCallUpdate): void { const current = this.toolCalls.get(update.toolCallId); let snapshot = mergeToolCallSnapshot(update, current); const context = { sessionId: this.options.boundarySessionId }; for (const transformer of this.transformers) snapshot = transformer.toolCall?.(snapshot, context) ?? snapshot; this.toolCalls.set(snapshot.id, snapshot); this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, item: toolTimelineItem(snapshot), }); } private vendorNotification(method: string, params: unknown): void { const notification = { method, params: jsonValue(params) }; const context = { sessionId: this.options.boundarySessionId }; for (const transformer of this.transformers) { const transformed = transformer.notification?.(notification, context); if (!transformed) continue; const updates = Array.isArray(transformed) ? transformed : [transformed]; for (const update of updates) this.emitVendorUpdate(update); } } private emitVendorUpdate(update: AcpVendorUpdate): void { if (update.type === "commands") { this.emit({ type: "session.commands", sessionId: this.options.boundarySessionId, commands: [...update.commands], }); } else if (update.type === "config") { if (this.configTransaction) this.stagedTransformerConfig = update.config; else this.emitConfigSnapshot(update.config); } else if (update.type === "timeline") { this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, item: update.item, }); } else { this.emit({ type: "session.notice", sessionId: this.options.boundarySessionId, notice: update.notice, }); } } private call(operation: Promise): Promise { if (this.connection.signal.aborted) { return Promise.reject(new Error("ACP transport is closed")); } return Promise.race([ operation, this.connection.closed.then(() => { throw new Error("ACP transport closed unexpectedly"); }), ]); } private enqueueNotification(operation: () => void): Promise { const result = this.notificationLane.then(operation); this.notificationLane = result.catch(() => undefined); return result; } private handleUnexpectedTransportClose(): void { if (this.closing || this.processFailed) return; this.processFailed = true; this.settlePendingWork(); if (!this.nativeSessionId) return; this.emit({ type: "session.runtime_failed", sessionId: this.options.boundarySessionId, error: { message: "ACP transport closed unexpectedly" }, }); } private settlePendingWork(): void { for (const permission of this.permissions.values()) { permission.resolve({ outcome: { outcome: "cancelled" } }); } this.permissions.clear(); this.commandWaiter?.(); this.commandWaiter = null; } private waitForInitialCommands(): Promise { const timeoutMs = this.options.options.acpOptions?.initialCommandsTimeoutMs ?? 1_000; const commands = new Promise((resolve) => { this.commandWaiter = resolve; }); const timeout = new Promise((resolve) => { setTimeout(resolve, timeoutMs); }); return Promise.race([commands, timeout]).finally(() => { this.commandWaiter = null; }); } } function routeVendorNotifications( stream: Stream, receive: (method: string, params: unknown) => void, ): Stream { const clientMethods = new Set(Object.values(CLIENT_METHODS)); return { writable: stream.writable, readable: stream.readable.pipeThrough( new TransformStream({ transform(message, controller) { if ("method" in message && !("id" in message) && !clientMethods.has(message.method)) { // The SDK dispatches extensions asynchronously and can resolve a later response first. // Run synchronous vendor transforms in wire order, inside the configuration transaction. try { receive(message.method, message.params); } catch (error) { console.error("Error handling ACP vendor notification", error); } } else controller.enqueue(message); }, }), ), }; } function selectPermissionOption( options: RequestPermissionRequest["options"], response: ProviderPermissionResponse, ): RequestPermissionRequest["options"][number] | null { if (response.selectedActionId !== undefined) { return ( options.find( (option) => option.optionId === response.selectedActionId && permissionOptionBehavior(option) === response.behavior, ) ?? null ); } return options.find((option) => permissionOptionBehavior(option) === response.behavior) ?? null; } function permissionOptionBehavior( option: RequestPermissionRequest["options"][number], ): ProviderPermissionResponse["behavior"] { return option.kind.startsWith("allow") ? "allow" : "deny"; } function ownConnectorStream(source: Stream): { stream: Stream; close(): Promise; } { const reader = source.readable.getReader(); const writer = source.writable.getWriter(); let closed = false; const stream: Stream = { readable: new ReadableStream({ async pull(controller) { try { const next = await reader.read(); if (next.done) controller.close(); else controller.enqueue(next.value); } catch (error) { controller.error(error); } }, cancel: (reason) => reader.cancel(reason), }), writable: new WritableStream({ write: (message) => writer.write(message), close: () => writer.close(), abort: (reason) => writer.abort(reason), }), }; return { stream, async close() { if (closed) return; closed = true; await Promise.allSettled([ reader.cancel(new Error("Paseo closed the ACP connector")), writer.close(), ]); }, }; } function mergeToolCallSnapshot( update: ToolCall | ToolCallUpdate, current: AcpToolCallSnapshot | undefined, ): AcpToolCallSnapshot { return { id: update.toolCallId, name: firstText(update.name, current?.name), title: firstText(update.title, current?.title, update.name) ?? "Tool call", kind: firstText(update.kind, current?.kind), status: firstDefined(update.status, current?.status, "pending") ?? "pending", input: jsonValue(firstDefined(update.rawInput, current?.input, null)), output: jsonValue(firstDefined(update.rawOutput, current?.output, null)), locations: update.locations?.map((location) => location.path) ?? current?.locations ?? [], }; } function firstDefined(...values: Array): Value | undefined { return values.find((value) => value !== undefined); } function firstText(...values: Array): string | undefined { return values.find((value): value is string => typeof value === "string"); } function toAcpMcpServers(config: ProviderSessionConfig) { return Object.entries(config.mcpServers).map(([name, server]) => { if (server.type === "stdio") { return { name, command: server.command, args: server.args ?? [], env: Object.entries(server.env ?? {}).map(([key, value]) => ({ name: key, value })), }; } return { type: server.type, name, url: server.url, headers: Object.entries(server.headers ?? {}).map(([key, value]) => ({ name: key, value })), }; }); } function toAcpPrompt( prompt: Extract["prompt"], ): ContentBlock[] { if (prompt.input.type === "command") { const suffix = prompt.input.arguments ? ` ${prompt.input.arguments}` : ""; return [{ type: "text", text: `/${prompt.input.name}${suffix}` }]; } return prompt.input.content.map((content) => { if (content.type === "text" || content.type === "image") return content; return { type: "text", text: JSON.stringify(content) }; }); } function nativePersistence(sessionId: string): ProviderPersistence { return { version: 1, data: { sessionId } }; } function readNativeSessionId(persistence: ProviderPersistence | undefined): string | null { if ( !persistence || typeof persistence.data !== "object" || persistence.data === null || Array.isArray(persistence.data) ) return null; return typeof persistence.data.sessionId === "string" ? persistence.data.sessionId : null; } function toProviderConfigState( modes: NewSessionResponse["modes"], options: readonly SessionConfigOption[], ): ProviderConfigState { const model = options.find((option) => option.category === "model"); const thinking = options.find((option) => option.category === "thought_level"); return { model: selectedValue(model), mode: modes?.currentModeId, thinkingOption: selectedValue(thinking), models: selectOptions(model).map((option) => ({ id: option.value, label: option.label })), modes: modes?.availableModes.map((mode) => ({ id: mode.id, label: mode.name, description: mode.description ?? undefined, })) ?? [], thinkingOptions: selectOptions(thinking).map((option) => ({ id: option.value, label: option.label, })), settings: options .filter((option) => option.category !== "model" && option.category !== "thought_level") .map(toProviderSetting), }; } function toProviderCatalog( modes: NewSessionResponse["modes"], options: readonly SessionConfigOption[], ): ProviderCatalog { const state = toProviderConfigState(modes, options); return { models: state.models, modes: state.modes, thinkingOptions: state.thinkingOptions, defaultModel: state.model, defaultMode: state.mode, defaultThinkingOption: state.thinkingOption, }; } function toProviderSetting(option: SessionConfigOption): ProviderSetting { if (option.type === "boolean") { return { type: "toggle", id: option.id, label: option.name, description: option.description ?? undefined, value: option.currentValue, }; } return { type: "select", id: option.id, label: option.name, description: option.description ?? undefined, value: option.currentValue, options: selectOptions(option), }; } function selectOptions( option: SessionConfigOption | undefined, ): Array<{ label: string; value: string }> { if (!option || option.type === "boolean") return []; return option.options.flatMap((candidate) => "group" in candidate ? candidate.options.map((nested) => ({ label: nested.name, value: nested.value })) : [{ label: candidate.name, value: candidate.value }], ); } function selectedValue(option: SessionConfigOption | undefined): string | undefined { return option && option.type !== "boolean" ? option.currentValue : undefined; } function configValues( options: readonly SessionConfigOption[], ): Readonly>> { return Object.fromEntries(options.map((option) => [option.id, option.currentValue])); } function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { const input = jsonRecord(snapshot.input); const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true; const detail = isEdit ? { type: "edit" as const, filePath: typeof input.filePath === "string" ? input.filePath : (snapshot.locations[0] ?? ""), oldString: typeof input.oldString === "string" ? input.oldString : undefined, newString: typeof input.newString === "string" ? input.newString : undefined, unifiedDiff: typeof input.unifiedDiff === "string" ? input.unifiedDiff : undefined, } : { type: "unknown" as const, input: snapshot.input, output: snapshot.output }; const status = snapshot.status === "in_progress" || snapshot.status === "pending" ? "running" : snapshot.status; const base = { type: "tool_call" as const, id: snapshot.id, callId: snapshot.id, name: snapshot.name ?? snapshot.title, detail, }; if (status === "failed") return { ...base, status, error: snapshot.output }; return { ...base, status, error: null }; } function jsonValue(value: unknown) { return z.json().parse(value); } function jsonRecord(value: unknown): Record> { const parsed = jsonValue(value); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; return parsed; } function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } function withTimeout( promise: Promise, timeoutMs: number, message: string, ): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(message)), timeoutMs); promise.then( (value) => { clearTimeout(timeout); resolve(value); return undefined; }, (error) => { clearTimeout(timeout); reject(error); return undefined; }, ); }); } async function settlesWithin(promise: Promise, timeoutMs: number): Promise { return Promise.race([ promise.then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), ]); }