通知系统
两个不同的管道 - 一个 in-REPL 烤面包机队列和一个操作系统级终端通知程序 - 以及为它们提供数据的每个钩子的完整目录。
Claude Code 有 两个完全独立的通知管道 它们服务于不同的目的并存在于代码库的不同层中:
In-REPL Toast 队列
状态消息显示在终端 UI 的页脚栏中。 管理人 context/notifications.tsx 并由渲染 Notifications.tsx.
操作系统/终端通知程序
本机桌面/终端通知(iTerm2、Kitty、Ghostty、bell)。 管理人 services/notifier.ts and ink/useTerminalNotification.ts.
context/notifications.tsx →
components/PromptInput/Notifications.tsx →
hooks/notifs/*.tsx →
services/notifier.ts →
ink/useTerminalNotification.ts →
utils/collapseBackgroundBashNotifications.ts
这些管道永远不会交叉:toast 队列仅修改 React 状态并在 Ink UI 中呈现; 终端通知程序将原始 OSC/BEL 转义序列直接写入 TTY。 了解两者对于诊断通知在任何给定环境中出现(或不出现)的原因至关重要。
通知类型
每个吐司都是一个 Notification 联合类型定义在顶部
context/notifications.tsx。 它可以是纯文本或任意 JSX:
// context/notifications.tsx
type Priority = 'low' | 'medium' | 'high' | 'immediate'
type BaseNotification = {
key: string
invalidates?: string[] // keys of notifs this one cancels
priority: Priority
timeoutMs?: number // default 8000 ms
fold?: (accumulator: Notification, incoming: Notification) => Notification
}
type TextNotification = BaseNotification & { text: string; color?: keyof Theme }
type JSXNotification = BaseNotification & { jsx: React.ReactNode }
export type Notification = TextNotification | JSXNotification
The fold 场特别有趣——它的行为就像
Array.reduce()。 当第二个通知具有相同内容时
key 当第一个仍在显示或排队时到达,
fold(accumulator, incoming) 将它们合并到位,而不是创建重复的条目。
优先订购
The getNext() 函数导出自 context/notifications.tsx
实现基于优先级的出队。 队列不是 FIFO - 它总是提升优先级最高的项目:
const PRIORITIES: Record<Priority, number> = {
immediate: 0,
high: 1,
medium: 2,
low: 3,
}
export function getNext(queue: Notification[]): Notification | undefined {
if (queue.length === 0) return undefined
return queue.reduce((min, n) =>
PRIORITIES[n.priority] < PRIORITIES[min.priority] ? n : min
)
}
| Priority | Rank | Behavior | 使用者 |
|---|---|---|---|
| immediate | 0 | 抢占当前显示的内容; 碰撞的项目重新排队(仅限非立即) | 达到速率限制、进入超额模式、外部编辑器提示 |
| high | 1 | 排队,出队时胜过中/低 | 速率限制警告、模型弃用 |
| medium | 2 | 排队,节奏低 | LSP 错误、env-hook 错误 |
| low | 3 | 已排队,最后显示 | Env-hook 成功消息(5 秒超时) |
国家形态
通知在中央内实时显示 AppState 作为两个字段 — 一个 current 插槽加上无界 queue:
// AppState.notifications shape
{
current: Notification | null,
queue: Notification[]
}
The useNotifications() 钩暴露 addNotification and
removeNotification。 模块级 currentTimeoutId
跟踪正在运行的自动关闭计时器 - 一个有意的模块单例而不是 React 状态,因此它可以在发生异常时同步清除
immediate 通知会抢占显示。
addNotification:完整的决策树
processQueue:推进显示
processQueue() 每次突变后都会调用。 它仅在以下情况下提升排队项目: current === null。 当它升级时,它会安排一个自动关闭超时,之后它就会失效 current 并打电话
processQueue() 再次——一个简单的递归泵:
const processQueue = useCallback(() => {
setAppState(prev => {
const next = getNext(prev.notifications.queue)
// Only advance if nothing is currently showing
if (prev.notifications.current !== null || !next) return prev
currentTimeoutId = setTimeout(
(setAppState, nextKey, processQueue) => {
currentTimeoutId = null
setAppState(prev => {
// Key comparison guards against stale closures
if (prev.notifications.current?.key !== nextKey) return prev
return { ...prev, notifications: { queue: prev.notifications.queue, current: null } }
})
processQueue()
},
next.timeoutMs ?? DEFAULT_TIMEOUT_MS,
setAppState, next.key, processQueue
)
return {
...prev,
notifications: {
queue: prev.notifications.queue.filter(_ => _ !== next),
current: next,
}
}
})
}, [setAppState])
setTimeout 回调接收 setAppState, nextKey, 和 processQueue 作为额外的参数而不是结束它们。 当组件在计时器触发和回调运行之间重新呈现时,这可以避免陈旧关闭错误。 关键比较(current?.key !== nextKey) 是针对同一类 bug 的第二个防护。
components/PromptInput/Notifications.tsx 是编排中心。 它位于 REPL 提示区域的页脚中。 它的工作是双重的:
- 运行所有
use*Notification钩子,以便它们记录其效果。 - 渲染 非 Toast 状态指示器 (IDE 选择、令牌警告、MCP 状态、语音、更新程序)。
The current 通知来自 AppState 阅读方式为:
const notifications = useAppState(_temp) // selector: s => s.notifications
该组件还连接 环境钩子通知程序 — 从文件更改观察者服务到 React 通知系统的桥梁:
useEffect(() => {
setEnvHookNotifier((text, isError) => {
addNotification({
key: "env-hook",
text,
color: isError ? "error" : undefined,
priority: isError ? "medium" : "low",
timeoutMs: isError ? 8000 : 5000,
})
})
}, [addNotification])
它处理外部编辑器提示——一个短暂的 immediate 每当提示被包装并且用户配置了外部编辑器时就会显示 toast:
if (shouldShowExternalEditorHint && editor) {
addNotification({
key: "external-editor-hint",
jsx: <ConfigurableShortcutHint action="chat:externalEditor" ... />,
priority: "immediate",
timeoutMs: 5000,
})
} else {
removeNotification("external-editor-hint")
}
The hooks/notifs/ 目录包含 14 个钩子,每个钩子只负责一个通知问题。 它们都遵循相同的模式:导入
useNotifications(),观察某个状态 useEffect, 称呼 addNotification() 当条件触发时。
useStartupNotification — DRY 基础
大多数启动钩子共享样板:在远程模式下跳过,每个会话触发一次,处理异步,记录错误。 useStartupNotification 封装了这个:
// hooks/notifs/useStartupNotification.ts
export function useStartupNotification(
compute: () => Result | Promise<Result>
): void {
const { addNotification } = useNotifications()
const hasRunRef = useRef(false)
const computeRef = useRef(compute)
computeRef.current = compute
useEffect(() => {
if (getIsRemoteMode() || hasRunRef.current) return
hasRunRef.current = true
void Promise.resolve()
.then(() => computeRef.current())
.then(result => {
if (!result) return
for (const n of Array.isArray(result) ? result : [result]) {
addNotification(n)
}
})
.catch(logError)
}, [addNotification])
}
computeRef 模式捕捉最新 compute 函数而不使其依赖于效果。 这意味着该效果在安装时只触发一次,但始终调用当前的计算函数 - 避免过时的闭包和在每次渲染时重新触发。
全钩库存
| 挂钩文件 | 通知键 | Priority | Trigger |
|---|---|---|---|
useRateLimitWarningNotification |
rate-limit-warning |
high | 接近当前模型的 Claude.ai 使用限制 |
useRateLimitWarningNotification |
limit-reached |
immediate | 进入超额模式; 每个超额条目触发一次 |
useDeprecationWarningNotification |
model-deprecation-warning |
high | 所选模型具有弃用警告字符串 |
useLspInitializationNotification |
lsp-error-{source} |
medium | LSP 管理器初始化失败或服务器进入错误状态(每 5 秒轮询一次) |
useFastModeNotification |
fast-mode |
high | 模型切换到快速/涡轮模式 |
useAutoModeUnavailableNotification |
auto-mode-unavailable |
high | 当前订阅无法自动选择型号 |
useModelMigrationNotifications |
model-migration |
high | 活动模型已迁移/重命名 |
useNpmDeprecationNotification |
npm-deprecation |
high | 活动插件使用的 npm 包已被弃用 |
usePluginAutoupdateNotification |
plugin-autoupdate |
low | 插件在后台成功自动更新 |
usePluginInstallationStatus |
plugin-install-{name} |
medium | 插件安装成功或失败 |
useMcpConnectivityStatus |
mcp-connectivity |
medium | MCP 服务器连接丢失或恢复 |
useIDEStatusIndicator |
ide-status |
medium | IDE 连接更改(不是祝酒词 — 呈现内联) |
useInstallMessages |
install-msg-* |
low | 发布清单中的更新后安装说明 |
useTeammateShutdownNotification |
teammate-shutdown |
high | 同伴代理进程意外退出 |
useSettingsErrors |
settings-error-* |
high | 启动时设置验证错误 |
Notifications.tsx inline |
external-editor-hint |
immediate | 提示已包装+编辑器已配置; 条件明确后移除 |
Notifications.tsx inline |
env-hook |
medium / low | 文件更改的观察者钩子触发(错误=中,信息=低) |
工作示例:速率限制警告
useRateLimitWarningNotification uses useRef 消除警告 - 每个唯一的警告字符串仅触发一次(不是在每次渲染时):
// hooks/notifs/useRateLimitWarningNotification.tsx
const shownWarningRef = useRef<string | null>(null)
useEffect(() => {
if (getIsRemoteMode()) return
if (rateLimitWarning && rateLimitWarning !== shownWarningRef.current) {
shownWarningRef.current = rateLimitWarning
addNotification({
key: 'rate-limit-warning',
jsx: <Text><Text color="warning">{rateLimitWarning}</Text></Text>,
priority: 'high',
})
}
}, [rateLimitWarning, addNotification])
// Separate effect: when overage actually kicks in, immediate preempt
useEffect(() => {
if (claudeAiLimits.isUsingOverage && !hasShownOverageNotification) {
addNotification({
key: 'limit-reached',
text: usingOverageText,
priority: 'immediate',
})
setHasShownOverageNotification(true)
}
}, [claudeAiLimits.isUsingOverage, ...])
当 Claude 完成长时间运行的任务并且窗口处于后台时,in-REPL toast 是不可见的。 那就是那里 services/notifier.ts 进来:它将通知路由到终端的本机操作系统通知 API。
sendNotification:入口点
// services/notifier.ts
export type NotificationOptions = {
message: string
title?: string
notificationType: string
}
export async function sendNotification(
notif: NotificationOptions,
terminal: TerminalNotification,
): Promise<void> {
const config = getGlobalConfig()
const channel = config.preferredNotifChannel
await executeNotificationHooks(notif) // user hooks first
const methodUsed = await sendToChannel(channel, notif, terminal)
logEvent('tengu_notification_method_used', {
configured_channel: channel,
method_used: methodUsed,
term: env.terminal,
})
}
钩子系统运行 before 内置通道调度。 这让用户可以拦截通知并将其路由到 Slack、Discord 或任何其他系统,而无需修改核心代码。
通道路由
config.preferredNotifChannel 映射到这些调度分支之一:
switch (channel) {
case 'auto': return sendAuto(opts, terminal)
case 'iterm2': terminal.notifyITerm2(opts); return 'iterm2'
case 'iterm2_with_bell': terminal.notifyITerm2(opts); terminal.notifyBell(); return 'iterm2_with_bell'
case 'kitty': terminal.notifyKitty({ ...opts, id: generateKittyId() }); return 'kitty'
case 'ghostty': terminal.notifyGhostty(opts); return 'ghostty'
case 'terminal_bell': terminal.notifyBell(); return 'terminal_bell'
case 'notifications_disabled': return 'disabled'
}
The auto 分支检测到终端来自 env.terminal 并选择最佳可用方法。 对于 Apple 终端,它甚至读取
com.apple.Terminal plist 通过 osascript 回退前检查响铃是否被禁用:
case 'Apple_Terminal': {
const bellDisabled = await isAppleTerminalBellDisabled()
return bellDisabled
? (terminal.notifyBell(), 'terminal_bell')
: 'no_method_available'
}
case 'iTerm.app': terminal.notifyITerm2(opts); return 'iterm2'
case 'kitty': terminal.notifyKitty(...); return 'kitty'
case 'ghostty': terminal.notifyGhostty(...); return 'ghostty'
useTerminalNotification — OSC 转义序列
实际的有线协议位于 ink/useTerminalNotification.ts。 每个终端都有自己的通知转义序列标准:
// iTerm2: OSC 9 sequence
notifyITerm2({ message, title }) {
const display = title ? `${title}:\n\n${message}` : message
writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, `\n\n${display}`)))
}
// Kitty: three-step OSC 99 sequence (title, body, focus)
notifyKitty({ message, title, id }) {
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=0:p=title`, title)))
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:p=body`, message)))
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=1:a=focus`, '')))
}
// Ghostty: single OSC sequence
notifyGhostty({ message, title }) {
writeRaw(wrapForMultiplexer(osc(OSC.GHOSTTY, 'notify', title, message)))
}
// Universal fallback: raw BEL character (0x07)
// NOT wrapped — tmux needs bare BEL to trigger bell-action
notifyBell() { writeRaw(BEL) }
bell-action (窗口标志),这是所需的跨终端通知后备。
进度报告
终端通知挂钩还公开了一个 progress() 发送 OSC 9;4 任务进度序列的方法 — 由 ConEmu、Ghostty 1.2.0+ 和 iTerm2 3.6.6+ 支持。 这将驱动终端选项卡/任务栏中的进度指示器:
terminal.progress('running', 42) // 42% progress bar
terminal.progress('indeterminate') // spinning indicator
terminal.progress('error', 80) // error state at 80%
terminal.progress('completed') // clears the indicator
terminal.progress(null) // explicit clear
第三个单独的通知机制处理后台 bash 完成。 这些不是祝酒词——它们是 user 角色消息注入到与 a 的对话中 <task_notification> XML 标签。
utils/collapseBackgroundBashNotifications.ts 在渲染之前对消息列表进行后处理。 当出现多个连续完成的后台 bash 任务时,它们会折叠成一条合成消息:
// utils/collapseBackgroundBashNotifications.ts
export function collapseBackgroundBashNotifications(
messages: RenderableMessage[],
verbose: boolean,
): RenderableMessage[] {
if (!isFullscreenEnvEnabled()) return messages
if (verbose) return messages // ctrl+O shows each individually
// Scan for runs of completed bash tasks
while (isCompletedBackgroundBash(messages[i])) {
count++; i++;
}
if (count > 1) {
// Synthesize: "<task_notification>...N background commands completed...</task_notification>"
result.push(syntheticCollapsedMessage(count))
}
}
Only successful 完成已折叠(状态标签= completed)。 失败和终止的任务始终保留为单独的消息。 监视器类型的完成有自己的摘要前缀,也被排除在外。 详细模式(ctrl+O)完全绕过折叠,因此您可以审核每个完成情况。
第四种通知途径适用于功能标记的集成。
services/mcp/channelNotification.ts 实施
notifications/claude/channel MCP 扩展 — 它允许外部渠道(通过 MCP 服务器的 Discord、Slack、iMessage)将入站消息推送到对话中。
// The MCP server declares this capability to opt in:
capabilities.experimental['claude/channel']: {}
// Messages arrive via:
{
method: 'notifications/claude/channel',
params: {
content: "user message text",
meta: { chat_id: "123", user: "alice" } // arbitrary attrs
}
}
处理程序将内容包装在 <channel> XML 标记并将其排入队列。 睡眠工具民意调查 hasCommandsInQueue() 每秒都会唤醒代理。 模型查看源并决定调用哪个 MCP 工具来进行回复。
通道通知由 6 层安全检查控制
gateChannelServer():
Capability
服务器必须声明 experimental['claude/channel']
运行时标志
isChannelsEnabled() — GrowthBook 终止开关
Auth
需要 Claude.ai OAuth 令牌(API 密钥用户被阻止)
组织政策
团队/企业组织必须设置 channelsEnabled: true
会话选择加入
服务器必须位于 --channels 本次会议的标志
Allowlist
插件市场验证+批准插件账本检查
要点
- 两个管道满足不同的需求:用于交互状态的 toast 队列; 后台会话的操作系统通知程序。
- 队列是基于优先级的(立即/高/中/低),而不是 FIFO。
getNext()优先级线性降低。 immediate通知抢占当前显示并将移位的项目重新放入队列中(如果不是立即的)。- The
fold字段允许相同键的通知合并,例如Array.reduce()— 每次折叠都会重置超时。 - 模块级
currentTimeoutId(不是 React 状态)在即时通知到达时启用同步超时取消。 useStartupNotification抽象每个启动钩子需要的远程模式门和每会话一次的引用保护。- 操作系统通知程序根据以下条件分派至 iTerm2 / Kitty / Ghostty / BEL
config.preferredNotifChannelandenv.terminal。 BEL 是故意展开的,因此 tmux 可以在本机处理它。 - 后台 bash 完成在消息列表级别折叠(而不是 React 通知),以保持对话干净而不会丢失失败详细信息。
- MCP 通道通知 (Kairos) 在注册处理程序之前会经过 6 层安全门。
知识检查
immediate 通知到达时 high 当前正在显示通知。 高通知会发生什么?fold 场上 BaseNotification?0x07) 写入 TTY without 多路复用器包装器,而 iTerm2 OSC 序列被包装?collapseBackgroundBashNotifications 仅在一种情况下折叠后台 bash 完成 - 当 verbose 是假的。 还有什么一定是真的?gateChannelServer()?