diff --git a/docs/cli/acp.mdx b/docs/cli/acp.mdx new file mode 100644 index 00000000000..39d04094b66 --- /dev/null +++ b/docs/cli/acp.mdx @@ -0,0 +1,62 @@ +--- +title: "ACP (Zed) Setup" +description: "Run Continue CLI as an ACP server for Zed and other ACP-compatible editors." +sidebarTitle: "ACP (Zed)" +--- + +Continue CLI can run as an Agent Client Protocol (ACP) server over stdio. This +lets editors like Zed connect directly to Continue for streaming responses and +tool execution. + +## Requirements + +- Continue CLI installed (`cn` on your PATH) +- A valid Continue config with at least one model +- Zed with External Agents enabled + +## Configure Zed + +Add an external agent entry to `~/.config/zed/settings.json`: + +```json +{ + "agent_servers": { + "Continue": { + "command": "cn", + "args": ["acp"] + } + } +} +``` + +### Optional flags + +```json +{ + "agent_servers": { + "Continue (Read-only)": { + "command": "cn", + "args": ["acp", "--readonly"] + }, + "Continue (Custom Config)": { + "command": "cn", + "args": ["acp", "--config", "/path/to/config.yaml"] + } + } +} +``` + +## Use in Zed + +1. Open a workspace in Zed. +2. Open the AI panel and select the Continue agent. +3. Send prompts as usual. Continue streams via ACP. + +## Notes + +- ACP mode defaults to auto-approve tool access. Use `--readonly` for a + read-only session. +- ACP currently supports `session/new`, `session/prompt`, `session/cancel`, and + `session/set_mode`. Session loading is not yet available. +- MCP servers provided by the client are ignored; configure MCP servers in your + Continue config instead. diff --git a/docs/docs.json b/docs/docs.json index afd202c9568..08ba41967db 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -87,6 +87,7 @@ "cli/overview", "cli/install", "cli/quick-start", + "cli/acp", "cli/guides" ] }, diff --git a/extensions/cli/src/acp/server.ts b/extensions/cli/src/acp/server.ts new file mode 100644 index 00000000000..c803973c9db --- /dev/null +++ b/extensions/cli/src/acp/server.ts @@ -0,0 +1,603 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { createInterface } from "node:readline"; + +import type { ModelConfig } from "@continuedev/config-yaml"; +import type { BaseLlmApi } from "@continuedev/openai-adapters"; +import type { ChatHistoryItem, Session } from "core/index.js"; + +import { processCommandFlags } from "../flags/flagProcessor.js"; +import { safeStderr, safeStdout } from "../init.js"; +import { logger } from "../util/logger.js"; +import { initializeServices, services } from "../services/index.js"; +import { serviceContainer } from "../services/ServiceContainer.js"; +import { SERVICE_NAMES, ModelServiceState } from "../services/types.js"; +import { streamChatResponse } from "../stream/streamChatResponse.js"; +import { StreamCallbacks } from "../stream/streamChatResponse.types.js"; +import { toolPermissionManager } from "../permissions/permissionManager.js"; +import { getVersion } from "../version.js"; + +import { + ACP_PROTOCOL_VERSION, + AcpContentBlock, + buildToolTitle, + convertPromptBlocks, + getAcpToolKind, + mapToolStatusToAcpStatus, +} from "./utils.js"; +import type { AcpToolKind } from "./utils.js"; + +type JsonRpcId = number | string | null; + +type JsonRpcRequest = { + jsonrpc?: "2.0"; + id?: JsonRpcId; + method?: unknown; + params?: unknown; +}; + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id: JsonRpcId; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +}; + +type SessionUpdate = { + sessionUpdate: string; + [key: string]: unknown; +}; + +type AcpSessionState = { + sessionId: string; + cwd: string; + history: ChatHistoryItem[]; + turnInFlight: boolean; + abortController: AbortController | null; + cancelRequested: boolean; +}; + +export class ContinueAcpServer { + private sessions = new Map(); + private initialized = false; + private servicesReady = false; + private rootCwd: string | null = null; + private activePromptSessionId: string | null = null; + + private model: ModelConfig | null = null; + private llmApi: BaseLlmApi | null = null; + + constructor(private readonly options: Record = {}) {} + + async run(): Promise { + const rl = createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, + }); + + rl.on("line", (line) => { + void this.handleLine(line); + }); + + await new Promise((resolve) => { + rl.on("close", resolve); + }); + } + + private writeMessage(message: object): void { + safeStdout(`${JSON.stringify(message)}\n`); + } + + private writeError( + id: JsonRpcId, + code: number, + message: string, + data?: unknown, + ): JsonRpcResponse { + return { + jsonrpc: "2.0", + id, + error: { + code, + message, + ...(data === undefined ? {} : { data }), + }, + }; + } + + private writeOk(id: JsonRpcId, result: unknown): JsonRpcResponse { + return { jsonrpc: "2.0", id, result }; + } + + private notifySessionUpdate(sessionId: string, update: SessionUpdate): void { + this.writeMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId, update }, + }); + } + + private async handleLine(line: string): Promise { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + + let parsed: JsonRpcRequest; + try { + parsed = JSON.parse(trimmed); + } catch { + this.writeMessage(this.writeError(null, -32700, "Parse error")); + return; + } + + if (!parsed || typeof parsed !== "object") { + this.writeMessage(this.writeError(null, -32600, "Invalid Request")); + return; + } + + if (typeof parsed.method !== "string") { + return; + } + + try { + const response = await this.handleRequest( + parsed.method, + parsed.params, + parsed.id, + ); + if (response) { + this.writeMessage(response); + } + } catch (error) { + if (parsed.id === undefined) { + return; + } + const message = error instanceof Error ? error.message : String(error); + this.writeMessage( + this.writeError(parsed.id, -32603, "Internal error", { message }), + ); + } + } + + private async handleRequest( + method: string, + params: unknown, + id: JsonRpcId | undefined, + ): Promise { + switch (method) { + case "initialize": + return this.handleInitialize(params, id); + case "session/new": + return this.handleSessionNew(params, id); + case "session/prompt": + return this.handleSessionPrompt(params, id); + case "session/cancel": + return this.handleSessionCancel(params, id); + case "session/set_mode": + return this.handleSessionSetMode(params, id); + default: + if (id === undefined) { + return null; + } + return this.writeError(id, -32601, "Method not found"); + } + } + + private handleInitialize( + params: unknown, + id: JsonRpcId | undefined, + ): JsonRpcResponse | null { + if (id === undefined) { + return null; + } + + this.initialized = true; + + return this.writeOk(id, { + protocolVersion: ACP_PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + promptCapabilities: { + image: false, + audio: false, + embeddedContext: true, + }, + mcpCapabilities: { + http: false, + sse: false, + }, + sessionCapabilities: {}, + }, + agentInfo: { + name: "continue-cli", + title: "Continue CLI", + version: getVersion(), + }, + authMethods: [], + }); + } + + private async handleSessionNew( + params: unknown, + id: JsonRpcId | undefined, + ): Promise { + if (id === undefined) { + return null; + } + if (!this.initialized) { + return this.writeError(id, -32600, "Invalid Request", { + message: "Must call initialize first", + }); + } + if (!params || typeof params !== "object") { + return this.writeError(id, -32602, "Invalid params"); + } + + const { cwd, mcpServers } = params as { + cwd?: string; + mcpServers?: unknown[]; + }; + + if (!cwd || typeof cwd !== "string") { + return this.writeError(id, -32602, "Invalid params", { + message: "`cwd` must be provided", + }); + } + if (!path.isAbsolute(cwd)) { + return this.writeError(id, -32602, "Invalid params", { + message: "`cwd` must be an absolute path", + }); + } + if (mcpServers !== undefined && !Array.isArray(mcpServers)) { + return this.writeError(id, -32602, "Invalid params", { + message: "`mcpServers` must be an array", + }); + } + + const mcpServerList = Array.isArray(mcpServers) ? mcpServers : []; + await this.ensureServicesInitialized(cwd); + + const sessionId = `sess_${randomUUID().replace(/-/g, "")}`; + const session: AcpSessionState = { + sessionId, + cwd, + history: [], + turnInFlight: false, + abortController: null, + cancelRequested: false, + }; + this.sessions.set(sessionId, session); + + if (mcpServerList.length > 0) { + logger.debug("ACP mcpServers provided but ignored", { + count: mcpServerList.length, + }); + } + + await this.initializeSessionState(session); + + return this.writeOk(id, { + sessionId, + modes: this.getModeState(), + }); + } + + private async handleSessionPrompt( + params: unknown, + id: JsonRpcId | undefined, + ): Promise { + if (id === undefined) { + return null; + } + if (!this.initialized) { + return this.writeError(id, -32600, "Invalid Request", { + message: "Must call initialize first", + }); + } + if (!params || typeof params !== "object") { + return this.writeError(id, -32602, "Invalid params"); + } + + const { sessionId, prompt } = params as { + sessionId?: string; + prompt?: AcpContentBlock[]; + }; + + if (!sessionId || typeof sessionId !== "string") { + return this.writeError(id, -32602, "Invalid params", { + message: "`sessionId` must be provided", + }); + } + + if (!Array.isArray(prompt)) { + return this.writeError(id, -32602, "Invalid params", { + message: "`prompt` must be an array", + }); + } + + const session = this.sessions.get(sessionId); + if (!session) { + return this.writeError(id, -32002, "Resource not found", { sessionId }); + } + + if (this.activePromptSessionId && this.activePromptSessionId !== sessionId) { + return this.writeError(id, -32000, "Server busy", { + message: "Another session is currently processing a prompt", + }); + } + + if (session.turnInFlight) { + return this.writeError(id, -32000, "Prompt already in progress", { + sessionId, + }); + } + + await this.ensureServicesInitialized(session.cwd); + + const { text, contextItems } = convertPromptBlocks(prompt, session.cwd); + if (!text && contextItems.length === 0) { + return this.writeError(id, -32602, "Invalid params", { + message: "Prompt contained no usable content", + }); + } + + session.turnInFlight = true; + session.cancelRequested = false; + this.activePromptSessionId = sessionId; + + try { + await this.initializeSessionState(session); + services.chatHistory.addUserMessage(text, contextItems); + + const abortController = new AbortController(); + session.abortController = abortController; + + const callbacks: StreamCallbacks = { + onContent: (content: string) => { + if (!content) return; + this.notifySessionUpdate(sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: content }, + }); + }, + onToolCall: (toolCall) => { + const title = buildToolTitle(toolCall.name, toolCall.arguments); + const kind: AcpToolKind = getAcpToolKind(toolCall.name); + this.notifySessionUpdate(sessionId, { + sessionUpdate: "tool_call", + toolCallId: toolCall.id, + title, + kind, + status: "pending", + rawInput: toolCall.arguments ?? {}, + }); + }, + onToolCallUpdate: (update) => { + const status = mapToolStatusToAcpStatus(update.status); + const content = update.output || update.error; + const contentBlocks = + content && typeof content === "string" + ? [ + { + type: "content", + content: { type: "text", text: content }, + }, + ] + : undefined; + + this.notifySessionUpdate(sessionId, { + sessionUpdate: "tool_call_update", + toolCallId: update.toolCallId, + ...(status ? { status } : {}), + ...(contentBlocks ? { content: contentBlocks } : {}), + ...(update.output ? { rawOutput: { output: update.output } } : {}), + ...(update.error ? { rawOutput: { error: update.error } } : {}), + }); + }, + onToolPermissionRequest: (_toolName, _toolArgs, requestId) => { + toolPermissionManager.approveRequest(requestId); + }, + }; + + if (!this.model || !this.llmApi) { + throw new Error("Model services were not initialized"); + } + + await streamChatResponse( + services.chatHistory.getHistory(), + this.model, + this.llmApi, + abortController, + callbacks, + ); + + session.history = services.chatHistory.getHistory(); + + if (session.cancelRequested || abortController.signal.aborted) { + return this.writeOk(id, { stopReason: "cancelled" }); + } + + return this.writeOk(id, { stopReason: "end_turn" }); + } catch (error) { + if ( + session.cancelRequested || + (error instanceof Error && error.name === "AbortError") + ) { + return this.writeOk(id, { stopReason: "cancelled" }); + } + + const message = error instanceof Error ? error.message : String(error); + safeStderr(`ACP prompt error: ${message}\n`); + return this.writeError(id, -32603, "Internal error", { message }); + } finally { + session.turnInFlight = false; + session.abortController = null; + this.activePromptSessionId = null; + } + } + + private async handleSessionCancel( + params: unknown, + id: JsonRpcId | undefined, + ): Promise { + const sessionId = + params && + typeof params === "object" && + typeof (params as { sessionId?: string }).sessionId === "string" + ? (params as { sessionId?: string }).sessionId + : undefined; + + if (sessionId) { + const session = this.sessions.get(sessionId); + if (session) { + session.cancelRequested = true; + if (session.abortController) { + session.abortController.abort(); + } + } + } + + if (id === undefined) { + return null; + } + return this.writeOk(id, null); + } + + private async handleSessionSetMode( + params: unknown, + id: JsonRpcId | undefined, + ): Promise { + if (id === undefined) { + return null; + } + if (!params || typeof params !== "object") { + return this.writeError(id, -32602, "Invalid params"); + } + + const { sessionId, modeId } = params as { + sessionId?: string; + modeId?: string; + }; + + if (!sessionId || typeof sessionId !== "string") { + return this.writeError(id, -32602, "Invalid params", { + message: "`sessionId` must be provided", + }); + } + if (!modeId || typeof modeId !== "string") { + return this.writeError(id, -32602, "Invalid params", { + message: "`modeId` must be provided", + }); + } + + if (!this.sessions.has(sessionId)) { + return this.writeError(id, -32002, "Resource not found", { sessionId }); + } + + const availableModes = this.getAvailableModes(); + if (!availableModes.some((mode) => mode.id === modeId)) { + return this.writeError(id, -32602, "Invalid params", { + message: `Unknown modeId "${modeId}"`, + }); + } + + services.toolPermissions.switchMode(modeId as any); + + this.notifySessionUpdate(sessionId, { + sessionUpdate: "current_mode_update", + currentModeId: modeId, + }); + + return this.writeOk(id, {}); + } + + private getAvailableModes(): Array<{ + id: string; + name: string; + description: string; + }> { + return services.toolPermissions.getAvailableModes().map((mode) => ({ + id: mode.mode, + name: mode.mode.charAt(0).toUpperCase() + mode.mode.slice(1), + description: mode.description, + })); + } + + private getModeState() { + const availableModes = this.getAvailableModes(); + const currentMode = services.toolPermissions.getCurrentMode(); + return { + currentModeId: currentMode, + availableModes, + }; + } + + private async ensureServicesInitialized(cwd: string): Promise { + if (this.servicesReady) { + if (this.rootCwd && path.resolve(cwd) !== this.rootCwd) { + throw new Error( + `ACP server already initialized for ${this.rootCwd}.`, + ); + } + return; + } + + this.rootCwd = path.resolve(cwd); + process.chdir(this.rootCwd); + + const options = { ...this.options } as Record; + if (!options.readonly) { + options.auto = true; + } + const { permissionOverrides } = processCommandFlags(options); + + await initializeServices({ + options, + headless: false, + skipOnboarding: true, + toolPermissionOverrides: permissionOverrides, + }); + + const modelState = await serviceContainer.get( + SERVICE_NAMES.MODEL, + ); + + if (!modelState.model || !modelState.llmApi) { + throw new Error("No model or LLM API configured"); + } + + this.model = modelState.model; + this.llmApi = modelState.llmApi; + services.chatHistory.setRemoteMode(true); + + this.servicesReady = true; + } + + private async initializeSessionState( + session: AcpSessionState, + ): Promise { + const snapshot: Session = { + sessionId: session.sessionId, + title: "ACP Session", + workspaceDirectory: session.cwd, + history: session.history, + usage: { + totalCost: 0, + promptTokens: 0, + completionTokens: 0, + promptTokensDetails: { + cachedTokens: 0, + cacheWriteTokens: 0, + }, + }, + }; + + await services.chatHistory.initialize(snapshot, true); + } +} diff --git a/extensions/cli/src/acp/utils.test.ts b/extensions/cli/src/acp/utils.test.ts new file mode 100644 index 00000000000..c90df74bd79 --- /dev/null +++ b/extensions/cli/src/acp/utils.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + buildToolTitle, + convertPromptBlocks, + getAcpToolKind, + mapToolStatusToAcpStatus, +} from "./utils.js"; + +describe("ACP utils", () => { + it("maps tool status to ACP status", () => { + expect(mapToolStatusToAcpStatus("generated")).toBe("pending"); + expect(mapToolStatusToAcpStatus("calling")).toBe("in_progress"); + expect(mapToolStatusToAcpStatus("done")).toBe("completed"); + expect(mapToolStatusToAcpStatus("errored")).toBe("failed"); + expect(mapToolStatusToAcpStatus("canceled")).toBe("failed"); + }); + + it("maps tool kinds for common tools", () => { + expect(getAcpToolKind("Read")).toBe("read"); + expect(getAcpToolKind("Write")).toBe("edit"); + expect(getAcpToolKind("Search")).toBe("search"); + expect(getAcpToolKind("Bash")).toBe("execute"); + expect(getAcpToolKind("Fetch")).toBe("fetch"); + expect(getAcpToolKind("UnknownTool")).toBe("other"); + }); + + it("builds a concise tool title", () => { + expect(buildToolTitle("Read", { filepath: "/tmp/test.txt" })).toContain( + "Read(", + ); + expect(buildToolTitle("List")).toBe("List"); + }); + + it("converts ACP prompt blocks into text and context items", () => { + const result = convertPromptBlocks( + [ + { type: "text", text: "Review this:" }, + { + type: "resource", + resource: { + uri: "file:///tmp/sample.txt", + text: "hello world", + }, + }, + { + type: "resource_link", + name: "Spec", + uri: "https://example.com/spec", + }, + ], + "/tmp", + ); + + expect(result.text).toContain("Review this:"); + expect(result.text).toContain("Resource Spec:"); + expect(result.contextItems.length).toBe(1); + expect(result.contextItems[0]?.content).toBe("hello world"); + }); +}); diff --git a/extensions/cli/src/acp/utils.ts b/extensions/cli/src/acp/utils.ts new file mode 100644 index 00000000000..dea39b19f44 --- /dev/null +++ b/extensions/cli/src/acp/utils.ts @@ -0,0 +1,226 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { ContextItem, ToolStatus } from "core/index.js"; + +import { formatToolArgument } from "../tools/formatters.js"; +import { getToolDisplayName } from "../tools/index.js"; + +export const ACP_PROTOCOL_VERSION = 1; + +export type AcpToolKind = + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + +export type AcpToolStatus = + | "pending" + | "in_progress" + | "completed" + | "failed"; + +export type AcpContentBlock = { + type: string; + [key: string]: unknown; +}; + +export interface PromptConversionResult { + text: string; + contextItems: ContextItem[]; +} + +export function mapToolStatusToAcpStatus( + status?: ToolStatus, +): AcpToolStatus | undefined { + switch (status) { + case "generating": + case "generated": + return "pending"; + case "calling": + return "in_progress"; + case "done": + return "completed"; + case "errored": + case "canceled": + return "failed"; + default: + return undefined; + } +} + +export function getAcpToolKind(toolName: string): AcpToolKind { + switch (toolName) { + case "Read": + case "List": + case "Diff": + return "read"; + case "Write": + case "Edit": + case "MultiEdit": + case "Checklist": + return "edit"; + case "Search": + return "search"; + case "Bash": + return "execute"; + case "Fetch": + return "fetch"; + default: + return "other"; + } +} + +export function buildToolTitle( + toolName: string, + args?: Record, +): string { + const displayName = getToolDisplayName(toolName); + const entries = args ? Object.entries(args) : []; + if (entries.length === 0) { + return displayName; + } + + const [key, value] = entries[0]; + let formattedValue = ""; + + if ( + key.toLowerCase().includes("path") || + typeof value === "number" || + typeof value === "boolean" + ) { + formattedValue = formatToolArgument(value); + } else if (typeof value === "string") { + const valueLines = value.split("\n"); + if (valueLines.length === 1) { + formattedValue = formatToolArgument(value); + } else { + const firstLine = valueLines[0].trim(); + formattedValue = firstLine + ? `${formatToolArgument(firstLine)}...` + : "..."; + } + } + + if (!formattedValue) { + return displayName; + } + + return `${displayName}(${formattedValue})`; +} + +function normalizeUri( + uri: string, + cwd: string, +): { type: "file" | "url"; value: string } { + if (uri.startsWith("file://")) { + return { type: "file", value: fileURLToPath(uri) }; + } + if (path.isAbsolute(uri)) { + return { type: "file", value: uri }; + } + if (!uri.includes("://")) { + return { type: "file", value: path.resolve(cwd, uri) }; + } + return { type: "url", value: uri }; +} + +function nameFromUri(uri?: string): string | undefined { + if (!uri) return undefined; + if (uri.startsWith("file://")) { + const filePath = fileURLToPath(uri); + return path.basename(filePath); + } + if (path.isAbsolute(uri)) { + return path.basename(uri); + } + try { + return path.basename(new URL(uri).pathname); + } catch { + return path.basename(uri); + } +} + +export function convertPromptBlocks( + blocks: AcpContentBlock[], + cwd: string, +): PromptConversionResult { + const textParts: string[] = []; + const contextItems: ContextItem[] = []; + + for (const block of blocks) { + if (!block || typeof block !== "object") { + continue; + } + + const type = typeof block.type === "string" ? block.type : ""; + + if (type === "text" && typeof block.text === "string") { + textParts.push(block.text); + continue; + } + + if (type === "resource") { + const resource = block.resource as + | { uri?: string; text?: string; mimeType?: string } + | undefined; + if (resource && typeof resource.text === "string") { + const uri = resource.uri; + const name = + (typeof block.name === "string" && block.name) || + nameFromUri(uri) || + "resource"; + contextItems.push({ + name, + content: resource.text, + description: + (typeof block.description === "string" && block.description) || + (uri ? `Embedded resource: ${uri}` : "Embedded resource"), + uri: uri ? normalizeUri(uri, cwd) : undefined, + }); + } else if (resource?.uri) { + textParts.push(`Resource: ${resource.uri}`); + } + continue; + } + + if (type === "resource_link" || type === "resourceLink") { + const uri = + (typeof block.uri === "string" && block.uri) || + (typeof (block as any).url === "string" && (block as any).url); + if (uri) { + const name = + (typeof block.name === "string" && block.name) || + nameFromUri(uri) || + "resource"; + const description = + (typeof block.description === "string" && block.description) || ""; + const detail = description ? ` - ${description}` : ""; + const resolvedUri = + !uri.includes("://") && cwd ? path.resolve(cwd, uri) : uri; + textParts.push(`Resource ${name}: ${resolvedUri}${detail}`); + } + continue; + } + + if (typeof block.text === "string") { + textParts.push(block.text); + } + } + + const text = textParts.join("\n").trim(); + if (!text && contextItems.length > 0) { + return { + text: "Review the attached context and respond.", + contextItems, + }; + } + + return { text, contextItems }; +} diff --git a/extensions/cli/src/commands/acp.ts b/extensions/cli/src/commands/acp.ts new file mode 100644 index 00000000000..70244eacd77 --- /dev/null +++ b/extensions/cli/src/commands/acp.ts @@ -0,0 +1,19 @@ +import { ContinueAcpServer } from "../acp/server.js"; +import { configureConsoleForHeadless, safeStderr } from "../init.js"; +import { logger } from "../util/logger.js"; + +import { ExtendedCommandOptions } from "./BaseCommandOptions.js"; + +export async function acp(options: ExtendedCommandOptions = {}) { + configureConsoleForHeadless(true); + logger.configureHeadlessMode(true); + + const server = new ContinueAcpServer(options); + try { + await server.run(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + safeStderr(`ACP server failed: ${message}\n`); + process.exit(1); + } +} diff --git a/extensions/cli/src/index.ts b/extensions/cli/src/index.ts index 4ed87837d28..a9fc6b876ab 100644 --- a/extensions/cli/src/index.ts +++ b/extensions/cli/src/index.ts @@ -6,6 +6,7 @@ import "./init.js"; import { Command } from "commander"; import { chat } from "./commands/chat.js"; +import { acp } from "./commands/acp.js"; import { login } from "./commands/login.js"; import { logout } from "./commands/logout.js"; import { listSessionsCommand } from "./commands/ls.js"; @@ -397,6 +398,24 @@ program await serve(prompt, mergedOptions); }); +// ACP subcommand +addCommonOptions( + program + .command("acp", { hidden: true }) + .description("Run Continue as an ACP stdio server"), +).action(async (options) => { + await posthogService.capture("cliCommand", { command: "acp" }); + + const mergedOptions = mergeParentOptions(program, options); + + if (mergedOptions.verbose) { + logger.setLevel("debug"); + logger.debug("Verbose logging enabled"); + } + + await acp(mergedOptions); +}); + // Remote test subcommand (for development) program .command("remote-test [prompt]") diff --git a/extensions/cli/src/stream/handleToolCalls.ts b/extensions/cli/src/stream/handleToolCalls.ts index 5150a436bd5..0cfd0e77de0 100644 --- a/extensions/cli/src/stream/handleToolCalls.ts +++ b/extensions/cli/src/stream/handleToolCalls.ts @@ -76,6 +76,10 @@ export async function handleToolCalls( parsedArgs: tc.arguments, })); + toolCalls.forEach((toolCall) => { + callbacks?.onToolCall?.(toolCall); + }); + // Create assistant message with tool calls const assistantMessage = { role: "assistant" as const, diff --git a/extensions/cli/src/stream/streamChatResponse.helpers.ts b/extensions/cli/src/stream/streamChatResponse.helpers.ts index a60b10af449..e53567afab2 100644 --- a/extensions/cli/src/stream/streamChatResponse.helpers.ts +++ b/extensions/cli/src/stream/streamChatResponse.helpers.ts @@ -429,6 +429,13 @@ export async function preprocessStreamedToolCalls( } catch (error) { // Notify the UI about the tool start, even though it failed callbacks?.onToolStart?.(toolCall.name, toolCall.arguments); + callbacks?.onToolCallUpdate?.({ + toolCallId: toolCall.id, + toolName: toolCall.name, + toolArgs: toolCall.arguments, + status: "errored", + error: error instanceof Error ? error.message : String(error), + }); const errorReason = error instanceof ContinueError @@ -548,6 +555,13 @@ export async function executeStreamedToolCalls( call.name, "canceled", ); + callbacks?.onToolCallUpdate?.({ + toolCallId: call.id, + toolName: call.name, + toolArgs: call.arguments, + status: "canceled", + error: deniedMessage, + }); // Immediate service update for UI feedback try { services.chatHistory.addToolResult( @@ -565,6 +579,12 @@ export async function executeStreamedToolCalls( try { services.chatHistory.updateToolStatus(call.id, "calling"); } catch {} + callbacks?.onToolCallUpdate?.({ + toolCallId: call.id, + toolName: call.name, + toolArgs: call.arguments, + status: "calling", + }); // Start execution immediately for approved calls execPromises.push( @@ -583,6 +603,13 @@ export async function executeStreamedToolCalls( }; entriesByIndex.set(index, entry); callbacks?.onToolResult?.(toolResult, call.name, "done"); + callbacks?.onToolCallUpdate?.({ + toolCallId: call.id, + toolName: call.name, + toolArgs: call.arguments, + status: "done", + output: toolResult, + }); // Immediate service update for UI feedback try { services.chatHistory.addToolResult( @@ -606,6 +633,13 @@ export async function executeStreamedToolCalls( status: "errored", }); callbacks?.onToolError?.(errorMessage, call.name); + callbacks?.onToolCallUpdate?.({ + toolCallId: call.id, + toolName: call.name, + toolArgs: call.arguments, + status: "errored", + error: errorMessage, + }); // Immediate service update for UI feedback try { services.chatHistory.addToolResult( @@ -632,6 +666,13 @@ export async function executeStreamedToolCalls( status: "errored", }); callbacks?.onToolError?.(errorMessage, call.name); + callbacks?.onToolCallUpdate?.({ + toolCallId: call.id, + toolName: call.name, + toolArgs: call.arguments, + status: "errored", + error: errorMessage, + }); // Treat permission errors like execution errors but do not stop the batch try { services.chatHistory.addToolResult( diff --git a/extensions/cli/src/stream/streamChatResponse.types.ts b/extensions/cli/src/stream/streamChatResponse.types.ts index 405505eee69..88c61facf10 100644 --- a/extensions/cli/src/stream/streamChatResponse.types.ts +++ b/extensions/cli/src/stream/streamChatResponse.types.ts @@ -3,12 +3,23 @@ import type { ToolStatus } from "core/index.js"; import type { ChatCompletionCreateParamsStreaming } from "openai/resources.mjs"; import { ToolCallPreview } from "../tools/types.js"; +import type { ToolCall } from "../tools/types.js"; export interface StreamCallbacks { onContent?: (content: string) => void; onContentComplete?: (content: string) => void; + onToolCall?: (toolCall: ToolCall) => void; onToolStart?: (toolName: string, toolArgs?: any) => void; onToolResult?: (result: string, toolName: string, status: ToolStatus) => void; + onToolCallUpdate?: (update: { + toolCallId: string; + toolName: string; + toolArgs?: any; + status?: ToolStatus; + output?: string; + error?: string; + preview?: ToolCallPreview[]; + }) => void; onToolError?: (error: string, toolName?: string) => void; onToolPermissionRequest?: ( toolName: string, diff --git a/extensions/cli/src/util/cli.test.ts b/extensions/cli/src/util/cli.test.ts index eebef662f5c..de6e4a13a92 100644 --- a/extensions/cli/src/util/cli.test.ts +++ b/extensions/cli/src/util/cli.test.ts @@ -28,6 +28,16 @@ describe("CLI utility functions", () => { process.argv = ["node", "script.js", "other", "args"]; expect(isHeadlessMode()).toBe(false); }); + + it("should return true when acp command is present", () => { + process.argv = ["node", "script.js", "acp"]; + expect(isHeadlessMode()).toBe(true); + }); + + it("should return false when acp help is requested", () => { + process.argv = ["node", "script.js", "acp", "--help"]; + expect(isHeadlessMode()).toBe(false); + }); }); describe("isServe", () => { diff --git a/extensions/cli/src/util/cli.ts b/extensions/cli/src/util/cli.ts index 5132fd4c108..b17c73f0e9b 100644 --- a/extensions/cli/src/util/cli.ts +++ b/extensions/cli/src/util/cli.ts @@ -7,7 +7,13 @@ */ export function isHeadlessMode(): boolean { const args = process.argv.slice(2); - return args.includes("-p") || args.includes("--print"); + const wantsHelp = args.includes("--help") || args.includes("-h"); + const isAcp = args.includes("acp") || args.includes("--acp"); + return ( + args.includes("-p") || + args.includes("--print") || + (isAcp && !wantsHelp) + ); } export function isServe(): boolean {