mirror of
https://github.com/cline/cline.git
synced 2026-09-12 17:19:32 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2c84a081 | ||
|
|
49fa2e3f58 |
@@ -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 Zed’s 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`.
|
||||
@@ -88,6 +88,7 @@
|
||||
"cline-cli/overview",
|
||||
"cline-cli/installation",
|
||||
"cline-cli/three-core-flows",
|
||||
"cline-cli/acp",
|
||||
{
|
||||
"group": "CLI Samples",
|
||||
"pages": [
|
||||
|
||||
+5
-2
@@ -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"],
|
||||
|
||||
Generated
+64
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user