Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad28889a3d | ||
|
|
3bd7615c4f | ||
|
|
41fbcc405f |
@ -10,6 +10,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
- 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.
|
- 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
|
- 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
|
### Fixes
|
||||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||||
|
|||||||
194
docs.acp.md
Normal file
194
docs.acp.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## How can I use this
|
||||||
|
|
||||||
|
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
||||||
|
drive a Clawdbot Gateway session.
|
||||||
|
|
||||||
|
Quick steps:
|
||||||
|
|
||||||
|
1. Run a Gateway (local or remote).
|
||||||
|
2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags.
|
||||||
|
3. Point the IDE to run `clawdbot acp` over stdio.
|
||||||
|
|
||||||
|
Example config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
||||||
|
clawdbot config set gateway.remote.token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Selecting agents
|
||||||
|
|
||||||
|
ACP does not pick agents directly. It routes by the Gateway session key.
|
||||||
|
|
||||||
|
Use agent-scoped session keys to target a specific agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --session agent:main:main
|
||||||
|
clawdbot acp --session agent:design:main
|
||||||
|
clawdbot acp --session agent:qa:bug-123
|
||||||
|
```
|
||||||
|
|
||||||
|
Each ACP session maps to a single Gateway session key. One agent can have many
|
||||||
|
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||||
|
the key or label.
|
||||||
|
|
||||||
|
## Zed editor setup
|
||||||
|
|
||||||
|
Add a custom ACP agent in `~/.config/zed/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": ["acp"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To target a specific Gateway or agent:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": [
|
||||||
|
"acp",
|
||||||
|
"--url", "wss://gateway-host:18789",
|
||||||
|
"--token", "<token>",
|
||||||
|
"--session", "agent:design:main"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
||||||
|
|
||||||
|
## 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`
|
||||||
143
docs/cli/acp.md
Normal file
143
docs/cli/acp.md
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to use this
|
||||||
|
|
||||||
|
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
|
||||||
|
it to drive a Clawdbot Gateway session.
|
||||||
|
|
||||||
|
1. Ensure the Gateway is running (local or remote).
|
||||||
|
2. Configure the Gateway target (config or flags).
|
||||||
|
3. Point your IDE to run `clawdbot acp` over stdio.
|
||||||
|
|
||||||
|
Example config (persisted):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
||||||
|
clawdbot config set gateway.remote.token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example direct run (no config write):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Selecting agents
|
||||||
|
|
||||||
|
ACP does not pick agents directly. It routes by the Gateway session key.
|
||||||
|
|
||||||
|
Use agent-scoped session keys to target a specific agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --session agent:main:main
|
||||||
|
clawdbot acp --session agent:design:main
|
||||||
|
clawdbot acp --session agent:qa:bug-123
|
||||||
|
```
|
||||||
|
|
||||||
|
Each ACP session maps to a single Gateway session key. One agent can have many
|
||||||
|
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||||
|
the key or label.
|
||||||
|
|
||||||
|
## Zed editor setup
|
||||||
|
|
||||||
|
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": ["acp"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To target a specific Gateway or agent:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": [
|
||||||
|
"acp",
|
||||||
|
"--url", "wss://gateway-host:18789",
|
||||||
|
"--token", "<token>",
|
||||||
|
"--session", "agent:design:main"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
||||||
|
|
||||||
|
## 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)
|
- [`message`](/cli/message)
|
||||||
- [`agent`](/cli/agent)
|
- [`agent`](/cli/agent)
|
||||||
- [`agents`](/cli/agents)
|
- [`agents`](/cli/agents)
|
||||||
|
- [`acp`](/cli/acp)
|
||||||
- [`status`](/cli/status)
|
- [`status`](/cli/status)
|
||||||
- [`health`](/cli/health)
|
- [`health`](/cli/health)
|
||||||
- [`sessions`](/cli/sessions)
|
- [`sessions`](/cli/sessions)
|
||||||
@ -125,6 +126,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
list
|
list
|
||||||
add
|
add
|
||||||
delete
|
delete
|
||||||
|
acp
|
||||||
status
|
status
|
||||||
health
|
health
|
||||||
sessions
|
sessions
|
||||||
@ -506,6 +508,11 @@ Options:
|
|||||||
- `--force`
|
- `--force`
|
||||||
- `--json`
|
- `--json`
|
||||||
|
|
||||||
|
### `acp`
|
||||||
|
Run the ACP bridge that connects IDEs to the Gateway.
|
||||||
|
|
||||||
|
See [`acp`](/cli/acp) for full options and examples.
|
||||||
|
|
||||||
### `status`
|
### `status`
|
||||||
Show linked session health and recent recipients.
|
Show linked session health and recent recipients.
|
||||||
|
|
||||||
|
|||||||
@ -140,6 +140,7 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.23.0",
|
"packageManager": "pnpm@10.23.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "0.13.0",
|
||||||
"@buape/carbon": "0.0.0-beta-20260110172854",
|
"@buape/carbon": "0.0.0-beta-20260110172854",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@grammyjs/runner": "^2.0.3",
|
"@grammyjs/runner": "^2.0.3",
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@ -13,6 +13,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@agentclientprotocol/sdk':
|
||||||
|
specifier: 0.13.0
|
||||||
|
version: 0.13.0(zod@4.3.5)
|
||||||
'@buape/carbon':
|
'@buape/carbon':
|
||||||
specifier: 0.0.0-beta-20260110172854
|
specifier: 0.0.0-beta-20260110172854
|
||||||
version: 0.0.0-beta-20260110172854(hono@4.11.4)
|
version: 0.0.0-beta-20260110172854(hono@4.11.4)
|
||||||
@ -238,6 +241,8 @@ importers:
|
|||||||
specifier: 3.14.5
|
specifier: 3.14.5
|
||||||
version: 3.14.5(typescript@5.9.3)
|
version: 3.14.5(typescript@5.9.3)
|
||||||
|
|
||||||
|
extensions/bluebubbles: {}
|
||||||
|
|
||||||
extensions/copilot-proxy: {}
|
extensions/copilot-proxy: {}
|
||||||
|
|
||||||
extensions/google-antigravity-auth: {}
|
extensions/google-antigravity-auth: {}
|
||||||
@ -337,6 +342,11 @@ importers:
|
|||||||
|
|
||||||
packages:
|
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':
|
'@anthropic-ai/sdk@0.71.2':
|
||||||
resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==}
|
resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -4394,6 +4404,10 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
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)':
|
'@anthropic-ai/sdk@0.71.2(zod@4.3.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
json-schema-to-ts: 3.1.1
|
json-schema-to-ts: 3.1.1
|
||||||
|
|||||||
34
src/acp/event-mapper.test.ts
Normal file
34
src/acp/event-mapper.test.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
73
src/acp/event-mapper.ts
Normal file
73
src/acp/event-mapper.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
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<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(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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";
|
||||||
|
}
|
||||||
4
src/acp/index.ts
Normal file
4
src/acp/index.ts
Normal file
@ -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";
|
||||||
35
src/acp/meta.ts
Normal file
35
src/acp/meta.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
57
src/acp/session-mapper.test.ts
Normal file
57
src/acp/session-mapper.test.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { GatewayClient } from "../gateway/client.js";
|
||||||
|
import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js";
|
||||||
|
|
||||||
|
function createGateway(resolveLabelKey = "agent:main:label"): {
|
||||||
|
gateway: GatewayClient;
|
||||||
|
request: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
|
||||||
|
if (method === "sessions.resolve" && "label" in params) {
|
||||||
|
return { ok: true, key: resolveLabelKey };
|
||||||
|
}
|
||||||
|
if (method === "sessions.resolve" && "key" in params) {
|
||||||
|
return { ok: true, key: params.key as string };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
gateway: { request } as unknown as GatewayClient,
|
||||||
|
request,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("acp session mapper", () => {
|
||||||
|
it("prefers explicit sessionLabel over sessionKey", async () => {
|
||||||
|
const { gateway, request } = createGateway();
|
||||||
|
const meta = parseSessionMeta({ sessionLabel: "support", sessionKey: "agent:main:main" });
|
||||||
|
|
||||||
|
const key = await resolveSessionKey({
|
||||||
|
meta,
|
||||||
|
fallbackKey: "acp:fallback",
|
||||||
|
gateway,
|
||||||
|
opts: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(key).toBe("agent:main:label");
|
||||||
|
expect(request).toHaveBeenCalledTimes(1);
|
||||||
|
expect(request).toHaveBeenCalledWith("sessions.resolve", { label: "support" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets meta sessionKey override default label", async () => {
|
||||||
|
const { gateway, request } = createGateway();
|
||||||
|
const meta = parseSessionMeta({ sessionKey: "agent:main:override" });
|
||||||
|
|
||||||
|
const key = await resolveSessionKey({
|
||||||
|
meta,
|
||||||
|
fallbackKey: "acp:fallback",
|
||||||
|
gateway,
|
||||||
|
opts: { defaultSessionLabel: "default-label" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(key).toBe("agent:main:override");
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
95
src/acp/session-mapper.ts
Normal file
95
src/acp/session-mapper.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import type { GatewayClient } from "../gateway/client.js";
|
||||||
|
|
||||||
|
import type { AcpServerOptions } from "./types.js";
|
||||||
|
import { readBool, readString } from "./meta.js";
|
||||||
|
|
||||||
|
export type AcpSessionMeta = {
|
||||||
|
sessionKey?: string;
|
||||||
|
sessionLabel?: string;
|
||||||
|
resetSession?: boolean;
|
||||||
|
requireExisting?: boolean;
|
||||||
|
prefixCwd?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseSessionMeta(meta: unknown): AcpSessionMeta {
|
||||||
|
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"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSessionKey(params: {
|
||||||
|
meta: AcpSessionMeta;
|
||||||
|
fallbackKey: string;
|
||||||
|
gateway: GatewayClient;
|
||||||
|
opts: AcpServerOptions;
|
||||||
|
}): Promise<string> {
|
||||||
|
const requestedLabel = params.meta.sessionLabel ?? params.opts.defaultSessionLabel;
|
||||||
|
const requestedKey = params.meta.sessionKey ?? params.opts.defaultSessionKey;
|
||||||
|
const requireExisting =
|
||||||
|
params.meta.requireExisting ?? params.opts.requireExistingSession ?? false;
|
||||||
|
|
||||||
|
if (params.meta.sessionLabel) {
|
||||||
|
const resolved = await params.gateway.request<{ ok: true; key: string }>(
|
||||||
|
"sessions.resolve",
|
||||||
|
{ label: params.meta.sessionLabel },
|
||||||
|
);
|
||||||
|
if (!resolved?.key) {
|
||||||
|
throw new Error(`Unable to resolve session label: ${params.meta.sessionLabel}`);
|
||||||
|
}
|
||||||
|
return resolved.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.meta.sessionKey) {
|
||||||
|
if (!requireExisting) return params.meta.sessionKey;
|
||||||
|
const resolved = await params.gateway.request<{ ok: true; key: string }>(
|
||||||
|
"sessions.resolve",
|
||||||
|
{ key: params.meta.sessionKey },
|
||||||
|
);
|
||||||
|
if (!resolved?.key) {
|
||||||
|
throw new Error(`Session key not found: ${params.meta.sessionKey}`);
|
||||||
|
}
|
||||||
|
return resolved.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedLabel) {
|
||||||
|
const resolved = await params.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 params.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 params.fallbackKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetSessionIfNeeded(params: {
|
||||||
|
meta: AcpSessionMeta;
|
||||||
|
sessionKey: string;
|
||||||
|
gateway: GatewayClient;
|
||||||
|
opts: AcpServerOptions;
|
||||||
|
}): Promise<void> {
|
||||||
|
const resetSession = params.meta.resetSession ?? params.opts.resetSession ?? false;
|
||||||
|
if (!resetSession) return;
|
||||||
|
await params.gateway.request("sessions.reset", { key: params.sessionKey });
|
||||||
|
}
|
||||||
26
src/acp/session.test.ts
Normal file
26
src/acp/session.test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { createInMemorySessionStore } from "./session.js";
|
||||||
|
|
||||||
|
describe("acp session manager", () => {
|
||||||
|
const store = createInMemorySessionStore();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.clearAllSessionsForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks active runs and clears on cancel", () => {
|
||||||
|
const session = store.createSession({
|
||||||
|
sessionKey: "acp:test",
|
||||||
|
cwd: "/tmp",
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
store.setActiveRun(session.sessionId, "run-1", controller);
|
||||||
|
|
||||||
|
expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
|
||||||
|
|
||||||
|
const cancelled = store.cancelActiveRun(session.sessionId);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
expect(store.getSessionByRunId("run-1")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
93
src/acp/session.ts
Normal file
93
src/acp/session.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import type { AcpSession } from "./types.js";
|
||||||
|
|
||||||
|
export type AcpSessionStore = {
|
||||||
|
createSession: (params: {
|
||||||
|
sessionKey: string;
|
||||||
|
cwd: string;
|
||||||
|
sessionId?: string;
|
||||||
|
}) => AcpSession;
|
||||||
|
getSession: (sessionId: string) => AcpSession | undefined;
|
||||||
|
getSessionByRunId: (runId: string) => AcpSession | undefined;
|
||||||
|
setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void;
|
||||||
|
clearActiveRun: (sessionId: string) => void;
|
||||||
|
cancelActiveRun: (sessionId: string) => boolean;
|
||||||
|
clearAllSessionsForTest: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createInMemorySessionStore(): AcpSessionStore {
|
||||||
|
const sessions = new Map<string, AcpSession>();
|
||||||
|
const runIdToSessionId = new Map<string, string>();
|
||||||
|
|
||||||
|
const createSession: AcpSessionStore["createSession"] = (params) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSession: AcpSessionStore["getSession"] = (sessionId) => sessions.get(sessionId);
|
||||||
|
|
||||||
|
const getSessionByRunId: AcpSessionStore["getSessionByRunId"] = (runId) => {
|
||||||
|
const sessionId = runIdToSessionId.get(runId);
|
||||||
|
return sessionId ? sessions.get(sessionId) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveRun: AcpSessionStore["setActiveRun"] = (
|
||||||
|
sessionId,
|
||||||
|
runId,
|
||||||
|
abortController,
|
||||||
|
) => {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
session.activeRunId = runId;
|
||||||
|
session.abortController = abortController;
|
||||||
|
runIdToSessionId.set(runId, sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearActiveRun: AcpSessionStore["clearActiveRun"] = (sessionId) => {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
if (session.activeRunId) runIdToSessionId.delete(session.activeRunId);
|
||||||
|
session.activeRunId = null;
|
||||||
|
session.abortController = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelActiveRun: AcpSessionStore["cancelActiveRun"] = (sessionId) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllSessionsForTest: AcpSessionStore["clearAllSessionsForTest"] = () => {
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
session.abortController?.abort();
|
||||||
|
}
|
||||||
|
sessions.clear();
|
||||||
|
runIdToSessionId.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
getSessionByRunId,
|
||||||
|
setActiveRun,
|
||||||
|
clearActiveRun,
|
||||||
|
cancelActiveRun,
|
||||||
|
clearAllSessionsForTest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultAcpSessionStore = createInMemorySessionStore();
|
||||||
420
src/acp/translator.ts
Normal file
420
src/acp/translator.ts
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
AgentSideConnection,
|
||||||
|
AuthenticateRequest,
|
||||||
|
AuthenticateResponse,
|
||||||
|
CancelNotification,
|
||||||
|
InitializeRequest,
|
||||||
|
InitializeResponse,
|
||||||
|
ListSessionsRequest,
|
||||||
|
ListSessionsResponse,
|
||||||
|
LoadSessionRequest,
|
||||||
|
LoadSessionResponse,
|
||||||
|
NewSessionRequest,
|
||||||
|
NewSessionResponse,
|
||||||
|
PromptRequest,
|
||||||
|
PromptResponse,
|
||||||
|
SetSessionModeRequest,
|
||||||
|
SetSessionModeResponse,
|
||||||
|
StopReason,
|
||||||
|
} 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 { readBool, readNumber, readString } from "./meta.js";
|
||||||
|
import {
|
||||||
|
extractAttachmentsFromPrompt,
|
||||||
|
extractTextFromPrompt,
|
||||||
|
formatToolTitle,
|
||||||
|
inferToolKind,
|
||||||
|
} from "./event-mapper.js";
|
||||||
|
import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js";
|
||||||
|
import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
|
||||||
|
import { defaultAcpSessionStore, type AcpSessionStore } 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 AcpGatewayAgentOptions = AcpServerOptions & {
|
||||||
|
sessionStore?: AcpSessionStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AcpGatewayAgent implements Agent {
|
||||||
|
private connection: AgentSideConnection;
|
||||||
|
private gateway: GatewayClient;
|
||||||
|
private opts: AcpGatewayAgentOptions;
|
||||||
|
private log: (msg: string) => void;
|
||||||
|
private sessionStore: AcpSessionStore;
|
||||||
|
private pendingPrompts = new Map<string, PendingPrompt>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
connection: AgentSideConnection,
|
||||||
|
gateway: GatewayClient,
|
||||||
|
opts: AcpGatewayAgentOptions = {},
|
||||||
|
) {
|
||||||
|
this.connection = connection;
|
||||||
|
this.gateway = gateway;
|
||||||
|
this.opts = opts;
|
||||||
|
this.log = opts.verbose
|
||||||
|
? (msg: string) => process.stderr.write(`[acp] ${msg}\n`)
|
||||||
|
: () => {};
|
||||||
|
this.sessionStore = opts.sessionStore ?? defaultAcpSessionStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`));
|
||||||
|
this.sessionStore.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 = parseSessionMeta(params._meta);
|
||||||
|
const sessionKey = await resolveSessionKey({
|
||||||
|
meta,
|
||||||
|
fallbackKey: `acp:${sessionId}`,
|
||||||
|
gateway: this.gateway,
|
||||||
|
opts: this.opts,
|
||||||
|
});
|
||||||
|
await resetSessionIfNeeded({
|
||||||
|
meta,
|
||||||
|
sessionKey,
|
||||||
|
gateway: this.gateway,
|
||||||
|
opts: this.opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = this.sessionStore.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 = parseSessionMeta(params._meta);
|
||||||
|
const sessionKey = await resolveSessionKey({
|
||||||
|
meta,
|
||||||
|
fallbackKey: params.sessionId,
|
||||||
|
gateway: this.gateway,
|
||||||
|
opts: this.opts,
|
||||||
|
});
|
||||||
|
await resetSessionIfNeeded({
|
||||||
|
meta,
|
||||||
|
sessionKey,
|
||||||
|
gateway: this.gateway,
|
||||||
|
opts: this.opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = this.sessionStore.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 = this.sessionStore.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 = this.sessionStore.getSession(params.sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session ${params.sessionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.abortController) {
|
||||||
|
this.sessionStore.cancelActiveRun(params.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const runId = randomUUID();
|
||||||
|
this.sessionStore.setActiveRun(params.sessionId, runId, abortController);
|
||||||
|
|
||||||
|
const meta = parseSessionMeta(params._meta);
|
||||||
|
const userText = extractTextFromPrompt(params.prompt);
|
||||||
|
const attachments = 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);
|
||||||
|
this.sessionStore.clearActiveRun(params.sessionId);
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(params: CancelNotification): Promise<void> {
|
||||||
|
const session = this.sessionStore.getSession(params.sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
this.sessionStore.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);
|
||||||
|
this.sessionStore.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
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 type { Command } from "commander";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { registerPluginCliCommands } from "../../plugins/cli.js";
|
import { registerPluginCliCommands } from "../../plugins/cli.js";
|
||||||
|
import { registerAcpCli } from "../acp-cli.js";
|
||||||
import { registerChannelsCli } from "../channels-cli.js";
|
import { registerChannelsCli } from "../channels-cli.js";
|
||||||
import { registerCronCli } from "../cron-cli.js";
|
import { registerCronCli } from "../cron-cli.js";
|
||||||
import { registerDaemonCli } from "../daemon-cli.js";
|
import { registerDaemonCli } from "../daemon-cli.js";
|
||||||
@ -22,6 +23,7 @@ import { registerTuiCli } from "../tui-cli.js";
|
|||||||
import { registerUpdateCli } from "../update-cli.js";
|
import { registerUpdateCli } from "../update-cli.js";
|
||||||
|
|
||||||
export function registerSubCliCommands(program: Command) {
|
export function registerSubCliCommands(program: Command) {
|
||||||
|
registerAcpCli(program);
|
||||||
registerDaemonCli(program);
|
registerDaemonCli(program);
|
||||||
registerGatewayCli(program);
|
registerGatewayCli(program);
|
||||||
registerLogsCli(program);
|
registerLogsCli(program);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
|||||||
import { agentCommand } from "../commands/agent.js";
|
import { agentCommand } from "../commands/agent.js";
|
||||||
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
|
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
|
||||||
import { registerAgentRunContext } from "../infra/agent-events.js";
|
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||||
|
import { isAcpSessionKey } from "../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
abortChatRunById,
|
abortChatRunById,
|
||||||
@ -385,6 +386,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
|
|||||||
runId: clientRunId,
|
runId: clientRunId,
|
||||||
status: "started" as const,
|
status: "started" as const,
|
||||||
};
|
};
|
||||||
|
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
|
||||||
void agentCommand(
|
void agentCommand(
|
||||||
{
|
{
|
||||||
message: parsedMessage,
|
message: parsedMessage,
|
||||||
@ -397,6 +399,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
|
|||||||
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||||
messageChannel: `node(${nodeId})`,
|
messageChannel: `node(${nodeId})`,
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
|
lane,
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
ctx.deps,
|
ctx.deps,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
|||||||
import { agentCommand } from "../../commands/agent.js";
|
import { agentCommand } from "../../commands/agent.js";
|
||||||
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
|
import { isAcpSessionKey } from "../../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||||
@ -299,6 +300,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
};
|
};
|
||||||
respond(true, ackPayload, undefined, { runId: clientRunId });
|
respond(true, ackPayload, undefined, { runId: clientRunId });
|
||||||
|
|
||||||
|
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
|
||||||
void agentCommand(
|
void agentCommand(
|
||||||
{
|
{
|
||||||
message: parsedMessage,
|
message: parsedMessage,
|
||||||
@ -311,6 +313,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||||
messageChannel: INTERNAL_MESSAGE_CHANNEL,
|
messageChannel: INTERNAL_MESSAGE_CHANNEL,
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
|
lane,
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
context.deps,
|
context.deps,
|
||||||
|
|||||||
@ -1,3 +1,15 @@
|
|||||||
|
import {
|
||||||
|
parseAgentSessionKey,
|
||||||
|
type ParsedAgentSessionKey,
|
||||||
|
} from "../sessions/session-key-utils.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
isAcpSessionKey,
|
||||||
|
isSubagentSessionKey,
|
||||||
|
parseAgentSessionKey,
|
||||||
|
type ParsedAgentSessionKey,
|
||||||
|
} from "../sessions/session-key-utils.js";
|
||||||
|
|
||||||
export const DEFAULT_AGENT_ID = "main";
|
export const DEFAULT_AGENT_ID = "main";
|
||||||
export const DEFAULT_MAIN_KEY = "main";
|
export const DEFAULT_MAIN_KEY = "main";
|
||||||
export const DEFAULT_ACCOUNT_ID = "default";
|
export const DEFAULT_ACCOUNT_ID = "default";
|
||||||
@ -11,11 +23,6 @@ export function normalizeMainKey(value: string | undefined | null): string {
|
|||||||
return trimmed ? trimmed : DEFAULT_MAIN_KEY;
|
return trimmed ? trimmed : DEFAULT_MAIN_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParsedAgentSessionKey = {
|
|
||||||
agentId: string;
|
|
||||||
rest: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function toAgentRequestSessionKey(storeKey: string | undefined | null): string | undefined {
|
export function toAgentRequestSessionKey(storeKey: string | undefined | null): string | undefined {
|
||||||
const raw = (storeKey ?? "").trim();
|
const raw = (storeKey ?? "").trim();
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
@ -70,28 +77,6 @@ export function normalizeAccountId(value: string | undefined | null): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAgentSessionKey(
|
|
||||||
sessionKey: string | undefined | null,
|
|
||||||
): ParsedAgentSessionKey | null {
|
|
||||||
const raw = (sessionKey ?? "").trim();
|
|
||||||
if (!raw) return null;
|
|
||||||
const parts = raw.split(":").filter(Boolean);
|
|
||||||
if (parts.length < 3) return null;
|
|
||||||
if (parts[0] !== "agent") return null;
|
|
||||||
const agentId = parts[1]?.trim();
|
|
||||||
const rest = parts.slice(2).join(":");
|
|
||||||
if (!agentId || !rest) return null;
|
|
||||||
return { agentId, rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
|
|
||||||
const raw = (sessionKey ?? "").trim();
|
|
||||||
if (!raw) return false;
|
|
||||||
if (raw.toLowerCase().startsWith("subagent:")) return true;
|
|
||||||
const parsed = parseAgentSessionKey(raw);
|
|
||||||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAgentMainSessionKey(params: {
|
export function buildAgentMainSessionKey(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
mainKey?: string | undefined;
|
mainKey?: string | undefined;
|
||||||
|
|||||||
35
src/sessions/session-key-utils.ts
Normal file
35
src/sessions/session-key-utils.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export type ParsedAgentSessionKey = {
|
||||||
|
agentId: string;
|
||||||
|
rest: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseAgentSessionKey(
|
||||||
|
sessionKey: string | undefined | null,
|
||||||
|
): ParsedAgentSessionKey | null {
|
||||||
|
const raw = (sessionKey ?? "").trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const parts = raw.split(":").filter(Boolean);
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
if (parts[0] !== "agent") return null;
|
||||||
|
const agentId = parts[1]?.trim();
|
||||||
|
const rest = parts.slice(2).join(":");
|
||||||
|
if (!agentId || !rest) return null;
|
||||||
|
return { agentId, rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
|
||||||
|
const raw = (sessionKey ?? "").trim();
|
||||||
|
if (!raw) return false;
|
||||||
|
if (raw.toLowerCase().startsWith("subagent:")) return true;
|
||||||
|
const parsed = parseAgentSessionKey(raw);
|
||||||
|
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:"));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user