先把场景放具体一点。用户提交一句话以后,Codex 可能先整理上下文,接着调用模型; 模型也许要跑 shell、改文件、访问 MCP 工具;中途上下文可能超限,需要 compact; 最后模型说完了,runtime 准备收尾。每个节点都有人会想插一段自动化:检查 prompt、 拦危险命令、给审批一个默认答案、记录工具结果,或者在结束前补一句“还要再检查测试”。
如果只把这些都叫作“hook”,源码会很快搅在一起。Codex 的做法更细:
hook 先归到具体事件,再由事件定义输入、输出和后果。
同样是一段 command hook,挂在 PreToolUse 上可以阻断工具;
挂在 PermissionRequest 上返回的是审批决策;挂在 Stop 上则可能把一次本该结束的
turn 拉回模型循环。
这篇抓住一个模型:Codex hooks 是 typed lifecycle slots。
它们由 HookEventName、request schema、
outcome parser、HookStarted/HookCompleted 事件和
hook_runtime 共同约束,最后接回 turn 主线。
证据边界。 本文只描述 openai/codex 公开源码中可验证的 hook event、hook discovery、 preview/run/parse 流程、turn 内调用点、tool approval 接入、compact 接入、stop continuation 以及 protocol 事件。hook command 具体写什么策略,属于用户、项目、系统或 plugin 自己的配置,不在本文推断范围内。
第八篇按六个问题走:
- 为什么 hook 要放回 turn 生命周期来看,而不能只看脚本列表?
- 源码怎样把“可见 hook 条目”和“可执行 handler”分开?
- hook run 为什么要先 preview,再 emit started/completed 事件?
- prompt、tool、approval、compact、stop 这些 slot 分别能改变什么?
PermissionRequesthook 和第 5 篇的 approval / sandbox 边界怎样区分?- 这些边界如何为下一篇性能与 prompt cache 铺路?
一、先把 hook 放回时间线
读 hook 的第一层,应该先看事件名,再看配置文件。protocol 里 HookEventName
列出十个事件:PreToolUse、PermissionRequest、PostToolUse、
PreCompact、PostCompact、SessionStart、
UserPromptSubmit、SubagentStart、SubagentStop、
Stop。这些名字天然就是一条生命周期。
| slot | 触发位置 | 能返回的后果 | 读源码时的 owner |
|---|---|---|---|
SessionStart / SubagentStart |
session 或 thread-spawn subagent 启动时。 | 补充 model context;SessionStart 还能停止本轮。 |
session_start.rs 与 run_pending_session_start_hooks。 |
UserPromptSubmit |
用户输入被接受进 history 之前。 | 补充 model context,或阻止这条输入继续进入 turn。 | user_prompt_submit.rs 与 inspect_pending_input。 |
PreToolUse |
工具真正执行之前。 | 阻断工具、补上下文、或者改写 hook-visible input。 | pre_tool_use.rs 与 tools/registry.rs。 |
PermissionRequest |
approval path 内,Guardian 或用户审批 UI 之前。 | 返回 Allow、Deny,或者不决定。 |
permission_request.rs 与 tool orchestrator。 |
PostToolUse |
工具成功产出结果之后。 | 补上下文、给模型反馈、或者让结果变成停止信息。 | post_tool_use.rs 与 PostToolUseFeedbackOutput。 |
PreCompact / PostCompact |
compact 前后。 | 允许 compact 继续,或把 compact/turn 打断。 | compact.rs 与 compact_remote_v2.rs。 |
Stop / SubagentStop |
runtime 认为本轮可以结束时。 | 停止、放行,或写入 continuation fragment 让模型继续。 | stop.rs 与 run_turn_stop_hooks。 |
这张表先建立一个直觉:hook 不是在 turn 外面绕一圈,它就在 turn 的关键边界上。
每个边界都只给它一个局部能力。能补上下文的地方,补进去的是 ContextualUserFragment;
能拦工具的地方,拦的是当前 tool call;能答审批的地方,答的是当前 permission request。
用一个 shell 命令看会更清楚。假设模型要执行 npm test,它会经过三个相邻但不同的边界:
tool call: shell("npm test")
-> PreToolUse sees tool name and input; may block or add context
-> PermissionRequest sees the approval payload; may allow or deny
-> ShellRuntime executes under the selected sandbox
-> PostToolUse sees the result; may add feedback for the next model step
所以不要把 hook 想成一个万能拦截器。PreToolUse 关心“这个 call 能不能进 handler”,
PermissionRequest 关心“这次审批怎么回答”,PostToolUse
关心“结果怎样回到模型视图”。它们共享时间线,但不共享权力。
二、先发现,再决定哪些真的能跑
第七篇讲 plugin 时提到,plugin manifest 可以声明 hooks。但一个 hook 被“列出来”,
和它真的会在 turn 中执行,中间还隔着 discovery 和 trust filter。
ClaudeHooksEngine::new 会接收几类输入:是否启用 hooks、是否绕过 trust、
config layer stack、plugin hook sources、plugin hook load warnings,以及 command shell。
启用后,它调用 discover_handlers,产出两类东西:
面向用户展示的 HookListEntry,以及 runtime 真正执行的 ConfiguredHandler。
| 结构 | 包含什么 | 为什么分开 |
|---|---|---|
HookListEntry |
event、matcher、command、source、plugin id、enabled、current hash、trust status。 | 让 UI 能展示“这里有一个 hook”,即使它未启用、未信任或已被修改。 |
ConfiguredHandler |
event、matcher、command、timeout、source path、source、display order、env。 | 只保留本次 runtime 可以执行的 handler。 |
discovery 的过滤逻辑很具体。它会从 config layers 和 plugin hook sources 读取 hook 配置,
校验 matcher,跳过空 command 和尚未支持的 async hook,计算 command hook hash,
再结合 HookTrustStatus 决定是否进入 handlers。managed hook 可以直接成为
managed;非 managed hook 只有在当前 hash 和已信任 hash 对得上时,才是 trusted。
这里保护的是一条很实际的边界:能被看见,不等于能被执行。 项目里多了一个 hook,或者 plugin 包里带了 hook,并不会自动绕过 trust 和 enablement。
三、hook run 是可见事实,不是后台日志
一个 hook event 触发时,Codex 不会直接静默运行 command。各个 event 文件都有相似的骨架:
先 preview 匹配 handler,把每个 handler 转成
HookRunSummary;
再 run command;最后把 stdout、stderr、exit code 或 JSON output 解析成 event-specific outcome。
dispatcher::running_summary 会给每个将要执行的 hook 生成 Running 状态。
hook_runtime 随后发出 EventMsg::HookStarted。
command 完成后,emit_hook_completed_events 会记录 telemetry 和 analytics,
再发出 EventMsg::HookCompleted。客户端不需要从 terminal log 猜测 hook 是否跑过;
它收到的是有类型、有状态、有 source 的事件。
pub struct HookRunSummary {
pub id: String,
pub event_name: HookEventName,
pub handler_type: HookHandlerType,
pub execution_mode: HookExecutionMode,
pub scope: HookScope,
pub status: HookRunStatus,
pub started_at: i64,
pub completed_at: Option<i64>,
pub entries: Vec<HookOutputEntry>,
}
这是 HookRunSummary 的关键字段摘录。它把“跑了一个脚本”拆成 event、handler 类型、
执行模式、scope、状态、时间和输出条目。换句话说,hook run 不是一行日志;
它是可以被 app-server、TUI、telemetry 和 rollout policy 分别处理的 turn fact。
为什么要 preview? 因为 UI 需要先展示“哪些 hook 正在跑”。如果等 command 跑完才知道,长耗时 hook 会看起来像 runtime 卡住。preview 让 hook 从一开始就是可观察的 turn fact。
四、prompt gate:输入进 history 前,先过一层
回到 run_turn。在第一次 model sampling 之前,Codex 先做 pre-sampling compact,
记录 context update,解析本轮 skill/plugin injection。接着调用
run_pending_session_start_hooks,再调用 run_hooks_and_record_inputs。
这条顺序说明了 prompt hook 的位置:用户输入还没有被 durable history 接受时,
inspect_pending_input 会构造 UserPromptSubmitRequest,
把 session id、turn id、cwd、transcript path、model、permission mode 和 prompt 发给 hook。
如果 hook 要停止,本轮可以在输入真正成为会话事实前结束;如果 hook 返回 additional context,
runtime 会把它转成 HookAdditionalContext,作为 developer message 记录。
这和 skill injection 的性质不同。skill 是用户点名后的工作方法,稍后作为 injection item 记录进 conversation; prompt hook 更像一个输入闸门,能在用户输入进入 history 前给出提醒、附加上下文,或者阻止这条输入继续。
五、tool gates:执行前、审批中、执行后是三件事
工具路径里最容易混的是三层:PreToolUse、PermissionRequest、
PostToolUse。它们都围绕一次 tool call,但回答的问题不一样。
5.1 PreToolUse:工具执行前的局部改写和阻断
在 tools/registry.rs 中,tool invocation 通过 payload 检查后,会先调用
run_pre_tool_use_hooks。这个 hook 收到稳定的 hook payload:
canonical tool name、matcher aliases、tool use id 和 tool input。handler 选择时可以用兼容别名,
但 stdin 里的 tool_name 保持 canonical,避免审计和策略判断漂移。
PreToolUseOutcome 有三种有用后果。第一,should_block 为真时,
tool 不会执行,runtime 把阻断原因返回给模型。第二,additional_contexts
会写入 model context。第三,updated_input 可以让 handler 重建 invocation;
如果被阻断,updated input 会被丢弃。
5.2 PermissionRequest:审批路径里优先回答一次请求
PermissionRequest 的位置更靠后。只有 policy 算出需要 approval,
并且当前路径允许评估 permission-request hooks 时,tool orchestrator 才会调用它。
它不是 pre-tool validation 的另一个名字;它返回的是审批决策:
Allow、Deny,或者没有 hook verdict。
决策 fold 很保守:任何 Deny 直接胜出;没有 deny 时,allow 可以批准;
如果没有 handler 给出决策,就继续走普通 Guardian 或用户审批路径。
所以 permission hook 能回答“这次是否允许尝试”,但它不替代 sandbox。即使审批通过,
后面的执行仍然要受 permission profile、sandbox 和具体 tool runtime 约束。
5.3 PostToolUse:工具结果回来后,给模型一个可见反馈
tool 成功运行之后,如果 handler 提供了 post-tool payload,runtime 会调用
run_post_tool_use_hooks。它看到 tool input 和 tool response,可以补 additional context,
也可以返回 feedback message。
这里有一个细节值得记住:如果 post-tool hook 返回反馈,tools/registry.rs
会把原工具结果包进 PostToolUseFeedbackOutput,让模型可见部分变成 hook feedback。
原始结果没有完全消失,但模型下一步看到的是被 hook 调整后的输出视图。
六、compact 和 stop:边界不只在工具旁边
hooks 还出现在上下文压力和 turn 收尾处。compact 前后,compact_remote_v2.rs
分别调用 run_pre_compact_hooks 和 run_post_compact_hooks。
这两个事件的 outcome 很克制:继续,或者停止。它们不负责发明 compact 摘要策略,
也不直接改写 summary;它们只是在 compact 这个边界上给外部策略一次中断机会。
Stop 更微妙。run_turn 在发现模型不需要 follow-up、没有 pending input、
token 状态也不要求继续之后,才调用 run_turn_stop_hooks。这时 runtime 已经准备收尾,
但 stop hook 仍然可以返回 should_block 和 continuation_fragments。
如果能构造出 HookPromptFragment,Codex 会把它记录为 hook prompt message,
设置 stop_hook_active,然后回到 model loop。也就是说,stop hook 不是“结束后通知”;
它是结束边界上的最后一道续跑机会。适合这里的是非常具体的收尾补问:
例如“工具刚改过文件,先总结未验证项”,或者“回答前确认是否遗漏测试结果”;
笼统策略放在这里反而容易让 turn 多跑无用循环。
到这里,hook 的设计取舍就很清楚:越靠近副作用,权限越窄、结果越具体; 越靠近输入和收尾,能影响的是模型上下文或循环是否继续。Codex 没有给 hook 一个全局万能开关, 而是把它切成生命周期上的小门。
七、常见误读:把相邻边界合并
读完源码后,最需要避免的是把相邻边界合并成一个词。尤其是 hook、approval、sandbox 和 event 这四个词,表面上都在“安全”和“可观察性”附近,实际 owner 完全不同。
| 误读 | 更准确的读法 | 出错后果 |
|---|---|---|
| hook list 就是可执行列表。 | list entry 可以展示未启用、未信任或修改过的 hook;runtime handler 还要经过 trust filter。 | 把“存在自动化”误解成“自动化一定会跑”。 |
| PreToolUse 等同于 approval。 | PreToolUse 可以阻断或改 input;PermissionRequest 才返回 allow/deny。 | 把 validation hook 误当成授权来源。 |
| approval 通过后 sandbox 就没用了。 | approval 只回答能否尝试;sandbox 仍然约束实际进程能访问什么。 | 把授权决策理解成执行边界放开。 |
| PostToolUse 只能做日志。 | 它可以返回 model-visible feedback,让下一次 model sampling 看到不同的结果视图。 | 低估工具结果返回模型前的最后一层解释权。 |
| Stop hook 是 after hook。 | Stop hook 在收尾边界上运行,可以通过 continuation fragment 让模型继续。 | 把一次续跑机会误读成纯通知。 |
八、下一篇为什么可以讲性能与 prompt cache
第八篇把 hooks 放回生命周期之后,前八篇其实已经搭出一条完整主线: 用户输入进 turn,context 和 extensions 先形成 model-visible view; tool surface 决定模型可以请求什么;hooks、approval、sandbox 决定副作用边界; event stream 把每个关键动作变成客户端可见事实;stop 和 compact 处理长任务的继续与收束。
这时再讲性能和 prompt cache,才不会只剩“缓存命中率”这一个指标。Codex 的性能不是某个函数变快, 而是很多边界共同决定:稳定前缀能不能保留,动态工具和 skill 说明是不是按需进入, hook additional context 会不会改变模型视图,compact 会不会重写历史形状, event projection 能不能让用户先看到进度。下一篇就从这些已经读过的 owner 出发, 专门看 prompt cache 和体感速度之间的关系。
参考源码
- openai/codex 固定源码快照
- EventMsg 包含 HookStarted / HookCompleted
- HookEventName 事件列表
- HookRunSummary、HookStartedEvent、HookCompletedEvent
- ConfiguredHandler、HookListEntry 与 ClaudeHooksEngine
- Hook engine 的 preview / run 入口
- discover_handlers 读取 config 与 plugin hook sources
- append_matcher_groups、trust status 与 runnable handler 过滤
- handler selection、running summary 与并发执行
- HookScope:thread 与 turn 范围
- run_turn 主循环中的 hook 调用位置
- run_hooks_and_record_inputs 先检查 hook 再记录输入
- run_pending_session_start_hooks
- run_pre_tool_use_hooks
- run_permission_request_hooks
- run_post_tool_use_hooks
- run_turn_stop_hooks
- pre/post compact hooks
- run_context_injecting_hook、record_additional_contexts 与 hook events
- SessionStart / SubagentStart request 与 outcome
- UserPromptSubmit request 与 outcome
- PreToolUse request、outcome 与 updated input 解析
- PermissionRequest hook contract 与 deny-wins fold
- PostToolUse request 与 outcome
- Stop / SubagentStop request 与 outcome
- Stop hook continuation fragments 聚合
- tool registry 中 pre/post tool hooks 的接入
- tool orchestrator 中 permission-request hook 优先回答 approval
- remote compact 前后的 hook gate
- PluginManifest paths 包含 hooks
- PluginLoadOutcome 暴露 effective plugin hook sources