mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 20:42:10 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a0d0b5522 | ||
|
|
7e0d2c9506 | ||
|
|
2835f030cf | ||
|
|
2710e146f4 | ||
|
|
bf396fea67 | ||
|
|
84b5744a2f | ||
|
|
b213152a94 | ||
|
|
cd5aabdd74 | ||
|
|
9e18a4e6fd | ||
|
|
48df285bd0 | ||
|
|
b138c84cdb | ||
|
|
0735aca015 | ||
|
|
847d62dacb | ||
|
|
becf1d4f0a | ||
|
|
5491bb8dcc | ||
|
|
17aad5b7a1 | ||
|
|
dadc7f9f40 | ||
|
|
552f0a286d | ||
|
|
589e1b5fc9 | ||
|
|
0d97bb4753 | ||
|
|
e3119b81f9 | ||
|
|
cd3c9f7b13 | ||
|
|
8aea8fd0e1 | ||
|
|
81a541c644 | ||
|
|
b49154bef8 | ||
|
|
5fa453a33a | ||
|
|
c2ab2da5ca | ||
|
|
373d940eb8 | ||
|
|
2c16a41fa4 | ||
|
|
656cbe5921 |
14
.github/workflows/publish.yml
vendored
14
.github/workflows/publish.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
jobs:
|
||||
build:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
@@ -22,3 +22,15 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [publish-npm]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
|
||||
with:
|
||||
draft: true
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
|
||||
67
CHANGELOG.md
67
CHANGELOG.md
@@ -1,5 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
## 0.10.2
|
||||
|
||||
- Fix incorrect stop reason for tool call refusals
|
||||
|
||||
## 0.10.1
|
||||
|
||||
- Add additional structured metadata to tool calls
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.37
|
||||
|
||||
## 0.10.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.30
|
||||
- Use `canUseTool` callback instead of launching an HTTP MCP server for permission checks.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
- Support slash commands coming from MCP servers (Prompts)
|
||||
|
||||
## 0.8.0
|
||||
|
||||
- Revert changes to filename for cli entrypoint
|
||||
- Provide library entrypoint via lib.ts
|
||||
|
||||
## 0.7.0
|
||||
|
||||
- Allow importing from this package as a library in addition to running it as a CLI. Allows for easier integration into existing node applications.
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.27
|
||||
|
||||
## 0.6.10
|
||||
|
||||
- Provide `agentInfo` on initialization response.
|
||||
- Update to @agentclientprotocol/sdk@0.5.1
|
||||
- Fix crash when receiving a hook_response event
|
||||
- Fix for invalid locations when read call has no path
|
||||
|
||||
## 0.6.9
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.26
|
||||
- Update to @agentclientprotocol/sdk@0.5.0
|
||||
|
||||
## 0.6.8
|
||||
|
||||
- Fix for duplicate tokens appearing in thread with streaming enabled
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.23
|
||||
- Update to @agentclientprotocol/sdk@0.4.9
|
||||
|
||||
## 0.6.7
|
||||
|
||||
- Fix for invalid plan input from the model introduced in latest agent-sdk
|
||||
|
||||
## 0.6.6
|
||||
|
||||
- Do not enable bypassPermissions mode if in root/sudo mode, because Claude Code will not start
|
||||
|
||||
## 0.6.5
|
||||
|
||||
- Fix for duplicated text content after streaming
|
||||
|
||||
## 0.6.4
|
||||
|
||||
- Support streaming partial messages!
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.21
|
||||
|
||||
## 0.6.3
|
||||
|
||||
- Fix issue where slash commands were loaded before initialization was complete.
|
||||
|
||||
## 0.6.2
|
||||
|
||||
- Fix bug where mode selection would sometimes fire before initialization was complete.
|
||||
|
||||
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
|
||||
1230
package-lock.json
generated
1230
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -3,12 +3,12 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.10.2",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
},
|
||||
"version": "0.6.2",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist/",
|
||||
@@ -51,30 +51,26 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.4.8",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.19",
|
||||
"@anthropic-ai/claude-code": "2.0.19",
|
||||
"@modelcontextprotocol/sdk": "1.20.0",
|
||||
"@agentclientprotocol/sdk": "0.5.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.37",
|
||||
"@anthropic-ai/claude-code": "2.0.37",
|
||||
"@modelcontextprotocol/sdk": "1.21.1",
|
||||
"diff": "8.0.2",
|
||||
"express": "5.1.0",
|
||||
"minimist": "1.2.8",
|
||||
"uuid": "13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.66.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/minimist": "1.2.5",
|
||||
"@types/node": "24.8.0",
|
||||
"@anthropic-ai/sdk": "0.68.0",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"eslint": "9.37.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.4",
|
||||
"@typescript-eslint/parser": "8.46.4",
|
||||
"@vitest/ui": "4.0.8",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "16.4.0",
|
||||
"globals": "16.5.0",
|
||||
"prettier": "3.6.2",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
"vitest": "4.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
521
src/acp-agent.ts
521
src/acp-agent.ts
@@ -26,14 +26,14 @@ import {
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
CanUseTool,
|
||||
McpServerConfig,
|
||||
Options,
|
||||
PermissionMode,
|
||||
Query,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKUserMessage,
|
||||
SDKUserMessageReplay,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
@@ -41,19 +41,18 @@ import * as os from "node:os";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
createMcpServer,
|
||||
createPermissionMcpServer,
|
||||
PERMISSION_TOOL_NAME,
|
||||
toolNames,
|
||||
} from "./mcp-server.js";
|
||||
import { AddressInfo } from "node:net";
|
||||
import { createMcpServer, EDIT_TOOL_NAMES, toolNames } from "./mcp-server.js";
|
||||
import {
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
ClaudePlanEntry,
|
||||
registerHookCallback,
|
||||
postToolUseHook,
|
||||
} from "./tools.js";
|
||||
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
|
||||
type Session = {
|
||||
query: Query;
|
||||
@@ -73,12 +72,30 @@ type BackgroundTerminal =
|
||||
pendingOutput: TerminalOutputResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra metadata that the agent provides for each tool_call / tool_update update, under the `_meta.claudeCode` key.
|
||||
*/
|
||||
export type ToolUpdateMeta = {
|
||||
/* The name of the tool that was used in Claude Code. */
|
||||
toolName: string;
|
||||
/* The structured output provided by Claude Code. */
|
||||
toolResponse?: unknown;
|
||||
};
|
||||
|
||||
type ToolUseCache = {
|
||||
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
|
||||
[key: string]: {
|
||||
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: any;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL_ID = "default";
|
||||
|
||||
// Bypass Permissions doesn't work if we are a root/sudo user
|
||||
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
||||
|
||||
// Implement the ACP Agent interface
|
||||
export class ClaudeAcpAgent implements Agent {
|
||||
sessions: {
|
||||
@@ -99,9 +116,31 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
|
||||
this.clientCapabilities = request.clientCapabilities;
|
||||
|
||||
// Default authMethod
|
||||
const authMethod: any = {
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude Code",
|
||||
id: "claude-login",
|
||||
};
|
||||
|
||||
// If client supports terminal-auth capability, use that instead.
|
||||
if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
|
||||
const cliPath = import.meta
|
||||
.resolve("@anthropic-ai/claude-agent-sdk/cli.js")
|
||||
.replace("file://", "");
|
||||
|
||||
authMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: "node",
|
||||
args: [cliPath, "/login"],
|
||||
label: "Claude Code Login",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
// todo!()
|
||||
agentCapabilities: {
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
@@ -112,13 +151,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
sse: true,
|
||||
},
|
||||
},
|
||||
authMethods: [
|
||||
{
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude Code",
|
||||
id: "claude-login",
|
||||
},
|
||||
],
|
||||
agentInfo: {
|
||||
name: packageJson.name,
|
||||
title: "Claude Code",
|
||||
version: packageJson.version,
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
};
|
||||
}
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
@@ -163,17 +201,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
instance: server,
|
||||
};
|
||||
|
||||
// Ideally replace with `canUseTool`
|
||||
const permissionServer = await createPermissionMcpServer(this, sessionId);
|
||||
const address = permissionServer.address() as AddressInfo;
|
||||
mcpServers["acpPermission"] = {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:" + address.port + "/mcp",
|
||||
headers: {
|
||||
"x-acp-proxy-session-id": sessionId,
|
||||
},
|
||||
};
|
||||
|
||||
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
|
||||
if (params._meta?.systemPrompt) {
|
||||
const customPrompt = params._meta.systemPrompt;
|
||||
@@ -188,15 +215,19 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
const permissionMode = "default";
|
||||
|
||||
const options: Options = {
|
||||
cwd: params.cwd,
|
||||
includePartialMessages: true,
|
||||
mcpServers,
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
// If we want bypassPermissions to be an option, we have to start the session with it on.
|
||||
// We change it immediately back to default below before returning the session.
|
||||
permissionMode: "bypassPermissions",
|
||||
permissionPromptToolName: PERMISSION_TOOL_NAME,
|
||||
// If we want bypassPermissions to be an option, we have to allow it here.
|
||||
// But it doesn't work in root mode, so we only activate it if it will work.
|
||||
allowDangerouslySkipPermissions: !IS_ROOT,
|
||||
permissionMode,
|
||||
canUseTool: this.canUseTool(sessionId),
|
||||
stderr: (err) => console.error(err),
|
||||
// note: although not documented by the types, passing an absolute path
|
||||
// here works to find zed's managed node version.
|
||||
@@ -204,6 +235,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
...(process.env.CLAUDE_CODE_EXECUTABLE && {
|
||||
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
|
||||
}),
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
hooks: [postToolUseHook],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const allowedTools = [];
|
||||
@@ -231,10 +269,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
prompt: input,
|
||||
options,
|
||||
});
|
||||
const models = await getAvailableModels(q);
|
||||
const permissionMode = "default";
|
||||
// Change it back to default
|
||||
await q.setPermissionMode(permissionMode);
|
||||
|
||||
this.sessions[sessionId] = {
|
||||
query: q,
|
||||
input: input,
|
||||
@@ -242,7 +277,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
permissionMode,
|
||||
};
|
||||
|
||||
getAvailableSlashCommands(q).then((availableCommands) => {
|
||||
const availableCommands = await getAvailableSlashCommands(q);
|
||||
const models = await getAvailableModels(q);
|
||||
|
||||
// Needs to happen after we return the session
|
||||
setTimeout(() => {
|
||||
this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
@@ -250,35 +289,40 @@ export class ClaudeAcpAgent implements Agent {
|
||||
availableCommands,
|
||||
},
|
||||
});
|
||||
});
|
||||
}, 0);
|
||||
|
||||
const availableModes = [
|
||||
{
|
||||
id: "default",
|
||||
name: "Always Ask",
|
||||
description: "Prompts for permission on first use of each tool",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept Edits",
|
||||
description: "Automatically accepts file edit permissions for the session",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan Mode",
|
||||
description: "Claude can analyze but not modify files or execute commands",
|
||||
},
|
||||
];
|
||||
// Only works in non-root mode
|
||||
if (!IS_ROOT) {
|
||||
availableModes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass Permissions",
|
||||
description: "Skips all permission prompts",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models,
|
||||
modes: {
|
||||
currentModeId: permissionMode,
|
||||
availableModes: [
|
||||
{
|
||||
id: "default",
|
||||
name: "Always Ask",
|
||||
description: "Prompts for permission on first use of each tool",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept Edits",
|
||||
description: "Automatically accepts file edit permissions for the session",
|
||||
},
|
||||
{
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass Permissions",
|
||||
description: "Skips all permission prompts",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan Mode",
|
||||
description: "Claude can analyze but not modify files or execute commands",
|
||||
},
|
||||
],
|
||||
availableModes,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -311,9 +355,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "init":
|
||||
break;
|
||||
case "compact_boundary":
|
||||
case "hook_response":
|
||||
case "status":
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
break;
|
||||
default:
|
||||
unreachable(message as never);
|
||||
unreachable(message);
|
||||
}
|
||||
break;
|
||||
case "result": {
|
||||
@@ -330,12 +377,26 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
case "error_during_execution":
|
||||
return { stopReason: "refusal" };
|
||||
return { stopReason: "end_turn" };
|
||||
case "error_max_budget_usd":
|
||||
case "error_max_turns":
|
||||
return { stopReason: "max_turn_requests" };
|
||||
default:
|
||||
return { stopReason: "refusal" };
|
||||
unreachable(message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "stream_event": {
|
||||
for (const notification of streamEventToAcpNotifications(
|
||||
message,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.fileContentCache,
|
||||
this.client,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "user":
|
||||
case "assistant": {
|
||||
@@ -361,7 +422,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
// Skip these user messages for now, since they seem to just be messages we don't want in the feed
|
||||
if (message.type === "user" && typeof message.message.content === "string") {
|
||||
if (
|
||||
message.type === "user" &&
|
||||
(typeof message.message.content === "string" ||
|
||||
(Array.isArray(message.message.content) &&
|
||||
message.message.content.length === 1 &&
|
||||
message.message.content[0].type === "text"))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -376,18 +443,30 @@ export class ClaudeAcpAgent implements Agent {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
const content =
|
||||
message.type === "assistant"
|
||||
? // Handled by stream events above
|
||||
message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
|
||||
: message.message.content;
|
||||
|
||||
for (const notification of toAcpNotifications(
|
||||
message,
|
||||
content,
|
||||
message.message.role,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.fileContentCache,
|
||||
this.client,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_progress":
|
||||
break;
|
||||
case "auth_status":
|
||||
break;
|
||||
default:
|
||||
unreachable(message as never);
|
||||
unreachable(message);
|
||||
}
|
||||
}
|
||||
throw new Error("Session did not end in result");
|
||||
@@ -446,6 +525,126 @@ export class ClaudeAcpAgent implements Agent {
|
||||
this.fileContentCache[params.path] = params.content;
|
||||
return response;
|
||||
}
|
||||
|
||||
canUseTool(sessionId: string): CanUseTool {
|
||||
return async (toolName, toolInput, { suggestions, toolUseID }) => {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "Session not found",
|
||||
interrupt: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (toolName === "ExitPlanMode") {
|
||||
const response = await this.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Yes, and auto-accept edits",
|
||||
optionId: "acceptEdits",
|
||||
},
|
||||
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
|
||||
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: toolUseID,
|
||||
rawInput: toolInput,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
|
||||
) {
|
||||
session.permissionMode = response.outcome.optionId;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: response.outcome.optionId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
updatedPermissions: suggestions ?? [
|
||||
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User rejected request to exit plan mode.",
|
||||
interrupt: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
session.permissionMode === "bypassPermissions" ||
|
||||
(session.permissionMode === "acceptEdits" && EDIT_TOOL_NAMES.includes(toolName))
|
||||
) {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
updatedPermissions: suggestions ?? [
|
||||
{ type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
optionId: "allow_always",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: toolUseID,
|
||||
rawInput: toolInput,
|
||||
},
|
||||
});
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
|
||||
) {
|
||||
// If Claude Code has suggestions, it will update their settings already
|
||||
if (response.outcome.optionId === "allow_always") {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
updatedPermissions: suggestions ?? [
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
interrupt: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailableModels(query: Query): Promise<SessionModelState> {
|
||||
@@ -470,54 +669,30 @@ async function getAvailableModels(query: Query): Promise<SessionModelState> {
|
||||
|
||||
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"add-dir",
|
||||
"agents", // Modal
|
||||
"bashes", // Modal
|
||||
"bug", // Modal
|
||||
"clear", // Escape Codes
|
||||
"config", // Modal
|
||||
"context", // Escape Codes
|
||||
"cost", // Escape Codes
|
||||
"doctor", // Escape Codes
|
||||
"exit",
|
||||
"export", // Modal
|
||||
"help", // Modal
|
||||
"hooks", // Modal
|
||||
"ide", // Modal
|
||||
"install-github-app", // Modal
|
||||
"context",
|
||||
"cost",
|
||||
"login",
|
||||
"logout",
|
||||
"memory",
|
||||
"mcp",
|
||||
"migrate-installer", // Modal
|
||||
"model", // Not supported via SDK?
|
||||
"output-style", // Modal
|
||||
"output-style:new", // Modal
|
||||
"permissions", // Modal
|
||||
"privacy-settings",
|
||||
"release-notes", // Escape Codes
|
||||
"resume",
|
||||
"status", // Not supported via SDK?
|
||||
"statusline", // Not needed
|
||||
"terminal-setup", // Not needed
|
||||
"todos", // Escape Codes
|
||||
"vim", // Not needed
|
||||
"output-style:new",
|
||||
"release-notes",
|
||||
"todos",
|
||||
];
|
||||
const commands = await query.supportedCommands();
|
||||
|
||||
return commands
|
||||
.map((command) => {
|
||||
const input = command.argumentHint ? { hint: command.argumentHint } : null;
|
||||
let name = command.name;
|
||||
if (command.name.endsWith(" (MCP)")) {
|
||||
name = `mcp:${name.replace(" (MCP)", "")}`;
|
||||
}
|
||||
return {
|
||||
name: command.name,
|
||||
name,
|
||||
description: command.description || "",
|
||||
input,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(command: AvailableCommand) =>
|
||||
!(command.name.match(/\(MCP\)/) || UNSUPPORTED_COMMANDS.includes(command.name)),
|
||||
);
|
||||
.filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name));
|
||||
}
|
||||
|
||||
function formatUriAsLink(uri: string): string {
|
||||
@@ -537,15 +712,23 @@ function formatUriAsLink(uri: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
const content: any[] = [];
|
||||
const context: any[] = [];
|
||||
|
||||
for (const chunk of prompt.prompt) {
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
content.push({ type: "text", text: chunk.text });
|
||||
case "text": {
|
||||
let text = chunk.text;
|
||||
// change /mcp:server:command args -> /server:command (MCP) args
|
||||
const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/);
|
||||
if (mcpMatch) {
|
||||
const [, server, command, args] = mcpMatch;
|
||||
text = `/${server}:${command} (MCP)${args || ""}`;
|
||||
}
|
||||
content.push({ type: "text", text });
|
||||
break;
|
||||
}
|
||||
case "resource_link": {
|
||||
const formattedUri = formatUriAsLink(chunk.uri);
|
||||
content.push({
|
||||
@@ -613,20 +796,19 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
* Only handles text, image, and thinking chunks for now.
|
||||
*/
|
||||
export function toAcpNotifications(
|
||||
message: SDKAssistantMessage | SDKUserMessage | SDKUserMessageReplay,
|
||||
content: string | ContentBlockParam[] | BetaContentBlock[] | BetaRawContentBlockDelta[],
|
||||
role: "assistant" | "user",
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
client: AgentSideConnection,
|
||||
): SessionNotification[] {
|
||||
const content = message.message.content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
return [
|
||||
{
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate:
|
||||
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: content,
|
||||
@@ -642,9 +824,9 @@ export function toAcpNotifications(
|
||||
let update: SessionNotification["update"] | null = null;
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
case "text_delta":
|
||||
update = {
|
||||
sessionUpdate:
|
||||
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.text,
|
||||
@@ -653,8 +835,7 @@ export function toAcpNotifications(
|
||||
break;
|
||||
case "image":
|
||||
update = {
|
||||
sessionUpdate:
|
||||
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "image",
|
||||
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
||||
@@ -664,6 +845,7 @@ export function toAcpNotifications(
|
||||
};
|
||||
break;
|
||||
case "thinking":
|
||||
case "thinking_delta":
|
||||
update = {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
@@ -673,13 +855,45 @@ export function toAcpNotifications(
|
||||
};
|
||||
break;
|
||||
case "tool_use":
|
||||
case "server_tool_use":
|
||||
case "mcp_tool_use": {
|
||||
toolUseCache[chunk.id] = chunk;
|
||||
if (chunk.name === "TodoWrite") {
|
||||
update = {
|
||||
sessionUpdate: "plan",
|
||||
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
|
||||
};
|
||||
// @ts-expect-error - sometimes input is empty object
|
||||
if (Array.isArray(chunk.input.todos)) {
|
||||
update = {
|
||||
sessionUpdate: "plan",
|
||||
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Register hook callback to receive the structured output from the hook
|
||||
registerHookCallback(chunk.id, {
|
||||
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
|
||||
const toolUse = toolUseCache[toolUseId];
|
||||
if (toolUse) {
|
||||
const update: SessionNotification["update"] = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolResponse,
|
||||
toolName: toolUse.name,
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: "tool_call_update",
|
||||
};
|
||||
await client.sessionUpdate({
|
||||
sessionId,
|
||||
update,
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let rawInput;
|
||||
try {
|
||||
rawInput = JSON.parse(JSON.stringify(chunk.input));
|
||||
@@ -687,6 +901,11 @@ export function toAcpNotifications(
|
||||
// ignore if we can't turn it to JSON
|
||||
}
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: chunk.name,
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
toolCallId: chunk.id,
|
||||
sessionUpdate: "tool_call",
|
||||
rawInput,
|
||||
@@ -695,8 +914,15 @@ export function toAcpNotifications(
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_result": {
|
||||
case "tool_result":
|
||||
case "web_fetch_tool_result":
|
||||
case "web_search_tool_result":
|
||||
case "code_execution_tool_result":
|
||||
case "bash_code_execution_tool_result":
|
||||
case "text_editor_code_execution_tool_result":
|
||||
case "mcp_tool_result": {
|
||||
const toolUse = toolUseCache[chunk.tool_use_id];
|
||||
if (!toolUse) {
|
||||
console.error(
|
||||
@@ -707,17 +933,31 @@ export function toAcpNotifications(
|
||||
|
||||
if (toolUse.name !== "TodoWrite") {
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: toolUse.name,
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: chunk.is_error ? "failed" : "completed",
|
||||
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
|
||||
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "document":
|
||||
case "search_result":
|
||||
case "redacted_thinking":
|
||||
case "input_json_delta":
|
||||
case "citations_delta":
|
||||
case "signature_delta":
|
||||
case "container_upload":
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error("unhandled chunk type: " + chunk.type);
|
||||
unreachable(chunk);
|
||||
}
|
||||
if (update) {
|
||||
output.push({ sessionId, update });
|
||||
@@ -727,6 +967,45 @@ export function toAcpNotifications(
|
||||
return output;
|
||||
}
|
||||
|
||||
export function streamEventToAcpNotifications(
|
||||
message: SDKPartialAssistantMessage,
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
client: AgentSideConnection,
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
switch (event.type) {
|
||||
case "content_block_start":
|
||||
return toAcpNotifications(
|
||||
[event.content_block],
|
||||
"assistant",
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
fileContentCache,
|
||||
client,
|
||||
);
|
||||
case "content_block_delta":
|
||||
return toAcpNotifications(
|
||||
[event.delta],
|
||||
"assistant",
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
fileContentCache,
|
||||
client,
|
||||
);
|
||||
// No content
|
||||
case "message_start":
|
||||
case "message_delta":
|
||||
case "message_stop":
|
||||
case "content_block_stop":
|
||||
return [];
|
||||
|
||||
default:
|
||||
unreachable(event);
|
||||
}
|
||||
}
|
||||
|
||||
export function runAcp() {
|
||||
const input = nodeToWebWritable(process.stdout);
|
||||
const output = nodeToWebReadable(process.stdin);
|
||||
|
||||
@@ -19,7 +19,7 @@ process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
import { runAcp as runAcp } from "./acp-agent.js";
|
||||
import { runAcp } from "./acp-agent.js";
|
||||
runAcp();
|
||||
|
||||
// Keep process alive
|
||||
|
||||
21
src/lib.ts
Normal file
21
src/lib.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Export the main agent class and utilities for library usage
|
||||
export {
|
||||
ClaudeAcpAgent,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
applyEnvironmentSettings,
|
||||
nodeToWebReadable,
|
||||
nodeToWebWritable,
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { createMcpServer, toolNames } from "./mcp-server.js";
|
||||
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
@@ -1,15 +1,10 @@
|
||||
import express from "express";
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
import { Server } from "node:http";
|
||||
import { ClaudeAcpAgent } from "./acp-agent.js";
|
||||
import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
|
||||
import * as diff from "diff";
|
||||
|
||||
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
|
||||
import { PermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
@@ -38,7 +33,7 @@ export const toolNames = {
|
||||
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
|
||||
};
|
||||
|
||||
const editToolNames = [toolNames.edit, toolNames.write];
|
||||
export const EDIT_TOOL_NAMES = [toolNames.edit, toolNames.write];
|
||||
|
||||
export function createMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
@@ -578,189 +573,6 @@ In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
|
||||
return server;
|
||||
}
|
||||
|
||||
const UNQUALIFIED_PERMISSION_TOOL_NAME = "permission";
|
||||
const PERMISSION_SERVER_PREFIX = "mcp__acpPermission__";
|
||||
export const PERMISSION_TOOL_NAME = PERMISSION_SERVER_PREFIX + UNQUALIFIED_PERMISSION_TOOL_NAME;
|
||||
|
||||
/**
|
||||
* Separate permission tool. Needs to be an HTTP server for now, because Claude Code doesn't
|
||||
* support permission tools via SDK servers.
|
||||
* Ideally it moves to the `canUseTool` callback in the future.
|
||||
*/
|
||||
export function createPermissionMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
): Promise<Server> {
|
||||
const server = new McpServer(
|
||||
{ name: "acpPermission", version: "1.0.0" },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
const alwaysAllowedTools: { [key: string]: boolean } = {};
|
||||
server.registerTool(
|
||||
UNQUALIFIED_PERMISSION_TOOL_NAME,
|
||||
{
|
||||
title: "Permission Tool",
|
||||
description: "Used to request tool permissions",
|
||||
inputSchema: {
|
||||
tool_name: z.string(),
|
||||
input: z.any(),
|
||||
tool_use_id: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const result = await canUseTool(input);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
async function canUseTool(input: {
|
||||
tool_use_id?: string;
|
||||
tool_name: string;
|
||||
input?: any;
|
||||
}): Promise<PermissionResult> {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "Session not found",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.tool_name === "ExitPlanMode") {
|
||||
const response = await agent.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Yes, and auto-accept edits",
|
||||
optionId: "acceptEdits",
|
||||
},
|
||||
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
|
||||
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: input.tool_use_id!,
|
||||
rawInput: input.input,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
|
||||
) {
|
||||
session.permissionMode = response.outcome.optionId;
|
||||
await agent.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: response.outcome.optionId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
updatedPermissions: [
|
||||
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User rejected request to exit plan mode.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
session.permissionMode === "bypassPermissions" ||
|
||||
(session.permissionMode === "acceptEdits" && editToolNames.includes(input.tool_name)) ||
|
||||
alwaysAllowedTools[input.tool_name]
|
||||
) {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await agent.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
optionId: "allow_always",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: input.tool_use_id!,
|
||||
rawInput: input.input,
|
||||
},
|
||||
});
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
|
||||
) {
|
||||
if (response.outcome.optionId === "allow_always") {
|
||||
alwaysAllowedTools[input.tool_name] = true;
|
||||
}
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/mcp", async (req, res) => {
|
||||
try {
|
||||
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
res.on("close", () => {
|
||||
transport.close();
|
||||
server.close();
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Internal server error: ${error}`,
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const listener = app.listen(0, "127.0.0.1", (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(listener);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stripCommonPrefix(a: string, b: string): string {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AvailableCommand,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
|
||||
import { toAcpNotifications } from "../acp-agent.js";
|
||||
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
|
||||
@@ -649,7 +650,16 @@ describe("tool conversions", () => {
|
||||
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
|
||||
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
|
||||
};
|
||||
expect(toAcpNotifications(received, "test", {}, {})).toStrictEqual([
|
||||
expect(
|
||||
toAcpNotifications(
|
||||
received.message.content,
|
||||
received.message.role,
|
||||
"test",
|
||||
{},
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{
|
||||
sessionId: "test",
|
||||
update: {
|
||||
@@ -781,6 +791,44 @@ describe("escape markdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt conversion", () => {
|
||||
it("should not change built-in slash commands", () => {
|
||||
const message = promptToClaude({
|
||||
sessionId: "test",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/compact args",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(message.message.content).toEqual([
|
||||
{
|
||||
text: "/compact args",
|
||||
type: "text",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should remove MCP prefix from MCP slash commands", () => {
|
||||
const message = promptToClaude({
|
||||
sessionId: "test",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/mcp:server:name args",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(message.message.content).toEqual([
|
||||
{
|
||||
text: "/server:name (MCP) args",
|
||||
type: "text",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SDK behavior", () => {
|
||||
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
"query has a 'default' model",
|
||||
|
||||
96
src/tools.ts
96
src/tools.ts
@@ -1,6 +1,15 @@
|
||||
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
|
||||
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -105,12 +114,14 @@ export function toolInfoFromToolUse(
|
||||
return {
|
||||
title: "Read " + (input.file_path ?? "File") + limit,
|
||||
kind: "read",
|
||||
locations: [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
],
|
||||
locations: input.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
@@ -336,7 +347,7 @@ export function toolInfoFromToolUse(
|
||||
|
||||
case "TodoWrite":
|
||||
return {
|
||||
title: input?.todos
|
||||
title: Array.isArray(input?.todos)
|
||||
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
||||
: "Update TODOs",
|
||||
kind: "think",
|
||||
@@ -385,7 +396,15 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult: ToolResultBlockParam,
|
||||
toolResult:
|
||||
| ToolResultBlockParam
|
||||
| BetaWebSearchToolResultBlockParam
|
||||
| BetaWebFetchToolResultBlockParam
|
||||
| WebSearchToolResultBlockParam
|
||||
| BetaCodeExecutionToolResultBlockParam
|
||||
| BetaBashCodeExecutionToolResultBlockParam
|
||||
| BetaTextEditorCodeExecutionToolResultBlockParam
|
||||
| BetaRequestMCPToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
): ToolUpdate {
|
||||
switch (toolUse?.name) {
|
||||
@@ -425,7 +444,12 @@ export function toolUpdateFromToolResult(
|
||||
case toolNames.edit:
|
||||
case toolNames.write:
|
||||
case "Write": {
|
||||
if (toolResult.is_error && toolResult.content && toolResult.content.length > 0) {
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
}
|
||||
@@ -451,7 +475,10 @@ export function toolUpdateFromToolResult(
|
||||
case "WebSearch":
|
||||
case "Other":
|
||||
default: {
|
||||
return toAcpContentUpdate(toolResult.content, toolResult.is_error);
|
||||
return toAcpContentUpdate(
|
||||
toolResult.content,
|
||||
"is_error" in toolResult ? toolResult.is_error : false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,3 +539,50 @@ export function markdownEscape(text: string): string {
|
||||
}
|
||||
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
|
||||
}
|
||||
|
||||
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
|
||||
const toolUseCallbacks: {
|
||||
[toolUseId: string]: {
|
||||
onPostToolUseHook?: (
|
||||
toolUseID: string,
|
||||
toolInput: unknown,
|
||||
toolResponse: unknown,
|
||||
) => Promise<void>;
|
||||
};
|
||||
} = {};
|
||||
|
||||
/* Setup callbacks that will be called when receiving hooks from Claude Code */
|
||||
export const registerHookCallback = (
|
||||
toolUseID: string,
|
||||
{
|
||||
onPostToolUseHook,
|
||||
}: {
|
||||
onPostToolUseHook?: (
|
||||
toolUseID: string,
|
||||
toolInput: unknown,
|
||||
toolResponse: unknown,
|
||||
) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
toolUseCallbacks[toolUseID] = {
|
||||
onPostToolUseHook,
|
||||
};
|
||||
};
|
||||
|
||||
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
||||
export const postToolUseHook: HookCallback = async (
|
||||
input: any,
|
||||
toolUseID: string | undefined,
|
||||
): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse" && toolUseID) {
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
console.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
@@ -76,7 +76,13 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
|
||||
}
|
||||
|
||||
export function unreachable(value: never): never {
|
||||
throw new Error(`Unexpected case: ${value}`);
|
||||
let valueAsString;
|
||||
try {
|
||||
valueAsString = JSON.stringify(value);
|
||||
} catch {
|
||||
valueAsString = value;
|
||||
}
|
||||
throw new Error(`Unexpected case: ${valueAsString}`);
|
||||
}
|
||||
|
||||
export function sleep(time: number): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user