wiki: Java stack + REST-as-test-surface + verified herdr 0.7.0 API

- Tech stack Go -> Java 21 (virtual threads, GraalVM native-image); MCP Java SDK;
  Javalin REST; UnixDomainSocketAddress; Jackson YAML; Lettuce; JUnit. Interface
  sketch rewritten in Java (2-Message-Server + 8-Roadmap).
- Testability: REST API is the contract surface — every feature = an endpoint =
  an acceptance test (no Claude/MCP in loop); MCP verified by parity. New
  Roadmap section + feature/endpoint/test map + diagram; Stage-1 tickets
  (CB-104/105) reframed around REST endpoints; CB-106 Jackson/SLF4J.
- Spike correction: verified against RUNNING herdr 0.7.0 (protocol 14). No
  session.snapshot (fixed 5 refs -> workspace.list/pane.list/ping). Documented
  the native agent.* namespace as a south-side opportunity; CB-102 now spikes
  agent.start first. pane.wait_for_output used.
- Envelope open-question already resolved -> Use Cases.
All 21 mermaid blocks validated.
Dai Ha
2026-07-12 09:23:25 +02:00
parent 9d7947c03a
commit 6835d0a89a
5 changed files with 183 additions and 105 deletions
+1 -1
@@ -238,7 +238,7 @@ deliberately:
| What dies | Effect | Recovery |
|---|---|---|
| **`bridged`** | **All** agent comms stop — sync *and* async — since it is the only gateway; in-flight blocking calls error out. | herdr + workers keep running (state on disk / queue). systemd restarts `bridged`; it re-attaches to existing panes via `session.snapshot` and drains its queue. This restart path is load-bearing — harden it. |
| **`bridged`** | **All** agent comms stop — sync *and* async — since it is the only gateway; in-flight blocking calls error out. | herdr + workers keep running (state on disk / queue). systemd restarts `bridged`; it re-attaches to existing panes via `workspace.list`/`pane.list` and drains its queue. This restart path is load-bearing — harden it. |
| **herdr** | No pane control; all delivery (sync + async injection) dead. | PTYs die with the *server* (only client detach survives). Respawn workers from persisted state (Ralph loop); replay unacked queue items. |
| **Broker / queue** (internal) | Durability + cross-host async degrade; **same-host async still works** (idle-injection needs no queue). | `bridged` delivers locally without it; ack + visibility timeout re-deliver on recovery. Nothing silently dropped. |
| **Model endpoint** | Workers stall or error mid-turn. | herdr status shows `working` stuck / `blocked`; `bridged` times out the blocking call and surfaces the error. |
+78 -68
@@ -219,7 +219,20 @@ primary out of herdr — see [Deployment model](#deployment-model).)*
## The herdr control contract (what `bridged` drives)
Everything `bridged` needs is in herdr's socket API (verified against `herdr.dev/docs/socket-api`):
> **Verified against the running herdr 0.7.0 (protocol 14), not just docs.** A spike hit the
> live socket. `ping` returns `{version:"0.7.0", protocol:14, capabilities:{live_handoff:true}}`;
> `workspace.list` / `pane.list` return the pane inventory (with `agent_status`); `events.subscribe`
> exists. **There is no `session.snapshot`** (the earlier draft was wrong — use
> `workspace.list`/`pane.list` to enumerate/re-attach). Crucially, herdr 0.7.0 exposes a
> **native `agent.*` namespace** — `agent.start`, `agent.send`, `agent.read`, `agent.list`,
> `agent.get`, `agent.focus`, plus `server.agent_manifests` and `pane.report_agent` — so herdr
> already models "agents", not just panes. **Open opportunity:** `bridged`'s south side may use
> `agent.start`/`agent.send`/`agent.list` directly instead of the "create pane + `send_text` a
> launch line" workaround below. Spike this in **Stage 1 (CB-102)** and prefer it if it carries
> env/model cleanly; the pane-based path stays the documented fallback. (Also available:
> `worktree.*` for git-isolated workers.)
The pane-based control path (the fallback, and what the examples below use):
```jsonc
// spawn: create a pane, then launch the worker in its shell (env stays worker-only)
@@ -289,8 +302,8 @@ then calls `send_text`. That read-then-send is a **TOCTOU window**: the pane cou
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.
pair as one non-preemptible unit on the same event-loop virtual thread 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
@@ -433,12 +446,14 @@ The invariant is unchanged from [Architecture](1-Architecture) — **anything th
## 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
below are the same operations for *non-Claude* clients (webhooks, dashboards, a human CLI)
and are kept AgentAPI-shaped so existing clients migrate without rewrites. The MCP tools are
thin wrappers over these routes — e.g. `bridge_send` → `POST /sessions/{id}/message`,
`bridge_status` → `GET /sessions/{id}/status`, `bridge_reply` → the reply-rendezvous callback.
**Two faces over one core — and REST is the testability surface.** Every feature is implemented
as a **REST route**; the **MCP tools are thin adapters over those routes** — e.g. `bridge_send`
→ `POST /sessions/{id}/message`, `bridge_status` → `GET /sessions/{id}/status`, `bridge_reply` →
`POST /sessions/{id}/reply` (rendezvous). The REST routes are also the surface non-Claude clients
use (webhooks, dashboards, a human CLI), kept AgentAPI-shaped for drop-in migration. Because the
logic lives in REST, **each feature is acceptance-tested by an HTTP call with no Claude/MCP in
the loop**, and MCP is verified by a **parity test** (tool result == REST result). See
[Roadmap → Testability](8-Roadmap#testability--the-rest-api-is-the-contract-surface).
| Method + path | Purpose |
|---|---|
@@ -533,71 +548,66 @@ turns. Until then, treat one `bridged` as one trust domain.
| Layer | Choice | Why | Alternative |
|---|---|---|---|
| **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 | — |
| **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 |
| **Process supervision** | **systemd** unit (or Docker Compose) colocating herdr + `bridged` | Restart-on-crash; ordered start (herdr before `bridged`) | k8s (overkill for one host) |
| **Testing** | **Mock herdr socket** server + golden transcripts | Deterministic CI without a real TTY | Dockerized herdr for e2e |
| **Server core** | **Java 21+ (virtual threads)** | Loom virtual threads fit the socket + MCP + queue + SSE **blocking fan-in** as cleanly as goroutines — one blocking thread per pane/call, no callback soup; mature libraries; the official MCP Java SDK exists. | **Kotlin** (same JVM, terser); Go (single static binary, smaller RSS); Rust (matches herdr, slower to build). |
| **Deploy artifact** | Runnable **JAR** on a JRE, or **GraalVM `native-image`** | native-image restores the "single binary → `scp` + systemd, fast cold start, small RSS" story the JVM otherwise gives up. | plain JRE + fat JAR (simplest); jlink custom runtime. |
| **herdr transport** | JDK **`UnixDomainSocketAddress` + `SocketChannel`** (native UDS, no dep), **NDJSON** via Jackson, `id`-correlated + a persistent events stream | Native herdr contract; a dedicated **virtual thread** blocks on the event stream. | — |
| **SERVER API — Claude** | **Official MCP Java SDK** (streamable-HTTP transport) on an embedded server (Jetty / Spring Boot) | The unified contract both primary and workers mount; native to Claude Code, no shell/`curl`, subscription-safe by construction | stdio MCP adapter (per-session subprocess) if a long-lived HTTP endpoint is undesirable |
| **SERVER API — others** | **REST + SSE** via **Javalin** (light) or Spring MVC | Drop-in for AgentAPI-shaped/non-Claude clients; SSE streams status cheaply | JAX-RS (Helidon/Quarkus); gRPC if callers are all code |
| **Internal queue** *(optional)* | **Redis Streams** via **Lettuce** (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-JVM queue and add this only when durability/cross-host is needed | NATS JetStream for multi-host scale; embedded H2/SQLite for a single host |
| **Config** | **YAML via Jackson** (`jackson-dataformat-yaml`) + env overrides | 12-factor; secrets via env only | MicroProfile Config; Spring config if on Spring Boot |
| **Observability** | **SLF4J + Logback**; **Micrometer** → Prometheus `/metrics`; `/healthz` | Ops from day one | OpenTelemetry traces |
| **Process supervision** | **systemd** unit (`java -jar` or the native-image binary), ordered after herdr | Restart-on-crash; ordered start (herdr before `bridged`) | Docker Compose colocating herdr + `bridged`; k8s (overkill for one host) |
| **Testing** | **JUnit 5** + a **mock UDS socket** server + golden transcripts; fake `ccs`/`claude` stubs | Deterministic CI without a real TTY | Testcontainers (Redis) + a real herdr for e2e |
**Recommendation: Go.** It gives the smallest operational footprint on the worker host, the
cleanest concurrency story for this exact fan-in shape, and direct code reuse from
`agentapi`. Pick **TypeScript** instead only if `bridged`'s primary *driver* is an Agent-SDK
program and shared types outweigh the deploy simplicity.
**Recommendation: Java 21+ with virtual threads.** The daemon is almost entirely
blocking-I/O fan-in (herdr socket, MCP calls, queue, SSE) — the exact shape Loom makes trivial:
one blocking virtual thread per pane and per in-flight call, no reactive plumbing. Ship a
**GraalVM `native-image`** build to recover Go's small-footprint/fast-start deploy. Use
**Kotlin** instead only if the team prefers it — same JVM, same libraries. (`agentapi`'s Go
`msgfmt` reply parser is trivial to reimplement; it isn't a reason to stay on Go.)
### Interface sketch (Go)
### Interface sketch (Java 21)
```go
// herdr socket client — one method, id-correlated; events on a separate stream.
type Herdr interface {
Call(ctx context.Context, method string, params any) (json.RawMessage, error)
Subscribe(ctx context.Context, subs []Sub) (<-chan Event, error)
```java
// herdr socket client — one call method, id-correlated; events on a separate virtual thread.
interface Herdr {
JsonNode call(String method, Object params) throws IOException; // blocking, id-correlated
BlockingQueue<Event> subscribe(List<Sub> subs) throws IOException; // fed by an event-loop vthread
}
// Injector: deliver ONLY when the pane is safe to type into.
func (i *Injector) Deliver(ctx context.Context, pane string, text string) error {
if st := i.status[pane]; st != Idle && st != Blocked {
i.queue[pane] = append(i.queue[pane], text) // hold until next idle event
return nil
}
if _, err := i.h.Call(ctx, "pane.send_text", P{"pane_id": pane, "text": text}); err != nil {
return err
}
_, err := i.h.Call(ctx, "pane.send_keys", P{"pane_id": pane, "keys": "enter"})
return err
}
// Subscription guard: the boundary, in code.
// 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
}
// 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")
// Injector: deliver ONLY when the pane is safe to type into (status-gated, single writer per pane).
final class Injector {
void deliver(String pane, String text) throws IOException {
Status st = status.get(pane);
if (st != Status.IDLE && st != Status.BLOCKED) {
queue.computeIfAbsent(pane, k -> new ArrayDeque<>()).add(text); // hold until next idle event
return;
}
herdr.call("pane.send_text", Map.of("pane_id", pane, "text", text));
herdr.call("pane.send_keys", Map.of("pane_id", pane, "keys", "enter"));
}
}
// Subscription guard: the boundary, in code. The base_url comes from `ccs env <profile>`
// (see Use Cases → ccs spawn), NOT a hand-built env — validate the RESOLVED host against an
// off-subscription allowlist, then confirm egress post-spawn via pane.process_info.
final class Guard {
private static final Set<String> OFF_SUB_HOSTS = Set.of("ollama.ltms.dev" /* + GX10 vLLM host */);
void assertWorker(String baseUrl) {
if (baseUrl == null || baseUrl.isBlank())
throw new GuardException("refusing to spawn worker without ANTHROPIC_BASE_URL");
String host = URI.create(baseUrl).getHost(); // api.anthropic.com must be rejected
if (host == null || !OFF_SUB_HOSTS.contains(host))
throw new GuardException("worker base_url host %s is not an approved off-subscription host".formatted(host));
}
// 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.
void assertLocalPrimaryClean(Map<String,String> env) {
if (env.containsKey("ANTHROPIC_BASE_URL"))
throw new GuardException("primary env is tainted — this is the subscription line");
}
return nil
}
```
@@ -622,7 +632,7 @@ func (g *Guard) AssertLocalPrimaryClean(env []string) error {
| **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. |
| **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 `ping` (assert `protocol: 14`) on connect and enumerate via `workspace.list`/`pane.list`, failing fast on an unexpected schema. Don't build against `UNCERTAIN` primitives (e.g. native spawn-with-env) until confirmed in the running CLI. |
| **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*. |
+2 -1
@@ -11,7 +11,8 @@ This page will cover standing up the bridge on an off-subscription worker host.
`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`.
`~/.config/herdr/herdr.sock` (or `HERDR_SOCKET_PATH`); verify with `ping` (expect
`protocol: 14` on herdr 0.7.0) and `workspace.list`.
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
+1 -1
@@ -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`; internal broker/queue replay of unacked items. See
re-attaches to existing panes via `workspace.list`/`pane.list`; 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.
+101 -34
@@ -40,16 +40,73 @@ Consolidated from [Message Server](2-Message-Server#proposed-tech-stack); the cc
| Concern | Choice | Note |
|---|---|---|
| **Language** | **Go** | Single static binary → `scp`/systemd; goroutines fit socket + MCP + queue fan-in; reuse `agentapi` `msgfmt`. |
| **MCP server** | `mcp-go` / official Go SDK, streamable-HTTP | The SERVER-face contract both sides mount. |
| **herdr client** | Unix-socket NDJSON, id-correlated + event stream | Native herdr contract. |
| **Language** | **Java 21+ (virtual threads)** | Loom fits the blocking socket + MCP + queue + SSE fan-in; **GraalVM `native-image`** recovers the `scp`+systemd single-binary deploy. Kotlin OK (same JVM). |
| **REST API core** | **Javalin** (or Spring MVC) + SSE | **The implementation & testability surface** — every feature is a REST endpoint (see [Testability](#testability--the-rest-api-is-the-contract-surface)). |
| **MCP server** | **Official MCP Java SDK**, streamable-HTTP (Jetty/Spring) | A **thin adapter over the REST core** — the Claude-facing face of the same features. |
| **herdr client** | JDK `UnixDomainSocketAddress` + `SocketChannel`, NDJSON (Jackson) | Native UDS, no dep; event stream on a virtual thread. Pinned to herdr **0.7.0 / protocol 14** ([verified API](2-Message-Server#the-herdr-control-contract-what-bridged-drives)). |
| **Worker spawn** | **`ccs <profile> claude`** into a herdr pane | Profile = worker identity; no env-prefix. |
| **Subscription guard** | **`ccs env <profile>`** → resolved `base_url` host allowlist | Boundary check in ccs terms. |
| **Config** | YAML (`koanf`) + env | `bridged.yaml`: worker profiles, allowlist, bind addr, lifecycle knobs. |
| **Internal queue** *(Stage 4)* | Redis Streams (ack + visibility) | Below the gateway; embedded queue OK single-host. |
| **Observability** | `slog` + Prometheus `/metrics` + `/healthz` | Stage 5. |
| **Supervision** | systemd unit, ordered after herdr | Stage 5. |
| **Testing** | Mock herdr socket + golden transcripts; fake `ccs`/`claude` stubs | Deterministic CI, no TTY. |
| **Config** | **YAML via Jackson** + env | `bridged.yaml`: worker profiles, allowlist, bind addr, lifecycle knobs. |
| **Internal queue** *(Stage 4)* | **Redis Streams via Lettuce** (ack + visibility) | Below the gateway; in-JVM queue OK single-host. |
| **Observability** | **SLF4J+Logback**; **Micrometer**→Prometheus `/metrics`; `/healthz` | Stage 5. |
| **Supervision** | systemd unit (`java -jar` or native-image), ordered after herdr | Stage 5. |
| **Testing** | **JUnit 5**; **mock UDS herdr** server; **REST contract tests per feature**; **contract test vs real herdr 0.7.0**; fake `ccs`/`claude` stubs | See [Testability](#testability--the-rest-api-is-the-contract-surface). |
## Testability — the REST API is the contract surface
Every feature is implemented as a **REST endpoint first**; the **MCP tools are thin adapters
over those endpoints** (already the design in [Message Server](2-Message-Server#api-surface-server-face)).
That inversion is what makes the system testable *against expectations*:
- **Each feature = one endpoint = one acceptance test.** You verify behaviour by calling REST
with an input and asserting the response — **no Claude session, no MCP client, no TTY in the
loop.** Deterministic and CI-friendly.
- **MCP is validated by parity.** For each tool, one test asserts the MCP call and the REST call
return the same result for the same input. If REST is green and parity holds, MCP is correct by
construction — we don't re-test business logic through the MCP layer.
- **Expectations are pinned to reality, not docs.** herdr behaviour is asserted by a **contract
test against the running herdr 0.7.0** (`ping.protocol == 14`, `workspace.list`/`pane.list`
shapes), so the socket client can't silently drift from the actual server.
```mermaid
flowchart TB
subgraph tests["Test layers (all hit the REST surface or below)"]
FT["feature/acceptance tests<br/>HTTP → REST endpoint"]
PT["parity tests<br/>MCP tool == REST route"]
UT["unit tests<br/>mock UDS herdr · fake ccs/claude"]
CT["contract test<br/>vs real herdr 0.7.0"]
end
MCPC["MCP client (Claude)"] -->|"thin adapter"| REST["REST API (feature core)"]
REST --> CORE["session mgr · injector · guard · rendezvous"]
CORE --> HERDR["herdr socket client"]
FT --> REST
PT --> MCPC
PT --> REST
UT --> CORE
CT --> HERDR
classDef core fill:#2f855a,stroke:#22543d,color:#ffffff;
classDef ext fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
class REST,CORE,HERDR core
class MCPC ext
```
*Figure: features live in the REST core; MCP wraps it; tests target REST (feature), MCP-vs-REST
(parity), the core with a mocked socket (unit), and the real herdr (contract).*
### Feature ⇄ endpoint ⇄ test map (the acceptance surface)
| Feature | REST endpoint | MCP tool | Acceptance test |
|---|---|---|---|
| Deliver a turn (blocking) | `POST /sessions/{id}/message` | `bridge_send` | reply returned; `working→done` unblocks; timeout → 202 |
| Worker status | `GET /sessions/{id}/status` | `bridge_status` | matches herdr `agent_status` |
| Async ticket + poll | `POST …/message?mode=async` · `GET /tickets/{t}` | `bridge_send`(async) · `bridge_poll` | ticket issued; reply retrievable |
| Worker reply | `POST /sessions/{id}/reply` | `bridge_reply` | resolves the awaiting request by `corr` |
| Worker question | `POST /sessions/{id}/ask` | `bridge_ask` | surfaces to primary; parks worker |
| Discovery | `GET /sessions` | `bridge_sessions` | roster + live match config/herdr |
| Spawn (guarded) | `POST /sessions` | *(internal)* | rejects on-subscription profile; accepts allowlisted |
| Health | `GET /healthz` · `GET /metrics` | — | liveness + Prometheus |
Every ticket's **Acceptance** below is written to be executed against these endpoints.
## Tickets by stage (2–5)
@@ -73,51 +130,61 @@ pane scrape (envelope comes in Stage 2). This is the thinnest end-to-end vertica
---
### CB-101 — herdr socket client
**Scope.** Connect to `~/.config/herdr/herdr.sock` (`HERDR_SOCKET_PATH`); implement
`Call(method, params) → result` (NDJSON, `id`-correlated) and `Subscribe(subs) → <-chan Event`
for `pane.agent_status_changed`. Probe `session.snapshot` on connect; fail fast on schema
mismatch.
**Acceptance.** Unit test against a **mock socket** replays a `workspace.create` round-trip and
one event; `session.snapshot` shape asserted.
**Scope.** Connect to `~/.config/herdr/herdr.sock` (`HERDR_SOCKET_PATH`) via
`UnixDomainSocketAddress`; implement `call(method, params) → JsonNode` (NDJSON, `id`-correlated)
and `subscribe(subs) → BlockingQueue<Event>` (fed by an event-loop virtual thread) for
`pane.agent_status_changed`. Probe `ping` on connect (assert `protocol: 14`); enumerate via
`workspace.list`/`pane.list`; fail fast on schema mismatch. **Build against the
[verified 0.7.0 API](2-Message-Server#the-herdr-control-contract-what-bridged-drives), not the docs.**
**Acceptance.** JUnit test against a **mock UDS socket** replays a `workspace.create` round-trip
and one event; a **contract test vs the real herdr** asserts `ping.protocol == 14` and the
`workspace.list`/`pane.list` shapes.
**Deps.** none.
### CB-102 — spawn a worker via ccs profile
**Scope.** Given a profile name, open a herdr pane and `send_text` `ccs <profile> claude`
(+ workspace `cwd`); detect **Ready** via `output_matched` / `pane.read {source:"detection"}`
(prompt-ready), not an absent status.
**Scope.** First **spike herdr's native `agent.start`/`agent.send`/`agent.list`** (0.7.0 has
them) — if they carry the `ccs <profile> claude` command + env/model cleanly, use them. Else
fall back to the documented path: open a herdr pane and `send_text` `ccs <profile> claude`
(+ workspace `cwd`), detecting **Ready** via `pane.wait_for_output` / `pane.read
{source:"detection"}` (prompt-ready), not an absent status.
**Acceptance.** Against a fake `ccs`+`claude` stub in a real herdr pane, the worker reaches
`Ready`; the resolved command is exactly `ccs gx00-vllm claude` (asserted from a spawn log).
**Deps.** CB-101.
### CB-103 — status-gated injector
**Scope.** Per-pane FIFO; `Deliver(pane, text)` sends `send_text` + `send_keys "enter"` **only**
**Scope.** Per-pane FIFO; `deliver(pane, text)` sends `send_text` + `send_keys "enter"` **only**
when `agent_status ∈ {idle, blocked}`, else queues until the next idle event. Single writer per
pane; status-check + send serialized on the event goroutine (close the TOCTOU window).
pane; status-check + send serialized on the event-loop virtual thread (close the TOCTOU window).
**Acceptance.** A delivery issued while the pane is `working` lands only after the `idle` event;
two rapid deliveries never interleave (golden transcript).
**Deps.** CB-101.
### CB-104 — blocking `bridge_send` + reply capture
**Scope.** `bridge_send(to, body, {mode:"block"})` → resolve `to` to the (single, hardcoded)
worker pane → inject the request text → wait for `agent_status=done` → return
`pane.read {source:"recent-unwrapped"}` of the last assistant block as the tool result. (No
envelope yet; scrape is acceptable for Stage 1.)
**Acceptance.** A canned request produces a non-empty reply string derived from the pane; a
`working→done` transition unblocks the call; a timeout returns a typed "still working" error.
### CB-104 — blocking `bridge_send` as a **REST endpoint** + reply capture
**Scope.** Implement the feature as **`POST /sessions/{id}/message`** (`{content}`, blocking) →
resolve to the (single, hardcoded) worker pane → inject → wait for `agent_status=done` → return
`pane.read {source:"recent-unwrapped"}` of the last assistant block in the response body. (No
envelope yet; scrape is acceptable for Stage 1.) This REST route is the feature; MCP wraps it in
CB-105.
**Acceptance.** A **REST contract test** (`POST /sessions/{id}/message`, **no Claude in the
loop**) returns a non-empty reply from the pane; a `working→done` transition unblocks it; a
timeout returns a typed "still working" (HTTP 202-style) response.
**Deps.** CB-102, CB-103.
### CB-105 — MCP server (SERVER face)
**Scope.** Streamable-HTTP MCP server exposing `bridge_send` and `bridge_status`; bind
`127.0.0.1:8080`. Provide the one-line mount: `claude mcp add --transport http bridge
### CB-105 — MCP adapter over the REST core (SERVER face)
**Scope.** Streamable-HTTP MCP server exposing `bridge_send`/`bridge_status` as **thin adapters
over the CB-104 REST routes** (`POST /sessions/{id}/message`, `GET /sessions/{id}/status`); bind
`127.0.0.1:8080`. One-line mount: `claude mcp add --transport http bridge
http://127.0.0.1:8080/mcp`.
**Acceptance.** `claude mcp list` shows `bridge` connected from the primary; calling
`bridge_status` returns the worker's live `agent_status`.
**Acceptance.** **Parity test** — `bridge_send` via MCP and `POST …/message` via REST produce
identical results for the same input; `claude mcp list` shows `bridge` connected; `bridge_status`
returns the live `agent_status`.
**Deps.** CB-104.
### CB-106 — config + wiring
**Scope.** `bridged.yaml` load (`koanf`): one worker profile (`gx00-vllm` → ccs profile, model,
`base_url` host), bind address, workspace root. `slog` startup line logging the resolved worker
command (secrets redacted). Run as a foreground process (systemd deferred to Stage 5).
**Scope.** `bridged.yaml` load (**Jackson YAML**): one worker profile (`gx00-vllm` → ccs profile,
model, `base_url` host), bind address, workspace root. **SLF4J/Logback** startup line logging the
resolved worker command (secrets redacted). Run as a foreground process (systemd deferred to
Stage 5).
**Acceptance.** Bad/missing profile fails fast with a clear message; a valid config boots and
logs the resolved (redacted) spawn command.
**Deps.** none (parallel with CB-101).