From 3132a94557ea2e9a5301c080f50731c4c0fd78fb Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Wed, 29 Jul 2026 15:43:25 -0400 Subject: [PATCH] feat: add ACP adapter for local and remote agents Signed-off-by: Colton Padden --- .changeset/bright-apes-serve-acp.md | 5 + docs/guides/acp.md | 88 ++++ docs/guides/meta.json | 1 + docs/reference/cli.md | 3 + packages/eve/package.json | 1 + .../@agentclientprotocol/sdk.mjs | 26 + .../eve/scripts/vendor-compiled/index.mjs | 2 + packages/eve/src/acp/adapter.test.ts | 347 +++++++++++++ packages/eve/src/acp/adapter.ts | 488 ++++++++++++++++++ packages/eve/src/acp/line-limit.test.ts | 36 ++ packages/eve/src/acp/line-limit.ts | 24 + packages/eve/src/acp/server.ts | 41 ++ packages/eve/src/cli/acp/command.ts | 118 +++++ packages/eve/src/cli/dev/command-options.ts | 28 + .../src/cli/dev/local-server-process.test.ts | 25 + .../eve/src/cli/dev/local-server-process.ts | 4 +- packages/eve/src/cli/run.test.ts | 63 +++ packages/eve/src/cli/run.ts | 39 +- packages/eve/src/internal/nitro/host/types.ts | 2 + pnpm-lock.yaml | 12 + research/acp-adapter-requirements.md | 351 +++++++++++++ 21 files changed, 1676 insertions(+), 28 deletions(-) create mode 100644 .changeset/bright-apes-serve-acp.md create mode 100644 docs/guides/acp.md create mode 100644 packages/eve/scripts/vendor-compiled/@agentclientprotocol/sdk.mjs create mode 100644 packages/eve/src/acp/adapter.test.ts create mode 100644 packages/eve/src/acp/adapter.ts create mode 100644 packages/eve/src/acp/line-limit.test.ts create mode 100644 packages/eve/src/acp/line-limit.ts create mode 100644 packages/eve/src/acp/server.ts create mode 100644 packages/eve/src/cli/acp/command.ts create mode 100644 packages/eve/src/cli/dev/command-options.ts create mode 100644 research/acp-adapter-requirements.md diff --git a/.changeset/bright-apes-serve-acp.md b/.changeset/bright-apes-serve-acp.md new file mode 100644 index 000000000..28e8314d8 --- /dev/null +++ b/.changeset/bright-apes-serve-acp.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Launch local or deployed eve applications as stable ACP v1 agents with `eve acp [url]`, including streamed messages, tool activity, human input, cancellation, and concurrent sessions. diff --git a/docs/guides/acp.md b/docs/guides/acp.md new file mode 100644 index 000000000..dd5f91ed9 --- /dev/null +++ b/docs/guides/acp.md @@ -0,0 +1,88 @@ +--- +title: "Use eve through ACP" +description: "Launch a local eve agent from Zed and other Agent Client Protocol clients." +--- + +Agent Client Protocol (ACP) clients can launch an authored eve application as a local subprocess. eve serves stable ACP v1 over stdio while its normal development server remains the execution runtime. + +```sh +eve acp +``` + +Without a URL, the client starts one process from the eve application root. It supervises a local development server, and closing the ACP connection stops that owned server. To bridge ACP to a deployed eve agent, pass its URL: + +```sh +eve acp https://agent.example.com +``` + +## Configure Zed + +Open the eve application root as the Zed workspace. In **Agent Settings → External Agents**, add a custom agent: + +```json +{ + "agent_servers": { + "eve-local": { + "type": "custom", + "command": "pnpm", + "args": ["exec", "eve", "acp"], + "env": {} + } + } +} +``` + +Use an absolute command path if Zed cannot find `pnpm` in its environment. The workspace must be the same directory as the eve application root; eve rejects a different `session/new.cwd` instead of running the wrong application. + +Disable Zed project MCP servers for this agent. The initial eve adapter does not accept client-provided MCP servers. + +## Supported behavior + +ACP clients receive: + +- streamed assistant text and reasoning; +- tool-call requests and results; +- one-time tool approval and denial requests; +- fixed-choice and freeform questions when the client supports ACP form elicitation; +- cooperative turn cancellation; +- independent concurrent ACP sessions; +- session closure and process cleanup. + +Development rebuilds retain normal eve semantics. In-flight work stays pinned to its generation, and the next turn uses the newest successful generation. + +## Security and capability limits + +ACP mode does not give the agent access to the editor's host filesystem or terminal. `session/new.cwd` identifies the eve application being launched; it is not mounted into the agent sandbox. + +The initial adapter does not support: + +- a deployed ACP HTTP or WebSocket endpoint;\n- ACP authentication (remote bridges use the deployed eve agent's existing HTTP authentication); +- ACP v2; +- client filesystem or terminal methods; +- client-provided MCP servers; +- images, audio, files, or embedded resources in prompts; +- session loading, listing, resumption, or durable ACP IDs across process restarts; +- ACP model or mode configuration. + +The agent continues to use the connections, tools, credentials, and sandbox policy authored in the eve application. Prompt text and ACP metadata never establish an authenticated end-user principal. + +## Diagnose a connection + +ACP reserves stdout for newline-delimited JSON-RPC. eve sends compilation output, server logs, and diagnostics to stderr so they cannot corrupt the protocol stream. + +For a quick headless smoke test, run an ACP client such as `acpx` from the application root. `acpx` launches the ACP process itself; do not start `eve acp` separately. + +```sh +npx acpx@latest \ + --agent 'pnpm exec eve acp' \ + exec 'Reply with exactly: ACP works' +``` + +When testing from the eve source checkout, use an authored fixture rather than the monorepo root, which does not provide an `eve` executable: + +```sh +cd apps/fixtures/weather-agent +npx acpx@latest --agent 'pnpm exec eve acp' exec 'Reply with exactly: ACP works' +``` + +If startup fails, inspect the ACP client's logs together with eve's stderr. A non-empty client MCP configuration, a mismatched working directory, and unsupported prompt content produce explicit protocol errors before model work begins. diff --git a/docs/guides/meta.json b/docs/guides/meta.json index f30738049..5ae6f4974 100644 --- a/docs/guides/meta.json +++ b/docs/guides/meta.json @@ -11,6 +11,7 @@ "dynamic-workflows", "remote-agents", "dev-tui", + "acp", "frontend", "client" ] diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4fee40001..8391b30e5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -15,6 +15,7 @@ The `eve` binary (`bin: eve`) runs from your app root, and every command first l | `eve start` | Serve the built `.output/` app; prints the listening URL | | `eve dev` | Start the local dev server and open the terminal UI | | `eve dev ` | Connect the UI to an existing server URL (e.g. a remote deployment) instead of booting a local server | +| `eve acp [url]` | Serve the local application or an existing eve server URL as a stable ACP v1 agent over stdio | | `eve logs [logid]` | Print an `eve dev` diagnostic log (the most recent when `logid` is omitted) | | `eve logs ls` | List `eve dev` diagnostic logs, most recent first | | `eve trace ls` | List locally captured agent traces, most recent first | @@ -181,6 +182,8 @@ Pass a bare URL and the UI connects to that server instead of booting a local on | `--context-size ` | number | none | Model context window size, shown as a usage percentage | | `--logs ` | enum | `stderr` | Server/agent logs to show: `all` \| `stderr` \| `sandbox` \| `none` | +`eve acp` reserves stdin and stdout for newline-delimited JSON-RPC and sends diagnostics to stderr. Without a URL, it supervises an isolated local development server. With a URL, it bridges ACP to that server's existing eve HTTP API and accepts the same URL credentials and request headers as `eve dev `. See [Use eve through ACP](../guides/acp) for client configuration and capability limits. + A fresh `eve init` passes `--input /model`. That bare local input starts onboarding: the TUI installs the Vercel CLI if needed, asks you to log in if needed, then opens `/model`. Other input stays editable in the prompt. For a URL target protected by HTTP Basic auth, put the credentials in the URL. eve sends them as a Basic `Authorization` header and strips them from the server URL before connecting: diff --git a/packages/eve/package.json b/packages/eve/package.json index d90bde11a..47e797a43 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -303,6 +303,7 @@ "undici": "8.9.0" }, "devDependencies": { + "@agentclientprotocol/sdk": "1.3.0", "@ai-sdk/anthropic": "catalog:", "@ai-sdk/google": "catalog:", "@ai-sdk/mcp": "catalog:", diff --git a/packages/eve/scripts/vendor-compiled/@agentclientprotocol/sdk.mjs b/packages/eve/scripts/vendor-compiled/@agentclientprotocol/sdk.mjs new file mode 100644 index 000000000..8d6f5037c --- /dev/null +++ b/packages/eve/scripts/vendor-compiled/@agentclientprotocol/sdk.mjs @@ -0,0 +1,26 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +const DECLARATIONS = [ + ["acp.d.ts", "index.d.ts"], + ["jsonrpc.d.ts", "jsonrpc.d.ts"], + ["stream.d.ts", "stream.d.ts"], + ["schema/guards.gen.d.ts", "schema/guards.gen.d.ts"], + ["schema/index.d.ts", "schema/index.d.ts"], + ["schema/types.gen.d.ts", "schema/types.gen.d.ts"], +]; + +/** Vendor the stable ACP v1 SDK without adding it to eve's runtime dependencies. */ +export default { + packageName: "@agentclientprotocol/sdk", + compiledPath: "@agentclientprotocol/sdk", + bundling: "standalone", + copyDeclarations: async ({ destinationRoot, packageInfo }) => { + const sourceRoot = join(packageInfo.packageRoot, "dist"); + for (const [sourceFile, destinationFile] of DECLARATIONS) { + const destinationPath = join(destinationRoot, destinationFile); + await mkdir(dirname(destinationPath), { recursive: true }); + await writeFile(destinationPath, await readFile(join(sourceRoot, sourceFile)), "utf8"); + } + }, +}; diff --git a/packages/eve/scripts/vendor-compiled/index.mjs b/packages/eve/scripts/vendor-compiled/index.mjs index 2b74bfce7..759e19fd7 100644 --- a/packages/eve/scripts/vendor-compiled/index.mjs +++ b/packages/eve/scripts/vendor-compiled/index.mjs @@ -3,6 +3,7 @@ * the orchestrator passes to `runVendor`. Adding a new vendored package * means writing a new per-package file and importing it here. */ +import acpSdk from "./@agentclientprotocol/sdk.mjs"; import anthropic from "./@ai-sdk/anthropic.mjs"; import google from "./@ai-sdk/google.mjs"; import mcp from "./@ai-sdk/mcp.mjs"; @@ -48,6 +49,7 @@ import zod from "./zod.mjs"; import zodValidationError from "./zod-validation-error.mjs"; export const MODULES = [ + acpSdk, anthropic, chat, chatAdapterSlack, diff --git a/packages/eve/src/acp/adapter.test.ts b/packages/eve/src/acp/adapter.test.ts new file mode 100644 index 000000000..edd6d9a24 --- /dev/null +++ b/packages/eve/src/acp/adapter.test.ts @@ -0,0 +1,347 @@ +import { RequestError, type AgentContext } from "#compiled/@agentclientprotocol/sdk/index.js"; +import { describe, expect, it, vi } from "vitest"; + +import { EveAcpAdapter } from "#acp/adapter.js"; +import type { SendTurnInput, SessionState } from "#client/types.js"; +import type { HandleMessageStreamEvent } from "#protocol/message.js"; + +class FakeClientSession { + readonly cancel = vi.fn(async () => ({ status: "accepted" })); + readonly reset = vi.fn(async () => ({ status: "reset" })); + readonly sends: SendTurnInput[] = []; + state: SessionState = { streamIndex: 0 }; + readonly #turns: Array; + + constructor(turns: Array) { + this.#turns = turns; + } + + async send(input: SendTurnInput): Promise> { + this.sends.push(input); + this.state = { ...this.state, sessionId: "eve-session" }; + const events = this.#turns.shift() ?? []; + return (async function* () { + for (const event of events) yield event; + })(); + } +} + +function adapterWith(turns: Array = []) { + const session = new FakeClientSession(turns); + const adapter = new EveAcpAdapter({ + appRoot: process.cwd(), + client: { session: () => session }, + eveVersion: "1.2.3", + serverUrl: "http://127.0.0.1:2000", + }); + return { adapter, session }; +} + +function acpClient(input?: { request?: AgentContext["request"] }) { + const notifications: Array<{ method: string; params: unknown }> = []; + const notify = vi.fn(async (method: string, params: unknown) => { + notifications.push({ method, params }); + }); + const request = input?.request ?? vi.fn(async () => ({})); + return { + client: Object.assign(Object.create(null), { notify, request }) as AgentContext, + notifications, + notify, + request, + }; +} + +async function createSession(adapter: EveAcpAdapter): Promise { + const result = await adapter.newSession({ cwd: process.cwd(), mcpServers: [] }); + return result.sessionId; +} + +const textPrompt = (sessionId: string, text = "hello") => ({ + prompt: [{ type: "text" as const, text }], + sessionId, +}); + +describe("EveAcpAdapter", () => { + it("negotiates stable v1 without advertising workspace capabilities", () => { + const { adapter } = adapterWith(); + expect( + adapter.initialize({ + protocolVersion: 2, + clientCapabilities: { fs: { readTextFile: true }, terminal: true }, + }), + ).toEqual({ + protocolVersion: 1, + agentCapabilities: { + loadSession: false, + promptCapabilities: {}, + sessionCapabilities: { close: {} }, + }, + agentInfo: { name: "eve", title: "eve", version: "1.2.3" }, + authMethods: [], + }); + }); + + it("rejects a different local cwd and client-provided MCP before creating a session", async () => { + const { adapter } = adapterWith(); + await expect(adapter.newSession({ cwd: process.cwd(), mcpServers: [] })).resolves.toEqual({ + sessionId: expect.any(String), + }); + await expect( + adapter.newSession({ cwd: process.cwd(), mcpServers: [{} as never] }), + ).rejects.toThrow("Client-provided MCP servers"); + await expect(adapter.newSession({ cwd: "/", mcpServers: [] })).rejects.toBeInstanceOf( + RequestError, + ); + }); + + it("does not treat a remote ACP client's cwd as a deployment workspace", async () => { + const session = new FakeClientSession([]); + const adapter = new EveAcpAdapter({ + appRoot: process.cwd(), + client: { session: () => session }, + eveVersion: "1.2.3", + serverUrl: "https://agent.example.com", + validateWorkspaceRoot: false, + }); + + await expect( + adapter.newSession({ cwd: "/a/client/path-that-does-not-exist", mcpServers: [] }), + ).resolves.toEqual({ sessionId: expect.any(String) }); + }); + + it("streams message, reasoning, and tool lifecycle updates in event order", async () => { + const events: HandleMessageStreamEvent[] = [ + { + type: "message.appended", + data: { messageDelta: "Hi", messageSoFar: "Hi", sequence: 1, stepIndex: 0, turnId: "t1" }, + }, + { + type: "reasoning.appended", + data: { + reasoningDelta: "Think", + reasoningSoFar: "Think", + sequence: 2, + stepIndex: 0, + turnId: "t1", + }, + }, + { + type: "actions.requested", + data: { + actions: [ + { callId: "call-1", input: { city: "SF" }, kind: "tool-call", toolName: "weather" }, + ], + sequence: 3, + stepIndex: 0, + turnId: "t1", + }, + }, + { + type: "action.result", + data: { + result: { + callId: "call-1", + kind: "tool-result", + output: { condition: "sunny" }, + toolName: "weather", + }, + sequence: 4, + status: "completed", + stepIndex: 0, + turnId: "t1", + }, + }, + { type: "session.waiting", data: { continuationToken: "secret", wait: "next-user-message" } }, + ]; + const { adapter, session } = adapterWith([events]); + const sessionId = await createSession(adapter); + const { client, notifications } = acpClient(); + + await expect( + adapter.prompt(textPrompt(sessionId), client, new AbortController().signal), + ).resolves.toEqual({ + stopReason: "end_turn", + }); + expect(session.sends).toEqual([{ message: [{ type: "text", text: "hello" }] }]); + expect(notifications.map(({ params }) => (params as any).update.sessionUpdate)).toEqual([ + "agent_message_chunk", + "agent_thought_chunk", + "tool_call", + "tool_call_update", + ]); + expect(JSON.stringify(notifications)).not.toContain("secret"); + }); + + it("round-trips a confirmation response through the same logical prompt", async () => { + const action = { + callId: "call-1", + input: { path: "x" }, + kind: "tool-call" as const, + toolName: "write", + }; + const { adapter, session } = adapterWith([ + [ + { + type: "actions.requested", + data: { actions: [action], sequence: 1, stepIndex: 0, turnId: "t1" }, + }, + { + type: "input.requested", + data: { + requests: [ + { + action, + display: "confirmation", + options: [ + { id: "approve", label: "Approve" }, + { id: "deny", label: "Deny" }, + ], + prompt: "Allow write?", + requestId: "request-1", + }, + ], + sequence: 2, + stepIndex: 0, + turnId: "t1", + }, + }, + { type: "session.waiting", data: { continuationToken: "one", wait: "next-user-message" } }, + ], + [{ type: "session.waiting", data: { continuationToken: "two", wait: "next-user-message" } }], + ]); + const request = vi.fn(async (_method: string, params: any) => ({ + outcome: { outcome: "selected", optionId: params.options[0].optionId }, + })) as AgentContext["request"]; + const { client } = acpClient({ request }); + const sessionId = await createSession(adapter); + + await expect( + adapter.prompt(textPrompt(sessionId), client, new AbortController().signal), + ).resolves.toEqual({ + stopReason: "end_turn", + }); + expect(request).toHaveBeenCalledOnce(); + expect(session.sends).toHaveLength(2); + expect(session.sends[1]).toEqual({ + inputResponses: [{ optionId: "approve", requestId: "request-1" }], + }); + }); + + it("cancels an outstanding permission request without parking the ACP prompt", async () => { + const action = { + callId: "call-1", + input: {}, + kind: "tool-call" as const, + toolName: "write", + }; + const { adapter, session } = adapterWith([ + [ + { + type: "input.requested", + data: { + requests: [ + { + action, + display: "confirmation", + options: [ + { id: "approve", label: "Approve" }, + { id: "deny", label: "Deny" }, + ], + prompt: "Allow write?", + requestId: "request-1", + }, + ], + sequence: 1, + stepIndex: 0, + turnId: "turn-1", + }, + }, + { type: "session.waiting", data: { continuationToken: "one", wait: "next-user-message" } }, + ], + ]); + let requestStarted!: () => void; + const started = new Promise((resolve) => (requestStarted = resolve)); + const request = vi.fn( + async (_method: string, _params: unknown, options?: { cancellationSignal?: AbortSignal }) => { + requestStarted(); + await new Promise((_resolve, reject) => { + options?.cancellationSignal?.addEventListener( + "abort", + () => reject(new Error("cancelled")), + { once: true }, + ); + }); + }, + ) as AgentContext["request"]; + const sessionId = await createSession(adapter); + const prompt = adapter.prompt( + textPrompt(sessionId), + acpClient({ request }).client, + new AbortController().signal, + ); + await started; + + await adapter.cancel(sessionId); + await expect(prompt).resolves.toEqual({ stopReason: "cancelled" }); + expect(session.cancel).toHaveBeenCalledWith({ turnId: "turn-1" }); + expect(session.sends).toHaveLength(1); + }); + + it("requests cooperative cancellation and waits for the cancelled boundary", async () => { + let release!: () => void; + let observeTurn!: () => void; + const pending = new Promise((resolve) => (release = resolve)); + const turnObserved = new Promise((resolve) => (observeTurn = resolve)); + const session = new FakeClientSession([]); + session.send = vi.fn(async () => { + session.state = { sessionId: "eve-session", streamIndex: 0 }; + return (async function* () { + observeTurn(); + yield { + type: "turn.started", + data: { sequence: 0, turnId: "turn-1" }, + } as HandleMessageStreamEvent; + await pending; + yield { + type: "turn.cancelled", + data: { sequence: 1, turnId: "turn-1" }, + } as HandleMessageStreamEvent; + yield { + type: "session.waiting", + data: { continuationToken: "next", wait: "next-user-message" }, + } as HandleMessageStreamEvent; + })(); + }); + const adapter = new EveAcpAdapter({ + appRoot: process.cwd(), + client: { session: () => session }, + eveVersion: "1.2.3", + serverUrl: "http://127.0.0.1:2000", + }); + const sessionId = await createSession(adapter); + const prompt = adapter.prompt( + textPrompt(sessionId), + acpClient().client, + new AbortController().signal, + ); + await turnObserved; + await new Promise((resolve) => setTimeout(resolve, 0)); + + await adapter.cancel(sessionId); + expect(session.cancel).toHaveBeenCalledWith({ turnId: "turn-1" }); + release(); + await expect(prompt).resolves.toEqual({ stopReason: "cancelled" }); + }); + + it("rejects unsupported prompt content instead of dropping it", async () => { + const { adapter } = adapterWith(); + const sessionId = await createSession(adapter); + await expect( + adapter.prompt( + { prompt: [{ type: "image", data: "a", mimeType: "image/png" }], sessionId }, + acpClient().client, + new AbortController().signal, + ), + ).rejects.toThrow("not supported"); + }); +}); diff --git a/packages/eve/src/acp/adapter.ts b/packages/eve/src/acp/adapter.ts new file mode 100644 index 000000000..59bdb4d16 --- /dev/null +++ b/packages/eve/src/acp/adapter.ts @@ -0,0 +1,488 @@ +import { randomUUID } from "node:crypto"; +import { realpath } from "node:fs/promises"; + +import { + PROTOCOL_VERSION, + RequestError, + methods, + type AgentContext, + type InitializeRequest, + type InitializeResponse, + type NewSessionRequest, + type NewSessionResponse, + type PromptRequest, + type PromptResponse, + type ToolCallUpdate, +} from "#compiled/@agentclientprotocol/sdk/index.js"; +import { Client } from "#client/index.js"; +import type { SendTurnInput, SessionState } from "#client/types.js"; +import type { HandleMessageStreamEvent } from "#protocol/message.js"; +import type { RuntimeActionRequest, RuntimeActionResult } from "#runtime/actions/types.js"; +import type { InputRequest, InputResponse } from "#runtime/input/types.js"; + +const ANSWER_FIELD = "answer"; +const ERROR_CODE_BUSY = -32_001; +const ERROR_CODE_EVE = -32_002; +const ERROR_CODE_UNSUPPORTED = -32_003; + +interface ActivePrompt { + cancelRequested: boolean; + protocolCancelled: boolean; + readonly outboundController: AbortController; + readonly settled: Promise; + readonly resolveSettled: () => void; + turnId?: string; +} + +interface AdapterClientSession { + readonly state: SessionState; + send(input: SendTurnInput): Promise>; + cancel(options?: { turnId?: string }): Promise; + reset(): Promise; +} + +interface AdapterClient { + session(): AdapterClientSession; +} + +interface AcpSession { + readonly client: AdapterClientSession; + active?: ActivePrompt; + closed: boolean; + readonly tools: Map; +} + +export interface EveAcpAdapterOptions { + readonly appRoot: string; + readonly eveVersion: string; + readonly headers?: Readonly>; + readonly serverUrl: string; + readonly validateWorkspaceRoot?: boolean; + /** @internal Test seam for the public eve client boundary. */ + readonly client?: AdapterClient; +} + +/** + * Translates stable ACP v1 sessions onto the public eve HTTP client. + * + * The adapter owns only process-local ACP identities. Conversation durability, + * event cursors, cancellation, and reset behavior remain owned by + * {@link ClientSession}. + */ +export class EveAcpAdapter { + readonly #appRoot: string; + readonly #client: AdapterClient; + readonly #eveVersion: string; + readonly #validateWorkspaceRoot: boolean; + #formElicitationSupported = false; + readonly #sessions = new Map(); + + constructor(options: EveAcpAdapterOptions) { + this.#appRoot = options.appRoot; + this.#eveVersion = options.eveVersion; + this.#validateWorkspaceRoot = options.validateWorkspaceRoot ?? true; + this.#client = + options.client ?? + new Client({ + headers: options.headers, + host: options.serverUrl, + preserveCompletedSessions: true, + }); + } + + initialize(params: InitializeRequest): InitializeResponse { + this.#formElicitationSupported = params.clientCapabilities?.elicitation?.form !== undefined; + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + promptCapabilities: {}, + sessionCapabilities: { close: {} }, + }, + agentInfo: { + name: "eve", + title: "eve", + version: this.#eveVersion, + }, + authMethods: [], + }; + } + + async newSession(params: NewSessionRequest): Promise { + if (params.mcpServers.length > 0) { + throw unsupported("Client-provided MCP servers are not supported by eve ACP mode."); + } + if ((params.additionalDirectories?.length ?? 0) > 0) { + throw unsupported("Additional workspace directories are not supported by eve ACP mode."); + } + + if (this.#validateWorkspaceRoot) { + const requestedRoot = await normalizedRealpath(params.cwd, "session/new.cwd"); + const appRoot = await normalizedRealpath(this.#appRoot, "the eve application root"); + if (requestedRoot !== appRoot) { + throw RequestError.invalidParams( + { appRoot, cwd: params.cwd }, + "ACP cwd must be the eve application root", + ); + } + } + + const sessionId = randomUUID(); + this.#sessions.set(sessionId, { + client: this.#client.session(), + closed: false, + tools: new Map(), + }); + return { sessionId }; + } + + async prompt( + params: PromptRequest, + client: AgentContext, + signal: AbortSignal, + ): Promise { + const session = this.#getSession(params.sessionId); + if (session.active !== undefined) { + throw new RequestError( + ERROR_CODE_BUSY, + `ACP session ${params.sessionId} already has an active prompt.`, + ); + } + + const message = promptContent(params); + const settled = Promise.withResolvers(); + const active: ActivePrompt = { + cancelRequested: false, + protocolCancelled: false, + outboundController: new AbortController(), + settled: settled.promise, + resolveSettled: settled.resolve, + }; + session.active = active; + const onProtocolCancel = () => { + active.protocolCancelled = true; + void this.#cancelActive(session); + }; + signal.addEventListener("abort", onProtocolCancel, { once: true }); + + try { + let input: SendTurnInput = { message }; + for (;;) { + const response = await session.client.send(input); + const inputRequests: InputRequest[] = []; + let cancelled = false; + let failure: + | Extract + | undefined; + + for await (const event of response) { + const turnId = "data" in event && "turnId" in event.data ? event.data.turnId : undefined; + if (typeof turnId === "string") active.turnId = turnId; + + if (event.type === "input.requested") inputRequests.push(...event.data.requests); + if (event.type === "turn.cancelled") cancelled = true; + if (event.type === "turn.failed" || event.type === "session.failed") failure = event; + await this.#projectEvent(params.sessionId, session, event, client); + } + + if (active.protocolCancelled) { + throw RequestError.requestCancelled({ sessionId: params.sessionId }); + } + if (cancelled || active.cancelRequested) return { stopReason: "cancelled" }; + if (failure !== undefined) throw eveFailure(failure); + if (inputRequests.length === 0) return { stopReason: "end_turn" }; + + let inputResponses: InputResponse[]; + try { + inputResponses = await Promise.all( + inputRequests.map((request) => + this.#requestInput(params.sessionId, session, request, client, signal), + ), + ); + } catch (error) { + if (active.protocolCancelled) { + throw RequestError.requestCancelled({ sessionId: params.sessionId }); + } + if (active.cancelRequested) return { stopReason: "cancelled" }; + throw error; + } + if (active.protocolCancelled) { + throw RequestError.requestCancelled({ sessionId: params.sessionId }); + } + if (active.cancelRequested) return { stopReason: "cancelled" }; + input = { inputResponses }; + } + } finally { + signal.removeEventListener("abort", onProtocolCancel); + active.resolveSettled(); + if (session.active === active) session.active = undefined; + } + } + + async cancel(sessionId: string): Promise { + const session = this.#sessions.get(sessionId); + if (session === undefined || session.closed || session.active === undefined) return; + session.active.cancelRequested = true; + await this.#cancelActive(session); + } + + async closeSession(sessionId: string): Promise { + const session = this.#getSession(sessionId); + if (session.active !== undefined) { + const active = session.active; + active.cancelRequested = true; + await this.#cancelActive(session); + await active.settled; + } + await session.client.reset(); + session.closed = true; + this.#sessions.delete(sessionId); + } + + async close(): Promise { + await Promise.allSettled( + [...this.#sessions.values()].map(async (session) => { + if (session.active !== undefined) { + const active = session.active; + active.cancelRequested = true; + await this.#cancelActive(session); + await active.settled; + } + }), + ); + this.#sessions.clear(); + } + + async #cancelActive(session: AcpSession): Promise { + session.active?.outboundController.abort(); + if (session.client.state.sessionId === undefined) return; + await session.client.cancel( + session.active?.turnId === undefined ? undefined : { turnId: session.active.turnId }, + ); + } + + #getSession(sessionId: string): AcpSession { + const session = this.#sessions.get(sessionId); + if (session === undefined || session.closed) { + throw RequestError.invalidParams({ sessionId }, "Unknown or closed ACP session"); + } + return session; + } + + async #projectEvent( + sessionId: string, + session: AcpSession, + event: HandleMessageStreamEvent, + client: AgentContext, + ): Promise { + switch (event.type) { + case "message.appended": + await notifyUpdate(client, sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: event.data.messageDelta }, + }); + return; + case "reasoning.appended": + await notifyUpdate(client, sessionId, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: event.data.reasoningDelta }, + }); + return; + case "actions.requested": + for (const action of event.data.actions) { + const toolCall = toolCallForAction(action); + session.tools.set(action.callId, toolCall); + await notifyUpdate(client, sessionId, { sessionUpdate: "tool_call", ...toolCall }); + } + return; + case "action.result": { + const result = event.data.result; + const status = event.data.status === "failed" || result.isError ? "failed" : "completed"; + await notifyUpdate(client, sessionId, { + sessionUpdate: "tool_call_update", + toolCallId: result.callId, + status, + content: [ + { type: "content", content: { type: "text", text: stringifyOutput(result.output) } }, + ], + rawOutput: result.output, + }); + return; + } + default: + return; + } + } + + async #requestInput( + sessionId: string, + session: AcpSession, + request: InputRequest, + client: AgentContext, + signal: AbortSignal, + ): Promise { + const cancellationSignal = AbortSignal.any([signal, session.active!.outboundController.signal]); + if (request.display === "confirmation") { + const approveId = `${request.requestId}:approve`; + const denyId = `${request.requestId}:deny`; + const toolCall = + session.tools.get(request.action.callId) ?? toolCallForAction(request.action); + const response = await client.request( + methods.client.session.requestPermission, + { + sessionId, + toolCall, + options: [ + { kind: "allow_once", name: "Approve", optionId: approveId }, + { kind: "reject_once", name: "Deny", optionId: denyId }, + ], + }, + { cancellationSignal }, + ); + if (response.outcome.outcome === "cancelled") { + session.active!.cancelRequested = true; + return { requestId: request.requestId }; + } + if (response.outcome.optionId === approveId) { + return { requestId: request.requestId, optionId: "approve" }; + } + if (response.outcome.optionId === denyId) { + return { requestId: request.requestId, optionId: "deny" }; + } + throw RequestError.invalidParams( + { optionId: response.outcome.optionId }, + "Unknown ACP permission option", + ); + } + + if (!this.#formElicitationSupported) { + throw unsupported( + "The ACP client does not support form elicitation required by this eve question.", + ); + } + + const property = elicitationProperty(request); + const response = await client.request( + methods.client.elicitation.create, + { + mode: "form", + sessionId, + message: request.prompt, + requestedSchema: { + type: "object", + properties: { [ANSWER_FIELD]: property }, + required: [ANSWER_FIELD], + }, + }, + { cancellationSignal }, + ); + if (response.action !== "accept") { + session.active!.cancelRequested = true; + return { requestId: request.requestId }; + } + const content = response.content as Record | null | undefined; + const answer = content?.[ANSWER_FIELD]; + if (typeof answer !== "string") { + throw RequestError.invalidParams( + response.content, + "ACP elicitation response must contain a string answer", + ); + } + if (request.display === "select") { + if (!request.options?.some((option) => option.id === answer)) { + throw RequestError.invalidParams({ answer }, "Unknown eve question option"); + } + return { requestId: request.requestId, optionId: answer }; + } + return { requestId: request.requestId, text: answer }; + } +} + +async function normalizedRealpath(path: string, label: string): Promise { + try { + return await realpath(path); + } catch (error) { + throw RequestError.invalidParams( + { path }, + `Could not resolve ${label}: ${errorMessage(error)}`, + ); + } +} + +function promptContent(params: PromptRequest): Array<{ type: "text"; text: string }> { + if (params.prompt.length === 0) { + throw RequestError.invalidParams( + params.prompt, + "ACP prompt must contain at least one text block", + ); + } + return params.prompt.map((part) => { + if (part.type !== "text") { + throw unsupported(`ACP prompt content type ${JSON.stringify(part.type)} is not supported.`); + } + return { type: "text", text: part.text }; + }); +} + +function toolCallForAction(action: RuntimeActionRequest): ToolCallUpdate { + const title = + action.kind === "tool-call" + ? action.toolName + : action.kind === "load-skill" + ? "Load skill" + : action.name; + return { + toolCallId: action.callId, + title, + kind: "other", + status: "pending", + rawInput: action.input, + }; +} + +function elicitationProperty(request: InputRequest) { + if (request.display === "select" && request.allowFreeform !== true && request.options?.length) { + return { + type: "string" as const, + title: request.prompt, + oneOf: request.options.map((option) => ({ + const: option.id, + title: option.label, + description: option.description, + })), + }; + } + if (request.display === "text" && request.allowFreeform !== false) { + return { type: "string" as const, title: request.prompt, minLength: 1 }; + } + throw unsupported("This eve question shape cannot be represented by ACP form elicitation."); +} + +async function notifyUpdate( + client: AgentContext, + sessionId: string, + update: Parameters[1], +): Promise { + await client.notify(methods.client.session.update, { sessionId, update } as never); +} + +function stringifyOutput(output: RuntimeActionResult["output"]): string { + return typeof output === "string" ? output : JSON.stringify(output, null, 2); +} + +function unsupported(message: string): RequestError { + return new RequestError(ERROR_CODE_UNSUPPORTED, message); +} + +function eveFailure( + event: Extract, +): RequestError { + return new RequestError(ERROR_CODE_EVE, event.data.message, { + code: event.data.code, + details: event.data.details, + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/eve/src/acp/line-limit.test.ts b/packages/eve/src/acp/line-limit.test.ts new file mode 100644 index 000000000..a5adfb14c --- /dev/null +++ b/packages/eve/src/acp/line-limit.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { limitAcpLineBytes } from "#acp/line-limit.js"; + +async function readAll(stream: ReadableStream): Promise { + const chunks: Uint8Array[] = []; + for await (const chunk of stream) chunks.push(chunk); + return new TextDecoder().decode(Buffer.concat(chunks)); +} + +describe("limitAcpLineBytes", () => { + it("counts a line across chunks and resets at each newline", async () => { + const input = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("123")); + controller.enqueue(new TextEncoder().encode("45\n12345\n")); + controller.close(); + }, + }); + + await expect(readAll(input.pipeThrough(limitAcpLineBytes(5)))).resolves.toBe("12345\n12345\n"); + }); + + it("rejects an oversized unterminated line", async () => { + const input = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("123456")); + controller.close(); + }, + }); + + await expect(readAll(input.pipeThrough(limitAcpLineBytes(5)))).rejects.toThrow( + "5-byte line limit", + ); + }); +}); diff --git a/packages/eve/src/acp/line-limit.ts b/packages/eve/src/acp/line-limit.ts new file mode 100644 index 000000000..a6c3f3673 --- /dev/null +++ b/packages/eve/src/acp/line-limit.ts @@ -0,0 +1,24 @@ +export const ACP_MAX_LINE_BYTES = 10 * 1024 * 1024; + +/** Rejects an ACP stdio line before the SDK's line buffer can grow without bound. */ +export function limitAcpLineBytes( + maximumBytes: number = ACP_MAX_LINE_BYTES, +): TransformStream { + let currentLineBytes = 0; + return new TransformStream({ + transform(chunk, controller) { + for (const byte of chunk) { + if (byte === 0x0a) { + currentLineBytes = 0; + continue; + } + currentLineBytes += 1; + if (currentLineBytes > maximumBytes) { + controller.error(new Error(`ACP message exceeds the ${maximumBytes}-byte line limit.`)); + return; + } + } + controller.enqueue(chunk); + }, + }); +} diff --git a/packages/eve/src/acp/server.ts b/packages/eve/src/acp/server.ts new file mode 100644 index 000000000..fb5eaf69c --- /dev/null +++ b/packages/eve/src/acp/server.ts @@ -0,0 +1,41 @@ +import { Readable, Writable } from "node:stream"; + +import { agent, methods, ndJsonStream } from "#compiled/@agentclientprotocol/sdk/index.js"; +import { EveAcpAdapter } from "#acp/adapter.js"; +import { limitAcpLineBytes } from "#acp/line-limit.js"; + +export interface RunAcpServerOptions { + readonly appRoot: string; + readonly eveVersion: string; + readonly headers?: Readonly>; + readonly serverUrl: string; + readonly signal?: AbortSignal; + readonly validateWorkspaceRoot: boolean; +} + +/** Serves one stable ACP v1 connection over process stdio until the client disconnects. */ +export async function runAcpServer(options: RunAcpServerOptions): Promise { + const adapter = new EveAcpAdapter(options); + const app = agent({ name: "eve" }) + .onRequest(methods.agent.initialize, ({ params }) => adapter.initialize(params)) + .onRequest(methods.agent.session.new, ({ params }) => adapter.newSession(params)) + .onRequest(methods.agent.session.prompt, ({ params, client, signal }) => + adapter.prompt(params, client, signal), + ) + .onRequest(methods.agent.session.close, ({ params }) => adapter.closeSession(params.sessionId)) + .onNotification(methods.agent.session.cancel, ({ params }) => adapter.cancel(params.sessionId)); + + const output = Writable.toWeb(process.stdout) as WritableStream; + const input = (Readable.toWeb(process.stdin) as ReadableStream).pipeThrough( + limitAcpLineBytes(), + ); + const connection = app.connect(ndJsonStream(output, input)); + const close = () => connection.close(); + options.signal?.addEventListener("abort", close, { once: true }); + try { + await connection.closed; + } finally { + options.signal?.removeEventListener("abort", close); + await adapter.close(); + } +} diff --git a/packages/eve/src/cli/acp/command.ts b/packages/eve/src/cli/acp/command.ts new file mode 100644 index 000000000..e2085b80f --- /dev/null +++ b/packages/eve/src/cli/acp/command.ts @@ -0,0 +1,118 @@ +import { Command } from "#compiled/commander/index.js"; +import { loadDevelopmentEnvironmentFiles } from "#cli/dev/environment.js"; +import { + parseDevelopmentHeaderOption, + resolveDevelopmentUrlTarget, + type DevelopmentRequestHeaders, +} from "#cli/dev/url-target.js"; +import { parseDevelopmentServerUrl } from "#cli/dev/url.js"; +import { FORCED_EXIT_BACKSTOP_MS, installShutdownSignal } from "#cli/shutdown.js"; +import type { DevelopmentServer, DevelopmentServerOptions } from "#internal/nitro/host/types.js"; + +export type RunAcpServer = (input: { + readonly appRoot: string; + readonly eveVersion: string; + readonly headers?: DevelopmentRequestHeaders; + readonly serverUrl: string; + readonly signal?: AbortSignal; + readonly validateWorkspaceRoot: boolean; +}) => Promise; + +export interface RegisterAcpCommandOptions { + readonly appRoot: string; + readonly eveVersion: string; + readonly program: Command; + readonly runAcpServer?: RunAcpServer; + readonly startHost?: (appRoot: string, options?: DevelopmentServerOptions) => DevelopmentServer; +} + +/** Registers the ACP stdio bridge for local and deployed eve agents. */ +export function registerAcpCommand(options: RegisterAcpCommandOptions): void { + options.program + .command("acp") + .description("Serve an eve agent through stable ACP v1 over stdio.") + .argument("[url]", "Connect to an existing server URL", parseDevelopmentServerUrl) + .option("-u, --url ", "Connect to an existing server URL", parseDevelopmentServerUrl) + .option( + "-H, --header
", + 'Request header for a URL target, in "Name: value" form (repeatable)', + parseDevelopmentHeaderOption, + ) + .addHelpText( + "after", + "\nWithout a URL, eve supervises a local development server. You can also pass a bare URL, for example: eve acp https://example.com\n", + ) + .action(async (positionalUrl: string | undefined, commandOptions: AcpCliOptions) => { + const target = resolveDevelopmentUrlTarget(commandOptions, positionalUrl); + loadDevelopmentEnvironmentFiles(options.appRoot); + const lifecycle = installShutdownSignal({ exitAfterMs: FORCED_EXIT_BACKSTOP_MS }); + + if (target !== undefined) { + try { + await runAcp(options, { + appRoot: options.appRoot, + headers: target.headers, + serverUrl: target.serverUrl, + signal: lifecycle.signal, + validateWorkspaceRoot: false, + }); + } finally { + lifecycle.dispose(); + } + return; + } + + let server: DevelopmentServer | undefined; + let closePromise: Promise | undefined; + const closeServer = () => { + if (server === undefined) return Promise.resolve(); + closePromise ??= server.close(); + void closePromise.catch(() => undefined); + return closePromise; + }; + lifecycle.signal.addEventListener("abort", () => void closeServer(), { once: true }); + + try { + const startHost = options.startHost ?? (await loadStartHost()); + server = startHost(options.appRoot, { + existing: "reject", + host: "127.0.0.1", + output: "stderr", + port: 0, + }); + const handle = await Promise.race([ + server.start(), + lifecycle.stopped.then(() => undefined), + ]); + if (handle === undefined) return; + + await runAcp(options, { + appRoot: handle.appRoot, + serverUrl: handle.url, + signal: lifecycle.signal, + validateWorkspaceRoot: true, + }); + lifecycle.requestStop(); + } finally { + await closeServer(); + lifecycle.dispose(); + } + }); +} + +interface AcpCliOptions { + readonly header?: DevelopmentRequestHeaders; + readonly url?: string; +} + +async function runAcp( + options: RegisterAcpCommandOptions, + input: Omit[0], "eveVersion">, +): Promise { + const run = options.runAcpServer ?? (await import("#acp/server.js")).runAcpServer; + await run({ eveVersion: options.eveVersion, ...input }); +} + +async function loadStartHost(): Promise> { + return (await import("#cli/dev/local-server-process.js")).createDevelopmentServer; +} diff --git a/packages/eve/src/cli/dev/command-options.ts b/packages/eve/src/cli/dev/command-options.ts new file mode 100644 index 000000000..7ef7df914 --- /dev/null +++ b/packages/eve/src/cli/dev/command-options.ts @@ -0,0 +1,28 @@ +import type { DevelopmentRequestHeaders } from "#cli/dev/url-target.js"; +import type { + AssistantResponseStatsMode, + LogDisplayMode, + TerminalPartDisplayMode, +} from "#cli/dev/tui/types.js"; + +export interface DevelopmentCliOptions { + assistantResponseStats?: AssistantResponseStatsMode; + connectionAuth?: TerminalPartDisplayMode; + contextSize?: number; + header?: DevelopmentRequestHeaders; + host?: string; + input?: string; + logs?: LogDisplayMode; + name?: string; + port?: number; + reasoning?: TerminalPartDisplayMode; + subagents?: TerminalPartDisplayMode; + tools?: TerminalPartDisplayMode; + ui?: boolean; + url?: string; +} + +export interface ProductionCliOptions { + host?: string; + port?: number; +} diff --git a/packages/eve/src/cli/dev/local-server-process.test.ts b/packages/eve/src/cli/dev/local-server-process.test.ts index 4fdc77d37..5538667f9 100644 --- a/packages/eve/src/cli/dev/local-server-process.test.ts +++ b/packages/eve/src/cli/dev/local-server-process.test.ts @@ -54,6 +54,31 @@ describe("createDevelopmentServer", () => { expect(child.unref).toHaveBeenCalledOnce(); }); + it("can route child stdout to stderr for protocol-owning parents", async () => { + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const server = createDevelopmentServer("/tmp/app", { output: "stderr" }); + const started = server.start(); + child.stdout.write("server output"); + child.emit("message", { + type: "started", + handle: { kind: "started", appRoot: "/tmp/app", url: "http://127.0.0.1:2000" }, + }); + await started; + + expect(stderr).toHaveBeenCalledWith(expect.any(Buffer)); + expect(stdout).not.toHaveBeenCalled(); + + const closing = server.close(); + child.emit("exit", 0, null); + await closing; + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + it("escalates when graceful cleanup misses the deadline", async () => { vi.useFakeTimers(); try { diff --git a/packages/eve/src/cli/dev/local-server-process.ts b/packages/eve/src/cli/dev/local-server-process.ts index 47511e872..c885bbdad 100644 --- a/packages/eve/src/cli/dev/local-server-process.ts +++ b/packages/eve/src/cli/dev/local-server-process.ts @@ -104,7 +104,9 @@ export function createDevelopmentServer( }, ); child = spawned; - spawned.stdout?.on("data", (chunk: Buffer) => process.stdout.write(chunk)); + spawned.stdout?.on("data", (chunk: Buffer) => + (options.output === "stderr" ? process.stderr : process.stdout).write(chunk), + ); spawned.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk)); return new Promise((resolve, reject) => { diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index 19c9c2261..c149d36e2 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -486,6 +486,68 @@ describe("eve dev --logs", () => { }); }); +describe("eve acp", () => { + it("starts an isolated local server and hands it to the ACP stdio adapter", async () => { + const close = vi.fn(async () => {}); + const startHost = vi.fn(() => ({ + start: async () => ({ + kind: "started" as const, + appRoot: "/canonical/app", + url: "http://127.0.0.1:4321/", + }), + close, + })); + const runAcpServer = vi.fn(async () => {}); + const output: string[] = []; + + await runCli( + ["acp"], + { error: (message) => output.push(message), log: (message) => output.push(message) }, + { runAcpServer, startHost }, + ); + + expect(startHost).toHaveBeenCalledWith(expect.any(String), { + existing: "reject", + host: "127.0.0.1", + output: "stderr", + port: 0, + }); + expect(runAcpServer).toHaveBeenCalledWith({ + appRoot: "/canonical/app", + eveVersion: expect.any(String), + serverUrl: "http://127.0.0.1:4321/", + signal: expect.any(AbortSignal), + validateWorkspaceRoot: true, + }); + expect(close).toHaveBeenCalledOnce(); + expect(output).toEqual([]); + }); + + it("connects ACP to a remote agent without starting a local server", async () => { + const startHost = vi.fn(); + const runAcpServer = vi.fn(async () => {}); + + await runCli( + ["acp", "https://user:pass@example.com", "-H", "X-Tenant: acme"], + { error: () => {}, log: () => {} }, + { runAcpServer, startHost }, + ); + + expect(startHost).not.toHaveBeenCalled(); + expect(runAcpServer).toHaveBeenCalledWith({ + appRoot: process.cwd(), + eveVersion: expect.any(String), + headers: { + Authorization: `Basic ${btoa("user:pass")}`, + "X-Tenant": "acme", + }, + serverUrl: "https://example.com/", + signal: expect.any(AbortSignal), + validateWorkspaceRoot: false, + }); + }); +}); + describe("eve dev boot progress", () => { it("passes one reporter through local startup and clears the row on failure", async () => { const writes: string[] = []; @@ -551,6 +613,7 @@ describe("eve dev local server ownership", () => { existing: "attach-if-unconfigured", host: undefined, onBootProgress: expect.any(Function), + output: undefined, port: undefined, }); expect(runDevelopmentTui).toHaveBeenCalledWith( diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 6985dddae..12fe59c87 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -9,6 +9,7 @@ import { registerIntegrationCommands } from "#cli/commands/register-integration- import { registerProjectCommands } from "#cli/commands/register-project-commands.js"; import { registerRegistryCommands } from "#cli/commands/register-registry-commands.js"; import { resolveDevUiMode, resolveTuiDisplayOptions } from "#cli/dev/ui-options.js"; +import { registerAcpCommand, type RunAcpServer } from "#cli/acp/command.js"; import { FORCED_EXIT_BACKSTOP_MS, installShutdownSignal, @@ -16,11 +17,8 @@ import { waitForShutdownSignal, } from "#cli/shutdown.js"; import { waitForServerOrStop, waitForUiOrServer } from "#cli/dev/wait-for-ui.js"; -import { - parseDevelopmentHeaderOption, - resolveDevelopmentUrlTarget, - type DevelopmentRequestHeaders, -} from "#cli/dev/url-target.js"; +import { parseDevelopmentHeaderOption, resolveDevelopmentUrlTarget } from "#cli/dev/url-target.js"; +import type { DevelopmentCliOptions, ProductionCliOptions } from "#cli/dev/command-options.js"; import type { RunDevelopmentTuiInput } from "#cli/dev/tui/tui.js"; import { registerRuntimeInvokeCommand, @@ -50,28 +48,6 @@ interface CliLogger { log(message: string): void; } -interface DevelopmentCliOptions { - assistantResponseStats?: AssistantResponseStatsMode; - connectionAuth?: TerminalPartDisplayMode; - contextSize?: number; - header?: DevelopmentRequestHeaders; - host?: string; - input?: string; - logs?: LogDisplayMode; - name?: string; - port?: number; - reasoning?: TerminalPartDisplayMode; - subagents?: TerminalPartDisplayMode; - tools?: TerminalPartDisplayMode; - ui?: boolean; - url?: string; -} - -interface ProductionCliOptions { - host?: string; - port?: number; -} - interface CliRuntimeDependencies { isCodingAgentLaunch(): Promise; isActiveDevelopmentServerForApp(input: { @@ -79,6 +55,7 @@ interface CliRuntimeDependencies { readonly serverUrl: string; }): Promise; buildHost: BuildHost; + runAcpServer: RunAcpServer; printApplicationInfo( logger: CliLogger, appRoot: string, @@ -370,6 +347,14 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm registerRuntimeInvokeCommand({ appRoot, logger, program, runtime }); + registerAcpCommand({ + appRoot, + eveVersion: packageVersion, + program, + runAcpServer: runtime.runAcpServer, + startHost: runtime.startHost, + }); + program .command("dev") .description("Start the eve development server or connect to an existing URL.") diff --git a/packages/eve/src/internal/nitro/host/types.ts b/packages/eve/src/internal/nitro/host/types.ts index bb7a1bc3c..2370844be 100644 --- a/packages/eve/src/internal/nitro/host/types.ts +++ b/packages/eve/src/internal/nitro/host/types.ts @@ -63,6 +63,8 @@ export interface DevelopmentServerOptions { readonly existing?: "attach-if-unconfigured" | "reject"; readonly host?: string; readonly onBootProgress?: DevBootProgressReporter; + /** Parent stream that receives a supervised development child's stdout. */ + readonly output?: "stderr" | "stdout"; readonly port?: number; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dac567a9..c00bfd348 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1046,6 +1046,9 @@ importers: specifier: 8.9.0 version: 8.9.0 devDependencies: + '@agentclientprotocol/sdk': + specifier: 1.3.0 + version: 1.3.0(zod@4.4.3) '@ai-sdk/anthropic': specifier: 'catalog:' version: 4.0.18(zod@4.4.3) @@ -1249,6 +1252,11 @@ packages: '@vercel/sandbox': optional: true + '@agentclientprotocol/sdk@1.3.0': + resolution: {integrity: sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@agentphone/chat-sdk-adapter@0.1.0': resolution: {integrity: sha512-cal0EcWdbROsBT2cFd4TPxDM6SggdxifOkGCI/YFrmWapXSUgdA3wXTsZ3C8Z9Pcs1Zlg8ShNf2JSOqkvfHIWw==} engines: {node: '>=18'} @@ -14611,6 +14619,10 @@ snapshots: optionalDependencies: '@vercel/sandbox': 2.8.0 + '@agentclientprotocol/sdk@1.3.0(zod@4.4.3)': + dependencies: + zod: 4.4.3 + '@agentphone/chat-sdk-adapter@0.1.0(ai@7.0.38(zod@4.4.3))(chat@4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3)': dependencies: '@chat-adapter/shared': 4.34.0(ai@7.0.38(zod@4.4.3))(supports-color@10.2.2)(zod@4.4.3) diff --git a/research/acp-adapter-requirements.md b/research/acp-adapter-requirements.md new file mode 100644 index 000000000..04a2a2bee --- /dev/null +++ b/research/acp-adapter-requirements.md @@ -0,0 +1,351 @@ +--- +issue: https://github.com/vercel/eve/issues/100 +status: proposed +last_updated: "2026-07-28" +--- + +# ACP adapter requirements + +## Purpose + +Allow an Agent Client Protocol (ACP) client to launch an authored eve application or bridge to a deployed eve agent: + +```sh +eve acp +eve acp https://agent.example.com +``` + +The adapter translates stable ACP v1 over stdio to eve's public `Client` and `ClientSession` API. Without a URL, it supervises the normal local development server; with a URL, it connects to that deployment's existing eve HTTP API. ACP is an additional client surface rather than a second agent runtime. + +The first release is intended for editors, desktop agent hosts, and local protocol harnesses. It is platform-neutral: it must not contain Buzz, Nostr, editor, or provider-specific behavior. + +In this document, **must**, **should**, and **may** are normative requirements. + +## Outcomes + +The first release must let a conforming ACP v1 client: + +- initialize and discover the adapter's actual capabilities; +- create multiple independent conversation sessions; +- send text prompts and render streamed messages, reasoning, and tool activity; +- answer tool permissions and supported structured questions; +- cancel active prompts without racing a subsequent prompt; +- close sessions and terminate the owned development process cleanly. + +Existing `eve dev`, `eve dev --no-ui`, and TUI behavior must remain unchanged when ACP mode is not selected. + +## Non-goals + +The first release does not provide: + +- a deployed ACP HTTP or WebSocket endpoint; +- ACP v2 support; +- ACP Streamable HTTP or WebSocket transport; +- Buzz, Nostr, or any other platform integration; +- client filesystem or terminal access; +- mounting the client's `cwd` into an eve sandbox; +- client-provided MCP servers or dynamic MCP connection injection; +- ACP session listing, loading, resumption, or durable ACP IDs across adapter restarts; +- model selection or other ACP session configuration; +- compatibility behavior for unsupported input that silently drops or flattens data. + +Remote targets and platform channels require separate requirements because their trust, identity, credential, and lifecycle boundaries differ from a local subprocess. + +## External contract + +### R1: CLI selection + +1. `eve acp` must select ACP mode explicitly. Non-TTY detection must not enable it implicitly. +2. `eve acp` without a URL must supervise an isolated loopback local development server. `eve acp ` must connect to that URL through eve's public `Client` API without starting a local server. +3. URL targets must accept the same credential-bearing URL and repeatable request-header inputs as `eve dev `. Credentials must use the client's existing redirect policy and never be forwarded to another origin. +4. Local ACP mode must use the same compilation, watch, generation-pinning, and runtime behavior as ordinary `eve dev`. +5. The process exit status must be nonzero for boot, configuration, authentication, or unrecoverable protocol failures. +6. The CLI help must describe ACP as a stdio agent bridge and state that it does not grant access to the client's workspace. + +Example client configuration: + +```json +{ + "command": "pnpm", + "args": ["exec", "eve", "acp"] +} +``` + +### R2: stdio integrity + +1. stdin and stdout must be reserved for newline-delimited JSON-RPC messages. +2. Every non-empty stdout line must be one complete valid JSON-RPC message. Protocol messages must never be split across lines. +3. Boot progress, compiler output, rebuild diagnostics, runtime logs, warnings, and uncaught-error detail must go to stderr. +4. Logging during boot failure, rebuild, tool execution, and shutdown must not corrupt stdout. +5. The adapter must apply a finite inbound line-size limit and reject oversized input without unbounded buffering. +6. A malformed JSON-RPC message must receive the appropriate parse or invalid-request error when an ID can be correlated. It must not crash unrelated sessions. +7. Unknown request methods must return JSON-RPC `method not found`; unknown notifications may be ignored as required by JSON-RPC. + +### R3: protocol version and capabilities + +1. The adapter must implement stable ACP v1 as defined by the pinned official schema used to build eve. +2. If a client requests an unsupported version, including draft v2, the adapter must negotiate according to ACP: respond with the latest version it supports rather than claiming the requested version. +3. The adapter must not advertise draft or unstable capabilities. +4. `initialize` must report the installed eve version and an eve-owned implementation name. +5. Advertised capabilities must be derived from implemented, tested behavior. Omitted capability fields must not be interpreted internally as enabled. +6. Client authentication methods, filesystem, terminal, and client-provided MCP capabilities must not be advertised. +7. Unknown extension fields must be ignored where ACP and JSON-RPC require forward compatibility; they must never implicitly enable behavior. + +A compatibility test must cover a client that requests version 2 and either continues using the negotiated v1 response or disconnects cleanly. + +## Session requirements + +### R4: ACP session identity + +1. `session/new` must allocate an opaque adapter-owned ACP session ID and one independent `ClientSession`. +2. An ACP session ID must not be an eve runtime session ID. The eve ID does not exist until the first accepted prompt and may change after terminal reset behavior. +3. Session mappings may be process-local for the first release and must be discarded when the stdio connection ends. +4. Requests naming an unknown or closed ACP session must fail before dispatching model work. +5. The adapter must permit different ACP sessions to execute concurrently. +6. Prompts within one ACP session must be single-flight. An overlapping `session/prompt` for the same session must receive a deterministic busy error; it must not be queued invisibly or start a second eve session. + +### R5: workspace declaration + +1. In local mode, `session/new.cwd` must be absolute and resolve, after symlink normalization, to the eve application root selected at process startup. +2. A mismatched local `cwd` must fail session creation. In URL mode, `cwd` is untrusted client metadata: it must not select an application, change the server working directory, or grant filesystem access. +3. An empty `mcpServers` collection must be accepted. A missing collection must follow the pinned ACP schema's validation behavior. +4. A non-empty `mcpServers` collection must be rejected with an actionable unsupported-capability error before allocating runtime work or spawning any supplied command. +5. Additional workspace roots, if supplied through a stable ACP extension, must be rejected unless empty. + +### R6: session closure + +1. The adapter should advertise stable `session/close` support because `ClientSession.reset()` provides the corresponding terminal cleanup primitive. +2. Closing an idle session must reset its eve session when one exists, remove the ACP mapping, and prevent later reuse of the ACP ID. +3. Closing a session with an active prompt must cooperatively cancel it, wait for settlement, reset it, and then remove the mapping. +4. Repeated close requests must have deterministic already-closed or unknown-session behavior and must not affect a newer session. +5. Adapter shutdown must apply equivalent cleanup to all owned sessions without treating normal process termination as an agent failure. + +## Prompt requirements + +### R7: accepted content + +1. The first release must accept one or more ACP text content blocks and preserve their order. +2. Empty prompt content must fail before calling `ClientSession.send()`. +3. Image, audio, embedded resource, file, and unknown content blocks must fail explicitly. They must not be omitted, stringified, or replaced with placeholders. +4. Text must be passed as user content without adding editor, Buzz, ACP, or framework policy to the user's message. +5. Support for additional content may be added only with explicit size limits, conversion semantics, capability advertisement, and focused tests. + +### R8: prompt lifecycle + +1. `session/prompt` must call `ClientSession.send()` and consume the resulting event stream through the current logical prompt boundary. +2. The request must remain open while the eve turn is active or parked on a supported human-input request. +3. An ordinary `session.waiting` or `session.completed` boundary must return ACP `end_turn`. +4. A confirmed eve cancellation boundary must return ACP `cancelled`. +5. Token or request limits may return their matching ACP stop reasons only when the eve event stream identifies that reason unambiguously; otherwise they must remain structured failures. +6. `turn.failed`, `session.failed`, stream corruption, authentication failure, and unsupported HITL must produce JSON-RPC errors with stable eve-owned codes and structured details. They must not be reported as `end_turn`. +7. The adapter must finish consuming or deliberately cancel the current stream before accepting another prompt for the same session. +8. Stream reconnect behavior must use the public client's cursor semantics and must not duplicate ACP updates after reconnect. + +## Event projection + +### R9: ordering and identity + +1. ACP updates must preserve the observable order of eve events. +2. Adapter-generated message and tool IDs must be stable within the ACP session and deterministic across stream reconnection for the same eve event. +3. The adapter must not expose eve-only continuation tokens, credentials, internal workflow handles, or raw adapter state in ACP metadata. +4. Unknown eve events may be omitted only when they have no ACP-visible semantic effect. Failures and terminal boundaries must never be omitted. + +### R10: minimum projection + +| eve event | Required ACP v1 projection | +| ------------------------------------ | ----------------------------------------------------------------------------------------- | +| `message.appended` | `agent_message_chunk`, preserving text delta order | +| `reasoning.appended` | `agent_thought_chunk` when reasoning is client-visible | +| `actions.requested` | `tool_call` with stable call ID, title, kind, pending status, and representable raw input | +| observable tool start | `tool_call_update` with `in_progress` | +| `action.result` | `tool_call_update` with completed or failed status and representable output | +| confirmation `input.requested` | `session/request_permission` | +| supported question `input.requested` | `elicitation/create` form request | +| known cumulative usage | stable ACP `usage_update` | +| `turn.cancelled` | cancellation settlement for the pending prompt | +| `turn.failed` / `session.failed` | structured failure for the pending prompt | + +1. A `message.completed` event with `finishReason: "tool-calls"` must not be presented as the final conversational answer. +2. Tool output that is not valid ACP content must have a bounded textual representation and may include safe raw JSON where the schema permits it. +3. Secret-bearing authorization details must not be copied into tool output or `_meta` fields. +4. Usage must be emitted only when both the meaning and units of required counters are known. The adapter must not invent missing token counts. +5. Subagent activity may remain represented as the parent runtime tool call. Nested eve-only child streams are not required in the first release. + +## Human-input requirements + +### R11: permissions + +1. An eve input request with `display: "confirmation"` and approve/deny options must map to `session/request_permission` associated with the corresponding tool call. +2. ACP option IDs must be adapter-owned and correlated to eve request and option IDs; client-provided IDs must not be trusted as eve IDs. +3. The adapter must offer `allow_once` and `reject_once` only. It must not offer persistent allow/deny choices because eve has no corresponding policy mutation. +4. A selected option must become the matching eve `InputResponse`; a cancelled permission must not be interpreted as approval. + +### R12: elicitation + +Stable ACP v1 form elicitation may represent a constrained subset of eve questions: + +1. A text request with freeform input must map to one required string field. +2. A single-select request with fixed options and no freeform alternative must map to one required string enum field. +3. Returned values must map back to the original eve request and option IDs without matching on display labels alone. +4. Mixed freeform-plus-options, unsupported schemas, sensitive input, and ambiguous request shapes must fail with an actionable unsupported-HITL error. +5. The adapter must use elicitation only when the client advertises compatible form support. Otherwise the prompt must fail rather than wait forever or disguise a question as permission. +6. URL elicitation is not required for eve questions in the first release. + +### R13: input batches + +1. If one eve event requests multiple inputs, the adapter must preserve the batch boundary. +2. It may present compatible requests sequentially or concurrently, but it must gather the complete response set and submit it to eve in one `inputResponses` delivery. +3. Cancellation while a batch is open must cancel every outstanding ACP permission or elicitation request and must not submit a partial approval set. +4. Replayed stream events must not open duplicate client prompts for requests that already have pending or completed correlations. + +## Cancellation requirements + +### R14: turn cancellation + +1. `session/cancel` must target only the active prompt for the named ACP session. +2. The adapter must call `ClientSession.cancel()` with the observed eve turn ID when available. +3. A cancellation received with no active prompt must be a successful no-op. +4. The pending `session/prompt` request must remain open until eve emits the cancellation settlement boundary. +5. The adapter must not accept a replacement prompt for that session until settlement, preventing a stale cancellation from reaching a newer turn. +6. Partial messages and completed side effects remain observable and must not be represented as rolled back. +7. Pending permission and elicitation requests must receive their ACP cancellation outcomes before the prompt returns `cancelled`. + +### R15: JSON-RPC request cancellation + +1. The adapter must handle stable `$/cancel_request` for adapter-owned outstanding requests. +2. Cancelling an active `session/prompt` request must cooperatively cancel the matching eve turn rather than merely abandoning its stream. +3. After settlement, request cancellation may return the standard JSON-RPC Request Cancelled error instead of the feature-specific `cancelled` stop reason. +4. Cancelling one request must not terminate the ACP session or affect unrelated sessions. +5. Unknown or already-settled request IDs must be harmless no-ops. + +## Security requirements + +### R16: trust boundaries + +1. The local subprocess boundary is the ACP client authentication boundary for the first release. The adapter must not advertise ACP authentication methods. +2. Environment variables supplied by an ACP host, including platform identity variables, must not change protocol semantics or establish an eve human principal. +3. Prompt text and `_meta` values must never be treated as authenticated actor identity. +4. `cwd`, filesystem methods, terminal methods, and MCP descriptors must not expand eve sandbox or host access. +5. The adapter must use only connections, tools, credentials, and sandbox policy authored in the eve application. +6. Diagnostic errors returned over ACP must be useful without exposing secrets, authorization headers, environment values, or internal capability tokens. +7. No ACP client request may cause the adapter to execute an arbitrary client-supplied command. + +## Process requirements + +### R17: ownership and isolation + +1. In local mode, the ACP parent process should supervise a headless local development-server child on an ephemeral loopback port. +2. The ACP parent alone must own protocol stdout. Child stdout and stderr must be redirected to the parent's stderr through bounded handling. +3. The child must never recursively start ACP mode. +4. Local mode must terminate only the server process it owns. It must not stop an independently running development server discovered on another port. URL mode must not start or stop a server. +5. Closing stdin, SIGINT, SIGTERM, or parent shutdown must cancel active adapter work and reap an owned local child process without leaving descendants. +6. Local rebuilds must retain ordinary eve generation semantics: an in-flight turn remains pinned, and the next turn uses the newest successful build. +7. A failed rebuild must be diagnostic only when the previous valid generation remains available; it must not corrupt an active ACP connection. + +### R18: dependency boundary + +1. ACP SDK types and runtime behavior must be wrapped behind eve-owned modules and must not become part of eve's public TypeScript API. +2. The official Apache-2.0 ACP implementation or generated schema should be the source of protocol truth; eve must not maintain ad hoc handwritten wire types. +3. Adding a new runtime dependency to `eve` should be avoided. If the SDK runtime is required, vendor the pinned stable v1 artifact through the existing compiled-dependency process with attribution. +4. Protocol schema upgrades must be deliberate and covered by compatibility tests; an upstream draft release must not silently alter the shipped wire contract. + +## Quality requirements + +### R19: test tiers + +The implementation must include: + +- unit tests for version negotiation, content conversion, event projection, ID correlation, HITL mapping, and error mapping using in-memory streams; +- integration tests for concurrent `ClientSession` handles, stream reconnection, cancellation settlement, and input-batch continuation; +- scenario tests for CLI conflicts, stdout purity, child boot failure, rebuild behavior, EOF, signals, and child reaping; +- a conformance fixture using the official ACP client SDK; +- a smoke test with at least one independent real ACP client. + +Tests must not require external network services, platform credentials, or model-provider credentials unless they are placed in the existing CI-only end-to-end tier. + +### R20: documentation and release + +1. Public documentation must show client configuration, supported capabilities, and shutdown behavior. +2. Documentation must clearly distinguish ACP chat/tool observability from editor workspace access. +3. Unsupported MCP, filesystem, terminal, content, session-resume, and remote-target behavior must be listed explicitly. +4. Because the published `eve` package changes, implementation must include a patch changeset. + +## Manual validation + +Use Zed as the primary user-facing validation client. Zed co-governs ACP, launches custom ACP agents directly, and exercises initialization, sessions, streamed updates, tools, permissions, elicitation, and cancellation through a real UI. + +### Configure Zed + +1. Build eve and ensure `pnpm exec eve` resolves from the application being tested. +2. Open the exact eve application root as the Zed workspace. The workspace must match the application root because `session/new.cwd` is intentionally validated. +3. Open **Agent Settings → External Agents → Add Agent → Add Custom Agent**. +4. Add this entry to Zed settings: + +```json +{ + "agent_servers": { + "eve-local": { + "type": "custom", + "command": "pnpm", + "args": ["exec", "eve", "acp"], + "env": {} + } + } +} +``` + +If the GUI process cannot find `pnpm`, replace `command` with its absolute path. Disable project-level Zed MCP servers for the initial validation: Zed may forward them in `session/new`, while the first adapter intentionally rejects non-empty `mcpServers`. + +### Run the Zed validation sequence + +1. **Basic prompt:** Ask the agent to return a known sentence. Confirm text streams progressively and the turn ends normally. +2. **Tool lifecycle:** Use a deterministic authored tool. Confirm Zed renders one stable tool call moving through pending, running when observable, and completed or failed states. +3. **Approval:** Trigger a tool requiring confirmation. Test approve and deny in separate turns and confirm each response resumes the same logical eve prompt. +4. **Elicitation:** Trigger one fixed-choice `ask_question` and one freeform text question. Confirm Zed renders the appropriate controls and the original turn resumes with the submitted answer. +5. **Cancellation:** Start a deliberately slow turn, cancel it from Zed, and immediately submit another prompt after cancellation settles. Confirm the first returns cancelled and no stale cancellation reaches the second. +6. **Concurrent sessions:** Open two Zed agent threads and prompt both concurrently. Confirm independent history, tool IDs, and completion. +7. **Rebuild:** Change the authored agent while Zed remains connected. Confirm in-flight work stays on its pinned generation and the next turn uses the newest successful build. +8. **Close and shutdown:** Close a thread, then close Zed or terminate the ACP process. Confirm session cleanup and that the supervised development server leaves no child processes. +9. **Negative boundaries:** Separately verify that a mismatched workspace root, non-empty MCP configuration, and unsupported content fail clearly before model work while protocol stdout remains valid. + +Capture Zed's ACP logs for any failure and compare them with the adapter's stderr. Neither log should contain credentials or continuation tokens. + +### Use acpx for repeatable iteration + +Before or alongside Zed, use `acpx` as a fast headless ACP client: + +```sh +npx acpx@latest \ + --agent 'pnpm exec eve acp' \ + exec 'Reply with exactly: ACP works' +``` + +Run the command from the eve application root. In an eve source checkout, for example, first run `cd apps/fixtures/weather-agent`; the monorepo root does not provide an `eve` executable. `acpx` launches `pnpm exec eve acp` itself, so do not start a separate ACP process first. Use named sessions and cancellation to exercise concurrency without the editor UI: + +```sh +npx acpx@latest --agent 'pnpm exec eve acp' -s first 'Start a slow task' +npx acpx@latest --agent 'pnpm exec eve acp' -s second 'Reply immediately' +npx acpx@latest --agent 'pnpm exec eve acp' -s first cancel +``` + +`acpx` is an additional smoke client, not the protocol oracle. Automated conformance remains pinned to the official ACP SDK and schema. The recommended validation order is: + +1. official SDK conformance tests; +2. `acpx` scripted smoke tests; +3. the full Zed sequence above; +4. Buzz BYOH compatibility testing in its separate integration scope. + +## Acceptance criteria + +The adapter is ready when all of the following are true: + +- An ACP v1 client can launch `eve acp`, initialize, create two sessions, and run them concurrently. The same client can launch `eve acp ` and receive equivalent ACP behavior from an authenticated deployed eve agent. +- Prompts within one session remain single-flight. +- Text, reasoning, tool calls, tool results, permissions, supported elicitation, usage, failures, and cancellation preserve order and stable IDs. +- Cancellation cannot affect a later turn and returns only after eve settles the observed turn. +- Non-empty MCP configuration, mismatched `cwd`, and unsupported content fail before model work. +- Draft v2 negotiation never causes eve to advertise v2. +- Every stdout line remains valid JSON-RPC during boot, rebuild, failure, and shutdown. +- EOF and process signals clean up all adapter-owned sessions and processes. +- No client request grants host filesystem, terminal, arbitrary process, or unauthenticated principal access. +- Existing development modes remain behaviorally unchanged.