Skip to content

Latest commit

 

History

History
291 lines (241 loc) · 15.8 KB

V2.md

File metadata and controls

291 lines (241 loc) · 15.8 KB

opencode V2 support

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.

What changed, and why the old verdict is void

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.

The decisive question, and its answer

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:

  1. @opencode/plugin@2.0.11 depends on @ai-sdk/provider at 3.0.8. This package already depends on ^3.0.8 and has 3.0.10 installed.
  2. ClaudeCodeLanguageModel.specificationVersion is 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.

Dual support is officially supported, and our entrypoint is already half of it

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.

Hook mapping

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.

Open questions, in the order they should be probed

Each is cheap, each is a measurement rather than a reasoning step, and none needs product code.

  1. Does an unresolvable package still reach aisdk.hook("language")? Register a provider with a nonsense package, hook both sdk and language, log what arrives. If it does not, the answer is to supply the sdk object from the sdk hook too. Everything else in this plan depends on this one.
  2. How does the model learn which session it is serving? V1 reads the x-session-affinity request header, and session affinity is the key to process reuse, proxy call routing and the todo ledger. Check what V2 passes into doStream; if it passes nothing, ctx.session.hook("context") knows the sessionID and can stash it.
  3. Is there a native question tool in V2? Account failover and the plan-mode bridge both end a turn on a synthetic question tool-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.
  4. Do MCP tools appear in ctx.tool.list()? If yes, resolveMcpProxyToolDefs gets simpler and the doStream tools argument read can go.
  5. Does the proxy still get permission prompts? V2 renamed permission actions (bash to shell, task to subagent, write/patch to edit) 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.
  6. How do we run V2 at all without losing V1? The installer replaces the V1 binary and both use the opencode command. Testing needs a pinned V2 binary in an isolated path with its own XDG_CONFIG_HOME, the same discipline the live probes already use.

Measured on opencode 2.0.11 (2026-09-23, Claude Code 2.1.280)

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.

  1. The model path works. AISDK.language never imports package. It checks the aisdk: prefix, gets the SDK object from the sdk hooks, then the model from the language hooks, falling back to sdk.languageModel(modelID ?? id). Our sdk hook returns createClaudeCode(settings) and a real turn streamed through it.
  2. Session affinity arrives as a header. callOptions forwards request.http.headers as the V3 headers, and session.hook("model.request") sets them. We set x-session-affinity (already what the language model reads) and x-opencode-agent, with kind: "compaction" | "title" mapped to those agent names.
  3. question exists with V1's exact input shape. todowrite does not, so the todo ledger's synthetic calls are dropped on V2.
  4. MCP tools in ctx.tool.list(): not measured yet.
  5. Permissions: a proxied shell ran under --auto. The interactive prompt is not measured yet.
  6. 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.provider plugin, after plugin transforms, so config overrides plugin defaults. accounts was absent from the seed record. v2.ts reads it from the same on-disk layers as the MCP bridge (opencodeConfigLayers) plus the V2-native plugin options. Per-call settings are unaffected: the sdk hook's options already hold the merged result.
  • opencode 1.18.32 calls a dual export's setup as well as server. A first build that set a process-wide "V2 mode" there made V1 emit shell and V1 rejected every tool call. The vocabulary is now carried per model (ClaudeCodeConfig.hostApi, set only by the V2 sdk hook), and setup registers nothing unless the context has the V2 provider, aisdk and session domains and no 1.x app.version.
  • V2 renamed tools and fields, and an unknown name is an error, not a harmless row. src/host-tools.ts rewrites the stream at its edge, on V2 only: bash to shell (no description), task to subagent (subagent_type to agent, task_id to sessionID), filePath to path for edit/write/read. It drops todowrite, plan_enter, plan_exit and notebookedit. Read off each tool's registered name and input schema in @opencode/core/dist/tool/plugin/*.js.
  • A configured plugin path must be a directory, and V2 resolves <dir>/server then <dir>/index, never package.json#main. An npm name resolves through exports, 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 in 65379ea.

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.

Phases

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.

Rules for this lane

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

Test strategy

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.