# 插件系统

> 插件的三个来源与加载流水线、服务端钩子清单与串行触发语义、自定义工具、TUI 插件，以及 V2 插件 API 的转向。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.net/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.net/manual/opencode/plugins
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

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 `wait` is 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` 细节见[认证与提供方插件](https://daiw.net/manual/opencode/auth-and-plugins)。

<Callout type="warn">
  `Hooks` 里还声明了 `permission.ask`，但在本版本的 V1 运行时里找不到任何触发点。想用插件拦截敏感操作，目前应改用 `tool.execute.before`。
</Callout>

触发语义很朴素：

```ts
      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`）：

```ts
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 服务端](https://daiw.net/manual/opencode/server)——OpenCode 以服务形态对外提供能力的那一层。
