Compare commits

...

18 Commits

Author SHA1 Message Date
Ben Brandt
e22d1ed52c v0.4.1 (#54)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-15 18:42:07 +02:00
Ben Brandt
ecfd36afa6 Support /compact (#51)
It would be nice to also support sending status via hooks when the
compact action is happening (and maybe show something in the UI to show
where compacting happened in the thread) but for now this at least
allows it.

---------

Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-15 18:27:43 +02:00
Richard Feldman
7bd163818b Add default limits to read tool (#52)
Now it won't read files over 50K code units by default, and instead
insist on breaking them up by line number.
2025-09-15 11:03:01 -04:00
Richard Feldman
491efe32e8 Tool error backticks (#53)
This looks more reasonable now, because the code is now rendered in a
monospace font:

<img width="475" height="450" alt="Screenshot 2025-09-15 at 10 58 20 AM"
src="https://github.com/user-attachments/assets/9870436b-ee8f-44cd-b814-b22ad9261ebe"
/>

That said, this definitely would benefit from a horizontal scroll bar on
the code blocks. You can't really read what it's saying without copying
the text out of the block.

Of course, in this particular case the relevant info is all in the
beginning of the error message (namely, that it failed to find the
`old_string`), and this does look overall better than before (where it
was just this big jumble of non-monospaced code), so I think it's worth
merging as-is and then revisiting horizontal scrolling later.
2025-09-15 11:02:47 -04:00
Ben Brandt
ee17e45030 Use Trusted Publishing for npm release (#49) 2025-09-15 10:59:16 +02:00
Ben Brandt
468c3522a0 Update deps to latest versions (#48)
Signed-off-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-09-15 10:46:05 +02:00
Ben Brandt
84d64b2762 Add build step to release 2025-09-15 10:35:21 +02:00
Myles Scolnick
9ff7919ea4 readme: Add marimo notebook (#47) 2025-09-15 10:11:14 +02:00
Agus Zubiaga
d58c2dd45d Update to acp 0.2.1 (#45) 2025-09-12 14:07:00 -03:00
Richard Feldman
55532ecc57 Re-upgrade to SDK version 1.0.111 (#44)
There's apparently a RCE on 1.0.100, so we shouldn't stay on that
version.
2025-09-11 12:50:19 -04:00
Richard Feldman
f4711e96b0 Pin npm dependencies (#43)
We discussed this and decided we'll manually upgrade on our own cadence.
Don't want surprises like
https://github.com/zed-industries/zed/issues/37976
2025-09-11 12:27:52 -04:00
Richard Feldman
f691024350 Load managed-settings.json (#42)
The SDK doesn't currently load [Claude Code's
`managed-settings.json`](https://docs.anthropic.com/en/docs/claude-code/settings#settings-files)
- now the adapter does (following [the rules in the
docs](https://docs.anthropic.com/en/docs/claude-code/settings#settings-files)),
but we can remove this once the SDK supports it.
2025-09-11 08:47:33 -04:00
Agus Zubiaga
d6c91b1ec1 0.4.0 2025-09-09 11:49:37 -03:00
Agus Zubiaga
0289dd9de7 Support plan mode and all other permission modes (#40)
Exposes all Claude Code permission modes as [ACP Session
modes](https://github.com/zed-industries/agent-client-protocol/pull/67).
This allows ACP clients to build mode selectors and to be notified when
the mode changes as a result of `ExitPlanMode`.

See a demo in the [Zed
PR](https://github.com/zed-industries/zed/pull/37632).

Closes #31

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-09 11:48:26 -03:00
Agus Zubiaga
5cd3ac91e8 Removing "Coming soon" 2025-09-08 13:18:28 -03:00
Agus Zubiaga
6b5873f005 0.3.0 2025-09-08 13:13:25 -03:00
Agus Zubiaga
539f94de45 Support HTTP/SSE MCP servers (#38)
HTTP/SSE MCP servers can be now included in `session/new`
2025-09-08 13:12:19 -03:00
Jie Wang
70441b0aa3 Move @types/express to dev dependencies (#34) 2025-09-05 06:19:09 +02:00
15 changed files with 1366 additions and 704 deletions

View File

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

View File

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

12
CHANGELOG.md Normal file
View File

@@ -0,0 +1,12 @@
# Changelog
## v0.4.1
### What's New
- 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

@@ -33,8 +33,9 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
### Other clients
- Neovim via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
- [marimo notebook](https://github.com/marimo-team/marimo)
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!
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
#### Installation

48
eslint.config.js Normal file
View File

@@ -0,0 +1,48 @@
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,
];

1108
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.2.6",
"version": "0.4.1",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
@@ -43,27 +43,28 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@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.6",
"diff": "^8.0.2",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
"@anthropic-ai/claude-code": "1.0.113",
"@modelcontextprotocol/sdk": "1.18.0",
"@zed-industries/agent-client-protocol": "0.3.1",
"diff": "8.0.2",
"express": "5.1.0",
"minimist": "1.2.8",
"uuid": "13.0.0"
},
"devDependencies": {
"@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"
"@types/express": "5.0.3",
"@types/minimist": "1.2.5",
"@types/node": "24.4.0",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.43.0",
"@typescript-eslint/parser": "8.43.0",
"@vitest/ui": "3.2.4",
"eslint": "9.35.0",
"eslint-config-prettier": "10.1.8",
"globals": "16.4.0",
"prettier": "3.6.2",
"ts-node": "10.9.2",
"typescript": "5.9.2",
"vitest": "3.2.4"
}
}

View File

@@ -14,6 +14,8 @@ import {
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
SetSessionModeRequest,
SetSessionModeResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
@@ -22,6 +24,7 @@ import {
import {
McpServerConfig,
Options,
PermissionMode,
Query,
query,
SDKAssistantMessage,
@@ -33,7 +36,7 @@ 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 { createMcpServer } from "./mcp-server.js";
import { createMcpServer, toolNames } from "./mcp-server.js";
import { AddressInfo } from "node:net";
import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
@@ -41,6 +44,7 @@ type Session = {
query: Query;
input: Pushable<SDKUserMessage>;
cancelled: boolean;
permissionMode: PermissionMode;
};
type BackgroundTerminal =
@@ -86,6 +90,10 @@ export class ClaudeAcpAgent implements Agent {
image: true,
embeddedContext: true,
},
mcpCapabilities: {
http: true,
sse: true,
},
},
authMethods: [
{
@@ -110,14 +118,24 @@ export class ClaudeAcpAgent implements Agent {
const mcpServers: Record<string, McpServerConfig> = {};
if (Array.isArray(params.mcpServers)) {
for (const server of params.mcpServers) {
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,
};
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,
};
}
}
}
@@ -134,7 +152,7 @@ export class ClaudeAcpAgent implements Agent {
const options: Options = {
cwd: params.cwd,
mcpServers,
permissionPromptToolName: "mcp__acp__permission",
permissionPromptToolName: toolNames.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.
@@ -144,15 +162,15 @@ export class ClaudeAcpAgent implements Agent {
const allowedTools = [];
const disallowedTools = [];
if (this.clientCapabilities?.fs?.readTextFile) {
allowedTools.push("mcp__acp__read");
allowedTools.push(toolNames.read);
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
allowedTools.push("mcp__acp__write");
allowedTools.push(toolNames.write);
disallowedTools.push("Write", "Edit", "MultiEdit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push("mcp__acp__BashOutput", "mcp__acp__KillBash");
allowedTools.push(toolNames.bashOutput, toolNames.killBash);
disallowedTools.push("Bash", "BashOutput", "KillBash");
}
@@ -171,6 +189,7 @@ export class ClaudeAcpAgent implements Agent {
query: q,
input: input,
cancelled: false,
permissionMode: "default",
};
getAvailableSlashCommands(q).then((availableCommands) => {
@@ -185,6 +204,31 @@ export class ClaudeAcpAgent implements Agent {
return {
sessionId,
modes: {
currentModeId: "default",
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",
},
],
},
};
}
@@ -240,6 +284,24 @@ export class ClaudeAcpAgent implements Agent {
continue;
}
// 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;
}
if (
typeof message.message.content === "string" &&
message.message.content.includes("<local-command-stderr>")
) {
console.error(message.message.content);
break;
}
if (
message.message.model === "<synthetic>" &&
message.message.content.length === 1 &&
@@ -258,7 +320,7 @@ export class ClaudeAcpAgent implements Agent {
break;
}
default:
unreachable(message);
unreachable(message as never);
}
}
throw new Error("Session did not end in result");
@@ -272,6 +334,29 @@ export class ClaudeAcpAgent implements Agent {
await this.sessions[params.sessionId].query.interrupt();
}
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 "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");
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) {
@@ -294,7 +379,6 @@ async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand
"bashes", // Modal
"bug", // Modal
"clear", // Escape Codes
"compact", // Not supported via SDK?
"config", // Modal
"context", // Escape Codes
"cost", // Escape Codes
@@ -323,14 +407,14 @@ async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand
"todos", // Escape Codes
"vim", // Not needed
];
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
const commands = await (query as any).supportedCommands();
const commands = await query.supportedCommands();
return commands
.map((command: { name: string; description: string; argumentHint: string }) => {
.map((command) => {
const input = command.argumentHint ? { hint: command.argumentHint } : null;
return {
name: command.name,
// @ts-expect-error type in the ts interface
description: command.description || "",
input,
};

View File

@@ -1,5 +1,13 @@
#!/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;

View File

@@ -8,7 +8,8 @@ import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agent-client-protocol";
import * as diff from "diff";
import { sleep, unreachable } from "./utils.js";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
import { PermissionResult } from "@anthropic-ai/claude-code";
export const SYSTEM_REMINDER = `
@@ -16,6 +17,33 @@ 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 unqualifiedToolNames = {
read: "read",
edit: "edit",
write: "write",
multiEdit: "multi-edit",
bash: "Bash",
killBash: "KillBash",
bashOutput: "BashOutput",
permission: "permission",
};
const SERVER_PREFIX = "mcp__acp__";
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,
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
permission: SERVER_PREFIX + unqualifiedToolNames.permission,
};
const editToolNames = [toolNames.edit, toolNames.multiEdit, toolNames.write];
export function createMcpServer(
agent: ClaudeAcpAgent,
sessionId: string,
@@ -29,21 +57,30 @@ export function createMcpServer(
if (clientCapabilities?.fs?.readTextFile) {
server.registerTool(
"read",
unqualifiedToolNames.read,
{
title: "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 mcp__acp__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.`,
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to read."),
offset: z
.number()
.optional()
.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."),
.default(0)
.describe(
"Which line to start reading from (0-based). Omit to start from the beginning.",
),
linesToRead: 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.)`,
),
},
annotations: {
title: "Read file",
@@ -66,18 +103,43 @@ In sessions with mcp__acp__read always use it instead of Read as it contains the
],
};
}
const content = await agent.readTextFile({
sessionId,
path: input.abs_path,
limit: input.limit,
line: input.offset,
});
// Extract lines with byte limit enforcement
const result = extractLinesWithByteLimit(
content.content,
input.offset ?? 0,
input.linesToRead,
defaults.maxFileSize,
);
// Construct informative message about what was read
let readInfo = "";
if (input.offset > 0 || 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.actualEndLine}. `;
}
if (result.wasLimited) {
readInfo += `Continue with offset=${result.actualEndLine}.`;
}
readInfo += "</file-read-info>";
}
return {
content: [
{
type: "text",
text: content.content + SYSTEM_REMINDER,
text: result.content + readInfo + SYSTEM_REMINDER,
},
],
};
@@ -97,12 +159,12 @@ In sessions with mcp__acp__read always use it instead of Read as it contains the
if (clientCapabilities?.fs?.writeTextFile) {
server.registerTool(
"write",
unqualifiedToolNames.write,
{
title: "Write",
description: `Writes content to the specified file in the project.
In sessions with mcp__acp__write always use it instead of Write as it will
In sessions with ${toolNames.write} always use it instead of Write as it will
allow the user to conveniently review changes.`,
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to write"),
@@ -152,12 +214,12 @@ allow the user to conveniently review changes.`,
);
server.registerTool(
"edit",
unqualifiedToolNames.edit,
{
title: "Edit",
description: `Edit a file.
In sessions with mcp__acp__edit always use it instead of Edit as it will
In sessions with ${toolNames.edit} always use it instead of Edit as it will
allow the user to conveniently review changes.
File editing instructions:
@@ -229,12 +291,12 @@ File editing instructions:
);
server.registerTool(
"multi-edit",
unqualifiedToolNames.multiEdit,
{
title: "Multi Edit",
description: `Edit a file with multiple sequential edits.
In sessions with mcp__acp__multi-edit always use it instead of MultiEdit as it will
In sessions with ${toolNames.multiEdit} always use it instead of MultiEdit as it will
allow the user to conveniently review changes.
File editing instructions:
@@ -319,7 +381,7 @@ File editing instructions:
if (agent.clientCapabilities?.terminal) {
server.registerTool(
"Bash",
unqualifiedToolNames.bash,
{
title: "Bash",
description: "Executes a bash command",
@@ -333,7 +395,7 @@ File editing instructions:
.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 `mcp__acp__BashOutput` tool to retrieve the current output, or the `mcp__acp__KillBash` 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 \`${toolNames.bashOutput}\` tool to retrieve the current output, or the \`${toolNames.killBash}\` tool to stop it early.`,
),
},
},
@@ -454,7 +516,7 @@ File editing instructions:
);
server.registerTool(
"BashOutput",
unqualifiedToolNames.bashOutput,
{
title: "BashOutput",
description:
@@ -462,7 +524,7 @@ File editing instructions:
inputSchema: {
id: z
.string()
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
@@ -505,14 +567,14 @@ File editing instructions:
);
server.registerTool(
"KillBash",
unqualifiedToolNames.killBash,
{
title: "KillBash",
description: "Stops a background command by its id",
inputSchema: {
id: z
.string()
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
},
},
async (input) => {
@@ -565,7 +627,7 @@ File editing instructions:
const alwaysAllowedTools: { [key: string]: boolean } = {};
server.registerTool(
"permission",
unqualifiedToolNames.permission,
{
title: "Permission Tool",
description: "Used to request tool permissions",
@@ -576,42 +638,37 @@ File editing instructions:
},
},
async (input) => {
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 result = await canUseTool(input);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
},
);
async function canUseTool(input: {
tool_use_id?: string;
tool_name: string;
input?: any;
}): Promise<PermissionResult> {
const session = agent.sessions[sessionId];
if (!session) {
return {
behavior: "deny",
message: "Session not found",
};
}
if (input.tool_name === "ExitPlanMode") {
const response = await agent.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Always Allow",
optionId: "allow_always",
name: "Yes, and auto-accept edits",
optionId: "acceptEdits",
},
{ kind: "allow_once", name: "Allow", optionId: "allow" },
{ kind: "reject_once", name: "Reject", optionId: "reject" },
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
],
sessionId,
toolCall: {
@@ -619,39 +676,80 @@ File editing instructions:
rawInput: input.input,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
) {
if (response.outcome.optionId === "allow_always") {
alwaysAllowedTools[input.tool_name] = true;
}
session.permissionMode = response.outcome.optionId;
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: response.outcome.optionId,
},
});
return {
content: [
{
type: "text",
text: JSON.stringify({
behavior: "allow",
updatedInput: input.input,
}),
},
behavior: "allow",
updatedInput: input.input,
updatedPermissions: [
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
],
};
} else {
return {
content: [
{
type: "text",
text: JSON.stringify({
behavior: "deny",
message: "User refused permission to run tool",
}),
},
],
behavior: "deny",
message: "User rejected request to exit plan mode.",
};
}
},
);
}
if (
session.permissionMode === "bypassPermissions" ||
(session.permissionMode === "acceptEdits" && editToolNames.includes(input.tool_name)) ||
alwaysAllowedTools[input.tool_name]
) {
return {
behavior: "allow",
updatedInput: input.input,
};
}
const response = await agent.client.requestPermission({
options: [
{
kind: "allow_always",
name: "Always Allow",
optionId: "allow_always",
},
{ kind: "allow_once", name: "Allow", optionId: "allow" },
{ kind: "reject_once", name: "Reject", optionId: "reject" },
],
sessionId,
toolCall: {
toolCallId: input.tool_use_id!,
rawInput: input.input,
},
});
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
) {
if (response.outcome.optionId === "allow_always") {
alwaysAllowedTools[input.tool_name] = true;
}
return {
behavior: "allow",
updatedInput: input.input,
};
} else {
return {
behavior: "deny",
message: "User refused permission to run tool",
};
}
}
const app = express();
app.use(express.json());

View File

@@ -91,7 +91,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.files.set(params.path, params.content);
return null;
return {};
}
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
@@ -191,6 +191,50 @@ 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", () => {
@@ -778,7 +822,10 @@ describe("tool conversions", () => {
// Should return empty object when parsing fails
expect(update).toEqual({
content: [
{ content: { type: "text", text: "Failed to find `old_string`" }, type: "content" },
{
content: { type: "text", text: "```\nFailed to find `old_string`\n```" },
type: "content",
},
],
});
});

View File

@@ -0,0 +1,174 @@
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, 0, 5, 1000);
expect(result.content).toBe(simpleContent);
expect(result.actualEndLine).toBe(4);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(5);
});
it("should extract partial lines with offset", () => {
const result = extractLinesWithByteLimit(simpleContent, 2, 2, 1000);
expect(result.content).toBe("Line 3\nLine 4");
expect(result.actualEndLine).toBe(3);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(2);
});
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, 0, 10, 250);
expect(result.wasLimited).toBe(true);
expect(result.actualEndLine).toBe(1); // Should only get 2 lines
expect(result.linesRead).toBe(2);
});
it("should handle offset beyond file length", () => {
const result = extractLinesWithByteLimit(simpleContent, 10, 5, 1000);
expect(result.content).toBe("");
expect(result.actualEndLine).toBe(10);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(0);
});
it("should handle empty content", () => {
const result = extractLinesWithByteLimit("", 0, 10, 1000);
expect(result.content).toBe("");
expect(result.actualEndLine).toBe(0); // Empty string splits to [""] and we process that one empty line
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, 0, 10, 1000);
expect(result.content).toBe(singleLine);
expect(result.actualEndLine).toBe(0);
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, 0, 10, 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, 0, 3, 85);
expect(result.content).toBe(`${line1}\n${line2}`);
expect(result.actualEndLine).toBe(1);
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, 0, 3, 17);
expect(result.content).toBe(exactContent);
expect(result.actualEndLine).toBe(2);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(3);
});
it("should handle reading from middle to end", () => {
const result = extractLinesWithByteLimit(simpleContent, 3, 100, 1000);
expect(result.content).toBe("Line 4\nLine 5");
expect(result.actualEndLine).toBe(4);
expect(result.wasLimited).toBe(false);
expect(result.linesRead).toBe(2);
});
it("should handle large content with typical parameters", () => {
const longContent = Array(2000).fill("Line content here").join("\n");
const result = extractLinesWithByteLimit(longContent, 0, 1000, 50000);
expect(result.actualEndLine).toBeLessThanOrEqual(1000); // Should be limited by line count
});
it("should provide correct data for partial read at start", () => {
const result = extractLinesWithByteLimit(simpleContent, 0, 2, 1000);
expect(result.actualEndLine).toBe(1);
expect(result.linesRead).toBe(2);
});
it("should provide correct data for partial read with offset", () => {
const result = extractLinesWithByteLimit(simpleContent, 1, 2, 1000);
expect(result.actualEndLine).toBe(2);
expect(result.linesRead).toBe(2);
});
it("should not add newline after last line", () => {
const result = extractLinesWithByteLimit("Line 1\nLine 2\nLine 3", 2, 1, 1000);
expect(result.content).toBe("Line 3"); // No trailing newline
});
it("should handle Windows-style line endings", () => {
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
const result = extractLinesWithByteLimit(windowsContent, 0, 3, 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, 0, 200, 50000);
expect(result.wasLimited).toBe(true);
expect(result.actualEndLine).toBeLessThan(50); // Should stop well before 200 lines
});
it("should handle line limit of 0", () => {
const result = extractLinesWithByteLimit(simpleContent, 0, 0, 1000);
expect(result.content).toBe("");
expect(result.actualEndLine).toBe(0);
expect(result.linesRead).toBe(0);
expect(result.wasLimited).toBe(false);
});
it("should allow at least one line even if it exceeds byte limit", () => {
const veryLongLine = "x".repeat(100000); // 100KB line
const result = extractLinesWithByteLimit(veryLongLine, 0, 1, 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.actualEndLine).toBe(0);
expect(result.linesRead).toBe(1);
expect(result.wasLimited).toBe(false);
});
});

View File

@@ -4,7 +4,7 @@ import {
ToolCallLocation,
ToolKind,
} from "@zed-industries/agent-client-protocol";
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
interface ToolInfo {
title: string;
@@ -67,7 +67,7 @@ export function toolInfoFromToolUse(
};
case "Bash":
case "mcp__acp__Bash":
case toolNames.bash:
return {
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
kind: "execute",
@@ -83,7 +83,7 @@ export function toolInfoFromToolUse(
};
case "BashOutput":
case "mcp__acp__BashOutput":
case toolNames.bashOutput:
return {
title: "Tail Logs",
kind: "execute",
@@ -91,14 +91,14 @@ export function toolInfoFromToolUse(
};
case "KillBash":
case "mcp__acp__KillBash":
case toolNames.killBash:
return {
title: "Kill Process",
kind: "execute",
content: [],
};
case "mcp__acp__read": {
case toolNames.read: {
let limit = "";
if (input.limit) {
limit =
@@ -135,7 +135,7 @@ export function toolInfoFromToolUse(
locations: [],
};
case "mcp__acp__edit":
case toolNames.edit:
case "Edit": {
const path = input?.abs_path ?? input?.file_path;
let oldText = input.old_string ?? null;
@@ -181,7 +181,7 @@ export function toolInfoFromToolUse(
};
}
case "mcp__acp__multi-edit":
case toolNames.multiEdit:
case "MultiEdit": {
const multiInput = input as {
file_path: string;
@@ -234,7 +234,7 @@ export function toolInfoFromToolUse(
: [],
};
}
case "mcp__acp__write": {
case toolNames.write: {
let content: ToolCallContent[] = [];
if (input && input.abs_path) {
content = [
@@ -400,10 +400,10 @@ export function toolInfoFromToolUse(
content: [],
};
case "exit_plan_mode":
case "ExitPlanMode":
return {
title: "Exit Plan Mode",
kind: "think",
title: "Ready to code?",
kind: "switch_mode",
content:
input && input.plan
? [{ type: "content", content: { type: "text", text: input.plan } }]
@@ -447,7 +447,7 @@ export function toolUpdateFromToolResult(
): ToolUpdate {
switch (toolUse?.name) {
case "Read":
case "mcp__acp__read":
case toolNames.read:
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
@@ -476,22 +476,26 @@ export function toolUpdateFromToolResult(
}
return {};
case "mcp__acp__Bash":
case "mcp__acp__edit":
case toolNames.bash:
case "edit":
case "Edit":
case "mcp__acp__multi-edit":
case toolNames.edit:
case toolNames.multiEdit:
case "multi-edit":
case "MultiEdit":
case "mcp__acp__write":
case toolNames.write:
case "Write": {
if (toolResult.is_error && toolResult.content?.length > 0) {
// Only return errors
return toAcpContentUpdate(toolResult.content);
return toAcpContentUpdate(toolResult.content, true);
}
return {};
}
case "ExitPlanMode": {
return { title: "Exited Plan Mode" };
}
case "Task":
case "NotebookEdit":
case "NotebookRead":
@@ -507,17 +511,26 @@ export function toolUpdateFromToolResult(
case "WebSearch":
case "Other":
default: {
return toAcpContentUpdate(toolResult.content);
return toAcpContentUpdate(toolResult.content, toolResult.is_error);
}
}
}
function toAcpContentUpdate(content: any): { content?: ToolCallContent[] } {
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,
content:
isError && content.type === "text"
? {
...content,
text: `\`\`\`\n${content.text}\n\`\`\``,
}
: content,
})),
};
} else if (typeof content === "string" && content.length > 0) {
@@ -527,7 +540,7 @@ function toAcpContentUpdate(content: any): { content?: ToolCallContent[] } {
type: "content",
content: {
type: "text",
text: content,
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
},
},
],

View File

@@ -2,6 +2,8 @@
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> {
@@ -80,3 +82,153 @@ 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;
actualEndLine: number;
wasLimited: boolean;
linesRead: number;
}
/**
* Extracts lines from file content with byte limit enforcement.
*
* @param fullContent - The complete file content
* @param linesToSkip - Starting line number (0-based)
* @param linesToRead - Maximum number of lines to read
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
* @returns Object containing extracted content and metadata
*/
export function extractLinesWithByteLimit(
fullContent: string,
linesToSkip: number,
linesToRead: number,
maxContentLength: number,
): ExtractLinesResult {
if (fullContent === "" || linesToRead === 0) {
if (linesToSkip === 0 && linesToRead > 0) {
return {
content: "",
actualEndLine: 0,
wasLimited: false,
linesRead: 1,
};
} else {
return {
content: "",
actualEndLine: linesToSkip,
wasLimited: false,
linesRead: 0,
};
}
}
let linesSeen = 0;
let index = 0;
while (linesSeen < linesToSkip) {
const nextIndex = fullContent.indexOf("\n", index);
// There were not enough lines to skip.
if (nextIndex < 0) {
return {
content: "",
actualEndLine: linesToSkip,
wasLimited: false,
linesRead: 0,
};
}
linesSeen += 1;
index = nextIndex + 1;
}
// We've successfully skipped over all the lines we were supposed to.
// Now we can actually start reading!
const startIndex = index;
linesSeen = 0;
let contentLength = 0;
let wasLimited = false;
while (linesSeen < linesToRead) {
const nextIndex = fullContent.indexOf("\n", index);
if (nextIndex < 0) {
// Last line in file (no trailing newline)
const newContentLength = fullContent.length - startIndex;
if (linesSeen > 0 && newContentLength > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = newContentLength;
break;
} else {
// Line with newline - include up to the newline
const newContentLength = nextIndex + 1 - startIndex;
if (linesSeen > 0 && newContentLength > maxContentLength) {
wasLimited = true;
break;
}
linesSeen += 1;
contentLength = newContentLength;
index = nextIndex + 1;
}
}
// If we ended with a newline and we stopped due to byte limit or line limit (not end of file), remove the trailing newline
if (contentLength > 0 && fullContent[startIndex + contentLength - 1] === "\n") {
// Check if there's more content after our current position that we didn't read
if (startIndex + contentLength < fullContent.length) {
contentLength -= 1;
}
}
return {
content: fullContent.slice(startIndex, startIndex + contentLength),
actualEndLine: linesSeen > 0 ? linesToSkip + linesSeen - 1 : linesToSkip,
wasLimited,
linesRead: linesSeen,
};
}