opencode 扩展指南:插件钩子与自定义工具,两条路怎么选

2026-08-04

本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。

在 opencode 这个开源的终端编码 Agent 项目里做扩展(是这个具体项目,不是”开源代码”这个泛称),选错入口的代价不是性能,是你要多写一大堆胶水代码去模拟另一条路本来就免费给你的东西。 判断标准只有一句:你想改的是”这个 Agent 走流程时的某一步”,还是”给模型多一件能干的事”。前者写插件,挂在生命周期钩子上;后者写自定义工具,它会被直接塞进模型看到的工具表。这两件事在仓库里由两套完全不同的代码路径承载,一个在 packages/opencode/src/plugin/,一个在 packages/opencode/src/tool/registry.ts,理解这一点比记住任何 API 都管用。

它用 MIT 许可证(LICENSE,Copyright 2025 opencode)开源,仓库是个体量不小的 monorepo,packages/ 下有 32 个包,英文文档目录 packages/web/src/content/docs/ 有 36 份 mdx。本文只盯其中两份文档和两条代码路径,不做全景介绍。

站内另有几篇讲扩展机制的文章,分工不同:pi 的扩展加载器讲的是另一套运行时怎么发现并装载扩展,Hermes 的插件系统偏常驻服务形态下的插件组织,ECC 的插件与集成偏配置层的拼装;这一篇只回答 opencode 里”插件还是工具”这一个选择题,以及选完之后各自会在哪里翻车。

一、两条路解决的问题根本不同

先把问题摆清楚。你想做的扩展,通常落在这三类里:

第一类:拦一下、改一下、记一笔。 比如不让这个 Agent 去读 .env;比如它每次跑完一轮就给你发个通知;比如给所有 shell 执行注入一组环境变量。这些事的共同点是——你并不想给模型新增能力,你只想在既有流程的某个时刻切一刀。这是插件的地盘。

第二类:给模型多一件能干的事。 比如让它能直接查你们的业务数据库、能调一个只有你们内部才有的脚本。模型得知道这件事存在、知道参数怎么填,这就必须进工具表。这是自定义工具的地盘。

第三类:改的是模型对既有工具的认知。 比如内置工具的描述写得太泛,模型老是用错场合,你想把描述改掉但不想重写这个工具。这一类容易被误判成第二类,然后有人真的去重写了一个同名工具——完全没必要,Hooks 里有 tool.definition 这个钩子,签名写在 packages/plugin/src/index.ts

"tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise<void>

它在 packages/opencode/src/tool/registry.ts 组装工具描述时被触发,对每一个即将交给模型的工具都会走一遍,你按 toolID 匹配、改 output.description 就完事。内置工具、目录里的自定义工具、插件带的工具,一视同仁。这就是典型的”选对了路就少写几百行”。

关于工具描述本身该怎么写才能让模型用对,可以配合工具描述的写法一起看,那篇讲的是内容,这篇讲的是挂载点。

二、插件:从哪儿被找到、怎么装、怎么跑

packages/web/src/content/docs/plugins.mdx,插件有两个来源。本地文件放在项目级的 .opencode/plugins/ 或全局的 ~/.config/opencode/plugins/,启动时自动加载;npm 包写在配置文件的 plugin 数组里,启动时用 Bun 装,包和依赖缓存在 ~/.cache/opencode/node_modules/。加载顺序文档写得很死:全局配置、项目配置、全局插件目录、项目插件目录,四个来源全部加载,所有钩子按顺序跑。同名同版本的 npm 包只加载一次,但名字相近的本地插件和 npm 插件会被当成两个分别加载——这条很容易在你把本地插件发布到 npm 之后咬你一口。

插件本体是一个模块,导出一个或多个插件函数。函数拿到上下文、返回一个钩子对象:

export const MyPlugin = async ({ project, client, $, directory, worktree }) => {
  console.log("Plugin initialized!")

  return {
    // Hook implementations go here
  }
}

文档列出的上下文是 projectdirectoryworktreeclient(一个 SDK 客户端)、$(Bun 的 shell API)。运行时实际组装这个对象的地方在 packages/opencode/src/plugin/index.ts,那里除了这五项还塞了 serverUrl 和一个标注为实验性的工作区注册入口,文档没写全,说明这部分还不稳定,别把身家压上去。

真正决定”插件能不能跑起来”的是 packages/opencode/src/plugin/loader.ts。它把加载切成了几个界限分明的阶段,每个阶段失败都会被单独归类:install(找到并按需安装)、entry(这个目标有没有暴露出所要的入口)、compatibility(版本兼容闸门)、load(真正 import)。这个切分不是洁癖,是为了让上层能准确告诉你到底卡在哪一步——packages/opencode/src/plugin/index.ts 里就按 stage 分支写错误消息:安装阶段失败会把包名和版本一起报出来,兼容性没过报成”skipped”,入口检测与动态导入失败则统一报成加载失败。所以看到”skipped”就别再去查语法错误了,那是版本闸门拦的。另外还有一类不算错误的情况:目标包确实存在、但根本没暴露出所要的入口,它走的是另一条 missing 通道,运行时那里目前是空实现,也就是你不会收到任何提示。

有三处行为值得单独记住。

其一,兼容性闸门只对 npm 插件生效。loader 里的注释写得明白:npm 插件可以声明自己支持哪些版本,而文件插件被当作本地开发代码,直接跳过这道闸。这意味着你本地写的插件不会因为版本声明被拦,但也没人替你挡住不兼容。

其二,重试只有一次,而且只对文件插件的”导入前失败”生效。判定条件是错误消息里包含缺少 package.json 或 index 文件那类信息,重试发生在调用方把依赖准备好之后。一旦进入动态 import 阶段,失败就是终局:

// Only pre-import file plugin setup failures are retried. Bun caches failed dynamic imports,
// so dependency waiting cannot fix load/build/runtime/shape failures in this process.

代码注释直接点名了原因——失败的动态导入会被缓存。所以你的插件如果有语法错误,改完不重启是不会自己好的,别在那儿反复保存等奇迹。

其三,解析和加载是并行的,但把钩子挂进去是串行的loadExternalPromise.all 并行跑完所有候选,而 index.ts 里逐个 applyPlugin 时留了这句注释:保持插件执行串行,这样钩子的注册与执行顺序在多次运行之间是确定的。顺序确定这件事对调试很重要——两个插件都改同一个 output 时,谁后跑谁说了算,而这个”谁后跑”是可复现的。

至于钩子触发,机制简单到有点朴素。packages/opencode/src/plugin/index.ts 里的 trigger 就是遍历所有钩子对象、取同名函数、依次 await

for (const hook of s.hooks) {
  const fn = hook[name] as any
  if (!fn) continue
  yield* Effect.promise(async () => fn(input, output))
}
return output

所有这类钩子的签名都是 (input, output) => Promise<void>,你不返回值,你改 output 上的字段。这就是为什么文档里那个 .env 防护插件长这样:

export const EnvProtection = async ({ project, client, $, directory, worktree }) => {
  return {
    "tool.execute.before": async (input, output) => {
      if (input.tool === "read" && output.args.filePath.includes(".env")) {
        throw new Error("Do not read .env files")
      }
    },
  }
}

拦截靠抛异常,改写靠改 output.args。除此之外还有事件流钩子 event,以及生命周期收尾用的 dispose——运行时给 dispose 注册了终结器,会在收尾时逐个调用。另外事件分发处有一道过滤:只有目录匹配当前实例的事件才会派给钩子,你在多目录场景下调试收不到事件时,先想想这条。

三、自定义工具:文件名就是工具名

自定义工具走的是另一套。按 packages/web/src/content/docs/custom-tools.mdx,工具文件放在项目的 .opencode/tools/ 或全局的 ~/.config/opencode/tools/,用 @opencode-ai/plugin 导出的 tool() 辅助函数定义,tool.schema 就是 Zod(packages/plugin/src/tool.ts 里那行 tool.schema = z 一目了然)。

命名规则是这条路最容易踩坑的地方,而扫描与命名的真实逻辑就写在 packages/opencode/src/tool/registry.ts

const matches = dirs.flatMap((dir) =>
  Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
)

三个信息量很大的细节:目录名 tooltools 都被扫;只匹配 .js.ts;而且是 * 不是 **——子目录里的文件不会被扫到。你想按业务分子目录整理工具?扫不到,一个都扫不到,而且不报错。

拿到文件后的命名:

const namespace = path.basename(match, path.extname(match))
// ...
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))

默认导出用文件名当工具名,命名导出用 文件名_导出名。文档里那个 math.ts 导出 addmultiply,最后就是 math_addmath_multiply 两个工具。还有一道静默过滤:只有同时具备 argsdescriptionexecute 三个字段的对象才会被认成工具定义,不合格的导出直接跳过,不吭声。你在工具文件里顺手 export const config = {...},它不会变成工具,这是好事;但你少写了 description,它也一样被跳过,这就不是好事了。

插件带的工具走的是另一段代码:遍历已加载插件的 tool 字段,键名原样作为工具 id,没有文件名前缀。所以同一个能力,从目录里给和从插件里给,最后叫的名字不一样,别照搬。

两边最后汇进同一个 custom 数组,而对外暴露的全集是内置在前、自定义在后。会话层按工具 id 往一个对象里写,同名后写的覆盖先写的——这正好对上文档那句”自定义工具与内置工具同名时,自定义工具优先”。顺带一个推论:目录工具先入表、插件工具后入表,两者同名时插件那个赢。

工具执行时拿到的上下文,packages/plugin/src/tool.ts 里的 ToolContext 写得很清楚:sessionIDmessageIDagentdirectoryworktreeabortmetadata()ask()。其中 ask() 是权限询问入口,注册表在把宿主的实现桥接给插件时特意包了一层,注释说是为了让上下文能保持住。返回值可以是字符串,也可以是带 titleoutputmetadataattachments 的对象;输出还会过一道截断,截断后会在 metadata 里标记并给出落盘路径。你写工具时输出别撒欢——它不是无限的。

工具定义本身也可以完全不用 TypeScript 写业务逻辑。文档给了个例子:TS 里只放定义,execute 里用 Bun 的 shell 起一个 Python 脚本,路径靠 context.worktree 拼。这条路对”内部已有一堆脚本,只想让 Agent 会用”的团队特别省事。工具本身怎么切分粒度、返回什么形状,可以参考工具设计那篇的判断框架。

四、两条路的落点对照

组成部分它负责什么对应仓库位置你什么时候会碰到它
插件加载器把配置里的插件解析成磁盘上的入口,分阶段报错,按条件重试一次packages/opencode/src/plugin/loader.ts插件”装不上/被跳过/加载失败”三选一时
插件运行时组装插件上下文、串行挂钩子、分发事件、收尾时调 disposepackages/opencode/src/plugin/index.ts钩子没触发、多插件互相覆盖、事件收不到时
钩子类型定义全部钩子的签名,(input, output) => Promise<void> 是主流形态packages/plugin/src/index.ts想知道某个钩子到底能改什么字段时
工具注册表扫目录、命名、把 Zod 参数转成 JSON Schema、包一层执行与截断packages/opencode/src/tool/registry.ts工具没出现在工具表、名字不对、输出被截断时
工具辅助函数与上下文tool() 定义形状、tool.schema 即 Zod、ToolContext 字段packages/plugin/src/tool.ts写工具本体、需要 sessionID / ask / worktree 时
两份官方文档插件与自定义工具的用法说明、目录约定、示例packages/web/src/content/docs/plugins.mdxcustom-tools.mdx动手前,以及怀疑自己记错约定时

五、边界与代价:这套设计明确不管什么

它不给你沙箱。 插件是被动态 import 进同一个进程的普通模块,上下文里直接递给你一个 shell 执行入口和一个能操作会话的 SDK 客户端。这意味着任何一个你装进来的第三方插件,在你的机器上拥有的权限和你自己写的代码没有区别——能读你的文件、能起进程、能把内容发出去。那份 .env 防护插件很好用,但它的存在本身就在提醒你:这类拦截是你自己补上去的,不是默认就有的。装别人的插件之前先看代码,尤其是 npm 来源的。相关的权限边界可以对照最小权限设计那篇再收一遍。

它不替你兜住外泄面。 自定义工具的返回值是要进模型上下文的。你写一个查数据库的工具,查出来的是什么就往上下文里进什么;你写一个读日志的工具,日志里的密钥就跟着走。工具边界就是数据出境边界,这件事没有任何机制帮你把关。涉及模型服务商时还要注意各家对数据的处理规则不同且会调整,以官方最新说明为准。

它不做版本兼容协商,只做一道单向闸。 而且这道闸只拦 npm 插件,本地文件插件完全裸奔。宿主升级之后你本地插件挂了,是你自己的事。

目录扫描不递归,也不给你反馈。 前面说过 * 不是 **。工具没出现,你不会收到任何提示——这是这条路上最不友好的一个点。

钩子改的是共享的 output,没有仲裁。 两个插件都想改同一个字段,后跑的覆盖先跑的,运行时只保证顺序确定,不保证语义正确。插件一多就得自己维护”谁负责改什么”的约定。

动态导入失败在本进程内不可恢复。 前面引的那条注释已经说死了。开发插件时,改代码就重启,别指望热加载。

它明确不管的还有这些: 不管你的工具在做什么危险操作——删文件、改代码、跑破坏性命令,工具里怎么写就怎么执行;不管你的插件里有没有死循环或者阻塞;不管你在 execute 里发起的网络请求打到哪里。把这套东西挂在一个能改你代码仓库的 Agent 上,误删误改是真会发生的,动手前把工作区提交干净,是最便宜的保险。

六、上手与避坑清单

1. 先问”要不要进工具表”,再动手。 会踩是因为”扩展”这个词太笼统,一想到定制就去写工具。判断法:如果你的扩展逻辑不需要模型知道它存在,那它就不该进工具表——进了只会白白占掉模型的注意力。改流程用钩子。

2. 工具文件别放子目录。 会踩是因为文件一多就想分类整理。扫描用的是单层通配,子目录里的文件不会被扫,而且没有任何报错。避法:所有工具文件平铺在 .opencode/tools/ 下,靠文件名前缀区分类别。

3. 别用命名导出的时候还按文件名去猜工具名。 会踩是因为默认导出和命名导出的命名规则不一样。默认导出叫文件名,命名导出叫 文件名_导出名。避法:写完之后实际看一眼工具表里叫什么,再去写提示词或权限规则。

4. 工具定义少一个字段就会被静默跳过。 会踩是因为过滤条件是”同时有 argsdescriptionexecute”,缺一个直接跳过且不报错。避法:无参工具也要写 args: {},别省。

5. 钩子里按名字匹配工具,名字要以运行时真正暴露的 id 为准,别照着源码里的变量名猜。 这是最容易翻车的一处:注册表里那个装工具的对象,键名和工具对外的 id 并不是一回事。翻实现就会发现,真正注册出去的 id 里,读写查改那几个是 readwriteeditglobgreptaskskill,而抓网页的叫 webfetch、联网搜的叫 websearch、待办的叫 todowrite、打补丁的叫 apply_patch,另外还有一个用于兜底非法参数调用的 invalid。执行命令那个工具尤其容易猜错——实现文件叫 shell,但对外暴露的 id 是 bash,源码里专门留了注释解释这么做是为了兼容既有插件和用户已经保存下来的权限配置。所以文档示例里写 input.tool === "bash" 是对的,你按文件名去写 "shell" 反而永远匹配不上。

更麻烦的是工具表并不固定。提问工具只在特定客户端或开关下才注册,lsp 与计划退出也各自挂在开关后面;到了组装给模型这一步还要再筛一道:联网搜索要看提供方与开关,打补丁工具和 edit/write 之间会按模型 id 二选一。同一个插件在不同模型、不同客户端下面对的工具表是不一样的。避法:先在 tool.execute.before 里把 input.tool 原样打出来看一轮,再写条件判断,别凭记忆硬编码。

6. 想让模型少用某个内置工具,先看能不能改描述,再考虑覆盖。 会踩是因为覆盖同名工具的做法太直观。但覆盖意味着你要自己实现整个工具,而 tool.definition 只要改一行描述。而且文档也点了:如果只是想禁用某个内置工具而不是替换它,走权限配置。

7. 本地插件和 npm 插件同名会被加载两次。 会踩是因为你把调试好的本地插件发布上去了,却忘了删本地那份。文档明确说这两者会分别加载。避法:发布后删掉本地目录里的那份,或者干脆换个名字。

8. 改完插件必须重启。 会踩是因为你以为它会热加载。失败的动态导入被缓存,本进程内不会自愈。避法:把重启当成插件开发循环的固定一步。

9. 装第三方插件前,把它的代码读一遍。 会踩是因为 npm 装起来太顺手了。它跑在你的进程里,拿到的是完整的 shell 与会话能力。避法:至少 grep 一遍网络请求和 shell 调用;来源不明的别装在有生产凭据的机器上。

最后

选择题的答案其实很短:要不要让模型知道这件事?知道就写工具,不知道就写钩子;只是想让模型换个说法理解已有的工具,那就既不写工具也不写完整插件,改个描述就够了。

想自己把这条链路走一遍,建议按这个顺序读四个文件:先 packages/web/src/content/docs/custom-tools.mdx 建立直觉,再 packages/opencode/src/tool/registry.ts 看扫描与命名的真实规则(这里藏着最多的”为什么我的工具没出现”),然后 packages/plugin/src/index.ts 通读一遍钩子清单——你会发现能挂的点比文档列的还多,最后 packages/opencode/src/plugin/loader.ts 看失败是怎么分类的,这样报错出现时你能一眼定位到阶段。

四个文件读完,你大概率能少写一半代码。

本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 终端编码 Agent opencode 的语言服务器集成:拉起、诊断回灌与失效表现开源终端 Agent opencode:服务端、SDK 与会话分享

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。