opencode2) Compatibilityoh-my-opencode-slim installs and runs on both OpenCode v1 (opencode)
and OpenCode v2 (opencode2) from a single published package. This document
describes how each host loads the plugin, what is supported where, and how to
register it.
Capability details: see the OpenCode v2 capability-gap report for the verified matrix, source citations, and migration dispositions.
The package's default export is an object:
export default {
id: 'oh-my-opencode-slim',
server: OhMyOpenCodeLite, // v1 plugin function (PluginInput) => Promise<Hooks>
setup: createV2Setup(), // v2 promise-plugin setup (ctx) => Promise<cleanup>
};
There is deliberately no tui key on this export: hosts validate a
server plugin module's tui field (it must be a function and must not
coexist with server), so a boolean tui: true marker gets the whole
plugin rejected with "invalid tui export".
readV1Plugin in packages/opencode/src/plugin/shared.ts)
detects an object with a server field and calls plugin.server(input)
with the full v1 PluginInput. Extra keys (such as setup) are ignored on
this path.plugin: entries to
plugins:) and calls setup(ctx) with a registration-only context
(agent/aisdk/catalog/command/integration/plugin/reference/skill — no
tool/session/event/mcp/generate). A dual-export plugin registered via the
v1 plugin: key therefore gets both invocations: full v1
functionality flows through server(), while the parallel pass produces
the expected [v2] … failed / bridges: 4 log noise (see
Environment caveats). A v2 plugins: entry yields
the setup pass alone — v1 does not convert v2 plugin declarations into v1
hooks.PluginModule schema in
packages/core/src/plugin/supervisor.ts) decodes default as
{ id, setup } (Effect Schema 4 rejects function defaults) and calls
setup(ctx) via the promise-plugin bridge../tui entry unconditionally: the TUI runtime runs
its own kind: "tui" loader pass over the same plugin list and resolves
the entry through the package's exports["./tui"] map — the server-side
export plays no role in that discovery.Three builds are produced:
| Export | File | Build | Externals |
|---|---|---|---|
. (main) |
dist/index.js |
build:plugin |
zod, jsdom, @opencode-ai/, @opentui/ (shared with v1 host) |
./server |
dist/server/index.js |
build:v2 |
jsdom only (self-contained for v2) |
./tui |
dist/tui2.js |
build:tui |
same external set as build:plugin (composes the v1 TUI entry; inlines zod) |
v2's plugin resolver tries the server subpath first
(subpaths: ["server", ""]), which the exports map resolves directly to
dist/server/index.js — the self-contained v2 server bundle, and also the
entrypoint v2 loads when the dist/server directory is registered directly
(see Installing on v2); the release artifact check
requires it. v1 uses the main entry.
Verified live on OpenCode v2 (all bridges green — health check
bridges:11; the event stream, bridges, and orchestrator-wake
children-driven degraded mode are exercised end-to-end on the stable host,
including a queued wake firing after 60 s of parent idle with a stalled
background child). Every v2 API the adapter touches is
capability-probed at runtime (typeof ctx.mcp?.transform === 'function',
s.switchModel, ctx.generate, …), so a host lacking one capability
degrades that single feature with a log line instead of breaking the load.
src/v2/setup.ts)setup(ctx) wraps the existing v1 factory rather than reimplementing it:
PluginInput from the v2 context
(src/v2/client-shim.ts): the project directory from ctx.location,
and a shim client that really delegates the v1 SDK call shapes to
v2 flat session calls — session.get, session.abort→interrupt,
session.messages→context, session.prompt (as delivery: "steer"),
session.update→rename, session.delete→remove (same
DELETE /api/session/:id; stops the smartfetch secondary-model temp
sessions leaking), and session.list (v2 Session.Info page → the v1
{data} envelope with directory derived from location and outcome
mapped, used by the interview dashboard's session scan and the
orchestrator-wake children enumeration). The shim marks the input
hostFlavor: 'v2' and never fakes success shapes: methods the host
lacks degrade with an honest log (or are omitted entirely, as with
session.get, so capability probes see the truth).OhMyOpenCodeLite(pluginInput) to reuse all existing build
logic (config, agents, tools, hooks, job board, multiplexer, companion).config() hook against a synthesized config to resolve agent
models and the slash commands.Hooks into v2 registrations:
agent → ctx.agent.transform (model/prompt/permission adaptation +
subagent/execute permission mapping + prompt rewrite task→subagent
draft.default("orchestrator"))tool → ctx.tool.transform (zod shape → JSON schema; execute
shimmed; every registration carries options: {codemode: false} —
see the feature matrix note below)mcp → ctx.mcp.transform (draft.set(name, adaptMcpServer(cfg)) for
the built-in MCPs)command → ctx.command.transform — v2 command drafts are add-only:
draft.add({name, description, execute}). execute submits a
<omos-cmd-command data-name="..."> marker as a user prompt; the
session context hook recovers it and dispatches to the v1
command.execute.before hook (deepwork/reflect/loop)ctx.session.hook("context") handles the system/messages
transforms (SystemPart[]/Message.content shape conversion),
chat.message agent tracking, and interview + generic command marker
dispatch — mutating only the trailing message so earlier content stays
byte-identical (provider prompt-cache prefix reuse). While the
bridged messages transform runs, parts injected through
cache-safe-injection carry a v2 ContentPart.cache
{type: "ephemeral"} hint (CacheHint tagging) so providers that
honor manual breakpoints cap the injected zone's cache contribution;
the hint is scoped per request (an AsyncLocalStorage scope around
the bridged transform) so concurrent sessions' transforms cannot
interleave their set/restore, and the v1 pipeline never enters the
scope, so v1 payload bytes never change.ctx.session.hook("prompt") registration (capability-
guarded): the v2 prompt hook fires once per admitted input with
the eventual inbox User messageID, giving the v1 chat.message
consumers (task-session-manager / orchestrator-wake
observeChatMessage, toolLoopGuard.observeNewUserMessage) true
once-per-admission fidelity with prompt parts. The FIRST admitted
prompt per session is deferred until the first agent-bearing
context event arrives, then delivered once with parts + agent
together — the v1 chat.message handler only registers the session
agent when a delivery carries one, and its consumers gate on that
registration, so an agent-less first forward would be dropped (lost
input-wait latch clearing / wake-progress rearm). Bounded fallbacks
(next admission, or a context event whose trailing user message has
moved past the pending one) flush a still-pending prompt best-known
when no agent is ever learned. When the prompt hook registers, the
context hook's per-request chat.message emulation narrows to
agent/model discovery; hosts that reject the hook name keep the
full emulation as fallback.tool.execute.before/after → ctx.tool.hook via
createToolExecuteBridges (src/v2/setup.ts): the host subagent
tool is normalized to v1 task semantics (name mapping, agent→
subagent_type, sessionID→task_id, and back after the hook so
v2 executes the repaired input). A throwing execute.before
rethrows — v2 rejects the tool call, which is how the v1
anti-duplicate / relaunch-lease guards enforce on v2. The after
bridge honors v2's status discrimination: error events synthesize
the v1 after-hook output from the error text (so json-error-recovery
still appends its reminder to a failed call's output), and an
errored call never presents its result content as a success.event → ctx.event.subscribe() loop feeding mapV2EventToV1
(src/v2/event-adapter.ts): additive synthesis only — the raw v2 event
is always dispatched first (the interview bridge depends on it), then
synthesized v1 shapes: flat child session.created → v1
early-registration {info: {id, parentID, agent?}}, usage telemetry
(session.usage.updated/session.step.ended) → a deduplicated
completed-assistant message.updated for the cache monitor, the Form
flow (form.created/form.replied/form.cancelled) → v1
question.asked/question.replied/question.rejected
(form.id → the question request id; forms owned by the "global"
sentinel are skipped), and permission.asked field mapping to the v1
names (permission ← action, patterns ← resources;
permission.replied passes through raw — v2's shape already matches
the v1 event). V2 hosts publish durable session.execution.started/
succeeded/failed/interrupted and emit no busy/idle session.status
and no session.idle on the event stream — the observed payloads
always ride under data (verified live, 80-event capture). The
adapter synthesizes the v1 lifecycle shapes from those execution
events (started → busy session.status; terminal subtypes → idle
session.status + session.idle; failed → a v1 session.error
with the host error payload before the idle pair), and no
session.status-based fallback remains. The execution-event
synthesis keeps orchestrator-wake suppression/arm scheduling and the
foreground fallback working on v2 hosts, while the Form and
permission bridges above feed the companion's waiting-input
indicator and the task-session-manager input-wait gate.generate.text → one-shot generation channel probed on ctx.generate
and threaded as experimental_v2.generateText, powering the webfetch
secondary-model summaries without a temp sessiondispose → returned cleanupEach bridge is independently try/catch-guarded so one failure cannot disable the rest, and a zero-registration load logs a loud health-check warning.
| Capability | v1 (opencode) |
v2 (opencode2) |
Notes |
|---|---|---|---|
| Orchestrator + specialist agents, prompts & permission mapping | ✅ | ✅ ctx.agent.transform |
— |
Delegation + background job board + task_* tools |
✅ task tool |
✅ host subagent (auto-bridged: name/args normalization in src/v2/delegation.ts, output parsing in the execute bridges) |
— |
| Tools (ast-grep, webfetch, task_message/task_cancel/task_revive, wait_for_user, acp_run) | ✅ | ✅ ctx.tool.transform |
v2 requires options: {codemode: false} on each registration (CodeMode split): without it a tool registers cleanly but is confined to the execute tool's JS runtime and session catalogs yield Unknown tool: <name>. The plugin stamps it on every adapted tool (adaptTool in src/v2/adapters.ts; additive field, older hosts ignore it). ast-grep needs its CLI binary (package, system, or lazy download); webfetch needs jsdom resolvable |
Slash commands /deepwork /reflect /loop |
✅ | ✅ marker round-trip | — |
/interview |
✅ | ✅ marker command + trailing-message context bridge | — |
| Message transforms (phase reminder, skills filter, image routing, display-name rewrite) | ✅ | ✅ via the single context hook | — |
| Event handling (session tracking, lifecycle, cache telemetry) | ✅ | ✅ event pump + additive v2→v1 synthesis | — |
| Tool execute hooks (apply-patch recovery, task-session, json-recovery) | ✅ | ✅ createToolExecuteBridges with subagent→task normalization |
— |
| Built-in MCPs (context7, gh_grep) auto-registered | ✅ | ✅ ctx.mcp.transform |
— |
| webfetch secondary-model summaries | ✅ | ✅ via ctx.generate.text |
host without ctx.generate → summaries unavailable (logged) |
| Foreground model fallback (rate-limit failover) | ✅ | ✅ shim translates re-prompt into session.switchModel + delivery:"steer" prompt |
— |
/preset (interactive switcher) |
✅ | ✅ TUI plugin entry (./tui → dist/tui2.js): sidebar + /preset dialog or /preset <name> fast path |
The layer registers from an append: "app" slot render because the host's keymap.layer is provider-scoped (calling it from plugin setup throws Keymap.Provider is missing); the command carries an id and slash.arguments; host needs ui.slot + keymap.layer; the interactive picker needs ui.dialog.select while /preset <name> works without it; feedback uses ui.toast.show; config-file preset still applies at load |
| TUI default agent | ✅ orchestrator | ✅ orchestrator — draft.default("orchestrator"); the v2 TUI honors default_agent and hoists the default to the head of the agent list |
— |
| Multiplexer (tmux/zellij/herdr/cmux panes) | ✅ | ❌ host-gated off (hostFlavor: 'v2' → shouldEnableMultiplexer returns false and the session manager is forced to type: "none") |
by design — v2 renders subagents natively |
| Orchestrator-wake scheduler | ✅ todo-gated (host todo/children/status APIs) |
✅ children-driven degraded mode (backgroundJobs.orchestratorWake.mode) |
v2 wake enumerates children via session.list({parentID}) with an event-tracked fallback, gates on children without a terminal outcome (staleness-bounded), and delivers with queue; v2's native subagent completion nudges still cover the happy path — the port adds a periodic watchdog for stuck children and unreconciled jobs |
chat.headers (custom request headers) |
✅ | ❌ unbridged | low value: v2 exposes a model request hook (session.hook("model.request"), with mutable headers) — will bridge only if asked for |
| Companion app | ✅ | ⚠️ unverified | independent desktop app; test separately against v2 |
Behaviors of v2 itself that plugin authors should know about — none currently break this plugin:
data, not properties. The v2
event stream (SSE and ctx.event.subscribe()) frames each event as
{id, created, type, location?, durable?, metadata?, data} — the payload
is the data record, unlike the v1 SDK's properties (verified live:
every observed event keyed ["id","created","type","durable","data"],
with the optional metadata? key observed on some events).
The adapter reads data first with properties as a legacy fallback and
always writes properties on the synthesized v1 shapes, because that is
the key the v1 consumers read.session.execution.*. V2 hosts publish durable
session.execution.started/succeeded/failed/interrupted events
({sessionID}, plus error on .failed and reason on
.interrupted) and emit no busy/idle session.status and no
session.idle on the event stream (session.status remains only in the
schema). The adapter synthesizes the v1 lifecycle shapes from the
execution events (started → busy session.status; terminal subtypes →
idle session.status + session.idle; failed → a v1 session.error
with the host error payload passed through best-effort, emitted before
the idle pair so the error-then-idle flow the event-router expects is
preserved). Without this synthesis the orchestrator-wake scheduler never
arms on live v2 hosts.{id, time, text,
type} only — no agent, no sessionID. The v1 injection gates
(phase-reminder, background-job-board, post-file-tool-nudge) key on
user-message info.agent/info.sessionID, so every injection would
skip. The v2 context bridge stamps the context event's sessionID and
the session's known agent (from the event, falling back to the
session-prompt bridge's learned state) onto transcript user messages
before the bridged messages transform runs — metadata-only envelope
enrichment, strictly absence-gated (host-provided values never
overwritten), parts/content bytes untouched, idempotent across
context events. This also makes the CacheHint-tagged injected parts
observable on live v2 hosts.client.session.status
is not a function on v2 hosts), so the task-session-manager's
runtime-status reconciliation poll is disabled entirely on hosts
without the method — a single per-instance log line notes the
disabled reconciliation instead of logging uncertainty every ~5s poll.
v1 hosts expose the method and keep the exact historical polling
behavior. Background job stop-confirmation was never obtainable from
the v2 poll anyway (the lookup failed every time).session.status and a session.idle from each terminal execution event,
so a consumer watching both sees idle twice per terminal transition.
Current consumers are idempotent per session (idle-reconciliation's
per-session timer guards); new idle consumers must tolerate duplicate
delivery.permission.asked delivery. The adapter appends a
v1-field-mapped copy after the raw v2 permission.asked event (raw
first is a load-bearing invariant for v2-native handlers). Consumers
watching both see the ask twice with the same request id — safe because
every ask consumer is idempotent per request id (the input-wait
tracker's Set, the companion's status setters, wake suppression); new
ask consumers must tolerate it, like idle.question.* with the Form
flow; the adapter synthesizes question.asked/replied/rejected from
form.created/replied/cancelled so v1 consumers keep working. Forms
owned by the "global" sentinel session (MCP elicitation) are not
synthesized — v1 question events are session-scoped."mcps": ["context7", "!gh_grep"] in agent config), and registration
uses its own server names via draft.set(name, ...).Add the npm package, pinned to an exact version — v2 auto-refreshes
unpinned npm plugins on every startup, so @latest effectively means
"silently upgrade whenever a new version ships". The global config root is
~/.config/opencode/opencode.json, shared with v1 (~/.config/opencode2/
is not read for plugin config):
{
"plugin": ["oh-my-opencode-slim@2.2.17"]
}
For local development, point the config at the built dist/server
directory:
{
"plugin": ["/path/to/oh-my-opencode-slim/dist/server"]
}
Then build:
bun install
bun run build # produces dist/index.js (v1), dist/server/index.js (v2
# server bundle, also served via the ./server subpath),
# dist/tui2.js (v2 TUI), dist/cli/
Verify with opencode2 run "list your specialist agents" --standalone — the
orchestrator should name explorer, librarian, oracle, designer, fixer.
…/dist/server.js) are rejected with the WARN
configured plugin path must be a directory. A directory entry's
index.js is the entrypoint — hence dist/server above.index.js re-exports the
original file, e.g. ~/.config/opencode/plugins-dev/<name>/index.js
containing export { default } from "/abs/path/to/plugin.js";. Do not
use the auto-scanned dir names plugin/plugins for wrapper dirs — a
scanned duplicate next to an explicit registration hard-dies on duplicate
plugin ID.Agent models are resolved the same way as v1 (per-agent model in
oh-my-opencode-slim.json, or inherited from the session/host default). On
v2, set a working provider+model in your config or the plugin's config file
so delegated subagents can run.
When the foreground model hits a rate limit, the plugin switches the
session's model (session.switchModel) and steers the re-prompt through
delivery: "steer". A failing switchModel call degrades honestly: the
re-prompt is still delivered (on the current model) and the plugin's logs
record that no switch happened — the fallback chain is not aborted. On
hosts without session.switchModel, the fallback replay is rejected with
a clear error instead of silently replaying on the model that just failed
(other prompt callers, like the orchestrator-wake scheduler, only pin the
current model and keep steering).
/interview is supported on v2 through a marker command and a
trailing-message context bridge. The bridge keeps an in-memory transcript
projection from v2 context and streamed text events, and uses the v2 session
methods for prompts, notifications, and renames. Interview notifications
admit the synthetic input with resume: false — the interview URL lands in
the session without waking an agent turn (the v1 noReply prompt
equivalent). The markdown document
remains the durable source of truth; completion responses without
<interview_state> rewrite the current spec while retaining frontmatter and
Q&A history.
shouldEnableMultiplexer / sessionManagerMultiplexerConfig in
src/index.ts).chat.headers. Not bridged (low value on v2 — a model request hook
exists, session.hook("model.request") with mutable headers, if
demand appears).The wake scheduler is active on v2 in a degraded mode, configured with
backgroundJobs.orchestratorWake.mode ("auto" | "todo" | "children",
default "auto": todo-gating on v1, children-driven on v2; an explicit
"todo" degrades to children because v2 has no todo surface — logged once).
How it differs from the v1 path:
session.list + promptAsync
(session.get is optional model enrichment). v1 keeps its exact
historical probe set (get/todo/children/status/promptAsync).session.list({ parentID }) through the shim
(v2 Session.Info → v1 envelope; outcome and time.updated mapped).
The in-process session surface of current v2 hosts does not expose
list, so the empty page falls back to an event-tracked view — the
adapter-synthesized session.created parentID links plus tracked
busy/idle statuses — refreshed on every evaluation with the host's
authoritative outcome/time.updated via session.get (fail-soft per
child). A finished child is therefore terminal immediately instead of
reading active for the whole staleness window, and a live child stays
visible on its host evidence rather than dropping out on stale local
evidence. Results are scoped to the session's directory when the host
reports one.outcome === undefined (v2 records an
outcome only on terminal transition: succeeded|failed|interrupted) that
still have fresh update evidence — host time.updated or a tracked status
change newer than 3× the wake interval (staleness bound for children that
crash mid-run without recording an outcome). Stopped-job recovery wakes
bypass the condition, as on v1.delivery: "queue" — v1 prompt_async queued, and a
v2 steer would hijack an in-flight run. The shim's promptAsync keeps
steer as the default so the foreground-fallback replay is unchanged.
The wake model pin carries the session model's variant as the v2-only
modelVariant argument, so switchModel preserves the reasoning-effort
setting instead of resetting it to the host default.v2's built-in subagent tool still posts completion notifications to the
parent natively — that covers the happy path. What the port adds is a
periodic watchdog: an idle parent with a stuck or unreconciled child (or a
job that stopped without a terminal result) gets woken to assess, cancel, or
respawn, bounded by the same no-progress cap as v1.
setup
with a reduced, TUI-side context that lacks agent.transform (and other
domains). The adapter capability-guards setup and skips registration
gracefully for those hosts instead of crashing or retry-storming. The
same applies to the embedded v2 pass inside every v1 host: it invokes
setup with registration-only domains, so a v1 session's plugin log
shows [v2] tool.transform failed-style lines and
health check passed {"bridges":4} — expected noise from that parallel
pass, not breakage. The classic server() path (a separate plugin-log
instance a few seconds apart) carries the full v1 functionality.[v2][tui] lines anywhere. Verify TUI behavior through the host (command
availability, on-disk effects), not via the plugin log.jsdom import must resolve from the plugin's
node_modules (webfetch imports it lazily, so the plugin still loads
without it — install as a package or ensure jsdom is resolvable to
enable webfetch locally). AST-grep resolves its CLI independently and
lazily downloads a binary when no package or system binary is available.src/hooks/cache-safety.property.test.ts and friends) covers the
shared transform code the v2 context hook invokes. The one v2-only
addition is CacheHint tagging: parts injected through
cache-safe-injection while the v2 context bridge runs carry
cache: {type: "ephemeral"} (v2 ContentPart.cache). The hint is
applied via a per-request scoped default (AsyncLocalStorage — the v2
host serves different sessions' requests concurrently, so the scope
must be isolated per bridged transform) inside the v2 bridge only — v1
callers never set it, so the v1 payload (and its snapshots) stay
byte-identical.The current v2 implementation is a transitional v1-to-v2 adapter. It preserves the shared plugin behavior while native migration remains a separate effort; the adapter is not itself a native Promise-plugin implementation.
The adapter constructs v1-shaped input and translates v1 hook payloads, so it cannot provide the type or lifecycle guarantees of native v2 code. Its client shim and interview projection intentionally cover only the operations described above, and registration failures are isolated so supported features can still load independently. Native migration should use the official v2 domains rather than expanding this shim.
The beta Promise-plugin API provides native opportunities for agent and tool
catalogues, executable commands, MCP registration, session context and model
hooks, tool lifecycle hooks, event subscriptions, and TUI integration. Session
context is the native replacement for v1 raw message transforms: native code
should mutate v2 system, messages, and tools directly instead of converting
through v1 { info, parts } objects.
The target is a separate native Promise-plugin composition, not a more capable adapter:
src/v1 boundary.define({ id, setup }) registration for agents, tools,
commands, MCPs, session hooks, tool hooks, and events.The native implementation must not invoke the v1 factory, convert v2 values to v1 hook payloads, or retain legacy lifecycle ownership merely to preserve parity.
Exact v1 parity is not presumed for the native v2 target. During migration, explicitly classify these behaviors as native, adapter-only, or retired rather than emulating them indefinitely:
Compaction prompt replacement, permission-prompt interception, and synthetic text completion also need a product-level replacement or explicit retirement where the native API has no exact equivalent. TUI controls and small-model selection are migration decisions, not predeclared retirements. The official API's MCP registration, model switching, and executable command registration are migration opportunities.
Validation is required at each migration boundary: