Files
fleetd/docs/CB-402-OpenCode-Adapter.md
T
kevin 2773ab600d CB-402: live dogfood complete — Stage B verified against opencode 1.18.5
Closes the one known-unverified item before cross-host. CB-402 merged in
ded226a with increment 5 (the §5 live checklist) deferred; it has now run.

Provider question (§7 Q1) resolved with no credentials needed: opencode's own
gateway serves free-tier models. `opencode auth list` reports 0 credentials,
yet `opencode run -m opencode/north-mini-code-free` answers. Distinct from the
primary's subscription by construction, and needs no guard entry — opencode
carries no ANTHROPIC_BASE_URL, so SubscriptionGuard never applies to it.

The schema-drift risk was the real one and it did not bite. The adapter was
designed against opencode 1.1.31; installed is 1.18.5. The generated config
still validates unchanged (type:"remote" + instructions:[path]), and
`OPENCODE_CONFIG=… opencode mcp list` reports the bridge connected. Pinned as
a verified fact for 1.18.5.

Full lifecycle through REST: spawn (201, kind-routed to OpenCodeLauncher) ->
CB-306 gate passed ~0.6s -> ready -> send -> {"replySource":"reply"} (a
STRUCTURED bridge_reply, not the CB-115 completion fallback) -> delete (204,
tolerant teardown).

Unplanned cross-validation with CB-501: the audit trail recorded the reply as
role=WORKER actor=worker:term_657c… — connection-based identity classified an
opencode process as a worker with no opencode-specific handling. The identity
model is peer-kind-agnostic, which is what CB-308 needs when the roster
stretches across hosts.

Stage 5 verified live on the same run: /workers (CB-304) answers where the
13-day-old daemon 404'd, /metrics counted the delegation
(sends_total{outcome=replied} 1, replies_total{path=rendezvous} 1,
inbox_depth 0), and the audit log captured SPAWN/SEND/REPLY with correct roles.

Adds the opencode-free dogfood profile to the local bridged.yaml (gitignored;
recorded here for reproducibility) and docs/CB-402 §8 as-built.
2026-07-29 22:49:15 +07:00

306 lines
16 KiB
Markdown

# 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](#8-as-built--live-dogfood-2026-07-29) 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`](../bridged/src/main/java/dev/ltms/bridged/peer/PeerLauncher.java) 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:
```mermaid
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.
```mermaid
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):
```java
/** 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`:
```java
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.
`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`, *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
- `Bridged.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 `BridgeMcp` + `BridgedApp` 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.
```mermaid
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: bridge_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 + `bridged.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, `bridge_send` a trivial task.
- **Bridge MCP visibility in opencode.** Confirm `OPENCODE_CONFIG` (or `opencode mcp add`)
actually surfaces the `bridge_*` tools inside the opencode session, and that `bridge_reply`
is callable — the reply-charter is worthless if the tool isn't mounted. *Validation:* the
worker completes a task by calling `bridge_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 `BridgedConfigTest`; `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 `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 + `bridge_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*
`bridge_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: `bridged_sends_total{outcome="replied"} 1`,
`bridged_replies_total{path="rendezvous"} 1`, `bridged_inbox_depth{...} 0`.