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.
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 against opencode2 beta-19365 (all bridges green — health check
bridges:11; live mock-driven re-verification on 2026-09-09 exercised the
event-stream bridge end-to-end, including the orchestrator-wake
children-driven degraded mode firing a queued wake 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: idle session.status → session.idle, 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). Newer hosts (verified live on beta-19365/beta-19378)
publish durable session.execution.started/succeeded/failed/
interrupted and no longer stream busy/idle session.status on the
event flow; those events are synthesized into the same v1 lifecycle
shapes (started → busy session.status; terminal subtypes → idle
session.status + session.idle; failed additionally emits a v1
session.error with the host error payload before the idle pair).
The session.status → session.idle path is retained for older
builds — hosts emitting both simply deliver idle repeatedly, which
the double-idle invariant tolerates. This is what keeps the
companion's waiting-input indicator, the task-session-manager
input-wait gate, orchestrator-wake suppression/arm scheduling, and
the foreground fallback working on v2 hosts.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 |
TUI host needs keymap.layer + ui.dialog.select; 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?, data} — the payload is the
data record, unlike the v1 SDK's properties (verified live on
beta-19365: every observed event keyed exactly
["id","created","type","durable","data"]). 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.* on newer hosts. Verified
live hosts (beta-19365/beta-19378) publish durable
session.execution.started/succeeded/failed/interrupted events
({sessionID}, plus error on .failed and reason on
.interrupted) and no longer publish busy/idle session.status on
the SSE 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). The session.status mapping is retained for older builds;
a host emitting both delivers idle repeatedly — the double-idle
invariant above covers it. 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 over
session.idle; the adapter synthesizes session.idle additively, so a
consumer watching both events sees idle twice per session. 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).
When the listing is unavailable (missing/erroring/empty), an event-tracked
fallback uses the adapter-synthesized session.created parentID links plus
tracked busy/idle statuses. 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.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.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.