Extension Runner 怎样串起事件与变换
从 ExtensionRunner.bindCore、AgentSession event projection 与专用 emit 方法,区分观察事件、链式变换、短路拦截和失败传播。
extension factory 调 pi.on("turn_end", handler) 时,只把函数存进某个 Extension.handlers map。真正运行 handler 的是 ExtensionRunner,而事件源大多仍在 AgentSession 与底层 Agent。Runner 做了三件容易混在一起的事:把 runtime action 绑定回当前 session,为每次调用生成带状态读取能力的 context,按事件合同组合多个 handler 的返回值。
如果把它概括成“扩展事件总线”,会丢掉最重要的差异。turn_start 只是观察通知;context 会把前一 handler 输出交给下一 handler;input 可以 transform 或 handled;session_before_compact 可以 cancel;tool_call 可以修改输入并阻断执行。它们共用注册形式,不共用结果语义。
Load-time API 到 session action 的接线
extension 加载时拿到的 API 引用同一个 ExtensionRuntime。AgentSession 创建 Runner 后调用 bindCore(),把 sendMessage、session metadata、active tools、model 和 thinking level 等 action 写回这份 runtime;同时把 context 所需的 model、idle、trust、signal、abort、usage 和 system prompt getter 绑定到当前 session。
provider 是 load-time 特例:factory 阶段的注册先排队,bindCore() 按队列顺序 flush 到 ModelRuntime,再把 register/unregister 换成即时 action。于是 extension 不必为了注册 provider 等 session_start,又不会在 model registry 尚未存在时直接访问空对象。
createContext() 没把 model、cwd、signal 等值复制成快照,而是用 getter 在访问时读取 Runner 当前绑定。session replacement 或 reload 会 invalidate() 旧 Runner 与共享 runtime;旧 context 的 getter 再访问就抛 stale message。扩展若跨 ctx.newSession() 保存旧 ctx,即使对象仍在内存,也不再拥有新 session 的操作权。
Agent 事件先在 AgentSession 里改名和补字段
底层 Agent 发出 turn_start、message_update、tool_execution_end 等事件。AgentSession._emitExtensionEvent() 把它们转换成 extension types,给 turn 加 index/timestamp,再交给 Runner。这个投影发生在 session 层,因为只有这里知道 session persistence、turn index 和当前 extension runtime。
message_end 是一个值得单独看清的例外。Runner 可以链式返回同 role 的 replacement;AgentSession 将 replacement 原地写回底层 Agent 已保存的 message object。这样后续 turn/agent event、listener 以及稍后的 SessionManager.appendMessage(event.message) 看到同一份修改。不同 role 的 replacement 被 Runner 拒绝并报告 extension error。
flowchart LR
accTitle: Agent 事件进入扩展运行时
accDescr: 底层 Agent 发出事件,AgentSession 补充 session 语义并调用专用 Runner 方法;Runner 再按事件合同广播、链式变换或短路。
AGENT["agent-core events"] --> PROJECT["AgentSession projection"]
PROJECT --> OBSERVE["generic observe events"]
PROJECT --> TRANSFORM["message/context/input transforms"]
PROJECT --> GATE["before-session / tool-call gates"]
OBSERVE --> HANDLERS["ordered extension handlers"]
TRANSFORM --> HANDLERS
GATE --> HANDLERS
HANDLERS --> STATE["Agent/session/provider state"]
四类 handler,四种合并方法
普通 emit() 遍历 extension 与其 handlers,逐个 await。大部分事件忽略返回值;handler 抛错会转成 ExtensionError 发给 listener,然后继续后面的 handler。session_before_switch/fork/compact/tree 属于同一方法里的 before-event:非空结果会暂存,cancel 为 true 立即返回。多个不取消的结果不是深度 merge,后一个赋值会替换前一个 result。
context、before_provider_request、before_agent_start、message_end 和 tool_result 使用专用方法做链式变换。context 先 structured clone messages,每个 handler 都看到上一项输出;provider payload 同理。before_agent_start 收集每个 extension 追加的 custom message,同时把 system prompt replacement 传给下一个 handler。tool result 则逐字段覆盖当前 event。
input 又多一个分支:transform 更新 text/images 后继续,handled 立即返回,后续 Skill/Prompt 展开和模型调用都不发生。AgentSession 在 slash extension command 之后、Skill/Prompt 展开之前发 input event,因此 handler 看到的是原始普通输入,却看不到已被 extension command 提前消费的命令。
tool_call 的错误会直接阻止执行
工具 interception 安装在底层 Agent 的 beforeToolCall 与 afterToolCall 上,而且 callback 每次读取 this._extensionRunner,所以 reload 换 Runner 后不用重装 hook。tool_result handler 的异常被 Runner 捕获并报告,其他 handler 仍可继续;tool_call 的实现则没有局部 try/catch。handler 抛错会回到 AgentSession,Error 原样继续抛,非 Error 被包装成“Extension failed, blocking execution”。
这不是所有 extension error 的通则。它只说明 tool preflight 采用 fail-closed:负责批准或修改参数的 hook 自己失效时,不应该悄悄执行原命令。普通 turn_end telemetry hook 失败则 fail-open,不阻断 Agent 已完成的工作。
可以用这组只读命令核对哪些事件走专用 emitter:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" grep -n 'async emitToolCall\|async emitToolResult\|async emitContext\|async emitInput' \
v0.83.0 -- packages/coding-agent/src/core/extensions/runner.ts
git -C "$repo" grep -n '_installAgentToolHooks\|_emitExtensionEvent' v0.83.0 -- \
packages/coding-agent/src/core/agent-session.ts
这能建立调用索引,但评审一个 extension 还要逐事件看 result contract。看到 pi.on() 不能推断它能取消动作,也不能推断异常一定被吞掉。
Runner 已经把多个 extension 排成有序处理链。最后一个问题是同名注册:两个 tool、两个 provider overlay、两个 custom renderer 相遇时,究竟谁留下?第 37 章不发明一条万能规则,而是分别沿三份 registry 读出 precedence。