UNPKG

acp-claude-code

Version:

ACP (Agent Client Protocol) bridge for Claude Code

619 lines 27.4 kB
import { query } from "@anthropic-ai/claude-code"; import { PROTOCOL_VERSION, } from "@zed-industries/agent-client-protocol"; import { toAsyncIterable } from "./utils.js"; export class ClaudeACPAgent { client; sessions = new Map(); DEBUG = process.env.ACP_DEBUG === "true"; defaultPermissionMode = process.env.ACP_PERMISSION_MODE || "default"; pathToClaudeCodeExecutable = process.env.ACP_PATH_TO_CLAUDE_CODE_EXECUTABLE; constructor(client) { this.client = client; this.log("Initialized with client"); } log(message, ...args) { if (this.DEBUG) { console.error(`[ClaudeACPAgent] ${message}`, ...args); } } async initialize(params) { this.log(`Initialize with protocol version: ${params.protocolVersion}`); return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true, // Enable session loading promptCapabilities: { image: true, audio: false, embeddedContext: true, }, }, }; } async newSession(_params) { this.log("Creating new session"); // For now, create a temporary session ID // We'll get the real Claude session_id on the first message // and store it for future use const sessionId = Math.random().toString(36).substring(2); this.sessions.set(sessionId, { pendingPrompt: null, abortController: null, claudeSessionId: undefined, // Will be set after first message permissionMode: this.defaultPermissionMode, todoWriteToolCallIds: new Set(), toolCallContents: new Map(), }); this.log(`Created session: ${sessionId}`); return { sessionId, }; } async loadSession(params) { this.log(`Loading session: ${params.sessionId}`); // Check if we already have this session const existingSession = this.sessions.get(params.sessionId); if (existingSession) { this.log(`Session ${params.sessionId} already exists with Claude session_id: ${existingSession.claudeSessionId}`); // Keep the existing session with its Claude session_id intact return; // Return null to indicate success } // Create a new session entry for this ID if it doesn't exist // This handles the case where the agent restarts but Zed still has the session ID this.sessions.set(params.sessionId, { pendingPrompt: null, abortController: null, claudeSessionId: undefined, permissionMode: this.defaultPermissionMode, todoWriteToolCallIds: new Set(), toolCallContents: new Map(), }); this.log(`Created new session entry for loaded session: ${params.sessionId}`); return; // Return null to indicate success } async authenticate(_params) { this.log("Authenticate called"); // Claude Code SDK handles authentication internally through ~/.claude/config.json // Users should run `claude setup-token` or login through the CLI this.log("Using Claude Code authentication from ~/.claude/config.json"); } async prompt(params) { const currentSessionId = params.sessionId; const session = this.sessions.get(currentSessionId); if (!session) { this.log(`Session ${currentSessionId} not found in map. Available sessions: ${Array.from(this.sessions.keys()).join(", ")}`); throw new Error(`Session ${currentSessionId} not found`); } this.log(`Processing prompt for session: ${currentSessionId}`); this.log(`Session state: claudeSessionId=${session.claudeSessionId}, pendingPrompt=${!!session.pendingPrompt}, abortController=${!!session.abortController}`); this.log(`Available sessions: ${Array.from(this.sessions.keys()).join(", ")}`); // Cancel any pending prompt if (session.abortController) { session.abortController.abort(); } session.abortController = new AbortController(); try { const userMessage = { type: "user", message: { role: "user", content: [], }, }; const textMessagePieces = []; let imageIdx = 0; for (const block of params.prompt) { if (block.type === "text") { textMessagePieces.push(block.text); } if (block.type === "image") { imageIdx++; textMessagePieces.push(`[Image #${imageIdx}]`); userMessage.message.content.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data, } }); } let uri; if (block.type === "resource") { uri = block.resource.uri; } if (block.type === "resource_link") { uri = block.uri; } if (uri) { if (uri.startsWith("file://")) { const filePath = uri.substring(7); textMessagePieces.push("@" + filePath); } else { textMessagePieces.push(uri); } } } const promptText = textMessagePieces.join(""); if (promptText) { userMessage.message.content.push({ type: "text", text: promptText, }); } if (!session.claudeSessionId) { this.log("First message for this session, no resume"); } else { this.log(`Resuming Claude session: ${session.claudeSessionId}`); } // Check for permission mode hints in the prompt let permissionMode = session.permissionMode || this.defaultPermissionMode; // Allow dynamic permission mode switching via special commands if (promptText.includes("[ACP:PERMISSION:ACCEPT_EDITS]")) { permissionMode = "acceptEdits"; session.permissionMode = "acceptEdits"; } else if (promptText.includes("[ACP:PERMISSION:BYPASS]")) { permissionMode = "bypassPermissions"; session.permissionMode = "bypassPermissions"; } else if (promptText.includes("[ACP:PERMISSION:DEFAULT]")) { permissionMode = "default"; session.permissionMode = "default"; } this.log(`Using permission mode: ${permissionMode}`); // Start Claude query const messages = query({ // eslint-disable-next-line @typescript-eslint/no-explicit-any prompt: toAsyncIterable([userMessage]), options: { permissionMode, pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, // Resume if we have a Claude session_id resume: session.claudeSessionId || undefined, }, }); session.pendingPrompt = messages; // Process messages and send updates let messageCount = 0; for await (const message of messages) { if (session.abortController?.signal.aborted) { return { stopReason: "cancelled" }; } messageCount++; this.log(`Processing message #${messageCount} of type: ${message.type}`); // Extract and store Claude's session_id from any message that has it const sdkMessage = message; this.tryToStoreClaudeSessionId(currentSessionId, sdkMessage); // Log message type and content for debugging if (sdkMessage.type === "user") { this.log(`Processing user message`); } else if (sdkMessage.type === "assistant") { this.log(`Processing assistant message`); // Log assistant message content for debugging if ("message" in sdkMessage && sdkMessage.message) { const assistantMsg = sdkMessage.message; if (assistantMsg.content) { this.log(`Assistant content: ${JSON.stringify(assistantMsg.content).substring(0, 200)}`); } } } await this.handleClaudeMessage(currentSessionId, message); } this.log(`Processed ${messageCount} messages total`); this.log(`Final Claude session_id: ${session.claudeSessionId}`); session.pendingPrompt = null; // Ensure the session is properly saved with the Claude session_id this.sessions.set(currentSessionId, session); return { stopReason: "end_turn", }; } catch (error) { this.log("Error during prompt processing:", error); if (session.abortController?.signal.aborted) { return { stopReason: "cancelled" }; } // Send error to client await this.client.sessionUpdate({ sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, }, }); return { stopReason: "end_turn", }; } finally { session.pendingPrompt = null; session.abortController = null; } } async cancel(params) { this.log(`Cancel requested for session: ${params.sessionId}`); const session = this.sessions.get(params.sessionId); if (session) { session.abortController?.abort(); if (session.pendingPrompt && session.pendingPrompt.return) { await session.pendingPrompt.return(); session.pendingPrompt = null; } } } async sendAgentPlan(sessionId, todos) { // The status and priority of ACP plan entry can directly correspond to the status and priority in claude-code todo, just need to remove the todo id. const planEntries = todos.map((todo) => { return { content: todo.content, status: todo.status, priority: todo.priority ?? "low", }; }); await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "plan", entries: planEntries, }, }); } async handleClaudeMessage(sessionId, message) { // Use a more flexible type handling approach const msg = message; const messageType = "type" in message ? message.type : undefined; this.log(`Handling message type: ${messageType}`, JSON.stringify(message).substring(0, 200)); const session = this.sessions.get(sessionId); switch (messageType) { case "system": // System messages are internal, don't send to client break; case "user": // Handle user message that may contain tool results if (msg.message && msg.message.content) { for (const content of msg.message.content) { if (content.type === "tool_result") { this.log(`Tool result received for: ${content.tool_use_id}`); if (content.tool_use_id && session?.todoWriteToolCallIds.has(content.tool_use_id)) { continue; } const newContent = { type: "content", content: { type: "text", text: (content.content || "") + "\n", }, }; const prevToolCallContent = session?.toolCallContents.get(content.tool_use_id || "") || []; const toolCallContent = [...prevToolCallContent, newContent]; session?.toolCallContents.set(content.tool_use_id || "", toolCallContent); // Send tool_call_update with completed status await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: content.tool_use_id || "", status: "completed", content: toolCallContent, rawOutput: content.content ? { output: content.content } : undefined, }, }); } } } break; case "assistant": // Handle assistant message from Claude if (msg.message && msg.message.content) { for (const content of msg.message.content) { if (content.type === "text") { // Send text content without adding extra newlines // Claude already formats the text properly await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: content.text || "", }, }, }); } else if (content.type === "tool_use") { // Handle tool_use blocks in assistant messages this.log(`Tool use block in assistant message: ${content.name}, id: ${content.id}`); if (content.name === "TodoWrite" && content.input?.todos) { session?.todoWriteToolCallIds.add(content.id || ""); const todos = content.input.todos; await this.sendAgentPlan(sessionId, todos); } else { const toolCallContent = this.getToolCallContent(content.name || "", content.input); session?.toolCallContents.set(content.id || "", toolCallContent); // Send tool_call notification to client await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "tool_call", toolCallId: content.id || "", title: content.name || "Tool", kind: this.mapToolKind(content.name || ""), status: "pending", content: toolCallContent, rawInput: content.input, }, }); } } } } else if ("text" in msg && typeof msg.text === "string") { // Handle direct text in assistant message await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: msg.text, }, }, }); } break; case "result": // Result message indicates completion this.log("Query completed with result:", msg.result); break; case "text": // Direct text messages - preserve formatting without extra newlines await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: msg.text || "", }, }, }); break; case "tool_use_start": { // Log the tool call details for debugging this.log(`Tool call started: ${msg.tool_name}`, `ID: ${msg.id}`); // Handle tool input - ensure it's a proper object const input = msg.input || {}; // Log the input for debugging if (this.DEBUG) { try { this.log(`Tool input:`, JSON.stringify(input, null, 2)); // Special logging for content field if (input && typeof input === "object" && "content" in input) { const content = input.content; if (typeof content === "string") { const preview = content.substring(0, 100); this.log(`Content preview: ${preview}${content.length > 100 ? "..." : ""}`); } } } catch (e) { this.log("Error logging input:", e); } } if (msg.tool_name === "TodoWrite" && input && typeof input === "object" && "todos" in input) { const todos = input.todos; if (todos && Array.isArray(todos)) { session?.todoWriteToolCallIds.add(msg.id || ""); await this.sendAgentPlan(sessionId, todos); } } else { const toolCallContent = this.getToolCallContent(msg.tool_name || "", input); session?.toolCallContents.set(msg.id || "", toolCallContent); await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "tool_call", toolCallId: msg.id || "", title: msg.tool_name || "Tool", kind: this.mapToolKind(msg.tool_name || ""), status: "pending", content: toolCallContent, // Pass the input directly without extra processing rawInput: input, }, }); } break; } case "tool_use_output": { const outputText = msg.output || ""; // Log the tool output for debugging this.log(`Tool call completed: ${msg.id}`); this.log(`Tool output length: ${outputText.length} characters`); if (msg.id && session?.todoWriteToolCallIds.has(msg.id)) { break; } const newContent = { type: "content", content: { type: "text", text: outputText, }, }; const prevToolCallContent = session?.toolCallContents.get(msg.id || "") || []; const toolCallContent = [...prevToolCallContent, newContent]; session?.toolCallContents.set(msg.id || "", toolCallContent); await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: msg.id || "", status: "completed", content: toolCallContent, // Pass output directly without extra wrapping rawOutput: msg.output ? { output: outputText } : undefined, }, }); break; } case "tool_use_error": await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: msg.id || "", status: "failed", content: [ { type: "content", content: { type: "text", text: `Error: ${msg.error}`, }, }, ], rawOutput: { error: msg.error }, }, }); break; case "stream_event": { // Handle stream events if needed const event = msg.event; if (event.type === "content_block_start" && event.content_block?.type === "text") { await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: event.content_block.text || "", }, }, }); } else if (event.type === "content_block_delta" && event.delta?.type === "text_delta") { await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: event.delta.text || "", }, }, }); } else if (event.type === "content_block_stop") { // Content block ended - Claude handles its own formatting this.log("Content block stopped"); } break; } default: this.log(`Unhandled message type: ${messageType}`, JSON.stringify(message).substring(0, 500)); } } mapToolKind(toolName) { const lowerName = toolName.toLowerCase(); if (lowerName.includes("read") || lowerName.includes("view") || lowerName.includes("get")) { return "read"; } else if (lowerName.includes("write") || lowerName.includes("create") || lowerName.includes("update") || lowerName.includes("edit")) { return "edit"; } else if (lowerName.includes("delete") || lowerName.includes("remove")) { return "delete"; } else if (lowerName.includes("move") || lowerName.includes("rename")) { return "move"; } else if (lowerName.includes("search") || lowerName.includes("find") || lowerName.includes("grep")) { return "search"; } else if (lowerName.includes("run") || lowerName.includes("execute") || lowerName.includes("bash")) { return "execute"; } else if (lowerName.includes("think") || lowerName.includes("plan")) { return "think"; } else if (lowerName.includes("fetch") || lowerName.includes("download")) { return "fetch"; } else { return "other"; } } getToolCallContent(toolName, toolInput) { const result = []; switch (toolName) { case "Edit": { if (toolInput.file_path && toolInput.old_string && toolInput.new_string) { result.push({ type: "diff", path: toolInput.file_path, oldText: toolInput.old_string, newText: toolInput.new_string, }); } break; } ; case "MultiEdit": { if (toolInput.file_path && toolInput.edits) { for (const edit of toolInput.edits) { result.push({ type: "diff", path: toolInput.file_path, oldText: edit.old_string, newText: edit.new_string, }); } } } ; } ; return result; } tryToStoreClaudeSessionId(sessionId, sdkMessage) { const session = this.sessions.get(sessionId); if (!session) { return; } if ("session_id" in sdkMessage && typeof sdkMessage.session_id === "string" && sdkMessage.session_id) { if (session.claudeSessionId !== sdkMessage.session_id) { this.log(`Updating Claude session_id from ${session.claudeSessionId} to ${sdkMessage.session_id}`); session.claudeSessionId = sdkMessage.session_id; return sdkMessage.session_id; } } } } //# sourceMappingURL=agent.js.map