mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
65
CHANGELOG.md
65
CHANGELOG.md
@@ -1,5 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## 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
1166
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.10.1",
|
||||
"version": "0.12.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
281
src/acp-agent.ts
281
src/acp-agent.ts
@@ -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,20 @@ 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";
|
||||
|
||||
/**
|
||||
* Logger interface for customizing logging output
|
||||
*/
|
||||
export interface Logger {
|
||||
log: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
}
|
||||
|
||||
type Session = {
|
||||
query: Query;
|
||||
@@ -73,13 +81,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 +123,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 +136,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 +157,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 +197,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
const sessionId = uuidv7();
|
||||
const sessionId = randomUUID();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
|
||||
const mcpServers: Record<string, McpServerConfig> = {};
|
||||
@@ -194,12 +224,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 +250,25 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
const permissionMode = "default";
|
||||
|
||||
// Extract options from _meta if provided
|
||||
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
|
||||
|
||||
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 },
|
||||
// Set our own session id
|
||||
extraArgs: { ...userProvidedOptions?.extraArgs, "session-id": sessionId },
|
||||
// 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 +276,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 +288,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 +340,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 +375,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 +399,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 +446,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 +455,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 +496,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
this.toolUseCache,
|
||||
this.fileContentCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -408,7 +514,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 +522,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 +560,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
this.toolUseCache,
|
||||
this.fileContentCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -459,6 +572,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
default:
|
||||
unreachable(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new Error("Session did not end in result");
|
||||
@@ -488,6 +602,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 +634,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 +659,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 +726,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 +773,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 +924,7 @@ export function toAcpNotifications(
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
client: AgentSideConnection,
|
||||
logger: Logger,
|
||||
): SessionNotification[] {
|
||||
if (typeof content === "string") {
|
||||
return [
|
||||
@@ -869,8 +1000,8 @@ export function toAcpNotifications(
|
||||
claudeCode: {
|
||||
toolResponse,
|
||||
toolName: toolUse.name,
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: "tool_call_update",
|
||||
};
|
||||
@@ -879,7 +1010,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 +1027,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 +1049,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 +1060,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 +1081,8 @@ export function toAcpNotifications(
|
||||
break;
|
||||
|
||||
default:
|
||||
unreachable(chunk);
|
||||
unreachable(chunk, logger);
|
||||
break;
|
||||
}
|
||||
if (update) {
|
||||
output.push({ sessionId, update });
|
||||
@@ -965,6 +1098,7 @@ export function streamEventToAcpNotifications(
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
client: AgentSideConnection,
|
||||
logger: Logger,
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
switch (event.type) {
|
||||
@@ -976,6 +1110,7 @@ export function streamEventToAcpNotifications(
|
||||
toolUseCache,
|
||||
fileContentCache,
|
||||
client,
|
||||
logger,
|
||||
);
|
||||
case "content_block_delta":
|
||||
return toAcpNotifications(
|
||||
@@ -985,6 +1120,7 @@ export function streamEventToAcpNotifications(
|
||||
toolUseCache,
|
||||
fileContentCache,
|
||||
client,
|
||||
logger,
|
||||
);
|
||||
// No content
|
||||
case "message_start":
|
||||
@@ -994,7 +1130,8 @@ export function streamEventToAcpNotifications(
|
||||
return [];
|
||||
|
||||
default:
|
||||
unreachable(event);
|
||||
unreachable(event, logger);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
|
||||
@@ -101,15 +101,19 @@ Usage:
|
||||
};
|
||||
}
|
||||
|
||||
const content = await agent.readTextFile({
|
||||
const readResponse = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
line: input.offset,
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
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 = "";
|
||||
@@ -253,47 +257,62 @@ Usage:
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const readResponse = await agent.readTextFile({
|
||||
sessionId,
|
||||
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 agent.writeTextFile({
|
||||
sessionId,
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -358,6 +377,7 @@ Output: Create directory 'foo'`),
|
||||
|
||||
const handle = await agent.client.createTerminal({
|
||||
command: input.command,
|
||||
env: [{ name: "CLAUDECODE", value: "1" }],
|
||||
sessionId,
|
||||
outputByteLimit: 32_000,
|
||||
});
|
||||
@@ -563,7 +583,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");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
46
src/tools.ts
46
src/tools.ts
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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