0189: OpenCode adapter — launchable workflow child plus plugin-delivered event evidence
Status: accepted (2026-09-20) · Scope: product tier (core/adapters, core/hooks, core/workflow, core/events/v3, commands/init, commands/doctor) plus a new opencode-plugin/ package
Context
Section titled “Context”Harnery supports three execution harnesses as both workflow adapters and event
adapters (ADR 0018, ADR 0125), and OpenClaw as an event-only adapter delivered
by a resident plugin (ADR 0164). OpenCode V2 (opencode CLI, v2.0.11 verified
on 2026-09-20) has emerged as a widely used harness, including a desktop app.
OpenCode V2 differs structurally from all four existing integrations:
- Client/server, not one-shot CLI. A persistent background service owns
sessions, config, plugins, permissions, and tool execution. The TUI, desktop
app,
opencode run, andopencode apiare all clients of that one server. Sessions persist in SQLite (session_v2,session_message,event), not in per-session.jsonltranscript files. - Lifecycle interception is plugin-only. OpenCode V2 has no
hooks.json-style command-hook mechanism. All interception is done by in-process TypeScript plugins (.opencode/plugins/*.tsoropencode.jsonplugins: []) that register hooks on a rich context object (ctx.session.hook,ctx.tool.hook,ctx.permission.hook,ctx.shell.hook,ctx.event.subscribe). Plugins run inside the server process, see every project session, and can spawn subprocesses (verified). - It is launchable headless.
opencode run --format jsonstreams NDJSON with a session id on every line and the final answer astextparts, so OpenCode is a real workflow-spawn backend, unlike OpenClaw. - It natively reads
AGENTS.mdand.agents/skills(and.claude/skills). It does not readCLAUDE.md. Harnery’s existing instruction and skills surfaces already reach OpenCode with no shim or mirror.
Without a native adapter, OpenCode users get none of harn init, harn doctor,
workflow child spawning, tool-use auditing, turn governance, or offline bench.
Alternatives considered
Section titled “Alternatives considered”- Event-only adapter (like OpenClaw). Rejected: OpenCode is launchable and
normalizable through
opencode run, so restricting it to the event axis would deny real workflow-spawn support that exists. - Bundle the V3 recorder into the OpenCode plugin (like OpenClaw’s
@harnery/openclaw). Rejected for the in-repo path: OpenClaw runs on a separate gateway host with no Harnery checkout, so it must carry the recorder. OpenCode shares the project checkout, so the plugin can shell out to the existingagent-hookbinary and reuse the entire parse → recorder → stop-ritual → prompt-context → guard pipeline with zero new recorder code. - Write a
hooks.jsonfor OpenCode. Rejected: OpenCode V2 has no such file; the only interception contract is the plugin API. - Coerce OpenCode into an existing adapter id. Rejected per ADR 0125: adapter identity fixes capability promises and drift checks.
- Enforce the Stop ritual by blocking the turn (like Claude Code
exit 2). Deferred: OpenCode has no non-zero-exit turn-block channel. The available re-prompt path issession.idle→ctx.session.synthetic/prompt, which can replace a completed answer if used carelessly. Sprint 1 declares Stop observe-only (as Codex, ADR 0051); enforced re-prompting is a follow-up once the idle→synthetic loop is proven safe.
Decision
Section titled “Decision”Add first-class "opencode" support on both axes.
1. Workflow adapter (core/adapters, core/workflow)
Section titled “1. Workflow adapter (core/adapters, core/workflow)”- Add
"opencode"to theAdapterunion (core/adapter.ts). - Register
BUILTIN_ADAPTER_PROFILES.opencode:binary: "opencode",installHint,loginHint: "opencode auth login",apiKeyEnv,modelFamily: "multi",effortValues: [](model selection isprovider/model#variant), nosandboxProjection. spawn-opencode.ts:opencode run --format json --model <m> --title <t> "<prompt>"; normalize NDJSON toSpawnResult(session id from any line, text from joinedtype:"text"parts). Registry entry + offline bench fixture.
2. Event adapter (core/events/v3)
Section titled “2. Event adapter (core/events/v3)”- Add
"opencode"toEVENT_ADAPTER_IDS_V3,BASE_SIGNAL_SUPPORT, andADAPTER_CAPABILITY_PROFILES_V3. The three prior profile digests stay byte-identical; the new id is additive, soharn initarchives the prior epoch and mints a new one on next run (expected, handled byeventLedgerV3RuntimeIssues).
3. Hook delivery via a Harnery-owned OpenCode plugin
Section titled “3. Hook delivery via a Harnery-owned OpenCode plugin”- Ship
opencode-plugin/(@harnery/opencode), a single self-contained source file thatinitcopies verbatim under a hash-stamped ownership header to.opencode/plugins/harnery/index.ts. The plugin registerssession.hook("prompt"),session.hook("context"),session.hook("compaction"),tool.hook("execute.before"),tool.hook("execute.after"),permission.hook("evaluate"),shell.hook("create.before"), andevent.subscribe, and bridges each toagent-hook <subcommand> --adapter opencode, translating plugin event objects into the payload shapeparse.tsexpects and returning hook stdout through the matching mutation channel (context injection, deny, synthetic message). Theshell.hookstampsOPENCODE_SESSION_IDinto every shell tool’s env so a coord CLI invoked as a Bash tool recovers its identity. harn init --adapter opencodeinstalls the plugin directory (.opencode/plugins/harnery/index.ts+harnery.jsonnaming theagent-hooklauncher) rather than writing a settings-file hook map, injects the managedAGENTS.mdregions, and skips the CLAUDE.md shim and the skills mirror because OpenCode readsAGENTS.mdand.agents/skillsnatively. OpenCode auto-discovers every directory under.opencode/plugins/, soopencode.jsonis never edited; a configured local plugin path must be a directory, and a configured-plus-discovered entry would load twice.harn init --check,harn doctor, and the wiring summary treat the owned entry file as the wiring: a hand edit or a stale launcher is drift, a foreign file at that path is an errorinitrefuses to overwrite.
4. Cross-cutting correctness (audit fixes forced by the new id)
Section titled “4. Cross-cutting correctness (audit fixes forced by the new id)”- Replace the ternary fall-throughs that would mislabel
opencodeas Codex (hooks/cli.tsplatform labels,lib/instructions/bundle.tselse-branch). - Give
guard/evaluator.tsandADAPTER_SKILLS_DIRexplicitopencodeentries so evidence is not silently rejected and skills install is not a silent no-op. - Add
OPENCODE_SESSION_IDtoSESSION_ID_ENV_VARS.
Result
Section titled “Result”-
harn adapter list/show opencode/bench opencodework offline. -
harn init --adapter opencodebootstraps an OpenCode project in one idempotent step (plugin +AGENTS.md), reusing native.agents/skills. -
harn run --adapter opencodelaunches a headless OpenCode child. -
OpenCode sessions (TUI, desktop app, headless) produce native V3 session, prompt, tool, and turn evidence through the plugin bridge. Prompt context rides the next model calls’ system prompt (
session.hook("context")) and is cleared at turn end; session-start context lands once as a synthetic message. -
The
shell.create.beforehook carries no session id, so the plugin keys the pending shell by its command string (recorded inexecute.before) and falls back to the last tool-calling session; a wrong stamp is possible only when two sessions run the same command concurrently. -
Verified live against OpenCode v2.0.11: one headless
opencode runin a plugin-wired project producedsession.started,turn.started,tool.requested,wait.started/wait.ended,tool.completed, andturn.completedwith zero diagnostics, the session-start context arrived as a synthetic message, and the tool shell carriedOPENCODE_SESSION_ID. The bridge runs inside the shared service, so a standalone consumer needsagent-hookon the PATH that service started with. -
Enforced Stop re-prompting and SQLite-backed context-window telemetry are tracked follow-ups;
emitStopOutcomereturns success without output foropencode, the same observe-only posture as Codex. ADR 0074 (Antigravity, accepted but unimplemented) is flagged for reconciliation; this ADR reuses its integration recipe. -
The cross-cutting fixes from § 4 landed on 2026-09-20: one shared platform-to-adapter normalizer next to the
Adapterunion replaced seven copies (three of which folded OpenCode into Claude Code), and the hook CLI and V3 producer now read per-adapter decisions fromsrc/core/hooks/adapter/behaviors/instead of adapter equality checks. -
The
bridge: "codex-wsl"literal in the V3 base contract was reviewed on 2026-09-20 as a candidate for an adapter-neutral bridge descriptor (a Windows-native harness driving a WSL workspace is a deployment shape, not a Codex property). It stays as it is. The literal appears in 43 places in the published schema and in every consumer’s recorded events, so widening it is a schema-digest change that rotates every V3 epoch, and no second adapter needs a bridge today: Claude Code, Cursor, and OpenCode all run natively inside the workspace shell. The per-adapter behavior modules now own the decision to stamp a bridge (bridgeFor), so a second bridged adapter can be added in code first and the schema generalized in one deliberate epoch when that adapter actually exists.
This decision follows the adapter registry in ADR 0018, the workflow/event adapter separation in ADR 0125, the resident-plugin precedent in ADR 0164, and the adapter integration recipe in ADR 0074.