Codex 工程实践(五):用 Hook 控制工作流程与上下文预算
上一轮 Codex 能记住生成文件不要手改,下一轮仍可能直接写入 dist/。把约定写进 AGENTS.md 可以让模型选对路径。要在提交修改前检查目标文件,或在模型准备收尾时跑一次真实测试,可以把检查挂到运行时的生命周期事件上。
《Codex 源码分析》第 16 章把 Hook 拆成配置发现、匹配与信任检查、脚本执行、结果处理。沿这条链路,就能弄清楚该在什么事件上写哪条规则,以及什么时候 Hook 本身会给模型带来过多上下文。
一、Hook 怎样被发现和执行
以命令 Hook 为例,Codex 先从活跃配置层收集定义。事件发生时,运行时按 matcher 选出处理器,检查信任状态,再启动脚本。脚本从 stdin 接收一份 JSON,常见字段有 session_id、cwd 和 hook_event_name。处理器按该事件的输出协议写 stdout,运行时再决定要不要阻止操作、补充模型上下文或继续工作。transcript_path 可能为 null,脚本要能处理没有轨迹文件的情况。第 16 章的配置发现与执行链路
| 想让谁使用 | 改哪里 | 生效条件 |
|---|---|---|
| 只给自己用 | $CODEX_HOME/hooks.json,或在同目录的 config.toml 写内联 [hooks]。CODEX_HOME 默认是 ~/.codex | 活跃用户配置层,Hook 需要经过信任审查 |
| 给同一个仓库用 | 提交 <repo>/.codex/hooks.json 以及仓库内的脚本,也可内联到 <repo>/.codex/config.toml | 项目配置层受信任后会被发现,还要单独审查 Hook |
| 随插件分发 | 插件默认用 hooks/hooks.json,也可在插件清单里声明其他位置 | 启用插件并信任 Hook,更新后需要留意重新审查 |
| 由组织强制部署 | 管理员在托管 requirements.toml 配置 [hooks],用设备管理工具分发脚本到 managed_dir 对应位置 | 由托管策略信任。只允许托管 Hook 时,管理员设 allow_managed_hooks_only = true |
Hook 会跨配置来源累加。同一层同时写 hooks.json 和内联 [hooks],两份会一起加载。同一个事件命中多个命令处理器时,它们可以并发启动。想先验证参数再重写,就把两个动作放进同一个处理器。CLI 用 /hooks 查看来源、审查定义和禁用单项 Hook。配置变更后重新查看信任状态,桌面端也可以从设置的 Hooks 页面检查。配置位置、匹配与信任校验
处理器的 type 可以是 command,也可以是 mcp_tool。前者运行本地脚本,后者调用已连接的 MCP 工具,例如把一次补丁提交给团队的扫描服务。MCP Hook 不会为你拉起或重连服务器,连接错误也不会自动拦住触发它的操作。需要拦截或决定审批时用同步处理器,统计和非关键审计可以用 async: true 的命令处理器,后台处理器的输出仍可能在后续请求进入上下文。命令与 MCP 处理器
二、哪些生命周期事件可以配置
下表按 OpenAI Docs 所列的本地 Codex 事件整理。第 16 章分析的源码版本包含其中的核心事件,较新的客户端文档还列出了模型请求、会话结束和中断相关事件。部署时用实际客户端版本核对。事件、输入字段与输出协议
| 事件 | 触发点与匹配字段 | 可以用来做什么 |
|---|---|---|
SessionStart | 启动、恢复、清空、压缩或 fork,按 source 匹配 | 注入一条项目入口或任务交接提示,按 ^compact$ 单独处理压缩后的续作 |
UserPromptSubmit | 用户提示即将送出,脚本读 prompt,matcher 不生效 | 拦截团队明确禁止提交的材料,或加一条针对本次请求的提醒 |
PreToolUse | 受支持的工具开始前,按工具名或别名匹配 | 校验参数、拒绝一次写入,或按事件协议重写参数 |
PermissionRequest | 即将进入审批流程,按工具名或别名匹配 | 对组织已定义的审批场景作允许或拒绝决定。无决定时走普通审批 |
PostToolUse | 工具返回结果后,按工具名或别名匹配 | 对补丁做局部 lint,留下审计或短反馈。原操作的副作用已经发生 |
PreModelRequest、PostModelRequest | 每次模型请求尝试前后,包括重试,matcher 不生效 | 记录请求与错误、观察可用的 token 用量。输出仅供诊断 |
PreCompact、PostCompact | 压缩前后,按 trigger 的 manual 或 auto 匹配 | 前者保存检查点或记录元数据,后者记录压缩完成。允许用 continue: false 在相应阶段停下 |
SubagentStart、SubagentStop | 子代理启动和收尾,按 agent_type 匹配 | 加入特定角色的工作约定,或要求这个子代理再做一次验收 |
Stop | 主回合准备结束,matcher 不生效 | 查真实产物或跑快速验收。用 decision: "block" 和短 reason 请求续作 |
Interrupt | 用户中断主线程中的活跃回合,matcher 不生效 | 快速记一条中断记录或清理 Hook 自己的状态 |
SessionEnd | 主会话结束,按结束原因匹配 | 记本地收尾记录。当前原因值为 other,处理器同步执行 |
matcher 针对事件的匹配字段。比如 ^Bash$ 对应 shell 工具,要识别命令是否调用包管理器,还要读取 tool_input.command。文件补丁可以匹配 apply_patch,也可以用 Edit 或 Write 别名,输入仍以实际工具名为准。工具 Hook 只覆盖支持的路径,例如通过 Bash 写文件和通过 apply_patch 写文件走不同的匹配;关键权限继续交给沙箱、命令规则和服务端授权。工具覆盖和审批行为
三、把三道检查接到日期排序任务上
沿用第一篇的练习仓库,从仓库根目录创建 .codex/hooks.json 和 .codex/hooks/workflow.py。这个例子适用于有 Git、Python 3 和 Node.js 的本地 CLI 环境,命令写法按 macOS 或 Linux。它会在补丁执行前拦截对生成目录的修改,在回合结束时验证日期排序,在压缩时写入一条本地记录并在续作时提供交接文件的路径。日期排序练习仓库
1 | { |
把下面的脚本存到 .codex/hooks/workflow.py。补丁 Hook 只处理练习里的生成目录约定。测试门槛检查 Git 工作区的实际文件状态,并根据测试退出码作决定。脚本在测试失败时只返回下一步命令,测试原始输出可由 Codex 按需重跑查看。
1 | import json |
先跑最小的本地验证。第一条应得到 JSON 拒绝决定,第二条应安静退出。两条命令都只把样本输入送给 Hook,apply_patch 不会实际运行。
1 | printf '%s\n' '{"hook_event_name":"PreToolUse","tool_input":{"command":"*** Update File: dist/output.js"}}' | |
再从练习仓库启动 Codex,进入 /hooks 检查四项定义的来源并完成信任审查。把集成检查分成三步。
- 改动前。 在练习仓库请求用
apply_patch新建一个无害的dist/hook-probe.txt,要求如果被拦就停止。预期界面显示拒绝原因,文件不存在。换成允许的源目录后应通过这条 Hook,工具本身仍按当前权限执行。 - 收尾时。 在源码加一条不影响行为的注释,让 Git 检测到改动,同时保留练习仓库原有的排序错误。先用
printf '%s\n' '{"hook_event_name":"Stop","stop_hook_active":false}' | python3 .codex/hooks/workflow.py验证失败测试会返回续作请求。把false改为true,预期脚本直接退出。接着请 Codex 完成日期排序任务。如果它在测试仍失败时准备结束,Hook 会要求它再做一次检查。持续失败的测试可以由 CI 阻止合并。 - 压缩时。 按第二篇准备
.task-notes/sort-dates.md,再用/compact。预期本地记录出现trigger: "manual",续作时能指出交接文件,原始日志没有被整段塞回去。自动压缩时,记录里的触发值应是auto。若新定义或改动没有触发,先检查/hooks的来源和信任状态,再查事件名、匹配值及脚本错误。压缩前后的验证步骤
迁到真实项目时,改 check_patch 的生成目录和适用工具,改 SOURCE、TEST 和测试命令,再把 HANDOFF 指向该任务的交接文件。并行任务可以各用自己的 worktree,避免复用同一份交接记录。多个 Hook 可能同时触发,脚本写入共享状态时应按事件或会话去重。
四、Hook 怎样把上下文撑满
上下文超限来自会进入模型请求的内容。SessionStart、UserPromptSubmit 以及部分工具 Hook 可以附加 additionalContext,PostToolUse 的反馈也可能成为下一次请求的内容。Stop 的 decision: "block" 会把 reason 变成续作输入。如果每次工具运行都回传整份日志,或者每次收尾都请求继续,历史会随着调用次数增长。最容易形成反复压缩的是 SessionStart 匹配 compact 后重新注入刚压掉的整份材料。根会话在回合中自动压缩时,这份 Hook 输出就会进入紧接着的模型请求。Hook 输出与压缩续作
第 16 章分析的 HookOutputSpiller 会逐条估算模型可见输出的体积。默认阈值约为 2,500 token。超过阈值,完整文本存到临时目录 hook_outputs/<session_id>/<uuid>.txt,模型收到首尾预览和路径。写盘失败时仍会给截断预览。支持 additionalContextLimit 的版本可以给命令处理器设置更小的正数,例如上例的 300。这个字段只调该处理器的 additionalContext,工具反馈和续作提示仍用默认阈值。0 会放过完整的 additionalContext。十条各自合规的输出仍会相加,压缩后重新注入的内容也会重新计入预算。Hook 输出裁剪的源码
在实践中,我会分别控制注入的大小、频率和重试次数。
- 短摘要加路径。 压缩前保留目标、用户最后一次纠正、已验证项、未完成项和证据入口。日志保存在本地,压缩后的
SessionStart只提示交接路径。需要日志时读指定片段,避免一次读取整个 spill 文件。 - 过滤高频事件。 用
matcher限定工具和压缩来源。成功时让 Hook 退出0且无输出,失败时只送一个可执行的下一步。调试和审计数据走本地文件,别在可见输出里放凭据。 - 限制自动续作。
Stop或SubagentStop先检查stop_hook_active,把自动补救限制在一次。仍不通过时,交付时报告失败并保留证据,由任务调用方或 CI 决定后续。 - 排查反复压缩。 对照
PreCompact、PostCompact的触发记录和PostModelRequest的用量与失败状态。如果只在某个 Hook 启用时出现,先用/hooks停用对应的非托管 Hook,再按相同任务复测。随后缩小匹配范围和输出,重新信任启用。当前历史已经过满时,用短交接和 Git 差异开一个新会话。
自己实现 Harness 时,可以在单条裁剪之上加一层请求总预算,把基础指令、压缩后历史、全部 Hook 片段、工具结果和输出预留一起计入。压缩后重新计算一次,如果追加 Hook 输出后仍超限,就降级为路径指针或结束本轮并报告原因,避免在相同输入上反复压缩。这是应用侧可加的保护。关于压缩和后续拼接的边界,见第二篇。上下文如何压缩与拼接
参考阅读
系列相关篇章 · 上下文压缩与拼接 · 工具、权限与沙箱 · 会话持久化与长期记忆