通知
CC 走「Notification hook + 终端通道自动探测」双轨;Codex 走「notify 外部程序(argv JSON)+ tui_notifications(OSC9/BEL)」双层,事件粒度与焦点感知不同
通知
结论
两家都是"双层通知"架构,但分层方式不同。CC 的两层是「可编程层 + 显示层」串联:每次通知先跑用户的 Notification hook(stdin JSON,含 notification_type 可按类型匹配),再按 preferredNotifChannel 探测终端(iTerm2/kitty/ghostty 专有协议、Apple Terminal 响铃),通知类型覆盖 permission_prompt、idle_prompt 等六种"需要人介入"的时刻,且有精细的 idle 判定(6 秒无交互才打扰)。Codex 的两层是「外部程序 + TUI 内置」并联:notify = ["program"] 在每个 turn 结束后 spawn 外部程序、把 JSON 作为最后一个 argv 传入(仅 agent-turn-complete 一种事件);[tui] 内置层则发 OSC 9 / BEL 桌面通知,覆盖 agent-turn-complete、approval-requested、plan-mode-prompt 三类,默认只在终端失焦时发送并做单槽优先级合并。harness 接管通知时必须把这两套全部静音或代理,否则用户会被同一事件打两次。
研究问题
- 各家通知由哪些事件触发?payload schema 是什么?
- 终端层(OSC9/BEL/专有协议)如何探测与降级?
- harness 接管通知后如何避免双重打扰?
各 Agent 设计与实现
Claude Code
统一入口:sendNotification(notif, terminal) 先执行 Notification hooks、再投递到终端通道([一手源码] src_2026-03-31/services/notifier.ts:18-36——hook 与显示串联在同一函数里)。
Hook 层:executeNotificationHooks() 组装 payload([一手源码] utils/hooks.ts:3570-3592):
{ "session_id": "...", "transcript_path": "...", "cwd": "...",
"hook_event_name": "Notification",
"message": "Claude needs your permission", "title": "Permission needed",
"notification_type": "permission_prompt" }hook matcher 匹配的就是 notification_type 字段(utils/hooks/hooksConfigManager.ts:70-72)。文档列出六种类型:permission_prompt / idle_prompt / auth_success / elicitation_dialog / elicitation_complete / elicitation_response([一手文档] docs/hooks.md:1831);源码另见内部类型 computer_use_enter/exit、worker_permission_prompt(utils/computerUse/wrapper.tsx:223、hooks/useInboxPoller.ts:359)。Notification hook 不能阻断或改写通知,只用于副作用转发([一手文档] hooks.md:1878)。
触发时机与 idle 判定:权限弹窗出现时走 useNotifyAfterTimeout(message, 'permission_prompt')——若用户 6 秒(DEFAULT_INTERACTION_THRESHOLD_MS = 6000)内有任何键盘交互则不发,避免"人就在屏幕前还弹通知"([一手源码] hooks/useNotifyAfterTimeout.ts:9,38-66、components/permissions/PermissionRequest.tsx:190)。回答完成后的 idle_prompt 由 REPL 计时:响应结束后用户无交互且超过 messageIdleNotifThresholdMs(global config 可调)才发 "Claude is waiting for your input"([一手源码] screens/REPL.tsx:3925-3940)。
终端通道:preferredNotifChannel 取值 auto / iterm2 / iterm2_with_bell / kitty / ghostty / terminal_bell / notifications_disabled([一手源码] services/notifier.ts:41-67)。auto 按 TERM_PROGRAM 探测:iTerm2/kitty/ghostty 用各自专有转义序列,Apple Terminal 落到响铃(还会检测系统设置里铃声是否被关掉,notifier.ts:84-92)。tmux 内需 allow-passthrough 否则通知被吞([一手文档] docs/terminal-config.md:104-112)。
Codex CLI
外部程序层(notify):config.toml 顶层 notify = ["notify-send", "Codex"]——argv 数组,Codex 在每个 turn 完成时 spawn 该程序并把 JSON 追加为最后一个参数([一手源码] codex-rs/core/src/config/mod.rs:682-702 文档注释)。payload 由 legacy_notify.rs 定义,目前只有一种事件:
{ "type": "agent-turn-complete",
"thread-id": "b5f6c1c2-...", "turn-id": "12345",
"cwd": "/Users/example/project", "client": "codex-tui",
"input-messages": ["Rename `foo` to `bar`..."],
"last-assistant-message": "Rename complete..." }([一手源码] codex-rs/hooks/src/legacy_notify.rs:13-40,kebab-case tag;stdin/stdout/stderr 全部 null,纯 fire-and-forget,spawn 失败也只是 FailedContinue,L58-66。模块名 legacy_notify + 所在 crate 是新的 hooks,暗示它正被通用 hooks 机制收编。)
TUI 内置层([tui] 通知设置)([一手源码] codex-rs/config/src/types.rs:615-637):
notifications:true/false或自定义白名单数组(Notifications::Custom,按事件 type 过滤)notification_method:auto / osc9 / bel——auto 时探测终端,Ghostty/iTerm2/kitty/Warp/WezTerm 支持 OSC 9,否则降级 BEL(tui/src/notifications/mod.rs:20-31,52-61);tmux 内自动用 DCS passthrough 包裹 OSC 序列(notifications/osc9.rs:22-50)notification_condition:unfocused(默认,仅终端失焦时发)/always(types.rs:595-603)
事件类型与合并:TUI 层事件共三个 type:agent-turn-complete(附响应预览)、approval-requested(exec 审批/edit 审批/MCP elicitation 三种合一)、plan-mode-prompt([一手源码] tui/src/chatwidget/notifications.rs:27-76)。单槽 pending 设计:同帧多个通知只保留优先级最高的(审批类 priority 1 > turn 完成 0),在下次绘制时统一投递(notifications.rs:8-23、tui/src/app.rs:1270)。
CC 的 auto 通道降级链(notifier.ts:80-100):
TERM_PROGRAM == iTerm.app → iTerm2 专有通知序列
== kitty → kitty 通知协议
== ghostty → ghostty 通知协议
== Apple_Terminal → 终端响铃(先检查系统设置铃声未关,notifier.ts:84-92)
其他 → 终端响铃设计洞察:两种"双层"的取舍
CC 把可编程层做在事件源头(每次 sendNotification 必先过 hook,notifier.ts:25),所以 hook 能看到全部 6+ 种类型,但 hook 与显示耦合在同一调用链;Codex 把外部程序挂在 turn 边界(core 层),TUI 通知则完全独立在表现层——好处是 headless/exec 模式下 notify 依然工作,代价是审批类事件天然进不了外部程序层(审批弹窗发生在 turn 内部,而 notify 只在 turn 完成时触发一次)。这解释了为何社区常见"Codex 审批通知只能在 TUI 里收到"的反馈:是架构位置决定的,不是遗漏。
差异矩阵
| 维度 | Claude Code | Codex CLI |
|---|---|---|
| 可编程层 | Notification hook(stdin JSON,可按 type matcher) | notify 外部程序(JSON 作最后一个 argv) |
| 可编程层事件 | 6+ 种 notification_type | 仅 agent-turn-complete |
| 显示层 | 终端专有协议探测(iTerm2/kitty/ghostty/响铃) | OSC 9 / BEL 二选一 + auto 探测 |
| 焦点感知 | 间接(6s 交互阈值 + idle 计时) | 直接(unfocused 默认仅失焦发送) |
| turn 完成通知 | idle_prompt(需超过 idle 阈值) | agent-turn-complete(即刻,带响应预览) |
| 审批请求通知 | permission_prompt(6s 无交互才发) | approval-requested(优先级高于完成) |
| 多通知合并 | 每事件独立 | 单槽 + 优先级,每帧最多一条 |
| 禁用方式 | preferredNotifChannel: notifications_disabled | tui.notifications = false |
| 白名单过滤 | hook matcher 字符串匹配 | notifications = ["approval-requested"] 数组 |
最小复现
# CC:把所有通知转发到日志,观察 payload
# settings.json → "hooks": { "Notification": [ { "hooks": [ { "type": "command",
# "command": "tee -a /tmp/cc-notifications.jsonl" } ] } ] }
# Codex:~/.codex/config.toml
# notify = ["sh", "-c", "echo \"$1\" >> /tmp/codex-notify.jsonl", "--"]
# [tui]
# notifications = ["approval-requested"] # 只要审批提醒
# notification_method = "osc9"
# notification_condition = "always"Harness 接入建议(Yoda 实践)
- 双重打扰是默认状态,必须主动消除:Yoda 以 GUI 形式接管通知(系统级 Notification API)后,对 CC 应在受管 settings 中设
preferredNotifChannel: notifications_disabled(hook 层保留——它不产生 UI),对 Codex 设tui.notifications = false;否则内嵌终端里的 OSC9/BEL 仍会穿透到宿主终端或被 xterm.js 解释。 - 事件源选择:CC 侧用 Notification hook 作为 Yoda 的通知事件源最干净(结构化、带 type、可 matcher 过滤);Codex 侧
notify只有 turn 完成一种事件,审批请求拿不到——审批提醒必须从 app-server / exec 事件流里自己提取(参见approval-requested仅存在于 TUI 内置层这一事实)。 - 抄 Codex 的两个好设计:
notification_condition: unfocused(窗口聚焦时不打扰)和单槽优先级合并(审批 > 完成)都应进 Yoda 的通知策略;CC 的 6 秒交互阈值("刚打过字就别弹")是第三个值得抄的细节。 - payload 归一:CC 的
{message, title, notification_type}与 Codex 的 kebab-case{type, thread-id, last-assistant-message}字段风格完全不同,Yoda 的通知总线应定义自己的 envelope,把两家映射进来,避免下游耦合任一家 schema。
失效条件
- Codex
legacy_notify正在被新 hooks crate 收编——notify的事件种类可能扩充,"仅 agent-turn-complete"需每版本复核(codex-rs/hooks/src/legacy_notify.rs) - CC
notification_type清单随功能涨(快照已有文档之外的 computer_use_*、worker_permission_prompt 等内部类型),matcher 白名单需定期 diff docs/hooks.md - Codex OSC9 支持终端清单(Ghostty/iTerm2/kitty/Warp/WezTerm)硬编码在
supports_osc9,新终端出现时 auto 探测结果会变 - CC 重建源码滞后线上约 2 个月,idle 阈值与通道清单以实测为准
参考资料
- [一手源码]
claude-code-source-code/src_2026-03-31/services/notifier.ts、utils/hooks.ts:3570-3592、hooks/useNotifyAfterTimeout.ts、screens/REPL.tsx:3925-3940(重建源码,仅作架构描述与短引用) - [一手文档]
claude-code-docs/docs/hooks.md(Notification 一节)、claude-code-docs/docs/terminal-config.md(tmux allow-passthrough) - [一手源码]
codex/codex-rs/core/src/config/mod.rs:682-702、codex/codex-rs/hooks/src/legacy_notify.rs、codex/codex-rs/config/src/types.rs:564-637、codex/codex-rs/tui/src/notifications/、codex/codex-rs/tui/src/chatwidget/notifications.rs