Compare commits

...

7 Commits

Author SHA1 Message Date
Ben Brandt
c2ab2da5ca Remove duplicated text content after streaming (#106) 2025-10-17 10:12:58 +00:00
Ben Brandt
373d940eb8 Update to claude-agent-sdk 0.1.21 and support streaming partial messages (#105) 2025-10-17 11:58:36 +02:00
Ben Brandt
2c16a41fa4 prep 0.6.3 (#102) 2025-10-16 12:35:47 +00:00
Ben Brandt
656cbe5921 Fix issue where slash commands were loaded before initialization was complete (#101) 2025-10-16 14:34:17 +02:00
Ben Brandt
2f811f8a85 Update to claude-agent-sdk 0.1.19 (#100) 2025-10-16 12:22:31 +00:00
Ben Brandt
6aa6537f67 Fix initialization error (#99)
Closes #98

It turns out we can't always call this right away, as on some machines
the process hasn't initialized yet.
Since available models waits for the process to start, as a workaround
we wait for that first, and then set the permission mode again once we
know that it is ready.
2025-10-16 12:17:32 +00:00
Ben Brandt
1b72ac7c8a Fix for allowing bypassPermissions to work (#97)
It seems that unless you start the session with bypassPermissions, you
can't change to it later.

So we start with it on, and immediately change it back to default. This
allows people to change it later however.

Closes https://github.com/zed-industries/zed/issues/39392
2025-10-15 13:23:26 +00:00
7 changed files with 216 additions and 67 deletions

View File

@@ -1,5 +1,27 @@
# Changelog
## 0.6.5
- Fix for duplicated text content after streaming
## 0.6.4
- Support streaming partial messages!
- Update to @anthropic-ai/claude-agent-sdk@v0.1.21
## 0.6.3
- Fix issue where slash commands were loaded before initialization was complete.
## 0.6.2
- Fix bug where mode selection would sometimes fire before initialization was complete.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.19
## 0.6.1
- Fix to allow bypassPermissions mode to be selected (it wasn't permitted previously)
## 0.6.0
- Provide a model selector. We use the "default" model by default, and the user can change it via the client.

44
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@zed-industries/claude-code-acp",
"version": "0.6.0",
"version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zed-industries/claude-code-acp",
"version": "0.6.0",
"version": "0.6.5",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.4.8",
"@anthropic-ai/claude-agent-sdk": "0.1.15",
"@anthropic-ai/claude-code": "2.0.15",
"@modelcontextprotocol/sdk": "1.20.0",
"@anthropic-ai/claude-agent-sdk": "0.1.21",
"@anthropic-ai/claude-code": "2.0.21",
"@modelcontextprotocol/sdk": "1.20.1",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
@@ -22,10 +22,10 @@
"claude-code-acp": "dist/index.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.65.0",
"@anthropic-ai/sdk": "^0.67.0",
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.7.2",
"@types/node": "24.8.1",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
@@ -49,9 +49,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.15.tgz",
"integrity": "sha512-x0UR/YW87lRel3wYVimWjAkUOEGapg/nXx2GYNLbUD1ORsetRHeXZGFdJMuhRkk1Jt9sbn5m/RpT42b5R0pgYg==",
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.21.tgz",
"integrity": "sha512-8qUD//GfS/SXHx2nnVOg4uwS2aqrULp/KKi6HvPqQpkTAOH0o393uWHNIi69WCshy9RLGO7NZWodlxTT3v+RdQ==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
@@ -69,9 +69,9 @@
}
},
"node_modules/@anthropic-ai/claude-code": {
"version": "2.0.15",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.0.15.tgz",
"integrity": "sha512-ZQFvnzBX7bQg5FaX9fvRDjS3JjUv0SLHDPXbKHIsrV3HBOMsNti9VtqGUI1MuPnV5hUHrthPe95mMSBWwdKh1w==",
"version": "2.0.21",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.0.21.tgz",
"integrity": "sha512-02NcxPVjioLjo4/djzIt4aAYkYqnZsHgdzgMexLURYLR9q1+B+OLkKddKwOHL8fas3WnUGV1oyybu73BfHqQ4A==",
"license": "SEE LICENSE IN README.md",
"bin": {
"claude": "cli.js"
@@ -89,9 +89,9 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.65.0.tgz",
"integrity": "sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw==",
"version": "0.67.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.67.0.tgz",
"integrity": "sha512-Buxbf6jYJ+pPtfCgXe1pcFtZmdXPrbdqhBjiscFt9irS1G0hCsmR/fPA+DwKTk4GPjqeNnnCYNecXH6uVZ4G/A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1066,9 +1066,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.0.tgz",
"integrity": "sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q==",
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",
@@ -1568,9 +1568,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.7.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
"version": "24.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -6,7 +6,7 @@
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.6.0",
"version": "0.6.5",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
@@ -52,19 +52,19 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.4.8",
"@anthropic-ai/claude-agent-sdk": "0.1.15",
"@anthropic-ai/claude-code": "2.0.15",
"@modelcontextprotocol/sdk": "1.20.0",
"@anthropic-ai/claude-agent-sdk": "0.1.21",
"@anthropic-ai/claude-code": "2.0.21",
"@modelcontextprotocol/sdk": "1.20.1",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
"uuid": "13.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.65.0",
"@anthropic-ai/sdk": "^0.67.0",
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.7.2",
"@types/node": "24.8.1",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",

View File

@@ -31,9 +31,8 @@ import {
PermissionMode,
Query,
query,
SDKAssistantMessage,
SDKPartialAssistantMessage,
SDKUserMessage,
SDKUserMessageReplay,
} from "@anthropic-ai/claude-agent-sdk";
import * as fs from "node:fs";
import * as path from "node:path";
@@ -54,6 +53,8 @@ import {
toolUpdateFromToolResult,
ClaudePlanEntry,
} from "./tools.js";
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
type Session = {
query: Query;
@@ -74,7 +75,12 @@ type BackgroundTerminal =
};
type ToolUseCache = {
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
[key: string]: {
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
id: string;
name: string;
input: any;
};
};
const DEFAULT_MODEL_ID = "default";
@@ -190,9 +196,13 @@ export class ClaudeAcpAgent implements Agent {
const options: Options = {
cwd: params.cwd,
includePartialMessages: true,
mcpServers,
systemPrompt,
settingSources: ["user", "project", "local"],
// If we want bypassPermissions to be an option, we have to start the session with it on.
// We change it immediately back to default below before returning the session.
permissionMode: "bypassPermissions",
permissionPromptToolName: PERMISSION_TOOL_NAME,
stderr: (err) => console.error(err),
// note: although not documented by the types, passing an absolute path
@@ -228,30 +238,34 @@ export class ClaudeAcpAgent implements Agent {
prompt: input,
options,
});
const models = await getAvailableModels(q);
const permissionMode = "default";
// Change it back to default
await q.setPermissionMode(permissionMode);
this.sessions[sessionId] = {
query: q,
input: input,
cancelled: false,
permissionMode: "default",
permissionMode,
};
getAvailableSlashCommands(q).then((availableCommands) => {
this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands,
},
});
setTimeout(() => {
this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands,
},
});
}, 0);
});
const models = await getAvailableModels(q);
return {
sessionId,
models,
modes: {
currentModeId: "default",
currentModeId: permissionMode,
availableModes: [
{
id: "default",
@@ -332,6 +346,17 @@ export class ClaudeAcpAgent implements Agent {
return { stopReason: "refusal" };
}
}
case "stream_event": {
for (const notification of streamEventToAcpNotifications(
message,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
)) {
await this.client.sessionUpdate(notification);
}
break;
}
case "user":
case "assistant": {
if (this.sessions[params.sessionId].cancelled) {
@@ -371,8 +396,15 @@ export class ClaudeAcpAgent implements Agent {
throw RequestError.authRequired();
}
const content =
message.type === "assistant"
? // Handled by stream events above
message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
: message.message.content;
for (const notification of toAcpNotifications(
message,
content,
message.message.role,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
@@ -608,20 +640,18 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
* Only handles text, image, and thinking chunks for now.
*/
export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage | SDKUserMessageReplay,
content: string | ContentBlockParam[] | BetaContentBlock[] | BetaRawContentBlockDelta[],
role: "assistant" | "user",
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
): SessionNotification[] {
const content = message.message.content;
if (typeof content === "string") {
return [
{
sessionId,
update: {
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: content,
@@ -637,9 +667,9 @@ export function toAcpNotifications(
let update: SessionNotification["update"] | null = null;
switch (chunk.type) {
case "text":
case "text_delta":
update = {
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: chunk.text,
@@ -648,8 +678,7 @@ export function toAcpNotifications(
break;
case "image":
update = {
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "",
@@ -659,6 +688,7 @@ export function toAcpNotifications(
};
break;
case "thinking":
case "thinking_delta":
update = {
sessionUpdate: "agent_thought_chunk",
content: {
@@ -668,6 +698,8 @@ export function toAcpNotifications(
};
break;
case "tool_use":
case "server_tool_use":
case "mcp_tool_use": {
toolUseCache[chunk.id] = chunk;
if (chunk.name === "TodoWrite") {
update = {
@@ -690,8 +722,15 @@ export function toAcpNotifications(
};
}
break;
}
case "tool_result": {
case "tool_result":
case "web_fetch_tool_result":
case "web_search_tool_result":
case "code_execution_tool_result":
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result":
case "mcp_tool_result": {
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
console.error(
@@ -704,15 +743,24 @@ export function toAcpNotifications(
update = {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: chunk.is_error ? "failed" : "completed",
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
};
}
break;
}
case "document":
case "search_result":
case "redacted_thinking":
case "input_json_delta":
case "citations_delta":
case "signature_delta":
case "container_upload":
break;
default:
throw new Error("unhandled chunk type: " + chunk.type);
unreachable(chunk);
}
if (update) {
output.push({ sessionId, update });
@@ -722,6 +770,49 @@ export function toAcpNotifications(
return output;
}
export function streamEventToAcpNotifications(
message: SDKPartialAssistantMessage,
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
): SessionNotification[] {
const event = message.event;
switch (event.type) {
case "message_start":
return toAcpNotifications(
event.message.content,
event.message.role,
sessionId,
toolUseCache,
fileContentCache,
);
case "content_block_start":
return toAcpNotifications(
[event.content_block],
"assistant",
sessionId,
toolUseCache,
fileContentCache,
);
case "content_block_delta":
return toAcpNotifications(
[event.delta],
"assistant",
sessionId,
toolUseCache,
fileContentCache,
);
case "message_delta":
case "message_stop":
case "content_block_stop":
// do we need stop reason?
return [];
default:
unreachable(event);
}
}
export function runAcp() {
const input = nodeToWebWritable(process.stdout);
const output = nodeToWebReadable(process.stdin);

View File

@@ -649,7 +649,9 @@ describe("tool conversions", () => {
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
};
expect(toAcpNotifications(received, "test", {}, {})).toStrictEqual([
expect(
toAcpNotifications(received.message.content, received.message.role, "test", {}, {}),
).toStrictEqual([
{
sessionId: "test",
update: {
@@ -782,10 +784,14 @@ describe("escape markdown", () => {
});
describe("SDK behavior", () => {
it("query has a 'default' model", async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
});
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"query has a 'default' model",
async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
},
10000,
);
});

View File

@@ -1,6 +1,14 @@
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import {
BetaBashCodeExecutionToolResultBlockParam,
BetaCodeExecutionToolResultBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaWebFetchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
interface ToolInfo {
title: string;
@@ -385,7 +393,15 @@ export function toolInfoFromToolUse(
}
export function toolUpdateFromToolResult(
toolResult: ToolResultBlockParam,
toolResult:
| ToolResultBlockParam
| BetaWebSearchToolResultBlockParam
| BetaWebFetchToolResultBlockParam
| WebSearchToolResultBlockParam
| BetaCodeExecutionToolResultBlockParam
| BetaBashCodeExecutionToolResultBlockParam
| BetaTextEditorCodeExecutionToolResultBlockParam
| BetaRequestMCPToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {
@@ -425,7 +441,12 @@ export function toolUpdateFromToolResult(
case toolNames.edit:
case toolNames.write:
case "Write": {
if (toolResult.is_error && toolResult.content && toolResult.content.length > 0) {
if (
"is_error" in toolResult &&
toolResult.is_error &&
toolResult.content &&
toolResult.content.length > 0
) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
@@ -451,7 +472,10 @@ export function toolUpdateFromToolResult(
case "WebSearch":
case "Other":
default: {
return toAcpContentUpdate(toolResult.content, toolResult.is_error);
return toAcpContentUpdate(
toolResult.content,
"is_error" in toolResult ? toolResult.is_error : false,
);
}
}
}

View File

@@ -76,7 +76,13 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
}
export function unreachable(value: never): never {
throw new Error(`Unexpected case: ${value}`);
let valueAsString;
try {
valueAsString = JSON.stringify(value);
} catch {
valueAsString = value;
}
throw new Error(`Unexpected case: ${valueAsString}`);
}
export function sleep(time: number): Promise<void> {