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.
The verified compatibility baseline is OpenCode v2.0.7. The plugin
requires OpenCode v2.0.7+ on v2 hosts; older v2 hosts are unsupported.
The adapter targets the v2 plugin API surface (see
The v2 plugin API surface),
and the compile-time mirror guard below is pinned to @opencode/plugin
2.0.7.
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".
- v1 loader (
readV1Plugininpackages/opencode/src/plugin/shared.ts) detects an object with aserverfield and callsplugin.server(input)with the full v1PluginInput. Extra keys (such assetup) 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 toplugins:) and callssetup(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 v1plugin:key therefore gets both invocations: full v1 functionality flows throughserver(), while the parallel pass produces the expected[v2] … failed/bridges: 4log noise (see Environment caveats). A v2plugins:entry yields the setup pass alone — v1 does not convert v2 plugin declarations into v1 hooks. - v2 loader (
PluginModuleschema inpackages/core/src/plugin/supervisor.ts) decodesdefaultas{ id, setup }(Effect Schema 4 rejects function defaults) and callssetup(ctx)via the promise-plugin bridge. - v2 TUI loads the
./tuientry unconditionally: the TUI runtime runs its ownkind: "tui"loader pass over the same plugin list and resolves the entry through the package'sexports["./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) |
The optional @opentui/* peers track @opencode/plugin's peer floor
(>=0.5.10; pinned exactly at 0.5.11) so the v2 TUI entry satisfies
the stable host's peer range.
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.
Upstream npm naming split with the stable line: v2 ships as
@opencode/plugin / @opencode/client / @opencode/sdk / @opencode/cli
(at 2.0.x), while @opencode-ai/plugin and @opencode-ai/sdk are V1-only
packages that will never carry the v2 surface. The plugin
intentionally keeps its v1 runtime pins and hand-mirrors the subset of the v2
plugin context it consumes in src/v2/types.ts — the v1 host must be able to
load the main build with no v2 package installed. This is a known tradeoff,
not an oversight: the mirror is refreshed by hand and can drift from
upstream, so every v2 release bump needs a deliberate diff of
src/v2/types.ts against the new @opencode/plugin. That diff is
backed by a compile-time tripwire: src/v2/mirror-conformance.ts — typechecked
against the @opencode/plugin devDependency, pinned to the audited
version — fails bun run typecheck when either the mirror or the
pinned official surface drifts (hook-name sets, key payload fields).
The guard file must stay non-test-suffixed: tsconfig excludes
test-suffixed files from tsc, so a .test.ts guard silently checks
nothing.
Verified live on OpenCode v2 (all bridges green — health check
bridges:11, +1 on hosts that accept the session.model.request hook
name for the chat.headers bridge; the event stream, bridges, and
orchestrator-wake children-driven degraded mode are exercised end-to-end
on the stable host — live mock-driven re-verification on 2026-09-09
included a queued wake firing after 60 s of parent idle with a stalled
background child). v2 conformance is compile-time-pinned by the
mirror-conformance guard against the @opencode/plugin 2.0.7
devDependency and exercised by the mock-driven bridge tests. 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 session hooks are the deliberate exception: on full contexts they
register unconditionally and a
registration failure fails setup (see
The v2 adapter).
ctx.mcp.transform in particular is present in all v2.0.x stable hosts;
its probe only ever matters on non-stable host builds.
setup(ctx) wraps the existing v1 factory rather than reimplementing it:
- Builds a v1-shaped
PluginInputfrom the v2 context (src/v2/client-shim.ts): the project directory fromctx.location, and a shimclientthat really delegates the v1 SDK call shapes to v2 flat session calls —session.get,session.abort→interrupt(resume: falseaborts the active run),session.messages→context,session.prompt(defaultdelivery: "steer";noReply: truemaps todelivery: "queue", resume: false),session.update→session.update({sessionID, title}),session.delete→remove(sameDELETE /api/session/:id; stops the smartfetch secondary-model temp sessions leaking), andsession.list(v2Session.Infopage → the v1{data}envelope withdirectoryderived fromlocationandoutcomemapped, used by the interview dashboard's session scan and the orchestrator-wake children enumeration). Note thatremoveandlistare not part of the stock v2 plugin session domain — the capability probes only succeed on hosts that extend it, and all v2.0.x stable hosts take the degraded paths (see Not exposed to plugins:session.list/session.remove). The shim marks the inputhostFlavor: 'v2'and never fakes success shapes: methods the host lacks degrade with an honest log (or are omitted entirely, as withsession.get, so capability probes see the truth). - Invokes
OhMyOpenCodeLite(pluginInput)to reuse all existing build logic (config, agents, tools, hooks, job board, multiplexer, companion). - Runs the v1
config()hook against a synthesized config to resolve agent models and the slash commands. - Bridges the returned v1
Hooksinto v2 registrations:agent→ctx.agent.transform(model/prompt/permission adaptation +subagent/executepermission mapping +draft.default("orchestrator")). On v2 hosts the generated orchestrator/council prompts already use native wording (subagenttool,agentparam); thetask→subagentprompt rewrite remains only as a fallback for user-custom presetstool→ctx.tool.transform(zod shape → JSON schema; execute shimmed; every registration carriesoptions: {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}).executesubmits a<omos-cmd-command data-name="...">marker as a user prompt; the session context hook recovers it and dispatches to the v1command.execute.beforehook (deepwork/reflect/loop)- a single
ctx.session.hook("context")handles the system/messages transforms (SystemPart[]/Message.content shape conversion),chat.messageagent 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 throughcache-safe-injectioncarry a v2ContentPart.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 (anAsyncLocalStoragescope 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 UsermessageID, giving the v1chat.messageconsumers (task-session-manager / orchestrator-wakeobserveChatMessage,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 v1chat.messagehandler 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-requestchat.messageemulation narrows to agent/model discovery; hosts that reject the hook name keep the full emulation as fallback. - a native
ctx.session.hook("model.request")registration (capability-guarded): the v2 equivalent of the v1chat.headershook. Fires once per provider request with a mutableheadersrecord the host merges into the outgoing HTTP request. The bridge replays the v1 Copilot initiator-header semantics: forgithub-copilot/github-copilot-enterpriseprimary requests whose trailing user message is an internal-initiator admission (orchestrator-wake queue prompts, foreground-fallback replays), it setsx-initiator: agentso Copilot's backend does not account plugin-driven turns as user activity. The internal marker is learned in-band — promptmetadatapersisted onto the transcript user message (visible on the context-event envelope) plus an admission tracker forsession.syntheticwakes (synthetic admissions skip the prompt hook and the host drops their metadata from the LLM envelope, so the shim records a client-chosenmsg_-prefixed admission id the host honors). Auxiliary kinds (compaction/title/generate) are skipped — v2's built-in Copilot provider hook already marks those, and the native fetch layer only escalates a pre-setx-initiator: agent(never resets it touser), so the bridge composes with the built-in. Known deviation: v1 also treats compaction-continuation turns (compaction_continuepart metadata) as internal; v2 core has no such key and the bridge checks only the plugin metadata key, so a primary continuation turn following compaction of an internal-initiated session goes unmarked (false-negative only — a narrow window that can only under-mark, never over-mark). Headers are transport-level; no payload content is read or mutated. Hosts that reject the hook name keep the pre-bridge behavior (header simply unset) with a one-time log. tool.execute.before/after→ctx.tool.hookviacreateToolExecuteBridges(src/v2/setup.ts): the hostsubagenttool is normalized to v1tasksemantics (name mapping,agent→subagent_type,sessionID→task_id, and back after the hook so v2 executes the repaired input). A throwingexecute.beforerethrows — 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:errorevents 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 feedingmapV2EventToV1(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 childsession.created→ v1 early-registration{info: {id, parentID, agent?}}, flatsession.deleted→ the v1 deletion-cleanup shape carrying both id spellings the v1 consumers read (properties.info.idfor the cache monitor's session eviction,properties.sessionIDfor the task-session-manager's tombstone/board teardown; nogenerationis fabricated, so the event-router's unproven-relaunch deletion fence keeps its strength), usage telemetry (session.usage.updated/session.step.ended) → a deduplicated completed-assistantmessage.updatedfor the cache monitor, the Form flow (form.created/form.replied/form.cancelled) → v1question.asked/question.replied/question.rejected(form.id→ the question request id; forms owned by the"global"sentinel are skipped), andpermission.askedfield mapping to the v1 names (permission←action,patterns←resources;permission.repliedpasses through raw — v2's shape already matches the v1 event). V2 hosts publish durablesession.execution.started/ succeeded/failed/interruptedand emit no busy/idlesession.statusand nosession.idleon the event stream — the observed payloads always ride underdata(verified live, 80-event capture). The adapter synthesizes the v1 lifecycle shapes from those execution events (started→ busysession.status; terminal subtypes → idlesession.status+session.idle;failed→ a v1session.errorwith the host error payload before the idle pair), and nosession.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 onctx.generateand threaded asexperimental_v2.generateText, powering the webfetch secondary-model summaries without a temp sessiondispose→ returned cleanup
Each domain-transform bridge (agent/tool/mcp/command) is independently
try/catch-guarded so one failure cannot disable the rest, and a
zero-registration load logs a loud health-check warning. The session hooks
(prompt, context, model.request, compaction) register
unconditionally on full v2 contexts: a registration failure fails
setup loudly instead of degrading — hook-name rejection is treated as a
host contract violation, not a degrade path.
The v1-facing session.prompt shim preserves text and file attachments. A
noReply: true write is a real, non-synthetic prompt with queue + resume: false;
queue alone would not preserve the no-resume intent. Unsupported per-call
agent/model/variant overrides are rejected before writing, never silently dropped
or implemented by non-atomic switchAgent/switchModel calls. task_message
explicitly inherits persisted selection on v2; other noReply callers, including
interview notifications, retain the same no-resume semantics.
experimental_v2.waitForSessionIdle(sessionID) exists only when the host provides
session.wait({sessionID}). It delegates that method alongside the unchanged
generateText channel; it fabricates neither a status map nor board state.
session.status remains absent. Historical session.get outcomes cannot authorize
revive. The official 2.0.5 prompt intent fields and wait signature are pinned in
mirror-conformance.ts, and tool→shim→host contracts have dedicated tests.
The 2.0.5 promise adapter does not forward AbortSignal to these methods, so the
bounded idle wait does not pretend to cancel the host operation: late settlement
is observed without authorizing a prompt. It waits for idle within its budget,
not for an atomic reservation; queued delivery can still follow an external resume.
The terminal gate alone attributes session.get outcomes, in both v1 and v2.
Only integrations explicitly declaring hostOutcomeClock: 'shared-unix-ms'
(the local in-process wiring) may use them. Other factories default to distrust.
Finite nonnegative timestamps must satisfy max(generation start, attempt start, latest live activity) < time.idle <= read completion; equality is ambiguous.
Attempt boundaries survive handoff promotion and late ACKs. An unattributable
outcome cannot establish or preserve host-outcome quiescence; it leaves the run
uncertain, not stopped. Fresh succeeded still requires valid post-baseline result
evidence. Independent runtime maps, native returns and cancellation keep their fences.
| 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 |
ctx.mcp.transform is present in all v2.0.x stable hosts; the runtime capability probe is belt-and-suspenders |
| webfetch secondary-model summaries | ✅ | ✅ via ctx.generate.text |
host without ctx.generate → summaries unavailable (logged) |
| Background-job state persistence (tombstones, deletion epochs, alias high-water marks) | ➖ process-local | ✅ via ctx.storage |
optional domain; absent → pure in-memory fallback, zero behavior change (see Background job state) |
| 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 (Copilot x-initiator routing) |
✅ | ✅ via session.hook("model.request") |
transport-level only; auxiliary kinds are covered by v2's built-in Copilot provider hook |
| Companion app | ✅ | independent desktop app; test separately against v2 |
Behaviors of v2 itself that plugin authors should know about — none currently break this plugin:
- Event payloads ride under
data, notproperties. The v2 event stream (SSE andctx.event.subscribe()) frames each event as{id, created, type, location?, durable?, metadata?, data}— the payload is thedatarecord, unlike the v1 SDK'sproperties(verified live: every observed event keyed["id","created","type","durable","data"], with the optionalmetadata?key observed on some events). The adapter readsdatafirst withpropertiesas a legacy fallback and always writespropertieson the synthesized v1 shapes, because that is the key the v1 consumers read. The interview bridge's event handler (handleEventinsrc/v2/interview-bridge.ts) resolves its payload data-first the same way — reading onlypropertieshad left its transcript projection and deletion cleanup dead on live v2 for every event (handleContextis unaffected; it consumes a different event type). session.deletedis synthesized with a dual id spelling. v2 delivers deletion flat ({sessionID}), and the v1 deletion consumers read two different spellings: the cache monitor's session eviction readsproperties.info.id, while the task-session-manager's deletion handler acceptsproperties.sessionID. The synthesized v1 event therefore carries both. Without this synthesis the deletion cleanup (rehydrate tombstone, board teardown, idle-token/input-wait clears) never fired on v2, and deleted runs resurrected as forever-running ghost records on the next request.- Lifecycle keys on
session.execution.*. V2 hosts publish durablesession.execution.started/succeeded/failed/interruptedevents ({sessionID}, pluserroron.failedandreasonon.interrupted) and emit no busy/idlesession.statusand nosession.idleon the event stream (session.statusremains only in the schema). The adapter synthesizes the v1 lifecycle shapes from the execution events (started→ busysession.status; terminal subtypes → idlesession.status+session.idle;failed→ a v1session.errorwith 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. - Transcript user messages carry no identity. Context-hook
transcript user messages on live v2 hosts carry
{id, time, text, type}only — noagent, nosessionID. The v1 injection gates (phase-reminder, background-job-board, post-file-tool-nudge) key on user-messageinfo.agent/info.sessionID, so every injection would skip. The v2 context bridge stamps the context event'ssessionIDand 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.statusis not a function on v2 hosts;session.statusis not exposed to plugins), 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). - Plugin API surface and adoption status. The v2 plugin API exposes
session hooks
compaction,generate, andtitle(the title and compaction hooks may setresultto skip the model call entirely) and TUIui.tabs.move()(tabs.opendoes not focus a tab;tabs.focusopens the tab if needed). TheSessionContextrequest shape carriesgenerationandproviderOptionsin a singleoptionsobject — the plugin is unaffected: its context bridge mutates only system/messages, and cache hints rideContentPart.cache. Adoption status: the compaction hook is adopted — the plugin strips its tagged synthetic parts from the compaction input; child-session permission rules are applied — plugin-managed child sessions receive exact-match task-policy rules once at creation viactx.session.update({sessionID, permissions})(createPermissionRulesBridgeinsrc/v2/setup.ts; exact-match strings only, no wildcards, while upstream matching semantics settle — PRs #48194/#46495/#46871; triggered on plugin-managed childsession.createdand fail-soft with a one-time warning on reduced hosts without the method); thegeneratesession hook (not thectx.generatetext channel the webfetch summaries use), thetitlehook, and thetabsmethods are not used. On the client SDK,server.statusis namedserver.info(the plugin never calls it), and thelocation.reload/fs.writeendpoints and the Formhiddenfield are likewise not adopted. The applied child-session allow rules always lose to a matching user-configdeny(see the config permission policies bullet above). Thetitlehook needs no adoption for child sessions: v2 hosts title subagent children deterministically at creation (thesubagenttool setstitlefrom itsdescriptionargument, which the delegation pipeline always supplies) — the hook only matters if custom title formats are wanted. Upstream is still actively fixing compaction×hook plumbing and compaction×cache behavior, so compaction-hook semantics may evolve; the plugin's hook callback is written shape-tolerant (messages-only mutation) to ride those changes. - Duplicate idle delivery. The adapter synthesizes both an idle
session.statusand asession.idlefrom 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. - Duplicate
permission.askeddelivery. The adapter appends a v1-field-mapped copy after the raw v2permission.askedevent (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 synthesizesquestion.asked/replied/rejectedfromform.created/replied/cancelledso 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 viadraft.set(name, ...). opencode reloadrecreates plugin instances in-process. The CLIopencode reloadcommand, the TUI reload command, and thelocation.reloadHTTP endpoint all rebuild the host's location service layer: plugin instances are destroyed and recreated,setupre-enters in the same process, and the host calls the cleanup returned by the previous generation before the new generation loads. Pending permissions and forms are cancelled, and running sessions swap to the new service layer at the next step boundary. There is no unload-style lifecycle hook. Module state of an npm-form plugin survives the reload (the module instance is reused); a local-directory plugin is forced onto a fresh module instance whenever its files change. The plugin handles the in-process re-entry explicitly: the v1disposehook cancels pending foreground-fallback initial-delay timers and fences off in-flight fallback chains at their suspension points (foregroundFallback.dispose()— no replay, abort, or transcript read continues through the destroyed client), clears the process-global wake-gate progress so the next generation does not inherit the previous generation's two-wake no-progress caps (clearAllWakeSessions()), and explicitly releases companion ownership (companionManager.onExit()— idempotent). The v2setupentry callsresetV2GenerationWarnings(), rearming the one-time degradation latches (model.request ordering drift, permission-rules unavailable, session.list/remove unavailable) so every generation warns once. Background-job persistence resets unconditionally on every generation setup behind an epoch fence, so persisted write queues cannot leak across generations. The event adapter passeslocation.shutdownevents through raw with no synthesis. The user-wait-gate (wait_for_userHITL latch) stays armed across a reload by design, and the admission runtime defers its final scheduler/tracker teardown by one macrotask so an immediate re-init can retain active and queued calls.- Config permission policies are kernel-enforced and deny-final.
experimental.policiessupportsaction: "permission"entries (effect: "allow"/"deny", resource wildcards) enforced by the host kernel. An explicitdenyis final and never reaches the evaluate hooks: a user-configdenycan suppress the plugin's allow rules or permission upgrades, and the plugin's exact-match allow rules on child sessions coexist with user config — on a match the userdenywins over the plugin's allow. No plugin code participates in that resolution. - Provider failure retries are aggressive. The host retries a failing provider call up to 10 times within a total window of about 84 s (4xx responses are never retried). Observation-style hooks therefore fire far more frequently in a failing session than in a healthy one.
session.update({sessionID, title?, permissions?}). Sets the session title and/or REPLACES the session-scoped permission rule list. The client shim's v1-facingsession.update({body: {title}})and the v2 interview bridge's session renames both call it, and the child-session permission bridge applies its rules through thepermissionsfield (see the adoption-status bullet above).session.interrupt({sessionID, resume}).resume: falseaborts the active run; the shim's abort path sends exactly that.experimental.ws.handshake/experimental.ws.send/experimental.ws.receivesession hooks. Official experimental hooks on the pinned surface (the mirror-conformance guard'sOfficialSessionHookNamesset of 12); not registered and not used by this plugin — they appear only in the official-set pin.- Core
# Your Modelidentity system part. v2 core splices a# Your Modelidentity part atsystem[1]on every LLM request. The plugin's system transform keeps appending the orchestrator prompt tosystem[0]and collapses all parts into one — locked by a regression test insrc/index.test.ts. - Subagent
modelparam. The built-insubagenttool takes an optionalmodelargument ("providerID/modelID"). The delegation vocabulary exposes it (modelParam) and the orchestrator/council prompts carry a one-sentence guardrail: only set it when the user explicitly asks for a specific model or variant; never guess the ID — look it up with the models tool first.
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.
- Directory or package entries only. File-path entries (e.g.
…/dist/server.js) are rejected with the WARNconfigured plugin path must be a directory. A directory entry'sindex.jsis the entrypoint — hencedist/serverabove. - Single-file plugins need a wrapper dir whose
index.jsre-exports the original file, e.g.~/.config/opencode/plugins-dev/<name>/index.jscontainingexport { default } from "/abs/path/to/plugin.js";. Do not use the auto-scanned dir namesplugin/pluginsfor 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).
Two mechanisms keep the in-memory background job board honest against the host across process and plugin restarts:
Rehydration re-registers persisted running task tool parts so a plugin
restart does not orphan in-flight background lanes — but a session deleted
while the plugin was down would resurrect as a forever-running ghost.
After rehydration registers a task, the task-session-manager transform
fires a fire-and-forget client.session.get probe per newly registered
taskID:
- Capability-gated, not host-gated — but v2-effective. The probe runs
whenever the client exposes
session.get; hosts without it skip silently. The typed NotFound classification only crosses the v2 plugin boundary (the host passes the raw core effect in-process): the v1 SDK wraps 4xx responses as plainErrorwith a.cause(or returns an{error}tuple whenthrowOnError: false), so on v1 hosts the probe runs but harmlessly never tombstones — the wrapped rejection falls into the transient fail-open path. The probe lives inside the existing task-session-manager transform — no new pipeline step. - NotFound classification is typed, never heuristic. A rejection
tombstones the task only when
err._tag === 'Session.NotFoundError'(property check; neverinstanceofor message matching — the SDK error class identity is unstable across host builds). Cleanup is generation-freshness-guarded: the board record's generation is captured before the asyncget, and a NotFound that resolves after a legitimate same-ID relaunch (new generation, tombstone cleared) skips all cleanup instead of deleting the live relaunched record. On a fresh hit, the four probe cleanup actions run as one synchronous block — supervisoronSessionDeletedfirst (it needs the record to exist so deadline-exceeded runs finalize their wall-clock timeout; same ordering as the event-router/coordinator deletion paths), then the rehydrate tombstone, board drop, and concurrencyreleaseTask(all idempotent; a missingreleaseTaskwould leak an admission slot forever). The canonical full deletion cleanup (input waits, idle tokens, pending-call tracker,clearParent, task-context tracker, snapshots) runs via thesession.deletedevent path. - Any other rejection fails open — the job stays registered and the normal reconciliation paths keep their chance. The probe never rejects unhandled.
- A resolved terminal outcome settles the job through the same
updateStatussemantics as the idle-reconciliation host-outcome path:succeededrequires usable final assistant text (otherwise the textless-completion diagnostics apply, per the #1115 precedent);failed/interruptedsettle as error with the host outcome recorded.
Related injection hardening: a remembered (possibly stale) processed
completion skips cleanly — the fence check runs before the
deletion-epoch fail-closed branch in updateFromInjectedCompletion, so
replaying an old completion after a delete + same-ID relaunch can no
longer poison the fresh generation with markStatusUncertain. Unobserved
completions for a deleted task still fail closed for every provenance
kind.
When the v2 host exposes the optional storage domain, the plugin
persists background-job lifecycle state through
src/utils/background-job-persistence.ts (configured in setup before
the v1 factory runs):
- Tombstones and deletion epochs are write-through: every in-memory ledger mutation queues a matching persisted update, so the persisted state tracks the ledger (writes are fire-and-forget — a crash between the in-memory mutation and the queue flush loses that persisted entry, an accepted degradation to process-local behavior). Clearing a tombstone on a legitimate relaunch is persisted too — a deleted-then-relaunched task is not ghost-skipped after a restart, while its deletion epoch survives for generation fencing (restored epochs keep the epoch counter monotonic).
- Alias counters persist the last-seen counter per
<parentSessionID>:<prefix>. A post-restart board seeds from these high-water marks, so a new alias never collides with a historical one. The alias→taskID mapping itself is not restored — old aliases resolve as not-found after a restart, which is the intended improvement over silently reusing them for unrelated tasks. - Seeding is backend-only. Without
ctx.storage(v1 hosts, hosts without the domain) the module is a pure in-memory no-op sink: zero behavior change, fresh boards and ledgers start exactly as process-local as before. - Bounded and serialized. Persisted tombstones (and their epoch entries) self-cap at the 500 most recent by recorded time; writes for one key are serialized in-process (no concurrent read-modify-write); write failures log and degrade to process-local behavior.
Two log lines aid drift diagnosis (both hosts): the task tool's terminal
output that carries no parsable task id is logged with a ~140-char
preview (task output without a task id — the host-output-drift
detector), and an idle observation for a tracked managed child with no
running board record logs with a [task-session-manager] WARN: prefix
instead of the routine idle line.
Secret redaction at the logger. Every plugin log line — file sink,
stderr fallback, and the append-failure path — passes through
shape-based secret redaction at the logger's single compose point
(src/utils/redact.ts): known vendor token prefixes (sk-, gh*,
glpat-, xox*, AKIA/ASIA), URL credentials
(scheme://user:password@ — password only), authorization schemes
(Bearer/Basic/token), and generic 32+-character opaque runs are masked
to 4 leading + 2 trailing characters. This is a best-effort barrier
against accidental leaks in short previews, not an adversarial
guarantee: unprefixed short secrets, secrets containing run-breaking
characters, and chunked or obfuscated content remain residual gaps,
while long opaque non-secrets (UUIDs, hashes, long paths) are masked as
accepted false positives. The parse-miss preview (task output without a task id) is stricter still: it is structure-only — tag and field
names survive for drift diagnosis, but every value (XML attribute
values, key:/key= prose values) is fully replaced with [masked]
before slicing, because parse-miss content is untrusted-by-format and
values (description fields in particular) carry user-authored text.
All other log sites rely on the shape-based redaction at the logger
choke point.
/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.
- 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/sessionManagerMultiplexerConfiginsrc/index.ts).
The v2 plugin session domain (packages/plugin/src/promise/session.ts,
SessionDomain, mirrored by the runtime object the promise adapter
builds) exposes exactly create/get/switchAgent/switchModel/
prompt/generate/command/synthetic/interrupt/update/move/
wait/context — list and remove are not handed to plugins.
Both endpoints exist on the host's HTTP API, but the
plugin context never receives them. session.status, session.todo, and
session.children are likewise not exposed to plugins — the wake
scheduler's fallback enumeration, the delete no-op below, and the disabled
runtime-status reconciliation (see
Upstream behaviors) all follow from these
gaps. The client shim capability-probes both at runtime
(typeof s.list === 'function', s.remove), so a future host that
extends the domain gets real delegation with no plugin change; on all
v2.0.x stable hosts both probes fail and the shims degrade:
- Children enumeration (orchestrator-wake). The shim's
client.session.listreturns the v1-parity empty page{data: []}, sosession.list({parentID})-based enumeration never yields children and the scheduler always runs its event-tracked fallback (adapter-synthesizedsession.createdparentID links plus tracked busy/idle statuses — the mode the doc's wake section describes as the fallback is effectively the only path on v2). The gate still passes because it probes the shim'slistfunction, which always exists. The interview dashboard's session scan likewise sees no sessions fromliston v2. - Session delete.
client.session.deleteis a no-op: the smartfetch secondary-model temp-session cleanup cannot remove sessions through the shim on v2 (the temp sessions simply persist; nothing fails loudly).
Both degradations announce themselves in the plugin log with a single
deterministic, one-time-per-process warning instead of degrading
silently (list) or logging on every call (remove):
[v2][shim] session.list unavailable on this host build; children enumeration falls back to event tracking
[v2][shim] session.remove unavailable on this host build; session delete is a no-op
The guards are module-level booleans with fixed text — no timestamps, session ids, or per-call payloads — so repeated wake polls and cleanup calls do not flood the log.
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 exposed to
plugins — logged once).
How it differs from the v1 path:
- Gate: v2 requires only the shim's
session.list+promptAsync(session.getis optional model enrichment). v1 keeps its exact historical probe set (get/todo/children/status/promptAsync). - Children enumeration:
session.list({ parentID })through the shim (v2Session.Info→ v1 envelope;outcomeandtime.updatedmapped). When the listing is unavailable (missing/erroring/empty), an event-tracked fallback uses the adapter-synthesizedsession.createdparentID links plus tracked busy/idle statuses — refreshed on every evaluation with the host's authoritativeoutcome/time.updatedviasession.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.session.listis not exposed to plugins on any stable host (see Not exposed to plugins), so the event-tracked fallback is the operative path. 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 — hosttime.updatedor 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"— v1prompt_asyncqueued, and a v2steerwould hijack an in-flight run. The shim'spromptAsynckeepssteeras the default so the foreground-fallback replay is unchanged. The wake model pin carries the session model's variant as the v2-onlymodelVariantargument, soswitchModelpreserves the reasoning-effort setting instead of resetting it to the host default. - Variant-preserving skip (shim-level guard): a variant-less model pin
that already matches the session's current model (same provider + id,
read via
session.getat delivery time) is treated as "continue on this model": the shim skipsswitchModelentirely instead of resetting the variant to default. This covers every internal caller that pins the current model without a variant opinion (wake pins, task-message, same-model fallback steps) even when the pin's source lost the variant. Explicit variants (includingdefaultviamodelVariant) and cross-model pins still switch. Hosts withoutsession.get, or a failingget, keep the legacy variant-free switch. - 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.
- Reduced/TUI-side hosts. Some host processes load the plugin's
setupwith a reduced, TUI-side context that lacksagent.transform(and other domains). The adapter capability-guardssetupand 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 invokessetupwith registration-only domains, so a v1 session's plugin log shows[v2] tool.transform failed-style lines andhealth check passed {"bridges":4}— expected noise from that parallel pass, not breakage. The classicserver()path (a separate plugin-log instance a few seconds apart) carries the full v1 functionality. - TUI-side plugin logs are not captured. The plugin logger initializes
in the server process only, so TUI-side registration failures write no
[v2][tui]lines anywhere. Verify TUI behavior through the host (command availability, on-disk effects), not via the plugin log. - Local-checkout loading. When the plugin is registered from a local
build, the externalized
jsdomimport must resolve from the plugin'snode_modules(webfetch imports it lazily, so the plugin still loads without it — install as a package or ensurejsdomis 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.tsand friends) covers the shared transform code the v2 context hook invokes. The one v2-only addition is CacheHint tagging: parts injected throughcache-safe-injectionwhile the v2 context bridge runs carrycache: {type: "ephemeral"}(v2ContentPart.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.