跳转至

Claude Code Windows 通知系统

概述

「Claude Code 只要停下来等你,就弹一条 Windows 系统通知」。

覆盖场景:请求权限 / 批准工具 / 提问 / 计划审批 / 空闲等待 / 任务完成 / API 报错 / 会话结束。

通知系统位于 C:\Users\YWH18\.claude\hooks\(2026-10-05 由 通知系统\ 改名而来)。职责单一——只发通知:不做审批决策、不修改权限模式、不拦截工具。

文件分工

文件 作用
notify_hook.py 唯一入口:读 stdin 的 hook JSON → 生成中文文案 → 调用 toast.ps1
toast.ps1 WinRT Toast 弹窗,失败时自动退回托盘气泡
focus_terminal.py claude-notify:// 协议处理器,点击通知后把终端窗口拉回前台
settings.snippet.json 通知系统的 5 个 hook 配置模板
notify.log 运行日志(排错第一现场)
notify_dedup.json 去重状态,按窗口自动清理,无需手动维护

settings.snippet.json 中不含 PreToolUse。settings.json 里那段 block_builtin_web.py 是内置 web 拦截,属于另一条链路,与本通知系统无关。

运行环境

D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/notify_hook.py

使用独立安装版 Python 3.12.9(D:\APP\python312\python.exe)。该环境日常禁碰、禁止装包,不要换成 conda 的 D:/APP/anaconda3/envs/py312/。

五个 hook 事件(缺一有漏报)

事件 覆盖场景
PermissionRequest(matcher .*) 请求权限 / 批准工具 / 提问 / 计划审批
Notification(排除 permission_prompt) 空闲 60s、MCP 提问、子代理要输入、认证成功
Stop 任务完成
StopFailure API 报错——Stop 在报错时不触发,只挂 Stop 会漏
SessionEnd 会话异常退出

全部 "async": true,实测单次约 391ms,Claude 零等待。

当前配置(settings.json)

{
  "PermissionRequest": [
    {
      "matcher": ".*",
      "hooks": [{
        "type": "command",
        "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/notify_hook.py",
        "timeout": 10,
        "async": true
      }]
    }
  ],
  "Notification": [
    {
      "matcher": "^(idle_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input|auth_success)$",
      "hooks": [{
        "type": "command",
        "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/notify_hook.py",
        "timeout": 10,
        "async": true
      }]
    }
  ],
  "Stop":       [{ "hooks": [{ "type": "command", "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/notify_hook.py", "timeout": 10, "async": true }] }],
  "StopFailure":[{ "hooks": [{ "type": "command", "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/notify_hook.py", "timeout": 10, "async": true }] }],
  "SessionEnd": [{ "hooks": [{ "type": "command", "command": "D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/notify_hook.py", "timeout": 10, "async": true }] }]
}

为什么 Notification 要排除 permission_prompt

permission_prompt 与 PermissionRequest 指向同一次权限请求,但前者晚约 6 秒触发、且每次击键都会重新计时。PermissionRequest 是即时信号,还自带工具名与内容,信息量严格更大。

notify_hook.py 的 main() 里还有一道显式丢弃(防回归):即使有人把 matcher 改回包含 permission_prompt,也不会双弹。

点击通知 → 跳回终端

完整链路:

1
2
3
4
5
6
notify_hook.py 从自身 PID 向上爬进程链,找到终端窗口
  → toast.ps1 给 <toast> 加 activationType="protocol"
      + launch="claude-notify://focus/?hwnd=&pid="
  → 用户点击通知
  → Windows Shell 拉起 focus_terminal.py(必须 pythonw.exe,否则闪黑窗)
  → SetForegroundWindow + AttachThreadInput 绕前台锁定

协议注册(已就位,无需重做)

1
2
3
4
5
HKCU\Software\Classes\claude-notify
    (Default)     = URL:Claude Code Hooks Notification
    URL Protocol  = (空字符串值)
    DefaultIcon   = D:\APP\python312\pythonw.exe,0
    shell\open\command = "D:\APP\python312\pythonw.exe" "...\hooks\focus_terminal.py" "%1"

关键点:URL Protocol 必须是空字符串值。 缺它 Windows 不把这个协议识别为可激活协议,点击通知会被静默丢弃。

通知来源名(AUMID 注册)

通知来源显示的名字由注册表决定:

1
2
3
4
HKCU\Software\Classes\AppUserModelId\ClaudeCode.Notify
    DisplayName         = Claude Code
    IconUri             = D:\AI\ClaudeCode\claude-logo.webp
    IconBackgroundColor = FFDDDDDD
  • 未注册 AUMID 也能正常弹通知,此时来源名直接显示 AUMID 字符串本身
  • 注册只影响「显示什么名字」,不影响「能不能弹」
  • 同名但在 ...\Notifications\Settings\ 下的键是系统自动创建的每应用通知开关,不存 DisplayName,别改错位置
  • IconUri 的真正约束是图片尺寸与体积:大图会导致图标渲染失败,换成小图即可(详见 Windows 系统设置合集 第七节)

去重机制

hook 每次触发都是全新进程,进程内状态下次必然为空,因此去重状态必须落盘到 notify_dedup.json。

去重指纹 = 事件 + session_id + cwd + 正文,四项齐全:

字段 防止的漏报/误杀
事件分类 区分 Stop / StopFailure / SessionEnd / 各类 Notification
session_id 同一会话 8 秒内第二次内容不同的批准被误杀
cwd 两个项目输出相同正文时,第二个项目的「任务完成」被吞
正文 同一会话内连续两次内容不同的权限请求必须都通知

去重窗口 8 秒。读-改-写不加锁——最坏结果只是多弹一次,不会产生正确性问题。

排查

通知没弹

  1. 看 C:\Users\YWH18\.claude\hooks\notify.log
  2. 确认 toast.ps1 存在
  3. 手动发一条测试通知(不读 stdin):
D:/APP/python312/python.exe C:/Users/YWH18/.claude/hooks/notify_hook.py --test 测试 "这是一条测试通知"

注意:Show() 返回值不可信。 即使 AUMID 未注册、通知实际被丢弃,它同样返回成功,日志也照写「已通知」。判断通知是否真的弹出,只能看屏幕。

点击通知没反应

  1. 看 %TEMP%\claude-notify-focus.log——协议处理器被 Shell 拉起时没有 stdout/stderr/弹窗,这是唯一线索
  2. 确认 HKCU\Software\Classes\claude-notify 下存在空的 URL Protocol 值
  3. 确认 toast.ps1 显式设置了 activationType="protocol" 与 launch

批量自测会丢通知

并发推送会静默丢失,且 Show() 与日志均无异常。批量自测时每条之间至少间隔 2 秒;真实 hook 事件间隔远大于此,无需修改脚本。

其他要点

  • claude.exe 有一个可见但 0×0 的 PseudoConsoleWindow,且在进程链上排在 Windows Terminal 之前。定位窗口时判据必须含有效尺寸并取面积最大者,否则点击毫无反应
  • 必须用 Windows PowerShell 5.1(powershell.exe)。New-Object Windows.Data.Xml.Dom.XmlDocument 会失败(Cannot find type),应改用 GetTemplateContent();pwsh 的 WinRT 类型投影不稳定
  • 修改 hooks 配置立即生效,无需重启会话(网上多数文章说必须重启,与本机实测不符)
  • async: true 的 hook 不写进会话 transcript,在 transcript 里查不到 notify_hook.py 不代表没有触发,地面真值是 notify.log

已知局限

Windows Terminal 没有公开的「按标签页聚焦」API,只能定位到窗口级。多标签页时,焦点会落在最后点过的那个标签页上。若要精确跳到原标签页,只能改用向该标签页的 ConPTY 写入转义序列,属于另一套方案。

设计原则

  • 绝不阻塞 Claude:全部 hook async: true 后台运行
  • 绝不 fail-open 出错:任何异常都吞掉,退出码恒为 0
  • 定位失败不影响送达:terminal_target() 找不到窗口时返回 (0, 0),此时发送不带 launch 的通知——照常弹出,只是点击不跳转。附加能力不能拖累主功能