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。失敗預設放行,要攔就給理由。每個掛鉤點能改的東西跟它在生命週期的位置綁定,拆卸期和工具跑完之後,已經來不及改已經發生的事。