diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a00e594..d59e5dd 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "a2a-bridge", "description": "Bidirectional bridge between Claude Code and external AI coding agents. Exposes Claude Code as an A2A server (Gemini CLI, any A2A client) and as an ACP agent (OpenClaw, Zed, VS Code); routes outbound tool calls to Codex, OpenClaw, and Hermes adapters through a shared daemon with per-room task logs and verification-artifact support.", - "version": "0.1.2", + "version": "0.2.0", "author": { "name": "FirstIntent", "url": "https://github.com/firstintent" diff --git a/CHANGELOG.md b/CHANGELOG.md index 22464ec..aba9848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,115 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] — 2026-04-16 + +Multi-target routing. One daemon can now front multiple Claude Code +workspaces at once, and ACP / A2A callers pick which one they want +via an explicit `kind:id` TargetId. Existing single-CC deployments +are unchanged — everything defaults to `claude:default`. + +Full design: [`docs/design/multi-target-routing.md`](./docs/design/multi-target-routing.md). + +### Added + +- **TargetId model** — a `kind:id` tuple (e.g. `claude:proj-a`, + `codex:default`) is the canonical identifier for any attached + agent instance. Validated against `[a-z0-9_-]+` at every + boundary; invalid targets are rejected at the source instead of + silently routing to `default`. +- **Plugin-side workspace id derivation.** `a2a-bridge claude` + announces a TargetId on `claude_connect` derived from (in order): + `A2A_BRIDGE_WORKSPACE_ID` env var → `A2A_BRIDGE_STATE_DIR` + basename → conversation id prefix → `default`. The result is + sanitised and prefixed with `claude:`. Two CC sessions with + distinct state-dirs therefore attach as distinct targets with no + extra config. +- **`a2a-bridge acp --target `.** Pick which attached + target handles the turn. Unattached targets return + `acp_turn_error { "target not attached" }`; missing flag keeps + v0.1 behaviour (routes to `claude:default`). +- **A2A `contextId → TargetId` routing.** `startA2AServer` accepts + a `contextRoutes: Record` map; the daemon reads + it from `A2A_BRIDGE_CONTEXT_ROUTES` (a JSON object env var). + Unmapped contexts fall back to `claude:default` instead of + minting their own Room. Configuration is validated at startup — + a malformed TargetId is a fail-fast error, not a 5xx at request + time. +- **Outbound reply targeting.** CC's `reply` tool schema grows an + optional `target` field. Present → the daemon forwards the reply + to that TargetId's Room instead of the inbound turn's + originator. Absent → today's behaviour. Unknown targets, bad + shapes, and self-loops all surface descriptive errors. +- **Attach conflict policy.** A second CC attaching to an + already-held TargetId is **rejected** with an error naming the + incumbent (`target claude:proj-a already attached — plugin conn + #1, attached 3m ago`). Rerun with `a2a-bridge claude --force` + (or `A2A_BRIDGE_FORCE_ATTACH=1`) to kick the old attach; the + evicted session receives a `claude_connect_replaced` frame that + surfaces as a CC-visible notification before disconnect. +- **`a2a-bridge daemon targets`.** New inspection subcommand. + Prints a plain-text table of every TargetId the daemon tracks, + with attach state, the WS connection id, and uptime since + attach. Powered by a new `list_targets` control-plane RPC. + +### Changed + +- **Per-target inbound gateway.** The ACP turn handler resolves + each turn's target to its own `DaemonClaudeCodeGateway` instance + (minted lazily by `RoomRouter.getOrCreateByTarget`). Cross-CC + delivery isolation: inbound text for `claude:ws-a` lands only on + CC-A's socket, and CC-A's reply can only close CC-A's in-flight + turn. Regression covered by the + [cross-target integration test](./src/cli/multi-target.test.ts). +- **`claude_to_codex` intercept is sender-target-aware.** The + daemon picks the sender's target's Room gateway when deciding + whether a reply closes an inbound turn, so a reply from CC-A + cannot accidentally complete CC-B's turn. +- **Plugin disabled-state recovery** now forwards the CC's + TargetId on the recovery attach (was silently dropping to + `claude:default` before the fix). + +### Fixed — from the v0.2.0 pre-release smoke pass + +- `a2a-bridge daemon targets` no longer advertises a phantom + `claude:default` row when every CC attach was under an explicit + TargetId. The legacy-singleton fallback only surfaces when no + per-target entry already covers that connection. +- `a2a-bridge acp` now advertises `agentCapabilities.loadSession: + true` and implements `session/load` as a stateless no-op. + OpenClaw acpx's "persistent session" mode previously tried to + resume a prior session id across subprocess restarts and blew + up on `agent does not support session/load`; the adopt-as-new + implementation keeps acpx happy without introducing cross- + restart session state we don't actually own. + +### Deferred to v0.3 + +- **Codex peer-id routing** (`a2a-bridge codex --id `). Codex + is a daemon-internal adapter, not a control-plane attach, so + multi-instancing requires a per-id peer registry + port + allocation + handler routing refactor. Out of scope for v0.2; + codex stays `codex:default` (single instance). Tracked in the + deferred P10.9 entry of `TASKS.md`. +- **Hot-reload of `contextRoutes`.** The A2A inbound reads the + map at startup; config changes require a daemon restart. +- **Dynamic target discovery.** ACP clients still register each + target statically (OpenClaw `acpx`, Zed `agent_servers`, etc.). + +### Control-plane wire additions (for embedders) + +- `claude_connect` grows `{ target?: string; force?: boolean }`. +- New server → plugin frames: + - `claude_connect_rejected { target, reason }` + - `claude_connect_replaced { target }` +- `acp_turn_start` grows `{ target?: string }`. +- `claude_to_codex` grows `{ target?: string }`. +- New RPC pair: `list_targets { requestId }` → `targets_response + { requestId, targets: TargetEntry[] }`. + +All additions are optional on the wire — v0.1 plugins / subprocesses +continue to work against a v0.2 daemon (and vice versa). + ## [0.1.0] — 2026-04-14 First broadly usable release. Turns a2a-bridge from a Codex-only diff --git a/README.md b/README.md index 3bfdb7f..e636023 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,9 @@ client config: | Agent | Protocol | Config | |-------|----------|--------| -| **OpenClaw** | ACP | `~/.acpx/config.json` → `{ "agents": { "a2a-bridge": { "command": "a2a-bridge acp" } } }` | -| **OpenClaw (remote)** | ACP | `"command": "env A2A_BRIDGE_CONTROL_URL=ws://:4512/ws a2a-bridge acp"` | +| **OpenClaw** | ACP | `openclaw.json` → add `"a2a-bridge"` to `acp.allowedAgents` + register `plugins.entries.acpx.config.agents["a2a-bridge"].command = "a2a-bridge acp"`, then `/acp spawn a2a-bridge` | +| **OpenClaw (remote)** | ACP | same + `"command": "a2a-bridge acp --url ws://:4512/ws"` | +| **OpenClaw (multi-CC)** | ACP | one `agents` entry per target: `"bridge-proj-a": { "command": "a2a-bridge acp --target claude:proj-a" }` (see [multi-target routing](./docs/design/multi-target-routing.md)) | | **Hermes Agent** | ACP | Same pattern as OpenClaw | | **Zed** | ACP | `settings.json` → `{ "agent_servers": { "a2a-bridge": { "command": "a2a-bridge", "args": ["acp"] } } }` | | **VS Code** | ACP | `{ "acp.agents": [{ "name": "a2a-bridge", "command": "a2a-bridge", "args": ["acp"] }] }` | @@ -120,6 +121,53 @@ tmux new-session -d -s cc-bridge "a2a-bridge claude" tmux send-keys -t cc-bridge Enter ``` +### Multi-workspace routing (v0.2) + +One daemon can front multiple Claude Code sessions at once. Each +session attaches under a `kind:id` **TargetId** (e.g. `claude:proj-a`, +`claude:proj-b`), and ACP / A2A callers pick which session they want: + +```bash +# Terminal 1 — two Claude Code sessions, one daemon +A2A_BRIDGE_STATE_DIR=~/.config/a2a-bridge/proj-a a2a-bridge claude +A2A_BRIDGE_STATE_DIR=~/.config/a2a-bridge/proj-b a2a-bridge claude +# → attach as claude:proj-a and claude:proj-b respectively + +# Terminal 3 — inspect +a2a-bridge daemon targets +# TARGET ATTACHED CLIENT UPTIME +# claude:proj-a yes 3 2m +# claude:proj-b yes 5 1m +``` + +ACP callers route with `--target`: + +```bash +a2a-bridge acp --target claude:proj-a -p "review this branch" +``` + +OpenClaw registers one `acpx` agent entry per target: + +```json +{ "plugins": { "entries": { "acpx": { "config": { "agents": { + "bridge-proj-a": { "command": "a2a-bridge acp --target claude:proj-a" }, + "bridge-proj-b": { "command": "a2a-bridge acp --target claude:proj-b" } +}}}}}} +``` + +A2A HTTP callers route via `contextId → TargetId` config +(`A2A_BRIDGE_CONTEXT_ROUTES` env var, see below). + +A second CC attaching to an already-claimed TargetId is **rejected** +with a descriptive error. Rerun with `a2a-bridge claude --force` to +kick the old attach and take over — the previous session gets a +CC-visible notification that it was replaced. + +Full design + deployment shapes: +[`docs/design/multi-target-routing.md`](./docs/design/multi-target-routing.md). +v0.2 ships with the multi-claude axis; codex multi-instance lands +in v0.3. + --- ## Architecture @@ -186,7 +234,10 @@ Common fixes: [see the env var reference below](#environment-variables). | `A2A_BRIDGE_CONTROL_HOST` | `127.0.0.1` | Control plane bind (`0.0.0.0` for remote) | | `A2A_BRIDGE_CONTROL_URL` | auto | Full WS URL for ACP subprocess | | `A2A_BRIDGE_ACP_ENSURE_DAEMON` | unset | Opt-in: auto-start daemon in `acp` (off by default) | -| `A2A_BRIDGE_STATE_DIR` | `~/.local/state/a2a-bridge` | Config, logs, task DB | +| `A2A_BRIDGE_STATE_DIR` | `~/.local/state/a2a-bridge` | Config, logs, task DB (basename also seeds the CC TargetId) | +| `A2A_BRIDGE_WORKSPACE_ID` | (derived) | Explicit override for this CC's id (wins over `STATE_DIR` basename) | +| `A2A_BRIDGE_FORCE_ATTACH` | unset | When `1`, `a2a-bridge claude` kicks an attached CC on the same TargetId (equivalent to `--force`) | +| `A2A_BRIDGE_CONTEXT_ROUTES` | unset | JSON map `{"ctx-id": "claude:workspace"}` for A2A `contextId → TargetId` routing; unmapped contexts fall back to `claude:default` | --- diff --git a/TASKS.md b/TASKS.md index 5f9dca1..f4151d6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -522,6 +522,102 @@ once that phase lands. remains the runbook; the maintainer takes over from there for `npm publish`, the marketplace form, and the ACP registry PR. +## Phase 10 — Multi-target routing (v0.2) + +> Ship the `kind:id` target model from +> [`docs/design/multi-target-routing.md`](./docs/design/multi-target-routing.md). +> v0.1 hardcodes one CC slot and one Codex peer; v0.2 lets one +> daemon front multiple Claude Code workspaces and multiple peer +> instances, selected by `--target kind:id`. + +- [x] **P10.1 — TargetId type + parser.** + Acceptance: new `src/shared/target-id.ts` exports a `TargetId` + branded string, a `parseTarget(s)` that returns `{kind, id}` or a + parse error, and a `formatTarget({kind, id})` inverse. Reject + empty kind/id and any character outside `[a-z0-9_-]` (plus the + single `:` separator). Unit tests cover happy path, defaults + (`claude` → `claude:default`), and every rejection case. + +- [x] **P10.2 — Plugin-side workspace id derivation.** + Acceptance: `src/runtime-plugin/bridge.ts` computes the CC's + TargetId at startup using the priority chain documented in the + design doc (`A2A_BRIDGE_WORKSPACE_ID` → `A2A_BRIDGE_STATE_DIR` + basename → conversation id prefix → `default`) and sends it on + the existing `claude_connect` frame. `ControlClientMessage` + gains an optional `target: string` field; unit test asserts the + frame round-trips. + +- [x] **P10.3 — Daemon rooms map keyed by TargetId.** + Acceptance: `RoomRouter` adds `getOrCreateByTarget(TargetId)` + alongside the existing context-id path. `daemon.ts`'s attach + handler stores `Map` instead of a single + `attachedClaude` variable. Existing single-CC behaviour is the + special case where every inbound request resolves to + `claude:default`. + +- [x] **P10.4 — `a2a-bridge acp --target` flag.** + Acceptance: `src/cli/acp.ts` parses `--target kind:id` via + `parseTarget`. `AcpTurnHandler` on the daemon side gets the + target field on every `acp_turn_start` and looks up the Room; + unresolvable target → `acp_turn_error { message: "target not + attached" }`. Unit test covers happy path + missing-target path. + +- [x] **P10.5 — `a2a-bridge daemon targets` subcommand.** + Acceptance: new subcommand prints a table of registered targets + with attach state, pid, and uptime. Reads from the daemon's + rooms map via a new control-plane `list_targets` RPC. Unit test + against a stub daemon. + +- [x] **P10.6 — Attach conflict policy (reject + `--force`).** + Acceptance: a second `a2a-bridge claude` / `a2a-bridge codex` + targeting an already-attached TargetId is rejected with a + descriptive error; rerunning with `--force` sends a + `claude_connect_replaced` / peer-specific kick frame to the old + attach and takes over. Unit tests for both outcomes. + +- [x] **P10.7 — A2A `contextId → TargetId` routing.** + Acceptance: `startA2AServer` accepts a `contextRoutes: Record` + config map; unmapped contexts fall back to `claude:default`. A2A + inbound handlers thread the resolved TargetId through to + `RoomRouter.getOrCreateByTarget`. Unit test covers both mapped + and fallback cases. + +- [x] **P10.8 — Outbound CC → peer via `reply` tool target.** + Acceptance: the plugin's `reply` tool schema gains an optional + `target` field; when present, the daemon forwards the reply to + that target's Room instead of the inbound turn's originator. + Keeps backward compat: absent `target` routes to the inbound + originator (today's behaviour). Unit test covers forward + omit + + unknown-target-error paths. + +- [~] **P10.9 — `a2a-bridge codex --id ` peer id flag. (deferred to v0.3)** + Deferred by maintainer 2026-04-15: unlike claude attach (plugin WS + → control plane), codex is a daemon-internal adapter with module- + level singletons (`CodexAdapter`, `TuiConnectionState`, proxy port + pair, `codexBootstrapped`, `replyRequired`). Multi-instance codex + needs a per-id peer registry + port allocation + handler routing + refactor — out of scope for v0.2. v0.2 ships with multi-claude + routing only; codex stays `codex:default` (single instance). + Original acceptance: the Codex peer process registers under + `codex:` (default `codex:default`). Multiple Codex adapters + can run concurrently on one daemon, each in their own Room. Unit + test asserts two Codex instances with distinct ids don't + cross-talk. + +- [x] **P10.10 — Cross-target integration test.** + Acceptance: new test in `src/cli/multi-target.test.ts` boots a + daemon, attaches two stub CCs as `claude:a` and `claude:b`, + drives two concurrent ACP subprocesses with distinct `--target` + values, asserts each gets its own reply without leakage. Runs + under `check:ci`. + +- [x] **P10.11 — Documentation sweep.** + Acceptance: README, `docs/join.md`, and + `docs/guides/rooms.md` updated to use the `kind:id` form and + explain the target model. `docs/design/multi-target-routing.md` + flips from "not yet implemented" to "implemented in v0.2.0". + CHANGELOG `## [0.2.0]` block drafted. + --- ## Phase footers (filled by the loop) diff --git a/docs/design/multi-target-routing.md b/docs/design/multi-target-routing.md new file mode 100644 index 0000000..1dbc0e2 --- /dev/null +++ b/docs/design/multi-target-routing.md @@ -0,0 +1,274 @@ +# Multi-target routing + +Status: **implemented in v0.2.0** (multi-claude axis). Codex peer-id +routing (`a2a-bridge codex --id `) is deferred to v0.3 — it needs +a daemon-internal refactor (per-id peer adapter registry + port +allocation) that did not fit the v0.2 minimum-diff window. v0.2 ships +with multi-claude routing end-to-end; codex stays `codex:default`. + +v0.1 daemon attaches exactly one Claude Code session and knows about +exactly one Codex peer. v0.2 generalises this into a single daemon +that fronts multiple agent instances — multiple Claude Code +workspaces, and (post-v0.3) multiple Codex / Hermes peers — and +routes each inbound request to the correct target. + +## Core model + +Every agent instance has a **TargetId** — a `kind:id` tuple. + +- `kind ∈ {claude, codex, hermes, openclaw, ...}` — the agent family. +- `id` — the instance identifier within that family (workspace name, + session label, whatever disambiguates it). +- There is no "bare kind" — every target has an explicit id. When + the user omits it, the system uses `default`. + +Examples: + +| TargetId | Meaning | +|----------|---------| +| `claude:project-a` | Claude Code session for project-a | +| `claude:project-b` | Claude Code session for project-b | +| `codex:dev` | Codex TUI bound to the dev workspace | +| `codex:prod` | Codex TUI bound to the prod workspace | +| `hermes:default` | Single Hermes instance | + +The daemon maintains a `Map` where each Room owns +its attached agent, a private message buffer, and a filtered view +of the SQLite TaskLog. + +## Who supplies which id + +| Role | How the id is set | +|------|-------------------| +| Attached CC (server) | Plugin sends workspace id on `claude_connect`, derived from `A2A_BRIDGE_STATE_DIR` or CC conversation id. | +| Attached Codex (peer) | `a2a-bridge codex --id ` CLI flag. | +| Attached Hermes (peer, v0.2) | `a2a-bridge hermes --id ` CLI flag. | +| ACP client (caller) | `a2a-bridge acp --target kind:id` routing parameter. | +| A2A client (caller) | `Message.contextId` on the JSON-RPC call — daemon maps contextId → TargetId (configurable). | + +### Default id derivation for Claude Code + +In priority order: +1. `A2A_BRIDGE_WORKSPACE_ID` env var (explicit override). +2. Basename of `A2A_BRIDGE_STATE_DIR` — e.g. `~/.config/a2a-bridge/project-a` → `project-a`. +3. First 8 chars of the CC conversation id. +4. Fallback: `default`. + +The derivation happens once on `claude_connect`; the plugin sends +the resulting id in the new frame: + +```json +{ "type": "claude_connect", "target": "claude:project-a" } +``` + +## Deployment scenarios + +### Single developer, two projects + +```bash +# Terminal 1 — daemon +a2a-bridge daemon start + +# Terminal 2 — project-a CC +A2A_BRIDGE_STATE_DIR=~/.config/a2a-bridge/project-a a2a-bridge claude +# attaches as claude:project-a + +# Terminal 3 — project-b CC +A2A_BRIDGE_STATE_DIR=~/.config/a2a-bridge/project-b a2a-bridge claude +# attaches as claude:project-b + +# Inspect +$ a2a-bridge daemon targets +claude:project-a attached (plugin conn #1, pid 12345) +claude:project-b attached (plugin conn #3, pid 12389) +``` + +OpenClaw config (`openclaw.json`): + +```json +{ + "acp": { + "allowedAgents": ["claude", "codex", "bridge-proj-a", "bridge-proj-b"] + }, + "plugins": { + "entries": { + "acpx": { + "config": { + "agents": { + "bridge-proj-a": { "command": "a2a-bridge acp --target claude:project-a" }, + "bridge-proj-b": { "command": "a2a-bridge acp --target claude:project-b" } + } + } + } + } + } +} +``` + +In OpenClaw: + +``` +/acp spawn bridge-proj-a # → project-a's CC +/acp spawn bridge-proj-b # → project-b's CC +``` + +### CC + Codex bidirectional + +```bash +a2a-bridge daemon start +a2a-bridge claude # claude:default +a2a-bridge codex --id main # codex:main +``` + +- OpenClaw calls CC: `/acp spawn bridge-cc` (routes to `claude:default`). +- CC delegates to Codex: the `reply` tool accepts an optional + `target="codex:main"` field; daemon forwards to the Codex adapter. + +### Cross-machine multi-tenant (post v0.2 TLS) + +Remote server: + +```bash +A2A_BRIDGE_CONTROL_HOST=0.0.0.0 a2a-bridge daemon start --tls +A2A_BRIDGE_STATE_DIR=~/.a2a/team-a a2a-bridge claude # claude:team-a +A2A_BRIDGE_STATE_DIR=~/.a2a/team-b a2a-bridge claude # claude:team-b +a2a-bridge codex --id shared # codex:shared +``` + +Client: + +```bash +a2a-bridge acp --url wss://remote:443/ws --target claude:team-a -p "hi" +a2a-bridge acp --url wss://remote:443/ws --target codex:shared -p "list files" +``` + +## Routing rules + +**Inbound ACP turn** (subprocess → daemon): +- Subprocess sends `acp_turn_start { target, turnId, sessionId, userText }`. +- Daemon looks up `rooms.get(target)`. +- Forwards to that Room's attached agent. +- Missing target → `acp_turn_error { turnId, message: "target not attached" }`. + +**Inbound A2A turn** (HTTP → daemon): +- Client's `message/stream` carries `contextId`. +- Daemon maps `contextId → TargetId` (config `a2a.contextRoutes`, + with a default fallback like `claude:default`). + +**Outbound CC → peer** (CC's reply tool): +- Extend the `reply` tool signature with optional `target` field. +- When present, daemon routes the reply to that target's Room + instead of the inbound turn's originator. +- Absent → routes back to whoever originated the current turn. + +**Peer → CC** (e.g. Codex adapter emits a message): +- The peer's `agentMessage` event is tagged with its own TargetId. +- Daemon routes to whichever Room has subscribed (Rooms subscribe + to peers they care about). + +## id conflict policy + +Two agents trying to attach with the same TargetId: + +**Default: reject.** The second attach fails with a descriptive +error so the user can diagnose. + +``` +$ a2a-bridge claude +Error: target claude:project-a already attached + (plugin conn #1, pid 12345, attached 2h ago) + + To take over: re-run with --force. + To use a different workspace: set A2A_BRIDGE_STATE_DIR to a fresh dir. +``` + +**`--force`:** new attach kicks the old one. The previous attach +receives a `disconnect { reason: "replaced" }` notification before +being dropped. + +## Daemon CLI surface + +```bash +# Lifecycle +a2a-bridge daemon start +a2a-bridge daemon stop +a2a-bridge daemon status +a2a-bridge daemon logs + +# Inspection +a2a-bridge daemon targets +# Lists every registered TargetId with attach state, pid, uptime. + +# Server / peer attach (from their own terminals) +a2a-bridge claude [--workspace-id | --force] +a2a-bridge codex --id [--force] +a2a-bridge hermes --id [--force] # v0.2 + +# Client side +a2a-bridge acp --target +a2a-bridge acp --target -p "" +a2a-bridge acp --target --url +``` + +## Backward compatibility + +- Absent `--target` → daemon routes to the unique attached target + matching the client's kind affinity (today's v0.1 behaviour). +- Single-instance deployments use `default` everywhere; users who + never run more than one CC see no behavioural change. +- Old plugin / old daemon combos keep working: the new `target` + field in `claude_connect` is optional; omitted → daemon assigns + `claude:default`. + +## Control-plane wire changes + +Extends `transport/control-protocol.ts`: + +```ts +// Plugin → daemon: optional target on attach +| { type: "claude_connect"; target?: string } + +// ACP subprocess → daemon: required target on every turn +| { type: "acp_turn_start"; target: string; turnId: string; ... } + +// Daemon → plugin: new response for conflict rejection +| { type: "claude_connect_rejected"; target: string; reason: string } + +// Daemon → plugin: kicked by --force +| { type: "claude_connect_replaced"; target: string } +``` + +Reuses the identifier-safe key validator already in place for the +permission-bridge frames — TargetId strings are `[a-z0-9_:-]+`. + +## Not in scope (deferred) + +- **Codex multi-instance** (`a2a-bridge codex --id `) — deferred + to v0.3. Unlike claude (which attaches via the control-plane WS + and was therefore straightforward to multi-instance), codex is a + daemon-internal adapter whose `CodexAdapter`, `TuiConnectionState`, + proxy port pair, and several module-level singletons would need a + per-id registry refactor. Tracked in the deferred P10.9 entry of + `TASKS.md`. +- **Dynamic target discovery** — an ACP client asking the daemon + "what targets do you have?" at connect time and auto-populating + its agent registry. Would require extending acpx (OpenClaw's + plugin) which we don't control. Users register each target + statically in acpx config instead. +- **Automatic target-id generation** — the daemon never invents an + id for an attaching agent. Every attach supplies its own. +- **Hot-reload of contextId → TargetId mapping** — the A2A inbound + side reads this map at startup; config changes require a daemon + restart. Hot-reload is v0.3+. + +## References + +- `src/runtime-daemon/rooms/room-router.ts` — existing multi-Room + scaffolding; the attach path is the only layer that becomes + per-target rather than global. +- `src/shared/state-dir.ts` — `A2A_BRIDGE_STATE_DIR` resolver, used + to derive the default workspace id. +- `references/claude-plugins-official/external_plugins/telegram/server.ts:26` + — precedent for `_STATE_DIR` workspace isolation. +- `references/openclaw/extensions/acpx/` — OpenClaw's ACP client + manager; reads static agent config from `openclaw.json`. diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index f0bd89f..85a10e0 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -266,22 +266,14 @@ applications) that the autonomous loop cannot provision in CI. - **MCP inbound** — `runtime-daemon/inbound/mcp/` shim mirroring the ACP shape. Targets Cursor and Claude Desktop. Same `ClaudeCodeGateway` underneath. -- **Multi-CC single-daemon** — multiple Claude Code instances attach - to one daemon, each assigned to its own Room via a workspace ID - (derived from `A2A_BRIDGE_STATE_DIR` or the CC conversation ID). - The plugin sends the workspace ID on `claude_connect`; the daemon's - attach logic becomes per-Room instead of a global single slot. - Inbound clients (OpenClaw, Gemini CLI) route to a specific Room - via `contextId`. RoomRouter already supports multi-Room; this item - wires the CC attach path through it. Follows the Telegram plugin's - `TELEGRAM_STATE_DIR` pattern for workspace isolation. - Reference implementations: - - a2a-bridge: `src/shared/state-dir.ts` — `StateDirResolver` reads - `A2A_BRIDGE_STATE_DIR` env, defaults to `$XDG_STATE_HOME/a2a-bridge`; - owns `daemon.pid`, `status.json`, `tasks.db`, `a2a-bridge.log`. - - Telegram plugin: `references/claude-plugins-official/external_plugins/ - telegram/server.ts:26` — `TELEGRAM_STATE_DIR` env, defaults to - `~/.claude/channels/telegram`; owns `access.json`, `bot.pid`, `inbox/`. +- **Multi-target routing via `--target`** — one daemon fronts + multiple agent instances (several Claude Code workspaces, several + Codex / Hermes peers, ...). Every target is identified by a + `kind:id` tuple, e.g. `claude:project-a`, `codex:dev`, + `hermes:default`. The ACP subprocess takes `--target kind:id` to + pick where its turns go; the plugin sends its workspace id on + attach; RoomRouter becomes `Map` with per-target + attach. Full design: [`multi-target-routing.md`](./multi-target-routing.md). - **Self-signed TLS listener** — `a2a-bridge daemon start --tls` auto-generates a self-signed certificate pair on first run, prints the fingerprint, and binds `wss://` on port 443 (configurable). diff --git a/docs/guides/rooms.md b/docs/guides/rooms.md index 06a094f..9fe5bd0 100644 --- a/docs/guides/rooms.md +++ b/docs/guides/rooms.md @@ -3,29 +3,72 @@ a2a-bridge routes every inbound turn through a **Room** — a per-session container that owns the resources one Claude Code conversation needs: the gateway that injects user text into CC, the -peer adapter set (Codex today; OpenClaw/Hermes post-v0.1), and the +peer adapter set (Codex today; OpenClaw/Hermes post-v0.2), and the task rows that track each turn's lifecycle. This document covers the four things callers ask about most: how Rooms are named, what multi-session isolation guarantees you get, what survives restarts, and when adapters live or die. -## RoomId derivation - -A RoomId is a **branded string** (`RoomId = string & { __brand }`) -derived per inbound request by -[`deriveRoomId`](../../src/runtime-daemon/rooms/room-id.ts): - -| Precedence | Source | Notes | -|------------|-------------------------------|-------------------------------------------------| -| 1 | `Message.contextId` | A2A inbound — minted by the client, echoed back | -| 2 | `A2A_BRIDGE_ROOM` env var | CLI-style callers without a contextId | -| 3 | literal `"default"` | Single-CC fallback — matches pre-Phase-4 behavior | - -Empty or whitespace values are treated as absent. `contextId` wins -over the env var; the env var wins over `"default"`. Clients that -want a stable room across calls either thread the same `contextId` -each request or export `A2A_BRIDGE_ROOM`. +## TargetId and RoomId + +Since v0.2, every agent instance has a **TargetId** — a `kind:id` +tuple like `claude:proj-a` or `codex:default`. TargetIds are the +canonical key for multi-target routing: CC attaches announce one, +ACP callers pick one with `--target`, and A2A callers map their +`contextId` to one via `A2A_BRIDGE_CONTEXT_ROUTES`. + +A **RoomId** is a branded string (`RoomId = string & { __brand }`) +derived per inbound request. In v0.2 the room is typically keyed +directly by its TargetId (`claude:proj-a` is both the attach id and +the Room id). The legacy fallback still applies when no target is +supplied — see precedence table below. + +TargetIds must pass `[a-z0-9_-]+` on each side of the `:` — +validated by [`parseTarget`](../../src/shared/target-id.ts) at every +boundary that accepts one. Invalid targets are rejected at the +source instead of silently routing to `default`. + +| Precedence | Source | Notes | +|------------|----------------------------------------|----------------------------------------------------------------------------------------------------------| +| 1 | ACP `--target kind:id` flag | Sets the Room key on `acp_turn_start`; unattached targets return `acp_turn_error` | +| 2 | A2A `contextRoutes[contextId]` | Operator map (`A2A_BRIDGE_CONTEXT_ROUTES` env var); unmapped contexts fall back to `claude:default` | +| 3 | A2A `Message.contextId` (no routes) | v0.1 compat — each distinct `contextId` is its own Room | +| 4 | `A2A_BRIDGE_ROOM` env var | CLI-style callers without a contextId | +| 5 | literal `"default"` | Single-CC fallback — matches pre-Phase-4 behavior | + +Empty or whitespace values are treated as absent. Clients that want +a stable room across calls either thread the same `contextId` each +request, export `A2A_BRIDGE_ROOM`, or — recommended on v0.2 — pick an +explicit TargetId. + +## CC attach and TargetId derivation + +`a2a-bridge claude` announces its TargetId to the daemon on the +control-plane WebSocket via `claude_connect { target }`. The id is +derived by [`resolveClaudeTarget`](../../src/shared/workspace-id.ts): + +1. `A2A_BRIDGE_WORKSPACE_ID` env var (explicit override) +2. Basename of `A2A_BRIDGE_STATE_DIR` (`~/.config/a2a-bridge/proj-a` → `proj-a`) +3. First 8 chars of the CC conversation id +4. Fallback: `default` + +The derived id is sanitised against `[a-z0-9_-]+` and prefixed with +`claude:` to produce the TargetId. A second `a2a-bridge claude` with +the same TargetId is **rejected** with a descriptive error; pass +`--force` (or set `A2A_BRIDGE_FORCE_ATTACH=1`) to kick the existing +attach and take over. + +## Inspection + +```bash +a2a-bridge daemon targets +``` + +Prints a table of every TargetId the daemon currently tracks, with +attach state, the WS connection id, and uptime since the attach +landed. Uses the `list_targets` control-plane RPC — no daemon +restart needed. ## Multi-session semantics @@ -103,13 +146,31 @@ any call sites. concurrent `getOrCreate` for the same id spins up a fresh Room rather than waiting on the in-flight teardown. +## Outbound reply routing (v0.2) + +CC's `reply` tool accepts an optional `target` field. Absent, the +reply routes back to the inbound turn's originator (v0.1 behaviour). +Present, the daemon delivers the reply to that target's Room +instead — handy for handing a conversation off between CCs or from a +CC to Codex: + +``` +reply({ text: "over to you", target: "codex:default" }) +``` + +Unknown or unattached targets surface a descriptive error through +the tool's error channel; replies cannot loop back to the sender. + ## Related reading - [`architecture.md`](../design/architecture.md) — the authoritative directory layout and the rules `lint:deps` enforces between `inbound/`, `peers/`, `rooms/`, `shared/`, `messages/`. +- [`multi-target-routing.md`](../design/multi-target-routing.md) — + the v0.2 `kind:id` model: who supplies which id, conflict policy, + deployment shapes. - [`roadmap.md`](../design/roadmap.md) — Phase 4 is where the room - abstraction was introduced; outbound peer adapters (per-Room - ownership of OpenClaw/Hermes) move to v0.2. + abstraction was introduced; Phase 10 (v0.2) added multi-target + routing on the claude axis; codex peer-id lands in v0.3. - [`cookbook.md`](./cookbook.md) — parallel-work pattern uses distinct `contextId`s so each branch gets its own Room. diff --git a/docs/join.md b/docs/join.md index 36e4e7a..2f0c110 100644 --- a/docs/join.md +++ b/docs/join.md @@ -1,11 +1,11 @@ # Join the a2a-bridge -> **Read me and follow the steps for your host.** The same document +> **Read me and follow the steps for your host.** The same document > covers both sides of the bridge — when a user hands this URL to > Claude Code **and** to OpenClaw (or Zed, or the VS Code ACP > extension), each AI self-installs its side and the two can talk. -This skill installs the a2a-bridge v0.1.0 tarball on whichever +This skill installs the current `a2a-bridge` npm package on whichever machine you (the host AI) are running on, wires up the matching config, and verifies the round-trip works end-to-end. @@ -16,17 +16,18 @@ config, and verifies the round-trip works end-to-end. Before doing anything else, tell the user which host you are: - If you are **Claude Code** (the CLI, desktop app, or IDE extension - from Anthropic), say so and jump to [**Claude Code side**](#claude-code-side). + from Anthropic), say so and jump to + [**Claude Code side**](#claude-code-side). - If you are **OpenClaw** (or Zed, VS Code ACP extension, or any other ACP-speaking editor that launches agents via `acpx` / `agent_servers` / similar), say so and jump to [**ACP client side**](#acp-client-side). - If neither — stop and ask the user which side they want you to - install. Do not guess. + install. Do not guess. -Only one side runs per host. The user is expected to run this skill +Only one side runs per host. The user is expected to run this skill twice — once on the Claude Code machine, once on the ACP-client -machine. If both machines are the same box, the order is: Claude +machine. If both machines are the same box, the order is: Claude Code side first (so the daemon is live), then ACP client side. --- @@ -34,7 +35,7 @@ Code side first (so the daemon is live), then ACP client side. ## Claude Code side On this side you install the tarball, generate a bearer token, and -start the daemon. You then report the control port and (optionally) +start the daemon. You then report the control port and (optionally) the bearer token to the user so they can paste it on the ACP side. ### 1. Install @@ -43,15 +44,14 @@ the bearer token to the user so they can paste it on the ACP side. npm install -g a2a-bridge@latest ``` -Verify the install: +Verify: ```bash -a2a-bridge --version +a2a-bridge --version # prints `a2a-bridge v` ``` -Expected output: `a2a-bridge v0.1.0`. If the command is not found, -check that your npm global bin directory (`npm config get prefix`) -is on `PATH`. +If the command is not found, check that your npm global bin +directory (`npm config get prefix`) is on `PATH`. ### 2. First-time configure @@ -59,12 +59,14 @@ is on `PATH`. a2a-bridge init ``` -This is idempotent — re-running prints the existing config instead -of overwriting it. The output includes: +Idempotent — re-running prints the existing config instead of +overwriting it. Output includes: - A freshly-minted 32-byte hex bearer token. - Copy-paste config snippets for Gemini CLI, OpenClaw, and Zed. -- The state-dir path (default: `~/.config/a2a-bridge/`). +- The state-dir path (default: `~/.local/state/a2a-bridge/`). + +Pass `--force` to rotate the bearer token if you need to. ### 3. Start the daemon @@ -72,33 +74,59 @@ of overwriting it. The output includes: a2a-bridge daemon start ``` -Then check it is healthy: +Then check: ```bash a2a-bridge daemon status ``` Expected output names the pid, control port (default `4512`), and -A2A inbound port (default `4520`). If `start` reports a port -collision, set `A2A_BRIDGE_A2A_PORT=` in your environment -and re-run `init --force` to regenerate the config snippets. +A2A inbound port (default `4520`). + +**Remote deployment?** If the ACP client is on a *different* machine, +bind the control plane to all interfaces before starting: + +```bash +export A2A_BRIDGE_CONTROL_HOST=0.0.0.0 +export A2A_BRIDGE_A2A_HOST=0.0.0.0 +a2a-bridge daemon start +``` -### 4. Report back to the user +On the ACP side, clients connect with `--url ws://:4512/ws` +(see ACP client side below). Put a TLS proxy in front if the link +leaves your trust boundary — the daemon terminates plaintext WS. + +If `start` reports a port collision, set +`A2A_BRIDGE_A2A_PORT=` in your environment and re-run +`init --force` to regenerate the config snippets. + +### 4. (Optional) Launch Claude Code with the bridge plugin + +```bash +a2a-bridge claude +``` + +Starts Claude Code with the a2a-bridge plugin auto-loaded. Keep the +window open for as long as you want to be reachable. If the daemon +isn't running yet, `a2a-bridge claude` bootstraps it. + +### 5. Report back to the user Tell the user, verbatim, so they can hand it to the ACP side: ``` Claude Code side ready. - control port: + server ip: + control port: bearer token: ``` -The control port is what `a2a-bridge acp` will connect to. The +The control port is what `a2a-bridge acp` will connect to. The bearer token is only needed for A2A HTTP callers (Gemini CLI etc.); -ACP clients connecting via stdio inherit filesystem trust and do -not need it. +ACP clients connecting via stdio or WS inherit filesystem / network +trust and don't need it. -### 5. Leave the daemon running +### 6. Leave the daemon running Do **not** kill the daemon after this skill finishes — the ACP side needs a live daemon to route turns into your Claude Code session. @@ -117,34 +145,45 @@ through to verify the real CC path works. ```bash npm install -g a2a-bridge@latest -``` - -Verify: - -```bash a2a-bridge --version ``` -Expected output: `a2a-bridge v0.1.0` or later. - ### 2. Register the ACP agent The config depends on which client you are and whether the Claude Code daemon is on the **same machine** or a **remote server**. -**Same machine** (daemon on localhost — no extra env vars needed): - -- **OpenClaw (`acpx`)** — `~/.acpx/config.json`: - - ```json - { - "agents": { - "a2a-bridge": { - "command": "a2a-bridge acp" - } - } - } - ``` +#### Same machine (daemon on localhost) + +- **OpenClaw** — edit `openclaw.json` (two places): + + 1. Add `a2a-bridge` to `acp.allowedAgents`: + ```json + "acp": { + "allowedAgents": ["claude", "codex", "a2a-bridge"] + } + ``` + + 2. Register the command under `plugins.entries.acpx.config.agents`: + ```json + "plugins": { + "entries": { + "acpx": { + "enabled": true, + "config": { + "agents": { + "a2a-bridge": { + "command": "a2a-bridge", + "args": ["acp"] + } + } + } + } + } + } + ``` + + Restart OpenClaw, then `/acp spawn a2a-bridge`. - **Zed** — `~/.config/zed/settings.json`: @@ -159,22 +198,41 @@ Code daemon is on the **same machine** or a **remote server**. } ``` -**Remote server** (daemon on a different machine at ``): +#### Remote server (daemon on a different machine at ``) -- **OpenClaw (`acpx`)** — env vars go inline in the command string - (see [OpenClaw ACP docs](https://docs.openclaw.ai/cli/acp)): +- **OpenClaw** — same two places, with an explicit `command` path + and `--url` in `args`: ```json - { - "agents": { - "a2a-bridge": { - "command": "env A2A_BRIDGE_CONTROL_URL=ws://:4512/ws a2a-bridge acp" + "acp": { + "allowedAgents": ["claude", "codex", "a2a-bridge"] + }, + "plugins": { + "entries": { + "acpx": { + "config": { + "agents": { + "a2a-bridge": { + "command": "a2a-bridge", + "args": [ + "acp", + "--url", "ws://:4512/ws" + ] + } + } + } } } } ``` - Replace `` with the Claude Code machine's IP from Step 1. + > Some acpx builds don't inherit the shell `PATH` when they spawn. + > If `/acp spawn a2a-bridge` fails with a vague + > `ACP_SESSION_INIT_FAILED`, replace `"command": "a2a-bridge"` + > with the absolute path from `which a2a-bridge`. + + Replace `` with the Claude Code machine's IP from the + CC side's step 5. Then `/acp spawn a2a-bridge`. - **Zed** — supports an `env` field: @@ -193,7 +251,7 @@ Code daemon is on the **same machine** or a **remote server**. ``` - **VS Code ACP extension** — settings JSON path depends on the - extension. Use: + extension. Use: ```json { @@ -201,7 +259,7 @@ Code daemon is on the **same machine** or a **remote server**. { "name": "a2a-bridge", "command": "a2a-bridge", - "args": ["acp"] + "args": ["acp", "--url", "ws://:4512/ws"] } ] } @@ -212,9 +270,9 @@ If the config file already has other agents, keep them; append ### 3. Restart the ACP client -Most ACP clients re-read their agent config at startup. Restart -the client (or run the client's "Reload agents" action if it has -one). Confirm that `a2a-bridge` appears in the agent picker. +Most ACP clients re-read their agent config at startup. Restart the +client (or run the client's "Reload agents" action if it has one). +Confirm that `a2a-bridge` appears in the agent picker. ### 4. Smoke-test the bridge @@ -227,20 +285,11 @@ Then assert: - You receive a reply. - The reply text does **not** start with `Echo:` or contain - `a2a-bridge ACP inbound: no ClaudeCodeGateway configured`. Either - indicates the subprocess has no live daemon to talk to. + `a2a-bridge ACP inbound: no ClaudeCodeGateway configured`. + Either indicates the subprocess has no live daemon to talk to. - The reply reads like something Claude Code would actually say (not a canned template). -If the reply is wrong or you get an `error: / fix:` block: - -- `error: a2a-bridge acp cannot reach the daemon at …` — - the Claude Code side did not finish step 3. Ask the user to - run `a2a-bridge daemon status` on the CC side. -- `ECONNREFUSED` or similar — the daemon was started but the ACP - side is pointed at the wrong control port. Check that the user - did not override `A2A_BRIDGE_CONTROL_PORT` on either side. - ### 5. Report back to the user Once the smoke prompt returns a real reply, tell the user: @@ -251,32 +300,134 @@ ACP client side ready. first prompt reply: "" ``` -The user can now drive real prompts through the ACP client normally; -a2a-bridge transparently relays them. +The user can now drive real prompts through the ACP client; the +bridge transparently relays them. + +--- + +## Advanced — multiple Claude Code workspaces (v0.2) + +One daemon can front **multiple** Claude Code sessions simultaneously. +Each session attaches under a `kind:id` **TargetId** (e.g. +`claude:proj-a`, `claude:proj-b`), and ACP callers pick which one +they want via `--target`. + +### CC side — one `a2a-bridge claude` per workspace + +Give each workspace a distinct state-dir; the directory's basename +becomes its TargetId id: + +```bash +# Terminal 1 — project A +A2A_BRIDGE_STATE_DIR=~/.config/a2a-bridge/proj-a a2a-bridge claude +# → attaches as claude:proj-a + +# Terminal 2 — project B +A2A_BRIDGE_STATE_DIR=~/.config/a2a-bridge/proj-b a2a-bridge claude +# → attaches as claude:proj-b +``` + +Inspect: + +```bash +a2a-bridge daemon targets +# TARGET ATTACHED CLIENT UPTIME +# claude:proj-a yes 3 2m +# claude:proj-b yes 5 1m +``` + +If a second attach collides on an already-held TargetId, the daemon +**rejects** it with a descriptive error. Re-run the colliding +`a2a-bridge claude` with `--force` (or set +`A2A_BRIDGE_FORCE_ATTACH=1` in its env) to kick the previous attach +and take over. The evicted session gets a CC-visible notification +that it was replaced. + +### ACP side — one registration per target + +Add one `acpx` agent entry per target: + +```json +{ + "acp": { + "allowedAgents": ["claude", "codex", "bridge-proj-a", "bridge-proj-b"] + }, + "plugins": { + "entries": { + "acpx": { + "enabled": true, + "config": { + "agents": { + "bridge-proj-a": { + "command": "a2a-bridge", + "args": [ + "acp", + "--url", "ws://:4512/ws", + "--target", "claude:proj-a" + ] + }, + "bridge-proj-b": { + "command": "a2a-bridge", + "args": [ + "acp", + "--url", "ws://:4512/ws", + "--target", "claude:proj-b" + ] + } + } + } + } + } + } +} +``` + +Use `/acp spawn bridge-proj-a` / `/acp spawn bridge-proj-b` and +each one routes to its own CC — no cross-talk. + +For the full design, deployment shapes, and A2A `contextRoutes` +configuration, see +[`docs/design/multi-target-routing.md`](./design/multi-target-routing.md). --- ## Troubleshooting -- **The install command fails with a 404.** The v0.1.0 draft - release may still be pending publication. Ask the user to - confirm the release is **published** (not just drafted) at - ; - draft assets are not reachable without authentication. +- **The install command fails with a 404.** The release may still + be draft. Ask the user to confirm the release is **published** + (not just drafted) at + ; draft + assets are not reachable without authentication. -- **`a2a-bridge doctor` reports `FAIL` on a required check.** Run +- **`a2a-bridge doctor` reports `FAIL` on a required check.** Run `a2a-bridge doctor` on whichever side is failing and follow the `fix:` hints — every required-check failure names the exact command or environment variable to set. -- **The smoke prompt times out.** Check `a2a-bridge daemon logs` - on the Claude Code side for the most recent turn. The daemon - log records each `acp_turn_start` / `chunk` / `complete`; if the - log shows `startTurn` but no reply, the attached Claude Code - session is the stuck party. - -- **Config already exists on first-run.** `a2a-bridge init` never - overwrites an existing token unless you pass `--force`. Tell the +- **`ACP_SESSION_INIT_FAILED: Failed to spawn agent command`.** + acpx couldn't exec `a2a-bridge`. Fix it by replacing + `"command": "a2a-bridge"` with the absolute path + (`which a2a-bridge`) and splitting flags into the `args` array + as shown above. + +- **`target claude: not attached`.** No CC with that TargetId + is currently connected. On the CC side, run `a2a-bridge daemon + targets` to see who's attached; on the ACP side, check the + `--target` value matches one of those rows. + +- **Reply comes back as `Echo: `.** The ACP subprocess + fell back to its echo executor, meaning the daemon routing + didn't land. Almost always means the daemon is unreachable + (check `curl http://:4512/healthz`). + +- **The smoke prompt times out.** Check `a2a-bridge daemon logs + --tail 50` on the Claude Code side. The daemon log records each + `acp_turn_start` / `chunk` / `complete`; if the log shows + `startTurn` but no reply, the attached Claude Code session is + the stuck party. + +- **Config already exists on first-run.** `a2a-bridge init` never + overwrites an existing token unless you pass `--force`. Tell the user: the previous token is fine to reuse, and they can print it again with `a2a-bridge init --print`. @@ -287,10 +438,10 @@ a2a-bridge transparently relays them. - It does **not** configure the A2A HTTP inbound (Gemini CLI `remoteAgents`) — `init` prints the snippet, but adding it to the Gemini CLI config is the user's call. -- It does **not** set up TLS or multi-machine daemons. The v0.1 - bridge assumes the CC daemon and the ACP client are on the same - host (unix socket / localhost WS). Cross-host deployment lands - in v0.2. +- It does **not** set up TLS. v0.2 supports cross-host deployment + via `A2A_BRIDGE_CONTROL_HOST=0.0.0.0`, but the daemon terminates + plaintext WebSocket. Put a TLS proxy in front if the link leaves + your trust boundary. - It does **not** run `npm publish` or submit the Claude Code marketplace / ACP registry packages — those are credentialed maintainer steps. diff --git a/docs/release/v0.2.0-github-release.md b/docs/release/v0.2.0-github-release.md new file mode 100644 index 0000000..69a2410 --- /dev/null +++ b/docs/release/v0.2.0-github-release.md @@ -0,0 +1,99 @@ +# a2a-bridge v0.2.0 — Multi-target routing + +One daemon can now front **multiple Claude Code workspaces** at +once. ACP and A2A callers pick which one they want via a +`kind:id` **TargetId**; single-CC deployments keep working without +any config change. + +## Highlights + +- **TargetId model.** Every attached agent is addressed as + `kind:id` (e.g. `claude:proj-a`, `codex:default`). Validated at + every boundary; no more silent fallback to `default`. +- **Plugin-side workspace id.** `a2a-bridge claude` announces its + TargetId on attach, derived from `A2A_BRIDGE_WORKSPACE_ID` → + `A2A_BRIDGE_STATE_DIR` basename → conversation id → `default`. + Two CC sessions with different state-dirs attach as distinct + targets with no extra config. +- **`a2a-bridge acp --target `.** ACP callers pick the + target. Missing flag keeps v0.1 behaviour. +- **A2A `contextId → TargetId` routing.** Operator-supplied map + via `A2A_BRIDGE_CONTEXT_ROUTES` (JSON env var). Unmapped + contexts fall back to `claude:default`. Validated at startup. +- **Outbound reply targeting.** CC's `reply` tool gains an + optional `target` field. Present → daemon forwards to that + Room; absent → today's behaviour. +- **Attach conflict policy.** Two CCs claiming the same TargetId: + the second is rejected with a descriptive error naming the + incumbent. Re-run with `a2a-bridge claude --force` (or + `A2A_BRIDGE_FORCE_ATTACH=1`) to take over — the evicted session + receives a CC-visible notification. +- **`a2a-bridge daemon targets`.** New inspection subcommand. + Prints a plain-text table of every TargetId the daemon tracks, + with attach state, the WS connection id, and uptime since the + attach landed. +- **Per-target inbound gateway.** Inbound A2A/ACP text for + `claude:ws-a` lands only on CC-A's socket, and CC-A's reply can + only close CC-A's in-flight turn — no cross-target leakage. +- **`session/load` compatibility.** Advertised + implemented as a + stateless no-op so OpenClaw acpx's "persistent session" mode + can respawn the subprocess without losing its prompt. + +## Backward compatibility + +All additions are **opt-in**. v0.1 plugins / subprocesses work +unchanged against a v0.2 daemon and vice versa. Callers that +don't supply `--target` / `contextRoutes` route to +`claude:default`, matching today's behaviour. + +## Deferred to v0.3 + +- **Codex peer-id routing** (`a2a-bridge codex --id `). Unlike + claude, codex is a daemon-internal adapter; multi-instancing + requires a per-id peer registry + port allocation refactor that + didn't fit v0.2. Codex remains `codex:default` (single + instance). +- Hot-reload of `contextRoutes` (startup-time only today). +- Dynamic target discovery on the ACP side. + +## Control-plane wire additions (for embedders) + +- `claude_connect` grows `{ target?: string; force?: boolean }`. +- New server → plugin frames: + - `claude_connect_rejected { target, reason }` + - `claude_connect_replaced { target }` +- `acp_turn_start` grows `{ target?: string }`. +- `claude_to_codex` grows `{ target?: string }`. +- New RPC pair: `list_targets { requestId }` → + `targets_response { requestId, targets: TargetEntry[] }`. + +## Install + +```bash +npm install -g a2a-bridge@0.2.0 +``` + +## Upgrade notes + +- Nothing breaks if you weren't using `--target` / + `contextRoutes`. +- If you had scripts greping `a2a-bridge daemon targets` for + `claude:default`, they now only match when a v0.1-style attach + (no explicit target) is actually live. +- ACP clients running in "persistent session" mode (OpenClaw + acpx) that previously failed on `session/load` will start + working without further config changes. + +## Docs + +- [Multi-target routing design](../../docs/design/multi-target-routing.md) +- [Rooms guide](../../docs/guides/rooms.md) — updated TargetId + precedence table and outbound reply section +- [Join skill](../../docs/join.md) — updated with the + multi-workspace advanced section +- [Full changelog](../../CHANGELOG.md#020--2026-04-16) + +--- + +**Full changelog:** see [`CHANGELOG.md`](../../CHANGELOG.md). +**Previous release:** [v0.1.0](https://github.com/firstintent/a2a-bridge/releases/tag/v0.1.0). diff --git a/docs/release/v0.2.0-openclaw-test.md b/docs/release/v0.2.0-openclaw-test.md new file mode 100644 index 0000000..6d45fda --- /dev/null +++ b/docs/release/v0.2.0-openclaw-test.md @@ -0,0 +1,183 @@ +# v0.2.0 OpenClaw 端测试提示词 + +把下面这段贴给 OpenClaw 里的 AI(或自己照着做),配合服务器端已挂好的 +两个 stub CC(`claude:proj-a` / `claude:proj-b`)验证 Phase 10 的 +multi-target 路由。服务器端的准备见 `v0.2.0-testing.md`。 + +占位符: +- `` → `172.17.191.253`(同内网)或 `146.174.131.159`(公网), + 选 OpenClaw 那台能 ping 通的那个。 +- 测试包路径按实际 `scp` 的位置改(下文以 `/tmp/a2a-bridge-0.1.1.tgz` + 为例)。 + +--- + +## 提示词(复制下面到 OpenClaw AI) + +任务:在本机接入 a2a-bridge(v0.2 Phase 10 测试版),通过它远程调用 +另一台服务器上的两个 Claude Code 工作区,验证 multi-target 路由。 + +### 背景 + +- 服务器在 ``,a2a-bridge daemon 监听控制端口 4512 + (WebSocket)和 A2A HTTP 端口 4520,已绑定 `0.0.0.0`。 +- 服务器上已挂载两个 CC 实例,TargetId 分别为 `claude:proj-a` 和 + `claude:proj-b`(当前挂的是 stub echo,会给每条消息加前缀 + `[stub-proj-a] echo: ...` / `[stub-proj-b] echo: ...` 回复,用来 + 验证路由)。 +- 此测试版 npm latest(v0.1.2)不认识 `--target`,必须用服务器发来 + 的 tgz 安装。 + +### 第 1 步:安装测试包 + +先从服务器拿到 tgz(用户会 `scp` 给你或者告诉你路径,假设落在 +`/tmp/a2a-bridge-0.1.1.tgz`)。然后: + +```bash +npm uninstall -g a2a-bridge 2>/dev/null +npm i -g /tmp/a2a-bridge-0.1.1.tgz +a2a-bridge --version # 显示 0.1.1(但里面是 Phase 10 代码,不要困惑) +``` + +验证 Phase 10 特性存在: + +```bash +a2a-bridge daemon --help 2>&1 | grep -E "targets" +a2a-bridge acp --help 2>&1 | grep -E "\-\-target" +``` + +两条都应该有输出。没有说明装错了版本,停下来反馈。 + +### 第 2 步:连通性检查 + +```bash +curl -sf http://:4512/healthz && echo OK +``` + +期望:`ok` 后跟 `OK`。失败说明 IP 或端口不对、或防火墙没放开,让 +用户确认。 + +### 第 3 步:注册两个 acpx agent + +编辑 `openclaw.json`。添加(或合并到已有的 `acp` / `plugins` 节点): + +```json +{ + "acp": { + "allowedAgents": ["claude", "codex", "bridge-proj-a", "bridge-proj-b"] + }, + "plugins": { + "entries": { + "acpx": { + "enabled": true, + "config": { + "agents": { + "bridge-proj-a": { + "command": "a2a-bridge acp --url ws://:4512/ws --target claude:proj-a" + }, + "bridge-proj-b": { + "command": "a2a-bridge acp --url ws://:4512/ws --target claude:proj-b" + } + } + } + } + } + } +} +``` + +如果已有其它 agent(claude、codex 等),保留它们,只追加这两行。 + +### 第 4 步:重启 OpenClaw,确认 agent 注册 + +重启后 agent 选择器里应该能看到 `bridge-proj-a` 和 `bridge-proj-b`。 + +### 第 5 步:分别冒烟 + +先单独测 proj-a: + +``` +/acp spawn bridge-proj-a +prompt: "ping proj-a" +期望返回:[stub-proj-a] echo: ping proj-a +``` + +再单独测 proj-b: + +``` +/acp spawn bridge-proj-b +prompt: "ping proj-b" +期望返回:[stub-proj-b] echo: ping proj-b +``` + +**关键校验**:返回的前缀必须和目标一致 —— 不能出现 `bridge-proj-a` +拿到 `stub-proj-b` 的响应。 + +### 第 6 步:并发验证(multi-target 隔离测试) + +两个 agent 同时各发一条不同内容的 prompt: + +``` +bridge-proj-a ← "alpha" +bridge-proj-b ← "beta" +``` + +期望: + +``` +bridge-proj-a 返回 [stub-proj-a] echo: alpha +bridge-proj-b 返回 [stub-proj-b] echo: beta +``` + +两条响应前缀互不串扰。这是 **P10.10 的核心验收点**。 + +### 第 7 步:回报 + +告诉用户: + +- 版本号是否 0.1.1 且 `--target` / `daemon targets` 都认 +- `healthz` 通不通 +- 两个 `bridge-proj-*` 是否都能单独响应、前缀正确 +- 并发时是否出现交叉响应 + +### 故障排查 + +- `bridge-proj-a` 启动失败 / 立刻断开 —— 多半是 ws 连不上服务器。 + 核对 ``、端口 4512 是否通(见第 2 步)。 +- 返回 `target claude:proj-a not attached` —— 服务器那边 stub CC + 没挂上,让用户在服务器查 `a2a-bridge daemon targets`。 +- 返回 `Echo: ping proj-a` 而不是 `[stub-proj-a] echo: ...` —— + 路由回退到了本地 echo executor,说明 daemon 没接到 target; + 同样先查服务器侧的 `daemon targets`。 +- 响应前缀串了(a 拿到 b 的) —— 这是 bug,立刻停下来记录触发条件 + 并反馈,这正是 P10 要防的问题。 + +不要修改访问控制、不要自动 accept 配对请求、不要 publish —— +这些都是 maintainer 手动决定的事。 + +--- + +## 服务器端辅助命令(调试时备用) + +```bash +# 看当前挂了哪些 target +a2a-bridge daemon targets + +# 看最近的日志 +a2a-bridge daemon logs --tail 50 + +# 看某个 stub CC 的 echo 输出 +tmux attach -t cc-proj-a # Ctrl-b d 脱离 +tmux attach -t cc-proj-b + +# 看 daemon 自身 +tmux attach -t a2a-daemon +``` + +收工: + +```bash +tmux kill-session -t cc-proj-a +tmux kill-session -t cc-proj-b +tmux kill-session -t a2a-daemon +``` diff --git a/docs/release/v0.2.0-testing.md b/docs/release/v0.2.0-testing.md new file mode 100644 index 0000000..ebe26d8 --- /dev/null +++ b/docs/release/v0.2.0-testing.md @@ -0,0 +1,242 @@ +# v0.2.0 pre-release testing checklist + +Ten hands-on checks covering every Phase 10 capability. Run them +locally before bumping the version and cutting the release. Each +step lists the command, the expected observable, and the pass/fail +line. Stop at the first failure — do not publish. + +Scope note: codex peer-id routing (P10.9) is **deferred to v0.3** — +skip any test involving `a2a-bridge codex --id `. `codex` +itself still works in single-instance mode (`codex:default`). + +## 0. Build and install from source + +```bash +cd /home/ubuntu/robtg/a2a-bridge +git checkout dev && git pull origin dev +bun install +bun run check:ci +``` + +Expected: all four check:ci stages green (tsc, depcruise, tests, +smoke tarball + smoke-e2e). `455 pass 0 fail`. + +Then install the freshly-packed tarball into a scratch directory +so the global npm install of `a2a-bridge@0.1.x` stays untouched +while you test: + +```bash +npm pack +mkdir -p /tmp/a2a-v020-test && cd /tmp/a2a-v020-test +npm init -y >/dev/null +npm install /home/ubuntu/robtg/a2a-bridge/a2a-bridge-*.tgz +./node_modules/.bin/a2a-bridge --version +``` + +Expected: `a2a-bridge v0.1.1` (the CHANGELOG is drafted as 0.2.0 +but the bump hasn't landed yet — that's step after this checklist). + +Keep two terminals open. `TEST_DIR=/tmp/a2a-v020-test` below. + +## 1. Backward compat: v0.1 single-CC flow still works + +```bash +# Terminal A — fresh daemon +A2A_BRIDGE_STATE_DIR=/tmp/a2a-v020-test/state-single \ + $TEST_DIR/node_modules/.bin/a2a-bridge daemon start + +$TEST_DIR/node_modules/.bin/a2a-bridge daemon status +``` + +Expected: `daemon status` prints pid, control port 4512, A2A port +4520. No errors about multi-target. Leave it running. + +```bash +# Terminal B — attach a single CC without any target env vars +$TEST_DIR/node_modules/.bin/a2a-bridge claude --help | head -5 +``` + +(You don't need a real Claude Code session for this checklist — +we're validating the wiring, not human-in-the-loop UX. The stub +CC pattern via `DaemonClient` already covers the CC side in the +test suite.) + +Pass: daemon is live, no startup errors, `daemon status` shows a +single pid, `daemon logs` shows no `force` / `target` rejections. + +## 2. `daemon targets` subcommand (P10.5) + +```bash +$TEST_DIR/node_modules/.bin/a2a-bridge daemon targets +``` + +With no CC attached yet, expected output is +`no targets registered`. + +In a second shell, simulate an attach by running the existing +conflict-test subprocess (it attaches a stub CC as `claude:ws-a`): + +```bash +cd /home/ubuntu/robtg/a2a-bridge +bun test src/cli/claude-conflict.test.ts 2>&1 | tail -10 +``` + +Expected: `2 pass 0 fail`. + +## 3. Multi-workspace attach + conflict reject (P10.2, P10.3, P10.6) + +```bash +cd /home/ubuntu/robtg/a2a-bridge +bun test src/runtime-plugin/daemon-client/daemon-client.test.ts +bun test src/cli/claude-conflict.test.ts +``` + +Expected: +- `daemon-client.test.ts` — new tests prove `attachClaude(t, true)` + serializes `force: true`; `connectRejected` / `connectReplaced` + events fire on incoming frames. +- `claude-conflict.test.ts` — `2 pass 0 fail`: + - second attach without `force` receives `connectRejected` + - second attach with `force: true` evicts the first, which sees + `connectReplaced` and a socket close. + +## 4. `a2a-bridge claude --force` CLI flag (P10.6) + +```bash +cd /home/ubuntu/robtg/a2a-bridge +bun test src/cli/claude-flags.test.ts +``` + +Expected: `4 pass 0 fail`. The parser strips `--force`, preserves +argv order, rejects partial matches like `--forceful`. + +Manual sanity: + +```bash +$TEST_DIR/node_modules/.bin/a2a-bridge claude --force 2>&1 | head -3 +``` + +Should NOT fail with "unrecognized option". The CLI strips the +flag and forwards the remaining argv to the native `claude` +binary; if you don't have Claude Code installed, the error you +get is about missing `claude`, not about `--force`. + +## 5. ACP `--target` flag (P10.4) + +```bash +cd /home/ubuntu/robtg/a2a-bridge +bun test src/cli/acp-cli.test.ts src/runtime-daemon/inbound/acp/turn-handler.test.ts +``` + +Expected: +- `acp-cli.test.ts` — `--target`, `-t`, and `--target=` all parse; + invalid target exits 1 with a descriptive error. +- `turn-handler.test.ts` — `19 pass`: unattached target → + `acp_turn_error`; attached target → turn forwards. + +## 6. A2A `contextId → TargetId` routing (P10.7) + +```bash +cd /home/ubuntu/robtg/a2a-bridge +bun test src/runtime-daemon/inbound/a2a-http/server.test.ts +``` + +Expected: the three P10.7 cases pass — mapped contextId routes to +its TargetId, unmapped falls back to `claude:default`, malformed +TargetId in the config is a startup error. + +Operator sanity: with a daemon running, set the env var and +verify via `daemon logs`: + +```bash +A2A_BRIDGE_CONTEXT_ROUTES='{"ctx-smoke":"claude:default"}' \ + $TEST_DIR/node_modules/.bin/a2a-bridge daemon start +``` + +The daemon should start without logging an `ignored` line — if +you see `A2A_BRIDGE_CONTEXT_ROUTES ignored`, the JSON was +malformed. + +## 7. Outbound `reply` tool target (P10.8) + +```bash +cd /home/ubuntu/robtg/a2a-bridge +bun test src/cli/reply-target.test.ts +``` + +Expected: `2 pass 0 fail`. +- **forward**: CC-A replies with `target: claude:ws-b`; CC-B + receives the text. +- **unknown target**: descriptive error. +- **omit target**: falls through to the legacy codex path (errors + with "Codex is not ready" since the test daemon has no Codex + adapter attached — proves the absent-target path is unchanged). +- **malformed target**: error. + +## 8. Cross-target isolation — the big one (P10.10) + +This is the Phase 10 acceptance test. Two ACP subprocesses, two +stub CCs, ensures no cross-talk. + +```bash +cd /home/ubuntu/robtg/a2a-bridge +bun test src/cli/multi-target.test.ts +``` + +Expected: `1 pass 0 fail`. Subprocess A sees only `CC-A:` prefixed +chunks; subprocess B sees only `CC-B:`; no fallback to the echo +executor. + +## 9. Full check:ci one more time + +```bash +cd /home/ubuntu/robtg/a2a-bridge +bun run check:ci +``` + +Expected: `455 pass 0 fail`, smoke tarball OK, smoke-e2e OK. + +## 10. Docs review + +Quick eyeball pass on the updated docs: + +- [`README.md`](../../README.md) — "Multi-workspace routing + (v0.2)" section + env vars table has `WORKSPACE_ID`, + `FORCE_ATTACH`, `CONTEXT_ROUTES`. +- [`docs/design/multi-target-routing.md`](../design/multi-target-routing.md) + — status line says "implemented in v0.2.0". +- [`docs/guides/rooms.md`](../guides/rooms.md) — TargetId + precedence table + outbound reply routing section present. +- [`docs/join.md`](../join.md) — "Multi-workspace (v0.2, + optional)" section at the bottom. +- [`CHANGELOG.md`](../../CHANGELOG.md) — `[0.2.0]` block drafted + with Added / Changed / Deferred / Wire additions. + +## After the checklist — version bump + publish + +Once every step above passes: + +1. Bump `package.json`, `plugins/a2a-bridge/plugin.json`, and + `plugins/marketplace.json` from `0.1.1` to `0.2.0`. +2. Change the CHANGELOG header from `## [0.2.0] — unreleased` + to `## [0.2.0] — `. +3. Follow [`publish.md`](./publish.md) from step 1 (bump check) — + it covers `bun scripts/check-plugin-versions.js`, commit + tag + + push, GitHub Actions release workflow, npm 2FA OTP, and the + post-publish smoke. + +Per CLAUDE.md iron law: none of the bump / publish steps run +under the autonomous loop. + +## Rollback + +If publish lands but smoke fails: + +```bash +npm unpublish a2a-bridge@0.2.0 # must be within 72h +git tag -d v0.2.0 +git push origin :refs/tags/v0.2.0 +``` + +(Unpublish only works in the 72-hour window — after that, cut +`0.2.1` instead.) diff --git a/package.json b/package.json index 46a88ea..a482062 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "a2a-bridge", - "version": "0.1.2", + "version": "0.2.0", "description": "Bidirectional bridge between Claude Code and other AI coding agents (Codex, OpenClaw, Hermes, ...) over the MCP Channels protocol.", "type": "module", "bin": { diff --git a/plugins/a2a-bridge/.claude-plugin/plugin.json b/plugins/a2a-bridge/.claude-plugin/plugin.json index 107568f..d907b8c 100644 --- a/plugins/a2a-bridge/.claude-plugin/plugin.json +++ b/plugins/a2a-bridge/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "a2a-bridge", - "version": "0.1.2", + "version": "0.2.0", "description": "Bridge Claude Code with other AI coding agents (Codex, OpenClaw, Hermes, ...) through a shared daemon, push channel delivery, and bidirectional reply tooling.", "author": { "name": "FirstIntent" diff --git a/plugins/a2a-bridge/server/bridge-server.js b/plugins/a2a-bridge/server/bridge-server.js index 3e91bca..7cd1707 100755 --- a/plugins/a2a-bridge/server/bridge-server.js +++ b/plugins/a2a-bridge/server/bridge-server.js @@ -13666,7 +13666,7 @@ import { appendFileSync } from "fs"; // package.json var package_default = { name: "a2a-bridge", - version: "0.1.2", + version: "0.2.0", description: "Bidirectional bridge between Claude Code and other AI coding agents (Codex, OpenClaw, Hermes, ...) over the MCP Channels protocol.", type: "module", bin: { @@ -13737,39 +13737,25 @@ var PLUGIN_SERVER_INFO = { version: package_default.version }; var CLAUDE_INSTRUCTIONS = [ - "Codex is an AI coding agent (OpenAI) running in a separate session on the same machine.", - "", - "## Message delivery", - "Messages from Codex may arrive in two ways depending on the connection mode:", - '- As tags (push mode)', - "- Via the get_messages tool (pull mode)", - "", - "## Collaboration roles", - "Default roles in this setup:", - "- Claude: Reviewer, Planner, Hypothesis Challenger", - "- Codex: Implementer, Executor, Reproducer/Verifier", - "- Expect Codex to provide independent technical judgment and evidence, not passive agreement.", - "", - "## Thinking patterns (task-driven)", - "- Analytical/review tasks: Independent Analysis & Convergence", - "- Implementation tasks: Architect -> Builder -> Critic", - "- Debugging tasks: Hypothesis -> Experiment -> Interpretation", - "", - "## Collaboration language", - '- Use explicit phrases such as "My independent view is:", "I agree on:", "I disagree on:", and "Current consensus:".', + "a2a-bridge connects you to other AI agents (Codex, OpenClaw, Hermes, Gemini CLI, etc.).", + "Messages from connected agents arrive as tags. The `user` field tells you which agent sent it.", "", "## How to interact", - "- Use the reply tool to send messages back to Codex \u2014 pass chat_id back.", - "- Use the get_messages tool to check for pending messages from Codex.", + "- Use the reply tool to send messages back \u2014 pass chat_id back.", + "- Use the get_messages tool to check for pending messages.", "- After sending a reply, call get_messages to check for responses.", - "- When the user asks about Codex status or progress, call get_messages.", "", "## Turn coordination", - "- When you see '\u23F3 Codex is working', do NOT call the reply tool \u2014 wait for '\u2705 Codex finished'.", - "- After Codex finishes a turn, you have an attention window to review and respond before new messages arrive.", - "- If the reply tool returns a busy error, Codex is still executing \u2014 wait and try again later." + "- When you see '\u23F3 ... is working', do NOT call the reply tool \u2014 wait for '\u2705 ... finished'.", + "- If the reply tool returns a busy error, the peer agent is still executing \u2014 wait and try again later." ].join(` `); +var SOURCE_DISPLAY_NAMES = { + codex: "Codex", + acp: "ACP agent", + a2a: "A2A agent", + system: "system" +}; var LOG_FILE = "/tmp/a2a-bridge.log"; class ClaudeAdapter extends EventEmitter { @@ -13834,8 +13820,9 @@ class ClaudeAdapter extends EventEmitter { } } async pushViaChannel(message) { - const msgId = `codex_msg_${this.notificationIdPrefix}_${++this.notificationSeq}`; + const msgId = `bridge_msg_${this.notificationIdPrefix}_${++this.notificationSeq}`; const ts = new Date(message.timestamp).toISOString(); + const displayName = SOURCE_DISPLAY_NAMES[message.source] ?? message.source; try { await this.server.notification({ method: "notifications/claude/channel", @@ -13844,10 +13831,10 @@ class ClaudeAdapter extends EventEmitter { meta: { chat_id: this.sessionId, message_id: msgId, - user: "Codex", - user_id: "codex", + user: displayName, + user_id: message.source, ts, - source_type: "codex" + source_type: message.source } } }); @@ -13906,7 +13893,7 @@ ${formatted}` tools: [ { name: "reply", - description: "Send a message back to Codex. Your reply will be injected into the Codex session as a new user turn.", + description: "Send a message back to the connected agent. Your reply is routed to the originator of the inbound turn by default; pass `target` to send it to a different attached agent instead.", inputSchema: { type: "object", properties: { @@ -13916,11 +13903,15 @@ ${formatted}` }, text: { type: "string", - description: "The message to send to Codex." + description: "The message to send." }, require_reply: { type: "boolean", - description: "When true, Codex is required to send a reply. All Codex messages from this turn will be forwarded immediately (bypassing STATUS buffering). Use this when you need a direct answer from Codex." + description: "When true, the receiving agent is required to send a reply; all messages from its turn will be forwarded immediately (bypassing STATUS buffering). Use when you need a direct answer." + }, + target: { + type: "string", + description: "Optional `kind:id` TargetId (e.g. `claude:project-b`, `codex:default`) to override the default routing. Use this to hand a reply off to a different attached agent instead of the one that sent the inbound turn. Omit to route back to the inbound turn's originator (default)." } }, required: ["text"] @@ -13960,6 +13951,7 @@ ${formatted}` }; } const requireReply = args?.require_reply === true; + const targetArg = typeof args?.target === "string" ? args.target : undefined; const bridgeMsg = { id: args?.chat_id ?? `reply_${Date.now()}`, source: "claude", @@ -13973,7 +13965,7 @@ ${formatted}` isError: true }; } - const result = await this.replySender(bridgeMsg, requireReply); + const result = await this.replySender(bridgeMsg, requireReply, targetArg); if (!result.success) { this.log(`Reply delivery failed: ${result.error}`); return { @@ -13982,7 +13974,7 @@ ${formatted}` }; } const pending = this.pendingMessages.length; - let responseText = "Reply sent to Codex."; + let responseText = targetArg ? `Reply sent to ${targetArg}.` : "Reply sent to Codex."; if (pending > 0) { responseText += ` Note: ${pending} unread Codex message${pending > 1 ? "s" : ""} already waiting \u2014 call get_messages to read them.`; } @@ -14067,8 +14059,12 @@ class DaemonClient extends EventEmitter2 { }; }); } - attachClaude() { - this.send({ type: "claude_connect" }); + attachClaude(target, force = false) { + this.send({ + type: "claude_connect", + ...target ? { target } : {}, + ...force ? { force: true } : {} + }); } async disconnect() { if (!this.ws) @@ -14082,7 +14078,7 @@ class DaemonClient extends EventEmitter2 { this.ws = null; this.rejectPendingReplies("Daemon connection closed"); } - async sendReply(message, requireReply) { + async sendReply(message, requireReply, target) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return { success: false, error: "A2aBridge daemon is not connected." }; } @@ -14097,7 +14093,8 @@ class DaemonClient extends EventEmitter2 { type: "claude_to_codex", requestId, message, - ...requireReply ? { requireReply: true } : {} + ...requireReply ? { requireReply: true } : {}, + ...target ? { target } : {} }); }); } @@ -14126,6 +14123,15 @@ class DaemonClient extends EventEmitter2 { case "status": this.emit("status", message.status); return; + case "claude_connect_rejected": + this.emit("connectRejected", { + target: message.target, + reason: message.reason + }); + return; + case "claude_connect_replaced": + this.emit("connectReplaced", { target: message.target }); + return; } }; ws.onclose = (event) => { @@ -14606,6 +14612,82 @@ class ConfigService { } } +// src/shared/workspace-id.ts +import { basename } from "path"; + +// src/shared/target-id.ts +var DEFAULT_INSTANCE_ID = "default"; +var VALID_SEGMENT = /^[a-z0-9_-]+$/; +function parseTarget(input) { + if (typeof input !== "string" || input.length === 0) { + return { ok: false, error: "Target specifier must be a non-empty string" }; + } + const parts = input.split(":"); + if (parts.length > 2) { + return { + ok: false, + error: `Target "${input}" has multiple ':' separators; expected "kind" or "kind:id"` + }; + } + const kind = parts[0] ?? ""; + const id = parts.length === 2 ? parts[1] ?? "" : DEFAULT_INSTANCE_ID; + if (kind.length === 0) { + return { ok: false, error: `Target "${input}" has an empty kind` }; + } + if (!VALID_SEGMENT.test(kind)) { + return { + ok: false, + error: `Target kind "${kind}" contains characters outside [a-z0-9_-]` + }; + } + if (id.length === 0) { + return { ok: false, error: `Target "${input}" has an empty id` }; + } + if (!VALID_SEGMENT.test(id)) { + return { + ok: false, + error: `Target id "${id}" contains characters outside [a-z0-9_-]` + }; + } + return { + ok: true, + target: `${kind}:${id}`, + parts: { kind, id } + }; +} + +// src/shared/workspace-id.ts +function resolveWorkspaceId(opts = {}) { + const env = opts.env ?? process.env; + const override = env.A2A_BRIDGE_WORKSPACE_ID; + const overrideClean = override && sanitize(override); + if (overrideClean) + return overrideClean; + const stateDir = opts.stateDirPath ?? env.A2A_BRIDGE_STATE_DIR; + if (stateDir) { + const stateClean = sanitize(basename(stateDir)); + if (stateClean) + return stateClean; + } + if (opts.conversationId) { + const convClean = sanitize(opts.conversationId.slice(0, 8)); + if (convClean) + return convClean; + } + return DEFAULT_INSTANCE_ID; +} +function resolveClaudeTarget(opts = {}) { + const id = resolveWorkspaceId(opts); + const r = parseTarget(`claude:${id}`); + if (!r.ok) { + throw new Error(`resolveClaudeTarget produced invalid target: ${r.error}`); + } + return r.target; +} +function sanitize(value) { + return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); +} + // src/runtime-plugin/bridge.ts var stateDir = new StateDirResolver; var configService = new ConfigService; @@ -14615,6 +14697,8 @@ var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, var CONTROL_WS_URL = daemonLifecycle.controlWsUrl; var claude = new ClaudeAdapter; var daemonClient = new DaemonClient(CONTROL_WS_URL); +var CLAUDE_TARGET = resolveClaudeTarget({ stateDirPath: stateDir.dir }); +var FORCE_ATTACH = process.env.A2A_BRIDGE_FORCE_ATTACH === "1"; var shuttingDown = false; var daemonDisabled = false; var RECONNECT_NOTIFY_COOLDOWN_MS = 30000; @@ -14623,7 +14707,7 @@ var lastDisconnectNotifyTs = 0; var lastReconnectNotifyTs = 0; var disabledRecoveryTimer = null; var disabledRecoveryInFlight = false; -claude.setReplySender(async (msg, requireReply) => { +claude.setReplySender(async (msg, requireReply, target) => { if (msg.source !== "claude") { return { success: false, error: "Invalid message source" }; } @@ -14633,7 +14717,7 @@ claude.setReplySender(async (msg, requireReply) => { error: "A2aBridge is disabled by `a2a-bridge kill`. Restart Claude Code (`a2a-bridge claude`), switch to a new conversation, or run `/resume` to reconnect." }; } - return daemonClient.sendReply(msg, requireReply); + return daemonClient.sendReply(msg, requireReply, target); }); daemonClient.on("codexMessage", (message) => { log(`Forwarding daemon \u2192 Claude (${message.content.length} chars)`); @@ -14642,6 +14726,12 @@ daemonClient.on("codexMessage", (message) => { daemonClient.on("status", (status) => { log(`Daemon status: ready=${status.bridgeReady} tui=${status.tuiConnected} thread=${status.threadId ?? "none"} queued=${status.queuedMessageCount}`); }); +daemonClient.on("connectRejected", ({ target, reason }) => { + enterDisabledState(`claude_connect rejected for ${target}: ${reason}`, `\u26D4 A2aBridge attach rejected for target ${target}. ${reason}`); +}); +daemonClient.on("connectReplaced", ({ target }) => { + enterDisabledState(`claude_connect replaced on ${target} \u2014 another CC took over with --force`, `\u26D4 A2aBridge attach for ${target} was replaced by another CC (--force). This bridge is now idle.`); +}); daemonClient.on("disconnect", () => { if (shuttingDown || daemonDisabled) return; @@ -14671,7 +14761,7 @@ async function connectToDaemon(isReconnect = false) { try { await daemonLifecycle.ensureRunning(); await daemonClient.connect(); - daemonClient.attachClaude(); + daemonClient.attachClaude(CLAUDE_TARGET, FORCE_ATTACH); if (!isReconnect) { claude.pushNotification(systemMessage("system_bridge_ready", "\u2705 A2aBridge bridge is ready. Daemon connected. ACP clients can now send prompts.")); } @@ -14771,7 +14861,7 @@ async function pollDisabledRecovery() { log("Disabled-state recovery conditions met \u2014 attempting direct daemon reconnect"); try { await daemonClient.connect(); - daemonClient.attachClaude(); + daemonClient.attachClaude(CLAUDE_TARGET); daemonDisabled = false; stopDisabledRecoveryPoller(); claude.pushNotification(systemMessage("system_bridge_recovered", "\u2705 A2aBridge recovered after the killed sentinel was cleared. Daemon reconnected.")); diff --git a/plugins/a2a-bridge/server/daemon.js b/plugins/a2a-bridge/server/daemon.js index 39829ff..fbcf22d 100755 --- a/plugins/a2a-bridge/server/daemon.js +++ b/plugins/a2a-bridge/server/daemon.js @@ -1529,6 +1529,47 @@ class WebSocketListener extends EventEmitter2 { } } +// src/shared/target-id.ts +var DEFAULT_INSTANCE_ID = "default"; +var VALID_SEGMENT = /^[a-z0-9_-]+$/; +function parseTarget(input) { + if (typeof input !== "string" || input.length === 0) { + return { ok: false, error: "Target specifier must be a non-empty string" }; + } + const parts = input.split(":"); + if (parts.length > 2) { + return { + ok: false, + error: `Target "${input}" has multiple ':' separators; expected "kind" or "kind:id"` + }; + } + const kind = parts[0] ?? ""; + const id = parts.length === 2 ? parts[1] ?? "" : DEFAULT_INSTANCE_ID; + if (kind.length === 0) { + return { ok: false, error: `Target "${input}" has an empty kind` }; + } + if (!VALID_SEGMENT.test(kind)) { + return { + ok: false, + error: `Target kind "${kind}" contains characters outside [a-z0-9_-]` + }; + } + if (id.length === 0) { + return { ok: false, error: `Target "${input}" has an empty id` }; + } + if (!VALID_SEGMENT.test(id)) { + return { + ok: false, + error: `Target id "${id}" contains characters outside [a-z0-9_-]` + }; + } + return { + ok: true, + target: `${kind}:${id}`, + parts: { kind, id } + }; +} + // src/runtime-daemon/inbound/daemon-claude-code-gateway.ts import { EventEmitter as EventEmitter3 } from "events"; @@ -1675,6 +1716,12 @@ class RoomRouter { get(id) { return this.rooms.get(id); } + async getOrCreateByTarget(target) { + return this.getOrCreate(target); + } + getByTarget(target) { + return this.rooms.get(target); + } adopt(room) { this.ensureLive(); if (this.rooms.has(room.id)) { @@ -2276,6 +2323,7 @@ function extractTaskId2(params) { } // src/runtime-daemon/inbound/a2a-http/server.ts +var A2A_FALLBACK_TARGET = "claude:default"; async function startA2AServer(config) { const host = config.host ?? "127.0.0.1"; const log = config.logger ?? createLogger({ tag: "A2aHttpServer", filePath: config.logFilePath }); @@ -2288,6 +2336,19 @@ async function startA2AServer(config) { if (roomRouter && !executorFactory) { throw new Error("startA2AServer: roomRouter requires executorFactory so each per-room gateway can drive its own message/stream executor"); } + const contextRoutes = config.contextRoutes; + const hasRoutes = contextRoutes !== undefined && Object.keys(contextRoutes).length > 0; + if (hasRoutes && !roomRouter) { + throw new Error("startA2AServer: contextRoutes requires roomRouter \u2014 multi-target routing needs a Room registry to dispatch into"); + } + if (contextRoutes) { + for (const [ctxId, target] of Object.entries(contextRoutes)) { + const parsed = parseTarget(target); + if (!parsed.ok) { + throw new Error(`startA2AServer: contextRoutes[${JSON.stringify(ctxId)}] = "${target}" is not a valid TargetId (${parsed.error})`); + } + } + } const handlers = { "tasks/get": createTasksGetHandler(registry), "tasks/cancel": createTasksCancelHandler(registry), @@ -2333,11 +2394,20 @@ async function startA2AServer(config) { let requestExecutor = defaultExecutor; let resolvedRoomId; if (roomRouter && executorFactory) { - resolvedRoomId = deriveRoomId({ - contextId: params.message.contextId - }); - const room = await roomRouter.getOrCreate(resolvedRoomId); - requestExecutor = executorFactory(room.gateway); + if (hasRoutes && contextRoutes) { + const ctxId = params.message.contextId; + const targetStr = ctxId !== undefined && contextRoutes[ctxId] || A2A_FALLBACK_TARGET; + const target = targetStr; + const room = await roomRouter.getOrCreateByTarget(target); + resolvedRoomId = targetStr; + requestExecutor = executorFactory(room.gateway); + } else { + resolvedRoomId = deriveRoomId({ + contextId: params.message.contextId + }); + const room = await roomRouter.getOrCreate(resolvedRoomId); + requestExecutor = executorFactory(room.gateway); + } } return handleMessageStream({ rpcId: normalizeId2(rpcId), @@ -2367,6 +2437,32 @@ async function startA2AServer(config) { } }; } +function parseContextRoutes(raw, log) { + if (!raw || raw.trim().length === 0) + return null; + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + log?.(`A2A_BRIDGE_CONTEXT_ROUTES ignored \u2014 not valid JSON: ${err.message}`); + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + log?.(`A2A_BRIDGE_CONTEXT_ROUTES ignored \u2014 expected a JSON object`); + return null; + } + const out = {}; + for (const [k, v] of Object.entries(parsed)) { + if (typeof v !== "string") { + log?.(`A2A_BRIDGE_CONTEXT_ROUTES[${JSON.stringify(k)}] ignored \u2014 value is not a string`); + continue; + } + out[k] = v; + } + if (Object.keys(out).length === 0) + return null; + return { contextRoutes: out }; +} function extractPath(url) { try { return new URL(url).pathname || "/"; @@ -2403,15 +2499,17 @@ function jsonRpcResponse(resp) { var DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; class AcpTurnHandler { - gateway; + gatewayForTarget; activeTurns = new Map; pendingPermissions = new Map; log; permissionTimeoutMs; - constructor(gateway, log, opts) { - this.gateway = gateway; + isTargetAttached; + constructor(gatewayForTarget, log, opts) { + this.gatewayForTarget = gatewayForTarget; this.log = log ?? (() => {}); this.permissionTimeoutMs = opts?.permissionTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS; + this.isTargetAttached = opts?.isTargetAttached; } routePermissionRequest(req) { const [conn, active] = this.activeTurns.entries().next().value ?? [undefined, undefined]; @@ -2455,12 +2553,32 @@ class AcpTurnHandler { this.log(`Permission ${msg.requestId} resolved: ${msg.outcome}`); pending.resolve(msg.outcome); } - handleTurnStart(conn, msg) { + async handleTurnStart(conn, msg) { + const target = msg.target ?? "claude:default"; + if (this.isTargetAttached && !this.isTargetAttached(target)) { + this.log(`Rejecting acp_turn_start ${msg.turnId} \u2014 target ${target} not attached`); + this.send(conn, { + type: "acp_turn_error", + turnId: msg.turnId, + message: `target ${target} not attached` + }); + return; + } + const gateway = await this.gatewayForTarget(target); + if (!gateway) { + this.log(`Rejecting acp_turn_start ${msg.turnId} \u2014 no gateway for ${target}`); + this.send(conn, { + type: "acp_turn_error", + turnId: msg.turnId, + message: `no gateway for target ${target}` + }); + return; + } this.settleTurn(conn, `superseded by ${msg.turnId}`); - const turn = this.gateway.startTurn(msg.userText); + const turn = gateway.startTurn(msg.userText); const settled = { value: false }; this.activeTurns.set(conn, { turnId: msg.turnId, turn, settled }); - this.log(`ACP turn started (turnId=${msg.turnId}, ${msg.userText.length} chars)`); + this.log(`ACP turn started (turnId=${msg.turnId}, ${msg.userText.length} chars, target=${target})`); turn.on("chunk", (text) => { if (settled.value) return; @@ -2553,16 +2671,30 @@ var A2A_INBOUND_PUBLIC_CARD = process.env.A2A_BRIDGE_PUBLIC_AGENT_CARD !== "fals var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log }); var controlListener = null; var a2aInboundServer = null; +var attachedClaudeByTarget = new Map; +var attachedAtByTarget = new Map; var attachedClaude = null; var controlClientMeta = new WeakMap; +var claudeConnTarget = new WeakMap; var nextControlClientId = 0; var inboundGateway = new DaemonClaudeCodeGateway({ sendToClaude: (text) => { - emitToClaude(systemMessage("a2a_inbound", text)); + emitToClaude(systemMessage("a2a_inbound", text, "acp")); }, log: (msg) => log(`[A2aGateway] ${msg}`) }); -var acpTurnHandler = new AcpTurnHandler(inboundGateway, (msg) => log(`[AcpTurnHandler] ${msg}`)); +var acpTurnHandler = new AcpTurnHandler(async (target) => { + const room = await inboundRoomRouter.getOrCreateByTarget(target); + return room.gateway; +}, (msg) => log(`[AcpTurnHandler] ${msg}`), { + isTargetAttached: (target) => { + if (attachedClaudeByTarget.has(target)) + return true; + if (target === "claude:default" && attachedClaude !== null) + return true; + return false; + } +}); var sharedTaskStore = SqliteTaskLog.open(stateDir.taskLogFile); var defaultRoom = new Room({ id: DEFAULT_ROOM_ID, @@ -2572,7 +2704,13 @@ var defaultRoom = new Room({ }); var codex = defaultRoom.getPeer("codex"); var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`; -var inboundRoomRouter = new RoomRouter((id) => new Room({ id, gateway: inboundGateway, registry: sharedTaskStore })); +var inboundRoomRouter = new RoomRouter((id) => { + const roomGateway = new DaemonClaudeCodeGateway({ + sendToClaude: (text) => emitToClaudeTarget(id, systemMessage("a2a_inbound", text, "acp")), + log: (msg) => log(`[A2aGateway:${id}] ${msg}`) + }); + return new Room({ id, gateway: roomGateway, registry: sharedTaskStore }); +}); inboundRoomRouter.adopt(defaultRoom); var nextSystemMessageId = 0; var codexBootstrapped = false; @@ -2741,7 +2879,7 @@ function handleControlMessage(conn, raw) { } switch (message.type) { case "claude_connect": - attachClaude(conn); + attachClaude(conn, message.target, message.force === true); return; case "claude_disconnect": detachClaude(conn, "frontend requested disconnect"); @@ -2781,6 +2919,15 @@ function handleControlMessage(conn, raw) { case "acp_permission_response": acpTurnHandler.handlePermissionResponse(conn, message); return; + case "list_targets": { + const entries = listTargetEntries(); + sendProtocolMessage(conn, { + type: "targets_response", + requestId: message.requestId, + targets: entries + }); + return; + } case "claude_to_codex": { if (message.message.source !== "claude") { sendProtocolMessage(conn, { @@ -2791,8 +2938,72 @@ function handleControlMessage(conn, raw) { }); return; } - if (inboundGateway.interceptReply(message.message.content)) { - log(`Claude reply consumed by inbound A2A turn (${message.message.content.length} chars)`); + if (message.target !== undefined) { + const parsed = parseTarget(message.target); + if (!parsed.ok) { + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: false, + error: `Invalid target "${message.target}": ${parsed.error}` + }); + return; + } + const targetStr = parsed.target; + if (parsed.parts.kind === "claude") { + const destConn = attachedClaudeByTarget.get(targetStr) ?? (targetStr === "claude:default" ? attachedClaude : null); + if (!destConn) { + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: false, + error: `target ${targetStr} is not attached` + }); + return; + } + if (destConn === conn) { + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: false, + error: `target ${targetStr} resolves to the sender \u2014 replies cannot loop back to self` + }); + return; + } + sendBridgeMessage(destConn, message.message); + clearAttentionWindow(); + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: true + }); + return; + } + if (parsed.parts.kind === "codex") { + if (parsed.parts.id !== "default") { + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: false, + error: `target ${targetStr} not recognized (only codex:default is supported until P10.9)` + }); + return; + } + } else { + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: false, + error: `Unsupported target kind "${parsed.parts.kind}"` + }); + return; + } + } + const senderTarget = claudeConnTarget.get(conn) ?? "claude:default"; + const senderRoom = inboundRoomRouter.getByTarget(senderTarget); + const senderGateway = senderRoom?.gateway ?? inboundGateway; + if (senderGateway.interceptReply(message.message.content)) { + log(`Claude reply consumed by inbound A2A/ACP turn on ${senderTarget} ` + `(${message.message.content.length} chars)`); clearAttentionWindow(); sendProtocolMessage(conn, { type: "claude_to_codex_result", @@ -2843,17 +3054,64 @@ function handleControlMessage(conn, raw) { } } } -function attachClaude(conn) { - if (attachedClaude && attachedClaude !== conn) { - attachedClaude.close(); +function attachClaude(conn, target, force = false) { + let resolvedTarget = "claude:default"; + if (target) { + const parsed = parseTarget(target); + if (!parsed.ok) { + log(`Rejecting claude_connect with invalid target "${target}": ${parsed.error}`); + sendProtocolMessage(conn, { + type: "claude_connect_rejected", + target, + reason: `invalid target: ${parsed.error}` + }); + return; + } + if (parsed.parts.kind !== "claude") { + log(`Rejecting claude_connect with non-claude target "${target}"`); + sendProtocolMessage(conn, { + type: "claude_connect_rejected", + target, + reason: `non-claude target rejected on claude_connect` + }); + return; + } + resolvedTarget = parsed.target; + } + const existing = attachedClaudeByTarget.get(resolvedTarget); + if (existing && existing !== conn) { + if (!force) { + const existingMeta = controlClientMeta.get(existing); + const attachedAt = attachedAtByTarget.get(resolvedTarget); + const ageMs = attachedAt !== undefined ? Date.now() - attachedAt : null; + const ageHint = ageMs !== null ? `, attached ${formatAttachAge(ageMs)}` : ""; + const connHint = existingMeta ? `plugin conn #${existingMeta.clientId}${ageHint}` : "unknown"; + const reason = `target ${resolvedTarget} already attached (${connHint}). ` + `Re-run with --force to take over, or use a different workspace id.`; + log(`Rejecting claude_connect for ${resolvedTarget} \u2014 ${connHint}`); + sendProtocolMessage(conn, { + type: "claude_connect_rejected", + target: resolvedTarget, + reason + }); + return; + } + log(`Force-replacing existing attachment for ${resolvedTarget}`); + sendProtocolMessage(existing, { + type: "claude_connect_replaced", + target: resolvedTarget + }); + existing.close(); } + attachedClaudeByTarget.set(resolvedTarget, conn); + attachedAtByTarget.set(resolvedTarget, Date.now()); + claudeConnTarget.set(conn, resolvedTarget); const meta = controlClientMeta.get(conn); clearPendingClaudeDisconnect("Claude frontend attached"); attachedClaude = conn; if (meta) meta.attached = true; cancelIdleShutdown(); - log(`Claude frontend attached (#${meta?.clientId ?? "?"})`); + log(`Claude frontend attached (#${meta?.clientId ?? "?"}) \u2192 ${resolvedTarget}`); statusBuffer.flush("claude reconnected"); sendStatus(conn); const now = Date.now(); @@ -2873,10 +3131,19 @@ function attachClaude(conn) { } } function detachClaude(conn, reason) { + const target = claudeConnTarget.get(conn); + if (target && attachedClaudeByTarget.get(target) === conn) { + attachedClaudeByTarget.delete(target); + attachedAtByTarget.delete(target); + } + claudeConnTarget.delete(conn); if (attachedClaude !== conn) return; const meta = controlClientMeta.get(conn); - attachedClaude = null; + let nextAttached = null; + for (const c of attachedClaudeByTarget.values()) + nextAttached = c; + attachedClaude = nextAttached; if (meta) meta.attached = false; log(`Claude frontend detached (#${meta?.clientId ?? "?"}, ${reason})`); @@ -2964,6 +3231,16 @@ function scheduleClaudeDisconnectNotification(clientId) { log(`Claude disconnect persisted past grace window (client #${clientId})`); }, CLAUDE_DISCONNECT_GRACE_MS); } +function emitToClaudeTarget(target, message) { + const dest = attachedClaudeByTarget.get(target) ?? (target === "claude:default" ? attachedClaude : null); + if (dest && dest.isOpen) { + if (trySendBridgeMessage(dest, message)) + return; + log(`Send to ${target} failed \u2014 dropping message (buffering not per-target yet)`); + return; + } + log(`No CC attached for ${target} \u2014 dropping inbound message`); +} function emitToClaude(message) { if (attachedClaude && attachedClaude.isOpen) { if (trySendBridgeMessage(attachedClaude, message)) @@ -3043,10 +3320,52 @@ function notifyCodexClaudeOnline() { function shouldNotifyCodexClaudeOnline() { return !claudeOnlineNoticeSent || claudeOfflineNoticeShown; } -function systemMessage(idPrefix, content) { +function formatAttachAge(ms) { + if (!Number.isFinite(ms) || ms < 0) + return "just now"; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) + return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) + return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} +function listTargetEntries() { + const entries = []; + for (const [target, conn] of attachedClaudeByTarget.entries()) { + const meta = controlClientMeta.get(conn); + entries.push({ + target, + attached: true, + ...meta ? { clientId: meta.clientId } : {}, + ...attachedAtByTarget.has(target) ? { attachedAt: attachedAtByTarget.get(target) } : {} + }); + } + if (attachedClaude && !attachedClaudeByTarget.has("claude:default")) { + let alreadyListed = false; + for (const conn of attachedClaudeByTarget.values()) { + if (conn === attachedClaude) { + alreadyListed = true; + break; + } + } + if (!alreadyListed) { + const meta = controlClientMeta.get(attachedClaude); + entries.push({ + target: "claude:default", + attached: true, + ...meta ? { clientId: meta.clientId } : {} + }); + } + } + return entries; +} +function systemMessage(idPrefix, content, source = "codex") { return { id: `${idPrefix}_${++nextSystemMessageId}`, - source: "codex", + source, content, timestamp: Date.now() }; @@ -3096,7 +3415,8 @@ async function bootInbound() { const echoMode = process.env.A2A_BRIDGE_INBOUND_ECHO === "1"; const routedConfig = echoMode ? { messageStreamExecutor: createEchoExecutor() } : { roomRouter: inboundRoomRouter, - executorFactory: (gateway) => createClaudeCodeExecutor({ gateway }) + executorFactory: (gateway) => createClaudeCodeExecutor({ gateway }), + ...parseContextRoutes(process.env.A2A_BRIDGE_CONTEXT_ROUTES, log) ?? {} }; try { a2aInboundServer = await startA2AServer({ diff --git a/src/cli/acp.ts b/src/cli/acp.ts index d0e15ad..4539969 100644 --- a/src/cli/acp.ts +++ b/src/cli/acp.ts @@ -22,6 +22,7 @@ import { AcpInboundService } from "@daemon/inbound/acp"; import { DaemonProxyGateway } from "@daemon/inbound/acp/daemon-proxy-gateway"; import type { ClaudeCodeGateway } from "@daemon/inbound/a2a-http/claude-code-gateway"; import type { AcpStdioPair } from "@daemon/inbound/acp/connection"; +import { parseTarget } from "@shared/target-id"; import { DaemonUnreachableError, renderFriendlyError, @@ -34,6 +35,13 @@ export interface RunAcpOptions { ensureDaemon?: boolean; /** Override the daemon control-plane WebSocket URL. */ controlWsUrl?: string; + /** + * TargetId (`kind:id` form) selecting which daemon Room handles + * every turn this subprocess sends. Validated via parseTarget. + * When omitted, frames go without a target field and the daemon + * defaults to `claude:default` (v0.1 backward compat). + */ + target?: string; /** * Test-only escape hatch: when provided, skips the daemon entirely * and uses this gateway directly. Production callers never set this. @@ -53,6 +61,16 @@ export async function runAcp( options = { ...options, controlWsUrl: parsed.url }; } + // --target validates and overrides the daemon Room selector. + if (parsed.target) { + const r = parseTarget(parsed.target); + if (!r.ok) { + console.error(`Invalid --target "${parsed.target}": ${r.error}`); + process.exit(1); + } + options = { ...options, target: r.target as string }; + } + // One-shot prompt mode: a2a-bridge acp -p "hello" / --prompt "hello" if (parsed.prompt !== undefined) { if (!parsed.prompt) { @@ -73,9 +91,11 @@ export async function runAcp( function parseAcpArgs(args: string[]): { prompt?: string; url?: string; + target?: string; } { let prompt: string | undefined; let url: string | undefined; + let target: string | undefined; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -85,10 +105,14 @@ function parseAcpArgs(args: string[]): { url = args[++i]; } else if (arg?.startsWith("--url=")) { url = arg.slice(6); + } else if ((arg === "--target" || arg === "-t") && i + 1 < args.length) { + target = args[++i]; + } else if (arg?.startsWith("--target=")) { + target = arg.slice(9); } } - return { prompt, url }; + return { prompt, url, target }; } /** @@ -155,6 +179,7 @@ async function resolveGateway(options: RunAcpOptions): Promise console.error(`[a2a-bridge acp] ${msg}`), + ...(options.target ? { target: options.target } : {}), }); try { await gateway.connect(); diff --git a/src/cli/claude-conflict.test.ts b/src/cli/claude-conflict.test.ts new file mode 100644 index 0000000..f74cf00 --- /dev/null +++ b/src/cli/claude-conflict.test.ts @@ -0,0 +1,239 @@ +/** + * P10.6 — Attach conflict policy integration test. + * + * Boots the real daemon, then attaches two CCs to the same TargetId + * via the plugin-side `DaemonClient` seam and asserts: + * + * 1. Without `force`, the second attach receives + * `claude_connect_rejected` (→ `connectRejected` event) and the + * first attach stays owner. + * 2. With `force=true`, the second attach takes over; the first + * receives `claude_connect_replaced` and its socket is closed. + * + * These are the core wire-level guarantees of P10.6. Unit tests at + * the DaemonClient layer (daemon-client.test.ts) cover the reverse + * direction (events fire on incoming frames); this test exercises + * the daemon's actual decision logic end-to-end. + */ +import { describe, test, expect, afterEach } from "bun:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { DaemonClient } from "@plugin/daemon-client/daemon-client"; +import type { TargetEntry } from "@transport/control-protocol"; + +/** + * Fire a one-shot `list_targets` RPC and return the snapshot, so the + * test can assert what `a2a-bridge daemon targets` would print. + */ +async function listTargetsRpc(controlWsUrl: string): Promise { + return new Promise((resolve, reject) => { + const requestId = `t_${Math.floor(Math.random() * 1e9)}`; + const ws = new WebSocket(controlWsUrl); + const timer = setTimeout(() => { + try { ws.close(); } catch {} + reject(new Error("timed out waiting for targets_response")); + }, 4000); + ws.onopen = () => ws.send(JSON.stringify({ type: "list_targets", requestId })); + ws.onmessage = (ev) => { + const raw = typeof ev.data === "string" ? ev.data : ev.data.toString(); + try { + const m = JSON.parse(raw); + if (m.type === "targets_response" && m.requestId === requestId) { + clearTimeout(timer); + try { ws.close(); } catch {} + resolve(m.targets as TargetEntry[]); + } + } catch {} + }; + ws.onerror = () => { + clearTimeout(timer); + reject(new Error("ws error")); + }; + }); +} + +const DAEMON_SRC = fileURLToPath( + new URL("../runtime-daemon/daemon.ts", import.meta.url), +); + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) { + try { + await cleanups.pop()!(); + } catch {} + } +}); +function register(fn: () => Promise | void) { + cleanups.push(fn); +} + +function pickPorts(count: number): number[] { + const base = 16800 + Math.floor(Math.random() * 300); + return Array.from({ length: count }, (_, i) => base + i); +} + +async function waitForHealthz(port: number, timeoutMs = 10_000): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + try { + const res = await fetch(`http://127.0.0.1:${port}/healthz`); + if (res.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`daemon did not become healthy on port ${port} within ${timeoutMs}ms`); +} + +async function startDaemon(opts: { + stateDir: string; + controlPort: number; + codexWsPort: number; + codexProxyPort: number; +}): Promise { + const proc = spawn("bun", ["run", DAEMON_SRC], { + env: { + ...process.env, + A2A_BRIDGE_STATE_DIR: opts.stateDir, + A2A_BRIDGE_CONTROL_PORT: String(opts.controlPort), + CODEX_WS_PORT: String(opts.codexWsPort), + CODEX_PROXY_PORT: String(opts.codexProxyPort), + A2A_BRIDGE_BEARER_TOKEN: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + register(async () => { + if (!proc.killed) proc.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 100)); + if (!proc.killed) proc.kill("SIGKILL"); + }); + await waitForHealthz(opts.controlPort); + return proc; +} + +describe("P10.6 attach conflict policy", () => { + test("second attach without force is rejected; first stays owner", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "a2a-bridge-p10-6-reject-")); + register(() => rmSync(stateDir, { recursive: true, force: true })); + + const [controlPort, codexWsPort, codexProxyPort] = pickPorts(3); + await startDaemon({ + stateDir, + controlPort: controlPort!, + codexWsPort: codexWsPort!, + codexProxyPort: codexProxyPort!, + }); + + const TARGET = "claude:ws-a"; + const ctrlUrl = `ws://127.0.0.1:${controlPort}/ws`; + + const first = new DaemonClient(ctrlUrl); + await first.connect(); + register(() => first.disconnect()); + first.attachClaude(TARGET); + + // Give the daemon a beat to install the first attach before the + // second arrives — otherwise the two `claude_connect` frames race + // and whichever lands first becomes "first". + await new Promise((r) => setTimeout(r, 80)); + + const second = new DaemonClient(ctrlUrl); + await second.connect(); + register(() => second.disconnect()); + + const rejection = new Promise<{ target: string; reason: string }>((resolve) => { + second.on("connectRejected", resolve); + }); + // First must NOT receive a replaced event — count events for a + // grace window and assert zero. + let firstReplacedCount = 0; + first.on("connectReplaced", () => { + firstReplacedCount += 1; + }); + + second.attachClaude(TARGET); + + const ev = await rejection; + expect(ev.target).toBe(TARGET); + expect(ev.reason).toMatch(/already attached/i); + expect(firstReplacedCount).toBe(0); + }, 20_000); + + test("daemon targets does not advertise a phantom claude:default row when all attaches are explicit targets", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "a2a-bridge-p10-6-noghost-")); + register(() => rmSync(stateDir, { recursive: true, force: true })); + + const [controlPort, codexWsPort, codexProxyPort] = pickPorts(3); + await startDaemon({ + stateDir, + controlPort: controlPort!, + codexWsPort: codexWsPort!, + codexProxyPort: codexProxyPort!, + }); + + const ctrlUrl = `ws://127.0.0.1:${controlPort}/ws`; + const a = new DaemonClient(ctrlUrl); + await a.connect(); + register(() => a.disconnect()); + a.attachClaude("claude:proj-a"); + const b = new DaemonClient(ctrlUrl); + await b.connect(); + register(() => b.disconnect()); + b.attachClaude("claude:proj-b"); + await new Promise((r) => setTimeout(r, 120)); + + // Query the daemon's `list_targets` RPC directly so we can assert + // the snapshot the CLI would print — no phantom `claude:default` + // row, just the two explicit attaches. + const targets = await listTargetsRpc(ctrlUrl); + const names = targets.map((t) => t.target).sort(); + expect(names).toEqual(["claude:proj-a", "claude:proj-b"]); + }, 20_000); + + test("second attach with force=true replaces the first", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "a2a-bridge-p10-6-force-")); + register(() => rmSync(stateDir, { recursive: true, force: true })); + + const [controlPort, codexWsPort, codexProxyPort] = pickPorts(3); + await startDaemon({ + stateDir, + controlPort: controlPort!, + codexWsPort: codexWsPort!, + codexProxyPort: codexProxyPort!, + }); + + const TARGET = "claude:ws-b"; + const ctrlUrl = `ws://127.0.0.1:${controlPort}/ws`; + + const first = new DaemonClient(ctrlUrl); + await first.connect(); + register(() => first.disconnect()); + first.attachClaude(TARGET); + + const replaced = new Promise<{ target: string }>((resolve) => { + first.on("connectReplaced", resolve); + }); + const firstDisconnected = new Promise((resolve) => { + first.on("disconnect", () => resolve()); + }); + + await new Promise((r) => setTimeout(r, 80)); + + const second = new DaemonClient(ctrlUrl); + await second.connect(); + register(() => second.disconnect()); + let secondRejected = false; + second.on("connectRejected", () => { + secondRejected = true; + }); + second.attachClaude(TARGET, true); + + const ev = await replaced; + expect(ev.target).toBe(TARGET); + await firstDisconnected; + expect(secondRejected).toBe(false); + }, 20_000); +}); diff --git a/src/cli/claude-flags.test.ts b/src/cli/claude-flags.test.ts new file mode 100644 index 0000000..aa476fe --- /dev/null +++ b/src/cli/claude-flags.test.ts @@ -0,0 +1,36 @@ +/** + * P10.6 — `a2a-bridge claude --force` flag extraction. + * + * The `--force` flag is ours, not CC's. We strip it from the raw + * argv before calling `claude`, and forward operator intent to the + * plugin via `A2A_BRIDGE_FORCE_ATTACH`. This test locks the parser + * so future refactors can't silently break either half. + */ +import { describe, test, expect } from "bun:test"; +import { extractForceFlag } from "./claude"; + +describe("extractForceFlag", () => { + test("strips --force and sets force=true", () => { + const res = extractForceFlag(["--force", "--verbose"]); + expect(res.force).toBe(true); + expect(res.forwarded).toEqual(["--verbose"]); + }); + + test("absent --force leaves argv untouched and force=false", () => { + const res = extractForceFlag(["--continue", "foo"]); + expect(res.force).toBe(false); + expect(res.forwarded).toEqual(["--continue", "foo"]); + }); + + test("preserves argument order around the stripped flag", () => { + const res = extractForceFlag(["-a", "--force", "-b", "c"]); + expect(res.force).toBe(true); + expect(res.forwarded).toEqual(["-a", "-b", "c"]); + }); + + test("does not match partial flags like --forceful", () => { + const res = extractForceFlag(["--forceful"]); + expect(res.force).toBe(false); + expect(res.forwarded).toEqual(["--forceful"]); + }); +}); diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 1f8c3ab..9eb4fb6 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -7,8 +7,13 @@ import { StateDirResolver } from "@shared/state-dir"; const OWNED_FLAGS = ["--channels", "--dangerously-load-development-channels"]; export async function runClaude(args: string[]) { + // P10.6 — strip our own `--force` flag before CC sees it, and + // forward it to the plugin via env. CC itself doesn't know what + // `--force` means for the daemon attach. + const { forwarded, force } = extractForceFlag(args); + // Check for owned flag conflicts - checkOwnedFlagConflicts(args, "a2a-bridge claude", OWNED_FLAGS); + checkOwnedFlagConflicts(forwarded, "a2a-bridge claude", OWNED_FLAGS); const stateDir = new StateDirResolver(); const controlPort = parseInt(process.env.A2A_BRIDGE_CONTROL_PORT ?? "4512", 10); @@ -31,12 +36,15 @@ export async function runClaude(args: string[]) { // Once published to the official marketplace, switch to --channels. const fullArgs = [ "--dangerously-load-development-channels", channelEntry, - ...args, + ...forwarded, ]; const child = spawn("claude", fullArgs, { stdio: "inherit", - env: process.env, + env: { + ...process.env, + ...(force ? { A2A_BRIDGE_FORCE_ATTACH: "1" } : {}), + }, }); child.on("exit", (code) => { @@ -54,6 +62,24 @@ export async function runClaude(args: string[]) { }); } +/** + * Extract the P10.6 `--force` flag from a raw argv. Returns the + * forwarded-through args (minus `--force`) and a boolean saying + * whether the flag was present. Exported for unit tests. + */ +export function extractForceFlag(args: string[]): { forwarded: string[]; force: boolean } { + const forwarded: string[] = []; + let force = false; + for (const arg of args) { + if (arg === "--force") { + force = true; + } else { + forwarded.push(arg); + } + } + return { forwarded, force }; +} + /** * Check if user passed any A2aBridge-owned flags. * Hard error if they did — mixed flag state is unpredictable. diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 37086e4..eaed2e9 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -92,7 +92,7 @@ Commands: codex [args...] Start Codex TUI connected to A2aBridge daemon acp [args...] Start ACP-over-stdio server (for Zed / OpenClaw / VS Code) doctor Run preflight checks (bun, ports, SDK, plugin, state-dir) - daemon start | stop | status | logs (daemon lifecycle) + daemon start | stop | status | logs | targets (daemon lifecycle) kill Force kill all A2aBridge processes Options: diff --git a/src/cli/daemon.test.ts b/src/cli/daemon.test.ts index c4f53a6..40b0c3a 100644 --- a/src/cli/daemon.test.ts +++ b/src/cli/daemon.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from "bun:test"; -import { runDaemon, type LifecycleView } from "./daemon"; +import { runDaemon, formatTargetsTable, type LifecycleView } from "./daemon"; import { StateDirResolver } from "@shared/state-dir"; +import type { TargetEntry } from "@transport/control-protocol"; class StubLifecycle implements LifecycleView { healthUrl = "http://127.0.0.1:4512/healthz"; @@ -199,4 +200,83 @@ describe("runDaemon", () => { expect(res.exitCode).toBe(1); expect(sink.err.join("\n")).toMatch(/Usage: a2a-bridge daemon/); }); + + test("targets reports not-running when the pid file is missing", async () => { + const lc = new StubLifecycle({ pid: null }); + const sink = capture(); + let calls = 0; + const res = await runDaemon(["targets"], { + buildLifecycle: () => lc, + log: sink.log, + error: sink.error, + queryTargets: async () => { + calls += 1; + return []; + }, + }); + expect(res.exitCode).toBe(0); + expect(calls).toBe(0); + expect(sink.out.join("\n")).toMatch(/not running/); + }); + + test("targets queries the control plane and prints a table", async () => { + const lc = new StubLifecycle({ pid: 9001 }); + const sink = capture(); + const now = 2_000_000; + const res = await runDaemon(["targets"], { + buildLifecycle: () => lc, + log: sink.log, + error: sink.error, + queryTargets: async (url) => { + expect(url).toBe("ws://127.0.0.1:4512/ws"); + const entries: TargetEntry[] = [ + { target: "claude:default", attached: true, clientId: 3, attachedAt: now - 42_000 }, + { target: "claude:alt", attached: true, clientId: 7, attachedAt: now - 5_000 }, + ]; + return entries; + }, + }); + expect(res.exitCode).toBe(0); + const joined = sink.out.join("\n"); + expect(joined).toMatch(/TARGET\s+ATTACHED\s+CLIENT\s+UPTIME/); + expect(joined).toMatch(/claude:default\s+yes\s+3/); + expect(joined).toMatch(/claude:alt\s+yes\s+7/); + }); + + test("targets surfaces query errors as exit 1", async () => { + const lc = new StubLifecycle({ pid: 9001 }); + const sink = capture(); + const res = await runDaemon(["targets"], { + buildLifecycle: () => lc, + log: sink.log, + error: sink.error, + queryTargets: async () => { + throw new Error("connection refused"); + }, + }); + expect(res.exitCode).toBe(1); + expect(sink.err.join("\n")).toMatch(/daemon targets failed: connection refused/); + }); +}); + +describe("formatTargetsTable", () => { + test("renders a fixed-width 4-column table", () => { + const now = 10_000_000; + const text = formatTargetsTable( + [ + { target: "claude:default", attached: true, clientId: 3, attachedAt: now - 90_000 }, + { target: "codex:dev", attached: false }, + ], + now, + ); + const lines = text.split("\n"); + expect(lines[0]).toContain("TARGET"); + expect(lines[0]).toContain("ATTACHED"); + expect(lines[1]).toMatch(/claude:default\s+yes\s+3\s+1m/); + expect(lines[2]).toMatch(/codex:dev\s+no\s+-\s+-/); + }); + + test("empty list prints a friendly message", () => { + expect(formatTargetsTable([])).toBe("no targets registered"); + }); }); diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts index 8f93fc1..1551534 100644 --- a/src/cli/daemon.ts +++ b/src/cli/daemon.ts @@ -24,9 +24,14 @@ import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { DaemonLifecycle } from "@shared/daemon-lifecycle"; import { StateDirResolver } from "@shared/state-dir"; +import type { + ControlClientMessage, + ControlServerMessage, + TargetEntry, +} from "@transport/control-protocol"; import { findPackageRoot } from "./pkg-root"; -export type DaemonSubcommand = "start" | "stop" | "status" | "logs"; +export type DaemonSubcommand = "start" | "stop" | "status" | "logs" | "targets"; export interface LifecycleView { healthUrl: string; @@ -47,6 +52,12 @@ export interface RunDaemonOptions { readLogTail?: (path: string, lines: number) => string | null; /** Override for `stop` graceful-kill window. Defaults to 3 seconds. */ killTimeoutMs?: number; + /** + * Override for `targets` — opens a WebSocket to the control plane, + * sends `list_targets`, and resolves with the daemon's snapshot. + * Tests inject a stub so they don't have to spin a real daemon. + */ + queryTargets?: (controlWsUrl: string) => Promise; } export interface RunDaemonResult { @@ -62,13 +73,14 @@ export async function runDaemon( const log = options.log ?? ((m: string) => console.log(m)); const error = options.error ?? ((m: string) => console.error(m)); - if (!sub || !["start", "stop", "status", "logs"].includes(sub)) { + if (!sub || !["start", "stop", "status", "logs", "targets"].includes(sub)) { error( - `Usage: a2a-bridge daemon \n` + + `Usage: a2a-bridge daemon \n` + ` daemon start Launch the daemon (no-op if already running)\n` + ` daemon stop Send SIGTERM via the pid file\n` + ` daemon status Print the running daemon's pid + ports\n` + - ` daemon logs Tail the state-dir log file`, + ` daemon logs Tail the state-dir log file\n` + + ` daemon targets List every TargetId Room the daemon tracks`, ); return { exitCode: sub ? 1 : 2 }; } @@ -130,6 +142,24 @@ export async function runDaemon( return { exitCode: 0 }; } + case "targets": { + const pid = lifecycle.readPid(); + if (pid === null) { + log("daemon is not running (no pid file)"); + return { exitCode: 0 }; + } + const query = options.queryTargets ?? defaultQueryTargets; + let entries: TargetEntry[]; + try { + entries = await query(lifecycle.controlWsUrl); + } catch (err) { + error(`daemon targets failed: ${err instanceof Error ? err.message : String(err)}`); + return { exitCode: 1 }; + } + log(formatTargetsTable(entries)); + return { exitCode: 0 }; + } + default: return { exitCode: 2 }; } @@ -183,3 +213,82 @@ function parseTailArg(args: string[]): number | null { if (Number.isNaN(n) || n <= 0) return null; return n; } + +/** + * Default `queryTargets` implementation: opens a WebSocket to the + * control plane, sends `list_targets`, awaits `targets_response`, and + * closes the socket. + */ +export async function defaultQueryTargets(controlWsUrl: string): Promise { + return new Promise((resolve, reject) => { + const requestId = `targets_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + const ws = new WebSocket(controlWsUrl); + let settled = false; + const settle = (err: Error | null, value?: TargetEntry[]) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { ws.close(); } catch {} + if (err) reject(err); + else resolve(value ?? []); + }; + const timer = setTimeout(() => { + settle(new Error(`Timed out waiting for targets_response from ${controlWsUrl}`)); + }, 5000); + ws.onopen = () => { + const frame: ControlClientMessage = { type: "list_targets", requestId }; + ws.send(JSON.stringify(frame)); + }; + ws.onmessage = (event) => { + const raw = typeof event.data === "string" ? event.data : event.data.toString(); + let msg: ControlServerMessage; + try { + msg = JSON.parse(raw); + } catch { + return; + } + if (msg.type === "targets_response" && msg.requestId === requestId) { + settle(null, msg.targets); + } + }; + ws.onerror = () => { + settle(new Error(`Failed to connect to daemon control plane at ${controlWsUrl}`)); + }; + ws.onclose = () => { + settle(new Error(`Daemon control plane closed before targets_response (${controlWsUrl})`)); + }; + }); +} + +/** + * Format a `TargetEntry[]` snapshot as a 4-column plain-text table + * (target, attached, client, uptime). "uptime" is wall-clock since + * `attachedAt`, formatted as `Xs` / `Xm` / `Xh`. + */ +export function formatTargetsTable(entries: TargetEntry[], now: number = Date.now()): string { + if (entries.length === 0) return "no targets registered"; + const header = ["TARGET", "ATTACHED", "CLIENT", "UPTIME"] as const; + const rows: string[][] = [header.slice()]; + for (const entry of entries) { + const attached = entry.attached ? "yes" : "no"; + const client = entry.clientId !== undefined ? String(entry.clientId) : "-"; + const uptime = entry.attachedAt !== undefined ? formatUptime(now - entry.attachedAt) : "-"; + rows.push([entry.target, attached, client, uptime]); + } + const widths = header.map((_, col) => + rows.reduce((w, row) => Math.max(w, row[col]!.length), 0), + ); + return rows + .map((row) => row.map((cell, col) => cell.padEnd(widths[col]!)).join(" ").trimEnd()) + .join("\n"); +} + +function formatUptime(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return "-"; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h`; +} diff --git a/src/cli/multi-target.test.ts b/src/cli/multi-target.test.ts new file mode 100644 index 0000000..5826460 --- /dev/null +++ b/src/cli/multi-target.test.ts @@ -0,0 +1,277 @@ +/** + * P10.10 — Cross-target integration test. + * + * Boots a real daemon, attaches two stub CCs as `claude:ws-a` and + * `claude:ws-b`, then drives two concurrent ACP subprocesses with + * distinct `--target` values. Each subprocess sends a unique prompt; + * the assertion is that each subprocess's reply carries ONLY the + * matching CC's tagged echo — proving the inbound ACP → CC → reply + * path is target-isolated (no cross-talk between rooms). + * + * Covers the end-to-end wire for multi-claude routing introduced + * across P10.1–P10.8: + * - P10.2 claude_connect.target + * - P10.4 acp_turn_start.target + * - P10.10 per-target DaemonClaudeCodeGateway so inbound text lands + * on the correct attached CC, and per-target interceptReply so + * each CC's reply closes its own room's in-flight turn. + * + * Scope note: codex peer-id routing is deferred to v0.3 (see P10.9). + * This test covers the multi-claude axis only. + */ +import { describe, test, expect, afterEach } from "bun:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { Readable } from "node:stream"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, +} from "@agentclientprotocol/sdk"; +import { DaemonClient } from "@plugin/daemon-client/daemon-client"; +import type { BridgeMessage } from "@messages/types"; + +interface CapturedUpdate { + sessionId: string; + kind: string; + text?: string; +} + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) { + try { + await cleanups.pop()!(); + } catch {} + } +}); +function register(fn: () => Promise | void) { + cleanups.push(fn); +} + +function pickPorts(count: number): number[] { + const base = 17800 + Math.floor(Math.random() * 300); + return Array.from({ length: count }, (_, i) => base + i); +} + +const DAEMON_SRC = fileURLToPath( + new URL("../runtime-daemon/daemon.ts", import.meta.url), +); +const CLI_SRC = fileURLToPath(new URL("./cli.ts", import.meta.url)); + +async function waitForHealthz(port: number, timeoutMs = 10_000): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + try { + const res = await fetch(`http://127.0.0.1:${port}/healthz`); + if (res.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`daemon did not become healthy on port ${port} within ${timeoutMs}ms`); +} + +async function startDaemon(opts: { + stateDir: string; + controlPort: number; + codexWsPort: number; + codexProxyPort: number; +}): Promise { + const proc = spawn("bun", ["run", DAEMON_SRC], { + env: { + ...process.env, + A2A_BRIDGE_STATE_DIR: opts.stateDir, + A2A_BRIDGE_CONTROL_PORT: String(opts.controlPort), + CODEX_WS_PORT: String(opts.codexWsPort), + CODEX_PROXY_PORT: String(opts.codexProxyPort), + A2A_BRIDGE_BEARER_TOKEN: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + register(async () => { + if (!proc.killed) proc.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 100)); + if (!proc.killed) proc.kill("SIGKILL"); + }); + await waitForHealthz(opts.controlPort); + return proc; +} + +/** Spawn an `a2a-bridge acp` subprocess for one target. */ +function spawnAcp(stateDir: string, controlPort: number, target: string): ChildProcess { + const proc = spawn("bun", ["run", CLI_SRC, "acp", "--target", target], { + env: { + ...process.env, + A2A_BRIDGE_STATE_DIR: stateDir, + A2A_BRIDGE_CONTROL_PORT: String(controlPort), + A2A_BRIDGE_ACP_SKIP_DAEMON: "1", + }, + stdio: ["pipe", "pipe", "inherit"], + }); + register(async () => { + if (!proc.killed) proc.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 100)); + if (!proc.killed) proc.kill("SIGKILL"); + }); + return proc; +} + +/** Wrap the subprocess's stdio in an ACP `ClientSideConnection`. */ +function clientForAcp(proc: ChildProcess): { + client: ClientSideConnection; + updates: CapturedUpdate[]; +} { + const updates: CapturedUpdate[] = []; + const input = Readable.toWeb(proc.stdout!) as unknown as ReadableStream; + const output: WritableStream = new WritableStream({ + write(chunk) { + proc.stdin!.write(chunk); + }, + close() { + proc.stdin!.end(); + }, + }); + const recording = { + async sessionUpdate(params: { + sessionId: string; + update: { sessionUpdate: string; content?: { type: string; text: string } }; + }): Promise { + updates.push({ + sessionId: params.sessionId, + kind: params.update.sessionUpdate, + text: + params.update.content?.type === "text" ? params.update.content.text : undefined, + }); + }, + async requestPermission(): Promise { + throw new Error("unexpected requestPermission"); + }, + async readTextFile(): Promise { + throw new Error("unexpected readTextFile"); + }, + async writeTextFile(): Promise { + throw new Error("unexpected writeTextFile"); + }, + }; + const client = new ClientSideConnection( + () => + recording as unknown as ConstructorParameters< + typeof ClientSideConnection + >[0] extends (c: unknown) => infer R + ? R + : never, + ndJsonStream(output, input), + ); + return { client, updates }; +} + +describe("P10.10 cross-target integration", () => { + test("two ACP turns on distinct targets don't cross-talk", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "a2a-bridge-p10-10-")); + register(() => rmSync(stateDir, { recursive: true, force: true })); + + const [controlPort, codexWsPort, codexProxyPort] = pickPorts(3); + await startDaemon({ + stateDir, + controlPort: controlPort!, + codexWsPort: codexWsPort!, + codexProxyPort: codexProxyPort!, + }); + + // Attach two stub CCs. Each echoes incoming text back to the + // daemon with a target-specific prefix, so we can tell CC-A's + // reply from CC-B's on the wire. + const ctrlUrl = `ws://127.0.0.1:${controlPort}/ws`; + const PREFIX_A = "CC-A:"; + const PREFIX_B = "CC-B:"; + + const ccA = new DaemonClient(ctrlUrl); + await ccA.connect(); + register(() => ccA.disconnect()); + ccA.attachClaude("claude:ws-a"); + ccA.on("codexMessage", (msg: BridgeMessage) => { + const reply: BridgeMessage = { + id: `a-${Date.now()}`, + source: "claude", + content: `${PREFIX_A} ${msg.content}`, + timestamp: Date.now(), + }; + void ccA.sendReply(reply); + }); + + const ccB = new DaemonClient(ctrlUrl); + await ccB.connect(); + register(() => ccB.disconnect()); + ccB.attachClaude("claude:ws-b"); + ccB.on("codexMessage", (msg: BridgeMessage) => { + const reply: BridgeMessage = { + id: `b-${Date.now()}`, + source: "claude", + content: `${PREFIX_B} ${msg.content}`, + timestamp: Date.now(), + }; + void ccB.sendReply(reply); + }); + + // Let both attaches settle before starting ACP work. + await new Promise((r) => setTimeout(r, 100)); + + const acpA = spawnAcp(stateDir, controlPort!, "claude:ws-a"); + const acpB = spawnAcp(stateDir, controlPort!, "claude:ws-b"); + + const { client: clientA, updates: updatesA } = clientForAcp(acpA); + const { client: clientB, updates: updatesB } = clientForAcp(acpB); + + // Initialise both subprocesses concurrently. + await Promise.all([ + clientA.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }), + clientB.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }), + ]); + const [sessionA, sessionB] = await Promise.all([ + clientA.newSession({ cwd: "/tmp/p10-10-a", mcpServers: [] }), + clientB.newSession({ cwd: "/tmp/p10-10-b", mcpServers: [] }), + ]); + + const USER_TEXT_A = "hello from acp-a"; + const USER_TEXT_B = "hello from acp-b"; + + const [respA, respB] = await Promise.all([ + clientA.prompt({ + sessionId: sessionA.sessionId, + prompt: [{ type: "text", text: USER_TEXT_A }], + }), + clientB.prompt({ + sessionId: sessionB.sessionId, + prompt: [{ type: "text", text: USER_TEXT_B }], + }), + ]); + expect(respA.stopReason).toBe("end_turn"); + expect(respB.stopReason).toBe("end_turn"); + + await new Promise((r) => setImmediate(r)); + + // Subprocess A must ONLY see CC-A's prefix; subprocess B, ONLY + // CC-B's. Cross-contamination would show up as the other prefix + // appearing in the wrong subprocess's update stream. + const chunksA = updatesA + .filter((u) => u.kind === "agent_message_chunk") + .map((u) => u.text ?? ""); + const chunksB = updatesB + .filter((u) => u.kind === "agent_message_chunk") + .map((u) => u.text ?? ""); + + expect(chunksA.some((t) => t.startsWith(PREFIX_A))).toBe(true); + expect(chunksA.some((t) => t.startsWith(PREFIX_B))).toBe(false); + + expect(chunksB.some((t) => t.startsWith(PREFIX_B))).toBe(true); + expect(chunksB.some((t) => t.startsWith(PREFIX_A))).toBe(false); + + // Nothing should have fallen through to an echo executor — that + // would mean the router didn't see the attached CC. + expect(chunksA.some((t) => t.startsWith("Echo:"))).toBe(false); + expect(chunksB.some((t) => t.startsWith("Echo:"))).toBe(false); + }, 45_000); +}); diff --git a/src/cli/reply-target.test.ts b/src/cli/reply-target.test.ts new file mode 100644 index 0000000..22a5250 --- /dev/null +++ b/src/cli/reply-target.test.ts @@ -0,0 +1,201 @@ +/** + * P10.8 — `reply` tool target routing integration test. + * + * Boots a real daemon, attaches two stub CCs (`claude:ws-a`, + * `claude:ws-b`), and verifies every branch of the new outbound + * routing via the control-plane `claude_to_codex` frame: + * + * - **forward**: CC-a sends a reply with `target="claude:ws-b"`; + * CC-b receives it as `codex_to_claude`; the sender sees a + * successful `claude_to_codex_result`. + * - **unknown target**: CC-a sends with a non-attached target; + * daemon returns `success: false` with a descriptive error. + * - **omit target**: CC-a sends with no target and no Codex/ACP + * inbound turn in flight; daemon returns the v0.1 "Codex not + * ready" error — proving the absent-target path is unchanged. + */ +import { describe, test, expect, afterEach } from "bun:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { DaemonClient } from "@plugin/daemon-client/daemon-client"; +import type { BridgeMessage } from "@messages/types"; + +const DAEMON_SRC = fileURLToPath( + new URL("../runtime-daemon/daemon.ts", import.meta.url), +); + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) { + try { + await cleanups.pop()!(); + } catch {} + } +}); +function register(fn: () => Promise | void) { + cleanups.push(fn); +} + +function pickPorts(count: number): number[] { + const base = 17200 + Math.floor(Math.random() * 300); + return Array.from({ length: count }, (_, i) => base + i); +} + +async function waitForHealthz(port: number, timeoutMs = 10_000): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + try { + const res = await fetch(`http://127.0.0.1:${port}/healthz`); + if (res.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`daemon did not become healthy on port ${port} within ${timeoutMs}ms`); +} + +async function startDaemon(opts: { + stateDir: string; + controlPort: number; + codexWsPort: number; + codexProxyPort: number; +}): Promise { + const proc = spawn("bun", ["run", DAEMON_SRC], { + env: { + ...process.env, + A2A_BRIDGE_STATE_DIR: opts.stateDir, + A2A_BRIDGE_CONTROL_PORT: String(opts.controlPort), + CODEX_WS_PORT: String(opts.codexWsPort), + CODEX_PROXY_PORT: String(opts.codexProxyPort), + A2A_BRIDGE_BEARER_TOKEN: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + register(async () => { + if (!proc.killed) proc.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 100)); + if (!proc.killed) proc.kill("SIGKILL"); + }); + await waitForHealthz(opts.controlPort); + return proc; +} + +describe("P10.8 reply tool target routing", () => { + test("forward / unknown / omit paths all behave correctly", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "a2a-bridge-p10-8-")); + register(() => rmSync(stateDir, { recursive: true, force: true })); + + const [controlPort, codexWsPort, codexProxyPort] = pickPorts(3); + await startDaemon({ + stateDir, + controlPort: controlPort!, + codexWsPort: codexWsPort!, + codexProxyPort: codexProxyPort!, + }); + + const ctrlUrl = `ws://127.0.0.1:${controlPort}/ws`; + + // Attach two stub CCs under distinct TargetIds. + const ccA = new DaemonClient(ctrlUrl); + await ccA.connect(); + register(() => ccA.disconnect()); + ccA.attachClaude("claude:ws-a"); + + const ccB = new DaemonClient(ctrlUrl); + await ccB.connect(); + register(() => ccB.disconnect()); + ccB.attachClaude("claude:ws-b"); + + // Give the daemon time to register both attaches before we start + // exercising routes — otherwise the first sendReply could land + // before `attachedClaudeByTarget` has the destination entry. + await new Promise((r) => setTimeout(r, 100)); + + // Buffer every codex_to_claude frame CC-b receives so we can + // assert post-hoc that exactly the right one arrived. + const bInbox: BridgeMessage[] = []; + ccB.on("codexMessage", (m) => bInbox.push(m)); + + // --- 1) forward: CC-a targets CC-b directly. --------------------- + const forwardResult = await ccA.sendReply( + { + id: "fwd-1", + source: "claude", + content: "hello from ws-a", + timestamp: Date.now(), + }, + false, + "claude:ws-b", + ); + expect(forwardResult.success).toBe(true); + expect(forwardResult.error).toBeUndefined(); + // Allow the asynchronous delivery to CC-b to land. + await new Promise((r) => setTimeout(r, 60)); + const forwarded = bInbox.find((m) => m.content === "hello from ws-a"); + expect(forwarded).toBeDefined(); + expect(forwarded?.source).toBe("claude"); + + // --- 2) unknown target: daemon must reject gracefully. ----------- + const unknownResult = await ccA.sendReply( + { + id: "unk-1", + source: "claude", + content: "should not arrive anywhere", + timestamp: Date.now(), + }, + false, + "claude:ws-nonexistent", + ); + expect(unknownResult.success).toBe(false); + expect(unknownResult.error ?? "").toMatch(/not attached/i); + + // --- 3) omit target: preserves v0.1 behaviour. ------------------- + // With no Codex TUI, the daemon's "Codex not ready" branch is the + // one observable proof that we took the legacy path. + const omitResult = await ccA.sendReply( + { + id: "omit-1", + source: "claude", + content: "no target — should fall through", + timestamp: Date.now(), + }, + false, + ); + expect(omitResult.success).toBe(false); + expect(omitResult.error ?? "").toMatch(/codex is not ready/i); + }, 25_000); + + test("invalid TargetId shape is rejected before any routing", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "a2a-bridge-p10-8-bad-")); + register(() => rmSync(stateDir, { recursive: true, force: true })); + + const [controlPort, codexWsPort, codexProxyPort] = pickPorts(3); + await startDaemon({ + stateDir, + controlPort: controlPort!, + codexWsPort: codexWsPort!, + codexProxyPort: codexProxyPort!, + }); + + const ccA = new DaemonClient(`ws://127.0.0.1:${controlPort}/ws`); + await ccA.connect(); + register(() => ccA.disconnect()); + ccA.attachClaude("claude:ws-a"); + await new Promise((r) => setTimeout(r, 80)); + + const res = await ccA.sendReply( + { + id: "bad-1", + source: "claude", + content: "x", + timestamp: Date.now(), + }, + false, + "NotAValidTarget", + ); + expect(res.success).toBe(false); + expect(res.error ?? "").toMatch(/invalid target/i); + }, 20_000); +}); diff --git a/src/runtime-daemon/daemon.ts b/src/runtime-daemon/daemon.ts index 15dd19e..c9cfe45 100644 --- a/src/runtime-daemon/daemon.ts +++ b/src/runtime-daemon/daemon.ts @@ -17,12 +17,14 @@ import type { Connection } from "@transport/listener"; import { WebSocketListener } from "@transport/websocket"; import type { ControlClientMessage, ControlServerMessage, DaemonStatus } from "@transport/control-protocol"; import type { BridgeMessage } from "@messages/types"; +import { parseTarget, type TargetId } from "@shared/target-id"; import { DaemonClaudeCodeGateway } from "@daemon/inbound/daemon-claude-code-gateway"; import { Room } from "@daemon/rooms/room"; import { RoomRouter } from "@daemon/rooms/room-router"; import { DEFAULT_ROOM_ID } from "@daemon/rooms/room-id"; import { SqliteTaskLog } from "@daemon/tasks/task-log"; import { + parseContextRoutes, startA2AServer, type A2aServerHandle, } from "@daemon/inbound/a2a-http/server"; @@ -62,10 +64,25 @@ const daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_POR let controlListener: WebSocketListener | null = null; let a2aInboundServer: A2aServerHandle | null = null; +// P10.3 — Map of currently attached Claude Code +// instances. v0.1 single-CC behaviour is the special case where the +// only key is "claude:default". `attachedClaude` (singular) is kept +// as a back-compat pointer to "the most-recently attached CC" so the +// existing emitToClaude / broadcast paths keep working unchanged +// until P10.4 / P10.7 wire per-target routing through the gateway. +const attachedClaudeByTarget = new Map(); +const attachedAtByTarget = new Map(); let attachedClaude: Connection | null = null; const controlClientMeta = new WeakMap(); +const claudeConnTarget = new WeakMap(); let nextControlClientId = 0; +// v0.1 default gateway — used by `defaultRoom` (room id `"default"`) +// for backward-compat A2A HTTP turns (no contextRoutes, no explicit +// target). Delivers via `emitToClaude` which hits the singleton +// `attachedClaude`. Per-target rooms get their own gateway below +// via the RoomRouter factory, so each target routes to its own +// attached CC instead of the singleton (P10.10). const inboundGateway = new DaemonClaudeCodeGateway({ sendToClaude: (text) => { emitToClaude(systemMessage("a2a_inbound", text, "acp")); @@ -73,10 +90,27 @@ const inboundGateway = new DaemonClaudeCodeGateway({ log: (msg) => log(`[A2aGateway] ${msg}`), }); -// Daemon-side handler for ACP turn relay (P8.2). Handles acp_turn_start / -// acp_turn_cancel messages from `a2a-bridge acp` subprocesses. -const acpTurnHandler = new AcpTurnHandler(inboundGateway, (msg) => - log(`[AcpTurnHandler] ${msg}`), +// Daemon-side handler for ACP turn relay (P8.2 / P10.10). +// Takes a `gatewayForTarget` function instead of a fixed gateway +// so each inbound ACP turn resolves its own per-room gateway (keyed +// by TargetId) and inbound text routes only to that target's CC. +const acpTurnHandler = new AcpTurnHandler( + async (target) => { + const room = await inboundRoomRouter.getOrCreateByTarget(target as TargetId); + return room.gateway; + }, + (msg) => log(`[AcpTurnHandler] ${msg}`), + { + // P10.4 — only accept turns whose target has an attached CC + // connection. Bare claude:default falls back to the legacy + // "any attached CC" check so v0.1 single-attach setups keep + // working when the subprocess doesn't send a target. + isTargetAttached: (target) => { + if (attachedClaudeByTarget.has(target)) return true; + if (target === "claude:default" && attachedClaude !== null) return true; + return false; + }, + }, ); // One daemon-wide task log; every Room tracks through this shared store @@ -96,8 +130,19 @@ const defaultRoom = new Room({ }); const codex = defaultRoom.getPeer("codex") as CodexAdapter; const attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`; +// P10.10 — each non-default Room gets its own gateway whose +// `sendToClaude` routes to the Room's TargetId via +// `emitToClaudeTarget`, so concurrent ACP turns for different +// targets deliver only to their own attached CC. const inboundRoomRouter = new RoomRouter( - (id) => new Room({ id, gateway: inboundGateway, registry: sharedTaskStore }), + (id) => { + const roomGateway = new DaemonClaudeCodeGateway({ + sendToClaude: (text) => + emitToClaudeTarget(id as string, systemMessage("a2a_inbound", text, "acp")), + log: (msg) => log(`[A2aGateway:${id}] ${msg}`), + }); + return new Room({ id, gateway: roomGateway, registry: sharedTaskStore }); + }, ); inboundRoomRouter.adopt(defaultRoom); let nextSystemMessageId = 0; @@ -334,7 +379,7 @@ function handleControlMessage(conn: Connection, raw: string) { switch (message.type) { case "claude_connect": - attachClaude(conn); + attachClaude(conn, message.target, message.force === true); return; case "claude_disconnect": detachClaude(conn, "frontend requested disconnect"); @@ -343,7 +388,7 @@ function handleControlMessage(conn: Connection, raw: string) { sendStatus(conn); return; case "acp_turn_start": - acpTurnHandler.handleTurnStart(conn, message); + void acpTurnHandler.handleTurnStart(conn, message); return; case "acp_turn_cancel": acpTurnHandler.handleTurnCancel(conn, message); @@ -380,6 +425,15 @@ function handleControlMessage(conn: Connection, raw: string) { case "acp_permission_response": acpTurnHandler.handlePermissionResponse(conn, message); return; + case "list_targets": { + const entries = listTargetEntries(); + sendProtocolMessage(conn, { + type: "targets_response", + requestId: message.requestId, + targets: entries, + }); + return; + } case "claude_to_codex": { if (message.message.source !== "claude") { sendProtocolMessage(conn, { @@ -391,10 +445,94 @@ function handleControlMessage(conn: Connection, raw: string) { return; } - // If an A2A inbound turn is in flight, the reply belongs to it, - // not to Codex. Delivers the chunk + completes the turn. - if (inboundGateway.interceptReply(message.message.content)) { - log(`Claude reply consumed by inbound A2A turn (${message.message.content.length} chars)`); + // P10.8 — optional `target` on the reply frame overrides default + // routing. `claude:*` targets deliver to that attached CC via + // `sendBridgeMessage`; `codex:default` falls through to today's + // injection path; anything else is a routing error surfaced back + // to the calling CC. + if (message.target !== undefined) { + const parsed = parseTarget(message.target); + if (!parsed.ok) { + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: false, + error: `Invalid target "${message.target}": ${parsed.error}`, + }); + return; + } + const targetStr = parsed.target as unknown as string; + if (parsed.parts.kind === "claude") { + // Deliver directly to the named CC attach. Surface `claude:default` + // via the legacy singleton when no explicit entry exists so v0.1 + // setups still round-trip a reply. + const destConn = + attachedClaudeByTarget.get(targetStr) ?? + (targetStr === "claude:default" ? attachedClaude : null); + if (!destConn) { + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: false, + error: `target ${targetStr} is not attached`, + }); + return; + } + if (destConn === conn) { + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: false, + error: `target ${targetStr} resolves to the sender — replies cannot loop back to self`, + }); + return; + } + sendBridgeMessage(destConn, message.message); + clearAttentionWindow(); + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: true, + }); + return; + } + if (parsed.parts.kind === "codex") { + // Codex peer id routing lands in P10.9 — today's daemon only + // hosts one Codex adapter, so only `codex:default` is valid. + if (parsed.parts.id !== "default") { + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: false, + error: `target ${targetStr} not recognized (only codex:default is supported until P10.9)`, + }); + return; + } + // Fall through to the existing codex injection path below. + } else { + sendProtocolMessage(conn, { + type: "claude_to_codex_result", + requestId: message.requestId, + success: false, + error: `Unsupported target kind "${parsed.parts.kind}"`, + }); + return; + } + } + + // If an A2A / ACP inbound turn is in flight for this CC's + // target, the reply belongs to it (not Codex). P10.10: pick + // the sender's per-target Room gateway so a reply from CC-A + // can't accidentally complete CC-B's in-flight turn. + const senderTarget = claudeConnTarget.get(conn) ?? "claude:default"; + const senderRoom = inboundRoomRouter.getByTarget(senderTarget as TargetId); + const senderGateway = + (senderRoom?.gateway as DaemonClaudeCodeGateway | undefined) ?? inboundGateway; + if (senderGateway.interceptReply(message.message.content)) { + log( + `Claude reply consumed by inbound A2A/ACP turn on ${senderTarget} ` + + `(${message.message.content.length} chars)`, + ); clearAttentionWindow(); sendProtocolMessage(conn, { type: "claude_to_codex_result", @@ -448,10 +586,71 @@ function handleControlMessage(conn: Connection, raw: string) { } } -function attachClaude(conn: Connection) { - if (attachedClaude && attachedClaude !== conn) { - attachedClaude.close(); +function attachClaude(conn: Connection, target?: string, force: boolean = false) { + // P10.3 — accept an optional `kind:id` target so multiple CC + // instances can attach to the same daemon. v0.1 frames omit the + // target field; default it to the canonical `claude:default`. + // Target validation lives in the parser (P10.1); reuse it so we + // never let a malformed string into our maps. + let resolvedTarget = "claude:default"; + if (target) { + const parsed = parseTarget(target); + if (!parsed.ok) { + log(`Rejecting claude_connect with invalid target "${target}": ${parsed.error}`); + sendProtocolMessage(conn, { + type: "claude_connect_rejected", + target, + reason: `invalid target: ${parsed.error}`, + }); + return; + } + if (parsed.parts.kind !== "claude") { + log(`Rejecting claude_connect with non-claude target "${target}"`); + sendProtocolMessage(conn, { + type: "claude_connect_rejected", + target, + reason: `non-claude target rejected on claude_connect`, + }); + return; + } + resolvedTarget = parsed.target as unknown as string; + } + + // P10.6 — conflict policy. If a different connection already owns + // this target, either reject the new attach (default) or kick the + // old one (force=true). The existing `attachedClaudeByTarget` + // entry is the single source of truth. + const existing = attachedClaudeByTarget.get(resolvedTarget); + if (existing && existing !== conn) { + if (!force) { + const existingMeta = controlClientMeta.get(existing); + const attachedAt = attachedAtByTarget.get(resolvedTarget); + const ageMs = attachedAt !== undefined ? Date.now() - attachedAt : null; + const ageHint = ageMs !== null ? `, attached ${formatAttachAge(ageMs)}` : ""; + const connHint = existingMeta ? `plugin conn #${existingMeta.clientId}${ageHint}` : "unknown"; + const reason = + `target ${resolvedTarget} already attached (${connHint}). ` + + `Re-run with --force to take over, or use a different workspace id.`; + log(`Rejecting claude_connect for ${resolvedTarget} — ${connHint}`); + sendProtocolMessage(conn, { + type: "claude_connect_rejected", + target: resolvedTarget, + reason, + }); + return; + } + log(`Force-replacing existing attachment for ${resolvedTarget}`); + // Tell the old attach it was kicked *before* closing its socket so + // the plugin can push a notification to its CC session. + sendProtocolMessage(existing, { + type: "claude_connect_replaced", + target: resolvedTarget, + }); + existing.close(); } + attachedClaudeByTarget.set(resolvedTarget, conn); + attachedAtByTarget.set(resolvedTarget, Date.now()); + claudeConnTarget.set(conn, resolvedTarget); const meta = controlClientMeta.get(conn); @@ -459,7 +658,7 @@ function attachClaude(conn: Connection) { attachedClaude = conn; if (meta) meta.attached = true; cancelIdleShutdown(); - log(`Claude frontend attached (#${meta?.clientId ?? "?"})`); + log(`Claude frontend attached (#${meta?.clientId ?? "?"}) → ${resolvedTarget}`); statusBuffer.flush("claude reconnected"); sendStatus(conn); @@ -486,10 +685,26 @@ function attachClaude(conn: Connection) { } function detachClaude(conn: Connection, reason: string) { + // Drop this conn from the per-target map regardless of whether it + // is the global "attachedClaude" pointer — multi-target attaches + // need cleanup either way. + const target = claudeConnTarget.get(conn); + if (target && attachedClaudeByTarget.get(target) === conn) { + attachedClaudeByTarget.delete(target); + attachedAtByTarget.delete(target); + } + claudeConnTarget.delete(conn); + if (attachedClaude !== conn) return; const meta = controlClientMeta.get(conn); - attachedClaude = null; + // Promote any other currently-attached CC to the global pointer so + // emitToClaude / broadcast still has a destination. When nothing is + // left, clear it. The next iteration order preserves "most recently + // inserted survives". + let nextAttached: Connection | null = null; + for (const c of attachedClaudeByTarget.values()) nextAttached = c; + attachedClaude = nextAttached; if (meta) meta.attached = false; log(`Claude frontend detached (#${meta?.clientId ?? "?"}, ${reason})`); @@ -600,6 +815,27 @@ function scheduleClaudeDisconnectNotification(clientId: number) { }, CLAUDE_DISCONNECT_GRACE_MS); } +/** + * P10.10 — target-aware delivery. Picks the Connection registered for + * `target` in `attachedClaudeByTarget`; for `claude:default` we also + * accept the legacy `attachedClaude` singleton so v0.1 setups that + * didn't send a target field still receive inbound traffic. When no + * CC is attached for the target, the message is dropped with a log — + * callers (ACP turn handler) already gate on `isTargetAttached`, and + * broadcast-style buffering across unrelated targets would leak data. + */ +function emitToClaudeTarget(target: string, message: BridgeMessage) { + const dest = + attachedClaudeByTarget.get(target) ?? + (target === "claude:default" ? attachedClaude : null); + if (dest && dest.isOpen) { + if (trySendBridgeMessage(dest, message)) return; + log(`Send to ${target} failed — dropping message (buffering not per-target yet)`); + return; + } + log(`No CC attached for ${target} — dropping inbound message`); +} + function emitToClaude(message: BridgeMessage) { if (attachedClaude && attachedClaude.isOpen) { if (trySendBridgeMessage(attachedClaude, message)) return; @@ -691,6 +927,60 @@ function shouldNotifyCodexClaudeOnline() { return !claudeOnlineNoticeSent || claudeOfflineNoticeShown; } +/** + * Render a conflict-reject attach age like `2h ago` / `3m ago` / `9s ago` + * for the human-readable `claude_connect_rejected.reason` string. + */ +function formatAttachAge(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return "just now"; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + +function listTargetEntries() { + // P10.5 — snapshot every TargetId the daemon currently tracks. + // Today's daemon only knows about Claude attachments via + // attachedClaudeByTarget; future Codex / Hermes peer adapters + // will register their own targets through the same registry. + const entries: import("@transport/control-protocol").TargetEntry[] = []; + for (const [target, conn] of attachedClaudeByTarget.entries()) { + const meta = controlClientMeta.get(conn); + entries.push({ + target, + attached: true, + ...(meta ? { clientId: meta.clientId } : {}), + ...(attachedAtByTarget.has(target) ? { attachedAt: attachedAtByTarget.get(target)! } : {}), + }); + } + // Surface the legacy `attachedClaude` singleton as `claude:default` + // ONLY when no per-target attach already covers it. Without this + // check we'd print a phantom `claude:default` row whenever any v0.2 + // attach lands (the singleton always tracks the most-recent attach, + // so it aliases an already-listed per-target conn). + if (attachedClaude && !attachedClaudeByTarget.has("claude:default")) { + let alreadyListed = false; + for (const conn of attachedClaudeByTarget.values()) { + if (conn === attachedClaude) { + alreadyListed = true; + break; + } + } + if (!alreadyListed) { + const meta = controlClientMeta.get(attachedClaude); + entries.push({ + target: "claude:default", + attached: true, + ...(meta ? { clientId: meta.clientId } : {}), + }); + } + } + return entries; +} + function systemMessage(idPrefix: string, content: string, source: BridgeMessage["source"] = "codex"): BridgeMessage { return { id: `${idPrefix}_${++nextSystemMessageId}`, @@ -775,6 +1065,11 @@ async function bootInbound() { roomRouter: inboundRoomRouter, executorFactory: (gateway: import("@daemon/inbound/a2a-http/claude-code-gateway").ClaudeCodeGateway) => createClaudeCodeExecutor({ gateway }), + // P10.7 — operator-supplied contextId → TargetId map. Format: + // `A2A_BRIDGE_CONTEXT_ROUTES='{"ctx-alice":"claude:alice"}'`. + // Malformed JSON is logged and the map is dropped so a typo + // degrades to v0.1 routing rather than bringing A2A down. + ...(parseContextRoutes(process.env.A2A_BRIDGE_CONTEXT_ROUTES, log) ?? {}), }; try { diff --git a/src/runtime-daemon/inbound/a2a-http/server.test.ts b/src/runtime-daemon/inbound/a2a-http/server.test.ts index 6cd360b..2c84430 100644 --- a/src/runtime-daemon/inbound/a2a-http/server.test.ts +++ b/src/runtime-daemon/inbound/a2a-http/server.test.ts @@ -1,5 +1,9 @@ import { describe, test, expect, afterEach } from "bun:test"; -import { startA2AServer, type A2aServerHandle } from "@daemon/inbound/a2a-http/server"; +import { + parseContextRoutes, + startA2AServer, + type A2aServerHandle, +} from "@daemon/inbound/a2a-http/server"; import { Room } from "@daemon/rooms/room"; import { RoomRouter } from "@daemon/rooms/room-router"; import type { RoomId } from "@daemon/rooms/room-id"; @@ -307,6 +311,195 @@ describe("startA2AServer", () => { expect(b).toBe("from-ctx-beta"); expect(router.size).toBe(2); }); + + test("P10.7: contextRoutes maps contextId to TargetId; unmapped falls back to claude:default", async () => { + // Three inbound contextIds: + // - `ctx-alice` is mapped to `claude:alice` → routes to its Room + // - `ctx-bob` is mapped to `claude:bob` → routes to its Room + // - `ctx-stranger` has no mapping → falls back to `claude:default` + // Each Room's stub gateway emits a label derived from its TargetId, + // proving the router keyed on the right entry. + const gatewayByRoom = new Map(); + const roomFactory = (id: RoomId) => { + const gateway = new StubGateway(`room-${id}`); + gatewayByRoom.set(id, gateway); + return new Room({ id, gateway, registry: new TaskRegistry() }); + }; + const router = new RoomRouter(roomFactory); + + const port = randomPort(); + const server = track( + await startA2AServer({ + port, + logger: () => {}, + agentCard: cardConfig(port), + bearerToken: "tok", + publicAgentCard: true, + roomRouter: router, + contextRoutes: { + "ctx-alice": "claude:alice", + "ctx-bob": "claude:bob", + }, + executorFactory: (gateway): MessageStreamExecutor => ({ + taskId, + contextId, + userText, + emit, + }) => + new Promise((resolve) => { + const turn = gateway.startTurn(userText); + void taskId; + void contextId; + emit({ kind: "status-update", state: "working" }); + turn.on("chunk", (text) => { + emit({ + kind: "artifact-update", + artifactId: "out", + text, + append: true, + }); + }); + turn.on("complete", () => { + emit({ + kind: "status-update", + state: "completed", + final: true, + message: { + kind: "message", + messageId: "m", + role: "agent", + parts: [{ kind: "text", text: "done" }], + }, + }); + resolve(); + }); + }), + }), + ); + + const postMessage = async (contextId: string) => { + const resp = await fetch(`http://localhost:${server.port}${server.rpcPath}`, { + method: "POST", + headers: { authorization: "Bearer tok", "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "message/stream", + params: { + message: { contextId, parts: [{ kind: "text", text: "hi" }] }, + }, + id: contextId, + }), + }); + expect(resp.status).toBe(200); + const text = await resp.text(); + const frames = text + .split("\n\n") + .filter((r) => r.startsWith("data: ")) + .map((r) => JSON.parse(r.slice("data: ".length))); + const joined = frames + .filter((f) => f.result.kind === "artifact-update") + .map((f) => (f.result.artifact.parts[0] as { text: string }).text) + .join(""); + return joined; + }; + + const pAlice = postMessage("ctx-alice"); + const pBob = postMessage("ctx-bob"); + const pStranger = postMessage("ctx-stranger"); + await new Promise((r) => setTimeout(r, 20)); + gatewayByRoom.get("claude:alice")!.pushAndComplete(); + gatewayByRoom.get("claude:bob")!.pushAndComplete(); + gatewayByRoom.get("claude:default")!.pushAndComplete(); + const [a, b, d] = await Promise.all([pAlice, pBob, pStranger]); + + expect(a).toBe("room-claude:alice"); + expect(b).toBe("room-claude:bob"); + expect(d).toBe("room-claude:default"); + // Three distinct Rooms were minted — one per resolved TargetId. + // Notably "ctx-stranger" did NOT mint its own Room (which it + // would under v0.1 deriveRoomId). + expect(router.size).toBe(3); + expect(router.get("claude:alice" as unknown as RoomId)).toBeDefined(); + expect(router.get("claude:default" as unknown as RoomId)).toBeDefined(); + expect(router.get("ctx-stranger" as unknown as RoomId)).toBeUndefined(); + }); + + test("P10.7: contextRoutes rejects a malformed TargetId at startup", async () => { + const router = new RoomRouter((id: RoomId) => { + const gateway = new StubGateway(`room-${id}`); + return new Room({ id, gateway, registry: new TaskRegistry() }); + }); + const port = randomPort(); + await expect( + startA2AServer({ + port, + logger: () => {}, + agentCard: cardConfig(port), + bearerToken: "tok", + publicAgentCard: true, + roomRouter: router, + contextRoutes: { "ctx-bad": "NotAValidTarget" }, + executorFactory: () => async () => {}, + }), + ).rejects.toThrow(/contextRoutes.*ctx-bad.*NotAValidTarget/); + }); + + test("P10.7: contextRoutes without roomRouter is a startup error", async () => { + const port = randomPort(); + await expect( + startA2AServer({ + port, + logger: () => {}, + agentCard: cardConfig(port), + bearerToken: "tok", + publicAgentCard: true, + contextRoutes: { "ctx-a": "claude:alice" }, + }), + ).rejects.toThrow(/contextRoutes requires roomRouter/); + }); +}); + +describe("parseContextRoutes", () => { + test("returns null when env var is unset or empty", () => { + expect(parseContextRoutes(undefined)).toBeNull(); + expect(parseContextRoutes("")).toBeNull(); + expect(parseContextRoutes(" ")).toBeNull(); + }); + + test("parses a valid JSON object", () => { + const res = parseContextRoutes(`{"a":"claude:alice","b":"claude:bob"}`); + expect(res).toEqual({ + contextRoutes: { a: "claude:alice", b: "claude:bob" }, + }); + }); + + test("returns null and logs on malformed JSON", () => { + const logs: string[] = []; + expect(parseContextRoutes("{bad", (m) => logs.push(m))).toBeNull(); + expect(logs.join("\n")).toMatch(/not valid JSON/); + }); + + test("drops entries whose value is not a string", () => { + const logs: string[] = []; + const res = parseContextRoutes( + `{"a":"claude:alice","b":42,"c":null}`, + (m) => logs.push(m), + ); + expect(res).toEqual({ contextRoutes: { a: "claude:alice" } }); + expect(logs.join("\n")).toMatch(/"b".*not a string/); + }); + + test("returns null when the object is empty (or all entries were dropped)", () => { + expect(parseContextRoutes(`{}`)).toBeNull(); + expect(parseContextRoutes(`{"a":42}`)).toBeNull(); + }); + + test("rejects arrays and primitives", () => { + const logs: string[] = []; + expect(parseContextRoutes(`[]`, (m) => logs.push(m))).toBeNull(); + expect(parseContextRoutes(`"string"`, (m) => logs.push(m))).toBeNull(); + expect(logs.join("\n")).toMatch(/expected a JSON object/); + }); }); class StubGateway implements ClaudeCodeGateway { diff --git a/src/runtime-daemon/inbound/a2a-http/server.ts b/src/runtime-daemon/inbound/a2a-http/server.ts index a0c0f9c..bfafbcd 100644 --- a/src/runtime-daemon/inbound/a2a-http/server.ts +++ b/src/runtime-daemon/inbound/a2a-http/server.ts @@ -1,4 +1,5 @@ import { createLogger, type Logger } from "@shared/logger"; +import { parseTarget, type TargetId } from "@shared/target-id"; import { AGENT_CARD_PATH, checkBearerAuth } from "@daemon/inbound/a2a-http/auth"; import { buildAgentCard, @@ -14,7 +15,7 @@ import { import type { ITaskStore } from "@daemon/tasks/task-store"; import { SqliteTaskLog } from "@daemon/tasks/task-log"; import type { RoomRouter } from "@daemon/rooms/room-router"; -import { deriveRoomId } from "@daemon/rooms/room-id"; +import { deriveRoomId, type RoomId } from "@daemon/rooms/room-id"; import type { ClaudeCodeGateway } from "@daemon/inbound/a2a-http/claude-code-gateway"; import { createEchoExecutor, @@ -25,6 +26,9 @@ import { import { createTasksGetHandler } from "@daemon/inbound/a2a-http/handlers/tasks-get"; import { createTasksCancelHandler } from "@daemon/inbound/a2a-http/handlers/tasks-cancel"; +/** Fallback TargetId when contextRoutes is supplied but the inbound contextId has no mapping. */ +const A2A_FALLBACK_TARGET = "claude:default"; + /** * A2A-over-HTTP server. * @@ -65,6 +69,21 @@ export interface A2aServerConfig { * Required when `roomRouter` is supplied; ignored otherwise. */ executorFactory?: (gateway: ClaudeCodeGateway) => MessageStreamExecutor; + /** + * P10.7 — `contextId → TargetId` routing map. When supplied, every + * inbound `message/stream` turn resolves its target by looking up + * its `contextId` in this map; unmapped contexts fall back to + * `claude:default`. The resolved TargetId keys the Room (via + * `RoomRouter.getOrCreateByTarget`) so multi-CC deployments can + * carve A2A traffic across distinct CC instances. + * + * Omitted (or absent/empty) → server preserves v0.1 behaviour where + * `deriveRoomId({ contextId })` keys each contextId as its own Room. + * Requires `roomRouter`; the server throws at startup otherwise. + * Every value is validated via `parseTarget` — a bad entry is a + * configuration error, not a runtime failure. + */ + contextRoutes?: Record; /** * Shared task store. Callers supply an `ITaskStore` (either the * in-memory `TaskRegistry` or a `SqliteTaskLog`); when omitted a @@ -114,6 +133,26 @@ export async function startA2AServer(config: A2aServerConfig): Promise 0; + if (hasRoutes && !roomRouter) { + throw new Error( + "startA2AServer: contextRoutes requires roomRouter — multi-target routing needs a Room registry to dispatch into", + ); + } + if (contextRoutes) { + for (const [ctxId, target] of Object.entries(contextRoutes)) { + const parsed = parseTarget(target); + if (!parsed.ok) { + throw new Error( + `startA2AServer: contextRoutes[${JSON.stringify(ctxId)}] = "${target}" is not a valid TargetId (${parsed.error})`, + ); + } + } + } + const handlers: JsonRpcHandlers = { "tasks/get": createTasksGetHandler(registry), "tasks/cancel": createTasksCancelHandler(registry), @@ -167,13 +206,27 @@ export async function startA2AServer(config: A2aServerConfig): Promise | undefined; + let resolvedRoomId: RoomId | undefined; if (roomRouter && executorFactory) { - resolvedRoomId = deriveRoomId({ - contextId: params.message.contextId, - }); - const room = await roomRouter.getOrCreate(resolvedRoomId); - requestExecutor = executorFactory(room.gateway); + if (hasRoutes && contextRoutes) { + // P10.7 — resolve contextId → TargetId via the operator + // config, falling back to `claude:default` for any + // unmapped context. The TargetId doubles as the Room + // key, so multi-tenant A2A traffic lands in the right CC. + const ctxId = params.message.contextId; + const targetStr = + (ctxId !== undefined && contextRoutes[ctxId]) || A2A_FALLBACK_TARGET; + const target = targetStr as unknown as TargetId; + const room = await roomRouter.getOrCreateByTarget(target); + resolvedRoomId = targetStr as unknown as RoomId; + requestExecutor = executorFactory(room.gateway); + } else { + resolvedRoomId = deriveRoomId({ + contextId: params.message.contextId, + }); + const room = await roomRouter.getOrCreate(resolvedRoomId); + requestExecutor = executorFactory(room.gateway); + } } return handleMessageStream({ rpcId: normalizeId(rpcId), @@ -207,6 +260,47 @@ export async function startA2AServer(config: A2aServerConfig): Promise void, +): { contextRoutes: Record } | null { + if (!raw || raw.trim().length === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + log?.( + `A2A_BRIDGE_CONTEXT_ROUTES ignored — not valid JSON: ${(err as Error).message}`, + ); + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + log?.(`A2A_BRIDGE_CONTEXT_ROUTES ignored — expected a JSON object`); + return null; + } + const out: Record = {}; + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v !== "string") { + log?.( + `A2A_BRIDGE_CONTEXT_ROUTES[${JSON.stringify(k)}] ignored — value is not a string`, + ); + continue; + } + out[k] = v; + } + if (Object.keys(out).length === 0) return null; + return { contextRoutes: out }; +} + function extractPath(url: string): string { try { return new URL(url).pathname || "/"; diff --git a/src/runtime-daemon/inbound/acp/acp.test.ts b/src/runtime-daemon/inbound/acp/acp.test.ts index 23b6123..48c88ac 100644 --- a/src/runtime-daemon/inbound/acp/acp.test.ts +++ b/src/runtime-daemon/inbound/acp/acp.test.ts @@ -116,7 +116,25 @@ describe("AcpInboundService handshake (P5.3)", () => { expect(resp.protocolVersion).toBe(PROTOCOL_VERSION); expect(resp.agentInfo?.name).toBe("a2a-bridge"); expect(resp.agentCapabilities).toBeDefined(); - expect(resp.agentCapabilities?.loadSession).toBe(false); + // P10-follow-up: loadSession is advertised so OpenClaw acpx's + // persistent-session mode can respawn us and reuse its sessionId. + // Implementation is a stateless no-op that adopts the supplied id. + expect(resp.agentCapabilities?.loadSession).toBe(true); + }); + + test("loadSession accepts an existing session id as a stateless no-op", async () => { + const { service, client } = buildInMemoryPair(); + await client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const before = service.sessionCount(); + await client.loadSession({ + sessionId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + cwd: "/tmp/p10-loadsession", + mcpServers: [], + }); + expect(service.sessionCount()).toBe(before + 1); }); test("initialize advertises the live package.json version (P8.7)", async () => { diff --git a/src/runtime-daemon/inbound/acp/daemon-proxy-gateway.ts b/src/runtime-daemon/inbound/acp/daemon-proxy-gateway.ts index b858881..4309a65 100644 --- a/src/runtime-daemon/inbound/acp/daemon-proxy-gateway.ts +++ b/src/runtime-daemon/inbound/acp/daemon-proxy-gateway.ts @@ -52,6 +52,13 @@ export interface DaemonProxyGatewayOptions { * and CC silently drops non-identifier keys. */ meta?: AcpTurnMeta; + /** + * TargetId (P10.4) — `kind:id` form selecting which daemon Room + * handles every turn from this subprocess. When omitted, frames + * are sent without a target and the daemon defaults to + * `claude:default` (v0.1 backward compat). + */ + target?: string; /** How long to wait for the daemon WS to open before failing. */ connectTimeoutMs?: number; /** Optional logger; defaults to no-op. */ @@ -74,6 +81,7 @@ export class DaemonProxyGateway private readonly log: (msg: string) => void; private readonly sessionId: string; private readonly meta: AcpTurnMeta | undefined; + private readonly target: string | undefined; private readonly connectTimeoutMs: number; constructor(private readonly opts: DaemonProxyGatewayOptions) { @@ -82,6 +90,7 @@ export class DaemonProxyGateway this.sessionId = opts.sessionId ?? "acp-default"; this.meta = opts.meta; if (this.meta) assertIdentifierSafeKeys(this.meta); + this.target = opts.target; this.connectTimeoutMs = opts.connectTimeoutMs ?? 10_000; } @@ -213,6 +222,7 @@ export class DaemonProxyGateway sessionId: this.sessionId, userText, ...(this.meta ? { meta: this.meta } : {}), + ...(this.target ? { target: this.target } : {}), }; this.log(`startTurn ${turnId} (${userText.length} chars)`); try { diff --git a/src/runtime-daemon/inbound/acp/index.ts b/src/runtime-daemon/inbound/acp/index.ts index 768c61f..3337222 100644 --- a/src/runtime-daemon/inbound/acp/index.ts +++ b/src/runtime-daemon/inbound/acp/index.ts @@ -17,6 +17,8 @@ import { type ContentBlock, type InitializeRequest, type InitializeResponse, + type LoadSessionRequest, + type LoadSessionResponse, type NewSessionRequest, type NewSessionResponse, type PromptRequest, @@ -132,10 +134,14 @@ export class AcpInboundService implements IInboundService { return { protocolVersion: negotiated, agentInfo: { name: pkg.name.split("/").pop() ?? "a2a-bridge", version: pkg.version }, - // Minimum capabilities — no tool-calling or resume surfaces - // advertised until later tasks fill them in. + // `loadSession: true` so ACP clients in "persistent session" + // mode (OpenClaw acpx, Zed's restartable agents) can resume a + // prior sessionId after their subprocess restarts. Our impl + // is stateless across subprocess lifetimes — every turn runs + // fresh through the daemon's gateway — so `loadSession` + // effectively adopts the supplied sessionId as a new session. agentCapabilities: { - loadSession: false, + loadSession: true, promptCapabilities: { image: false, audio: false, embeddedContext: false }, }, }; @@ -145,6 +151,16 @@ export class AcpInboundService implements IInboundService { self.activeSessions.set(sessionId, { cwd: params.cwd }); return { sessionId }; }, + async loadSession(params: LoadSessionRequest): Promise { + // Our ACP server is stateless across subprocess lifetimes — each + // turn is driven fresh through the daemon's gateway, so there's + // no historical state to "restore". Treat `session/load` as a + // lightweight "adopt this sessionId" so clients that persist + // sessionIds across subprocess restarts (OpenClaw acpx's + // persistent-session mode) don't blow up when acpx respawns us. + self.activeSessions.set(params.sessionId, { cwd: params.cwd }); + return {}; + }, async authenticate(_: AuthenticateRequest): Promise { throw NOT_IMPLEMENTED("authenticate"); }, diff --git a/src/runtime-daemon/inbound/acp/turn-handler.test.ts b/src/runtime-daemon/inbound/acp/turn-handler.test.ts index 42dbf0e..3225ed0 100644 --- a/src/runtime-daemon/inbound/acp/turn-handler.test.ts +++ b/src/runtime-daemon/inbound/acp/turn-handler.test.ts @@ -59,13 +59,13 @@ class FakeConnection extends EventEmitter implements Connection { // Tests // --------------------------------------------------------------------------- -describe("AcpTurnHandler — happy path", () => { - test("acp_turn_start forwards text to gateway and relays chunk+complete frames", () => { +describe("AcpTurnHandler — happy path", async () => { + test("acp_turn_start forwards text to gateway and relays chunk+complete frames", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t1", sessionId: "s1", @@ -86,12 +86,12 @@ describe("AcpTurnHandler — happy path", () => { ]); }); - test("gateway error produces an acp_turn_error frame", () => { + test("gateway error produces an acp_turn_error frame", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t2", sessionId: "s1", @@ -106,13 +106,13 @@ describe("AcpTurnHandler — happy path", () => { }); }); -describe("AcpTurnHandler — cancel", () => { - test("acp_turn_cancel calls turn.cancel() and suppresses subsequent events", () => { +describe("AcpTurnHandler — cancel", async () => { + test("acp_turn_cancel calls turn.cancel() and suppresses subsequent events", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t3", sessionId: "s1", @@ -131,10 +131,10 @@ describe("AcpTurnHandler — cancel", () => { expect(conn.sent).toEqual([]); }); - test("cancel for an unknown turnId is a no-op", () => { + test("cancel for an unknown turnId is a no-op", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); // No active turn for this connection expect(() => @@ -142,12 +142,12 @@ describe("AcpTurnHandler — cancel", () => { ).not.toThrow(); }); - test("cancel with wrong turnId on same connection is a no-op", () => { + test("cancel with wrong turnId on same connection is a no-op", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t4", sessionId: "s1", @@ -169,13 +169,13 @@ describe("AcpTurnHandler — cancel", () => { }); }); -describe("AcpTurnHandler — supersede + connection close", () => { - test("a second acp_turn_start on the same connection cancels the previous turn", () => { +describe("AcpTurnHandler — supersede + connection close", async () => { + test("a second acp_turn_start on the same connection cancels the previous turn", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t5", sessionId: "s1", @@ -183,7 +183,7 @@ describe("AcpTurnHandler — supersede + connection close", () => { }); const first = gw.turns[0]!; - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t6", sessionId: "s1", @@ -206,12 +206,12 @@ describe("AcpTurnHandler — supersede + connection close", () => { ]); }); - test("onConnectionClose cancels an in-flight turn", () => { + test("onConnectionClose cancels an in-flight turn", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t7", sessionId: "s1", @@ -228,19 +228,19 @@ describe("AcpTurnHandler — supersede + connection close", () => { expect(conn.sent).toEqual([]); }); - test("onConnectionClose with no active turn is a no-op", () => { + test("onConnectionClose with no active turn is a no-op", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); expect(() => handler.onConnectionClose(conn)).not.toThrow(); }); }); -describe("AcpTurnHandler — permission bridging (P8.2a)", () => { +describe("AcpTurnHandler — permission bridging (P8.2a)", async () => { test("routePermissionRequest auto-denies when no ACP turn is active", async () => { const gw = new StubGateway(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); const outcome = await handler.routePermissionRequest({ requestId: "p1", @@ -254,9 +254,9 @@ describe("AcpTurnHandler — permission bridging (P8.2a)", () => { test("routePermissionRequest sends acp_permission_request to the active connection", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t10", sessionId: "s1", @@ -296,9 +296,9 @@ describe("AcpTurnHandler — permission bridging (P8.2a)", () => { const gw = new StubGateway(); const owner = new FakeConnection(); const other = new FakeConnection(); - const handler = new AcpTurnHandler(gw, undefined, { permissionTimeoutMs: 50 }); + const handler = new AcpTurnHandler(() => gw, undefined, { permissionTimeoutMs: 50 }); - handler.handleTurnStart(owner, { + await handler.handleTurnStart(owner, { type: "acp_turn_start", turnId: "t11", sessionId: "s1", @@ -326,9 +326,9 @@ describe("AcpTurnHandler — permission bridging (P8.2a)", () => { test("onConnectionClose auto-denies pending permissions from that connection", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw, undefined, { permissionTimeoutMs: 60_000 }); + const handler = new AcpTurnHandler(() => gw, undefined, { permissionTimeoutMs: 60_000 }); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t12", sessionId: "s1", @@ -350,9 +350,9 @@ describe("AcpTurnHandler — permission bridging (P8.2a)", () => { test("permission request times out and auto-denies when no answer arrives", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw, undefined, { permissionTimeoutMs: 20 }); + const handler = new AcpTurnHandler(() => gw, undefined, { permissionTimeoutMs: 20 }); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t13", sessionId: "s1", @@ -368,10 +368,10 @@ describe("AcpTurnHandler — permission bridging (P8.2a)", () => { expect(outcome).toBe("deny"); }); - test("unknown permission response is silently dropped", () => { + test("unknown permission response is silently dropped", async () => { const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); expect(() => handler.handlePermissionResponse(conn, { @@ -390,10 +390,10 @@ describe("AcpTurnHandler — permission bridging (P8.2a)", () => { // reply is forwarded back before the turn completes. const gw = new StubGateway(); const conn = new FakeConnection(); - const handler = new AcpTurnHandler(gw); + const handler = new AcpTurnHandler(() => gw); // 1. ACP subprocess opens a turn. - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "turn-e2e", sessionId: "sess-e2e", @@ -442,3 +442,93 @@ describe("AcpTurnHandler — permission bridging (P8.2a)", () => { ]); }); }); + +describe("AcpTurnHandler — target routing (P10.4)", async () => { + test("turn is forwarded when target's CC is attached", async () => { + const gw = new StubGateway(); + const conn = new FakeConnection(); + const attached = new Set(["claude:project-a"]); + const handler = new AcpTurnHandler(() => gw, undefined, { + isTargetAttached: (t) => attached.has(t), + }); + + await handler.handleTurnStart(conn, { + type: "acp_turn_start", + turnId: "t-ok", + sessionId: "s", + userText: "hi", + target: "claude:project-a", + }); + + expect(gw.turns).toHaveLength(1); + gw.lastTurn.emit("chunk", "ok"); + gw.lastTurn.emit("complete"); + expect(conn.sent).toEqual([ + { type: "acp_turn_chunk", turnId: "t-ok", text: "ok" }, + { type: "acp_turn_complete", turnId: "t-ok" }, + ]); + }); + + test("turn is rejected with acp_turn_error when target is unattached", async () => { + const gw = new StubGateway(); + const conn = new FakeConnection(); + const attached = new Set(["claude:project-a"]); + const handler = new AcpTurnHandler(() => gw, undefined, { + isTargetAttached: (t) => attached.has(t), + }); + + await handler.handleTurnStart(conn, { + type: "acp_turn_start", + turnId: "t-ghost", + sessionId: "s", + userText: "hi", + target: "claude:does-not-exist", + }); + + // Gateway was never asked to start a turn. + expect(gw.turns).toHaveLength(0); + // Subprocess got an explicit error frame. + expect(conn.sent).toEqual([ + { + type: "acp_turn_error", + turnId: "t-ghost", + message: "target claude:does-not-exist not attached", + }, + ]); + }); + + test("missing target field defaults to claude:default", async () => { + const gw = new StubGateway(); + const conn = new FakeConnection(); + const attached = new Set(["claude:default"]); + const handler = new AcpTurnHandler(() => gw, undefined, { + isTargetAttached: (t) => attached.has(t), + }); + + await handler.handleTurnStart(conn, { + type: "acp_turn_start", + turnId: "t-default", + sessionId: "s", + userText: "hi", + // No target — should resolve to claude:default and succeed. + }); + + expect(gw.turns).toHaveLength(1); + }); + + test("no isTargetAttached predicate → every target accepted (v0.1 compat)", async () => { + const gw = new StubGateway(); + const conn = new FakeConnection(); + const handler = new AcpTurnHandler(() => gw); // no opts + + await handler.handleTurnStart(conn, { + type: "acp_turn_start", + turnId: "t-legacy", + sessionId: "s", + userText: "hi", + target: "claude:anything", + }); + + expect(gw.turns).toHaveLength(1); + }); +}); diff --git a/src/runtime-daemon/inbound/acp/turn-handler.ts b/src/runtime-daemon/inbound/acp/turn-handler.ts index 22852dd..aa5d314 100644 --- a/src/runtime-daemon/inbound/acp/turn-handler.ts +++ b/src/runtime-daemon/inbound/acp/turn-handler.ts @@ -48,19 +48,45 @@ interface PendingPermission { */ const DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1_000; +export interface AcpTurnHandlerOpts { + permissionTimeoutMs?: number; + /** + * Optional predicate (P10.4): given a TargetId from acp_turn_start, + * return true iff a Claude Code instance is currently attached for + * that target. Returning false short-circuits the turn with an + * `acp_turn_error` instead of forwarding to the singleton gateway. + * When omitted, every target is considered attached (v0.1 behaviour). + */ + isTargetAttached?: (target: string) => boolean; +} + +/** + * Resolve the `ClaudeCodeGateway` for a given TargetId. The daemon + * wires this through `inboundRoomRouter.getOrCreateByTarget` so each + * TargetId gets its own per-Room gateway (P10.10). Returning `null` + * treats the target as unresolvable and surfaces an `acp_turn_error` + * to the caller — same shape as the `isTargetAttached` rejection + * path. Async because creating a Room may spin up per-Room state. + */ +export type GatewayForTarget = ( + target: string, +) => ClaudeCodeGateway | null | Promise; + export class AcpTurnHandler { private readonly activeTurns = new Map(); private readonly pendingPermissions = new Map(); private readonly log: (msg: string) => void; private readonly permissionTimeoutMs: number; + private readonly isTargetAttached?: (target: string) => boolean; constructor( - private readonly gateway: ClaudeCodeGateway, + private readonly gatewayForTarget: GatewayForTarget, log?: (msg: string) => void, - opts?: { permissionTimeoutMs?: number }, + opts?: AcpTurnHandlerOpts, ) { this.log = log ?? (() => {}); this.permissionTimeoutMs = opts?.permissionTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS; + this.isTargetAttached = opts?.isTargetAttached; } // --------------------------------------------------------------------------- @@ -136,17 +162,45 @@ export class AcpTurnHandler { pending.resolve(msg.outcome); } - handleTurnStart( + async handleTurnStart( conn: Connection, msg: Extract, - ): void { + ): Promise { + // P10.4 — verify the requested TargetId has an attached CC before + // we reach into the gateway. Falls back to claude:default when the + // subprocess didn't send a target (v0.1 wire compatibility). + const target = msg.target ?? "claude:default"; + if (this.isTargetAttached && !this.isTargetAttached(target)) { + this.log(`Rejecting acp_turn_start ${msg.turnId} — target ${target} not attached`); + this.send(conn, { + type: "acp_turn_error", + turnId: msg.turnId, + message: `target ${target} not attached`, + }); + return; + } + + // P10.10 — resolve the Room's gateway for this target. Rejection + // path: if the gateway can't be found/created, surface a matching + // error frame instead of forwarding into a shared singleton. + const gateway = await this.gatewayForTarget(target); + if (!gateway) { + this.log(`Rejecting acp_turn_start ${msg.turnId} — no gateway for ${target}`); + this.send(conn, { + type: "acp_turn_error", + turnId: msg.turnId, + message: `no gateway for target ${target}`, + }); + return; + } + // Settle + cancel any existing turn for this connection before starting a new one. this.settleTurn(conn, `superseded by ${msg.turnId}`); - const turn = this.gateway.startTurn(msg.userText); + const turn = gateway.startTurn(msg.userText); const settled = { value: false }; this.activeTurns.set(conn, { turnId: msg.turnId, turn, settled }); - this.log(`ACP turn started (turnId=${msg.turnId}, ${msg.userText.length} chars)`); + this.log(`ACP turn started (turnId=${msg.turnId}, ${msg.userText.length} chars, target=${target})`); turn.on("chunk", (text) => { if (settled.value) return; diff --git a/src/runtime-daemon/rooms/room-router.test.ts b/src/runtime-daemon/rooms/room-router.test.ts index 2f583de..db5eef3 100644 --- a/src/runtime-daemon/rooms/room-router.test.ts +++ b/src/runtime-daemon/rooms/room-router.test.ts @@ -198,4 +198,62 @@ describe("RoomRouter", () => { expect(disposed.sort()).toEqual(["a", "b"] as RoomId[]); await expect(router.getOrCreate("c" as RoomId)).rejects.toThrow(/disposeAll/); }); + + // ------------------------------------------------------------------ + // P10.3 — getOrCreateByTarget / getByTarget + // ------------------------------------------------------------------ + + test("getOrCreateByTarget mints a Room keyed by the TargetId string", async () => { + const { factory, calls } = defaultFactory(); + const router = new RoomRouter(factory); + + const room = await router.getOrCreateByTarget( + "claude:project-a" as unknown as import("@shared/target-id").TargetId, + ); + expect(room.id).toBe("claude:project-a" as RoomId); + expect(calls).toEqual(["claude:project-a"] as RoomId[]); + + // Same target → same Room (no factory re-call). + const again = await router.getOrCreateByTarget( + "claude:project-a" as unknown as import("@shared/target-id").TargetId, + ); + expect(again).toBe(room); + expect(calls).toEqual(["claude:project-a"] as RoomId[]); + }); + + test("getByTarget returns existing Room without minting", async () => { + const { factory } = defaultFactory(); + const router = new RoomRouter(factory); + + expect( + router.getByTarget( + "claude:default" as unknown as import("@shared/target-id").TargetId, + ), + ).toBeUndefined(); + + await router.getOrCreateByTarget( + "claude:default" as unknown as import("@shared/target-id").TargetId, + ); + const found = router.getByTarget( + "claude:default" as unknown as import("@shared/target-id").TargetId, + ); + expect(found).toBeDefined(); + expect(found?.id).toBe("claude:default" as RoomId); + }); + + test("different targets get separate Rooms", async () => { + const { factory, calls } = defaultFactory(); + const router = new RoomRouter(factory); + + const a = await router.getOrCreateByTarget( + "claude:proj-a" as unknown as import("@shared/target-id").TargetId, + ); + const b = await router.getOrCreateByTarget( + "claude:proj-b" as unknown as import("@shared/target-id").TargetId, + ); + + expect(a).not.toBe(b); + expect(router.size).toBe(2); + expect(calls.sort()).toEqual(["claude:proj-a", "claude:proj-b"] as RoomId[]); + }); }); diff --git a/src/runtime-daemon/rooms/room-router.ts b/src/runtime-daemon/rooms/room-router.ts index 4112f75..4f8a319 100644 --- a/src/runtime-daemon/rooms/room-router.ts +++ b/src/runtime-daemon/rooms/room-router.ts @@ -15,6 +15,7 @@ import { Room } from "@daemon/rooms/room"; import type { RoomId } from "@daemon/rooms/room-id"; +import type { TargetId } from "@shared/target-id"; export type RoomFactory = (id: RoomId) => Room | Promise; @@ -57,6 +58,21 @@ export class RoomRouter { return this.rooms.get(id); } + /** + * Convenience for the v0.2 multi-target router (P10.3): a TargetId + * is a `kind:id` string and is also a valid RoomId, so we just + * forward to `getOrCreate`. Type-clarifying alias — call sites + * carrying a TargetId can use this instead of casting. + */ + async getOrCreateByTarget(target: TargetId): Promise { + return this.getOrCreate(target as unknown as RoomId); + } + + /** TargetId-typed accessor; returns undefined when no Room exists yet. */ + getByTarget(target: TargetId): Room | undefined { + return this.rooms.get(target as unknown as RoomId); + } + /** * Seed the router with a pre-built Room so subsequent `getOrCreate(id)` * calls return it without re-invoking the factory. Throws if a Room is diff --git a/src/runtime-plugin/bridge.ts b/src/runtime-plugin/bridge.ts index 2fa4f1f..9dd11de 100644 --- a/src/runtime-plugin/bridge.ts +++ b/src/runtime-plugin/bridge.ts @@ -6,6 +6,7 @@ import { DaemonClient } from "@plugin/daemon-client/daemon-client"; import { DaemonLifecycle } from "@shared/daemon-lifecycle"; import { StateDirResolver } from "@shared/state-dir"; import { ConfigService } from "@shared/config-service"; +import { resolveClaudeTarget } from "@shared/workspace-id"; import type { BridgeMessage } from "@messages/types"; const stateDir = new StateDirResolver(); @@ -19,6 +20,17 @@ const CONTROL_WS_URL = daemonLifecycle.controlWsUrl; const claude = new ClaudeAdapter(); const daemonClient = new DaemonClient(CONTROL_WS_URL); +// P10.2 — derive this CC's TargetId from env + state-dir so the +// daemon can route inbound traffic to the right Room when multiple +// CC instances share one daemon. v0.1 backward compat: bare +// `claude` always resolves to `claude:default` when no env vars set. +const CLAUDE_TARGET = resolveClaudeTarget({ stateDirPath: stateDir.dir }); + +// P10.6 — `a2a-bridge claude --force` / `A2A_BRIDGE_FORCE_ATTACH=1` +// kicks an existing CC attached to the same TargetId. Read once at +// startup so operator intent is clear and can't race a reconnect. +const FORCE_ATTACH = process.env.A2A_BRIDGE_FORCE_ATTACH === "1"; + let shuttingDown = false; let daemonDisabled = false; @@ -30,7 +42,7 @@ let lastReconnectNotifyTs = 0; let disabledRecoveryTimer: ReturnType | null = null; let disabledRecoveryInFlight = false; -claude.setReplySender(async (msg: BridgeMessage, requireReply?: boolean) => { +claude.setReplySender(async (msg: BridgeMessage, requireReply?: boolean, target?: string) => { if (msg.source !== "claude") { return { success: false, error: "Invalid message source" }; } @@ -42,7 +54,7 @@ claude.setReplySender(async (msg: BridgeMessage, requireReply?: boolean) => { }; } - return daemonClient.sendReply(msg, requireReply); + return daemonClient.sendReply(msg, requireReply, target); }); daemonClient.on("codexMessage", (message) => { @@ -56,6 +68,22 @@ daemonClient.on("status", (status) => { ); }); +// P10.6 — conflict outcomes on the multi-target attach path. In both +// cases the daemon won't serve this CC any further, so stop looping +// and surface the situation to the user via a CC notification. +daemonClient.on("connectRejected", ({ target, reason }) => { + void enterDisabledState( + `claude_connect rejected for ${target}: ${reason}`, + `⛔ A2aBridge attach rejected for target ${target}. ${reason}`, + ); +}); +daemonClient.on("connectReplaced", ({ target }) => { + void enterDisabledState( + `claude_connect replaced on ${target} — another CC took over with --force`, + `⛔ A2aBridge attach for ${target} was replaced by another CC (--force). This bridge is now idle.`, + ); +}); + daemonClient.on("disconnect", () => { if (shuttingDown || daemonDisabled) return; @@ -95,7 +123,7 @@ async function connectToDaemon(isReconnect = false) { try { await daemonLifecycle.ensureRunning(); await daemonClient.connect(); - daemonClient.attachClaude(); + daemonClient.attachClaude(CLAUDE_TARGET, FORCE_ATTACH); if (!isReconnect) { void claude.pushNotification(systemMessage( "system_bridge_ready", @@ -228,7 +256,10 @@ async function pollDisabledRecovery() { log("Disabled-state recovery conditions met — attempting direct daemon reconnect"); try { await daemonClient.connect(); - daemonClient.attachClaude(); + // Recovery never forces — it's an automatic reconnect, not an + // operator-initiated takeover. Pass `CLAUDE_TARGET` so the + // daemon keeps the same Room it did on the initial attach. + daemonClient.attachClaude(CLAUDE_TARGET); daemonDisabled = false; stopDisabledRecoveryPoller(); void claude.pushNotification(systemMessage( diff --git a/src/runtime-plugin/claude-channel/claude-adapter.ts b/src/runtime-plugin/claude-channel/claude-adapter.ts index 917907d..d64608c 100644 --- a/src/runtime-plugin/claude-channel/claude-adapter.ts +++ b/src/runtime-plugin/claude-channel/claude-adapter.ts @@ -32,7 +32,11 @@ export const PLUGIN_SERVER_INFO = { version: pkg.version, } as const; -export type ReplySender = (msg: BridgeMessage, requireReply?: boolean) => Promise<{ success: boolean; error?: string }>; +export type ReplySender = ( + msg: BridgeMessage, + requireReply?: boolean, + target?: string, +) => Promise<{ success: boolean; error?: string }>; export type DeliveryMode = "push" | "pull" | "auto"; export const CLAUDE_INSTRUCTIONS = [ @@ -235,7 +239,7 @@ export class ClaudeAdapter extends EventEmitter { { name: "reply", description: - "Send a message back to Codex. Your reply will be injected into the Codex session as a new user turn.", + "Send a message back to the connected agent. Your reply is routed to the originator of the inbound turn by default; pass `target` to send it to a different attached agent instead.", inputSchema: { type: "object" as const, properties: { @@ -245,11 +249,15 @@ export class ClaudeAdapter extends EventEmitter { }, text: { type: "string", - description: "The message to send to Codex.", + description: "The message to send.", }, require_reply: { type: "boolean", - description: "When true, Codex is required to send a reply. All Codex messages from this turn will be forwarded immediately (bypassing STATUS buffering). Use this when you need a direct answer from Codex.", + description: "When true, the receiving agent is required to send a reply; all messages from its turn will be forwarded immediately (bypassing STATUS buffering). Use when you need a direct answer.", + }, + target: { + type: "string", + description: "Optional `kind:id` TargetId (e.g. `claude:project-b`, `codex:default`) to override the default routing. Use this to hand a reply off to a different attached agent instead of the one that sent the inbound turn. Omit to route back to the inbound turn's originator (default).", }, }, required: ["text"], @@ -296,6 +304,10 @@ export class ClaudeAdapter extends EventEmitter { } const requireReply = args?.require_reply === true; + // P10.8 — optional target routing. Pass-through to the daemon; + // validation of the `kind:id` shape and attach state happens on + // the daemon side so we don't duplicate parseTarget here. + const targetArg = typeof args?.target === "string" ? (args.target as string) : undefined; const bridgeMsg: BridgeMessage = { id: (args?.chat_id as string) ?? `reply_${Date.now()}`, @@ -312,7 +324,7 @@ export class ClaudeAdapter extends EventEmitter { }; } - const result = await this.replySender(bridgeMsg, requireReply); + const result = await this.replySender(bridgeMsg, requireReply, targetArg); if (!result.success) { this.log(`Reply delivery failed: ${result.error}`); return { @@ -323,7 +335,7 @@ export class ClaudeAdapter extends EventEmitter { // Include pending message hint const pending = this.pendingMessages.length; - let responseText = "Reply sent to Codex."; + let responseText = targetArg ? `Reply sent to ${targetArg}.` : "Reply sent to Codex."; if (pending > 0) { responseText += ` Note: ${pending} unread Codex message${pending > 1 ? "s" : ""} already waiting \u2014 call get_messages to read them.`; } diff --git a/src/runtime-plugin/daemon-client/daemon-client.test.ts b/src/runtime-plugin/daemon-client/daemon-client.test.ts index 603b996..db72494 100644 --- a/src/runtime-plugin/daemon-client/daemon-client.test.ts +++ b/src/runtime-plugin/daemon-client/daemon-client.test.ts @@ -228,4 +228,70 @@ describe("DaemonClient", () => { const msg = await received; expect(msg.type).toBe("claude_connect"); }); + + test("attachClaude(target, true) sends force=true on the wire", async () => { + const received = new Promise((resolve) => { + onServerMessage = (_ws: any, raw: any) => { + resolve(JSON.parse(typeof raw === "string" ? raw : raw.toString())); + }; + }); + + await client.connect(); + client.attachClaude("claude:ws-a", true); + + const msg = await received; + expect(msg).toMatchObject({ + type: "claude_connect", + target: "claude:ws-a", + force: true, + }); + }); + + test("attachClaude() defaults to no force field (backward compat)", async () => { + const received = new Promise((resolve) => { + onServerMessage = (_ws: any, raw: any) => { + resolve(JSON.parse(typeof raw === "string" ? raw : raw.toString())); + }; + }); + + await client.connect(); + client.attachClaude(); + + const msg = await received; + expect(msg.type).toBe("claude_connect"); + expect(msg.force).toBeUndefined(); + }); + + test("emits connectRejected on claude_connect_rejected", async () => { + await client.connect(); + + const seen = new Promise<{ target: string; reason: string }>((resolve) => { + client.on("connectRejected", (ev) => resolve(ev)); + }); + + sendToClient({ + type: "claude_connect_rejected", + target: "claude:ws-a", + reason: "target already attached", + }); + + const ev = await seen; + expect(ev).toEqual({ target: "claude:ws-a", reason: "target already attached" }); + }); + + test("emits connectReplaced on claude_connect_replaced", async () => { + await client.connect(); + + const seen = new Promise<{ target: string }>((resolve) => { + client.on("connectReplaced", (ev) => resolve(ev)); + }); + + sendToClient({ + type: "claude_connect_replaced", + target: "claude:ws-a", + }); + + const ev = await seen; + expect(ev).toEqual({ target: "claude:ws-a" }); + }); }); diff --git a/src/runtime-plugin/daemon-client/daemon-client.ts b/src/runtime-plugin/daemon-client/daemon-client.ts index edd76fd..225746b 100644 --- a/src/runtime-plugin/daemon-client/daemon-client.ts +++ b/src/runtime-plugin/daemon-client/daemon-client.ts @@ -6,6 +6,12 @@ interface DaemonClientEvents { codexMessage: [BridgeMessage]; disconnect: []; status: [DaemonStatus]; + // P10.6 — conflict outcomes on `claude_connect`. + // `connectRejected` fires when another CC already owns the target + // and the plugin didn't pass `force=true`. `connectReplaced` fires + // on the old attach when someone else took over with `force=true`. + connectRejected: [{ target: string; reason: string }]; + connectReplaced: [{ target: string }]; } let nextSocketId = 0; @@ -80,8 +86,22 @@ export class DaemonClient extends EventEmitter { }); } - attachClaude() { - this.send({ type: "claude_connect" }); + /** + * Attach this client as Claude Code on the daemon control plane. + * Pass `target` ("kind:id" form) to claim a specific Room when + * the daemon supports multi-target routing (P10.x / v0.2). When + * omitted, the daemon assigns `claude:default` (v0.1 behaviour). + * + * P10.6: `force=true` kicks an attached CC that already owns the + * target. Default (`force=false`) makes the daemon reject the + * attach and emit a `connectRejected` event on this client. + */ + attachClaude(target?: string, force: boolean = false) { + this.send({ + type: "claude_connect", + ...(target ? { target } : {}), + ...(force ? { force: true } : {}), + }); } async disconnect() { @@ -99,7 +119,17 @@ export class DaemonClient extends EventEmitter { this.rejectPendingReplies("Daemon connection closed"); } - async sendReply(message: BridgeMessage, requireReply?: boolean): Promise<{ success: boolean; error?: string }> { + /** + * Ship a `BridgeMessage` back over the control plane as a + * `claude_to_codex` frame. P10.8 adds optional `target`: when set, + * the daemon forwards the reply to that TargetId's Room instead of + * the inbound turn's originator. Omitted = today's behaviour. + */ + async sendReply( + message: BridgeMessage, + requireReply?: boolean, + target?: string, + ): Promise<{ success: boolean; error?: string }> { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return { success: false, error: "A2aBridge daemon is not connected." }; } @@ -117,6 +147,7 @@ export class DaemonClient extends EventEmitter { requestId, message, ...(requireReply ? { requireReply: true } : {}), + ...(target ? { target } : {}), }); }); } @@ -147,6 +178,15 @@ export class DaemonClient extends EventEmitter { case "status": this.emit("status", message.status); return; + case "claude_connect_rejected": + this.emit("connectRejected", { + target: message.target, + reason: message.reason, + }); + return; + case "claude_connect_replaced": + this.emit("connectReplaced", { target: message.target }); + return; } }; diff --git a/src/shared/target-id.test.ts b/src/shared/target-id.test.ts new file mode 100644 index 0000000..8b5da70 --- /dev/null +++ b/src/shared/target-id.test.ts @@ -0,0 +1,130 @@ +/** + * P10.1 unit tests for TargetId parser / formatter. + */ +import { describe, test, expect } from "bun:test"; +import { + parseTarget, + formatTarget, + assertTarget, + DEFAULT_INSTANCE_ID, +} from "@shared/target-id"; + +describe("parseTarget — happy path", () => { + test("kind:id splits cleanly", () => { + const r = parseTarget("claude:project-a"); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.parts).toEqual({ kind: "claude", id: "project-a" }); + expect(r.target as string).toBe("claude:project-a"); + } + }); + + test("bare kind defaults id to 'default'", () => { + const r = parseTarget("claude"); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.parts).toEqual({ kind: "claude", id: DEFAULT_INSTANCE_ID }); + expect(r.target as string).toBe("claude:default"); + } + }); + + test("underscore and digits allowed in both segments", () => { + const r = parseTarget("codex_v2:instance_01"); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.parts).toEqual({ kind: "codex_v2", id: "instance_01" }); + } + }); + + test("hyphen allowed in id", () => { + const r = parseTarget("claude:my-project-2"); + expect(r.ok).toBe(true); + }); +}); + +describe("parseTarget — rejection cases", () => { + test("empty string rejected", () => { + const r = parseTarget(""); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("non-empty"); + }); + + test("only colon rejected", () => { + const r = parseTarget(":"); + expect(r.ok).toBe(false); + }); + + test("leading colon rejected", () => { + const r = parseTarget(":foo"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("empty kind"); + }); + + test("trailing colon rejected", () => { + const r = parseTarget("foo:"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("empty id"); + }); + + test("multiple colons rejected", () => { + const r = parseTarget("a:b:c"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("multiple"); + }); + + test("uppercase rejected in kind", () => { + const r = parseTarget("Claude:foo"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("kind"); + }); + + test("uppercase rejected in id", () => { + const r = parseTarget("claude:Foo"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("id"); + }); + + test("space rejected", () => { + const r = parseTarget("claude:foo bar"); + expect(r.ok).toBe(false); + }); + + test("dot rejected", () => { + const r = parseTarget("claude:foo.bar"); + expect(r.ok).toBe(false); + }); + + test("slash rejected", () => { + const r = parseTarget("claude:foo/bar"); + expect(r.ok).toBe(false); + }); +}); + +describe("formatTarget", () => { + test("round-trips via parseTarget", () => { + const formatted = formatTarget({ kind: "claude", id: "project-a" }); + expect(formatted as string).toBe("claude:project-a"); + const r = parseTarget(formatted); + expect(r.ok).toBe(true); + if (r.ok) expect(r.parts).toEqual({ kind: "claude", id: "project-a" }); + }); + + test("throws on invalid parts", () => { + expect(() => formatTarget({ kind: "Claude", id: "foo" })).toThrow(); + expect(() => formatTarget({ kind: "claude", id: "" })).toThrow(); + expect(() => formatTarget({ kind: "", id: "foo" })).toThrow(); + }); +}); + +describe("assertTarget", () => { + test("returns branded TargetId for valid input", () => { + const t = assertTarget("codex:main"); + expect(t as string).toBe("codex:main"); + }); + + test("throws on invalid input", () => { + expect(() => assertTarget("bad string")).toThrow(); + expect(() => assertTarget("")).toThrow(); + expect(() => assertTarget(":bad")).toThrow(); + }); +}); diff --git a/src/shared/target-id.ts b/src/shared/target-id.ts new file mode 100644 index 0000000..10298b5 --- /dev/null +++ b/src/shared/target-id.ts @@ -0,0 +1,105 @@ +/** + * TargetId — a `kind:id` tuple identifying an agent instance the + * daemon routes to (P10.1, design: docs/design/multi-target-routing.md). + * + * Every agent instance — a Claude Code workspace, a Codex peer, a + * Hermes peer, etc. — has a TargetId. `kind` names the agent family + * ("claude", "codex", ...); `id` disambiguates instances inside that + * family ("project-a", "dev", ...). There is no "bare kind" — every + * target has an explicit id; when the caller omits one, `parseTarget` + * fills it in as "default". + * + * Branded string form: `":"`. Both fields must be + * identifier-safe (`[a-z0-9_-]+`) so the string survives transport + * layers that silently drop non-identifier characters (notably + * `notifications/claude/channel` meta keys — see the permission + * bridge's `assertIdentifierSafeKeys`). + */ + +declare const TargetIdBrand: unique symbol; +export type TargetId = string & { [TargetIdBrand]: true }; + +/** Structural form of a parsed target. */ +export interface TargetParts { + kind: string; + id: string; +} + +export const DEFAULT_INSTANCE_ID = "default"; + +const VALID_SEGMENT = /^[a-z0-9_-]+$/; + +export type ParseTargetResult = + | { ok: true; target: TargetId; parts: TargetParts } + | { ok: false; error: string }; + +/** + * Parse a target specifier into a normalised `kind:id` TargetId. + * + * - `"claude:project-a"` → `{kind: "claude", id: "project-a"}` + * - `"claude"` → `{kind: "claude", id: "default"}` (id defaults) + * - `"Claude:Foo"` or `"claude:foo bar"` → error (only [a-z0-9_-]) + * - `""` / `":foo"` / `"foo:"` → error (empty segment) + * - `"a:b:c"` → error (multiple separators) + */ +export function parseTarget(input: string): ParseTargetResult { + if (typeof input !== "string" || input.length === 0) { + return { ok: false, error: "Target specifier must be a non-empty string" }; + } + + const parts = input.split(":"); + if (parts.length > 2) { + return { + ok: false, + error: `Target "${input}" has multiple ':' separators; expected "kind" or "kind:id"`, + }; + } + + const kind = parts[0] ?? ""; + const id = parts.length === 2 ? (parts[1] ?? "") : DEFAULT_INSTANCE_ID; + + if (kind.length === 0) { + return { ok: false, error: `Target "${input}" has an empty kind` }; + } + if (!VALID_SEGMENT.test(kind)) { + return { + ok: false, + error: `Target kind "${kind}" contains characters outside [a-z0-9_-]`, + }; + } + if (id.length === 0) { + return { ok: false, error: `Target "${input}" has an empty id` }; + } + if (!VALID_SEGMENT.test(id)) { + return { + ok: false, + error: `Target id "${id}" contains characters outside [a-z0-9_-]`, + }; + } + + return { + ok: true, + target: `${kind}:${id}` as TargetId, + parts: { kind, id }, + }; +} + +/** Inverse of `parseTarget` — format `{kind, id}` as a TargetId string. */ +export function formatTarget(parts: TargetParts): TargetId { + const combined = `${parts.kind}:${parts.id}`; + const parsed = parseTarget(combined); + if (!parsed.ok) { + throw new Error(`formatTarget: invalid parts ${JSON.stringify(parts)} — ${parsed.error}`); + } + return parsed.target; +} + +/** + * Assert that a caller-supplied string is a valid TargetId and throw + * a descriptive Error otherwise. Returns the branded TargetId. + */ +export function assertTarget(input: string): TargetId { + const r = parseTarget(input); + if (!r.ok) throw new Error(r.error); + return r.target; +} diff --git a/src/shared/workspace-id.test.ts b/src/shared/workspace-id.test.ts new file mode 100644 index 0000000..de36a01 --- /dev/null +++ b/src/shared/workspace-id.test.ts @@ -0,0 +1,102 @@ +/** + * P10.2 unit tests — workspace id derivation. + */ +import { describe, test, expect } from "bun:test"; +import { + resolveWorkspaceId, + resolveClaudeTarget, +} from "@shared/workspace-id"; + +describe("resolveWorkspaceId — priority chain", () => { + test("env A2A_BRIDGE_WORKSPACE_ID wins over everything", () => { + const id = resolveWorkspaceId({ + env: { + A2A_BRIDGE_WORKSPACE_ID: "explicit", + A2A_BRIDGE_STATE_DIR: "/some/path/should-not-be-used", + }, + stateDirPath: "/another/path/also-ignored", + conversationId: "abcdef1234567890", + }); + expect(id).toBe("explicit"); + }); + + test("falls back to A2A_BRIDGE_STATE_DIR basename", () => { + const id = resolveWorkspaceId({ + env: { A2A_BRIDGE_STATE_DIR: "/home/user/.config/a2a-bridge/project-a" }, + conversationId: "abcdef1234567890", + }); + expect(id).toBe("project-a"); + }); + + test("falls back to stateDirPath when env unset", () => { + const id = resolveWorkspaceId({ + env: {}, + stateDirPath: "/tmp/workspace-x", + }); + expect(id).toBe("workspace-x"); + }); + + test("falls back to conversationId prefix", () => { + const id = resolveWorkspaceId({ + env: {}, + conversationId: "abcd1234ef567890", + }); + expect(id).toBe("abcd1234"); + }); + + test("falls back to 'default' when nothing else", () => { + const id = resolveWorkspaceId({ env: {} }); + expect(id).toBe("default"); + }); +}); + +describe("resolveWorkspaceId — sanitisation", () => { + test("uppercase normalised to lowercase", () => { + const id = resolveWorkspaceId({ + env: { A2A_BRIDGE_WORKSPACE_ID: "Project-A" }, + }); + expect(id).toBe("project-a"); + }); + + test("spaces and dots replaced with hyphens", () => { + const id = resolveWorkspaceId({ + env: { A2A_BRIDGE_WORKSPACE_ID: "my project.v2" }, + }); + expect(id).toBe("my-project-v2"); + }); + + test("repeated hyphens collapsed", () => { + const id = resolveWorkspaceId({ + env: { A2A_BRIDGE_WORKSPACE_ID: "a---b__c" }, + }); + expect(id).toBe("a-b__c"); + }); + + test("leading/trailing hyphens stripped", () => { + const id = resolveWorkspaceId({ + env: { A2A_BRIDGE_WORKSPACE_ID: "-foo-" }, + }); + expect(id).toBe("foo"); + }); + + test("all-disallowed override falls through to next source", () => { + const id = resolveWorkspaceId({ + env: { A2A_BRIDGE_WORKSPACE_ID: "!!!", A2A_BRIDGE_STATE_DIR: "/x/clean-name" }, + }); + expect(id).toBe("clean-name"); + }); +}); + +describe("resolveClaudeTarget", () => { + test("returns a valid claude: TargetId", () => { + const t = resolveClaudeTarget({ + env: { A2A_BRIDGE_WORKSPACE_ID: "team-alpha" }, + }); + expect(t as string).toBe("claude:team-alpha"); + }); + + test("default when nothing supplied", () => { + const t = resolveClaudeTarget({ env: {} }); + expect(t as string).toBe("claude:default"); + }); +}); diff --git a/src/shared/workspace-id.ts b/src/shared/workspace-id.ts new file mode 100644 index 0000000..aa0a93a --- /dev/null +++ b/src/shared/workspace-id.ts @@ -0,0 +1,78 @@ +/** + * Workspace id derivation for the Claude Code plugin (P10.2). + * + * Resolves the `id` half of `claude:` from environment + state-dir, + * following the priority chain in + * docs/design/multi-target-routing.md §"Default id derivation". + */ + +import { basename } from "node:path"; +import { DEFAULT_INSTANCE_ID, parseTarget } from "@shared/target-id"; + +export interface ResolveWorkspaceIdOptions { + /** Optional state-dir path; basename is used when env vars are absent. */ + stateDirPath?: string; + /** Optional CC conversation id, first 8 chars used as fallback. */ + conversationId?: string; + /** Override for tests; defaults to process.env. */ + env?: Record; +} + +/** + * Derive the CC workspace id following the documented priority chain: + * 1. A2A_BRIDGE_WORKSPACE_ID (explicit override) + * 2. basename of A2A_BRIDGE_STATE_DIR (or stateDirPath) + * 3. first 8 chars of conversationId + * 4. "default" + * + * The returned id is **always identifier-safe** (`[a-z0-9_-]+`): + * any disallowed characters in derived sources are replaced with `-`, + * leading/trailing `-` are stripped, and an empty result falls through + * to the next source. This guarantees `claude:` parses cleanly. + */ +export function resolveWorkspaceId(opts: ResolveWorkspaceIdOptions = {}): string { + const env = opts.env ?? process.env; + + // 1. Explicit override + const override = env.A2A_BRIDGE_WORKSPACE_ID; + const overrideClean = override && sanitize(override); + if (overrideClean) return overrideClean; + + // 2. State-dir basename + const stateDir = opts.stateDirPath ?? env.A2A_BRIDGE_STATE_DIR; + if (stateDir) { + const stateClean = sanitize(basename(stateDir)); + if (stateClean) return stateClean; + } + + // 3. Conversation id prefix + if (opts.conversationId) { + const convClean = sanitize(opts.conversationId.slice(0, 8)); + if (convClean) return convClean; + } + + // 4. Fallback + return DEFAULT_INSTANCE_ID; +} + +/** Build the full TargetId string `claude:` from the same options. */ +export function resolveClaudeTarget(opts: ResolveWorkspaceIdOptions = {}): string { + const id = resolveWorkspaceId(opts); + const r = parseTarget(`claude:${id}`); + if (!r.ok) { + // Should be unreachable — sanitize() guarantees identifier-safe output. + throw new Error(`resolveClaudeTarget produced invalid target: ${r.error}`); + } + return r.target; +} + +function sanitize(value: string): string { + // Lowercase, then replace any character outside [a-z0-9_-] with `-`, + // collapse runs of `-`, strip leading/trailing `-`. Empty result + // signals "this source did not produce a usable id". + return value + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} diff --git a/src/transport/control-protocol.test.ts b/src/transport/control-protocol.test.ts index d206a73..20c376b 100644 --- a/src/transport/control-protocol.test.ts +++ b/src/transport/control-protocol.test.ts @@ -29,6 +29,100 @@ function roundtripServer(msg: ControlServerMessage): ControlServerMessage { // Client → Daemon frames // --------------------------------------------------------------------------- +describe("P10.2 control-plane: claude_connect carries optional target", () => { + test("claude_connect with target round-trips", () => { + const msg: ControlClientMessage = { + type: "claude_connect", + target: "claude:project-a", + }; + const restored = roundtripClient(msg); + expect(restored).toEqual(msg); + if (restored.type === "claude_connect") { + expect(restored.target).toBe("claude:project-a"); + } + }); + + test("claude_connect without target round-trips (v0.1 backward compat)", () => { + const msg: ControlClientMessage = { type: "claude_connect" }; + const restored = roundtripClient(msg); + expect(restored).toEqual(msg); + if (restored.type === "claude_connect") { + expect(restored.target).toBeUndefined(); + } + }); +}); + +describe("P10.8 control-plane: claude_to_codex carries optional target", () => { + test("claude_to_codex with target round-trips", () => { + const msg: ControlClientMessage = { + type: "claude_to_codex", + requestId: "r42", + message: { + id: "m1", + source: "claude", + content: "hand-off", + timestamp: 1_700_000_000_000, + }, + target: "claude:project-b", + }; + const restored = roundtripClient(msg); + expect(restored).toEqual(msg); + if (restored.type === "claude_to_codex") { + expect(restored.target).toBe("claude:project-b"); + } + }); + + test("claude_to_codex without target round-trips (v0.1 backward compat)", () => { + const msg: ControlClientMessage = { + type: "claude_to_codex", + requestId: "r42", + message: { + id: "m1", + source: "claude", + content: "hand-off", + timestamp: 1_700_000_000_000, + }, + }; + const restored = roundtripClient(msg); + expect(restored).toEqual(msg); + if (restored.type === "claude_to_codex") { + expect(restored.target).toBeUndefined(); + } + }); +}); + +describe("P10.6 control-plane: claude_connect force + conflict frames", () => { + test("claude_connect with force=true round-trips", () => { + const msg: ControlClientMessage = { + type: "claude_connect", + target: "claude:ws-a", + force: true, + }; + const restored = roundtripClient(msg); + expect(restored).toEqual(msg); + if (restored.type === "claude_connect") { + expect(restored.force).toBe(true); + } + }); + + test("claude_connect_rejected (daemon → plugin) round-trips", () => { + const msg: ControlServerMessage = { + type: "claude_connect_rejected", + target: "claude:ws-a", + reason: "target already attached (plugin conn #1)", + }; + expect(roundtripServer(msg)).toEqual(msg); + }); + + test("claude_connect_replaced (daemon → plugin) round-trips", () => { + const msg: ControlServerMessage = { + type: "claude_connect_replaced", + target: "claude:ws-a", + }; + expect(roundtripServer(msg)).toEqual(msg); + }); +}); + describe("P8.1 control-plane: ControlClientMessage ACP variants", () => { test("acp_turn_start round-trips with required fields", () => { const msg: ControlClientMessage = { diff --git a/src/transport/control-protocol.ts b/src/transport/control-protocol.ts index 2453060..67e6310 100644 --- a/src/transport/control-protocol.ts +++ b/src/transport/control-protocol.ts @@ -53,19 +53,34 @@ export function assertIdentifierSafeKeys(meta: AcpTurnMeta): void { export type PermissionOutcome = "allow" | "deny"; export type ControlClientMessage = - | { type: "claude_connect" } + // `target` is the `kind:id` TargetId (see shared/target-id.ts). + // Optional for v0.1 backward compat; when omitted the daemon + // assigns `claude:default`. + // `force` (P10.6): when true and the target already has an attached + // CC, kick the old attach; without `force` the daemon rejects the + // new attach instead. + | { type: "claude_connect"; target?: string; force?: boolean } | { type: "claude_disconnect" } - | { type: "claude_to_codex"; requestId: string; message: BridgeMessage; requireReply?: boolean } + // `target` (P10.8): optional `kind:id` TargetId that overrides the + // default outbound routing. Absent → today's behaviour (deliver to + // inbound turn originator, else Codex). Present → daemon forwards + // to that target's Room instead. + | { type: "claude_to_codex"; requestId: string; message: BridgeMessage; requireReply?: boolean; target?: string } | { type: "status" } // ACP turn relay — sent by the `a2a-bridge acp` subprocess to the daemon. - | { type: "acp_turn_start"; turnId: string; sessionId: string; userText: string; meta?: AcpTurnMeta } + // `target` (P10.4) selects which TargetId Room handles the turn. + // Optional for v0.1 backward compat; daemon defaults to `claude:default`. + | { type: "acp_turn_start"; turnId: string; sessionId: string; userText: string; meta?: AcpTurnMeta; target?: string } | { type: "acp_turn_cancel"; turnId: string } // Plugin → daemon: CC asked for a permission verdict; daemon decides where // to forward it based on the currently-active inbound turn. | { type: "plugin_permission_request"; requestId: string; toolName: string; description: string; inputPreview: string } // ACP subprocess → daemon: the ACP client answered a previously-forwarded // permission request (see `acp_permission_request` below). - | { type: "acp_permission_response"; requestId: string; outcome: PermissionOutcome }; + | { type: "acp_permission_response"; requestId: string; outcome: PermissionOutcome } + // Inspection RPC (P10.5): list every target the daemon currently + // tracks, used by `a2a-bridge daemon targets`. + | { type: "list_targets"; requestId: string }; export type ControlServerMessage = | { type: "codex_to_claude"; message: BridgeMessage } @@ -80,4 +95,26 @@ export type ControlServerMessage = | { type: "plugin_permission_response"; requestId: string; outcome: PermissionOutcome } // Daemon → ACP subprocess: route a CC-originated permission request to the // ACP client via `AgentSideConnection.requestPermission`. - | { type: "acp_permission_request"; requestId: string; turnId: string; toolName: string; description: string; inputPreview: string }; + | { type: "acp_permission_request"; requestId: string; turnId: string; toolName: string; description: string; inputPreview: string } + // Inspection RPC response (P10.5): one entry per registered target. + | { type: "targets_response"; requestId: string; targets: TargetEntry[] } + // Daemon → plugin (P10.6): the plugin's `claude_connect` lost a + // conflict — another CC is already attached to the same TargetId. + // The plugin should surface `reason` to CC and stop reconnecting. + | { type: "claude_connect_rejected"; target: string; reason: string } + // Daemon → plugin (P10.6): another `claude_connect` arrived with + // `force: true` and took over this TargetId. The old attach + // receives this frame just before the daemon closes the socket. + | { type: "claude_connect_replaced"; target: string }; + +/** Snapshot of one TargetId Room's attach state for `daemon targets`. */ +export interface TargetEntry { + /** `kind:id` form. */ + target: string; + /** True iff a CC / peer is currently attached for this target. */ + attached: boolean; + /** Numeric attach connection id (for diagnostics); undefined when detached. */ + clientId?: number; + /** ms since epoch when the current attach landed; undefined when detached. */ + attachedAt?: number; +}