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