Hooks · 在关键节点插入你的脚本
Codex 的 hooks 是一个名为 ClaudeHooksEngine 的引擎:12 个事件、按哈希记账的信任机制、会话 shell 里起进程、stdin 进 JSON、退出码与 stdout 出决定。本篇讲它从哪里收集钩子、怎样匹配和并发执行,以及拦截、改写、续写这些决定如何回到 agent 循环。
Hooks · 在关键节点插入你的脚本
Hooks 让用户在 agent 生命周期的固定节点跑自己的命令,把“每次都必须做”的事从“指望模型记得”变成确定执行。Codex 的实现在 codex-hooks crate,核心类型直接叫 ClaudeHooksEngine——事件名、JSON 字段、退出码约定都沿用 Claude Code 的格式。
怎么用
钩子写在 hooks.json 或 config.toml 的 [hooks] 表里,三层结构:事件 → 匹配组(matcher)→ 处理器(type = "command" 或 "mcp_tool")。命令从 stdin 读到事件 JSON,用退出码与 stdout 表态;新增或改动的钩子要先在 /hooks 里审阅信任才会运行。事件清单、字段与示例见手册 Hooks。
12 个事件挂在哪里
HOOK_EVENT_NAMES 列出 12 个事件(codex-rs/hooks/src/lib.rs:23),引擎本身不知道 agent 循环长什么样,由 codex-core 的 hook_runtime.rs 在各处调用:
| 事件 | 触发位置 |
|---|---|
SessionStart、SubagentStart | 启动、恢复、分叉、清空、压缩时只记一笔待办,下一轮开始时由 run_pending_session_start_hooks 执行,注入的上下文直接进这一轮(codex-rs/core/src/hook_runtime.rs:128) |
UserPromptSubmit | inspect_pending_input 逐条检查排队的用户输入 |
PreToolUse、PostToolUse | ToolRegistry 分派工具的前后(codex-rs/core/src/tools/registry.rs:603) |
PermissionRequest | 审批流程里,排在自动审查与用户之前 |
PreCompact、PostCompact | 本地压缩、远程压缩 v2 与 token budget 模式的压缩,三处实现各自调用 |
Stop、SubagentStop | run_turn 发现模型不再需要继续时 |
Interrupt | 任务被中断时(codex-rs/core/src/tasks/mod.rs:815) |
SessionEnd | 会话关闭时,先把对话记录刷盘,此时 MCP 连接已经关闭(codex-rs/core/src/session/handlers.rs:321) |
SessionEnd 也是唯一不接受 mcp_tool 处理器、也不能异步运行的事件;它和 Interrupt 的超时默认 1 秒、最多 3 秒,其余事件默认 600 秒(codex-rs/hooks/src/engine/discovery.rs:742)。以 PreToolUse 为例,一次调用的路径是:
发现:从哪里收集钩子
discover_handlers(codex-rs/hooks/src/engine/discovery.rs:94)按固定顺序收集:先是 requirements 里托管的钩子,它们标记为 Required,加载失败会让会话直接启动失败;再按优先级从低到高遍历生效的配置层,每层读所在目录的 hooks.json 和该层 config.toml 的 [hooks],两者都有就一起加载并警告;最后是插件带来的钩子,命令里的 ${PLUGIN_ROOT}、${PLUGIN_DATA} 会被替换,同时注入同名环境变量,另有 CLAUDE_PLUGIN_ROOT、CLAUDE_PLUGIN_DATA 兼容现成插件。
遍历用的是 layers_low_to_high(),它会跳过带 disabled_reason 的层,所以未受信任项目的 .codex/hooks.json 根本不会被读到(见配置系统)。type = "prompt" 与 "agent" 能解析,但一律跳过并告警。requirements 设了 allow_managed_hooks_only 时,非托管来源整层跳过。
信任:按内容哈希记账
每个处理器都有一个哈希:把事件名、matcher 和规范化后的处理器配置序列化成 TOML,再用配置层同款的 SHA-256 指纹计算,所以同一个钩子写在 hooks.json 还是 config.toml 里,信任身份相同。信任状态按下面的规则判定:
fn hook_trust_status(
is_managed: bool,
is_builtin: bool,
current_hash: &str,
trusted_hash: Option<&str>,
) -> HookTrustStatus {
if is_builtin {
HookTrustStatus::Trusted
} else if is_managed {
HookTrustStatus::Managed
} else {
match trusted_hash {
Some(trusted_hash) if trusted_hash == current_hash => HookTrustStatus::Trusted,
Some(_) => HookTrustStatus::Modified,
None => HookTrustStatus::Untrusted,
}
}
}(codex-rs/hooks/src/engine/discovery.rs:794)
只有启用、并且状态为 Trusted 或 Managed 的处理器才进入执行列表,--dangerously-bypass-hook-trust 会跳过这一步。你在 /hooks 里点“信任”,就是把当前哈希写进 hooks.state 里对应条目的 trusted_hash;钩子内容一变,哈希对不上,状态变成 Modified,重新等待审阅。hook_states_from_stack 只从用户层和会话层读这张表(codex-rs/hooks/src/config_rules.rs:15),仓库里的配置没法替自己的钩子签字。托管来源包括 requirements、MDM、云端配置和 system 层——/etc/codex/config.toml 里的钩子也不需要逐条信任。
匹配与并发
matcher 有两种解释:只由字母、数字、_、| 组成时,按 | 拆开做精确比较,Bash 不会误中别的名字;含其他字符才当正则。*、空串或省略表示全部匹配,UserPromptSubmit、Stop、Interrupt 忽略 matcher(codex-rs/hooks/src/events/common.rs:137)。工具名由 HookToolName 提供:stdin 里写的是 Codex 的真名,匹配时另加 Claude Code 风格的别名,apply_patch 还能被 Write、Edit 选中,spawn_agent 能被 Agent 选中,shell 类工具统一叫 Bash(codex-rs/core/src/tools/hook_names.rs:34)。
选中的同步处理器用 FuturesUnordered 并发启动,彼此看不到对方的结果,全部完成后按配置顺序整理(codex-rs/hooks/src/engine/dispatcher.rs:115)。async: true 的处理器交给后台任务集,每个会话最多同时跑 8 个(codex-rs/hooks/src/engine/command_runner.rs:49),结果在每次采样及其工具调用结束后、以及下一轮用户输入之前取回,只能补充上下文,不能拦截或改写。
进程协议
命令用会话所用的 shell 执行(bash、zsh 用 -c,PowerShell 加 -NoProfile -Command),拿不到时退回 $SHELL -lc 或 %COMSPEC% /C。工作目录是会话 cwd;Unix 上进程开在新会话里、没有控制终端;环境变量是会话启动时的快照加上钩子自己的变量,再剔除 NON_INHERITABLE_ENV_VARS 里的几个 Codex 内部凭据变量。写 stdin 和读输出同时进行,并且都算在超时里:
let timeout_duration = Duration::from_secs(handler.timeout_sec);
// Drain output while sending input so neither pipe can block the other, and
// include stdin writes in the deadline even when the hook never reads them.
match timeout(timeout_duration, try_join(write_stdin, wait_with_output)).await {(codex-rs/hooks/src/engine/command_runner.rs:280)
超时后,守卫对象在析构时杀掉整个进程组(Windows 上用 Job Object 或 taskkill /T)。输入 JSON 的结构用 Rust 类型定义,例如 PreToolUse:
pub(crate) struct PreToolUseCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
// ...
pub tool_name: String,
pub tool_input: Value,
pub tool_use_id: String,
}(codex-rs/hooks/src/schema.rs:278)
省略的字段是子代理才有的 agent_id、agent_type,以及 transcript_path、cwd、hook_event_name、model、permission_mode;最后一个由审批策略换算,never 报 bypassPermissions,其余都报 default。每个事件的输入与输出都有对应的 JSON Schema,生成在 codex-rs/hooks/schema/generated/,共 23 份。输出按三种情况解读:退出码 0 时,stdout 为空表示没意见,是 JSON 就按该事件的结构解析;退出码 2 时,对能拦截的事件而言 stderr 就是拦截或续写的理由,为空则记为失败;其他退出码、超时、启动失败都只记为失败,原操作照常进行。additionalContext 超过约 2500 token 时,全文写进 <临时目录>/hook_outputs/<thread_id>/,模型只看到头尾预览和文件路径(codex-rs/hooks/src/output_spill.rs:12)。
决定怎样回到循环
PreToolUse 任一处理器拦截即拦截,理由取配置顺序里的第一个;被拦下时 ToolRegistry 返回 FunctionCallError::RespondToModel,形如 Command blocked by PreToolUse hook: ... 的文字作为工具结果交还模型。多个处理器都改写参数时,取最后完成的那个,再交给各工具的 with_updated_hook_input 重新构造调用,转换失败就按失败处理:
/// Chooses the rewrite from the hook that actually finished last.
///
/// Hook results stay in configured order for stable reporting, but the
/// `PreToolUse` contract resolves competing rewrites by completion order.
fn latest_updated_input((codex-rs/hooks/src/events/pre_tool_use.rs:149)
PermissionRequest 在审批流程的最前面:
// Approval precedence is:
// 1. Hooks
// 2. If StrictAutoReview || Guardian enabled, then Guardian. Else, user.(codex-rs/core/src/tools/approvals.rs:505)
多个处理器里任一 deny 立即生效,否则有 allow 就放行,都不表态才转给自动审查或用户。
Stop 里任一处理器返回 continue: false 就结束本轮,优先于续写;否则只要有处理器拦截,所有拦截理由拼成一条提示消息记入历史,run_turn 带着 stop_hook_active = true 再转一圈(codex-rs/core/src/session/turn.rs:659)。循环里没有续写次数上限,防死循环要靠钩子自己检查 stop_hook_active。PostToolUse 拦截时,反馈文字替换模型看到的工具结果;UserPromptSubmit 能拦下这条输入。
旧配置项 notify 也跑在这个 crate 里:它被包装成一个 AfterAgent 钩子,每轮结束把 JSON 作为最后一个命令行参数传给外部程序,不等待结果(codex-rs/hooks/src/legacy_notify.rs:45)。
和《从 LLM 到 Coding Agent》对照
那本书的 Hooks 把 PreToolUse 看作“用户可编程的权限闸门”,Codex 的代码把这句话写实了:PermissionRequest 钩子就排在审批优先级的第一位。书里提醒 Stop 钩子要配最大重试次数;Codex 没有设硬上限,而是把 stop_hook_active 传给钩子,由钩子自己刹车。对比 Grok Build:它的 Stop 续写每轮最多 8 次、第一个 Deny 就短路,还支持 HTTP 回调;Codex 让所有同步处理器并发跑完再汇总,并用内容哈希把“谁写的钩子能运行”交给用户逐条确认。
上一篇:Skills · 发现、选择与注入 · 下一篇:插件与 marketplace · 打包分发扩展