Repository navigation
Feature: Opencode Plugin
The opencode plugin supports both opencode lines from one npm package: the 1.18.x plugin API and the 2.x plugin API. The user's setup (shim file, config key, DB, skills) is identical under either host.
src/index.ts dual entry -- merged default export { id, setup, server }
│ opencode v1 reads default.server; v2 reads
│ default.setup and strips excess keys. Both loaders
│ verified against tagged upstream source.
├── (lazy) → src/opencode/v1.ts v1 adapter (@opencode-ai/plugin)
└── (lazy) → src/opencode/v2.ts v2 adapter (@opencode/plugin)
both delegate to:
src/runtime.ts shared runtime (nudges, system prompt,
session events, extraction, bookkeeping)
↑ consumes
src/capabilities.ts HostCapabilities seam
src/index.ts's static import graph must stay SDK-free. Each adapter
imports its host's SDK (@opencode-ai/plugin vs @opencode/plugin), and the
SDKs resolve only under their own host's install, so the entry reaches both
adapters exclusively through dynamic import(). A top-level await would make
either host's load block on (or fail from) the other host's adapter. The pure
helpers (osProcessArgs, startupSessionId, ...) live in src/os-args.ts,
which is safe to re-export statically.
Upstream loaders decide everything:
-
v1 (
packages/opencode/src/plugin/shared.ts,readV1Plugin, identical across 1.18.0-1.18.32): reads onlymod.default; usesdefault.serverif present and never reaches the named-export ("legacy") fallback. Excess keys (likesetup) are invisible to it. A default export withidbut noserverthrows and the host SILENTLY SKIPS the plugin (error-publish is commented out in the upstream caller). -
v2 (
packages/core/src/plugin/module.ts@ v2.0.9): decodesdefaultas{ id, effect }or{ id, setup }with Effect Schema; excess keys (likeserver) are tolerated and stripped (verified empirically at effect@4.0.0-rc.112, the version v2 pins).
So the merged object { id, setup, server } satisfies both. A module with a
named server export plus a v2-shaped default does NOT work on v1 -- that
shape throws in readV1Plugin.
HostCapabilities (src/capabilities.ts) is the interface of host operations
the shared runtime needs. It is the lifecycle-level sibling of CoreContext
(src/tool-defs.ts, the per-tool-call seam); they stay separate on purpose.
| Capability | v1 source | v2 source | v2 strategy |
|---|---|---|---|
| bus events |
event hook |
event.subscribe (raw SSE) |
client-side location filter; location-less events resolve via session.get ({sessionID} input) and drop unresolvable (mirrors v1's server-side filter); events for plugin-created extraction children pass by ID (they live in the project directory, which differs from the instance directory on below-root launches) |
| incoming message |
chat.message hook |
session.hook("prompt") |
the hook awaits the runtime; nudge injections append to the OUTBOUND request's user message whose text matches the entry's stored prompt, in the context hook (v2 wire Message carries parts in content, not parts). Entries form a per-session set (cap 3, oldest evicted) so a prompt queued mid-turn keeps its own entry instead of evicting the running turn's; a prompt whose nudge computation comes up empty clears the session's whole set, so the running turn's remaining rounds lose their block in that one case (smaller than the staleness it prevents: an entry left behind would match its own prompt text in later requests' session history and replay a stale block there) |
| system prompt | experimental.chat.system.transform |
session.hook("context") |
mutates the request's system array; the runtime's plain strings convert to {type: "text", text} SystemPart objects at the boundary |
| compaction |
experimental.session.compacting + .autocontinue + session.compacted
|
session.hook("compaction") |
flag lands; context-injection surface unverified |
| wrap-up commands |
command.execute.before + command files |
command.transform + CommandEditor.add
|
registered in code: execute arms the greenlight check (onCommandExecuteBefore) and delivers the same prompt body the v1 file carries, substituting the invocation's prompt text for $ARGUMENTS (v2's session.prompt does no template expansion); the runtime installs only ACTION command files on v2 and REMOVES stale wrap-up files from earlier v1 runs (a file and a registered command with the same name would collide) |
| tools |
hooks.tool map via tool()
|
tool.transform + ToolEditor.add
|
zod shapes pre-converted to JSON Schema with our own zod (v2's instanceof $ZodType detection fails for ours and drops the schema); results wrap as { content }
|
| tool buffering |
tool.execute.after hook |
tool.hook("execute.after") |
wired: feeds the same extraction buffer; Tool.Result.content is flattened (string passes through, content-part arrays contribute their text parts); the title is derived from the tool name and args (deriveTitle), since v2's Tool.Result.output is a typed output value, not a title |
| noReply deliveries |
promptAsync with noReply
|
none | gated off via HostCapabilities.noReplyDelivery -- chat echoes and the session-start reminder are skipped on v2 (delivering them would start real model turns: a feedback loop) |
| synthetic wake deliveries |
promptAsync with synthetic parts |
session.synthetic endpoint |
watcher + chat wake nudges route to v2's synthetic endpoint (TUI-hidden), matching v1 |
| child sessions | client.session.create/promptAsync/prompt/delete |
session.create/prompt |
create+prompt supported (a missing id throws into the extraction fallback); delete rides session.remove (upstream #52387, in v2.0.22), runtime-guarded like compact -- an older host keeps the documented gap (the session picker accumulates one thatch-extraction entry per extraction, and -c "continue last session" logic that picks the newest top-level session will land in an extraction child after any session that triggered extraction) |
| session status | client.session.status |
none | returns {}; the wake gate treats unknown as idle and the event-fed status map does the gating |
| session messages | client.session.messages |
session.context |
mapped into the v1 {info: {role}, parts} shape, so the wrap-up greenlight check works (the promise domain has no message accessor) |
| session list | client.session.list |
none | degrade (-c resume listing loses its data source) |
| compaction trigger | client.tui.executeCommand("session_compact") |
session.compact({sessionID}) |
wired behind a runtime guard: upstream #52385 exposed session.compact on the promise domain (in the installed v2.0.25), newer than the pinned dev types - an older host degrades to a logged no-op (the wrap-up checklist and flush still run, the automatic compaction is skipped) |
| toasts | client.tui.showToast |
toast rpc event | wired: every runtime toast call site (alerts, extraction metrics, chat registration, blocked wrap-ups, watcher rearm notices) emits the bridge's toast event, which the ./tui plugin shows via ui.toast.show; the pump never translates it (TUI-only by nature). Best-effort by contract: a TUI-less environment or an rpc-less SDK floor shows nothing, silently |
| wrap-up exit | client.tui.publish |
exit-tab-closed rpc event | the wrap-up exit records the session's watcher deaths synchronously (the runtime, before the chat unregister - sessionDied places the death row by the chat row, so the order is load-bearing) and then emits rpc.thatch-tabs.exit-tab-closed, a TUI-ONLY close of the session's OWN tab (the ./tui plugin's ui.tabs.close): the tab-scoped semantic upstream anomalyco/opencode#50984 asks for, since the daemon hosts every tab and app.exit would take down unrelated sessions. The pump deliberately never translates this event (a second death pass would erase the durable death row the first one wrote; the session_tab_close tool's tab-closed is the only death-translating event). Degrades to a logged no-op without the rpc surface; a TUI with no tab for the session (headless run, tabs off) never acts - the event is ephemeral |
| session tabs | n/a | rpc event bridge |
thatch_session_tab (v2Only) spawns a detached subordinate session (create -> move -> chat-register -> emit rpc.thatch-tabs.tab-opened -> prompt); the ./tui CLI plugin consumes the event and calls ui.tabs.open (open-without-focus, idempotent). thatch_session_tab_close emits rpc.thatch-tabs.tab-closed {sessionID, chatName} - the pump translates it into the runtime's confirmed-death path. The registration's third event, exit-tab-closed (the wrap-up exit's), is TUI-only - see the wrap-up exit row |
| tab close | n/a | no session.deleted on v2 tab close | bounded 2026-10-07: watcher death detection cancels a closed tab's watchers once its pending events age past THATCH_WATCH_DEATH_MINUTES (default 120) while deliveries throw 404-class errors, and announces the death to live same-project sessions; the roster row itself still lingers until the reaper. NOTE: an earlier version of this row claimed deliveries "fail their idle gate" - wrong: the v2 gate opens (the status stub reads as idle) and the PROMPT fails; same leak, different link |
| wake gate after reload |
session.status event map |
map rehydrates empty | known gap: v2's fetchStatuses stub is structural (the promise context has no session.status), so in the reload window - until the session's next status event - the proactive-prompt gate is fully open |
| dispose |
dispose hook |
cleanup returned from setup
|
must be idempotent: v2 auto-reloads plugins on file change, and a non-idempotent cleanup doubles pollers/pumps/nudges. Volatile runtime state (extraction buffer + accepted entries, child bookkeeping, wrap-up arms, watcher definitions) is journaled to the runtime_state table - instance-scoped by the writing location's directory - and rehydrated on the next setup(): rows from the same process AND directory (a reload) all restore; child rows carry a kind marker (extraction vs task-dispatched sub-agent, defaulting to extraction for pre-marker rows) so only extraction children restore the extracting/extractionChildren sets; rows from a dead process are pruned unless the session is the -c/-s startup resume, which inherits recovery-safe state only (buffer requeued, no live child bookkeeping - the child died with the old process). Exception: a dead process's watcher definitions are kept as DORMANT rows (no polling, no delivery) instead of pruned - resuming the session re-arms them on its first message (a chat.message scan: own rows re-arm, same-directory rows of still-dead sessions earn the live session a one-line death notice gated on the owning pid being dead, rows past the watcher TTL plus a 24h grace age out). Restored sessions on v1 get a synthetic noReply re-attach notice (v2 has no turn-free delivery and skips it). The chat poller re-hosts the instance's journaled hosted set on reload, so pending chat mail wakes sessions the reload left asleep and heartbeats keep the reaper from reaping live registrations. The embedding model is refcounted per db path, so N location instances share one resident model |
The v2 adapter was written against tagged source (v2.0.9) before a live binary was available, then verified against a real v2.0.15 binary on 2026-09-23: merged-default loading, tool schemas (via pre-converted JSON Schema - see the gotcha), event shapes, the execution-lifecycle idle signal, synthetic wake delivery, and command registration all work; the full QA suite runs green against a v2 serve.
Still unverified against the live binary (static analysis only):
- Whether
session.hook("prompt")fires forsession.syntheticinbox items. If it does not,pendingInjectionsfrom the prior real turn would be appended to the synthetic turn's outbound request. Structurally bounded 2026-10-12: eachpendingInjectionsentry is bound to the prompt text it was computed from, and the context hook injects it only into a request user message whose joined text equals that prompt - a replay onto any other message (synthetic turn, missed prompt hook, failedonChatMessage) is skipped. Text equality is the tightest binding available, not an airtight one: an identical prompt re-submitted after a missed prompt hook still matches and replays; the next prompt-hook run that fires is the recovery. The prompt-time block still re-fires on every round of its OWN turn (v1's persistent-synthetic-part semantics), and the block carries a snapshot footer stating the labels are a submission-time snapshot and pointing at the live re-check tool. - Whether
SessionGenerate.messagesare fresh objects per model call within a turn. The context hook appends injections to the matched message's content, which changes the text the binding check compares. With fresh objects per call (what the tests assume), the prompt-time block re-fires on every round of its own turn. With reused objects, round 1's injections would change the matched message's text, so the binding check would skip rounds 2 and on - within-turn replay would break after round 1 instead of duplicating. - Entries outliving their session:
pendingInjectionsis keyed by session id and never swept on session end. Bounded - a per-session set capped at 3 entries, and the map itself dies with the adapter activation - but a session's entries do persist after the session is gone, pre-existing. - Cross-directory hook dispatch scope: the event pump filters events by
directory, but the session hooks (
prompt,context,compaction) have no such filter - whether a hook can fire for another directory's session is unverified, pre-existing. - An npm-installed copy (not the dev checkout, which has devDependencies present) loading under v2 - the isolation rule's real-world proof.
The wrap-up exit closes the session's own tab over the session-tab rpc
bridge - the tab-scoped close upstream
anomalyco/opencode#50984
asks for. Known degrade: a TUI with no tab for the session (headless run,
tabs off) never acts - the event is ephemeral, nobody is listening.
Install opencode-v2 (brew formula anomalyco/tap/opencode-v2; conflicts
with the v1 formula's binary name, so it cannot coexist) and re-run the
QA live suite against it.
No config file moves or changes format. The DB, model cache, version-check
files, and skills dirs are host-agnostic and shared. opencode's own config
key (plugin vs plugins) is auto-migrated by v2. The only user-owned file
with a content change is the dev-session shim, and its new dual-shape content
loads under both hosts.
The session-tab feature adds a second user-owned artifact: the TUI-side
plugin entry. A local FILE plugin shim carries a server entrypoint only
(v2's plugin/module.ts), and the TUI's plugin discovery loads directories
and symlinks, never plain files - so the dev-checkout install must be a
DIRECTORY shim: thatch/index.ts (the dual-shape re-export, unchanged) plus
thatch/tui.ts (re-exporting the TUI plugin). DELETE the old thatch.ts
file in the same step - a file and a directory shim coexisting dies with
duplicate instance plugin ids: thatch before any plugin code runs (the
duplicate check is in activation, so nothing inside the plugin can
self-heal it).
-
src/index.ts-- dual entry, merged default export, lazy wrappers -
src/opencode/v1.ts-- v1 hooks adapter -
src/opencode/v2.ts-- v2 promise-context adapter + capability impl -
src/opencode/tui-plugin.ts-- v2 TUI CLI plugin (the./tuientrypoint: consumes the session-tab rpc events, drivesui.tabs) -
src/session-tab-shared.ts-- the cross-entry session-tab contract (rpc definition, prompt builder, validators) -
src/capabilities.ts--HostCapabilities,capabilitiesFromClient -
src/runtime.ts-- the shared runtime (all behavioral logic) -
src/os-args.ts-- pure argv helpers -
tests/opencode-v2.test.ts-- v2 adapter contract tests (mocked context) -
tests/session-tab-shared.test.ts-- session-tab validators, rpc definition shape, TUI guard logic -
tests/plugin.test.ts-- v1 adapter contract tests (mocked client)
Everything downstream of the hooks is unchanged by the dual adapter: extraction.md, nudge-pipeline.md, session-lifecycle.md, cross-session-chat.md, watchers.md, notifications.md. The MCP path (mcp-parity.md) does not touch any of this.
User
- Guide: Behavior Engine
- Guide: Cli
- Guide: Code Review
- Guide: Commands
- Guide: Cross Session Chat
- Guide: Deduplication
- Guide: Default Behaviors
- Guide: Extraction
- Guide: Hygiene
- Guide: Memory
- Guide: Notifications
- Guide: Prediction Engine
- Guide: Overview
- Guide: Setup
- Guide: Skills
- Guide: Watchers
Developer
Dev Feature Guides
- Feature: Behavior Engine
- Feature: Cicd
- Feature: Cli
- Feature: Commands
- Feature: Compaction Recovery
- Feature: Cross Session Chat
- Feature: Database
- Feature: Deduplication
- Feature: Extraction
- Feature: Hygiene
- Feature: Memory Store
- Feature: Multi Host
- Feature: Notifications
- Feature: Nudge Pipeline
- Feature: Opencode Plugin
- Feature: Prediction Engine
- Feature: Qa System
- Feature: Overview
- Feature: Repo Identity
- Feature: Session Lifecycle
- Feature: Session Tabs
- Feature: Setup
- Feature: Sideband
- Feature: Watchers