OpenCode 的 Hooks 机制:事件总线、插件钩子与配置钩子
编码智能体的核心挑战之一,是让各模块在不紧密依赖的情况下协作。文件编辑后需要自动格式化,会话状态变更需要通知 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 X 和 export 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 对象。这意味着后加载的插件能看到前面插件的修改结果 - 类似中间件链。
可变的 output。input 提供上下文(只读语义),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(配置钩子定义)。
参考资料
- OpenCode 源码 - 事件总线和插件系统实现
- OpenCode Plugin SDK - 插件类型定义和工具接口
相关文章
2026年2月6日
Claude Code Hooks 实战:实时感知会话状态的绝佳机制
深入 Claude Code Hooks 机制:配置格式、事件生命周期、三种钩子类型,以及一个真实案例 - 如何用 Hooks 驱动桌面宠物实时反映 AI 编码状态。
2026年2月2日
深入解析 OpenClaw 多智能体架构:为什么它比 Claude Code 更强大
通过源码分析,详解 OpenClaw 如何实现多智能体协作、动态提示词构建、工具系统和长时任务执行,揭示其超越 Claude Code 的技术秘密。
2026年2月9日
给 Agent 加定时任务?七个你一定会踩的坑
从 OpenClaw 一次关掉 60+ cron issues 的重构中,提炼出 Agent 定时任务系统的七个可靠性教训:亚秒精度陷阱、LLM 调用必须有超时、失败退避不能省、单次任务的死循环、投递上下文会过期、重复管道要合并、以及 —— 不是所有模型都会按你的 schema 传参。