Part of #145 (CB-632). Documentation only, plus one internal literal. Unit 1 renamed the package and classes, which left every doc describing classes that no longer exist. This fixes the prose across README.md, docs/ and bridged/docs/ -- 18 files. Renamed: dev.ltms.bridged -> dev.ltms.fleet, the five class names, and "bridged" where it names the daemon as a product rather than a path. Also renamed two literals, because a doc that disagrees with the code is worse than one that is out of date: - bridged-local-noauth -> fleetd-local-noauth. A placeholder apiKey OpenCodeLauncher sends when a profile resolves no token, to a local endpoint that does not check it. No test asserts the old string. - the vnd.ltms.bridged.* media type in the M4 design doc. It appears in no Java file, so nothing implements it yet. Deliberately NOT renamed, because each is still literally true today and changes only at the cutover: - paths: bridged/, bridged.yaml, bridged.example.yaml, bridged.jar, .bridged-worktrees, deploy/dev.ltms.bridged.plist, scripts/redeploy-bridged.sh, bridged-launchd-wrapper.sh - bridged_* metric names -- renaming these after the monitoring is wired would break dashboard continuity, so they move before it is - bridge_* MCP tool names, which answer alongside fleet_* on purpose - BRIDGED_* environment variables, read by a file outside this repo Method note: perl, not sed. BSD sed has no \b and no lookaround, and a word-boundary expression there fails silently. The prose replace uses (?<![\w./-])bridged(?![\w./-]) so it cannot touch a path or an identifier, then every remaining hit was read by hand. Verified: mvn clean install green, 51 classes, 878 tests, 0 failures.
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: Fleetd.main still casts
(ClaudeCodeLauncher) workers at the FleetMcp and FleetApp 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 / FleetMcp / FleetApp) 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(FleetConfig.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 FleetConfig.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
Fleetd.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
FleetMcp+FleetAppto 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 FleetMcp / 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 inFleetConfigTest;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 fleetd 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.