Rename the eleven MCP tool names (bridge_ack/ask/list/poll/profiles/reply/ send/spawn/status/stop/whoami) to their fleet_* names across the Markdown documentation. fleet_* is written as the normal name; one deprecation note in README.md says bridge_* still works for one release. docs/MCP-Contract.md is renamed only inside section 6 (lines 210-293): sections 1-5 and 7-11 are stale pre-build design text (CB-609) and are deliberately left with old names so dead text does not look maintained. Also renames e2e/bridge_ask_transcript.md to e2e/fleet_ask_transcript.md to match its content. CLAUDE.md, wiki/, plugin/skills/setup/SKILL.md and .claude/skills/port-to-opencode/SKILL.md are owned by other units and are untouched.
16 KiB
CB-402 — Second peer adapter: opencode (Stage B of the Peer Launcher SPI)
Status: ✅ complete — implemented, merged (ded226a), and live-dogfooded 2026-07-29.
All five increments of §4 are done, including increment 5 (the §5 live checklist). See
§8 As-built for the run. Gitea issue #7 closed.
Depends on: CB-401 Stage A (PeerLauncher SPI, merged 3aa69a9)
Stage: 4 (Pluggable peers) · Stage B
Owner action: design-note → file issue → delegate → primary-verify (per CB-401/306/307)
1. Goal
Prove the PeerLauncher SPI
actually holds for a non-Claude coding agent by shipping a second, first-class in-tree
adapter: opencode (opencode 1.1.31, a provider-agnostic terminal coding agent).
The product direction is heterogeneous coding agents, Claude Code first-class — not a human/mock peer. opencode is the right proof precisely because it differs from Claude Code on every seam the SPI is meant to hide:
| Seam | Claude Code | opencode | ⇒ SPI proof |
|---|---|---|---|
| Subscription boundary | ANTHROPIC_BASE_URL + SubscriptionGuard.assertWorker() before any herdr call |
none — provider-agnostic, off-subscription by nature | the guard is Claude-private, not core |
| MCP mount | inline --mcp-config '{…}' launch flag |
opencode mcp add / config file (OPENCODE_CONFIG) — no inline flag |
"mount the bridge MCP" is adapter-private |
| Instruction injection | --append-system-prompt "<REPLY_CHARTER>" |
config instructions / AGENTS.md / --agent — no append flag |
the reply-charter mount is adapter-private |
| Model selection | ANTHROPIC_MODEL env |
-m provider/model flag |
env-vs-flag is adapter-private |
| Name / reap scheme | claude-<profile>-<nonce>-<seq> |
opencode-<profile>-<nonce>-<seq> |
each adapter reaps only its own kind |
Everything else — herdr tab/pane placement, the CB-306 spawn-readiness gate, CB-301-ext
worktree provisioning, CB-117 orphan reap, teardown, list(), cwd resolution — is transport
machinery that is identical for both. That split is the whole design.
Out of scope (deferred to Stage C / later): dynamic external plugin loading behind a trust/capability model, capability enforcement at the verb layer (Stage A only declares caps), and a
human/mock peer.
2. Current state — one launcher, two concerns mixed
ClaudeCodeLauncher (584 LOC) is the sole PeerLauncher. It interleaves two concerns:
flowchart TB
subgraph CCL["ClaudeCodeLauncher (584 LOC) — today"]
direction TB
T["herdr transport (GENERIC / reusable)<br/>tab-pane placement · spawn-ready gate · worktree<br/>orphan reap · teardown · list · cwd resolution · unique naming"]
C["Claude-specific (per-agent)<br/>ANTHROPIC_BASE_URL + SubscriptionGuard · ANTHROPIC_MODEL<br/>argv --mcp-config · --append-system-prompt REPLY_CHARTER · 'claude-' name prefix"]
end
classDef generic fill:#2f855a,stroke:#22543d,color:#ffffff;
classDef specific fill:#b7791f,stroke:#7b341e,color:#ffffff;
class T generic
class C specific
Figure 1 — the two concerns tangled inside today's single launcher; CB-402 splits them.
There is also a Stage-A deferral to finish: Bridged.main still casts
(ClaudeCodeLauncher) workers at the BridgeMcp and BridgedApp constructors. Those two
callers only invoke profiles(), defaultProfile(), and list() — all already on the
PeerLauncher interface. The cast survives for one reason only: PeerLauncher.list()
returns List<?> (element type erased) while the callers use Agent element methods in their
roster join. Finishing the migration is therefore small and contained (§4.D).
3. Target design
Template-Method base + two thin adapters + a routing composite that keeps the Stage-A seam
(one PeerLauncher reference held by SessionManager / BridgeMcp / BridgedApp) intact.
flowchart TB
IFACE["«interface»<br/>PeerLauncher"]
COMP["CompositePeerLauncher<br/>routes by profile kind; fans out list/reap/caps"]
BASE["«abstract»<br/>HerdrPeerLauncher<br/>transport: placement · ready-gate · reap · stop · cwd · naming"]
CCL2["ClaudeCodeLauncher<br/>hooks: guard+ANTHROPIC_* env · --mcp-config · charter flag · prefix 'claude'"]
OCL["OpenCodeLauncher<br/>hooks: provider env · OPENCODE_CONFIG file · AGENTS charter · prefix 'opencode'"]
IFACE -.implemented by.-> COMP
IFACE -.implemented by.-> BASE
BASE --> CCL2
BASE --> OCL
COMP -->|"kind=claude-code"| CCL2
COMP -->|"kind=opencode"| OCL
classDef iface fill:#2b6cb0,stroke:#1a365d,color:#ffffff;
classDef base fill:#2f855a,stroke:#22543d,color:#ffffff;
classDef leaf fill:#6b46c1,stroke:#44337a,color:#ffffff;
class IFACE,COMP iface
class BASE base
class CCL2,OCL leaf
Figure 2 — extracted base, two adapters, and a routing composite behind the unchanged SPI.
A. Extract HerdrPeerLauncher (abstract base)
Move all transport machinery down from ClaudeCodeLauncher. What stays generic:
- fields
agents,spaces,profiles,defaultProfile,env,nameSeq, the CB-306 gate knobs (spawnReadyTimeoutMs/spawnReadyPollMs/nowMillis/sleeper), andnameNonce; profiles(),defaultProfile(),parityOverlay(),effectiveCwd(…),resolveCwd;- the
spawn(SpawnRequest)skeleton: resolve profile → cfg → hook → placement → gate →WorkerHandle; spawnInTab/spawnAsPane/tidy/startUniquelyNamed(name built from a hook prefix);list(),reapOrphanWorkers()/isForeignWorker/workerNonce(pattern built from the prefix hook),stop()/usesTabPlacement/isAlreadyGone;waitUntilInjectableOrThrow, theWorkerHandlerecord,putIfPresent,resolveEnv,sleepUninterruptibly.
Two adapter hooks (abstract):
/** Label prefix for this peer kind; drives unique naming AND the orphan-reap pattern. */
protected abstract String namePrefix(); // "claude" | "opencode"
/** Build the peer-specific launch: env map + argv. Runs any pre-spawn guard here. */
protected abstract Launch buildLaunch(BridgedConfig.Worker cfg, SpawnRequest req);
record Launch(Map<String,String> env, List<String> argv) {}
capabilities() stays abstract/per-adapter (it already is). The subscription guard is not
a base field — it is a constructor dependency of ClaudeCodeLauncher alone.
Reap isolation: the reap pattern becomes Pattern.compile(namePrefix() + "-.*-([0-9a-f]{6})-\\d+"),
so the opencode adapter never reaps a claude-* pane and vice-versa. The composite sums both.
B. kind: config discriminator
Add one field to BridgedConfig.Worker:
String kind // "claude-code" (default) | "opencode"
- Compact-ctor default:
kind = blank ? "claude-code" : kind.toLowerCase(). argvdefault is currentlyList.of("claude"); whenkind=opencodeand the operator leftargvunset, default it toList.of("opencode"). (Handle in normalization, keyed offkind, so the record stays declarative.)- Keep the existing back-compat constructors;
kindis additive and optional.
bridged.example.yaml documents a two-kind workers: block.
C. OpenCodeLauncher — the adapter hooks for opencode
namePrefix() → "opencode". buildLaunch(cfg, req):
- Env: no
ANTHROPIC_BASE_URL, noSubscriptionGuardcall. Pass through provider credentials the operator names (reuse the existingtokenEnvindirection; opencode reads provider keys from env /opencode auth). CB-302 git-token injection is reused unchanged (it is peer-neutral:GITEA_TOKEN/GITEA_HOST). - MCP mount (non-invasive): opencode has no inline
--mcp-config. The adapter writes a throwaway config file and pointsOPENCODE_CONFIG=<tempfile>in the worker env, containing the bridge MCP server block (opencode HTTP MCP schema,type: "remote") — the opencode analog of Claude Code's inline flag. Nothing is written into the worker's real project or profile. - Reply-charter: carry
REPLY_CHARTERas aninstructionsentry in that same generated config (or anAGENTS.mdwritten into the per-worker worktree, which is already a throwaway isolated checkout under CB-301-ext). Recommend the config-file route to keep the "touch nothing the user owns" invariant. - argv:
opencode <project-or-cwd>(interactive TUI, the mode a herdr pane drives), plus-m <provider/model>when the profile sets a model.
REPLY_CHARTERis peer-neutral text — hoist it to a shared constant (base or a smallPeerCharter), consumed by each adapter through its own injection mechanism.
D. CompositePeerLauncher + finish the Stage-A migration
Bridged.maingroups configured profiles bykind, instantiates one launcher per kind present, and wraps them inCompositePeerLauncher implements PeerLauncher.- Routing methods (
spawn(req),effectiveCwd(req),parityOverlay(name)) dispatch by the profile's kind. Fan-out methods (list(),reapOrphanWorkers(),capabilities(),profiles(),defaultProfile()) merge across sub-launchers.stop(id)tries each (teardown only knows the pane id) — already best-effort/idempotent. - Migrate
BridgeMcp+BridgedAppto thePeerLauncherinterface, dropping both(ClaudeCodeLauncher)casts. Only friction islist()'sList<?>; resolve by giving the SPI a typed roster element (small neutralPeerAgentview exposingid()/name()/status) that the CB-304 roster join consumes — or, minimally, narrow at the callsite. Prefer the typed view.
sequenceDiagram
autonumber
participant P as Primary
participant M as BridgeMcp / REST
participant C as CompositePeerLauncher
participant O as OpenCodeLauncher
participant B as HerdrPeerLauncher (base)
participant H as herdr
P->>M: fleet_spawn(profile="oc-impl")
M->>C: spawn(SpawnRequest)
C->>C: kind(profile)=="opencode"
C->>O: spawn(req)
O->>O: buildLaunch → provider env + OPENCODE_CONFIG file + argv
O->>B: placement + startUniquelyNamed("opencode-…")
B->>H: agent.start(name, argv, env, tab, cwd)
B->>H: poll status until injectable (CB-306 gate)
B-->>O: Agent
O-->>C: PeerHandle(paneId, terminalId)
C-->>M: PeerHandle
M-->>P: session id
Figure 3 — an opencode spawn: composite routes by kind, adapter builds the peer-specific launch, shared base drives herdr + the readiness gate.
4. Increment plan (delegate-then-verify friendly)
- Extract base, no behaviour change. Introduce
HerdrPeerLauncher; makeClaudeCodeLauncherextend it withnamePrefix()="claude"andbuildLaunch()wrapping today's guard+env+argv logic. Green build, identical tests — pure refactor. (IDErefactorwhere possible; the primary re-runs the gate workers can't.) kind:discriminator. Add the field + normalization +bridged.example.yaml. Default path unchanged (kind=claude-code).OpenCodeLauncher. Implement the three hooks; unit-testbuildLaunch(env has noANTHROPIC_BASE_URL;OPENCODE_CONFIGpoints at a file carrying the bridge MCP block + charter; argv shape).CompositePeerLauncher+ wiring + finish Stage-A migration (drop the two casts).- Live dogfood (§5) + wiki as-built (primary-gated submodule commit).
Each increment is independently buildable/mergeable; the feature branch stays unmerged until it is "major" (Stage-B whole), per the CB-401 bar.
5. Risks & validation (live dogfood, not assumed)
- opencode TUI ⇄ herdr injection. herdr drives a pane by typing into a TUI. Must confirm
opencode's TUI accepts injected keystrokes/submit the way
claudedoes, and reaches aninjectablestatus the CB-306 gate recognizes. Validation: spawn one opencode worker, watch the readiness gate pass,fleet_senda trivial task. - Bridge MCP visibility in opencode. Confirm
OPENCODE_CONFIG(oropencode mcp add) actually surfaces thefleet_*tools inside the opencode session, and thatfleet_replyis callable — the reply-charter is worthless if the tool isn't mounted. Validation: the worker completes a task by callingfleet_reply; the reply lands via the CB-307 path. - opencode MCP/config schema drift. opencode is fast-moving (1.1.31 today). Pin the config
schema we generate against the installed version; treat the exact keys (
type: "remote"vs"http",instructionsshape) as a dogfood-verified fact, not an assumption. - Provider credentials. opencode needs a configured provider (env key or
opencode auth). The dogfood profile must name a provider the host actually has, distinct from the primary's subscription.
6. Test plan
- Unit (hermetic): base-extraction regression (existing
ClaudeCodeLaunchertests pass unchanged);OpenCodeLauncher.buildLaunchenv/argv/config assertions;kindnormalization inBridgedConfigTest;CompositePeerLauncherrouting + fan-out (merge ofprofiles(), summedreapOrphanWorkers(), per-kind reap isolation) with fake sub-launchers. - Live (dogfood, manual): the §5 checklist on the running daemon.
- Gate (primary): IDE diagnostics 0/0 on every changed file,
mvn clean installgreen with the surefire summary captured (not| tail), manual diff review — the authoritative checks a worker cannot self-run.
7. Open questions for the lead
- ✅ Provider — resolved 2026-07-29. None was needed. opencode's own gateway serves
free-tier models with zero credentials (
opencode auth list→ 0 credentials, yetopencode run -m opencode/north-mini-code-freeanswers). The dogfood profile usesopencode/north-mini-code-free. It is distinct from the primary's Anthropic subscription by construction, and needs noguardentry — opencode carries noANTHROPIC_BASE_URL, so theSubscriptionGuarddoes not apply to it at all. - ✅ Charter carrier — confirmed as designed: generated
OPENCODE_CONFIGinstructions. Verified working against the installed version. - ✅ Merge cadence — resolved as it happened: Stage B landed as one unit (
ded226a).
8. As-built — live dogfood (2026-07-29)
Run against bridged on 127.0.0.1:8766 at main 19cdf8d, with opencode 1.18.5 installed
via Homebrew. Every §5 risk is now a verified fact rather than an assumption.
The version-drift risk was the real one, and it did not bite. This adapter was designed against
opencode 1.1.31; the installed version is 1.18.5. The generated config schema 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 here
as a dogfood-verified fact for 1.18.5.
| §5 risk | Result |
|---|---|
| opencode TUI ⇄ herdr injection; CB-306 gate | ✅ peer pane=wD:p3 reached injectable state ~0.6s after agent.start |
Bridge MCP visible + fleet_reply callable |
✅ MCP initialize from Implementation[name=opencode, version=1.18.5]; worker replied through the tool |
| Config schema drift (1.1.31 → 1.18.5) | ✅ unchanged, see above |
| Provider credentials | ✅ free tier, zero credentials |
Full lifecycle exercised through the REST surface:
POST /workers?profile=opencode-free→201, routed bykind:throughCompositePeerLaunchertoOpenCodeLauncher(spawning opencode profile=opencode-free), panewD:p3.- Readiness:
{"ready":true,"status":"idle"}, roster stateready. POST /sessions/{id}/message→{"replySource":"reply","reply":"391"}— a structuredfleet_reply, not the CB-115 completion-fallback transcript scrape. The clean path.DELETE /workers/wD:p3→204, roster empty, tolerant teardown (tab_not_foundignored — opencode had already closed its own tab).
Unplanned cross-validation with CB-501. The audit trail recorded the worker's 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 — confirming the identity model is peer-kind-agnostic, which is exactly
what CB-308 needs when it stretches the roster across hosts.
CB-502 counters for the same run: bridged_sends_total{outcome="replied"} 1,
bridged_replies_total{path="rendezvous"} 1, bridged_inbox_depth{...} 0.