# OpenCode v2 (`opencode2`) Compatibility oh-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. ## How it works The package's default export is an object: ```ts export default { id: 'oh-my-opencode-slim', server: OhMyOpenCodeLite, // v1 plugin function (PluginInput) => Promise setup: createV2Setup(), // v2 promise-plugin setup (ctx) => Promise }; ``` 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". - **v1 loader** (`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. - **Embedded v2 pass on v1 hosts.** Every v1 host (≥ v1.17.10) also boots the v2 core, which reads the same config (migrating `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](#environment-caveats)). A v2 `plugins:` entry yields the setup pass alone — v1 does not convert v2 plugin declarations into v1 hooks. - **v2 loader** (`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. - **v2 TUI** loads the `./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](#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. ## The v2 adapter (`src/v2/setup.ts`) `setup(ctx)` wraps the existing v1 factory rather than reimplementing it: 1. Builds a v1-shaped `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). 2. Invokes `OhMyOpenCodeLite(pluginInput)` to reuse **all** existing build logic (config, agents, tools, hooks, job board, multiplexer, companion). 3. Runs the v1 `config()` hook against a synthesized config to resolve agent models and the slash commands. 4. Bridges the returned v1 `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 `` marker as a user prompt; the session context hook recovers it and dispatches to the v1 `command.execute.before` hook (deepwork/reflect/loop) - a single `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. - a native `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 session - `dispose` → returned cleanup Each bridge is independently try/catch-guarded so one failure cannot disable the rest, and a zero-registration load logs a loud health-check warning. ## Feature matrix | 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: `. 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 ` 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 | ## Upstream behaviors to know Behaviors of v2 itself that plugin authors should know about — none currently break this plugin: - **Event payloads ride under `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. - **Lifecycle keys on `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. - **Transcript user messages carry no identity.** Context-hook transcript user messages on live v2 hosts carry `{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. - **Runtime status reconciliation is capability-gated.** v2 has no equivalent of the v1 live session-status map (`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). - **Duplicate idle delivery.** v2 favors `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. - **Duplicate `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 flow is Form-based.** v2 replaced `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. - **MCP tool-name namespaces are host-generated.** This plugin never matches raw MCP tool names: MCP access is granted per server name (`"mcps": ["context7", "!gh_grep"]` in agent config), and registration uses its own server names via `draft.set(name, ...)`. ## Installing on v2 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): ```json { "plugin": ["oh-my-opencode-slim@2.2.17"] } ``` For local development, point the config at the built `dist/server` **directory**: ```json { "plugin": ["/path/to/oh-my-opencode-slim/dist/server"] } ``` Then build: ```bash 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. ### Registration rules - **Directory or package entries only.** File-path entries (e.g. `…/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. - **Single-file plugins need a wrapper dir** whose `index.js` re-exports the original file, e.g. `~/.config/opencode/plugins-dev//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. ## Configuring models on v2 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). ## Limitations ### Interview `/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 `` rewrite the current spec while retaining frontmatter and Q&A history. ### v1-only, by design - **Multiplexer panes.** tmux/zellij/herdr integration is a v1-TUI feature; v2 renders subagents natively, so the multiplexer is host-gated off on v2 (`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). ### Orchestrator-wake on v2 (children-driven degraded mode) 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: - **Gate:** v2 requires only the shim's `session.list` + `promptAsync` (`session.get` is optional model enrichment). v1 keeps its exact historical probe set (`get`/`todo`/`children`/`status`/`promptAsync`). - **Children enumeration:** `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. - **Wake condition:** children with `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. - **Wake delivery:** `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. - **Fingerprint:** children-only (id + outcome + tracked status + update evidence); the two-wake no-progress cap still bounds cost. 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. ### Environment caveats - **Reduced/TUI-side hosts.** Some host processes load the plugin's `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. - **Local-checkout loading.** When the plugin is registered from a local build, the externalized `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. - **Companion app unverified on v2.** The companion is an independent desktop app; test it separately against v2 hosts. - **Prompt-cache rules unchanged.** The v2 bridges reuse the v1 transform pipeline under the same cache-safety contract: only trailing messages are mutated, earlier content stays byte-identical, and the v1 enforcement suite (`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.