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": "当 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 设置变更后可能需要重新审查;不要只因文件存在,就假设它已经生效。

不要一开始就同时测试声音、手机与手表

最安全的测试顺序是逐层打开:

  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 主题页一起看。工具会换、事件格式会变,但语音与震动的用途很直接:让工作进度离开屏幕,成为人随时能察觉的信号。