diff --git a/1-Architecture.md b/1-Architecture.md index 97eb1fc..b583dd9 100644 --- a/1-Architecture.md +++ b/1-Architecture.md @@ -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
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
MCP server · REST/SSE · policy"] CLI["CLIENT face
herdr socket client"] SRV --> CLI end HERDR["herdr
panes · agent-status"] WCC["worker pane · claude
ANTHROPIC_BASE_URL set · MCP client"] - HOOK["Stop-hook
long-poll client"] + BROKER["broker / durable queue
(bridged-owned · below the gateway)"] CLI -->|"Unix socket
send_text · events.subscribe"| HERDR HERDR -->|"drives PTY"| WCC - WCC --- HOOK + SRV -.->|"durability · cross-host (internal)"| BROKER end - BROKER["Broker
Redis Streams / NATS JetStream
inbox-primary · inbox-worker"] MODEL["ollama.ltms.dev /v1
or GX10 vLLM
(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 —
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
MCP · REST/SSE"] HS["herdr server"] W2["worker claude pane(s)"] + BR["broker / queue
(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. | diff --git a/2-Message-Server.md b/2-Message-Server.md index 3ee79db..6e11d92 100644 --- a/2-Message-Server.md +++ b/2-Message-Server.md @@ -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
(worker model)"] - BROKER["Broker (optional)
Redis / NATS — async duplex"] + BROKER["broker / queue (optional, internal)
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//.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
(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.
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
:8080 MCP · REST/SSE"] HS["herdr server"] W2["worker claude pane(s)"] + BR["broker / queue
(bridged-owned, internal)"] BD -->|"Unix socket"| HS --> W2 + BD -.->|"durability / cross-host"| BR end - BR["Broker
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. | diff --git a/3-Approaches.md b/3-Approaches.md index 3e09b64..eee72e7 100644 --- a/3-Approaches.md +++ b/3-Approaches.md @@ -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. diff --git a/4-Setup.md b/4-Setup.md index dc17c7d..825f098 100644 --- a/4-Setup.md +++ b/4-Setup.md @@ -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 diff --git a/6-Team.md b/6-Team.md index 3e20420..6de6017 100644 --- a/6-Team.md +++ b/6-Team.md @@ -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. diff --git a/Home.md b/Home.md index d3bf979..19b0eea 100644 --- a/Home.md +++ b/Home.md @@ -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