永远不再错过 Claude 的消息:macOS 通知系统完整方案

从一行代码到完整通知系统,解决 Claude Code 任务完成后用户不知道的真实痛点。

一个浪费二十分钟的故事

让 Claude 跑一个重构任务,切到浏览器查资料。写了会儿笔记,回了几封邮件,想起来切回终端看看——Claude 十分钟前就完成了。还算好。

更糟的情况是:Claude 在等我回答一个问题,"这个函数的命名你倾向哪种?",而我在浏览器里毫不知情地又耗了二十分钟。阻塞类等待才是真正的时间黑洞——Claude 完全停住了,等你回来之前什么都不会做。

Claude Code 默认没有主动通知机制。它就安静地待在终端窗口里,等你自己切回来看。如果你是同时开着多个实例跑不同任务的用户,这个问题会被成倍放大。

这篇文章分享我逐步搭建的 macOS 通知方案。从一行代码的最简版本开始,到能通过声音区分事件类型、穿透勿扰模式、点击通知直接跳回终端窗口的完整系统。


最简方案:一行代码搞定

Hooks 是 Claude Code 的事件驱动自动化机制——当 Claude 完成任务、需要回答、请求权限时,自动触发你指定的脚本。

最简单的通知方案,只需要一行 osascript

osascript -e 'display notification "Task complete" with title "Claude Code"'

把它配到 ~/.claude/settings.jsonStop 事件上:

~/.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Done\" with title \"Claude\"'",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

保存后,下次 Claude 完成任务时,你就会在 macOS 通知中心看到弹窗。

这就是全部了——一行命令,一个配置,解决 80% 的痛点。

但如果你和我一样,是同时跑多个任务、经常开着勿扰模式工作的用户,这个最简方案会很快暴露不足。你不知道 Claude 是完成了任务还是在等你回答问题,通知没有声音区分,勿扰模式下什么都收不到。


四个关键时刻

Claude Code 的 Hooks 支持多种事件。对通知系统而言,有四个时刻值得关注:

事件含义通知标题你需要做什么
Stop任务完成Task Complete可以回来看结果了
Notification (elicitation)Claude 在等你回答问题Needs Answer必须回去回答,否则 Claude 完全停住
Notification (permission)Claude 在等你授权操作Needs Permission必须回去授权,否则 Claude 完全停住
SessionEnd会话异常结束Session Ended了解情况,可能需要重新启动

前两列是技术事实,后两列是使用体验。关键区别在于:Stop 和 SessionEnd 是信息类通知,你知道了就行,什么时候回来都可以;Notification 的两种子类型是阻塞类通知,Claude 在等你操作,每多等一秒都是浪费。

这个区别决定了后面所有的设计选择——声音、DnD 穿透、通知分组,全部围绕"信息类 vs 阻塞类"展开。

为什么不只监听 Stop?因为 Stop 只覆盖"任务完成"。如果 Claude 中途需要你回答一个问题或授权一个操作,触发的是 Notification 事件,不是 Stop。只监听 Stop 的话,你会在 Claude 等你回答的时候一无所知。

而 SessionEnd 中有个细节:用户主动退出(user_exitprompt_input_exit)时不需要通知——你自己退出的当然知道。只有异常结束(上下文溢出、API 错误等)才值得发一条通知。

完整的四事件配置:

~/.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 '~/claude-notify/notify.py'",
            "timeout": 10
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "elicitation_dialog|permission_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "python3 '~/claude-notify/notify.py'",
            "timeout": 10
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 '~/claude-notify/notify.py'",
            "timeout": 10
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 '~/claude-notify/notify.py'",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

四个事件都指向同一个 notify.py 脚本。脚本内部通过 stdin 传入的 hook_event_name 字段分发处理——不同事件做不同的事。

注意 Notification 事件上的 matcher: "elicitation_dialog|permission_prompt"。这是一个正则过滤器,只匹配需要用户操作的两种通知类型,排除了 idle_prompt(由 Stop 事件处理,避免重复通知)。

SessionStart 不发通知,它的作用是记录窗口位置,为后面的"点击通知跳回窗口"做准备。


声音设计:用耳朵区分事件类型

核心使用场景是这样的:你在另一个应用里工作,听到一声通知音。不看通知内容,先通过声音判断类型,再决定是否切换回终端

这意味着四种事件的声音必须在音色上彼此差异足够大,听一声就知道是什么事。

macOS 自带 14 个系统声音,位于 /System/Library/Sounds/。我对所有声音做了频率和时长分析,最终选了四个音色差异最大的:

事件声音频率/时长为什么选它
Task CompleteGlass~833Hz / 1.65s清脆明亮,天然的完成感。macOS 经典通知音,听到就联想"好消息"
Needs AnswerBottle~263Hz / 0.77s圆润的中频空洞共鸣,"等等,有个事"的温和提醒
Needs PermissionFunk~286Hz / 2.16s14 个声音中振幅最高,明确的"注意!"信号
Session EndedTink~67Hz / 0.56s最短最轻,极低调的轻叩。像一声轻柔告别

四种声音形成的强度梯度,精确对齐了事件的紧迫度梯度:

Tink(最轻)  <  Glass(悦耳)  <  Bottle(温和)  <  Funk(有力)
 Session Ended  <  Task Complete  <  Needs Answer   <  Needs Permission

这不是第一版方案。早期版本里 Funk 用在了 Needs Answer 上,Basso(100Hz 的极低频)用在了 Needs Permission 上。两个问题:Basso 的极低频在 MacBook 扬声器上表现很差,几乎听不清;更重要的是,Basso 给人"出错了"的消极感,但 permission request 不是错误——Claude 只是需要你点个同意。

所以 Funk 从 Needs Answer "上移"到了 Needs Permission,Bottle 补了 Needs Answer 的位置。声音强度梯度和事件紧迫度梯度对齐了,音色辨识度也提高了:Glass 是高频玻璃质感,Bottle 是中频空洞共鸣,Funk 是有力电子音,Tink 是极轻叩击——四种完全不同的音色特征。

还有一个听觉疲劳的考量。多实例并行时,一天可能收到上百条通知。最高频的事件 Task Complete 用了 Glass,有效信息集中在前 0.5 秒;Session Ended 用了最短的 Tink(0.56 秒),几乎无认知负担。避免了 Hero 这种情感色彩太强、持续时间太长的声音。

实际使用 2-3 天后,条件反射就建立了——听到 Bottle 就知道该切回去回答问题,听到 Glass 就知道任务结束了,不需要看通知内容。


DnD 穿透:只在真正需要时打扰你

开着勿扰模式写代码是常态。问题是:macOS 的勿扰模式会屏蔽所有通知,包括 Claude 等你回答问题的通知。

设计原则很清晰:仅阻塞类事件突破勿扰模式

  • elicitation_dialog(Claude 等你回答) → 穿透 DnD
  • permission_prompt(Claude 等你授权) → 穿透 DnD
  • Stop / SessionEnd(纯信息) → 尊重用户 DnD 设置

逻辑是:DnD 是你主动选择的专注屏障。只有当 Claude 完全被阻断、等你操作才能继续工作时,才有理由突破这道屏障。任务完成了?不急,你忙完了再看。但如果 Claude 在等你回答一个问题,每多等一分钟就是一分钟的空转。

技术实现上,terminal-notifier 提供了 -ignoreDnD 参数。在通知脚本中,根据事件类型决定是否加上这个参数:

# 构建 terminal-notifier 命令
cmd = [notifier_path, "-title", title, "-message", body,
       "-sound", sound, "-group", group_id]
 
# 仅阻塞类事件穿透勿扰模式
if event_type in ("elicitation_dialog", "permission_prompt"):
    cmd.append("-ignoreDnD")

配合通知分组策略,效果更好:阻塞类通知和信息类通知使用不同的 group ID。

# 通知分组
if event_type in ("elicitation_dialog", "permission_prompt"):
    group_id = f"claude-{session_id}-action"    # 阻塞类
else:
    group_id = f"claude-{session_id}-info"       # 信息类

这样做的好处是:Task Complete 通知不会替换 Needs Answer 通知。如果 Claude 先问了个问题(Needs Answer),然后在另一个上下文完成了任务(Task Complete),两条通知各自独立显示。用户不会因为后到的信息类通知覆盖了先到的阻塞类通知而错过重要消息。


完整方案预览

以上已经是一个很实用的通知系统了——四事件覆盖、声音区分、DnD 穿透、分组策略。如果你想进一步打磨体验,还有几个方向可以探索。

窗口聚焦:点击通知直接跳回终端

听到 Bottle 的声音,点击通知弹窗,直接跳到发出这条消息的终端窗口。这是最理想的交互链路。

实现思路是坐标匹配:SessionStart 时记录当前终端窗口的位置坐标,通知点击时遍历所有终端窗口,按坐标匹配找到正确的那个。

#!/bin/bash
# focus-terminal.sh — 通知点击回调
SESSION_ID="$1"
STATE_FILE="/tmp/claude-notify/$SESSION_ID"
 
# 先激活终端应用
osascript -e 'tell application "YourTerminal" to activate'
 
# 如果有存储的窗口位置,提升匹配的窗口
if [ -f "$STATE_FILE" ]; then
    X=$(cut -d',' -f1 < "$STATE_FILE" | tr -d ' ')
    Y=$(cut -d',' -f2 < "$STATE_FILE" | tr -d ' ')
    osascript <<EOF
tell application "System Events" to tell process "YourTerminal"
    repeat with w in windows
        set {wx, wy} to position of w
        if wx is $X and wy is $Y then
            perform action "AXRaise" of w
            exit repeat
        end if
    end repeat
end tell
EOF
fi

为什么不用窗口标题匹配?因为有些终端(比如 Warp)会用 AI 自动重命名窗口标题,同一个项目的窗口标题可能随时变化。坐标匹配虽然有窗口移动后失效的局限,但在实际使用中窗口位置通常是稳定的,且失效时会静默降级为仅激活终端应用,不会报错。

注意:这个功能需要终端应用获得 macOS Accessibility 权限(System Settings > Privacy & Security > Accessibility)。

随机完成语

Task Complete 通知的 body 不总是千篇一律的"Done"。从 12 条英文短语中随机选一条:

COMPLETION_MESSAGES = [
    "All done, ready for the next one.",
    "Task wrapped up successfully.",
    "Finished! Awaiting your next move.",
    "Done and dusted.",
    "Mission accomplished.",
    "Work complete, standing by.",
    "All set! What's next?",
    "That's a wrap.",
    "Knocked it out. Your turn.",
    "Ready when you are.",
    "Task complete. Over to you.",
    "Delivered as requested.",
]

小细节,但每次收到不同的完成语确实让通知不那么机械。

自定义通知图标

macOS 通知图标由发送应用的 bundle icon 决定,无法按单条通知覆盖。terminal-notifier 的 -appIcon 参数使用的是 macOS 私有 API,在 Sequoia 上已经失效。

解决方案是创建一个自定义 app bundle:复制 terminal-notifier.app,替换图标文件,更改 bundle identifier,重新签名。这样通知中心会显示你指定的图标。具体步骤涉及 codesignlsregister、通知中心缓存清理等,细节较多,完整制作流程请参考教程。


完整脚本的核心结构

把上面所有功能整合到一个 Python 脚本里,大约 300 行出头。核心结构是这样的:

notify.py — 核心结构
#!/usr/bin/env python3
"""Claude Code Hooks 通知系统"""
 
import json, sys, os, random, subprocess
 
# ── 配置 ──────────────────────────────────────────
NOTIFIER = os.path.expanduser("~/claude-notify/ClaudeNotify.app/"
    "Contents/MacOS/terminal-notifier")
 
# 事件 → 通知配置映射
NOTIFICATION_MAP = {
    "elicitation_dialog": {
        "subtitle": "Needs Answer",
        "sound": "Bottle",
        "ignore_dnd": True,
        "group_suffix": "action",
    },
    "permission_prompt": {
        "subtitle": "Needs Permission",
        "sound": "Funk",
        "ignore_dnd": True,
        "group_suffix": "action",
    },
    "stop": {
        "subtitle": "Task Complete",
        "sound": "Glass",
        "ignore_dnd": False,
        "group_suffix": "info",
    },
    "session_end": {
        "subtitle": "Session Ended",
        "sound": "Tink",
        "ignore_dnd": False,
        "group_suffix": "info",
    },
}
 
# ── 主逻辑 ─────────────────────────────────────────
def main():
    # 1. 读取 stdin JSON(Hooks 通过 stdin 传入事件数据)
    raw = sys.stdin.read()
    data = json.loads(raw)
 
    event = data.get("hook_event_name", "")
    session_id = data.get("session_id", "unknown")
    project = os.path.basename(
        os.environ.get("CLAUDE_PROJECT_DIR", data.get("cwd", ""))
    )
 
    # 2. SessionStart:仅记录窗口位置,不发通知
    if event == "SessionStart":
        save_window_position(session_id)
        return
 
    # 3. SessionEnd:用户主动退出不通知
    if event == "SessionEnd":
        reason = data.get("reason", "")
        if reason in ("user_exit", "prompt_input_exit"):
            return
        # 异常退出才通知...
 
    # 4. 根据事件类型获取配置,构建 terminal-notifier 命令
    cfg = resolve_config(event, data)
    title = f"{project} | {cfg['subtitle']}"
    group = f"claude-{session_id}-{cfg['group_suffix']}"
 
    cmd = [NOTIFIER, "-title", title, "-message", cfg["body"],
           "-sound", cfg["sound"], "-group", group]
 
    if cfg["ignore_dnd"]:
        cmd.append("-ignoreDnD")
 
    subprocess.run(cmd, timeout=5)
 
if __name__ == "__main__":
    main()

几个实现要点:

  • stdin JSON 解析:Hooks 通过 stdin 向脚本传入 JSON 数据,包含 session_idhook_event_namecwd 等公共字段,以及各事件的特有字段(如 Notification 事件的 notification_typemessage
  • 项目名提取:从 CLAUDE_PROJECT_DIR 环境变量(优先)或 cwd 的 basename 获取,显示在通知标题中——多实例场景下一眼看出是哪个项目
  • subprocess.run 而非 Popen:fire-and-forget 的 Popen 会丢失 app bundle context,导致 macOS 误识别通知发送者。必须用 subprocess.run 配合短 timeout
  • 方括号陷阱:terminal-notifier 的 title 中包含 [] 会导致标题被静默丢弃。如果你的项目名含方括号,需要预处理移除

进阶阅读

这篇文章覆盖了通知系统的核心设计思路和关键代码。如果你想从零开始搭建完整系统:

Hooks 深度配置Hooks 桌面通知 — 按 Stop / SessionEnd / Notification 事件分别推送带差异化音效、点击回终端的桌面通知(macOS)

相关文章打造你的 Claude 终端仪表盘 — StatusLine + Hooks 构成完整的终端增强体验:一个让你实时看到费用和上下文,一个在你离开时主动通知你

本文基于 Claude Code v2.1.x 编写,当前版本 Hooks 支持 24 个事件类型,本文展示的是通知场景最常用的四个。