Compare commits

...
Author SHA1 Message Date
Luca King ed2c84a081 Merge branch 'main' into acp-support 2025-12-19 13:08:05 -06:00
Luca 49fa2e3f58 Add ACP server entrypoint 2025-12-19 12:27:07 -06:00
10 changed files with 1392 additions and 2 deletions
+74
View File
@@ -0,0 +1,74 @@
---
title: "ACP Integration (Zed + Custom Clients)"
description: "Run Cline as an Agent Client Protocol (ACP) server and connect it to Zed or any ACP-compatible client."
---
## Overview
Cline ships an ACP-compatible stdio server so you can run it inside editors like Zed or any client that speaks the Agent Client Protocol.
The ACP entrypoint lives at `dist-standalone/cline-acp.js` after building the standalone bundle.
## Build the ACP server
From the Cline repo root:
```bash
npm install
npm run compile-standalone
```
The ACP server will be available at:
```text
dist-standalone/cline-acp.js
```
## Run manually
```bash
node dist-standalone/cline-acp.js --config ~/.cline
```
Notes:
- `--config` sets the Cline data directory (same as `CLINE_DIR`). If omitted, Cline defaults to `~/.cline`.
- Logs go to stderr to keep ACP JSON-RPC traffic clean on stdout.
## Connect to Zed
Add a custom ACP agent to your Zed `settings.json` (open with `zed: open settings`):
```json [settings]
{
"agent_servers": {
"Cline (ACP)": {
"type": "custom",
"command": "node",
"args": [
"/absolute/path/to/cline/dist-standalone/cline-acp.js",
"--config",
"/absolute/path/to/.cline"
],
"env": {}
}
}
}
```
Then open the Agent Panel in Zed and start a new thread for **Cline (ACP)**.
Zed will:
- Provide the workspace `cwd` for each session.
- Forward MCP servers you configured in Zed to Cline over ACP.
## Limitations
- Cline currently supports one ACP session at a time; starting a new session closes the previous one.
- Some Cline UI affordances (like special buttons for "start new task") are represented as standard prompts in ACP clients.
## Troubleshooting
- Check Zeds ACP logs (`Help` → `Open ACP Logs`) if the agent fails to start.
- Ensure the path to `cline-acp.js` is absolute and your Node binary is on `PATH`.
+1
View File
@@ -88,6 +88,7 @@
"cline-cli/overview",
"cline-cli/installation",
"cline-cli/three-core-flows",
"cline-cli/acp",
{
"group": "CLI Samples",
"pages": [
+5 -2
View File
@@ -204,8 +204,11 @@ const extensionConfig = {
// Standalone-specific configuration
const standaloneConfig = {
...baseConfig,
entryPoints: ["src/standalone/cline-core.ts"],
outfile: `${destDir}/cline-core.js`,
entryPoints: {
"cline-core": "src/standalone/cline-core.ts",
"cline-acp": "src/standalone/cline-acp.ts",
},
outdir: destDir,
// These modules need to load files from the module directory at runtime,
// so they cannot be bundled.
external: ["vscode", "@grpc/reflection", "grpc-health-check", "better-sqlite3"],
+64
View File
@@ -9,6 +9,7 @@
"version": "3.45.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
@@ -146,6 +147,15 @@
"vscode": "^1.84.0"
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.11.0.tgz",
"integrity": "sha512-hngnMwQ13DCC7oEr0BUnrx+vTDFf/ToCLhF0YcCMWRs+v4X60rKQyAENsx0PdbQF21jC1VjMFkh2+vwNBLh6fQ==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.37.0",
"license": "MIT",
@@ -6429,6 +6439,60 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.5.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.5.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.5",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
+1
View File
@@ -452,6 +452,7 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
+558
View File
@@ -0,0 +1,558 @@
import {
Agent,
AgentSideConnection,
CancelNotification,
InitializeRequest,
InitializeResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
RequestError,
SetSessionModeRequest,
SetSessionModeResponse,
type RequestPermissionResponse,
type SessionNotification,
type ContentBlock,
ndJsonStream,
} from "@agentclientprotocol/sdk"
import { fileURLToPath } from "node:url"
import * as path from "node:path"
import { randomUUID } from "node:crypto"
import type { ExtensionContext } from "vscode"
import { initialize, tearDown } from "@/common"
import { Controller } from "@/core/controller"
import { getRequestRegistry } from "@/core/controller/grpc-handler"
import { subscribeToState } from "@/core/controller/state/subscribeToState"
import { subscribeToPartialMessage } from "@/core/controller/ui/subscribeToPartialMessage"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { ExternalCommentReviewController } from "@/hosts/external/ExternalCommentReviewController"
import { ExternalDiffViewProvider } from "@/hosts/external/ExternalDiffviewProvider"
import { ExternalWebviewProvider } from "@/hosts/external/ExternalWebviewProvider"
import { ExternalHostBridgeClientManager } from "@/hosts/external/host-bridge-client-manager"
import { HostProvider } from "@/hosts/host-provider"
import { StandaloneTerminalManager } from "@/integrations/terminal"
import type { ExtensionState, ClineMessage } from "@/shared/ExtensionMessage"
import { convertProtoToClineMessage } from "@/shared/proto-conversions/cline-message"
import { EmptyRequest } from "@/shared/proto/cline/common"
import { State } from "@/shared/proto/cline/state"
import type { ClineMessage as ProtoClineMessage } from "@/shared/proto/cline/ui"
import type { McpServerConfig } from "@/services/mcp/types"
import { waitForHostBridgeReady } from "@/standalone/hostbridge-client"
import { initializeContext } from "@/standalone/vscode-context"
import {
buildModeState,
buildNotificationsForMessage,
buildNotificationsForPartialMessage,
buildPermissionToolCall,
buildToolCallDetailsFromMessage,
createAcpConversionState,
isPermissionAskType,
resolveClineModeId,
shouldSkipStateMessage,
} from "./convert"
import { nodeToWebReadable, nodeToWebWritable } from "./stdio"
import packageJson from "../../../package.json"
const PERMISSION_OPTIONS = [
{ optionId: "allow", name: "Allow", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
{ optionId: "allow_always", name: "Always Allow", kind: "allow_always" },
]
type ClineAcpRuntime = {
controller: Controller
dispose: () => Promise<void>
}
type SessionSubscriptions = {
stateRequestId: string
partialRequestId: string
}
type ClineAcpSession = {
sessionId: string
controller: Controller
conversionState: ReturnType<typeof createAcpConversionState>
lastMessageCount: number
lastModeId: "plan" | "act"
awaitingUserInput: boolean
pendingAsk?: ClineMessage
permissionRequests: Set<number>
promptInFlight: boolean
cancelled: boolean
subscriptions: SessionSubscriptions
}
export class ClineAcpAgent implements Agent {
private runtime?: ClineAcpRuntime
private runtimePromise?: Promise<ClineAcpRuntime>
private sessions = new Map<string, ClineAcpSession>()
constructor(private client: AgentSideConnection) {}
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
return {
protocolVersion: 1,
agentCapabilities: {
promptCapabilities: {
image: true,
embeddedContext: true,
audio: false,
},
mcpCapabilities: {
http: true,
sse: true,
},
sessionCapabilities: {},
},
agentInfo: {
name: packageJson.name,
title: "Cline",
version: packageJson.version,
},
authMethods: [],
_meta: request._meta ?? null,
}
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
const runtime = await this.ensureRuntime(params.cwd)
if (this.sessions.size > 0) {
for (const sessionId of this.sessions.keys()) {
await this.closeSession(sessionId)
}
}
await this.configureMcpServers(runtime.controller, params.mcpServers)
const sessionId = randomUUID()
const initialMode = resolveClineModeId(runtime.controller.stateManager.getGlobalSettingsKey("mode"))
const subscriptions = await this.attachSubscriptions(runtime.controller, sessionId)
this.sessions.set(sessionId, {
sessionId,
controller: runtime.controller,
conversionState: createAcpConversionState(),
lastMessageCount: 0,
lastModeId: initialMode,
awaitingUserInput: false,
permissionRequests: new Set(),
promptInFlight: false,
cancelled: false,
subscriptions,
})
await this.warnIfCwdMismatch(params.cwd)
return {
sessionId,
modes: buildModeState(initialMode),
}
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.getSession(params.sessionId)
if (session.promptInFlight) {
throw new Error("Prompt already in progress")
}
session.promptInFlight = true
session.cancelled = false
try {
const { text, images, files } = extractPromptContent(params.prompt)
const task = session.controller.task
const isStreaming = task?.taskState.isStreaming || task?.taskState.isWaitingForFirstChunk
if (session.pendingAsk && task) {
session.awaitingUserInput = false
session.pendingAsk = undefined
await task.handleWebviewAskResponse("messageResponse", text, images, files)
} else if (!task || !isStreaming) {
await session.controller.initTask(text, images, files)
} else {
throw new Error("Task is busy; wait for the current turn to finish")
}
const stopReason = await this.waitForTurnCompletion(session)
return { stopReason }
} finally {
session.promptInFlight = false
}
}
async cancel(params: CancelNotification): Promise<void> {
const session = this.getSession(params.sessionId)
session.cancelled = true
await session.controller.cancelTask()
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
const session = this.getSession(params.sessionId)
const modeId = params.modeId === "act" ? "act" : "plan"
await session.controller.togglePlanActMode(modeId)
session.lastModeId = modeId
await this.client.sessionUpdate({
sessionId: session.sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: modeId,
},
})
return {}
}
private async ensureRuntime(cwd: string): Promise<ClineAcpRuntime> {
if (this.runtime) {
return this.runtime
}
if (!this.runtimePromise) {
this.runtimePromise = this.createRuntime(cwd)
}
this.runtime = await this.runtimePromise
return this.runtime
}
private async createRuntime(cwd: string): Promise<ClineAcpRuntime> {
process.chdir(path.dirname(fileURLToPath(import.meta.url)))
await waitForHostBridgeReady()
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeContext(undefined)
this.setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR)
const webviewProvider = await initialize(extensionContext)
AuthHandler.getInstance().setEnabled(true)
return {
controller: webviewProvider.controller,
dispose: async () => {
await tearDown()
},
}
}
private setupHostProvider(extensionContext: ExtensionContext, extensionDir: string, dataDir: string) {
const createWebview = () => new ExternalWebviewProvider(extensionContext)
const createDiffView = () => new ExternalDiffViewProvider()
const createCommentReview = () => new ExternalCommentReviewController()
const createTerminalManager = () => new StandaloneTerminalManager()
const getCallbackUrl = async () => AuthHandler.getInstance().getCallbackUrl()
const getBinaryLocation = async (name: string) => path.join(process.cwd(), name)
HostProvider.initialize(
createWebview,
createDiffView,
createCommentReview,
createTerminalManager,
new ExternalHostBridgeClientManager(),
(...args: unknown[]) => console.error(...args),
getCallbackUrl,
getBinaryLocation,
extensionDir,
dataDir,
)
}
private async configureMcpServers(controller: Controller, servers: NewSessionRequest["mcpServers"]) {
if (!servers?.length) {
return
}
const serverConfigs: Record<string, McpServerConfig> = {}
for (const server of servers) {
if ("type" in server && server.type && server.type !== "stdio") {
const transportType = server.type === "http" ? "streamableHttp" : "sse"
serverConfigs[server.name] = {
type: transportType,
url: server.url,
headers: server.headers ? Object.fromEntries(server.headers.map((h) => [h.name, h.value])) : undefined,
}
} else {
serverConfigs[server.name] = {
type: "stdio",
command: server.command,
args: server.args,
env: server.env ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) : undefined,
}
}
}
await controller.mcpHub.updateServerConnectionsRPC(serverConfigs)
}
private async warnIfCwdMismatch(cwd: string) {
try {
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primary = workspacePaths.paths[0]
if (primary && path.resolve(primary) !== path.resolve(cwd)) {
console.error(
`[cline-acp] Warning: ACP session cwd (${cwd}) does not match host bridge workspace (${primary}).`,
)
}
} catch (error) {
console.error("[cline-acp] Failed to check workspace paths:", error)
}
}
private async attachSubscriptions(controller: Controller, sessionId: string): Promise<SessionSubscriptions> {
const stateRequestId = `cline-acp-state-${sessionId}`
const partialRequestId = `cline-acp-partial-${sessionId}`
await subscribeToState(
controller,
EmptyRequest.create(),
async (state: State) => {
await this.handleStateUpdate(sessionId, state)
},
stateRequestId,
)
await subscribeToPartialMessage(
controller,
EmptyRequest.create(),
async (message) => {
await this.handlePartialMessage(sessionId, message)
},
partialRequestId,
)
return { stateRequestId, partialRequestId }
}
private async handleStateUpdate(sessionId: string, state: State) {
const session = this.sessions.get(sessionId)
if (!session || !state.stateJson) {
return
}
let parsed: ExtensionState
try {
parsed = JSON.parse(state.stateJson) as ExtensionState
} catch (error) {
console.error("[cline-acp] Failed to parse state update:", error)
return
}
const modeId = resolveClineModeId(parsed.mode)
if (modeId !== session.lastModeId) {
session.lastModeId = modeId
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: modeId,
},
})
}
const messages = parsed.clineMessages ?? []
if (messages.length <= session.lastMessageCount) {
return
}
const newMessages = messages.slice(session.lastMessageCount)
session.lastMessageCount = messages.length
for (const message of newMessages) {
if (shouldSkipStateMessage(message, session.conversionState)) {
continue
}
await this.handleAskMessage(session, message)
await this.sendNotifications(sessionId, buildNotificationsForMessage(message, sessionId, session.conversionState))
}
}
private async handlePartialMessage(sessionId: string, message: ProtoClineMessage) {
const session = this.sessions.get(sessionId)
if (!session) {
return
}
const clineMessage = convertProtoToClineMessage(message)
const notifications = buildNotificationsForPartialMessage(
clineMessage,
sessionId,
session.conversionState,
message.partial === false,
)
await this.sendNotifications(sessionId, notifications)
}
private async handleAskMessage(session: ClineAcpSession, message: ClineMessage) {
if (message.type !== "ask") {
return
}
if (isPermissionAskType(message.ask)) {
await this.handlePermissionRequest(session, message)
return
}
session.awaitingUserInput = true
session.pendingAsk = message
}
private async handlePermissionRequest(session: ClineAcpSession, message: ClineMessage) {
if (session.permissionRequests.has(message.ts)) {
return
}
session.permissionRequests.add(message.ts)
const details =
buildToolCallDetailsFromMessage(message, session.conversionState, {
toolCallPrefix: "cline-permission",
fallbackTitle: "Permission required",
}) ??
buildToolCallDetailsFromMessage(
{ ...message, type: "say", say: "command", text: message.text },
session.conversionState,
{ toolCallPrefix: "cline-permission", fallbackTitle: "Permission required" },
)
if (!details) {
return
}
let response: RequestPermissionResponse
try {
response = await this.client.requestPermission({
sessionId: session.sessionId,
toolCall: buildPermissionToolCall(details),
options: PERMISSION_OPTIONS,
})
} catch (error) {
console.error("[cline-acp] Permission request failed:", error)
return
}
if (response.outcome?.outcome === "cancelled" || session.cancelled) {
session.cancelled = true
await session.controller.cancelTask()
return
}
const approved =
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
await session.controller.task?.handleWebviewAskResponse(approved ? "yesButtonClicked" : "noButtonClicked")
}
private async waitForTurnCompletion(session: ClineAcpSession): Promise<PromptResponse["stopReason"]> {
while (true) {
if (session.cancelled) {
return "cancelled"
}
const task = session.controller.task
if (!task) {
return "end_turn"
}
if (session.awaitingUserInput) {
return "end_turn"
}
if (!task.taskState.isStreaming && !task.taskState.isWaitingForFirstChunk) {
return "end_turn"
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
private async sendNotifications(sessionId: string, notifications: SessionNotification[]) {
for (const notification of notifications) {
try {
await this.client.sessionUpdate(notification)
} catch (error) {
console.error(`[cline-acp] Failed to send session update for ${sessionId}:`, error)
}
}
}
private async closeSession(sessionId: string) {
const session = this.sessions.get(sessionId)
if (!session) {
return
}
getRequestRegistry().cancelRequest(session.subscriptions.stateRequestId)
getRequestRegistry().cancelRequest(session.subscriptions.partialRequestId)
await session.controller.clearTask()
this.sessions.delete(sessionId)
}
private getSession(sessionId: string): ClineAcpSession {
const session = this.sessions.get(sessionId)
if (!session) {
throw RequestError.invalidParams("Session not found")
}
return session
}
}
export function runAcp() {
const input = nodeToWebWritable(process.stdout)
const output = nodeToWebReadable(process.stdin)
const stream = ndJsonStream(input, output)
new AgentSideConnection((client) => new ClineAcpAgent(client), stream)
}
function extractPromptContent(blocks: ContentBlock[]): { text: string; images: string[]; files: string[] } {
const textParts: string[] = []
const images: string[] = []
const files: string[] = []
const embeddedResources: string[] = []
for (const block of blocks ?? []) {
switch (block.type) {
case "text":
textParts.push(block.text)
break
case "image":
if (block.data && block.mimeType) {
images.push(`data:${block.mimeType};base64,${block.data}`)
}
break
case "resource":
if ("text" in block.resource && block.resource.text) {
embeddedResources.push(
`<file_content path="${block.resource.uri}">\n${block.resource.text}\n</file_content>`,
)
}
break
case "resource_link": {
const filePath = resolveFilePath(block.uri)
if (filePath) {
files.push(filePath)
} else {
textParts.push(`Resource: ${block.uri}`)
}
break
}
default:
break
}
}
if (embeddedResources.length > 0) {
textParts.push(`Embedded resources:\n\n${embeddedResources.join("\n\n")}`)
}
return {
text: textParts.join("\n\n"),
images,
files,
}
}
function resolveFilePath(uri: string): string | undefined {
try {
const url = new URL(uri)
if (url.protocol === "file:") {
return fileURLToPath(url)
}
} catch (_error) {
if (path.isAbsolute(uri)) {
return uri
}
}
return undefined
}
+457
View File
@@ -0,0 +1,457 @@
import * as path from "node:path"
import type {
SessionNotification,
ToolCallContent,
ToolCallLocation,
ToolKind,
ToolCallStatus,
ToolCallUpdate,
} from "@agentclientprotocol/sdk"
import type { ClineAsk, ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
export type AcpConversionState = {
partialTextByKey: Map<string, string>
partialMessageKeys: Set<string>
toolCallIds: Map<string, string>
lastCommandToolCallId?: string
lastBrowserToolCallId?: string
}
export type ToolCallDetails = {
toolCallId: string
title: string
kind?: ToolKind
locations?: ToolCallLocation[]
rawInput?: Record<string, unknown>
contentText?: string
}
const PERMISSION_ASK_TYPES: Set<ClineAsk> = new Set(["tool", "command", "browser_action_launch", "use_mcp_server"])
const TOOL_KIND_BY_CLINE_TOOL: Record<string, ToolKind> = {
readFile: "read",
listFilesTopLevel: "search",
listFilesRecursive: "search",
listCodeDefinitionNames: "search",
searchFiles: "search",
webSearch: "search",
webFetch: "fetch",
editedExistingFile: "edit",
newFileCreated: "edit",
fileDeleted: "delete",
summarizeTask: "think",
}
const TOOL_TITLE_BY_CLINE_TOOL: Record<string, string> = {
readFile: "Read file",
listFilesTopLevel: "List files",
listFilesRecursive: "List files recursively",
listCodeDefinitionNames: "List code definitions",
searchFiles: "Search files",
webSearch: "Search the web",
webFetch: "Fetch URL",
editedExistingFile: "Edit file",
newFileCreated: "Create file",
fileDeleted: "Delete file",
summarizeTask: "Summarize task",
}
const ASK_FALLBACK_TEXT: Partial<Record<ClineAsk, string>> = {
completion_result: "Task complete. Provide feedback or send the next prompt.",
resume_task: "Provide input to resume this task.",
resume_completed_task: "Provide input to resume this completed task.",
plan_mode_respond: "Provide feedback on the plan to continue.",
act_mode_respond: "Provide feedback to continue in act mode.",
command_output: "Provide input for the running command.",
mistake_limit_reached: "Cline needs guidance to continue. Provide next steps.",
api_req_failed: "The request failed. Provide guidance to retry or adjust.",
new_task: "Provide the next task.",
condense: "Provide input to continue after condensing.",
summarize_task: "Provide input to continue after summarizing.",
report_bug: "Provide details to report the bug.",
}
export function createAcpConversionState(): AcpConversionState {
return {
partialTextByKey: new Map(),
partialMessageKeys: new Set(),
toolCallIds: new Map(),
}
}
export function isPermissionAskType(ask?: ClineAsk): boolean {
return ask ? PERMISSION_ASK_TYPES.has(ask) : false
}
export function messageKey(message: ClineMessage): string {
return `${message.ts}:${message.type}:${message.say ?? ""}:${message.ask ?? ""}`
}
export function formatAskText(ask: ClineAsk | undefined, text: string | undefined): string | undefined {
if (text && text.trim().length > 0) {
return text
}
return ask ? ASK_FALLBACK_TEXT[ask] : undefined
}
export function parseToolPayload(text: string | undefined): ClineSayTool | undefined {
if (!text) {
return undefined
}
try {
const parsed = JSON.parse(text) as Record<string, unknown>
if (parsed && typeof parsed === "object" && typeof parsed.tool === "string") {
return parsed as ClineSayTool
}
} catch (_error) {
return undefined
}
return undefined
}
function toolKindForToolName(tool?: string): ToolKind | undefined {
if (!tool) {
return undefined
}
return TOOL_KIND_BY_CLINE_TOOL[tool]
}
function toolTitleForPayload(payload: ClineSayTool | undefined): string | undefined {
if (!payload) {
return undefined
}
const baseTitle = TOOL_TITLE_BY_CLINE_TOOL[payload.tool] ?? payload.tool
if (payload.path) {
return `${baseTitle}: ${payload.path}`
}
if (payload.regex) {
return `${baseTitle}: ${payload.regex}`
}
return baseTitle
}
function toolLocationsForPayload(payload: ClineSayTool | undefined): ToolCallLocation[] | undefined {
if (!payload?.path) {
return undefined
}
if (!path.isAbsolute(payload.path)) {
return undefined
}
return [{ path: payload.path }]
}
function toolCallIdForMessage(state: AcpConversionState, message: ClineMessage, prefix: string): string {
const key = messageKey(message)
const existing = state.toolCallIds.get(key)
if (existing) {
return existing
}
const id = `${prefix}-${message.ts}`
state.toolCallIds.set(key, id)
return id
}
function contentBlocksForText(text?: string): ToolCallContent[] | undefined {
if (!text) {
return undefined
}
return [
{
type: "content",
content: {
type: "text",
text,
},
},
]
}
export function buildToolCallDetailsFromMessage(
message: ClineMessage,
state: AcpConversionState,
options?: {
toolCallPrefix?: string
fallbackTitle?: string
},
): ToolCallDetails | undefined {
const prefix = options?.toolCallPrefix ?? "cline-tool"
if (message.type === "say" && message.say === "command" && message.text) {
const toolCallId = toolCallIdForMessage(state, message, prefix)
state.lastCommandToolCallId = toolCallId
return {
toolCallId,
title: `Run command: ${message.text}`,
kind: "execute",
rawInput: { command: message.text },
contentText: message.text,
}
}
if ((message.type === "say" && message.say === "tool") || (message.type === "ask" && message.ask === "tool")) {
const payload = parseToolPayload(message.text)
const toolCallId = toolCallIdForMessage(state, message, prefix)
return {
toolCallId,
title: toolTitleForPayload(payload) ?? options?.fallbackTitle ?? "Tool request",
kind: toolKindForToolName(payload?.tool),
locations: toolLocationsForPayload(payload),
rawInput: payload ? { ...payload } : undefined,
contentText: payload ? JSON.stringify(payload, null, 2) : message.text,
}
}
if (message.type === "say" && message.say === "browser_action") {
const toolCallId = toolCallIdForMessage(state, message, prefix)
state.lastBrowserToolCallId = toolCallId
return {
toolCallId,
title: "Browser action",
kind: "execute",
contentText: message.text,
}
}
if (message.type === "ask" && message.ask === "browser_action_launch") {
const toolCallId = toolCallIdForMessage(state, message, prefix)
return {
toolCallId,
title: "Launch browser action",
kind: "execute",
contentText: message.text,
}
}
if (message.type === "ask" && message.ask === "use_mcp_server") {
const toolCallId = toolCallIdForMessage(state, message, prefix)
return {
toolCallId,
title: "Use MCP server",
kind: "read",
contentText: message.text,
}
}
return undefined
}
export function buildNotificationsForMessage(
message: ClineMessage,
sessionId: string,
state: AcpConversionState,
): SessionNotification[] {
if (message.type === "ask") {
if (isPermissionAskType(message.ask)) {
return []
}
const askText = formatAskText(message.ask, message.text)
if (!askText) {
return []
}
return [
{
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: askText },
},
},
]
}
if (message.type !== "say") {
return []
}
switch (message.say) {
case "reasoning": {
const reasoningText = message.reasoning ?? message.text
if (!reasoningText) {
return []
}
return [
{
sessionId,
update: {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: reasoningText },
},
},
]
}
case "command": {
if (!message.text) {
return []
}
const details = buildToolCallDetailsFromMessage(message, state, { toolCallPrefix: "cline-command" })
if (!details) {
return []
}
const status: ToolCallStatus = message.commandCompleted ? "completed" : "in_progress"
return [buildToolCallNotification(sessionId, details, status)]
}
case "command_output": {
if (state.lastCommandToolCallId && message.text) {
return [
{
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: state.lastCommandToolCallId,
status: "completed",
content: contentBlocksForText(message.text),
},
},
]
}
return buildTextNotification(sessionId, message.text)
}
case "tool": {
const details = buildToolCallDetailsFromMessage(message, state, { toolCallPrefix: "cline-tool" })
if (!details) {
return buildTextNotification(sessionId, message.text)
}
const status: ToolCallStatus = message.partial ? "in_progress" : "completed"
return [buildToolCallNotification(sessionId, details, status)]
}
case "browser_action": {
const details = buildToolCallDetailsFromMessage(message, state, { toolCallPrefix: "cline-browser" })
if (!details) {
return buildTextNotification(sessionId, message.text)
}
return [buildToolCallNotification(sessionId, details, "completed")]
}
default: {
return buildTextNotification(sessionId, message.text)
}
}
}
export function buildNotificationsForPartialMessage(
message: ClineMessage,
sessionId: string,
state: AcpConversionState,
isFinal: boolean,
): SessionNotification[] {
if (message.type !== "say") {
return []
}
if (message.say !== "text" && message.say !== "reasoning") {
return []
}
const text = message.say === "reasoning" ? message.reasoning ?? message.text : message.text
if (!text) {
return []
}
const key = messageKey(message)
const delta = extractDelta(state.partialTextByKey, key, text)
state.partialMessageKeys.add(key)
if (isFinal) {
state.partialTextByKey.delete(key)
}
if (!delta) {
return []
}
const update: SessionNotification = {
sessionId,
update: {
sessionUpdate: message.say === "reasoning" ? "agent_thought_chunk" : "agent_message_chunk",
content: { type: "text", text: delta },
},
}
return [update]
}
export function shouldSkipStateMessage(message: ClineMessage, state: AcpConversionState): boolean {
if (message.partial) {
return true
}
const key = messageKey(message)
return state.partialMessageKeys.has(key)
}
function extractDelta(cache: Map<string, string>, key: string, text: string): string | undefined {
const previous = cache.get(key) ?? ""
if (text.startsWith(previous)) {
const delta = text.slice(previous.length)
cache.set(key, text)
return delta || undefined
}
cache.set(key, text)
return text
}
function buildTextNotification(sessionId: string, text?: string): SessionNotification[] {
if (!text) {
return []
}
return [
{
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text },
},
},
]
}
function buildToolCallNotification(
sessionId: string,
details: ToolCallDetails,
status: ToolCallStatus,
): SessionNotification {
return {
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: details.toolCallId,
title: details.title,
kind: details.kind,
status,
rawInput: details.rawInput,
locations: details.locations,
content: contentBlocksForText(details.contentText),
},
}
}
export function buildPermissionToolCall(details: ToolCallDetails): ToolCallUpdate {
return {
sessionUpdate: "tool_call_update",
toolCallId: details.toolCallId,
title: details.title,
kind: details.kind,
status: "pending",
rawInput: details.rawInput,
locations: details.locations,
content: contentBlocksForText(details.contentText),
}
}
export function resolveClineModeId(mode: string | undefined): "plan" | "act" {
return mode === "act" ? "act" : "plan"
}
export function buildModeState(currentMode: string | undefined) {
return {
currentModeId: resolveClineModeId(currentMode),
availableModes: [
{
id: "plan",
name: "Plan",
description: "Plan and ask for confirmation before edits",
},
{
id: "act",
name: "Act",
description: "Execute changes and tools when approved",
},
],
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Readable, Writable } from "node:stream"
import { ReadableStream, WritableStream } from "node:stream/web"
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
nodeStream.write(Buffer.from(chunk), (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
},
})
}
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
nodeStream.on("data", (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk))
})
nodeStream.on("end", () => controller.close())
nodeStream.on("error", (err) => controller.error(err))
},
})
}
+47
View File
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { runAcp } from "./acp/acp-agent"
// Ensure logs do not interfere with ACP stdio traffic
console.log = console.error
console.info = console.error
console.warn = console.error
console.debug = console.error
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason)
})
const args = parseArgs(process.argv.slice(2))
if (args.version) {
const here = dirname(fileURLToPath(import.meta.url))
const packagePath = resolve(here, "../../package.json")
const raw = readFileSync(packagePath, "utf8")
const json = JSON.parse(raw) as { version?: string }
const version = json.version ?? "0.0.0"
process.stdout.write(`${version}\n`)
process.exit(0)
}
if (args.config) {
process.env.CLINE_DIR = args.config
}
runAcp()
// Keep the process alive for stdio communication
process.stdin.resume()
function parseArgs(argv: string[]): { config?: string; version?: boolean } {
const result: { config?: string; version?: boolean } = {}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === "--config" || arg === "-c") {
result.config = argv[i + 1]
i += 1
} else if (arg === "--version" || arg === "-v") {
result.version = true
}
}
return result
}
+155
View File
@@ -0,0 +1,155 @@
import { expect } from "chai"
import { describe, it } from "mocha"
import type { ClineMessage } from "@/shared/ExtensionMessage"
import {
buildNotificationsForMessage,
buildNotificationsForPartialMessage,
createAcpConversionState,
} from "@/standalone/acp/convert"
describe("ACP conversion", () => {
it("maps say text to agent_message_chunk", () => {
const state = createAcpConversionState()
const message: ClineMessage = {
ts: 1,
type: "say",
say: "text",
text: "Hello",
}
const notifications = buildNotificationsForMessage(message, "sess-1", state)
const update = notifications[0].update
expect(notifications).to.have.length(1)
expect(update.sessionUpdate).to.equal("agent_message_chunk")
if (update.sessionUpdate !== "agent_message_chunk") {
throw new Error("Expected agent_message_chunk")
}
expect(update.content?.type).to.equal("text")
if (update.content?.type !== "text") {
throw new Error("Expected text content")
}
expect(update.content.text).to.equal("Hello")
})
it("maps reasoning to agent_thought_chunk", () => {
const state = createAcpConversionState()
const message: ClineMessage = {
ts: 2,
type: "say",
say: "reasoning",
reasoning: "Thinking",
}
const notifications = buildNotificationsForMessage(message, "sess-1", state)
const update = notifications[0].update
expect(notifications).to.have.length(1)
expect(update.sessionUpdate).to.equal("agent_thought_chunk")
if (update.sessionUpdate !== "agent_thought_chunk") {
throw new Error("Expected agent_thought_chunk")
}
expect(update.content?.type).to.equal("text")
if (update.content?.type !== "text") {
throw new Error("Expected text content")
}
expect(update.content.text).to.equal("Thinking")
})
it("maps tool messages to tool_call", () => {
const state = createAcpConversionState()
const payload = JSON.stringify({ tool: "readFile", path: "src/index.ts" })
const message: ClineMessage = {
ts: 3,
type: "say",
say: "tool",
text: payload,
}
const notifications = buildNotificationsForMessage(message, "sess-1", state)
const update = notifications[0].update
expect(notifications).to.have.length(1)
expect(update.sessionUpdate).to.equal("tool_call")
if (update.sessionUpdate !== "tool_call") {
throw new Error("Expected tool_call")
}
expect(update.title).to.include("Read file")
expect(update.kind).to.equal("read")
})
it("links command output to last command tool call", () => {
const state = createAcpConversionState()
const commandMessage: ClineMessage = {
ts: 4,
type: "say",
say: "command",
text: "ls -la",
}
const commandNotifications = buildNotificationsForMessage(commandMessage, "sess-1", state)
const commandUpdate = commandNotifications[0].update
expect(commandNotifications).to.have.length(1)
expect(commandUpdate.sessionUpdate).to.equal("tool_call")
if (commandUpdate.sessionUpdate !== "tool_call") {
throw new Error("Expected tool_call")
}
const outputMessage: ClineMessage = {
ts: 5,
type: "say",
say: "command_output",
text: "output",
}
const outputNotifications = buildNotificationsForMessage(outputMessage, "sess-1", state)
const outputUpdate = outputNotifications[0].update
expect(outputNotifications).to.have.length(1)
expect(outputUpdate.sessionUpdate).to.equal("tool_call_update")
if (outputUpdate.sessionUpdate !== "tool_call_update") {
throw new Error("Expected tool_call_update")
}
expect(outputUpdate.content?.[0]?.type).to.equal("content")
})
it("emits deltas for partial text", () => {
const state = createAcpConversionState()
const baseMessage: ClineMessage = {
ts: 6,
type: "say",
say: "text",
text: "Hello",
}
const first = buildNotificationsForPartialMessage(baseMessage, "sess-1", state, false)
const firstUpdate = first[0].update
expect(first).to.have.length(1)
expect(firstUpdate.sessionUpdate).to.equal("agent_message_chunk")
if (firstUpdate.sessionUpdate !== "agent_message_chunk") {
throw new Error("Expected agent_message_chunk")
}
expect(firstUpdate.content?.type).to.equal("text")
if (firstUpdate.content?.type !== "text") {
throw new Error("Expected text content")
}
expect(firstUpdate.content.text).to.equal("Hello")
const nextMessage: ClineMessage = {
...baseMessage,
text: "Hello world",
}
const second = buildNotificationsForPartialMessage(nextMessage, "sess-1", state, true)
const secondUpdate = second[0].update
expect(second).to.have.length(1)
expect(secondUpdate.sessionUpdate).to.equal("agent_message_chunk")
if (secondUpdate.sessionUpdate !== "agent_message_chunk") {
throw new Error("Expected agent_message_chunk")
}
expect(secondUpdate.content?.type).to.equal("text")
if (secondUpdate.content?.type !== "text") {
throw new Error("Expected text content")
}
expect(secondUpdate.content.text).to.equal(" world")
})
})