阅读契约。按触发时刻跟踪 hook 的输入、同步决定、异步结果和事件,区分续跑机会与结束通知。 本文核实于 2026-09-20,依据公开源码 固定公开源码快照;不推断未公开的服务实现。

先把场景放具体一点。用户提交一句话以后,Codex 可能先整理上下文,接着调用模型; 模型也许要跑 shell、改文件、访问 MCP 工具;中途上下文可能超限,需要 compact; 最后模型说完了,runtime 准备收尾。每个节点都有人会想插一段自动化:检查 prompt、 拦危险命令、给审批一个默认答案、记录工具结果,或者在结束前补一句“还要再检查测试”。

如果只把这些都叫作“hook”,源码会很快搅在一起。Codex 的做法更细: hook 先归到具体事件,再由事件定义输入、输出和后果。 同样是一段 command hook,挂在 PreToolUse 上可以阻断工具; 挂在 PermissionRequest 上返回的是审批决策;挂在 Stop 上则可能把一次本该结束的 turn 拉回模型循环。

每类 hook 都有明确的 HookEventName、request schema 和 outcome parser。 hook_runtime 在对应阶段调用它,并用 HookStarted / HookCompleted 记录开始与结束,再按事件与执行模式处理结果;结束通知和异步结果不参与当前同步决策。

取材范围。 本文只描述 openai/codex 公开源码中可验证的 hook event、hook discovery、 preview/run/parse 流程、turn 内调用点、tool approval 接入、compact 接入、stop continuation 以及 protocol 事件。hook command 具体写什么策略,属于用户、项目、系统或 plugin 自己的配置,不在本文推断范围内。

第八篇按六个问题走:

  1. 为什么 hook 要放回 turn 生命周期来看,而不能只看脚本列表?
  2. 源码怎样把“可见 hook 条目”和“可执行 handler”分开?
  3. hook run 为什么要先 preview,再 emit started/completed 事件?
  4. prompt、tool、approval、compact、stop 这些 slot 分别能改变什么?
  5. PermissionRequest hook 和第 5 篇的 approval / sandbox 怎样区分?
  6. 这些执行阶段如何影响下一篇要讲的性能与 prompt cache?

一、先把 hook 放回时间线

当前 HookEventName 包含十二个事件:PreToolUse、PermissionRequest、PostToolUse、PreCompact、PostCompact、SessionStart、SessionEnd、UserPromptSubmit、SubagentStart、SubagentStop、Stop 和 Interrupt。这些是条件触发点,不是一条每轮必经的固定流水线。

slot 触发位置 能返回的后果 对应调用位置
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 与 Session::request_approval。
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,沿执行路径可能触发三个职责不同的 hook;是否进入审批取决于策略与审核模式:

tool call: exec_command("npm test")
  -> PreToolUse sees tool name and input; may block or add context
  -> [if approval is requested] PermissionRequest may allow or deny
  -> [if allowed] UnifiedExecRuntime executes with the selected isolation
  -> [after a result] PostToolUse may add feedback for the next model step

所以不要把 hook 想成一个万能拦截器。PreToolUse 关心“这个 call 能不能进 handler”, PermissionRequest 关心“这次审批怎么回答”,PostToolUse 关心“结果怎样回到模型视图”。它们共享时间线,但不共享权力。

二、先发现,再决定哪些真的能跑

第七篇讲 plugin 时提到,plugin manifest 可以声明 hooks。但一个 hook 被“列出来”, 和它真的会在 turn 中执行,中间还隔着 discovery 和 trust filter。

ClaudeHooksEngine 根据功能开关、配置层、plugin sources、trust 设置和执行依赖建立 handler 集合。发现结果仍分为用户可见的 HookListEntry 与可运行的 ConfiguredHandler;后者通过 kind 区分命令执行或 MCP 调用,而不再只保存一条 shell command。

结构 包含什么 为什么分开
HookListEntry event、matcher、command、source、plugin id、enabled、current hash、trust status。 让 UI 能展示“这里有一个 hook”,即使它未启用、未信任或已被修改。
ConfiguredHandler event、matcher、timeout、source path、source、display order,以及 command/MCP kind。 只保留本次 runtime 可以执行的 handler。

discovery 从 config layers 与 plugin hook sources 读取配置,校验 matcher、启用状态和 trust hash,然后生成运行时 handler。当前支持 command 与 MCP tool 两种 handler:command 可以声明异步执行,MCP hook 则指定 server、tool 和 input 模板。SessionEnd 是例外:异步 command 被改为同步运行,MCP handler 被拒绝。未信任或未启用的条目仍可展示,但不会因此获得执行资格。

这里保证了一件很实际的事:能被看见,不等于能被执行。 项目里多了一个 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。

3.1 同步决策与异步通知要分开观察

dispatcher 用 FuturesUnordered 并发运行匹配的同步 handler,收集完后按配置顺序整理结果;异步 command 则进入 session 的后台任务集合,不参与当前同步决策。异步结果处理 保留 context、warning、error,丢弃 Stop 与 Feedback 条目。因此看到 HookStarted 只代表运行开始,不能认为阻断检查已经完成;异步 hook 也不能在副作用发生后追溯否决它。

当多个同步 PreToolUse handler 都返回 updated_input 时,采用最后完成的改写;若任意结果要求 block,就丢弃改写。这是按完成顺序选输入、按事件规则汇总决定的组合,不能简单理解为配置文件最后一条赢。

MCP hook 的 input 还支持类型保留的模板替换。例如 完整占位符 {"count":"${tool_input.count}"} 在事件给出数字 3 时会成为 {"count":3};嵌入文字中的占位符才转成字符串,字段缺失会使 hook 失败。

四、UserPromptSubmit:输入写进 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 前运行,可以给出提醒、附加上下文,或者阻止这条输入继续。

五、工具执行前、审批中、执行后是三类 hook

工具路径里最容易混的是三层: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 由 Session::request_approval 统一调用。NeedsApproval 会到达这里;strict auto-review 下,策略阶段的 Skip 也可能进入审核。hook 收到 action 的审批 payload,返回 Allow、Deny 或无 verdict,再由统一模块决定是否交给 Guardian 或用户。

决策 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 前后与 turn 停止前也能运行 hook

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 event。

6.1 中断与会话关闭只有通知,不会续跑

run_turn_interrupt_hooks 运行时 active turn 已经脱离,会复用最后一个执行步骤的 hook discovery,而不会再创建一个工作步骤。SessionEnd 在关闭根会话时运行;这两个入口都排除 subagent,子任务使用自己的 SubagentStart / SubagentStop 生命周期。

两种通知默认超时 1 秒,上限 3 秒。SessionEnd 在 shutdown 已关闭 MCP runtime 后运行,所以只允许同步 command;它与 Interrupt 的结果用于完成事件,不接受 Stop 那样的 continuation。把“用户中断任务”误当成 Stop,会错误地期待 hook 有权让模型继续工作。

七、常见误读:把相邻阶段当成同一件事

读完源码后,最需要避免的是把相邻阶段合并成一个词。尤其是 hook、approval、sandbox 和 event 这四个词,表面上都和“安全”或“可观察性”有关,实际由不同模块处理。

误读 更准确的读法 出错后果
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 在 turn 完成前运行,可以通过 continuation fragment 让模型继续。 把一次续跑机会误读成纯通知。

八、下一篇为什么可以讲性能与 prompt cache

第八篇把 hooks 放回生命周期之后,前八篇其实已经搭出一条完整主线: 用户输入进 turn,context 和 extensions 先形成 model-visible view; 工具列表决定模型可以请求什么;hooks、approval、sandbox 决定副作用能否发生; event stream 把每个关键动作变成客户端可见事实;stop 和 compact 处理长任务的继续与收束。

这时再讲性能和 prompt cache,才不会只剩“缓存命中率”这一个指标。Codex 的性能不是某个函数变快, 而是多个具体因素共同决定:稳定前缀能不能保留,动态工具和 skill 说明是不是按需进入, hook additional context 会不会改变模型视图,compact 会不会重写历史形状, 事件转换能不能让用户先看到进度。下一篇就从这些已经读过的模块出发, 专门看 prompt cache 和体感速度之间的关系。

参考源码