Claude Code 源码分析第 30 课 · 第 04
第 30 课

通知系统

两个不同的管道 - 一个 in-REPL 烤面包机队列和一个操作系统级终端通知程序 - 以及为它们提供数据的每个钩子的完整目录。

01 Overview

Claude Code 有 两个完全独立的通知管道 它们服务于不同的目的并存在于代码库的不同层中:

管道1

In-REPL Toast 队列

状态消息显示在终端 UI 的页脚栏中。 管理人 context/notifications.tsx 并由渲染 Notifications.tsx.

管道2

操作系统/终端通知程序

本机桌面/终端通知(iTerm2、Kitty、Ghostty、bell)。 管理人 services/notifier.ts and ink/useTerminalNotification.ts.

覆盖源文件
context/notifications.tsxcomponents/PromptInput/Notifications.tsxhooks/notifs/*.tsxservices/notifier.tsink/useTerminalNotification.tsutils/collapseBackgroundBashNotifications.ts

这些管道永远不会交叉:toast 队列仅修改 React 状态并在 Ink UI 中呈现; 终端通知程序将原始 OSC/BEL 转义序列直接写入 TTY。 了解两者对于诊断通知在任何给定环境中出现(或不出现)的原因至关重要。

02 管道 1 — In-REPL Toast 队列

通知类型

每个吐司都是一个 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:完整的决策树

flowchart TD A["addNotification(notif)"] --> B{priority\n=== 'immediate'?} B -->|Yes| C["clearTimeout(currentTimeoutId)"] C --> D["Set new timeout for this notif\n(timeoutMs ?? 8000 ms)"] D --> E["setAppState: current = notif\nqueue = [prev current if not immediate,\n...prev queue]\n.filter(not immediate,\nnot in invalidates)"] E --> F["return early"] B -->|No| G{notif.fold\ndefined?} G -->|Yes| H{current.key\n=== notif.key?} H -->|Yes| I["fold(current, notif)\nreset timeout\nreturn folded as current"] H -->|No| J{queue has\nsame key?} J -->|Yes| K["fold(queue[i], notif)\nreplace in queue"] J -->|No| L{key already\nin queue\nor current?} G -->|No| L L -->|Yes, duplicate| M["return prev (no-op)"] L -->|No| N{notif.invalidates\ncontains current.key?} N -->|Yes| O["clearTimeout\ncurrent = null"] N -->|No| P["Keep current as-is"] O --> Q["append notif to queue\n(filtered of invalidated)"] P --> Q Q --> R["processQueue()"]

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])
实施细节
The setTimeout 回调接收 setAppState, nextKey, 和 processQueue 作为额外的参数而不是结束它们。 当组件在计时器触发和回调运行之间重新呈现时,这可以避免陈旧关闭错误。 关键比较(current?.key !== nextKey) 是针对同一类 bug 的第二个防护。
03 通知渲染组件

components/PromptInput/Notifications.tsx 是编排中心。 它位于 REPL 提示区域的页脚中。 它的工作是双重的:

  1. 运行所有 use*Notification 钩子,以便它们记录其效果。
  2. 渲染 非 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")
}
04 完整的 Hook 目录 — hooks/notifs/

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])
}
设计洞察
The 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, ...])
05 管道 2 — 操作系统/终端通知程序

当 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) }
为什么是wrapForMultiplexer而不是BEL?
大多数 OSC 序列都包含在 DCS 直通转义中,因此 tmux/screen 将它们中继到外部终端。 BEL是故意的 not 包裹 — 在 tmux 内部,裸露的 BEL 触发 tmux 自己的 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
06 后台任务通知折叠

第三个单独的通知机制处理后台 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)完全绕过折叠,因此您可以审核每个完成情况。

07 MCP 频道通知 (Kairos)

第四种通知途径适用于功能标记的集成。 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():

1号门

Capability

服务器必须声明 experimental['claude/channel']

2号门

运行时标志

isChannelsEnabled() — GrowthBook 终止开关

3号门

Auth

需要 Claude.ai OAuth 令牌(API 密钥用户被阻止)

4号门

组织政策

团队/企业组织必须设置 channelsEnabled: true

5号门

会话选择加入

服务器必须位于 --channels 本次会议的标志

6号登机口

Allowlist

插件市场验证+批准插件账本检查

08 完整架构图
graph TD subgraph "In-REPL Toast Queue" CTX["context/notifications.tsx\nuseNotifications()\naddNotification / removeNotification"] AS["AppState\n{ current: Notification|null\n queue: Notification[] }"] NR["PromptInput/Notifications.tsx\nOrchestrator + Renderer"] HOOKS["hooks/notifs/*.tsx\n14 specialized hooks"] CTX -- "setAppState()" --> AS AS -- "useAppState()" --> NR HOOKS -- "addNotification()" --> CTX NR -- "runs hooks\non mount" --> HOOKS end subgraph "OS/Terminal Notifier" SN["services/notifier.ts\nsendNotification()"] TN["ink/useTerminalNotification.ts\nwriteRaw() via context"] TTY["TTY stdout\nOSC / BEL escapes"] SN -- "channel dispatch" --> TN TN -- "OSC 9/99/BEL" --> TTY end subgraph "Background Tasks" CBN["utils/collapseBackgroundBashNotifications.ts"] MSG["Message list\n(pre-render)"] CBN -- "collapses run\nof completed\nbash tasks" --> MSG end subgraph "MCP Channel (Kairos)" MCN["services/mcp/channelNotification.ts"] Q["Command queue\nhasCommandsInQueue()"] MCN -- "wrapChannelMessage()\nenqueue" --> Q end NR -.->|"task done\n(long-running)"| SN

要点

  • 两个管道满足不同的需求:用于交互状态的 toast 队列; 后台会话的操作系统通知程序。
  • 队列是基于优先级的(立即/高/中/低),而不是 FIFO。 getNext() 优先级线性降低。
  • immediate 通知抢占当前显示并将移位的项目重新放入队列中(如果不是立即的)。
  • The fold 字段允许相同键的通知合并,例如 Array.reduce() — 每次折叠都会重置超时。
  • 模块级 currentTimeoutId (不是 React 状态)在即时通知到达时启用同步超时取消。
  • useStartupNotification 抽象每个启动钩子需要的远程模式门和每会话一次的引用保护。
  • 操作系统通知程序根据以下条件分派至 iTerm2 / Kitty / Ghostty / BEL config.preferredNotifChannel and env.terminal。 BEL 是故意展开的,因此 tmux 可以在本机处理它。
  • 后台 bash 完成在消息列表级别折叠(而不是 React 通知),以保持对话干净而不会丢失失败详细信息。
  • MCP 通道通知 (Kairos) 在注册处理程序之前会经过 6 层安全门。

知识检查

Q1. An immediate 通知到达时 high 当前正在显示通知。 高通知会发生什么?
Q2. 目的是什么 fold 场上 BaseNotification?
Q3. 为什么是BEL(0x07) 写入 TTY without 多路复用器包装器,而 iTerm2 OSC 序列被包装?
Q4. collapseBackgroundBashNotifications 仅在一种情况下折叠后台 bash 完成 - 当 verbose 是假的。 还有什么一定是真的?
Q5. 以下哪一个不是 6 个门之一 gateChannelServer()?