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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
{
"hooks": {
"PreToolUse": [
{
"matcher": "^apply_patch$",
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/workflow.py\"",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/workflow.py\"",
"timeout": 15
}
]
}
],
"PreCompact": [
{
"matcher": "^(manual|auto)$",
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/workflow.py\"",
"timeout": 5
}
]
}
],
"SessionStart": [
{
"matcher": "^compact$",
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/workflow.py\"",
"timeout": 5,
"additionalContextLimit": 300
}
]
}
]
}
}

把下面的脚本存到 .codex/hooks/workflow.py。补丁 Hook 只处理练习里的生成目录约定。测试门槛检查 Git 工作区的实际文件状态,并根据测试退出码作决定。脚本在测试失败时只返回下一步命令,测试原始输出可由 Codex 按需重跑查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import json
import re
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
HANDOFF = ROOT / ".task-notes/sort-dates.md"
SOURCE = "src/sort-dates.mjs"
TEST = "tests/sort-dates.test.mjs"


def emit(data):
print(json.dumps(data, ensure_ascii=False))


def check_patch(event):
patch = event.get("tool_input", {}).get("command", "")
paths = re.findall(r"^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:) (.+)$",
patch, flags=re.MULTILINE)
cwd = Path(event.get("cwd") or ROOT)
generated = (ROOT / "dist").resolve()
for name in paths:
target = (cwd / name).resolve()
if target == generated or generated in target.parents:
emit({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "dist/ is generated. Edit src/ instead.",
}})
return


def check_before_stop(event):
if event.get("stop_hook_active"):
return
changed = subprocess.check_output(
["git", "status", "--porcelain", "--", SOURCE, TEST], cwd=ROOT, timeout=3
)
if not changed:
return
try:
result = subprocess.run(
["node", "--test", TEST], cwd=ROOT, timeout=8,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
except (OSError, subprocess.TimeoutExpired):
emit({"systemMessage": "无法执行日期排序验收,请手动检查 Node.js 和测试超时。"})
return
if result.returncode != 0:
emit({
"decision": "block",
"reason": f"运行 node --test {TEST},处理失败用例,最后报告实际测试结果。",
})


def record_compaction(event):
HANDOFF.parent.mkdir(exist_ok=True)
record = {key: event.get(key) for key in ("session_id", "turn_id", "trigger")}
with (HANDOFF.parent / "hook-compactions.jsonl").open("a", encoding="utf-8") as log:
log.write(json.dumps(record) + "\n")


def restore_handoff(event):
if event.get("source") == "compact" and HANDOFF.is_file():
emit({"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "续作前查看仓库根的 .task-notes/sort-dates.md,"
"用 git diff 和测试结果核对当前进度。",
}})


handlers = {
"PreToolUse": check_patch,
"Stop": check_before_stop,
"PreCompact": record_compaction,
"SessionStart": restore_handoff,
}
event = json.load(sys.stdin)
handler = handlers.get(event["hook_event_name"])
if handler:
handler(event)

先跑最小的本地验证。第一条应得到 JSON 拒绝决定,第二条应安静退出。两条命令都只把样本输入送给 Hook,apply_patch 不会实际运行。

1
2
3
4
printf '%s\n' '{"hook_event_name":"PreToolUse","tool_input":{"command":"*** Update File: dist/output.js"}}' |
python3 .codex/hooks/workflow.py
printf '%s\n' '{"hook_event_name":"PreToolUse","tool_input":{"command":"*** Update File: src/sort-dates.mjs"}}' |
python3 .codex/hooks/workflow.py

再从练习仓库启动 Codex,进入 /hooks 检查四项定义的来源并完成信任审查。把集成检查分成三步。

  1. 改动前。 在练习仓库请求用 apply_patch 新建一个无害的 dist/hook-probe.txt,要求如果被拦就停止。预期界面显示拒绝原因,文件不存在。换成允许的源目录后应通过这条 Hook,工具本身仍按当前权限执行。
  2. 收尾时。 在源码加一条不影响行为的注释,让 Git 检测到改动,同时保留练习仓库原有的排序错误。先用 printf '%s\n' '{"hook_event_name":"Stop","stop_hook_active":false}' | python3 .codex/hooks/workflow.py 验证失败测试会返回续作请求。把 false 改为 true,预期脚本直接退出。接着请 Codex 完成日期排序任务。如果它在测试仍失败时准备结束,Hook 会要求它再做一次检查。持续失败的测试可以由 CI 阻止合并。
  3. 压缩时。 按第二篇准备 .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 输出后仍超限,就降级为路径指针或结束本轮并报告原因,避免在相同输入上反复压缩。这是应用侧可加的保护。关于压缩和后续拼接的边界,见第二篇。上下文如何压缩与拼接

参考阅读

系列相关篇章 · 上下文压缩与拼接 · 工具、权限与沙箱 · 会话持久化与长期记忆