opencode-v2-compatibility.md 45 KB

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.

The verified compatibility baseline is OpenCode v2.0.3 stable (Sep 12, 2026). The stable line so far — v2.0.0 (Sep 11) through v2.0.3 — shipped 35 commits with zero packages/plugin API changes (every fix landed elsewhere), so the plugin's shim targets are valid across the whole v2.0.x range, not just the newest patch release.

How it works

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 (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). 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)

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 now has 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). 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. ctx.mcp.transform in particular is present in all v2.0.x stable hosts; its probe only ever matters on pre-stable beta builds.

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.abortinterrupt, session.messagescontext, session.prompt (as delivery: "steer"), session.updaterename, session.deleteremove (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). Note that remove and list are 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 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:
    • agentctx.agent.transform (model/prompt/permission adaptation + subagent/execute permission mapping + draft.default("orchestrator")). On v2 hosts the generated orchestrator/council prompts already use native wording (subagent tool, agent param); the tasksubagent prompt rewrite remains only as a fallback for user-custom presets
    • toolctx.tool.transform (zod shape → JSON schema; execute shimmed; every registration carries options: {codemode: false} — see the feature matrix note below)
    • mcpctx.mcp.transform (draft.set(name, adaptMcpServer(cfg)) for the built-in MCPs)
    • commandctx.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)
    • 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.
    • a native ctx.session.hook("model.request") registration (capability-guarded): the v2 equivalent of the v1 chat.headers hook. Fires once per provider request with a mutable headers record the host merges into the outgoing HTTP request. The bridge replays the v1 Copilot initiator-header semantics: for github-copilot / github-copilot-enterprise primary requests whose trailing user message is an internal-initiator admission (orchestrator-wake queue prompts, foreground-fallback replays), it sets x-initiator: agent so Copilot's backend does not account plugin-driven turns as user activity. The internal marker is learned in-band — prompt metadata persisted onto the transcript user message (visible on the context-event envelope) plus an admission tracker for session.synthetic wakes (synthetic admissions skip the prompt hook and the host drops their metadata from the LLM envelope, so the shim records a client-chosen msg_-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-set x-initiator: agent (never resets it to user), so the bridge composes with the built-in. Known deviation: v1 also treats compaction-continuation turns (compaction_continue part 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/afterctx.tool.hook via createToolExecuteBridges (src/v2/setup.ts): the host subagent tool is normalized to v1 task semantics (name mapping, agentsubagent_type, sessionIDtask_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.
    • eventctx.event.subscribe() loop feeding mapV2EventToV1 (src/v2/event-adapter.ts): additive synthesis only — the raw v2 event is always dispatched first (the interview bridge depends on it), then synthesized v1 shapes: flat child session.created → v1 early-registration {info: {id, parentID, agent?}}, flat session.deleted → the v1 deletion-cleanup shape carrying both id spellings the v1 consumers read (properties.info.id for the cache monitor's session eviction, properties.sessionID for the task-session-manager's tombstone/board teardown; no generation is fabricated, so the event-router's unproven-relaunch deletion fence keeps its strength), 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 (permissionaction, patternsresources; permission.replied passes through raw — v2's shape already matches the v1 event). V2 hosts publish durable session.execution.started/ succeeded/failed/interrupted and emit no busy/idle session.status and no session.idle on the event stream — the observed payloads always ride under data (verified live, 80-event capture). The adapter synthesizes the v1 lifecycle shapes from those execution events (started → busy session.status; terminal subtypes → idle session.status + session.idle; failed → a v1 session.error with the host error payload before the idle pair), and no session.status-based fallback remains. The execution-event synthesis keeps orchestrator-wake suppression/arm scheduling and the foreground fallback working on v2 hosts, while the Form and permission bridges above feed the companion's waiting-input indicator and the task-session-manager input-wait gate.
    • generate.text → one-shot generation channel probed on ctx.generate and threaded as experimental_v2.generateText, powering the webfetch secondary-model summaries without a temp 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: <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 only ever matters on pre-stable beta builds
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 (./tuidist/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 ⚠️ 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?, metadata?, data} — the payload is the data record, unlike the v1 SDK's properties (verified live: every observed event keyed ["id","created","type","durable","data"], with the optional metadata? key observed on some events). The adapter reads data first with properties as a legacy fallback and always writes properties on the synthesized v1 shapes, because that is the key the v1 consumers read. The interview bridge's event handler (handleEvent in src/v2/interview-bridge.ts) resolves its payload data-first the same way — reading only properties had left its transcript projection and deletion cleanup dead on live v2 for every event (handleContext is unaffected; it consumes a different event type).
  • session.deleted is 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 reads properties.info.id, while the task-session-manager's deletion handler accepts properties.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 durable session.execution.started/succeeded/failed/interrupted events ({sessionID}, plus error on .failed and reason on .interrupted) and emit no busy/idle session.status and no session.idle on the event stream (session.status remains only in the schema). The adapter synthesizes the v1 lifecycle shapes from the execution events (started → busy session.status; terminal subtypes → idle session.status + session.idle; failed → a v1 session.error with the host error payload passed through best-effort, emitted before the idle pair so the error-then-idle flow the event-router expects is preserved). Without this synthesis the orchestrator-wake scheduler never arms on live v2 hosts.
  • 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; session.status is not exposed to plugins as of v2.0.3), 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).
  • Stable-line plugin API additions (verified in v2.0.3). Between the pre-stable betas and v2.0.0, upstream grew the plugin surface with session hooks compaction, generate, and title (the title and compaction hooks may set result to skip the model call entirely), per-session permission.rules, and TUI ui.tabs.move() (with tabs.open no longer focusing a tab and tabs.focus now opening the tab if needed). The SessionContext request shape also merged generation and providerOptions into a single options object — the plugin is unaffected: its context bridge mutates only system/messages, and cache hints ride ContentPart.cache, which is unchanged. Adoption status: the compaction hook is adopted — the plugin strips its tagged synthetic parts from the compaction input (new in this release); permission.rules is adopted — plugin-managed child sessions receive exact-match task-policy rules once at creation (createPermissionRulesBridge in src/v2/setup.ts; exact-match strings only, no wildcards, while upstream matching semantics settle — PRs #48194/#46495/#46871); the generate session hook (not the ctx.generate text channel the webfetch summaries use), the title hook, and the new tabs methods are not used. The title hook needs no adoption for child sessions: v2 hosts title subagent children deterministically at creation (the subagent tool sets title from its description argument, which the delegation pipeline always supplies) — the earlier "deterministic child titles" future-work item is closed as natively covered; the hook only matters if custom title formats are wanted. Upstream is still actively fixing compaction×hook plumbing and compaction×cache behavior after v2.0.3, 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.status and a session.idle from each terminal execution event, so a consumer watching both sees idle twice per terminal transition. Current consumers are idempotent per session (idle-reconciliation's per-session timer guards); new idle consumers must tolerate duplicate delivery.
  • 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):

{
  "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.

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/<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.

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).

Background job state: rehydrate probe and persistence

Two mechanisms keep the in-memory background job board honest against the host across process and plugin restarts:

Rehydrate existence probe (session.get)

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 plain Error with a .cause (or returns an {error} tuple when throwOnError: 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; never instanceof or 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 async get, 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 — supervisor onSessionDeleted first (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 concurrency releaseTask (all idempotent; a missing releaseTask would leak an admission slot forever). The canonical full deletion cleanup (input waits, idle tokens, pending-call tracker, clearParent, task-context tracker, snapshots) runs via the session.deleted event 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 updateStatus semantics as the idle-reconciliation host-outcome path: succeeded requires usable final assistant text (otherwise the textless-completion diagnostics apply, per the #1115 precedent); failed/interrupted settle as error with the host outcome recorded.

Related injection hardening: a remembered (possibly stale) processed completion now 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.

Persistence via ctx.storage (v2)

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.

Diagnostics

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.

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 <interview_state> 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).

Not exposed to plugins: session.list / session.remove

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/rename/move/ wait/contextlist and remove are not handed to plugins (as of v2.0.3). 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 as of v2.0.3 — 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.list returns the v1-parity empty page {data: []}, so session.list({parentID})-based enumeration never yields children and the scheduler always runs its event-tracked fallback (adapter-synthesized session.created parentID 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's list function, which always exists. The interview dashboard's session scan likewise sees no sessions from list on v2.
  • Session delete. client.session.delete is 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 remove warning used to fire once per delete attempt).

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 exposed to plugins as of v2.0.3 — 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 — refreshed on every evaluation with the host's authoritative outcome/time.updated via session.get (fail-soft per child). A finished child is therefore terminal immediately instead of reading active for the whole staleness window, and a live child stays visible on its host evidence rather than dropping out on stale local evidence. As of v2.0.3, session.list is 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 — 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. The wake model pin carries the session model's variant as the v2-only modelVariant argument, so switchModel preserves the reasoning-effort setting instead of resetting it to the host default.
  • 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.get at delivery time) is treated as "continue on this model": the shim skips switchModel entirely 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 (including default via modelVariant) and cross-model pins still switch. Hosts without session.get, or a failing get, 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.

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.
  • 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 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.