Working plan for supporting opencode 1.x (V1) and opencode 2.x (V2) from one package.
Written 2026-09-23 against @opencode/plugin@2.0.11 and @opencode-ai/plugin@1.18.31.
Tracker: issue #31. This file supersedes the "not planned" verdict recorded in AGENTS.md.
AGENTS.md parked V2 on the reasoning that the v2 API was an additive alternative
surface inside opencode 1.x (@opencode-ai/plugin/v2), with no reload of the model
implementation path and therefore no payoff. That reading is now wrong on the premise.
V2 is a new major of opencode itself, not a second plugin API inside 1.x. From the
migration guide: V1 and V2 both use the opencode command, are no longer installed side
by side, and the V2 installer replaces the V1 binary. And, stated as a warning rather
than a footnote:
V1 plugin implementations do not run in V2. Moving a file or renaming its config entry is not enough.
So this is not an optimisation. The day the maintainer's opencode moves to 2.x, this plugin stops existing for them, along with every account provider, proxy tool and model id it registers. That is the actual deadline.
Shipped and confirmed on the registry: @opencode/plugin@2.0.11, @opencode/client@2.0.11,
@opencode/ai@2.0.11. V1's latest is @opencode-ai/plugin@1.18.31, which is what we
build against today.
This plugin does not describe a provider. It implements one: ClaudeCodeLanguageModel
is a real LanguageModelV3 that spawns and drives the claude CLI. Any migration lives
or dies on whether V2 lets a plugin supply a model implementation.
The published V2 plugin documentation says no. Its provider surface is metadata only:
ctx.provider.transform adds { info, models }, where info.package names a package
opencode instantiates itself ("aisdk:@ai-sdk/openai-compatible",
"@opencode/ai/providers/openai-compatible"). Model transforms "apply restrictions and
overrides". Nothing in the documented API returns a model object. Taken at face value
that would have forced a rewrite into a local HTTP shim speaking the Anthropic Messages
protocol, with the CLI behind it.
The shipped package says otherwise. @opencode/plugin@2.0.11 carries an aisdk
domain that the website does not document, and it is exactly the escape hatch needed:
// package/dist/promise/aisdk.d.ts
import type { LanguageModelV3 } from "@ai-sdk/provider"
export interface AISDKHooks {
sdk: {
readonly model: Model.Info
readonly package: string
readonly options: Record<string, any>
sdk?: any
}
language: {
readonly model: Model.Info
readonly sdk: any
readonly options: Record<string, any>
language?: LanguageModelV3
}
}
export interface AISDKDomain {
readonly hook: ModelHooks<AISDKHooks>
}ctx.aisdk is on the plugin context (dist/promise/context.d.ts), and ModelHooks
takes an optional { providerID } scope, so the hook can be limited to our own
providers rather than intercepting everyone's models.
Two facts make this cheap rather than a rewrite:
@opencode/plugin@2.0.11depends on@ai-sdk/providerat 3.0.8. This package already depends on^3.0.8and has 3.0.10 installed.ClaudeCodeLanguageModel.specificationVersionis already"v3".
So the expensive part of this plugin, the several thousand lines of CLI transport, session management, proxy MCP broker and stream translation, is already the exact type V2 asks for. The port is a new entrypoint and a new wiring layer, not a rewrite of the thing that took a year to get right.
Not yet verified, and it is the first thing to probe: whether a provider registered
with no resolvable package still reaches the language hook, or whether opencode
tries to instantiate the SDK first and fails before we are asked. The sdk hook exists
and lets us supply that object too, which suggests the seam is intentional, but a
suggestion is not a measurement.
From the plugin migration guide, verbatim shape:
export default {
...Plugin.define({
id: "example",
async setup(ctx) { /* v2 */ },
}),
async server() {
return { /* v1 hooks */ }
},
}V1 calls server(), V2 calls setup(). The two implementations stay separate; nothing
is translated for us. V1 object entrypoints require opencode >= 1.18.29, which is
below the 1.18.31 we already verify against, so adopting this costs no V1 users.
We are already at that shape. src/index.ts:653:
export default {
id: "@khalilgharbaoui/opencode-claude-code-plugin",
server,
}Adding V2 is spreading Plugin.define({...}) into that object. The seam exists; what
has to be built is what goes behind it.
| V1 | V2 | Note |
|---|---|---|
provider returning models() |
ctx.provider.transform + ctx.aisdk.hook("language") |
The load-bearing one. Metadata and implementation split across two calls. |
chat.params (agent tagging) |
ctx.session.hook("context"), set event.options |
V2 gives sessionID directly instead of the x-session-affinity header. |
config (registering /btw, /claude-code-doctor) |
ctx.command.transform with a real execute() |
Strictly better. V1 forces us to inject into the user's config object and guard against clobbering their own command of that name. |
event (session.deleted) |
ctx.event.subscribe() with an AbortController |
Cleanup moves into the function setup returns. |
MCP bridging (--mcp-config on disk) |
ctx.mcp.transform |
May retire a lot of mcp-bridge.ts, including the content-addressed scratch file. |
| skill bridge | ctx.skill.transform / ctx.skill.list() |
V2 can register a skill directly instead of staging a throwaway plugin dir. |
| proxy tool discovery | ctx.tool.list() |
V1's registry deliberately excludes MCP tools, which is why proxyOpencodeMcpTools had to read doStream's tools argument. Re-measure on V2. |
tool.execute.before/after |
ctx.tool.hook("execute.before"/"after") |
Not used here today. |
permission.ask |
ctx.permission.hook("evaluate") |
Not used here today. |
V2 also adds things V1 never had that this plugin has hand-rolled around: ctx.storage
for durable plugin state, ctx.session.hook("retry") for provider failure policy (which
is the natural home for account failover), and ctx.session.hook("compaction") with a
result field that can skip the model call entirely.
Each is cheap, each is a measurement rather than a reasoning step, and none needs product code.
- Does an unresolvable
packagestill reachaisdk.hook("language")? Register a provider with a nonsense package, hook bothsdkandlanguage, log what arrives. If it does not, the answer is to supply thesdkobject from thesdkhook too. Everything else in this plan depends on this one. - How does the model learn which session it is serving? V1 reads the
x-session-affinityrequest header, and session affinity is the key to process reuse, proxy call routing and the todo ledger. Check what V2 passes intodoStream; if it passes nothing,ctx.session.hook("context")knows thesessionIDand can stash it. - Is there a native
questiontool in V2? Account failover and the plan-mode bridge both end a turn on a syntheticquestiontool-call and resume from its tool result. V2 registers tools through plugins, so confirm the tool exists and the round-trip still works before porting either feature. - Do MCP tools appear in
ctx.tool.list()? If yes,resolveMcpProxyToolDefsgets simpler and thedoStreamtoolsargument read can go. - Does the proxy still get permission prompts? V2 renamed permission actions
(
bashtoshell,tasktosubagent,write/patchtoedit) and replaced the per-tool map with one ordered array. The proxy exists so Claude's tool calls run under opencode's permissions; confirm that still holds. - How do we run V2 at all without losing V1? The installer replaces the V1 binary
and both use the
opencodecommand. Testing needs a pinned V2 binary in an isolated path with its ownXDG_CONFIG_HOME, the same discipline the live probes already use.
Sandbox. ~/opencode-v2-sandbox. The binary is extracted from
@opencode/cli-darwin-arm64@2.0.11 with no install scripts run (2.0.12 to 2.0.14 answer
403 on their tarballs even though latest says 2.0.14; that was Aikido Endpoint Protection's minimum-package-age policy on this machine, not npm, see AGENTS.md). env.sh redirects all four XDG
dirs and TMPDIR. The plugin is loaded through work/.opencode/plugins/claude-code.js,
a one-line re-export of this working tree's dist/. Always pass --standalone, or V2
starts its shared background service. V1 and V2 both read ~/.opencode/ as a config
layer, so a plugin listed there is installed into the sandbox too (it fails harmlessly
when it is a V1 plugin). The V1 regression probe runs the real 1.18.32 binary with a
scratch XDG_CONFIG_HOME under /tmp/v1probe and asserts plugin ready exactly once.
The probes, answered.
- The model path works.
AISDK.languagenever importspackage. It checks theaisdk:prefix, gets the SDK object from thesdkhooks, then the model from thelanguagehooks, falling back tosdk.languageModel(modelID ?? id). Oursdkhook returnscreateClaudeCode(settings)and a real turn streamed through it. - Session affinity arrives as a header.
callOptionsforwardsrequest.http.headersas the V3headers, andsession.hook("model.request")sets them. We setx-session-affinity(already what the language model reads) andx-opencode-agent, withkind: "compaction" | "title"mapped to those agent names. questionexists with V1's exact input shape.todowritedoes not, so the todo ledger's synthetic calls are dropped on V2.- MCP tools in
ctx.tool.list(): not measured yet. - Permissions: a proxied
shellran under--auto. The interactive prompt is not measured yet. - Running V2 without losing V1: done, above.
Things the plan did not predict.
- A plugin's provider transform cannot see the config's provider settings. V2 applies
the config file's provider block through its own
opencode.config.providerplugin, after plugin transforms, so config overrides plugin defaults.accountswas absent from the seed record.v2.tsreads it from the same on-disk layers as the MCP bridge (opencodeConfigLayers) plus the V2-native pluginoptions. Per-call settings are unaffected: thesdkhook'soptionsalready hold the merged result. - opencode 1.18.32 calls a dual export's
setupas well asserver. A first build that set a process-wide "V2 mode" there made V1 emitshelland V1 rejected every tool call. The vocabulary is now carried per model (ClaudeCodeConfig.hostApi, set only by the V2sdkhook), andsetupregisters nothing unless the context has the V2provider,aisdkandsessiondomains and no 1.xapp.version. - V2 renamed tools and fields, and an unknown name is an error, not a harmless row.
src/host-tools.tsrewrites the stream at its edge, on V2 only:bashtoshell(nodescription),tasktosubagent(subagent_typetoagent,task_idtosessionID),filePathtopathforedit/write/read. It dropstodowrite,plan_enter,plan_exitandnotebookedit. Read off each tool's registerednameand input schema in@opencode/core/dist/tool/plugin/*.js. - A configured plugin path must be a directory, and V2 resolves
<dir>/serverthen<dir>/index, neverpackage.json#main. An npm name resolves throughexports, so published installs work. A local directory install needs a root entry file. - Found on the way, a V1 bug:
overageStatus: "rejected"alone was treated as a rejection, so a served turn could end on the account-failover form. Fixed on master in65379ea.
Live evidence, one build for both majors (branch v2-support):
| Feature | opencode 2.0.11 | opencode 1.18.32 |
|---|---|---|
| text turn, account providers | yes | yes |
| proxied tool runs in opencode | shell completed |
bash completed |
| Claude-executed tool rendered | read completed |
unchanged |
| subagent dispatch | subagent to general, agent list offered |
unchanged |
| reasoning variant | #low reached the CLI as effort=low |
unchanged |
| compaction | lean path, summary kept the codeword | unchanged |
| title request | stub, one spawn per session | unchanged |
/claude-code-doctor |
report, no spawn | unchanged |
| bundled skill | listed by skill.list |
unchanged |
/btw |
side question answered from the live session, no turn | unchanged |
| deleted session | released claude state for deleted session |
unchanged |
| local install | plugins: ["<repo>/dist"] |
file:// as before |
Not verified live on V2, each for a stated reason: account failover and the
plan-mode form (a real usage limit and a headless ExitPlanMode cannot be produced on
demand; both use V2's question tool, whose input matches V1's), permission prompts in
the TUI (probes ran with --auto), and an npm-name install (needs a publish; it
resolves through exports["."]).
Known V2 gaps, deliberate: /btw is answered after a running turn rather than inside
it, and there is no todo panel because V2 has no todowrite.
Phase 0, probes. Answer the six questions above with throwaway plugins against a sandboxed V2. No changes to this package. Write findings back into this file. Stop here and re-plan if question 1 comes back negative, because that answer changes the architecture rather than the schedule.
Phase 1, separate the transport from the V1 glue. Nothing under the CLI transport
should know what an opencode plugin API looks like. Today src/opencode-types.ts is a
hand-written mirror of V1's types and the V1 wiring lives in src/index.ts. Target:
index.ts becomes a thin dual entrypoint, v1.ts holds today's server(), v2.ts
holds setup(), and everything else is shared and API-agnostic. This phase ships on its
own with zero behaviour change and the full suite green.
Phase 2, V2 entrypoint, feature by feature. Order by dependency: provider and models first (nothing works without a model), then commands, then MCP, then skills, then the question-dependent features (failover, plan mode) once probe 3 has an answer. V1 stays the default and stays untouched.
Phase 3, parity and release. A parity checklist per feature with its V2 evidence, the README and the bundled skill updated to say which opencode majors are supported, and a minor release. Support both until the maintainer's own opencode is on 2.x and has stayed there.
- V1 may not regress. 660 tests green at every step, and no V1 behaviour changed because V2 would prefer it differently.
- Every V2 claim gets a measurement, the same standard the rest of AGENTS.md holds.
The website's provider documentation would have sent this lane down a rewrite; the
shipped
.d.tsdid not. Read the package. - Do not convert the maintainer's own opencode config to native V2 shape yet. V1 and V2 read the same paths, and the migration guide warns against pointing V1 at converted files.
- Do not install V2 over the working V1 until the port runs, because the installer replaces the binary and there is no plugin on the other side yet.
The suite drives V1 by calling server(input) with a fake plugin input. V2 needs the
same treatment: a fake Context with the domains we touch, so setup(ctx) can be
asserted offline exactly as the V1 hooks are. Shared code keeps its existing tests. The
probes in phase 0 are live and paid, and stay out of npm test.