feat(acp): add interactive client harness

This commit is contained in:
Peter Steinberger
2026-01-18 08:27:27 +00:00
parent 68d79e56c2
commit 9809b47d45
4 changed files with 236 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule). - macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo as a git submodule in postinstall. - macOS: stop syncing Peekaboo as a git submodule in postinstall.
- Swabble: use the tagged Commander Swift package release. - Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
### Fixes ### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee. - Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.

View File

@@ -30,6 +30,21 @@ clawdbot acp --session-label "support inbox"
clawdbot acp --session agent:main:main --reset-session 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 <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 ## How to use this
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want 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. - `--reset-session`: reset the session key before first use.
- `--no-prefix-cwd`: do not prefix prompts with the working directory. - `--no-prefix-cwd`: do not prefix prompts with the working directory.
- `--verbose, -v`: verbose logging to stderr. - `--verbose, -v`: verbose logging to stderr.
### `acp client` options
- `--cwd <dir>`: working directory for the ACP session.
- `--server <command>`: ACP server command (default: `clawdbot`).
- `--server-args <args...>`: extra arguments passed to the ACP server.
- `--server-verbose`: enable verbose logging on the ACP server.
- `--verbose, -v`: verbose client logging.

185
src/acp/client.ts Normal file
View File

@@ -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<AcpClientHandle> {
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<Uint8Array>;
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<void> {
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);
});
}

View File

@@ -1,14 +1,15 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { runAcpClientInteractive } from "../acp/client.js";
import { serveAcpGateway } from "../acp/server.js"; import { serveAcpGateway } from "../acp/server.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
export function registerAcpCli(program: Command) { export function registerAcpCli(program: Command) {
program const acp = program.command("acp").description("Run an ACP bridge backed by the Gateway");
.command("acp")
.description("Run an ACP bridge backed by the Gateway") acp
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)") .option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)") .option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (if required)") .option("--password <password>", "Gateway password (if required)")
@@ -40,4 +41,27 @@ export function registerAcpCli(program: Command) {
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
}); });
acp
.command("client")
.description("Run an interactive ACP client against the local ACP bridge")
.option("--cwd <dir>", "Working directory for the ACP session")
.option("--server <command>", "ACP server command (default: clawdbot)")
.option("--server-args <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);
}
});
} }