diff --git a/package-lock.json b/package-lock.json index 706bf515..9c2735e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,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", @@ -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" diff --git a/package.json b/package.json index ab3c6a21..e7055ac3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 06dd24c6..1c1a0e9c 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -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; 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 { + 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 { const response = await this.client.readTextFile(params); if (!params.limit && !params.line) { diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 550aa5c6..c6fb04b9 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -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. `; +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 { + 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()); diff --git a/src/tools.ts b/src/tools.ts index 9fdc6f79..593cadee 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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":