wiki: architect review fixes + numbered pages & sidebar nav
Architect pass (weak spots fixed): - Resolve sync/async contradiction: primary consumes replies via one blocking request; SSE is an observers-only side-channel - Qualify 'symmetric 2-way' as single-host only; split-host worker->primary goes via broker + primary Stop-hook - Specify reply-envelope mechanism (worker Stop-hook -> callback/XADD) - Concretize Ralph-loop state (externalized artifacts, not --resume) - Harden subscription guard (host allowlist + process_info, not substring) - Add failure-modes/SPOF, delivery-gating races, multi-tenancy/security, herdr-versioning notes; add Setup/Operations stubs Navigation (Gitea): - Add _Sidebar.md with numbered chapter nav - Number page H1s 1..5 and the Home index in reading order
+16
-6
@@ -1,4 +1,4 @@
|
||||
# Approaches
|
||||
# 3. Approaches
|
||||
|
||||
How does a **driver** (the primary Opus session, or an external event bus) deliver a
|
||||
message into an **already-running Claude Code worker** — *without* spawning a fresh
|
||||
@@ -19,7 +19,7 @@ flowchart TD
|
||||
Q -->|"worker PULLS on idle via a hook"| BUS["Message-queue<br/>+ Stop-hook long-poll"]
|
||||
Q -->|"raw keystrokes into the tmux pane"| TMUX["tmux send-keys<br/>/ PTY paste"]
|
||||
|
||||
HD --> V0["✅ our leading choice<br/>(status events + symmetric + multiplex)"]
|
||||
HD --> V0["✅ our leading choice<br/>(status events + multiplex;<br/>symmetric single-host)"]
|
||||
AA --> V1["◐ fallback injector<br/>(swappable behind bridged)"]
|
||||
SDK --> V2["✅ if driver is our own code"]
|
||||
BUS --> V3["⚠ async bus events only<br/>(lands at turn boundary)"]
|
||||
@@ -54,8 +54,10 @@ Unix-socket JSON API. `bridged` (see [[Message-Server]]) drives it: `pane.send_t
|
||||
`pane.send_keys` deliver a turn into the *running* pane, and `events.subscribe`
|
||||
(`pane.agent_status_changed`) reports **working / blocked / done** as real events.
|
||||
|
||||
- **Injects into a live session:** yes — into *either* the worker **or** the primary pane
|
||||
(typing keystrokes is subscription-safe), so both directions use one mechanism.
|
||||
- **Injects into a live session:** yes into the worker; and into the primary pane **only when
|
||||
the primary itself runs inside herdr (single-host)** — typing keystrokes is
|
||||
subscription-safe. Split-host (primary off-herdr, e.g. on a Mac) can't use this for
|
||||
worker→primary; that direction falls to the broker + the primary's `Stop`-hook (Channel 2).
|
||||
- **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
|
||||
@@ -154,17 +156,25 @@ 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 | ✅ | via broker / `bridged` HTTP | ✅ (guard in code) | Low–Med (herdr young) |
|
||||
| **herdr via `bridged`** ✅ | socket → terminal + events | ✅ (idle-gated) | ✅ status events² | ◐ single-host¹ | via broker / `bridged` HTTP | ✅ (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 |
|
||||
| **tmux `send-keys`** | keystrokes / PTY | ✅ | ❌ scrape `capture-pane` | ✅ | ⚠ ssh | ✅ | High |
|
||||
| ~~`claude -p`~~ (excluded) | new process/turn | ❌ (fresh session) | ✅ | — | ✅ | ✅ | — |
|
||||
|
||||
¹ **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.
|
||||
|
||||
² **"Completion signal" for `bridged` is the timing signal; reply *content* rides a worker
|
||||
`Stop`-hook envelope** (see [[Message-Server]]), not the status event itself.
|
||||
|
||||
## Recommendation
|
||||
|
||||
- **Primary Opus → worker (the bridge's main channel):** **herdr via `bridged`** —
|
||||
status-gated injection, structured completion/blocked events, symmetric, multiplexed,
|
||||
status-gated injection, structured completion/blocked events, symmetric (single-host), multiplexed,
|
||||
persistent, with the subscription boundary enforced in code. Selected. See
|
||||
[[Message-Server]] / [[Architecture]].
|
||||
- **Keep AgentAPI as a swappable fallback injector** behind `bridged`'s interface, so
|
||||
|
||||
+57
-24
@@ -1,16 +1,18 @@
|
||||
# Architecture
|
||||
# 1. Architecture
|
||||
|
||||
`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:
|
||||
|
||||
1. **Synchronous** — primary delegates a task and streams the reply → the **`bridged`
|
||||
message server driving [herdr](https://herdr.dev)**. This is the main channel; its full
|
||||
design is in **[[Message-Server]]**.
|
||||
1. **Request/response (blocking)** — the primary delegates a task with **one blocking
|
||||
request** that `bridged` (driving [herdr](https://herdr.dev)) holds open until the
|
||||
worker's turn completes, then returns the reply as the response body. This is the main
|
||||
channel; its full design is in **[[Message-Server]]**. "Blocking" here means a single
|
||||
tool call parked on a result — *not* a busy-poll — so it costs the primary no quota.
|
||||
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 optional layer for detached progress reports and
|
||||
out-of-band questions.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -40,8 +42,8 @@ flowchart TB
|
||||
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 -->|"POST /message (sync)"| BD
|
||||
BD -.->|"SSE /events (reply)"| OPUS
|
||||
OPUS -->|"blocking POST /message → reply in body"| BD
|
||||
BD -.->|"SSE /events (status, for observers)"| OPUS
|
||||
OPUS -.->|"write / long-poll (async)"| BROKER
|
||||
HOOK -.->|"long-poll / write (async)"| BROKER
|
||||
BD -.->|"bridge inbox ↔ session"| BROKER
|
||||
@@ -55,7 +57,7 @@ flowchart TB
|
||||
class BROKER warn
|
||||
```
|
||||
|
||||
*Figure: solid arrows = the synchronous `bridged`/herdr channel; dotted = the optional async
|
||||
*Figure: solid arrows = the blocking request/response `bridged`/herdr 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.*
|
||||
|
||||
@@ -73,16 +75,23 @@ cheaper/local model — without a policy-violating proxy on the primary.
|
||||
not a subagent).
|
||||
- **`bridged` is not a `claude` process** and consumes zero Anthropic quota, so it may
|
||||
busy-poll the broker and hold a permanent herdr event subscription with no policy concern.
|
||||
Its **subscription guard** refuses to spawn a *worker* pane without `ANTHROPIC_BASE_URL`
|
||||
and refuses to ever set it on a pane tagged *primary*.
|
||||
Its **subscription guard** refuses to spawn a *worker* pane without an off-subscription
|
||||
`ANTHROPIC_BASE_URL` and refuses to ever set it on a pane tagged *primary*. The guard only
|
||||
constrains panes `bridged` itself spawns; it **cannot** inspect a primary it does not host
|
||||
(e.g. an Opus on your Mac, split-host) — that primary's env cleanliness is the operator's
|
||||
responsibility, backed by a best-effort startup self-check (see [[Message-Server]]).
|
||||
- Injecting keystrokes into the **primary** pane (worker → primary replies) is
|
||||
subscription-safe: it is simulated typing, identical to the human at the keyboard — the
|
||||
primary still authenticates to `api.anthropic.com` on Pro/Max.
|
||||
primary still authenticates to `api.anthropic.com` on Pro/Max. **This path exists only when
|
||||
the primary runs *as a herdr pane* (single-host).** When the primary is off-herdr
|
||||
(split-host), `bridged` has nothing to type into; worker → primary then goes over the
|
||||
**broker + the primary's own `Stop`-hook** (Channel 2). Every page should be read 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.
|
||||
|
||||
## Channel 1 — `bridged` over herdr (synchronous, the main path)
|
||||
## Channel 1 — `bridged` over herdr (blocking request/response, the main path)
|
||||
|
||||
`bridged` translates a clean north-side message API (`POST /message`, `GET /events` SSE,
|
||||
`GET /status`) into herdr socket calls, and — unlike a screen-stability heuristic — gates
|
||||
@@ -90,6 +99,13 @@ every injection on herdr's live `agent_status_changed` events. One session per p
|
||||
panes per herdr. Full contract, components, and the Go interface sketch are in
|
||||
**[[Message-Server]]**.
|
||||
|
||||
The primary's `POST /message` **blocks** until `bridged` sees `agent_status = done` and
|
||||
collects the reply, which it returns as the response body — the primary reads it as an
|
||||
ordinary tool result. SSE (`GET /events`) is a parallel channel for *observers* (a human, a
|
||||
dashboard) to watch `working`/`blocked`/`done` transitions; the primary does not need to
|
||||
hold it. For a task that may outrun a reasonable request timeout, prefer Channel 2
|
||||
(fire-and-forget, reply comes back async).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as "Primary (Opus)"
|
||||
@@ -97,32 +113,32 @@ sequenceDiagram
|
||||
participant H as "herdr"
|
||||
participant W as "Worker claude (other model)"
|
||||
|
||||
P->>S: "POST /message (task, full context inlined)"
|
||||
P->>S: "POST /message (task, full context inlined) — BLOCKS"
|
||||
S->>S: "await pane status = idle"
|
||||
S->>H: "pane.send_text + send_keys enter"
|
||||
H->>W: "inject into running session"
|
||||
activate W
|
||||
H-->>S: "event: agent_status_changed = working"
|
||||
S-->>P: "SSE: status working"
|
||||
S-->>P: "SSE: status working (observers only)"
|
||||
W-->>H: "produces reply (writes envelope)"
|
||||
H-->>S: "event: agent_status_changed = done"
|
||||
deactivate W
|
||||
S-->>P: "SSE: assistant reply"
|
||||
S-->>P: "200 body = assistant reply (unblocks the call)"
|
||||
Note over P: "review diff / result, merge"
|
||||
alt worker is blocked
|
||||
H-->>S: "event: agent_status_changed = blocked"
|
||||
S-->>P: "SSE / GET /status: blocked"
|
||||
P->>S: "POST /message (answer inlined)"
|
||||
S-->>P: "body = blocked + question"
|
||||
P->>S: "POST /message (answer inlined) — new blocking call"
|
||||
end
|
||||
```
|
||||
|
||||
*Figure: the primary delegates and streams status/reply over SSE; `bridged` injects only
|
||||
when the pane is `idle`, and a blocked worker surfaces on a real herdr event (not a guess).
|
||||
The primary re-sends with the answer inlined — the return-and-reinvoke pattern `crush-bridge`
|
||||
uses.*
|
||||
*Figure: the primary's request blocks until the reply is ready and reads it from the response
|
||||
body; SSE carries status to observers, not the answer. `bridged` injects only when the pane is
|
||||
`idle`, and a blocked worker surfaces on a real herdr event (not a guess). A block returns to
|
||||
the primary as the call's result; the primary re-sends the answer with a fresh blocking call.*
|
||||
|
||||
Why herdr over the earlier `agentapi` plan: structured **agent-status events** (vs a
|
||||
screen-stability heuristic), **symmetric** injection into either pane, native
|
||||
screen-stability heuristic), **symmetric** injection into either pane (single-host), native
|
||||
**multiplexing** of a herd of workers, and **persistence/detach**. AgentAPI is kept as a
|
||||
swappable *fallback injector* behind the same interface. See [[Approaches]] for the full
|
||||
transport comparison and [[Message-Server]] for the design.
|
||||
@@ -159,7 +175,8 @@ sequenceDiagram
|
||||
|
||||
*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 `bridged` typing into the primary's idle pane.*
|
||||
same hook on the primary's inbox — or, *single-host only*, `bridged` typing into the
|
||||
primary's idle pane.*
|
||||
|
||||
### Guardrails (mandatory for the async layer)
|
||||
|
||||
@@ -201,6 +218,22 @@ primary reaches it over HTTP (sync) and the broker (async). herdr's socket is lo
|
||||
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.*
|
||||
|
||||
## Failure modes & single points of failure
|
||||
|
||||
Both channels have a distinct SPOF; neither is redundant in the target design, so degrade
|
||||
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. |
|
||||
| **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. |
|
||||
|
||||
The primary is deliberately **not** downstream of any bridge component: a total bridge
|
||||
outage costs you the workers, never your own session.
|
||||
|
||||
## Related pages
|
||||
|
||||
- **[[Message-Server]]** — the `bridged` design: herdr control contract, lifecycle, API, tech stack
|
||||
|
||||
+43
-22
@@ -8,36 +8,57 @@ session drive a **secondary Claude agent running a different model** via its own
|
||||
> on GX10 DeepSeek). `claude-bridge` keeps the worker a *real Claude Code process* so it
|
||||
> inherits `CLAUDE.md`, hooks, skills, and MCP — just pointed at a cheaper/local model.
|
||||
|
||||
## Leading approach — AgentAPI
|
||||
## Leading approach — herdr-centric message server (`bridged`)
|
||||
|
||||
[`coder/agentapi`](https://github.com/coder/agentapi) wraps the Claude Code **CLI** as an
|
||||
HTTP server (terminal emulation): `POST /message`, `GET /messages`, `GET /events` (SSE),
|
||||
`GET /status`. One session per server. **The model is whatever env the wrapped `claude`
|
||||
process is launched with** — so we launch the worker's AgentAPI server with
|
||||
`ANTHROPIC_BASE_URL=https://ollama.ltms.dev` + a bearer token, and the primary Opus
|
||||
session talks to it over HTTP.
|
||||
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 (HTTP/SSE + optional broker). 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 env-clean and talks to `bridged` over HTTP.
|
||||
|
||||
```
|
||||
Opus (Claude Code, subscription, env CLEAN)
|
||||
│ HTTP POST /message ─┐
|
||||
▼ ▼
|
||||
agentapi server ──launches──► claude (ANTHROPIC_BASE_URL=ollama.ltms.dev, worker model)
|
||||
▲ GET /events (SSE) ◄──┘
|
||||
```mermaid
|
||||
flowchart LR
|
||||
OPUS["Opus — primary<br/>(Claude Code, env CLEAN)"]
|
||||
BD["bridged<br/>message server<br/>(not a claude process)"]
|
||||
HERDR["herdr<br/>panes · agent-status"]
|
||||
W["worker claude<br/>ANTHROPIC_BASE_URL set"]
|
||||
M["ollama.ltms.dev<br/>(worker model)"]
|
||||
|
||||
OPUS -->|"blocking POST /message"| BD
|
||||
BD -->|"Unix socket<br/>send_text · events.subscribe"| HERDR
|
||||
HERDR -->|"drives PTY"| W
|
||||
W -->|"inference"| M
|
||||
|
||||
classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
|
||||
classDef core fill:#2f855a,stroke:#22543d,color:#ffffff;
|
||||
class OPUS ext
|
||||
class BD,HERDR core
|
||||
```
|
||||
|
||||
- **Subscription boundary:** the *primary* never sets `ANTHROPIC_BASE_URL` (stays on
|
||||
Pro/Max). Only the *secondary* process is off-subscription.
|
||||
- **Different model:** set per worker process — sidesteps Claude Code's lack of
|
||||
per-subagent provider routing (issue #38698), because the worker isn't a subagent, it's
|
||||
its own configured process.
|
||||
Pro/Max). Only the *secondary* process is off-subscription; `bridged` is a plain daemon
|
||||
(no Anthropic quota), so it may poll/subscribe freely.
|
||||
- **How the primary gets a reply:** it makes **one blocking request** (a `curl`/MCP tool
|
||||
call) that `bridged` holds open until the worker's turn completes, then returns the reply
|
||||
as the response body. Long/detached work instead uses the async broker path (see
|
||||
[[Architecture]]) — the primary never busy-polls across turns.
|
||||
- **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
|
||||
swappable *fallback injector* behind the same interface. See [[Approaches]] for the
|
||||
comparison and [[Message-Server]] for the full design.
|
||||
|
||||
## Pages
|
||||
|
||||
- **[[Message-Server]]** — 🟢 **`bridged`**, the herdr-centric message server (current primary approach)
|
||||
- **[[Architecture]]** — process model, subscription boundary, the two-channel model it refines
|
||||
- **[[Approaches]]** — herdr-centric vs AgentAPI vs Agent SDK vs bus/tmux (research matrix)
|
||||
- **[[Setup]]** — running herdr + `bridged` + a worker pointed at `ollama.ltms.dev`
|
||||
- **[[Operations]]** — health, restart, model swaps, troubleshooting
|
||||
Read in order (the sidebar mirrors this):
|
||||
|
||||
1. **[[Architecture]]** — process model, subscription boundary, the two-channel model
|
||||
2. **[[Message-Server]]** — 🟢 **`bridged`**, the herdr-centric message server (primary approach)
|
||||
3. **[[Approaches]]** — herdr-centric vs AgentAPI vs Agent SDK vs bus/tmux (research matrix)
|
||||
4. **[[Setup]]** — running herdr + `bridged` + a worker pointed at `ollama.ltms.dev`
|
||||
5. **[[Operations]]** — health, restart, model swaps, troubleshooting
|
||||
|
||||
## Status
|
||||
|
||||
|
||||
+150
-26
@@ -1,4 +1,4 @@
|
||||
# Herdr Message Server (`bridged`)
|
||||
# 2. Herdr Message Server (`bridged`)
|
||||
|
||||
> **Status:** 🟢 Proposed primary approach (2026-07-11) — supersedes AgentAPI as the
|
||||
> centric transport. AgentAPI is retained only as a *fallback injector* (see [[Approaches]]).
|
||||
@@ -20,16 +20,35 @@ 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 — **symmetric 2-way** |
|
||||
| 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 |
|
||||
| "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 |
|
||||
| Persistence / detach-reattach over SSH | ❌ | ✅ headless server |
|
||||
|
||||
The trade `bridged` accepts: **no synchronous call-and-await** — every exchange is async
|
||||
*return-and-reinvoke* (the same pattern `crush-bridge` uses). For a subscription-safe bridge
|
||||
that is the correct pattern anyway: a primary that blocks-and-waits either freezes the UI or
|
||||
busy-polls (quota burn). See **Trade-offs** below.
|
||||
### How the primary actually consumes a reply (blocking call vs return-and-reinvoke)
|
||||
|
||||
An earlier draft claimed "no synchronous call-and-await." That over-stated it. The precise
|
||||
model has **two** shapes, and the distinction is *cross-turn busy-polling* (forbidden), not
|
||||
*blocking* (fine):
|
||||
|
||||
- **Blocking request/response (default, short/medium tasks).** The primary issues **one**
|
||||
tool call — a `curl`/MCP `POST /sessions/{id}/message` — and `bridged` **holds the HTTP
|
||||
request open** until it observes `agent_status = done`, then returns the collected reply as
|
||||
the response body. From the primary's view this is a single tool call parked on a result,
|
||||
exactly like any long-running `Bash` command: it consumes **no** Anthropic quota (the
|
||||
primary isn't looping, it's idle-waiting) and doesn't freeze anything 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.
|
||||
|
||||
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
|
||||
limitation. See **Trade-offs** below.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -92,7 +111,7 @@ because it consumes no Anthropic quota.*
|
||||
| **herdr socket client** | NDJSON over `~/.config/herdr/herdr.sock`; correlates responses by `id`; maintains a long-lived `events.subscribe` stream. |
|
||||
| **Session manager** | Maps a logical session → herdr `workspace/tab/pane` id. Spawns the worker `claude` (env-prefixed launch line into a fresh pane's shell), health-checks, and **recycles on context ceiling** (Ralph loop, see below). |
|
||||
| **Injector** | Per-pane FIFO queue. Delivers `send_text` + `send_keys "enter"` **only when** that pane's `agent_status ∈ {idle, blocked}` — never mid-run. |
|
||||
| **Reply collector** | Preferred: worker writes a **structured envelope** (broker `XADD` or an HTTP callback to `bridged`). Fallback: `pane.read {source:"recent-unwrapped"}` scrape. |
|
||||
| **Reply collector** | Preferred: worker emits a **structured envelope** via a worker-side `Stop`-hook (HTTP callback to `bridged`, or broker `XADD`) — see [Reply envelope](#reply-envelope-how-a-worker-emits-a-structured-reply). Fallback: `pane.read {source:"recent-unwrapped"}` scrape. |
|
||||
| **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`. |
|
||||
| **API layer** | REST + SSE, OpenAPI schema. Route names kept AgentAPI-shaped so existing clients drop in. |
|
||||
| **Broker connector** *(optional)* | Bridges `inbox-*` streams ↔ session messages for async, duplex, cross-host traffic. |
|
||||
@@ -129,9 +148,54 @@ Everything `bridged` needs is in herdr's socket API (verified against `herdr.dev
|
||||
> the **worker pane's shell only**. If a native spawn call exists, prefer it and pass env
|
||||
> explicitly; the guard invariant is unchanged.
|
||||
|
||||
## Reply envelope (how a worker emits a structured reply)
|
||||
|
||||
A Claude Code worker cannot natively `XADD` to a broker or POST a callback — it is a TUI
|
||||
process, not our code. So "the worker writes an envelope" resolves to **a worker-side hook or
|
||||
skill that runs our code at turn end**. The concrete, buildable path:
|
||||
|
||||
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`).
|
||||
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*.
|
||||
3. If no hook is installed, `bridged` falls back to `pane.read {source:"recent-unwrapped"}`
|
||||
and best-effort parses the last assistant block (reuse AgentAPI's `msgfmt`). This is
|
||||
lossy and is the reason the envelope path is preferred.
|
||||
|
||||
> **Open design question.** The envelope contract (fields, how the worker signals "blocked,
|
||||
> need input" vs "done", how tool/diff artifacts are attached) is **not yet pinned down**.
|
||||
> A `Stop`-hook only sees the transcript, so rich structure (e.g. a machine-readable result
|
||||
> object) requires the worker to *emit* it deliberately — via a skill/slash-command it calls
|
||||
> before finishing, or a convention the hook parses. Treat the envelope schema as a design
|
||||
> deliverable for M2, not a solved detail.
|
||||
|
||||
## Delivery gating & races (the injector is a single writer)
|
||||
|
||||
The injector gates on cached `agent_status` (updated by the `events.subscribe` stream) and
|
||||
then calls `send_text`. That read-then-send is a **TOCTOU window**: the pane could leave
|
||||
`idle` between the status read and the keystrokes landing. Mitigations, and the residual gap:
|
||||
|
||||
- **Single writer per pane.** `bridged` is the *only* automated injector into a worker pane;
|
||||
the per-pane FIFO queue serializes deliveries so two turns never interleave. This removes
|
||||
injector-vs-injector races, not injector-vs-agent ones.
|
||||
- **Serialize send within the event loop.** Do the status check and the `send_text`/`send_keys`
|
||||
pair as one non-preemptible unit on the same goroutine that consumes events, so a status
|
||||
change can't be processed mid-send.
|
||||
- **Initial readiness needs prompt detection, not just status.** On spawn there may be no
|
||||
`agent_status` event until the first turn. Gate the *first* injection on an
|
||||
`output_matched`/`pane.read {source:"detection"}` prompt-ready signal (the `Ready` state
|
||||
below), not on an absent status.
|
||||
- **Residual race (accepted).** A human typing into the same worker pane, or the agent
|
||||
self-transitioning to `working` in the millisecond after the gate, can still collide. The
|
||||
cost is a corrupted turn, not a subscription breach; recovery is `ctrl+c` + re-inject.
|
||||
Treat a worker pane as **bridged-owned** (don't hand-drive it) to avoid this.
|
||||
|
||||
## Message flow
|
||||
|
||||
### Synchronous delegation (primary → worker → primary)
|
||||
### Blocking delegation (primary → worker → primary)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -140,23 +204,24 @@ sequenceDiagram
|
||||
participant H as "herdr"
|
||||
participant W as "Worker claude"
|
||||
|
||||
P->>S: "POST /sessions/{id}/message (task, context inlined)"
|
||||
P->>S: "POST /sessions/{id}/message — request BLOCKS"
|
||||
S->>S: "await pane status = idle"
|
||||
S->>H: "pane.send_text + send_keys enter"
|
||||
H->>W: "inject turn"
|
||||
activate W
|
||||
H-->>S: "event: agent_status_changed = working"
|
||||
S-->>P: "SSE: status working"
|
||||
W-->>H: "produces reply (writes envelope)"
|
||||
S-->>P: "SSE: status working (observers only)"
|
||||
W-->>H: "turn ends → Stop-hook posts envelope"
|
||||
H-->>S: "event: agent_status_changed = done"
|
||||
deactivate W
|
||||
S->>S: "collect reply (envelope, or pane.read fallback)"
|
||||
S-->>P: "SSE: assistant reply"
|
||||
S-->>P: "200 body = assistant reply (unblocks call)"
|
||||
Note over P: "review diff / result, merge"
|
||||
```
|
||||
|
||||
*Figure: `bridged` gates injection on `idle`, streams status transitions over SSE, and
|
||||
returns the reply from a structured envelope (falling back to a `recent-unwrapped` scrape).*
|
||||
*Figure: the primary's request blocks; `bridged` gates injection on `idle`, streams status
|
||||
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)
|
||||
|
||||
@@ -174,7 +239,7 @@ sequenceDiagram
|
||||
H->>W: "new turn"
|
||||
W-->>S: "reply envelope (callback / XADD inbox-primary)"
|
||||
S->>B: "XADD inbox-primary (result / question)"
|
||||
Note over S: "primary is woken on its own idle boundary<br/>(Stop-hook long-poll OR bridged inject into primary pane)"
|
||||
Note over S: "primary woken on its own idle boundary<br/>(split-host: primary Stop-hook long-poll · single-host: bridged injects primary pane)"
|
||||
```
|
||||
|
||||
*Figure: the broker is the durable, cross-host spine; `bridged` is the local actuator that
|
||||
@@ -185,7 +250,26 @@ envelopes — herdr scrollback is never the source of truth.*
|
||||
|
||||
`bridged` treats a worker as a **recyclable** resource, not one immortal session — a
|
||||
long-lived pane fills its context window. When idle-cycle or token caps trip, `bridged`
|
||||
snapshots state to the filesystem, kills the pane, and respawns fresh (**Ralph loop**).
|
||||
kills the pane and respawns fresh (**Ralph loop**).
|
||||
|
||||
**What "state on disk" actually means (be precise — this is easy to hand-wave).** Recycling
|
||||
deliberately **sheds the conversation transcript**; it is *not* `claude --resume`, which would
|
||||
reload the full context you are trying to drop. Continuity is instead carried by **artifacts
|
||||
the worker externalizes as it works**:
|
||||
|
||||
- Git commits / a working branch (the real output).
|
||||
- A durable **task/progress file** (a scratchpad, `TODO`/`STATE.md`, or the broker's own
|
||||
record of the outstanding work item) that the worker is instructed — via `CLAUDE.md` /
|
||||
skill — to keep current.
|
||||
- The fresh worker is spawned with a prompt that says *"here is the task and the state file;
|
||||
continue from it,"* not with the old messages.
|
||||
|
||||
This only works if the worker is disciplined about writing that state **before** a recycle
|
||||
boundary. `bridged` can enforce a checkpoint (inject "commit and update STATE.md" before it
|
||||
kills the pane), but a worker that ignores it loses in-flight context. **Open question:**
|
||||
whether to also snapshot the raw transcript (`--resume` the *same* session on crash-restart,
|
||||
vs. a clean context on a planned recycle) — the two restart reasons may want different
|
||||
policies. Treat cross-recycle continuity as a pattern to prove in M2, not a guarantee.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
@@ -212,13 +296,28 @@ The invariant is unchanged from [[Architecture]] — **anything that sets
|
||||
|
||||
- `bridged` is **not** a `claude` process. It consumes zero Anthropic quota, so it may
|
||||
busy-poll the broker and hold a permanent herdr event subscription with no policy concern.
|
||||
- The **subscription guard** blocks any spawn of a *worker* pane whose launch line lacks
|
||||
`ANTHROPIC_BASE_URL`, and blocks any attempt to set it on a pane tagged *primary*.
|
||||
- The **subscription guard** blocks any spawn of a *worker* pane whose launch does not
|
||||
resolve an **off-subscription** `ANTHROPIC_BASE_URL`, and blocks any attempt to set that
|
||||
var on a pane tagged *primary*.
|
||||
- **The guard's reach is limited — be honest about it.** A substring check on the launch
|
||||
*string* is necessary but not sufficient:
|
||||
- The var may arrive from a **profile / `.envrc` / direnv / systemd `Environment=`**, not
|
||||
the launch line — so string-absence does **not** prove the worker is on-subscription, and
|
||||
string-presence does **not** prove it points off-subscription (`ANTHROPIC_BASE_URL=https://api.anthropic.com`
|
||||
would pass a naive check while burning subscription-adjacent auth). The guard must
|
||||
validate the **resolved value's host** against an allowlist and, after spawn, confirm
|
||||
egress via `pane.process_info` / a health call to the worker model — not trust the string.
|
||||
- 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.
|
||||
- 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.
|
||||
- A startup self-check asserts the primary process env has **no** `ANTHROPIC_BASE_URL` and
|
||||
logs the worker's resolved egress host.
|
||||
`api.anthropic.com` on Pro/Max. `bridged` never re-points the primary's endpoint. (This
|
||||
path exists only single-host, where the primary is a herdr pane.)
|
||||
- A startup self-check asserts any **locally-hosted** primary pane's env has **no**
|
||||
`ANTHROPIC_BASE_URL` and logs each worker's resolved egress host. It cannot self-check a
|
||||
remote primary.
|
||||
|
||||
## API surface (north side)
|
||||
|
||||
@@ -301,7 +400,14 @@ 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
|
||||
surface. (This closes the gap left by AgentAPI's open `:3284`.)
|
||||
surface: `POST /message` runs arbitrary prompts, and `POST /keys` sends raw keystrokes
|
||||
(including `ctrl+c`) into a live agent. (This closes the gap left by AgentAPI's open `:3284`.)
|
||||
|
||||
**Multi-tenancy is an open item.** A single `bridged` fronting a *herd* of workers today has
|
||||
**one shared token = full control of every session**; there is no per-session authorization.
|
||||
That is acceptable for a single-operator box but not for shared/multi-user use. Before that,
|
||||
add per-session scoping (a capability token per `session_id`) and an audit log of injected
|
||||
turns. Until then, treat one `bridged` as one trust domain.
|
||||
|
||||
## Proposed tech stack
|
||||
|
||||
@@ -344,13 +450,27 @@ func (i *Injector) Deliver(ctx context.Context, pane string, text string) error
|
||||
}
|
||||
|
||||
// Subscription guard: the boundary, in code.
|
||||
func (g *Guard) AssertWorker(launch string) error {
|
||||
if !strings.Contains(launch, "ANTHROPIC_BASE_URL=") {
|
||||
// NOTE: a substring check is not enough — validate the RESOLVED host against an
|
||||
// off-subscription allowlist, and confirm egress post-spawn via pane.process_info.
|
||||
var offSubHosts = map[string]bool{"ollama.ltms.dev": true /* + GX10 vLLM host */}
|
||||
|
||||
func (g *Guard) AssertWorker(baseURL string) error {
|
||||
if baseURL == "" {
|
||||
return fmt.Errorf("refusing to spawn worker without ANTHROPIC_BASE_URL")
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("worker ANTHROPIC_BASE_URL unparseable: %w", err)
|
||||
}
|
||||
if !offSubHosts[u.Hostname()] { // api.anthropic.com etc. must be rejected here
|
||||
return fmt.Errorf("worker base_url %q is not an approved off-subscription host", u.Hostname())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (g *Guard) AssertPrimaryClean(env []string) error {
|
||||
|
||||
// Only meaningful for a primary bridged itself hosts (single-host). A remote/Mac
|
||||
// primary is a process bridged never sees — its cleanliness is the operator's.
|
||||
func (g *Guard) AssertLocalPrimaryClean(env []string) error {
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, "ANTHROPIC_BASE_URL=") {
|
||||
return fmt.Errorf("primary env is tainted — this is the subscription line")
|
||||
@@ -374,12 +494,16 @@ func (g *Guard) AssertPrimaryClean(env []string) error {
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| **No synchronous reply** — all exchanges are return-and-reinvoke | Acceptable by design (sync waiting is quota-hostile); mirrors `crush-bridge`. SSE gives near-real-time status without blocking a turn. |
|
||||
| **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. |
|
||||
| **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]] → *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
|
||||
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# 5. Operations
|
||||
|
||||
> **Status:** 🟠 Stub — scope defined, runbook not yet written. Fills in as `bridged` reaches
|
||||
> **M4 — Harden** (auth/TLS, metrics, systemd) in the [[Message-Server]] build plan.
|
||||
|
||||
Day-2 runbook for a running bridge.
|
||||
|
||||
## Scope (what this page will contain)
|
||||
|
||||
- **Health** — `GET /healthz` liveness, `GET /metrics` (Prometheus), and reading live
|
||||
`agent_status` per session via `GET /sessions`.
|
||||
- **Restart & recovery** — ordered restart (herdr before `bridged`); how `bridged`
|
||||
re-attaches to existing panes via `session.snapshot`; broker replay of unacked items. See
|
||||
[[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.
|
||||
- **Lifecycle / context ceilings** — observing recycle events; confirming workers externalize
|
||||
state (git + `STATE.md`) before a recycle so continuity survives (see [[Message-Server]] →
|
||||
*Worker session lifecycle*).
|
||||
- **Troubleshooting** — stuck `working` (model endpoint down), `blocked` awaiting input,
|
||||
injection collisions on a hand-driven pane, envelope-vs-scrape reply mismatches.
|
||||
- **Security ops** — token rotation, keeping the port off public interfaces; note that one
|
||||
`bridged` is currently **one trust domain** (no per-session authz yet — [[Message-Server]]
|
||||
→ *Security*).
|
||||
|
||||
## Guardrails to watch (from [[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.
|
||||
|
||||
## Related
|
||||
|
||||
- [[Message-Server]] · [[Architecture]] · [[Setup]] · [[Approaches]]
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# 4. Setup
|
||||
|
||||
> **Status:** 🟠 Stub — scope defined, procedure not yet written. `bridged` is at the design
|
||||
> stage ([[Message-Server]]); concrete install steps land with **M0–M1** of the build plan.
|
||||
|
||||
This page will cover standing up the bridge on an off-subscription worker host.
|
||||
|
||||
## Scope (what this page will contain)
|
||||
|
||||
1. **Prerequisites** — [herdr](https://herdr.dev) installed and its server running; a worker
|
||||
`claude` that inherits your `CLAUDE.md`/hooks/skills/MCP; a reachable worker model
|
||||
(`ollama.ltms.dev` or GX10 vLLM) with a bearer token.
|
||||
2. **herdr** — start the headless server; confirm the socket at
|
||||
`~/.config/herdr/herdr.sock` (or `HERDR_SOCKET_PATH`); verify with `session.snapshot`.
|
||||
3. **`bridged`** — deploy the binary, `bridged.yaml` (worker model, `base_url` allowlist,
|
||||
auth token, bind address), and a **systemd** unit ordered *after* herdr.
|
||||
4. **Worker session** — create the first worker pane with the env-prefixed launch line
|
||||
(`ANTHROPIC_BASE_URL=https://ollama.ltms.dev ANTHROPIC_AUTH_TOKEN=… claude`); confirm the
|
||||
subscription guard accepts it and `pane.process_info` shows the expected egress host.
|
||||
5. **Primary wiring** — point the primary Opus at `bridged` over HTTP (single blocking
|
||||
request per delegation); for split-host, add the primary's `Stop`-hook against the broker.
|
||||
6. **Topology choice** — single-host vs split-host (see [[Message-Server]] → *Deployment
|
||||
model*), and the broker (Redis Streams / NATS) if async/duplex is needed.
|
||||
|
||||
## Non-negotiable during setup
|
||||
|
||||
The **primary** host/process must **never** be given `ANTHROPIC_BASE_URL`. Only worker panes
|
||||
carry it. See [[Architecture]] → *Subscription boundary*.
|
||||
|
||||
## Related
|
||||
|
||||
- [[Message-Server]] · [[Architecture]] · [[Operations]] · [[Approaches]]
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
### 📖 claude-bridge
|
||||
|
||||
[[Home]] — overview & the decision
|
||||
|
||||
**Chapters**
|
||||
|
||||
1. [[Architecture]] — system · invariant · 2 channels
|
||||
2. [[Message-Server]] — the `bridged` design
|
||||
3. [[Approaches]] — transports compared, why herdr
|
||||
4. [[Setup]] — bring-up
|
||||
5. [[Operations]] — day-2 runbook
|
||||
|
||||
---
|
||||
🟢 herdr-centric `bridged` · AgentAPI = fallback
|
||||
Reference in New Issue
Block a user