Compare commits

..

3 Commits

Author SHA1 Message Date
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
6 changed files with 218 additions and 99 deletions

View File

@@ -34,7 +34,7 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
- Neovim via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
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

12
package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "@zed-industries/claude-code-acp",
"version": "0.3.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zed-industries/claude-code-acp",
"version": "0.3.0",
"version": "0.4.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.100",
"@modelcontextprotocol/sdk": "^1.17.4",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.7",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.8",
"diff": "^8.0.2",
"express": "^5.1.0",
"minimist": "^1.2.8",
@@ -1808,9 +1808,9 @@
}
},
"node_modules/@zed-industries/agent-client-protocol": {
"version": "0.2.0-alpha.7",
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.2.0-alpha.7.tgz",
"integrity": "sha512-LusOBTbhX1veoL2IwUigWcCUf4r7Wl0nFJPeXoBx3OXTWCGE5WfmUPjpXVpduegxEipqea53Q/ls5lQfyoYd3Q==",
"version": "0.2.0-alpha.8",
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.2.0-alpha.8.tgz",
"integrity": "sha512-60DDfM5ghChYkbz0Nk2nWDuWQ/BOpU+/4BcIgzHYhEQvCcJRbcs5piVjTXx7TryHNTOTyCmLmoX/WRpAZtva/Q==",
"license": "Apache-2.0",
"dependencies": {
"zod": "^3.0.0"

View File

@@ -6,7 +6,7 @@
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.3.0",
"version": "0.4.0",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
@@ -45,7 +45,7 @@
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.100",
"@modelcontextprotocol/sdk": "^1.17.4",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.7",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.8",
"diff": "^8.0.2",
"express": "^5.1.0",
"minimist": "^1.2.8",

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 =
@@ -148,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.
@@ -158,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");
}
@@ -185,6 +189,7 @@ export class ClaudeAcpAgent implements Agent {
query: q,
input: input,
cancelled: false,
permissionMode: "default",
};
getAvailableSlashCommands(q).then((availableCommands) => {
@@ -199,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",
},
],
},
};
}
@@ -286,6 +316,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) {

View File

@@ -9,6 +9,7 @@ import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agen
import * as diff from "diff";
import { sleep, unreachable } from "./utils.js";
import { PermissionResult } from "@anthropic-ai/claude-code";
export const SYSTEM_REMINDER = `
@@ -16,6 +17,31 @@ 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 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,14 +55,14 @@ 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
@@ -97,12 +123,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 +178,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 +255,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 +345,7 @@ File editing instructions:
if (agent.clientCapabilities?.terminal) {
server.registerTool(
"Bash",
unqualifiedToolNames.bash,
{
title: "Bash",
description: "Executes a bash command",
@@ -333,7 +359,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 +480,7 @@ File editing instructions:
);
server.registerTool(
"BashOutput",
unqualifiedToolNames.bashOutput,
{
title: "BashOutput",
description:
@@ -462,7 +488,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 +531,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 +591,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 +602,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 +640,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

@@ -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,14 +476,14 @@ 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
@@ -492,6 +492,10 @@ export function toolUpdateFromToolResult(
return {};
}
case "ExitPlanMode": {
return { title: "Exited Plan Mode" };
}
case "Task":
case "NotebookEdit":
case "NotebookRead":