Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c55b773
docs: correct OpenClaw acpx config path — openclaw.json (two keys)
Apr 15, 2026
6e79620
docs(roadmap): consolidate Multi-CC into Multi-target routing
Apr 15, 2026
4c59255
docs(design): multi-target-routing design doc + roadmap reference
Apr 15, 2026
5501b65
tasks: add Phase 10 — Multi-target routing (v0.2)
Apr 15, 2026
bf3f9af
feat(shared): TargetId type + parser (P10.1)
Apr 15, 2026
3ab3446
chore(tasks): mark P10.1 complete
Apr 15, 2026
f7830f9
feat(plugin): derive CC workspace id + send target on claude_connect …
Apr 15, 2026
eb14c77
chore(tasks): mark P10.2 complete
Apr 15, 2026
2647918
feat(daemon): per-target attach map + RoomRouter.getOrCreateByTarget …
Apr 15, 2026
86ed26b
chore(tasks): mark P10.3 complete
Apr 15, 2026
ec7ee9f
feat(acp): --target flag + per-target turn rejection (P10.4)
Apr 15, 2026
779248e
chore(tasks): mark P10.4 complete
Apr 15, 2026
f244a9c
feat(cli): daemon targets subcommand + list_targets RPC (P10.5)
Apr 15, 2026
49add75
chore(tasks): mark P10.5 complete
Apr 15, 2026
c47400f
feat(attach): attach conflict policy (reject + --force) (P10.6)
Apr 15, 2026
623c32f
chore(tasks): mark P10.6 complete
Apr 15, 2026
b63d82a
feat(a2a): contextId → TargetId routing config (P10.7)
Apr 15, 2026
5bd97f6
chore(tasks): mark P10.7 complete
Apr 15, 2026
c12f08d
feat(reply): outbound target routing on reply tool (P10.8)
Apr 15, 2026
7ced6ed
chore(tasks): mark P10.8 complete
Apr 15, 2026
d2ba7d0
chore(tasks): defer P10.9 codex peer-id flag to v0.3; start P10.10
Apr 15, 2026
13c73c5
feat(daemon): per-target inbound gateway + P10.10 cross-target test
Apr 15, 2026
943261d
chore(tasks): mark P10.10 complete
Apr 15, 2026
9680a17
docs: multi-target routing sweep + 0.2.0 CHANGELOG (P10.11)
Apr 15, 2026
460d7d9
chore(tasks): mark P10.11 complete — Phase 10 done (with P10.9 deferred)
Apr 15, 2026
88221e6
docs(release): v0.2.0 pre-publish testing checklist
Apr 15, 2026
b38ffc8
fix(daemon): no phantom claude:default row when all attaches are expl…
Apr 15, 2026
b76a5a2
feat(acp): advertise loadSession + adopt sessionId as stateless no-op
Apr 15, 2026
ddb663c
docs(join): refresh for v0.2 + multi-workspace advanced section
Apr 15, 2026
9fb1955
chore(release): bump to 0.2.0 + release notes
Apr 15, 2026
672cf22
Merge branch 'main' into dev
firstintent Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
{
"name": "a2a-bridge",
"description": "Bidirectional bridge between Claude Code and external AI coding agents. Exposes Claude Code as an A2A server (Gemini CLI, any A2A client) and as an ACP agent (OpenClaw, Zed, VS Code); routes outbound tool calls to Codex, OpenClaw, and Hermes adapters through a shared daemon with per-room task logs and verification-artifact support.",
"version": "0.1.2",
"version": "0.2.0",
"author": {
"name": "FirstIntent",
"url": "https://github.com/firstintent"
Expand Down
109 changes: 109 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,115 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] — 2026-04-16

Multi-target routing. One daemon can now front multiple Claude Code
workspaces at once, and ACP / A2A callers pick which one they want
via an explicit `kind:id` TargetId. Existing single-CC deployments
are unchanged — everything defaults to `claude:default`.

Full design: [`docs/design/multi-target-routing.md`](./docs/design/multi-target-routing.md).

### Added

- **TargetId model** — a `kind:id` tuple (e.g. `claude:proj-a`,
`codex:default`) is the canonical identifier for any attached
agent instance. Validated against `[a-z0-9_-]+` at every
boundary; invalid targets are rejected at the source instead of
silently routing to `default`.
- **Plugin-side workspace id derivation.** `a2a-bridge claude`
announces a TargetId on `claude_connect` derived from (in order):
`A2A_BRIDGE_WORKSPACE_ID` env var → `A2A_BRIDGE_STATE_DIR`
basename → conversation id prefix → `default`. The result is
sanitised and prefixed with `claude:`. Two CC sessions with
distinct state-dirs therefore attach as distinct targets with no
extra config.
- **`a2a-bridge acp --target <kind:id>`.** 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<string, string>` map; the daemon reads
it from `A2A_BRIDGE_CONTEXT_ROUTES` (a JSON object env var).
Unmapped contexts fall back to `claude:default` instead of
minting their own Room. Configuration is validated at startup —
a malformed TargetId is a fail-fast error, not a 5xx at request
time.
- **Outbound reply targeting.** CC's `reply` tool schema grows an
optional `target` field. Present → the daemon forwards the reply
to that TargetId's Room instead of the inbound turn's
originator. Absent → today's behaviour. Unknown targets, bad
shapes, and self-loops all surface descriptive errors.
- **Attach conflict policy.** A second CC attaching to an
already-held TargetId is **rejected** with an error naming the
incumbent (`target claude:proj-a already attached — plugin conn
#1, attached 3m ago`). Rerun with `a2a-bridge claude --force`
(or `A2A_BRIDGE_FORCE_ATTACH=1`) to kick the old attach; the
evicted session receives a `claude_connect_replaced` frame that
surfaces as a CC-visible notification before disconnect.
- **`a2a-bridge daemon targets`.** New inspection subcommand.
Prints a plain-text table of every TargetId the daemon tracks,
with attach state, the WS connection id, and uptime since
attach. Powered by a new `list_targets` control-plane RPC.

### Changed

- **Per-target inbound gateway.** The ACP turn handler resolves
each turn's target to its own `DaemonClaudeCodeGateway` instance
(minted lazily by `RoomRouter.getOrCreateByTarget`). Cross-CC
delivery isolation: inbound text for `claude:ws-a` lands only on
CC-A's socket, and CC-A's reply can only close CC-A's in-flight
turn. Regression covered by the
[cross-target integration test](./src/cli/multi-target.test.ts).
- **`claude_to_codex` intercept is sender-target-aware.** The
daemon picks the sender's target's Room gateway when deciding
whether a reply closes an inbound turn, so a reply from CC-A
cannot accidentally complete CC-B's turn.
- **Plugin disabled-state recovery** now forwards the CC's
TargetId on the recovery attach (was silently dropping to
`claude:default` before the fix).

### Fixed — from the v0.2.0 pre-release smoke pass

- `a2a-bridge daemon targets` no longer advertises a phantom
`claude:default` row when every CC attach was under an explicit
TargetId. The legacy-singleton fallback only surfaces when no
per-target entry already covers that connection.
- `a2a-bridge acp` now advertises `agentCapabilities.loadSession:
true` and implements `session/load` as a stateless no-op.
OpenClaw acpx's "persistent session" mode previously tried to
resume a prior session id across subprocess restarts and blew
up on `agent does not support session/load`; the adopt-as-new
implementation keeps acpx happy without introducing cross-
restart session state we don't actually own.

### Deferred to v0.3

- **Codex peer-id routing** (`a2a-bridge codex --id <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
Expand Down
57 changes: 54 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ client config:

| Agent | Protocol | Config |
|-------|----------|--------|
| **OpenClaw** | ACP | `~/.acpx/config.json` → `{ "agents": { "a2a-bridge": { "command": "a2a-bridge acp" } } }` |
| **OpenClaw (remote)** | ACP | `"command": "env A2A_BRIDGE_CONTROL_URL=ws://<ip>: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://<ip>: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"] }] }` |
Expand All @@ -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
Expand Down Expand Up @@ -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` |

---

Expand Down
96 changes: 96 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,102 @@ once that phase lands.
remains the runbook; the maintainer takes over from there for
`npm publish`, the marketplace form, and the ACP registry PR.

## Phase 10 — Multi-target routing (v0.2)

> Ship the `kind:id` target model from
> [`docs/design/multi-target-routing.md`](./docs/design/multi-target-routing.md).
> v0.1 hardcodes one CC slot and one Codex peer; v0.2 lets one
> daemon front multiple Claude Code workspaces and multiple peer
> instances, selected by `--target kind:id`.

- [x] **P10.1 — TargetId type + parser.**
Acceptance: new `src/shared/target-id.ts` exports a `TargetId`
branded string, a `parseTarget(s)` that returns `{kind, id}` or a
parse error, and a `formatTarget({kind, id})` inverse. Reject
empty kind/id and any character outside `[a-z0-9_-]` (plus the
single `:` separator). Unit tests cover happy path, defaults
(`claude` → `claude:default`), and every rejection case.

- [x] **P10.2 — Plugin-side workspace id derivation.**
Acceptance: `src/runtime-plugin/bridge.ts` computes the CC's
TargetId at startup using the priority chain documented in the
design doc (`A2A_BRIDGE_WORKSPACE_ID` → `A2A_BRIDGE_STATE_DIR`
basename → conversation id prefix → `default`) and sends it on
the existing `claude_connect` frame. `ControlClientMessage`
gains an optional `target: string` field; unit test asserts the
frame round-trips.

- [x] **P10.3 — Daemon rooms map keyed by TargetId.**
Acceptance: `RoomRouter` adds `getOrCreateByTarget(TargetId)`
alongside the existing context-id path. `daemon.ts`'s attach
handler stores `Map<TargetId, Connection>` instead of a single
`attachedClaude` variable. Existing single-CC behaviour is the
special case where every inbound request resolves to
`claude:default`.

- [x] **P10.4 — `a2a-bridge acp --target` flag.**
Acceptance: `src/cli/acp.ts` parses `--target kind:id` via
`parseTarget`. `AcpTurnHandler` on the daemon side gets the
target field on every `acp_turn_start` and looks up the Room;
unresolvable target → `acp_turn_error { message: "target not
attached" }`. Unit test covers happy path + missing-target path.

- [x] **P10.5 — `a2a-bridge daemon targets` subcommand.**
Acceptance: new subcommand prints a table of registered targets
with attach state, pid, and uptime. Reads from the daemon's
rooms map via a new control-plane `list_targets` RPC. Unit test
against a stub daemon.

- [x] **P10.6 — Attach conflict policy (reject + `--force`).**
Acceptance: a second `a2a-bridge claude` / `a2a-bridge codex`
targeting an already-attached TargetId is rejected with a
descriptive error; rerunning with `--force` sends a
`claude_connect_replaced` / peer-specific kick frame to the old
attach and takes over. Unit tests for both outcomes.

- [x] **P10.7 — A2A `contextId → TargetId` routing.**
Acceptance: `startA2AServer` accepts a `contextRoutes: Record<string, TargetId>`
config map; unmapped contexts fall back to `claude:default`. A2A
inbound handlers thread the resolved TargetId through to
`RoomRouter.getOrCreateByTarget`. Unit test covers both mapped
and fallback cases.

- [x] **P10.8 — Outbound CC → peer via `reply` tool target.**
Acceptance: the plugin's `reply` tool schema gains an optional
`target` field; when present, the daemon forwards the reply to
that target's Room instead of the inbound turn's originator.
Keeps backward compat: absent `target` routes to the inbound
originator (today's behaviour). Unit test covers forward + omit
+ unknown-target-error paths.

- [~] **P10.9 — `a2a-bridge codex --id <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:<id>` (default `codex:default`). Multiple Codex adapters
can run concurrently on one daemon, each in their own Room. Unit
test asserts two Codex instances with distinct ids don't
cross-talk.

- [x] **P10.10 — Cross-target integration test.**
Acceptance: new test in `src/cli/multi-target.test.ts` boots a
daemon, attaches two stub CCs as `claude:a` and `claude:b`,
drives two concurrent ACP subprocesses with distinct `--target`
values, asserts each gets its own reply without leakage. Runs
under `check:ci`.

- [x] **P10.11 — Documentation sweep.**
Acceptance: README, `docs/join.md`, and
`docs/guides/rooms.md` updated to use the `kind:id` form and
explain the target model. `docs/design/multi-target-routing.md`
flips from "not yet implemented" to "implemented in v0.2.0".
CHANGELOG `## [0.2.0]` block drafted.

---

## Phase footers (filled by the loop)
Expand Down
Loading
Loading