不是泛泛介绍。这份指南结合源码、schema 与真实实验,把 hooks 怎么开、怎么配、怎么调,以及 Stop 到底是什么、为什么能无限续跑,一次讲透。
如果你只有几分钟,这 10 条是整份指南最值得记住的。
TaskDone、ReviewFinished,只有 5 个固定事件。config.toml,规则写在 hooks.json,很多人第一眼会搞错。command 必须是单个 shell 字符串,这是最容易踩的坑。decision: "block",Codex 就会被推回去继续干。这很危险,会不断消耗 token。从会话开始到 turn 结束,Codex 在 5 个关键节点派发事件,每个都可以挂载自定义脚本。
| 事件名 | 支持 matcher | 可拦截 | 可注入上下文 | 最低版本 |
|---|---|---|---|---|
| SessionStart | 是 | continue: false | additionalContext | v0.114.0 |
| UserPromptSubmit | 忽略 | decision: block | 纯文本 stdout | v0.116.0 |
| PreToolUse | 是(正则) | decision: block | 暂不支持 | v0.117.0 |
| PostToolUse | 是(正则) | 切断后续 | additionalContext | v0.117.0 |
| Stop | 忽略 | decision: block | 通过 reason | v0.114.0 |
hooks 需要先在 config.toml 打开功能开关,然后在单独的 hooks.json 里写规则。
~/.codex/hooks.json,项目级放 <repo>/.codex/hooks.json。两者可以同时生效。# 打开 hooks 功能开关 [features] codex_hooks = true # 可选:临时启动时启用 # codex --enable codex_hooks
{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "/usr/bin/python3 ~/.codex/hooks/stop_demo.py", "timeout": 10 } ] } ] } }
<repo>/.codex/hooks.json 可能不会被加载。发现项目级 hook 没反应,先检查 trust 设置。.git 等标记,加载器会把当前 cwd 当作 project root。但子目录不会自动继承父目录的 hooks。理解执行模型,才能正确写脚本、正确读输入、正确返回输出。
$SHELL -lc,不可用时回退 /bin/sh -lc。所以 command 写成 shell 字符串是合理的。json.load(sys.stdin) 读取最稳妥,不推荐按行读取。timeout 或别名 timeoutSec 字段控制。import json import sys # 推荐写法:整体读取 payload = json.load(sys.stdin) # 或者:先读再解析 # raw = sys.stdin.read() # payload = json.loads(raw) # hook 不只做副作用,还可以通过 stdout 控制 Codex 行为 # 只做通知时,返回空 JSON 最稳妥 print("{}")
结合源码和实测结果,把每个事件的触发时机、能做什么、不能做什么写清楚。
在会话启动或恢复时触发,发生在你发第一条用户消息之前。source 字段当前除文档说的 startup、resume 外,schema 里还能看到 clear。
#!/usr/bin/env python3 import json print(json.dumps({ "hookSpecificOutput": { "additionalContext": ( "You are running on macOS. Prefer Chinese replies. " "Do not assume old hook examples are correct; " "current command format is a shell string." ) } }))
在用户 prompt 真正提交给模型前触发——不是在输入框里打字时触发,而是真正提交的那一刻。不支持 matcher,注入的 context 会进入 developer instructions 侧。
#!/usr/bin/env python3 import json import re import sys payload = json.load(sys.stdin) text = json.dumps(payload, ensure_ascii=False) patterns = [ r"sk-[A-Za-z0-9]+", # OpenAI key r"AKIA[0-9A-Z]{16}", # AWS key ] for pattern in patterns: if re.search(pattern, text): print(json.dumps({ "decision": "block", "reason": "Prompt appears to contain a secret. Submission was blocked." })) raise SystemExit(0) print("{}")
名字听起来像"所有工具执行前都能挂",但当前 schema 里 tool_name 实际限定为 Bash。不要假设它覆盖全部工具。支持 matcher 正则匹配。
#!/usr/bin/env python3 import json import sys payload = json.load(sys.stdin) serialized = json.dumps(payload, ensure_ascii=False) dangerous = [ "rm -rf /", "mkfs", ] for item in dangerous: if item in serialized: print(json.dumps({ "decision": "block", "reason": f"Blocked dangerous command pattern: {item}" })) raise SystemExit(0) print("{}")
工具执行后触发。此时工具已经跑完了,decision: "block" 不是"阻止刚才的执行",而是把一个"反馈/替代结果"送回后续处理流程。
这是整份指南最核心的部分。Stop 不是"会话结束",也不是"扫描固定文本",而是一个可以被反复拦截的运行时生命周期钩子。
模型认为任务完成,运行时进入"准备停止"阶段
运行时主动派发 Stop 事件,不是扫描模型输出的固定文本
脚本输出 JSON,告诉 Codex "还不能停"
格式为 <hook_prompt ...>,作为隐藏用户消息送回
stop_hook_active 字段从 false 变成 true,可以用来判断是否已续跑过一次
如果 hook 继续 block,就会无限循环。如果不 block,turn 正常结束。
stop_hook_active 是 false,续跑后变成 true。检查这个字段,就能做"只续跑一次"的安全版本。#!/usr/bin/env python3 import json import sys payload = json.load(sys.stdin) # 已经续跑过一次,不再拦截 if payload.get("stop_hook_active"): print("{}") else: print(json.dumps({ "decision": "block", "reason": "Do one more check pass." }))
#!/usr/bin/env python3 import json import subprocess import sys payload = json.load(sys.stdin) message = payload.get("last_assistant_message", "") or "Codex finished." message = " ".join(message.split())[:120] apple_script = ( f'display notification {json.dumps(message)} ' f'with title {json.dumps("Codex Stop")}' ) subprocess.run( ["/usr/bin/osascript", "-e", apple_script], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) print("{}") # 不 block,只做通知
根据你的实际需求选择合适的方案,比照着改一改就能跑。
#!/usr/bin/env python3 # 完全不知道 stdin 里是什么时,先跑这个把 payload 落盘 import sys from pathlib import Path raw = sys.stdin.read() Path("/tmp/codex-hook-dump.json").write_text(raw, encoding="utf-8") print("{}")
这些错误在网上旧帖子、旧示例里非常常见,现在花一分钟记住,省去之后的排查时间。
旧示例:["bash", "-c", "echo hello"]。当前版本要求是单个 shell 字符串:"echo hello"。这是第一大坑。
Stop 是 turn 级 事件,是"Codex 准备结束当前这一轮 agent turn",不是整个 shell 退出,也不是"用户半天没说话"。
开关写在 config.toml 的 [features] 节,但 hooks 具体规则必须写在 hooks.json。
当前版本里,这两个事件实际上主要针对 Bash。不要默认以为它已经覆盖全部工具生态。
这两个事件会完全忽略 matcher 字段,写了也没用。只有 SessionStart、PreToolUse、PostToolUse 支持 matcher。
当前 async、prompt、agent、allow、ask、updatedInput、updatedMCPToolOutput 虽然在枚举里出现,但当前版本未支持。
按序逐项排查,通常前 4 步就能找到问题。
codex --version 版本足够新config.toml 是否开了 codex_hooks = truehooks.json,不是 config.tomlcommand 是否写成了当前版本支持的字符串# 查看当天的 rollout 日志 ls ~/.codex/sessions/$(date +%Y/%m/%d)/*.jsonl # 确认 turn_id 是否被续跑(同一 turn_id 出现多次) # 以及是否出现了隐藏的 <hook_prompt ...> 内容 cat ~/.codex/sessions/YYYY/MM/DD/rollout-<id>.jsonl | grep turn_id