AI 工程

Codex 0.148.0 實戰拆解:/export 存檔、exec fork 分岔、hooks 終於能非同步跑

先說結論:openai/codexrust-v0.148.0 對已經天天在用 Codex CLI 的人來說,是少數幾個會真的改掉日常操作習慣的版本。不是模型變聰明,是「session 這個東西終於可以被搬運、被複製、被監控」。

三件事:

  1. TUI 多了 /export,整段對話完整倒成 Markdown(PR #37358)
  2. codex exec fork 把「從某個 session 分岔出去」帶進 headless/CI(PR #37367)
  3. hooks 支援 "async": true,而且新增 mcp_tool handler(PR #37533、#38705)

先聲明:我沒有把每個 flag 都在正式專案上跑過一輪。以下機制描述是從 release notes、PR 說明,加上 codex-rs/ 原始碼對出來的,凡是具體數字我都會標出處檔名,讀到哪就寫到哪,沒有的就說沒有。npm 上 @openai/codexlatest 目前是 0.148.0npm i -g @openai/codex 就會裝到。

為什麼這三件事要放在一起講

你跟 coding agent 的一次對話,實際上是一份很貴的資產:裡面有你花二十分鐘餵進去的 repo 脈絡、被否決過的三種做法、agent 自己讀出來的檔案結構。但在 0.148.0 之前,這份資產基本上被鎖在 TUI 裡——你能 resume,但不能複製、不能搬給別人看、也不能在它做出決定的當下攔截它。

這一版做的三件事剛好對應三個動詞:export(搬出去)fork(複製一份)hook async(在旁邊看著)。這是把 session 從「一次性對話」變成「可操作的物件」。

Codex session 的三個出口:exec fork 分岔、/export 匯出 Markdown、resume picker 封存還原

一、/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.rsForkArgs 讀出來,實際只有三個東西:

# 只複製一份,不跑
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 特定生命週期點的機制。載入位置依序是:

  1. ~/.codex/hooks.json,或 ~/.codex/config.toml 裡的 [hooks]
  2. <repo>/.codex/hooks.json,或 repo 的 .codex/config.toml
  3. 啟用中的 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.rsHookEventsToml 可以一次數完,共 11 個:

PreToolUsePermissionRequestPostToolUsePreCompactPostCompactSessionStartSessionEndUserPromptSubmitSubagentStartSubagentStopStop

設定的巢狀結構是「事件 → 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 契約高度重疊——continuesystemMessagehookSpecificOutput.additionalContextpermissionDecisionpermissionDecisionReasonupdatedInput。這不是巧合,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 才讓它真的跑起來。

Codex hook 的 sync 與 async 兩種執行模式對比

跑起來之後的四條規則,我逐條對過 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)跟 WarningError 三種 entry,StopFeedback 直接丟棄。你在 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 = 1SESSION_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 裡,有三條值得記住:

  1. 遞迴展開,object 跟 array 巢狀都會處理
  2. 整個字串剛好就是一個 placeholder 時,保留原本的 JSON 型別。文件裡的例子:template {"count":"${tool_input.count}"} 配上事件 {"tool_input":{"count":3}},結果是 {"count":3} 而不是 {"count":"3"}。夾在文字中間的 placeholder 才會被轉成字串拼接
  3. 找不到欄位就整個 hook 失敗,不會把沒解析的 ${...} 字面值送給 MCP server。這是刻意的設計取捨——寧可失敗也不要餵髒資料

四個限制要先知道:

  • mcp_tool 沒有 async 欄位,只能同步跑。這在 code 層面是硬的:HookHandlerConfig::McpTool 的欄位只有 server/tool/input/timeout/statusMessage
  • SessionEnd 不支援 mcp_tool,載入時直接 skip 並發 warning
  • input 必須能表示成 TOMLnull 會被拒絕。原因是這份 input 要拿去算 trust hash
  • 執行環境若不支援 MCP invocation,啟動時會 warning 並跳過

信任模型與管理員開關

非 managed 的 command hook 要先審過才會執行。用 /hooks 看清單、檢查、標記信任;trust 是記在 hook 定義的 hash 上,所以你改一個字元就要重審一次。這代表 hook 不是「寫進 config 就會跑」,第一次要人工過一關。

hooks/list 現在會回 executionModesyncasync,預設 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.rsasync 欄位
支援的 hook 事件數 11 HookEventsToml

這一版沒有的東西,也講清楚:

  • release notes 沒有給任何效能 benchmark。「async 讓 turn 快多少」沒有官方數字,要自己量
  • promptagent 兩種 handler type 在 code 裡是空的 struct(Prompt {}Agent {}),載入時直接 skip 並回「not supported yet」。看得到但用不了
  • PreToolUsePostToolUse 的 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 呼叫來說太寬鬆了)。

給工程團隊的可執行清單

  1. 升級npm i -g @openai/codexlatest = 0.148.0)。升完先跑一次既有的自動化流程,確認沒被 sandbox 的 fail-closed 修正擋掉
  2. 盤點現有 hooks:打 /hooks,看每一條的 executionMode。把不需要否決權的(telemetry、通知、log)逐條改成 "async": true
  3. 確認載入層疊加行為:如果你同時有家目錄跟 repo 的 hooks,記得兩邊都會跑。重複的通知類 hook 要挑一邊留
  4. 加一條 async 的品質回饋 hookPostToolUse + matcher apply_patch + 背景 type check/lint,記得設 additionalContextLimit
  5. /export 寫進 code review 流程:非顯而易見的改動,附一份 session transcript 比補一段 PR 描述有用
  6. 建立 fork 慣例:探索型任務先跑一輪「只讀不改」的 session 建立脈絡,再從那個 SESSION_ID fork 出各種嘗試;做完的用 resume picker archive 掉
  7. 管理員檢查allow_managed_hooks_only 要放 requirements.toml,放錯檔案等於沒設

一句話總結

0.148.0 沒有讓 Codex 變聰明,但讓你的 session 從「關掉就沒了的對話」變成「可以複製、可以搬走、可以在旁邊被監控的物件」。對認真把 coding agent 放進工作流的團隊來說,這比模型換代更值得花一個下午去調。

來源

整理:DataAgent · Coding Agent 實戰教學

發表迴響

%d 位部落客按了讚: