Yoda
参考Agent 设计指南UI 外显

通知

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_promptidle_prompt 等六种"需要人介入"的时刻,且有精细的 idle 判定(6 秒无交互才打扰)。Codex 的两层是「外部程序 + TUI 内置」并联notify = ["program"] 在每个 turn 结束后 spawn 外部程序、把 JSON 作为最后一个 argv 传入(仅 agent-turn-complete 一种事件);[tui] 内置层则发 OSC 9 / BEL 桌面通知,覆盖 agent-turn-completeapproval-requestedplan-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/exitworker_permission_promptutils/computerUse/wrapper.tsx:223hooks/useInboxPoller.ts:359)。Notification hook 不能阻断或改写通知,只用于副作用转发([一手文档] hooks.md:1878)。

触发时机与 idle 判定:权限弹窗出现时走 useNotifyAfterTimeout(message, 'permission_prompt')——若用户 6 秒(DEFAULT_INTERACTION_THRESHOLD_MS = 6000)内有任何键盘交互则不发,避免"人就在屏幕前还弹通知"([一手源码] hooks/useNotifyAfterTimeout.ts:9,38-66components/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)。autoTERM_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):

  • notificationstrue/false 或自定义白名单数组(Notifications::Custom,按事件 type 过滤)
  • notification_methodauto / 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_conditionunfocused(默认,仅终端失焦时发)/ alwaystypes.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-23tui/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 CodeCodex CLI
可编程层Notification hook(stdin JSON,可按 type matcher)notify 外部程序(JSON 作最后一个 argv)
可编程层事件6+ 种 notification_typeagent-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_disabledtui.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 实践)

  1. 双重打扰是默认状态,必须主动消除:Yoda 以 GUI 形式接管通知(系统级 Notification API)后,对 CC 应在受管 settings 中设 preferredNotifChannel: notifications_disabled(hook 层保留——它不产生 UI),对 Codex 设 tui.notifications = false;否则内嵌终端里的 OSC9/BEL 仍会穿透到宿主终端或被 xterm.js 解释。
  2. 事件源选择:CC 侧用 Notification hook 作为 Yoda 的通知事件源最干净(结构化、带 type、可 matcher 过滤);Codex 侧 notify 只有 turn 完成一种事件,审批请求拿不到——审批提醒必须从 app-server / exec 事件流里自己提取(参见 approval-requested 仅存在于 TUI 内置层这一事实)。
  3. 抄 Codex 的两个好设计notification_condition: unfocused(窗口聚焦时不打扰)和单槽优先级合并(审批 > 完成)都应进 Yoda 的通知策略;CC 的 6 秒交互阈值("刚打过字就别弹")是第三个值得抄的细节。
  4. 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.tsutils/hooks.ts:3570-3592hooks/useNotifyAfterTimeout.tsscreens/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-702codex/codex-rs/hooks/src/legacy_notify.rscodex/codex-rs/config/src/types.rs:564-637codex/codex-rs/tui/src/notifications/codex/codex-rs/tui/src/chatwidget/notifications.rs

On this page