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": "当 Codex 需要批准时,通过 Mac 语音和 Pushover 提醒。",
"hooks": {
"PermissionRequest": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "/Users/yourname/.codex/hooks/permission-request.sh",
"timeout": 30,
"statusMessage": "正在发送批准提醒"
}
]
}
]
}
}
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 主题页一起看。工具会换、事件格式会变,但语音与震动的用途很直接:让工作进度离开屏幕,成为人随时能察觉的信号。
💬 留言讨论
加载中...