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 只是本文實際走過的兩個案例。

AI Agent 多感官協作示意圖:畫面負責閱讀成果與判斷,聲音負責在需要時喚回注意力,手機與手錶震動負責把提醒帶離座位
視覺、聽覺與觸覺各自承接不同資訊。聲音與震動只在需要改變人的行動時出現。手機可左右滑動,點圖可放大。

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

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

📦 下載:AI Agent 語音、手機與手錶通知安裝懶人包

上傳檔案後,只要告訴 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 接入;工作流程判斷需要人介入或無法繼續時,則用 waitingfailed 模式呼叫同一個通知器。Claude Code 官方文件將 Stop 定義為每回合完成回覆時觸發,也提供 PermissionRequest 等事件;hook 會收到事件的 JSON 脈絡。因此,實際系統仍要加上自己的「完整任務」判斷,不能只看到 Stop 就宣布交付完成。可參考 Claude Code Hooks reference

後來工作環境加入 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-idturn-id、工作目錄、使用者訊息與最後一則助理訊息等欄位。官方用語是一次 agent turn 結束,不是「使用者交付的一整件工作已經完整結束」。兩者不能直接畫上等號。詳見 Codex Advanced Configuration

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

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

整體架構:讓提醒離開螢幕,但不讓判斷離開人

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

Claude 與 Codex 跨平台通知流程圖:兩個平台各自接收事件並經過防重複條件,再轉成完成、待決策或失敗的共同語意;敏感內容遮蔽後,由 macOS 語音與 Pushover 傳到 iPhone 和 Apple Watch,通知不替人批准
Claude 與 Codex 使用不同事件接頭,最後收斂成相同通知語意與安全原則。手機可左右滑動,點圖可放大。

它有五個安全原則:

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

第五點尤其重要。Pushover 的 Message APIpriority=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(選用)。

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

claude --version
codex --version

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

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

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

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

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

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

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

#!/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 的平台通知入口。我目前讓它接受三種模式:

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

其中 done 由上述 Stop 流程進入;waitingfailed 由 Claude 工作流程在確定需要人介入或無法繼續時呼叫。若要把 Claude 的 PermissionRequest 全自動接上,也應另寫事件接頭,從標準輸入解析 tool_nametool_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 的核心結構:

#!/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()

儲存後:

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.tomlnotify 啟動。先查自己的絕對家目錄:

echo "$HOME"

假設結果是 /Users/yourname,設定為:

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

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

agent-turn-complete.sh

#!/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"
chmod 700 "$HOME/.codex/hooks/agent-turn-complete.sh"

決策通知放在 ~/.codex/hooks.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

#!/bin/bash
set -u
exec /usr/bin/python3 "$HOME/.codex/hooks/codex_notify.py" permission
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=1AI_NOTIFY_NO_SOUND=1AI_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 的 StopPermissionRequest,以及 Codex 的 PermissionRequest 都是平台提供的 hook 事件;Codex 的 agent-turn-complete 也是官方提供給 notify 的事件。但是這些名稱不代表兩個平台的 payload、觸發時機或完成語意完全相同。

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

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

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

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

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

這也呼應我在代理營運與無聲失敗裡反覆處理的問題:系統不能只靠自己宣布成功。通知不是完成證明,它只是把一個經過條件檢查的狀態送到人身邊。

設定完成後,怎樣才算驗收通過?

我用七項結果判斷 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 主題頁一起看。工具會換、事件格式會變,但語音與震動的用途很直接:讓工作進度離開螢幕,成為人隨時能察覺的訊號。