wiki: bridged is the sole communication gateway (no Claude<->broker, no mainline Stop-hook)
Now that every Claude session mounts bridged over MCP, make bridged the ONLY thing a Claude session talks to. Claude never posts to / polls a broker; async delivery is bridged injecting an idle pane (event-driven off agent_status). The broker drops below the gateway line as bridged-owned durability/cross-host infra. The Stop-hook survives only as a split-host escape hatch that polls bridged (not the broker). - 1-Architecture: add the gateway invariant; rewrite Channel 2 as bridged-mediated async; redraw components + deployment diagrams (broker below gateway, drop Claude/Hook -> broker arrows); guardrails now bridged-enforced; failure-modes updated (bridged down = whole gateway down) - 2-Message-Server: reply-model, reply-envelope (hook posts bridged not broker), async-duplex sequence, components/API/tech-stack/milestones/trade-offs, both deployment diagrams - 3-Approaches: sole-gateway notes in herdr/AgentAPI/queue sections, matrix + recs - 4-Setup: split-host Stop-hook polls bridged; queue is internal - 6-Team: detached jobs via bridged async, not broker - Home + README: 'one gateway' bullet; intros updated All 16 mermaid blocks validated with mmdc; 2 rendered to PNG for layout.
+77
-63
@@ -2,7 +2,15 @@
|
||||
|
||||
`claude-bridge` connects a **primary** Claude Code session (Opus 4.8, on your Pro/Max
|
||||
subscription) to one or more **secondary** Claude Code **workers** running a *different*
|
||||
model. Two channels carry traffic between them:
|
||||
model.
|
||||
|
||||
> **The gateway invariant.** `bridged` is the **sole communication gateway**: every Claude
|
||||
> session — primary and workers alike — talks *only* to `bridged`, over the MCP tools it
|
||||
> mounts. **No Claude session ever addresses the broker, a peer session, or the network
|
||||
> directly.** Anything else — the herdr socket, a durable queue, an event-bus, cross-host
|
||||
> transport — lives *inside or south of* `bridged` and is invisible to the Claude sessions.
|
||||
|
||||
Two modes of traffic cross that gateway, but both are pure MCP from the Claude side:
|
||||
|
||||
1. **Request/response (blocking)** — the primary delegates a task with **one blocking MCP
|
||||
tool call** (`bridge_send`) that `bridged` (driving [herdr](https://herdr.dev)) holds open
|
||||
@@ -11,10 +19,13 @@ model. Two channels carry traffic between them:
|
||||
means a single tool call parked on a result — *not* a busy-poll — so it costs the primary
|
||||
no quota. Both the primary and the workers reach `bridged` by **mounting it as an MCP
|
||||
server** — one unified Claude setup (see [Message Server](2-Message-Server)).
|
||||
2. **Asynchronous / duplex** — either side drops a message for the other to pick up when
|
||||
idle → a **message broker** polled by a `Stop`-hook long-poll (or injected by `bridged`
|
||||
into an idle pane). This is the layer for detached progress reports, out-of-band
|
||||
questions, and any task that outlives a sane request timeout.
|
||||
2. **Asynchronous / duplex** — for traffic with no caller waiting on a connection (a detached
|
||||
progress report, an out-of-band question, a webhook injecting work), **`bridged` delivers
|
||||
it by injecting the recipient's idle pane over herdr** — event-driven off the live
|
||||
`agent_status`, not a poll loop. If `bridged` needs durability or a host hop, *it* owns a
|
||||
broker for that, **below** the gateway line. A `Stop`-hook survives only as a split-host
|
||||
escape hatch for a primary `bridged` cannot inject into — and even then it polls `bridged`,
|
||||
never the broker (see [Channel 2](#channel-2--bridged-mediated-async-duplex)).
|
||||
|
||||
The engine of the sync channel is **herdr**, fronted by `bridged`: herdr owns the PTYs,
|
||||
multiplexing, persistence, and **agent-status events**; `bridged` owns policy (the
|
||||
@@ -28,33 +39,29 @@ positions swapped.)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph prim["PRIMARY — subscription (env CLEAN)"]
|
||||
subgraph prim["PRIMARY — subscription (env CLEAN) · MCP client"]
|
||||
OPUS["Claude Code · Opus 4.8<br/>leads, reviews, merges"]
|
||||
end
|
||||
|
||||
subgraph work["SECONDARY worker host — off-subscription"]
|
||||
subgraph BD["bridged — standalone daemon (not a claude process)"]
|
||||
subgraph BD["bridged — SOLE GATEWAY (standalone daemon, not a claude process)"]
|
||||
SRV["SERVER face<br/>MCP server · REST/SSE · policy"]
|
||||
CLI["CLIENT face<br/>herdr socket client"]
|
||||
SRV --> CLI
|
||||
end
|
||||
HERDR["herdr<br/>panes · agent-status"]
|
||||
WCC["worker pane · claude<br/>ANTHROPIC_BASE_URL set · MCP client"]
|
||||
HOOK["Stop-hook<br/>long-poll client"]
|
||||
BROKER["broker / durable queue<br/>(bridged-owned · below the gateway)"]
|
||||
CLI -->|"Unix socket<br/>send_text · events.subscribe"| HERDR
|
||||
HERDR -->|"drives PTY"| WCC
|
||||
WCC --- HOOK
|
||||
SRV -.->|"durability · cross-host (internal)"| BROKER
|
||||
end
|
||||
|
||||
BROKER["Broker<br/>Redis Streams / NATS JetStream<br/>inbox-primary · inbox-worker"]
|
||||
MODEL["ollama.ltms.dev /v1<br/>or GX10 vLLM<br/>(worker model)"]
|
||||
|
||||
OPUS -->|"MCP bridge_send → reply in tool result"| SRV
|
||||
WCC -.->|"MCP bridge_reply / bridge_ask"| SRV
|
||||
SRV -.->|"SSE status (observers)"| OPUS
|
||||
OPUS -.->|"write / long-poll (async)"| BROKER
|
||||
HOOK -.->|"long-poll / write (async)"| BROKER
|
||||
SRV -.->|"bridge inbox ↔ session"| BROKER
|
||||
WCC -->|"inference"| MODEL
|
||||
|
||||
classDef sub fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
|
||||
@@ -65,19 +72,20 @@ flowchart TB
|
||||
class BROKER warn
|
||||
```
|
||||
|
||||
*Figure: `bridged` is one **standalone daemon** split into a **SERVER face** (the MCP server
|
||||
the Claude sessions mount, plus REST/SSE + policy) and a **CLIENT face** (the herdr socket
|
||||
client). Solid arrows = the blocking MCP request/response channel; dotted = the optional async
|
||||
broker. The primary's env stays clean; only the worker sets `ANTHROPIC_BASE_URL`, and
|
||||
`bridged` — a plain daemon — enforces that boundary in code.*
|
||||
*Figure: every Claude session — primary and worker — connects **only** to `bridged`'s SERVER
|
||||
face over MCP; async delivery is `bridged` injecting an idle pane through its CLIENT face over
|
||||
herdr. The broker sits **below the gateway line**, owned by `bridged` for durability/cross-host
|
||||
and never touched by a Claude session. The primary's env stays clean; only the worker sets
|
||||
`ANTHROPIC_BASE_URL`, and `bridged` enforces that boundary in code.*
|
||||
|
||||
## Subscription boundary (the non-negotiable)
|
||||
|
||||
The whole design exists to keep the **primary** on Pro/Max while the **worker** runs a
|
||||
cheaper/local model — without a policy-violating proxy on the primary.
|
||||
|
||||
- The **primary** `claude` process **never** sets `ANTHROPIC_BASE_URL`. It talks to the
|
||||
worker over **HTTP** (`bridged`) or a **broker**, never by re-pointing its own endpoint.
|
||||
- The **primary** `claude` process **never** sets `ANTHROPIC_BASE_URL`. It reaches the worker
|
||||
**only through `bridged`'s MCP tools** — never by re-pointing its own endpoint, and never by
|
||||
addressing a broker or the worker directly.
|
||||
- Only the **worker's** `claude` process launches with
|
||||
`ANTHROPIC_BASE_URL=https://ollama.ltms.dev` (+ `ANTHROPIC_AUTH_TOKEN` bearer) or a GX10
|
||||
vLLM URL. Because it is a *separate process*, model selection is just per-process env —
|
||||
@@ -97,8 +105,10 @@ cheaper/local model — without a policy-violating proxy on the primary.
|
||||
subscription-safe by construction. Two fallbacks remain: (a) *single-host, non-MCP primary*
|
||||
— `bridged` can type into the primary pane (simulated typing, identical to the human at the
|
||||
keyboard; the primary still authenticates to `api.anthropic.com` on Pro/Max); (b)
|
||||
*split-host / detached* — worker → primary goes over the **broker + the primary's own
|
||||
`Stop`-hook** (Channel 2). Read every page with that topology split in mind.
|
||||
*split-host / detached* — a primary `bridged` cannot inject into wakes via its own
|
||||
`Stop`-hook, which **long-polls `bridged`** (not the broker) for queued messages (Channel 2).
|
||||
In both fallbacks the Claude side still speaks only to `bridged`. Read every page with that
|
||||
topology split in mind.
|
||||
|
||||
> **Rule:** anything that sets `ANTHROPIC_BASE_URL` is, by definition, the worker. If you
|
||||
> ever feel tempted to set it on the primary, stop — that is the subscription line.
|
||||
@@ -157,48 +167,51 @@ screen-stability heuristic), **symmetric** injection into either pane (single-ho
|
||||
swappable *fallback injector* behind the same interface. See [Approaches](3-Approaches) for the full
|
||||
transport comparison and [Message Server](2-Message-Server) for the design.
|
||||
|
||||
## Channel 2 — broker + Stop-hook long-poll (async, duplex)
|
||||
## Channel 2 — bridged-mediated async (duplex)
|
||||
|
||||
For traffic that has **no caller waiting on a connection** — a long-running worker posting
|
||||
progress, an out-of-band question, another agent or a webhook injecting work — use a
|
||||
broker. The mechanism is symmetric, so it carries **both** directions: each side long-polls
|
||||
its **own** inbox stream and writes to the **other's**. `bridged` can also bridge a broker
|
||||
inbox directly onto a session (delivering into an idle pane) instead of a Stop-hook.
|
||||
For traffic with **no caller waiting on a connection** — a long-running worker posting
|
||||
progress, an out-of-band question, another agent or a webhook injecting work — the recipient
|
||||
must be *woken*. Under the gateway invariant, **`bridged` does the waking by injecting the
|
||||
recipient's idle pane over herdr** — driven by the live `agent_status`, so it delivers the
|
||||
instant the pane goes idle rather than on a timed poll. No Claude session polls, writes to, or
|
||||
even knows about a broker; if `bridged` needs durability or a host hop it enqueues internally
|
||||
(below the gateway) and still delivers by injection.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as "Primary (Opus)"
|
||||
participant B as "Broker (Redis / NATS)"
|
||||
participant H as "Worker Stop-hook"
|
||||
participant W as "Worker claude"
|
||||
participant SRC as "Source (worker bridge_reply/ask · webhook · bus)"
|
||||
participant S as "bridged (gateway)"
|
||||
participant Q as "broker / queue (internal)"
|
||||
participant H as "herdr"
|
||||
participant R as "Recipient pane (idle Claude)"
|
||||
|
||||
W->>H: "turn ends → Stop fires"
|
||||
H->>B: "long-poll inbox-worker (BRPOPLPUSH, ≤30s)"
|
||||
alt message queued
|
||||
B-->>H: "payload"
|
||||
H-->>W: "{decision: block, reason: [payload]} → new turn"
|
||||
else timeout
|
||||
B-->>H: "(nothing)"
|
||||
H-->>W: "allow stop → idle"
|
||||
SRC->>S: "MCP bridge_reply / bridge_ask · or REST ingress"
|
||||
opt durability / cross-host
|
||||
S->>Q: "enqueue (ack + visibility timeout)"
|
||||
end
|
||||
Note over W,B: "worker writes result/question → inbox-primary"
|
||||
W->>B: "XADD inbox-primary (result / question)"
|
||||
P->>B: "Stop-hook long-poll inbox-primary (ON IDLE ONLY)"
|
||||
B-->>P: "payload → primary resumes"
|
||||
S->>H: "await recipient agent_status = idle"
|
||||
H-->>S: "event: idle"
|
||||
S->>H: "pane.send_text + send_keys (inject)"
|
||||
H->>R: "new turn = the async message"
|
||||
Note over S,R: "recipient polled nothing —<br/>bridged pushed on the idle edge"
|
||||
```
|
||||
|
||||
*Figure: a `Stop` hook long-polls the broker and injects any message as the block `reason`,
|
||||
forcing another turn; on timeout the session idles. Reversing it (worker → primary) is the
|
||||
same hook on the primary's inbox — or, *single-host only*, `bridged` typing into the
|
||||
primary's idle pane.*
|
||||
*Figure: `bridged` is the mediator for async too. It accepts the message over MCP (or REST for
|
||||
non-Claude sources), optionally parks it on its **internal** queue, waits for the recipient's
|
||||
idle event, and injects. The **only** exception is a primary `bridged` cannot inject into
|
||||
(split-host, off-herdr): that primary runs a `Stop`-hook which long-polls **`bridged`'s**
|
||||
inbox endpoint — still the gateway, still never the broker.*
|
||||
|
||||
### Guardrails (mandatory for the async layer)
|
||||
### Guardrails (enforced centrally in `bridged`)
|
||||
|
||||
Collapsing everything behind one gateway turns these from cooperative conventions into
|
||||
`bridged`-enforced policy — a real win over per-hook envelope sentinels:
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| **Primary quota burn** | The primary must **never** perpetual-poll. Use the `Stop`-hook variant (fires only at a natural idle boundary), have `bridged` inject on idle, or pull on-demand. Free busy-polling is for `bridged` and the off-subscription **worker** only. |
|
||||
| **Cross-agent ping-pong** | A→B→A→B can loop forever. `stop_hook_active` guards single-agent re-entry but **not** cross-agent. Carry a round/turn budget or a `no-reply-needed` sentinel in the message envelope. |
|
||||
| **Lost / double-processed messages** | Use a broker with **ack + visibility timeout + consumer groups** (Redis Streams `XACK`, NATS JetStream). A crash mid-turn re-delivers instead of dropping. |
|
||||
| **Primary quota burn** | Structurally impossible now — the primary has no broker to poll and no perpetual loop. It either blocks on one MCP call (`bridged` holds it, idle-waiting) or is injected on its idle edge. The split-host `Stop`-hook fires only at a natural turn boundary, never in a spin. |
|
||||
| **Cross-agent ping-pong** | A→B→A→B can loop forever. `bridged` sees every hop (sole gateway), so it enforces a **round/turn budget centrally** and drops on breach — no reliance on a `no-reply-needed` sentinel each side must honor. |
|
||||
| **Lost / double-processed messages** | `bridged`'s internal queue uses **ack + visibility timeout + consumer groups** (Redis Streams `XACK`, NATS JetStream). A crash mid-turn re-delivers instead of dropping. |
|
||||
| **Context growth** | A perpetual worker's context window fills up. `bridged` caps idle cycles / tokens, then **recycles the pane fresh with state on the filesystem** (Ralph loop — see [Message Server](2-Message-Server)). Perpetual != one infinite session. |
|
||||
|
||||
## Deployment shape (target)
|
||||
@@ -213,13 +226,12 @@ flowchart LR
|
||||
BD["bridged :8080<br/>MCP · REST/SSE"]
|
||||
HS["herdr server"]
|
||||
W2["worker claude pane(s)"]
|
||||
BR["broker / queue<br/>(bridged-owned, internal)"]
|
||||
BD -->|"Unix socket"| HS --> W2
|
||||
BD -.->|"durability / cross-host"| BR
|
||||
end
|
||||
BR["Broker"]
|
||||
ML["ollama.ltms.dev / GX10 vLLM"]
|
||||
OPUS -->|"MCP over HTTP (sync)"| BD
|
||||
OPUS -.->|"async"| BR
|
||||
BD -.-> BR
|
||||
OPUS -->|"MCP over HTTP — the only link (sync + async)"| BD
|
||||
W2 --> ML
|
||||
classDef sub fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
|
||||
classDef pick fill:#2f855a,stroke:#22543d,color:#ffffff;
|
||||
@@ -227,10 +239,12 @@ flowchart LR
|
||||
class BD,HS pick
|
||||
```
|
||||
|
||||
*Figure: `bridged` + herdr + workers live on an off-subscription host near the model; the
|
||||
primary reaches it over HTTP (sync) and the broker (async). herdr's socket is local to the
|
||||
worker host — only the broker (or `bridged`'s HTTP) crosses the network. Bind `bridged` to
|
||||
localhost + tunnel, or front it with a token; never expose the port unauthenticated.*
|
||||
*Figure: `bridged` is the **only** thing the primary connects to. Sync replies come back on
|
||||
the blocking MCP call; because this split-host primary isn't a herdr pane, async wake-ups
|
||||
arrive via its `Stop`-hook polling **that same `bridged` endpoint** — never a broker. herdr's
|
||||
socket and the broker are local to the worker host and `bridged`-owned; neither crosses to the
|
||||
primary. Bind `bridged` to localhost + tunnel, or front it with a token; never expose the port
|
||||
unauthenticated.*
|
||||
|
||||
## Failure modes & single points of failure
|
||||
|
||||
@@ -239,9 +253,9 @@ deliberately:
|
||||
|
||||
| What dies | Effect | Degradation / recovery |
|
||||
|---|---|---|
|
||||
| **`bridged`** | Sync channel down; no new delegations, in-flight blocking calls error out | herdr + workers keep running (state on disk / broker). systemd restarts `bridged`; it re-attaches to existing panes via `session.snapshot`. Async broker traffic is unaffected. |
|
||||
| **herdr** | No pane control at all; sync channel dead | Workers' PTYs die with the herdr server (no detach survives a *server* crash, only client detach). Respawn from persisted worker state (Ralph loop); replay unacked broker items. |
|
||||
| **Broker** | Async/duplex down; worker→primary (split-host) stalls | Sync channel still works. Buffer/ack semantics (visibility timeout) re-deliver on recovery; nothing is silently dropped. |
|
||||
| **`bridged`** | **The whole gateway is down** — no delegations, no async wake-ups, in-flight blocking calls error out (it is the sole gateway, so sync *and* async stop together) | herdr + workers keep running (state on disk / queue). systemd restarts `bridged`; it re-attaches to existing panes via `session.snapshot` and drains its queue. Nothing reaches a Claude session in the meantime — by design there is no side path. |
|
||||
| **herdr** | No pane control at all; delivery (sync and async injection) dead | Workers' PTYs die with the herdr server (no detach survives a *server* crash, only client detach). Respawn from persisted worker state (Ralph loop); replay unacked queue items. |
|
||||
| **Broker / queue** (internal) | Durability + cross-host async degrade; **same-host async still works** (direct idle-pane injection needs no queue) | `bridged` can deliver locally without it; only durable replay and host-hop traffic pause. Ack + visibility timeout re-deliver on recovery; nothing silently dropped. |
|
||||
| **Model endpoint** (`ollama.ltms.dev` / vLLM) | Workers stall or error mid-turn | herdr status shows `working` stuck or `blocked`; `bridged` times out the blocking call and surfaces the error. Primary (subscription) is never affected. |
|
||||
| **All three** | Full sync + async outage | Primary Opus remains fully usable on its own subscription — the bridge is additive, never on the primary's critical path. |
|
||||
|
||||
|
||||
+58
-45
@@ -23,7 +23,7 @@ persistent service, and adds three things AgentAPI cannot:
|
||||
| Capability | AgentAPI | herdr (via `bridged`) |
|
||||
|---|---|---|
|
||||
| Inject a turn into a **worker** | ✅ terminal emulation | ✅ `pane.send_text` + `pane.send_keys` |
|
||||
| Inject a turn into the **primary** | ❌ (only wraps worker) | ◐ same primitive — **only when the primary is a herdr pane** (single-host); split-host uses the broker |
|
||||
| Inject a turn into the **primary** | ❌ (only wraps worker) | ◐ same primitive — **only when the primary is a herdr pane** (single-host); split-host wakes via the primary's `Stop`-hook polling `bridged` |
|
||||
| "Done / blocked" signal | ⚠ screen-stability heuristic | ✅ `events.subscribe(pane.agent_status_changed)` |
|
||||
| Worker self-reports state | ❌ | ✅ `pane.report_agent` (via herdr `SKILL.md`) |
|
||||
| Multiplex a *herd* of workers + attach/observe | ❌ one server per session | ✅ native workspaces/tabs/panes |
|
||||
@@ -44,10 +44,12 @@ model has **two** shapes, and the distinction is *cross-turn busy-polling* (forb
|
||||
the primary needs. SSE (`GET /events`) is a *parallel observer channel* for
|
||||
humans/dashboards — the primary never has to hold it.
|
||||
- **Return-and-reinvoke (long/detached/async tasks).** When a task may outrun a sane request
|
||||
timeout, or is fire-and-forget, the primary's call returns immediately and the worker's
|
||||
reply comes back later over the **broker** — delivered to the primary by its own `Stop`-hook
|
||||
(split-host) or by `bridged` injecting the primary pane (single-host). This is the pattern
|
||||
`crush-bridge` uses, and the only correct one for work that outlives a connection.
|
||||
timeout, or is fire-and-forget, the primary's call returns immediately and the reply comes
|
||||
back later **through `bridged`** — `bridged` injects it into the primary's idle pane over
|
||||
herdr (same-host), or a split-host primary's `Stop`-hook long-polls **`bridged`** for it. In
|
||||
neither case does the primary touch a broker: if `bridged` needs durability it queues the
|
||||
message internally (below the gateway) and still delivers by the same route. This is the
|
||||
correct shape for work that outlives a connection.
|
||||
|
||||
What `bridged` does **not** offer is a *held-open bidirectional conversation* — each exchange
|
||||
is one request in, one reply out. That is a feature for a subscription-safe bridge, not a
|
||||
@@ -177,13 +179,13 @@ flowchart TB
|
||||
end
|
||||
|
||||
MODEL["ollama.ltms.dev / GX10 vLLM<br/>(worker model)"]
|
||||
BROKER["Broker (optional)<br/>Redis / NATS — async duplex"]
|
||||
BROKER["broker / queue (optional, internal)<br/>Redis / NATS — durability · cross-host"]
|
||||
|
||||
PP -->|"MCP tools"| MCP
|
||||
WP -->|"MCP tools"| MCP
|
||||
HCL -->|"Unix socket · drive + status"| herd
|
||||
WP -->|"inference"| MODEL
|
||||
POL -.->|"async"| BROKER
|
||||
POL -.->|"async: enqueue / cross-host"| BROKER
|
||||
|
||||
classDef core fill:#2f855a,stroke:#22543d,color:#ffffff;
|
||||
classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
|
||||
@@ -198,7 +200,9 @@ Claude panes mount, over the policy brain) and a **CLIENT** face (the herdr sock
|
||||
The Claude sessions are herdr **panes**: they call *up* into the MCP server, while `bridged`'s
|
||||
client drives them *down* through herdr's socket and gates every injection on live
|
||||
agent-status. Only worker panes carry `ANTHROPIC_BASE_URL`; `bridged` holds no quota, so it
|
||||
subscribes freely. (Split-host moves the primary out of herdr — see [Deployment model](#deployment-model).)*
|
||||
subscribes freely. The broker is **`bridged`-internal** (durability / cross-host) — no pane
|
||||
ever addresses it; async delivery is `bridged` injecting an idle pane. (Split-host moves the
|
||||
primary out of herdr — see [Deployment model](#deployment-model).)*
|
||||
|
||||
### Components
|
||||
|
||||
@@ -210,7 +214,7 @@ subscribes freely. (Split-host moves the primary out of herdr — see [Deploymen
|
||||
| **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. |
|
||||
| **Broker connector** *(optional)* | Bridges `inbox-*` streams ↔ session messages for async, duplex, cross-host traffic. |
|
||||
| **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)
|
||||
|
||||
@@ -258,7 +262,8 @@ envelope" resolves to **a worker-side hook that runs our code at turn end**:
|
||||
1. A **`Stop`-hook** on the worker fires when its turn ends. The hook reads the **last
|
||||
assistant message** from the session transcript (`~/.claude/projects/<proj>/<session>.jsonl`,
|
||||
the path Claude Code exposes to hooks) and POSTs `{session_id, turn_id, status, text,
|
||||
artifacts}` to `bridged`'s callback (or `XADD inbox-primary`).
|
||||
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
|
||||
*timing* signal; the hook payload is the *content*.
|
||||
@@ -324,28 +329,32 @@ sequenceDiagram
|
||||
transitions over SSE for observers, and returns the reply — from the worker's `Stop`-hook
|
||||
envelope, falling back to a `recent-unwrapped` scrape — as the blocking call's response body.*
|
||||
|
||||
### Async duplex via broker (event bus ↔ worker, worker → primary)
|
||||
### Async duplex — bridged-mediated (event bus / worker → recipient)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant BUS as "Event bus / webhook"
|
||||
participant B as "Broker (Redis/NATS)"
|
||||
participant S as "bridged"
|
||||
participant SRC as "Source: webhook/bus (REST) · worker bridge_reply/ask (MCP)"
|
||||
participant S as "bridged (gateway)"
|
||||
participant Q as "broker / queue (internal)"
|
||||
participant H as "herdr"
|
||||
participant W as "Worker claude"
|
||||
participant R as "Recipient pane (idle Claude)"
|
||||
|
||||
BUS->>B: "XADD inbox-worker (work item)"
|
||||
S->>B: "consume inbox-worker (group + XACK)"
|
||||
S->>H: "inject when pane idle"
|
||||
H->>W: "new turn"
|
||||
W-->>S: "reply envelope (callback / XADD inbox-primary)"
|
||||
S->>B: "XADD inbox-primary (result / question)"
|
||||
Note over S: "primary woken on its own idle boundary<br/>(split-host: primary Stop-hook long-poll · single-host: bridged injects primary pane)"
|
||||
SRC->>S: "REST ingress · or MCP bridge_reply / bridge_ask"
|
||||
opt durability / cross-host
|
||||
S->>Q: "enqueue (group + XACK)"
|
||||
Q-->>S: "dequeue when ready"
|
||||
end
|
||||
S->>H: "await recipient agent_status = idle"
|
||||
H-->>S: "event: idle"
|
||||
S->>H: "pane.send_text + send_keys (inject)"
|
||||
H->>R: "new turn = the message"
|
||||
Note over S,R: "recipient polled nothing — bridged pushed on the idle edge.<br/>split-host primary: its Stop-hook polls bridged, never the broker"
|
||||
```
|
||||
|
||||
*Figure: the broker is the durable, cross-host spine; `bridged` is the local actuator that
|
||||
turns queued messages into herdr injections and back. Content rides the broker as structured
|
||||
envelopes — herdr scrollback is never the source of truth.*
|
||||
*Figure: `bridged` mediates async in both directions. Every source reaches it over the gateway
|
||||
(MCP for Claude, REST for external), it optionally parks the message on its **internal** queue,
|
||||
waits for the recipient's idle event, and injects. The queue is a `bridged` implementation
|
||||
detail — no Claude session touches it, and herdr scrollback is never the source of truth.*
|
||||
|
||||
## Worker session lifecycle
|
||||
|
||||
@@ -411,7 +420,8 @@ The invariant is unchanged from [Architecture](1-Architecture) — **anything th
|
||||
- The guard only constrains panes **`bridged` spawns**. A split-host primary on your Mac is
|
||||
a process `bridged` never sees; it cannot inspect that env. There, subscription safety
|
||||
rests on the operator (the Mac `claude` simply is never given the var) plus the fact that
|
||||
the *only* thing crossing to the worker host is HTTP/broker traffic, never an endpoint swap.
|
||||
the *only* thing crossing to the worker host is **MCP/HTTP traffic to `bridged`**, never an
|
||||
endpoint swap and never a broker connection.
|
||||
- Injecting keystrokes into the **primary** pane is subscription-safe: it is simulated
|
||||
typing, identical to the human at the keyboard — the primary still talks to
|
||||
`api.anthropic.com` on Pro/Max. `bridged` never re-points the primary's endpoint. (This
|
||||
@@ -449,11 +459,12 @@ thin wrappers over these routes — e.g. `bridge_send` → `POST /sessions/{id}/
|
||||
2. **A herd of specialized workers.** One `bridged` + herdr multiplexes several workers
|
||||
(e.g. a DeepSeek coder, a fast summarizer, a long-context reader), each its own pane,
|
||||
each addressable by `session_id`. Rolls up to a single status sidebar.
|
||||
3. **Async event-bus automation.** A webhook/CI/NATS event drops a work item on
|
||||
`inbox-worker`; `bridged` wakes an idle worker, and the result flows back to the primary
|
||||
(or a Slack/Telegram bridge) on `inbox-primary` — no human in the loop.
|
||||
3. **Async event-bus automation.** A webhook/CI/NATS event hits `bridged`'s **REST ingress**;
|
||||
`bridged` wakes an idle worker by injection, and the result flows back to the primary (or a
|
||||
Slack/Telegram bridge) — all mediated by `bridged`, no human in the loop. The source never
|
||||
addresses a worker or a broker directly.
|
||||
4. **Human co-pilot from anywhere.** Because herdr persists and detaches, the same worker is
|
||||
reachable from a phone/chat bridge writing to the broker while you're away, and from the
|
||||
reachable from a phone/chat bridge **posting to `bridged`** while you're away, and from the
|
||||
attached TUI when you're back.
|
||||
5. **Long-running "perpetual" workers.** The Ralph-loop lifecycle lets a worker run for hours
|
||||
across many context recycles without a human respawning it, state carried on disk.
|
||||
@@ -469,8 +480,9 @@ optional. Best for a workstation or a single dev box.
|
||||
### Split-host (primary local, workers remote near the model)
|
||||
|
||||
Primary Opus runs on your Mac; `bridged` + herdr + workers run on the GPU host next to
|
||||
`ollama.ltms.dev` / GX10 vLLM. herdr's socket is **local-only**, so the two hosts are joined
|
||||
by the **broker** (or `bridged`'s HTTP), never by a remote herdr socket.
|
||||
`ollama.ltms.dev` / GX10 vLLM. herdr's socket is **local-only**, so the Mac reaches the worker
|
||||
host **only over `bridged`'s MCP/HTTP endpoint** — never a remote herdr socket and never the
|
||||
broker (the broker, if any, stays `bridged`-internal on the worker host).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -482,14 +494,13 @@ flowchart LR
|
||||
BD["bridged<br/>:8080 MCP · REST/SSE"]
|
||||
HS["herdr server"]
|
||||
W2["worker claude pane(s)"]
|
||||
BR["broker / queue<br/>(bridged-owned, internal)"]
|
||||
BD -->|"Unix socket"| HS --> W2
|
||||
BD -.->|"durability / cross-host"| BR
|
||||
end
|
||||
BR["Broker<br/>Redis / NATS"]
|
||||
ML["ollama.ltms.dev / GX10 vLLM"]
|
||||
|
||||
OPUS -->|"HTTP + SSE (sync)"| BD
|
||||
OPUS -.->|"async duplex"| BR
|
||||
BD -.-> BR
|
||||
OPUS -->|"MCP/HTTP — the only link (sync + Stop-hook async)"| BD
|
||||
W2 --> ML
|
||||
|
||||
classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
|
||||
@@ -500,9 +511,11 @@ flowchart LR
|
||||
class BR warn
|
||||
```
|
||||
|
||||
*Figure: only the broker (or `bridged`'s HTTP) crosses the network; herdr stays local to the
|
||||
worker host. The subscription boundary tracks the host boundary — nothing on the Mac ever
|
||||
sets `ANTHROPIC_BASE_URL`.*
|
||||
*Figure: the Mac's **only** link to the worker host is `bridged`'s MCP/HTTP endpoint — it
|
||||
carries sync replies, and (since the Mac primary isn't a herdr pane) its `Stop`-hook polls that
|
||||
same endpoint for async wake-ups. herdr's socket and the broker stay local and `bridged`-owned.
|
||||
The subscription boundary tracks the host boundary — nothing on the Mac ever sets
|
||||
`ANTHROPIC_BASE_URL`.*
|
||||
|
||||
**Security:** bind `bridged`'s HTTP to `localhost` and reach it over an SSH tunnel, or front
|
||||
it with a bearer token + TLS. Never expose the port unauthenticated — it is an agent-control
|
||||
@@ -523,7 +536,7 @@ turns. Until then, treat one `bridged` as one trust domain.
|
||||
| **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) |
|
||||
| **Async bus** *(optional)* | **Redis Streams** (consumer groups, `XACK`, visibility timeout) | Simplest durable duplex; satisfies the async guardrails in [Architecture](1-Architecture) | NATS JetStream for multi-host scale / replay |
|
||||
| **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 |
|
||||
| **Process supervision** | **systemd** unit (or Docker Compose) colocating herdr + `bridged` | Restart-on-crash; ordered start (herdr before `bridged`) | k8s (overkill for one host) |
|
||||
@@ -594,17 +607,17 @@ func (g *Guard) AssertLocalPrimaryClean(env []string) error {
|
||||
| **M0 — Spike** | herdr client + spawn one worker pane + one `send_text`/`send_keys` round-trip | herdr socket drives a real `claude` |
|
||||
| **M1 — Status gate + MCP** | `events.subscribe` → Injector delivers only on `idle`/`blocked`; **MCP `bridge_send`/`status` mounted on the primary**, blocking reply via the rendezvous; SSE status out | No mid-run corruption; real completion signal; primary drives over MCP |
|
||||
| **M2 — Boundary + lifecycle + worker MCP** | Subscription guard + Ralph-loop recycle + worker `bridge_reply`/`bridge_ask` (unified mount) + envelope fallback | Subscription-safe; survives context ceiling; symmetric 2-way |
|
||||
| **M3 — Broker + duplex** | Redis Streams connector; worker→primary path; split-host deploy | Async, cross-host, 2-way |
|
||||
| **M3 — Async + durability + split-host** | Idle-injection async delivery; `bridged`-internal queue (Redis Streams) for durability/cross-host; split-host `Stop`-hook adapter that polls `bridged` | Detached/long work, cross-host, gateway-only (no Claude↔broker) |
|
||||
| **M4 — Harden** | Auth/TLS, metrics, mock-socket CI, systemd unit | Production shape |
|
||||
|
||||
## Trade-offs & risks
|
||||
|
||||
| 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** over the broker. 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, poll/await async" and the reply lands via the broker path instead of erroring the delegation. Pick Channel 2 up front for known-long work. |
|
||||
| **herdr is young / single-dev** — betting transport on it | The **durable spine is the broker** (mature); herdr carries only ephemeral delivery + status. The injector is a **pluggable interface** — fall back to `tmux send-keys` or AgentAPI without touching the bus. |
|
||||
| **herdr socket is local-only** | Broker (or `bridged` HTTP) is the sole cross-host link; herdr stays per-host. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
|
||||
+26
-17
@@ -58,18 +58,20 @@ 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); split-host uses the broker + the primary's `Stop`-hook (Channel 2).
|
||||
- **North-face contract is MCP** — both primary and workers mount `bridged` as an MCP server
|
||||
(one unified Claude setup); the herdr injection here is the *south* side, orthogonal to it.
|
||||
keystrokes); a split-host primary wakes via its own `Stop`-hook polling `bridged` (Channel 2).
|
||||
- **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.
|
||||
- **Completion signal:** structured events — not the screen-stability *guess* AgentAPI makes.
|
||||
The worker can even `pane.report_agent` its own state via herdr's `SKILL.md`.
|
||||
- **Multiplex + persist:** a herd of workers as addressable panes; headless server survives
|
||||
detach/reattach over SSH.
|
||||
- **Subscription-safe:** only the worker pane launches with `ANTHROPIC_BASE_URL`; `bridged`
|
||||
is a plain daemon (no quota) that enforces the boundary in code.
|
||||
- **Trade-off:** herdr's socket is **local-only** (broker spans hosts, not herdr), and it is
|
||||
a young, single-dev project — so `bridged` keeps the injector **pluggable** and the
|
||||
durable spine on the broker. Replies are best carried as a structured envelope, not scraped.
|
||||
- **Trade-off:** herdr's socket is **local-only** (`bridged`'s MCP/HTTP spans hosts, not
|
||||
herdr), and it is a young, single-dev project — so `bridged` keeps the injector **pluggable**
|
||||
and its durability in an **internal** queue behind the gateway. Replies are best carried as a
|
||||
structured envelope, not scraped.
|
||||
|
||||
## 2. AgentAPI — HTTP over terminal emulation *(fallback injector)*
|
||||
|
||||
@@ -80,7 +82,8 @@ the original leading choice; herdr now supersedes it, but it remains a **swappab
|
||||
injector** behind `bridged`'s interface.
|
||||
|
||||
- **Injects into a live session:** yes — but only the *worker* (it wraps one CLI); the
|
||||
primary direction still needs a broker.
|
||||
primary direction still needs `bridged`'s async path (idle-injection, or a split-host
|
||||
`Stop`-hook polling `bridged`).
|
||||
- **Completion signal:** a **screen-stability heuristic**, not structured events.
|
||||
- **Cross-host:** native HTTP — its one edge over herdr, but `bridged` already provides the
|
||||
HTTP layer on top of herdr, so that edge is neutralized.
|
||||
@@ -100,10 +103,13 @@ events and permission callbacks instead of scraping a terminal.
|
||||
|
||||
## 4. Message-queue + Stop-hook long-poll *(the async layer — complementary)*
|
||||
|
||||
The **only pure-hooks** way to pull an external message into the **same** session, and the
|
||||
right fit when the trigger is an **asynchronous event bus** (NATS / Redis / webhook) rather
|
||||
than a synchronous driver waiting on a reply. Used as `claude-bridge`'s **Channel 2**
|
||||
alongside the herdr sync channel, not as a replacement.
|
||||
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
|
||||
sole-gateway rule `bridged` delivers async by **injecting an idle pane**, and no Claude session
|
||||
polls a queue. The Stop-hook survives in exactly one place: a **split-host primary** that isn't
|
||||
a herdr pane, where the hook long-polls **`bridged`** (not the queue) for wake-ups. The raw
|
||||
mechanism below is shown for the comparison; note the poll target is the gateway, and the queue
|
||||
itself sits *behind* `bridged`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -159,7 +165,7 @@ status events, and multiplexing — which is why we build on herdr rather than h
|
||||
|
||||
| Approach | Transport | Inject into running session? | Completion signal | Symmetric (both panes)? | Cross-host | Subscription-safe | Fragility |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **herdr via `bridged`** ✅ | socket → terminal + events | ✅ (idle-gated) | ✅ status events² | ◐ single-host¹ | via broker / `bridged` HTTP | ✅ (guard in code) | Low–Med (herdr young) |
|
||||
| **herdr via `bridged`** ✅ | socket → terminal + events | ✅ (idle-gated) | ✅ status events² | ◐ single-host¹ | via `bridged` MCP/HTTP (sole gateway) | ✅ (guard in code) | Low–Med (herdr young) |
|
||||
| **AgentAPI** ◐ (fallback) | HTTP → terminal emulation | ✅ (worker only) | ⚠ screen-stability | ❌ | ✅ native HTTP | ✅ (worker-only env) | Low |
|
||||
| **Agent SDK streaming** | in-process generator | ✅ | ✅ typed events | n/a | ✅ | ✅ | Low (driver = code) |
|
||||
| **Queue + Stop-hook** | hook long-poll | ✅ (turn boundary) | via turn end | ✅ (symmetric) | ✅ | ✅ | Medium |
|
||||
@@ -168,8 +174,10 @@ status events, and multiplexing — which is why we build on herdr rather than h
|
||||
|
||||
¹ **Symmetric only single-host.** herdr can type into *either* pane, but the primary is a
|
||||
herdr pane only when it runs on the herdr host. In the split-host target (primary on a Mac),
|
||||
worker→primary is **not** symmetric — it goes via the broker + the primary's `Stop`-hook. The
|
||||
"one mechanism, both directions" story holds for a single-box setup, not the distributed one.
|
||||
worker→primary goes through `bridged` all the same — the primary's `Stop`-hook long-polls
|
||||
`bridged` (never a broker) for the wake-up. The "one mechanism, both directions via herdr"
|
||||
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.
|
||||
@@ -182,9 +190,10 @@ worker→primary is **not** symmetric — it goes via the broker + the primary's
|
||||
[Message Server](2-Message-Server) / [Architecture](1-Architecture).
|
||||
- **Keep AgentAPI as a swappable fallback injector** behind `bridged`'s interface, so
|
||||
herdr's immaturity is a de-riskable risk rather than a load-bearing one.
|
||||
- **External event bus → worker (async wake-ups):** layer a **Stop-hook long-poll** (or
|
||||
`bridged` inbox bridging) onto the worker so bus events resume it on idle. Complementary
|
||||
to the sync channel, not a replacement — different trigger shape.
|
||||
- **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
|
||||
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.
|
||||
|
||||
|
||||
+5
-3
@@ -22,10 +22,12 @@ This page will cover standing up the bridge on an off-subscription worker host.
|
||||
`claude mcp add --transport http bridge http://127.0.0.1:8080/mcp` (or a shared
|
||||
`.mcp.json` / `CLAUDE.md` entry every session inherits). The primary then delegates via the
|
||||
`bridge_send` tool (single blocking call per delegation) and workers reply via
|
||||
`bridge_reply`. For split-host, also add the primary's `Stop`-hook against the broker for
|
||||
detached work.
|
||||
`bridge_reply`. Same-host needs nothing more — `bridged` delivers async by injecting an idle
|
||||
pane. Only for a **split-host** primary (not a herdr pane) add a `Stop`-hook that long-polls
|
||||
**`bridged`** (never a broker) for detached wake-ups.
|
||||
6. **Topology choice** — single-host vs split-host (see [Message Server](2-Message-Server) → *Deployment
|
||||
model*), and the broker (Redis Streams / NATS) if async/duplex is needed.
|
||||
model*). If durability or cross-host async is needed, configure `bridged`'s **internal** queue
|
||||
(Redis Streams / NATS); it stays behind the gateway — no Claude session connects to it.
|
||||
|
||||
## Non-negotiable during setup
|
||||
|
||||
|
||||
+4
-2
@@ -113,8 +113,10 @@ sequenceDiagram
|
||||
worker's turn completes (status-gated) and returns the reply as the tool result.
|
||||
- **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 the async broker path instead of a held request (Channel 2 in
|
||||
[Architecture](1-Architecture)), so the lead never busy-polls across turns.
|
||||
- **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
|
||||
[Architecture](1-Architecture)). The lead talks only to `bridged`, never a broker, and never
|
||||
busy-polls across turns.
|
||||
|
||||
Fan-out is bounded by the herd size (pane count) and `bridged`'s concurrency policy, not by
|
||||
the lead.
|
||||
|
||||
+11
-8
@@ -13,8 +13,9 @@ session drive a **secondary Claude agent running a different model** via its own
|
||||
A small always-on message server, **`bridged`**, controls
|
||||
[herdr](https://herdr.dev) (an agent multiplexer, "tmux for agents") over its Unix-socket
|
||||
API and exposes a clean 2-way messaging API as an **MCP server that both the primary and the
|
||||
workers mount** — one unified Claude setup (REST/SSE stays for non-Claude clients, plus an
|
||||
optional broker). herdr owns the PTYs, multiplexing, persistence, and **agent-status
|
||||
workers mount** — one unified Claude setup and the **sole communication gateway** (REST/SSE
|
||||
stays for non-Claude clients; any broker is `bridged`-internal, below the gateway). herdr owns
|
||||
the PTYs, multiplexing, persistence, and **agent-status
|
||||
events**; `bridged` owns policy (subscription boundary, session lifecycle, status-gated
|
||||
delivery) and the client contract. The worker `claude` launches with
|
||||
`ANTHROPIC_BASE_URL=https://ollama.ltms.dev` + a bearer token; the primary Opus stays
|
||||
@@ -47,15 +48,17 @@ flowchart LR
|
||||
- **Subscription boundary:** the *primary* never sets `ANTHROPIC_BASE_URL` (stays on
|
||||
Pro/Max). Only the *secondary* process is off-subscription; `bridged` is a plain daemon
|
||||
(no Anthropic quota), so it may poll/subscribe freely.
|
||||
- **Unified MCP setup:** both the primary and the workers mount `bridged` as an MCP server
|
||||
(one `claude mcp add` line). Claude ↔ Claude goes over MCP tools; there is no `curl` or
|
||||
bespoke client to maintain.
|
||||
- **One gateway:** `bridged` is the **sole communication path** for every Claude session. Both
|
||||
the primary and the workers mount it as an MCP server (one `claude mcp add` line) and talk
|
||||
*only* to it — **no Claude session ever addresses a broker, a peer, or the network directly.**
|
||||
Any broker/queue is `bridged`-internal, below the gateway.
|
||||
- **How the primary gets a reply:** it makes **one blocking MCP tool call** (`bridge_send`)
|
||||
that `bridged` holds open until the worker replies (`bridge_reply`) or its turn completes,
|
||||
then returns the reply as the tool result. Worker → primary rides `bridged`'s state, so no
|
||||
keystroke into the primary pane is needed even single-host. Long/detached work instead uses
|
||||
the async broker path (see [Architecture](1-Architecture)) — the primary never busy-polls
|
||||
across turns.
|
||||
keystroke into the primary pane is needed even single-host. Long/detached work comes back the
|
||||
same way — `bridged` **injects the primary's idle pane** when the result is ready (a
|
||||
split-host primary's `Stop`-hook polls `bridged`, not a broker). The primary never busy-polls
|
||||
and never touches a broker.
|
||||
- **Different model** per worker process sidesteps Claude Code's lack of per-subagent
|
||||
provider routing — the worker isn't a subagent, it's its own configured process.
|
||||
- **AgentAPI** ([`coder/agentapi`](https://github.com/coder/agentapi)) is retained only as a
|
||||
|
||||
Reference in New Issue
Block a user