diff --git a/apps/kilocode-docs/docs/cli.md b/apps/kilocode-docs/docs/cli.md index 8f47db021ae..bce745868e4 100644 --- a/apps/kilocode-docs/docs/cli.md +++ b/apps/kilocode-docs/docs/cli.md @@ -435,6 +435,225 @@ kilocode --continue - Cannot be used with a prompt argument - Only works when there's at least one previous task in the workspace +## ACP Mode (Agent Client Protocol) + +ACP mode enables Kilo Code to integrate with code editors that support the [Agent Client Protocol](https://agentclientprotocol.com/), such as [Zed](https://zed.dev/). This allows you to use Kilo Code as an AI coding agent directly within your editor. + +### What is ACP? + +The Agent Client Protocol (ACP) is a standardized protocol for communication between code editors and AI coding agents. It enables: + +- **Seamless editor integration**: Use Kilo Code directly in your editor's AI panel +- **Human-in-the-loop approval**: Review and approve tool actions before they execute +- **Streaming responses**: See agent responses as they're generated +- **Context awareness**: The agent has access to your workspace and can read/write files + +### Running in ACP Mode + +```bash +# Run the CLI in ACP mode +kilocode --acp + +# Run with a specific workspace +kilocode --acp --workspace /path/to/project +``` + +When running in ACP mode, the CLI: + +1. Communicates over stdin/stdout using JSON-RPC 2.0 +2. Waits for commands from the connected editor +3. Requests permission from the user before executing tools +4. Streams responses back to the editor in real-time + +### Full Build from Source + +If running from a fresh clone or the extension files are missing, build everything: + +```bash +# 1. Install dependencies (from repo root) +pnpm install + +# 2. Build the VS Code extension +cd src && pnpm bundle + +# 3. Package and unpack the extension +pnpm vsix && pnpm vsix:unpacked + +# 4. Build the CLI and copy extension files +cd ../cli && pnpm build && pnpm copy:kilocode + +# 5. Run in ACP mode +node dist/index.js --acp --workspace /path/to/project +``` + +### Testing with Zed Editor + +Follow these steps to test Kilo Code's ACP integration with Zed: + +#### Prerequisites + +1. **Install Kilo Code CLI globally**: + + ```bash + npm install -g @kilocode/cli + ``` + +2. **Verify the CLI is working**: + + ```bash + kilocode --help | grep acp + # Should show: --acp Run in ACP (Agent Client Protocol) mode... + ``` + +3. **Ensure you have a provider configured**: + + ```bash + kilocode config + # Configure your API provider (OpenRouter, Anthropic, etc.) + ``` + +4. **Install Zed** (if not already installed): + - Download from [zed.dev](https://zed.dev/) + - Or on macOS: `brew install zed` + +#### Configure Zed + +1. Open Zed's settings file: + + - macOS: `~/.config/zed/settings.json` + - Linux: `~/.config/zed/settings.json` + - Or open Zed and press `Cmd/Ctrl + ,` then click "Open Settings (JSON)" + +2. Add the Kilo Code agent server configuration: + +```json +{ + "agent_servers": { + "kilocode": { + "type": "custom", + "command": "kilocode", + "args": ["--acp"], + "env": {} + } + } +} +``` + +For local development, use the full path to the built CLI: + +```json +{ + "agent_servers": { + "kilocode-dev": { + "type": "custom", + "command": "node", + "args": ["/path/to/kilocode/cli/dist/index.js", "--acp"], + "env": {} + } + } +} +``` + +3. Save the file and restart Zed + +#### Test the Integration + +1. **Open a project in Zed**: + + ```bash + cd /path/to/your/project + zed . + ``` + +2. **Open the AI panel**: + + - Press `Ctrl/Cmd + Shift + A` + - Or use the menu: View → AI Panel + +3. **Select the Kilo Code agent**: + + - Click the agent dropdown in the AI panel + - Select "kilocode" + +4. **Send a test message**: + + - Type a message like "What files are in this project?" + - Press Enter to send + +5. **Verify the response**: + - You should see the agent streaming a response + - Tool calls (like reading files) will prompt for approval in the editor + +#### Troubleshooting + +**Agent not appearing in Zed:** + +- Ensure the CLI is installed globally and in your PATH +- Try running `which kilocode` to verify installation +- Check Zed's logs for error messages + +**Connection errors:** + +- Verify your provider API key is configured: `kilocode config` +- Check network connectivity +- Look at Zed's developer console for detailed errors + +**Permission prompts not appearing:** + +- Ensure you're using a recent version of Zed with ACP support +- The agent requires approval for file operations and commands + +**Debugging mode:** + +- Run `kilocode --acp` directly in a terminal to see raw JSON-RPC messages +- This can help identify protocol-level issues + +### Supported ACP Features + +| Feature | Status | Description | +| ------------------- | ------ | --------------------------------------------------- | +| `initialize` | ✅ | Establish connection and negotiate capabilities | +| `newSession` | ✅ | Create a new conversation session | +| `prompt` | ✅ | Send user messages and receive agent responses | +| `cancel` | ✅ | Cancel an ongoing operation | +| `sessionUpdate` | ✅ | Stream agent responses in real-time | +| `requestPermission` | ✅ | Request user approval for tool actions | +| `setSessionMode` | ✅ | Switch between modes (architect, code, debug, etc.) | + +### Known Limitations + +#### State Synchronization Bridge + +The ACP implementation bridges two different communication models: + +- **Kilo Code Extension**: Uses state synchronization (full state snapshots on every update) +- **ACP Protocol**: Expects incremental streaming (chunked content updates) + +To derive incremental updates from state snapshots, the agent tracks sent messages to avoid duplicates. This adds some complexity but works reliably. + +**Future Improvement:** A more efficient implementation would have the CLI emit streaming events directly from the LLM, rather than batching into full state updates. + +#### Other Current Limitations + +- **Image support**: Not yet implemented +- **Session persistence**: Sessions are ephemeral (no `loadSession` support) +- **MCP servers**: Connection parameters accepted but servers not automatically connected + +### How Tool Approval Works + +When Kilo Code needs to perform an action (like writing a file or running a command), it requests permission from the editor. The editor displays this to the user, who can: + +- **Allow**: Execute the action +- **Deny**: Reject the action and continue + +This ensures you maintain control over what changes the agent makes to your codebase. + +### Notes + +- ACP mode is designed for editor integration, not direct terminal use +- Your provider configuration from `kilocode config` is used for API access +- The same modes (architect, code, debug, etc.) are available as in the standard CLI + ## Environment Variable Overrides The CLI supports overriding config values with environment variables. The supported environment variables are: diff --git a/cli/package.dist.json b/cli/package.dist.json index d00dac64f26..ba61a2e95b1 100644 --- a/cli/package.dist.json +++ b/cli/package.dist.json @@ -9,6 +9,7 @@ "kilo": "index.js" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.7.0", "@anthropic-ai/bedrock-sdk": "^0.22.0", "@anthropic-ai/sdk": "^0.51.0", "@anthropic-ai/vertex-sdk": "^0.11.3", diff --git a/cli/package.json b/cli/package.json index b6630c6d50a..e4a1a939400 100644 --- a/cli/package.json +++ b/cli/package.json @@ -32,6 +32,7 @@ "changeset:version": "jq --arg version \"$(jq -r '.version' package.json)\" '.version = $version' package.dist.json > tmp.json && mv tmp.json package.dist.json && prettier --write package.dist.json" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.7.0", "@anthropic-ai/bedrock-sdk": "^0.22.0", "@anthropic-ai/sdk": "^0.51.0", "@anthropic-ai/vertex-sdk": "^0.11.3", diff --git a/cli/scripts/acp-debug.sh b/cli/scripts/acp-debug.sh new file mode 100755 index 00000000000..88d39e2e813 --- /dev/null +++ b/cli/scripts/acp-debug.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# ACP Debug Wrapper - logs stderr to /tmp/kilocode-acp.log +exec node /Users/silv/projects/kilocode/cli/dist/index.js --acp --acp-debug "$@" 2>/tmp/kilocode-acp.log \ No newline at end of file diff --git a/cli/src/acp/__tests__/agent.test.ts b/cli/src/acp/__tests__/agent.test.ts new file mode 100644 index 00000000000..fc90cf15586 --- /dev/null +++ b/cli/src/acp/__tests__/agent.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { KiloCodeAgent, createKiloCodeAgent } from "../agent.js" +import type { AgentSideConnection, InitializeRequest, NewSessionRequest, PromptRequest } from "@agentclientprotocol/sdk" +import type { ExtensionService } from "../../services/extension.js" +import { EventEmitter } from "events" + +// Mock AgentSideConnection +function createMockConnection(): AgentSideConnection { + return { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + requestPermission: vi.fn().mockResolvedValue({ + outcome: { outcome: "selected", optionId: "allow" }, + }), + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + createTerminal: vi.fn(), + extMethod: vi.fn(), + extNotification: vi.fn(), + signal: new AbortController().signal, + closed: new Promise(() => {}), // Never resolves in tests + } as unknown as AgentSideConnection +} + +// Mock ExtensionService +function createMockExtensionService(): ExtensionService & { _emitter: EventEmitter } { + const emitter = new EventEmitter() + const mockExtensionHost = { + sendWebviewMessage: vi.fn().mockResolvedValue(undefined), + } + const service = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + emitter.on(event, handler) + return service + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + emitter.off(event, handler) + return service + }), + emit: vi.fn((event: string, ...args: unknown[]) => emitter.emit(event, ...args)), + sendWebviewMessage: vi.fn().mockResolvedValue(undefined), + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn().mockResolvedValue(undefined), + getState: vi.fn(() => null), + isReady: vi.fn(() => true), + getExtensionHost: vi.fn(() => mockExtensionHost), + // Expose emitter for testing + _emitter: emitter, + } + return service as unknown as ExtensionService & { _emitter: EventEmitter } +} + +// Helper to create valid NewSessionRequest +function createNewSessionRequest(overrides: Partial = {}): NewSessionRequest { + return { + cwd: "/test/workspace", + mcpServers: [], + ...overrides, + } +} + +describe("KiloCodeAgent", () => { + let agent: KiloCodeAgent + let mockConnection: AgentSideConnection + let mockExtensionService: ExtensionService & { _emitter: EventEmitter } + let createExtensionService: (workspace: string) => Promise + + beforeEach(() => { + mockConnection = createMockConnection() + mockExtensionService = createMockExtensionService() + createExtensionService = vi.fn().mockResolvedValue(mockExtensionService) + agent = new KiloCodeAgent(mockConnection, createExtensionService, "/test/workspace") + }) + + describe("initialize", () => { + it("should return protocol version and agent info", async () => { + const params: InitializeRequest = { + protocolVersion: 1, + clientInfo: { + name: "Test Client", + version: "1.0.0", + }, + } + + const response = await agent.initialize(params) + + expect(response.protocolVersion).toBe(1) + expect(response.agentInfo).toEqual({ + name: "Kilo Code", + version: "1.0.0", + }) + expect(response.agentCapabilities).toEqual({ + promptCapabilities: { + image: false, + embeddedContext: true, + }, + }) + }) + }) + + describe("newSession", () => { + it("should create a new session with a unique ID", async () => { + const params = createNewSessionRequest() + + const response = await agent.newSession(params) + + expect(response.sessionId).toMatch(/^kilo-\d+-[a-z0-9]+$/) + expect(createExtensionService).toHaveBeenCalledWith("/test/workspace") + }) + + it("should create different session IDs for each call", async () => { + const response1 = await agent.newSession(createNewSessionRequest()) + const response2 = await agent.newSession(createNewSessionRequest()) + + expect(response1.sessionId).not.toBe(response2.sessionId) + }) + }) + + describe("prompt", () => { + it("should throw error for unknown session", async () => { + const params: PromptRequest = { + sessionId: "unknown-session", + prompt: [{ type: "text", text: "Hello" }], + } + + await expect(agent.prompt(params)).rejects.toThrow("Session not found: unknown-session") + }) + + it("should extract text from prompt content blocks", async () => { + const { sessionId } = await agent.newSession(createNewSessionRequest()) + + // Start the prompt (but don't await it yet) + const promptPromise = agent.prompt({ + sessionId, + prompt: [ + { type: "text", text: "Hello " }, + { type: "text", text: "World" }, + ], + }) + + // Simulate task completion + mockExtensionService._emitter.emit("message", { + say: "completion_result", + }) + + const response = await promptPromise + + expect(mockExtensionService.sendWebviewMessage).toHaveBeenCalledWith({ + type: "newTask", + text: "Hello \nWorld", + }) + expect(response.stopReason).toBe("end_turn") + }) + + it("should return cancelled stop reason when session is cancelled", async () => { + const { sessionId } = await agent.newSession(createNewSessionRequest()) + + // Start prompt + const promptPromise = agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "Test" }], + }) + + // Cancel the session + await agent.cancel({ sessionId }) + + const response = await promptPromise + + expect(response.stopReason).toBe("cancelled") + }) + }) + + describe("cancel", () => { + it("should cancel an active session", async () => { + const { sessionId } = await agent.newSession(createNewSessionRequest()) + + // Should not throw + await expect(agent.cancel({ sessionId })).resolves.not.toThrow() + }) + + it("should handle cancelling unknown session gracefully", async () => { + // Should not throw even for unknown session + await expect(agent.cancel({ sessionId: "unknown" })).resolves.not.toThrow() + }) + }) + + describe("authenticate", () => { + it("should return empty response (no-op)", async () => { + const response = await agent.authenticate({ methodId: "test" }) + expect(response).toEqual({}) + }) + }) + + describe("setSessionMode", () => { + it("should send mode change message to extension", async () => { + const { sessionId } = await agent.newSession(createNewSessionRequest()) + + await agent.setSessionMode({ sessionId, modeId: "architect" }) + + expect(mockExtensionService.sendWebviewMessage).toHaveBeenCalledWith({ + type: "mode", + text: "architect", + }) + }) + + it("should throw for unknown session", async () => { + await expect(agent.setSessionMode({ sessionId: "unknown", modeId: "code" })).rejects.toThrow( + "Session not found: unknown", + ) + }) + }) +}) + +describe("createKiloCodeAgent", () => { + it("should return a factory function", () => { + const createExtensionService = vi.fn() + const factory = createKiloCodeAgent(createExtensionService, "/test/workspace") + + expect(typeof factory).toBe("function") + }) + + it("should create a KiloCodeAgent when factory is called", () => { + const createExtensionService = vi.fn() + const factory = createKiloCodeAgent(createExtensionService, "/test/workspace") + const mockConnection = createMockConnection() + + const agent = factory(mockConnection) + + expect(agent).toBeInstanceOf(KiloCodeAgent) + }) +}) diff --git a/cli/src/acp/agent.ts b/cli/src/acp/agent.ts new file mode 100644 index 00000000000..5ca84e39984 --- /dev/null +++ b/cli/src/acp/agent.ts @@ -0,0 +1,516 @@ +/** + * ACP Agent implementation for Kilo Code. + * + * This module implements the ACP Agent interface, bridging incoming ACP requests + * to the existing CLI ExtensionService/ExtensionHost architecture. + */ + +import { acpDebug } from "./index.js" +import type { + Agent, + AgentSideConnection, + InitializeRequest, + InitializeResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + CancelNotification, + AuthenticateRequest, + AuthenticateResponse, + SetSessionModeRequest, + SetSessionModeResponse, + ToolCallUpdate, + PermissionOption, + ToolKind, + ContentBlock, +} from "@agentclientprotocol/sdk" +import type { ExtensionService } from "../services/extension.js" +import type { ExtensionMessage, ClineAskResponse } from "../types/messages.js" +import type { ClineAsk } from "@roo-code/types" + +/** + * Session state for an ACP session + */ +interface ACPSessionState { + id: string + cancelled: boolean + extensionService?: ExtensionService + taskCompletionPromise?: { + resolve: (value: void) => void + reject: (error: Error) => void + } + /** Track which message timestamps we've already sent to avoid duplicates */ + sentMessageTimestamps: Set + /** Track the last text we sent to avoid sending duplicates */ + lastSentText?: string + /** Track if we've sent any assistant response (not just thinking indicator) */ + hasReceivedAssistantResponse: boolean + /** Track if we've sent the thinking indicator */ + sentThinkingIndicator: boolean +} + +/** + * KiloCodeAgent implements the ACP Agent interface. + * + * It receives ACP protocol calls (initialize, newSession, prompt, etc.) and + * translates them into actions on the ExtensionService. + */ +export class KiloCodeAgent implements Agent { + private connection: AgentSideConnection + private sessions: Map = new Map() + private createExtensionService: (workspace: string) => Promise + private workspace: string + + constructor( + connection: AgentSideConnection, + createExtensionService: (workspace: string) => Promise, + workspace: string, + ) { + this.connection = connection + this.createExtensionService = createExtensionService + this.workspace = workspace + } + + /** + * Initialize the agent connection. + */ + async initialize(params: InitializeRequest): Promise { + acpDebug("initialize() called", { protocolVersion: params.protocolVersion }) + const response = { + protocolVersion: params.protocolVersion, + agentInfo: { + name: "Kilo Code", + version: "1.0.0", + }, + agentCapabilities: { + promptCapabilities: { + image: false, + embeddedContext: true, + }, + }, + } + acpDebug("initialize() response", response) + return response + } + + /** + * Create a new session. + */ + async newSession(params: NewSessionRequest): Promise { + acpDebug("newSession() called", { cwd: params.cwd }) + const sessionId = `kilo-${Date.now()}-${Math.random().toString(36).slice(2, 11)}` + acpDebug("Creating session:", sessionId) + + // Use the cwd from the session request, falling back to CLI workspace + const sessionWorkspace = params.cwd || this.workspace + acpDebug("Session workspace:", sessionWorkspace) + + const session: ACPSessionState = { + id: sessionId, + cancelled: false, + sentMessageTimestamps: new Set(), + hasReceivedAssistantResponse: false, + sentThinkingIndicator: false, + } + + // Initialize extension service for this session using the session's cwd + acpDebug("Initializing extension service for session...") + let extensionService: ExtensionService + try { + extensionService = await this.createExtensionService(sessionWorkspace) + session.extensionService = extensionService + acpDebug("Extension service ready for session:", sessionId) + + // TEMPORARY: Enable yoloMode to test if this unblocks LLM calls + // TODO: Remove this and properly handle approvals through ACP + const extensionHost = extensionService.getExtensionHost() + extensionHost.sendWebviewMessage({ + type: "yoloMode", + bool: true, + }) + acpDebug("yoloMode enabled for testing") + } catch (error) { + const err = error as Error + acpDebug("Failed to create extension service:", err.message, err.stack) + throw error + } + + // Listen for messages from the extension + this.setupExtensionMessageHandler(session, extensionService) + + this.sessions.set(sessionId, session) + + acpDebug("newSession() response:", { sessionId }) + return { + sessionId, + } + } + + /** + * Process a prompt request. + */ + async prompt(params: PromptRequest): Promise { + acpDebug("prompt() called", { sessionId: params.sessionId, promptBlocks: params.prompt.length }) + const session = this.sessions.get(params.sessionId) + if (!session) { + acpDebug("Session not found:", params.sessionId) + throw new Error(`Session not found: ${params.sessionId}`) + } + + if (!session.extensionService) { + acpDebug("Session not initialized:", params.sessionId) + throw new Error(`Session not initialized: ${params.sessionId}`) + } + + // Extract prompt text from the prompt content blocks + const promptText = params.prompt + .filter( + (block: ContentBlock): block is ContentBlock & { type: "text"; text: string } => block.type === "text", + ) + .map((block: ContentBlock & { type: "text"; text: string }) => block.text) + .join("\n") + + acpDebug("Extracted prompt text:", promptText.substring(0, 200) + (promptText.length > 200 ? "..." : "")) + + // Create a promise that will resolve when the task completes + const taskComplete = new Promise((resolve, reject) => { + session.taskCompletionPromise = { resolve, reject } + }) + + // Send the prompt to the extension as a new task + acpDebug("Sending newTask to extension service...") + try { + await session.extensionService.sendWebviewMessage({ + type: "newTask", + text: promptText, + }) + acpDebug("newTask sent successfully") + } catch (error) { + const err = error as Error + acpDebug("Failed to send newTask:", err.message, err.stack) + throw error + } + + // Wait for task completion + acpDebug("Waiting for task completion...") + await taskComplete + acpDebug("Task completed, cancelled:", session.cancelled) + + return { + stopReason: session.cancelled ? "cancelled" : "end_turn", + } + } + + /** + * Cancel a session's current operation. + */ + async cancel(params: CancelNotification): Promise { + const session = this.sessions.get(params.sessionId) + if (session) { + session.cancelled = true + // Resolve the task completion promise to unblock prompt() + if (session.taskCompletionPromise) { + session.taskCompletionPromise.resolve() + } + } + } + + /** + * Authenticate (no-op for now). + */ + async authenticate(_params: AuthenticateRequest): Promise { + return {} + } + + /** + * Set session mode (optional). + */ + async setSessionMode(params: SetSessionModeRequest): Promise { + const session = this.sessions.get(params.sessionId) + if (!session?.extensionService) { + throw new Error(`Session not found: ${params.sessionId}`) + } + + // Switch mode via the extension service + await session.extensionService.sendWebviewMessage({ + type: "mode", + text: params.modeId, + }) + + return {} + } + + /** + * Set up the message handler for extension messages. + */ + private setupExtensionMessageHandler(session: ACPSessionState, extensionService: ExtensionService): void { + acpDebug("Setting up extension message handler for session:", session.id) + extensionService.on("message", async (message: ExtensionMessage) => { + // Log the full message for debugging (more chars for larger states) + const msgStr = JSON.stringify(message) + acpDebug("Extension message received (full):", msgStr.substring(0, 2000)) + + // Cast to chat message type for proper typing + const chatMessage = message as { + type: string + text?: string + say?: string + ask?: ClineAsk + state?: { + clineMessages?: Array<{ + ts?: number + type: string + say?: string + text?: string + partial?: boolean + }> + } + } + + // Handle state messages - extract and stream any NEW assistant content + if (chatMessage.type === "state" && chatMessage.state?.clineMessages) { + const messages = chatMessage.state.clineMessages + + // Check if we have an api_req_started - send thinking indicator + // This keeps the connection alive while the LLM is processing + const hasApiReqStarted = messages.some((m) => m?.say === "api_req_started") + const hasApiReqFinished = messages.some((m) => m?.say === "api_req_finished") + + if (hasApiReqStarted && !hasApiReqFinished && !session.sentThinkingIndicator) { + acpDebug("LLM is processing, sending thinking indicator...") + session.sentThinkingIndicator = true + try { + await this.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "Analyzing your request...\n\n", + }, + }, + }) + acpDebug("Thinking indicator sent") + } catch (error) { + const err = error as Error + acpDebug("Failed to send thinking indicator:", err.message) + } + } + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + if (!msg) continue + + // Look for assistant text responses + // Assistant text messages have say="text" and come AFTER user messages + // The first message is always the user's prompt, so we skip index 0 + // Also skip messages that look like API requests or tools + if (msg.say === "text" && msg.text && msg.ts) { + // Skip the first message in the conversation (user's initial prompt) + // User prompts are at the start of the conversation + if (i === 0) { + acpDebug("Skipping first message (user prompt):", msg.text.substring(0, 50)) + continue + } + + // Skip if we've already sent this message + if (session.sentMessageTimestamps.has(msg.ts)) { + continue + } + + // Skip if this is the same text we just sent (duplicate detection) + if (session.lastSentText === msg.text) { + continue + } + + acpDebug( + "Found NEW assistant text at index", + i, + ":", + msg.text.substring(0, 100), + "partial:", + msg.partial, + ) + session.sentMessageTimestamps.add(msg.ts) + session.lastSentText = msg.text + session.hasReceivedAssistantResponse = true + + try { + await this.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: msg.text, + }, + }, + }) + acpDebug("Sent assistant text to client") + } catch (error) { + const err = error as Error + acpDebug("Failed to send text from state:", err.message) + } + } + + // Also handle tool usage messages for progress feedback + if (msg.say === "tool" && msg.text && msg.ts && !session.sentMessageTimestamps.has(msg.ts)) { + acpDebug("Tool usage detected:", msg.text.substring(0, 100)) + session.sentMessageTimestamps.add(msg.ts) + + // Send a brief tool status update + try { + await this.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `\n[Using tool...]\n`, + }, + }, + }) + } catch (error) { + const err = error as Error + acpDebug("Failed to send tool status:", err.message) + } + } + } + } + + // Handle streamed text output (direct say messages from extension) + if (chatMessage.say === "text" && chatMessage.text) { + // Avoid sending duplicates + if (session.lastSentText !== chatMessage.text) { + acpDebug("Sending direct text chunk to client:", chatMessage.text.substring(0, 100)) + session.lastSentText = chatMessage.text + try { + await this.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: chatMessage.text, + }, + }, + }) + acpDebug("Text chunk sent successfully") + } catch (error) { + const err = error as Error + acpDebug("Failed to send text chunk:", err.message) + } + } + } + + // Handle tool calls that need approval + if (chatMessage.type === "ask" && chatMessage.ask) { + acpDebug("Tool approval needed:", chatMessage.ask) + const approved = await this.handleToolApproval(session, chatMessage.ask, chatMessage.text) + acpDebug("Tool approval result:", approved) + + // Send response back + const response: ClineAskResponse = approved ? "yesButtonClicked" : "noButtonClicked" + await extensionService.sendWebviewMessage({ + type: "askResponse", + askResponse: response, + }) + } + + // Handle task completion + if (chatMessage.say === "completion_result" || chatMessage.say === "error") { + acpDebug("Task completed with:", chatMessage.say, "text:", chatMessage.text?.substring(0, 100)) + + // Send the completion result text to the client before completing + if (chatMessage.text && session.lastSentText !== chatMessage.text) { + session.lastSentText = chatMessage.text + session.hasReceivedAssistantResponse = true + try { + await this.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: chatMessage.text, + }, + }, + }) + acpDebug("Sent completion result text to client") + } catch (error) { + const err = error as Error + acpDebug("Failed to send completion text:", err.message) + } + } + + if (session.taskCompletionPromise) { + session.taskCompletionPromise.resolve() + } + } + }) + } + + /** + * Handle tool approval by requesting permission from the ACP client. + */ + private async handleToolApproval( + session: ACPSessionState, + askType: ClineAsk, + description?: string, + ): Promise { + // Map ClineAsk types to appropriate tool kinds + const toolKindMap: Record = { + tool: "other", + command: "execute", + browser_action: "fetch", + write_to_file: "edit", + apply_diff: "edit", + read_file: "read", + execute_command: "execute", + } + + const toolCallId = `tool-${Date.now()}` + const toolCall: ToolCallUpdate = { + toolCallId, + title: description || `Tool: ${askType}`, + kind: toolKindMap[askType] || "other", + status: "in_progress", + } + + const options: PermissionOption[] = [ + { + optionId: "allow", + name: "Allow", + kind: "allow_once", + }, + { + optionId: "deny", + name: "Deny", + kind: "reject_once", + }, + ] + + try { + const response = await this.connection.requestPermission({ + sessionId: session.id, + toolCall, + options, + }) + + return response.outcome.outcome === "selected" && response.outcome.optionId === "allow" + } catch { + return false + } + } +} + +/** + * Factory function to create a KiloCodeAgent. + */ +export function createKiloCodeAgent( + createExtensionService: (workspace: string) => Promise, + workspace: string, +): (conn: AgentSideConnection) => Agent { + return (conn: AgentSideConnection) => new KiloCodeAgent(conn, createExtensionService, workspace) +} diff --git a/cli/src/acp/index.ts b/cli/src/acp/index.ts new file mode 100644 index 00000000000..8205476617f --- /dev/null +++ b/cli/src/acp/index.ts @@ -0,0 +1,135 @@ +/** + * ACP (Agent Client Protocol) entry point for Kilo Code CLI. + * + * This module provides the main entry point for running the CLI in ACP mode, + * enabling communication with code editors like Zed that support the ACP protocol. + */ + +import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" +import { createKiloCodeAgent } from "./agent.js" +import { ExtensionService } from "../services/extension.js" + +/** + * Debug logging to stderr (doesn't interfere with JSON-RPC on stdout) + */ +let debugEnabled = false + +export function enableACPDebug(enabled: boolean): void { + debugEnabled = enabled +} + +export function acpDebug(message: string, ...args: unknown[]): void { + if (debugEnabled) { + const timestamp = new Date().toISOString() + const formatted = args.length > 0 ? `${message} ${JSON.stringify(args)}` : message + process.stderr.write(`[ACP ${timestamp}] ${formatted}\n`) + } +} + +/** + * Options for running ACP mode. + */ +export interface ACPServerOptions { + workspace: string + debug?: boolean +} + +/** + * Run the CLI in ACP mode. + * + * This function sets up the ACP server using stdin/stdout for communication + * with an ACP client (e.g., Zed editor). + */ +export async function runACPMode(options: ACPServerOptions): Promise { + // Enable debug if requested + if (options.debug) { + enableACPDebug(true) + } + + acpDebug("Starting ACP mode", { workspace: options.workspace }) + + // Create stream from stdin/stdout using the SDK's ndJsonStream helper + // The SDK expects Web Streams API + const output = new WritableStream({ + write(chunk) { + acpDebug(">>> SEND:", new TextDecoder().decode(chunk).trim()) + process.stdout.write(chunk) + }, + }) + + const input = new ReadableStream({ + start(controller) { + acpDebug("Input stream started, waiting for messages...") + process.stdin.on("data", (chunk: Buffer) => { + const data = chunk.toString() + acpDebug("<<< RECV:", data.trim()) + controller.enqueue(new Uint8Array(chunk)) + }) + process.stdin.on("end", () => { + acpDebug("Input stream ended") + controller.close() + }) + process.stdin.on("error", (err) => { + acpDebug("Input stream error:", err.message) + controller.error(err) + }) + }, + }) + + // Create the ACP stream + acpDebug("Creating ndJsonStream...") + const stream = ndJsonStream(output, input) + + // Factory function to create ExtensionService instances + const createExtensionService = async (workspace: string): Promise => { + acpDebug("Creating ExtensionService for workspace:", workspace) + try { + const service = new ExtensionService({ + workspace, + }) + + // Add error and warning handlers to catch any issues + service.on("error", (error) => { + acpDebug("ExtensionService ERROR:", error.message, error.stack) + }) + + service.on("warning", (warning) => { + acpDebug("ExtensionService WARNING:", warning.context, warning.error?.message) + }) + + acpDebug("ExtensionService created, initializing...") + await service.initialize() + acpDebug("ExtensionService initialized successfully") + + // Log the initial state to see current config + const state = service.getState() + if (state) { + acpDebug("Initial state - apiProvider:", state.apiConfiguration?.apiProvider) + acpDebug( + "Initial state - model:", + state.apiConfiguration?.kilocodeModel || state.apiConfiguration?.openAiModelId, + ) + } + + return service + } catch (error) { + const err = error as Error + acpDebug("ExtensionService initialization failed:", err.message, err.stack) + throw error + } + } + + // Create the agent factory + acpDebug("Creating agent factory...") + const agentFactory = createKiloCodeAgent(createExtensionService, options.workspace) + + // Create the ACP connection + acpDebug("Creating AgentSideConnection...") + const connection = new AgentSideConnection(agentFactory, stream) + + acpDebug("ACP connection established, waiting for messages...") + + // Wait for the connection to close + await connection.closed + acpDebug("ACP connection closed") +} diff --git a/cli/src/acp/types.ts b/cli/src/acp/types.ts new file mode 100644 index 00000000000..b097cc7e578 --- /dev/null +++ b/cli/src/acp/types.ts @@ -0,0 +1,18 @@ +/** + * ACP (Agent Client Protocol) types for Kilo Code integration. + * + * This module provides TypeScript type definitions and re-exports for the ACP SDK + * to simplify imports throughout the ACP implementation. + */ + +// Re-export schema types from the SDK +export * from "@agentclientprotocol/sdk" +import * as schema from "@agentclientprotocol/sdk" +export { schema } + +/** + * Options for initializing the ACP server. + */ +export interface ACPServerOptions { + workspace: string +} diff --git a/cli/src/host/ExtensionHost.ts b/cli/src/host/ExtensionHost.ts index 85c08c67c88..eb38172eebf 100644 --- a/cli/src/host/ExtensionHost.ts +++ b/cli/src/host/ExtensionHost.ts @@ -626,16 +626,20 @@ export class ExtensionHost extends EventEmitter { }, ) => { this.safeExecute(() => { - // Create a unique ID for this message to prevent loops - const messageId = `${message.type}_${Date.now()}_${JSON.stringify(message).slice(0, 50)}` + // Skip deduplication for messageUpdated - these are streaming updates that should always be processed + // The message reconciliation logic in extension.ts will handle any actual duplicates + if (message.type !== "messageUpdated") { + // For other message types, use timestamp and content for deduplication + const messageId = `${message.type}_${Date.now()}_${JSON.stringify(message).slice(0, 50)}` + + if (processedMessageIds.has(messageId)) { + logs.debug(`Skipping duplicate message: ${message.type}`, "ExtensionHost") + return + } - if (processedMessageIds.has(messageId)) { - logs.debug(`Skipping duplicate message: ${message.type}`, "ExtensionHost") - return + processedMessageIds.add(messageId) } - processedMessageIds.add(messageId) - // Clean up old message IDs to prevent memory leaks if (processedMessageIds.size > 100) { const oldestIds = Array.from(processedMessageIds).slice(0, 50) diff --git a/cli/src/index.ts b/cli/src/index.ts index 9a649e33b40..efd6f89fee1 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -47,8 +47,20 @@ program .option("-s, --session ", "Restore a session by ID") .option("-f, --fork ", "Fork a session by ID") .option("--nosplash", "Disable the welcome message and update notifications", false) + .option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration", false) + .option("--acp-debug", "Enable debug logging for ACP mode (output to stderr)", false) .argument("[prompt]", "The prompt or command to execute") .action(async (prompt, options) => { + // ACP mode - run the ACP server and exit + if (options.acp) { + const { runACPMode } = await import("./acp/index.js") + await runACPMode({ + workspace: options.workspace, + debug: options.acpDebug, + }) + return + } + // Validate that --existing-branch requires --parallel if (options.existingBranch && !options.parallel) { console.error("Error: --existing-branch option requires --parallel flag to be enabled") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7cef7b7fca..8a0cc01d89a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -580,6 +580,9 @@ importers: cli: dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.7.0 + version: 0.7.0(zod@3.25.76) '@anthropic-ai/bedrock-sdk': specifier: ^0.22.0 version: 0.22.4 @@ -2380,6 +2383,11 @@ packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} + '@agentclientprotocol/sdk@0.7.0': + resolution: {integrity: sha512-lZrBwaP7qpDgwvhZOl61te9bpigpOXJfVTwLWqjMsnZI7oe5rgHdxGWfTHcqiRy4ITg9qbz3ApgYsxKoZpYMhg==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/gateway@2.0.9': resolution: {integrity: sha512-E6x4h5CPPPJ0za1r5HsLtHbeI+Tp3H+YFtcH8G3dSSPFE6w+PZINzB4NxLZmg1QqSeA5HTP3ZEzzsohp0o2GEw==} engines: {node: '>=18'} @@ -4890,6 +4898,7 @@ packages: '@koa/router@13.1.1': resolution: {integrity: sha512-JQEuMANYRVHs7lm7KY9PCIjkgJk73h4m4J+g2mkw2Vo1ugPZ17UJVqEH8F+HeAdjKz5do1OaLe7ArDz+z308gw==} engines: {node: '>= 18'} + deprecated: Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved. '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -4948,6 +4957,7 @@ packages: '@lancedb/lancedb@0.21.3': resolution: {integrity: sha512-hfzp498BfcCJ730fV1YGGoXVxRgE+W1n0D0KwanKlbt8bBPSQ6E6Tf8mPXc8rKdAXIRR3o5mTzMG3z3Fda+m3Q==} engines: {node: '>= 18'} + cpu: [x64, arm64] os: [darwin, linux, win32] peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' @@ -14960,6 +14970,7 @@ packages: next@15.2.5: resolution: {integrity: sha512-LlqS8ljc7RWR3riUwxB5+14v7ULAa5EuLUyarD/sFgXPd6Hmmscg8DXcu9hDdh5atybrIDVBrFhjDpRIQo/4pQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -19892,6 +19903,10 @@ snapshots: '@adobe/css-tools@4.4.2': {} + '@agentclientprotocol/sdk@0.7.0(zod@3.25.76)': + dependencies: + zod: 3.25.76 + '@ai-sdk/gateway@2.0.9(zod@4.1.12)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -27870,7 +27885,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.19.4)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@2.0.5': dependencies: diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 30c33c5eab1..7b581782e38 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -152,11 +152,6 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { this.removeClosingTag("result", result, block.partial), undefined, false, - // kilocode_change start - undefined, - undefined, - await getClineMessageOptions(task), - // kilocode_change end ) TelemetryService.instance.captureTaskCompleted(task.taskId) @@ -172,11 +167,6 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { this.removeClosingTag("result", result, block.partial), undefined, block.partial, - // kilocode_change start - undefined, - undefined, - await getClineMessageOptions(task), - // kilocode_change end ) } } diff --git a/src/services/glob/list-files.ts b/src/services/glob/list-files.ts index 5366bbb84b4..f30e8b74a71 100644 --- a/src/services/glob/list-files.ts +++ b/src/services/glob/list-files.ts @@ -46,6 +46,11 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb // Get ripgrep path const rgPath = await getRipgrepPath() + // If ripgrep is not available, use fallback method + if (!rgPath) { + return await listFilesWithFallback(dirPath, recursive, limit) + } + if (!recursive) { // For non-recursive, use the existing approach const files = await listFilesWithRipgrep(rgPath, dirPath, false, limit) @@ -182,13 +187,15 @@ async function handleSpecialDirectories(dirPath: string): Promise<[string[], boo /** * Get the path to the ripgrep binary + * Returns undefined if ripgrep is not available */ -async function getRipgrepPath(): Promise { +async function getRipgrepPath(): Promise { const vscodeAppRoot = vscode.env.appRoot const rgPath = await getBinPath(vscodeAppRoot) if (!rgPath) { - throw new Error("Could not find ripgrep binary") + console.warn("Could not find ripgrep binary, using fallback file listing method") + return undefined } return rgPath @@ -725,3 +732,76 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi } }) } + +/** + * Fallback method to list files when ripgrep is not available + * Uses native Node.js fs methods to list files recursively + */ +async function listFilesWithFallback(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> { + const absolutePath = path.resolve(dirPath) + const files: string[] = [] + const directories: string[] = [] + let fileCount = 0 + let dirCount = 0 + const ignoreInstance = await createIgnoreInstance(dirPath) + + async function scanDirectory(currentPath: string, depth: number): Promise { + if (fileCount + dirCount >= limit) { + return true // Signal that limit was reached + } + + try { + const entries = await fs.promises.readdir(currentPath, { withFileTypes: true }) + + for (const entry of entries) { + if (fileCount + dirCount >= limit) { + return true + } + + const fullPath = path.join(currentPath, entry.name) + const relativePath = path.relative(absolutePath, fullPath) + + // Check if should be ignored + const normalizedPath = relativePath.replace(/\\/g, "/") + if (ignoreInstance.ignores(normalizedPath) || ignoreInstance.ignores(normalizedPath + "/")) { + continue + } + + // Skip common directories to ignore + if (entry.isDirectory() && DIRS_TO_IGNORE.includes(entry.name)) { + continue + } + + // Skip hidden files/directories unless at root level + if (entry.name.startsWith(".") && depth > 0) { + continue + } + + if (entry.isDirectory() && !entry.isSymbolicLink()) { + const formattedPath = fullPath.endsWith("/") ? fullPath : `${fullPath}/` + directories.push(formattedPath) + dirCount++ + + // Recurse if in recursive mode and depth allows + if (recursive) { + const limitReached = await scanDirectory(fullPath, depth + 1) + if (limitReached) { + return true + } + } + } else if (entry.isFile()) { + files.push(fullPath) + fileCount++ + } + } + } catch (err) { + console.warn(`Could not read directory ${currentPath}: ${err}`) + } + + return false + } + + await scanDirectory(absolutePath, 0) + + return formatAndCombineResults(files, directories, limit) +}