wiki: consistency pass vs rewritten Architecture (5-agent review)

Independent cold reads of every page against 1-Architecture found no invariant
violations; fixed the drift the rewrite introduced plus one real contradiction:

- terminology: Channel 1/2 -> Mode 1/2, 'two-channel' -> 'two invariants / two
  modes' (Approaches, Team, Home, Sidebar, Message-Server); north/south face ->
  SERVER/CLIENT face (Message-Server, 6 spots)
- contradiction reconciled: Architecture now acknowledges a non-MCP *worker*
  Stop-hook (POSTs reply to bridged) as well as the split-host-primary hook -
  both target bridged, never a broker; Message-Server tier table split into
  Unified / Hooked / Unmodified to match
- Approaches: footnote credits bridge_reply (Stop-hook = fallback); §4 subtitle
  reframed; <payload> mermaid label de-angled (parse-safe)
- Team: SERVER 'role router' -> 'policy brain'; fan-out sequence quoted; inference edges labeled
- Home/README: CLIENT-face node regains 'status-gated injector'
- Operations: 'broker' -> 'internal broker/queue'; Stop-hook framed as split-host exception
- async ticket/bridge_poll reframed as injection-first (push), poll = non-pane fallback
All 16 mermaid blocks validated with mmdc.
Dai Ha
2026-07-12 07:11:03 +02:00
parent 22c1e20e83
commit 6b3c2a1d16
7 changed files with 70 additions and 57 deletions
+12 -4
@@ -174,10 +174,18 @@ sequenceDiagram
for Claude, REST for external), it optionally parks the message on its internal queue, waits
for the idle event, and injects.*
> **The one exception.** A **split-host primary that is not a herdr pane** (e.g. Opus on your
> Mac) is the sole session `bridged` cannot inject into. There, the primary runs a `Stop`-hook
> that **long-polls `bridged`** for queued messages — still the gateway, **never** the broker.
> The gateway invariant holds in every topology.
> **Where a hook is used.** A `Stop`-hook appears in exactly the spots where neither an MCP
> tool call nor a pane injection can serve — and in **every** case it targets `bridged`, never
> a broker:
> - a **split-host primary that is not a herdr pane** (e.g. Opus on your Mac) — the only
> session `bridged` cannot inject into — runs a `Stop`-hook that **long-polls `bridged`** for
> queued messages; and
> - a **non-MCP (herdr-only) worker** may run a `Stop`-hook that **POSTs its reply to
> `bridged`** at turn end, a structured alternative to scraping the pane (see
> [Message Server](2-Message-Server) → *Reply envelope*).
>
> The gateway invariant holds in every topology: a hook is just a transport adapter to
> `bridged` for a session MCP/injection can't reach.
## Worker lifecycle — the Ralph loop
+18 -17
@@ -67,7 +67,7 @@ claude mcp add --transport http bridge http://127.0.0.1:8080/mcp
# or a project .mcp.json / CLAUDE.md entry that every session on the host inherits
```
That is the point of the MCP north face: **one unified Claude setup**. Primary and workers
That is the point of the MCP SERVER face: **one unified Claude setup**. Primary and workers
load the *same* server and differ only in which tools they call — no shell step that could
leak env, no hand-rolled HTTP client, no per-session bespoke wiring. REST/SSE (below) stays
for *non-Claude* callers (webhooks, dashboards, a human CLI); Claude ↔ Claude goes over MCP.
@@ -76,8 +76,8 @@ 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`. |
| **Primary** | `bridge_poll(ticket)` | no | Fetch the reply for an async task once it is ready. |
| **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. |
| **Worker** | `bridge_ask(question)` | yes | Worker-initiated question up the chain (true 2-way); parks the worker until the primary answers. |
@@ -131,7 +131,8 @@ it degrades cleanly if a worker is left unmodified:
| Tier | Worker setup | Reply channel | Worker can ask back? |
|---|---|---|---|
| **Unified (recommended)** | mounts `bridge` MCP (same one line) | structured `bridge_reply` | yes — `bridge_ask` |
| **Herdr-only (fallback)** | unmodified `claude` | `pane.read` on `agent_status=done` | no |
| **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 |
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
@@ -143,11 +144,11 @@ bridge on either side is subscription-safe by construction (see
`bridged` is a **standalone daemon** — one component, two faces:
- a **SERVER (north face)** — an **MCP server** the Claude Code sessions mount, plus REST/SSE
for non-Claude clients, sitting over the session tracker, subscription guard, and reply
- a **SERVER face** — an **MCP server** the Claude Code sessions mount, plus REST/SSE for
non-Claude clients, sitting over the session tracker, subscription guard, and reply
rendezvous (the policy brain); and
- a **CLIENT (south face)** — a herdr socket client that injects turns (status-gated) and
subscribes to agent-status.
- a **CLIENT face** — a herdr socket client that injects turns (status-gated) and subscribes
to agent-status.
The Claude sessions themselves live **as panes inside herdr**. Each pane reaches *up* to
`bridged`'s MCP server (to send/reply); `bridged`'s herdr client reaches *down* through
@@ -163,12 +164,12 @@ flowchart TB
end
subgraph bridged["bridged — standalone daemon (NOT a claude process)"]
subgraph srv["SERVER — north face"]
subgraph srv["SERVER face"]
MCP["MCP server<br/>bridge_send · reply · ask · status"]
REST["REST / SSE<br/>(non-Claude clients)"]
POL["policy brain<br/>session tracker · subscription guard<br/>· reply rendezvous"]
end
subgraph cli["CLIENT — south face"]
subgraph cli["CLIENT face"]
INJ["injector<br/>status-gated"]
HCL["herdr socket client<br/>send_text · events · pane.read"]
end
@@ -213,7 +214,7 @@ primary out of herdr — see [Deployment model](#deployment-model).)*
| **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). |
| **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`. |
| **North API** | **MCP server** — the Claude-facing contract both primary and workers mount (`bridge_send`/`reply`/`ask`/`status`). Plus **REST + SSE** (OpenAPI, AgentAPI-shaped) for non-Claude clients — webhooks, dashboards, a human CLI. |
| **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. |
## The herdr control contract (what `bridged` drives)
@@ -430,7 +431,7 @@ The invariant is unchanged from [Architecture](1-Architecture) — **anything th
`ANTHROPIC_BASE_URL` and logs each worker's resolved egress host. It cannot self-check a
remote primary.
## API surface (north side)
## API surface (SERVER face)
**Two faces over one core.** Claude sessions use the **MCP tools**
([above](#the-client-contract--mcp-unified-for-primary--workers)); the **REST/SSE** routes
@@ -534,8 +535,8 @@ turns. Until then, treat one `bridged` as one trust domain.
|---|---|---|---|
| **Server core** | **Go** | Single static binary → trivial `scp`/systemd deploy to the worker host; goroutines fit the socket + HTTP + broker + SSE fan-in; mirrors `coder/agentapi` (can reuse its `msgfmt` reply parser). | Rust (matches herdr, slower to build); **TypeScript/Node** if the driver is the Agent SDK and you want shared types; Python for a quick spike. |
| **herdr transport** | Unix domain socket, **NDJSON**, `id`-correlated request/response + a persistent events stream | Native herdr contract | — |
| **North API — Claude** | **MCP server**, streamable-HTTP transport (`mcp-go` / official Go SDK) | The unified contract both primary and workers mount; native to Claude Code, no shell/`curl` step, subscription-safe by construction | stdio MCP adapter (per-session subprocess) if a long-lived HTTP endpoint is undesirable |
| **North API — others** | **REST + SSE**, OpenAPI-generated | Drop-in for AgentAPI-shaped/non-Claude clients; SSE streams status cheaply | gRPC (if callers are all code); WebSocket (bidi UI) |
| **SERVER API — Claude** | **MCP server**, streamable-HTTP transport (`mcp-go` / official Go SDK) | The unified contract both primary and workers mount; native to Claude Code, no shell/`curl` step, subscription-safe by construction | stdio MCP adapter (per-session subprocess) if a long-lived HTTP endpoint is undesirable |
| **SERVER API — others** | **REST + SSE**, OpenAPI-generated | Drop-in for AgentAPI-shaped/non-Claude clients; SSE streams status cheaply | gRPC (if callers are all code); WebSocket (bidi UI) |
| **Internal queue** *(optional)* | **Redis Streams** (consumer groups, `XACK`, visibility timeout) — `bridged`-owned, below the gateway | Durability + cross-host for async; satisfies the guardrails in [Architecture](1-Architecture). Same-host can start with an in-process queue and add this only when durability/cross-host is needed | NATS JetStream for multi-host scale / replay; embedded (BadgerDB/SQLite) for a single host |
| **Config** | Env + YAML (`koanf`) | 12-factor; secrets via env only | — |
| **Observability** | `slog` + Prometheus `/metrics` + `/healthz` | Ops from day one | OpenTelemetry traces |
@@ -615,19 +616,19 @@ func (g *Guard) AssertLocalPrimaryClean(env []string) error {
| Risk | Mitigation |
|---|---|
| **No held-open conversation** — each exchange is one request in, one reply out | By design. Short/medium tasks use a **single blocking call** (fine — no quota burn); long/detached tasks use **return-and-reinvoke**, the reply delivered later **through `bridged`** (idle-pane injection, or a split-host `Stop`-hook polling `bridged`). What's excluded is a persistent bidirectional stream the primary must babysit. See *How the primary actually consumes a reply*. |
| **Blocking call can outlive its timeout** on a very long task | Set a request deadline; on timeout `bridged` returns "still working, await async" and the reply lands via `bridged`'s async path (idle-injection) instead of erroring the delegation. Pick Channel 2 up front for known-long work. |
| **Blocking call can outlive its timeout** on a very long task | Set a request deadline; on timeout `bridged` returns "still working, await async" and the reply lands via `bridged`'s async path (idle-injection) instead of erroring the delegation. Pick async delivery (Mode 2) up front for known-long work. |
| **herdr is young / single-dev** — betting transport on it | Durability lives in `bridged`'s **internal queue** (mature Redis/NATS), not herdr — herdr carries only ephemeral delivery + status. The injector is a **pluggable interface** — fall back to `tmux send-keys` or AgentAPI without touching the queue or the gateway contract. |
| **herdr socket is local-only** | `bridged`'s **MCP/HTTP** is the sole cross-host link; herdr and the queue stay per-host and `bridged`-owned. |
| **Mid-run interrupt still unsolved** | Same as AgentAPI. Injection gates on status; `ctrl+c` via `pane.send_input` is the only (disruptive) interrupt. |
| **Spawn-with-env uncertainty in socket API** | Launch via `send_text` of the env-prefixed command → env is provably worker-only; verify native spawn in the CLI reference and prefer it if present. |
| **Reply-scrape fragility (fallback path)** | Prefer the structured **envelope** path; scrape `recent-unwrapped` only as a last resort. |
| **herdr socket API is unversioned + single-dev churn** | Pin the herdr version in the systemd/Compose unit; keep the socket client behind the `Herdr` interface; probe `session.snapshot` shape on startup and fail fast on an unexpected schema. Don't build against `UNCERTAIN` primitives (e.g. native spawn-with-env) until confirmed in the running CLI. |
| **SPOF per channel (bridged / herdr / broker)** | Documented in [Architecture](1-Architecture) → *Failure modes*. Key property: the **primary is never downstream** of a bridge component, so a total outage costs workers only, never the subscription session. |
| **SPOF (bridged / herdr / queue)** | Documented in [Architecture](1-Architecture) → *Failure modes*. Key property: the **primary is never downstream** of a bridge component, so a total outage costs workers only, never the subscription session. |
| **Injection TOCTOU / shared pane** | Single-writer injector + serialized send; worker panes are bridged-owned. Residual collision corrupts a turn (recoverable), never the subscription boundary. See *Delivery gating & races*. |
## Related pages
- **[Architecture](1-Architecture)** — the two-channel model this refines; subscription boundary
- **[Architecture](1-Architecture)** — the two-invariant / two-mode model this refines; subscription boundary
- **[Approaches](3-Approaches)** — transport comparison; AgentAPI now the *fallback injector*
- **[Home](Home)** — project overview
+9 -7
@@ -58,7 +58,8 @@ Unix-socket JSON API. `bridged` (see [Message Server](2-Message-Server)) drives
**MCP rendezvous** — the worker's `bridge_reply` (or the `done` event) 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` (Channel 2).
keystrokes); a split-host primary wakes via its own `Stop`-hook polling `bridged` (the async
path — Mode 2 in [Architecture](1-Architecture)).
- **North-face contract is MCP — and the sole gateway.** Both primary and workers mount
`bridged` as an MCP server (one unified Claude setup); no Claude session ever addresses a
broker or peer directly. The herdr injection here is the *south* side, orthogonal to it.
@@ -101,7 +102,7 @@ events and permission callbacks instead of scraping a terminal.
- **Trade-off:** the worker is an SDK-hosted loop, not a stock `claude` TUI — further from
"a real Claude Code process" than herdr/AgentAPI, and the driver must be code.
## 4. Message-queue + Stop-hook long-poll *(the async layer — complementary)*
## 4. Message-queue + Stop-hook long-poll *(raw async primitive — behind the gateway in our design)*
The **only pure-hooks** way to pull an external message into the **same** session. In
`claude-bridge` this is **not** how a Claude session normally receives async work — under the
@@ -120,7 +121,7 @@ sequenceDiagram
H->>Q: long-poll (BRPOPLPUSH, ≤30s)
alt message arrives
Q-->>H: payload
H-->>CC: {"decision":"block","reason":"<payload>"}
H-->>CC: "{ decision: block, reason: payload }"
Note over CC: message injected as context → new turn
else timeout
Q-->>H: (nothing)
@@ -179,12 +180,13 @@ worker→primary goes through `bridged` all the same — the primary's `Stop`-ho
story holds for a single-box setup; across hosts the gateway is still `bridged`, just not by
injection.
² **"Completion signal" for `bridged` is the timing signal; reply *content* rides a worker
`Stop`-hook envelope** (see [Message Server](2-Message-Server)), not the status event itself.
² **"Completion signal" for `bridged` is the timing signal; reply *content* rides the worker's
structured `bridge_reply` (preferred), with a `Stop`-hook envelope as fallback** (see
[Message Server](2-Message-Server)) — not the status event itself.
## Recommendation
- **Primary Opus → worker (the bridge's main channel):** **herdr via `bridged`** —
- **Primary Opus → worker (the bridge's main path):** **herdr via `bridged`** —
status-gated injection, structured completion/blocked events, symmetric (single-host), multiplexed,
persistent, with the subscription boundary enforced in code. Selected. See
[Message Server](2-Message-Server) / [Architecture](1-Architecture).
@@ -192,7 +194,7 @@ injection.
herdr's immaturity is a de-riskable risk rather than a load-bearing one.
- **External event bus → worker (async wake-ups):** the bus hits **`bridged`'s REST ingress**;
`bridged` enqueues internally if needed and **injects the idle worker** — the worker runs no
queue-polling hook. Complementary to the sync channel, not a replacement — different trigger
queue-polling hook. Complementary to the sync path, not a replacement — different trigger
shape, same single gateway.
- **Avoid hand-rolled `tmux send-keys`** unless neither herdr nor AgentAPI can run; it's the
same idea with all the fragility left in.
+8 -6
@@ -11,7 +11,7 @@ Day-2 runbook for a running bridge.
`agent_status` per session via `GET /sessions`, and confirming the **MCP endpoint** is
reachable from both the primary and the workers (`claude mcp list` shows `bridge` connected).
- **Restart & recovery** — ordered restart (herdr before `bridged`); how `bridged`
re-attaches to existing panes via `session.snapshot`; broker replay of unacked items. See
re-attaches to existing panes via `session.snapshot`; internal broker/queue replay of unacked items. See
[Architecture](1-Architecture) → *Failure modes & single points of failure* for what each outage costs.
- **Model swaps** — repoint a worker to a different `base_url`/model by recycling its pane
(Ralph loop); the subscription guard re-validates the new host against the allowlist.
@@ -26,11 +26,13 @@ Day-2 runbook for a running bridge.
## Guardrails to watch (from [Architecture](1-Architecture))
- The **primary must never perpetual-poll** — quota burn. Async wake-ups use the `Stop`-hook
or `bridged` inject-on-idle only.
- Cross-agent ping-pong needs a round/turn budget in the message envelope.
- Broker must run with **ack + visibility timeout + consumer groups** so a mid-turn crash
re-delivers instead of dropping.
- The **primary must never perpetual-poll** — quota burn. Async wake-ups are `bridged`
inject-on-idle by default; the `Stop`-hook is only the split-host-primary exception, and it
polls `bridged` (never a broker).
- Cross-agent ping-pong needs a round/turn budget — enforced centrally in `bridged` (sole
gateway), not per-session sentinels.
- The **internal** broker/queue must run with **ack + visibility timeout + consumer groups**
so a mid-turn crash re-delivers instead of dropping.
## Related
+20 -20
@@ -32,7 +32,7 @@ alike. Scale each kind horizontally by adding panes.
flowchart TB
LEAD["lead — Opus<br/>(Claude Code, env CLEAN)<br/>MCP client"]
subgraph BD["bridged — standalone daemon"]
SRV["SERVER face<br/>MCP · REST/SSE · role router"]
SRV["SERVER face<br/>MCP · REST/SSE · policy brain"]
CLI["CLIENT face<br/>herdr socket"]
SRV --> CLI
end
@@ -49,10 +49,10 @@ flowchart TB
HERDR --> WC1 & WC2 & WL1 & WL2
WC1 -.->|"MCP bridge_reply"| SRV
WL1 -.->|"MCP bridge_reply"| SRV
WC1 --> ANT
WC2 --> ANT
WL1 --> OLL
WL2 --> OLL
WC1 -->|"inference"| ANT
WC2 -->|"inference"| ANT
WL1 -->|"inference"| OLL
WL2 -->|"inference"| OLL
classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
classDef core fill:#2f855a,stroke:#22543d,color:#ffffff;
@@ -88,24 +88,24 @@ different workers at once, then results are gathered.
```mermaid
sequenceDiagram
participant L as lead (Opus)
participant B as bridged
participant WC as w-claude-1
participant WL as w-local-1
participant L as "lead (Opus)"
participant B as "bridged"
participant WC as "w-claude-1"
participant WL as "w-local-1"
Note over L: split job → subtask A (reasoning), subtask B (bulk)
Note over L: "split job → subtask A (reasoning), subtask B (bulk)"
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)
B-->>L: tool result reply A
L->>B: "bridge_send {role: w-claude, prompt: A}"
B->>WC: "send_text into running pane"
WC-->>B: "bridge_reply (or status done)"
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)
B-->>L: tool result reply B
L->>B: "bridge_send {role: w-local, prompt: B}"
B->>WL: "send_text into running pane"
WL-->>B: "bridge_reply (or status done)"
B-->>L: "tool result reply B"
end
Note over L: reduce → integrate A + B into final answer
Note over L: "reduce → integrate A + B into final answer"
```
- **Map:** the lead issues N concurrent blocking `bridge_send` tool calls (one per subtask →
@@ -114,7 +114,7 @@ sequenceDiagram
- **Reduce:** the lead collects the N replies and integrates. A slow local worker never
blocks a fast Claude worker — wall-clock ≈ the slowest single subtask, not the sum.
- **Detached / long jobs** use `bridged`'s async path instead of a held request — the result
is delivered when ready by `bridged` injecting the lead's idle pane (Channel 2 in
is delivered when ready by `bridged` injecting the lead's idle pane (Mode 2 in
[Architecture](1-Architecture)). The lead talks only to `bridged`, never a broker, and never
busy-polls across turns.
+2 -2
@@ -26,7 +26,7 @@ flowchart LR
OPUS["Opus — primary<br/>(Claude Code, env CLEAN)<br/>MCP client"]
subgraph BD["bridged — standalone daemon (not a claude process)"]
SRV["SERVER face<br/>MCP · REST/SSE · policy"]
CLI["CLIENT face<br/>herdr socket"]
CLI["CLIENT face<br/>status-gated injector · herdr socket"]
SRV --> CLI
end
HERDR["herdr<br/>panes · agent-status"]
@@ -69,7 +69,7 @@ flowchart LR
Read in order (the sidebar mirrors this):
1. **[Architecture](1-Architecture)** — process model, subscription boundary, the two-channel model
1. **[Architecture](1-Architecture)** — process model, the two invariants, two traffic modes
2. **[Message Server](2-Message-Server)** — 🟢 **`bridged`**, the herdr-centric message server (primary approach)
3. **[Approaches](3-Approaches)** — herdr-centric vs AgentAPI vs Agent SDK vs bus/tmux (research matrix)
4. **[Setup](4-Setup)** — running herdr + `bridged` + a worker pointed at `ollama.ltms.dev`
+1 -1
@@ -4,7 +4,7 @@
**Chapters**
1. [Architecture](1-Architecture) — system · invariant · 2 channels
1. [Architecture](1-Architecture) — system · 2 invariants · 2 modes
2. [Message Server](2-Message-Server) — the `bridged` design
3. [Approaches](3-Approaches) — transports compared, why herdr
4. [Setup](4-Setup) — bring-up