拆解终端编码 Agent opencode:事件清单如何撑起 TUI 与插件
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
opencode 这个开源终端编码 Agent 最值得抄的一处设计,不是它的 TUI 好看,而是它把”哪些事件对外可见”做成了一份代码里显式声明、且有测试盯着的清单——界面、插件、外部程序全都只是这份清单的订阅者,谁也没有特权通道。 这句话决定了它能被改成别的形态:同一个内核,套一个终端界面是 TUI,套一个 Electron 壳是桌面端,接进 Slack 就是聊天机器人,而这三者读的是同一份事件流。
站内已经有几篇讲”怎么观测 Agent”的文章:Agent 可观察日志怎么设计 讲的是日志字段和排查口径,pi 的 hooks 可观察性 讲的是钩子这一类拦截点,Hermes 的监控可观测 讲的是常驻服务怎么盯指标。本篇不重复这三件事,只钻一件:opencode 是怎么把事件本身定义成一份对外契约的,以及这份契约的代价在哪。
一、这层设计到底解决什么问题
任何一个能干活的编码 Agent,内部都在不停地发生状态变化:会话建了、模型换了、某个工具开始跑了、跑完了、文件被改了、权限在等你点确认。问题是这些状态得同时喂给好几张嘴。
终端界面要实时刷新流式文本;桌面端要在回复完成时弹系统通知;你自己写的插件要在某个工具执行前拦一下;CI 里的脚本要知道这一轮结束没有。如果每加一个消费方就在内核里加一段”顺便调一下它”的代码,内核很快就会被所有下游的细节污染,改一处 UI 就得动核心逻辑。
opencode 的选择是把状态变化统一抽象成事件,然后把”外部能看到哪些事件”单独列成一份清单。内核只管往总线上发,谁订阅、订阅完干什么,内核不关心也不知道。
这带来一个直接后果:前端不是内核的一部分,而是内核的一个客户端。 你可以把它换掉、加一个、或者根本不要界面只跑脚本。
二、一个事件是怎么被定义出来的
事件定义在 packages/schema/src/event.ts。它没有用类继承或者装饰器,而是一个 define 函数,吃一个 type 字符串加一份字段 schema,吐出一个既是运行时校验器、又带静态类型的对象。
关键在 Definition 类型上那个可选的 durable 字段:
export type Definition<
Type extends string = string,
DataSchema extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>,
> = Schema.Top & {
readonly type: Type
readonly durable?: {
readonly version: number
readonly aggregate: string
}
readonly data: DataSchema
}
durable 存在,说明这个事件不只是”广播一下就算了”,它要落库、要有单调递增的序号、要能被重放。aggregate 指的是拿事件数据里的哪个字段当聚合键。会话相关的事件在 packages/schema/src/session-event.ts 里就是这么声明的,一份共用的常量:
const options = {
durable: {
aggregate: "sessionID",
version: 1,
},
} as const
const stepSettlementOptions = {
durable: {
aggregate: "sessionID",
version: 2,
},
} as const
于是 session.next.agent.switched、session.next.tool.called、session.next.text.delta 这一票事件全部按 sessionID 聚合,一个会话就是一条事件流。而”某一步结算完了”这个事件用的是版本 2 的那份配置——它改过一次结构,旧版本被留在了历史里。
版本怎么区分?versionedType 就一行:把类型名和版本号用点拼起来。durable() 函数拿这个拼出来的串当 key 建表,同一个 key 出现两次直接抛错。latest() 则相反,它按类型名去重,同名且都带 durable 时留版本号大的那个,如果两个不同的定义撞了同一个类型名又分不出版本,它会抛出 Duplicate latest event definition for ... 让构建挂掉。
这两个函数是整套设计的安全带:事件名冲突不是运行时才发现的 bug,是启动就崩的错误。
三、清单是分层的,而且对外那一份被刻意收窄了
packages/schema/src/event-manifest.ts 是清单的总装配现场。它先按语义分了几组:
coreDefinitions:会话事件,加上旧版会话事件里带durable的那些foundationDefinitions:在 core 之上加模型目录、集成、catalogfeatureDefinitions:文件系统、引用、权限、插件、目录、文件监听、伪终端、提问
然后拼出两份完全不同的成品:
export const ServerDefinitions = Event.inventory(
...foundationDefinitions,
...featureDefinitions,
...SessionTodo.Event.Definitions,
)
Definitions 是另外单独装配的一份:同样铺上 foundation 与 feature 两组和 SessionTodo,再往上堆旧版会话的非持久事件、安装更新事件、LSP、旧版权限、TUI 事件、MCP 事件、遗留事件、项目、会话状态、旧版提问、压缩、VCS、工作区、worktree、服务器事件。内容上它是 ServerDefinitions 的超集,但代码里两份是各自列出来的,不是一份在另一份上做加法。
两份的差别就是”服务端最小面”和”完整面”的差别。packages/core/src/public-event-manifest.ts 统共不到十行,它挑的是前者:
export const Definitions = EventManifest.ServerDefinitions
export const Latest = Event.latest(Definitions)
而 packages/opencode/src/event-manifest.ts 只有三行,直接把完整版原样透出去。同一套事件,两个包按各自的责任范围各取一份,谁也不能越界拿到不该拿的。
收窄不是嘴上说说。仓库里 packages/schema/test/event-manifest.test.ts 和 packages/opencode/test/event-manifest.test.ts 两份测试会逐条断言:某个类型必须指向某个具体定义、session.next.step.ended 的持久版本必须是 2 而不能是 1、ide.installed 这个类型不能出现在对外清单里(虽然它在 packages/schema/src/ide-event.ts 里确实定义了)。
也就是说,删一个对外事件、改一个字段、手滑把内部事件暴露出去,测试会直接红。这就是”对外声明的清单”里”声明”两个字的分量。
四、从内核到外面,一共走三跳
内核总线在 packages/core/src/event.ts,导出名是 EventV2。它的接口面比一般的 EventEmitter 宽不少:publish、subscribe(按定义订阅单一类型)、all(订阅全部)、durable(按聚合键读历史再接实时)、listen(旧式监听器,已标记 deprecated)、project(注册投影器)、replay / replayAll、remove、claim。
持久事件的写入逻辑值得看一眼:它在一个数据库事务里先查当前序号,编码数据,跑一遍已注册的投影器,再写 EventSequenceTable 和 EventTable。重放时如果发现同一序号上存着的事件 id、类型、数据跟传进来的对不上,就抛 InvalidDurableEventError,消息里明确写着”Replay diverged at aggregate … sequence …”。它宁可炸也不肯让状态悄悄分叉。
第二跳是桥接。packages/opencode/src/event-v2-bridge.ts 挂一个监听器在内核总线上,把每个事件转成进程内的广播格式,并且对持久事件额外再发一条 sync 包裹:
GlobalBus.emit("event", {
directory: event.location?.directory ?? ctx?.directory,
project: ctx?.project.id,
workspace: workspaceID,
payload: { id: event.id, type: event.type, properties: event.data },
})
注意这里 data 被改名成了 properties——这就是你在插件和 SDK 里看到 event.properties 而不是 event.data 的原因。
第三跳是 packages/opencode/src/bus/global.ts,这个文件短到可以整段读完。它是一个只有一个频道的 Node EventEmitter 子类:
class GlobalBusEmitter extends EventEmitter<{
event: [GlobalEvent]
}> {
override emit(eventName: "event", event: GlobalEvent): boolean {
if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) {
event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending")
}
return super.emit(eventName, event)
}
}
它只干一件小事:谁忘了填 id,它补一个。GlobalEvent 本身就四个字段——directory、project、workspace 三个可选的定位信息,加一个 payload: any。
payload 是 any 这件事有意思。到了这一层,类型安全已经在上游的 schema 里保过了,这里只负责搬运。代价是这一层不再拦截任何东西——有几个地方就绕过了内核总线直接往这儿发:worktree 检出失败时发失败事件、升级检查发现新版本时用 directory: "global" 发通知、实例销毁时发 server.instance.disposed。这些事件不进数据库、不参与重放。
五、订阅者都长什么样
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 事件定义与版本工具 | define / inventory / latest / durable / versionedType | packages/schema/src/event.ts | 想加一个新事件类型时 |
| 清单装配 | 拼出 ServerDefinitions、Definitions、Latest,并把 Durable 转出 | packages/schema/src/event-manifest.ts | 想知道到底有哪些事件可用 |
| 内核总线 | 发布、订阅、落库、重放、投影 | packages/core/src/event.ts | 排查事件丢失或重放报错 |
| 对外清单(收窄版) | 只取 ServerDefinitions | packages/core/src/public-event-manifest.ts | 判断某事件算不算公开面 |
| 对外清单(完整版) | 原样透出全部定义 | packages/opencode/src/event-manifest.ts | 生成 HTTP schema 时 |
| 桥接层 | 内核事件转广播格式,data 改名 properties | packages/opencode/src/event-v2-bridge.ts | 搞不清字段名叫什么时 |
| 进程内广播 | 单频道 EventEmitter,补 id | packages/opencode/src/bus/global.ts | 想在进程内挂钩子时 |
| SSE 端点 | /global/event,响应 schema 由清单生成 | packages/opencode/src/server/routes/instance/httpapi/groups/global.ts | 外部程序接入时 |
| 终端界面订阅 | 拉 SSE、16 毫秒批处理、指数退避重连 | packages/tui/src/context/sdk.tsx | 界面卡顿或断流时 |
| 界面状态机 | 按 event.type 分支更新本地状态 | packages/tui/src/context/sync.tsx | 想看事件怎么变成 UI |
| 插件钩子 | Hooks.event 收到全部事件 | packages/plugin/src/index.ts | 写插件时 |
| 外部程序范例 | 订阅 SSE 把工具进度同步到聊天 | packages/slack/src/index.ts | 想接第三方系统时 |
这张表里最能说明问题的是最后三行。插件那边的类型签名简单得过分:
event?: (input: { event: Event }) => Promise<void>
官方文档 packages/web/src/content/docs/plugins.mdx 里给的例子就是靠这一个钩子做完成通知:
export const NotificationPlugin = async ({ project, client, $, directory, worktree }) => {
return {
event: async ({ event }) => {
if (event.type === "session.idle") {
await $`osascript -e 'display notification "Session completed!" with title "opencode"'`
}
},
}
}
而外部程序走的是 HTTP。packages/slack/src/index.ts 里那段循环,跟终端界面拿到的是同一条流:
const events = await opencode.client.event.subscribe()
for await (const event of events.stream) {
if (event.type === "message.part.updated") {
const part = event.properties.part
终端界面自己也没有走后门。packages/tui/src/context/sdk.tsx 里是 sdk.global.event({ signal, sseMaxRetryAttempts: 0 }),拿到流以后 for await 逐条喂给处理函数,短时间内密集到达的事件会攒 16 毫秒一起刷,断了就按指数退避重连。到了 sync.tsx,处理函数就是一个大 switch (event.type):看到 server.instance.disposed 重新引导,看到 permission.asked 且处于自动模式就直接回一个允许。
这就是”它能被改成别的形态”的实际含义——换掉这个 switch,你就换掉了一个前端。
六、边界与代价:这套设计放弃了什么
它不是没有账要付的。
清单本身是死约束。 加事件容易,删事件和改字段很贵。那两份测试逐条断言了具体类型指向具体定义,任何一次收缩都要同时改测试,而测试改动本身就是一次”我确实要破坏兼容”的签字。好处是不会误伤,坏处是重构成本被显性地摆在那里。
它明确不管顺序保证之外的东西。 持久事件按聚合键有序号,能重放;但走 GlobalBus 直发的那些(worktree 失败、升级提示、实例销毁)既不入库也不参与重放。你不能假设从 SSE 收到的每一条都能被回溯。
订阅者慢了会被丢。 内核总线提供了一个 allBounded 辅助函数,用的是 dropping 队列,塞不进去就直接给这个订阅者报 SubscriberOverflowError。设计取向很清楚:宁可丢事件,也不让一个慢消费者拖住内核。你要做严格的审计留存,就不能只靠这条实时流。
订阅者报错不会冒泡。 桥接场景下,监听器抛异常会被捕获并记成一条 Event listener failed 日志,带上事件 id 和类型。插件写崩了不会拖垮会话,但也意味着你的插件静默失灵时,只能去日志里翻。
事件流不是权限边界。 只要能连上这个 HTTP 端点,就能看到会话里流过的全部内容——包括模型输出的文本、工具的输入输出、被改动的文件。这条流的敏感度等同于你的代码本身。
它不解决 Agent 该怎么想。 事件层管的是”发生了什么怎么传出去”,不管提示词怎么写、上下文怎么裁、模型怎么选。这些是另一套东西的职责,别指望在这一层找答案。
七、上手与避坑清单
第一条:别直接引用内部事件定义文件。 会踩是因为 packages/schema/src 下事件定义散在十几个文件里,随手 import 一个看起来对的就用了。但对外可见的是经过清单筛选的那一份——ide.installed 在源码里明明白白定义着,却被测试断言必须不在对外清单里。避法是只认 EventManifest.Latest 里有的类型,拿不准就去那两份测试文件里搜一遍。
第二条:字段名是 properties 不是 data。 会踩是因为你读 schema 定义时看到的是 data,写消费代码时顺手就写了 event.data,然后拿到 undefined。改名发生在桥接层那一步。避法是以文档 packages/web/src/content/docs/sdk.mdx 里的示例为准,那里写的就是 event.type 和 event.properties。
第三条:SSE 连接一定要自己处理重连。 会踩是因为本机开发时连接稳,你不会遇到断流,上了长时间运行的场景才发现事件默默停了。仓库里终端界面那份实现给了参考答案:把 SSE 的内置重试次数设成 0,自己在外层用指数退避循环重连,重连成功后重新走一遍状态引导。避法是照抄这个结构,别指望默认行为兜底。
第四条:别拿实时流当唯一真相。 会踩是因为丢弃策略是静默的——队列满了只有那个订阅者收到溢出错误,内核照跑不误。避法是把需要完整性的场景(审计、计费统计、状态恢复)走持久事件的按聚合键读取路径,那条路径会先读历史再接实时。
第五条:权限别一把梭。 这类工具会在你机器上跑 shell 命令、直接改你的代码文件、把代码内容发给模型服务商。官方 packages/web/src/content/docs/permissions.mdx 里的 permission 配置支持 allow / ask / deny 三档,还可以针对具体工具单独设。会踩是因为频繁弹确认很烦,很多人直接开全局允许或者常年挂着 --auto。代价是误删误改没有最后一道拦截,而 deny 是唯一在自动模式下仍然生效的档位。避法是把破坏性的那几类锁死在 deny,把日常读取放开,中间地带留 ask。
第六条:把服务端口当敏感端口对待。 会踩是因为为了方便调试把监听地址放开,忘了这条流里跑的是你的源码。仓库里终端界面的工作进程在转发请求时会补上鉴权头(见 packages/opencode/src/cli/tui/worker.ts 里读取鉴权头再拼进 Authorization 的那段)。避法是别在共享网络里裸奔这个端口,也别把订阅到的原始事件直接写进公共日志——关于日志里的敏感信息怎么处理,可以看 日志里的敏感信息。
收束
这套东西真正的价值不在”用了事件总线”这个技术选择本身——用事件总线的项目多了去了。价值在于它把”哪些事件对外可见”这件事从口头约定变成了代码里的清单,再变成了会失败的测试。有了这层,前端才是可替换的,插件才敢写,外部程序才敢接。
你要顺着读源码的话,建议这个顺序:先 packages/schema/src/event.ts 看事件怎么定义和版本化,再 packages/schema/src/event-manifest.ts 看清单怎么装配,然后 packages/core/src/event.ts 看总线本体,接着 packages/opencode/src/event-v2-bridge.ts 和 packages/opencode/src/bus/global.ts 看两跳转发,最后拿 packages/tui/src/context/sync.tsx 当”一个完整消费者长什么样”的范本。两份 event-manifest.test.ts 建议穿插着看,它们比注释更能说明维护者认为什么不能改。
移植到自己项目时,可以先问三个问题:你的事件类型名撞了会不会在启动时就崩?你有没有一份代码里能读到的”对外可见清单”?你的慢订阅者拖垮内核时,是丢事件还是丢服务?这三个答案,opencode 都写在源码里了。如果你还在选终端形态的 Agent 工具,开源终端 Agent 选型 那篇可以配合着看。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 opencode 会话数据存在哪:本地存储层与同步层是怎么拆开的 和 开源项目 opencode 怎么把一个 Agent 内核挂上四五套界面。