OpenAI Codex · Hooks

挂钩点能改什么,由事件合同决定

一次 turn 会经过十一个挂钩。协议认四种处理器,运行表只装命令和 MCP。超时默认放行,拆卸期丢掉 stdout。

课程目标读完能说清三件事:四个 type 里哪些会进运行表;hook 超时为什么拦不住工具;SessionEnd 为什么不读输出。挂钩点是生命周期上预留的插口,能改下一步的是事件合同,不是配置文件里的名字。
先玩一遍 · 一次 turn 里钩子按什么顺序响
同一条会话时间轴,换一种返回值,看谁还能改下一步
这次怎么回
五种返回值挂在同一条轴上。超时和没实现的 type 都改不了下一步,明确拦截和带 prompt 的 block 可以。
主轴 · 一次 turn 经过的挂钩
平行细轴 · 仅 ThreadSpawn 的子 agent
这一步拿到什么、能改什么
拿到还没起跑。
能改先选一种返回值再播。
工具、审批、上下文
工具还没到 PreToolUse。
上下文空着。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 协议枚举列出十一个事件名protocol.rs L1510
  2. 配置层认四种 type,后两个是空结构体hook_config.rs L183
  3. 发现阶段把 Prompt 和 Agent 写成 not supported yetdiscovery.rs L626
  4. 运行表只收下 Command 和 McpToolengine/mod.rs L107
  5. 只有同步 hook 能施加控制效果engine/mod.rs L146
  6. 超时写入 error,should_block 保持 falsecommand_runner.rs L317
  7. 退出码 2 加 stderr 才标成 Blockedpre_tool_use.rs L261
  8. Allow 映射成一次性 Approvedapprovals.rs L465
  9. Stop 的 block 带 prompt 才在轮次层 continueturn.rs L509
  10. SessionEnd 退出码 0 即完成,丢掉 stdoutsession_end.rs L109
点播放,看一次 turn 里每个挂钩拿到什么、能改什么、什么时候来不及了。
名字不等于能力配置能写下 Prompt 和 Agent,发现阶段会撕掉。能跑的只有命令和 MCP 工具。
失败默认放行超时、崩溃、非法 JSON 都把控制位留在 false。要拦,就给明确的 deny 或退出码 2 加理由。
位置决定合同工具跑完再拦,拦的是结果。拆卸期写 JSON,stdout 直接丢掉。
教学示意:主轴收成八个点,压缩与子 agent 画在旁路,用于展示触发顺序和合同差异。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 认得出和跑得了拆开
它解决什么问题

你刚从 Claude Code 把一份 hooks.json 搬过来。文件里有三条处理器:命令拦危险 shell,提示词让小模型审用户提交,agent 在 Stop 时再起一个子会话跑 linter。Claude 那边三条都能跑。

贴进 Codex,启动会话。命令那条亮了。后两条日志各写一句 not supported yet。协议枚举明明列着 PromptAgent,配置解析也认这两个 tag。装进运行表的只有命令和 MCP 工具。

思路是什么

内核对外有三张表,宽度不一样。

协议面列出十一个事件名,serde 走 snake_case。旁边四个处理器类型也在:CommandMcpToolPromptAgent

出处:codex-rs/protocol/src/protocol.rs 第 1508 至 1531 行

配置面用 type 标签把这四个名字都接住。后两个是空结构体。解析能过,字段里没有可执行内容。

出处:codex-rs/config/src/hook_config.rs 第 183 至 187 行

发现阶段看见后两个就 continue,文案是 prompt hooks are not supported yetagent hooks are not supported yet。引擎里真正可执行的种类只有命令和 MCP 工具。普通用户配置里,这两条只进 warning,会话继续。托管必选 hook 配了它们,启动会失败。

出处:codex-rs/hooks/src/engine/discovery.rs 第 626 至 645 行

协议枚举 11 个事件,4 个 type 配置解析 Prompt / Agent 是空结构体 运行表:Command / McpTool skip:not supported yet 引擎类型名就叫 ClaudeHooksEngine 兼容 Claude 的 hooks.json 是产品入口,先接住四个名字,执行器后补 wire 枚举少 SessionEnd,因为拆卸期根本不判别 stdout
三张表分开写:认得出、解析得过、跑得了,是三件不同的事。

JSON hook 的运行时类型名直接写成 ClaudeHooksEngine。兼容不是注释里的愿望,是类型名。stdin 喂 JSON,stdout 按 schema 解析。旁边还留着一条旧 notify 路,只在 turn 收工时 fire-and-forget 一条命令。两条路不要混。

出处:codex-rs/hooks/src/engine/mod.rs 第 107 至 119 行

为什么长期成立

配置面可以比执行器宽。先把生态里已有的四个 type 接住,未知 tag 才不会把整份 hooks.json 打爆。代价是搬家的人会按枚举名理解能力。所以发现阶段必须留下稳定文案,必选策略碰到未实现 type 必须拒绝启动。换个语言重写,最小形态仍是两张表加一个 skip。

思路二 · 失败默认放行
它解决什么问题

有人写了一条 PreToolUse 脚本,超时设成 1 秒,脚本里 sleep 5 秒。他们以为 hook 崩溃等于拦截。工具照样执行。日志里这条 hook 的状态是 failed,文案带 timed out after 1s

思路是什么

run_command 超时把 error 写成 hook timed out after {n}sexit_code 是空的。解析看见 error 只标 Failedshould_block 保持默认 false。工具注册表于是继续 handle_any_tool

出处:codex-rs/hooks/src/engine/command_runner.rs 第 317 至 326 行

会拦的路只有两条。JSON 里给出 deny 或 block。或者退出码 2 且 stderr 非空。退出码 2 却没有理由,算失败,不拦。异步 hook 即使返回 deny,也加不上控制效果。只有同步、可信、未超时的处理器能改下一步。

出处:codex-rs/hooks/src/events/pre_tool_use.rs 第 261 至 277 行

PreToolUse 超时 / 崩溃 退出码 2 + 理由 Failed,继续执行工具 Blocked,跳过工具 handle_any_tool RespondToModel hook 自己崩了,工具还是会跑。这是默认放行。
同一挂钩点,超时和显式拦截把工具带去两个方向。

审批路径上的规则反过来。PermissionRequest 跑在 Guardian 和用户审批 UI 之前。它不改工具输入。折叠规则是:任一 deny 立刻赢,否则保留最后一次 allow。Allow 映射成一次性 Approved,不进会话缓存。下次同样的命令还要再问。

出处:codex-rs/core/src/tools/approvals.rs 第 454 至 474 行

Claude 的 PermissionRequest 输出里有 updatedInput。Codex 把这个字段标成 reserved,看见就 fail closed。从 Claude 原样搬一条带改写的审批 hook,在这里会失败,不会改写。

要拦,就给理由。沉默和超时都放行。
为什么长期成立

一条挂掉的 linter hook 不该让所有工具停摆。可用性放在拦截可靠性前面。想改流程,必须同步、必须有明确决策。想发通知,可以异步、最多 8 个并行。审批路径对歧义输出 fail closed,因为那一层不能把看不懂的字段当成允许。

思路三 · 晚了就改不了已经发生的事
它解决什么问题

PostToolUse 想拦一次危险写入,文件已经落盘。SessionEnd 想往上下文里塞收尾说明,stdout 被丢掉。wire 枚举里也没有这个事件名。两处翻车的共同点是:挂钩点已经走过它能改的那一段。

思路是什么

十一个挂钩点按生命周期排开。主轴是 SessionStartUserPromptSubmitPreToolUsePermissionRequest → 工具 → PostToolUsePreCompactPostCompactStopSessionEnd。子 agent 另走一条细轴,SubagentStartSubagentStop 只在 ThreadSpawn 上响。

每个点的合同不一样。

PreToolUse 能拦工具、能改输入。失败默认放行。PostToolUse 只在工具成功之后跑,block 拒绝的是结果,副作用已经发生。Stop 的 block 带着 continuation prompt,才在轮次层 continue,不重发 TurnStarted。没有 prompt 的 block 被忽略。should_stop 才把控制权交回任务壳。stop 优先于 block。

出处:codex-rs/core/src/session/turn.rs 第 509 至 538 行

UserPromptSubmit 的 block 写成 should_stop,停的是当前这条用户消息,不会拿 stderr 当下一轮 prompt。matcher 在这里被忽略。SessionStart 尊重 continue: falseSubagentStart 只做上下文注入,同样的字段被丢掉。

SessionEnd 是拆卸期通知。超时默认 1 秒,上限 3 秒,给 app-server 的五秒 shutdown 留余量。退出码 0 就是完成,stdout 整段丢掉。MCP 形态直接 skip。reason 目前写死 other

出处:codex-rs/hooks/src/events/session_end.rs 第 20 至 24 行

hook 文本进模型之前先变成 developer 角色的片段。默认预算 2500 个近似 token。超限时全文写到临时目录,模型看见头尾预览加一行路径。写盘失败就只截断。

出处:codex-rs/hooks/src/output_spill.rs 第 53 至 91 行

为什么长期成立

挂钩点的能力跟它在生命周期的位置绑定。工具还没跑,才能改输入或跳过。工具跑完,只能改模型看见的那一截。拆卸期只有几秒,读 JSON、回灌上下文、再等 MCP,都会把关机拖过上限。于是它变成纯通知。换一套运行时,该问的仍是:这个点还来不来得及改已经发生的事。

横向对比 · 同一份 hooks.json 的三种接法

Claude Code:二十七个事件,Prompt 和 Agent 真的会跑

还原源码里 HOOK_EVENTS 有 27 项。Codex 的十一点都在,另外还有失败后事件、通知、工作树和文件变更。execPromptHook 用小模型跑一段提示词,构造 user message 时绕开 processUserInput,避免再次触发 UserPromptSubmitexecAgentHook 会起一轮完整 query。

Claude 的运行面比配置面宽。Codex 反过来,先把四个 type 接住,执行器后补。事件名高度重合,这是对齐动作。ClaudeHooksEngine 这个类型名把产品判断写进了标识符。

两侧均已核对源码 · 2026-08-22

DSH:插件瀑布是原生接口,hook 文件是兼容桥

DSH 的工具管道在 tools/pre-executetools/post-execute 上各开一道瀑布。原生插件能做桥能做的一切。hooks-codex 只映射五点:PreToolUsePostToolUseSessionStartUserPromptSubmitStop。没有 rewrite,没有 PermissionRequesttype: command 以外的、异步的,解析后跳过。

DSH 先有插件再补文件兼容。Codex 先有 Claude 文件合同,再让插件往同一引擎里塞声明。Grok 用十五个事件名补观察面,is_blocking() 只对 PreToolUse 返回真,没有审批前 hook。

两侧均已核对源码 · 2026-08-22 · DSH · 插件瀑布
课堂练习
01

同一条 PreToolUse,两种返回值

项目里有一条 PreToolUse 命令 hook,matcher 对着无害的 echo。先把 timeout 设成 1,脚本里 sleep 5 秒。再把脚本改成退出码 2,并向 stderr 写 blocked by test

推演两趟结局:工具会不会执行,模型看见什么,hook 状态分别是 failed 还是 blocked。然后解释,为什么第一种不能靠「hook 挂了」来当拦截。

Takeaway:协议、配置、运行是三张表,能跑的只有命令和 MCP。失败默认放行,要拦就给理由。每个挂钩点能改的东西跟它在生命周期的位置绑定,拆卸期和工具跑完之后,已经来不及改已经发生的事。