Skip to content

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

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:

  1. Client/server, not one-shot CLI. A persistent background service owns sessions, config, plugins, permissions, and tool execution. The TUI, desktop app, opencode run, and opencode api are all clients of that one server. Sessions persist in SQLite (session_v2, session_message, event), not in per-session .jsonl transcript files.
  2. 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/*.ts or opencode.json plugins: []) 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).
  3. It is launchable headless. opencode run --format json streams NDJSON with a session id on every line and the final answer as text parts, so OpenCode is a real workflow-spawn backend, unlike OpenClaw.
  4. It natively reads AGENTS.md and .agents/skills (and .claude/skills). It does not read CLAUDE.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.

  • 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 existing agent-hook binary and reuse the entire parse → recorder → stop-ritual → prompt-context → guard pipeline with zero new recorder code.
  • Write a hooks.json for 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 is session.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.

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 the Adapter union (core/adapter.ts).
  • Register BUILTIN_ADAPTER_PROFILES.opencode: binary: "opencode", installHint, loginHint: "opencode auth login", apiKeyEnv, modelFamily: "multi", effortValues: [] (model selection is provider/model#variant), no sandboxProjection.
  • spawn-opencode.ts: opencode run --format json --model <m> --title <t> "<prompt>"; normalize NDJSON to SpawnResult (session id from any line, text from joined type:"text" parts). Registry entry + offline bench fixture.
  • Add "opencode" to EVENT_ADAPTER_IDS_V3, BASE_SIGNAL_SUPPORT, and ADAPTER_CAPABILITY_PROFILES_V3. The three prior profile digests stay byte-identical; the new id is additive, so harn init archives the prior epoch and mints a new one on next run (expected, handled by eventLedgerV3RuntimeIssues).

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 that init copies verbatim under a hash-stamped ownership header to .opencode/plugins/harnery/index.ts. The plugin registers session.hook("prompt"), session.hook("context"), session.hook("compaction"), tool.hook("execute.before"), tool.hook("execute.after"), permission.hook("evaluate"), shell.hook("create.before"), and event.subscribe, and bridges each to agent-hook <subcommand> --adapter opencode, translating plugin event objects into the payload shape parse.ts expects and returning hook stdout through the matching mutation channel (context injection, deny, synthetic message). The shell.hook stamps OPENCODE_SESSION_ID into every shell tool’s env so a coord CLI invoked as a Bash tool recovers its identity.
  • harn init --adapter opencode installs the plugin directory (.opencode/plugins/harnery/index.ts + harnery.json naming the agent-hook launcher) rather than writing a settings-file hook map, injects the managed AGENTS.md regions, and skips the CLAUDE.md shim and the skills mirror because OpenCode reads AGENTS.md and .agents/skills natively. OpenCode auto-discovers every directory under .opencode/plugins/, so opencode.json is 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 error init refuses 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 opencode as Codex (hooks/cli.ts platform labels, lib/instructions/bundle.ts else-branch).
  • Give guard/evaluator.ts and ADAPTER_SKILLS_DIR explicit opencode entries so evidence is not silently rejected and skills install is not a silent no-op.
  • Add OPENCODE_SESSION_ID to SESSION_ID_ENV_VARS.
  • harn adapter list/show opencode/bench opencode work offline.

  • harn init --adapter opencode bootstraps an OpenCode project in one idempotent step (plugin + AGENTS.md), reusing native .agents/skills.

  • harn run --adapter opencode launches 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.before hook carries no session id, so the plugin keys the pending shell by its command string (recorded in execute.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 run in a plugin-wired project produced session.started, turn.started, tool.requested, wait.started/wait.ended, tool.completed, and turn.completed with zero diagnostics, the session-start context arrived as a synthetic message, and the tool shell carried OPENCODE_SESSION_ID. The bridge runs inside the shared service, so a standalone consumer needs agent-hook on the PATH that service started with.

  • Enforced Stop re-prompting and SQLite-backed context-window telemetry are tracked follow-ups; emitStopOutcome returns success without output for opencode, 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 Adapter union replaced seven copies (three of which folded OpenCode into Claude Code), and the hook CLI and V3 producer now read per-adapter decisions from src/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.