mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-13 12:51:35 +00:00
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`.
109 lines
3.8 KiB
JavaScript
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();
|
|
}
|