diff --git a/package-lock.json b/package-lock.json index 55164745..2a37b426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.12.3", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "0.9.0", + "@agentclientprotocol/sdk": "0.10.0", "@anthropic-ai/claude-agent-sdk": "0.1.65", "@modelcontextprotocol/sdk": "1.24.3", "diff": "8.0.2", @@ -33,9 +33,9 @@ } }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.9.0.tgz", - "integrity": "sha512-JoLjIYS+lfxKXnSBRDCQDzY19gGp6DYcYojMXHNC430b3R32CUOXCMZjndI+yz8QDFY87Dx+S76oLAZ99X+W5w==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.10.0.tgz", + "integrity": "sha512-+Bwmf8Uli480h4ONjANxVP6tnACqHOaCiLJslgwOJHJvjDFzhAHZX4aL/bPKpOPc3LU1RgkPnJ98rCdPBnakaQ==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -1579,6 +1579,7 @@ "integrity": "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1618,6 +1619,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -1962,6 +1964,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2457,6 +2460,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2760,6 +2764,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3618,6 +3623,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4209,6 +4215,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4265,6 +4272,7 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4488,6 +4496,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 66bce3f9..361fced8 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "author": "Zed Industries", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "0.9.0", + "@agentclientprotocol/sdk": "0.10.0", "@anthropic-ai/claude-agent-sdk": "0.1.65", "@modelcontextprotocol/sdk": "1.24.3", "diff": "8.0.2", diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 04686b6c..2843da59 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -5,6 +5,8 @@ import { AvailableCommand, CancelNotification, ClientCapabilities, + ForkSessionRequest, + ForkSessionResponse, InitializeRequest, InitializeResponse, ndJsonStream, @@ -186,6 +188,10 @@ export class ClaudeAcpAgent implements Agent { http: true, sse: true, }, + sessionCapabilities: { + // TODO: announce fork capability when sessionId handling is fixed + // fork: {}, + }, }, agentInfo: { name: packageJson.name, @@ -195,6 +201,7 @@ export class ClaudeAcpAgent implements Agent { authMethods: [authMethod], }; } + async newSession(params: NewSessionRequest): Promise { if ( fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) && @@ -203,237 +210,24 @@ export class ClaudeAcpAgent implements Agent { throw RequestError.authRequired(); } - // Extract options from _meta if provided - const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options; - - const sessionId = userProvidedOptions?.resume || randomUUID(); - const input = new Pushable(); - - const settingsManager = new SettingsManager(params.cwd, { - logger: this.logger, + return await this.createSession(params, { + // Revisit these meta values once we support resume + resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.resume, }); - await settingsManager.initialize(); - - const mcpServers: Record = {}; - if (Array.isArray(params.mcpServers)) { - for (const server of params.mcpServers) { - if ("type" in server) { - mcpServers[server.name] = { - type: server.type, - url: server.url, - headers: server.headers - ? Object.fromEntries(server.headers.map((e) => [e.name, e.value])) - : undefined, - }; - } else { - mcpServers[server.name] = { - type: "stdio", - command: server.command, - args: server.args, - env: server.env - ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) - : undefined, - }; - } - } - } - - // Only add the acp MCP server if built-in tools are not disabled - if (!params._meta?.disableBuiltInTools) { - const server = createMcpServer(this, sessionId, this.clientCapabilities); - mcpServers["acp"] = { - type: "sdk", - name: "acp", - instance: server, - }; - } - - 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 permissionMode = "default"; - - const extraArgs = { ...userProvidedOptions?.extraArgs }; - if (userProvidedOptions?.resume === undefined) { - // Set our own session id if not resuming an existing session. - extraArgs["session-id"] = sessionId; - } - - const options: Options = { - systemPrompt, - settingSources: ["user", "project", "local"], - stderr: (err) => this.logger.error(err), - ...userProvidedOptions, - // Override certain fields that must be controlled by ACP - cwd: params.cwd, - includePartialMessages: true, - mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers }, - extraArgs, - // If we want bypassPermissions to be an option, we have to allow it here. - // But it doesn't work in root mode, so we only activate it if it will work. - allowDangerouslySkipPermissions: !IS_ROOT, - permissionMode, - canUseTool: this.canUseTool(sessionId), - // 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, - }), - hooks: { - ...userProvidedOptions?.hooks, - PreToolUse: [ - ...(userProvidedOptions?.hooks?.PreToolUse || []), - { - hooks: [createPreToolUseHook(settingsManager, this.logger)], - }, - ], - PostToolUse: [ - ...(userProvidedOptions?.hooks?.PostToolUse || []), - { - hooks: [createPostToolUseHook(this.logger)], - }, - ], - }, - }; - - const allowedTools = []; - const disallowedTools = []; - - // Check if built-in tools should be disabled - const disableBuiltInTools = params._meta?.disableBuiltInTools === true; - - if (!disableBuiltInTools) { - if (this.clientCapabilities?.fs?.readTextFile) { - allowedTools.push(acpToolNames.read); - disallowedTools.push("Read"); - } - if (this.clientCapabilities?.fs?.writeTextFile) { - disallowedTools.push("Write", "Edit"); - } - if (this.clientCapabilities?.terminal) { - allowedTools.push(acpToolNames.bashOutput, acpToolNames.killShell); - disallowedTools.push("Bash", "BashOutput", "KillShell"); - } - } else { - // When built-in tools are disabled, explicitly disallow all of them - disallowedTools.push( - acpToolNames.read, - acpToolNames.write, - acpToolNames.edit, - acpToolNames.bash, - acpToolNames.bashOutput, - acpToolNames.killShell, - "Read", - "Write", - "Edit", - "Bash", - "BashOutput", - "KillShell", - "Glob", - "Grep", - "Task", - "TodoWrite", - "ExitPlanMode", - "WebSearch", - "WebFetch", - "AskUserQuestion", - "SlashCommand", - "Skill", - "NotebookEdit", - ); - } - - if (allowedTools.length > 0) { - options.allowedTools = allowedTools; - } - if (disallowedTools.length > 0) { - options.disallowedTools = disallowedTools; - } - - // Handle abort controller from meta options - const abortController = userProvidedOptions?.abortController; - if (abortController?.signal.aborted) { - throw new Error("Cancelled"); - } - - const q = query({ - prompt: input, - options, - }); - - this.sessions[sessionId] = { - query: q, - input: input, - cancelled: false, - permissionMode, - settingsManager, - }; - - const availableCommands = await getAvailableSlashCommands(q); - const models = await getAvailableModels(q); - - // Needs to happen after we return the session - setTimeout(() => { - this.client.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "available_commands_update", - availableCommands, - }, - }); - }, 0); + } - const availableModes = [ + async forkSession(params: ForkSessionRequest): Promise { + return await this.createSession( { - id: "default", - name: "Default", - description: "Standard behavior, prompts for dangerous operations", + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + _meta: params._meta, }, { - id: "acceptEdits", - name: "Accept Edits", - description: "Auto-accept file edit operations", + resume: params.sessionId, + forkSession: true, }, - { - id: "plan", - name: "Plan Mode", - description: "Planning mode, no actual tool execution", - }, - { - id: "dontAsk", - name: "Don't Ask", - description: "Don't prompt for permissions, deny if not pre-approved", - }, - ]; - // Only works in non-root mode - if (!IS_ROOT) { - availableModes.push({ - id: "bypassPermissions", - name: "Bypass Permissions", - description: "Bypass all permission checks", - }); - } - - return { - sessionId, - models, - modes: { - currentModeId: permissionMode, - availableModes, - }, - }; + ); } async authenticate(_params: AuthenticateRequest): Promise { @@ -458,6 +252,7 @@ export class ClaudeAcpAgent implements Agent { } break; } + switch (message.type) { case "system": switch (message.subtype) { @@ -791,6 +586,244 @@ export class ClaudeAcpAgent implements Agent { } }; } + + private async createSession( + params: NewSessionRequest, + creationOpts: { resume?: string; forkSession?: boolean } = {}, + ): Promise { + const sessionId = creationOpts.resume ?? randomUUID(); + const input = new Pushable(); + + const settingsManager = new SettingsManager(params.cwd, { + logger: this.logger, + }); + await settingsManager.initialize(); + + const mcpServers: Record = {}; + if (Array.isArray(params.mcpServers)) { + for (const server of params.mcpServers) { + if ("type" in server) { + mcpServers[server.name] = { + type: server.type, + url: server.url, + headers: server.headers + ? Object.fromEntries(server.headers.map((e) => [e.name, e.value])) + : undefined, + }; + } else { + mcpServers[server.name] = { + type: "stdio", + command: server.command, + args: server.args, + env: server.env + ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) + : undefined, + }; + } + } + } + + // Only add the acp MCP server if built-in tools are not disabled + if (!params._meta?.disableBuiltInTools) { + const server = createMcpServer(this, sessionId, this.clientCapabilities); + mcpServers["acp"] = { + type: "sdk", + name: "acp", + instance: server, + }; + } + + 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 permissionMode = "default"; + + // Extract options from _meta if provided + const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options; + const extraArgs = { ...userProvidedOptions?.extraArgs }; + if (creationOpts?.resume === undefined) { + // Set our own session id if not resuming an existing session. + // TODO: find a way to make this work for fork + extraArgs["session-id"] = sessionId; + } + + const options: Options = { + systemPrompt, + settingSources: ["user", "project", "local"], + stderr: (err) => this.logger.error(err), + ...userProvidedOptions, + // Override certain fields that must be controlled by ACP + cwd: params.cwd, + includePartialMessages: true, + mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers }, + extraArgs, + // If we want bypassPermissions to be an option, we have to allow it here. + // But it doesn't work in root mode, so we only activate it if it will work. + allowDangerouslySkipPermissions: !IS_ROOT, + permissionMode, + canUseTool: this.canUseTool(sessionId), + // 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, + }), + hooks: { + ...userProvidedOptions?.hooks, + PreToolUse: [ + ...(userProvidedOptions?.hooks?.PreToolUse || []), + { + hooks: [createPreToolUseHook(settingsManager, this.logger)], + }, + ], + PostToolUse: [ + ...(userProvidedOptions?.hooks?.PostToolUse || []), + { + hooks: [createPostToolUseHook(this.logger)], + }, + ], + }, + ...creationOpts, + }; + + const allowedTools = []; + const disallowedTools = []; + + // Check if built-in tools should be disabled + const disableBuiltInTools = params._meta?.disableBuiltInTools === true; + + if (!disableBuiltInTools) { + if (this.clientCapabilities?.fs?.readTextFile) { + allowedTools.push(acpToolNames.read); + disallowedTools.push("Read"); + } + if (this.clientCapabilities?.fs?.writeTextFile) { + disallowedTools.push("Write", "Edit"); + } + if (this.clientCapabilities?.terminal) { + allowedTools.push(acpToolNames.bashOutput, acpToolNames.killShell); + disallowedTools.push("Bash", "BashOutput", "KillShell"); + } + } else { + // When built-in tools are disabled, explicitly disallow all of them + disallowedTools.push( + acpToolNames.read, + acpToolNames.write, + acpToolNames.edit, + acpToolNames.bash, + acpToolNames.bashOutput, + acpToolNames.killShell, + "Read", + "Write", + "Edit", + "Bash", + "BashOutput", + "KillShell", + "Glob", + "Grep", + "Task", + "TodoWrite", + "ExitPlanMode", + "WebSearch", + "WebFetch", + "AskUserQuestion", + "SlashCommand", + "Skill", + "NotebookEdit", + ); + } + + if (allowedTools.length > 0) { + options.allowedTools = allowedTools; + } + if (disallowedTools.length > 0) { + options.disallowedTools = disallowedTools; + } + + // Handle abort controller from meta options + const abortController = userProvidedOptions?.abortController; + if (abortController?.signal.aborted) { + throw new Error("Cancelled"); + } + + const q = query({ + prompt: input, + options, + }); + + this.sessions[sessionId] = { + query: q, + input: input, + cancelled: false, + permissionMode, + settingsManager, + }; + + const availableCommands = await getAvailableSlashCommands(q); + const models = await getAvailableModels(q); + + // Needs to happen after we return the session + setTimeout(() => { + this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands, + }, + }); + }, 0); + + const availableModes = [ + { + id: "default", + name: "Default", + description: "Standard behavior, prompts for dangerous operations", + }, + { + id: "acceptEdits", + name: "Accept Edits", + description: "Auto-accept file edit operations", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning mode, no actual tool execution", + }, + { + id: "dontAsk", + name: "Don't Ask", + description: "Don't prompt for permissions, deny if not pre-approved", + }, + ]; + // Only works in non-root mode + if (!IS_ROOT) { + availableModes.push({ + id: "bypassPermissions", + name: "Bypass Permissions", + description: "Bypass all permission checks", + }); + } + + return { + sessionId, + models, + modes: { + currentModeId: permissionMode, + availableModes, + }, + }; + } } async function getAvailableModels(query: Query): Promise {