feat(acp): add interactive client harness
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
185
src/acp/client.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user