diff --git a/src/acp/client.ts b/src/acp/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1b8697902953267c70494bb9dca8cfc531b46d2 --- /dev/null +++ b/src/acp/client.ts @@ -0,0 +1,191 @@ +import { + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, + type RequestPermissionRequest, + type SessionNotification, +} from "@agentclientprotocol/sdk"; +import { spawn, type ChildProcess } from "node:child_process"; +import * as readline from "node:readline"; +import { Readable, Writable } from "node:stream"; +import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; + +export type AcpClientOptions = { + cwd?: string; + serverCommand?: string; + serverArgs?: string[]; + serverVerbose?: boolean; + verbose?: boolean; +}; + +export type AcpClientHandle = { + client: ClientSideConnection; + agent: ChildProcess; + sessionId: string; +}; + +function toArgs(value: string[] | string | undefined): string[] { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +function buildServerArgs(opts: AcpClientOptions): string[] { + const args = ["acp", ...toArgs(opts.serverArgs)]; + if (opts.serverVerbose && !args.includes("--verbose") && !args.includes("-v")) { + args.push("--verbose"); + } + return args; +} + +function printSessionUpdate(notification: SessionNotification): void { + const update = notification.update; + if (!("sessionUpdate" in update)) { + return; + } + + switch (update.sessionUpdate) { + case "agent_message_chunk": { + if (update.content?.type === "text") { + process.stdout.write(update.content.text); + } + return; + } + case "tool_call": { + console.log(`\n[tool] ${update.title} (${update.status})`); + return; + } + case "tool_call_update": { + if (update.status) { + console.log(`[tool update] ${update.toolCallId}: ${update.status}`); + } + return; + } + case "available_commands_update": { + const names = update.availableCommands?.map((cmd) => `/${cmd.name}`).join(" "); + if (names) { + console.log(`\n[commands] ${names}`); + } + return; + } + default: + return; + } +} + +export async function createAcpClient(opts: AcpClientOptions = {}): Promise { + const cwd = opts.cwd ?? process.cwd(); + const verbose = Boolean(opts.verbose); + const log = verbose ? (msg: string) => console.error(`[acp-client] ${msg}`) : () => {}; + + ensureOpenClawCliOnPath({ cwd }); + const serverCommand = opts.serverCommand ?? "openclaw"; + const serverArgs = buildServerArgs(opts); + + log(`spawning: ${serverCommand} ${serverArgs.join(" ")}`); + + const agent = spawn(serverCommand, serverArgs, { + stdio: ["pipe", "pipe", "inherit"], + cwd, + }); + + if (!agent.stdin || !agent.stdout) { + throw new Error("Failed to create ACP stdio pipes"); + } + + const input = Writable.toWeb(agent.stdin); + const output = Readable.toWeb(agent.stdout) as unknown as ReadableStream; + const stream = ndJsonStream(input, output); + + const client = new ClientSideConnection( + () => ({ + sessionUpdate: async (params: SessionNotification) => { + printSessionUpdate(params); + }, + requestPermission: async (params: RequestPermissionRequest) => { + console.log("\n[permission requested]", params.toolCall?.title ?? "tool"); + const options = params.options ?? []; + const allowOnce = options.find((option) => option.kind === "allow_once"); + const fallback = options[0]; + return { + outcome: { + outcome: "selected", + optionId: allowOnce?.optionId ?? fallback?.optionId ?? "allow", + }, + }; + }, + }), + stream, + ); + + log("initializing"); + await client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, + clientInfo: { name: "openclaw-acp-client", version: "1.0.0" }, + }); + + log("creating session"); + const session = await client.newSession({ + cwd, + mcpServers: [], + }); + + return { + client, + agent, + sessionId: session.sessionId, + }; +} + +export async function runAcpClientInteractive(opts: AcpClientOptions = {}): Promise { + const { client, agent, sessionId } = await createAcpClient(opts); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + console.log("OpenClaw ACP client"); + console.log(`Session: ${sessionId}`); + console.log('Type a prompt, or "exit" to quit.\n'); + + const prompt = () => { + rl.question("> ", async (input) => { + const text = input.trim(); + if (!text) { + prompt(); + return; + } + if (text === "exit" || text === "quit") { + agent.kill(); + rl.close(); + process.exit(0); + } + + try { + const response = await client.prompt({ + sessionId, + prompt: [{ type: "text", text }], + }); + console.log(`\n[${response.stopReason}]\n`); + } catch (err) { + console.error(`\n[error] ${String(err)}\n`); + } + + prompt(); + }); + }; + + prompt(); + + agent.on("exit", (code) => { + console.log(`\nAgent exited with code ${code ?? 0}`); + rl.close(); + process.exit(code ?? 0); + }); +} diff --git a/src/acp/commands.ts b/src/acp/commands.ts new file mode 100644 index 0000000000000000000000000000000000000000..6bd8e85a819de43537ff645309b41a33250df596 --- /dev/null +++ b/src/acp/commands.ts @@ -0,0 +1,40 @@ +import type { AvailableCommand } from "@agentclientprotocol/sdk"; + +export function getAvailableCommands(): AvailableCommand[] { + return [ + { name: "help", description: "Show help and common commands." }, + { name: "commands", description: "List available commands." }, + { name: "status", description: "Show current status." }, + { + name: "context", + description: "Explain context usage (list|detail|json).", + input: { hint: "list | detail | json" }, + }, + { name: "whoami", description: "Show sender id (alias: /id)." }, + { name: "id", description: "Alias for /whoami." }, + { name: "subagents", description: "List or manage sub-agents." }, + { name: "config", description: "Read or write config (owner-only)." }, + { name: "debug", description: "Set runtime-only overrides (owner-only)." }, + { name: "usage", description: "Toggle usage footer (off|tokens|full)." }, + { name: "stop", description: "Stop the current run." }, + { name: "restart", description: "Restart the gateway (if enabled)." }, + { name: "dock-telegram", description: "Route replies to Telegram." }, + { name: "dock-discord", description: "Route replies to Discord." }, + { name: "dock-slack", description: "Route replies to Slack." }, + { name: "activation", description: "Set group activation (mention|always)." }, + { name: "send", description: "Set send mode (on|off|inherit)." }, + { name: "reset", description: "Reset the session (/new)." }, + { name: "new", description: "Reset the session (/reset)." }, + { + name: "think", + description: "Set thinking level (off|minimal|low|medium|high|xhigh).", + }, + { name: "verbose", description: "Set verbose mode (on|full|off)." }, + { name: "reasoning", description: "Toggle reasoning output (on|off|stream)." }, + { name: "elevated", description: "Toggle elevated mode (on|off)." }, + { name: "model", description: "Select a model (list|status|)." }, + { name: "queue", description: "Adjust queue mode and options." }, + { name: "bash", description: "Run a host command (if enabled)." }, + { name: "compact", description: "Compact the session history." }, + ]; +} diff --git a/src/acp/event-mapper.test.ts b/src/acp/event-mapper.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b7682ef358bb70c6e8ed3cd4e50d81a959ca2b0 --- /dev/null +++ b/src/acp/event-mapper.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js"; + +describe("acp event mapper", () => { + it("extracts text and resource blocks into prompt text", () => { + const text = extractTextFromPrompt([ + { type: "text", text: "Hello" }, + { type: "resource", resource: { text: "File contents" } }, + { type: "resource_link", uri: "https://example.com", title: "Spec" }, + { type: "image", data: "abc", mimeType: "image/png" }, + ]); + + expect(text).toBe("Hello\nFile contents\n[Resource link (Spec)] https://example.com"); + }); + + it("extracts image blocks into gateway attachments", () => { + const attachments = extractAttachmentsFromPrompt([ + { type: "image", data: "abc", mimeType: "image/png" }, + { type: "image", data: "", mimeType: "image/png" }, + { type: "text", text: "ignored" }, + ]); + + expect(attachments).toEqual([ + { + type: "image", + mimeType: "image/png", + content: "abc", + }, + ]); + }); +}); diff --git a/src/acp/event-mapper.ts b/src/acp/event-mapper.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e1179f1cb5ff46f36837b1b67d4ee89644c2ea6 --- /dev/null +++ b/src/acp/event-mapper.ts @@ -0,0 +1,95 @@ +import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk"; + +export type GatewayAttachment = { + type: string; + mimeType: string; + content: string; +}; + +export function extractTextFromPrompt(prompt: ContentBlock[]): string { + const parts: string[] = []; + for (const block of prompt) { + if (block.type === "text") { + parts.push(block.text); + continue; + } + if (block.type === "resource") { + const resource = block.resource as { text?: string } | undefined; + if (resource?.text) { + parts.push(resource.text); + } + continue; + } + if (block.type === "resource_link") { + const title = block.title ? ` (${block.title})` : ""; + const uri = block.uri ?? ""; + const line = uri ? `[Resource link${title}] ${uri}` : `[Resource link${title}]`; + parts.push(line); + } + } + return parts.join("\n"); +} + +export function extractAttachmentsFromPrompt(prompt: ContentBlock[]): GatewayAttachment[] { + const attachments: GatewayAttachment[] = []; + for (const block of prompt) { + if (block.type !== "image") { + continue; + } + const image = block as ImageContent; + if (!image.data || !image.mimeType) { + continue; + } + attachments.push({ + type: "image", + mimeType: image.mimeType, + content: image.data, + }); + } + return attachments; +} + +export function formatToolTitle( + name: string | undefined, + args: Record | undefined, +): string { + const base = name ?? "tool"; + if (!args || Object.keys(args).length === 0) { + return base; + } + const parts = Object.entries(args).map(([key, value]) => { + const raw = typeof value === "string" ? value : JSON.stringify(value); + const safe = raw.length > 100 ? `${raw.slice(0, 100)}...` : raw; + return `${key}: ${safe}`; + }); + return `${base}: ${parts.join(", ")}`; +} + +export function inferToolKind(name?: string): ToolKind { + if (!name) { + return "other"; + } + const normalized = name.toLowerCase(); + if (normalized.includes("read")) { + return "read"; + } + if (normalized.includes("write") || normalized.includes("edit")) { + return "edit"; + } + if (normalized.includes("delete") || normalized.includes("remove")) { + return "delete"; + } + if (normalized.includes("move") || normalized.includes("rename")) { + return "move"; + } + if (normalized.includes("search") || normalized.includes("find")) { + return "search"; + } + if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) { + return "execute"; + } + if (normalized.includes("fetch") || normalized.includes("http")) { + return "fetch"; + } + return "other"; +} diff --git a/src/acp/index.ts b/src/acp/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6af9efffbe1a90590553b50b18da726b453a1ac1 --- /dev/null +++ b/src/acp/index.ts @@ -0,0 +1,4 @@ +export { serveAcpGateway } from "./server.js"; +export { createInMemorySessionStore } from "./session.js"; +export type { AcpSessionStore } from "./session.js"; +export type { AcpServerOptions } from "./types.js"; diff --git a/src/acp/meta.ts b/src/acp/meta.ts new file mode 100644 index 0000000000000000000000000000000000000000..eccd865dbd51111abeaf72e60ea5e4691a0ad9c2 --- /dev/null +++ b/src/acp/meta.ts @@ -0,0 +1,47 @@ +export function readString( + meta: Record | null | undefined, + keys: string[], +): string | undefined { + if (!meta) { + return undefined; + } + for (const key of keys) { + const value = meta[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return undefined; +} + +export function readBool( + meta: Record | null | undefined, + keys: string[], +): boolean | undefined { + if (!meta) { + return undefined; + } + for (const key of keys) { + const value = meta[key]; + if (typeof value === "boolean") { + return value; + } + } + return undefined; +} + +export function readNumber( + meta: Record | null | undefined, + keys: string[], +): number | undefined { + if (!meta) { + return undefined; + } + for (const key of keys) { + const value = meta[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return undefined; +} diff --git a/src/acp/server.ts b/src/acp/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a2c835b5491117d3f51cb9c507f58482553a359 --- /dev/null +++ b/src/acp/server.ts @@ -0,0 +1,144 @@ +#!/usr/bin/env node +import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; +import { Readable, Writable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import type { AcpServerOptions } from "./types.js"; +import { loadConfig } from "../config/config.js"; +import { resolveGatewayAuth } from "../gateway/auth.js"; +import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { GatewayClient } from "../gateway/client.js"; +import { isMainModule } from "../infra/is-main.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { AcpGatewayAgent } from "./translator.js"; + +export function serveAcpGateway(opts: AcpServerOptions = {}): void { + const cfg = loadConfig(); + const connection = buildGatewayConnectionDetails({ + config: cfg, + url: opts.gatewayUrl, + }); + + const isRemoteMode = cfg.gateway?.mode === "remote"; + const remote = isRemoteMode ? cfg.gateway?.remote : undefined; + const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); + + const token = + opts.gatewayToken ?? + (isRemoteMode ? remote?.token?.trim() : undefined) ?? + process.env.OPENCLAW_GATEWAY_TOKEN ?? + auth.token; + const password = + opts.gatewayPassword ?? + (isRemoteMode ? remote?.password?.trim() : undefined) ?? + process.env.OPENCLAW_GATEWAY_PASSWORD ?? + auth.password; + + let agent: AcpGatewayAgent | null = null; + const gateway = new GatewayClient({ + url: connection.url, + token: token || undefined, + password: password || undefined, + clientName: GATEWAY_CLIENT_NAMES.CLI, + clientDisplayName: "ACP", + clientVersion: "acp", + mode: GATEWAY_CLIENT_MODES.CLI, + onEvent: (evt) => { + void agent?.handleGatewayEvent(evt); + }, + onHelloOk: () => { + agent?.handleGatewayReconnect(); + }, + onClose: (code, reason) => { + agent?.handleGatewayDisconnect(`${code}: ${reason}`); + }, + }); + + const input = Writable.toWeb(process.stdout); + const output = Readable.toWeb(process.stdin) as unknown as ReadableStream; + const stream = ndJsonStream(input, output); + + new AgentSideConnection((conn: AgentSideConnection) => { + agent = new AcpGatewayAgent(conn, gateway, opts); + agent.start(); + return agent; + }, stream); + + gateway.start(); +} + +function parseArgs(args: string[]): AcpServerOptions { + const opts: AcpServerOptions = {}; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--url" || arg === "--gateway-url") { + opts.gatewayUrl = args[i + 1]; + i += 1; + continue; + } + if (arg === "--token" || arg === "--gateway-token") { + opts.gatewayToken = args[i + 1]; + i += 1; + continue; + } + if (arg === "--password" || arg === "--gateway-password") { + opts.gatewayPassword = args[i + 1]; + i += 1; + continue; + } + if (arg === "--session") { + opts.defaultSessionKey = args[i + 1]; + i += 1; + continue; + } + if (arg === "--session-label") { + opts.defaultSessionLabel = args[i + 1]; + i += 1; + continue; + } + if (arg === "--require-existing") { + opts.requireExistingSession = true; + continue; + } + if (arg === "--reset-session") { + opts.resetSession = true; + continue; + } + if (arg === "--no-prefix-cwd") { + opts.prefixCwd = false; + continue; + } + if (arg === "--verbose" || arg === "-v") { + opts.verbose = true; + continue; + } + if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } + } + return opts; +} + +function printHelp(): void { + console.log(`Usage: openclaw acp [options] + +Gateway-backed ACP server for IDE integration. + +Options: + --url Gateway WebSocket URL + --token Gateway auth token + --password Gateway auth password + --session Default session key (e.g. "agent:main:main") + --session-label