diff --git a/package-lock.json b/package-lock.json index ac7c910c..202a5b1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.85.0", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "1.5.1", + "@agentclientprotocol/sdk": "1.6.0", "@anthropic-ai/claude-agent-sdk": "0.3.286", "diff": "9.0.0", "zod": "4.6.5" @@ -39,9 +39,9 @@ } }, "node_modules/@agentclientprotocol/sdk": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.5.1.tgz", - "integrity": "sha512-nSUIrC1fOR9+ppKfx9KdwjBBND2sAPzFHo4yVQrGbXZHnayUYpwL8CDVR5LSxbapY/oKMOzNP0wvwfyx3PS2KA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.6.0.tgz", + "integrity": "sha512-XxXrmX7aZkDgOB0Rg9cu+ZFyiUUc5lF2n9seO3Gc4OR+MTdfZOwIqF6m3LvsmmM8K3qgPmXkDH8/IFM2u9vdcQ==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" diff --git a/package.json b/package.json index 1e1f91a0..7b0f128c 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "node": ">=22" }, "dependencies": { - "@agentclientprotocol/sdk": "1.5.1", + "@agentclientprotocol/sdk": "1.6.0", "@anthropic-ai/claude-agent-sdk": "0.3.286", "diff": "9.0.0", "zod": "4.6.5" diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 73d07532..63633803 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -1,5 +1,6 @@ import { agent as acpAgent, + AgentApp, AgentContext, AuthenticateRequest, AuthMethod, @@ -10874,21 +10875,25 @@ export async function runPromptWithCancellation( } } -export function runAcp(logger?: Logger) { - const input = nodeToWebWritable(process.stdout); - const output = nodeToWebReadable(process.stdin); - - const stream = ndJsonStream(input, output); - - // `connect(...)` returns a connection-scoped peer handle (`connection.client`) - // that stays valid for the whole connection, so the agent captures it once. - // Handlers close over `agent`, which is assigned synchronously right after - // `connect()` returns — before the connection processes any inbound message. - // It cannot be `const`: its value depends on `connection.client`, which does - // not exist until `connect()` has been called. - // eslint-disable-next-line prefer-const - let agent: ClaudeAcpAgent; - const connection = acpAgent({ name: "claude-code-acp" }) +/** + * The ACP v1 surface of the adapter, for one connection. + * + * The agent of the connection is created when the connection opens, from the + * connection-scoped peer handle (`connection.client`), which stays valid for + * the whole connection. Connect handlers run before the connection processes + * any inbound message, so every handler below sees the agent. `onAgent` + * receives it for the owner of the process (shutdown). + */ +export function v1AgentApp( + logger: Logger | undefined, + onAgent: (agent: ClaudeAcpAgent) => void, +): AgentApp { + let agent!: ClaudeAcpAgent; + return acpAgent({ name: "claude-code-acp" }) + .onConnect((connection) => { + agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger); + onAgent(agent); + }) .onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params)) .onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params)) .onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params)) @@ -10922,10 +10927,15 @@ export function runAcp(logger?: Logger) { GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params), - ) - .connect(stream); + ); +} - agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger); +/** Serves ACP v1 on stdio. */ +export function runAcp(logger?: Logger) { + const stream = ndJsonStream(nodeToWebWritable(process.stdout), nodeToWebReadable(process.stdin)); + let agent!: ClaudeAcpAgent; + // `connect` runs the connect handlers before it returns, so `agent` is set. + const connection = v1AgentApp(logger, (created) => (agent = created)).connect(stream); return { connection, agent }; } diff --git a/src/index.ts b/src/index.ts index 530598b4..3229cbcf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node -import { claudeCliPath, runAcp } from "./acp-agent.js"; +import { claudeCliPath } from "./acp-agent.js"; import { applyManagedPolicyEnv } from "./managed-policy.js"; +import { serveAcp } from "./serve.js"; import packageJson from "../package.json" with { type: "json" }; import { appendFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; @@ -82,10 +83,13 @@ if (process.argv.includes("--cli")) { : undefined; logger?.log("Claude ACP started"); - const { connection, agent } = runAcp(logger); + // The draft ACP v2 is opt-in while it is a draft. + const acp = serveAcp(logger, { + experimentalV2: process.env.CLAUDE_AGENT_ACP_EXPERIMENTAL_V2 === "1", + }); async function shutdown() { - await agent.dispose().catch((err) => { + await acp.dispose().catch((err) => { console.error("Error during cleanup:", err); }); process.exit(0); @@ -94,7 +98,7 @@ if (process.argv.includes("--cli")) { // Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport // error). Without this, `process.stdin.resume()` keeps the event loop // alive indefinitely, causing orphan process accumulation in oneshot mode. - connection.closed.then(shutdown); + acp.closed.then(shutdown); process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); diff --git a/src/serve.ts b/src/serve.ts new file mode 100644 index 00000000..d7ba9e21 --- /dev/null +++ b/src/serve.ts @@ -0,0 +1,52 @@ +import { ndJsonStream } from "@agentclientprotocol/sdk"; +import * as v2 from "@agentclientprotocol/sdk/experimental/v2"; +import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js"; +import { nodeToWebReadable, nodeToWebWritable } from "./utils.js"; +import { v2AgentApp } from "./v2/agent.js"; + +/** + * Serves ACP on stdio. + * + * With `experimentalV2`, the connection goes through {@link acpProtocolRouter}. + * Without it, the adapter serves ACP v1 alone and never routes by protocol + * version, exactly as before. + */ +export function serveAcp( + logger: Logger | undefined, + options: { experimentalV2: boolean }, +): { closed: Promise; dispose(): Promise } { + const output = nodeToWebWritable(process.stdout); + const input = nodeToWebReadable(process.stdin); + // The router opens the connection of the negotiated version only when it + // reads `initialize`, so a client that leaves before then leaves no agent. + let agent: ClaudeAcpAgent | undefined; + const onAgent = (created: ClaudeAcpAgent) => { + agent = created; + }; + const { closed } = options.experimentalV2 + ? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input)) + : v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input)); + return { + // The router types `closed` as optional, but always sets it. + closed: closed!, + dispose: async () => { + await agent?.dispose(); + }, + }; +} + +/** + * Routes a connection to the protocol version that the client negotiates in + * `initialize`: the experimental draft ACP v2 for a client that requests + * version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of + * the connection once the router has opened it. + */ +export function acpProtocolRouter( + logger: Logger | undefined, + onAgent: (agent: ClaudeAcpAgent) => void, +): v2.AgentProtocolRouter { + return v2 + .agentProtocolRouter() + .withV1(v1AgentApp(logger, onAgent)) + .withV2(v2AgentApp(logger, onAgent)); +} diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts new file mode 100644 index 00000000..dd43243e --- /dev/null +++ b/src/tests/acp-v2.test.ts @@ -0,0 +1,208 @@ +/** + * The experimental draft ACP v2 surface, driven through the protocol router + * with the SDK's own v1 and v2 client apps. The v2 client app validates every + * response against the v2 schema, so a malformed v2 message fails the test. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as v1 from "@agentclientprotocol/sdk"; +import * as v2 from "@agentclientprotocol/sdk/experimental/v2"; +import type { ClaudeAcpAgent } from "../acp-agent.js"; +import type { AuthStatusUpdateNotification } from "../auth-status.js"; +import { acpProtocolRouter } from "../serve.js"; +import packageJson from "../../package.json" with { type: "json" }; + +const execFileSpy = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { ...actual, execFile: execFileSpy }; +}); + +const CLI_SUBSCRIPTION = JSON.stringify({ + loggedIn: true, + authMethod: "claude.ai", + apiProvider: "firstParty", + email: "user@example.com", + subscriptionType: "max", +}); + +const V2_CLIENT_INFO = { name: "v2-test-client", version: "1.0.0" }; + +const agents: ClaudeAcpAgent[] = []; + +beforeEach(() => { + // `claude auth status --json` reports a subscription, `claude auth logout` succeeds. + execFileSpy.mockImplementation((...invocation: unknown[]) => { + const args = invocation[1] as string[]; + const callback = invocation[invocation.length - 1] as (...a: unknown[]) => void; + callback(null, { stdout: args[1] === "status" ? CLI_SUBSCRIPTION : "", stderr: "" }); + }); +}); + +afterEach(async () => { + await Promise.all(agents.splice(0).map((agent) => agent.dispose())); + execFileSpy.mockReset(); +}); + +/** Connects a fresh router to a client, and returns the client end of the stream. */ +function connectRouter(): v2.Stream { + const toAgent = new TransformStream(); + const toClient = new TransformStream(); + acpProtocolRouter(undefined, (agent) => agents.push(agent)).connect({ + readable: toAgent.readable, + writable: toClient.writable, + }); + return { readable: toClient.readable, writable: toAgent.writable }; +} + +/** A v2 client app that collects every `_auth/status_update` it receives. */ +function v2Client() { + const authUpdates: AuthStatusUpdateNotification[] = []; + let notifyAuthUpdate = () => {}; + const app = v2.client({ name: V2_CLIENT_INFO.name }).onNotification( + "_auth/status_update", + (params) => params as AuthStatusUpdateNotification, + ({ params }) => { + authUpdates.push(params); + notifyAuthUpdate(); + }, + ); + /** Resolves once the client has received `count` auth status updates. */ + const authUpdate = (count: number) => + new Promise((resolve) => { + notifyAuthUpdate = () => { + if (authUpdates.length >= count) { + resolve(authUpdates[count - 1]); + } + }; + notifyAuthUpdate(); + }); + return { app, authUpdate }; +} + +describe("ACP protocol routing", () => { + it("serves the unchanged v1 handshake to a v1 client", async () => { + let authUpdated!: () => void; + const authUpdate = new Promise((resolve) => (authUpdated = resolve)); + const response = await v1 + .client({ name: "v1-test-client" }) + .onNotification( + "_auth/status_update", + (params) => params, + () => authUpdated(), + ) + .connectWith(connectRouter() as unknown as v1.Stream, async (agent) => { + const response = await agent.request(v1.methods.agent.initialize, { + protocolVersion: v1.PROTOCOL_VERSION, + clientCapabilities: { auth: { terminal: true } }, + }); + await authUpdate; + return response; + }); + + expect(response.protocolVersion).toBe(1); + expect(response.agentCapabilities?.loadSession).toBe(true); + expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({}); + expect(response.authMethods?.length).toBeGreaterThan(0); + for (const method of response.authMethods ?? []) { + expect(method).toHaveProperty("id"); + } + }); + + it("serves the v2 handshake to a v2 client", async () => { + const { app, authUpdate } = v2Client(); + const response = await app.connectWith(connectRouter(), async (agent) => { + const response = await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: V2_CLIENT_INFO, + capabilities: { auth: { terminal: {} } }, + }); + await authUpdate(1); + return response; + }); + + expect(response.protocolVersion).toBe(2); + expect(response.info).toEqual({ + name: packageJson.name, + title: "Claude Agent", + version: packageJson.version, + }); + // The v2 session baseline and the providers methods are not served yet. + expect(response.capabilities?.session).toBeUndefined(); + expect(response.capabilities?.providers).toBeUndefined(); + expect(response.authMethods?.length).toBeGreaterThan(0); + for (const method of response.authMethods ?? []) { + expect(method).not.toHaveProperty("id"); + expect(method).toMatchObject({ methodId: expect.any(String), type: "terminal" }); + expect((method as v2.AuthMethodTerminal).args?.[0]).toBe("--cli"); + } + }); + + it("rejects v2 session methods, which the v2 surface does not serve yet", async () => { + const { app, authUpdate } = v2Client(); + await app.connectWith(connectRouter(), async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: V2_CLIENT_INFO, + }); + await expect( + agent.request(v2.methods.agent.session.new, { cwd: process.cwd() }), + ).rejects.toMatchObject({ code: -32601 }); + await authUpdate(1); + }); + }); +}); + +describe("ACP v2 auth", () => { + it("pushes the auth status of the connection after initialize", async () => { + const { app, authUpdate } = v2Client(); + const update = await app.connectWith(connectRouter(), async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: V2_CLIENT_INFO, + }); + return authUpdate(1); + }); + + expect(update.authStatus).toMatchObject({ + kind: "account", + account: { email: "user@example.com" }, + }); + }); + + it("logs in to a gateway with auth/login and out with auth/logout", async () => { + const { app, authUpdate } = v2Client(); + await app.connectWith(connectRouter(), async (agent) => { + const response = await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: V2_CLIENT_INFO, + capabilities: { auth: { _meta: { gateway: true } } }, + }); + expect(response.authMethods).toContainEqual({ + methodId: "gateway", + type: "agent", + name: "Custom model gateway", + description: "Use a custom gateway to authenticate and access models", + _meta: { gateway: { protocol: "anthropic" } }, + }); + await authUpdate(1); + + await agent.request(v2.methods.agent.auth.login, { + methodId: "gateway", + _meta: { gateway: { baseUrl: "https://gateway.example.com", headers: {} } }, + }); + expect((await authUpdate(2)).authStatus).toMatchObject({ + kind: "gateway", + detail: "gateway.example.com", + }); + + await agent.request(v2.methods.agent.auth.logout, {}); + expect(execFileSpy).toHaveBeenCalledWith( + expect.any(String), + ["auth", "logout"], + expect.any(Function), + ); + expect((await authUpdate(3)).authStatus).toMatchObject({ kind: "account" }); + }); + }); +}); diff --git a/src/v2/agent.ts b/src/v2/agent.ts new file mode 100644 index 00000000..d665642a --- /dev/null +++ b/src/v2/agent.ts @@ -0,0 +1,88 @@ +/** + * The experimental draft ACP v2 surface of the adapter. + * + * `ClaudeAcpAgent` speaks ACP v1 types. This surface translates each v2 + * request into the v1 request that the agent serves, and each v1 message that + * the agent sends into its v2 form, so v1 and v2 share one implementation. + */ +import * as v2 from "@agentclientprotocol/sdk/experimental/v2"; +import { ClaudeAcpAgent, type AcpClient, type Logger } from "../acp-agent.js"; +import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js"; + +/** + * The ACP v2 surface, for one connection. + * + * As in `v1AgentApp`, the agent of the connection is created when the + * connection opens, before the connection processes any inbound message. + * `onAgent` receives it for the owner of the process (shutdown). + */ +export function v2AgentApp( + logger: Logger | undefined, + onAgent: (agent: ClaudeAcpAgent) => void, +): v2.AgentApp { + let agent!: ClaudeAcpAgent; + return v2 + .agent({ name: "claude-code-acp" }) + .onConnect((connection) => { + agent = new ClaudeAcpAgent(new V2ClientConnection(connection.client), logger); + onAgent(agent); + }) + .onRequest(v2.methods.agent.initialize, async ({ params }) => + v2InitializeResponse(await agent.initialize(v1InitializeRequest(params))), + ) + .onRequest(v2.methods.agent.auth.login, ({ params }) => agent.authenticate(params)) + .onRequest(v2.methods.agent.auth.logout, ({ params }) => agent.logout(params)); +} + +/** + * The {@link AcpClient} of an ACP v2 connection: it sends the v1 messages of + * the agent as v2 messages. + * + * The v2 surface serves no sessions yet, so only connection-level messages + * reach it, such as the `_auth/status_update` extension notification. Every + * session-scoped method rejects until the v2 surface serves sessions. + */ +class V2ClientConnection implements AcpClient { + constructor(private readonly ctx: v2.AgentContext) {} + + extNotification(method: string, params: Record): Promise { + if (!isExtensionMethod(method)) { + return Promise.reject(new Error(`${method} is not an ACP extension method`)); + } + return this.ctx.notify(method, params); + } + + sessionUpdate(): Promise { + return noV2Sessions("session/update"); + } + + requestPermission(): Promise { + return noV2Sessions("session/request_permission"); + } + + createElicitation(): Promise { + return noV2Sessions("elicitation/create"); + } + + completeElicitation(): Promise { + return noV2Sessions("elicitation/complete"); + } + + // v2 has no client file system. The agent never calls these on v2, because + // `v1InitializeRequest` reports no `fs` capability. + readTextFile(): Promise { + return Promise.reject(new Error("ACP v2 has no client file system")); + } + + writeTextFile(): Promise { + return Promise.reject(new Error("ACP v2 has no client file system")); + } +} + +function isExtensionMethod(method: string): method is v2.ExtensionMethod { + return method.startsWith("_"); +} + +function noV2Sessions(method: string): Promise { + return Promise.reject(new Error(`The ACP v2 surface does not serve sessions yet (${method})`)); +} diff --git a/src/v2/initialize.ts b/src/v2/initialize.ts new file mode 100644 index 00000000..002ac780 --- /dev/null +++ b/src/v2/initialize.ts @@ -0,0 +1,80 @@ +/** + * The ACP v2 `initialize` handshake, expressed through the v1 handshake of + * `ClaudeAcpAgent`. + * + * The agent decides its capabilities and auth methods in v1 terms. The v2 + * request becomes the v1 request that the agent reads, and the v1 response of + * the agent becomes the v2 response, so both protocol versions advertise from + * one source. + */ +import { + PROTOCOL_VERSION as V1_PROTOCOL_VERSION, + type AuthMethod, + type InitializeRequest, + type InitializeResponse, +} from "@agentclientprotocol/sdk"; +import * as v2 from "@agentclientprotocol/sdk/experimental/v2"; + +/** The v1 `initialize` request that a v2 client means. */ +export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRequest { + const capabilities = request.capabilities; + return { + protocolVersion: V1_PROTOCOL_VERSION, + clientInfo: request.info, + clientCapabilities: { + // v2 has no client file system and no client terminals: the agent + // reads, writes, and runs commands itself. + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + // Every v2 client handles boolean config options; v1 asks for a marker. + session: { configOptions: { boolean: {} } }, + // v1 marks terminal auth support with a boolean, v2 with an object. + auth: { + terminal: capabilities?.auth?.terminal != null, + ...(capabilities?.auth?._meta != null ? { _meta: capabilities.auth._meta } : {}), + }, + ...(capabilities?.elicitation != null ? { elicitation: capabilities.elicitation } : {}), + ...(capabilities?._meta != null ? { _meta: capabilities._meta } : {}), + }, + ...(request._meta != null ? { _meta: request._meta } : {}), + }; +} + +/** + * The v2 `initialize` response for the v1 response of the agent. + * + * It advertises no `session` capability yet. A v2 agent that advertises + * `session` must serve the whole session baseline (`session/new`, `list`, + * `resume`, `close`, `prompt`, `cancel`, and `update`), and the v2 surface of + * the adapter does not serve it yet. Nor does it advertise the unstable + * `providers` methods, which the v2 surface does not serve yet either. + */ +export function v2InitializeResponse(response: InitializeResponse): v2.InitializeResponse { + if (!response.agentInfo) { + throw new Error("ACP v2 requires agentInfo, and the agent reported none"); + } + const meta = response.agentCapabilities?._meta; + return { + protocolVersion: v2.PROTOCOL_VERSION, + info: response.agentInfo, + // v1 `auth.logout` has no v2 counterpart: a v2 agent that advertises auth + // methods must serve `auth/logout`. + capabilities: meta != null ? { _meta: meta } : {}, + authMethods: (response.authMethods ?? []).map(v2AuthMethod), + ...(response._meta != null ? { _meta: response._meta } : {}), + }; +} + +/** v2 names the id of a method `methodId`, requires `type`, and lists `env` as name and value pairs. */ +function v2AuthMethod(method: AuthMethod): v2.AuthMethod { + if ("type" in method && method.type === "terminal") { + const { id, env, ...terminal } = method; + return { + ...terminal, + methodId: id, + ...(env ? { env: Object.entries(env).map(([name, value]) => ({ name, value })) } : {}), + }; + } + const { id, ...agent } = method; + return { ...agent, methodId: id, type: "agent" }; +}