feat: add acp bridge
This commit is contained in:
parent
f5f7f47c81
commit
41fbcc405f
@ -10,6 +10,7 @@ Docs: https://docs.clawd.bot
|
||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||
- CLI: add `clawdbot acp` ACP bridge for IDE integrations.
|
||||
|
||||
### Fixes
|
||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||
|
||||
115
docs.acp.md
Normal file
115
docs.acp.md
Normal file
@ -0,0 +1,115 @@
|
||||
# Clawdbot ACP Bridge
|
||||
|
||||
This document describes how the Clawdbot ACP (Agent Client Protocol) bridge works,
|
||||
how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it.
|
||||
|
||||
## Overview
|
||||
|
||||
`clawdbot acp` exposes an ACP agent over stdio and forwards prompts to a running
|
||||
Clawdbot Gateway over WebSocket. It keeps ACP session ids mapped to Gateway
|
||||
session keys so IDEs can reconnect to the same agent transcript or reset it on
|
||||
request.
|
||||
|
||||
Key goals:
|
||||
|
||||
- Minimal ACP surface area (stdio, NDJSON).
|
||||
- Stable session mapping across reconnects.
|
||||
- Works with existing Gateway session store (list/resolve/reset).
|
||||
- Safe defaults (isolated ACP session keys by default).
|
||||
|
||||
## Execution Model
|
||||
|
||||
- ACP client spawns `clawdbot acp` and speaks ACP messages over stdio.
|
||||
- The bridge connects to the Gateway using existing auth config (or CLI flags).
|
||||
- ACP `prompt` translates to Gateway `chat.send`.
|
||||
- Gateway streaming events are translated back into ACP streaming events.
|
||||
- ACP `cancel` maps to Gateway `chat.abort` for the active run.
|
||||
|
||||
## Session Mapping
|
||||
|
||||
By default each ACP session is mapped to a dedicated Gateway session key:
|
||||
|
||||
- `acp:<uuid>` unless overridden.
|
||||
|
||||
You can override or reuse sessions in two ways:
|
||||
|
||||
1) CLI defaults
|
||||
|
||||
```bash
|
||||
clawdbot acp --session agent:main:main
|
||||
clawdbot acp --session-label "support inbox"
|
||||
clawdbot acp --reset-session
|
||||
```
|
||||
|
||||
2) ACP metadata per session
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"sessionKey": "agent:main:main",
|
||||
"sessionLabel": "support inbox",
|
||||
"resetSession": true,
|
||||
"requireExisting": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `sessionKey`: direct Gateway session key.
|
||||
- `sessionLabel`: resolve an existing session by label.
|
||||
- `resetSession`: mint a new transcript for the key before first use.
|
||||
- `requireExisting`: fail if the key/label does not exist.
|
||||
|
||||
### Session Listing
|
||||
|
||||
ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered
|
||||
summary suitable for IDE session pickers. `_meta.limit` can cap the number of
|
||||
sessions returned.
|
||||
|
||||
## Prompt Translation
|
||||
|
||||
ACP prompt inputs are converted into a Gateway `chat.send`:
|
||||
|
||||
- `text` and `resource` blocks become prompt text.
|
||||
- `resource_link` with image mime types become attachments.
|
||||
- The working directory can be prefixed into the prompt (default on, can be
|
||||
disabled with `--no-prefix-cwd`).
|
||||
|
||||
Gateway streaming events are translated into ACP `message` and `tool_call`
|
||||
updates. Terminal Gateway states map to ACP `done` with stop reasons:
|
||||
|
||||
- `complete` -> `stop`
|
||||
- `aborted` -> `cancel`
|
||||
- `error` -> `error`
|
||||
|
||||
## Auth + Gateway Discovery
|
||||
|
||||
`clawdbot acp` resolves the Gateway URL and auth from CLI flags or config:
|
||||
|
||||
- `--url` / `--token` / `--password` take precedence.
|
||||
- Otherwise use configured `gateway.remote.*` settings.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- ACP sessions are stored in memory for the bridge process lifetime.
|
||||
- Gateway session state is persisted by the Gateway itself.
|
||||
- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout).
|
||||
- ACP runs can be canceled and the active run id is tracked per session.
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
|
||||
- Works with ACP clients that implement `initialize`, `newSession`,
|
||||
`loadSession`, `prompt`, `cancel`, and `listSessions`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `src/acp/session.test.ts` covers run id lifecycle.
|
||||
- Full gate: `pnpm lint && pnpm build && pnpm test && pnpm docs:build`.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- CLI usage: `docs/cli/acp.md`
|
||||
- Session model: `docs/concepts/session.md`
|
||||
- Session management internals: `docs/reference/session-management-compaction.md`
|
||||
66
docs/cli/acp.md
Normal file
66
docs/cli/acp.md
Normal file
@ -0,0 +1,66 @@
|
||||
---
|
||||
summary: "Run the ACP bridge for IDE integrations"
|
||||
read_when:
|
||||
- Setting up ACP-based IDE integrations
|
||||
- Debugging ACP session routing to the Gateway
|
||||
---
|
||||
|
||||
# acp
|
||||
|
||||
Run the ACP (Agent Client Protocol) bridge that talks to a Clawdbot Gateway.
|
||||
|
||||
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
||||
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
clawdbot acp
|
||||
|
||||
# Remote Gateway
|
||||
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||
|
||||
# Attach to an existing session key
|
||||
clawdbot acp --session agent:main:main
|
||||
|
||||
# Attach by label (must already exist)
|
||||
clawdbot acp --session-label "support inbox"
|
||||
|
||||
# Reset the session key before the first prompt
|
||||
clawdbot acp --session agent:main:main --reset-session
|
||||
```
|
||||
|
||||
## Session mapping
|
||||
|
||||
By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix.
|
||||
To reuse a known session, pass a session key or label:
|
||||
|
||||
- `--session <key>`: use a specific Gateway session key.
|
||||
- `--session-label <label>`: resolve an existing session by label.
|
||||
- `--reset-session`: mint a fresh session id for that key (same key, new transcript).
|
||||
|
||||
If your ACP client supports metadata, you can override per session:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"sessionKey": "agent:main:main",
|
||||
"sessionLabel": "support inbox",
|
||||
"resetSession": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Learn more about session keys at [/concepts/session](/concepts/session).
|
||||
|
||||
## Options
|
||||
|
||||
- `--url <url>`: Gateway WebSocket URL (defaults to gateway.remote.url when configured).
|
||||
- `--token <token>`: Gateway auth token.
|
||||
- `--password <password>`: Gateway auth password.
|
||||
- `--session <key>`: default session key.
|
||||
- `--session-label <label>`: default session label to resolve.
|
||||
- `--require-existing`: fail if the session key/label does not exist.
|
||||
- `--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.
|
||||
@ -23,6 +23,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`message`](/cli/message)
|
||||
- [`agent`](/cli/agent)
|
||||
- [`agents`](/cli/agents)
|
||||
- [`acp`](/cli/acp)
|
||||
- [`status`](/cli/status)
|
||||
- [`health`](/cli/health)
|
||||
- [`sessions`](/cli/sessions)
|
||||
@ -125,6 +126,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
list
|
||||
add
|
||||
delete
|
||||
acp
|
||||
status
|
||||
health
|
||||
sessions
|
||||
@ -506,6 +508,11 @@ Options:
|
||||
- `--force`
|
||||
- `--json`
|
||||
|
||||
### `acp`
|
||||
Run the ACP bridge that connects IDEs to the Gateway.
|
||||
|
||||
See [`acp`](/cli/acp) for full options and examples.
|
||||
|
||||
### `status`
|
||||
Show linked session health and recent recipients.
|
||||
|
||||
|
||||
@ -140,6 +140,7 @@
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.13.0",
|
||||
"@buape/carbon": "0.0.0-beta-20260110172854",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@ -13,6 +13,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk':
|
||||
specifier: 0.13.0
|
||||
version: 0.13.0(zod@4.3.5)
|
||||
'@buape/carbon':
|
||||
specifier: 0.0.0-beta-20260110172854
|
||||
version: 0.0.0-beta-20260110172854(hono@4.11.4)
|
||||
@ -238,6 +241,8 @@ importers:
|
||||
specifier: 3.14.5
|
||||
version: 3.14.5(typescript@5.9.3)
|
||||
|
||||
extensions/bluebubbles: {}
|
||||
|
||||
extensions/copilot-proxy: {}
|
||||
|
||||
extensions/google-antigravity-auth: {}
|
||||
@ -337,6 +342,11 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@agentclientprotocol/sdk@0.13.0':
|
||||
resolution: {integrity: sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw==}
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@anthropic-ai/sdk@0.71.2':
|
||||
resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==}
|
||||
hasBin: true
|
||||
@ -4394,6 +4404,10 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@agentclientprotocol/sdk@0.13.0(zod@4.3.5)':
|
||||
dependencies:
|
||||
zod: 4.3.5
|
||||
|
||||
'@anthropic-ai/sdk@0.71.2(zod@4.3.5)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
|
||||
2
src/acp/index.ts
Normal file
2
src/acp/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { serveAcpGateway } from "./server.js";
|
||||
export type { AcpServerOptions } from "./types.js";
|
||||
149
src/acp/server.ts
Normal file
149
src/acp/server.ts
Normal file
@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env node
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
|
||||
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";
|
||||
import type { AcpServerOptions } from "./types.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.CLAWDBOT_GATEWAY_TOKEN ??
|
||||
auth.token;
|
||||
const password =
|
||||
opts.gatewayPassword ??
|
||||
(isRemoteMode ? remote?.password?.trim() : undefined) ??
|
||||
process.env.CLAWDBOT_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 ReadableStream<Uint8Array>;
|
||||
const stream = ndJsonStream(input, output);
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
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: clawdbot acp [options]
|
||||
|
||||
Gateway-backed ACP server for IDE integration.
|
||||
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL
|
||||
--token <token> Gateway auth token
|
||||
--password <password> Gateway auth password
|
||||
--session <key> Default session key (e.g. "agent:main:main")
|
||||
--session-label <label> Default session label to resolve
|
||||
--require-existing Fail if the session key/label does not exist
|
||||
--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
|
||||
--help, -h Show this help message
|
||||
`);
|
||||
}
|
||||
|
||||
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
serveAcpGateway(opts);
|
||||
}
|
||||
30
src/acp/session.test.ts
Normal file
30
src/acp/session.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
cancelActiveRun,
|
||||
clearAllSessionsForTest,
|
||||
createSession,
|
||||
getSessionByRunId,
|
||||
setActiveRun,
|
||||
} from "./session.js";
|
||||
|
||||
describe("acp session manager", () => {
|
||||
afterEach(() => {
|
||||
clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("tracks active runs and clears on cancel", () => {
|
||||
const session = createSession({
|
||||
sessionKey: "acp:test",
|
||||
cwd: "/tmp",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
setActiveRun(session.sessionId, "run-1", controller);
|
||||
|
||||
expect(getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
|
||||
|
||||
const cancelled = cancelActiveRun(session.sessionId);
|
||||
expect(cancelled).toBe(true);
|
||||
expect(getSessionByRunId("run-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
71
src/acp/session.ts
Normal file
71
src/acp/session.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { AcpSession } from "./types.js";
|
||||
|
||||
const sessions = new Map<string, AcpSession>();
|
||||
const runIdToSessionId = new Map<string, string>();
|
||||
|
||||
export function createSession(params: {
|
||||
sessionKey: string;
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
}): AcpSession {
|
||||
const sessionId = params.sessionId ?? randomUUID();
|
||||
const session: AcpSession = {
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
cwd: params.cwd,
|
||||
createdAt: Date.now(),
|
||||
abortController: null,
|
||||
activeRunId: null,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function getSession(sessionId: string): AcpSession | undefined {
|
||||
return sessions.get(sessionId);
|
||||
}
|
||||
|
||||
export function getSessionByRunId(runId: string): AcpSession | undefined {
|
||||
const sessionId = runIdToSessionId.get(runId);
|
||||
return sessionId ? sessions.get(sessionId) : undefined;
|
||||
}
|
||||
|
||||
export function setActiveRun(
|
||||
sessionId: string,
|
||||
runId: string,
|
||||
abortController: AbortController,
|
||||
): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.activeRunId = runId;
|
||||
session.abortController = abortController;
|
||||
runIdToSessionId.set(runId, sessionId);
|
||||
}
|
||||
|
||||
export function clearActiveRun(sessionId: string): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
|
||||
session.activeRunId = null;
|
||||
session.abortController = null;
|
||||
}
|
||||
|
||||
export function cancelActiveRun(sessionId: string): boolean {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session?.abortController) return false;
|
||||
session.abortController.abort();
|
||||
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
|
||||
session.abortController = null;
|
||||
session.activeRunId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearAllSessionsForTest(): void {
|
||||
for (const session of sessions.values()) {
|
||||
session.abortController?.abort();
|
||||
}
|
||||
sessions.clear();
|
||||
runIdToSessionId.clear();
|
||||
}
|
||||
556
src/acp/translator.ts
Normal file
556
src/acp/translator.ts
Normal file
@ -0,0 +1,556 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
AuthenticateResponse,
|
||||
CancelNotification,
|
||||
ContentBlock,
|
||||
ImageContent,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
ListSessionsRequest,
|
||||
ListSessionsResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
StopReason,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
||||
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import type { EventFrame } from "../gateway/protocol/index.js";
|
||||
import type { SessionsListResult } from "../gateway/session-utils.js";
|
||||
import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
|
||||
import {
|
||||
cancelActiveRun,
|
||||
clearActiveRun,
|
||||
createSession,
|
||||
getSession,
|
||||
setActiveRun,
|
||||
} from "./session.js";
|
||||
|
||||
type PendingPrompt = {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
idempotencyKey: string;
|
||||
resolve: (response: PromptResponse) => void;
|
||||
reject: (err: Error) => void;
|
||||
sentTextLength?: number;
|
||||
sentText?: string;
|
||||
toolCalls?: Set<string>;
|
||||
};
|
||||
|
||||
type SessionMeta = {
|
||||
sessionKey?: string;
|
||||
sessionLabel?: string;
|
||||
resetSession?: boolean;
|
||||
requireExisting?: boolean;
|
||||
prefixCwd?: boolean;
|
||||
};
|
||||
|
||||
export class AcpGatewayAgent implements Agent {
|
||||
private connection: AgentSideConnection;
|
||||
private gateway: GatewayClient;
|
||||
private opts: AcpServerOptions;
|
||||
private log: (msg: string) => void;
|
||||
private pendingPrompts = new Map<string, PendingPrompt>();
|
||||
|
||||
constructor(
|
||||
connection: AgentSideConnection,
|
||||
gateway: GatewayClient,
|
||||
opts: AcpServerOptions = {},
|
||||
) {
|
||||
this.connection = connection;
|
||||
this.gateway = gateway;
|
||||
this.opts = opts;
|
||||
this.log = opts.verbose
|
||||
? (msg: string) => process.stderr.write(`[acp] ${msg}\n`)
|
||||
: () => {};
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.log("ready");
|
||||
}
|
||||
|
||||
handleGatewayReconnect(): void {
|
||||
this.log("gateway reconnected");
|
||||
}
|
||||
|
||||
handleGatewayDisconnect(reason: string): void {
|
||||
this.log(`gateway disconnected: ${reason}`);
|
||||
for (const pending of this.pendingPrompts.values()) {
|
||||
pending.reject(new Error(`Gateway disconnected: ${reason}`));
|
||||
clearActiveRun(pending.sessionId);
|
||||
}
|
||||
this.pendingPrompts.clear();
|
||||
}
|
||||
|
||||
async handleGatewayEvent(evt: EventFrame): Promise<void> {
|
||||
if (evt.event === "chat") {
|
||||
await this.handleChatEvent(evt);
|
||||
return;
|
||||
}
|
||||
if (evt.event === "agent") {
|
||||
await this.handleAgentEvent(evt);
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
return {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
audio: false,
|
||||
embeddedContext: true,
|
||||
},
|
||||
mcpCapabilities: {
|
||||
http: false,
|
||||
sse: false,
|
||||
},
|
||||
sessionCapabilities: {
|
||||
list: {},
|
||||
},
|
||||
},
|
||||
agentInfo: ACP_AGENT_INFO,
|
||||
authMethods: [],
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
if (params.mcpServers.length > 0) {
|
||||
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const meta = this.parseSessionMeta(params._meta);
|
||||
const sessionKey = await this.resolveSessionKey(meta, `acp:${sessionId}`);
|
||||
await this.resetSessionIfNeeded(meta, sessionKey);
|
||||
|
||||
const session = createSession({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
cwd: params.cwd,
|
||||
});
|
||||
this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`);
|
||||
return { sessionId: session.sessionId };
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
if (params.mcpServers.length > 0) {
|
||||
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
|
||||
}
|
||||
|
||||
const meta = this.parseSessionMeta(params._meta);
|
||||
const sessionKey = await this.resolveSessionKey(meta, params.sessionId);
|
||||
await this.resetSessionIfNeeded(meta, sessionKey);
|
||||
|
||||
const session = createSession({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey,
|
||||
cwd: params.cwd,
|
||||
});
|
||||
this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
||||
const limit = readNumber(params._meta, ["limit"]) ?? 100;
|
||||
const result = await this.gateway.request<SessionsListResult>("sessions.list", { limit });
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
return {
|
||||
sessions: result.sessions.map((session) => ({
|
||||
sessionId: session.key,
|
||||
cwd,
|
||||
title: session.displayName ?? session.label ?? session.key,
|
||||
updatedAt: session.updatedAt ? new Date(session.updatedAt).toISOString() : undefined,
|
||||
_meta: {
|
||||
sessionKey: session.key,
|
||||
kind: session.kind,
|
||||
channel: session.channel,
|
||||
},
|
||||
})),
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(_params: AuthenticateRequest): Promise<AuthenticateResponse> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSessionMode(
|
||||
params: SetSessionModeRequest,
|
||||
): Promise<SetSessionModeResponse | void> {
|
||||
const session = getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${params.sessionId} not found`);
|
||||
}
|
||||
if (!params.modeId) return {};
|
||||
try {
|
||||
await this.gateway.request("sessions.patch", {
|
||||
key: session.sessionKey,
|
||||
thinkingLevel: params.modeId,
|
||||
});
|
||||
this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
|
||||
} catch (err) {
|
||||
this.log(`setSessionMode error: ${String(err)}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
const session = getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${params.sessionId} not found`);
|
||||
}
|
||||
|
||||
if (session.abortController) {
|
||||
cancelActiveRun(params.sessionId);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const runId = randomUUID();
|
||||
setActiveRun(params.sessionId, runId, abortController);
|
||||
|
||||
const meta = this.parseSessionMeta(params._meta);
|
||||
const userText = this.extractTextFromPrompt(params.prompt);
|
||||
const attachments = this.extractAttachmentsFromPrompt(params.prompt);
|
||||
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
|
||||
const message = prefixCwd ? `[Working directory: ${session.cwd}]\n\n${userText}` : userText;
|
||||
|
||||
return new Promise<PromptResponse>((resolve, reject) => {
|
||||
this.pendingPrompts.set(params.sessionId, {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: session.sessionKey,
|
||||
idempotencyKey: runId,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
this.gateway
|
||||
.request(
|
||||
"chat.send",
|
||||
{
|
||||
sessionKey: session.sessionKey,
|
||||
message,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
idempotencyKey: runId,
|
||||
thinking: readString(params._meta, ["thinking", "thinkingLevel"]),
|
||||
deliver: readBool(params._meta, ["deliver"]),
|
||||
timeoutMs: readNumber(params._meta, ["timeoutMs"]),
|
||||
},
|
||||
{ expectFinal: true },
|
||||
)
|
||||
.catch((err) => {
|
||||
this.pendingPrompts.delete(params.sessionId);
|
||||
clearActiveRun(params.sessionId);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
const session = getSession(params.sessionId);
|
||||
if (!session) return;
|
||||
|
||||
cancelActiveRun(params.sessionId);
|
||||
try {
|
||||
await this.gateway.request("chat.abort", { sessionKey: session.sessionKey });
|
||||
} catch (err) {
|
||||
this.log(`cancel error: ${String(err)}`);
|
||||
}
|
||||
|
||||
const pending = this.pendingPrompts.get(params.sessionId);
|
||||
if (pending) {
|
||||
this.pendingPrompts.delete(params.sessionId);
|
||||
pending.resolve({ stopReason: "cancelled" });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAgentEvent(evt: EventFrame): Promise<void> {
|
||||
const payload = evt.payload as Record<string, unknown> | undefined;
|
||||
if (!payload) return;
|
||||
const stream = payload.stream as string | undefined;
|
||||
const data = payload.data as Record<string, unknown> | undefined;
|
||||
const sessionKey = payload.sessionKey as string | undefined;
|
||||
if (!stream || !data || !sessionKey) return;
|
||||
|
||||
if (stream !== "tool") return;
|
||||
const phase = data.phase as string | undefined;
|
||||
const name = data.name as string | undefined;
|
||||
const toolCallId = data.toolCallId as string | undefined;
|
||||
if (!toolCallId) return;
|
||||
|
||||
const pending = this.findPendingBySessionKey(sessionKey);
|
||||
if (!pending) return;
|
||||
|
||||
if (phase === "start") {
|
||||
if (!pending.toolCalls) pending.toolCalls = new Set();
|
||||
if (pending.toolCalls.has(toolCallId)) return;
|
||||
pending.toolCalls.add(toolCallId);
|
||||
const args = data.args as Record<string, unknown> | undefined;
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId: pending.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: formatToolTitle(name, args),
|
||||
status: "in_progress",
|
||||
rawInput: args,
|
||||
kind: inferToolKind(name),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "result") {
|
||||
const isError = Boolean(data.isError);
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId: pending.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: isError ? "failed" : "completed",
|
||||
rawOutput: data.result,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleChatEvent(evt: EventFrame): Promise<void> {
|
||||
const payload = evt.payload as Record<string, unknown> | undefined;
|
||||
if (!payload) return;
|
||||
|
||||
const sessionKey = payload.sessionKey as string | undefined;
|
||||
const state = payload.state as string | undefined;
|
||||
const runId = payload.runId as string | undefined;
|
||||
const messageData = payload.message as Record<string, unknown> | undefined;
|
||||
if (!sessionKey || !state) return;
|
||||
|
||||
const pending = this.findPendingBySessionKey(sessionKey);
|
||||
if (!pending) return;
|
||||
if (runId && pending.idempotencyKey !== runId) return;
|
||||
|
||||
if (state === "delta" && messageData) {
|
||||
await this.handleDeltaEvent(pending.sessionId, messageData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "final") {
|
||||
this.finishPrompt(pending.sessionId, pending, "end_turn");
|
||||
return;
|
||||
}
|
||||
if (state === "aborted") {
|
||||
this.finishPrompt(pending.sessionId, pending, "cancelled");
|
||||
return;
|
||||
}
|
||||
if (state === "error") {
|
||||
this.finishPrompt(pending.sessionId, pending, "refusal");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDeltaEvent(
|
||||
sessionId: string,
|
||||
messageData: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const content = messageData.content as Array<{ type: string; text?: string }> | undefined;
|
||||
const fullText = content?.find((c) => c.type === "text")?.text ?? "";
|
||||
const pending = this.pendingPrompts.get(sessionId);
|
||||
if (!pending) return;
|
||||
|
||||
const sentSoFar = pending.sentTextLength ?? 0;
|
||||
if (fullText.length <= sentSoFar) return;
|
||||
|
||||
const newText = fullText.slice(sentSoFar);
|
||||
pending.sentTextLength = fullText.length;
|
||||
pending.sentText = fullText;
|
||||
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: newText },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private finishPrompt(
|
||||
sessionId: string,
|
||||
pending: PendingPrompt,
|
||||
stopReason: StopReason,
|
||||
): void {
|
||||
this.pendingPrompts.delete(sessionId);
|
||||
clearActiveRun(sessionId);
|
||||
pending.resolve({ stopReason });
|
||||
}
|
||||
|
||||
private findPendingBySessionKey(sessionKey: string): PendingPrompt | undefined {
|
||||
for (const pending of this.pendingPrompts.values()) {
|
||||
if (pending.sessionKey === sessionKey) return pending;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private 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");
|
||||
}
|
||||
|
||||
private extractAttachmentsFromPrompt(
|
||||
prompt: ContentBlock[],
|
||||
): Array<{ type: string; mimeType: string; content: string }> {
|
||||
const attachments: Array<{ type: string; mimeType: string; content: string }> = [];
|
||||
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;
|
||||
}
|
||||
|
||||
private parseSessionMeta(meta: unknown): SessionMeta {
|
||||
if (!meta || typeof meta !== "object") return {};
|
||||
const record = meta as Record<string, unknown>;
|
||||
return {
|
||||
sessionKey: readString(record, ["sessionKey", "session", "key"]),
|
||||
sessionLabel: readString(record, ["sessionLabel", "label"]),
|
||||
resetSession: readBool(record, ["resetSession", "reset"]),
|
||||
requireExisting: readBool(record, ["requireExistingSession", "requireExisting"]),
|
||||
prefixCwd: readBool(record, ["prefixCwd"]),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveSessionKey(meta: SessionMeta, fallbackKey: string): Promise<string> {
|
||||
const requestedKey = meta.sessionKey ?? this.opts.defaultSessionKey;
|
||||
const requestedLabel = meta.sessionLabel ?? this.opts.defaultSessionLabel;
|
||||
const requireExisting =
|
||||
meta.requireExisting ?? this.opts.requireExistingSession ?? false;
|
||||
|
||||
if (requestedLabel) {
|
||||
const resolved = await this.gateway.request<{ ok: true; key: string }>(
|
||||
"sessions.resolve",
|
||||
{ label: requestedLabel },
|
||||
);
|
||||
if (!resolved?.key) {
|
||||
throw new Error(`Unable to resolve session label: ${requestedLabel}`);
|
||||
}
|
||||
return resolved.key;
|
||||
}
|
||||
|
||||
if (requestedKey) {
|
||||
if (!requireExisting) return requestedKey;
|
||||
const resolved = await this.gateway.request<{ ok: true; key: string }>(
|
||||
"sessions.resolve",
|
||||
{ key: requestedKey },
|
||||
);
|
||||
if (!resolved?.key) {
|
||||
throw new Error(`Session key not found: ${requestedKey}`);
|
||||
}
|
||||
return resolved.key;
|
||||
}
|
||||
|
||||
return fallbackKey;
|
||||
}
|
||||
|
||||
private async resetSessionIfNeeded(meta: SessionMeta, sessionKey: string): Promise<void> {
|
||||
const resetSession = meta.resetSession ?? this.opts.resetSession ?? false;
|
||||
if (!resetSession) return;
|
||||
await this.gateway.request("sessions.reset", { key: sessionKey });
|
||||
}
|
||||
}
|
||||
|
||||
function readString(
|
||||
meta: Record<string, unknown> | 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;
|
||||
}
|
||||
|
||||
function readBool(
|
||||
meta: Record<string, unknown> | 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;
|
||||
}
|
||||
|
||||
function readNumber(
|
||||
meta: Record<string, unknown> | 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;
|
||||
}
|
||||
|
||||
function formatToolTitle(
|
||||
name: string | undefined,
|
||||
args: Record<string, unknown> | 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(", ")}`;
|
||||
}
|
||||
|
||||
function inferToolKind(name?: string): ToolKind | undefined {
|
||||
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";
|
||||
}
|
||||
30
src/acp/types.ts
Normal file
30
src/acp/types.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { SessionId } from "@agentclientprotocol/sdk";
|
||||
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
export type AcpSession = {
|
||||
sessionId: SessionId;
|
||||
sessionKey: string;
|
||||
cwd: string;
|
||||
createdAt: number;
|
||||
abortController: AbortController | null;
|
||||
activeRunId: string | null;
|
||||
};
|
||||
|
||||
export type AcpServerOptions = {
|
||||
gatewayUrl?: string;
|
||||
gatewayToken?: string;
|
||||
gatewayPassword?: string;
|
||||
defaultSessionKey?: string;
|
||||
defaultSessionLabel?: string;
|
||||
requireExistingSession?: boolean;
|
||||
resetSession?: boolean;
|
||||
prefixCwd?: boolean;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
export const ACP_AGENT_INFO = {
|
||||
name: "clawdbot-acp",
|
||||
title: "Clawdbot ACP Gateway",
|
||||
version: VERSION,
|
||||
};
|
||||
43
src/cli/acp-cli.ts
Normal file
43
src/cli/acp-cli.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
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")
|
||||
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (if required)")
|
||||
.option("--session <key>", "Default session key (e.g. agent:main:main)")
|
||||
.option("--session-label <label>", "Default session label to resolve")
|
||||
.option("--require-existing", "Fail if the session key/label does not exist", false)
|
||||
.option("--reset-session", "Reset the session key before first use", false)
|
||||
.option("--no-prefix-cwd", "Do not prefix prompts with the working directory", false)
|
||||
.option("--verbose, -v", "Verbose logging to stderr", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/acp", "docs.clawd.bot/cli/acp")}\n`,
|
||||
)
|
||||
.action((opts) => {
|
||||
try {
|
||||
serveAcpGateway({
|
||||
gatewayUrl: opts.url as string | undefined,
|
||||
gatewayToken: opts.token as string | undefined,
|
||||
gatewayPassword: opts.password as string | undefined,
|
||||
defaultSessionKey: opts.session as string | undefined,
|
||||
defaultSessionLabel: opts.sessionLabel as string | undefined,
|
||||
requireExistingSession: Boolean(opts.requireExisting),
|
||||
resetSession: Boolean(opts.resetSession),
|
||||
prefixCwd: !opts.noPrefixCwd,
|
||||
verbose: Boolean(opts.verbose),
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { registerPluginCliCommands } from "../../plugins/cli.js";
|
||||
import { registerAcpCli } from "../acp-cli.js";
|
||||
import { registerChannelsCli } from "../channels-cli.js";
|
||||
import { registerCronCli } from "../cron-cli.js";
|
||||
import { registerDaemonCli } from "../daemon-cli.js";
|
||||
@ -22,6 +23,7 @@ import { registerTuiCli } from "../tui-cli.js";
|
||||
import { registerUpdateCli } from "../update-cli.js";
|
||||
|
||||
export function registerSubCliCommands(program: Command) {
|
||||
registerAcpCli(program);
|
||||
registerDaemonCli(program);
|
||||
registerGatewayCli(program);
|
||||
registerLogsCli(program);
|
||||
|
||||
@ -6,6 +6,7 @@ import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
|
||||
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { isAcpSessionKey } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
abortChatRunById,
|
||||
@ -385,6 +386,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
|
||||
runId: clientRunId,
|
||||
status: "started" as const,
|
||||
};
|
||||
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
|
||||
void agentCommand(
|
||||
{
|
||||
message: parsedMessage,
|
||||
@ -397,6 +399,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
|
||||
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||
messageChannel: `node(${nodeId})`,
|
||||
abortSignal: abortController.signal,
|
||||
lane,
|
||||
},
|
||||
defaultRuntime,
|
||||
ctx.deps,
|
||||
|
||||
@ -7,6 +7,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { agentCommand } from "../../commands/agent.js";
|
||||
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { isAcpSessionKey } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
@ -299,6 +300,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
};
|
||||
respond(true, ackPayload, undefined, { runId: clientRunId });
|
||||
|
||||
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
|
||||
void agentCommand(
|
||||
{
|
||||
message: parsedMessage,
|
||||
@ -311,6 +313,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||
messageChannel: INTERNAL_MESSAGE_CHANNEL,
|
||||
abortSignal: abortController.signal,
|
||||
lane,
|
||||
},
|
||||
defaultRuntime,
|
||||
context.deps,
|
||||
|
||||
@ -92,6 +92,15 @@ export function isSubagentSessionKey(sessionKey: string | undefined | null): boo
|
||||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
|
||||
}
|
||||
|
||||
export function isAcpSessionKey(sessionKey: string | undefined | null): boolean {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) return false;
|
||||
const normalized = raw.toLowerCase();
|
||||
if (normalized.startsWith("acp:")) return true;
|
||||
const parsed = parseAgentSessionKey(raw);
|
||||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:"));
|
||||
}
|
||||
|
||||
export function buildAgentMainSessionKey(params: {
|
||||
agentId: string;
|
||||
mainKey?: string | undefined;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user