ARTICLE · 1143226
OpenCode V1/V2 插件加载剖析:如何实现双版本共包与本地调试
随着 OpenCode 2.0 的正式发布,插件生态迎来了从 V1 的 Hooks 架构向 V2 的 Plugin SDK / Transforms 架构的全面代际跃迁。为了保证存量用户与新版本用户的平滑过渡,开发兼具 V1 与 V2 兼容能力的“双模(Combined)插件”成为当下开发者的必然选择。
然而,当我们严格按照 OpenCode 官方文档编写 Dual-Version 代码时,却遇到了本地调试无法加载、静默失败等一系列诡异问题。
本文基于 OpenCode v2.0.15 代码中的官方 TypeScript 源码,拆解其内部底层的加载与探测算法,还原 npm 包与本地开发模式下的差异,并给出工业级的双版本打包/调试实践指南。
先记住三个结论
在 OpenCode V2 中通过 plugins 配置本地插件时,传入的路径应当是目录,不要直接传 dist/index.js。OpenCode V1 则可以直接引用插件文件,也可以引用代码根目录。 本地目录主要按目录下的 server 和 index 文件寻找入口,不会因为根目录的 package.json 有 main: ./dist/index.js 就自动跳到 dist。 npm 包和本地目录的解析路径不同。npm 包会优先尝试包的 server 子路径,因此双版本包最好显式导出包根和 ./server。
这里的目录限制特指 OpenCode V2 的本地加载流程,不适用于 OpenCode V1。OpenCode V1 更接近直接导入用户指定的模块,因此可以引用 dist/index.js、dist/server.js,或者能够解析到 V1 server 函数的项目根目录;OpenCode V2 在本地加载时则应优先提供包含可探测入口的插件目录。
官方示例看似清晰,实际行为却不一致
官方在OpenCode V2 插件迁移指南中给出了双版本入口示例:V2 使用 Plugin.define,V1 保留顶层 server 函数。
import { Plugin } from ”@opencode/plugin”export default {...Plugin.define({id: "example",async setup(ctx) {await ctx.tool.hook(”execute.before”, () => {console.log(”A tool is about to run (V2)”)})},}),async server() {return {"tool.execute.before": async () => {console.log(”A tool is about to run (V1)”)},}},}
OpenCode 的迁移文档给出的双版本思路是:OpenCode V1 找到默认导出的 server 函数,把它作为旧版 hooks 插件运行;OpenCode V2 找到 id,以及 setup 或 effect,把它作为新版插件注册;两套协议放在同一个模块中,由不同版本的宿主使用各自认识的字段。
同时官方也在OpenCode V2 使用文档:插件配置中给出了插件加载的配置示例,允许用户通过 plugins 配置本地目录、文件、npm 包和插件选项等。
{"$schema": "https://opencode.ai/config.json","plugins": ["opencode-acme-plugin","opencode-acme-plugin@1.2.0","@acme/opencode-plugin","./plugins/local","../shared/plugin.ts","/absolute/path/plugin.ts","file:///home/me/plugins/local",{"package": "@acme/opencode-plugin","options": {"agent": "reviewer","strict": true,},},],}
这些配置示例描述了官方文档允许的配置写法,但不代表每一种写法都能在当前 OpenCode V2 实现中正常工作。尤其是显式配置本地单文件这一写法,正是本文后面要分析的文档与实现差异。我们在实际开发调试时,才会发现入口寻址往往更早发生,也更容易让问题看起来像“插件没有反应”。
本地调试 OpenCode V2 插件时遇到的加载问题
直接引用加载 JavaScript 文件:被跳过
显式 plugins 配置如果直接指向 file:///path/to/project/dist/index.js,OpenCode V2 的配置扫描会先检查目标是不是文件。如果是文件,就记录 warning 并跳过:
configured plugin path must be a directory因此,在 OpenCode V2 的显式 plugins 配置中应当传入目录,而不是某个 .js 文件。需要注意,.opencode/plugins/ 下的自动发现机制仍然可以发现单个 .ts/.js 文件;这两种来源走的是不同的扫描路径。
指向项目根目录:没有报错,但也没有插件
假设项目结构如下:
my-plugin/├── package.json # main 指向 ./dist/index.js├── src/└── dist/├── index.js└── server.js
如果把配置指向项目根目录,插件可能仍然加载不到。本地目录的入口探测主要尝试:
/path/to/my-plugin/server/path/to/my-plugin/index
它不会把项目根目录当成一个 npm 包,再根据 package.json 的 main 跳转到 dist/index.js。如果根目录没有 server.js、server.ts、index.js 或 index.ts,探测就会落空。
指向构建目录:成功加载
如果改成指向 "file:///path/to/my-plugin/dist" 或者 "/path/to/my-plugin/dist",OpenCode V2 就可以直接命中 dist/server.js 或 dist/index.js。这解释了为什么指向子目录可以,指向拥有 package.json 的项目根目录却不行:两种配置触发的是不同的入口寻址规则。
文档、源码与实际行为的差异
截至 OpenCode v2.0.24,相关行为仍可在上游 Issue 中追踪:#46551讨论显式本地文件路径被丢弃,#52300讨论本地目录忽略 package.json 的 main,#49608讨论 V2 本地插件路径和 @opencode/plugin 解析问题。
这意味着,写插件时需要区分三件事:官方文档展示的配置契约、某个具体版本的源码实现,以及你实际运行的构建版本。最终发布或调试前,应以目标版本的实际行为为准。
为什么显式本地插件要求目录
目录限制不只是一个路径校验细节,也与 V2 的多端插件设计有关。一个完整的 V2 插件目录可能同时包含:
index.ts # 主入口rpc.ts # RPC 扩展tui.ts # 终端 UI 扩展
OpenCode 需要以插件目录为根,继续探测这些同级入口。
官方在 PR#46105中引入 Typed RPC 和 Custom Events 后,这种目录结构变得更重要;
PR#46898则让非目录配置被拒绝时能够留下更明确的诊断。
因此,官方实现选择保留“显式配置必须是目录”的规则,而不是把裸文件当作只有一个入口的完整 V2 插件。需要注意的是,官方迁移文档仍然保留了本地文件路径示例,这正是文档与当前实现容易产生误解的地方。
V1 与 V2 的本地加载方式不同
两代宿主对本地插件的入口寻址并不完全相同:
所以不能把下面两种配置当成等价写法:
V1: plugin = ["file:///path/to/project/dist/server.js"]V2: plugins = ["file:///path/to/project/dist"]
前者更接近“直接导入一个模块文件”,后者是把目录交给 V2 的入口解析器。对于 V2 本地调试,优先使用构建目录,通常比直接引用单文件更可靠。
从源码看 OpenCode V2 的入口解析
在 v2.0.15 中,相关实现位于 source.ts、module.ts、host.ts 和 import.bun.ts:
source.ts:配置扫描和路径校验。 module.ts:模块加载和 V2 默认导出校验。 host.ts:server、根入口、tui 和 rpc 的入口寻址。 import.bun.ts:Bun 运行时的模块解析。
1. 本地路径校验的死命令
在 source.ts 的 ConfigPluginSource.scan 中,对配置得到的绝对路径会先检查是否为文件。真实源码不是抛出异常,而是记录 warning 并返回 Option.none(),使该配置项被过滤掉:
if(yield* fs.isFile(operation.target)) {yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })return Option.none<Operation>()}
这就是为什么通过 plugins 配置直接指向 .js 文件不会进入正常的 V2 配置插件加载流程;它会被记录 warning 后忽略。PluginModule.load 仍保留了面向旧的自动发现来源的单文件兼容分支,但这不改变配置扫描阶段的行为。
2. 双轨制解析逻辑:Host.resolve
以下完整摘录 host.ts 的完整 resolve 函数:
export function resolve(target: Target): Entrypoints {const entry = (subpaths: readonlystring[]) => {for (const subpath of subpaths) {const specifier = target.name? [target.name, subpath].filter(Boolean).join("/"): path.resolve(target.directory, subpath || "index")try {return resolveModule(specifier, target.directory)} catch (error) {if (!(error instanceof Error) ||!("code" in error) ||!["ENOENT","ENOTDIR","MODULE_NOT_FOUND","ERR_MODULE_NOT_FOUND","ERR_PACKAGE_PATH_NOT_EXPORTED","ERR_UNSUPPORTED_DIR_IMPORT",].includes(String(error.code)))throw error}}return undefined}return { server: entry(["server", ""]), tui: entry(["tui"]), rpc: entry(["rpc"]) }}
可以把 Host.resolve 简化理解为:本地目录依次寻找 目录/server 和 目录/index;npm 包依次尝试 包名/server 和 包名。npm 包的第二条路径才会进入常规 package.json exports、包根入口,以及在适用情况下的 main 解析。
因此,有 exports 时应明确提供包根入口,不能假设缺少 exports[.] 时一定会回退到 main。解析器也只会忽略有限几类“找不到入口”的错误,其他异常会继续抛出。
找到入口后:校验默认导出
入口文件被找到,只代表模块可以被导入。OpenCode V2 还会检查默认导出是否包含字符串类型的 id,以及 setup 或 effect 函数。否则可能看到:
Plugin must export a default definition with an id and an effect or setup function.V2 不会把 V1 的 server() 返回值自动转换成 V2 插件。插件加载可以拆成三个阶段:找到物理入口,导入模块,校验默认导出并注册对应版本的能力。
推荐的双版本打包方式
1. 准备明确的入口
让构建产物同时包含 dist/index.js 和 dist/server.js。默认导出可以同时包含 V2 定义与 V1 server;V1 和 V2 不必完全拆成两个项目,但最终入口必须清晰。
2. 显式声明 exports
在 package.json 中同时导出包根和 ./server:
{"name": "opencode-models-discovery","main": "./dist/index.js","exports": {".": { "default": "./dist/index.js" },"./server": { "default": "./dist/server.js" }},"files": ["dist", "README.md", "LICENSE"]}
main 可以保留用于兼容传统工具;exports 则明确写出真正开放的入口。发布前要确认两个构建文件都进入 npm 包。
3. 分别验证 npm 和本地调试
发布为 npm 包时使用包名,例如:
plugins: ["opencode-models-discovery@1.8.0"]本地开发时推荐直接指向构建目录,例如:
plugins: ["/path/to/opencode-models-discovery/dist"]如果必须指向项目根目录,可以放置 server.js 或 index.js 作为转发入口,再由它导入 dist 中的构建结果。但这只是开发辅助方案,发布包仍应依赖清晰的 exports 配置。
4. 用共享 Runtime 避免重复打包
如果 dist/index.js 和 dist/server.js 分别 bundle 全部业务代码,两个入口可能包含大量重复内容。可以把实现集中到一个 Runtime 中,让两个入口只承担薄包装职责:
dist/runtime.js # 完整实现,只构建一次dist/index.js # 根入口薄包装dist/server.js # ./server 入口薄包装
例如:
// src/index.tsimport { combinedPlugin, ModelDiscoveryPlugin, setupV2 } from "./runtime.js"export { ModelDiscoveryPlugin, setupV2 }export default combinedPlugin// src/server.tsimport { combinedPlugin } from "./runtime.js"export default combinedPlugin
这里有一个容易犯的错误:不能因为文件名叫 server.js,就让它只导出 V1 的 ModelDiscoveryPlugin。当 V2 使用 file:///path/to/dist 时,宿主可能优先探测 server.js,随后校验它的默认导出是否包含 V2 所需的 id 和 setup。因此,server.js 也应导出 combined plugin。
排查清单
本地 plugins 路径应该指向的是目录,不要指向具体的.js文件 目标目录下是否存在 server 或 index 对应的构建文件? 构建产物是否被 package.json 的 files 字段排除? npm 包是否同时提供 exports[.] 和 exports[./server]? 默认导出是否包含字符串 id 和 setup/effect? V1 的 server 是否仍然保留并返回旧版需要的 hooks? 本地和 npm 包测试的是否是同一个构建产物?
结语
双版本插件的难点有两层:V1/V2 的 API 契约,以及不同来源的入口寻址。只看迁移文档中的默认导出示例,容易误以为只要对象写对就足够;实际加载流程还要先解决“宿主从哪里找到这个对象”。
最稳妥的做法是:为构建产物准备清晰的 server 和根入口,使用 exports 明确暴露两个子路径,本地调试时直接指向构建目录,并分别在 OpenCode V1 和 OpenCode V2 中验证。