Compare commits

..

27 Commits

Author SHA1 Message Date
Ben Brandt
5133e2dec7 Update to claude-agent-sdk 0.1.42 (#160) 2025-11-17 10:24:02 +00:00
Tim Visher
ed035da9d8 Add title field to tool permission requests (#159)
🤖 Co-authored-by: Claude (Anthropic AI Assistant)

## Problem

Tool approval dialogs in ACP clients (like Emacs
[agent-shell](https://github.com/xenodium/agent-shell)) were showing up
empty because the `toolCall` object in `session/request_permission`
requests was missing the `title` field.

This caused dialogs to appear with just buttons and no description of
what tool is being approved, making it impossible for users to make
informed decisions.

## Solution

Add the `title` field to both `requestPermission` calls (lines 553 and
612) by using the existing `toolInfoFromToolUse()` function which
already generates appropriate titles for all tool types.

## Before
```
  ╭─
      ⚠ Tool Permission ⚠
      
      
       Allow (y)   Reject (n)   Always Allow (!)
  ╰─
```

## After
```
  ╭─
      ⚠ Tool Permission ⚠
      
      Write /path/to/file.txt
      
       Allow (y)   Reject (n)   Always Allow (!)
  ╰─
```

## References

- This agent-shell.el issue shows (as has been shown above) what the
expected interaction would look like and what this changeset supports:
https://github.com/xenodium/agent-shell/issues/123
- The ACP protocol already supports the `title` field and agent-shell
displays it correctly when present (confirmed with OpenCode Agent which
does send titles)

## Testing

- **Manual testing**: Confirmed with agent-shell that permission dialogs
now show descriptive titles like "Write /path/to/file.txt"
- **Unit tests**: Added test coverage that verifies the `title` field is
present in permission request structures for multiple tool types (Write,
Bash, Read)
- **Regression testing**: Verified the new test fails when the `title`
field is omitted, confirming it catches the bug this PR fixes
- **Existing tests**: All 61 existing unit tests pass

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-17 11:19:22 +01:00
Ben Brandt
8df7f10058 Use better file path resolution for experimental auth support (#156) 2025-11-14 12:21:49 +01:00
Ben Brandt
6a0d0b5522 Fix incorrect stop reason after tool call rejection (#153)
We were mapping tool call rejections to actual "refusals" which isn't
quite right in ACP.

Also filters out more of these status messages that Claude is returning.
2025-11-13 11:19:48 +01:00
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
12 changed files with 1111 additions and 980 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,60 @@
# Changelog
## 0.10.4
- Fix tool call titles not appearing during approval in some cases
- Update to @anthropic-ai/claude-agent-sdk@v0.1.42
## 0.10.3
- Fix for experimental terminal auth support
## 0.10.2
- Fix incorrect stop reason for tool call refusals
## 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

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

1229
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.4",
"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.7",
"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.21",
"@anthropic-ai/claude-code": "2.0.21",
"@modelcontextprotocol/sdk": "1.20.1",
"@agentclientprotocol/sdk": "0.5.1",
"@anthropic-ai/claude-agent-sdk": "0.1.42",
"@anthropic-ai/claude-code": "2.0.42",
"@modelcontextprotocol/sdk": "1.22.0",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
"uuid": "13.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.67.0",
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.8.1",
"@anthropic-ai/sdk": "0.69.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.9",
"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.9"
}
}

View File

@@ -26,6 +26,7 @@ import {
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import {
CanUseTool,
McpServerConfig,
Options,
PermissionMode,
@@ -40,21 +41,19 @@ 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" };
import { fileURLToPath } from "node:url";
type Session = {
query: Query;
@@ -74,6 +73,16 @@ 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" | "server_tool_use" | "mcp_tool_use";
@@ -108,9 +117,29 @@ 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 = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
authMethod._meta = {
"terminal-auth": {
command: cliPath,
args: ["/login"],
label: "Claude Code Login",
},
};
}
return {
protocolVersion: 1,
// todo!()
agentCapabilities: {
promptCapabilities: {
image: true,
@@ -121,13 +150,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> {
@@ -172,17 +200,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;
@@ -205,10 +222,11 @@ export class ClaudeAcpAgent implements Agent {
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: IS_ROOT ? 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.
@@ -216,6 +234,13 @@ export class ClaudeAcpAgent implements Agent {
...(process.env.CLAUDE_CODE_EXECUTABLE && {
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
}),
hooks: {
PostToolUse: [
{
hooks: [postToolUseHook],
},
],
},
};
const allowedTools = [];
@@ -253,10 +278,6 @@ export class ClaudeAcpAgent implements Agent {
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(() => {
@@ -333,9 +354,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": {
@@ -352,12 +376,14 @@ export class ClaudeAcpAgent implements Agent {
return { stopReason: "end_turn" };
}
case "error_during_execution":
return { stopReason: "refusal" };
return { stopReason: "end_turn" };
case "error_max_budget_usd":
case "error_max_turns":
return { stopReason: "max_turn_requests" };
default:
return { stopReason: "refusal" };
unreachable(message);
}
break;
}
case "stream_event": {
for (const notification of streamEventToAcpNotifications(
@@ -365,6 +391,7 @@ export class ClaudeAcpAgent implements Agent {
params.sessionId,
this.toolUseCache,
this.fileContentCache,
this.client,
)) {
await this.client.sessionUpdate(notification);
}
@@ -394,7 +421,13 @@ export class ClaudeAcpAgent implements Agent {
break;
}
// Skip these user messages for now, since they seem to just be messages we don't want in the feed
if (message.type === "user" && typeof message.message.content === "string") {
if (
message.type === "user" &&
(typeof message.message.content === "string" ||
(Array.isArray(message.message.content) &&
message.message.content.length === 1 &&
message.message.content[0].type === "text"))
) {
break;
}
@@ -421,13 +454,18 @@ export class ClaudeAcpAgent implements Agent {
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");
@@ -486,6 +524,130 @@ 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,
title: toolInfoFromToolUse({ name: toolName, input: toolInput }, this.fileContentCache)
.title,
},
});
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,
title: toolInfoFromToolUse({ name: toolName, input: toolInput }, this.fileContentCache)
.title,
},
});
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> {
@@ -510,54 +672,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 {
@@ -577,15 +715,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({
@@ -658,6 +804,7 @@ export function toAcpNotifications(
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
): SessionNotification[] {
if (typeof content === "string") {
return [
@@ -723,6 +870,33 @@ export function toAcpNotifications(
};
}
} 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));
@@ -730,6 +904,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,
@@ -757,6 +936,11 @@ 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: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
@@ -791,17 +975,10 @@ export function streamEventToAcpNotifications(
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
): 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],
@@ -809,6 +986,7 @@ export function streamEventToAcpNotifications(
sessionId,
toolUseCache,
fileContentCache,
client,
);
case "content_block_delta":
return toAcpNotifications(
@@ -817,11 +995,13 @@ export function streamEventToAcpNotifications(
sessionId,
toolUseCache,
fileContentCache,
client,
);
// No content
case "message_start":
case "message_delta":
case "message_stop":
case "content_block_stop":
// do we need stop reason?
return [];
default:

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", () => {
@@ -650,7 +651,14 @@ describe("tool conversions", () => {
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
};
expect(
toAcpNotifications(received.message.content, received.message.role, "test", {}, {}),
toAcpNotifications(
received.message.content,
received.message.role,
"test",
{},
{},
{} as AgentSideConnection,
),
).toStrictEqual([
{
sessionId: "test",
@@ -783,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",
@@ -795,3 +841,60 @@ describe("SDK behavior", () => {
10000,
);
});
describe("permission requests", () => {
it("should include title field in tool permission request structure", () => {
// Test various tool types to ensure title is correctly generated
const testCases = [
{
toolUse: {
type: "tool_use" as const,
id: "test-1",
name: "Write",
input: { file_path: "/test/file.txt", content: "test" },
},
expectedTitlePart: "/test/file.txt",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-2",
name: "Bash",
input: { command: "ls -la", description: "List files" },
},
expectedTitlePart: "`ls -la`",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-3",
name: "mcp__acp__Read",
input: { file_path: "/test/data.json" },
},
expectedTitlePart: "/test/data.json",
},
];
for (const testCase of testCases) {
// Get the tool info that would be used in requestPermission
const toolInfo = toolInfoFromToolUse(testCase.toolUse, {});
// Verify toolInfo has a title
expect(toolInfo.title).toBeDefined();
expect(toolInfo.title).toContain(testCase.expectedTitlePart);
// Verify the structure that our fix creates for requestPermission
const requestStructure = {
toolCall: {
toolCallId: testCase.toolUse.id,
rawInput: testCase.toolUse.input,
title: toolInfo.title, // This is what commit 1785d86 adds
},
};
// Ensure the title field is present and populated
expect(requestStructure.toolCall.title).toBeDefined();
expect(requestStructure.toolCall.title).toContain(testCase.expectedTitlePart);
}
});
});

View File

@@ -9,6 +9,7 @@ import {
BetaWebFetchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
interface ToolInfo {
title: string;
@@ -113,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: [],
};
}
@@ -536,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 };
};