From 9809b47d4545b394a5e49624796297147a8253cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 08:27:27 +0000 Subject: [PATCH] feat(acp): add interactive client harness --- CHANGELOG.md | 1 + docs/cli/acp.md | 23 ++++++ src/acp/client.ts | 185 +++++++++++++++++++++++++++++++++++++++++++++ src/cli/acp-cli.ts | 30 +++++++- 4 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 src/acp/client.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 490128a6b..2533626aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot - macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule). - macOS: stop syncing Peekaboo as a git submodule in postinstall. - Swabble: use the tagged Commander Swift package release. +- CLI: add `clawdbot acp client` interactive ACP harness for debugging. ### Fixes - Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee. diff --git a/docs/cli/acp.md b/docs/cli/acp.md index fae4a6390..6e27416b3 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -30,6 +30,21 @@ clawdbot acp --session-label "support inbox" clawdbot acp --session agent:main:main --reset-session ``` +## ACP client (debug) + +Use the built-in ACP client to sanity-check the bridge without an IDE. +It spawns the ACP bridge and lets you type prompts interactively. + +```bash +clawdbot acp client + +# Point the spawned bridge at a remote Gateway +clawdbot acp client --server-args --url wss://gateway-host:18789 --token + +# Override the server command (default: clawdbot) +clawdbot acp client --server "node" --server-args dist/entry.js acp --url ws://127.0.0.1:19001 +``` + ## How to use this Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want @@ -141,3 +156,11 @@ Learn more about session keys at [/concepts/session](/concepts/session). - `--reset-session`: reset the session key before first use. - `--no-prefix-cwd`: do not prefix prompts with the working directory. - `--verbose, -v`: verbose logging to stderr. + +### `acp client` options + +- `--cwd `: working directory for the ACP session. +- `--server `: ACP server command (default: `clawdbot`). +- `--server-args `: extra arguments passed to the ACP server. +- `--server-verbose`: enable verbose logging on the ACP server. +- `--verbose, -v`: verbose client logging. diff --git a/src/acp/client.ts b/src/acp/client.ts new file mode 100644 index 000000000..3fd63a6ed --- /dev/null +++ b/src/acp/client.ts @@ -0,0 +1,185 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import * as readline from "node:readline"; +import { Readable, Writable } from "node:stream"; + +import { + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, + type SessionNotification, +} from "@agentclientprotocol/sdk"; + +import { ensureClawdbotCliOnPath } 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}`) : () => {}; + + ensureClawdbotCliOnPath({ cwd }); + const serverCommand = opts.serverCommand ?? "clawdbot"; + 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 ReadableStream; + const stream = ndJsonStream(input, output); + + const client = new ClientSideConnection( + () => ({ + sessionUpdate: async (params: SessionNotification) => { + printSessionUpdate(params); + }, + requestPermission: async (params) => { + console.log("\n[permission requested]", params.toolCall?.title ?? "tool"); + const allowOnce = params.options.find((option) => option.kind === "allow_once"); + const fallback = params.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: "clawdbot-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("Clawdbot 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/cli/acp-cli.ts b/src/cli/acp-cli.ts index c6628b7aa..f2283d23d 100644 --- a/src/cli/acp-cli.ts +++ b/src/cli/acp-cli.ts @@ -1,14 +1,15 @@ import type { Command } from "commander"; +import { runAcpClientInteractive } from "../acp/client.js"; import { serveAcpGateway } from "../acp/server.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; export function registerAcpCli(program: Command) { - program - .command("acp") - .description("Run an ACP bridge backed by the Gateway") + const acp = program.command("acp").description("Run an ACP bridge backed by the Gateway"); + + acp .option("--url ", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)") .option("--token ", "Gateway token (if required)") .option("--password ", "Gateway password (if required)") @@ -40,4 +41,27 @@ export function registerAcpCli(program: Command) { defaultRuntime.exit(1); } }); + + acp + .command("client") + .description("Run an interactive ACP client against the local ACP bridge") + .option("--cwd ", "Working directory for the ACP session") + .option("--server ", "ACP server command (default: clawdbot)") + .option("--server-args ", "Extra arguments for the ACP server") + .option("--server-verbose", "Enable verbose logging on the ACP server", false) + .option("--verbose, -v", "Verbose client logging", false) + .action(async (opts) => { + try { + await runAcpClientInteractive({ + cwd: opts.cwd as string | undefined, + serverCommand: opts.server as string | undefined, + serverArgs: opts.serverArgs as string[] | undefined, + serverVerbose: Boolean(opts.serverVerbose), + verbose: Boolean(opts.verbose), + }); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); }