Compare commits

...

15 Commits

Author SHA1 Message Date
Ben Brandt
6ae2c05ddb Prep v0.6.0 (#96) 2025-10-15 10:25:43 +00:00
Bennet Fenner
8f0fc59a48 Add model selection support (#93)
This part of ACP is not yet stabilized, so marking as Draft for now

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-10-15 12:21:30 +02:00
Ben Brandt
c25150523a Update claude-agent-sdk to 0.1.15 (#95)
- **Update claude-agent-sdk to 0.1.15**
- **v0.5.6**
2025-10-15 09:05:16 +00:00
WhiskeyJack96
ea796f3cbd Add support for appending or overriding the system prompt (#91)
resolves #90 

Tested both the `append` and `override` flows by sending:
```javascript
const resp = await this.connection.newSession({
			cwd: basePath,
			_meta: {
				systemPrompt: "you are an obsidian assistant agent, you should be helpful to the user when working with their notes"
			},
			mcpServers: []
		});
```

```javascript
const resp = await this.connection.newSession({
			cwd: basePath,
			_meta: {
				systemPrompt: {
					append: "always speak in spanish"
				}
			},
			mcpServers: []
		});
```

and confirmed that in both cases those prompts are what gets sent to the
agent!

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-15 10:48:06 +02:00
WhiskeyJack96
7f5ebca3fb Make write and edit permissions consistent (#92)
Currently edit requires a permissions call and write does not. This
means that if clients want to allow users to approve `Write` calls they
need to add handling to `HandleFileWrite` but if they do that then edits
get _two_ permissions checks.

Alternatively we could remove the write tool from approved tools (which
would make more sense to me) but I'm assuming its there for a good
reason, if not happy to change to remove it instead!

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-15 10:34:49 +02:00
Ben Brandt
75b44e945e Update to claude-agent-sdk 0.1.13 (#89) 2025-10-10 14:43:44 +00:00
Ben Brandt
ad8611c399 Migrate to @agentclientprotocol/sdk (#88) 2025-10-10 16:17:37 +02:00
ozer
c5abff0e43 Improve setSessionMode error handling for SDK messages (#85)
When `default`, `acceptEdits`, or `plan` modes fail, users see the real
error from the SDK instead of a generic message.

`Bypass permissions` gets special treatment - it shows both the SDK
error AND explains it needs proper configuration. I couldn't test SDK
thats why I added special treatment for bypass permissions. If you can
test it we can talk further.

Related to: https://github.com/zed-industries/zed/pull/39524 @benbrandt
2025-10-09 12:49:44 +02:00
Ben Brandt
5c98bd0404 Update to claude-agent-sdk@1.0.11 (#87)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-09 08:30:56 +00:00
Alfredo Luque
71b660ae3b Enable setting CLAUDE_CODE_EXECUTABLE to override the claude code CLI used (#86)
## Description
Closes #84 by allowing users to set `CLAUDE_CODE_EXECUTABLE` to override
the binary that's used by the claude code SDK. This is key in enterprise
environments where claude CLI has been shimmed in some way.
2025-10-07 22:54:50 +02:00
Ben Brandt
4ee25af3f9 Update to claude-agent-sdk 0.1.8 (#83) 2025-10-06 09:01:40 +00:00
Ben Brandt
1a5a9c16c6 Add back @anthropic-ai/claude-code as runtime dependency (#79)
Some clients (i.e. Zed) still relied on this package being there at
runtime.
Adding it as a dependency for now.
2025-09-30 16:46:04 +02:00
Ben Brandt
5ca27369ef Prep v0.5.1 (#78) 2025-09-30 09:19:37 +00:00
Ben Brandt
8068d894c1 Align ACP tools with latest Claude Code tools (#77)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-30 11:08:24 +02:00
Ben Brandt
71e745d5fb Use default behavior for bypassPermissions mode (#75)
They updated the types to allow us to just pass this through now. So we
shouldn't have to have the special casing here.
2025-09-29 23:13:12 +02:00
7 changed files with 639 additions and 672 deletions

View File

@@ -1,5 +1,37 @@
# Changelog
## 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

699
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.5.0",
"version": "0.6.0",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
@@ -51,9 +51,10 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.1.0",
"@modelcontextprotocol/sdk": "1.18.2",
"@zed-industries/agent-client-protocol": "0.4.3",
"@agentclientprotocol/sdk": "0.4.8",
"@anthropic-ai/claude-agent-sdk": "0.1.15",
"@anthropic-ai/claude-code": "2.0.15",
"@modelcontextprotocol/sdk": "1.20.0",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
@@ -63,17 +64,17 @@
"@anthropic-ai/sdk": "^0.65.0",
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.6.0",
"@types/node": "24.7.2",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.45.0",
"@typescript-eslint/parser": "8.45.0",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@vitest/ui": "3.2.4",
"eslint": "9.36.0",
"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.2",
"typescript": "5.9.3",
"vitest": "3.2.4"
}
}

View File

@@ -15,13 +15,16 @@ import {
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
SessionModelState,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
} from "@agentclientprotocol/sdk";
import {
McpServerConfig,
Options,
@@ -37,7 +40,7 @@ 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 "@zed-industries/agent-client-protocol";
import { SessionNotification } from "@agentclientprotocol/sdk";
import {
createMcpServer,
createPermissionMcpServer,
@@ -74,6 +77,8 @@ 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: {
@@ -169,16 +174,33 @@ 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: { type: "preset", preset: "claude_code" },
systemPrompt,
settingSources: ["user", "project", "local"],
permissionPromptToolName: PERMISSION_TOOL_NAME,
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 = [];
@@ -188,12 +210,11 @@ export class ClaudeAcpAgent implements Agent {
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
allowedTools.push(toolNames.write);
disallowedTools.push("Write", "Edit", "MultiEdit");
disallowedTools.push("Write", "Edit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push(toolNames.bashOutput, toolNames.killBash);
disallowedTools.push("Bash", "BashOutput", "KillBash");
allowedTools.push(toolNames.bashOutput, toolNames.killShell);
disallowedTools.push("Bash", "BashOutput", "KillShell");
}
if (allowedTools.length > 0) {
@@ -224,8 +245,11 @@ export class ClaudeAcpAgent implements Agent {
});
});
const models = await getAvailableModels(q);
return {
sessionId,
models,
modes: {
currentModeId: "default",
availableModes: [
@@ -308,7 +332,12 @@ export class ClaudeAcpAgent implements Agent {
return { stopReason: "refusal" };
}
}
case "user": {
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 (
@@ -326,15 +355,13 @@ export class ClaudeAcpAgent implements Agent {
console.error(message.message.content);
break;
}
// Skip user messages for now, since they seem to just be messages we don't want in the feed
break;
}
case "assistant": {
if (this.sessions[params.sessionId].cancelled) {
// 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 &&
@@ -369,6 +396,13 @@ 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");
@@ -377,18 +411,20 @@ export class ClaudeAcpAgent implements Agent {
switch (params.modeId) {
case "default":
case "acceptEdits":
case "bypassPermissions":
case "plan":
this.sessions[params.sessionId].permissionMode = params.modeId;
await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
return {};
case "bypassPermissions":
// For some reason, the SDK doesn't support setting the mode to `bypassPermissions`
// so we do it ourselves
this.sessions[params.sessionId].permissionMode = "bypassPermissions";
await this.sessions[params.sessionId].query.setPermissionMode("acceptEdits");
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");
throw new Error("Invalid Mode");
}
}
@@ -407,6 +443,26 @@ 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[]> {
const UNSUPPORTED_COMMANDS = [
"add-dir",

View File

@@ -5,7 +5,7 @@ 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 "@zed-industries/agent-client-protocol";
import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
import * as diff from "diff";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
@@ -17,15 +17,14 @@ 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: 1000 };
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
const unqualifiedToolNames = {
read: "read",
edit: "edit",
write: "write",
multiEdit: "multi-edit",
read: "Read",
edit: "Edit",
write: "Write",
bash: "Bash",
killBash: "KillBash",
killShell: "KillShell",
bashOutput: "BashOutput",
};
@@ -34,13 +33,12 @@ export const toolNames = {
read: SERVER_PREFIX + unqualifiedToolNames.read,
edit: SERVER_PREFIX + unqualifiedToolNames.edit,
write: SERVER_PREFIX + unqualifiedToolNames.write,
multiEdit: SERVER_PREFIX + unqualifiedToolNames.multiEdit,
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
killBash: SERVER_PREFIX + unqualifiedToolNames.killBash,
killShell: SERVER_PREFIX + unqualifiedToolNames.killShell,
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
};
const editToolNames = [toolNames.edit, toolNames.multiEdit, toolNames.write];
const editToolNames = [toolNames.edit, toolNames.write];
export function createMcpServer(
agent: ClaudeAcpAgent,
@@ -54,27 +52,36 @@ export function createMcpServer(
server.registerTool(
unqualifiedToolNames.read,
{
title: "Read",
title: unqualifiedToolNames.read,
description: `Reads the content of the given file in the project.
Never attempt to read a path that hasn't been previously mentioned.
In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.
In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.`,
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.`,
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to read."),
file_path: z.string().describe("The absolute path to the file to read"),
offset: z
.number()
.optional()
.default(0)
.default(1)
.describe(
"Which line to start reading from (0-based). Omit to start from the beginning.",
"The line number to start reading from. Only provide if the file is too large to read at once",
),
linesToRead: z
limit: z
.number()
.optional()
.default(defaults.linesToRead)
.describe(
`How many lines to read. Omit for ${defaults.linesToRead}. (Reading files that are too large can fill up the context window unnecessarily, so be thoughtful about how much you read at a time.)`,
`The number of lines to read. Only provide if the file is too large to read at once.`,
),
},
annotations: {
@@ -101,9 +108,9 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
const content = await agent.readTextFile({
sessionId,
path: input.abs_path,
line: input.offset + 1,
limit: input.linesToRead,
path: input.file_path,
line: input.offset,
limit: input.limit,
});
// Extract lines with byte limit enforcement
@@ -111,13 +118,13 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
// Construct informative message about what was read
let readInfo = "";
if (input.offset > 0 || result.wasLimited) {
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 ?? 0) + 1}-${result.linesRead}. `;
readInfo += `Read lines ${input.offset}-${result.linesRead}. `;
}
if (result.wasLimited) {
@@ -153,14 +160,23 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
server.registerTool(
unqualifiedToolNames.write,
{
title: "Write",
description: `Writes content to the specified file in the project.
title: unqualifiedToolNames.write,
description: `Writes a file to the local filesystem..
In sessions with ${toolNames.write} always use it instead of Write as it will
allow the user to conveniently review changes.`,
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.`,
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to write"),
content: z.string().describe("The full content to write"),
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"),
},
annotations: {
title: "Write file",
@@ -185,7 +201,7 @@ allow the user to conveniently review changes.`,
}
await agent.writeTextFile({
sessionId,
path: input.abs_path,
path: input.file_path,
content: input.content,
});
@@ -208,26 +224,30 @@ allow the user to conveniently review changes.`,
server.registerTool(
unqualifiedToolNames.edit,
{
title: "Edit",
description: `Edit a file.
title: unqualifiedToolNames.edit,
description: `Performs exact string replacements in files.
In sessions with ${toolNames.edit} always use it instead of Edit as it will
allow the user to conveniently review changes.
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.
- If the \`old_string\` value isn't found in the file, the edit won't be applied. The tool will fail and must be retried.`,
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.`,
inputSchema: {
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."),
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)"),
},
annotations: {
title: "Edit file",
@@ -252,105 +272,17 @@ File editing instructions:
const { content } = await agent.readTextFile({
sessionId,
path: input.abs_path,
path: input.file_path,
});
const { newContent } = replaceAndCalculateLocation(content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: false,
replaceAll: input.replace_all,
},
]);
const patch = diff.createPatch(input.abs_path, content, newContent);
await agent.writeTextFile({
sessionId,
path: input.abs_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: patch,
},
],
};
},
);
server.registerTool(
unqualifiedToolNames.multiEdit,
{
title: "Multi Edit",
description: `Edit a file with multiple sequential edits.
In sessions with ${toolNames.multiEdit} always use it instead of MultiEdit as it will
allow the user to conveniently review changes.
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, unless you're using \`replace_all\`
- Do not escape quotes, newlines, or other characters.
- If any of the provided \`old_string\` values aren't found in the file, no edits will be applied. The tool will fail and must be retried.`,
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) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const { content } = await agent.readTextFile({
sessionId,
path: input.file_path,
});
const { newContent } = replaceAndCalculateLocation(
content,
input.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all ?? false,
})),
);
const patch = diff.createPatch(input.file_path, content, newContent);
await agent.writeTextFile({
@@ -375,19 +307,34 @@ File editing instructions:
server.registerTool(
unqualifiedToolNames.bash,
{
title: "Bash",
description: "Executes a bash command",
title: unqualifiedToolNames.bash,
description: `Executes a bash command
In sessions with ${toolNames.bash} always use it instead of Bash`,
inputSchema: {
command: z.string().describe("The bash command to execute as a one-liner"),
timeout_ms: z
command: z.string().describe("The command to execute"),
timeout: z
.number()
.default(2 * 60 * 1000)
.describe("Optional timeout in milliseconds"),
.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'`),
run_in_background: z
.boolean()
.default(false)
.describe(
`When set to true, the command is started 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.killBash}\` tool to stop it early.`,
`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.`,
),
},
},
@@ -426,6 +373,7 @@ File editing instructions:
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
title: input.description,
content: [{ type: "terminal", terminalId: handle.id }],
},
});
@@ -443,7 +391,7 @@ File editing instructions:
const statusPromise = Promise.race([
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
sleep(input.timeout_ms).then(async () => {
sleep(input.timeout).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
@@ -510,20 +458,25 @@ File editing instructions:
server.registerTool(
unqualifiedToolNames.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.",
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.`,
inputSchema: {
id: z
shell_id: z
.string()
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.id];
const bgTerm = agent.backgroundTerminals[input.shell_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.id}`);
throw new Error(`Unknown shell ${input.shell_id}`);
}
if (bgTerm.status === "started") {
@@ -559,21 +512,26 @@ File editing instructions:
);
server.registerTool(
unqualifiedToolNames.killBash,
unqualifiedToolNames.killShell,
{
title: "KillBash",
description: "Stops a background command by its id",
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.`,
inputSchema: {
id: z
shell_id: z
.string()
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.id];
const bgTerm = agent.backgroundTerminals[input.shell_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.id}`);
throw new Error(`Unknown shell ${input.shell_id}`);
}
switch (bgTerm.status) {

View File

@@ -14,11 +14,11 @@ import {
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
} from "@agentclientprotocol/sdk";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications } from "../acp-agent.js";
import { SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
@@ -371,13 +371,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: {
abs_path: "/Users/test/project/config.json",
file_path: "/Users/test/project/config.json",
content: '{"version": "1.0.0"}',
},
};
@@ -415,13 +415,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: {
abs_path: "/Users/test/project/data.json",
file_path: "/Users/test/project/data.json",
},
};
@@ -433,13 +433,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: {
abs_path: "/Users/test/project/large.txt",
file_path: "/Users/test/project/large.txt",
limit: 100,
},
};
@@ -452,13 +452,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: {
abs_path: "/Users/test/project/large.txt",
file_path: "/Users/test/project/large.txt",
offset: 50,
limit: 100,
},
@@ -472,13 +472,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: {
abs_path: "/Users/test/project/large.txt",
file_path: "/Users/test/project/large.txt",
offset: 200,
},
};
@@ -539,7 +539,7 @@ describe("tool conversions", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillBash",
name: "KillShell",
input: {
shell_id: "bash_1",
},
@@ -701,78 +701,13 @@ describe("tool conversions", () => {
]);
});
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",
line: 1,
},
{
path: "/Users/test/project/config.json",
line: 3,
},
],
title: "Edit /Users/test/project/config.json",
});
});
it("should return empty update for successful edit result", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__edit",
name: "mcp__acp__Edit",
input: {
abs_path: "/Users/test/project/test.txt",
file_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
},
@@ -800,9 +735,9 @@ describe("tool conversions", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__edit",
name: "mcp__acp__Edit",
input: {
abs_path: "/Users/test/project/test.txt",
file_path: "/Users/test/project/test.txt",
old_string: "old",
new_string: "new",
},
@@ -845,3 +780,12 @@ describe("escape markdown", () => {
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
});
});
describe("SDK behavior", () => {
it("query has a 'default' model", async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
});
});

View File

@@ -1,9 +1,4 @@
import {
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@zed-industries/agent-client-protocol";
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
@@ -91,8 +86,8 @@ export function toolInfoFromToolUse(
content: [],
};
case "KillBash":
case toolNames.killBash:
case "KillShell":
case toolNames.killShell:
return {
title: "Kill Process",
kind: "execute",
@@ -108,11 +103,11 @@ export function toolInfoFromToolUse(
limit = " (from line " + (input.offset + 1) + ")";
}
return {
title: "Read " + (input.abs_path ?? "File") + limit,
title: "Read " + (input.file_path ?? "File") + limit,
kind: "read",
locations: [
{
path: input.abs_path,
path: input.file_path,
line: input.offset ?? 0,
},
],
@@ -138,7 +133,7 @@ export function toolInfoFromToolUse(
case toolNames.edit:
case "Edit": {
const path = input?.abs_path ?? input?.file_path;
const path = input?.file_path ?? input?.file_path;
let oldText = input.old_string ?? null;
let newText = input.new_string ?? "";
let affectedLines: number[] = [];
@@ -182,66 +177,13 @@ export function toolInfoFromToolUse(
};
}
case toolNames.multiEdit:
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");
let affectedLines: number[] = [];
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;
affectedLines = newContent.lineNumbers;
}
} 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
? affectedLines.length > 0
? affectedLines.map((line) => ({ line, path: input.file_path }))
: [{ path: input.file_path }]
: [],
};
}
case toolNames.write: {
let content: ToolCallContent[] = [];
if (input && input.abs_path) {
if (input && input.file_path) {
content = [
{
type: "diff",
path: input.abs_path,
path: input.file_path,
oldText: null,
newText: input.content,
},
@@ -255,10 +197,10 @@ export function toolInfoFromToolUse(
];
}
return {
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
title: input?.file_path ? `Write ${input.file_path}` : "Write",
kind: "edit",
content,
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
}
@@ -481,9 +423,6 @@ export function toolUpdateFromToolResult(
case "edit":
case "Edit":
case toolNames.edit:
case toolNames.multiEdit:
case "multi-edit":
case "MultiEdit":
case toolNames.write:
case "Write": {
if (toolResult.is_error && toolResult.content && toolResult.content.length > 0) {