fix(opencode): support OpenCode 2 plugin API (V1/V2 dual export) - #1171
Scratchydisk wants to merge 7 commits into
Conversation
OpenCode 2's plugin loader ignores the V1 `{ id, server }` shape entirely
(opencode.ai/v2/docs/migrate-v1: "V1 plugin implementations do not run in
V2") — it only recognizes a default export produced by
`Plugin.define({ id, setup })` from `@opencode/plugin`. Verified against a
real opencode@2.0.3 install: pointing `"plugins": ["context-mode"]` at the
currently-published package fails immediately with
`PluginModule.LoadError: Plugin must export a default definition with an
id and an effect or setup function.` The same repro against this fix's
build loads cleanly.
Adds a V2 `setup()` entrypoint alongside the untouched V1 `server()`,
following OpenCode's own documented "support V1 and V2 from one package"
pattern — both are spread into one default export, so OpenCode 1.x and
KiloCode keep calling `server()` unchanged while OpenCode 2 reads `id`/
`setup()` and ignores `server()`. Hook-for-hook mapping (per OpenCode's
migration guide, e.g. tool.execute.before -> ctx.tool.hook("execute.before"),
chat.message -> ctx.session.hook("prompt"), the tool map ->
ctx.tool.transform) is documented inline above `setupContextModePluginV2`.
`@opencode/plugin` is imported dynamically (not statically) so a stale
install missing that dependency degrades to V1-only instead of breaking
the whole module for existing users. Zero changes to V1 behavior — all
120 existing opencode-plugin/adapter tests still pass unchanged, plus 2
new tests locking in the dual-export shape.
…ed names OpenCode 2's editor.add() defaults tools to codemode: true, which keeps them out of the model's direct tool list -- they are only reachable by writing code against the execute tool's catalog. Routing enforcement, meanwhile, tells the model to call context-mode_ctx_execute / ctx_fetch_and_index / ctx_search directly, and the V2 registration used the bare ctx_* names, so the redirect pointed at tools that did not exist. Register the V2 tools with codemode: false and under createToolNamer(platform) names, the same names the routing block and execute.before redirects use. Observed on OpenCode 2.0.7: before, the model's direct tool list had no ctx_* tools while curl was redirected to context-mode_ctx_execute; after, all ctx_* tools are direct and the redirect names match. Adds a test that runs the real V2 setup() against a fake OpenCode 2 context and asserts codemode: false plus routed names (red without the fix).
…option
OpenCode 2 composes the model-visible tool name as <namespace>_<name>. Instead
of baking the routed prefix into the name, split toolNamer(name) into
{ name, namespace } (v2ToolIdentity) so the tools use the host's native
namespace field while the visible names stay exactly what the router tells the
model to call. Any namer shape other than a clean <prefix>_<tool> (bare names,
mcp__x__tool) is registered verbatim with no namespace.
Verified on OpenCode 2.0.7: the model sees context-mode_ctx_execute etc., and
context-mode_ctx_execute runs.
Tests: v2ToolIdentity unit cases; the setup() test now asserts, per tool, that
the host-composed visible name equals namer(name) (no prefix-startsWith
tautology), awaits the transform callback, and removes its abort listener.
hooks/core/routing.mjs only redirects curl/wget and large-output commands when isMCPReady() finds a live readiness sentinel, which the stdio MCP server writes from its main(). With native V2 tools there is no MCP server, so the redirects silently depended on an unrelated context-mode MCP process (e.g. another client's) being alive: on a host with none, curl ran straight through. The V2 setup() now writes the same sentinel for its own process (PID contents, 30s refresh against the reader's 90s freshness window, unref'd timer) and removes it on dispose. Observed on OpenCode 2.0.7: curl was redirected on a machine where a Claude Code context-mode MCP happened to be running and not on one without; with this change it is redirected on both. The V1 native-tool path looks to share this dependency; left untouched here as it was not tested against a live OpenCode 1.x host.
|
Thanks for this port. I ran it against a live OpenCode 2.0.7 server, and it loads and hooks fine. Three things stopped it being automatic there, though, and I've put fixes for them in a PR against your branch, so they can go into this PR: Scratchydisk#1 In short:
Each fix has a test that fails without it, and all three are verified live on 2.0.7. Details are in the linked PR. |
|
Huge thanks to @qoole for the three follow-up commits — they've now been folded into this branch (fast-forward
Verified live against opencode @qoole — grateful for the live-host testing on 2.0.7 that surfaced all three; the |
|
Reproduced on opencode 2.0.10, the newest release — same load error as described in the PR body. Environment
ReproFails on every session start, not intermittently. Two consecutive starts produced the same rejection: with the cause reducing to: followed by a SchemaError naming the two missing keys under the default export: Static confirmationThe entry declared in export default { id: "context-mode", server: createContextModePlugin };
export { createContextModePlugin as ContextModePlugin };Checks against that file: On the failure modeThe only signal is a WARN in the background service log. Version coverageThe PR describes live verification against 2.0.3 / 2.0.4 / 2.0.7. The same load error reproduces on 2.0.10, so the V2 API surface the fix targets has not drifted in a way that would break the approach. |
…2 export shape Plugin.define() in @opencode/plugin is the identity function, so the dynamic import that built the V2 default-export shape added no behavior — only a dependency that isn't guaranteed to resolve on every host. When it failed to resolve, the code silently fell back to a V1-only shape missing `setup`, which OpenCode 2 rejects outright. Reported on upstream mksglu#1171 by imyu37: opencode 2.0.10 standalone Windows binary, installed via `opencode plugin add`, failed to load with "Plugin must export a default definition with an id and an effect or setup function." Reproduced live against the real opencode 2.0.10 CLI (Linux) by hiding @opencode/plugin from node_modules at runtime — got the identical LoadError — then confirmed the fix loads clean under the same conditions. Windows itself is unverified; no Windows box to run the actual installer end-to-end on.
…2 export shape Plugin.define() in @opencode/plugin is the identity function, so the dynamic import that built the V2 default-export shape added no behavior — only a dependency that isn't guaranteed to resolve on every host. When it failed to resolve, the code silently fell back to a V1-only shape missing `setup`, which OpenCode 2 rejects outright. Reported on upstream mksglu#1171 by imyu37: opencode 2.0.10 standalone Windows binary, installed via `opencode plugin add`, failed to load with "Plugin must export a default definition with an id and an effect or setup function." Reproduced live against the real opencode 2.0.10 CLI (Linux) by hiding @opencode/plugin from node_modules at runtime — got the identical LoadError — then confirmed the fix loads clean under the same conditions. Windows itself is unverified; no Windows box to run the actual installer end-to-end on.
|
Pushed a fix for @imyu37's report above ( Root cause: the V2 default-export shape was built via Fix: build the V2 shape inline, no import — Reproduced and verified live, not just in unit tests: built the pre-fix code, hid Caveat: I don't have a Windows machine, so I can't run the actual |
… failure Adds a regression test proving the merged default export still exposes a working v2 setup() even when @opencode/plugin cannot be resolved on the host (the mksglu#1171 failure mode: opencode plugin add skipping full dependency resolution). Our default export is a plain object literal that never depends on that import, so the test passes by construction — but it locks the invariant against a future reintroduction of a dynamic-import-with- fallback that would silently drop setup and make OpenCode 2 reject the whole plugin. Verified the guard is real by temporarily swapping in the buggy await-import + fallback pattern: the test fails with "expected 'undefined' to be 'function'", then reverted. typecheck EXIT 0; v2 suite 57 pass.
… failure Adds a regression test proving the merged default export still exposes a working v2 setup() even when @opencode/plugin cannot be resolved on the host (the mksglu#1171 failure mode: opencode plugin add skipping full dependency resolution). Our default export is a plain object literal that never depends on that import, so the test passes by construction — but it locks the invariant against a future reintroduction of a dynamic-import-with- fallback that would silently drop setup and make OpenCode 2 reject the whole plugin. Verified the guard is real by temporarily swapping in the buggy await-import + fallback pattern: the test fails with "expected 'undefined' to be 'function'", then reverted. typecheck EXIT 0; v2 suite 57 pass.
|
Thanks for the quick turnaround — I can see One factual clarification from my side, about the exact artifact my log came from. My report was against the published npm
export default { id: "context-mode", server: createContextModePlugin };So on the published package the missing Both cases happen to produce the identical Environment facts unchanged from my earlier comment: opencode 2.0.10, Windows x64 standalone, installed with |
…s in mksglu#1171 regression framing imyu37 clarification 2026-09-24: published 1.0.169 never had V2 code (no setup, no @opencode/plugin ref) — fix is merge+release. Pre-fix branch separately had silent fallback when dynamic import failed — hardened by inline {id, setup}. No logic change, comment-only.
…s in mksglu#1171 regression framing imyu37 clarification 2026-09-24: published 1.0.169 never had V2 code (no setup, no @opencode/plugin ref) — fix is merge+release. Pre-fix branch separately had silent fallback when dynamic import failed — hardened by inline {id, setup}. No logic change, comment-only.
|
You're right, thanks for re-checking the installed artifact — correction accepted. To keep the two cases distinct:
Same Still unverified on my side: actual |
|
Hello, Just confirming this patch fixes context-mode on opencode 2.0.16 tried it via the TUI and also via Openchamber v2 Thanks! A small quirk though: PR #1171 fixes loading under OpenCode 2.0.16 and ctx_doctor/ctx_stats become available. However, ctx_doctor selects Gemini CLI when ~/.gemini exists, and ctx_stats reports OpenCode as “Skipped … no real chat activity.” This suggests V2 tool registration works, but platform detection and/or OpenCode activity capture may not. |
|
Thank you @jfayad — both for confirming the load fix on 2.0.16 and for the sharp follow-up. The quirk you found is real, and I dug into both halves: Doctor picking Gemini CLI: root cause is in Stats showing OpenCode as "Skipped": I verified every V2 hook payload field name against the published One thing I couldn't explain from here: "Skipped" means zero captured events of any type, and the |
… hooks Follow-up to jfayad's mksglu#1171 report (opencode 2.0.16): - withPinnedPlatform(): inside the OpenCode 2 server process no OPENCODE_* markers are set, so detectPlatform() fell through to ~/.claude / ~/.gemini fallbacks and ctx_doctor misreported the platform. Pin CONTEXT_MODE_PLATFORM to the plugin's known platform around ctx_* handler execution (V1 bridge + V2 registration); explicit user override wins, value restored afterwards. - Usage capture via session.step.started/ended correlation (parseOpencodeV2StepUsage): message.updated does not exist on the v2 bus schema (verified against @opencode/plugin@2.0.16 types), so the old filter could never fire. Model correlated by assistantMessageID, reasoning folded into output, native USD cost verbatim; message.updated kept as fallback. - Collect every hook/transform Registration dispose; release on cleanup and tear down partial registrations if setup fails mid-way. Tests: 13 new (pin unit x3, lifecycle x2, V1/V2 doctor wiring with gemini decoy, step correlation e2e, parser x5). Targeted suites 140 passed, typecheck clean. Live on 2.0.16 scrubbed-env standalone: zero load errors, ctx_doctor resolves the OpenCode adapter, ctx_stats reports real totals.
|
Code update pushed to this branch (5bf8ab5) covering the jfayad follow-ups discussed above:
Verification: 13 new tests (targeted suites 140 passed, typecheck clean) plus a live scrubbed-env standalone run on opencode 2.0.16 — zero load errors, |
I might have an error in my install ? I re-ran the command and here's the new output: context-mode 37 min 4 calls 10.7 KB entered context | 0 tokens saved Persistent memory ✓ preserved across compact, restart & upgrade Skipped (4): Gemini CLI, Antigravity, Antigravity CLI, OpenCode ───────────────────────────────────────────────────────────────── v1.0.169 |
Supersedes #1169 (closed to allow further local testing — same branch, force-pushed since to rebase onto current
main, which is why GitHub wouldn't let me reopen it directly).Summary
OpenCode 2's plugin loader ignores the V1
{ id, server }shape this package'ssrc/adapters/opencode/plugin.tsexports — per OpenCode's own migration guide (opencode.ai/v2/docs/migrate-v1): "V1 plugin implementations do not run in V2." It only recognizes a default export produced byPlugin.define({ id, setup })from the new@opencode/pluginpackage.Repro against the currently-published package (verified against a real
opencode@2.0.3/2.0.4install)ctx_*tools never register, no hooks fire, andopencode plugin listsilently shows nothing for it — worse than a crash, since there's no indication anything is wrong until a user notices the tools are gone.Fix
Adds a V2
setup()entrypoint alongside the existing V1server(), following OpenCode's own documented "support V1 and V2 from one package" pattern (opencode.ai/v2/docs/build/plugins#support-v1): spreadPlugin.define({ id, setup })into the same default-export object that already carriesserver. OpenCode 1.x and KiloCode keep callingserver()— completely unchanged — while OpenCode 2 readsid/setup()off the same object and ignoresserver().The V2
setup()re-implements all five hooks using OpenCode 2's documented mapping (opencode.ai/v2/docs/build/plugins/migrate-v1):tool.execute.beforectx.tool.hook("execute.before", ...)tool.execute.afterctx.tool.hook("execute.after", ...)event(message.updated)ctx.event.subscribe()chat.messagectx.session.hook("prompt", ...)experimental.session.compactingctx.session.hook("compaction", ...)experimental.chat.system.transformctx.session.hook("context", ...)toolmapctx.tool.transform(editor => editor.add(...))The custom
ctx_*tools now declare JSON Schema (required by V2) instead of a Zod shape, reusing the existingzod3ShapeToV4conversion (added for #574) plus Zod v4's owntoJSONSchema().@opencode/pluginis imported dynamically (await import(...)inside a try/catch), not as a static top-level import, so a stale/partial install missing that dependency degrades to V1-only instead of breaking the whole module for existing OpenCode 1.x/KiloCode users.Verification
This branch has now been tested two ways:
opencode@2.0.3package cache and confirmed the unmodified published package fails with the exact error above while this branch's build loads with no error.~/.cache/opencode/.../node_modules/context-modeon a machine actually running this plugin day-to-day (realopencode.jsonwith"plugins": ["context-mode", ...], real provider credentials), restarted the OpenCode background service (which picked it up along with an unrelated OpenCode 2.0.3→2.0.4 self-update), and confirmed:"failed to load plugin"entries forcontext-modein the service log (previously, every single load attempt failed with the schema error above).ctx_statstool and got back a fully-formed real report pulled from the actual session database (event counts, per-tool capture stats, lifetime cost) — end-to-end proof thatPlugin.define/setup()→ctx.tool.transform→ tool execution → session DB all work correctly under a real OpenCode 2 session.All 122 pre-existing
opencode-plugin/adapters/opencode/parse-opencode-usagetests pass unchanged — zero behavior change to the V1/KiloCode path. Plus 2 new tests locking in the dual-export shape.npm run typecheckandnpm run buildboth clean.Known gap for maintainer review
The exact field names on OpenCode 2's
ToolExecuteBefore/ToolExecuteCompleted/ToolExecuteFailedevent types aren't spelled out in the public docs beyond short examples (event.tool,event.input,event.status,event.result,event.error). I inferredevent.sessionIDby analogy with every other hook interface OpenCode documents (SessionRequestHook,SessionRetryHook,PermissionEvaluation, etc. all expose it), and defensively no-op when it's absent. The routing-enforcement path (tool.execute.before/after→ session event capture) hasn't yet been exercised against a live agent turn that actually triggers a blocked/modified tool call — worth a maintainer smoke test before release, though thectx_*tool-execution path itself (registration, JSON Schema conversion, execute, result handling) is now confirmed working live.Test plan
npm run typechecknpm run buildnpx vitest run tests/opencode-plugin.test.ts tests/adapters/opencode.test.ts tests/session/parse-opencode-usage.test.ts(122 passed)ctx_statstool call succeeds end-to-end against a real session DBtool.execute.before/after(routing enforcement + session capture) against a live OpenCode 2 session with a real blocked/modified tool call