Agent 原生系统通知实现总结(Claude、Codex 等)
在使用 Codex 时,每当一个完整的对话轮次结束或需要请求执行许可时,macOS 的通知中心都会即时推送通知给我。
起初我并未深究这一功能的实现方式,直到通过 tmux 使用 Codex 时,发现通知完全消失了,这才引起了我的好奇。 为什么会这样呢?
Codex 是如何实现原生系统通知
我们先从了解 Codex 原生系统通知的实现机制开始。
Codex 通过 OSC 9(Operating System Command sequences,操作系统命令序列)来触发系统通知。
这一功能最初似乎是 iTerm2 的特有实现,但我在 Ghostty 终端中也能正常使用。 如果你使用其他终端模拟器,可以用以下命令测试是否能够正常唤起系统通知:
printf '\033]9;%s\a' "This is the notification text"
其中 \033] 是 ANSI Escape Sequences 中的 OSC(操作系统命令序列)。
\a 对应 ASCII BEL(响铃,0x07),在 OSC 中常作为结束符(terminator)。
那为什么在 tmux 中失效了
这是因为 tmux 的 OSC 解析器只“认识/处理”少数几个 OSC 编号, 其它一律当成 “unknown” 丢弃(仅记录调试日志),不会转发给外层终端。
因此通知就无法送达了。不过,tmux 其实提供了一种解决方案。让我详细说明一下。
折中的方案
方案一:DCS 透传
tmux 提供了 DCS passthrough 功能,只需将需要透传的转义序列按以下格式包裹即可:
printf '\033Ptmux;\033%s\033\\' "$(printf '\033]9;%s\a' "This is the notification text")"
其中 \033P 是 ANSI 转义序列中的 DCS(Device Control String),它以 \033\\ 作为结束符。
方案二:直接写入客户端 TTY
通过获取当前 tmux 客户端的 TTY 地址,直接向其写入转义序列:
tty="$(tmux display-message -p '#{client_tty}' 2>/dev/null || true)"
printf '%b' "$(printf '\033]9;%s\a' "This is the notification text")" >"$tty"
如果需要定义通知标题,可以使用 OSC 777 序列:
printf '\033]777;notify;%s;%s\a' "This is the title" "This is the text"
现在就差如何让 Codex 调用这些方法了。
TL;DR
Codex 原生系统通知配置方式
增加一个 notify.sh 脚本,负责接收 Codex 的调用并解析其参数。
#!/usr/bin/env bash
set -eo pipefail
_write() {
local msg="$1"
if [ -n "$TMUX" ]; then
# tmux inside:write into tty directly
local tty
tty="$(tmux display-message -p '#{client_tty}' 2>/dev/null || true)"
if [[ -n "$tty" && -w "$tty" ]]; then
printf '%b' "$msg" >"$tty"
else
# fallback: DCS passthrough + double ESC
printf '\033Ptmux;\033%s\033\\' "$msg"
fi
else
# tmux outside:direct OSC 9
printf '%b' "$msg"
fi
}
# OSC 9: notify with a single message string.
notify_osc9() {
local msg="$1"
_write $'\033]9;'"${msg}"$'\a'
}
# OSC 777 (Blink-style): notify;Title;Body
# Note: Title/Body must not contain unescaped ';' ideally.
notify_osc777() {
local title="$1"
local body="$2"
# Basic sanitation: replace newlines with spaces; replace ';' to avoid field split.
title="${title//$'\n'/ }"
body="${body//$'\n'/ }"
title="${title//;/:}"
body="${body//;/:}"
_write $'\033]777;notify;'"${title}"$';'"${body}"$'\a'
}
main() {
[[ $# -ge 1 ]] || exit 0
local json="$1"
# Fast type check
local type
type="$(jq -r '.type // empty' <<<"$json" 2>/dev/null || true)"
[[ "$type" == "agent-turn-complete" ]] || exit 0
local last_msg input_messages thread_id title message
last_msg="$(jq -r '."last-assistant-message" // "Turn Complete!"' <<<"$json")"
input_messages="$(jq -r '."input-messages" // [] | join(" ")' <<<"$json")"
thread_id="$(jq -r '."thread-id" // ""' <<<"$json")"
title="Codex: ${last_msg}"
message="${input_messages}"
# Prefer OSC 777 (Title/Body)
notify_osc777 "$title" "$message"
# notify_osc9 "${title}\n${message}"
}
main "$@"
然后在 Codex 的配置文件(通常是 ~/.codex/config.yaml)中添加以下配置:
notifications:
enabled: true
command: /path/to/your/notify.sh
这样当 Codex 完成一个对话轮次时,就会调用你的脚本发送系统通知了。
Claude 原生系统通知配置方式
用以下代码替换以上 notify.sh 脚本中的 main 函数,新建 ~/.claude/scripts/notify.sh 即可。
main() {
local json
json="$(cat)"
# Fast type check
local type
type="$(jq -r '.hook_event_name // empty' <<<"$json" 2>/dev/null || true)"
case "$type" in
"Notification")
local notification_type title message
notification_type="$(jq -r '."notification_type" // "Unknown"' <<<"$json")"
title="Claude: ${notification_type}"
message="$(jq -r '.message // empty' <<<"$json")"
;;
"Stop")
title="Claude: ${type}"
message="All done!"
;;
*)
exit 0
;;
esac
# Prefer OSC 777 (Title/Body)
notify_osc777 "$title" "$message"
# notify_osc9 "${title}\n${message}"
}
然后在 Claude 的配置文件(通常是 ~/.claude/settings.json )中添加以下配置片段:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/scripts/notify.sh"
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/scripts/notify.sh"
}
]
}
]
}
}
本文基于个人实践经验编写,并使用 Avante + DeepSeek V3.2 进行了润色优化。
This work by 林玮 (Jade Lin) is licensed under
CC BY-NC-ND 4.0