Compare commits

...

27 Commits

Author SHA1 Message Date
Ben Brandt
7e0d2c9506 Update to claude-agent-sdk 0.1.37 (#152) 2025-11-13 09:56:19 +00:00
Guillaume Sabran
2835f030cf Add metadata to tool calls, including structured tool output (#128)
Use the `_meta` field for tool calls to forward:
- the original tool name (ie `Bash` etc)
- the structured output of the tool call

The output is received by using the `PostToolUse` hook from the SDK. The
`tool_call_update` for the tool result is now only sent when both the
hook callback and the original tool result (plain text) have been
received.

Claude Code describes [in its documentation
](https://docs.claude.com/en/api/agent-sdk/typescript#tool-output-types)the
expected structure of the output for each tools. Unfortunately those are
not part of the SDK, and [empirically I've found that there was some
differences](58c3100a36/local-server/src/server/endpoints/sendMessage/acp/clients/claudeCode/types.ts (L265)).
Still I've found that having this structured output was useful for my
client to provide a richer UX. Maybe Zed will too.

I was not sure how to add tests for this change, given the current setup
2025-11-13 10:42:21 +01:00
Ben Brandt
2710e146f4 Use vendored claude cli for experimental login method (#143) 2025-11-05 11:36:02 +01:00
Richard Feldman
bf396fea67 Use terminal-auth from _meta if supported (#141) 2025-11-04 21:34:16 -05:00
Ben Brandt
84b5744a2f prep v0.10.0 (#140) 2025-11-04 11:30:01 +00:00
Ben Brandt
b213152a94 Migrate from permission MCP server to canUseTool callback (#139)
- **Migrate from permission MCP server to canUseTool callback**
- **Use SDK suggestions for permission updates**
2025-11-04 12:26:25 +01:00
Ben Brandt
cd5aabdd74 Update to claude-agent-sdk 0.1.30 (#138) 2025-11-04 10:38:20 +00:00
Bennet Bo Fenner
9e18a4e6fd Add docs for release process (#135) 2025-10-31 18:12:11 +01:00
Bennet Bo Fenner
48df285bd0 Prep v0.9.0 (#134) 2025-10-31 16:50:55 +01:00
Bennet Bo Fenner
b138c84cdb Support slash commands coming from MCP prompts (#133)
Closes #115. Thanks to @joeljohansson99 for reporting/debugging.
2025-10-31 16:32:30 +01:00
Bennet Fenner
0735aca015 Update UNSUPPORTED_COMMANDS (#132)
The SDK seems to now filter out slash commands that result in
errors/crashes.

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-30 17:17:47 +01:00
Ben Brandt
847d62dacb Revert entrypoint changes (#127) 2025-10-27 12:45:58 +00:00
Ben Brandt
becf1d4f0a Update to claude agent sdk 0.1.27 (#125)
Also simplifies the permission mode workaround so we don't have to
change the profile immediately, which could have raced with client
changes anwyay.
2025-10-27 12:34:43 +01:00
Guillaume Sabran
5491bb8dcc Allow for the package to be used as a dependency, while preserving existing behavior when run (#124)
This change will allow for this package to be used directly, without
launching a subprocess and communicating over stdio.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-27 11:10:41 +00:00
Ben Brandt
17aad5b7a1 Provide agentInfo on initialization response (#123)
- **Provide agentInfo on initialization**
- **Prep 0.6.10**
2025-10-24 17:02:07 +00:00
Steffen Deusch
dadc7f9f40 prevent invalid locations when read call has no path (#121)
The ACP spec has the path as a required field for tool call locations,
but I've seen Claude generate read calls without an input path, which
would generate:

```json
{
  "content": [],
  "kind": "read",
  "locations": [
    {
      "line": 0
    }
  ],
  "rawInput": {},
  "sessionUpdate": "tool_call",
  "status": "pending",
  "title": "Read File",
  "toolCallId": "toolu_012PzeR5whUjWN4ij2HBubJw"
}
```

And then fail to parse as SessionNotification in the SDK.
2025-10-24 12:49:43 +00:00
Ben Brandt
552f0a286d Fix job name 2025-10-24 13:05:27 +02:00
Ben Brandt
589e1b5fc9 Update claude-agent-sdk to 0.1.26 (#120) 2025-10-24 11:03:18 +00:00
Ben Brandt
0d97bb4753 Fix type holes in switch statements (#119)
We were relying on unreachable to catch stuff for us, but it wasn't
erroring because we were manually casting a few of the types to never,
leading to a bug when a hook response was encountered.

We'll revisit showing these hook responses to the user once ACP supports
multiple types of status messages.
2025-10-24 10:19:41 +00:00
Ben Brandt
e3119b81f9 Prep v0.6.8 (#114) 2025-10-21 09:19:37 +00:00
Ben Brandt
cd3c9f7b13 Update to claude-agent-sdk v0.1.23 (#113)
- **Don't emit content from message start**
- **Update to claude-agent-sdk v0.1.23**
2025-10-21 11:16:35 +02:00
Ben Brandt
8aea8fd0e1 Remove extra console.log 2025-10-17 13:35:36 +02:00
Ben Brandt
81a541c644 Fix for invalid plan input in the model (#110)
Seems to have been introduced in the latest claude agent sdk.

Closes #109
2025-10-17 11:33:46 +00:00
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
13 changed files with 1227 additions and 1027 deletions

View File

@@ -4,7 +4,7 @@ on:
tags:
- "v*.*.*"
jobs:
build:
publish-npm:
runs-on: ubuntu-latest
environment: release # Optional: for enhanced security
permissions:
@@ -22,3 +22,15 @@ jobs:
- run: npm ci
- run: npm run build
- run: npm publish
release:
runs-on: ubuntu-latest
needs: [publish-npm]
permissions:
contents: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
with:
draft: true

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules
dist
.DS_Store

View File

@@ -1,5 +1,64 @@
# Changelog
## 0.10.1
- Add additional structured metadata to tool calls
- Update to @anthropic-ai/claude-agent-sdk@v0.1.37
## 0.10.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.30
- Use `canUseTool` callback instead of launching an HTTP MCP server for permission checks.
## 0.9.0
- Support slash commands coming from MCP servers (Prompts)
## 0.8.0
- Revert changes to filename for cli entrypoint
- Provide library entrypoint via lib.ts
## 0.7.0
- Allow importing from this package as a library in addition to running it as a CLI. Allows for easier integration into existing node applications.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.27
## 0.6.10
- Provide `agentInfo` on initialization response.
- Update to @agentclientprotocol/sdk@0.5.1
- Fix crash when receiving a hook_response event
- Fix for invalid locations when read call has no path
## 0.6.9
- Update to @anthropic-ai/claude-agent-sdk@v0.1.26
- Update to @agentclientprotocol/sdk@0.5.0
## 0.6.8
- Fix for duplicate tokens appearing in thread with streaming enabled
- Update to @anthropic-ai/claude-agent-sdk@v0.1.23
- Update to @agentclientprotocol/sdk@0.4.9
## 0.6.7
- Fix for invalid plan input from the model introduced in latest agent-sdk
## 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.

24
docs/RELEASES.md Normal file
View File

@@ -0,0 +1,24 @@
## How to publish a new release
1. Create a new branch prep-vX.X.X
```sh
git checkout -b prep-vX.X.X
```
2. Bump the version in package.json
```sh
npm version vX.X.X
```
3. Add a new entry in CHANGELOG.md
4. Create a PR for `prev-vX.X.X` & merge it
5. Create tag and push it
```sh
git tag vX.X.X
git push origin vX.X.X
```
6. Paste CHANGELOG into release notes and publish the version on GitHub

1230
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,12 +3,12 @@
"publishConfig": {
"access": "public"
},
"version": "0.10.1",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.6.3",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
"files": [
"dist/",
@@ -51,30 +51,26 @@
"author": "Zed Industries",
"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",
"@agentclientprotocol/sdk": "0.5.1",
"@anthropic-ai/claude-agent-sdk": "0.1.37",
"@anthropic-ai/claude-code": "2.0.37",
"@modelcontextprotocol/sdk": "1.21.1",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
"uuid": "13.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.66.0",
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.8.0",
"@anthropic-ai/sdk": "0.68.0",
"@types/node": "24.10.1",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@vitest/ui": "3.2.4",
"eslint": "9.37.0",
"@typescript-eslint/eslint-plugin": "8.46.4",
"@typescript-eslint/parser": "8.46.4",
"@vitest/ui": "4.0.8",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"globals": "16.4.0",
"globals": "16.5.0",
"prettier": "3.6.2",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "3.2.4"
"vitest": "4.0.8"
}
}

View File

@@ -26,14 +26,14 @@ import {
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import {
CanUseTool,
McpServerConfig,
Options,
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";
@@ -41,19 +41,18 @@ import * as os from "node:os";
import { v7 as uuidv7 } from "uuid";
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
import { SessionNotification } from "@agentclientprotocol/sdk";
import {
createMcpServer,
createPermissionMcpServer,
PERMISSION_TOOL_NAME,
toolNames,
} from "./mcp-server.js";
import { AddressInfo } from "node:net";
import { createMcpServer, EDIT_TOOL_NAMES, toolNames } from "./mcp-server.js";
import {
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
ClaudePlanEntry,
registerHookCallback,
postToolUseHook,
} from "./tools.js";
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
import packageJson from "../package.json" with { type: "json" };
type Session = {
query: Query;
@@ -73,12 +72,30 @@ type BackgroundTerminal =
pendingOutput: TerminalOutputResponse;
};
/**
* Extra metadata that the agent provides for each tool_call / tool_update update, under the `_meta.claudeCode` key.
*/
export type ToolUpdateMeta = {
/* The name of the tool that was used in Claude Code. */
toolName: string;
/* The structured output provided by Claude Code. */
toolResponse?: unknown;
};
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: {
@@ -99,9 +116,31 @@ export class ClaudeAcpAgent implements Agent {
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
// Default authMethod
const authMethod: any = {
description: "Run `claude /login` in the terminal",
name: "Log in with Claude Code",
id: "claude-login",
};
// If client supports terminal-auth capability, use that instead.
if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
const cliPath = import.meta
.resolve("@anthropic-ai/claude-agent-sdk/cli.js")
.replace("file://", "");
authMethod._meta = {
"terminal-auth": {
command: "node",
args: [cliPath, "/login"],
label: "Claude Code Login",
},
};
}
return {
protocolVersion: 1,
// todo!()
agentCapabilities: {
promptCapabilities: {
image: true,
@@ -112,13 +151,12 @@ export class ClaudeAcpAgent implements Agent {
sse: true,
},
},
authMethods: [
{
description: "Run `claude /login` in the terminal",
name: "Log in with Claude Code",
id: "claude-login",
},
],
agentInfo: {
name: packageJson.name,
title: "Claude Code",
version: packageJson.version,
},
authMethods: [authMethod],
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
@@ -163,17 +201,6 @@ export class ClaudeAcpAgent implements Agent {
instance: server,
};
// Ideally replace with `canUseTool`
const permissionServer = await createPermissionMcpServer(this, sessionId);
const address = permissionServer.address() as AddressInfo;
mcpServers["acpPermission"] = {
type: "http",
url: "http://127.0.0.1:" + address.port + "/mcp",
headers: {
"x-acp-proxy-session-id": sessionId,
},
};
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
if (params._meta?.systemPrompt) {
const customPrompt = params._meta.systemPrompt;
@@ -188,15 +215,19 @@ 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",
permissionPromptToolName: PERMISSION_TOOL_NAME,
// If we want bypassPermissions to be an option, we have to allow it here.
// But it doesn't work in root mode, so we only activate it if it will work.
allowDangerouslySkipPermissions: !IS_ROOT,
permissionMode,
canUseTool: this.canUseTool(sessionId),
stderr: (err) => console.error(err),
// note: although not documented by the types, passing an absolute path
// here works to find zed's managed node version.
@@ -204,6 +235,13 @@ export class ClaudeAcpAgent implements Agent {
...(process.env.CLAUDE_CODE_EXECUTABLE && {
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
}),
hooks: {
PostToolUse: [
{
hooks: [postToolUseHook],
},
],
},
};
const allowedTools = [];
@@ -231,10 +269,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,45 +277,52 @@ export class ClaudeAcpAgent implements Agent {
permissionMode,
};
getAvailableSlashCommands(q).then((availableCommands) => {
setTimeout(() => {
this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands,
},
});
}, 0);
});
const availableCommands = await getAvailableSlashCommands(q);
const models = await getAvailableModels(q);
// Needs to happen after we return the session
setTimeout(() => {
this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
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,
},
};
}
@@ -313,9 +355,12 @@ export class ClaudeAcpAgent implements Agent {
case "init":
break;
case "compact_boundary":
case "hook_response":
case "status":
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
default:
unreachable(message as never);
unreachable(message);
}
break;
case "result": {
@@ -339,6 +384,18 @@ export class ClaudeAcpAgent implements Agent {
return { stopReason: "refusal" };
}
}
case "stream_event": {
for (const notification of streamEventToAcpNotifications(
message,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
this.client,
)) {
await this.client.sessionUpdate(notification);
}
break;
}
case "user":
case "assistant": {
if (this.sessions[params.sessionId].cancelled) {
@@ -378,18 +435,30 @@ 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,
this.client,
)) {
await this.client.sessionUpdate(notification);
}
break;
}
case "tool_progress":
break;
case "auth_status":
break;
default:
unreachable(message as never);
unreachable(message);
}
}
throw new Error("Session did not end in result");
@@ -448,6 +517,126 @@ export class ClaudeAcpAgent implements Agent {
this.fileContentCache[params.path] = params.content;
return response;
}
canUseTool(sessionId: string): CanUseTool {
return async (toolName, toolInput, { suggestions, toolUseID }) => {
const session = this.sessions[sessionId];
if (!session) {
return {
behavior: "deny",
message: "Session not found",
interrupt: true,
};
}
if (toolName === "ExitPlanMode") {
const response = await this.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Yes, and auto-accept edits",
optionId: "acceptEdits",
},
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
],
sessionId,
toolCall: {
toolCallId: toolUseID,
rawInput: toolInput,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
) {
session.permissionMode = response.outcome.optionId;
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: response.outcome.optionId,
},
});
return {
behavior: "allow",
updatedInput: toolInput,
updatedPermissions: suggestions ?? [
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
],
};
} else {
return {
behavior: "deny",
message: "User rejected request to exit plan mode.",
interrupt: true,
};
}
}
if (
session.permissionMode === "bypassPermissions" ||
(session.permissionMode === "acceptEdits" && EDIT_TOOL_NAMES.includes(toolName))
) {
return {
behavior: "allow",
updatedInput: toolInput,
updatedPermissions: suggestions ?? [
{ type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
],
};
}
const response = await this.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Always Allow",
optionId: "allow_always",
},
{ kind: "allow_once", name: "Allow", optionId: "allow" },
{ kind: "reject_once", name: "Reject", optionId: "reject" },
],
sessionId,
toolCall: {
toolCallId: toolUseID,
rawInput: toolInput,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
) {
// If Claude Code has suggestions, it will update their settings already
if (response.outcome.optionId === "allow_always") {
return {
behavior: "allow",
updatedInput: toolInput,
updatedPermissions: suggestions ?? [
{
type: "addRules",
rules: [{ toolName }],
behavior: "allow",
destination: "session",
},
],
};
}
return {
behavior: "allow",
updatedInput: toolInput,
};
} else {
return {
behavior: "deny",
message: "User refused permission to run tool",
interrupt: true,
};
}
};
}
}
async function getAvailableModels(query: Query): Promise<SessionModelState> {
@@ -472,54 +661,30 @@ async function getAvailableModels(query: Query): Promise<SessionModelState> {
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
const UNSUPPORTED_COMMANDS = [
"add-dir",
"agents", // Modal
"bashes", // Modal
"bug", // Modal
"clear", // Escape Codes
"config", // Modal
"context", // Escape Codes
"cost", // Escape Codes
"doctor", // Escape Codes
"exit",
"export", // Modal
"help", // Modal
"hooks", // Modal
"ide", // Modal
"install-github-app", // Modal
"context",
"cost",
"login",
"logout",
"memory",
"mcp",
"migrate-installer", // Modal
"model", // Not supported via SDK?
"output-style", // Modal
"output-style:new", // Modal
"permissions", // Modal
"privacy-settings",
"release-notes", // Escape Codes
"resume",
"status", // Not supported via SDK?
"statusline", // Not needed
"terminal-setup", // Not needed
"todos", // Escape Codes
"vim", // Not needed
"output-style:new",
"release-notes",
"todos",
];
const commands = await query.supportedCommands();
return commands
.map((command) => {
const input = command.argumentHint ? { hint: command.argumentHint } : null;
let name = command.name;
if (command.name.endsWith(" (MCP)")) {
name = `mcp:${name.replace(" (MCP)", "")}`;
}
return {
name: command.name,
name,
description: command.description || "",
input,
};
})
.filter(
(command: AvailableCommand) =>
!(command.name.match(/\(MCP\)/) || UNSUPPORTED_COMMANDS.includes(command.name)),
);
.filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name));
}
function formatUriAsLink(uri: string): string {
@@ -539,15 +704,23 @@ function formatUriAsLink(uri: string): string {
}
}
function promptToClaude(prompt: PromptRequest): SDKUserMessage {
export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
const content: any[] = [];
const context: any[] = [];
for (const chunk of prompt.prompt) {
switch (chunk.type) {
case "text":
content.push({ type: "text", text: chunk.text });
case "text": {
let text = chunk.text;
// change /mcp:server:command args -> /server:command (MCP) args
const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/);
if (mcpMatch) {
const [, server, command, args] = mcpMatch;
text = `/${server}:${command} (MCP)${args || ""}`;
}
content.push({ type: "text", text });
break;
}
case "resource_link": {
const formattedUri = formatUriAsLink(chunk.uri);
content.push({
@@ -615,20 +788,19 @@ 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 },
client: AgentSideConnection,
): 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,
@@ -644,9 +816,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,
@@ -655,8 +827,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 : "",
@@ -666,6 +837,7 @@ export function toAcpNotifications(
};
break;
case "thinking":
case "thinking_delta":
update = {
sessionUpdate: "agent_thought_chunk",
content: {
@@ -675,13 +847,45 @@ export function toAcpNotifications(
};
break;
case "tool_use":
case "server_tool_use":
case "mcp_tool_use": {
toolUseCache[chunk.id] = chunk;
if (chunk.name === "TodoWrite") {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
};
// @ts-expect-error - sometimes input is empty object
if (Array.isArray(chunk.input.todos)) {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
};
}
} else {
// Register hook callback to receive the structured output from the hook
registerHookCallback(chunk.id, {
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
const toolUse = toolUseCache[toolUseId];
if (toolUse) {
const update: SessionNotification["update"] = {
_meta: {
claudeCode: {
toolResponse,
toolName: toolUse.name,
} satisfies ToolUpdateMeta,
},
toolCallId: toolUseId,
sessionUpdate: "tool_call_update",
};
await client.sessionUpdate({
sessionId,
update,
});
} else {
console.error(
`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
);
}
},
});
let rawInput;
try {
rawInput = JSON.parse(JSON.stringify(chunk.input));
@@ -689,6 +893,11 @@ export function toAcpNotifications(
// ignore if we can't turn it to JSON
}
update = {
_meta: {
claudeCode: {
toolName: chunk.name,
} satisfies ToolUpdateMeta,
},
toolCallId: chunk.id,
sessionUpdate: "tool_call",
rawInput,
@@ -697,8 +906,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,17 +925,31 @@ export function toAcpNotifications(
if (toolUse.name !== "TodoWrite") {
update = {
_meta: {
claudeCode: {
toolName: toolUse.name,
} satisfies ToolUpdateMeta,
},
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 });
@@ -729,6 +959,45 @@ export function toAcpNotifications(
return output;
}
export function streamEventToAcpNotifications(
message: SDKPartialAssistantMessage,
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
): SessionNotification[] {
const event = message.event;
switch (event.type) {
case "content_block_start":
return toAcpNotifications(
[event.content_block],
"assistant",
sessionId,
toolUseCache,
fileContentCache,
client,
);
case "content_block_delta":
return toAcpNotifications(
[event.delta],
"assistant",
sessionId,
toolUseCache,
fileContentCache,
client,
);
// No content
case "message_start":
case "message_delta":
case "message_stop":
case "content_block_stop":
return [];
default:
unreachable(event);
}
}
export function runAcp() {
const input = nodeToWebWritable(process.stdout);
const output = nodeToWebReadable(process.stdin);

View File

@@ -19,7 +19,7 @@ process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
import { runAcp as runAcp } from "./acp-agent.js";
import { runAcp } from "./acp-agent.js";
runAcp();
// Keep process alive

21
src/lib.ts Normal file
View File

@@ -0,0 +1,21 @@
// Export the main agent class and utilities for library usage
export {
ClaudeAcpAgent,
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
type ToolUpdateMeta,
} from "./acp-agent.js";
export {
loadManagedSettings,
applyEnvironmentSettings,
nodeToWebReadable,
nodeToWebWritable,
Pushable,
unreachable,
} from "./utils.js";
export { createMcpServer, toolNames } from "./mcp-server.js";
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
// Export types
export type { ClaudePlanEntry } from "./tools.js";

View File

@@ -1,15 +1,10 @@
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { Server } from "node:http";
import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
import * as diff from "diff";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
import { PermissionResult } from "@anthropic-ai/claude-agent-sdk";
export const SYSTEM_REMINDER = `
@@ -38,7 +33,7 @@ export const toolNames = {
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
};
const editToolNames = [toolNames.edit, toolNames.write];
export const EDIT_TOOL_NAMES = [toolNames.edit, toolNames.write];
export function createMcpServer(
agent: ClaudeAcpAgent,
@@ -578,189 +573,6 @@ In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
return server;
}
const UNQUALIFIED_PERMISSION_TOOL_NAME = "permission";
const PERMISSION_SERVER_PREFIX = "mcp__acpPermission__";
export const PERMISSION_TOOL_NAME = PERMISSION_SERVER_PREFIX + UNQUALIFIED_PERMISSION_TOOL_NAME;
/**
* Separate permission tool. Needs to be an HTTP server for now, because Claude Code doesn't
* support permission tools via SDK servers.
* Ideally it moves to the `canUseTool` callback in the future.
*/
export function createPermissionMcpServer(
agent: ClaudeAcpAgent,
sessionId: string,
): Promise<Server> {
const server = new McpServer(
{ name: "acpPermission", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
const alwaysAllowedTools: { [key: string]: boolean } = {};
server.registerTool(
UNQUALIFIED_PERMISSION_TOOL_NAME,
{
title: "Permission Tool",
description: "Used to request tool permissions",
inputSchema: {
tool_name: z.string(),
input: z.any(),
tool_use_id: z.string().optional(),
},
},
async (input) => {
const result = await canUseTool(input);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
},
);
async function canUseTool(input: {
tool_use_id?: string;
tool_name: string;
input?: any;
}): Promise<PermissionResult> {
const session = agent.sessions[sessionId];
if (!session) {
return {
behavior: "deny",
message: "Session not found",
};
}
if (input.tool_name === "ExitPlanMode") {
const response = await agent.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Yes, and auto-accept edits",
optionId: "acceptEdits",
},
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
],
sessionId,
toolCall: {
toolCallId: input.tool_use_id!,
rawInput: input.input,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
) {
session.permissionMode = response.outcome.optionId;
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: response.outcome.optionId,
},
});
return {
behavior: "allow",
updatedInput: input.input,
updatedPermissions: [
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
],
};
} else {
return {
behavior: "deny",
message: "User rejected request to exit plan mode.",
};
}
}
if (
session.permissionMode === "bypassPermissions" ||
(session.permissionMode === "acceptEdits" && editToolNames.includes(input.tool_name)) ||
alwaysAllowedTools[input.tool_name]
) {
return {
behavior: "allow",
updatedInput: input.input,
};
}
const response = await agent.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Always Allow",
optionId: "allow_always",
},
{ kind: "allow_once", name: "Allow", optionId: "allow" },
{ kind: "reject_once", name: "Reject", optionId: "reject" },
],
sessionId,
toolCall: {
toolCallId: input.tool_use_id!,
rawInput: input.input,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
) {
if (response.outcome.optionId === "allow_always") {
alwaysAllowedTools[input.tool_name] = true;
}
return {
behavior: "allow",
updatedInput: input.input,
};
} else {
return {
behavior: "deny",
message: "User refused permission to run tool",
};
}
}
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
try {
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: `Internal server error: ${error}`,
},
id: null,
});
}
}
});
return new Promise((resolve, reject) => {
const listener = app.listen(0, "127.0.0.1", (error) => {
if (error) {
reject(error);
return;
}
resolve(listener);
});
});
}
function stripCommonPrefix(a: string, b: string): string {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) {

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
AgentSideConnection,
AvailableCommand,
Client,
ClientSideConnection,
@@ -17,7 +18,7 @@ import {
} from "@agentclientprotocol/sdk";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications } from "../acp-agent.js";
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
@@ -649,7 +650,16 @@ 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",
{},
{},
{} as AgentSideConnection,
),
).toStrictEqual([
{
sessionId: "test",
update: {
@@ -781,6 +791,44 @@ describe("escape markdown", () => {
});
});
describe("prompt conversion", () => {
it("should not change built-in slash commands", () => {
const message = promptToClaude({
sessionId: "test",
prompt: [
{
type: "text",
text: "/compact args",
},
],
});
expect(message.message.content).toEqual([
{
text: "/compact args",
type: "text",
},
]);
});
it("should remove MCP prefix from MCP slash commands", () => {
const message = promptToClaude({
sessionId: "test",
prompt: [
{
type: "text",
text: "/mcp:server:name args",
},
],
});
expect(message.message.content).toEqual([
{
text: "/server:name (MCP) args",
type: "text",
},
]);
});
});
describe("SDK behavior", () => {
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"query has a 'default' model",

View File

@@ -1,6 +1,15 @@
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";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
interface ToolInfo {
title: string;
@@ -105,12 +114,14 @@ export function toolInfoFromToolUse(
return {
title: "Read " + (input.file_path ?? "File") + limit,
kind: "read",
locations: [
{
path: input.file_path,
line: input.offset ?? 0,
},
],
locations: input.file_path
? [
{
path: input.file_path,
line: input.offset ?? 0,
},
]
: [],
content: [],
};
}
@@ -336,7 +347,7 @@ export function toolInfoFromToolUse(
case "TodoWrite":
return {
title: input?.todos
title: Array.isArray(input?.todos)
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
: "Update TODOs",
kind: "think",
@@ -385,7 +396,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 +444,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 +475,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,
);
}
}
}
@@ -512,3 +539,50 @@ export function markdownEscape(text: string): string {
}
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
}
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
const toolUseCallbacks: {
[toolUseId: string]: {
onPostToolUseHook?: (
toolUseID: string,
toolInput: unknown,
toolResponse: unknown,
) => Promise<void>;
};
} = {};
/* Setup callbacks that will be called when receiving hooks from Claude Code */
export const registerHookCallback = (
toolUseID: string,
{
onPostToolUseHook,
}: {
onPostToolUseHook?: (
toolUseID: string,
toolInput: unknown,
toolResponse: unknown,
) => Promise<void>;
},
) => {
toolUseCallbacks[toolUseID] = {
onPostToolUseHook,
};
};
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
export const postToolUseHook: HookCallback = async (
input: any,
toolUseID: string | undefined,
): Promise<{ continue: boolean }> => {
if (input.hook_event_name === "PostToolUse" && toolUseID) {
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
if (onPostToolUseHook) {
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
} else {
console.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
delete toolUseCallbacks[toolUseID];
}
}
return { continue: true };
};

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