Compare commits

...

5 Commits

Author SHA1 Message Date
Agus Zubiaga
02fac67f52 Do not send tool_call_update for TodoWrite (#10)
When we receive a `TodoWrite` tool use, we send a plan update instead of
a tool call. However, we were still trying to send a tool call update
when we received the `TodoWrite` tool result, which caused an ACP error.

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-02 11:53:54 -03:00
Agus Zubiaga
1ef3ba327d Unpin SDK now that entrypoint is fixed (#9)
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-02 11:38:45 -03:00
Agus Zubiaga
617233d693 0.1.7 2025-09-02 11:25:56 -03:00
Bennet Bo Fenner
0dc5a4b366 Add support for slash commands (#7)
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-09-02 10:29:45 +02:00
Agus Zubiaga
6d1711cfa4 Use ACP terminal support when available (#8)
When the `terminal` client capability is available, we'll now replace
Claude Code's bash tools with our own that go over ACP. This includes
support for background terminals.

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-01 12:01:50 -03:00
8 changed files with 580 additions and 53 deletions

20
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@zed-industries/claude-code-acp",
"version": "0.1.5",
"version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zed-industries/claude-code-acp",
"version": "0.1.5",
"version": "0.1.7",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.96",
"@anthropic-ai/claude-code": "^1.0.100",
"@modelcontextprotocol/sdk": "^1.17.4",
"@types/express": "^5.0.3",
"@zed-industries/agent-client-protocol": "0.1.2",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
@@ -32,9 +32,9 @@
}
},
"node_modules/@anthropic-ai/claude-code": {
"version": "1.0.96",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.96.tgz",
"integrity": "sha512-xnxhYzuh6PYlMcw56REMQiGMW20WaLLOvG8L8TObq70zhNKs3dro7nhYwHRe1c2ubTr20oIJK0aSkyD2BpO8nA==",
"version": "1.0.100",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.100.tgz",
"integrity": "sha512-b4FRo3t46kPawzE8pT7nxtDc9NdYPTFaPoGGns/ZNqsqRu2jKMiSjjt5DiFvfaBZA8Tnt2qXbv4nd1vqR9Ru8Q==",
"license": "SEE LICENSE IN README.md",
"bin": {
"claude": "cli.js"
@@ -1353,9 +1353,9 @@
}
},
"node_modules/@zed-industries/agent-client-protocol": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.1.2.tgz",
"integrity": "sha512-A1RCN7Uf09vngbhBVLiZENenKYOYD2OGiILLGXPaSBloDbem8xmLaDmrKQuYl0kjLJ1DcWmVaHUv97n1l5vJ6w==",
"version": "0.2.0-alpha.4",
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.2.0-alpha.4.tgz",
"integrity": "sha512-l/MdK0MrvEb55/U3wAtAN/MC04YK+N6vNyakNgLsK2JTcmG54n45gXa0OZ08lYUmEvLe0shdu1dDtzlOV/Luxw==",
"license": "Apache-2.0",
"dependencies": {
"zod": "^3.0.0"

View File

@@ -6,7 +6,7 @@
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.1.6",
"version": "0.1.7",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
@@ -34,10 +34,10 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.96",
"@anthropic-ai/claude-code": "^1.0.100",
"@modelcontextprotocol/sdk": "^1.17.4",
"@types/express": "^5.0.3",
"@zed-industries/agent-client-protocol": "0.1.2",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"

View File

@@ -2,12 +2,11 @@ import {
Agent,
AgentSideConnection,
AuthenticateRequest,
AvailableCommand,
CancelNotification,
Client,
ClientCapabilities,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
@@ -15,8 +14,8 @@ import {
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
ToolCallContent,
ToolKind,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
@@ -26,10 +25,8 @@ import {
Query,
query,
SDKAssistantMessage,
SDKMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-code";
import * as readline from "node:readline";
import { v7 as uuidv7 } from "uuid";
import {
nodeToWebReadable,
@@ -37,7 +34,6 @@ import {
Pushable,
unreachable,
} from "./utils.js";
import { ContentBlock } from "@zed-industries/agent-client-protocol";
import { SessionNotification } from "@zed-industries/agent-client-protocol";
import { createMcpServer } from "./mcp-server.js";
import { AddressInfo } from "node:net";
@@ -53,29 +49,45 @@ type Session = {
cancelled: boolean;
};
type BackgroundTerminal =
| {
handle: TerminalHandle;
status: "started";
lastOutput: TerminalOutputResponse | null;
}
| {
status: "aborted" | "exited" | "killed" | "timedOut";
pendingOutput: TerminalOutputResponse;
};
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
sessions: {
[key: string]: Session;
};
client: Client;
client: AgentSideConnection;
toolUseCache: { [key: string]: any };
fileContentCache: { [key: string]: any };
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities;
constructor(client: Client) {
constructor(client: AgentSideConnection) {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.fileContentCache = {};
}
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
return {
protocolVersion: 1,
// todo!()
agentCapabilities: {
promptCapabilities: { image: true, embeddedContext: true },
promptCapabilities: {
image: true,
embeddedContext: true,
},
},
authMethods: [
{
@@ -126,16 +138,30 @@ export class ClaudeAcpAgent implements Agent {
let options: Options = {
cwd: params.cwd,
mcpServers,
disallowedTools: [],
permissionPromptToolName: "mcp__acp__permission",
stderr: (err) => console.error(err),
};
const allowedTools = [];
const disallowedTools = [];
if (this.clientCapabilities?.fs?.readTextFile) {
options.allowedTools = ["mcp__acp__read"];
options.disallowedTools!.push("Read");
allowedTools.push("mcp__acp__read");
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
options.disallowedTools!.push("Write", "Edit", "MultiEdit");
allowedTools.push("mcp__acp__write");
disallowedTools.push("Write", "Edit", "MultiEdit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push("mcp__acp__BashOutput", "mcp__acp__KillBash");
disallowedTools.push("Bash", "BashOutput", "KillBash");
}
if (allowedTools.length > 0) {
options.allowedTools = allowedTools;
}
if (disallowedTools.length > 0) {
options.disallowedTools = disallowedTools;
}
let q = query({
@@ -148,12 +174,14 @@ export class ClaudeAcpAgent implements Agent {
cancelled: false,
};
const availableCommands = await availableSlashCommands(q);
return {
sessionId,
availableCommands,
};
}
async authenticate(params: AuthenticateRequest): Promise<void> {
async authenticate(_params: AuthenticateRequest): Promise<void> {
throw new Error("Method not implemented.");
}
@@ -256,6 +284,67 @@ export class ClaudeAcpAgent implements Agent {
}
}
async function availableSlashCommands(
query: Query,
): Promise<AvailableCommand[]> {
const UNSUPPORTED_COMMANDS = [
"agents", // Modal
"bashes", // Modal
"bug", // Modal
"clear", // Escape Codes
"compact", // Not supported via SDK?
"config", // Modal
"context", // Escape Codes
"cost", // Escape Codes
"doctor", // Escape Codes
"exit",
"export", // Modal
"help", // Modal
"hooks", // Modal
"ide", // Modal
"install-github-app", // Modal
"login",
"logout",
"mcp",
"migrate-installer", // Modal
"model", // Not supported via SDK?
"output-style", // Modal
"output-style:new", // Modal
"permissions", // Modal
"release-notes", // Escape Codes
"resume",
"status", // Not supported via SDK?
"statusline", // Not needed
"terminal-setup", // Not needed
"todos", // Escape Codes
"vim", // Not needed
];
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
const commands = await (query as any).supportedCommands();
return commands
.map(
(command: {
name: string;
description: string;
argumentHint: string;
}) => {
const input = command.argumentHint
? { hint: command.argumentHint }
: null;
return {
name: command.name,
description: command.description || "",
input,
};
},
)
.filter(
(command: AvailableCommand) =>
!UNSUPPORTED_COMMANDS.includes(command.name),
);
}
function formatUriAsLink(uri: string): string {
try {
if (uri.startsWith("file://")) {
@@ -351,14 +440,16 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage,
sessionId: string,
toolUseCache: { [key: string]: any },
toolUseCache: {
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
},
fileContentCache: { [key: string]: string },
): SessionNotification[] {
let chunks = message.message.content as ContentChunk[];
let output = [];
// Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of chunks) {
let update: SessionNotification["update"];
let update: SessionNotification["update"] | null = null;
switch (chunk.type) {
case "text":
update = {
@@ -408,19 +499,32 @@ export function toAcpNotifications(
}
break;
case "tool_result":
update = {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: chunk.is_error ? "failed" : "completed",
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
};
case "tool_result": {
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
console.error(
`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
);
break;
}
if (toolUse.name !== "TodoWrite") {
update = {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: chunk.is_error ? "failed" : "completed",
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
};
}
break;
}
default:
throw new Error("unhandled chunk type: " + chunk.type);
}
output.push({ sessionId, update });
if (update) {
output.push({ sessionId, update });
}
}
return output;

View File

@@ -5,7 +5,11 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
import { z } from "zod";
import { Server } from "node:http";
import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities } from "@zed-industries/agent-client-protocol";
import {
ClientCapabilities,
TerminalOutputResponse,
} from "@zed-industries/agent-client-protocol";
import { tool } from "@anthropic-ai/claude-code";
export const SYSTEM_REMINDER = `
@@ -329,6 +333,324 @@ File editing instructions:
);
}
if (agent.clientCapabilities?.terminal) {
server.registerTool(
"Bash",
{
title: "Bash",
description: "Executes a bash command",
inputSchema: {
command: z
.string()
.describe("The bash command to execute as a one-liner"),
timeout_ms: z
.number()
.default(2 * 60 * 1000)
.describe("Optional timeout in milliseconds"),
run_in_background: z
.boolean()
.default(false)
.describe(
"When set to true, the command is started in the background. The tool returns an `id` that can be used with the `mcp__acp__BashOutput` tool to retrieve the current output, or the `mcp__acp__KillBash` tool to stop it early.",
),
},
},
async (input, extra) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const toolCallId = extra._meta?.["claudecode/toolUseId"];
if (typeof toolCallId !== "string") {
throw new Error("No tool call ID found");
}
if (
!agent.clientCapabilities?.terminal ||
!agent.client.createTerminal
) {
throw new Error("unreachable");
}
const handle = await agent.client.createTerminal({
command: input.command,
sessionId,
outputByteLimit: 32_000,
});
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
content: [{ type: "terminal", terminalId: handle.id }],
},
});
const abortPromise = new Promise((resolve) => {
if (extra.signal.aborted) {
resolve(null);
} else {
extra.signal.addEventListener("abort", () => {
resolve(null);
});
}
});
const statusPromise = Promise.race([
handle.waitForExit().then(() => ({ status: "exited" as const })),
abortPromise.then(() => ({ status: "aborted" as const })),
sleep(input.timeout_ms).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
return { status: "timedOut" as const };
}),
]);
if (input.run_in_background) {
agent.backgroundTerminals[handle.id] = {
handle,
lastOutput: null,
status: "started",
};
statusPromise.then(async ({ status }) => {
const bgTerm = agent.backgroundTerminals[handle.id];
if (bgTerm.status !== "started") {
return;
}
const currentOutput = await handle.currentOutput();
agent.backgroundTerminals[handle.id] = {
status,
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
currentOutput.output,
),
},
};
return handle.release();
});
return {
content: [
{
type: "text",
text: `Command started in background with id: ${handle.id}`,
},
],
};
}
await using terminal = handle;
const { status } = await statusPromise;
if (status === "aborted") {
return {
content: [{ type: "text", text: "Tool cancelled by user" }],
};
}
const output = await terminal.currentOutput();
return {
content: [{ type: "text", text: toolCommandOutput(status, output) }],
};
},
);
server.registerTool(
"BashOutput",
{
title: "BashOutput",
description:
"Returns the current output and exit status of a background bash command by its id. Includes only new output since last invocation.",
inputSchema: {
id: z
.string()
.describe(
"The id of the background bash command as returned by `mcp__acp__Bash`",
),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.id}`);
}
if (bgTerm.status === "started") {
const newOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
newOutput.output,
);
bgTerm.lastOutput = newOutput;
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, {
...newOutput,
output: strippedOutput,
}),
},
],
};
} else {
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
},
],
};
}
},
);
server.registerTool(
"KillBash",
{
title: "KillBash",
description: "Stops a background command by its id",
inputSchema: {
id: z
.string()
.describe(
"The id of the background bash command as returned by `mcp__acp__Bash`",
),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.id}`);
}
switch (bgTerm.status) {
case "started":
await bgTerm.handle.kill();
const currentOutput = await bgTerm.handle.currentOutput();
agent.backgroundTerminals[bgTerm.handle.id] = {
status: "killed",
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
currentOutput.output,
),
},
};
await bgTerm.handle.release();
return {
content: [{ type: "text", text: "Command killed successfully." }],
};
case "aborted":
return {
content: [{ type: "text", text: "Command aborted by user." }],
};
case "exited":
return {
content: [{ type: "text", text: "Command had already exited." }],
};
case "killed":
return {
content: [{ type: "text", text: "Command was already killed." }],
};
case "timedOut":
return {
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
const unreachable: never = bgTerm;
return unreachable;
}
}
},
);
function stripCommonPrefix(a: string, b: string): string {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) i++;
return b.slice(i);
}
function sleep(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}
function toolCommandOutput(
status: "started" | "aborted" | "exited" | "killed" | "timedOut",
output: TerminalOutputResponse,
): string {
const { exitStatus, output: commandOutput, truncated } = output;
let toolOutput = "";
switch (status) {
case "started": {
if (exitStatus?.exitCode == null) {
toolOutput += `Interrupted. `;
}
break;
}
case "killed":
toolOutput += `Killed. `;
break;
case "timedOut":
toolOutput += `Timed out. `;
break;
case "exited":
case "aborted":
break;
default: {
const unreachable: never = status;
return unreachable;
}
}
if (exitStatus?.exitCode != null && exitStatus.exitCode !== 0) {
toolOutput += `Failed with exit code ${exitStatus.exitCode}.`;
}
if (exitStatus?.signal != null) {
toolOutput += `Signal \`${exitStatus.signal}\`. `;
}
toolOutput += "Output:\n\n";
toolOutput += commandOutput;
if (truncated) {
toolOutput += `\n\nCommand output was too long, so it was truncated to ${commandOutput.length} bytes.`;
}
return toolOutput;
}
}
let alwaysAllowedTools: { [key: string]: boolean } = {};
server.registerTool(
"permission",

View File

@@ -0,0 +1,5 @@
---
description: 10 * 3 = 30
---
What is 10 \* 3. Only respond with the number.

View File

@@ -0,0 +1,6 @@
---
description: Say hello
argument-hint: [name]
---
Respond with "Hello $1" and nothing else.

View File

@@ -4,6 +4,7 @@ import {
Agent,
Client,
ClientSideConnection,
NewSessionResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
@@ -50,37 +51,67 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
class TestClient implements Client {
agent: Agent;
files: Map<string, string> = new Map();
receivedText: string = "";
constructor(agent: Agent) {
this.agent = agent;
}
requestPermission(
takeReceivedText() {
const text = this.receivedText;
this.receivedText = "";
return text;
}
async requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
throw new Error("Method not implemented.");
const optionId = params.options.find(
(p) => p.kind === "allow_once",
)!.optionId;
return { outcome: { outcome: "selected", optionId } };
}
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEVIED", JSON.stringify(params, null, 4));
console.error("RECEIVED", JSON.stringify(params, null, 4));
if (
params.update.sessionUpdate === "agent_message_chunk" &&
params.update.content.type === "text"
) {
this.receivedText += params.update.content.text;
}
}
writeTextFile(
async writeTextFile(
params: WriteTextFileRequest,
): Promise<WriteTextFileResponse> {
throw new Error("Method not implemented.");
this.files.set(params.path, params.content);
return null;
}
async readTextFile(
params: ReadTextFileRequest,
): Promise<ReadTextFileResponse> {
const content = this.files.get(params.path) ?? "";
return {
content:
"One\nTwo\nThree\nFour\nFive\nSix\nSeven\nEight\nNine\nTen\nEleven\nTwelve\nThirteen\nFourteen\nFifteen\nSixteen\nSeventeen\nEighteen\nNineteen\nTwenty\n",
content,
};
}
}
it("should connect to the ACP subprocess", async () => {
async function setupTestSession(cwd: string): Promise<{
client: TestClient;
connection: ClientSideConnection;
newSessionResponse: NewSessionResponse;
}> {
let client;
const connection = new ClientSideConnection(
(agent) => {
return new TestClient(agent);
client = new TestClient(agent);
return client;
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
@@ -96,18 +127,70 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
},
});
let session = await connection.newSession({ cwd: "./", mcpServers: [] });
const newSessionResponse = await connection.newSession({
cwd,
mcpServers: [],
});
return { client: client!, connection, newSessionResponse };
}
it("should connect to the ACP subprocess", async () => {
const { client, connection, newSessionResponse } =
await setupTestSession("./");
await connection.prompt({
prompt: [
{
type: "text",
text: "Use the multi edit tool to remove all multiples of 5 from the README and replace with buzz",
text: "Hello",
},
],
sessionId: session.sessionId,
sessionId: newSessionResponse.sessionId,
});
console.log("DONE AWAITING");
}, 20000);
expect(client.takeReceivedText()).not.toEqual("");
}, 30000);
it("should include available commands", async () => {
const { client, connection, newSessionResponse } =
await setupTestSession(__dirname);
expect(newSessionResponse.availableCommands).toContainEqual({
name: "quick-math",
description: "10 * 3 = 30 (project)",
input: null,
});
expect(newSessionResponse.availableCommands).toContainEqual({
name: "say-hello",
description: "Say hello (project)",
input: { hint: "[name]" },
});
await connection.prompt({
prompt: [
{
type: "text",
text: "/quick-math",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("30");
await connection.prompt({
prompt: [
{
type: "text",
text: "/say-hello GPT-5",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("Hello GPT-5");
}, 30000);
},
);

View File

@@ -74,6 +74,7 @@ export function toolInfoFromToolUse(
};
case "Bash":
case "mcp__acp__Bash":
return {
title: input?.command
? "`" + input.command.replaceAll("`", "\\`") + "`"
@@ -91,6 +92,7 @@ export function toolInfoFromToolUse(
};
case "BashOutput":
case "mcp__acp__BashOutput":
return {
title: "Tail Logs",
kind: "execute",
@@ -98,6 +100,7 @@ export function toolInfoFromToolUse(
};
case "KillBash":
case "mcp__acp__KillBash":
return {
title: "Kill Process",
kind: "execute",
@@ -527,6 +530,10 @@ export function toolUpdateFromToolResult(
}
return {};
case "mcp__acp__Bash":
// The bash tool already embeds a terminal
return {};
case "Task":
case "NotebookEdit":
case "NotebookRead":