From 479ccab9d1adf5d61530e6692f7ab44907b264e3 Mon Sep 17 00:00:00 2001 From: Kevin Nguyen Date: Tue, 14 Jul 2026 19:59:53 +0700 Subject: [PATCH] =?UTF-8?q?Fix=20doc=20drift=20vs=20docs/MCP-Contract.md:?= =?UTF-8?q?=20no=20'done'=20agent=5Fstatus=20(turn-done=20=3D=20working?= =?UTF-8?q?=E2=86=92idle=20edge);=20bridge=5Fsessions=E2=86=92bridge=5Flis?= =?UTF-8?q?t,=20bridge=5Fpoll=E2=86=92bridge=5Fstatus=20drain,=20mode?= =?UTF-8?q?=E2=86=92block=20param?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- 1-Architecture.md | 17 +++++++++-------- 2-Message-Server.md | 42 ++++++++++++++++++++++++------------------ 3-Approaches.md | 5 +++-- 6-Team.md | 6 +++--- 7-Use-Cases.md | 17 +++++++++-------- 8-Roadmap.md | 20 ++++++++++---------- 6 files changed, 58 insertions(+), 49 deletions(-) diff --git a/1-Architecture.md b/1-Architecture.md index 752a065..901de9b 100644 --- a/1-Architecture.md +++ b/1-Architecture.md @@ -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.
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.
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 diff --git a/2-Message-Server.md b/2-Message-Server.md index 87ee21f..0f99941 100644 --- a/2-Message-Server.md +++ b/2-Message-Server.md @@ -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 | diff --git a/3-Approaches.md b/3-Approaches.md index 2dbdeda..a615930 100644 --- a/3-Approaches.md +++ b/3-Approaches.md @@ -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 diff --git a/6-Team.md b/6-Team.md index 74f41f4..b9944f2 100644 --- a/6-Team.md +++ b/6-Team.md @@ -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. diff --git a/7-Use-Cases.md b/7-Use-Cases.md index 7ab8366..5877b05 100644 --- a/7-Use-Cases.md +++ b/7-Use-Cases.md @@ -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 diff --git a/8-Roadmap.md b/8-Roadmap.md index c40bc22..e12b94c 100644 --- a/8-Roadmap.md +++ b/8-Roadmap.md @@ -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.