opencode2) Compatibilityoh-my-opencode-slim installs and runs on both OpenCode v1 (opencode)
and OpenCode v2 (opencode2) from a single published package. This document
describes how each host loads the plugin, what is supported where, and how to
register it.
The 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.
The package's default export is an object:
export default {
id: 'oh-my-opencode-slim',
server: OhMyOpenCodeLite, // v1 plugin function (PluginInput) => Promise<Hooks>
setup: createV2Setup(), // v2 promise-plugin setup (ctx) => Promise<cleanup>
};
There is deliberately no tui key on this export: hosts validate a
server plugin module's tui field (it must be a function and must not
coexist with server), so a boolean tui: true marker gets the whole
plugin rejected with "invalid tui export".
readV1Plugin in packages/opencode/src/plugin/shared.ts)
detects an object with a server field and calls plugin.server(input)
with the full v1 PluginInput. Extra keys (such as setup) are ignored on
this path.plugin: entries to
plugins:) and calls setup(ctx) with a registration-only context
(agent/aisdk/catalog/command/integration/plugin/reference/skill — no
tool/session/event/mcp/generate). A dual-export plugin registered via the
v1 plugin: key therefore gets both invocations: full v1
functionality flows through server(), while the parallel pass produces
the expected [v2] … failed / bridges: 4 log noise (see
Environment caveats). A v2 plugins: entry yields
the setup pass alone — v1 does not convert v2 plugin declarations into v1
hooks.PluginModule schema in
packages/core/src/plugin/supervisor.ts) decodes default as
{ id, setup } (Effect Schema 4 rejects function defaults) and calls
setup(ctx) via the promise-plugin bridge../tui entry unconditionally: the TUI runtime runs
its own kind: "tui" loader pass over the same plugin list and resolves
the entry through the package's exports["./tui"] map — the server-side
export plays no role in that discovery.Three builds are produced:
| Export | File | Build | Externals |
|---|---|---|---|
. (main) |
dist/index.js |
build:plugin |
zod, jsdom, @opencode-ai/, @opentui/ (shared with v1 host) |
./server |
dist/server/index.js |
build:v2 |
jsdom only (self-contained for v2) |
./tui |
dist/tui2.js |
build:tui |
same external set as build:plugin (composes the v1 TUI entry; inlines zod) |
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.
src/v2/setup.ts)setup(ctx) wraps the existing v1 factory rather than reimplementing it:
PluginInput from the v2 context
(src/v2/client-shim.ts): the project directory from ctx.location,
and a shim client that really delegates the v1 SDK call shapes to
v2 flat session calls — session.get, session.abort→interrupt,
session.messages→context, session.prompt (as delivery: "steer"),
session.update→rename, session.delete→remove (same
DELETE /api/session/:id; stops the smartfetch secondary-model temp
sessions leaking), and session.list (v2 Session.Info page → the v1
{data} envelope with directory derived from location and outcome
mapped, used by the interview dashboard's session scan and the
orchestrator-wake children enumeration). 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).OhMyOpenCodeLite(pluginInput) to reuse all existing build
logic (config, agents, tools, hooks, job board, multiplexer, companion).config() hook against a synthesized config to resolve agent
models and the slash commands.Hooks into v2 registrations:
agent → ctx.agent.transform (model/prompt/permission adaptation +
subagent/execute permission mapping + draft.default("orchestrator")).
On v2 hosts the generated orchestrator/council prompts already use
native wording (subagent tool, agent param); the task→subagent
prompt rewrite remains only as a fallback for user-custom presetstool → ctx.tool.transform (zod shape → JSON schema; execute
shimmed; every registration carries options: {codemode: false} —
see the feature matrix note below)mcp → ctx.mcp.transform (draft.set(name, adaptMcpServer(cfg)) for
the built-in MCPs)command → ctx.command.transform — v2 command drafts are add-only:
draft.add({name, description, execute}). execute submits a
<omos-cmd-command data-name="..."> marker as a user prompt; the
session context hook recovers it and dispatches to the v1
command.execute.before hook (deepwork/reflect/loop)ctx.session.hook("context") handles the system/messages
transforms (SystemPart[]/Message.content shape conversion),
chat.message agent tracking, and interview + generic command marker
dispatch — mutating only the trailing message so earlier content stays
byte-identical (provider prompt-cache prefix reuse). While the
bridged messages transform runs, parts injected through
cache-safe-injection carry a v2 ContentPart.cache
{type: "ephemeral"} hint (CacheHint tagging) so providers that
honor manual breakpoints cap the injected zone's cache contribution;
the hint is scoped per request (an AsyncLocalStorage scope around
the bridged transform) so concurrent sessions' transforms cannot
interleave their set/restore, and the v1 pipeline never enters the
scope, so v1 payload bytes never change.ctx.session.hook("prompt") registration (capability-
guarded): the v2 prompt hook fires once per admitted input with
the eventual inbox User messageID, giving the v1 chat.message
consumers (task-session-manager / orchestrator-wake
observeChatMessage, toolLoopGuard.observeNewUserMessage) true
once-per-admission fidelity with prompt parts. The FIRST admitted
prompt per session is deferred until the first agent-bearing
context event arrives, then delivered once with parts + agent
together — the v1 chat.message handler only registers the session
agent when a delivery carries one, and its consumers gate on that
registration, so an agent-less first forward would be dropped (lost
input-wait latch clearing / wake-progress rearm). Bounded fallbacks
(next admission, or a context event whose trailing user message has
moved past the pending one) flush a still-pending prompt best-known
when no agent is ever learned. When the prompt hook registers, the
context hook's per-request chat.message emulation narrows to
agent/model discovery; hosts that reject the hook name keep the
full emulation as fallback.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/after → ctx.tool.hook via
createToolExecuteBridges (src/v2/setup.ts): the host subagent
tool is normalized to v1 task semantics (name mapping, agent→
subagent_type, sessionID→task_id, and back after the hook so
v2 executes the repaired input). A throwing execute.before
rethrows — v2 rejects the tool call, which is how the v1
anti-duplicate / relaunch-lease guards enforce on v2. The after
bridge honors v2's status discrimination: error events synthesize
the v1 after-hook output from the error text (so json-error-recovery
still appends its reminder to a failed call's output), and an
errored call never presents its result content as a success.event → ctx.event.subscribe() loop feeding mapV2EventToV1
(src/v2/event-adapter.ts): additive synthesis only — the raw v2 event
is always dispatched first (the interview bridge depends on it), then
synthesized v1 shapes: flat child session.created → v1
early-registration {info: {id, parentID, agent?}}, 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 (permission ← action, patterns ← resources;
permission.replied passes through raw — v2's shape already matches
the v1 event). V2 hosts publish durable session.execution.started/
succeeded/failed/interrupted and emit no busy/idle session.status
and no session.idle on the event stream — the observed payloads
always ride under data (verified live, 80-event capture). The
adapter synthesizes the v1 lifecycle shapes from those execution
events (started → busy session.status; terminal subtypes → idle
session.status + session.idle; failed → a v1 session.error
with the host error payload before the idle pair), and no
session.status-based fallback remains. The execution-event
synthesis keeps orchestrator-wake suppression/arm scheduling and the
foreground fallback working on v2 hosts, while the Form and
permission bridges above feed the companion's waiting-input
indicator and the task-session-manager input-wait gate.generate.text → one-shot generation channel probed on ctx.generate
and threaded as experimental_v2.generateText, powering the webfetch
secondary-model summaries without a temp sessiondispose → returned cleanupEach bridge is independently try/catch-guarded so one failure cannot disable the rest, and a zero-registration load logs a loud health-check warning.
| Capability | v1 (opencode) |
v2 (opencode2) |
Notes |
|---|---|---|---|
| Orchestrator + specialist agents, prompts & permission mapping | ✅ | ✅ ctx.agent.transform |
— |
Delegation + background job board + task_* tools |
✅ task tool |
✅ host subagent (auto-bridged: name/args normalization in src/v2/delegation.ts, output parsing in the execute bridges) |
— |
| Tools (ast-grep, webfetch, task_message/task_cancel/task_revive, wait_for_user, acp_run) | ✅ | ✅ ctx.tool.transform |
v2 requires options: {codemode: false} on each registration (CodeMode split): without it a tool registers cleanly but is confined to the execute tool's JS runtime and session catalogs yield Unknown tool: <name>. The plugin stamps it on every adapted tool (adaptTool in src/v2/adapters.ts; additive field, older hosts ignore it). ast-grep needs its CLI binary (package, system, or lazy download); webfetch needs jsdom resolvable |
Slash commands /deepwork /reflect /loop |
✅ | ✅ marker round-trip | — |
/interview |
✅ | ✅ marker command + trailing-message context bridge | — |
| Message transforms (phase reminder, skills filter, image routing, display-name rewrite) | ✅ | ✅ via the single context hook | — |
| Event handling (session tracking, lifecycle, cache telemetry) | ✅ | ✅ event pump + additive v2→v1 synthesis | — |
| Tool execute hooks (apply-patch recovery, task-session, json-recovery) | ✅ | ✅ createToolExecuteBridges with subagent→task normalization |
— |
| Built-in MCPs (context7, gh_grep) auto-registered | ✅ | ✅ ctx.mcp.transform |
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 (./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 | ✅ | ⚠️ unverified | independent desktop app; test separately against v2 |
Behaviors of v2 itself that plugin authors should know about — none currently break this plugin:
data, not properties. The v2
event stream (SSE and ctx.event.subscribe()) frames each event as
{id, created, type, location?, durable?, metadata?, data} — the payload
is the data record, unlike the v1 SDK's properties (verified live:
every observed event keyed ["id","created","type","durable","data"],
with the optional metadata? key observed on some events).
The adapter reads data first with properties as a legacy fallback and
always writes properties on the synthesized v1 shapes, because that is
the key the v1 consumers read. 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.session.execution.*. V2 hosts publish durable
session.execution.started/succeeded/failed/interrupted events
({sessionID}, plus error on .failed and reason on
.interrupted) and emit no busy/idle session.status and no
session.idle on the event stream (session.status remains only in the
schema). The adapter synthesizes the v1 lifecycle shapes from the
execution events (started → busy session.status; terminal subtypes →
idle session.status + session.idle; failed → a v1 session.error
with the host error payload passed through best-effort, emitted before
the idle pair so the error-then-idle flow the event-router expects is
preserved). Without this synthesis the orchestrator-wake scheduler never
arms on live v2 hosts.{id, time, text,
type} only — no agent, no sessionID. The v1 injection gates
(phase-reminder, background-job-board, post-file-tool-nudge) key on
user-message info.agent/info.sessionID, so every injection would
skip. The v2 context bridge stamps the context event's sessionID and
the session's known agent (from the event, falling back to the
session-prompt bridge's learned state) onto transcript user messages
before the bridged messages transform runs — metadata-only envelope
enrichment, strictly absence-gated (host-provided values never
overwritten), parts/content bytes untouched, idempotent across
context events. This also makes the CacheHint-tagged injected parts
observable on live v2 hosts.client.session.status
is not a function on v2 hosts; 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).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.session.status and a session.idle from each terminal execution event,
so a consumer watching both sees idle twice per terminal transition.
Current consumers are idempotent per session (idle-reconciliation's
per-session timer guards); new idle consumers must tolerate duplicate
delivery.permission.asked delivery. The adapter appends a
v1-field-mapped copy after the raw v2 permission.asked event (raw
first is a load-bearing invariant for v2-native handlers). Consumers
watching both see the ask twice with the same request id — safe because
every ask consumer is idempotent per request id (the input-wait
tracker's Set, the companion's status setters, wake suppression); new
ask consumers must tolerate it, like idle.question.* with the Form
flow; the adapter synthesizes question.asked/replied/rejected from
form.created/replied/cancelled so v1 consumers keep working. Forms
owned by the "global" sentinel session (MCP elicitation) are not
synthesized — v1 question events are session-scoped."mcps": ["context7", "!gh_grep"] in agent config), and registration
uses its own server names via draft.set(name, ...).Add the npm package, pinned to an exact version — v2 auto-refreshes
unpinned npm plugins on every startup, so @latest effectively means
"silently upgrade whenever a new version ships". The global config root is
~/.config/opencode/opencode.json, shared with v1 (~/.config/opencode2/
is not read for plugin config):
{
"plugin": ["oh-my-opencode-slim@2.2.17"]
}
For local development, point the config at the built dist/server
directory:
{
"plugin": ["/path/to/oh-my-opencode-slim/dist/server"]
}
Then build:
bun install
bun run build # produces dist/index.js (v1), dist/server/index.js (v2
# server bundle, also served via the ./server subpath),
# dist/tui2.js (v2 TUI), dist/cli/
Verify with opencode2 run "list your specialist agents" --standalone — the
orchestrator should name explorer, librarian, oracle, designer, fixer.
…/dist/server.js) are rejected with the WARN
configured plugin path must be a directory. A directory entry's
index.js is the entrypoint — hence dist/server above.index.js re-exports the
original file, e.g. ~/.config/opencode/plugins-dev/<name>/index.js
containing export { default } from "/abs/path/to/plugin.js";. Do not
use the auto-scanned dir names plugin/plugins for wrapper dirs — a
scanned duplicate next to an explicit registration hard-dies on duplicate
plugin ID.Agent models are resolved the same way as v1 (per-agent model in
oh-my-opencode-slim.json, or inherited from the session/host default). On
v2, set a working provider+model in your config or the plugin's config file
so delegated subagents can run.
When the foreground model hits a rate limit, the plugin switches the
session's model (session.switchModel) and steers the re-prompt through
delivery: "steer". A failing switchModel call degrades honestly: the
re-prompt is still delivered (on the current model) and the plugin's logs
record that no switch happened — the fallback chain is not aborted. On
hosts without session.switchModel, the fallback replay is rejected with
a clear error instead of silently replaying on the model that just failed
(other prompt callers, like the orchestrator-wake scheduler, only pin the
current model and keep steering).
Two mechanisms keep the in-memory background job board honest against the host across process and plugin restarts:
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:
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.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.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.
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):
<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.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.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.
shouldEnableMultiplexer / sessionManagerMultiplexerConfig in
src/index.ts).session.list / session.removeThe 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/context — list 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:
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.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).
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:
session.list + promptAsync
(session.get is optional model enrichment). v1 keeps its exact
historical probe set (get/todo/children/status/promptAsync).session.list({ parentID }) through the shim
(v2 Session.Info → v1 envelope; outcome and time.updated mapped).
When the listing is unavailable (missing/erroring/empty), an event-tracked
fallback uses the adapter-synthesized session.created parentID links plus
tracked busy/idle statuses — 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.outcome === undefined (v2 records an
outcome only on terminal transition: succeeded|failed|interrupted) that
still have fresh update evidence — host time.updated or a tracked status
change newer than 3× the wake interval (staleness bound for children that
crash mid-run without recording an outcome). Stopped-job recovery wakes
bypass the condition, as on v1.delivery: "queue" — v1 prompt_async queued, and a
v2 steer would hijack an in-flight run. The shim's promptAsync keeps
steer as the default so the foreground-fallback replay is unchanged.
The wake model pin carries the session model's variant as the v2-only
modelVariant argument, so switchModel preserves the reasoning-effort
setting instead of resetting it to the host default.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.v2's built-in subagent tool still posts completion notifications to the
parent natively — that covers the happy path. What the port adds is a
periodic watchdog: an idle parent with a stuck or unreconciled child (or a
job that stopped without a terminal result) gets woken to assess, cancel, or
respawn, bounded by the same no-progress cap as v1.
setup
with a reduced, TUI-side context that lacks agent.transform (and other
domains). The adapter capability-guards setup and skips registration
gracefully for those hosts instead of crashing or retry-storming. The
same applies to the embedded v2 pass inside every v1 host: it invokes
setup with registration-only domains, so a v1 session's plugin log
shows [v2] tool.transform failed-style lines and
health check passed {"bridges":4} — expected noise from that parallel
pass, not breakage. The classic server() path (a separate plugin-log
instance a few seconds apart) carries the full v1 functionality.[v2][tui] lines anywhere. Verify TUI behavior through the host (command
availability, on-disk effects), not via the plugin log.jsdom import must resolve from the plugin's
node_modules (webfetch imports it lazily, so the plugin still loads
without it — install as a package or ensure jsdom is resolvable to
enable webfetch locally). AST-grep resolves its CLI independently and
lazily downloads a binary when no package or system binary is available.src/hooks/cache-safety.property.test.ts and friends) covers the
shared transform code the v2 context hook invokes. The one v2-only
addition is CacheHint tagging: parts injected through
cache-safe-injection while the v2 context bridge runs carry
cache: {type: "ephemeral"} (v2 ContentPart.cache). The hint is
applied via a per-request scoped default (AsyncLocalStorage — the v2
host serves different sessions' requests concurrently, so the scope
must be isolated per bridged transform) inside the v2 bridge only — v1
callers never set it, so the v1 payload (and its snapshots) stay
byte-identical.