CB-402: second peer adapter — opencode (Stage B of the Peer Launcher SPI) #7

Closed
opened 2026-07-21 04:59:07 +02:00 by ltms · 1 comment
Owner

Stage 4 (Pluggable peers) · Stage B. Depends on CB-401 Stage A (PeerLauncher SPI, merged 3aa69a9).

Prove the PeerLauncher SPI holds for a non-Claude coding agent by shipping a second, first-class in-tree adapter: opencode (v1.1.31, provider-agnostic terminal coding agent). Direction: heterogeneous coding agents, Claude Code first-class.

opencode is the proof because it differs on every seam the SPI hides:

  • No subscription boundary — no ANTHROPIC_BASE_URL / SubscriptionGuard ⇒ the guard is Claude-private, not core.
  • MCP mount — no inline --mcp-config; uses OPENCODE_CONFIG file / opencode mcp add.
  • Instruction injection — no --append-system-prompt; uses config instructions / AGENTS.md.
  • Model — -m provider/model flag vs ANTHROPIC_MODEL env.
  • Name/reap scheme — opencode-* vs claude-*.

Everything else (herdr tab/pane placement, CB-306 readiness gate, CB-301-ext worktree, CB-117 reap, teardown, list, cwd) is shared transport.

Design (see docs/CB-402-OpenCode-Adapter.md)

  • A. Extract HerdrPeerLauncher abstract base (Template Method); ClaudeCodeLauncher becomes a thin adapter — hooks namePrefix() + buildLaunch(cfg, req). Pure refactor, no behaviour change.
  • B. Add kind: discriminator (claude-code default | opencode) to BridgedConfig.Worker.
  • C. OpenCodeLauncher — provider env (no guard), OPENCODE_CONFIG bridge-MCP + charter, opencode <cwd> -m … argv.
  • D. CompositePeerLauncher routing by kind + finish the Stage-A migration (drop the two (ClaudeCodeLauncher) casts in BridgeMcp/BridgedApp — they only call profiles()/defaultProfile()/list(), all on the SPI; only friction is list()'s List<?>).
  • E. Live dogfood + wiki as-built.

Validation risks (dogfood, not assumed)

  • opencode TUI ⇄ herdr keystroke injection + CB-306 injectable status recognition.
  • Bridge MCP visibility inside opencode (bridge_reply must be callable, else the reply-charter is worthless).
  • opencode config/MCP schema drift (fast-moving; pin against installed version).
  • Provider credentials distinct from the primary's subscription.

Open questions

  1. Which provider/model does this host have credentials for (distinct from the primary's subscription)?
  2. Charter carrier: generated OPENCODE_CONFIG instructions (recommended) vs AGENTS.md in worktree?
  3. Merge cadence: hold all of Stage B on a feature branch (per CB-401 "major" bar), or land the behaviour-preserving base-extraction to main first?

Out of scope (Stage C / later): dynamic external plugin loading behind a trust model; capability enforcement at the verb layer; human/mock peer.

**Stage 4 (Pluggable peers) · Stage B.** Depends on CB-401 Stage A (`PeerLauncher` SPI, merged `3aa69a9`). Prove the `PeerLauncher` SPI holds for a **non-Claude** coding agent by shipping a second, first-class in-tree adapter: **opencode** (v1.1.31, provider-agnostic terminal coding agent). Direction: heterogeneous coding agents, Claude Code first-class. opencode is the proof because it differs on every seam the SPI hides: - **No subscription boundary** — no `ANTHROPIC_BASE_URL` / `SubscriptionGuard` ⇒ the guard is Claude-private, not core. - **MCP mount** — no inline `--mcp-config`; uses `OPENCODE_CONFIG` file / `opencode mcp add`. - **Instruction injection** — no `--append-system-prompt`; uses config `instructions` / `AGENTS.md`. - **Model** — `-m provider/model` flag vs `ANTHROPIC_MODEL` env. - **Name/reap scheme** — `opencode-*` vs `claude-*`. Everything else (herdr tab/pane placement, CB-306 readiness gate, CB-301-ext worktree, CB-117 reap, teardown, list, cwd) is shared transport. ### Design (see `docs/CB-402-OpenCode-Adapter.md`) - **A.** Extract `HerdrPeerLauncher` abstract base (Template Method); `ClaudeCodeLauncher` becomes a thin adapter — hooks `namePrefix()` + `buildLaunch(cfg, req)`. Pure refactor, no behaviour change. - **B.** Add `kind:` discriminator (`claude-code` default | `opencode`) to `BridgedConfig.Worker`. - **C.** `OpenCodeLauncher` — provider env (no guard), `OPENCODE_CONFIG` bridge-MCP + charter, `opencode <cwd> -m …` argv. - **D.** `CompositePeerLauncher` routing by kind + finish the Stage-A migration (drop the two `(ClaudeCodeLauncher)` casts in `BridgeMcp`/`BridgedApp` — they only call `profiles()`/`defaultProfile()`/`list()`, all on the SPI; only friction is `list()`'s `List<?>`). - **E.** Live dogfood + wiki as-built. ### Validation risks (dogfood, not assumed) - opencode TUI ⇄ herdr keystroke injection + CB-306 `injectable` status recognition. - Bridge MCP visibility inside opencode (`bridge_reply` must be callable, else the reply-charter is worthless). - opencode config/MCP schema drift (fast-moving; pin against installed version). - Provider credentials distinct from the primary's subscription. ### Open questions 1. Which provider/model does this host have credentials for (distinct from the primary's subscription)? 2. Charter carrier: generated `OPENCODE_CONFIG` `instructions` (recommended) vs `AGENTS.md` in worktree? 3. Merge cadence: hold all of Stage B on a feature branch (per CB-401 "major" bar), or land the behaviour-preserving base-extraction to `main` first? Out of scope (Stage C / later): dynamic external plugin loading behind a trust model; capability *enforcement* at the verb layer; human/mock peer.
Owner

Live dogfood complete (2026-07-29) — closing

The implementation merged in ded226a, but increment 5 (the §5 live checklist) was deferred at merge time. It has now run against bridged at main 19cdf8d with opencode 1.18.5.

The open question is resolved — no credentials were needed

§7 Q1 asked which provider this host has credentials for. Answer: none, and none are required. opencode's own gateway serves free-tier models with zero credentials — opencode auth list reports 0 credentials, yet opencode run -m opencode/north-mini-code-free answers. The dogfood profile uses that model. It is distinct from the primary's Anthropic subscription by construction, and needs no guard allowlist entry, since opencode carries no ANTHROPIC_BASE_URL and the SubscriptionGuard never applies to it.

The schema-drift risk was the real one, and it did not bite

This adapter was designed against opencode 1.1.31; installed is 1.18.5. The generated config still validates unchanged — mcp.<name>.type: "remote", url, enabled, and instructions: [path] are all accepted, and OPENCODE_CONFIG=… opencode mcp list reports ✓ bridge connected. Pinned as a verified fact for 1.18.5 in the doc.

§5 checklist

Risk Result
opencode TUI ⇄ herdr injection; CB-306 gate ✅ peer pane=wD:p3 reached injectable state, ~0.6s after agent.start
Bridge MCP visible + bridge_reply callable ✅ MCP initialize from Implementation[name=opencode, version=1.18.5]; worker replied via the tool
Config schema drift ✅ unchanged (above)
Provider credentials ✅ free tier, zero credentials

Full lifecycle, through the REST surface

  1. POST /workers?profile=opencode-free → 201; kind: routed through CompositePeerLauncher to OpenCodeLauncher (spawning opencode profile=opencode-free), pane wD:p3.
  2. Readiness {"ready":true,"status":"idle"}, roster state ready.
  3. POST /sessions/{id}/message → {"replySource":"reply","reply":"391"} — a structured bridge_reply, not the CB-115 completion-fallback transcript scrape.
  4. DELETE /workers/wD:p3 → 204, roster empty, tolerant teardown (tab_not_found ignored; opencode had already closed its own tab).

Unplanned cross-validation with CB-501

The new audit trail recorded the reply as role: WORKER, actor: worker:term_657c1dad2b9731e, action: REPLY, outcome: allowed. Connection-based identity (loopback peer PID → herdr pane) classified an opencode process as a worker with no opencode-specific handling — so the identity model is peer-kind-agnostic, which is what CB-308 needs when the roster stretches across hosts.

CB-502 counters for the run: bridged_sends_total{outcome="replied"} 1, bridged_replies_total{path="rendezvous"} 1, bridged_inbox_depth{...} 0.

Stage B is done. Stage C (dynamic external plugin loading) remains future work, gated by the trust/capability model.

## Live dogfood complete (2026-07-29) — closing The implementation merged in `ded226a`, but increment 5 (the §5 live checklist) was deferred at merge time. It has now run against `bridged` at main `19cdf8d` with **opencode 1.18.5**. ### The open question is resolved — no credentials were needed §7 Q1 asked which provider this host has credentials for. Answer: **none, and none are required.** opencode's own gateway serves free-tier models with zero credentials — `opencode auth list` reports *0 credentials*, yet `opencode run -m opencode/north-mini-code-free` answers. The dogfood profile uses that model. It is distinct from the primary's Anthropic subscription by construction, and needs no `guard` allowlist entry, since opencode carries no `ANTHROPIC_BASE_URL` and the `SubscriptionGuard` never applies to it. ### The schema-drift risk was the real one, and it did not bite This adapter was designed against opencode **1.1.31**; installed is **1.18.5**. The generated config still validates unchanged — `mcp.<name>.type: "remote"`, `url`, `enabled`, and `instructions: [path]` are all accepted, and `OPENCODE_CONFIG=… opencode mcp list` reports `✓ bridge connected`. Pinned as a verified fact for 1.18.5 in the doc. ### §5 checklist | Risk | Result | |---|---| | opencode TUI ⇄ herdr injection; CB-306 gate | ✅ `peer pane=wD:p3 reached injectable state`, ~0.6s after `agent.start` | | Bridge MCP visible + `bridge_reply` callable | ✅ MCP `initialize` from `Implementation[name=opencode, version=1.18.5]`; worker replied via the tool | | Config schema drift | ✅ unchanged (above) | | Provider credentials | ✅ free tier, zero credentials | ### Full lifecycle, through the REST surface 1. `POST /workers?profile=opencode-free` → `201`; `kind:` routed through `CompositePeerLauncher` to `OpenCodeLauncher` (`spawning opencode profile=opencode-free`), pane `wD:p3`. 2. Readiness `{"ready":true,"status":"idle"}`, roster state `ready`. 3. `POST /sessions/{id}/message` → **`{"replySource":"reply","reply":"391"}`** — a *structured* `bridge_reply`, not the CB-115 completion-fallback transcript scrape. 4. `DELETE /workers/wD:p3` → `204`, roster empty, tolerant teardown (`tab_not_found` ignored; opencode had already closed its own tab). ### Unplanned cross-validation with CB-501 The new audit trail recorded the reply as `role: WORKER, actor: worker:term_657c1dad2b9731e, action: REPLY, outcome: allowed`. Connection-based identity (loopback peer PID → herdr pane) classified an **opencode** process as a worker with no opencode-specific handling — so the identity model is peer-kind-agnostic, which is what CB-308 needs when the roster stretches across hosts. CB-502 counters for the run: `bridged_sends_total{outcome="replied"} 1`, `bridged_replies_total{path="rendezvous"} 1`, `bridged_inbox_depth{...} 0`. Stage B is done. Stage C (dynamic external plugin loading) remains future work, gated by the trust/capability model.
kevin closed this issue 2026-07-29 17:48:52 +02:00
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#7