Files
claude-agent-acp/src/index.ts
Ben Brandt 8e893f51ea refactor: add an opt-in experimental ACP v2 handshake (#1217)
First step toward optional support for the [draft ACP v2
protocol](https://agentclientprotocol.com/protocol/v2/draft/overview).
ACP v1 stays the default and is unchanged.

## What it does

Set `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1` and the adapter routes each
connection by the protocol version the client requests in `initialize`
(via the SDK's `AgentProtocolRouter`):

- A client that requests version 2 or later gets the draft ACP v2
`initialize`, `auth/login`, and `auth/logout`, plus the
`_auth/status_update` push.
- Every other client gets ACP v1, served by the same handlers as today.

Without the variable, the adapter never builds the router and serves ACP
v1 exactly as before.

The v2 response does not advertise `capabilities.session` yet. A v2
agent that advertises it must serve the whole session baseline
(`session/new`, `list`, `resume`, `close`, `prompt`, `cancel`,
`update`), which follow-up PRs add. So for now a v2 client can
initialize and authenticate, but cannot open a session.

## How

`ClaudeAcpAgent` keeps speaking ACP v1 types. The v2 surface in
`src/v2/` is a thin translation layer around it, so v1 and v2 share one
implementation:

- `v2AgentApp` registers the v2 methods and translates each request to
the v1 request the agent already serves, and each response back. For
`initialize` that means `info`/`capabilities` instead of
`agentInfo`/`agentCapabilities`, and auth methods with `methodId` and a
required `type`, with terminal `env` as `{ name, value }` pairs. A v2
client gets no client `fs` or terminals, and boolean config options are
baseline.
- `V2ClientConnection` is the `AcpClient` of a v2 connection. It sends
the agent's v1-shaped messages in their v2 form. Only connection-level
messages can reach it so far; session-scoped methods reject until v2
sessions land.
- `v1AgentApp` is the existing v1 handler registration, pulled out of
`runAcp` so the router can reuse it. Each surface now creates the agent
of its connection in `onConnect`, because under the router the
connection opens only after `initialize` has been read. `runAcp` (a
public export) behaves as before.

Also bumps `@agentclientprotocol/sdk` to 1.6.0 (schema v2.0.0-alpha.7).
The bump changes nothing for v1: the full suite passes unchanged on it.

## Testing

- `src/tests/acp-v2.test.ts` drives the real router over in-memory
streams with the SDK's own v1 and v2 client apps (the v2 client
validates every response against the v2 schema). It covers the v1
handshake through the router, the v2 handshake, method-not-found for v2
session methods, the `_auth/status_update` push, and gateway
`auth/login` followed by `auth/logout`.
- Ran the built binary over stdio: v2 client with the flag, v1 client
with the flag, v2 client without the flag (gets the unchanged v1
response), and stdin EOF before `initialize` (exits 0).
- `npm run build`, `npm run check`, `npm run test:run`.
2026-10-01 16:46:57 +02:00

109 lines
3.8 KiB
JavaScript

#!/usr/bin/env node
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";
// `--cli` is checked first so that `--version`/`-v` (and any other flags) are
// forwarded to the wrapped native CLI rather than swallowed by our own version
// handler below. Our version flag only applies when not delegating.
if (process.argv.includes("--cli")) {
const { spawn } = await import("node:child_process");
const args = process.argv.slice(2).filter((arg) => arg !== "--cli");
const child = spawn(await claudeCliPath(), args, { stdio: "inherit" });
const signals =
process.platform === "win32"
? (["SIGINT", "SIGTERM"] as const)
: (["SIGINT", "SIGTERM", "SIGHUP"] as const);
for (const sig of signals) {
process.on(sig, () => {
if (!child.killed) child.kill(sig);
});
}
child.on("exit", (code, signal) => {
if (signal && process.platform !== "win32") {
// Remove our listener so re-raising actually terminates instead of
// re-entering the no-op handler, which would let us exit with code 0
// instead of the signal's conventional 128+N.
process.removeAllListeners(signal);
process.kill(process.pid, signal);
} else {
process.exit(code ?? 1);
}
});
child.on("error", (err) => {
console.error(err);
process.exit(1);
});
} else if (process.argv.includes("--version") || process.argv.includes("-v")) {
console.log(packageJson.version);
process.exit(0);
} else {
// Apply env vars from the managed-policy tier before any SDK call so the
// SDK subprocess inherits them. Reading the tier is best-effort: a transient
// failure leaves the agent running with no policy env instead of aborting
// module evaluation here, before any ACP traffic.
await applyManagedPolicyEnv();
// stdout is used to send messages to the client
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
console.log = console.error;
console.info = console.error;
console.warn = console.error;
console.debug = console.error;
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
const logDirectory = process.env.CLAUDE_AGENT_LOGS;
const logger = logDirectory
? (() => {
mkdirSync(logDirectory, { recursive: true });
const logFile = join(logDirectory, "agent.log");
const writeLog = (...args: unknown[]) => {
const rendered = args
.map((arg) => (arg instanceof Error ? (arg.stack ?? arg.message) : String(arg)))
.join(" ");
appendFileSync(logFile, `${new Date().toISOString()} pid=${process.pid} ${rendered}\n`);
};
return {
log: writeLog,
error: (...args: unknown[]) => {
console.error(...args);
writeLog(...args);
},
};
})()
: undefined;
logger?.log("Claude ACP started");
// 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 acp.dispose().catch((err) => {
console.error("Error during cleanup:", err);
});
process.exit(0);
}
// 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.
acp.closed.then(shutdown);
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
// Keep process alive while connection is open
process.stdin.resume();
}