返回博客2026年2月6日

OpenCode 的 Hooks 机制:事件总线、插件钩子与配置钩子

OpenCodeHooksAgent架构分析事件驱动

编码智能体的核心挑战之一,是让各模块在不紧密依赖的情况下协作。文件编辑后需要自动格式化,会话状态变更需要通知 UI 刷新,插件需要在不修改核心代码的前提下注入行为。这些需求指向同一个答案:Hooks。

OpenCode 的 Hooks 机制分三层 - 底层的事件总线、中层的插件钩子、以及面向用户的配置钩子。每一层解决不同粒度的问题,组合起来形成了一个既灵活又可控的扩展体系。

事件总线:一切的基础

最底层是一个类型安全的 Pub/Sub 事件总线。它的设计简洁到令人舒适。

定义事件

事件通过 BusEvent.define() 声明,每个事件都有一个字符串类型标识和一个 Zod schema 描述其载荷结构:

// packages/opencode/src/bus/bus-event.ts
export namespace BusEvent {
  export type Definition = ReturnType<typeof define>

  const registry = new Map<string, Definition>()

  export function define<Type extends string, Properties extends ZodType>(
    type: Type,
    properties: Properties,
  ) {
    const result = { type, properties }
    registry.set(type, result)
    return result
  }
}

注册表不只是存储 - 它还能生成一个 Zod discriminated union,覆盖系统中所有已注册的事件类型。这意味着任何消费者都能拿到完整的类型推导。

事件定义散布在各模块中,每个模块声明自己关心的事件:

// packages/opencode/src/file/index.ts
export const Event = {
  Edited: BusEvent.define(
    "file.edited",
    z.object({ file: z.string() }),
  ),
}

发布与订阅

总线的核心实现不到 100 行:

// packages/opencode/src/bus/index.ts
export async function publish<Definition extends BusEvent.Definition>(
  def: Definition,
  properties: z.output<Definition["properties"]>,
) {
  const payload = { type: def.type, properties }
  const pending = []
  for (const key of [def.type, "*"]) {
    const match = state().subscriptions.get(key)
    for (const sub of match ?? []) {
      pending.push(sub(payload))
    }
  }
  GlobalBus.emit("event", {
    directory: Instance.directory,
    payload,
  })
  return Promise.all(pending)
}

几个值得注意的设计选择:

通配符订阅。除了精确匹配事件类型,还支持 "*" 通配符。这是插件系统的关键 - 插件通过 subscribeAll 订阅所有事件,再在内部分发。

双通道发布。事件同时发送到本地订阅者和 GlobalBus。后者是一个 Node.js EventEmitter,负责跨进程通信(IPC):

// packages/opencode/src/bus/global.ts
export const GlobalBus = new EventEmitter<{
  event: [{ directory?: string; payload: any }]
}>()

这让 TUI 进程、LSP 服务和主进程能共享事件流。

异步并发。所有订阅者回调通过 Promise.all 并发执行。发布是非阻塞的 - 发布者不需要等待所有订阅者处理完毕。

订阅 API 有三种形式,覆盖不同场景:

// 持续监听特定事件
Bus.subscribe(File.Event.Edited, async (payload) => { ... })

// 一次性监听(回调返回 "done" 后自动取消)
Bus.once(Session.Event.Created, (payload) => {
  if (someCondition) return "done"
})

// 监听所有事件(插件系统使用)
Bus.subscribeAll(async (event) => { ... })

所有 subscribe 调用都返回一个取消订阅函数,便于清理。

配置钩子:面向用户的扩展点

事件总线是内部机制,普通用户不需要写代码就能使用的是配置钩子。目前 OpenCode 支持两种实验性钩子,都通过 opencode.jsonc 配置。

file_edited 钩子

当文件被编辑时触发,支持按文件扩展名匹配:

{
  "experimental": {
    "hook": {
      "file_edited": {
        "*.ts": [
          {
            "command": ["prettier", "--write", "$FILE"],
            "environment": { "NODE_ENV": "production" }
          }
        ],
        "*.py": [
          {
            "command": ["black", "$FILE"]
          }
        ]
      }
    }
  }
}

键是 glob 模式,值是命令数组。命令中的 $FILE 会被替换为实际的文件路径。

session_completed 钩子

会话结束时触发,适合做通知、日志归档等收尾工作:

{
  "experimental": {
    "hook": {
      "session_completed": [
        {
          "command": ["notify-send", "OpenCode", "Session completed"]
        }
      ]
    }
  }
}

配置 schema

这些钩子的类型定义很直白:

// packages/opencode/src/config/config.ts
experimental: z.object({
  hook: z.object({
    file_edited: z.record(
      z.string(),  // glob 模式
      z.object({
        command: z.string().array(),
        environment: z.record(z.string(), z.string()).optional(),
      }).array(),
    ).optional(),
    session_completed: z.object({
      command: z.string().array(),
      environment: z.record(z.string(), z.string()).optional(),
    }).array().optional(),
  }).optional(),
})

z.record(z.string(), ...)file_edited 可以用任意字符串作为键,每个键对应一组命令。简单、声明式、不需要写代码。

格式化器:配置钩子的内置实现

格式化器是配置钩子模式的最佳范例。它展示了事件总线和外部命令执行如何结合在一起。

// packages/opencode/src/format/index.ts
export function init() {
  Bus.subscribe(File.Event.Edited, async (payload) => {
    const file = payload.properties.file
    const ext = path.extname(file)

    for (const item of await getFormatter(ext)) {
      try {
        const proc = Bun.spawn({
          cmd: item.command.map((x) => x.replace("$FILE", file)),
          cwd: Instance.directory,
          env: { ...process.env, ...item.environment },
          stdout: "ignore",
          stderr: "ignore",
        })
        const exit = await proc.exited
        if (exit !== 0)
          log.error("failed", { command: item.command })
      } catch (error) {
        log.error("failed to format file", { error, file })
      }
    }
  })
}

整个流程:文件被智能体编辑 → Bus.publish(File.Event.Edited, { file }) → 格式化器收到事件 → 按扩展名匹配命令 → Bun.spawn 执行外部工具 → $FILE 被替换为实际路径。

格式化器本身也可以通过配置文件定制:

{
  "formatter": {
    "prettier": {
      "command": ["prettier", "--write", "$FILE"],
      "extensions": [".ts", ".tsx", ".js", ".jsx"]
    },
    "black": {
      "disabled": true
    }
  }
}

设置 disabled: true 可以关掉内置的格式化器。配置中的格式化器和内置格式化器通过 mergeDeep 合并,用户配置优先。

插件钩子:代码级的深度扩展

配置钩子能力有限 - 只能运行外部命令。对于需要深度定制智能体行为的场景,OpenCode 提供了插件钩子系统。

插件加载

插件从三个来源加载:内置插件、配置指定的 npm 包、本地 .opencode/plugin/*.ts 文件。

// packages/opencode/src/plugin/index.ts
const BUILTIN = [
  "opencode-copilot-auth@0.0.9",
  "opencode-anthropic-auth@0.0.5",
]

const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
  plugins.push(...BUILTIN)
}

for (let plugin of plugins) {
  const mod = await import(plugin)
  const seen = new Set<PluginInstance>()
  for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
    if (seen.has(fn)) continue
    seen.add(fn)
    const init = await fn(input)
    hooks.push(init)
  }
}

seen 集合防止了一个常见问题:当模块同时导出 export const Xexport default X 时,Object.entries 会返回两个指向同一函数的条目,导致重复初始化。

每个插件接收一组上下文信息,包括 SDK 客户端、项目信息、工作目录等:

export type PluginInput = {
  client: ReturnType<typeof createOpencodeClient>
  project: Project
  directory: string
  worktree: string
  serverUrl: URL
  $: BunShell
}

export type Plugin = (input: PluginInput) => Promise<Hooks>

钩子类型一览

插件可以实现的钩子覆盖了智能体工作流的关键节点:

钩子触发时机能力
event任何总线事件监听全局事件流
config初始化时修改运行时配置
tool工具注册时注入自定义工具
auth认证流程自定义认证提供商
chat.message收到新消息拦截/修改用户消息
chat.params发送给 LLM 前调整模型参数
permission.ask权限请求自动批准/拒绝
tool.execute.before工具执行前修改工具参数
tool.execute.after工具执行后修改工具输出

还有几个实验性钩子,用于更底层的行为定制:

// 修改发送给 LLM 的消息列表
"experimental.chat.messages.transform"?: (
  input: {},
  output: { messages: { info: Message; parts: Part[] }[] },
) => Promise<void>

// 修改系统提示词
"experimental.chat.system.transform"?: (
  input: {},
  output: { system: string[] },
) => Promise<void>

// 自定义会话压缩行为
"experimental.session.compacting"?: (
  input: { sessionID: string },
  output: { context: string[]; prompt?: string },
) => Promise<void>

钩子执行模型

理解钩子的执行方式很重要:

export async function trigger<Name extends keyof Hooks>(
  name: Name,
  input: Input,
  output: Output,
): Promise<Output> {
  for (const hook of await state().then((x) => x.hooks)) {
    const fn = hook[name]
    if (!fn) continue
    await fn(input, output)
  }
  return output
}

顺序执行,不是并发。每个插件按加载顺序依次处理 output 对象。这意味着后加载的插件能看到前面插件的修改结果 - 类似中间件链。

可变的 outputinput 提供上下文(只读语义),output 是可修改的结果对象。插件通过直接修改 output 来注入行为,而不是返回新值。这个设计避免了复杂的合并逻辑。

初始化与事件桥接

插件初始化在项目引导阶段完成,是启动序列中最早执行的步骤之一:

// packages/opencode/src/project/bootstrap.ts
export async function InstanceBootstrap() {
  await Plugin.init()    // 插件最先初始化
  Format.init()          // 格式化器依赖事件总线
  await LSP.init()       // LSP 可能需要插件提供的工具
  FileWatcher.init()
  File.init()
  Vcs.init()
}

Plugin.init() 做两件事:把配置传给每个插件的 config 钩子,然后通过 Bus.subscribeAll 把事件总线桥接到插件的 event 钩子:

export async function init() {
  const hooks = await state().then((x) => x.hooks)
  const config = await Config.get()

  for (const hook of hooks) {
    await hook.config?.(config)
  }

  Bus.subscribeAll(async (input) => {
    for (const hook of hooks) {
      hook["event"]?.({ event: input })
    }
  })
}

这是事件总线和插件系统的连接点 - subscribeAll 把所有内部事件转发给插件,插件不需要知道总线的存在,只需要实现 event 钩子。

三层架构的关系

把这三层拉远来看:

flowchart TB
    subgraph Layer3["配置钩子(用户层)"]
        FE["file_edited: *.ts → prettier"]
        SC["session_completed → notify"]
    end

    subgraph Layer2["插件钩子(开发者层)"]
        TEB["tool.execute.before"]
        TEA["tool.execute.after"]
        CM["chat.message"]
        PA["permission.ask"]
    end

    subgraph Layer1["事件总线(系统层)"]
        FEV["file.edited"]
        SEV["session.created"]
        MEV["session.message.updated"]
    end

    FEV -->|"触发"| FE
    FEV -->|"subscribeAll 桥接"| Layer2
    SEV -->|"subscribeAll 桥接"| Layer2
    MEV -->|"subscribeAll 桥接"| Layer2

    style Layer1 fill:#e8f5e9
    style Layer2 fill:#e3f2fd
    style Layer3 fill:#fff3e0

事件总线是基础设施,所有模块通过它通信。插件系统通过 subscribeAll 桥接总线事件,同时在工作流关键节点(工具执行、消息处理、权限检查)提供精细的拦截能力。配置钩子是最上层,让不写代码的用户也能在特定事件发生时运行外部命令。

这种分层让每一层都保持简单。事件总线不关心谁在监听,插件不关心事件从哪来,配置钩子不关心底层实现。松耦合不是口号,是实际的架构选择。

实际应用场景

理解了机制,来看几个实际的用法。

自动格式化是最直接的。智能体编辑了一个 .ts 文件,file.edited 事件触发,格式化器运行 prettier --write。整个过程对用户透明。

权限自动化。通过 permission.ask 钩子,插件可以根据工具类型和参数自动批准或拒绝权限请求,减少审批弹窗。

消息预处理chat.message 钩子可以在消息发送给 LLM 之前注入额外的上下文,比如自动附加相关文件内容或项目约定。

自定义认证auth 钩子让第三方 LLM 提供商可以接入 OpenCode,内置的 Copilot 和 Anthropic 认证就是通过这个机制实现的。

会话压缩定制experimental.session.compacting 钩子让插件控制上下文压缩时保留哪些信息、如何总结历史,这对特定领域的智能体尤其有用。


本文基于 OpenCode 源码分析。核心实现集中在 packages/opencode/src/bus/(事件总线)、packages/opencode/src/plugin/(插件系统)、packages/opencode/src/format/(格式化器)和 packages/opencode/src/config/config.ts(配置钩子定义)。

参考资料

相关文章

2026年2月9日

给 Agent 加定时任务?七个你一定会踩的坑

从 OpenClaw 一次关掉 60+ cron issues 的重构中,提炼出 Agent 定时任务系统的七个可靠性教训:亚秒精度陷阱、LLM 调用必须有超时、失败退避不能省、单次任务的死循环、投递上下文会过期、重复管道要合并、以及 —— 不是所有模型都会按你的 schema 传参。

AIAgentCron

准备开始了吗?

先简单说明目标,我会给出最合适的沟通方式。