From 6835d0a89a83c7250c3746246be95c798865d298 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sun, 12 Jul 2026 09:23:25 +0200 Subject: [PATCH] wiki: Java stack + REST-as-test-surface + verified herdr 0.7.0 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- 1-Architecture.md | 2 +- 2-Message-Server.md | 146 +++++++++++++++++++++++--------------------- 4-Setup.md | 3 +- 5-Operations.md | 2 +- 8-Roadmap.md | 135 +++++++++++++++++++++++++++++----------- 5 files changed, 183 insertions(+), 105 deletions(-) diff --git a/1-Architecture.md b/1-Architecture.md index 73bbb81..752a065 100644 --- a/1-Architecture.md +++ b/1-Architecture.md @@ -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. | diff --git a/2-Message-Server.md b/2-Message-Server.md index 2b60bd7..8d61936 100644 --- a/2-Message-Server.md +++ b/2-Message-Server.md @@ -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 subscribe(List 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 ` +// (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 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 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*. | diff --git a/4-Setup.md b/4-Setup.md index 825f098..680bd4f 100644 --- a/4-Setup.md +++ b/4-Setup.md @@ -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 diff --git a/5-Operations.md b/5-Operations.md index 9ef218b..a7d0bd8 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`; 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. diff --git a/8-Roadmap.md b/8-Roadmap.md index 515a6e4..d93cef1 100644 --- a/8-Roadmap.md +++ b/8-Roadmap.md @@ -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 claude`** into a herdr pane | Profile = worker identity; no env-prefix. | | **Subscription guard** | **`ccs env `** → 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
HTTP → REST endpoint"] + PT["parity tests
MCP tool == REST route"] + UT["unit tests
mock UDS herdr · fake ccs/claude"] + CT["contract test
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` (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 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 claude` command + env/model cleanly, use them. Else +fall back to the documented path: open a herdr pane and `send_text` `ccs 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).