diff --git a/docs/CB-402-OpenCode-Adapter.md b/docs/CB-402-OpenCode-Adapter.md new file mode 100644 index 0000000..88875ed --- /dev/null +++ b/docs/CB-402-OpenCode-Adapter.md @@ -0,0 +1,262 @@ +# CB-402 — Second peer adapter: opencode (Stage B of the Peer Launcher SPI) + +**Status:** design note (pre-implementation) +**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 ""` | 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---` | `opencode---` | 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)
tab-pane placement · spawn-ready gate · worktree
orphan reap · teardown · list · cwd resolution · unique naming"] + C["Claude-specific (per-agent)
ANTHROPIC_BASE_URL + SubscriptionGuard · ANTHROPIC_MODEL
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»
PeerLauncher"] + COMP["CompositePeerLauncher
routes by profile kind; fans out list/reap/caps"] + BASE["«abstract»
HerdrPeerLauncher
transport: placement · ready-gate · reap · stop · cwd · naming"] + CCL2["ClaudeCodeLauncher
hooks: guard+ANTHROPIC_* env · --mcp-config · charter flag · prefix 'claude'"] + OCL["OpenCodeLauncher
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 env, List 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=` 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 ` (interactive TUI, the mode a herdr pane drives), plus + `-m ` 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 for the opencode dogfood profile** — which provider/model does this host have + credentials for that is distinct from the primary's subscription? +2. **Charter carrier** — generated `OPENCODE_CONFIG` `instructions` (recommended) vs an + `AGENTS.md` in the worktree? +3. **Merge cadence** — hold the whole Stage B on a feature branch to merge as one "major" + unit (per CB-401), or land the behaviour-preserving base-extraction (increment 1) to `main` + first to shrink the branch?