From c55b773b5f079c17d04a8f0718da42ca5375ec0b Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 20:22:23 +0800 Subject: [PATCH 01/30] =?UTF-8?q?docs:=20correct=20OpenClaw=20acpx=20confi?= =?UTF-8?q?g=20path=20=E2=80=94=20openclaw.json=20(two=20keys)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous docs said to edit a separate ~/.acpx/config.json with a top-level agents key. Actual OpenClaw structure is a single openclaw.json with two nested locations: - acp.allowedAgents gates which agent names /acp spawn accepts - plugins.entries.acpx.config.agents maps agent name → command Updated join.md Step 2 (same-machine + remote-server blocks) and README's OpenClaw rows to reflect this. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 4 ++-- docs/join.md | 63 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 3bfdb7f..99d4993 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ 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"` | | **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"] }] }` | diff --git a/docs/join.md b/docs/join.md index 36e4e7a..ae31d01 100644 --- a/docs/join.md +++ b/docs/join.md @@ -132,19 +132,34 @@ Expected output: `a2a-bridge v0.1.0` or later. 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 — no extra flags needed): + +- **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 acp" } + } + } + } + } + } + ``` + + Restart OpenClaw and use `/acp spawn a2a-bridge`. - **Zed** — `~/.config/zed/settings.json`: @@ -161,20 +176,30 @@ Code daemon is on the **same machine** or a **remote server**. **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 `--url` on the command + (see [OpenClaw ACP docs](https://docs.openclaw.ai/tools/acp-agents)): ```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 acp --url ws://:4512/ws" + } + } + } } } } ``` Replace `` with the Claude Code machine's IP from Step 1. + Then `/acp spawn a2a-bridge`. - **Zed** — supports an `env` field: From 6e79620e299844ce279667e5850bd8059a43b3b9 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 20:50:50 +0800 Subject: [PATCH 02/30] docs(roadmap): consolidate Multi-CC into Multi-target routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace routing is a special case of agent-kind routing where the kind is 'claude' and the instance distinguishes sessions. Merged the two separate roadmap items into one — 'Multi-target routing via --target' — with concrete acpx config examples showing how OpenClaw registers multiple bridge entries to reach different CC workspaces and peer adapters through one daemon. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/design/roadmap.md | 43 ++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index f0bd89f..179c350 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -266,22 +266,37 @@ 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: +- **Multi-target routing via `--target`** — a single daemon fronts + multiple agents behind it: one or more Claude Code workspaces + (project-a, project-b, ...), one or more peer adapters (Codex, + Hermes, OpenClaw outbound), and potentially multiple MCP clients. + Today there is exactly one attached Claude Code slot; v0.2 turns + that into a general `Map` where a target is + identified by `[:]`, e.g.: + - `claude:project-a`, `claude:project-b` — distinct CC sessions + - `codex` — the Codex peer adapter + - `hermes` — the Hermes peer adapter (when v0.2 lands) + Workspace routing is the special case where the agent-kind is + `claude` and the instance-id distinguishes CC sessions. + The `a2a-bridge acp --target ` flag selects the route; the + plugin sends the workspace ID on `claude_connect`; RoomRouter + becomes the unified dispatch layer. + OpenClaw / Zed / VS Code register one `acpx` entry per target + they care about: + ```json + "agents": { + "bridge-proj-a": { "command": "a2a-bridge acp --target claude:project-a --url …" }, + "bridge-proj-b": { "command": "a2a-bridge acp --target claude:project-b --url …" }, + "bridge-codex": { "command": "a2a-bridge acp --target codex --url …" } + } + ``` + Workspace IDs are derived from `A2A_BRIDGE_STATE_DIR` (same pattern + as the Telegram plugin's `TELEGRAM_STATE_DIR`). 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`. + `A2A_BRIDGE_STATE_DIR`, defaults to `$XDG_STATE_HOME/a2a-bridge`. - 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/`. + telegram/server.ts:26` — `TELEGRAM_STATE_DIR` env. - **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). From 4c5925553602d1bd379a41009114be425111cd37 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 21:19:06 +0800 Subject: [PATCH 03/30] docs(design): multi-target-routing design doc + roadmap reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted the full Multi-target routing design into a dedicated design doc (docs/design/multi-target-routing.md) and shrank the roadmap entry to a one-paragraph summary that points at it. The design covers: - TargetId model (kind:id) - Deployment scenarios (single-dev multi-project, bidirectional, cross-machine multi-tenant) - Routing rules (inbound ACP, inbound A2A, outbound CC→peer, peer→CC) - id conflict policy (reject by default, --force to kick) - CLI surface (claude, codex, acp --target, daemon targets) - Wire-format extensions (claude_connect target, rejection frames) - Backward compatibility - Explicitly out of scope (dynamic discovery, auto-id) Not yet implemented — v0.2 scheduled work. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/design/multi-target-routing.md | 263 ++++++++++++++++++++++++++++ docs/design/roadmap.md | 39 +---- 2 files changed, 271 insertions(+), 31 deletions(-) create mode 100644 docs/design/multi-target-routing.md diff --git a/docs/design/multi-target-routing.md b/docs/design/multi-target-routing.md new file mode 100644 index 0000000..50addcc --- /dev/null +++ b/docs/design/multi-target-routing.md @@ -0,0 +1,263 @@ +# Multi-target routing + +Status: **v0.2 design (approved, not yet implemented)** + +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, 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) + +- **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 179c350..85a10e0 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -266,37 +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-target routing via `--target`** — a single daemon fronts - multiple agents behind it: one or more Claude Code workspaces - (project-a, project-b, ...), one or more peer adapters (Codex, - Hermes, OpenClaw outbound), and potentially multiple MCP clients. - Today there is exactly one attached Claude Code slot; v0.2 turns - that into a general `Map` where a target is - identified by `[:]`, e.g.: - - `claude:project-a`, `claude:project-b` — distinct CC sessions - - `codex` — the Codex peer adapter - - `hermes` — the Hermes peer adapter (when v0.2 lands) - Workspace routing is the special case where the agent-kind is - `claude` and the instance-id distinguishes CC sessions. - The `a2a-bridge acp --target ` flag selects the route; the - plugin sends the workspace ID on `claude_connect`; RoomRouter - becomes the unified dispatch layer. - OpenClaw / Zed / VS Code register one `acpx` entry per target - they care about: - ```json - "agents": { - "bridge-proj-a": { "command": "a2a-bridge acp --target claude:project-a --url …" }, - "bridge-proj-b": { "command": "a2a-bridge acp --target claude:project-b --url …" }, - "bridge-codex": { "command": "a2a-bridge acp --target codex --url …" } - } - ``` - Workspace IDs are derived from `A2A_BRIDGE_STATE_DIR` (same pattern - as the Telegram plugin's `TELEGRAM_STATE_DIR`). Reference - implementations: - - a2a-bridge: `src/shared/state-dir.ts` — `StateDirResolver` reads - `A2A_BRIDGE_STATE_DIR`, defaults to `$XDG_STATE_HOME/a2a-bridge`. - - Telegram plugin: `references/claude-plugins-official/external_plugins/ - telegram/server.ts:26` — `TELEGRAM_STATE_DIR` env. +- **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). From 5501b651fa5c48df91f590e17a6fb9c2d171c0ba Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 21:22:20 +0800 Subject: [PATCH 04/30] =?UTF-8?q?tasks:=20add=20Phase=2010=20=E2=80=94=20M?= =?UTF-8?q?ulti-target=20routing=20(v0.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 atomic tasks implementing the kind:id target model: - P10.1 TargetId type + parser - P10.2 Plugin workspace-id derivation + claude_connect extension - P10.3 Daemon rooms map keyed by TargetId - P10.4 a2a-bridge acp --target flag - P10.5 a2a-bridge daemon targets subcommand - P10.6 Attach conflict policy (reject + --force) - P10.7 A2A contextId → TargetId routing - P10.8 Outbound CC→peer via reply tool target field - P10.9 a2a-bridge codex --id flag - P10.10 Cross-target integration test - P10.11 Documentation sweep + CHANGELOG 0.2.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/TASKS.md b/TASKS.md index 5f9dca1..6a9884d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -522,6 +522,95 @@ 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`. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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`. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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.** + 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. + +- [ ] **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`. + +- [ ] **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) From bf3f9af9a8fd611f3346042396d1d4f29f101605 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 21:29:12 +0800 Subject: [PATCH 05/30] feat(shared): TargetId type + parser (P10.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the TargetId model for v0.2 multi-target routing: every agent instance is identified by a branded 'kind:id' string. - parseTarget(input) returns a Result with parsed {kind, id} or a descriptive error. Accepts bare kind (id defaults to "default") or full "kind:id" form. - formatTarget({kind, id}) is the inverse, throws on invalid parts. - assertTarget(input) returns the branded TargetId or throws. - Validation: both segments match /^[a-z0-9_-]+$/ — same constraint as identifier-safe channel meta keys, so TargetId strings survive every transport layer without silent mangling. 18 unit tests cover happy paths (kind:id, bare kind, digits, underscores, hyphens), rejection cases (empty, only colon, leading/ trailing colon, multiple colons, uppercase, space, dot, slash), format round-trips, and assertTarget behaviour. 402 pass total (was 384). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/shared/target-id.test.ts | 130 +++++++++++++++++++++++++++++++++++ src/shared/target-id.ts | 105 ++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/shared/target-id.test.ts create mode 100644 src/shared/target-id.ts 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; +} From 3ab34460a7d81e8e31277c7d680760213d525ef5 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 21:29:40 +0800 Subject: [PATCH 06/30] chore(tasks): mark P10.1 complete Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 6a9884d..72c432a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -530,7 +530,7 @@ once that phase lands. > daemon front multiple Claude Code workspaces and multiple peer > instances, selected by `--target kind:id`. -- [ ] **P10.1 — TargetId type + parser.** +- [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 From f7830f92d9a4a50852ee17be6e13c8c3296b14f2 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 21:35:26 +0800 Subject: [PATCH 07/30] feat(plugin): derive CC workspace id + send target on claude_connect (P10.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin now sends its TargetId to the daemon on attach so v0.2's multi-target router can place it in the right Room. Backward compat: when no env vars are set, derives `claude:default` (today's behaviour). - src/shared/workspace-id.ts: resolveWorkspaceId() walks the priority chain documented in docs/design/multi-target-routing.md (A2A_BRIDGE_WORKSPACE_ID → A2A_BRIDGE_STATE_DIR basename → conversationId prefix → "default") with sanitisation that guarantees identifier-safe output. resolveClaudeTarget() builds the full `claude:` TargetId. - src/transport/control-protocol.ts: claude_connect frame gains optional `target?: string`. Round-trip test covers both forms. - src/runtime-plugin/daemon-client/daemon-client.ts: attachClaude() now accepts an optional target argument; sends it on the frame when present. - src/runtime-plugin/bridge.ts: computes CLAUDE_TARGET at startup from stateDir.dir + env, passes it to attachClaude(). 15 new tests (workspace-id sanitisation, priority chain, claude_connect round-trip with/without target). 416 pass total (was 402). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/runtime-plugin/bridge.ts | 9 +- .../daemon-client/daemon-client.ts | 10 +- src/shared/workspace-id.test.ts | 102 ++++++++++++++++++ src/shared/workspace-id.ts | 78 ++++++++++++++ src/transport/control-protocol.test.ts | 23 ++++ src/transport/control-protocol.ts | 5 +- 6 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 src/shared/workspace-id.test.ts create mode 100644 src/shared/workspace-id.ts diff --git a/src/runtime-plugin/bridge.ts b/src/runtime-plugin/bridge.ts index 2fa4f1f..1a52d50 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,12 @@ 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 }); + let shuttingDown = false; let daemonDisabled = false; @@ -95,7 +102,7 @@ async function connectToDaemon(isReconnect = false) { try { await daemonLifecycle.ensureRunning(); await daemonClient.connect(); - daemonClient.attachClaude(); + daemonClient.attachClaude(CLAUDE_TARGET); if (!isReconnect) { void claude.pushNotification(systemMessage( "system_bridge_ready", diff --git a/src/runtime-plugin/daemon-client/daemon-client.ts b/src/runtime-plugin/daemon-client/daemon-client.ts index edd76fd..97b6010 100644 --- a/src/runtime-plugin/daemon-client/daemon-client.ts +++ b/src/runtime-plugin/daemon-client/daemon-client.ts @@ -80,8 +80,14 @@ 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). + */ + attachClaude(target?: string) { + this.send({ type: "claude_connect", ...(target ? { target } : {}) }); } async disconnect() { 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..31c2d03 100644 --- a/src/transport/control-protocol.test.ts +++ b/src/transport/control-protocol.test.ts @@ -29,6 +29,29 @@ 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("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..d63821b 100644 --- a/src/transport/control-protocol.ts +++ b/src/transport/control-protocol.ts @@ -53,7 +53,10 @@ 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`. + | { type: "claude_connect"; target?: string } | { type: "claude_disconnect" } | { type: "claude_to_codex"; requestId: string; message: BridgeMessage; requireReply?: boolean } | { type: "status" } From eb14c778a1d98fb48661815588ebf79f39dff7f2 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 21:35:49 +0800 Subject: [PATCH 08/30] chore(tasks): mark P10.2 complete Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 72c432a..5cab02b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -538,7 +538,7 @@ once that phase lands. single `:` separator). Unit tests cover happy path, defaults (`claude` → `claude:default`), and every rejection case. -- [ ] **P10.2 — Plugin-side workspace id derivation.** +- [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` From 2647918c706a2b4fe4db7c3e9459db3d19f91523 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 21:41:52 +0800 Subject: [PATCH 09/30] feat(daemon): per-target attach map + RoomRouter.getOrCreateByTarget (P10.3) RoomRouter gains TargetId-typed helpers: - getOrCreateByTarget(target) returns/mints the Room for a TargetId. Internally just forwards to getOrCreate(target as RoomId) since a TargetId is structurally a RoomId, but the typed wrapper makes call sites declarative and avoids unsafe casts at every use. - getByTarget(target) is the non-creating accessor. daemon.ts attach handler now stores a Map in parallel with the existing single attachedClaude pointer: - claude_connect carries optional `target`. Validated via parseTarget; malformed or non-claude targets are rejected with a log line. - Default target is "claude:default" (v0.1 behaviour preserved). - attachClaude updates both the per-target map and the back-compat attachedClaude singleton. - detachClaude cleans up the per-target entry first; if the global pointer was this conn, it falls through to "any other attached" so emitToClaude / broadcast keep a destination. Per-target routing through the gateway (so different inbound turns reach different attached CCs) lands in P10.4 / P10.7. 3 new RoomRouter tests for the TargetId methods. 419 pass total (was 416). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/runtime-daemon/daemon.ts | 62 ++++++++++++++++++-- src/runtime-daemon/rooms/room-router.test.ts | 58 ++++++++++++++++++ src/runtime-daemon/rooms/room-router.ts | 16 +++++ 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/runtime-daemon/daemon.ts b/src/runtime-daemon/daemon.ts index 15dd19e..04df1c0 100644 --- a/src/runtime-daemon/daemon.ts +++ b/src/runtime-daemon/daemon.ts @@ -17,6 +17,7 @@ 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 } 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"; @@ -62,8 +63,16 @@ 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(); let attachedClaude: Connection | null = null; const controlClientMeta = new WeakMap(); +const claudeConnTarget = new WeakMap(); let nextControlClientId = 0; const inboundGateway = new DaemonClaudeCodeGateway({ @@ -334,7 +343,7 @@ function handleControlMessage(conn: Connection, raw: string) { switch (message.type) { case "claude_connect": - attachClaude(conn); + attachClaude(conn, message.target); return; case "claude_disconnect": detachClaude(conn, "frontend requested disconnect"); @@ -448,18 +457,44 @@ function handleControlMessage(conn: Connection, raw: string) { } } -function attachClaude(conn: Connection) { - if (attachedClaude && attachedClaude !== conn) { - attachedClaude.close(); +function attachClaude(conn: Connection, target?: string) { + // 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}`); + return; + } + if (parsed.parts.kind !== "claude") { + log(`Rejecting claude_connect with non-claude target "${target}"`); + return; + } + resolvedTarget = parsed.target as unknown as string; } + // If a different connection already owns this target, kick the + // old one. v0.2 P10.6 will replace this last-wins behaviour with + // explicit reject + --force. + const existing = attachedClaudeByTarget.get(resolvedTarget); + if (existing && existing !== conn) { + log(`Replacing existing attachment for ${resolvedTarget}`); + existing.close(); + } + attachedClaudeByTarget.set(resolvedTarget, conn); + 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 ?? "?"}) → ${resolvedTarget}`); statusBuffer.flush("claude reconnected"); sendStatus(conn); @@ -486,10 +521,25 @@ 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); + } + 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})`); 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 From 86ed26b7f465ebdbd4613fea191e7a96885331a2 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 21:42:18 +0800 Subject: [PATCH 10/30] chore(tasks): mark P10.3 complete Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 5cab02b..4ce1210 100644 --- a/TASKS.md +++ b/TASKS.md @@ -547,7 +547,7 @@ once that phase lands. gains an optional `target: string` field; unit test asserts the frame round-trips. -- [ ] **P10.3 — Daemon rooms map keyed by TargetId.** +- [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 From ec7ee9f9ee5c77937b3da77a08a27aa9f5724da6 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 21:50:00 +0800 Subject: [PATCH 11/30] feat(acp): --target flag + per-target turn rejection (P10.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes ACP turns to a specific daemon Room via a kind:id TargetId: - transport/control-protocol.ts: acp_turn_start gains optional `target` field (string). - runtime-daemon/inbound/acp/daemon-proxy-gateway.ts: new `target` ctor option; subprocess stamps it on every acp_turn_start frame when set. - cli/acp.ts: parseAcpArgs recognises --target / -t / --target=, validates via parseTarget, plumbs through RunAcpOptions.target → DaemonProxyGateway. Invalid values exit 1 with a clear message. - runtime-daemon/inbound/acp/turn-handler.ts: new `isTargetAttached` predicate ctor option. When supplied, every turn is gated on the predicate; rejection emits acp_turn_error with "target X not attached" and never touches the gateway. Missing `target` field defaults to claude:default. No predicate → all targets accepted (v0.1 backward compat). - runtime-daemon/daemon.ts: wires the predicate to attachedClaudeByTarget (P10.3 map). claude:default also accepts the legacy attachedClaude pointer so single-CC v0.1 flows work without a target field. 4 new turn-handler tests (happy/missing/default/no-predicate). 423 pass total (was 419). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/acp.ts | 27 +++++- src/runtime-daemon/daemon.ts | 16 +++- .../inbound/acp/daemon-proxy-gateway.ts | 10 +++ .../inbound/acp/turn-handler.test.ts | 90 +++++++++++++++++++ .../inbound/acp/turn-handler.ts | 30 ++++++- src/transport/control-protocol.ts | 4 +- 6 files changed, 172 insertions(+), 5 deletions(-) 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/runtime-daemon/daemon.ts b/src/runtime-daemon/daemon.ts index 04df1c0..688a2bd 100644 --- a/src/runtime-daemon/daemon.ts +++ b/src/runtime-daemon/daemon.ts @@ -84,8 +84,20 @@ const inboundGateway = new DaemonClaudeCodeGateway({ // 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}`), +const acpTurnHandler = new AcpTurnHandler( + inboundGateway, + (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 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/turn-handler.test.ts b/src/runtime-daemon/inbound/acp/turn-handler.test.ts index 42dbf0e..11e2032 100644 --- a/src/runtime-daemon/inbound/acp/turn-handler.test.ts +++ b/src/runtime-daemon/inbound/acp/turn-handler.test.ts @@ -442,3 +442,93 @@ describe("AcpTurnHandler — permission bridging (P8.2a)", () => { ]); }); }); + +describe("AcpTurnHandler — target routing (P10.4)", () => { + test("turn is forwarded when target's CC is attached", () => { + 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), + }); + + 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", () => { + 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), + }); + + 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", () => { + 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), + }); + + 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)", () => { + const gw = new StubGateway(); + const conn = new FakeConnection(); + const handler = new AcpTurnHandler(gw); // no opts + + 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..371e423 100644 --- a/src/runtime-daemon/inbound/acp/turn-handler.ts +++ b/src/runtime-daemon/inbound/acp/turn-handler.ts @@ -48,19 +48,33 @@ 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; +} + 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, log?: (msg: string) => void, - opts?: { permissionTimeoutMs?: number }, + opts?: AcpTurnHandlerOpts, ) { this.log = log ?? (() => {}); this.permissionTimeoutMs = opts?.permissionTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS; + this.isTargetAttached = opts?.isTargetAttached; } // --------------------------------------------------------------------------- @@ -140,6 +154,20 @@ export class AcpTurnHandler { conn: Connection, msg: Extract, ): void { + // 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; + } + // Settle + cancel any existing turn for this connection before starting a new one. this.settleTurn(conn, `superseded by ${msg.turnId}`); diff --git a/src/transport/control-protocol.ts b/src/transport/control-protocol.ts index d63821b..51c21d2 100644 --- a/src/transport/control-protocol.ts +++ b/src/transport/control-protocol.ts @@ -61,7 +61,9 @@ export type ControlClientMessage = | { type: "claude_to_codex"; requestId: string; message: BridgeMessage; requireReply?: boolean } | { 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. From 779248e5900ebdf2db1a6157b930ecbd28e95204 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 21:50:22 +0800 Subject: [PATCH 12/30] chore(tasks): mark P10.4 complete Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 4ce1210..59d52d7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -555,7 +555,7 @@ once that phase lands. special case where every inbound request resolves to `claude:default`. -- [ ] **P10.4 — `a2a-bridge acp --target` flag.** +- [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; From f244a9c8baf823f11707260640f1847c9c77d1af Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 22:00:49 +0800 Subject: [PATCH 13/30] feat(cli): daemon targets subcommand + list_targets RPC (P10.5) Adds `a2a-bridge daemon targets` that opens a WebSocket to the control plane, sends a new `list_targets` client message, and prints a plain-text table of every TargetId the daemon currently tracks. Daemon side: - `ControlClientMessage` / `ControlServerMessage` grow the `list_targets` / `targets_response` pair plus a `TargetEntry` struct (target, attached, clientId, attachedAt). - `runtime-daemon/daemon.ts` snapshots `attachedClaudeByTarget` and surfaces the legacy singleton `attachedClaude` as `claude:default` for v0.1 wire compatibility. An `attachedAtByTarget` Map tracks per-target attach wall-clock time for uptime rendering. CLI side: - `RunDaemonOptions.queryTargets` is injectable so tests stub the WebSocket round-trip. - `formatTargetsTable` renders a 4-column table (target, attached, client, uptime); empty input prints a friendly hint. - Usage/help text updated; `cli.ts` lists the new subcommand. Tests: - `daemon.test.ts` covers not-running short-circuit, successful query + table rendering, and error surface. A separate `formatTargetsTable` suite locks the layout. Backward compat preserved: the subcommand is additive, the RPC is optional on the wire, and v0.1 single-CC setups render as a single `claude:default` row. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/a2a-bridge/server/bridge-server.js | 132 +++++++++++++----- plugins/a2a-bridge/server/daemon.js | 154 +++++++++++++++++++-- src/cli/cli.ts | 2 +- src/cli/daemon.test.ts | 82 ++++++++++- src/cli/daemon.ts | 117 +++++++++++++++- src/runtime-daemon/daemon.ts | 40 ++++++ src/transport/control-protocol.ts | 21 ++- 7 files changed, 494 insertions(+), 54 deletions(-) diff --git a/plugins/a2a-bridge/server/bridge-server.js b/plugins/a2a-bridge/server/bridge-server.js index 7db995e..24f768a 100755 --- a/plugins/a2a-bridge/server/bridge-server.js +++ b/plugins/a2a-bridge/server/bridge-server.js @@ -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 } } }); @@ -14067,8 +14054,8 @@ class DaemonClient extends EventEmitter2 { }; }); } - attachClaude() { - this.send({ type: "claude_connect" }); + attachClaude(target) { + this.send({ type: "claude_connect", ...target ? { target } : {} }); } async disconnect() { if (!this.ws) @@ -14606,6 +14593,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 +14678,7 @@ 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 shuttingDown = false; var daemonDisabled = false; var RECONNECT_NOTIFY_COOLDOWN_MS = 30000; @@ -14671,9 +14735,9 @@ async function connectToDaemon(isReconnect = false) { try { await daemonLifecycle.ensureRunning(); await daemonClient.connect(); - daemonClient.attachClaude(); + daemonClient.attachClaude(CLAUDE_TARGET); if (!isReconnect) { - claude.pushNotification(systemMessage("system_bridge_ready", "\u2705 A2aBridge bridge is ready. Daemon connected. Start Codex in another terminal with: a2a-bridge codex")); + claude.pushNotification(systemMessage("system_bridge_ready", "\u2705 A2aBridge bridge is ready. Daemon connected. ACP clients can now send prompts.")); } } catch (err) { log(`Failed to connect to daemon: ${err.message}`); diff --git a/plugins/a2a-bridge/server/daemon.js b/plugins/a2a-bridge/server/daemon.js index 2ab4af8..719a033 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)) { @@ -2408,10 +2455,12 @@ class AcpTurnHandler { pendingPermissions = new Map; log; permissionTimeoutMs; + isTargetAttached; constructor(gateway, log, opts) { this.gateway = gateway; 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]; @@ -2456,6 +2505,16 @@ class AcpTurnHandler { pending.resolve(msg.outcome); } 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; + } this.settleTurn(conn, `superseded by ${msg.turnId}`); const turn = this.gateway.startTurn(msg.userText); const settled = { value: false }; @@ -2553,16 +2612,27 @@ 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(inboundGateway, (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, @@ -2741,7 +2811,7 @@ function handleControlMessage(conn, raw) { } switch (message.type) { case "claude_connect": - attachClaude(conn); + attachClaude(conn, message.target); return; case "claude_disconnect": detachClaude(conn, "frontend requested disconnect"); @@ -2781,6 +2851,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, { @@ -2843,17 +2922,35 @@ function handleControlMessage(conn, raw) { } } } -function attachClaude(conn) { - if (attachedClaude && attachedClaude !== conn) { - attachedClaude.close(); +function attachClaude(conn, target) { + let resolvedTarget = "claude:default"; + if (target) { + const parsed = parseTarget(target); + if (!parsed.ok) { + log(`Rejecting claude_connect with invalid target "${target}": ${parsed.error}`); + return; + } + if (parsed.parts.kind !== "claude") { + log(`Rejecting claude_connect with non-claude target "${target}"`); + return; + } + resolvedTarget = parsed.target; } + const existing = attachedClaudeByTarget.get(resolvedTarget); + if (existing && existing !== conn) { + log(`Replacing existing attachment for ${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 +2970,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})`); @@ -3043,10 +3149,31 @@ function notifyCodexClaudeOnline() { function shouldNotifyCodexClaudeOnline() { return !claudeOnlineNoticeSent || claudeOfflineNoticeShown; } -function systemMessage(idPrefix, content) { +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")) { + 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() }; @@ -3080,8 +3207,11 @@ async function bootCodex() { emitToClaude(systemMessage("system_waiting", currentWaitingMessage())); broadcastStatus(); } catch (err) { - log(`Failed to start Codex: ${err.message}`); - emitToClaude(systemMessage("system_codex_start_failed", `\u274C A2aBridge failed to start Codex app-server: ${err.message}`)); + const msg = err.message ?? String(err); + log(`Codex start skipped: ${msg}`); + if (!msg.includes("not found in $PATH") && !msg.includes("Executable not found")) { + emitToClaude(systemMessage("system_codex_start_failed", `\u26A0\uFE0F Codex app-server failed to start: ${msg}. The ACP bridge still works \u2014 Codex is optional.`)); + } broadcastStatus(); } } 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/runtime-daemon/daemon.ts b/src/runtime-daemon/daemon.ts index 688a2bd..d1ebb12 100644 --- a/src/runtime-daemon/daemon.ts +++ b/src/runtime-daemon/daemon.ts @@ -70,6 +70,7 @@ let a2aInboundServer: A2aServerHandle | null = null; // 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(); @@ -401,6 +402,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, { @@ -498,6 +508,7 @@ function attachClaude(conn: Connection, target?: string) { existing.close(); } attachedClaudeByTarget.set(resolvedTarget, conn); + attachedAtByTarget.set(resolvedTarget, Date.now()); claudeConnTarget.set(conn, resolvedTarget); const meta = controlClientMeta.get(conn); @@ -539,6 +550,7 @@ function detachClaude(conn: Connection, reason: string) { const target = claudeConnTarget.get(conn); if (target && attachedClaudeByTarget.get(target) === conn) { attachedClaudeByTarget.delete(target); + attachedAtByTarget.delete(target); } claudeConnTarget.delete(conn); @@ -753,6 +765,34 @@ function shouldNotifyCodexClaudeOnline() { return !claudeOnlineNoticeSent || claudeOfflineNoticeShown; } +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` + // when no explicit target was sent (v0.1 wire compatibility). + if (attachedClaude && !attachedClaudeByTarget.has("claude:default")) { + 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}`, diff --git a/src/transport/control-protocol.ts b/src/transport/control-protocol.ts index 51c21d2..6d54702 100644 --- a/src/transport/control-protocol.ts +++ b/src/transport/control-protocol.ts @@ -70,7 +70,10 @@ export type ControlClientMessage = | { 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 } @@ -85,4 +88,18 @@ 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[] }; + +/** 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; +} From 49add75158d31e3e101a395883a302f87f04873a Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 22:01:10 +0800 Subject: [PATCH 14/30] chore(tasks): mark P10.5 complete Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 59d52d7..3966afd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -562,7 +562,7 @@ once that phase lands. unresolvable target → `acp_turn_error { message: "target not attached" }`. Unit test covers happy path + missing-target path. -- [ ] **P10.5 — `a2a-bridge daemon targets` subcommand.** +- [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 From c47400f27fc7e3b2e8233cb1b30d09c7ec695225 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 22:11:39 +0800 Subject: [PATCH 15/30] feat(attach): attach conflict policy (reject + --force) (P10.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the v0.1 last-wins behaviour on `claude_connect` with an explicit conflict policy. When a new CC tries to attach to a TargetId that is already owned by another connection, the daemon now rejects the new attach by default and surfaces a descriptive error. Re-running with `--force` tells the daemon to kick the old attach, notifying it via a new `claude_connect_replaced` frame. Wire: - `claude_connect` grows an optional `force?: boolean` field. - Two new server-bound frames: - `claude_connect_rejected { target, reason }` — sent to the new conn when a conflict is detected without force. - `claude_connect_replaced { target }` — sent to the old conn just before the daemon closes its socket under force. - Round-trip tests lock both variants. Daemon: - `attachClaude(conn, target?, force=false)` applies the new policy. The reason string includes the old conn's client id and a human uptime (`2h ago`, `3m ago`) pulled from `attachedAtByTarget`. - `sendProtocolMessage` deliveries are unchanged otherwise; the existing map bookkeeping stays correct for both outcomes. Plugin: - `DaemonClient.attachClaude(target, force)` threads the flag through. New `connectRejected` / `connectReplaced` events fire on incoming frames. - `runtime-plugin/bridge.ts` reads `A2A_BRIDGE_FORCE_ATTACH=1` once at startup and passes it to the initial attach. On either conflict event we enter the existing `disabledState` so we stop reconnect loops and push a CC-visible notification. - Drive-by fix: the disabled-state recovery path was passing no target to `attachClaude()`, which would silently route a recovering CC to `claude:default` instead of its real workspace. It now passes `CLAUDE_TARGET` (and never forces). CLI: - `a2a-bridge claude --force` strips the flag before spawning `claude` and sets `A2A_BRIDGE_FORCE_ATTACH=1` on the child env. - `extractForceFlag` helper + unit tests lock the parser. Tests: - `src/cli/claude-conflict.test.ts` boots a real daemon, attaches two `DaemonClient`s to the same TargetId, and asserts both outcomes: without force → second gets `connectRejected`, first stays owner; with force → second wins, first receives `connectReplaced` and its socket closes. - `daemon-client.test.ts` covers the wire flag + event emission. - `control-protocol.test.ts` locks the two new server frames. Scope note: Codex peer conflict handling is deferred to P10.9, which introduces target ids on codex. This change covers claude only, matching the attach surface that exists today. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/a2a-bridge/server/bridge-server.js | 28 ++- plugins/a2a-bridge/server/daemon.js | 47 ++++- src/cli/claude-conflict.test.ts | 176 ++++++++++++++++++ src/cli/claude-flags.test.ts | 36 ++++ src/cli/claude.ts | 32 +++- src/runtime-daemon/daemon.ts | 60 +++++- src/runtime-plugin/bridge.ts | 28 ++- .../daemon-client/daemon-client.test.ts | 66 +++++++ .../daemon-client/daemon-client.ts | 27 ++- src/transport/control-protocol.test.ts | 32 ++++ src/transport/control-protocol.ts | 15 +- 11 files changed, 525 insertions(+), 22 deletions(-) create mode 100644 src/cli/claude-conflict.test.ts create mode 100644 src/cli/claude-flags.test.ts diff --git a/plugins/a2a-bridge/server/bridge-server.js b/plugins/a2a-bridge/server/bridge-server.js index 24f768a..bdce818 100755 --- a/plugins/a2a-bridge/server/bridge-server.js +++ b/plugins/a2a-bridge/server/bridge-server.js @@ -14054,8 +14054,12 @@ class DaemonClient extends EventEmitter2 { }; }); } - attachClaude(target) { - this.send({ type: "claude_connect", ...target ? { target } : {} }); + attachClaude(target, force = false) { + this.send({ + type: "claude_connect", + ...target ? { target } : {}, + ...force ? { force: true } : {} + }); } async disconnect() { if (!this.ws) @@ -14113,6 +14117,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) => { @@ -14679,6 +14692,7 @@ 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; @@ -14706,6 +14720,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; @@ -14735,7 +14755,7 @@ async function connectToDaemon(isReconnect = false) { try { await daemonLifecycle.ensureRunning(); await daemonClient.connect(); - daemonClient.attachClaude(CLAUDE_TARGET); + 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.")); } @@ -14835,7 +14855,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 719a033..e36507c 100755 --- a/plugins/a2a-bridge/server/daemon.js +++ b/plugins/a2a-bridge/server/daemon.js @@ -2811,7 +2811,7 @@ function handleControlMessage(conn, raw) { } switch (message.type) { case "claude_connect": - attachClaude(conn, message.target); + attachClaude(conn, message.target, message.force === true); return; case "claude_disconnect": detachClaude(conn, "frontend requested disconnect"); @@ -2922,23 +2922,52 @@ function handleControlMessage(conn, raw) { } } } -function attachClaude(conn, target) { +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) { - log(`Replacing existing attachment for ${resolvedTarget}`); + 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); @@ -3149,6 +3178,18 @@ function notifyCodexClaudeOnline() { function shouldNotifyCodexClaudeOnline() { return !claudeOnlineNoticeSent || claudeOfflineNoticeShown; } +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()) { diff --git a/src/cli/claude-conflict.test.ts b/src/cli/claude-conflict.test.ts new file mode 100644 index 0000000..5fa199b --- /dev/null +++ b/src/cli/claude-conflict.test.ts @@ -0,0 +1,176 @@ +/** + * 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"; + +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("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/runtime-daemon/daemon.ts b/src/runtime-daemon/daemon.ts index d1ebb12..c997a81 100644 --- a/src/runtime-daemon/daemon.ts +++ b/src/runtime-daemon/daemon.ts @@ -356,7 +356,7 @@ function handleControlMessage(conn: Connection, raw: string) { switch (message.type) { case "claude_connect": - attachClaude(conn, message.target); + attachClaude(conn, message.target, message.force === true); return; case "claude_disconnect": detachClaude(conn, "frontend requested disconnect"); @@ -479,7 +479,7 @@ function handleControlMessage(conn: Connection, raw: string) { } } -function attachClaude(conn: Connection, target?: string) { +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`. @@ -490,21 +490,55 @@ function attachClaude(conn: Connection, target?: string) { 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; } - // If a different connection already owns this target, kick the - // old one. v0.2 P10.6 will replace this last-wins behaviour with - // explicit reject + --force. + // 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) { - log(`Replacing existing attachment for ${resolvedTarget}`); + 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); @@ -765,6 +799,20 @@ 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 diff --git a/src/runtime-plugin/bridge.ts b/src/runtime-plugin/bridge.ts index 1a52d50..f89ee45 100644 --- a/src/runtime-plugin/bridge.ts +++ b/src/runtime-plugin/bridge.ts @@ -26,6 +26,11 @@ const daemonClient = new DaemonClient(CONTROL_WS_URL); // `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; @@ -63,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; @@ -102,7 +123,7 @@ async function connectToDaemon(isReconnect = false) { try { await daemonLifecycle.ensureRunning(); await daemonClient.connect(); - daemonClient.attachClaude(CLAUDE_TARGET); + daemonClient.attachClaude(CLAUDE_TARGET, FORCE_ATTACH); if (!isReconnect) { void claude.pushNotification(systemMessage( "system_bridge_ready", @@ -235,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/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 97b6010..5d4a2dc 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; @@ -85,9 +91,17 @@ export class DaemonClient extends EventEmitter { * 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) { - this.send({ type: "claude_connect", ...(target ? { target } : {}) }); + attachClaude(target?: string, force: boolean = false) { + this.send({ + type: "claude_connect", + ...(target ? { target } : {}), + ...(force ? { force: true } : {}), + }); } async disconnect() { @@ -153,6 +167,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/transport/control-protocol.test.ts b/src/transport/control-protocol.test.ts index 31c2d03..a21b2db 100644 --- a/src/transport/control-protocol.test.ts +++ b/src/transport/control-protocol.test.ts @@ -52,6 +52,38 @@ describe("P10.2 control-plane: claude_connect carries optional target", () => { }); }); +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 6d54702..f470f38 100644 --- a/src/transport/control-protocol.ts +++ b/src/transport/control-protocol.ts @@ -56,7 +56,10 @@ export type ControlClientMessage = // `target` is the `kind:id` TargetId (see shared/target-id.ts). // Optional for v0.1 backward compat; when omitted the daemon // assigns `claude:default`. - | { type: "claude_connect"; target?: string } + // `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 } | { type: "status" } @@ -90,7 +93,15 @@ export type ControlServerMessage = // ACP client via `AgentSideConnection.requestPermission`. | { 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[] }; + | { 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 { From 623c32f882ff007926181464357a4411d63821fb Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 22:12:01 +0800 Subject: [PATCH 16/30] chore(tasks): mark P10.6 complete Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 3966afd..590e56e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -568,7 +568,7 @@ once that phase lands. rooms map via a new control-plane `list_targets` RPC. Unit test against a stub daemon. -- [ ] **P10.6 — Attach conflict policy (reject + `--force`).** +- [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 From b63d82acdc5777d7e35473051f5e93981649ad86 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 22:19:42 +0800 Subject: [PATCH 17/30] =?UTF-8?q?feat(a2a):=20contextId=20=E2=86=92=20Targ?= =?UTF-8?q?etId=20routing=20config=20(P10.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startA2AServer` gains a `contextRoutes: Record` config knob. When supplied, every inbound `message/stream` turn resolves its target by looking up `params.message.contextId` in the 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 without each contextId spawning its own Room. - Startup validates every route value via `parseTarget` — a typo fails fast rather than 500ing the first inbound call. Supplying `contextRoutes` without a `roomRouter` is also a startup error. - When `contextRoutes` is omitted, the server keeps the v0.1 `deriveRoomId({ contextId })` behaviour so existing deployments and tests are unaffected. - Daemon wiring: `A2A_BRIDGE_CONTEXT_ROUTES` env var (a JSON object literal) is parsed via a new exported `parseContextRoutes` helper. Malformed input is logged and dropped — a bad env var degrades to v0.1 routing rather than bringing the daemon down. Tests: - Integration: three contextIds (two mapped, one stranger) hit three distinct Rooms keyed by TargetId; stranger routes to `claude:default`, which notably did NOT mint its own Room. - Startup: malformed TargetId and missing roomRouter both throw. - Unit: `parseContextRoutes` handles empty/invalid/partial JSON. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/a2a-bridge/server/daemon.js | 62 +++++- src/runtime-daemon/daemon.ts | 6 + .../inbound/a2a-http/server.test.ts | 195 +++++++++++++++++- src/runtime-daemon/inbound/a2a-http/server.ts | 108 +++++++++- 4 files changed, 357 insertions(+), 14 deletions(-) diff --git a/plugins/a2a-bridge/server/daemon.js b/plugins/a2a-bridge/server/daemon.js index e36507c..784209a 100755 --- a/plugins/a2a-bridge/server/daemon.js +++ b/plugins/a2a-bridge/server/daemon.js @@ -2323,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 }); @@ -2335,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), @@ -2380,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), @@ -2414,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 || "/"; @@ -3264,7 +3313,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/runtime-daemon/daemon.ts b/src/runtime-daemon/daemon.ts index c997a81..33934e8 100644 --- a/src/runtime-daemon/daemon.ts +++ b/src/runtime-daemon/daemon.ts @@ -24,6 +24,7 @@ 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"; @@ -925,6 +926,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 || "/"; From 5bd97f624599ffca5d466903203d7bdc5d1d93fd Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 22:20:01 +0800 Subject: [PATCH 18/30] chore(tasks): mark P10.7 complete Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 590e56e..c3d8bd0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -575,7 +575,7 @@ once that phase lands. `claude_connect_replaced` / peer-specific kick frame to the old attach and takes over. Unit tests for both outcomes. -- [ ] **P10.7 — A2A `contextId → TargetId` routing.** +- [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 From c12f08d3d433f6d6dbf4324bfe748a0d07299c5f Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 22:28:01 +0800 Subject: [PATCH 19/30] feat(reply): outbound target routing on reply tool (P10.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin's `reply` tool accepts an optional `target` field. When supplied, the daemon forwards the reply to that TargetId's Room instead of the inbound turn's originator. Absent target keeps v0.1 behaviour (try inbound intercept → else Codex injection). Wire: - `claude_to_codex` grows an optional `target?: string` field. Round-trip tests lock both the present and absent variants. Plugin: - `reply` tool schema gains a `target` property (description covers the intended hand-off pattern — `claude:project-b`, `codex:default`). - `ReplySender` type, `bridge.ts` adapter, and `DaemonClient.sendReply` all thread `target` through without changing defaults. - Response text names the target when present; preserves the "Reply sent to Codex." phrasing when absent so dual-mode tests stay green. Daemon: - `case "claude_to_codex"` parses `target` via `parseTarget` at the top of the handler. `claude:*` targets deliver directly to the named attached CC (falling back to the legacy `attachedClaude` singleton for `claude:default`). `codex:default` falls through to the existing injection path; other codex ids are deferred to P10.9 and rejected with a descriptive error. Self-delivery is also rejected — replies must not loop back. Tests: - New `src/cli/reply-target.test.ts` spawns a real daemon, attaches two stub CCs, and covers forward / unknown / omit / invalid paths in a single integration run (plus a dedicated test for malformed TargetId strings). Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/a2a-bridge/server/bridge-server.js | 24 ++- plugins/a2a-bridge/server/daemon.js | 61 ++++++ src/cli/reply-target.test.ts | 201 ++++++++++++++++++ src/runtime-daemon/daemon.ts | 75 +++++++ src/runtime-plugin/bridge.ts | 4 +- .../claude-channel/claude-adapter.ts | 24 ++- .../daemon-client/daemon-client.ts | 13 +- src/transport/control-protocol.test.ts | 39 ++++ src/transport/control-protocol.ts | 6 +- 9 files changed, 428 insertions(+), 19 deletions(-) create mode 100644 src/cli/reply-target.test.ts diff --git a/plugins/a2a-bridge/server/bridge-server.js b/plugins/a2a-bridge/server/bridge-server.js index bdce818..d69156a 100755 --- a/plugins/a2a-bridge/server/bridge-server.js +++ b/plugins/a2a-bridge/server/bridge-server.js @@ -13893,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: { @@ -13903,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"] @@ -13947,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", @@ -13960,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 { @@ -13969,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.`; } @@ -14073,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." }; } @@ -14088,7 +14093,8 @@ class DaemonClient extends EventEmitter2 { type: "claude_to_codex", requestId, message, - ...requireReply ? { requireReply: true } : {} + ...requireReply ? { requireReply: true } : {}, + ...target ? { target } : {} }); }); } @@ -14701,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" }; } @@ -14711,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)`); diff --git a/plugins/a2a-bridge/server/daemon.js b/plugins/a2a-bridge/server/daemon.js index 784209a..6c262a0 100755 --- a/plugins/a2a-bridge/server/daemon.js +++ b/plugins/a2a-bridge/server/daemon.js @@ -2919,6 +2919,67 @@ function handleControlMessage(conn, raw) { }); return; } + 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; + } + } if (inboundGateway.interceptReply(message.message.content)) { log(`Claude reply consumed by inbound A2A turn (${message.message.content.length} chars)`); clearAttentionWindow(); 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 33934e8..83cb2cd 100644 --- a/src/runtime-daemon/daemon.ts +++ b/src/runtime-daemon/daemon.ts @@ -423,6 +423,81 @@ function handleControlMessage(conn: Connection, raw: string) { return; } + // 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 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)) { diff --git a/src/runtime-plugin/bridge.ts b/src/runtime-plugin/bridge.ts index f89ee45..9dd11de 100644 --- a/src/runtime-plugin/bridge.ts +++ b/src/runtime-plugin/bridge.ts @@ -42,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" }; } @@ -54,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) => { 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.ts b/src/runtime-plugin/daemon-client/daemon-client.ts index 5d4a2dc..225746b 100644 --- a/src/runtime-plugin/daemon-client/daemon-client.ts +++ b/src/runtime-plugin/daemon-client/daemon-client.ts @@ -119,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." }; } @@ -137,6 +147,7 @@ export class DaemonClient extends EventEmitter { requestId, message, ...(requireReply ? { requireReply: true } : {}), + ...(target ? { target } : {}), }); }); } diff --git a/src/transport/control-protocol.test.ts b/src/transport/control-protocol.test.ts index a21b2db..20c376b 100644 --- a/src/transport/control-protocol.test.ts +++ b/src/transport/control-protocol.test.ts @@ -52,6 +52,45 @@ describe("P10.2 control-plane: claude_connect carries optional target", () => { }); }); +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 = { diff --git a/src/transport/control-protocol.ts b/src/transport/control-protocol.ts index f470f38..67e6310 100644 --- a/src/transport/control-protocol.ts +++ b/src/transport/control-protocol.ts @@ -61,7 +61,11 @@ export type ControlClientMessage = // 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. // `target` (P10.4) selects which TargetId Room handles the turn. From 7ced6edf959100775b9b6ee6a062a7d1f3722063 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 22:28:21 +0800 Subject: [PATCH 20/30] chore(tasks): mark P10.8 complete Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index c3d8bd0..6f1ca96 100644 --- a/TASKS.md +++ b/TASKS.md @@ -582,7 +582,7 @@ once that phase lands. `RoomRouter.getOrCreateByTarget`. Unit test covers both mapped and fallback cases. -- [ ] **P10.8 — Outbound CC → peer via `reply` tool target.** +- [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. From d2ba7d0bb6d09d79aed1e24fa49fada7393a6dc0 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 23:33:39 +0800 Subject: [PATCH 21/30] chore(tasks): defer P10.9 codex peer-id flag to v0.3; start P10.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer decision (2026-04-15): v0.2 ships with multi-claude routing only. Codex peer-id support is out of scope for v0.2 — it would require refactoring the daemon's peer hosting model (per-id registry + port allocation + singleton teardown), too heavy for this phase. P10.10 and P10.11 proceed with claude-only cross-target coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/TASKS.md b/TASKS.md index 6f1ca96..205bd33 100644 --- a/TASKS.md +++ b/TASKS.md @@ -590,14 +590,21 @@ once that phase lands. originator (today's behaviour). Unit test covers forward + omit + unknown-target-error paths. -- [ ] **P10.9 — `a2a-bridge codex --id ` peer id flag.** - Acceptance: the Codex peer process registers under +- [~] **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 + can run concurrently on one daemon, each in their own Room. Unit + test asserts two Codex instances with distinct ids don't cross-talk. -- [ ] **P10.10 — Cross-target integration test.** +- [/] **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` From 13c73c557f4fef1b2e09b41f9406435a76c04b97 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 23:41:08 +0800 Subject: [PATCH 22/30] feat(daemon): per-target inbound gateway + P10.10 cross-target test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end multi-claude routing: two concurrent ACP subprocesses targeting `claude:ws-a` and `claude:ws-b` now each receive only their own CC's reply, with no cross-talk. This was the missing plumbing between the P10.4 target-rejection predicate and actual target-aware delivery. Daemon (src/runtime-daemon/daemon.ts): - New `emitToClaudeTarget(target, message)` helper. Looks up `attachedClaudeByTarget[target]`; for `claude:default` falls back to the legacy singleton `attachedClaude` for v0.1 compat. Drops with a log when no CC is attached for the target — no cross-target buffering, which would leak data. - `inboundRoomRouter` factory now mints a per-Room `DaemonClaudeCodeGateway` whose `sendToClaude` routes to the Room's TargetId via `emitToClaudeTarget`. `defaultRoom` keeps the shared `inboundGateway` so v0.1 `"default"` routing is unchanged. - `claude_to_codex` handler's interceptReply path now picks the sender's target (via `claudeConnTarget`) and delegates to that Room's gateway — so a reply from CC-A can only close CC-A's in-flight ACP turn, never CC-B's. AcpTurnHandler (src/runtime-daemon/inbound/acp/turn-handler.ts): - Constructor takes a `GatewayForTarget` function instead of a fixed gateway. Daemon wires it to `inboundRoomRouter.getOrCreateByTarget(target).gateway`. - `handleTurnStart` becomes async. Resolves the target's gateway after the `isTargetAttached` check; missing gateway surfaces `acp_turn_error { "no gateway for target ..." }` — mirrors the existing not-attached error path. - Existing 19 unit tests updated to wrap the stub gateway in `() => gw` and await `handleTurnStart` (all still green). P10.10 test (src/cli/multi-target.test.ts): - Boots real daemon, attaches two stub CCs, drives two concurrent ACP subprocesses with distinct `--target` values, asserts each subprocess sees ONLY its CC's prefixed reply. Also asserts no fallback to the echo executor (proves the router saw the CC). Scope note: codex peer-id routing remains deferred to v0.3 (P10.9) per the maintainer decision on 2026-04-15 — this test covers the multi-claude axis only. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/a2a-bridge/server/daemon.js | 52 +++- src/cli/multi-target.test.ts | 277 ++++++++++++++++++ src/runtime-daemon/daemon.ts | 72 ++++- .../inbound/acp/turn-handler.test.ts | 106 +++---- .../inbound/acp/turn-handler.ts | 36 ++- 5 files changed, 465 insertions(+), 78 deletions(-) create mode 100644 src/cli/multi-target.test.ts diff --git a/plugins/a2a-bridge/server/daemon.js b/plugins/a2a-bridge/server/daemon.js index 6c262a0..964873c 100755 --- a/plugins/a2a-bridge/server/daemon.js +++ b/plugins/a2a-bridge/server/daemon.js @@ -2499,14 +2499,14 @@ function jsonRpcResponse(resp) { var DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; class AcpTurnHandler { - gateway; + gatewayForTarget; activeTurns = new Map; pendingPermissions = new Map; log; permissionTimeoutMs; isTargetAttached; - constructor(gateway, log, opts) { - this.gateway = gateway; + constructor(gatewayForTarget, log, opts) { + this.gatewayForTarget = gatewayForTarget; this.log = log ?? (() => {}); this.permissionTimeoutMs = opts?.permissionTimeoutMs ?? DEFAULT_PERMISSION_TIMEOUT_MS; this.isTargetAttached = opts?.isTargetAttached; @@ -2553,7 +2553,7 @@ 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`); @@ -2564,11 +2564,21 @@ class AcpTurnHandler { }); 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; @@ -2673,7 +2683,10 @@ var inboundGateway = new DaemonClaudeCodeGateway({ }, 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; @@ -2691,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; @@ -2980,8 +2999,11 @@ function handleControlMessage(conn, raw) { return; } } - if (inboundGateway.interceptReply(message.message.content)) { - log(`Claude reply consumed by inbound A2A turn (${message.message.content.length} chars)`); + 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", @@ -3209,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)) 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/runtime-daemon/daemon.ts b/src/runtime-daemon/daemon.ts index 83cb2cd..4ddf543 100644 --- a/src/runtime-daemon/daemon.ts +++ b/src/runtime-daemon/daemon.ts @@ -17,7 +17,7 @@ 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 } from "@shared/target-id"; +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"; @@ -77,6 +77,12 @@ 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")); @@ -84,10 +90,15 @@ 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. +// 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( - inboundGateway, + 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 @@ -119,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; @@ -366,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); @@ -498,10 +520,19 @@ function handleControlMessage(conn: Connection, raw: string) { } } - // 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)`); + // 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", @@ -784,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; diff --git a/src/runtime-daemon/inbound/acp/turn-handler.test.ts b/src/runtime-daemon/inbound/acp/turn-handler.test.ts index 11e2032..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", @@ -443,16 +443,16 @@ describe("AcpTurnHandler — permission bridging (P8.2a)", () => { }); }); -describe("AcpTurnHandler — target routing (P10.4)", () => { - test("turn is forwarded when target's CC is attached", () => { +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, { + const handler = new AcpTurnHandler(() => gw, undefined, { isTargetAttached: (t) => attached.has(t), }); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t-ok", sessionId: "s", @@ -469,15 +469,15 @@ describe("AcpTurnHandler — target routing (P10.4)", () => { ]); }); - test("turn is rejected with acp_turn_error when target is unattached", () => { + 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, { + const handler = new AcpTurnHandler(() => gw, undefined, { isTargetAttached: (t) => attached.has(t), }); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t-ghost", sessionId: "s", @@ -497,15 +497,15 @@ describe("AcpTurnHandler — target routing (P10.4)", () => { ]); }); - test("missing target field defaults to claude:default", () => { + 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, { + const handler = new AcpTurnHandler(() => gw, undefined, { isTargetAttached: (t) => attached.has(t), }); - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t-default", sessionId: "s", @@ -516,12 +516,12 @@ describe("AcpTurnHandler — target routing (P10.4)", () => { expect(gw.turns).toHaveLength(1); }); - test("no isTargetAttached predicate → every target accepted (v0.1 compat)", () => { + 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 + const handler = new AcpTurnHandler(() => gw); // no opts - handler.handleTurnStart(conn, { + await handler.handleTurnStart(conn, { type: "acp_turn_start", turnId: "t-legacy", sessionId: "s", diff --git a/src/runtime-daemon/inbound/acp/turn-handler.ts b/src/runtime-daemon/inbound/acp/turn-handler.ts index 371e423..aa5d314 100644 --- a/src/runtime-daemon/inbound/acp/turn-handler.ts +++ b/src/runtime-daemon/inbound/acp/turn-handler.ts @@ -60,6 +60,18 @@ export interface AcpTurnHandlerOpts { 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(); @@ -68,7 +80,7 @@ export class AcpTurnHandler { private readonly isTargetAttached?: (target: string) => boolean; constructor( - private readonly gateway: ClaudeCodeGateway, + private readonly gatewayForTarget: GatewayForTarget, log?: (msg: string) => void, opts?: AcpTurnHandlerOpts, ) { @@ -150,10 +162,10 @@ 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). @@ -168,13 +180,27 @@ export class AcpTurnHandler { 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; From 943261def292c8d79f70313867b093c4b57f0033 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 23:41:29 +0800 Subject: [PATCH 23/30] chore(tasks): mark P10.10 complete Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index 205bd33..de1ad2c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -604,7 +604,7 @@ once that phase lands. test asserts two Codex instances with distinct ids don't cross-talk. -- [/] **P10.10 — Cross-target integration test.** +- [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` From 9680a1784b3cd732926be381a584d539d3928740 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 23:47:48 +0800 Subject: [PATCH 24/30] docs: multi-target routing sweep + 0.2.0 CHANGELOG (P10.11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `docs/design/multi-target-routing.md` — status flipped from "v0.2 design (approved, not yet implemented)" to "implemented in v0.2.0 (multi-claude axis); codex peer-id deferred to v0.3". - `docs/guides/rooms.md` — new TargetId section explains the `kind:id` model, the updated precedence table (ACP `--target`, A2A `contextRoutes`, legacy deriveRoomId path), CC attach id derivation, conflict policy, and the outbound reply target. - `docs/join.md` — new "Multi-workspace (v0.2, optional)" section covers the per-workspace STATE_DIR + `--target` pattern. - `README.md` — new "Multi-workspace routing (v0.2)" section; env var table grows `A2A_BRIDGE_WORKSPACE_ID`, `A2A_BRIDGE_FORCE_ATTACH`, and `A2A_BRIDGE_CONTEXT_ROUTES`; connect table gets an OpenClaw multi-CC row. - `CHANGELOG.md` — 0.2.0 block drafted: TargetId model, plugin workspace-id derivation, `--target`, `contextRoutes`, outbound reply target, attach conflict policy, `daemon targets` subcommand; per-target inbound gateway; wire additions; deferred items (codex peer-id, hot-reload, dynamic discovery). Scope note: architecture.md, positioning.md, and roadmap.md are read-only under the autonomous-mode rules and were not touched. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 95 +++++++++++++++++++++++++++ README.md | 53 ++++++++++++++- docs/design/multi-target-routing.md | 17 ++++- docs/guides/rooms.md | 99 +++++++++++++++++++++++------ docs/join.md | 45 +++++++++++-- 5 files changed, 282 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22464ec..4c805b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,101 @@ 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] — unreleased + +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). + +### 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 99d4993..e636023 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ client config: |-------|----------|--------| | **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/docs/design/multi-target-routing.md b/docs/design/multi-target-routing.md index 50addcc..1dbc0e2 100644 --- a/docs/design/multi-target-routing.md +++ b/docs/design/multi-target-routing.md @@ -1,12 +1,16 @@ # Multi-target routing -Status: **v0.2 design (approved, not yet implemented)** +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, multiple Codex / Hermes peers — and routes each inbound -request to the correct target. +workspaces, and (post-v0.3) multiple Codex / Hermes peers — and +routes each inbound request to the correct target. ## Core model @@ -239,6 +243,13 @@ 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 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 ae31d01..211df8a 100644 --- a/docs/join.md +++ b/docs/join.md @@ -307,15 +307,52 @@ a2a-bridge transparently relays them. --- +## Multi-workspace (v0.2, optional) + +If the user wants to attach **multiple Claude Code sessions** to one +daemon (e.g. a separate CC per project), add a per-workspace env var +on each CC side and a matching `--target` on each ACP registration. + +CC side, per workspace: + +```bash +A2A_BRIDGE_STATE_DIR=~/.config/a2a-bridge/proj-a a2a-bridge claude +A2A_BRIDGE_STATE_DIR=~/.config/a2a-bridge/proj-b a2a-bridge claude +``` + +Each attaches as `claude:proj-a` / `claude:proj-b`. Verify with: + +```bash +a2a-bridge daemon targets +``` + +ACP side — one registration per target: + +```json +{ "agents": { + "bridge-proj-a": { "command": "a2a-bridge", "args": ["acp", "--target", "claude:proj-a"] }, + "bridge-proj-b": { "command": "a2a-bridge", "args": ["acp", "--target", "claude:proj-b"] } +}} +``` + +If a second CC attach collides on an already-held TargetId, the +daemon **rejects** it. Add `--force` (or `A2A_BRIDGE_FORCE_ATTACH=1`) +on the new attach to kick the old one. + +Full design + OpenClaw example: +[`docs/design/multi-target-routing.md`](./design/multi-target-routing.md). + ## What this skill does not do - 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. The v0.2 bridge supports cross-host + deployment (bind the control plane with + `A2A_BRIDGE_CONTROL_HOST=0.0.0.0` and point clients at + `A2A_BRIDGE_CONTROL_URL=ws://:4512/ws`), but terminates + plaintext WebSocket on the daemon side — 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. From 460d7d922ba0373ab9b683bd5927eccf982e7b83 Mon Sep 17 00:00:00 2001 From: ccd Date: Wed, 15 Apr 2026 23:48:13 +0800 Subject: [PATCH 25/30] =?UTF-8?q?chore(tasks):=20mark=20P10.11=20complete?= =?UTF-8?q?=20=E2=80=94=20Phase=2010=20done=20(with=20P10.9=20deferred)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10 close-out status: [x] P10.1 TargetId type + parser [x] P10.2 Plugin-side workspace id derivation [x] P10.3 Daemon rooms map keyed by TargetId [x] P10.4 a2a-bridge acp --target flag [x] P10.5 a2a-bridge daemon targets subcommand [x] P10.6 Attach conflict policy (reject + --force) [x] P10.7 A2A contextId → TargetId routing [x] P10.8 Outbound CC → peer via reply target [~] P10.9 codex --id flag (deferred to v0.3) [x] P10.10 Cross-target integration test [x] P10.11 Documentation sweep Co-Authored-By: Claude Opus 4.6 (1M context) --- TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TASKS.md b/TASKS.md index de1ad2c..f4151d6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -611,7 +611,7 @@ once that phase lands. values, asserts each gets its own reply without leakage. Runs under `check:ci`. -- [ ] **P10.11 — Documentation sweep.** +- [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` From 88221e65948b36ffa7a75e6b98fe05c8c470f236 Mon Sep 17 00:00:00 2001 From: ccd Date: Thu, 16 Apr 2026 00:25:00 +0800 Subject: [PATCH 26/30] docs(release): v0.2.0 pre-publish testing checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten-step hands-on checklist the maintainer runs locally before bumping the version and cutting the release. Covers every P10.x capability end-to-end: backward compat smoke, multi-workspace attach + conflict reject, --force, ACP --target, A2A contextRoutes, reply.target, cross-target isolation, docs review. Also includes the post-checklist bump + publish pointer to the existing publish.md runbook and a 72h rollback note. Scope note: codex peer-id (P10.9) is explicitly marked deferred to v0.3 so testers skip it — codex remains single-instance in v0.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/release/v0.2.0-testing.md | 242 +++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 docs/release/v0.2.0-testing.md 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.) From b38ffc8486ad041d45eaadc4429e6f10d7b04b7a Mon Sep 17 00:00:00 2001 From: ccd Date: Thu, 16 Apr 2026 00:33:36 +0800 Subject: [PATCH 27/30] fix(daemon): no phantom claude:default row when all attaches are explicit targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listTargetEntries` surfaced the legacy `attachedClaude` singleton as a `claude:default` row whenever no per-target entry matched that key — but the singleton is ALWAYS set to the most-recent attach, so for any v0.2 setup (e.g. CC-A as claude:proj-a + CC-B as claude:proj-b) `a2a-bridge daemon targets` printed three rows (proj-a, proj-b, and a phantom claude:default aliasing whichever proj-X attached last). The "no explicit default" guard now also checks that the singleton's connection isn't already listed under a different target, so the legacy default only shows up when a real v0.1 attach (no target field) landed. Regression test in claude-conflict.test.ts drives `list_targets` directly against a real daemon with two explicit-target attaches and asserts the snapshot contains exactly those two (no ghost row). Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/a2a-bridge/server/daemon.js | 21 +++++++--- src/cli/claude-conflict.test.ts | 63 +++++++++++++++++++++++++++++ src/runtime-daemon/daemon.ts | 28 +++++++++---- 3 files changed, 98 insertions(+), 14 deletions(-) diff --git a/plugins/a2a-bridge/server/daemon.js b/plugins/a2a-bridge/server/daemon.js index 964873c..fbcf22d 100755 --- a/plugins/a2a-bridge/server/daemon.js +++ b/plugins/a2a-bridge/server/daemon.js @@ -3344,12 +3344,21 @@ function listTargetEntries() { }); } if (attachedClaude && !attachedClaudeByTarget.has("claude:default")) { - const meta = controlClientMeta.get(attachedClaude); - entries.push({ - target: "claude:default", - attached: true, - ...meta ? { clientId: meta.clientId } : {} - }); + 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; } diff --git a/src/cli/claude-conflict.test.ts b/src/cli/claude-conflict.test.ts index 5fa199b..f74cf00 100644 --- a/src/cli/claude-conflict.test.ts +++ b/src/cli/claude-conflict.test.ts @@ -22,6 +22,38 @@ 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), @@ -130,6 +162,37 @@ describe("P10.6 attach conflict policy", () => { 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 })); diff --git a/src/runtime-daemon/daemon.ts b/src/runtime-daemon/daemon.ts index 4ddf543..c9cfe45 100644 --- a/src/runtime-daemon/daemon.ts +++ b/src/runtime-daemon/daemon.ts @@ -956,15 +956,27 @@ function listTargetEntries() { ...(attachedAtByTarget.has(target) ? { attachedAt: attachedAtByTarget.get(target)! } : {}), }); } - // Surface the legacy attachedClaude singleton as `claude:default` - // when no explicit target was sent (v0.1 wire compatibility). + // 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")) { - const meta = controlClientMeta.get(attachedClaude); - entries.push({ - target: "claude:default", - attached: true, - ...(meta ? { clientId: meta.clientId } : {}), - }); + 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; } From b76a5a2d218d475a8d6079b815b41113ce09dcec Mon Sep 17 00:00:00 2001 From: ccd Date: Thu, 16 Apr 2026 01:26:52 +0800 Subject: [PATCH 28/30] feat(acp): advertise loadSession + adopt sessionId as stateless no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ACP `session/load` so clients in "persistent session" mode (OpenClaw acpx, Zed's restartable agents) can reuse a sessionId after our subprocess respawns. Without it, acpx's next prompt after a subprocess restart would fail with "agent does not support session/load" — the ACP compat gap surfaced during v0.2 OpenClaw smoke testing. Our ACP server is stateless across subprocess lifetimes — every turn runs fresh through the daemon's gateway. There is no turn history to restore, so `loadSession` just registers the sessionId in `activeSessions` and returns empty, which is enough for acpx to proceed with its next `session/prompt`. Clients get the same behaviour as a fresh `newSession` without the caller having to reconcile ids across restarts. - `initialize` → `agentCapabilities.loadSession: true`. - `loadSession(params)` → register, return `{}`. - Unit test: `service.sessionCount()` bumps by one after a `loadSession` call on a previously-unknown id. Also commits docs/release/v0.2.0-openclaw-test.md (the test prompt handed to OpenClaw, written earlier for the smoke pass). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/release/v0.2.0-openclaw-test.md | 183 +++++++++++++++++++++ src/runtime-daemon/inbound/acp/acp.test.ts | 20 ++- src/runtime-daemon/inbound/acp/index.ts | 22 ++- 3 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 docs/release/v0.2.0-openclaw-test.md 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/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/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"); }, From ddb663cd02f7645143eeb4433faae98824e0196a Mon Sep 17 00:00:00 2001 From: ccd Date: Thu, 16 Apr 2026 01:54:52 +0800 Subject: [PATCH 29/30] docs(join): refresh for v0.2 + multi-workspace advanced section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass over the skill prompt that CC / OpenClaw read when they self-install each side. Reshapes around what landed in v0.2 and what the v0.2.0 OpenClaw smoke pass exposed as gotchas. - Drops the hardcoded `a2a-bridge v0.1.0` from the verify steps — now just asserts the binary prints any version. Avoids having to bump the skill on every release. - Promotes `--url`-based remote setup into a top-level "Remote server" subsection for each client (OpenClaw, Zed, VS Code). Notes the `A2A_BRIDGE_CONTROL_HOST=0.0.0.0` env var on the CC side, matching what v0.2 cross-host actually needs. - Adds a separate "Advanced — multiple Claude Code workspaces (v0.2)" chapter with the `--target` pattern, the `--force` conflict policy, and a concrete OpenClaw acpx config that registers one agent per target. - Expands Troubleshooting with the real failure shapes we hit during the pre-release smoke: `ACP_SESSION_INIT_FAILED` (acpx PATH), `target not attached`, echo-fallback as a routing-failure signal. - Drops the v0.1-specific "draft release" troubleshooting line — releases have been public for a while. - Small rephrasing throughout (imperative voice, split args into `command` + `args` arrays so acpx doesn't depend on shell parsing). No behavioural change; pure documentation. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/join.md | 299 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 194 insertions(+), 105 deletions(-) diff --git a/docs/join.md b/docs/join.md index 211df8a..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 +``` + +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. -### 4. Report back to the user +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,22 +145,15 @@ 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 flags needed): +#### Same machine (daemon on localhost) - **OpenClaw** — edit `openclaw.json` (two places): @@ -151,7 +172,10 @@ Code daemon is on the **same machine** or a **remote server**. "enabled": true, "config": { "agents": { - "a2a-bridge": { "command": "a2a-bridge acp" } + "a2a-bridge": { + "command": "a2a-bridge", + "args": ["acp"] + } } } } @@ -159,7 +183,7 @@ Code daemon is on the **same machine** or a **remote server**. } ``` - Restart OpenClaw and use `/acp spawn a2a-bridge`. + Restart OpenClaw, then `/acp spawn a2a-bridge`. - **Zed** — `~/.config/zed/settings.json`: @@ -174,10 +198,10 @@ 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** — same two places, with `--url` on the command - (see [OpenClaw ACP docs](https://docs.openclaw.ai/tools/acp-agents)): +- **OpenClaw** — same two places, with an explicit `command` path + and `--url` in `args`: ```json "acp": { @@ -189,7 +213,11 @@ Code daemon is on the **same machine** or a **remote server**. "config": { "agents": { "a2a-bridge": { - "command": "a2a-bridge acp --url ws://:4512/ws" + "command": "a2a-bridge", + "args": [ + "acp", + "--url", "ws://:4512/ws" + ] } } } @@ -198,8 +226,13 @@ Code daemon is on the **same machine** or a **remote server**. } ``` - Replace `` with the Claude Code machine's IP from Step 1. - Then `/acp spawn a2a-bridge`. + > 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: @@ -218,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 { @@ -226,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"] } ] } @@ -237,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 @@ -252,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: @@ -276,83 +300,148 @@ 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. --- -## 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. - -- **`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 - user: the previous token is fine to reuse, and they can print it - again with `a2a-bridge init --print`. +## 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`. -## Multi-workspace (v0.2, optional) +### CC side — one `a2a-bridge claude` per workspace -If the user wants to attach **multiple Claude Code sessions** to one -daemon (e.g. a separate CC per project), add a per-workspace env var -on each CC side and a matching `--target` on each ACP registration. - -CC side, 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 ``` -Each attaches as `claude:proj-a` / `claude:proj-b`. Verify with: +Inspect: ```bash a2a-bridge daemon targets +# TARGET ATTACHED CLIENT UPTIME +# claude:proj-a yes 3 2m +# claude:proj-b yes 5 1m ``` -ACP side — one registration per target: +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 -{ "agents": { - "bridge-proj-a": { "command": "a2a-bridge", "args": ["acp", "--target", "claude:proj-a"] }, - "bridge-proj-b": { "command": "a2a-bridge", "args": ["acp", "--target", "claude:proj-b"] } -}} +{ + "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" + ] + } + } + } + } + } + } +} ``` -If a second CC attach collides on an already-held TargetId, the -daemon **rejects** it. Add `--force` (or `A2A_BRIDGE_FORCE_ATTACH=1`) -on the new attach to kick the old one. +Use `/acp spawn bridge-proj-a` / `/acp spawn bridge-proj-b` and +each one routes to its own CC — no cross-talk. -Full design + OpenClaw example: +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 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` on whichever side is failing and follow the + `fix:` hints — every required-check failure names the exact + command or environment variable to set. + +- **`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`. + +--- + ## What this skill does not do - 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. The v0.2 bridge supports cross-host - deployment (bind the control plane with - `A2A_BRIDGE_CONTROL_HOST=0.0.0.0` and point clients at - `A2A_BRIDGE_CONTROL_URL=ws://:4512/ws`), but terminates - plaintext WebSocket on the daemon side — put a TLS proxy in - front if the link leaves your trust boundary. +- 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. From 9fb195511a1bba135b9ab40019575564f7e8ea51 Mon Sep 17 00:00:00 2001 From: ccd Date: Thu, 16 Apr 2026 01:55:11 +0800 Subject: [PATCH 30/30] chore(release): bump to 0.2.0 + release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10 shipped, pre-release smoke passed (multi-claude routing end-to-end through OpenClaw), and the two follow-up fixes surfaced during that pass are landed: - no phantom `claude:default` row in `daemon targets` - `session/load` implemented as a stateless no-op so acpx persistent-session mode respawns cleanly Bumps all three version manifests to 0.2.0: - `package.json` - `plugins/a2a-bridge/.claude-plugin/plugin.json` - `.claude-plugin/marketplace.json` Flips the CHANGELOG header from `## [0.2.0] — unreleased` to `## [0.2.0] — 2026-04-16` and appends the two post-Phase-10 fixes to the changelog body. Drafts `docs/release/v0.2.0-github-release.md` as the body for `gh release create --notes-file`, so the GitHub release ships with a proper changelog block this time (previous release had the changelog only in `CHANGELOG.md`). Publishing itself is a human step — see `docs/release/publish.md`. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 16 ++- docs/release/v0.2.0-github-release.md | 99 +++++++++++++++++++ package.json | 2 +- plugins/a2a-bridge/.claude-plugin/plugin.json | 2 +- plugins/a2a-bridge/server/bridge-server.js | 2 +- 6 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 docs/release/v0.2.0-github-release.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f3ea64e..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.1", + "version": "0.2.0", "author": { "name": "FirstIntent", "url": "https://github.com/firstintent" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c805b2..aba9848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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] — unreleased +## [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 @@ -73,6 +73,20 @@ Full design: [`docs/design/multi-target-routing.md`](./docs/design/multi-target- 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 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/package.json b/package.json index 4804e2b..a482062 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "a2a-bridge", - "version": "0.1.1", + "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 aabd922..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.1", + "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 d69156a..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.1", + 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: {