# 把 Claude 與 Codex 工作進度變成聽得到的通知：Mac、Pushover 與 Apple Watch 實作

> AI Agent 可以長時間自行工作，人卻常被迫反覆查看螢幕。這篇從 Claude 遷移到 Codex 的實作經驗出發，說明如何用 macOS 語音、Pushover、iPhone 與 Apple Watch，把任務完成和決策請求轉成聽得到、感覺得到的工作訊號。

- Author: Paul Kuo（郭曜郎）
- Date: 2026-08-31
- Pillar: 智能與秩序 / Intelligence & Order
- Tags: Claude Code, Codex, macOS, Pushover, Apple Watch, AI 協作, 自動化治理, 實作教學
- Canonical: https://paulkuo.tw/articles/codex-mac-voice-pushover-apple-watch-notifications/
- Site index for LLMs: https://paulkuo.tw/llms.txt

---

> **TL;DR**：AI Agent 原本應該釋放人的時間，但如果進度只存在螢幕上，人仍得不斷回頭查看。這套系統把少數真正需要人的時刻分配給不同感官：畫面用來閱讀與決定，語音讓人在電腦附近時聽見進度，手機與 Apple Watch 的震動則把狀態帶到座位之外。它先在 Claude 建立，後來遷移到 Codex；現在兩者都能在完整任務完成或需要人決定時，主動說明發生了什麼。

把一件需要較長時間的工作交給 AI Agent 後，人應該可以去處理別的事情。實際上，我卻常遇到另一種注意力負擔：因為不知道任務何時完成、是否卡住，或正在等待我的決定，只好隔一段時間回到視窗查看。

這讓 AI Agent 形成一個矛盾：它能在沒有人持續操作的情況下工作，人的注意力卻仍被綁在進度畫面上。問題不只是「有沒有通知」，而是通知是否能在正確的時間，透過人當下最容易察覺的方式出現。

對我來說，加入語音與震動之後，與 AI 的互動不再只停留在視覺。人在房間裡但沒有看螢幕，耳朵可以接住「任務完成」或「需要你決定」；離開座位後，手機通知與手腕震動又把這個狀態帶到身邊。等我回到畫面，才用視覺閱讀成果、理解脈絡並做出判斷。

我先為 Claude 建立這套回報，再把它遷移到 Codex。這篇文章會保留完整實作，讓想動手的人能一步一步設定；但它更想分享一個所有 AI Agent 使用者都能採用的觀念：當 Agent 開始長時間替人工作，互動介面也應該離開單一螢幕，配合人的注意力、位置與感官。

## 語音與震動要解決什麼問題？

語音通報要解決的核心問題，是人必須主動查看，才能知道 AI Agent 的工作狀態。只要進度仍完全依賴視覺，人就很難真正離開工作視窗。

我不是要讓 Agent 把每個步驟都念出來。真正有用的提醒只有少數幾種：完整工作已經完成、工作無法繼續，以及系統需要人批准或選擇。階段性進度留在畫面即可；只有需要改變人的行動時，聲音或震動才出現。

三種感官在這套協作裡扮演不同角色：

| 感官通道 | 最適合承載的內容 | 對使用者的幫助 |
|---|---|---|
| 視覺 | 完整成果、脈絡、選項與風險 | 適合閱讀、比較和做決定 |
| 聽覺 | 任務完成、需要決策等短訊號 | 人在附近但沒看螢幕時仍能知道狀態 |
| 觸覺 | 手機或手錶的震動提醒 | 人離開座位或環境吵雜時仍能察覺 |

語音與震動補上畫面無法觸及的時刻，畫面仍負責呈現完整內容。它們讓使用者不必持續監看，又不會錯過真正需要介入的節點。對任何會執行長任務、等待權限或要求中途決策的 AI Agent，這個原則都成立；Claude 與 Codex 只是本文實際走過的兩個案例。

<figure style="margin:56px auto;text-align:center;">
  <div style="overflow-x:auto;-webkit-overflow-scrolling:touch;border-radius:16px;">
    <a href="/images/articles/codex-mac-voice-pushover-apple-watch-notifications-senses.svg" target="_blank" rel="noopener" style="display:block;">
      <img src="/images/articles/codex-mac-voice-pushover-apple-watch-notifications-senses.svg" alt="AI Agent 多感官協作示意圖：畫面負責閱讀成果與判斷，聲音負責在需要時喚回注意力，手機與手錶震動負責把提醒帶離座位" loading="lazy" style="display:block;width:100%;min-width:680px;height:auto;margin:0 auto;border:1px solid #DDD6E7;border-radius:16px;background:#071426;" />
    </a>
  </div>
  <figcaption style="margin-top:14px;font-size:14px;color:#8A857B;line-height:1.7;">視覺、聽覺與觸覺各自承接不同資訊。聲音與震動只在需要改變人的行動時出現。手機可左右滑動，點圖可放大。</figcaption>
</figure>

## 不懂技術，也可以把安裝懶人包交給 AI

如果你不熟悉終端機、設定檔或 hooks，不需要逐行理解後面的程式。你可以下載我整理的 AI 安裝任務書，直接交給能操作這台 Mac 的 AI，請它依照你的環境偵測、規劃與設定。

📦 **下載：[AI Agent 語音、手機與手錶通知安裝懶人包](/downloads/ai-agent-voice-notification-setup-kit.md)**

上傳檔案後，只要告訴 AI：

> 請依照這份檔案協助我設定。先偵測與規劃，得到我同意後再修改；遇到金鑰、登入、系統權限或不確定的既有設定時，請停下來讓我本人操作。

這份懶人包不會要求 AI 直接照抄我電腦上的路徑。它會先辨識使用者採用 Claude Code、Codex 或其他 Agent，再確認該版本是否有正式事件入口。既有設定有衝突、平台無法可靠判斷完整任務，或只有定時輪詢這類替代方案時，AI 必須停下來說明，不能為了「看起來有完成」而偷偷建立長駐程序。

懶人包也把安全界線寫進任務：Pushover 憑證由使用者本人輸入；待決策通知不能替人按下批准；所有測試從停用與乾跑開始，最後才依序開啟語音、手機與手錶。對不懂技術的使用者來說，把完成條件、停止條件與驗收方法一起交給 AI，比逐行指定程式更重要。

## 這套通知從 Claude 開始，後來怎麼遷移到 Codex？

開發起點是 Claude。當時先用 Claude Code 的 `Stop` hook 接到 `task-done.sh`，再交給 `notify.sh` 播放語音並傳送 Pushover。`task-done.sh` 會先檢查一個明確的任務標記；沒有正在追蹤的完整任務，就安靜退出。這個條件避免把 Claude 每一次回覆結束都說成「任務完成」。

Claude 的通知入口也保留三種狀態：完成、等待人決定、執行失敗。完成狀態由 `Stop` 接入；工作流程判斷需要人介入或無法繼續時，則用 `waiting` 或 `failed` 模式呼叫同一個通知器。Claude Code 官方文件將 `Stop` 定義為每回合完成回覆時觸發，也提供 `PermissionRequest` 等事件；hook 會收到事件的 JSON 脈絡。因此，實際系統仍要加上自己的「完整任務」判斷，不能只看到 `Stop` 就宣布交付完成。可參考 [Claude Code Hooks reference](https://code.claude.com/docs/en/hooks)。

後來工作環境加入 Codex，我沒有重新發明一套提醒，而是遷移同一份通知契約：

- 完成時說明「哪一個 AI 完成了什麼」。
- 等待決策時說明「哪一個 AI 需要決定什麼」。
- Mac 播放語音，Pushover 把同一狀態送到 iPhone 與 Apple Watch。
- 同一事件最多輸出一次，而且可以立即停用。

遷移不等於直接複製 hook。Claude 與 Codex 的事件名稱、輸入資料與完成判斷不同，所以兩邊各自保留平台接頭。接頭只負責把原始事件轉成一致的完成、待決策或失敗狀態；單次鎖、內容遮蔽、語音與跨裝置輸出則遵守相同設計。現在的結果不是「Codex 也有一套獨立提醒」，而是 Claude 與 Codex 都接上同一種可感知的工作進度層。

## 先定義兩種事件，不要把所有停止都叫做完成

Claude 與 Codex 的底層事件不同，但我把主要通知拆成兩類：

| 事件 | 人需要做什麼 | 通知原則 |
|---|---|---|
| 需要決策 | 回到 Claude 或 Codex 批准、拒絕或回答 | 立即通知，但同一請求只通知一次 |
| 任務完成 | 查看最終成果，決定下一步 | 只有完整任務的最終回覆才通知 |

這個區分看似簡單，實作上卻是整套系統的核心。

Codex 的官方進階設定提供 `notify`，目前支援 `agent-turn-complete`。事件 JSON 會帶上 `thread-id`、`turn-id`、工作目錄、使用者訊息與最後一則助理訊息等欄位。官方用語是一次 agent turn 結束，不是「使用者交付的一整件工作已經完整結束」。兩者不能直接畫上等號。詳見 [Codex Advanced Configuration](https://learn.chatgpt.com/docs/config-file/config-advanced)。

工具步驟、階段性輸出或其他工作視窗都可能讓通知入口被呼叫。因此，完成通知要先確認最新訊息確實是主工作視窗的 `phase=final`，再進入單次鎖。

決策請求則有更精確的入口。Codex hooks 的 `PermissionRequest` 會在系統需要核准時執行，適合拿來提醒人回到電腦。它不應該替人自動批准；通知腳本只負責叫人回來，判斷仍留在 Codex 畫面。設定位置與信任機制可參考 [Codex Hooks 文件](https://developers.openai.com/codex/hooks)。

## 整體架構：讓提醒離開螢幕，但不讓判斷離開人

整體架構如下。兩個平台共用的是通知語意與輸出規則，不是同一份事件設定：

<figure style="margin:56px auto;text-align:center;">
  <div style="overflow-x:auto;-webkit-overflow-scrolling:touch;border-radius:16px;">
    <a href="/images/articles/codex-mac-voice-pushover-apple-watch-notifications-flow.svg" target="_blank" rel="noopener" style="display:block;">
      <img src="/images/articles/codex-mac-voice-pushover-apple-watch-notifications-flow.svg" alt="Claude 與 Codex 跨平台通知流程圖：兩個平台各自接收事件並經過防重複條件，再轉成完成、待決策或失敗的共同語意；敏感內容遮蔽後，由 macOS 語音與 Pushover 傳到 iPhone 和 Apple Watch，通知不替人批准" loading="lazy" style="display:block;width:100%;min-width:760px;height:auto;margin:0 auto;border:1px solid #DDD6E7;border-radius:16px;background:#071426;" />
    </a>
  </div>
  <figcaption style="margin-top:14px;font-size:14px;color:#8A857B;line-height:1.7;">Claude 與 Codex 使用不同事件接頭，最後收斂成相同通知語意與安全原則。手機可左右滑動，點圖可放大。</figcaption>
</figure>

它有五個安全原則：

1. 沒有明確啟用檔，就完全不執行。
2. Claude 完成通知要有明確任務標記；Codex 完成通知只接受主工作視窗的 `phase=final`。
3. Claude 的任務標記只消耗一次；Codex 的穩定事件要先取得 SQLite 一次性主鍵。
4. Codex 的頻率熔斷器限制短時間事件量；Claude 的語音佇列避免多視窗聲音互相重疊。
5. Pushover 不用緊急優先級，程式也不重試。

第五點尤其重要。Pushover 的 [Message API](https://pushover.net/api) 把 `priority=2` 定義為緊急通知，會反覆提醒直到使用者確認。那適合真正的緊急事故，不適合一般 AI 工作進度。本文使用正常優先級 `0`。

## 準備 Pushover、iPhone 與 Apple Watch

你需要：

- 一台 Mac，以及可執行本機 hooks 的 Claude Code、Codex，或兩者。
- Python 3。本文腳本只使用 Python 標準函式庫。
- Pushover 帳號、安裝在 iPhone 上的 Pushover App。
- Pushover 的 User Key，以及自行建立 Application 後取得的 API Token。
- Apple Watch（選用）。

先確認你正在使用的平台版本：

```bash
claude --version
codex --version
```

再到 Pushover 建立自己的 Application。把 User Key 與 API Token 存在本機，不要寫進 Git repository：

```bash
mkdir -p "$HOME/.codex/hooks" "$HOME/.codex/notification-state"
chmod 700 "$HOME/.codex/hooks" "$HOME/.codex/notification-state"

cat > "$HOME/.codex/pushover.env" <<'EOF'
PUSHOVER_TOKEN='替換成你的 Application API Token'
PUSHOVER_USER='替換成你的 User Key'
EOF

chmod 600 "$HOME/.codex/pushover.env"
```

上面沿用本文 Codex 範例的路徑。若 Claude 與 Codex 要讀取同一份 Pushover 憑證，可以改放在 `~/.config/ai-notify/pushover.env`，再讓兩邊的通知器都指向它；重點是權限設為 `600`，而且不要提交到版本控制。

這是教學中的一次性建檔指令。實際操作時，請確認終端機歷史、螢幕錄影或分享畫面不會暴露金鑰；不要把這個檔案內容貼給 AI。

接著在 iPhone 的「設定 → 通知 → Pushover」允許通知。Apple Watch 預設可鏡像 iPhone App 的通知設定；也可在 iPhone 的 Watch App 裡，進入「通知」調整 Pushover。Apple 的說明可見 [Change notification settings on Apple Watch](https://support.apple.com/guide/watch/change-notification-settings-apd9b833c9f3/watchos)。

要注意：Pushover API 回傳成功，只代表服務端接受了訊息。手錶是否震動，還取決於通知權限、專注模式、靜音、手錶是否正在佩戴與解鎖，以及通知當下 iPhone 與 Apple Watch 的狀態。

## 先接 Claude：用明確任務標記避免把每次 Stop 當成完成

Claude Code 可在使用者層級的 `~/.claude/settings.json` 設定 hooks。以下把 `Stop` 接到一個完成判斷腳本：

```json
{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/yourname/.claude/hooks/task-done.sh"
          }
        ]
      }
    ]
  }
}
```

不要把 `Stop` 直接接到「任務已完成」語音。官方定義是 Claude 結束一次回覆，不保證使用者交辦的完整工作已經完成。我目前的做法是在工作開始時建立 `~/.claude/last-task.txt`，只有這個檔案存在，`task-done.sh` 才通報並移除標記：

```bash
#!/bin/bash
set -u

TASK_FILE="$HOME/.claude/last-task.txt"
[ -s "$TASK_FILE" ] || exit 0

TASK_TITLE="$(head -n 1 "$TASK_FILE")"
rm -f "$TASK_FILE"
exec "$HOME/.claude/hooks/notify.sh" "$TASK_TITLE" done
```

開始一件需要追蹤的完整工作時，工作流程先把不含機密的短標題寫入標記檔。Claude 正常完成並觸發 `Stop` 後，標記只會被消耗一次。一般問答沒有標記，腳本便保持安靜。

`notify.sh` 是 Claude 的平台通知入口。我目前讓它接受三種模式：

```bash
"$HOME/.claude/hooks/notify.sh" "完成了什麼" done
"$HOME/.claude/hooks/notify.sh" "需要決定什麼" waiting
"$HOME/.claude/hooks/notify.sh" "卡在哪裡" failed
```

其中 `done` 由上述 `Stop` 流程進入；`waiting` 與 `failed` 由 Claude 工作流程在確定需要人介入或無法繼續時呼叫。若要把 Claude 的 `PermissionRequest` 全自動接上，也應另寫事件接頭，從標準輸入解析 `tool_name` 與 `tool_input`，遮蔽路徑、金鑰與原始指令後，再交給通知器。不要把批准或拒絕結果寫回 hook；通知只負責找人，決定仍由人完成。

公開教學可以用 macOS 內建 `say`；我的實際版本會先嘗試個人化語音，失敗時再退回 `Meijia`。無論用哪一種聲音，`notify.sh` 都應該有單次鎖或語音佇列，避免多個 Claude 視窗同時播放。Pushover 的金鑰則沿用前一節的本機檔案，不寫入腳本或 repository。

## 再接 Codex：建立只執行一次的通知程式

Codex 延續 Claude 已經建立的通知語意，但需要自己的事件接頭。完整的正式版本要處理事件解析、內容摘要、敏感資訊遮蔽、主視窗判斷、SQLite 併發鎖、熔斷、Pushover 與語音。完成通知會摘要 `last-assistant-message`；決策通知則優先讀取 `tool_input.description`，缺少描述時只通報工具類型，不傳送原始指令。以下提供可以自行整理成 `~/.codex/hooks/codex_notify.py` 的核心結構：

```python
#!/usr/bin/env python3
import hashlib, json, os, re, shlex, sqlite3, subprocess, sys, time
import urllib.parse, urllib.request
from pathlib import Path

HOME = Path.home()
STATE = HOME / ".codex/notification-state"
ENABLED = HOME / ".codex/notifications.enabled"
SESSIONS = HOME / ".codex/sessions"
ENV_FILE = HOME / ".codex/pushover.env"

MESSAGES = {
    "completion": "Codex 任務已完成",
    "permission": "Codex 需要你決定",
}

def h(text):
    return hashlib.sha256(text.encode()).hexdigest()[:32]

def read_event(mode, argv_json=None):
    raw = argv_json if mode == "completion" else sys.stdin.read()
    try:
        event = json.loads((raw or "{}").strip())
        return event if isinstance(event, dict) else {"value": event}
    except json.JSONDecodeError:
        return {"unparsed": raw}

def safe_text(value, limit=120):
    if not isinstance(value, str):
        return ""
    fence = re.escape("`" * 3)
    text = re.sub(f"{fence}.*?{fence}", " ", value, flags=re.S)
    text = re.sub(r"https?://\S+", "[連結]", text)
    text = re.sub(r"(?i)Bearer\s+\S+", "Bearer [已遮蔽]", text)
    text = re.sub(
        r"(?i)\b(api[_-]?key|token|password|secret)\b\s*[:=]\s*\S+",
        r"\1=[已遮蔽]",
        text,
    )
    text = re.sub(r"/(?:Users|home)/\S+", "[本機路徑]", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text if len(text) <= limit else text[:limit - 1].rstrip() + "…"

def event_detail(mode, event):
    if mode == "completion":
        raw = event.get("last-assistant-message", "")
        first = re.split(r"\n\s*\n", raw)[0] if isinstance(raw, str) else ""
        return safe_text(first) or "本次交辦工作已形成最終結果"
    tool = str(event.get("tool_name") or "")
    tool_input = event.get("tool_input")
    if isinstance(tool_input, dict):
        for key in ("description", "justification", "reason"):
            detail = safe_text(tool_input.get(key))
            if detail:
                return detail
    if tool.lower() in {"bash", "shell", "exec_command"}:
        return "是否允許執行終端機指令"
    if tool.lower() in {"apply_patch", "edit", "write"}:
        return "是否允許修改檔案"
    return "請回到 Codex 查看待確認的操作"

def final_gate(event):
    """目前版本相依：只接受主視窗最新的 phase=final。"""
    thread_id = event.get("thread-id") or event.get("thread_id")
    if not thread_id:
        return False, None
    files = list(SESSIONS.rglob(f"*{thread_id}.jsonl"))
    if not files:
        return False, None
    path = max(files, key=lambda p: p.stat().st_mtime_ns)
    lines = path.read_text(errors="replace").splitlines()
    if not lines:
        return False, None
    try:
        meta = json.loads(lines[0]).get("payload", {})
    except json.JSONDecodeError:
        return False, None
    if meta.get("thread_source") == "subagent" or "subagent" in str(meta.get("source", {})):
        return False, None

    latest_activity_turn = None
    latest_assistant = None
    for line in reversed(lines[-10000:]):
        try:
            record = json.loads(line)
        except json.JSONDecodeError:
            continue
        if record.get("type") != "response_item":
            continue
        payload = record.get("payload", {})
        md = payload.get("internal_chat_message_metadata_passthrough", {})
        item_turn = md.get("turn_id") if isinstance(md, dict) else None
        latest_activity_turn = latest_activity_turn or item_turn
        if latest_assistant is None and payload.get("type") == "message" and payload.get("role") == "assistant":
            latest_assistant = (payload.get("phase"), item_turn)
        if latest_activity_turn and latest_assistant:
            break
    if not latest_assistant:
        return False, None
    phase, assistant_turn = latest_assistant
    allowed = phase == "final" and assistant_turn == latest_activity_turn and bool(assistant_turn)
    return allowed, assistant_turn

def db():
    STATE.mkdir(mode=0o700, parents=True, exist_ok=True)
    conn = sqlite3.connect(STATE / "events.sqlite3", isolation_level=None)
    conn.execute("PRAGMA busy_timeout=5000")
    conn.execute("CREATE TABLE IF NOT EXISTS events (event_key TEXT PRIMARY KEY, mode TEXT, created_at INTEGER)")
    return conn

def claim_once(mode, key):
    conn = db()
    now = int(time.time())
    conn.execute("BEGIN IMMEDIATE")
    try:
        if conn.execute("SELECT 1 FROM events WHERE event_key=?", (key,)).fetchone():
            conn.execute("COMMIT")
            return False
        # 簡單熔斷：10 分鐘內同類事件最多 3 次。
        count = conn.execute(
            "SELECT COUNT(*) FROM events WHERE mode=? AND created_at>=?", (mode, now - 600)
        ).fetchone()[0]
        if count >= 3:
            conn.execute("COMMIT")
            return False
        conn.execute("INSERT INTO events VALUES (?, ?, ?)", (key, mode, now))
        conn.execute("COMMIT")
        return True
    except Exception:
        conn.execute("ROLLBACK")
        raise
    finally:
        conn.close()

def credentials():
    result = {}
    if not ENV_FILE.is_file():
        return result
    for raw in ENV_FILE.read_text().splitlines():
        if "=" not in raw or raw.lstrip().startswith("#"):
            continue
        key, value = raw.split("=", 1)
        if key in {"PUSHOVER_TOKEN", "PUSHOVER_USER"}:
            parsed = shlex.split(value.strip())
            result[key] = parsed[0] if parsed else ""
    return result

def push(message):
    c = credentials()
    if not c.get("PUSHOVER_TOKEN") or not c.get("PUSHOVER_USER"):
        return
    body = urllib.parse.urlencode({
        "token": c["PUSHOVER_TOKEN"],
        "user": c["PUSHOVER_USER"],
        "title": "Codex",
        "message": message,
        "priority": "0",
        "sound": "pushover",
    }).encode()
    req = urllib.request.Request(
        "https://api.pushover.net/1/messages.json", data=body, method="POST"
    )
    try:
        urllib.request.urlopen(req, timeout=6).read(4096)
    except Exception:
        pass  # 刻意不重試；可另寫本機 log 供排查。

def main():
    mode = sys.argv[1]
    event = read_event(mode, sys.argv[2] if len(sys.argv) > 2 else None)
    if not ENABLED.is_file():
        return
    task_turn = event.get("turn-id") or event.get("turn_id")
    if mode == "completion":
        allowed, task_turn = final_gate(event)
        if not allowed:
            return
    canonical = json.dumps(event, sort_keys=True, ensure_ascii=False)
    thread = event.get("thread-id") or event.get("thread_id")
    if mode == "permission":
        session = event.get("session_id") or thread or "unknown"
        tool = event.get("tool_name") or "tool-unknown"
        tool_input = json.dumps(event.get("tool_input"), sort_keys=True, ensure_ascii=False)
        identity = f"{session}:{task_turn}:{tool}:{h(tool_input)}"
    else:
        identity = f"{thread}:{task_turn}" if thread and task_turn else canonical
    key = f"{mode}:{h(identity)}"
    if not claim_once(mode, key):
        return
    message = f"{MESSAGES[mode]}。{event_detail(mode, event)}"
    push(message)
    subprocess.run(["/usr/bin/say", "-v", "Meijia", message], timeout=15, check=False)

if __name__ == "__main__":
    main()
```

儲存後：

```bash
chmod 700 "$HOME/.codex/hooks/codex_notify.py"
touch "$HOME/.codex/notifications.enabled"
chmod 600 "$HOME/.codex/notifications.enabled"
```

如果要換成自己的稱呼，只改 `MESSAGES` 裡的兩句。`event_detail()` 負責補上「完成了什麼」或「需要決定什麼」，請保留敏感資訊遮蔽與長度限制；事件判斷及去重邏輯也不要任意刪除。

### 為什麼用 SQLite，而不是暫存文字檔？

文字檔可以記錄「我看過這個 ID」，但兩個工作視窗幾乎同時抵達時，可能都在寫入前判斷自己是第一個。SQLite 的 `BEGIN IMMEDIATE` 與唯一主鍵，能把「檢查」和「認領」放進同一個原子交易。只有拿到主鍵的那一個程序會發通知。

單次鎖之外，還要考慮上游可能替不同階段建立不同 ID。這些 ID 對 SQLite 而言都是新事件，因此需要第二層熔斷：限制短時間內同類事件的數量。正式使用時，我對完成與決策通知分別設定上限，讓 hook 行為改變時仍有明確邊界。

## 接上 Codex 的完成通知與決策通知

完成通知由 `~/.codex/config.toml` 的 `notify` 啟動。先查自己的絕對家目錄：

```bash
echo "$HOME"
```

假設結果是 `/Users/yourname`，設定為：

```toml
notify = ["/Users/yourname/.codex/hooks/agent-turn-complete.sh"]
```

如果原本已經有 `notify`，不要直接蓋掉。Codex 這裡是一組外部程式命令，不是可任意追加多條 listener 的清單；需要由既有入口明確串接新腳本。

`agent-turn-complete.sh`：

```bash
#!/bin/bash
set -u
EVENT_JSON="${1-}"
if [ -z "$EVENT_JSON" ]; then
  EVENT_JSON="{}"
fi
exec /usr/bin/python3 "$HOME/.codex/hooks/codex_notify.py" completion "$EVENT_JSON"
```

```bash
chmod 700 "$HOME/.codex/hooks/agent-turn-complete.sh"
```

決策通知放在 `~/.codex/hooks.json`：

```json
{
  "description": "Mac speech and Pushover alert when Codex needs approval.",
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/yourname/.codex/hooks/permission-request.sh",
            "timeout": 30,
            "statusMessage": "Sending approval alert"
          }
        ]
      }
    ]
  }
}
```

`permission-request.sh`：

```bash
#!/bin/bash
set -u
exec /usr/bin/python3 "$HOME/.codex/hooks/codex_notify.py" permission
```

```bash
chmod 700 "$HOME/.codex/hooks/permission-request.sh"
```

這支 hook 不輸出批准或拒絕決策。它只通報，Codex 仍會留在原本的核准畫面等待人。

修改 hooks 後，重新開啟 Codex，使用 `/hooks` 檢查載入與信任狀態。官方文件提醒，hook 設定變更後可能需要重新審查；不要只因檔案存在，就假設它已經生效。

## 不要一開始就同時測試聲音、手機與手錶

最安全的測試順序是逐層打開：

1. 先停用 Claude 與 Codex 的通知開關，確認所有事件都保持安靜。
2. 讓腳本只寫本機紀錄，不播放聲音、不呼叫 Pushover。
3. 用同一份假事件執行兩次，確認只有第一次取得 SQLite 鎖。
4. 單獨開啟 Mac 語音，確認只說一次。
5. 最後才傳一則正常優先級 Pushover，確認 iPhone 與 Apple Watch 行為。

正式腳本可以加入 `AI_NOTIFY_DRY_RUN=1`、`AI_NOTIFY_NO_SOUND=1` 與 `AI_NOTIFY_NO_PUSH=1` 這類跨平台環境變數。測試時逐一解除，不要一口氣把所有輸出端打開。

也不要用同一句正式訊息反覆測試。測試訊息應明確寫「TEST」，避免人在手機上把測試誤認為真實任務狀態。

## 從一開始就做對的三個設計決定

### 每次 hook 只啟動一個短命程序

事件到達後，Claude 或 Codex 的 hook 各自啟動一次通知程式，完成 Pushover 與語音輸出便退出。這裡不建立 daemon、不交給 launchd 週期重跑，也不在網路失敗時自動 retry。

這個選擇讓事件與通知維持一對一關係。若 Pushover 暫時無法連線，本機紀錄可以保留失敗結果，但通知程式不自行創造第二次傳送。

### 先判斷完整任務，再認領單次鎖

SQLite 鍵不能只依賴 `turn-id`。一件完整工作在執行工具、回報階段進度與產生最終答案時，可能出現不同的 turn ID。完成通知應先檢查三件事：

- 事件來自主工作視窗，不是子代理或其他背景工作。
- 最新助理訊息的 phase 是 `final`，不是 `commentary`。
- 這個 final 屬於目前最新活動，不是前一件任務留下的紀錄。

三項都通過後，才用主工作視窗 ID 加上最終任務 turn ID 建立穩定鍵。決策通知則使用 session、turn、工具名稱與工具輸入摘要組成事件鍵，讓同一個請求只取得一次認領。

### 每個輸出只有一個擁有者

同一個平台的完成通知與決策通知可以共用一支程式，但每個事件只能有一條入口。Claude 與 Codex 可以各自保留事件接頭；進入語音或 Pushover 前，必須清楚知道是哪個平台擁有這次事件。不要讓新 hook 再呼叫另一套既有通知器，也不要讓兩個背景工具同時處理相同事件。

當既有 `notify` 已經被其他工具使用，應在原入口明確串接，而不是另外建立一條看不見的平行路徑。通知系統和其他自動化一樣，需要清楚的觸發條件、事件鍵、輸出端與停用方式。

## Claude 與 Codex 的完成判斷都有版本邊界

Claude Code 的 `Stop`、`PermissionRequest`，以及 Codex 的 `PermissionRequest` 都是平台提供的 hook 事件；Codex 的 `agent-turn-complete` 也是官方提供給 `notify` 的事件。但是這些名稱不代表兩個平台的 payload、觸發時機或完成語意完全相同。

Claude 這一側用明確任務標記，避免把每次 `Stop` 都當成完整交付。Codex 為了分辨 `commentary` 和 `final`，上面的實作另外讀取了 `~/.codex/sessions/` 中的本機 JSONL 紀錄。

Codex 官方 hooks 文件明確提醒，transcript 的格式不是穩定介面，未來可能改變。因此這個 final gate 是針對目前版本的實務防線，不是可以永遠不管的官方契約。

每次升級 Claude Code 或 Codex 後，我建議分平台做回歸測試。Claude 至少要確認：沒有任務標記的 `Stop` 保持安靜、有任務標記時只完成一次、`waiting` 內容能說明需要決定什麼。Codex 則確認：

1. 主視窗最終回覆：應通知一次。
2. 主視窗階段性 commentary：不應通知。
3. 子工作或子代理 final：不應冒充主任務完成。
4. 同一 final 輸入兩次：第二次應被 SQLite 擋住。

如果 session 格式改變，安全預設應是「不通知」，而不是猜測完成。漏掉一次提醒很麻煩，但錯誤地反覆通知更危險。

這也呼應我在[代理營運與無聲失敗](/articles/agentic-operations-silent-failure)裡反覆處理的問題：系統不能只靠自己宣布成功。通知不是完成證明，它只是把一個經過條件檢查的狀態送到人身邊。

## 設定完成後，怎樣才算驗收通過？

我用七項結果判斷 Claude 與 Codex 是否都可以正式啟用：

1. Claude 沒有完整任務標記時，`Stop` 不宣告完成。
2. Claude 有任務標記並完成時，Mac 與 Pushover 各通報一次，而且訊息說明完成了什麼。
3. Claude 進入 `waiting` 時，訊息說明需要決定什麼，Claude 畫面仍等待人決定。
4. Codex 需要核准時，Mac 與 Pushover 各提醒一次，而且訊息說明待確認操作；Codex 畫面仍等待人決定。
5. Codex 主工作視窗輸出 `commentary` 時不宣告完成，形成 `final` 後才通報一次。
6. 同一份事件再次送入通知程式時，單次鎖會拒絕第二次認領。
7. iPhone 能收到 Claude 與 Codex 各自標示來源的 Pushover，Apple Watch 也依鏡像設定顯示或震動。

最後再分別測試兩個平台的停用方式，確認其中一邊停用時，不會影響另一邊，也不會留下背景程序繼續送訊息。這讓通知功能保持可控，也方便日後升級任一平台時重新驗證。

## 讓工作進度成為聽得到、感覺得到的狀態

語音通知服務人在電腦附近、注意力卻不在螢幕上的時刻。Pushover 與 Apple Watch 則把同一個工作狀態帶到座位之外。

兩者接起來之後，我不需要把注意力綁在進度動畫上。Claude 與 Codex 可以繼續執行；需要權限或判斷時，語音或手錶提醒我回來；形成最終結果後，我再回到對應畫面驗收。

每一次聲音和震動都對應一個明確狀態。階段性進度留在 Claude 或 Codex，只有需要判斷、無法繼續或形成完整成果時，系統才主動提醒。這樣的通知能增加工作進度的可見性，也減少反覆查看不同視窗造成的注意力切換。

這套系統建立了一條跨工具的人機協作邊界：AI 可以在沒有人盯著螢幕時繼續工作；需要人介入或已經完成交付時，再用一次清楚而可追溯的通知把狀態送回來。它從 Claude 起步，遷移到 Codex 後沒有取代原系統，而是讓兩個工作環境都遵守同一套回報方式。

即使你不打算照著本文寫程式，也可以用三個問題檢查自己正在使用的 AI Agent：它能否分辨完整完成與階段性停頓？需要你介入時，能否說清楚要決定什麼？當你沒有看螢幕時，狀態能否透過其他感官找到你？只要其中一題的答案是否定的，這篇文章的設計原則就仍有用武之地。

如果你正在建立自己的 AI 工作環境，可以把這篇與本站的 [AI 主題頁](/topics/ai/)一起看。工具會換、事件格式會變，但語音與震動的用途很直接：**讓工作進度離開螢幕，成為人隨時能察覺的訊號。**
