Files
fleetd/docs/CB-402-OpenCode-Adapter.md
T
Dai Ha 2e138a199b CB-634: one shared "fleet" workspace + rename bridged -> fleetd cutover
Two changes ship together here.

1. One shared herdr workspace. The lead and every worker now live in one
   workspace called "fleet", so the operator sees one "session" with many
   windows, not two. Before, the lead sat in a "leads" workspace and workers
   in "bridged-workers", which read as two sessions. The lead is still told
   apart from workers by its exact tab label ("lead: <name>"), so putting them
   in one space is safe. LeadTabScanner keeps the exclude-by-label mechanism
   for split layouts; Fleetd now passes an empty exclude set.

2. Rename the daemon from "bridged" to "fleetd" (the binary, config, scripts,
   launchd/systemd units, module dir, and MCP mount).
   - Module dir bridged/ -> fleetd/; jar finalName -> fleetd.jar.
   - Log line, comments, docs, and CLAUDE.md updated to say fleetd.
   - Scripts renamed: redeploy-bridged.sh -> redeploy-fleetd.sh,
     bridged-launchd-wrapper.sh -> fleetd-launchd-wrapper.sh.
   - Deploy units renamed: dev.ltms.bridged.plist -> dev.ltms.fleetd.plist,
     bridged.service -> fleetd.service; launchd Label -> dev.ltms.fleetd.
   - Config default bridged.yaml -> fleetd.yaml; the legacy bridged.yaml is
     still read as a fallback, and still gitignored.
   - MCP: drop the deprecated bridge_* tool twins; only fleet_* remain. The
     server name is "fleet". The mount name in the local .mcp.json becomes
     "fleet" (gitignored, not in this commit).
   - Env var defaults BRIDGED_API_TOKEN -> FLEETD_API_TOKEN, fixture
     BRIDGED_WORKER_TOKEN -> FLEETD_WORKER_TOKEN.

Kept on purpose: the BRIDGED_MEMBER marker. Renaming it is a coupled change to
the credential-scrub security control (an operator secrets.sh may guard on it),
so it stays until that migration is done on its own.

Metrics were already fleet_* (CB-632); MetricNamesTest still guards that no
name says bridged_.

The canonical CLAUDE.md block and the wiki template stay byte-identical
(wiki working tree edited, committed to the wiki repo separately).

949 tests pass (mvn clean install). 4 fewer than before = the 4 removed
bridge_* alias tests.
2026-08-25 04:01:08 +02:00

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), and nameNonce;
  • 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, the WorkerHandle record, 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().
  • argv default is currently List.of("claude"); when kind=opencode and the operator left argv unset, default it to List.of("opencode"). (Handle in normalization, keyed off kind, so the record stays declarative.)
  • Keep the existing back-compat constructors; kind is additive and optional.

fleetd.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, no SubscriptionGuard call. Pass through provider credentials the operator names (reuse the existing tokenEnv indirection; 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 points OPENCODE_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_CHARTER as an instructions entry in that same generated config (or an AGENTS.md written 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_CHARTER is peer-neutral text — hoist it to a shared constant (base or a small PeerCharter), consumed by each adapter through its own injection mechanism.

D. CompositePeerLauncher + finish the Stage-A migration

  • Fleetd.main groups configured profiles by kind, instantiates one launcher per kind present, and wraps them in CompositePeerLauncher 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 + FleetApp to the PeerLauncher interface, dropping both (ClaudeCodeLauncher) casts. Only friction is list()'s List<?>; resolve by giving the SPI a typed roster element (small neutral PeerAgent view exposing id()/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)

  1. Extract base, no behaviour change. Introduce HerdrPeerLauncher; make ClaudeCodeLauncher extend it with namePrefix()="claude" and buildLaunch() wrapping today's guard+env+argv logic. Green build, identical tests — pure refactor. (IDE refactor where possible; the primary re-runs the gate workers can't.)
  2. kind: discriminator. Add the field + normalization + fleetd.example.yaml. Default path unchanged (kind=claude-code).
  3. OpenCodeLauncher. Implement the three hooks; unit-test buildLaunch (env has no ANTHROPIC_BASE_URL; OPENCODE_CONFIG points at a file carrying the bridge MCP block + charter; argv shape).
  4. CompositePeerLauncher + wiring + finish Stage-A migration (drop the two casts).
  5. 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 claude does, and reaches an injectable status the CB-306 gate recognizes. Validation: spawn one opencode worker, watch the readiness gate pass, fleet_send a trivial task.
  • Bridge MCP visibility in opencode. Confirm OPENCODE_CONFIG (or opencode mcp add) actually surfaces the fleet_* tools inside the opencode session, and that fleet_reply is callable — the reply-charter is worthless if the tool isn't mounted. Validation: the worker completes a task by calling fleet_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", instructions shape) 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 ClaudeCodeLauncher tests pass unchanged); OpenCodeLauncher.buildLaunch env/argv/config assertions; kind normalization in FleetConfigTest; CompositePeerLauncher routing + fan-out (merge of profiles(), summed reapOrphanWorkers(), 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 install green with the surefire summary captured (not | tail), manual diff review — the authoritative checks a worker cannot self-run.

7. Open questions for the lead

  1. ✅ Provider — resolved 2026-07-29. None was needed. opencode's own gateway serves free-tier models with zero credentials (opencode auth list → 0 credentials, yet opencode run -m opencode/north-mini-code-free answers). The dogfood profile uses opencode/north-mini-code-free. It is distinct from the primary's Anthropic subscription by construction, and needs no guard entry — opencode carries no ANTHROPIC_BASE_URL, so the SubscriptionGuard does not apply to it at all.
  2. ✅ Charter carrier — confirmed as designed: generated OPENCODE_CONFIG instructions. Verified working against the installed version.
  3. ✅ 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:

  1. POST /workers?profile=opencode-free → 201, routed by kind: 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 fleet_reply, not the CB-115 completion-fallback transcript scrape. The clean path.
  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 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: fleet_sends_total{outcome="replied"} 1, fleet_replies_total{path="rendezvous"} 1, fleet_inbox_depth{...} 0.