Compare commits

..

2 Commits

Author SHA1 Message Date
Agus Zubiaga
0df3dea08d 0.1.11 2025-09-02 23:13:44 -03:00
Agus Zubiaga
182a40ccae Add backticks around glob tool label 2025-09-02 23:12:08 -03:00
16 changed files with 1437 additions and 2084 deletions

6
.eslintignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
*.min.js
*.config.js
.github/

33
.eslintrc.json Normal file
View File

@@ -0,0 +1,33 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"env": {
"node": true,
"es2020": true
},
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"no-console": "off",
"no-constant-condition": "off",
"default-case": "error",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": ["error", "always"],
"curly": ["error", "all"]
},
"ignorePatterns": ["dist", "node_modules", "*.js"]
}

View File

@@ -6,7 +6,6 @@ on:
jobs:
build:
runs-on: ubuntu-latest
environment: release # Optional: for enhanced security
permissions:
contents: read
id-token: write
@@ -17,8 +16,8 @@ jobs:
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"
- name: Update npm
run: npm install -g npm@latest
- run: npm ci
- run: npm run build
- run: npm publish
# Add back provenance once repo is public
- run: npm publish # --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -1,87 +0,0 @@
# Changelog
## 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.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.19
## 0.6.1
- Fix to allow bypassPermissions mode to be selected (it wasn't permitted previously)
## 0.6.0
- Provide a model selector. We use the "default" model by default, and the user can change it via the client.
- Make sure writes require permissions when necessary: https://github.com/zed-industries/claude-code-acp/pull/92
- Add support for appending or overriding the system prompt: https://github.com/zed-industries/claude-code-acp/pull/91
- Update to @anthropic-ai/claude-agent-sdk@v0.1.15
- Update to @agentclientprotocol/sdk@0.4.8
## 0.5.5
- Migrate to @agentclientprotocol/sdk@0.4.5
- Update to @anthropic-ai/claude-agent-sdk@v0.1.13
## 0.5.4
- Update to @anthropic-ai/claude-agent-sdk@v0.1.11
- Enable setting CLAUDE_CODE_EXECUTABLE to override the executable used by the SDK https://github.com/zed-industries/claude-code-acp/pull/86
## 0.5.3
- Update to @anthropic-ai/claude-agent-sdk@v0.1.8
- Update to @zed-industries/agent-client-protocol@v0.4.5
## 0.5.2
- Add back @anthropic-ai/claude-code@2.0.1 as runtime dependency
## 0.5.1
- Update to @anthropic-ai/claude-agent-sdk@v0.1.1
- Make improvements to ACP tools provided to the model
## 0.5.0
- Migrate to @anthropic-ai/claude-agent-sdk@v0.1.0
## v0.4.7
- More efficient file reads from the client.
## v0.4.6
- Update to @anthropic-ai/claude-code@v1.0.128
## v0.4.5
- Update to @anthropic-ai/claude-code@v1.0.124
- Update to @zed-industries/agent-client-protocol@v0.4.3
## v0.4.4
- Update to @anthropic-ai/claude-code@v1.0.123
- Update to @zed-industries/agent-client-protocol@v0.4.2
## v0.4.3
- Move ACP tools over MCP from an "http" MCP server to an "sdk" one so more tool calls can stay in-memory.
- Update to @anthropic-ai/claude-code@v1.0.119
- Update to @zed-industries/agent-client-protocol@v0.4.0
## v0.4.2
- Fix missing package.json metadata
## v0.4.1
- Add support for /compact command [ecfd36a](https://github.com/zed-industries/claude-code-acp/commit/ecfd36afa6c4e31f12e1daf9b8a2bdc12dda1794)
- Add default limits to read tool [7bd1638](https://github.com/zed-industries/claude-code-acp/commit/7bd163818bb959b11fd2c933eff73ad83c57abb8)
- Better rendering of Tool errors [491efe3](https://github.com/zed-industries/claude-code-acp/commit/491efe32e8547075842e448d873fc01b2ffabf3a)
- Load managed-settings.json [f691024](https://github.com/zed-industries/claude-code-acp/commit/f691024350362858e00b97248ac68e356d2331c2)
- Update to @anthropic-ai/claude-code@v1.0.113
- Update to @zed-industries/agent-client-protocol@v0.3.1

View File

@@ -1,14 +1,12 @@
# ACP adapter for Claude Code
[![npm](https://img.shields.io/npm/v/%40zed-industries%2Fclaude-code-acp)](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
This tool implements an ACP agent by using the official [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-overview), supporting:
- Context @-mentions
- Images
- Tool calls (with permission requests)
- Tool calls
- Following
- Edit review
- TODO lists
@@ -28,30 +26,24 @@ To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support.
[Read the docs](https://zed.dev/docs/ai/external-agents) on External Agent support.
### Other clients
- Emacs via [agent-shell.el](https://github.com/xenodium/agent-shell)
- [marimo notebook](https://github.com/marimo-team/marimo)
- Neovim
- via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
- via [yetone/avante.nvim](https://github.com/yetone/avante.nvim)
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
Setup instructions for other clients are coming soon. Feel free to [submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
#### Installation
Install the adapter from `npm`:
```bash
npm install @zed-industries/claude-code-acp
$ npm install @zed-industries/claude-code-acp
```
You can then use `claude-code-acp` as a regular ACP agent:
```
ANTHROPIC_API_KEY=sk-... claude-code-acp
$ ANTHROPIC_API_KEY=sk-... claude-code-acp
```
## License

View File

@@ -1,48 +0,0 @@
import js from "@eslint/js";
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import prettierConfig from "eslint-config-prettier";
import globals from "globals";
export default [
{
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
},
js.configs.recommended,
{
files: ["src/**/*.ts"],
languageOptions: {
ecmaVersion: 2020,
sourceType: "module",
parser: tsparser,
parserOptions: {
project: "./tsconfig.json",
},
globals: {
...globals.node,
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
},
],
"no-console": "off",
"no-constant-condition": "off",
"default-case": "error",
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always"],
curly: ["error", "all"],
},
},
prettierConfig,
];

1518
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.6.3",
"version": "0.1.11",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
@@ -40,41 +40,29 @@
"sdk",
"code"
],
"homepage": "https://github.com/zed-industries/claude-code-acp#readme",
"bugs": {
"url": "https://github.com/zed-industries/claude-code-acp/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/zed-industries/claude-code-acp.git"
},
"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",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
"uuid": "13.0.0"
"@anthropic-ai/claude-code": "^1.0.100",
"@modelcontextprotocol/sdk": "^1.17.4",
"@types/express": "^5.0.3",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.66.0",
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.8.0",
"@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",
"eslint-config-prettier": "10.1.8",
"globals": "16.4.0",
"prettier": "3.6.2",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "3.2.4"
"@types/minimist": "^1.2.5",
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitest/ui": "^3.2.4",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^3.2.4"
}
}

View File

@@ -7,7 +7,6 @@ import {
ClientCapabilities,
InitializeRequest,
InitializeResponse,
ndJsonStream,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
@@ -15,51 +14,33 @@ import {
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
SessionModelState,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
} from "@zed-industries/agent-client-protocol";
import {
McpServerConfig,
Options,
PermissionMode,
Query,
query,
SDKAssistantMessage,
SDKUserMessage,
SDKUserMessageReplay,
} from "@anthropic-ai/claude-agent-sdk";
} from "@anthropic-ai/claude-code";
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 { nodeToWebReadable, nodeToWebWritable, Pushable, sleep, unreachable } from "./utils.js";
import { SessionNotification } from "@zed-industries/agent-client-protocol";
import { createMcpServer } from "./mcp-server.js";
import { AddressInfo } from "node:net";
import {
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
ClaudePlanEntry,
} from "./tools.js";
import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
type Session = {
query: Query;
input: Pushable<SDKUserMessage>;
cancelled: boolean;
permissionMode: PermissionMode;
};
type BackgroundTerminal =
@@ -77,8 +58,6 @@ type ToolUseCache = {
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
};
const DEFAULT_MODEL_ID = "default";
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
sessions: {
@@ -107,66 +86,42 @@ export class ClaudeAcpAgent implements Agent {
image: true,
embeddedContext: true,
},
mcpCapabilities: {
http: true,
sse: true,
},
},
authMethods: [
{
description: "Run `claude /login` in the terminal",
name: "Log in with Claude Code",
name: "Login with Claude CLI",
id: "claude-login",
},
{
description: "Anthropic API KEY",
name: "Use Anthropic API Key",
id: "anthropic-api-key",
},
],
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
if (
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
) {
throw RequestError.authRequired();
}
const sessionId = uuidv7();
const input = new Pushable<SDKUserMessage>();
const mcpServers: Record<string, McpServerConfig> = {};
if (Array.isArray(params.mcpServers)) {
for (const server of params.mcpServers) {
if ("type" in server) {
mcpServers[server.name] = {
type: server.type,
url: server.url,
headers: server.headers
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
: undefined,
};
} else {
mcpServers[server.name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
: undefined,
};
}
mcpServers[server.name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
: undefined,
};
}
}
const server = createMcpServer(this, sessionId, this.clientCapabilities);
const server = await createMcpServer(this, sessionId, this.clientCapabilities);
const address = server.address() as AddressInfo;
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: {
@@ -174,50 +129,26 @@ export class ClaudeAcpAgent implements Agent {
},
};
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
if (params._meta?.systemPrompt) {
const customPrompt = params._meta.systemPrompt;
if (typeof customPrompt === "string") {
systemPrompt = customPrompt;
} else if (
typeof customPrompt === "object" &&
"append" in customPrompt &&
typeof customPrompt.append === "string"
) {
systemPrompt.append = customPrompt.append;
}
}
const options: Options = {
cwd: params.cwd,
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,
permissionPromptToolName: "mcp__acp__permission",
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,
...(process.env.CLAUDE_CODE_EXECUTABLE && {
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
}),
};
const allowedTools = [];
const disallowedTools = [];
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push(toolNames.read);
allowedTools.push("mcp__acp__read");
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
disallowedTools.push("Write", "Edit");
allowedTools.push("mcp__acp__write");
disallowedTools.push("Write", "Edit", "MultiEdit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
disallowedTools.push("Bash", "BashOutput", "KillShell");
allowedTools.push("mcp__acp__BashOutput", "mcp__acp__KillBash");
disallowedTools.push("Bash", "BashOutput", "KillBash");
}
if (allowedTools.length > 0) {
@@ -231,57 +162,16 @@ 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,
cancelled: false,
permissionMode,
};
getAvailableSlashCommands(q).then((availableCommands) => {
setTimeout(() => {
this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands,
},
});
}, 0);
});
const availableCommands = await availableSlashCommands(q);
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",
},
],
},
availableCommands,
};
}
@@ -309,14 +199,6 @@ export class ClaudeAcpAgent implements Agent {
}
switch (message.type) {
case "system":
switch (message.subtype) {
case "init":
break;
case "compact_boundary":
break;
default:
unreachable(message as never);
}
break;
case "result": {
if (this.sessions[params.sessionId].cancelled) {
@@ -342,42 +224,16 @@ export class ClaudeAcpAgent implements Agent {
case "user":
case "assistant": {
if (this.sessions[params.sessionId].cancelled) {
break;
}
// Slash commands like /compact can generate invalid output... doesn't match
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
if (
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stdout>")
) {
console.log(message.message.content);
break;
continue;
}
if (
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stderr>")
) {
console.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") {
break;
}
if (
message.type === "assistant" &&
message.message.model === "<synthetic>" &&
Array.isArray(message.message.content) &&
message.message.content.length === 1 &&
message.message.content[0].type === "text" &&
message.message.content[0].text.includes("Please run /login")
) {
throw RequestError.authRequired();
}
for (const notification of toAcpNotifications(
message,
params.sessionId,
@@ -389,7 +245,7 @@ export class ClaudeAcpAgent implements Agent {
break;
}
default:
unreachable(message as never);
unreachable(message);
}
}
throw new Error("Session did not end in result");
@@ -403,38 +259,6 @@ export class ClaudeAcpAgent implements Agent {
await this.sessions[params.sessionId].query.interrupt();
}
async setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse | void> {
if (!this.sessions[params.sessionId]) {
throw new Error("Session not found");
}
await this.sessions[params.sessionId].query.setModel(params.modelId);
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
if (!this.sessions[params.sessionId]) {
throw new Error("Session not found");
}
switch (params.modeId) {
case "default":
case "acceptEdits":
case "bypassPermissions":
case "plan":
this.sessions[params.sessionId].permissionMode = params.modeId;
try {
await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
} catch (error) {
const errorMessage =
error instanceof Error && error.message ? error.message : "Invalid Mode";
throw new Error(errorMessage);
}
return {};
default:
throw new Error("Invalid Mode");
}
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const response = await this.client.readTextFile(params);
if (!params.limit && !params.line) {
@@ -450,33 +274,14 @@ 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];
await query.setModel(currentModel.value);
const availableModels = models.map((model) => ({
modelId: model.value,
name: model.displayName,
description: model.description,
}));
return {
availableModels,
currentModelId: currentModel.value,
};
}
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
async function availableSlashCommands(query: Query): Promise<AvailableCommand[]> {
const UNSUPPORTED_COMMANDS = [
"add-dir",
"agents", // Modal
"bashes", // Modal
"bug", // Modal
"clear", // Escape Codes
"compact", // Not supported via SDK?
"config", // Modal
"context", // Escape Codes
"cost", // Escape Codes
@@ -505,10 +310,23 @@ async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand
"todos", // Escape Codes
"vim", // Not needed
];
const commands = await query.supportedCommands();
const commands = await Promise.race([
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
(query as any).supportedCommands(),
sleep(5000).then(() => {
if (
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
) {
throw RequestError.authRequired();
}
throw new Error("can't load supported slash commands");
}),
]);
return commands
.map((command) => {
.map((command: { name: string; description: string; argumentHint: string }) => {
const input = command.argumentHint ? { hint: command.argumentHint } : null;
return {
name: command.name,
@@ -615,38 +433,20 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
* Only handles text, image, and thinking chunks for now.
*/
export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage | SDKUserMessageReplay,
message: SDKAssistantMessage | SDKUserMessage,
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
): SessionNotification[] {
const content = message.message.content;
if (typeof content === "string") {
return [
{
sessionId,
update: {
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: content,
},
},
},
];
}
const chunks = message.message.content as ContentChunk[];
const output = [];
// Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of content) {
for (const chunk of chunks) {
let update: SessionNotification["update"] | null = null;
switch (chunk.type) {
case "text":
update = {
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: chunk.text,
@@ -655,8 +455,7 @@ export function toAcpNotifications(
break;
case "image":
update = {
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
sessionUpdate: "agent_message_chunk",
content: {
type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "",
@@ -679,19 +478,13 @@ export function toAcpNotifications(
if (chunk.name === "TodoWrite") {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
entries: planEntries(chunk.input),
};
} else {
let rawInput;
try {
rawInput = JSON.parse(JSON.stringify(chunk.input));
} catch {
// ignore if we can't turn it to JSON
}
update = {
toolCallId: chunk.id,
sessionUpdate: "tool_call",
rawInput,
rawInput: chunk.input,
status: "pending",
...toolInfoFromToolUse(chunk, fileContentCache),
};
@@ -730,9 +523,30 @@ export function toAcpNotifications(
}
export function runAcp() {
const input = nodeToWebWritable(process.stdout);
const output = nodeToWebReadable(process.stdin);
const stream = ndJsonStream(input, output);
new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
new AgentSideConnection(
(client) => new ClaudeAcpAgent(client),
nodeToWebWritable(process.stdout),
nodeToWebReadable(process.stdin),
);
}
type ContentChunk =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: any }
| {
type: "tool_result";
content: string;
tool_use_id: string;
is_error: boolean;
} // content type depends on your Content definition
| { type: "thinking"; thinking: string }
| { type: "redacted_thinking" }
| { type: "image"; source: ImageSource }
| { type: "document" }
| { type: "web_search_tool_result" }
| { type: "untagged_text"; text: string };
// Example ImageSource type (adjust as needed)
type ImageSource =
| { type: "base64"; data: string; media_type: string }
| { type: "url"; url: string };

View File

@@ -1,13 +1,5 @@
#!/usr/bin/env node
// Load managed settings and apply environment variables
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
const managedSettings = loadManagedSettings();
if (managedSettings) {
applyEnvironmentSettings(managedSettings);
}
// stdout is used to send messages to the client
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
console.log = console.error;
@@ -15,10 +7,6 @@ console.info = console.error;
console.warn = console.error;
console.debug = console.error;
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
import { runAcp as runAcp } from "./acp-agent.js";
runAcp();

View File

@@ -5,11 +5,9 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
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 { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agent-client-protocol";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
import { PermissionResult } from "@anthropic-ai/claude-agent-sdk";
import { sleep, unreachable } from "./utils.js";
export const SYSTEM_REMINDER = `
@@ -17,72 +15,34 @@ export const SYSTEM_REMINDER = `
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
</system-reminder>`;
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
const unqualifiedToolNames = {
read: "Read",
edit: "Edit",
write: "Write",
bash: "Bash",
killShell: "KillShell",
bashOutput: "BashOutput",
};
const SERVER_PREFIX = "mcp__acp__";
export const toolNames = {
read: SERVER_PREFIX + unqualifiedToolNames.read,
edit: SERVER_PREFIX + unqualifiedToolNames.edit,
write: SERVER_PREFIX + unqualifiedToolNames.write,
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
killShell: SERVER_PREFIX + unqualifiedToolNames.killShell,
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
};
const editToolNames = [toolNames.edit, toolNames.write];
export function createMcpServer(
agent: ClaudeAcpAgent,
sessionId: string,
clientCapabilities: ClientCapabilities | undefined,
): McpServer {
): Promise<Server> {
// Create MCP server
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
const server = new McpServer({
name: "acp-mcp-server",
version: "1.0.0",
});
if (clientCapabilities?.fs?.readTextFile) {
server.registerTool(
unqualifiedToolNames.read,
"read",
{
title: unqualifiedToolNames.read,
title: "Read",
description: `Reads the content of the given file in the project.
In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.
Never attempt to read a path that hasn't been previously mentioned.
Reads a file from the local filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to ${defaults.linesToRead} lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any files larger than ${defaults.maxFileSize} bytes will be truncated
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
- This tool can only read files, not directories. To read a directory, use an ls command via the ${toolNames.bash} tool.
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.`,
In sessions with mcp__acp__read always use it instead of Read as it contains the most up-to-date contents.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to read"),
abs_path: z.string().describe("The absolute path to the file to read."),
offset: z
.number()
.optional()
.default(1)
.describe(
"The line number to start reading from. Only provide if the file is too large to read at once",
),
limit: z
.number()
.optional()
.default(defaults.linesToRead)
.describe(
`The number of lines to read. Only provide if the file is too large to read at once.`,
),
.describe("Which line to start reading from. Omit to start from the beginning."),
limit: z.number().optional().describe("How many lines to read. Omit for the whole file."),
},
annotations: {
title: "Read file",
@@ -105,40 +65,18 @@ Usage:
],
};
}
const content = await agent.readTextFile({
sessionId,
path: input.file_path,
line: input.offset,
path: input.abs_path,
limit: input.limit,
line: input.offset,
});
// Extract lines with byte limit enforcement
const result = extractLinesWithByteLimit(content.content, defaults.maxFileSize);
// Construct informative message about what was read
let readInfo = "";
if (input.offset > 1 || result.wasLimited) {
readInfo = "\n\n<file-read-info>";
if (result.wasLimited) {
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
} else {
readInfo += `Read lines ${input.offset}-${result.linesRead}. `;
}
if (result.wasLimited) {
readInfo += `Continue with offset=${result.linesRead}.`;
}
readInfo += "</file-read-info>";
}
return {
content: [
{
type: "text",
text: result.content + readInfo + SYSTEM_REMINDER,
text: content.content + SYSTEM_REMINDER,
},
],
};
@@ -158,25 +96,16 @@ Usage:
if (clientCapabilities?.fs?.writeTextFile) {
server.registerTool(
unqualifiedToolNames.write,
"write",
{
title: unqualifiedToolNames.write,
description: `Writes a file to the local filesystem..
title: "Write",
description: `Writes content to the specified file in the project.
In sessions with ${toolNames.write} always use it instead of Write as it will
allow the user to conveniently review changes.
Usage:
- This tool will overwrite the existing file if there is one at the provided path.
- If this is an existing file, you MUST use the ${toolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
In sessions with mcp__acp__write always use it instead of Write as it will
allow the user to conveniently review changes.`,
inputSchema: {
file_path: z
.string()
.describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
abs_path: z.string().describe("The absolute path to the file to write"),
content: z.string().describe("The full content to write"),
},
annotations: {
title: "Write file",
@@ -201,7 +130,7 @@ Usage:
}
await agent.writeTextFile({
sessionId,
path: input.file_path,
path: input.abs_path,
content: input.content,
});
@@ -222,32 +151,27 @@ Usage:
);
server.registerTool(
unqualifiedToolNames.edit,
"edit",
{
title: unqualifiedToolNames.edit,
description: `Performs exact string replacements in files.
title: "Edit",
description: `Edit a file.
In sessions with ${toolNames.edit} always use it instead of Edit as it will
In sessions with mcp__acp__edit always use it instead of Edit as it will
allow the user to conveniently review changes.
Usage:
- You must use your \`${toolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
File editing instructions:
- The \`old_string\` param must match existing file content, including indentation.
- The \`old_string\` param must come from the actual file, not an outline.
- The \`old_string\` section must not be empty.
- Be minimal with replacements:
- For unique lines, include only those lines.
- For non-unique lines, include enough context to identify them.
- Do not escape quotes, newlines, or other characters.
- Only edit the specified file.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to modify"),
old_string: z.string().describe("The text to replace"),
new_string: z
.string()
.describe("The text to replace it with (must be different from old_string)"),
replace_all: z
.boolean()
.default(false)
.optional()
.describe("Replace all occurences of old_string (default false)"),
abs_path: z.string().describe("The absolute path to the file to read."),
old_string: z.string().describe("The old text to replace (must be unique in the file)"),
new_string: z.string().describe("The new text."),
},
annotations: {
title: "Edit file",
@@ -257,6 +181,89 @@ Usage:
idempotentHint: false,
},
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const { content } = await agent.readTextFile({
sessionId,
path: input.abs_path,
});
const { newContent, lineNumbers } = replaceAndCalculateLocation(content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: false,
},
]);
await agent.writeTextFile({
sessionId,
path: input.abs_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers }),
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Editing file failed: " + error.message,
},
],
};
}
},
);
server.registerTool(
"multi-edit",
{
title: "Multi Edit",
description: `Edit a file with multiple sequential edits.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
old_string: z.string().describe("The text to replace"),
new_string: z.string().describe("The text to replace it with"),
replace_all: z
.boolean()
.optional()
.describe("Replace all occurrences of old_string (default false)"),
}),
)
.min(1)
.describe("Array of edit operations to perform sequentially on the file"),
},
annotations: {
title: "Multi Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
const session = agent.sessions[sessionId];
if (!session) {
@@ -275,15 +282,14 @@ Usage:
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);
const { newContent, lineNumbers } = replaceAndCalculateLocation(
content,
input.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all ?? false,
})),
);
await agent.writeTextFile({
sessionId,
@@ -295,7 +301,7 @@ Usage:
content: [
{
type: "text",
text: patch,
text: JSON.stringify({ lineNumbers }),
},
],
};
@@ -305,36 +311,21 @@ Usage:
if (agent.clientCapabilities?.terminal) {
server.registerTool(
unqualifiedToolNames.bash,
"Bash",
{
title: unqualifiedToolNames.bash,
description: `Executes a bash command
In sessions with ${toolNames.bash} always use it instead of Bash`,
title: "Bash",
description: "Executes a bash command",
inputSchema: {
command: z.string().describe("The command to execute"),
timeout: z
command: z.string().describe("The bash command to execute as a one-liner"),
timeout_ms: z
.number()
.default(2 * 60 * 1000)
.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
Output: List files in current directory
Input: git status
Output: Show working tree status
Input: npm install
Output: Install package dependencies
Input: mkdir foo
Output: Create directory 'foo'`),
.describe("Optional timeout in milliseconds"),
run_in_background: z
.boolean()
.default(false)
.describe(
`Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${toolNames.bashOutput}\` tool to retrieve the current output, or the \`${toolNames.killShell}\` tool to stop it early.`,
"When set to true, the command is started in the background. The tool returns an `id` that can be used with the `mcp__acp__BashOutput` tool to retrieve the current output, or the `mcp__acp__KillBash` tool to stop it early.",
),
},
},
@@ -373,7 +364,6 @@ Output: Create directory 'foo'`),
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
title: input.description,
content: [{ type: "terminal", terminalId: handle.id }],
},
});
@@ -391,7 +381,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_ms).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
@@ -456,27 +446,22 @@ Output: Create directory 'foo'`),
);
server.registerTool(
unqualifiedToolNames.bashOutput,
"BashOutput",
{
title: unqualifiedToolNames.bashOutput,
description: `- Retrieves output from a running or completed background bash shell
- Takes a shell_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.`,
title: "BashOutput",
description:
"Returns the current output and exit status of a background bash command by its id. Includes only new output since last invocation.",
inputSchema: {
shell_id: z
id: z
.string()
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
const bgTerm = agent.backgroundTerminals[input.id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
throw new Error(`Unknown shell ${input.id}`);
}
if (bgTerm.status === "started") {
@@ -512,26 +497,21 @@ In sessions with ${toolNames.bashOutput} always use it instead of BashOutput.`,
);
server.registerTool(
unqualifiedToolNames.killShell,
"KillBash",
{
title: unqualifiedToolNames.killShell,
description: `- Kills a running background bash shell by its ID
- Takes a shell_id parameter identifying the shell to kill
- Returns a success or failure status
- Use this tool when you need to terminate a long-running shell
In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
title: "KillBash",
description: "Stops a background command by its id",
inputSchema: {
shell_id: z
id: z
.string()
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.shell_id];
const bgTerm = agent.backgroundTerminals[input.id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
throw new Error(`Unknown shell ${input.id}`);
}
switch (bgTerm.status) {
@@ -575,30 +555,9 @@ 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,
"permission",
{
title: "Permission Tool",
description: "Used to request tool permissions",
@@ -609,37 +568,42 @@ export function createPermissionMcpServer(
},
},
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 session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: JSON.stringify({
behavior: "deny",
message: "Session not found",
}),
},
],
};
}
if (alwaysAllowedTools[input.tool_name]) {
return {
content: [
{
type: "text",
text: JSON.stringify({
behavior: "allow",
updatedInput: input.input,
}),
},
],
};
}
const response = await agent.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Yes, and auto-accept edits",
optionId: "acceptEdits",
name: "Always Allow",
optionId: "allow_always",
},
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
{ kind: "allow_once", name: "Allow", optionId: "allow" },
{ kind: "reject_once", name: "Reject", optionId: "reject" },
],
sessionId,
toolCall: {
@@ -647,80 +611,39 @@ export function createPermissionMcpServer(
rawInput: input.input,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
) {
session.permissionMode = response.outcome.optionId;
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: response.outcome.optionId,
},
});
if (response.outcome.optionId === "allow_always") {
alwaysAllowedTools[input.tool_name] = true;
}
return {
behavior: "allow",
updatedInput: input.input,
updatedPermissions: [
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
content: [
{
type: "text",
text: JSON.stringify({
behavior: "allow",
updatedInput: input.input,
}),
},
],
};
} else {
return {
behavior: "deny",
message: "User rejected request to exit plan mode.",
content: [
{
type: "text",
text: JSON.stringify({
behavior: "deny",
message: "User refused permission to run tool",
}),
},
],
};
}
}
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());
@@ -848,7 +771,7 @@ export function replaceAndCalculateLocation(
for (const edit of edits) {
// Skip empty oldText
if (edit.oldText === "") {
throw new Error(`The provided \`old_string\` is empty.\n\nNo edits were applied.`);
continue;
}
if (edit.replaceAll) {
@@ -860,11 +783,6 @@ export function replaceAndCalculateLocation(
while (true) {
const index = currentContent.indexOf(edit.oldText, searchIndex);
if (index === -1) {
if (searchIndex === 0) {
throw new Error(
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
);
}
break;
}
@@ -886,11 +804,7 @@ export function replaceAndCalculateLocation(
} else {
// Replace first occurrence only
const index = currentContent.indexOf(edit.oldText);
if (index === -1) {
throw new Error(
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
);
} else {
if (index !== -1) {
const marker = `${markerPrefix}${markerCounter++}__`;
markers.push(marker);
currentContent =

View File

@@ -2,10 +2,8 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
AvailableCommand,
Client,
ClientSideConnection,
ndJsonStream,
NewSessionResponse,
ReadTextFileRequest,
ReadTextFileResponse,
@@ -14,11 +12,11 @@ import {
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
} from "@zed-industries/agent-client-protocol";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications } from "../acp-agent.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
@@ -49,15 +47,9 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
agent: Agent;
files: Map<string, string> = new Map();
receivedText: string = "";
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
availableCommandsPromise: Promise<AvailableCommand[]>;
constructor(agent: Agent) {
this.agent = agent;
this.resolveAvailableCommands = () => {};
this.availableCommandsPromise = new Promise((resolve) => {
this.resolveAvailableCommands = resolve;
});
}
takeReceivedText() {
@@ -75,24 +67,17 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEIVED", JSON.stringify(params, null, 4));
switch (params.update.sessionUpdate) {
case "agent_message_chunk": {
if (params.update.content.type === "text") {
this.receivedText += params.update.content.text;
}
break;
}
case "available_commands_update":
this.resolveAvailableCommands(params.update.availableCommands);
break;
default:
break;
if (
params.update.sessionUpdate === "agent_message_chunk" &&
params.update.content.type === "text"
) {
this.receivedText += params.update.content.text;
}
}
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.files.set(params.path, params.content);
return {};
return null;
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
@@ -109,13 +94,14 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
newSessionResponse: NewSessionResponse;
}> {
let client;
const input = nodeToWebWritable(child.stdin!);
const output = nodeToWebReadable(child.stdout!);
const stream = ndJsonStream(input, output);
const connection = new ClientSideConnection((agent) => {
client = new TestClient(agent);
return client;
}, stream);
const connection = new ClientSideConnection(
(agent) => {
client = new TestClient(agent);
return client;
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
await connection.initialize({
protocolVersion: 1,
@@ -154,14 +140,12 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
it("should include available commands", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
const commands = await client.availableCommandsPromise;
expect(commands).toContainEqual({
expect(newSessionResponse.availableCommands).toContainEqual({
name: "quick-math",
description: "10 * 3 = 30 (project)",
input: null,
});
expect(commands).toContainEqual({
expect(newSessionResponse.availableCommands).toContainEqual({
name: "say-hello",
description: "Say hello (project)",
input: { hint: "[name]" },
@@ -191,50 +175,6 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
expect(client.takeReceivedText()).toContain("Hello GPT-5");
}, 30000);
it("/compact works", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
const commands = await client.availableCommandsPromise;
expect(commands).toContainEqual({
description:
"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]",
input: {
hint: "<optional custom summarization instructions>",
},
name: "compact",
});
// Error case (no previous message)
await connection.prompt({
prompt: [{ type: "text", text: "/compact" }],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toBe("");
// Send something
await connection.prompt({
prompt: [{ type: "text", text: "Hi" }],
sessionId: newSessionResponse.sessionId,
});
// Clear response
client.takeReceivedText();
// Test with instruction
await connection.prompt({
prompt: [
{
type: "text",
text: "/compact greeting",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("");
}, 30000);
});
describe("tool conversions", () => {
@@ -371,13 +311,13 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Write tool calls", () => {
it("should handle mcp__acp__write tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01GHI789JKL456",
name: "mcp__acp__Write",
name: "mcp__acp__write",
input: {
file_path: "/Users/test/project/config.json",
abs_path: "/Users/test/project/config.json",
content: '{"version": "1.0.0"}',
},
};
@@ -415,13 +355,13 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Read tool calls", () => {
it("should handle mcp__acp__read tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01YZA789BCD123",
name: "mcp__acp__Read",
name: "mcp__acp__read",
input: {
file_path: "/Users/test/project/data.json",
abs_path: "/Users/test/project/data.json",
},
};
@@ -433,13 +373,13 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Read with limit", () => {
it("should handle mcp__acp__read with limit", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EFG456HIJ789",
name: "mcp__acp__Read",
name: "mcp__acp__read",
input: {
file_path: "/Users/test/project/large.txt",
abs_path: "/Users/test/project/large.txt",
limit: 100,
},
};
@@ -452,13 +392,13 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Read with offset and limit", () => {
it("should handle mcp__acp__read with offset and limit", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01KLM789NOP456",
name: "mcp__acp__Read",
name: "mcp__acp__read",
input: {
file_path: "/Users/test/project/large.txt",
abs_path: "/Users/test/project/large.txt",
offset: 50,
limit: 100,
},
@@ -472,13 +412,13 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Read with only offset", () => {
it("should handle mcp__acp__read with only offset", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01QRS123TUV789",
name: "mcp__acp__Read",
name: "mcp__acp__read",
input: {
file_path: "/Users/test/project/large.txt",
abs_path: "/Users/test/project/large.txt",
offset: 200,
},
};
@@ -539,7 +479,7 @@ describe("tool conversions", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillShell",
name: "KillBash",
input: {
shell_id: "bash_1",
},
@@ -576,7 +516,6 @@ describe("tool conversions", () => {
id: "msg_017eNosJgww7F5qD4a8BcAcx",
type: "message",
role: "assistant",
container: null,
model: "claude-sonnet-4-20250514",
content: [
{
@@ -641,9 +580,7 @@ describe("tool conversions", () => {
},
output_tokens: 1,
service_tier: "standard",
server_tool_use: null,
},
context_management: null,
},
parent_tool_use_id: null,
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
@@ -701,13 +638,196 @@ describe("tool conversions", () => {
]);
});
it("should return empty update for successful edit result", () => {
it("should extract locations from edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01ABC123",
name: "edit",
input: {
abs_path: "/Users/test/project/example.txt",
old_string: "old content",
new_string: "new content",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [5, 10, 15] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/example.txt", line: 5 },
{ path: "/Users/test/project/example.txt", line: 10 },
{ path: "/Users/test/project/example.txt", line: 15 },
]);
});
it("should show full diff for multi edit tool", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01DEF456",
name: "mcp__acp__multi-edit",
input: {
file_path: "/Users/test/project/config.json",
edits: [
{
old_string: 'version": 1',
new_string: 'version": 2',
replace_all: false,
},
{
old_string: 'enabled": false',
new_string: 'enabled": true',
replace_all: true,
},
],
},
};
const fileCache: { [key: string]: string } = {
"/Users/test/project/config.json": JSON.stringify(
{
version: 1,
filler: "filler",
enabled: false,
},
null,
4,
),
};
expect(toolInfoFromToolUse(toolUse, fileCache)).toEqual({
content: [
{
newText: `{
"version": 2,
"filler": "filler",
"enabled": true
}`,
oldText: `{
"version": 1,
"filler": "filler",
"enabled": false
}`,
path: "/Users/test/project/config.json",
type: "diff",
},
],
kind: "edit",
locations: [
{
path: "/Users/test/project/config.json",
},
],
title: "Edit /Users/test/project/config.json",
});
});
it("should extract locations from multi-edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01DEF456",
name: "multi-edit",
input: {
file_path: "/Users/test/project/config.json",
edits: [
{
old_string: "version: 1.0",
new_string: "version: 2.0",
replace_all: false,
},
{
old_string: "enabled: false",
new_string: "enabled: true",
replace_all: true,
},
],
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [2, 8, 12, 20] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([
{ path: "/Users/test/project/config.json", line: 2 },
{ path: "/Users/test/project/config.json", line: 8 },
{ path: "/Users/test/project/config.json", line: 12 },
{ path: "/Users/test/project/config.json", line: 20 },
]);
});
it("should handle mcp__acp__edit tool results", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01GHI789",
name: "mcp__acp__edit",
input: {
abs_path: "/Users/test/project/main.py",
old_string: "def hello():",
new_string: "def hello_world():",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [42] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([{ path: "/Users/test/project/main.py", line: 42 }]);
});
it("should handle empty locations from edit tools", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01JKL012",
name: "edit",
input: {
abs_path: "/Users/test/project/notfound.txt",
old_string: "nonexistent",
new_string: "replacement",
},
};
const toolResult = {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers: [] }),
},
],
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([]);
});
it("should handle malformed tool results gracefully", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__Edit",
name: "edit",
input: {
file_path: "/Users/test/project/test.txt",
abs_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
},
@@ -716,13 +836,10 @@ describe("tool conversions", () => {
const toolResult = {
content: [
{
type: "text" as const,
type: "text",
text: "not valid json",
},
],
tool_use_id: "test",
is_error: false,
type: "tool_result" as const,
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
@@ -730,43 +847,6 @@ describe("tool conversions", () => {
// Should return empty object when parsing fails
expect(update).toEqual({});
});
it("should return content update for edit failure", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__Edit",
input: {
file_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
},
};
const toolResult = {
content: [
{
type: "text" as const,
text: "Failed to find `old_string`",
},
],
tool_use_id: "test",
is_error: true,
type: "tool_result" as const,
};
const update = toolUpdateFromToolResult(toolResult, toolUse);
// Should return empty object when parsing fails
expect(update).toEqual({
content: [
{
content: { type: "text", text: "```\nFailed to find `old_string`\n```" },
type: "content",
},
],
});
});
});
describe("escape markdown", () => {
@@ -780,16 +860,3 @@ describe("escape markdown", () => {
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
});
});
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,
);
});

View File

@@ -1,103 +0,0 @@
import { describe, it, expect } from "vitest";
import { extractLinesWithByteLimit } from "../utils.js";
describe("extractLinesWithByteLimit", () => {
const simpleContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
it("should extract all lines when under byte limit", () => {
const result = extractLinesWithByteLimit(simpleContent, 1000);
expect(result.content).toBe(simpleContent);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(5);
});
it("should limit output when exceeding byte limit", () => {
// Create content that will exceed byte limit
const longLine = "x".repeat(100);
const manyLines = Array(10).fill(longLine).join("\n");
const result = extractLinesWithByteLimit(manyLines, 250);
expect(result.wasLimited).toBe(true);
expect(result.linesRead).toBe(2);
});
it("should handle empty content", () => {
const result = extractLinesWithByteLimit("", 1000);
expect(result.content).toBe("");
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(1); // We read the one empty line
});
it("should handle single line file", () => {
const singleLine = "This is a single line without newline";
const result = extractLinesWithByteLimit(singleLine, 1000);
expect(result.content).toBe(singleLine);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(1);
});
it("should correctly count bytes with multi-byte characters", () => {
const unicodeContent = "Hello 世界\n你好 World\nEmoji: 🌍\nNormal line";
const result = extractLinesWithByteLimit(unicodeContent, 1000);
expect(result.content).toBe(unicodeContent);
expect(result.linesRead).toBe(4);
});
it("should stop at byte limit even with one more line available", () => {
// Create lines where adding one more would exceed limit
const line1 = "a".repeat(40);
const line2 = "b".repeat(40);
const line3 = "c".repeat(40);
const content = `${line1}\n${line2}\n${line3}`;
const result = extractLinesWithByteLimit(content, 85);
expect(result.content).toBe(`${line1}\n${line2}\n`);
expect(result.wasLimited).toBe(true);
expect(result.linesRead).toBe(2);
});
it("should read exactly to limit when possible", () => {
const exactContent = "12345\n67890\n12345"; // 17 bytes total
const result = extractLinesWithByteLimit(exactContent, 17);
expect(result.content).toBe(exactContent);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(3);
});
it("should handle Windows-style line endings", () => {
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
const result = extractLinesWithByteLimit(windowsContent, 1000);
// Note: split("\n") will keep the \r characters
expect(result.content).toBe("Line 1\r\nLine 2\r\nLine 3");
expect(result.linesRead).toBe(3);
});
it("should handle very large files efficiently", () => {
// Create a 100KB file
const largeLine = "x".repeat(1000);
const largeContent = Array(110).fill(largeLine).join("\n");
const result = extractLinesWithByteLimit(largeContent, 50000);
expect(result.wasLimited).toBe(true);
});
it("should allow at least one line even if it exceeds byte limit", () => {
const veryLongLine = "x".repeat(100000); // 100KB line
const result = extractLinesWithByteLimit(veryLongLine, 50000);
// Should return the line even though it exceeds the byte limit
// because we always allow at least one line if no lines have been added yet
expect(result.content).toBe(veryLongLine);
expect(result.linesRead).toBe(1);
expect(result.wasLimited).toBe(false);
});
});

View File

@@ -22,9 +22,12 @@ describe("replaceAndCalculateLocation", () => {
it("should return empty line numbers array when no match found", () => {
const content = "line 1\nline 2\nline 3";
expect(() => {
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
const result = replaceAndCalculateLocation(content, [
{ oldText: "notfound", newText: "replaced" },
]);
expect(result.newContent).toBe(content);
expect(result.lineNumbers).toEqual([]);
});
it("should handle Windows line endings (CRLF)", () => {
@@ -86,31 +89,35 @@ describe("replaceAndCalculateLocation", () => {
});
it("should handle empty file content", () => {
expect(() => {
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
}).toThrowError(
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
);
const content = "";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("");
expect(result.lineNumbers).toEqual([]);
});
it("should handle empty search text", () => {
// Test with replaceAll false (default)
const content1 = "line 1\nline 2\nline 3";
expect(() => {
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
const result1 = replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
expect(result1.newContent).toBe(content1);
expect(result1.lineNumbers).toEqual([]);
// Test with replaceAll true on single line
const content2 = "abc";
expect(() => {
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
const result2 = replaceAndCalculateLocation(content2, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result2.newContent).toBe(content2);
expect(result2.lineNumbers).toEqual([]);
// Test with replaceAll true on multiline content
const content3 = "ab\ncd";
expect(() => {
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
const result3 = replaceAndCalculateLocation(content3, [
{ oldText: "", newText: "X", replaceAll: true },
]);
expect(result3.newContent).toBe(content3);
expect(result3.lineNumbers).toEqual([]);
});
it("should handle single line content", () => {

View File

@@ -1,6 +1,10 @@
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 {
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@zed-industries/agent-client-protocol";
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
interface ToolInfo {
title: string;
@@ -63,7 +67,7 @@ export function toolInfoFromToolUse(
};
case "Bash":
case toolNames.bash:
case "mcp__acp__Bash":
return {
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
kind: "execute",
@@ -79,22 +83,22 @@ export function toolInfoFromToolUse(
};
case "BashOutput":
case toolNames.bashOutput:
case "mcp__acp__BashOutput":
return {
title: "Tail Logs",
kind: "execute",
content: [],
};
case "KillShell":
case toolNames.killShell:
case "KillBash":
case "mcp__acp__KillBash":
return {
title: "Kill Process",
kind: "execute",
content: [],
};
case toolNames.read: {
case "mcp__acp__read": {
let limit = "";
if (input.limit) {
limit =
@@ -103,11 +107,11 @@ export function toolInfoFromToolUse(
limit = " (from line " + (input.offset + 1) + ")";
}
return {
title: "Read " + (input.file_path ?? "File") + limit,
title: "Read " + (input.abs_path ?? "File") + limit,
kind: "read",
locations: [
{
path: input.file_path,
path: input.abs_path,
line: input.offset ?? 0,
},
],
@@ -131,26 +135,26 @@ export function toolInfoFromToolUse(
locations: [],
};
case toolNames.edit:
case "mcp__acp__edit":
case "Edit": {
const path = input?.file_path ?? input?.file_path;
const path = input?.abs_path ?? input?.file_path;
let oldText = input.old_string ?? null;
let newText = input.new_string ?? "";
let affectedLines: number[] = [];
let line = null;
if (path && oldText) {
if (path && input.old_text) {
try {
const oldContent = cachedFileContent[path] || "";
const newContent = replaceAndCalculateLocation(oldContent, [
{
oldText,
newText,
oldText: input.old_string,
newText: input.new_string,
replaceAll: false,
},
]);
oldText = oldContent;
newText = newContent.newContent;
affectedLines = newContent.lineNumbers;
line = newContent.lineNumbers[0];
} catch (e) {
console.error(e);
}
@@ -169,21 +173,64 @@ export function toolInfoFromToolUse(
},
]
: [],
locations: path
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path }))
: [{ path }]
: [],
locations: path ? [{ path, line }] : [],
};
}
case toolNames.write: {
case "mcp__acp__multi-edit":
case "MultiEdit": {
const multiInput = input as {
file_path: string;
edits: {
old_string: string;
new_string: string;
replace_all?: boolean;
}[];
};
let oldTextMulti = multiInput.edits.map((edit: any) => edit.old_string).join("\n");
let newTextMulti = multiInput.edits.map((edit: any) => edit.new_string).join("\n");
try {
if (multiInput.edits && multiInput.file_path) {
const oldContent =
cachedFileContent[multiInput.file_path] ||
multiInput.edits.map((edit: any) => edit.oldText).join("\n");
const newContent = replaceAndCalculateLocation(
oldContent,
multiInput.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all,
})),
);
oldTextMulti = oldContent;
newTextMulti = newContent.newContent;
}
} catch (e) {
console.error(e);
}
// Display it as a normal edit, because end users don't care about
// the distinction between edits and multi-edits.
return {
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
kind: "edit",
content: [
{
type: "diff" as const,
path: input.file_path,
oldText: oldTextMulti,
newText: newTextMulti,
},
],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
}
case "mcp__acp__write": {
let content: ToolCallContent[] = [];
if (input && input.file_path) {
if (input && input.abs_path) {
content = [
{
type: "diff",
path: input.file_path,
path: input.abs_path,
oldText: null,
newText: input.content,
},
@@ -197,10 +244,10 @@ export function toolInfoFromToolUse(
];
}
return {
title: input?.file_path ? `Write ${input.file_path}` : "Write",
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
kind: "edit",
content,
locations: input?.file_path ? [{ path: input.file_path }] : [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
};
}
@@ -343,23 +390,17 @@ export function toolInfoFromToolUse(
content: [],
};
case "ExitPlanMode":
case "exit_plan_mode":
return {
title: "Ready to code?",
kind: "switch_mode",
title: "Exit Plan Mode",
kind: "think",
content:
input && input.plan
? [{ type: "content", content: { type: "text", text: input.plan } }]
: [],
};
case "Other": {
let output;
try {
output = JSON.stringify(input, null, 2);
} catch {
output = typeof input === "string" ? input : "{}";
}
case "Other":
return {
title: name || "Unknown Tool",
kind: "other",
@@ -368,12 +409,11 @@ export function toolInfoFromToolUse(
type: "content",
content: {
type: "text",
text: `\`\`\`json\n${output}\`\`\``,
text: `\`\`\`json\n${JSON.stringify(input, null, 2) || "{}"}\`\`\``,
},
},
],
};
}
default:
return {
@@ -384,13 +424,62 @@ export function toolInfoFromToolUse(
}
}
export function toolUpdateFromToolResult(
toolResult: ToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefined): ToolUpdate {
switch (toolUse?.name) {
case "mcp__acp__edit":
case "edit":
case "Edit": {
// Parse the line numbers from the tool result
if (toolResult.content && toolResult.content.length > 0) {
try {
const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map((line: number) => ({
path: toolUse?.input?.abs_path || toolUse?.input?.file_path,
line: line,
}));
return { locations };
}
} catch (error) {
console.error(`Error parsing edit tool result: ${error}`);
// If parsing fails, return empty object
return {};
}
}
return {};
}
case "mcp__acp__multi-edit":
case "multi-edit":
case "MultiEdit": {
// Parse the line numbers from the tool result
if (toolResult.content && toolResult.content.length > 0) {
try {
const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map((line: number) => ({
path: toolUse?.input?.file_path || toolUse?.input?.abs_path,
line: line,
}));
return { locations };
}
} catch (error) {
console.error(`Error parsing edit tool result: ${error}`);
// If parsing fails, return empty object
return {};
}
}
return {};
}
case "mcp__acp__write":
case "Write":
return {};
case "Read":
case toolNames.read:
case "mcp__acp__read":
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
@@ -419,22 +508,9 @@ export function toolUpdateFromToolResult(
}
return {};
case toolNames.bash:
case "edit":
case "Edit":
case toolNames.edit:
case toolNames.write:
case "Write": {
if (toolResult.is_error && toolResult.content && toolResult.content.length > 0) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
case "mcp__acp__Bash":
// The bash tool already embeds a terminal
return {};
}
case "ExitPlanMode": {
return { title: "Exited Plan Mode" };
}
case "Task":
case "NotebookEdit":
@@ -451,45 +527,32 @@ export function toolUpdateFromToolResult(
case "WebSearch":
case "Other":
default: {
return toAcpContentUpdate(toolResult.content, toolResult.is_error);
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content,
})),
};
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: toolResult.content,
},
},
],
};
}
return {};
}
}
}
function toAcpContentUpdate(
content: any,
isError: boolean = false,
): { content?: ToolCallContent[] } {
if (Array.isArray(content) && content.length > 0) {
return {
content: content.map((content: any) => ({
type: "content",
content:
isError && content.type === "text"
? {
...content,
text: `\`\`\`\n${content.text}\n\`\`\``,
}
: content,
})),
};
} else if (typeof content === "string" && content.length > 0) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
},
},
],
};
}
return {};
}
export type ClaudePlanEntry = {
type ClaudePlanEntry = {
content: string;
status: "pending" | "in_progress" | "completed";
activeForm: string;

View File

@@ -2,8 +2,6 @@
import { Readable, Writable } from "node:stream";
import { WritableStream, ReadableStream } from "node:stream/web";
import { readFileSync } from "node:fs";
import { platform } from "node:os";
// Useful for bridging push-based and async-iterator-based code.
export class Pushable<T> implements AsyncIterable<T> {
@@ -82,107 +80,3 @@ export function unreachable(value: never): never {
export function sleep(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}
interface ManagedSettings {
permissions?: {
allow?: string[];
deny?: string[];
};
env?: Record<string, string>;
}
// Following the rules in https://docs.anthropic.com/en/docs/claude-code/settings#settings-files
// This can be removed once the SDK supports it natively.
function getManagedSettingsPath(): string {
const os = platform();
switch (os) {
case "darwin":
return "/Library/Application Support/ClaudeCode/managed-settings.json";
case "linux": // including WSL
return "/etc/claude-code/managed-settings.json";
case "win32":
return "C:\\ProgramData\\ClaudeCode\\managed-settings.json";
default:
return "/etc/claude-code/managed-settings.json";
}
}
export function loadManagedSettings(): ManagedSettings | null {
try {
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ManagedSettings;
} catch {
return null;
}
}
export function applyEnvironmentSettings(settings: ManagedSettings): void {
if (settings.env) {
for (const [key, value] of Object.entries(settings.env)) {
process.env[key] = value;
}
}
}
export interface ExtractLinesResult {
content: string;
wasLimited: boolean;
linesRead: number;
}
/**
* Extracts lines from file content with byte limit enforcement.
*
* @param fullContent - The complete file content
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
* @returns Object containing extracted content and metadata
*/
export function extractLinesWithByteLimit(
fullContent: string,
maxContentLength: number,
): ExtractLinesResult {
if (fullContent === "") {
return {
content: "",
wasLimited: false,
linesRead: 1,
};
}
let linesSeen = 0;
let index = 0;
linesSeen = 0;
let contentLength = 0;
let wasLimited = false;
while (true) {
const nextIndex = fullContent.indexOf("\n", index);
if (nextIndex < 0) {
// Last line in file (no trailing newline)
if (linesSeen > 0 && fullContent.length > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = fullContent.length;
break;
} else {
// Line with newline - include up to the newline
const newContentLength = nextIndex + 1;
if (linesSeen > 0 && newContentLength > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = newContentLength;
index = newContentLength;
}
}
return {
content: fullContent.slice(0, contentLength),
wasLimited,
linesRead: linesSeen,
};
}