Compare commits

...

6 Commits

Author SHA1 Message Date
Ben Brandt
b49154bef8 Don't allow bypassPermissions to be enabled if in root/sudo mode, as Claude Code won't start (#108)
Closes #104

Turns out this was the root cause, not the order of messages after all.
2025-10-17 10:58:38 +00:00
Ben Brandt
5fa453a33a Reorganize order of loading config from the query. (#107) 2025-10-17 12:36:58 +02:00
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
7 changed files with 227 additions and 79 deletions

View File

@@ -1,5 +1,22 @@
# Changelog
## 0.6.6
- Do not enable bypassPermissions mode if in root/sudo mode, because Claude Code will not start
## 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.

44
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@zed-industries/claude-code-acp",
"version": "0.6.2",
"version": "0.6.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zed-industries/claude-code-acp",
"version": "0.6.2",
"version": "0.6.6",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.4.8",
"@anthropic-ai/claude-agent-sdk": "0.1.19",
"@anthropic-ai/claude-code": "2.0.19",
"@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.66.0",
"@anthropic-ai/sdk": "^0.67.0",
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.8.0",
"@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.19",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.19.tgz",
"integrity": "sha512-k4GtWygAkp3d0Npo/cS16MAfnSEcjttT5ijZzEWIzpWClL5wOYCpVyuMjnZGsYPDDZCb26pVb7Wm0ZDFj149Cg==",
"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.19",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.0.19.tgz",
"integrity": "sha512-9InIRKliS3TaPbWfuzJA0SoC3hLQ0Lk2lmBVFBChgd8f6aTVV5dQwHNgDnLjtl1L+HhRfMqjSgBfNaeUmzet/A==",
"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.66.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.66.0.tgz",
"integrity": "sha512-GBSSby0P4BW/sOdvsTXaHJDPnGEL5tuB4TtsU4SXG7+dVULQ9MkKgNznCALDCgSV5yhrtQlctvEdMqePVIXTiw==",
"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.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.0.tgz",
"integrity": "sha512-5x08bUtU8hfboMTrJ7mEO4CpepS9yBwAqcL52y86SWNmbPX8LVbNs3EP4cNrIZgdjk2NAlP2ahNihozpoZIxSg==",
"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.2",
"version": "0.6.6",
"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.19",
"@anthropic-ai/claude-code": "2.0.19",
"@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.66.0",
"@anthropic-ai/sdk": "^0.67.0",
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.8.0",
"@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,11 +75,19 @@ 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";
// Bypass Permissions doesn't work if we are a root/sudo user
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
sessions: {
@@ -188,14 +197,17 @@ export class ClaudeAcpAgent implements Agent {
}
}
const permissionMode = "default";
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",
permissionMode: IS_ROOT ? permissionMode : "bypassPermissions",
permissionPromptToolName: PERMISSION_TOOL_NAME,
stderr: (err) => console.error(err),
// note: although not documented by the types, passing an absolute path
@@ -231,10 +243,7 @@ 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,
@@ -242,7 +251,15 @@ export class ClaudeAcpAgent implements Agent {
permissionMode,
};
getAvailableSlashCommands(q).then((availableCommands) => {
const availableCommands = await getAvailableSlashCommands(q);
const models = await getAvailableModels(q);
// Change it back to default
if (!IS_ROOT) {
await q.setPermissionMode(permissionMode);
}
// Needs to happen after we return the session
setTimeout(() => {
this.client.sessionUpdate({
sessionId,
update: {
@@ -250,35 +267,40 @@ export class ClaudeAcpAgent implements Agent {
availableCommands,
},
});
});
}, 0);
const availableModes = [
{
id: "default",
name: "Always Ask",
description: "Prompts for permission on first use of each tool",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Automatically accepts file edit permissions for the session",
},
{
id: "plan",
name: "Plan Mode",
description: "Claude can analyze but not modify files or execute commands",
},
];
// Only works in non-root mode
if (!IS_ROOT) {
availableModes.push({
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Skips all permission prompts",
});
}
return {
sessionId,
models,
modes: {
currentModeId: permissionMode,
availableModes: [
{
id: "default",
name: "Always Ask",
description: "Prompts for permission on first use of each tool",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Automatically accepts file edit permissions for the session",
},
{
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Skips all permission prompts",
},
{
id: "plan",
name: "Plan Mode",
description: "Claude can analyze but not modify files or execute commands",
},
],
availableModes,
},
};
}
@@ -337,6 +359,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) {
@@ -376,8 +409,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,
@@ -613,20 +653,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,
@@ -642,9 +680,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,
@@ -653,8 +691,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 : "",
@@ -664,6 +701,7 @@ export function toAcpNotifications(
};
break;
case "thinking":
case "thinking_delta":
update = {
sessionUpdate: "agent_thought_chunk",
content: {
@@ -673,6 +711,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 = {
@@ -695,8 +735,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(
@@ -709,15 +756,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 });
@@ -727,6 +783,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: {

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> {