Fix doc drift vs docs/MCP-Contract.md: no 'done' agent_status (turn-done = working→idle edge); bridge_sessions→bridge_list, bridge_poll→bridge_status drain, mode→block param

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:59:53 +07:00
parent 8c37bb71c1
commit 479ccab9d1
6 changed files with 58 additions and 49 deletions
+9 -8
@@ -99,7 +99,7 @@ agent traffic. This is a deliberate simplification with real payoffs:
| Component | Role | Notes |
|---|---|---|
| **`bridged` — SERVER face** | The gateway: **MCP server** (the contract every session mounts) + REST/SSE for non-Claude clients, over the **policy brain** — session tracker, subscription guard, reply rendezvous. | `bridge_send` · `bridge_reply` · `bridge_ask` · `bridge_status` · `bridge_poll` · `bridge_sessions`. |
| **`bridged` — SERVER face** | The gateway: **MCP server** (the contract every session mounts) + REST/SSE for non-Claude clients, over the **policy brain** — session tracker, subscription guard, reply rendezvous. | `bridge_send` · `bridge_reply` · `bridge_ask` · `bridge_status` · `bridge_spawn` · `bridge_list` · `bridge_stop` · `bridge_read`. |
| **`bridged` — CLIENT face** | Drives herdr: a **status-gated injector** (per-pane FIFO, delivers only when `agent_status ∈ {idle, blocked}`) over a **herdr socket client** (NDJSON, id-correlated, live event stream). | Single writer per pane → no injector-vs-injector races. |
| **herdr** | Agent multiplexer. Owns the PTYs, panes, persistence, and — crucially — **`agent_status_changed` events**. Claude sessions run here as panes. | Socket is **local-only**; young/single-dev → injector kept pluggable. |
| **Worker `claude`** | A *real* Claude Code process (inherits `CLAUDE.md`, hooks, skills, MCP), pointed at a different model. Recyclable, not immortal. | Only these carry `ANTHROPIC_BASE_URL`. |
@@ -115,7 +115,8 @@ the connection.
The primary delegates with **one** `bridge_send` tool call. `bridged` holds it open (the
primary is idle-waiting, spending no quota) and **resolves it on whichever lands first**: the
worker's structured `bridge_reply`, or herdr's `agent_status = done` event. The reply comes
worker's structured `bridge_reply`, or the worker's turn-done status edge
(`agent_status: working → idle` — herdr has no `done` status). The reply comes
back as the tool result — so worker → primary rides `bridged`'s state and needs **no keystroke
into the primary pane**, even single-host.
@@ -133,10 +134,10 @@ sequenceDiagram
activate W
H-->>S: "event: agent_status = working"
W->>S: "bridge_reply(result) — structured (preferred)"
H-->>S: "event: agent_status = done"
H-->>S: "event: agent_status working → idle (turn done)"
deactivate W
S-->>P: "tool result = reply (unparks the call)"
Note over P,W: "resolves on bridge_reply or the done event — whichever lands first.<br/>a blocked worker returns as the tool result, then the primary re-answers"
Note over P,W: "resolves on bridge_reply or the idle edge — whichever lands first.<br/>a blocked worker returns as the tool result, then the primary re-answers"
```
*Figure: a single parked tool call, not a busy-poll. SSE (`GET /events`) carries status to
@@ -202,7 +203,7 @@ stateDiagram-v2
Ready --> Working: "turn injected"
Working --> Blocked: "permission / question"
Blocked --> Working: "bridged answers (send_input)"
Working --> Ready: "agent_status = done"
Working --> Ready: "agent_status → idle (turn done)"
Ready --> Recycling: "context / idle cap hit"
Recycling --> Spawning: "state persisted to disk"
Working --> Failed: "pane.exited (crash)"
@@ -210,9 +211,9 @@ stateDiagram-v2
Ready --> [*]: "drain / shutdown"
```
*Figure: `Blocked`, `done`, and `exited` are real herdr events, not heuristics — the reason
herdr-centric beats screen scraping. Full lifecycle detail is in
[Message Server](2-Message-Server) → *Worker session lifecycle*.*
*Figure: `blocked`, the turn-done `working → idle` edge, and `pane.exited` are real herdr
signals, not heuristics — the reason herdr-centric beats screen scraping. Full lifecycle
detail is in [Message Server](2-Message-Server) → *Worker session lifecycle*.*
## Topologies
+24 -18
@@ -36,8 +36,9 @@ model has **two** shapes, and the distinction is *cross-turn busy-polling* (forb
*blocking* (fine):
- **Blocking request/response (default, short/medium tasks).** The primary issues **one**
MCP tool call — `bridge_send(session, task, {mode:"block"})` — and `bridged` **holds it
open** until it observes `agent_status = done` (or a worker `bridge_reply`), then returns
MCP tool call — `bridge_send(target, task)` (blocking by default) — and `bridged` **holds it
open** until it observes the turn-done edge (`agent_status: working → idle` — herdr has no
`done` status) or a worker `bridge_reply`, then returns
the collected reply as the tool result. From the primary's view this is a single tool call
parked on a result, exactly like any long-running `Bash` command: it consumes **no**
Anthropic quota (the primary isn't looping, it's idle-waiting) and doesn't freeze anything
@@ -76,20 +77,24 @@ for *non-Claude* callers (webhooks, dashboards, a human CLI); Claude ↔ Claude
| Caller | Tool | Blocks? | Does |
|---|---|---|---|
| **Primary** | `bridge_send(session, task, {mode})` | `"block"` → yes · `"async"` → no | Deliver a turn to a worker. Blocking form returns the worker's reply as the tool result; async form returns a `ticket` and the reply is **injected into the primary's idle pane** when ready. |
| **Primary** | `bridge_poll(ticket)` | no | Retrieve an async reply **when injection can't serve** — a split-host / non-pane primary pulls it from `bridged` (the gateway, never a broker) instead of being injected. |
| **Primary** | `bridge_status(session)` | no | Worker's live `agent_status` — `idle`\|`working`\|`blocked`\|`done`. |
| **Worker** | `bridge_reply(result)` | no | Emit a **structured** reply/payload to whoever awaits this turn. |
| **Primary** | `bridge_send(message, target?, {block, timeout_seconds, auto_spawn, turn_id})` | `block:true` (default) → yes · `block:false` → no | Deliver a turn to a worker. Blocking form returns the outcome as the tool result (`reply` \| `question` \| `turn_done` \| `timeout`); detached form returns a `dispatch_id` and the reply is **injected into the primary's idle pane** when ready. |
| **Primary** | `bridge_status(target?)` | no | Worker's live `agent_status` (`idle`\|`working`\|`blocked`\|`unknown`), queue depth, open rendezvous — and for the *calling* session it also **reports/drains pending messages addressed to it**: how a split-host / non-pane primary pulls replies injection can't serve (subsumes the earlier `bridge_poll`). |
| **Worker** | `bridge_reply(text, {final})` | no | Emit a **structured** reply/payload to whoever awaits this turn. |
| **Worker** | `bridge_ask(question)` | yes | Worker-initiated question up the chain (true 2-way); parks the worker until the primary answers. |
| both | `bridge_sessions()` | no | List sessions + status, for orchestration or a human. |
| both | `bridge_list()` | no | List workers + status, for orchestration or a human (the earlier `bridge_sessions`). |
| **Primary** | `bridge_spawn({profile?})` · `bridge_stop(target)` · `bridge_read(target, source)` | no | Worker lifecycle (guard-checked spawn, idempotent teardown) and peeking at a detached worker's terminal. |
> The normative tool-by-tool surface (parameters, outcomes, error model) is
> **`docs/MCP-Contract.md`** in the main repo (2026-07-14); this table mirrors it.
### The rendezvous — why worker → primary needs no keystrokes
`bridged` is the meeting point. When the primary is parked in a blocking `bridge_send`,
`bridged` resolves that pending tool call the instant **either** signal arrives:
- herdr fires `agent_status_changed = done` for the pane (**passive** — fires even for an
uncooperative worker), **or**
- herdr's status stream shows the pane's turn-done edge — `agent_status_changed: working →
idle` (**passive** — fires even for an uncooperative worker; herdr has no `done` status),
**or**
- the worker calls `bridge_reply(result)` (**active** — a structured payload; preferred).
Because the reply flows back through `bridged`'s own state, the old "herdr types the answer
@@ -112,7 +117,7 @@ sequenceDiagram
par active payload
W->>S: "bridge_reply(result) — structured"
and passive signal
H-->>S: "event: agent_status_changed = done"
H-->>S: "event: agent_status_changed → idle (turn done)"
end
deactivate W
S-->>P: "tool result = reply (unparks bridge_send)"
@@ -132,7 +137,7 @@ it degrades cleanly if a worker is left unmodified:
|---|---|---|---|
| **Unified (recommended)** | mounts `bridge` MCP (same one line) | structured `bridge_reply` | yes — `bridge_ask` |
| **Hooked (no MCP)** | a `Stop`-hook installed | structured envelope POSTed to `bridged` (see [Reply envelope](#reply-envelope-how-a-worker-emits-a-structured-reply)) | no |
| **Unmodified (last resort)** | stock `claude` | `pane.read` scrape on `agent_status=done` (lossy) | no |
| **Unmodified (last resort)** | stock `claude` | `pane.read` scrape on the turn-done `working→idle` edge (lossy) | no |
The **primary-side contract is identical** in both tiers; only the worker's reply fidelity
changes. Ship the unified setup — one MCP line on every session — and keep herdr-only as the
@@ -212,7 +217,7 @@ primary out of herdr — see [Deployment model](#deployment-model).)*
| **herdr socket client** | NDJSON over `~/.config/herdr/herdr.sock`; correlates responses by `id`; maintains a long-lived `events.subscribe` stream. |
| **Session manager** | Maps a logical session → herdr `workspace/tab/pane` id. Spawns the worker `claude` (env-prefixed launch line into a fresh pane's shell), health-checks, and **recycles on context ceiling** (Ralph loop, see below). |
| **Injector** | Per-pane FIFO queue. Delivers `send_text` + `send_keys "enter"` **only when** that pane's `agent_status ∈ {idle, blocked}` — never mid-run. |
| **Reply rendezvous** | Resolves an awaiting `bridge_send` on whichever lands first: a worker `bridge_reply` (structured, **preferred**), the `agent_status_changed = done` event (timing guarantee), or — worker-side hook path — a `Stop`-hook envelope; last-resort `pane.read {source:"recent-unwrapped"}` scrape. See [Reply envelope](#reply-envelope-how-a-worker-emits-a-structured-reply). |
| **Reply rendezvous** | Resolves an awaiting `bridge_send` on whichever lands first: a worker `bridge_reply` (structured, **preferred**), the turn-done `working → idle` status edge (timing guarantee), or — worker-side hook path — a `Stop`-hook envelope; last-resort `pane.read {source:"recent-unwrapped"}` scrape. See [Reply envelope](#reply-envelope-how-a-worker-emits-a-structured-reply). |
| **Subscription guard** | Refuses to spawn a *worker* pane without `ANTHROPIC_BASE_URL`; refuses to *ever* set it on a pane designated *primary*; can assert egress host via `pane.process_info`. |
| **SERVER API** | **MCP server** — the Claude-facing contract both primary and workers mount (`bridge_send`/`reply`/`ask`/`status`/`poll`/`sessions`). Plus **REST + SSE** (OpenAPI, AgentAPI-shaped) for non-Claude clients — webhooks, dashboards, a human CLI. |
| **Broker connector** *(optional, internal)* | `bridged`-owned durability + cross-host transport, **below the gateway**. Enqueues async messages `bridged` will later inject into an idle pane. No Claude session ever connects to it. |
@@ -323,7 +328,7 @@ envelope" resolves to **a worker-side hook that runs our code at turn end**:
artifacts}` to **`bridged`'s callback endpoint** — the gateway, not a broker (the worker-side
hook never writes the broker directly; `bridged` queues internally if it must).
2. `bridged` correlates that envelope to the open blocking request by `session_id`/`turn_id`
and returns it as the response body. The `agent_status_changed = done` event is the
and returns it as the response body. The turn-done `working → idle` status edge is the
*timing* signal; the hook payload is the *content*.
3. If no hook is installed, `bridged` falls back to `pane.read {source:"recent-unwrapped"}`
and best-effort parses the last assistant block (reuse AgentAPI's `msgfmt`). This is
@@ -376,7 +381,7 @@ sequenceDiagram
H-->>S: "event: agent_status_changed = working"
S-->>P: "SSE: status working (observers only)"
W->>S: "bridge_reply(result) — or Stop-hook envelope (fallback)"
H-->>S: "event: agent_status_changed = done"
H-->>S: "event: agent_status_changed → idle (turn done)"
deactivate W
S->>S: "resolve reply (bridge_reply, event, or pane.read fallback)"
S-->>P: "tool result = assistant reply (unparks call)"
@@ -446,7 +451,7 @@ stateDiagram-v2
Ready --> Working: "turn injected"
Working --> Blocked: "permission / question"
Blocked --> Working: "bridged answers (send_input)"
Working --> Ready: "agent_status = done"
Working --> Ready: "agent_status → idle (turn done)"
Ready --> Recycling: "context / idle cap hit"
Recycling --> Spawning: "state persisted to disk"
Working --> Failed: "pane.exited (crash)"
@@ -454,8 +459,9 @@ stateDiagram-v2
Ready --> [*]: "drain / shutdown"
```
*Figure: the state machine `bridged` drives per worker. `Blocked`, `done`, and `exited` are
real herdr events, not heuristics — the reason herdr-centric beats screen scraping.*
*Figure: the state machine `bridged` drives per worker. `blocked`, the turn-done
`working → idle` edge, and `pane.exited` are real herdr signals, not heuristics — the reason
herdr-centric beats screen scraping.*
## Subscription boundary (enforced, not just documented)
@@ -506,7 +512,7 @@ the loop**, and MCP is verified by a **parity test** (tool result == REST result
| `POST /sessions/{id}/message` | Deliver a turn `{content, type:"user"\|"raw"}` (queued, status-gated) |
| `GET /sessions/{id}/events` | **SSE**: `status`, `message`, `blocked`, `exited` |
| `GET /sessions/{id}/messages` | Conversation history |
| `GET /sessions/{id}/status` | `idle` \| `working` \| `blocked` \| `done` |
| `GET /sessions/{id}/status` | `idle` \| `working` \| `blocked` \| `unknown` |
| `POST /sessions/{id}/keys` | Raw keys passthrough `{keys:"ctrl+c"}` — answer/interrupt |
| `DELETE /sessions/{id}` | Drain + recycle |
| `GET /healthz` · `GET /metrics` | Liveness + Prometheus |
+3 -2
@@ -52,10 +52,11 @@ scraping a screen.
[herdr](https://herdr.dev) is a persistent agent multiplexer (a "tmux for agents") with a
Unix-socket JSON API. `bridged` (see [Message Server](2-Message-Server)) drives it: `pane.send_text` +
`pane.send_keys` deliver a turn into the *running* pane, and `events.subscribe`
(`pane.agent_status_changed`) reports **working / blocked / done** as real events.
(`pane.agent_status_changed`) reports **working / blocked / idle** as real events (turn-done
= the `working → idle` edge; herdr has no `done` status).
- **Injects into a live session:** yes into the worker. **Worker → primary** rides `bridged`'s
**MCP rendezvous** — the worker's `bridge_reply` (or the `done` event) resolves the primary's
**MCP rendezvous** — the worker's `bridge_reply` (or the turn-done idle edge) resolves the primary's
blocking `bridge_send` tool call, so no keystroke into the primary pane is needed, even
single-host. Fallbacks: herdr can type into a single-host non-MCP primary (subscription-safe
keystrokes); a split-host primary wakes via its own `Stop`-hook polling `bridged` (the async
+3 -3
@@ -97,12 +97,12 @@ sequenceDiagram
par A → Claude worker
L->>B: "bridge_send {role: w-claude, prompt: A}"
B->>WC: "send_text into running pane"
WC-->>B: "bridge_reply (or status done)"
WC-->>B: "bridge_reply (or idle edge)"
B-->>L: "tool result reply A"
and B → local worker
L->>B: "bridge_send {role: w-local, prompt: B}"
B->>WL: "send_text into running pane"
WL-->>B: "bridge_reply (or status done)"
WL-->>B: "bridge_reply (or idle edge)"
B-->>L: "tool result reply B"
end
Note over L: "reduce → integrate A + B into final answer"
@@ -130,7 +130,7 @@ in the lead's `CLAUDE.md` turns Opus into the orchestrator:
```markdown
## Your team (via bridged)
You are the team-lead. Delegate through the bridge MCP tools — never launch workers yourself.
Roster: call bridge_sessions for current sessions/roles.
Roster: call bridge_list for current sessions/roles.
- w-claude-* — Claude Sonnet. Reasoning-heavy / high-accuracy subtasks.
- w-local-* — remote local LLM. Bulk, cheap, or parallelizable subtasks.
+9 -8
@@ -22,7 +22,7 @@ sequenceDiagram
participant C as "ccs + herdr"
participant W as "worker · gx00-vllm"
O->>B: "bridge_sessions() — what workers can I use?"
O->>B: "bridge_list() — what workers can I use?"
B-->>O: "profiles:[gx00-vllm=DeepSeek, …] · sessions:[]"
O->>B: "bridge_send(to: reviewer@gx00-vllm, {kind: review.request, diff, focus})"
Note over B: "guard: ccs env gx00-vllm → base_url host on allowlist ✓"
@@ -58,23 +58,24 @@ bridge_send({
"kind": "review.request",
"body": { "workspace": "/repo", "base": "main", "head": "HEAD",
"focus": ["correctness","security"], "instructions": "…" },
"mode": "block" // block (default) → reply as tool result; async → ticket
"block": true // true (default) → reply as tool result; false → dispatch_id
})
```
`bridged` resolves the target (spawn-or-reuse, below), injects the turn into the worker's
herdr pane gated on `agent_status`, and — for `mode:"block"` — holds the call open until the
reply lands (worker `bridge_reply` or `agent_status=done`), returning it as the tool result.
Review turns are short, so they block; a long/detached job would use `mode:"async"` and come
herdr pane gated on `agent_status`, and — with `block:true` (the default) — holds the call
open until the reply lands (worker `bridge_reply` or the turn-done `working→idle` edge),
returning it as the tool result.
Review turns are short, so they block; a long/detached job would use `block:false` and come
back via idle-pane injection ([Mode 2](1-Architecture#traffic-two-modes-across-the-gateway)).
## Mechanism 2 — worker discovery (knowing your choices)
Opus shouldn't hard-code pane ids or guess what's available. `bridge_sessions()` returns both
Opus shouldn't hard-code pane ids or guess what's available. `bridge_list()` returns both
what's **runnable** and what's **live**:
```jsonc
bridge_sessions() → {
bridge_list() → {
"profiles": [ // spawnable = the ccs worker roster (Mechanism 3)
{ "name": "gx00-vllm", "model": "DeepSeek-V3", "host": "gx00.ltms.dev", "status": "available" },
{ "name": "ollama-local","model": "llama3.1", "host": "ollama.ltms.dev","status": "available" }
@@ -106,7 +107,7 @@ just picks a profile:
profile) is **refused as a worker** — that would burn your quota. The primary Opus is *your*
session on *your* subscription profile; `bridged` never spawns it.
- **Swap = repoint.** Changing the reviewer's model is choosing a different ccs profile — no
`bridged` code change. Add a profile → it appears in `bridge_sessions().profiles`.
`bridged` code change. Add a profile → it appears in `bridge_list().profiles`.
```mermaid
flowchart LR
+10 -10
@@ -30,8 +30,8 @@ gantt
|---|---|---|
| **1 — Walking skeleton** | One review, happy path | Opus mounts `bridged` (MCP), calls `bridge_send` with a diff, gets a review back from a real `ccs gx00-vllm claude` worker. Single hardcoded profile, same host, no guard/lifecycle. |
| **2 — Contract + guard** | Trust the reply, trust the boundary | Structured [envelope](7-Use-Cases#mechanism-4--the-id-contract-envelope) + worker `bridge_reply`; reply rendezvous; subscription guard via `ccs env`; reviewer skill. |
| **3 — Lifecycle + discovery** | Reuse, recycle, choose | Session manager (spawn/reuse/recycle Ralph loop, `idle_ttl`); `bridge_sessions` roster+live; multiple profiles; `bridge_ask`. |
| **4 — Async + split-host** | Detached + cross-host | `mode:"async"` + idle-pane injection; internal queue (durability); split-host `Stop`-hook adapter that polls `bridged`. |
| **3 — Lifecycle + discovery** | Reuse, recycle, choose | Session manager (spawn/reuse/recycle Ralph loop, `idle_ttl`); `bridge_list` roster+live; multiple profiles; `bridge_ask`. |
| **4 — Async + split-host** | Detached + cross-host | `block:false` (detached dispatch) + idle-pane injection; internal queue (durability); split-host `Stop`-hook adapter that polls `bridged`. |
| **5 — Harden** | Production shape | Auth/TLS, `/metrics` + `/healthz`, mock-socket CI, systemd unit, per-session authz + audit. |
## Tech stack
@@ -97,12 +97,12 @@ flowchart TB
| Feature | REST endpoint | MCP tool | Acceptance test |
|---|---|---|---|
| Deliver a turn (blocking) | `POST /sessions/{id}/message` | `bridge_send` | reply returned; `working→done` unblocks; timeout → 202 |
| Deliver a turn (blocking) | `POST /sessions/{id}/message` | `bridge_send` | reply returned; `working→idle` unblocks; timeout → 202 |
| Worker status | `GET /sessions/{id}/status` | `bridge_status` | matches herdr `agent_status` |
| Async ticket + poll | `POST …/message?mode=async` · `GET /tickets/{t}` | `bridge_send`(async) · `bridge_poll` | ticket issued; reply retrievable |
| Detached dispatch + drain | `POST …/message?block=false` · `GET /sessions/{id}/status` (pending drain) | `bridge_send(block:false)` · `bridge_status` | `dispatch_id` issued; reply retrievable |
| Worker reply | `POST /sessions/{id}/reply` | `bridge_reply` | resolves the awaiting request by `corr` |
| Worker question | `POST /sessions/{id}/ask` | `bridge_ask` | surfaces to primary; parks worker |
| Discovery | `GET /sessions` | `bridge_sessions` | roster + live match config/herdr |
| Discovery | `GET /sessions` | `bridge_list` | roster + live match config/herdr |
| Spawn (guarded) | `POST /sessions` | *(internal)* | rejects on-subscription profile; accepts allowlisted |
| Health | `GET /healthz` · `GET /metrics` | — | liveness + Prometheus |
@@ -114,9 +114,9 @@ Compact scope; expand into detailed tickets when a stage starts (as Stage 1 is b
| Stage | Tickets |
|---|---|
| **2** | `CB-201` envelope schema + codec · `CB-202` worker `bridge_reply` tool + reviewer skill · `CB-203` reply rendezvous (corr match; resolve on reply *or* `done`) · `CB-204` subscription guard via `ccs env` + allowlist · `CB-205` blocked-worker path (`bridge_ask`) |
| **3** | `CB-301` session manager (spawn/reuse/recycle) · `CB-302` Ralph checkpoint (`STATE.md` + commit) · `CB-303` `idle_ttl`/`context_cap`/drain · `CB-304` `bridge_sessions` roster+live · `CB-305` multi-profile routing (`role@profile`) |
| **4** | `CB-401` `mode:"async"` + ticket · `CB-402` idle-pane injection delivery · `CB-403` internal queue (Redis Streams) · `CB-404` split-host `Stop`-hook adapter (polls `bridged`) · `CB-405` REST ingress for event-bus |
| **2** | `CB-201` envelope schema + codec · `CB-202` worker `bridge_reply` tool + reviewer skill · `CB-203` reply rendezvous (corr match; resolve on reply *or* the `working→idle` edge) · `CB-204` subscription guard via `ccs env` + allowlist · `CB-205` blocked-worker path (`bridge_ask`) |
| **3** | `CB-301` session manager (spawn/reuse/recycle) · `CB-302` Ralph checkpoint (`STATE.md` + commit) · `CB-303` `idle_ttl`/`context_cap`/drain · `CB-304` `bridge_list` roster+live · `CB-305` multi-profile routing (`role@profile`) |
| **4** | `CB-401` `block:false` + `dispatch_id` · `CB-402` idle-pane injection delivery · `CB-403` internal queue (Redis Streams) · `CB-404` split-host `Stop`-hook adapter (polls `bridged`) · `CB-405` REST ingress for event-bus |
| **5** | `CB-501` bearer auth + TLS · `CB-502` `/metrics` + `/healthz` · `CB-503` mock-socket CI · `CB-504` systemd unit + ordered start · `CB-505` per-session authz + audit log |
## Stage 1 — detailed tickets
@@ -181,12 +181,12 @@ two rapid deliveries never interleave (golden transcript).
### CB-104 — blocking `bridge_send` as a **REST endpoint** + reply capture
**Scope.** Implement the feature as **`POST /sessions/{id}/message`** (`{content}`, blocking) →
resolve to the (single, hardcoded) worker pane → inject → wait for `agent_status=done` → return
resolve to the (single, hardcoded) worker pane → inject → wait for the turn-done `working→idle` edge → return
`pane.read {source:"recent-unwrapped"}` of the last assistant block in the response body. (No
envelope yet; scrape is acceptable for Stage 1.) This REST route is the feature; MCP wraps it in
CB-105.
**Acceptance.** A **REST contract test** (`POST /sessions/{id}/message`, **no Claude in the
loop**) returns a non-empty reply from the pane; a `working→done` transition unblocks it; a
loop**) returns a non-empty reply from the pane; a `working→idle` transition unblocks it; a
timeout returns a typed "still working" (HTTP 202-style) response.
**Deps.** CB-102, CB-103.