插件系统
插件的三个来源与加载流水线、服务端钩子清单与串行触发语义、自定义工具、TUI 插件,以及 V2 插件 API 的转向。
OpenCode 的插件是跑在进程内的 JS / TS 模块。API 由 packages/plugin(npm 包 @opencode-ai/plugin)定义,导出 .(服务端钩子)、./tool、./tui,以及 ./v2/effect、./v2/promise 两套新接口;宿主实现在 packages/opencode/src/plugin/。
从哪里来
| 来源 | 写法 | 说明 |
|---|---|---|
| 内置插件 | 无需配置 | Codex、Copilot、GitLab、Azure、xAI 等 12 个提供方插件,OPENCODE_DISABLE_DEFAULT_PLUGINS 整体关闭 |
| 配置声明 | "plugin": ["pkg@1.2.0", ["pkg2", { "key": 1 }], "./local.ts"] | npm 包名或路径;元组第二项是传给插件的 options |
| 目录发现 | .opencode/plugins/*.ts | 所有配置目录下的 {plugin,plugins}/*.{ts,js} |
几条规则:
- 路径写法相对声明它的那个配置文件解析,避免多份配置合并后被按别的目录误读;
- 同一个包(本地插件按文件 URL)只加载一次,后声明者胜;
--pure(设置OPENCODE_PURE)跳过全部外部插件,内置插件照常加载;- 已经内置的旧包
opencode-openai-codex-auth、opencode-copilot-auth会被静默忽略。
opencode plugin <module>(别名 plug)一步完成安装与登记:装包,读 package.json 判断它提供服务端还是 TUI 入口,再用 jsonc-parser 写进项目 .opencode/ 或全局(-g)的 opencode.json、tui.json;已登记过的包要加 -f 才会替换版本。
加载流水线
PluginLoader(loader.ts)分四个阶段处理每条声明,失败时带着阶段名上报为会话错误(插件函数自身抛错则只记日志):
- install:npm 插件用
Npm.add装进缓存目录下的packages/,不带版本时补成@latest;本地目录找package.json或index.*; - entry:优先
package.json的exports["./server"](TUI 插件看exports["./tui"]),服务端再退回main,入口不许逃出包目录; - compatibility:只对 npm 插件检查
engines.opencode的 semver 范围,本地插件视为开发中代码,跳过; - load:动态
import()。
失败后只重试一种情况,注释说明了原因:
If
waitis provided, file-based plugins with retryable pre-import setup failures are retried once after the caller finishes preparing dependencies. Once dynamic import runs, failures are treated as permanent for this process because Bun caches failed module resolution.
模块形状有两种。新式是 export default { id, server },本地文件插件必须提供 id,server 与 tui 不能同在一个默认导出里;旧式则把模块的每个导出都当成插件函数,只要有一个导出不是函数就会报错。
钩子清单
插件函数收到 PluginInput:SDK client、project、directory、worktree、serverUrl、Bun 的 $,以及实验性的工作区注册;返回一个 Hooks 对象(packages/plugin/src/index.ts:222)。逐个核对触发位置后,可以分成几组:
| 组 | 钩子 | 触发位置 |
|---|---|---|
| 生命周期 | config、event、dispose | 插件加载完、实例内每个事件、实例销毁 |
| 贡献能力 | tool、auth、provider | 工具注册表、提供方认证与模型列表 |
| 对话 | chat.message、chat.params、chat.headers | 收到用户消息、组装模型请求参数与请求头 |
| 工具 | tool.definition、tool.execute.before、tool.execute.after | 改工具描述与参数、调用前改参数、调用后改输出 |
| 命令与 Shell | command.execute.before、shell.env | 命令展开后、bash 工具与 PTY 启动前注入环境变量 |
| 实验 | experimental.chat.system.transform 等 6 个 | 系统提示词、消息列表、压缩提示与自动续跑、文本完成、small model |
认证与模型相关的 auth、provider 细节见认证与提供方插件。
Hooks 里还声明了 permission.ask,但在本版本的 V1 运行时里找不到任何触发点。想用插件拦截敏感操作,目前应改用 tool.execute.before。
触发语义很朴素:
if (!name) return output
const s = yield* InstanceState.get(state)
for (const hook of s.hooks) {
const fn = hook[name] as any
if (!fn) continue
yield* Effect.promise(async () => fn(input, output))
}
return output(packages/opencode/src/plugin/index.ts:289-296)
所有插件按加载顺序串行调用,output 是同一个可变对象,前一个插件的修改后一个能看到。内置插件先于外部插件加载,外部插件之间也刻意保持串行:“Keep plugin execution sequential so hook registration and execution order remains deterministic across plugin runs.”。实例启动时插件最先初始化,project/bootstrap.ts 的注释给了理由:“Plugin can mutate config so it has to be initialized before anything else.”
写一个插件
放进 .opencode/plugins/guard.ts 就会被发现(旧式导出,不需要 id):
import type { Plugin } from "@opencode-ai/plugin"
import { tool } from "@opencode-ai/plugin"
export const Guard: Plugin = async ({ $, directory }) => ({
"tool.execute.before": async (input, output) => {
if (input.tool === "read" && String(output.args.filePath).includes(".env")) {
throw new Error("不要读取 .env 文件")
}
},
tool: {
branch: tool({
description: "返回当前 git 分支名",
args: {},
async execute() {
return (await $`git -C ${directory} branch --show-current`.text()).trim()
},
}),
},
})在 tool.execute.before 里抛错即可中止这次调用,官方文档的 .env 保护示例也是这么写的。
自定义工具还有更轻的写法:不写插件,直接在配置目录的 {tool,tools}/*.{js,ts} 里导出 tool(...)。默认导出以文件名为工具名,具名导出则是「文件名_导出名」。参数用 zod 描述(tool.schema 就是 zod),注册表把它转成 JSON Schema 交给模型;执行上下文带有 sessionID、agent、directory、worktree、abort 与可发起权限询问的 ask(),输出和内置工具一样会被截断。
TUI 插件
终端界面有自己的一套插件,声明在 tui.json 的 plugin 数组里,模块形如 export default { id, tui(api, options, meta) }。TuiPluginApi(packages/plugin/src/tui.ts:581)几乎能碰到界面的方方面面:route 注册页面,ui 里的对话框与 Slot,keymap 快捷键,theme,kv 持久存储,event 事件总线,SDK client,以及管理其他插件的 plugins.activate、plugins.install 等。
启用状态由 tui.json 的 plugin_enabled 与运行时 KV 里记住的开关合并而来;加载同样严格串行,注释列出了顺序敏感的地方:
Keep plugin execution sequential for deterministic side effects: command registration order affects keybind/command precedence, route registration is last-wins when ids collide, and hook chains rely on stable plugin ordering.
npm 包可以用 package.json 的 oc-themes 字段附带主题文件。每次加载都会在状态目录的 plugin-meta.json 里记下指纹,插件通过 meta.state 得知自己是首次加载、已更新还是未变(first、updated、same)。--pure 同样跳过外部 TUI 插件。
V2:从返回钩子到注册变换
packages/plugin/src/v2/ 换了一种思路。插件用 define({ id, effect }) 定义(Promise 版是 setup),不再返回钩子对象,而是命令式地注册:
- transform 钩子按领域划分:
ctx.agent、catalog、command、integration、reference、skill。每次注册或撤销,都从全新状态出发、按注册顺序重放全部 transform 来重建该领域; - runtime 钩子拦截实时操作,例如
ctx.aisdk.sdk与ctx.aisdk.language替换 AI SDK 实例; - 注册归插件的 scope 所有,scope 关闭即自动撤销。
宿主一侧是 packages/core/src/plugin.ts 的 PluginV2 服务,提供 add、remove、wait 并检测加载环。最能说明方向的是 packages/core/src/plugin/internal.ts:连读取配置里的 agent、命令、skill、提供方,都被写成了内置插件(ConfigAgentPlugin、ConfigCommandPlugin……)。V2 配置把 plugin 改为复数 plugins,条目是包名或 { package, options },本地代码只从 .opencode/plugins/ 发现。
下一篇:HTTP 服务端——OpenCode 以服务形态对外提供能力的那一层。