mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
38 Commits
v0.9.0
...
fix-plan-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fda22f227c | ||
|
|
7643799805 | ||
|
|
ce4ae06993 | ||
|
|
c3e1418d89 | ||
|
|
3b4cd4b2d5 | ||
|
|
9320e05891 | ||
|
|
3f44543019 | ||
|
|
4db3d855bf | ||
|
|
e57893f6d0 | ||
|
|
6a93d7e786 | ||
|
|
c3c0f4da36 | ||
|
|
73d308e4ad | ||
|
|
49b28a25eb | ||
|
|
8516df7188 | ||
|
|
e31a16228a | ||
|
|
c00782c103 | ||
|
|
4211f8cf05 | ||
|
|
ac2119a23a | ||
|
|
427117dce6 | ||
|
|
b5a7e0f28d | ||
|
|
8ef0b4e4c6 | ||
|
|
959cd6c8e2 | ||
|
|
ef209d9d12 | ||
|
|
91b00b71e3 | ||
|
|
101a527732 | ||
|
|
59c5a0a8b8 | ||
|
|
5133e2dec7 | ||
|
|
ed035da9d8 | ||
|
|
8df7f10058 | ||
|
|
6a0d0b5522 | ||
|
|
7e0d2c9506 | ||
|
|
2835f030cf | ||
|
|
2710e146f4 | ||
|
|
bf396fea67 | ||
|
|
84b5744a2f | ||
|
|
b213152a94 | ||
|
|
cd5aabdd74 | ||
|
|
9e18a4e6fd |
80
CHANGELOG.md
80
CHANGELOG.md
@@ -1,5 +1,85 @@
|
||||
# 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
|
||||
- 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)
|
||||
|
||||
24
docs/RELEASES.md
Normal file
24
docs/RELEASES.md
Normal 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
|
||||
1705
package-lock.json
generated
1705
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.9.0",
|
||||
"version": "0.12.3",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"bin": {
|
||||
@@ -51,30 +51,22 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.5.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.27",
|
||||
"@anthropic-ai/claude-code": "2.0.27",
|
||||
"@modelcontextprotocol/sdk": "1.20.2",
|
||||
"diff": "8.0.2",
|
||||
"express": "5.1.0",
|
||||
"minimist": "1.2.8",
|
||||
"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.67.0",
|
||||
"@types/express": "5.0.4",
|
||||
"@types/minimist": "1.2.5",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.2",
|
||||
"@typescript-eslint/parser": "8.46.2",
|
||||
"@vitest/ui": "4.0.3",
|
||||
"eslint": "9.38.0",
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@types/node": "24.10.1",
|
||||
"@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.4.0",
|
||||
"prettier": "3.6.2",
|
||||
"globals": "16.5.0",
|
||||
"prettier": "3.7.4",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.3"
|
||||
"vitest": "4.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
475
src/acp-agent.ts
475
src/acp-agent.ts
@@ -16,6 +16,7 @@ import {
|
||||
ReadTextFileResponse,
|
||||
RequestError,
|
||||
SessionModelState,
|
||||
SessionNotification,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
CanUseTool,
|
||||
McpServerConfig,
|
||||
Options,
|
||||
PermissionMode,
|
||||
@@ -37,25 +39,30 @@ 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,
|
||||
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,
|
||||
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;
|
||||
@@ -75,6 +82,40 @@ type BackgroundTerminal =
|
||||
pendingOutput: TerminalOutputResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
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 = {
|
||||
[key: string]: {
|
||||
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
|
||||
@@ -84,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;
|
||||
|
||||
@@ -99,16 +138,39 @@ 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> {
|
||||
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: "node",
|
||||
// args: [cliPath, "/login"],
|
||||
// label: "Claude Code Login",
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
@@ -126,13 +188,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
title: "Claude Code",
|
||||
version: packageJson.version,
|
||||
},
|
||||
authMethods: [
|
||||
{
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude Code",
|
||||
id: "claude-login",
|
||||
},
|
||||
],
|
||||
authMethods: [authMethod],
|
||||
};
|
||||
}
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
@@ -143,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> = {};
|
||||
@@ -170,23 +229,15 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
const server = createMcpServer(this, sessionId, this.clientCapabilities);
|
||||
mcpServers["acp"] = {
|
||||
type: "sdk",
|
||||
name: "acp",
|
||||
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,
|
||||
},
|
||||
};
|
||||
// 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) {
|
||||
@@ -204,38 +255,89 @@ 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,
|
||||
permissionPromptToolName: PERMISSION_TOOL_NAME,
|
||||
stderr: (err) => console.error(err),
|
||||
canUseTool: this.canUseTool(sessionId),
|
||||
// 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,
|
||||
...(process.env.CLAUDE_CODE_EXECUTABLE && {
|
||||
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
|
||||
}),
|
||||
hooks: {
|
||||
...userProvidedOptions?.hooks,
|
||||
PostToolUse: [
|
||||
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
||||
{
|
||||
hooks: [createPostToolUseHook(this.logger)],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
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) {
|
||||
@@ -245,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,
|
||||
@@ -274,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
|
||||
@@ -293,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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -335,12 +448,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "init":
|
||||
break;
|
||||
case "compact_boundary":
|
||||
break;
|
||||
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);
|
||||
unreachable(message, this.logger);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "result": {
|
||||
@@ -348,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(
|
||||
@@ -370,6 +502,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.fileContentCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -387,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;
|
||||
}
|
||||
|
||||
@@ -395,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;
|
||||
}
|
||||
|
||||
@@ -426,13 +566,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.fileContentCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_progress":
|
||||
break;
|
||||
case "auth_status":
|
||||
break;
|
||||
default:
|
||||
unreachable(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new Error("Session did not end in result");
|
||||
@@ -462,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 {
|
||||
@@ -491,14 +639,149 @@ export class ClaudeAcpAgent implements Agent {
|
||||
this.fileContentCache[params.path] = params.content;
|
||||
return response;
|
||||
}
|
||||
|
||||
canUseTool(sessionId: string): CanUseTool {
|
||||
return async (toolName, toolInput, { signal, 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,
|
||||
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")
|
||||
) {
|
||||
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,
|
||||
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")
|
||||
) {
|
||||
// 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> {
|
||||
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) => ({
|
||||
@@ -647,6 +930,8 @@ export function toAcpNotifications(
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
client: AgentSideConnection,
|
||||
logger: Logger,
|
||||
): SessionNotification[] {
|
||||
if (typeof content === "string") {
|
||||
return [
|
||||
@@ -712,6 +997,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 {
|
||||
logger.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));
|
||||
@@ -719,17 +1031,23 @@ 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,
|
||||
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":
|
||||
@@ -738,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;
|
||||
@@ -746,6 +1064,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",
|
||||
@@ -765,7 +1088,8 @@ export function toAcpNotifications(
|
||||
break;
|
||||
|
||||
default:
|
||||
unreachable(chunk);
|
||||
unreachable(chunk, logger);
|
||||
break;
|
||||
}
|
||||
if (update) {
|
||||
output.push({ sessionId, update });
|
||||
@@ -780,6 +1104,8 @@ export function streamEventToAcpNotifications(
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
client: AgentSideConnection,
|
||||
logger: Logger,
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
switch (event.type) {
|
||||
@@ -790,6 +1116,8 @@ export function streamEventToAcpNotifications(
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
fileContentCache,
|
||||
client,
|
||||
logger,
|
||||
);
|
||||
case "content_block_delta":
|
||||
return toAcpNotifications(
|
||||
@@ -798,6 +1126,8 @@ export function streamEventToAcpNotifications(
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
fileContentCache,
|
||||
client,
|
||||
logger,
|
||||
);
|
||||
// No content
|
||||
case "message_start":
|
||||
@@ -807,7 +1137,8 @@ export function streamEventToAcpNotifications(
|
||||
return [];
|
||||
|
||||
default:
|
||||
unreachable(event);
|
||||
unreachable(event, logger);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ export {
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
@@ -13,12 +15,7 @@ export {
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export {
|
||||
createMcpServer,
|
||||
createPermissionMcpServer,
|
||||
PERMISSION_TOOL_NAME,
|
||||
toolNames,
|
||||
} from "./mcp-server.js";
|
||||
export { createMcpServer, toolNames } from "./mcp-server.js";
|
||||
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
|
||||
// Export types
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import express from "express";
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import {
|
||||
BashInput,
|
||||
BashOutputInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
KillShellInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import { z } from "zod";
|
||||
import { Server } from "node:http";
|
||||
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";
|
||||
import { PermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
@@ -38,13 +47,69 @@ 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,
|
||||
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: {} } });
|
||||
|
||||
@@ -92,7 +157,7 @@ Usage:
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
async (input: FileReadInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
@@ -106,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) {
|
||||
@@ -186,7 +250,7 @@ Usage:
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
async (input: FileWriteInput) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
@@ -199,11 +263,7 @@ Usage:
|
||||
],
|
||||
};
|
||||
}
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
content: input.content,
|
||||
});
|
||||
await writeTextFile(input);
|
||||
|
||||
return {
|
||||
content: [],
|
||||
@@ -247,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",
|
||||
@@ -257,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,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -313,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
|
||||
@@ -338,7 +405,7 @@ Output: Create directory 'foo'`),
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input, extra) => {
|
||||
async (input: BashInput, extra) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
@@ -363,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,
|
||||
});
|
||||
@@ -391,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();
|
||||
}
|
||||
@@ -460,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") {
|
||||
@@ -527,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) {
|
||||
@@ -568,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");
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -578,189 +647,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]) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AvailableCommand,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
@@ -19,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>;
|
||||
@@ -650,7 +652,15 @@ 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,
|
||||
console,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{
|
||||
sessionId: "test",
|
||||
@@ -821,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
64
src/tools.ts
64
src/tools.ts
@@ -6,9 +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;
|
||||
@@ -26,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;
|
||||
@@ -130,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":
|
||||
@@ -162,7 +173,7 @@ export function toolInfoFromToolUse(
|
||||
newText = newContent.newContent;
|
||||
affectedLines = newContent.lineNumbers;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -403,7 +414,8 @@ export function toolUpdateFromToolResult(
|
||||
| BetaCodeExecutionToolResultBlockParam
|
||||
| BetaBashCodeExecutionToolResultBlockParam
|
||||
| BetaTextEditorCodeExecutionToolResultBlockParam
|
||||
| BetaRequestMCPToolResultBlockParam,
|
||||
| BetaRequestMCPToolResultBlockParam
|
||||
| BetaToolSearchToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
): ToolUpdate {
|
||||
switch (toolUse?.name) {
|
||||
@@ -538,3 +550,49 @@ 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 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 };
|
||||
};
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user