导读:随着 OpenCode 2.0 的正式发布,插件生态迎来了从 V1 的 Hooks 架构向 V2 的 Plugin SDK / Transforms 架构的全面代际跃迁。为了保证存量用户与新版本用户的平滑过渡,开发兼具 V1 与 V2 兼容能力的“双模(Combined)插件”成为当下开发者的必然选择。然而,当我们严格按照 OpenCode 官方文档编写 Dual-Version 代码时,却遇到了本地调试无法加载、静默失败等一系列诡异问题。本文基于 OpenCode
v2.0.15标签中的官方 TypeScript 源码,拆解其内部底层的加载与探测算法,还原 npm 包与本地开发模式下的差异,并给出工业级的双版本打包实践指南。
一、官方文档的“理想蓝图”
在 OpenCode 官方提供的 V2 插件迁移指南中,关于如何让一个 npm 包同时兼容 OpenCode V1 和 OpenCode V2,官方给出了如下代码范例(此处增加注释并区分日志文案):
import { Plugin } from "@opencode/plugin"
export default {
// OpenCode V2 契约:展开 Plugin.define 返回的对象
...Plugin.define({
id: "example",
async setup(ctx) {
await ctx.tool.hook("execute.before", () => {
console.log("A tool is about to run (V2)")
})
},
}),
// OpenCode V1 (>= 1.18.29) 契约:保留顶层 server 函数
async server() {
return {
"tool.execute.before": async () => {
console.log("A tool is about to run (V1)")
},
}
},
}
官方文档宣称的运行机制是:
- OpenCode V1(>= 1.18.29) 检测到模块导出了
server函数,将其作为 V1 插件执行; - OpenCode V2 检测到导出了
id与setup,将其作为 V2 插件注册; - 开发者只需维护单一入口,即可无缝支持两代宿主。
这套逻辑在 JavaScript 对象的概念模型上挑不出毛病。然而,当你满怀信心地在现代 TypeScript 工程(src/ 源码,编译输出到 dist/)中写下这段代码并尝试在本地通过 opencode.json 测试时,现实会给你一记重锤:插件根本没有被加载,终端没有任何报错,宿主直接静默忽略了它。
二、遇坑现场:本地配置为何频频失效?
在实际测试中,我们配置了本地插件路径,尝试了如下常见写法:
现场 1:直接指向编译后的单文件
{
"plugins": [
{
"package": "file:///path/to/project/dist/index.js",
"options": {}
}
]
}
结果:OpenCode 2 会记录 warning 并跳过该条配置,而不是把异常抛到启动流程:
configured plugin path must be a directory
这里确实存在文档与实现不一致:同一标签的迁移指南仍展示 "package": "./plugin/local.ts",而配置扫描实现会跳过这种文件路径。这个限制针对显式 plugins 配置;自动发现的 .opencode/plugin/、.opencode/plugins/ 下的 .ts/.js 单文件仍受支持,见 PluginSourceDirectory.discover。
现场 2:按常理指向项目根目录
既然强制要求目录,且 package.json 中配置了 "main": "./dist/index.js",那么指向项目根目录总行了吧?
{
"plugins": [
{
"package": "file:///path/to/project",
"options": {}
}
]
}
结果:没有报错,但插件完全没被激活。通过 GET /api/plugin 审查,列表中根本没有我们的插件。
现场 3:偶然发现指向子目录却能成功?
在我们尝试把路径指向 TypeScript 源码目录或构建输出目录时:
file:///path/to/project/src-v2(里面有index.ts)👉 成功加载!file:///path/to/project/dist(里面有构建好的server.js或index.js)👉 成功加载!
为什么指向根目录不行,指向子目录就可以?package.json 里的 main 和 exports 难道被无视了吗?
三、官方源码:揭开 OpenCode 2 的加载器底牌
为了彻底弄清真相,本文直接读取官方仓库 anomalyco/opencode 的 v2.0.15 源码。相关实现位于:
packages/core/src/config/plugin/source.ts:配置扫描、文件路径校验和入口筛选;packages/core/src/plugin/module.ts:插件模块加载和 V2 默认导出校验;packages/plugin/src/host.ts:server、根入口、tui、rpc的入口寻址;packages/util/src/runtime/import.bun.ts:Bun 运行时的模块解析实现。
本次通过 gh api 核实的标签提交为 6f3639d82ed0760091792189b78f8eeb44f699b1。可复现的读取命令如下;将路径替换为上面的其他文件即可逐项核对:
gh api repos/anomalyco/opencode/git/ref/tags/v2.0.15 --jq '.object'
gh api 'repos/anomalyco/opencode/contents/packages/plugin/src/host.ts?ref=6f3639d82ed0760091792189b78f8eeb44f699b1' \
-H 'Accept: application/vnd.github.raw'
以下结论限定于该源码版本。原文声称来自同版本二进制的抛错片段与标签源码不一致,本文采用可复查的标签源码,不将原二进制观察当作已验证事实。
1. 本地路径校验的死命令
在 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
以下逐字摘录 packages/plugin/src/host.ts 的完整 resolve 函数:
export function resolve(target: Target): Entrypoints {
const entry = (subpaths: readonly string[]) => {
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"]) }
}
通过这段源码,可以确认以下行为:
核心区别 1:本地目录不按包根 main / exports 选择入口
当用户配置本地路径(如 file:///Users/.../project)时,最终会转换为绝对目录路径,target.name 为空。
解析器走的是右侧分支:path.resolve(target.directory, subpath || "index")。
它会硬编码按顺序执行两次探测:
path.resolve(directory, "server")➡️ 寻找目录下的server.ts、server.js等;path.resolve(directory, "index")➡️ 寻找目录下的index.ts、index.js等。
它不会把本地目录本身作为包根交给解析器,因此仅在项目根目录的 package.json 中设置 main / exports 指向 dist/,不能让这里自动找到构建文件。 这不等于整个加载过程完全不接触 package.json:扫描逻辑会读取它的修改时间,底层运行时也有自己的模块解析规则。
- 如果你指向项目根目录,而根目录只有
src/和dist/,没有根级server.js或index.js,探测全部落空; Host.resolve最终返回{ server: undefined };- 上层扫描逻辑发现没有
server入口后返回空操作列表,因此该目录不会被激活; - 而指向
src-v2/命中src-v2/index.ts,指向dist/命中dist/server.js或dist/index.js,所以能跑通。
核心秘密 2:npm 包走规范的 exports 子路径寻址
对于包配置(例如 "package": "opencode-models-discovery"),PluginModule.load 将 npm 解析/安装结果传给 Host.resolve。
当 target.name 存在时,解析器走左侧分支:[target.name, subpath].filter(Boolean).join("/")。
subpaths 参数是 ["server", ""],因此它会依次尝试解析:
opencode-models-discovery/serveropencode-models-discovery
此时,resolveModule 会调用运行时解析器(Bun 版本中是 Bun.resolveSync),由包解析规则处理 package.json:
- 优先去匹配
package.json中的"exports": { "./server": "..." }; - 如果第一个候选触发代码中列出的可忽略解析错误,再尝试包根。包根可以由
exports["."]解析;没有exports限制时才按运行时规则使用main等传统入口。不能理解为有exports但缺少.时一定回退main。
注意:Host.resolve 只吞掉列举的解析错误,其他错误会重新抛出;原反编译片段中的空 catch 丢失了这一重要条件。
3. 找到入口之后,还要校验默认导出
PluginModule.load 导入模块后,校验默认导出是否有字符串 id 和函数 setup 或 effect。校验失败会产生 PluginModule.LoadError:
Plugin must export a default definition with an id and an effect or setup function.
V2 不会把 server() 返回的 V1 hooks 自动转换为 V2 注册。另一方面,扫描一个已存在的本地目录时,源码中的 if (!entrypoints.server) return [] 确实会无日志过滤无入口目录;这不能推广成所有加载失败都静默处理。加载阶段还存在 Plugin entrypoint not found 错误。目录入口还必须通过 FSUtil.contains(root, server) 检查,解析到目录之外会被过滤。
四、官方文档与现实的冲突总结
| 关注维度 | 官方文档传达的心智 | OpenCode 2 底层真实实现 |
|---|---|---|
| 入口对象定义 | 单一模块 default 导出 { id, setup, server } 即可 | 代码结构确实如此,但前提是物理文件必须先被探测器找到 |
| 本地开发路径 | 迁移指南仍展示单文件配置 | 配置中的单个 .js 文件会记录 warning 并跳过;目录则继续执行入口探测 |
| 本地目录解析 | 开发者以为会遵循 package.json 的 main | 按目录下 server / index 路径解析,不按项目包根的 main 定位 dist/ |
| 入口寻址优先级 | 隐性假定为包根入口 | 优先寻址 /server 子路径,其次才寻址包根入口 |
| 错误反馈 | 预期会有找不到入口的友好提示 | 找不到入口时该配置操作会被过滤;单文件配置会记录 configured plugin path must be a directory warning |
五、工业级解决方案:双模插件最佳打包实践
依据以上源码,可以采用下面的双入口打包方式。双入口是工程组织选择,并非宿主硬性要求;只有根入口的包也可能通过回退解析成功。发布前仍需分别验证目标 V1、V2 版本。
1. 显式构建双入口
在项目中建立独立的 combined 入口文件,确保输出 dist/index.js 和 dist/server.js。
// src/server.ts 或 src/index.ts
import { Plugin } from "@opencode/plugin"
import { ModelDiscoveryPlugin } from "./plugin/index.js" // V1 业务实现
import { setupV2 } from "../src-v2/index.js" // V2 业务实现
const combinedPlugin = {
// 注入 V2 规范协议
...Plugin.define({
id: "opencode.models-discovery",
setup: setupV2,
}),
// 注入 V1 规范协议
server: ModelDiscoveryPlugin,
}
export { ModelDiscoveryPlugin }
export default combinedPlugin
2. 配置 package.json 的 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"
]
}
3. 多场景适配指南
遵循以上规范构建后,插件在各种场景下的表现如下:
-
npm 包发布场景(面向最终用户):
- 用户在 OpenCode 2 中配置
"package": "opencode-models-discovery"。 - OpenCode 2 自动请求
opencode-models-discovery/server,命中dist/server.js并激活 V2。 - OpenCode 1
v1.18.29配置"plugin": ["opencode-models-discovery"]时,也会优先使用exports["./server"],此例会加载dist/server.js,然后调用默认导出的server函数;没有该子路径时才考虑main。依据为该版本的shared.ts与index.ts。因此两个构建入口均应导出 combined 对象,不能假定 V1 必然加载index.js。
- 用户在 OpenCode 2 中配置
-
本地调试场景(面向插件开发者):
- 推荐方案 A(指向构建目录):配置
"package": "file:///path/to/project/dist"。OpenCode 2 进入dist/,直接命中dist/server.js,无需在根目录创建临时胶水代码。 - 备选方案 B(根目录软链/转发):在项目根目录下放置一个转发式的
server.js或index.js,指向./dist/。此时直接写项目根目录file:///path/to/project亦可生效。
- 推荐方案 A(指向构建目录):配置
六、结语
开发跨版本插件需要分别验证入口寻址、默认导出契约和业务 API 适配。源码能确认这些分支的行为,但不能仅凭实现推断作者的性能取舍动机。
本文通过官方源码确认了目录与 npm 包的寻址差异,也纠正了原文对抛错行为、异常捕获范围和 V1 入口优先级的描述。源码核对为双版本打包提供依据,但不能替代对最终发布产物的 V1/V2 运行验证。