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
如果你不熟悉終端機、設定檔或 hooks,不需要逐行理解後面的程式。你可以下載我整理的 AI 安裝任務書,直接交給能操作這台 Mac 的 AI,請它依照你的環境偵測、規劃與設定。
上傳檔案後,只要告訴 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。
後來工作環境加入 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。
工具步驟、階段性輸出或其他工作視窗都可能讓通知入口被呼叫。因此,完成通知要先確認最新訊息確實是主工作視窗的 phase=final,再進入單次鎖。
決策請求則有更精確的入口。Codex hooks 的 PermissionRequest 會在系統需要核准時執行,適合拿來提醒人回到電腦。它不應該替人自動批准;通知腳本只負責叫人回來,判斷仍留在 Codex 畫面。設定位置與信任機制可參考 Codex Hooks 文件。
整體架構:讓提醒離開螢幕,但不讓判斷離開人
整體架構如下。兩個平台共用的是通知語意與輸出規則,不是同一份事件設定:
它有五個安全原則:
- 沒有明確啟用檔,就完全不執行。
- Claude 完成通知要有明確任務標記;Codex 完成通知只接受主工作視窗的
phase=final。 - Claude 的任務標記只消耗一次;Codex 的穩定事件要先取得 SQLite 一次性主鍵。
- Codex 的頻率熔斷器限制短時間事件量;Claude 的語音佇列避免多視窗聲音互相重疊。
- Pushover 不用緊急優先級,程式也不重試。
第五點尤其重要。Pushover 的 Message 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(選用)。
先確認你正在使用的平台版本:
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 流程進入;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 的核心結構:
#!/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.toml 的 notify 啟動。先查自己的絕對家目錄:
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 設定變更後可能需要重新審查;不要只因檔案存在,就假設它已經生效。
不要一開始就同時測試聲音、手機與手錶
最安全的測試順序是逐層打開:
- 先停用 Claude 與 Codex 的通知開關,確認所有事件都保持安靜。
- 讓腳本只寫本機紀錄,不播放聲音、不呼叫 Pushover。
- 用同一份假事件執行兩次,確認只有第一次取得 SQLite 鎖。
- 單獨開啟 Mac 語音,確認只說一次。
- 最後才傳一則正常優先級 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 則確認:
- 主視窗最終回覆:應通知一次。
- 主視窗階段性 commentary:不應通知。
- 子工作或子代理 final:不應冒充主任務完成。
- 同一 final 輸入兩次:第二次應被 SQLite 擋住。
如果 session 格式改變,安全預設應是「不通知」,而不是猜測完成。漏掉一次提醒很麻煩,但錯誤地反覆通知更危險。
這也呼應我在代理營運與無聲失敗裡反覆處理的問題:系統不能只靠自己宣布成功。通知不是完成證明,它只是把一個經過條件檢查的狀態送到人身邊。
設定完成後,怎樣才算驗收通過?
我用七項結果判斷 Claude 與 Codex 是否都可以正式啟用:
- Claude 沒有完整任務標記時,
Stop不宣告完成。 - Claude 有任務標記並完成時,Mac 與 Pushover 各通報一次,而且訊息說明完成了什麼。
- Claude 進入
waiting時,訊息說明需要決定什麼,Claude 畫面仍等待人決定。 - Codex 需要核准時,Mac 與 Pushover 各提醒一次,而且訊息說明待確認操作;Codex 畫面仍等待人決定。
- Codex 主工作視窗輸出
commentary時不宣告完成,形成final後才通報一次。 - 同一份事件再次送入通知程式時,單次鎖會拒絕第二次認領。
- iPhone 能收到 Claude 與 Codex 各自標示來源的 Pushover,Apple Watch 也依鏡像設定顯示或震動。
最後再分別測試兩個平台的停用方式,確認其中一邊停用時,不會影響另一邊,也不會留下背景程序繼續送訊息。這讓通知功能保持可控,也方便日後升級任一平台時重新驗證。
讓工作進度成為聽得到、感覺得到的狀態
語音通知服務人在電腦附近、注意力卻不在螢幕上的時刻。Pushover 與 Apple Watch 則把同一個工作狀態帶到座位之外。
兩者接起來之後,我不需要把注意力綁在進度動畫上。Claude 與 Codex 可以繼續執行;需要權限或判斷時,語音或手錶提醒我回來;形成最終結果後,我再回到對應畫面驗收。
每一次聲音和震動都對應一個明確狀態。階段性進度留在 Claude 或 Codex,只有需要判斷、無法繼續或形成完整成果時,系統才主動提醒。這樣的通知能增加工作進度的可見性,也減少反覆查看不同視窗造成的注意力切換。
這套系統建立了一條跨工具的人機協作邊界:AI 可以在沒有人盯著螢幕時繼續工作;需要人介入或已經完成交付時,再用一次清楚而可追溯的通知把狀態送回來。它從 Claude 起步,遷移到 Codex 後沒有取代原系統,而是讓兩個工作環境都遵守同一套回報方式。
即使你不打算照著本文寫程式,也可以用三個問題檢查自己正在使用的 AI Agent:它能否分辨完整完成與階段性停頓?需要你介入時,能否說清楚要決定什麼?當你沒有看螢幕時,狀態能否透過其他感官找到你?只要其中一題的答案是否定的,這篇文章的設計原則就仍有用武之地。
如果你正在建立自己的 AI 工作環境,可以把這篇與本站的 AI 主題頁一起看。工具會換、事件格式會變,但語音與震動的用途很直接:讓工作進度離開螢幕,成為人隨時能察覺的訊號。
💬 留言討論
載入中...