Compare commits

..

30 Commits

Author SHA1 Message Date
Ben Brandt
fda22f227c prep release 2025-12-11 14:02:33 +01:00
Ben Brandt
7643799805 Allow agent to write plans and todos to its config directory 2025-12-11 14:00:24 +01:00
Ben Brandt
ce4ae06993 Use official sdk types for tools 2025-12-11 13:19:22 +01:00
Guillaume Sabran
c3e1418d89 Allow to resume previous session (#199)
When the ACP client persists the session id to support resuming, it has
no way to pass it back to the agent to resume an existing session as the
current implementation always creates a new id that is now shared with
Claude Code.

My client used to pass the value in the `--resume` within Claude Code
option, but `--resume` is not compatible with the new `--session-id`:

```
--session-id cannot be used with --continue or --resume.
```

Change:

When `--resume` is used, extract its value and use it as the session id.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-11 12:18:38 +01:00
Bennet Bo Fenner
3b4cd4b2d5 Fix duplicate tool use IDs error (#198)
<img width="595" height="107" alt="image"
src="https://github.com/user-attachments/assets/3b9ae5e9-cbc8-425a-8cb6-1d201df3a812"
/>

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-09 22:00:38 +01:00
Ben Brandt
9320e05891 Update to claude-agent-sdk 0.1.61 (#195) 2025-12-08 12:17:49 +01:00
Ben Brandt
3f44543019 Unify ACP and Claude Code session ids (#193)
There have been some issues for folks because the session id we returned
for ACP wasn't the same as the session id for Claude Code.

Now, we pass it through to the `--session-id` flag of the Claude Code
CLI, which means both ACP and Claude Code are always referring to the
same UUID. Yay!
2025-12-05 15:14:16 +00:00
Ben Brandt
4db3d855bf Update to claude-agent-sdk v0.1.59 (#192) 2025-12-05 16:06:31 +01:00
Ben Brandt
e57893f6d0 Update to claude-agent-sdk 0.1.57 (#191) 2025-12-03 11:27:01 +00:00
Ben Brandt
6a93d7e786 Update to ACP SDK v0.7.0 (#188) 2025-12-01 17:45:24 +01:00
RAIT-09
c3c0f4da36 Prevent invalid locations when Read tool call has no file_path (#186)
## Summary

Follow-up to #121. The same issue (invalid locations when `file_path` is
undefined) existed in the `Read` tool case as well.

## Problem

The ACP spec requires `path` as a required field in `ToolCallLocation`.
PR #121 fixed this for `mcp__acp__Read`, but the same issue remained for
the `Read` tool case.

When Claude generates a Read tool call without `file_path`, the current
code produces an invalid notification:

```json
{
  "locations": [
    {
      "line": 0
    }
  ]
}
```

This causes the following error in the client:

```
Error handling notification
{code: -32602, message: 'Invalid params', data: {…}}
```

## Solution

Return an empty `locations` array when `input.file_path` is undefined,
consistent with the fix in #121.
2025-12-01 13:22:06 +01:00
Ben Brandt
73d308e4ad Update to claude-agent-sdk 0.1.55 (#182) 2025-11-27 14:35:53 +01:00
Ben Brandt
49b28a25eb Add CLAUDECODE environment to terminal invocations (#181) 2025-11-27 13:10:11 +00:00
Ben Brandt
8516df7188 fix: Access to undefined meta field (#180) 2025-11-27 14:06:47 +01:00
Guillaume Sabran
e31a16228a Allow user to pass Options to Claude Code in new session (#176)
When using Claude Code ACP as a direct dependency, this will allow the
user to configure Claude Code using the `Options` provided by the SDK.
2025-11-26 11:36:01 +01:00
Guillaume Sabran
c00782c103 Logging configuration (#177)
Allow to provide a logger to replace `console` (`console` remains the
default value)
2025-11-25 11:29:16 +01:00
Ben Brandt
4211f8cf05 Update to claude-agent-sdk 0.1.51 (#178)
Adds support for Opus 4.5
2025-11-24 23:24:34 +01:00
Ben Brandt
ac2119a23a More forgiving unreachable (#173)
Currently, if we get an unexpected message, we crash. While this is fine
in the world where we vendor CC (which we do by default) we also allow
users to override the executable for cases where their work needs a
wrapper.

This makes it so that we just log the event that we get, but don't crash
right away in case we can continue on.
2025-11-24 12:17:11 +00:00
Ben Brandt
427117dce6 Update to claude-agent-sdk 0.1.50 (#171) 2025-11-24 12:05:38 +00:00
Paul Nelson
b5a7e0f28d Fix read/edit tool error handling so upstream errors surface (#163)
Fixes #162:
- guard both tool callbacks so they return the host-provided error
instead of crashing on missing content
- skip writeTextFile in the edit tool when the initial read fails,
mirroring the read tool behavior
- add regression tests

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-24 11:37:18 +01:00
Ben Brandt
8ef0b4e4c6 Update to claude-agent-sdk 0.1.46 (#168) 2025-11-19 08:42:32 +00:00
Ben Brandt
959cd6c8e2 Disable experimental terminal auth support for now (#167)
This was causing issues on Windows in the current form, and also has
some other issues that need looking into. Will revisit later.
2025-11-19 08:37:59 +00:00
Ben Brandt
ef209d9d12 Don't special-case "default" model id (#166)
A model of id "default" only shows up sometimes in certain plans. So
rather than special-case this one, we just take the first one in the
list.
2025-11-18 10:51:20 +00:00
Ben Brandt
91b00b71e3 Report errors from "status" messages (#165)
The "status" message may say "success" but can actually have an error
which we weren't surfacing.

We should now be returning a more helpful message to the user.

Closes #161 and possibly #146
2025-11-18 10:30:30 +00:00
Ben Brandt
101a527732 Update to claude-agent-sdk 0.1.44 (#164) 2025-11-18 09:59:41 +00:00
Amos Wenger
59c5a0a8b8 Add experimental support for disabling built-in tools via _meta flag (#158)
Add ability to disable built-in tools (Read, Write, Edit, Bash, etc.)
when `_meta.disableBuiltInTools` is set to true. This allows clients to
rely solely on MCP tools without the built-in tool implementations.

---

This is just my take on #157 — not necessarily the one that makes sense
/cc @benbrandt

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-11-17 10:31:59 +00:00
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
9 changed files with 982 additions and 915 deletions

View File

@@ -1,5 +1,75 @@
# Changelog
## 0.12.3
- Allow agent to write plans and todos to its config directory
- Fix experimental resume ids
## 0.12.2
- Fix duplicate tool use IDs error
## 0.12.1
- Update to @anthropic-ai/claude-agent-sdk@v0.1.61
- Update to @agentclientprotocol/sdk@0.8.0
## 0.12.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.59
- Brings Opus to Claude Pro plans
- Support "Don't Ask" profile
- Unify ACP + Claude Code session ids
## 0.11.0
- Update to @anthropic-ai/claude-agent-sdk@v0.1.57
- Removed dependency on @anthropic-ai/claude-code since this is no longer needed
## 0.10.10
- Update to @agentclientprotocol/sdk@0.7.0
## 0.10.9
- Update to @anthropic-ai/claude-agent-sdk@v0.1.55
- Allow defining a custom logger when used as a library
- Allow specifying custom options when used as a library
- Add `CLAUDECODE=1` to terminal invocations to match default Claude Code behavior
## 0.10.8
- Update to @anthropic-ai/claude-agent-sdk@v0.1.51 (adds support for Opus 4.5)
## 0.10.7
- Fix read/edit tool error handling so upstream errors surface
- Update to @anthropic-ai/claude-agent-sdk@v0.1.50
## 0.10.6
- Disable experimental terminal auth support for now, as it was causing issues on Windows. Will revisit with a fix later.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.46
## 0.10.5
- Better error messages at end of turn if there were any
- Add experimental support for disabling built-in tools via \_meta flag
- Update to @anthropic-ai/claude-agent-sdk@v0.1.44
## 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

1166
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.10.1",
"version": "0.12.3",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"bin": {
@@ -51,26 +51,22 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@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",
"uuid": "13.0.0"
"@agentclientprotocol/sdk": "0.8.0",
"@anthropic-ai/claude-agent-sdk": "0.1.61",
"@modelcontextprotocol/sdk": "1.24.3",
"diff": "8.0.2"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.68.0",
"@anthropic-ai/sdk": "0.71.2",
"@types/node": "24.10.1",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.46.4",
"@typescript-eslint/parser": "8.46.4",
"@vitest/ui": "4.0.8",
"@typescript-eslint/eslint-plugin": "8.48.1",
"@typescript-eslint/parser": "8.48.1",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"globals": "16.5.0",
"prettier": "3.6.2",
"prettier": "3.7.4",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.8"
"vitest": "4.0.15"
}
}

View File

@@ -16,6 +16,7 @@ import {
ReadTextFileResponse,
RequestError,
SessionModelState,
SessionNotification,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
@@ -38,9 +39,7 @@ import {
import * as fs from "node:fs";
import * as path from "node:path";
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, EDIT_TOOL_NAMES, toolNames } from "./mcp-server.js";
import {
toolInfoFromToolUse,
@@ -48,11 +47,22 @@ import {
toolUpdateFromToolResult,
ClaudePlanEntry,
registerHookCallback,
postToolUseHook,
createPostToolUseHook,
} 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 { randomUUID } from "node:crypto";
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE ?? path.join(os.homedir(), ".claude");
/**
* Logger interface for customizing logging output
*/
export interface Logger {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
}
type Session = {
query: Query;
@@ -73,13 +83,37 @@ type BackgroundTerminal =
};
/**
* Extra metadata that the agent provides for each tool_call / tool_update update, under the `_meta.claudeCode` key.
* Extra metadata that can be given to Claude Code when creating a new session.
*/
export type NewSessionMeta = {
claudeCode?: {
/**
* Options forwarded to Claude Code when starting a new session.
* Those parameters will be ignored and managed by ACP:
* - cwd
* - includePartialMessages
* - allowDangerouslySkipPermissions
* - permissionMode
* - canUseTool
* - executable
* Those parameters will be used and updated to work with ACP:
* - hooks (merged with ACP's hooks)
* - mcpServers (merged with ACP's mcpServers)
*/
options?: Options;
};
};
/**
* Extra metadata that the agent provides for each tool_call / tool_update update.
*/
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;
claudeCode?: {
/* The name of the tool that was used in Claude Code. */
toolName: string;
/* The structured output provided by Claude Code. */
toolResponse?: unknown;
};
};
type ToolUseCache = {
@@ -91,8 +125,6 @@ type ToolUseCache = {
};
};
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;
@@ -106,12 +138,14 @@ export class ClaudeAcpAgent implements Agent {
fileContentCache: { [key: string]: string };
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities;
logger: Logger;
constructor(client: AgentSideConnection) {
constructor(client: AgentSideConnection, logger?: Logger) {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.fileContentCache = {};
this.logger = logger ?? console;
}
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
@@ -125,19 +159,17 @@ export class ClaudeAcpAgent implements Agent {
};
// 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://", "");
// 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: "node",
args: [cliPath, "/login"],
label: "Claude Code Login",
},
};
}
// authMethod._meta = {
// "terminal-auth": {
// command: "node",
// args: [cliPath, "/login"],
// label: "Claude Code Login",
// },
// };
// }
return {
protocolVersion: 1,
@@ -167,7 +199,10 @@ export class ClaudeAcpAgent implements Agent {
throw RequestError.authRequired();
}
const sessionId = uuidv7();
// Extract options from _meta if provided
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
const sessionId = userProvidedOptions?.resume || randomUUID();
const input = new Pushable<SDKUserMessage>();
const mcpServers: Record<string, McpServerConfig> = {};
@@ -194,12 +229,15 @@ export class ClaudeAcpAgent implements Agent {
}
}
const server = createMcpServer(this, sessionId, this.clientCapabilities);
mcpServers["acp"] = {
type: "sdk",
name: "acp",
instance: server,
};
// Only add the acp MCP server if built-in tools are not disabled
if (!params._meta?.disableBuiltInTools) {
const server = createMcpServer(this, sessionId, this.clientCapabilities);
mcpServers["acp"] = {
type: "sdk",
name: "acp",
instance: server,
};
}
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
if (params._meta?.systemPrompt) {
@@ -217,18 +255,27 @@ export class ClaudeAcpAgent implements Agent {
const permissionMode = "default";
const extraArgs = { ...userProvidedOptions?.extraArgs };
if (userProvidedOptions?.resume === undefined) {
// Set our own session id if not resuming an existing session.
extraArgs["session-id"] = sessionId;
}
const options: Options = {
cwd: params.cwd,
includePartialMessages: true,
mcpServers,
systemPrompt,
settingSources: ["user", "project", "local"],
stderr: (err) => this.logger.error(err),
...userProvidedOptions,
// Override certain fields that must be controlled by ACP
cwd: params.cwd,
includePartialMessages: true,
mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
extraArgs,
// 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.
executable: process.execPath as any,
@@ -236,9 +283,11 @@ export class ClaudeAcpAgent implements Agent {
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
}),
hooks: {
...userProvidedOptions?.hooks,
PostToolUse: [
...(userProvidedOptions?.hooks?.PostToolUse || []),
{
hooks: [postToolUseHook],
hooks: [createPostToolUseHook(this.logger)],
},
],
},
@@ -246,16 +295,49 @@ export class ClaudeAcpAgent implements Agent {
const allowedTools = [];
const disallowedTools = [];
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push(toolNames.read);
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
disallowedTools.push("Write", "Edit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
disallowedTools.push("Bash", "BashOutput", "KillShell");
// Check if built-in tools should be disabled
const disableBuiltInTools = params._meta?.disableBuiltInTools === true;
if (!disableBuiltInTools) {
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push(toolNames.read);
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
disallowedTools.push("Write", "Edit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
disallowedTools.push("Bash", "BashOutput", "KillShell");
}
} else {
// When built-in tools are disabled, explicitly disallow all of them
disallowedTools.push(
toolNames.read,
toolNames.write,
toolNames.edit,
toolNames.bash,
toolNames.bashOutput,
toolNames.killShell,
"Read",
"Write",
"Edit",
"Bash",
"BashOutput",
"KillShell",
"Glob",
"Grep",
"Task",
"TodoWrite",
"ExitPlanMode",
"WebSearch",
"WebFetch",
"AskUserQuestion",
"SlashCommand",
"Skill",
"NotebookEdit",
);
}
if (allowedTools.length > 0) {
@@ -265,6 +347,12 @@ export class ClaudeAcpAgent implements Agent {
options.disallowedTools = disallowedTools;
}
// Handle abort controller from meta options
const abortController = userProvidedOptions?.abortController;
if (abortController?.signal.aborted) {
throw new Error("Cancelled");
}
const q = query({
prompt: input,
options,
@@ -294,18 +382,23 @@ export class ClaudeAcpAgent implements Agent {
const availableModes = [
{
id: "default",
name: "Always Ask",
description: "Prompts for permission on first use of each tool",
name: "Default",
description: "Standard behavior, prompts for dangerous operations",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Automatically accepts file edit permissions for the session",
description: "Auto-accept file edit operations",
},
{
id: "plan",
name: "Plan Mode",
description: "Claude can analyze but not modify files or execute commands",
description: "Planning mode, no actual tool execution",
},
{
id: "dontAsk",
name: "Don't Ask",
description: "Don't prompt for permissions, deny if not pre-approved",
},
];
// Only works in non-root mode
@@ -313,7 +406,7 @@ export class ClaudeAcpAgent implements Agent {
availableModes.push({
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Skips all permission prompts",
description: "Bypass all permission checks",
});
}
@@ -360,7 +453,8 @@ export class ClaudeAcpAgent implements Agent {
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
default:
unreachable(message);
unreachable(message, this.logger);
break;
}
break;
case "result": {
@@ -368,21 +462,39 @@ export class ClaudeAcpAgent implements Agent {
return { stopReason: "cancelled" };
}
// todo!() how is rate-limiting handled?
switch (message.subtype) {
case "success": {
if (message.result.includes("Please run /login")) {
throw RequestError.authRequired();
}
if (message.is_error) {
throw RequestError.internalError(undefined, message.result);
}
return { stopReason: "end_turn" };
}
case "error_during_execution":
return { stopReason: "refusal" };
if (message.is_error) {
throw RequestError.internalError(
undefined,
message.errors.join(", ") || message.subtype,
);
}
return { stopReason: "end_turn" };
case "error_max_budget_usd":
case "error_max_turns":
case "error_max_structured_output_retries":
if (message.is_error) {
throw RequestError.internalError(
undefined,
message.errors.join(", ") || message.subtype,
);
}
return { stopReason: "max_turn_requests" };
default:
return { stopReason: "refusal" };
unreachable(message, this.logger);
break;
}
break;
}
case "stream_event": {
for (const notification of streamEventToAcpNotifications(
@@ -391,6 +503,7 @@ export class ClaudeAcpAgent implements Agent {
this.toolUseCache,
this.fileContentCache,
this.client,
this.logger,
)) {
await this.client.sessionUpdate(notification);
}
@@ -408,7 +521,7 @@ export class ClaudeAcpAgent implements Agent {
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stdout>")
) {
console.log(message.message.content);
this.logger.log(message.message.content);
break;
}
@@ -416,11 +529,17 @@ export class ClaudeAcpAgent implements Agent {
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stderr>")
) {
console.error(message.message.content);
this.logger.error(message.message.content);
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;
}
@@ -448,6 +567,7 @@ export class ClaudeAcpAgent implements Agent {
this.toolUseCache,
this.fileContentCache,
this.client,
this.logger,
)) {
await this.client.sessionUpdate(notification);
}
@@ -459,6 +579,7 @@ export class ClaudeAcpAgent implements Agent {
break;
default:
unreachable(message);
break;
}
}
throw new Error("Session did not end in result");
@@ -488,6 +609,7 @@ export class ClaudeAcpAgent implements Agent {
case "default":
case "acceptEdits":
case "bypassPermissions":
case "dontAsk":
case "plan":
this.sessions[params.sessionId].permissionMode = params.modeId;
try {
@@ -519,7 +641,7 @@ export class ClaudeAcpAgent implements Agent {
}
canUseTool(sessionId: string): CanUseTool {
return async (toolName, toolInput, { suggestions, toolUseID }) => {
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
const session = this.sessions[sessionId];
if (!session) {
return {
@@ -544,9 +666,17 @@ export class ClaudeAcpAgent implements Agent {
toolCall: {
toolCallId: toolUseID,
rawInput: toolInput,
title: toolInfoFromToolUse(
{ name: toolName, input: toolInput },
this.fileContentCache,
this.logger,
).title,
},
});
if (signal.aborted || response.outcome?.outcome === "cancelled") {
throw new Error("Tool use aborted");
}
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
@@ -603,8 +733,16 @@ export class ClaudeAcpAgent implements Agent {
toolCall: {
toolCallId: toolUseID,
rawInput: toolInput,
title: toolInfoFromToolUse(
{ name: toolName, input: toolInput },
this.fileContentCache,
this.logger,
).title,
},
});
if (signal.aborted || response.outcome?.outcome === "cancelled") {
throw new Error("Tool use aborted");
}
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
@@ -642,9 +780,8 @@ export class ClaudeAcpAgent implements Agent {
async function getAvailableModels(query: Query): Promise<SessionModelState> {
const models = await query.supportedModels();
// Query doesn't give us access to the currently selected model, so we default to "default" if it is there,
// otherwise we just choose the first model in the list.
const currentModel = models.find((model) => model.value === DEFAULT_MODEL_ID) || models[0];
// Query doesn't give us access to the currently selected model, so we just choose the first model in the list.
const currentModel = models[0];
await query.setModel(currentModel.value);
const availableModels = models.map((model) => ({
@@ -794,6 +931,7 @@ export function toAcpNotifications(
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
logger: Logger,
): SessionNotification[] {
if (typeof content === "string") {
return [
@@ -869,8 +1007,8 @@ export function toAcpNotifications(
claudeCode: {
toolResponse,
toolName: toolUse.name,
} satisfies ToolUpdateMeta,
},
},
} satisfies ToolUpdateMeta,
toolCallId: toolUseId,
sessionUpdate: "tool_call_update",
};
@@ -879,7 +1017,7 @@ export function toAcpNotifications(
update,
});
} else {
console.error(
logger.error(
`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
);
}
@@ -896,19 +1034,20 @@ export function toAcpNotifications(
_meta: {
claudeCode: {
toolName: chunk.name,
} satisfies ToolUpdateMeta,
},
},
} satisfies ToolUpdateMeta,
toolCallId: chunk.id,
sessionUpdate: "tool_call",
rawInput,
status: "pending",
...toolInfoFromToolUse(chunk, fileContentCache),
...toolInfoFromToolUse(chunk, fileContentCache, logger),
};
}
break;
}
case "tool_result":
case "tool_search_tool_result":
case "web_fetch_tool_result":
case "web_search_tool_result":
case "code_execution_tool_result":
@@ -917,7 +1056,7 @@ export function toAcpNotifications(
case "mcp_tool_result": {
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
console.error(
logger.error(
`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
);
break;
@@ -928,8 +1067,8 @@ export function toAcpNotifications(
_meta: {
claudeCode: {
toolName: toolUse.name,
} satisfies ToolUpdateMeta,
},
},
} satisfies ToolUpdateMeta,
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
@@ -949,7 +1088,8 @@ export function toAcpNotifications(
break;
default:
unreachable(chunk);
unreachable(chunk, logger);
break;
}
if (update) {
output.push({ sessionId, update });
@@ -965,6 +1105,7 @@ export function streamEventToAcpNotifications(
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
client: AgentSideConnection,
logger: Logger,
): SessionNotification[] {
const event = message.event;
switch (event.type) {
@@ -976,6 +1117,7 @@ export function streamEventToAcpNotifications(
toolUseCache,
fileContentCache,
client,
logger,
);
case "content_block_delta":
return toAcpNotifications(
@@ -985,6 +1127,7 @@ export function streamEventToAcpNotifications(
toolUseCache,
fileContentCache,
client,
logger,
);
// No content
case "message_start":
@@ -994,7 +1137,8 @@ export function streamEventToAcpNotifications(
return [];
default:
unreachable(event);
unreachable(event, logger);
return [];
}
}

View File

@@ -5,6 +5,7 @@ export {
toAcpNotifications,
streamEventToAcpNotifications,
type ToolUpdateMeta,
type NewSessionMeta,
} from "./acp-agent.js";
export {
loadManagedSettings,

View File

@@ -1,8 +1,22 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
BashInput,
BashOutputInput,
FileEditInput,
FileReadInput,
FileWriteInput,
KillShellInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import { z } from "zod";
import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
import { CLAUDE_CONFIG_DIR, ClaudeAcpAgent } from "./acp-agent.js";
import {
ClientCapabilities,
ReadTextFileResponse,
TerminalOutputResponse,
} from "@agentclientprotocol/sdk";
import * as diff from "diff";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
@@ -40,6 +54,62 @@ export function createMcpServer(
sessionId: string,
clientCapabilities: ClientCapabilities | undefined,
): McpServer {
/**
* This checks if a given path is related to internal agent persistence and if the agent should be allowed to read/write from here.
* We let the agent do normal fs operations on these paths so that it can persist its state.
* However, we block access to settings files for security reasons.
*/
function internalPath(file_path: string) {
return (
file_path.startsWith(CLAUDE_CONFIG_DIR) &&
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "settings.json")) &&
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "session-env"))
);
}
async function readTextFile(input: FileReadInput): Promise<ReadTextFileResponse> {
if (internalPath(input.file_path)) {
const content = await fs.readFile(input.file_path, "utf8");
// eslint-disable-next-line eqeqeq
if (input.offset != null || input.limit != null) {
const lines = content.split("\n");
// Apply offset and limit if provided
const offset = input.offset ?? 1;
const limit = input.limit ?? lines.length;
// Extract the requested lines (offset is 1-based)
const startIndex = Math.max(0, offset - 1);
const endIndex = Math.min(lines.length, startIndex + limit);
const selectedLines = lines.slice(startIndex, endIndex);
return { content: selectedLines.join("\n") };
} else {
return { content };
}
}
return agent.readTextFile({
sessionId,
path: input.file_path,
line: input.offset,
limit: input.limit,
});
}
async function writeTextFile(input: FileWriteInput): Promise<void> {
if (internalPath(input.file_path)) {
await fs.writeFile(input.file_path, input.content, "utf8");
} else {
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: input.content,
});
}
}
// Create MCP server
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
@@ -87,7 +157,7 @@ Usage:
idempotentHint: false,
},
},
async (input) => {
async (input: FileReadInput) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
@@ -101,19 +171,18 @@ Usage:
};
}
const content = await agent.readTextFile({
sessionId,
path: input.file_path,
line: input.offset,
limit: input.limit,
});
const readResponse = await readTextFile(input);
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
}
// Extract lines with byte limit enforcement
const result = extractLinesWithByteLimit(content.content, defaults.maxFileSize);
const result = extractLinesWithByteLimit(readResponse.content, defaults.maxFileSize);
// Construct informative message about what was read
let readInfo = "";
if (input.offset > 1 || result.wasLimited) {
if ((input.offset && input.offset > 1) || result.wasLimited) {
readInfo = "\n\n<file-read-info>";
if (result.wasLimited) {
@@ -181,7 +250,7 @@ Usage:
idempotentHint: false,
},
},
async (input) => {
async (input: FileWriteInput) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
@@ -194,11 +263,7 @@ Usage:
],
};
}
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: input.content,
});
await writeTextFile(input);
return {
content: [],
@@ -242,7 +307,7 @@ Usage:
.boolean()
.default(false)
.optional()
.describe("Replace all occurences of old_string (default false)"),
.describe("Replace all occurrences of old_string (default false)"),
},
annotations: {
title: "Edit file",
@@ -252,48 +317,58 @@ Usage:
idempotentHint: false,
},
},
async (input) => {
const session = agent.sessions[sessionId];
if (!session) {
async (input: FileEditInput) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const readResponse = await readTextFile({
file_path: input.file_path,
});
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
}
const { newContent } = replaceAndCalculateLocation(readResponse.content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: input.replace_all,
},
]);
const patch = diff.createPatch(input.file_path, readResponse.content, newContent);
await writeTextFile({ file_path: input.file_path, content: newContent });
return {
content: [
{
type: "text",
text: "The user has left the building",
text: patch,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Editing file failed: " + (error?.message ?? String(error)),
},
],
};
}
const { content } = await agent.readTextFile({
sessionId,
path: input.file_path,
});
const { newContent } = replaceAndCalculateLocation(content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: input.replace_all,
},
]);
const patch = diff.createPatch(input.file_path, content, newContent);
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: patch,
},
],
};
},
);
}
@@ -308,10 +383,7 @@ Usage:
In sessions with ${toolNames.bash} always use it instead of Bash`,
inputSchema: {
command: z.string().describe("The command to execute"),
timeout: z
.number()
.default(2 * 60 * 1000)
.describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
timeout: z.number().describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
description: z.string().optional()
.describe(`Clear, concise description of what this command does in 5-10 words, in active voice. Examples:
Input: ls
@@ -333,7 +405,7 @@ Output: Create directory 'foo'`),
),
},
},
async (input, extra) => {
async (input: BashInput, extra) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
@@ -358,6 +430,7 @@ Output: Create directory 'foo'`),
const handle = await agent.client.createTerminal({
command: input.command,
env: [{ name: "CLAUDECODE", value: "1" }],
sessionId,
outputByteLimit: 32_000,
});
@@ -386,7 +459,7 @@ Output: Create directory 'foo'`),
const statusPromise = Promise.race([
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
sleep(input.timeout).then(async () => {
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
@@ -455,23 +528,23 @@ Output: Create directory 'foo'`),
{
title: unqualifiedToolNames.bashOutput,
description: `- Retrieves output from a running or completed background bash shell
- Takes a shell_id parameter identifying the shell
- Takes a bash_id parameter identifying the shell
- Always returns only new output since the last check
- Returns stdout and stderr output along with shell status
- Use this tool when you need to monitor or check the output of a long-running shell
In sessions with ${toolNames.bashOutput} always use it instead of BashOutput.`,
inputSchema: {
shell_id: z
bash_id: z
.string()
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
async (input: BashOutputInput) => {
const bgTerm = agent.backgroundTerminals[input.bash_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
throw new Error(`Unknown shell ${input.bash_id}`);
}
if (bgTerm.status === "started") {
@@ -522,7 +595,7 @@ In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
async (input: KillShellInput) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
if (!bgTerm) {
@@ -563,7 +636,8 @@ In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
return unreachable(bgTerm);
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
}
}
},

View File

@@ -20,6 +20,7 @@ import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
@@ -658,6 +659,7 @@ describe("tool conversions", () => {
{},
{},
{} as AgentSideConnection,
console,
),
).toStrictEqual([
{
@@ -829,15 +831,84 @@ describe("prompt conversion", () => {
});
});
describe("SDK behavior", () => {
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"query has a 'default' model",
async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
},
10000,
);
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
it("query has a 'default' model", async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
}, 10000);
it("custom session id", async () => {
const sessionId = randomUUID();
const q = query({
prompt: "hi",
options: {
systemPrompt: { type: "preset", preset: "claude_code" },
extraArgs: { "session-id": sessionId },
settingSources: ["user", "project", "local"],
includePartialMessages: true,
},
});
const { value } = await q.next();
expect(value).toMatchObject({ type: "system", subtype: "init", session_id: sessionId });
}, 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

@@ -6,10 +6,12 @@ import {
BetaCodeExecutionToolResultBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaWebFetchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import { Logger } from "./acp-agent.js";
interface ToolInfo {
title: string;
@@ -27,6 +29,7 @@ interface ToolUpdate {
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
logger: Logger = console,
): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -131,7 +134,14 @@ export function toolInfoFromToolUse(
title: "Read File",
kind: "read",
content: [],
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
locations: input.file_path
? [
{
path: input.file_path,
line: input.offset ?? 0,
},
]
: [],
};
case "LS":
@@ -163,7 +173,7 @@ export function toolInfoFromToolUse(
newText = newContent.newContent;
affectedLines = newContent.lineNumbers;
} catch (e) {
console.error(e);
logger.error(e);
}
}
return {
@@ -404,7 +414,8 @@ export function toolUpdateFromToolResult(
| BetaCodeExecutionToolResultBlockParam
| BetaBashCodeExecutionToolResultBlockParam
| BetaTextEditorCodeExecutionToolResultBlockParam
| BetaRequestMCPToolResultBlockParam,
| BetaRequestMCPToolResultBlockParam
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {
@@ -570,19 +581,18 @@ export const registerHookCallback = (
};
/* 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];
export const createPostToolUseHook =
(logger: Logger = console): 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 {
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
delete toolUseCallbacks[toolUseID];
}
}
}
return { continue: true };
};
return { continue: true };
};

View File

@@ -4,6 +4,7 @@ import { Readable, Writable } from "node:stream";
import { WritableStream, ReadableStream } from "node:stream/web";
import { readFileSync } from "node:fs";
import { platform } from "node:os";
import { Logger } from "./acp-agent.js";
// Useful for bridging push-based and async-iterator-based code.
export class Pushable<T> implements AsyncIterable<T> {
@@ -75,14 +76,14 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
});
}
export function unreachable(value: never): never {
export function unreachable(value: never, logger: Logger = console) {
let valueAsString;
try {
valueAsString = JSON.stringify(value);
} catch {
valueAsString = value;
}
throw new Error(`Unexpected case: ${valueAsString}`);
logger.error(`Unexpected case: ${valueAsString}`);
}
export function sleep(time: number): Promise<void> {