Summary
OpenCode V2 ships two mutually incompatible plugin contracts, and a V1 plugin cannot be made to load on V2 by wrapping its export shape. There is no migration path for plugins that observe runtime activity (tool executions, chat messages, compaction) or register custom tools.
Verified against OpenCode v2.0.22 on Windows.
The break
V1 plugins are an async factory returning a hooks object:
export default async ({ client }) => ({
"tool.execute.after": async (input, output) => { /* ... */ },
})
V2 validates the module with Schema.Struct and accepts only two shapes:
// packages/core/src/config/plugin/external.ts
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({ id: Schema.String, effect: Schema.declare(...) }),
Schema.Struct({ id: Schema.String, setup: Schema.declare(...) }),
]),
})
A V1 module fails to load with:
Plugin must export a default definition with an id and an effect or setup function.
(cause: SchemaError(Expected object at ["def...
Wrapping the export does not help
The migration guide is explicit that the APIs are not translated into each other:
Keep each implementation on its own API; sharing an export does not translate V1 hooks into V2 hooks.
So a shim that adds id + setup() around the V1 factory loads successfully and then silently records nothing. That is worse than the current hard failure: the user believes memory/context capture is running when no hook is ever invoked.
The gap is in the V2 plugin API surface, not the loader
The V2 PluginContext does have equivalents for every V1 hook — this part is fine. What is missing is a way for a V1-shaped plugin to reach them without a rewrite:
| V1 hook |
V2 equivalent |
available in v2.0.22 |
tool.execute.after |
ctx.tool.hook("execute.after") |
yes |
chat.message |
ctx.session.hook("prompt") |
yes |
experimental.chat.system.transform |
ctx.session.hook("context") |
yes |
experimental.session.compacting |
ctx.session.hook("compaction") |
yes |
event |
ctx.event.subscribe() |
yes |
tool (custom tool map) |
ctx.tool.transform() |
yes |
The problem is purely the entry contract: a plugin that only knows V1 cannot register any of the above, because V1 offers no ctx handle to register with. Every affected plugin must be rewritten hook by hook.
Measured ctx keys on v2.0.22:
agent, aisdk, app, command, event, experimental, generate, integration,
location, mcp, model, options, permission, plugin, provider, reference,
rpc, session, shell, skill, storage, tool, vcs, websearch, worktree
Impact
Plugins whose entire purpose is observing runtime activity cannot run on V2 at all. Concretely blocked on v2.0.22:
- claude-mem (thedotmack/claude-mem) — persistent agent memory. Fails to load;
--ide opencode install is unusable. Its MCP tools still work (registered separately), so search works while capture silently does not.
- Any plugin built on
experimental.chat.system.transform to inject context into the system prompt.
- Any plugin registering custom tools via the V1
tool map.
This follows the V2 release; the migration guide mentions V1 support but only for the { id, server, setup } dual shape, which requires the plugin author to do the rewrite first.
Suggestions
-
A compatibility path in the loader. When the default export is a function (the V1 shape), call it with a V2-compatible context whose tool.hook / session.hook / event.subscribe / tool.transform are present, and translate the returned V1 hooks object into registrations. This is a contained change in external.ts and would unblock every affected plugin at once.
-
A first-class V1 export. Accept Schema.Struct({ server: Schema.declare(...) }) (no id, no setup) as a documented third union member, warning that only V1 hook names are honored. Cheaper than (1), but each plugin still needs its V1 hook names honored and gets no V2 capability.
-
Actionable error message. When the default export is a function, the load error currently reads as if the shape were simply wrong. Naming the V1->V2 rewrite (and linking the migration guide) would save the debugging time I spent here.
Reproduction
// ~/.config/opencode/plugins/v1plugin.js
export default async () => ({ "tool.execute.after": async () => {} })
$ opencode run "hi"
Server plugin error
~/.config/opencode/plugins/v1plugin.js
Plugin must export a default definition with an id and an effect or setup function.
Related
Summary
OpenCode V2 ships two mutually incompatible plugin contracts, and a V1 plugin cannot be made to load on V2 by wrapping its export shape. There is no migration path for plugins that observe runtime activity (tool executions, chat messages, compaction) or register custom tools.
Verified against OpenCode v2.0.22 on Windows.
The break
V1 plugins are an async factory returning a hooks object:
V2 validates the module with
Schema.Structand accepts only two shapes:A V1 module fails to load with:
Wrapping the export does not help
The migration guide is explicit that the APIs are not translated into each other:
So a shim that adds
id+setup()around the V1 factory loads successfully and then silently records nothing. That is worse than the current hard failure: the user believes memory/context capture is running when no hook is ever invoked.The gap is in the V2 plugin API surface, not the loader
The V2
PluginContextdoes have equivalents for every V1 hook — this part is fine. What is missing is a way for a V1-shaped plugin to reach them without a rewrite:tool.execute.afterctx.tool.hook("execute.after")chat.messagectx.session.hook("prompt")experimental.chat.system.transformctx.session.hook("context")experimental.session.compactingctx.session.hook("compaction")eventctx.event.subscribe()tool(custom tool map)ctx.tool.transform()The problem is purely the entry contract: a plugin that only knows V1 cannot register any of the above, because V1 offers no
ctxhandle to register with. Every affected plugin must be rewritten hook by hook.Measured
ctxkeys on v2.0.22:Impact
Plugins whose entire purpose is observing runtime activity cannot run on V2 at all. Concretely blocked on v2.0.22:
--ide opencodeinstall is unusable. Its MCP tools still work (registered separately), so search works while capture silently does not.experimental.chat.system.transformto inject context into the system prompt.toolmap.This follows the V2 release; the migration guide mentions V1 support but only for the
{ id, server, setup }dual shape, which requires the plugin author to do the rewrite first.Suggestions
A compatibility path in the loader. When the default export is a function (the V1 shape), call it with a V2-compatible context whose
tool.hook/session.hook/event.subscribe/tool.transformare present, and translate the returned V1 hooks object into registrations. This is a contained change inexternal.tsand would unblock every affected plugin at once.A first-class V1 export. Accept
Schema.Struct({ server: Schema.declare(...) })(noid, nosetup) as a documented third union member, warning that only V1 hook names are honored. Cheaper than (1), but each plugin still needs its V1 hook names honored and gets no V2 capability.Actionable error message. When the default export is a function, the load error currently reads as if the shape were simply wrong. Naming the V1->V2 rewrite (and linking the migration guide) would save the debugging time I spent here.
Reproduction
Related
{ id, server }module shape fail to load, silently #42878 — plugins using the exported v1{ id, server }module shape