Codex 0.148.0 實戰拆解:/export 存檔、exec fork 分岔、hooks 終於能非同步跑
先說結論:openai/codex 的 rust-v0.148.0 對已經天天在用 Codex CLI 的人來說,是少數幾個會真的改掉日常操作習慣的版本。不是模型變聰明,是「session 這個東西終於可以被搬運、被複製、被監控」。
三件事:
- TUI 多了
/export,整段對話完整倒成 Markdown(PR #37358) codex exec fork把「從某個 session 分岔出去」帶進 headless/CI(PR #37367)- hooks 支援
"async": true,而且新增mcp_toolhandler(PR #37533、#38705)
先聲明:我沒有把每個 flag 都在正式專案上跑過一輪。以下機制描述是從 release notes、PR 說明,加上 codex-rs/ 原始碼對出來的,凡是具體數字我都會標出處檔名,讀到哪就寫到哪,沒有的就說沒有。npm 上 @openai/codex 的 latest 目前是 0.148.0,npm i -g @openai/codex 就會裝到。
本文大綱
為什麼這三件事要放在一起講
你跟 coding agent 的一次對話,實際上是一份很貴的資產:裡面有你花二十分鐘餵進去的 repo 脈絡、被否決過的三種做法、agent 自己讀出來的檔案結構。但在 0.148.0 之前,這份資產基本上被鎖在 TUI 裡——你能 resume,但不能複製、不能搬給別人看、也不能在它做出決定的當下攔截它。
這一版做的三件事剛好對應三個動詞:export(搬出去)、fork(複製一份)、hook async(在旁邊看著)。這是把 session 從「一次性對話」變成「可操作的物件」。

一、/export:把對話變成可交付物
怎麼用
在 TUI 裡打 /export,會跳出一個兩選一的 picker(這段是 repo 裡 slash_export_destination_picker 快照測試的實際內容):
Export conversation
Save the complete conversation as Markdown
› 1. Copy to clipboard Copy the complete Markdown transcript
2. Save to file Choose a Markdown filename
選「Save to file」會再問檔名,預設值是 codex-session-<session-id>.md。你也可以直接帶路徑參數跳過 picker:
/export ./docs/decisions/2026-08-19-cache-layer.md
相對路徑跟 ~/ 開頭都會被解析;同名檔不會被覆蓋,這點 PR 描述裡明講了有 overwrite protection,不用擔心手滑蓋掉舊紀錄。
匯出的東西長什麼樣
從 transcript_export 的 snapshot 測試看,格式是很乾淨的 heading 結構:
# Codex conversation
## User
Explain **the change**
## Assistant
```rust
let answer = 42;
```
## Plan
completed plan
## Activity
$ cargo test
## User
[Image #1] describe this
PR 描述列出保留的東西:user/assistant 訊息、plan、reasoning、activity、圖片標籤、file changes、MCP tool 細節。有一個細節值得注意——它會遵守你的 reasoning visibility 設定。你在 TUI 把推理過程折起來,export 就不會把它倒出來。要完整 reasoning 就先把顯示打開再匯出。
另外兩個邊界情況:分頁式歷史讀不到時會 fall back 到舊的 history 載入方式;ephemeral session 則直接用畫面上看得到的 transcript。
三個實際用得上的招式
招式一:debug 過程直接變 issue 附件。 以前貼 agent 對話要一段一段複製,現在 /export → 剪貼簿 → 貼進 GitHub issue,完整的「我試過什麼、為什麼不行」就在裡面了。
招式二:當 ADR 草稿。 架構決策討論完直接 /export ./docs/adr/0012-queue-choice.md,再叫 agent 把它壓縮成正式格式。比從零寫 ADR 快非常多,因為所有 trade-off 討論都已經在檔案裡。
招式三:跨工具搬 context。 在 Codex 裡把問題釐清完,export 成 md,餵給 Claude Code 或別的 agent 當起手 context。這比重講一次省得多。
順帶一提,別跟 /copy 搞混——/copy 的說明是「copy last response as markdown」,只拿最後一則;/export 才是整段。
二、codex exec fork:把分岔帶進 CI
TUI 裡本來就有 /fork(說明文字是「fork the current chat」)。0.148.0 補上的是 headless 那一半。
CLI 形狀
從 codex-rs/exec/src/cli.rs 的 ForkArgs 讀出來,實際只有三個東西:
# 只複製一份,不跑
codex exec fork <SESSION_ID>
# 複製後立刻帶著新 prompt 續一輪
codex exec fork <SESSION_ID> "改成用 sqlite,只動 cache 層"
# prompt 從 stdin 讀
echo "把這段改成 streaming" | codex exec fork <SESSION_ID> -
# 帶圖
codex exec fork <SESSION_ID> -i before.png,after.png "照右邊那版改"
SESSION_ID可以是 UUID,也可以是 thread name- prompt 省略 → 只建 fork 不啟動 turn
- prompt 給
-→ 從 stdin 讀 --image/-i支援多檔,逗號分隔- 原 session 完全不動,新 thread 的 session configuration 裡會記著 source thread ID
這解鎖了什麼:同題多解
fork 最有價值的用法是「共用前情提要,只換最後一手」。你花二十分鐘讓 agent 讀懂了整個 cache 層,接下來想比三種實作——不需要重講三次背景:
SID=$(codex exec --json "讀懂 src/cache/ 的現況,先不要改任何東西" \
| jq -r 'select(.type=="session.created") | .session_id')
codex exec fork "$SID" "改成 Redis-backed,只動 cache 層"
codex exec fork "$SID" "改成 SQLite-backed,只動 cache 層"
codex exec fork "$SID" "改成純 in-memory + LRU,只動 cache 層"
注意:上面這段是照 CLI 形狀寫的示意,
--json事件欄位名稱請以你當下版本的codex exec --json實際輸出為準。而且三條 fork 若同時寫同一個 repo 會互相踩,實務上要搭git worktree各給一份工作目錄,或乾脆序列跑。
搭配 resume picker 新增的封存/還原:跑完的實驗分支按 archive 收起來,picker 不會被幾十條半途而廢的 session 塞爆;真的需要再 restore 回來。這在會大量 fork 之後變成必需品,不是可有可無的裝飾。
還有一個相關修正值得提:0.148.0 修好了「resume 的 session 會忘記 cwd 跟 approval policy」的問題(#37198、#37368)。以前 resume 回來 approval policy 被重置成預設值,這在自動化腳本裡是會咬人的。
三、hooks 的 async 與 MCP tool(本版最深的一塊)
先把 hooks 的地基講清楚
Codex hooks 是把腳本插進 agent loop 特定生命週期點的機制。載入位置依序是:
~/.codex/hooks.json,或~/.codex/config.toml裡的[hooks]<repo>/.codex/hooks.json,或 repo 的.codex/config.toml- 啟用中的 plugin 自帶的 hooks
關鍵:高優先層不會覆蓋低優先層。 官方文件原文是「Higher-precedence config layers don't replace lower-precedence hooks. If more than one hook source exists, Codex loads all matching hooks.」——所有符合的 hook 都會跑。你在專案裡加一條,不會把家目錄那條蓋掉,兩條都會執行。這跟一般 config 的直覺相反,很容易踩到。
支援的事件從 codex-rs/config/src/hook_config.rs 的 HookEventsToml 可以一次數完,共 11 個:
PreToolUse、PermissionRequest、PostToolUse、PreCompact、PostCompact、SessionStart、SessionEnd、UserPromptSubmit、SubagentStart、SubagentStop、Stop
設定的巢狀結構是「事件 → matcher group → handler 陣列」:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.codex/hooks/scan-secrets.sh",
"timeout": 30,
"async": false,
"statusMessage": "掃描指令中的密鑰"
}
]
}
]
}
}
Hook 從 stdin 收事件 JSON,從 stdout 回一段 JSON。欄位名稱跟 Claude Code 的 hook 契約高度重疊——continue、systemMessage、hookSpecificOutput.additionalContext、permissionDecision、permissionDecisionReason、updatedInput。這不是巧合,engine 那個 struct 在 code 裡就叫 ClaudeHooksEngine。已經寫過 Claude Code hook 的人可以幾乎直接搬。
async 到底改了什麼
這是本版最容易被低估的一行。PR #37533 的「Why」原文很誠實:
Hook configurations can mark command handlers as asynchronous, but Codex previously skipped those handlers outside
SessionEnd.
翻譯:你以前寫 "async": true 等於寫了個寂寞,除了 SessionEnd 之外那些 handler 根本不會被執行。0.148.0 才讓它真的跑起來。

跑起來之後的四條規則,我逐條對過 code:
1. 背景執行,每個 session 最多 8 個同時跑。 codex-rs/hooks/src/engine/command_runner.rs 裡的 const MAX_CONCURRENT_ASYNC_HOOKS: usize = 8;,實作是一個 tokio Semaphore,超過就排隊等 permit。
2. async hook 拿不到否決權。 PR 寫「Prevent asynchronous hooks from blocking, stopping, rewriting, or otherwise controlling the operation that launched them」,實作方式很直接:async 路徑處理輸出時,只保留 Context(也就是 additionalContext)跟 Warning/Error 三種 entry,Stop 跟 Feedback 直接丟棄。你在 async hook 裡回 permissionDecision: deny,不會有任何效果。
3. 結果在安全的回合邊界才注入。 turn 還在跑 → sampling 之後注入到當前 turn;session 閒置 → buffer 起來,等下一次 user prompt 前才送。所以 async hook 的輸出不保證即時可見,它是「下一拍」才出現。
4. 生命週期有處理。 config reload 時 in-flight 的 hook 會被保留;session 關閉時 shutdown() 會 abort_all() 收乾淨。搭配 #37527「Terminate timed-out hook process trees」——逾時的 hook 現在會連整棵 process tree 一起殺掉,不會留孤兒進程。
5. SessionEnd 是例外。 在 discovery.rs 裡,runs_async 的條件寫死了 event_name != SessionEnd,宣告 async 會被降回同步,並且發一條 warning:running async SessionEnd hook synchronously in ...。SessionEnd 的 timeout 也特別短——SESSION_END_DEFAULT_TIMEOUT_SEC = 1、SESSION_END_MAX_TIMEOUT_SEC = 3,你設超過 3 秒會被 clamp 回去並警告。原因不難猜:沒人想在關掉 terminal 的時候等腳本跑完。
哪些 hook 該改成 async
判斷標準只有一句:這個 hook 需不需要「否決權」?
需要 → 留 sync:
- 掃 secret/危險指令,要能
permissionDecision: deny - 用
updatedInput改寫工具參數(注意:updatedInput只有在permissionDecision: allow時才有效,否則 output parser 會回一條 unsupported 警告) - 需要
ask把決定丟回人類
不需要 → 改 async:
- telemetry/audit log 上報
- 跑 lint、type check、測試,把結果當 additionalContext 餵回去
- 發 Slack 通知
- 自動寫 memory/筆記
具體改法就是加一個欄位:
{
"hooks": {
"PostToolUse": [
{
"matcher": "apply_patch",
"hooks": [
{
"type": "command",
"command": "~/.codex/hooks/typecheck.sh",
"timeout": 120,
"async": true,
"statusMessage": "背景 type check",
"additionalContextLimit": 1200
}
]
}
]
}
}
這個例子的效果是:agent 改完檔案繼續往下做,type check 在背景跑;跑完如果有錯,錯誤訊息會在下一個回合邊界注入成 additionalContext,agent 自己看到自己剛剛寫壞了。以前這件事你只能用 sync hook 做,代價是每次 apply_patch 都要卡兩分鐘。
additionalContextLimit 是 token 門檻,超過會把內容 spill 到磁碟只留 preview + 復原 metadata。不設是約 2,500 tokens,設 0 等於關閉 spill。type check 輸出動輒幾百行,這欄位建議明確設。
mcp_tool handler:hook 不一定要是腳本
以前 hook 只能是 shell command。0.148.0 加了第二種 handler type,schema 在 hook_config.rs 裡:
{
"hooks": {
"PostToolUse": [
{
"matcher": "apply_patch",
"hooks": [
{
"type": "mcp_tool",
"server": "memory",
"tool": "append_note",
"input": {
"path": "${tool_input.path}",
"note": "Codex 改了這個檔案",
"retries": 3
},
"timeout": 60,
"statusMessage": "寫入 memory"
}
]
}
]
}
}
input 裡的 ${...} 是 placeholder,展開規則寫在 mcp_runner.rs 的 doc comment 裡,有三條值得記住:
- 遞迴展開,object 跟 array 巢狀都會處理
- 整個字串剛好就是一個 placeholder 時,保留原本的 JSON 型別。文件裡的例子:template
{"count":"${tool_input.count}"}配上事件{"tool_input":{"count":3}},結果是{"count":3}而不是{"count":"3"}。夾在文字中間的 placeholder 才會被轉成字串拼接 - 找不到欄位就整個 hook 失敗,不會把沒解析的
${...}字面值送給 MCP server。這是刻意的設計取捨——寧可失敗也不要餵髒資料
四個限制要先知道:
mcp_tool沒有async欄位,只能同步跑。這在 code 層面是硬的:HookHandlerConfig::McpTool的欄位只有 server/tool/input/timeout/statusMessage- SessionEnd 不支援
mcp_tool,載入時直接 skip 並發 warning input必須能表示成 TOML,null會被拒絕。原因是這份 input 要拿去算 trust hash- 執行環境若不支援 MCP invocation,啟動時會 warning 並跳過
信任模型與管理員開關
非 managed 的 command hook 要先審過才會執行。用 /hooks 看清單、檢查、標記信任;trust 是記在 hook 定義的 hash 上,所以你改一個字元就要重審一次。這代表 hook 不是「寫進 config 就會跑」,第一次要人工過一關。
hooks/list 現在會回 executionMode(sync/async,預設 sync 以維持相容),以及 handler-specific metadata(mcp_tool 會帶 server 跟 tool 欄位),TUI 的 hooks browser 直接顯示。你可以在 /hooks 裡一眼看出哪些是背景跑的。
管理員層面兩個開關:
# requirements.toml — 只允許 managed hooks,忽略 user/project/session 層
allow_managed_hooks_only = true
# config.toml — 整個關掉
[features]
hooks = false
官方 doc 特別註明:allow_managed_hooks_only 只在 requirements.toml 有效,放進 config.toml 不會生效。這種只在特定檔案生效的設定最容易被誤設成「以為關掉了其實沒關」,值得寫進團隊的 onboarding 文件。
數字與限制(誠實版)
| 項目 | 值 | 出處 |
|---|---|---|
| async hook 併發上限 | 8 / session | hooks/src/engine/command_runner.rs 常數 |
| command hook 預設 timeout | 600 秒(下限 1 秒) | hooks/src/engine/discovery.rs |
| SessionEnd hook timeout | 預設 1 秒,上限 3 秒 | hooks/src/events/session_end.rs |
| additionalContext spill 門檻 | 約 2,500 tokens;0 = 關閉 |
hook_config.rs 註解、官方 hooks doc |
mcp_tool 支援 async |
否 | hook_config.rs 無 async 欄位 |
| 支援的 hook 事件數 | 11 | HookEventsToml |
這一版沒有的東西,也講清楚:
- release notes 沒有給任何效能 benchmark。「async 讓 turn 快多少」沒有官方數字,要自己量
prompt跟agent兩種 handler type 在 code 裡是空的 struct(Prompt {}、Agent {}),載入時直接 skip 並回「not supported yet」。看得到但用不了PreToolUse/PostToolUse的 matcher 涵蓋 shell command、apply_patch、MCP tools 跟大部分 local function tool,但hosted tool(例如 WebSearch)不走 hook 路徑,你攔不到/export的效能表現、超長 session 的匯出行為,我沒有實測資料
另外,這一版有一批安全性修正是「fail closed」方向的:Linux 跟 Windows 的 sandbox 對被拒絕或無法讀取的路徑改成失敗關閉(#37875、#38026、#38416、#38660)。如果你的腳本以前靠某些邊界情況「剛好能過」,升上來可能會開始被擋。這是好事,但值得在升級前先跑一次自動化流程。
什麼時候該用、什麼時候別用
/export — 幾乎沒有不用的理由。 唯一要留意的是對話裡可能有 repo 內容跟環境資訊,貼到公開 issue 前自己掃一遍。
exec fork — 適合「背景昂貴、變因單一」的場景。 前情提要越貴,fork 越划算。反過來說,如果每條分支要動的檔案範圍高度重疊,平行跑只會製造衝突,那不如序列跑或搭 worktree。
async hooks — 適合觀測與回饋,不適合守門。 一個實務上的陷阱:不要把「安全檢查」改成 async 換效能。async hook 看得到事件、但攔不住動作,改完你會得到一份「事後才發現密鑰已經被送出去了」的漂亮 log。
mcp_tool hook — 適合「hook 要做的事剛好已經有 MCP server」。 少寫一支 wrapper script、少維護一份認證邏輯。但它是同步的,MCP server 慢就會直接拖慢 agent,記得把 timeout 設小(預設 600 秒對一個 MCP 呼叫來說太寬鬆了)。
給工程團隊的可執行清單
- 升級:
npm i -g @openai/codex(latest=0.148.0)。升完先跑一次既有的自動化流程,確認沒被 sandbox 的 fail-closed 修正擋掉 - 盤點現有 hooks:打
/hooks,看每一條的executionMode。把不需要否決權的(telemetry、通知、log)逐條改成"async": true - 確認載入層疊加行為:如果你同時有家目錄跟 repo 的 hooks,記得兩邊都會跑。重複的通知類 hook 要挑一邊留
- 加一條 async 的品質回饋 hook:
PostToolUse+ matcherapply_patch+ 背景 type check/lint,記得設additionalContextLimit - 把
/export寫進 code review 流程:非顯而易見的改動,附一份 session transcript 比補一段 PR 描述有用 - 建立 fork 慣例:探索型任務先跑一輪「只讀不改」的 session 建立脈絡,再從那個 SESSION_ID fork 出各種嘗試;做完的用 resume picker archive 掉
- 管理員檢查:
allow_managed_hooks_only要放requirements.toml,放錯檔案等於沒設
一句話總結
0.148.0 沒有讓 Codex 變聰明,但讓你的 session 從「關掉就沒了的對話」變成「可以複製、可以搬走、可以在旁邊被監控的物件」。對認真把 coding agent 放進工作流的團隊來說,這比模型換代更值得花一個下午去調。
來源
- OpenAI Codex — Release rust-v0.148.0:https://github.com/openai/codex/releases/tag/rust-v0.148.0
- PR #37358 Add Markdown conversation export to the TUI:https://github.com/openai/codex/pull/37358
- PR #37367 Add session forking to
codex exec:https://github.com/openai/codex/pull/37367 - PR #37533 Support asynchronous command hooks:https://github.com/openai/codex/pull/37533
- PR #37363 Recognize MCP tool hook configurations:https://github.com/openai/codex/pull/37363
- PR #38705 Add MCP tool handler support to the hooks engine:https://github.com/openai/codex/pull/38705
- PR #37538 Expose execution mode in hook listings:https://github.com/openai/codex/pull/37538
- OpenAI 官方 Codex Hooks 文件:https://developers.openai.com/codex/hooks
- 原始碼:
codex-rs/config/src/hook_config.rs、codex-rs/hooks/src/engine/command_runner.rs、codex-rs/hooks/src/engine/mcp_runner.rs、codex-rs/hooks/src/engine/discovery.rs、codex-rs/exec/src/cli.rs(openai/codex, main branch)
整理:DataAgent · Coding Agent 實戰教學


