3.9 成本预警 — 退出钩子、$5 阈值对话框与多通道成本通知

3.9 成本预警 --- 退出钩子、$5 阈值对话框与多通道成本通知

对应原书 :第3章 第3.10.2节 tokenBudget.ts(预算决策引擎)及 3.3.3 节(循环不变式)中的成本控制上下文

辅助源码costHook.ts(22行)、utils/billing.ts(78行)、components/CostThresholdDialog.tsx(49行)、commands/cost/cost.ts(24行)、hooks/notifs/useRateLimitWarningNotification.tsx(113行)、services/rateLimitMessages.ts(345行)、screens/REPL.tsx(成本对话框与会话切换部分)、utils/fpsTracker.ts(FPS 指标)、utils/warningHandler.ts(122行)、utils/envUtils.ts(环境变量工具)

重点关注:成本 Hook 的退出时摘要打印与持久化、$5 阈值对话框的触发与确认机制、双轨 Billing Access 控制(Console vs Claude.ai)、订阅用户速率限制预警通知


1. 导语:从"记账"到"预警"的成本感知闭环

在 3.8 中,我们分析了成本控制的"四层架构"------定价层、追踪层、预算决策层、终止保护层。那套体系解决了"如何精确记账"和"何时强制停止"的问题。但仅有记账和终止是不够的------用户还需要主动感知当前的花费状态。

Claude Code 的成本预警系统通过三个独立通道实现这种感知:

通道 触发时机 目标用户 表现形式
退出摘要 进程退出时 API Key 用户(非订阅) stdout 打印成本明细
$5 阈值对话框 会话累计成本 ≥ $5 时 API Key 用户(有 billing 权限) 模态对话框,需用户确认
速率限制预警 订阅用户接近配额上限时 Claude.ai 订阅用户 通知栏 + 状态栏文字

这三个通道覆盖了两种完全不同的用户群体:API Key 用户 关心的是"这个会话花了多少美元",而订阅用户 关心的是"我的 5 小时/7 天配额还剩多少"。两套体系完全独立,通过 hasConsoleBillingAccess()hasClaudeAiBillingAccess() 两个函数实现分流。

核心设计原则:成本信息不是"一刀切"地展示给所有人,而是根据用户身份、权限角色、订阅类型进行精细化分流。这避免了订阅用户看到不相关的美元金额(他们按配额而非按量付费),也避免了 API Key 用户看到不相关的配额信息。


2. 成本预警全景:三通道架构

复制代码
                    ┌─────────────────────────────────────────────┐
                    │            成本预警三通道架构                  │
                    └─────────────────────────────────────────────┘
                                    │
                ┌───────────────────┼───────────────────┐
                │                   │                   │
        ┌───────▼───────┐  ┌───────▼───────┐  ┌───────▼───────┐
        │   退出摘要     │  │  $5 阈值对话框  │  │  速率限制预警  │
        │  costHook.ts  │  │ CostThreshold  │  │ useRateLimit  │
        │               │  │   Dialog.tsx   │  │ WarningNotif  │
        └───────┬───────┘  └───────┬───────┘  └───────┬───────┘
                │                   │                   │
     process.on('exit')     useEffect 监听        useEffect 监听
                │           messages 变化         claudeAiLimits
                │                   │                   │
        ┌───────▼───────┐  ┌───────▼───────┐  ┌───────▼───────┐
        │ hasConsole    │  │ hasConsole    │  │ hasClaudeAi   │
        │ BillingAccess │  │ BillingAccess │  │ BillingAccess │
        │     () ?      │  │     () ?      │  │     () ?      │
        └───────┬───────┘  └───────┬───────┘  └───────┬───────┘
                │                   │                   │
        API Key 用户           API Key 用户          订阅用户
        (非订阅)               (admin/billing)       (Max/Pro/Team)
                │                   │                   │
        ┌───────▼───────┐  ┌───────▼───────┐  ┌───────▼───────┐
        │ formatTotal   │  │ "You've spent │  │ 5h/7d/overage │
        │ Cost() 打印   │  │  $5 on the    │  │ 配额预警通知   │
        │ + saveCosts   │  │  API..."      │  │ + overage 通知│
        └───────────────┘  └───────────────┘  └───────────────┘

三个通道完全正交:一个用户只会命中其中一个或两个,永远不会同时命中全部三个。这种正交性由 billing.ts 中的两个访问控制函数保证。


3. 退出钩子:costHook.ts 完整解析

3.1 完整源码

typescript 复制代码
// costHook.ts --- 完整 22 行
import { useEffect } from 'react'
import { formatTotalCost, saveCurrentSessionCosts } from './cost-tracker.js'
import { hasConsoleBillingAccess } from './utils/billing.js'
import type { FpsMetrics } from './utils/fpsTracker.js'

export function useCostSummary(
  getFpsMetrics?: () => FpsMetrics | undefined,
): void {
  useEffect(() => {
    const f = () => {
      if (hasConsoleBillingAccess()) {
        process.stdout.write('\n' + formatTotalCost() + '\n')
      }

      saveCurrentSessionCosts(getFpsMetrics?.())
    }
    process.on('exit', f)
    return () => {
      process.off('exit', f)
    }
  }, [])
}

3.2 逐行解析

第1行import { useEffect } from 'react'

costHook.ts 是一个 React Hook,不是普通的 Node.js 模块。它通过 useEffect 在组件挂载时注册退出监听器,在组件卸载时移除。这保证了:

  • 多次调用不会重复注册(useEffect 依赖数组为空 []
  • 组件卸载时自动清理(返回的 cleanup 函数调用 process.off

第2-4行:导入三个依赖

导入 来源 职责
formatTotalCost cost-tracker.ts 生成格式化的成本摘要字符串
saveCurrentSessionCosts cost-tracker.ts 保存 15 个字段到项目配置
hasConsoleBillingAccess utils/billing.ts 检查用户是否有控制台成本展示权限
FpsMetrics utils/fpsTracker.ts FPS 性能指标类型

第6-8行:函数签名

typescript 复制代码
export function useCostSummary(
  getFpsMetrics?: () => FpsMetrics | undefined,
): void

接收一个可选的 FPS 指标获取函数 。注意它是一个 getter 函数(() => FpsMetrics),而不是直接传入 FpsMetrics 值。这是因为退出时的 FPS 指标需要实时获取,而不是使用挂载时的快照。

第9-16行:核心逻辑

typescript 复制代码
const f = () => {
  if (hasConsoleBillingAccess()) {
    process.stdout.write('\n' + formatTotalCost() + '\n')
  }
  saveCurrentSessionCosts(getFpsMetrics?.())
}

退出回调 f 做两件事:

  1. 有条件地打印成本摘要 :只有 hasConsoleBillingAccess() 返回 true 时才打印。这意味着订阅用户、未认证用户、没有 admin/billing 角色的用户都看不到成本摘要。

  2. 无条件地保存成本数据 :无论是否有 billing access,都会调用 saveCurrentSessionCosts 保存成本数据到项目配置。这确保了即使当前用户看不到成本,切换会话后恢复成本状态仍然有效。

第17-20行:注册与清理

typescript 复制代码
process.on('exit', f)
return () => {
  process.off('exit', f)
}

使用 Node.js 的 process.on('exit', ...) 注册同步退出钩子。注意:

  • exit 事件的回调必须是同步的------不支持 async 操作
  • cleanup 函数通过 process.off('exit', f) 移除监听器
  • 依赖数组 [] 确保只在组件挂载/卸载时执行一次

3.3 调用点:REPL.tsx

typescript 复制代码
// REPL.tsx 第3822-3823行
// Register cost summary tracker
useCostSummary(useFpsMetrics());

useFpsMetrics() 是一个 React Context Hook,返回一个 () => FpsMetrics | undefined 函数。这个函数在退出时被调用,获取最终的 FPS 指标。

设计决策 :为什么用 React Hook 而不是直接在 main.tsx 中注册?

因为 useCostSummary 需要访问 React Context 中的 FPS 指标。如果放在 main.tsx 中,就需要通过其他方式传递 FPS 数据。通过 React Hook,可以自然地接入组件树中的 Context,同时利用 useEffect 的生命周期管理自动处理注册和清理。


4. $5 阈值对话框:CostThresholdDialog

4.1 触发机制

typescript 复制代码
// REPL.tsx 第1480行
const [showCostDialog, setShowCostDialog] = useState(false);

// REPL.tsx 第1506行 --- 从全局配置读取是否已确认过
const [haveShownCostDialog, setHaveShownCostDialog] = 
  useState(getGlobalConfig().hasAcknowledgedCostThreshold);

// REPL.tsx 第2203-2215行 --- 触发逻辑
useEffect(() => {
  const totalCost = getTotalCost();
  if (totalCost >= 5 /* $5 */ && !showCostDialog && !haveShownCostDialog) {
    logEvent('tengu_cost_threshold_reached', {});
    // Mark as shown even if the dialog won't render (no console billing
    // access). Otherwise this effect re-fires on every message change for
    // the rest of the session --- 200k+ spurious events observed.
    setHaveShownCostDialog(true);
    if (hasConsoleBillingAccess()) {
      setShowCostDialog(true);
    }
  }
}, [messages, showCostDialog, haveShownCostDialog]);

触发条件(全部满足):

  1. totalCost >= 5 --- 会话累计成本达到 $5
  2. !showCostDialog --- 对话框当前未显示
  3. !haveShownCostDialog --- 本会话(或全局)未已确认

关键防抖设计

注释中提到的 bug 值得注意------即使 hasConsoleBillingAccess() 返回 false(用户看不到对话框),也必须调用 setHaveShownCostDialog(true)。否则这个 useEffect 会在每次 messages 变化时重新触发,导致 20 万+ 次冗余的 analytics 事件

这是一个典型的 React + analytics 陷阱:useEffect 的依赖 messages 在每轮对话中都会变化,如果不在第一次触发时就标记为"已处理",就会无限重复触发。

4.2 对话框组件

typescript 复制代码
// CostThresholdDialog.tsx --- 编译前源码(从 sourcemap 还原)
export function CostThresholdDialog({ onDone }: Props): React.ReactNode {
  return (
    <Dialog
      title="You've spent $5 on the Anthropic API this session."
      onCancel={onDone}
    >
      <Box flexDirection="column">
        <Text>Learn more about how to monitor your spending:</Text>
        <Link url="https://code.claude.com/docs/en/costs" />
      </Box>
      <Select
        options={[{
          value: 'ok',
          label: 'Got it, thanks!',
        }]}
        onChange={onDone}
      />
    </Dialog>
  )
}

极简设计------一个标题、一个文档链接、一个确认按钮。没有"停止会话"或"调整预算"的选项。这个对话框的目的是提醒 ,不是干预

4.3 确认回调

typescript 复制代码
// REPL.tsx 第4750-4758行
{focusedInputDialog === 'cost' && <CostThresholdDialog onDone={() => {
  setShowCostDialog(false);
  setHaveShownCostDialog(true);
  saveGlobalConfig(current => ({
    ...current,
    hasAcknowledgedCostThreshold: true  // 持久化到全局配置
  }));
  logEvent('tengu_cost_threshold_acknowledged', {});
}} />}

确认时做四件事:

  1. 关闭对话框(setShowCostDialog(false)
  2. 标记已显示(setHaveShownCostDialog(true)
  3. 持久化到全局配置hasAcknowledgedCostThreshold: true
  4. 上报 analytics 事件

关键设计hasAcknowledgedCostThreshold 被保存到全局配置(~/.claude.json),这意味着用户只需要确认一次------以后所有新会话都不会再弹出这个对话框。这是一个"一次性教育"模式:提醒用户关注成本,确认后不再打扰。

4.4 对话框优先级

typescript 复制代码
// REPL.tsx 第2035行
if (allowDialogsWithAnimation && showingCostDialog) return 'cost';

成本对话框在对话框优先级链中的位置(从高到低):

复制代码
tool-permission > prompt > worker-sandbox-permission > elicitation 
> cost > idle-return > ultraplan-choice > ultraplan-launch > ...

成本对话框的优先级中等------高于 idle-return 和 ultraplan,但低于权限请求和用户输入提示。这意味着如果同时有权限请求弹出,成本对话框会被推迟。

4.5 加载状态保护

typescript 复制代码
// REPL.tsx 第2010-2011行
const showingCostDialog = !isLoading && showCostDialog;

成本对话框不会在加载/处理中显示 。即使用户已经达到 $5 阈值,也要等到当前查询完成(isLoading 变为 false)后才弹出。这避免了打断正在进行的操作。


5. 双轨 Billing Access:Console vs Claude.ai

成本预警系统的核心分流逻辑在 utils/billing.ts 中,定义了两个独立的访问控制函数。

5.1 hasConsoleBillingAccess --- 控制台成本展示

typescript 复制代码
// utils/billing.ts 第10-44行
export function hasConsoleBillingAccess(): boolean {
  // 1. 环境变量全局禁用
  if (isEnvTruthy(process.env.DISABLE_COST_WARNINGS)) {
    return false
  }

  // 2. 订阅用户不显示控制台成本(他们按配额付费)
  const isSubscriber = isClaudeAISubscriber()
  if (isSubscriber) return false

  // 3. 未认证用户不显示
  const authSource = getAuthTokenSource()
  const hasApiKey = getAnthropicApiKey() !== null
  if (!authSource.hasToken && !hasApiKey) {
    return false
  }

  // 4. 没有 org/workspace 角色的老用户不显示
  const config = getGlobalConfig()
  const orgRole = config.oauthAccount?.organizationRole
  const workspaceRole = config.oauthAccount?.workspaceRole
  if (!orgRole || !workspaceRole) {
    return false
  }

  // 5. 只有 admin/billing 角色才显示
  return (
    ['admin', 'billing'].includes(orgRole) ||
    ['workspace_admin', 'workspace_billing'].includes(workspaceRole)
  )
}

四层过滤

条件 返回 说明
1 DISABLE_COST_WARNINGS 为 truthy false 全局静默开关
2 用户是 Claude.ai 订阅者 false 订阅用户按配额付费,不看美元
3 无任何认证(无 token + 无 API key) false 未登录用户不显示
4 无 org/workspace 角色 false 老用户未重新认证
5 角色 ∈ {admin, billing, workspace_admin, workspace_billing} true 只有管理/计费角色可见

5.2 hasClaudeAiBillingAccess --- 订阅用户计费展示

typescript 复制代码
// utils/billing.ts 第53-78行
export function hasClaudeAiBillingAccess(): boolean {
  // 测试 mock
  if (mockBillingAccessOverride !== null) {
    return mockBillingAccessOverride
  }

  // 非订阅用户直接返回 false
  if (!isClaudeAISubscriber()) {
    return false
  }

  const subscriptionType = getSubscriptionType()

  // Consumer 计划(Max/Pro)--- 个人用户始终有 billing 权限
  if (subscriptionType === 'max' || subscriptionType === 'pro') {
    return true
  }

  // Team/Enterprise --- 需要 admin/billing/owner/primary_owner 角色
  const config = getGlobalConfig()
  const orgRole = config.oauthAccount?.organizationRole
  return (
    !!orgRole &&
    ['admin', 'billing', 'owner', 'primary_owner'].includes(orgRole)
  )
}

与 Console 版本的关键差异

维度 hasConsoleBillingAccess hasClaudeAiBillingAccess
目标用户 API Key 用户 Claude.ai 订阅用户
订阅用户 返回 false 返回 true(有权限时)
Max/Pro 用户 返回 false 返回 true
Team/Enterprise 需要 admin/billing 需要 admin/billing/owner/primary_owner
环境变量 DISABLE_COST_WARNINGS 控制 不受控制
Mock 支持 mockBillingAccessOverride

5.3 互斥关系

复制代码
                    用户身份判断
                         │
              ┌──────────┴──────────┐
              │                     │
        isClaudeAISubscriber    非订阅用户
         = true                  │
              │                     │
    ┌─────────▼─────────┐         │
    │                   │         │
  hasClaudeAi       hasConsole
  BillingAccess     BillingAccess
  = true/false      = false (被第2层拦截)
    │                   │
    │              ┌────▼────┐
    │              │ 有API Key│
    │              │ + 角色?  │
    │              └────┬────┘
    │                   │
    │              hasConsole
    │              BillingAccess
    │              = true/false
    │
    ▼
  显示速率限制
  预警 + overage

两个函数形成互斥 关系:hasConsoleBillingAccess 对订阅用户返回 false,而 hasClaudeAiBillingAccess 对非订阅用户返回 false。这确保了一个用户只会看到一种类型的成本信息。


6. DISABLE_COST_WARNINGS:全局静默开关

6.1 isEnvTruthy 函数

typescript 复制代码
// utils/envUtils.ts 第32-37行
export function isEnvTruthy(envVar: string | boolean | undefined): boolean {
  if (!envVar) return false
  if (typeof envVar === 'boolean') return envVar
  const normalizedValue = envVar.toLowerCase().trim()
  return ['1', 'true', 'yes', 'on'].includes(normalizedValue)
}

接受以下值(不区分大小写)为 truthy:'1''true''yes''on'。其他所有值(包括 '0''false'、空字符串、undefined)均为 falsy。

6.2 受控环境变量列表

typescript 复制代码
// utils/managedEnvConstants.ts 第151-156行
'DISABLE_AUTOUPDATER',
'DISABLE_BUG_COMMAND',
'DISABLE_COST_WARNINGS',     // ← 成本预警开关
'DISABLE_ERROR_REPORTING',
'DISABLE_FEEDBACK_COMMAND',
'DISABLE_TELEMETRY',

DISABLE_COST_WARNINGS 被列入 managedEnvConstants.ts 的受控环境变量列表。这个列表的作用是允许企业管理员通过远程管理设置(managed settings)统一控制这些环境变量,而不需要用户手动设置。

6.3 影响范围

DISABLE_COST_WARNINGS 只影响 hasConsoleBillingAccess() 的返回值,进而影响:

受影响功能 说明
退出时成本摘要打印 costHook.ts 中的 formatTotalCost() 调用
$5 阈值对话框显示 REPL.tsx 中的 setShowCostDialog(true)

不受影响的功能:

  • saveCurrentSessionCosts() --- 无论是否禁用警告,成本数据仍然保存
  • hasClaudeAiBillingAccess() --- 订阅用户的速率限制预警不受影响
  • /cost 命令 --- 仍然可用,只是 API Key 用户的输出受 billing access 控制

7. /cost 命令:按用户类型分流展示

typescript 复制代码
// commands/cost/cost.ts --- 完整 24 行
import { formatTotalCost } from '../../cost-tracker.js'
import { currentLimits } from '../../services/claudeAiLimits.js'
import type { LocalCommandCall } from '../../types/command.js'
import { isClaudeAISubscriber } from '../../utils/auth.js'

export const call: LocalCommandCall = async () => {
  if (isClaudeAISubscriber()) {
    let value: string

    if (currentLimits.isUsingOverage) {
      value =
        'You are currently using your overages to power your Claude Code usage. '
        + 'We will automatically switch you back to your subscription rate limits '
        + 'when they reset'
    } else {
      value =
        'You are currently using your subscription to power your Claude Code usage'
    }

    // [ANT-ONLY] 内部用户仍然显示完整成本
    if (process.env.USER_TYPE === 'ant') {
      value += `\n\n[ANT-ONLY] Showing cost anyway:\n ${formatTotalCost()}`
    }
    return { type: 'text', value }
  }
  return { type: 'text', value: formatTotalCost() }
}

7.1 三种输出模式

用户类型 isUsingOverage 输出
API Key 用户 N/A formatTotalCost() --- 完整成本明细
订阅用户 false "You are currently using your subscription..."
订阅用户 true "You are currently using your overages..."
内部用户(USER_TYPE === 'ant' 任意 订阅信息 + [ANT-ONLY] 完整成本

7.2 设计意图

订阅用户通过 /cost 看到的是配额状态 而非美元金额 。这是因为订阅用户按月付费,关心的是"我的配额还够用吗"而不是"这次会话花了多少钱"。currentLimits.isUsingOverage 来自 claudeAiLimits.ts,是一个全局可变状态,记录当前是否处于超额使用模式。


8. 订阅用户速率限制预警:useRateLimitWarningNotification

8.1 Hook 概览

typescript 复制代码
// hooks/notifs/useRateLimitWarningNotification.tsx
export function useRateLimitWarningNotification(model) {
  const { addNotification } = useNotifications();
  const claudeAiLimits = useClaudeAiLimits();
  
  const rateLimitWarning = getRateLimitWarning(claudeAiLimits, model);
  const usingOverageText = getUsingOverageText(claudeAiLimits);
  
  // ... 两个独立的 useEffect
}

这个 Hook 为订阅用户提供两种通知:

  1. 速率限制预警 --- 配额接近上限时的提前警告
  2. Overage 模式通知 --- 从订阅配额切换到超额使用时的通知

8.2 速率限制预警:五小时/七天窗口

typescript 复制代码
// services/claudeAiLimits.ts 第53-70行
const EARLY_WARNING_CONFIGS: EarlyWarningConfig[] = [
  {
    rateLimitType: 'five_hour',
    claimAbbrev: '5h',
    windowSeconds: 5 * 60 * 60,        // 5小时
    thresholds: [{ utilization: 0.9, timePct: 0.72 }],
    // 当使用量 ≥ 90% 且时间进度 ≤ 72% 时触发
  },
  {
    rateLimitType: 'seven_day',
    claimAbbrev: '7d',
    windowSeconds: 7 * 24 * 60 * 60,   // 7天
    thresholds: [
      { utilization: 0.75, timePct: 0.6 },
      { utilization: 0.5, timePct: 0.35 },
      { utilization: 0.25, timePct: 0.15 },
    ],
  },
]

预警逻辑 :当配额使用率超过阈值 时间窗口已过比例低于阈值时触发。这意味着用户"花钱太快了"------如果在 5 小时窗口的 72% 时间点(3.6小时)就用掉了 90% 的配额,就会收到警告。

7 天窗口有三个阈值层次:

  • 25% 配额 + 15% 时间 → 早期提醒
  • 50% 配额 + 35% 时间 → 中期警告
  • 75% 配额 + 60% 时间 → 紧急警告

8.3 通知优先级

typescript 复制代码
// Overage 通知
addNotification({
  key: "limit-reached",
  text: usingOverageText,
  priority: "immediate"    // 最高优先级
});

// 速率限制预警
addNotification({
  key: "rate-limit-warning",
  jsx: <Text><Text color="warning">{rateLimitWarning}</Text></Text>,
  priority: "high"         // 高优先级
});

两种通知的优先级不同------overage 通知为 immediate(立即显示),速率限制预警为 high(高优先级但可排队)。

8.4 远程模式排除

typescript 复制代码
if (getIsRemoteMode()) {
  return;
}

远程模式(通过 claude.ai 网页版使用)下不显示这些通知,因为远程 UI 有自己的通知机制。

8.5 Team/Enterprise 过滤

typescript 复制代码
const isTeamOrEnterprise = subscriptionType === "team" || subscriptionType === "enterprise";

// Overage 通知只对有 billing access 的 Team/Enterprise 用户显示
if (claudeAiLimits.isUsingOverage && !hasShownOverageNotification 
    && (!isTeamOrEnterprise || hasBillingAccess)) {
  addNotification({ ... });
}

Team/Enterprise 用户的 overage 通知需要 hasClaudeAiBillingAccess() 返回 true(即 admin/billing/owner/primary_owner 角色)。普通成员看不到 overage 通知------因为他们无法控制是否启用超额使用。


9. Overage 模式通知:从订阅到超额的平滑过渡

9.1 三态配额状态

typescript 复制代码
type QuotaStatus = 'allowed' | 'allowed_warning' | 'rejected'
状态 含义 用户感知
allowed 配额充足 无通知
allowed_warning 接近上限 预警通知
rejected 配额耗尽 错误消息 + 可能切换到 overage

9.2 五种速率限制类型

typescript 复制代码
type RateLimitType =
  | 'five_hour'         // 5小时会话限制
  | 'seven_day'         // 7天周限制
  | 'seven_day_opus'    // 7天 Opus 专用限制
  | 'seven_day_sonnet'  // 7天 Sonnet 专用限制
  | 'overage'           // 超额使用限制

9.3 Overage 通知文本

typescript 复制代码
// services/rateLimitMessages.ts 第303-331行
export function getUsingOverageText(limits: ClaudeAILimits): string {
  const resetTime = limits.resetsAt
    ? formatResetTime(limits.resetsAt, true)
    : ''

  let limitName = ''
  if (limits.rateLimitType === 'five_hour') {
    limitName = 'session limit'
  } else if (limits.rateLimitType === 'seven_day') {
    limitName = 'weekly limit'
  } else if (limits.rateLimitType === 'seven_day_opus') {
    limitName = 'Opus limit'
  } else if (limits.rateLimitType === 'seven_day_sonnet') {
    limitName = 'Sonnet limit'
  }

  if (!limitName) {
    return 'Now using extra usage'
  }

  const resetMessage = resetTime
    ? ` · Your ${limitName} resets ${resetTime}`
    : ''
  return `You're now using extra usage${resetMessage}`
}

设计要点

  • 通知文本包含重置时间,让用户知道何时恢复正常
  • 不同限制类型有不同的名称展示(session/weekly/Opus/Sonnet)
  • 通用兜底文本为 "Now using extra usage"

9.4 接近超额上限的警告

typescript 复制代码
// services/rateLimitMessages.ts 第51-57行
if (limits.isUsingOverage) {
  if (limits.overageStatus === 'allowed_warning') {
    return {
      message: "You're close to your extra usage spending limit",
      severity: 'warning',
    }
  }
}

当用户处于 overage 模式 overage 配额也接近上限时,会显示额外的警告消息。这是成本预警的"最后一道防线"------之后就是 rejected 状态,用户被完全阻止。


10. StatusLine 实时成本暴露

typescript 复制代码
// components/StatusLine.tsx 第83-89行
cost: {
  total_cost_usd: getTotalCost(),
  total_duration_ms: getTotalDuration(),
  total_api_duration_ms: getTotalAPIDuration(),
  total_lines_added: getTotalLinesAdded(),
  total_lines_removed: getTotalLinesRemoved()
},

StatusLine 组件将成本数据暴露为结构化字段,供外部消费者(如 IDE 集成、状态栏插件)使用。注意这里没有 billing access 检查------StatusLine 的成本数据始终被收集,只是展示与否由消费者决定。

getTotalCost()getTotalCostUSD 的重导出别名:

typescript 复制代码
// cost-tracker.ts 第49-50行
export {
  getTotalCostUSD as getTotalCost,
  // ...
}

11. 会话切换成本保存与恢复流程

当用户通过 /resume/branch 切换会话时,成本状态需要完整保存和恢复。这个流程在 REPL.tsx 中实现:

typescript 复制代码
// REPL.tsx 第1833-1841行
// 1. 先读取目标会话的成本(因为 save 会覆盖配置)
const targetSessionCosts = getStoredSessionCosts(sessionId);

// 2. 保存当前会话的成本
saveCurrentSessionCosts();

// 3. 重置成本状态
resetCostState();

// 4. 恢复目标会话的成本(如果有匹配的 sessionId)
// → 通过 setCostStateForRestore() 在后续流程中完成

四步流程的必要性

复制代码
步骤1: getStoredSessionCosts(targetId)
  → 读取项目配置中的 lastSessionId
  → 如果 lastSessionId === targetId,返回存储的成本
  → 否则返回 undefined

步骤2: saveCurrentSessionCosts()
  → 将当前 STATE 中的成本写入项目配置
  → ⚠️ 这会覆盖步骤1读取的数据!
  → 所以必须先执行步骤1

步骤3: resetCostState()
  → 清空 STATE.totalCostUSD、modelUsage 等
  → 为恢复目标会话成本准备干净的 slate

步骤4: restoreCostStateForSession(targetId)
  → 使用步骤1获取的 targetSessionCosts
  → 通过 setCostStateForRestore() 恢复到 STATE

11.1 saveCurrentSessionCosts 保存的 15 个字段

typescript 复制代码
// cost-tracker.ts 第143-175行
export function saveCurrentSessionCosts(fpsMetrics?: FpsMetrics): void {
  saveCurrentProjectConfig(current => ({
    ...current,
    lastCost: getTotalCostUSD(),                              // 1. 总美元成本
    lastAPIDuration: getTotalAPIDuration(),                   // 2. API 调用总时长
    lastAPIDurationWithoutRetries: getTotalAPIDurationWithoutRetries(), // 3. 无重试时长
    lastToolDuration: getTotalToolDuration(),                  // 4. 工具执行时长
    lastDuration: getTotalDuration(),                          // 5. 墙钟总时长
    lastLinesAdded: getTotalLinesAdded(),                      // 6. 新增行数
    lastLinesRemoved: getTotalLinesRemoved(),                  // 7. 删除行数
    lastTotalInputTokens: getTotalInputTokens(),               // 8. 输入 token
    lastTotalOutputTokens: getTotalOutputTokens(),             // 9. 输出 token
    lastTotalCacheCreationInputTokens: getTotalCacheCreationInputTokens(), // 10. 缓存创建
    lastTotalCacheReadInputTokens: getTotalCacheReadInputTokens(),         // 11. 缓存读取
    lastTotalWebSearchRequests: getTotalWebSearchRequests(),   // 12. Web 搜索次数
    lastFpsAverage: fpsMetrics?.averageFps,                    // 13. 平均 FPS
    lastFpsLow1PctFps: fpsMetrics?.low1PctFps,                 // 14. 最低 1% FPS
    lastModelUsage: Object.fromEntries(/* ... */),             // 15. 按模型分拆用量
    lastSessionId: getSessionId(),                             // 16. 会话 ID(用于匹配)
  }))
}

11.2 恢复时的 sessionId 匹配

typescript 复制代码
// cost-tracker.ts 第87-96行
export function getStoredSessionCosts(
  sessionId: string,
): StoredCostState | undefined {
  const projectConfig = getCurrentProjectConfig()

  // 只有当存储的 lastSessionId 与请求的 sessionId 匹配时才返回
  if (projectConfig.lastSessionId !== sessionId) {
    return undefined
  }
  // ... 构建并返回 StoredCostState
}

匹配逻辑 :如果用户先在会话 A 中工作(成本已保存),然后打开会话 B(成本覆盖了 A 的数据),再切回会话 A 时,lastSessionId 已经是 B,不匹配 A,因此返回 undefined------会话 A 的成本丢失

这是一个已知的设计限制 :项目配置中只存储最后一个会话的成本数据。这不是 bug,而是在单文件配置约束下的合理折衷------如果要支持多会话成本独立存储,需要引入独立的成本存储文件。


12. FPS 指标与成本的联合持久化

12.1 FpsMetrics 类型

typescript 复制代码
// utils/fpsTracker.ts 第1-4行
export type FpsMetrics = {
  averageFps: number    // 平均帧率
  low1PctFps: number    // 最低 1% 帧率
}

12.2 为什么成本保存需要 FPS?

FPS 指标与成本一起保存到项目配置(lastFpsAveragelastFpsLow1PctFps),用于:

  • 性能回归检测:对比不同会话的 FPS 数据,发现渲染性能退化
  • 成本-性能关联分析:高成本会话是否伴随低 FPS(可能是渲染负载过重)
  • 用户体验监控:低 FPS 意味着卡顿,可能影响用户对成本价值的感知

12.3 获取时机

typescript 复制代码
// costHook.ts 第15行
saveCurrentSessionCosts(getFpsMetrics?.())

FPS 指标在进程退出时获取------这是最终的性能快照,反映了整个会话的渲染性能。


13. 进程退出钩子全景:costHook 在生态中的位置

process.on('exit', ...) 在 Claude Code 中被多处使用,costHook 是其中之一。

13.1 所有退出钩子一览

文件 职责 同步/异步
costHook.ts 打印成本摘要 + 保存成本数据 同步
main.tsx resetCursor() --- 恢复终端光标 同步
context/stats.tsx flush() --- 保存会话指标到配置 同步
utils/telemetry/instrumentation.ts forceFlush() --- 刷新日志和追踪 同步
utils/telemetry/perfettoTracing.ts 写入 Perfetto 追踪文件 同步
services/analytics/growthbook.ts client?.destroy() --- 销毁 GrowthBook 客户端 同步
services/lsp/LSPClient.ts LSP 客户端清理 同步
utils/nativeInstaller/pidLock.ts PID 锁文件清理 同步

13.2 执行顺序

Node.js 的 exit 事件回调按注册顺序同步执行。costHook 通过 React useEffect 在 REPL 组件挂载时注册,通常在 main.tsxresetCursor 之后、telemetry forceFlush 之前。

13.3 同步约束

process.on('exit', ...) 的回调必须是同步的。这意味着:

  • 不能使用 async/await
  • 不能使用 Promise
  • 不能使用 fs.writeFile(异步),但可以使用 fs.writeFileSync(同步)

saveCurrentSessionCosts 内部使用 saveCurrentProjectConfig,后者最终使用同步文件写入。这确保了在进程退出时成本数据能够可靠保存。

13.4 与 warningHandler 的对比

typescript 复制代码
// utils/warningHandler.ts --- 全局警告处理器
export function initializeWarningHandler(): void {
  // 移除默认的 Node.js 警告处理器
  process.removeAllListeners('warning')
  
  // 安装自定义处理器
  warningHandler = (warning: Error) => {
    // 上报到 analytics
    logEvent('tengu_node_warning', { ... })
    // 对用户隐藏所有警告
  }
  process.on('warning', warningHandler)
}

warningHandler 监听的是 process.on('warning', ...)(Node.js 警告事件),而 costHook 监听的是 process.on('exit', ...)(进程退出事件)。两者完全独立,但都体现了 Claude Code "拦截系统事件 → 上报到 analytics → 对用户透明" 的设计模式。


14. 设计模式提炼

模式一:条件展示 + 无条件保存

typescript 复制代码
// costHook.ts
const f = () => {
  if (hasConsoleBillingAccess()) {       // 条件展示
    process.stdout.write(formatTotalCost())
  }
  saveCurrentSessionCosts(...)            // 无条件保存
}

模式:信息的"展示"和"保存"分离。展示受权限控制,保存始终执行。

好处

  • 即使用户看不到成本(如订阅用户),成本数据仍然被记录
  • 会话切换时的成本恢复不依赖于当前用户的 billing access
  • 简化了权限逻辑------只需要在展示层做一次检查

模式二:一次性教育 + 持久化确认

typescript 复制代码
// REPL.tsx
saveGlobalConfig(current => ({
  ...current,
  hasAcknowledgedCostThreshold: true  // 全局持久化
}));

模式:重要的提醒只展示一次,确认状态持久化到全局配置。

好处

  • 避免重复打扰已知晓的用户
  • 跨会话生效------新会话不会重复弹出
  • 配合 analytics 事件(tengu_cost_threshold_reached / tengu_cost_threshold_acknowledged)可以追踪用户行为

模式三:双轨分流 --- 按用户身份选择通知通道

复制代码
API Key 用户 → Console Billing Access → 退出摘要 + $5 对话框
订阅用户     → Claude.ai Billing Access → 速率限制预警 + Overage 通知

模式:同一关注点(成本感知)针对不同用户群体提供完全不同的通知机制。

好处

  • 每种用户只看到与自己相关的信息
  • 避免订阅用户看到不理解的美元金额
  • 避免 API Key 用户看到不相关的配额信息
  • 两套系统完全独立,互不干扰

15. 原书对照验证

# 原书描述 代码实际 验证
1 "Token Budget检查:每轮结束后检查累计token使用量是否超过预算" tokenBudget.tscheckTokenBudget() 在每轮循环后调用 ✅ 准确
2 "TokenBudgetDecision = continue | stop | warn_and_continue" 实际只有 ContinueDecisionStopDecision,无 warn_and_continue ❌ 原书不准确(详见 3.8 笔记)
3 原书未提及 costHook.ts costHook.ts 是独立的 React Hook,负责退出时成本摘要和持久化 ❌ 原书未描述
4 原书未提及 $5 阈值对话框 CostThresholdDialog.tsx 在累计成本 ≥ $5 时弹出,确认后全局持久化 ❌ 原书未描述
5 原书未提及双轨 Billing Access hasConsoleBillingAccess vs hasClaudeAiBillingAccess 实现用户分流 ❌ 原书未描述
6 原书未提及 DISABLE_COST_WARNINGS 环境变量全局静默成本警告,受 managedEnvConstants.ts 管理 ❌ 原书未描述
7 原书未提及速率限制预警 5小时/7天窗口的多阈值递进式预警 ❌ 原书未描述
8 原书未提及 Overage 模式通知 订阅用户从配额切换到超额使用时的即时通知 ❌ 原书未描述
9 原书未提及 FPS 指标与成本的联合保存 saveCurrentSessionCosts 接收 FpsMetrics 参数 ❌ 原书未描述
10 原书未提及会话切换时的成本保存/恢复流程 REPL.tsx 中的四步流程(读取→保存→重置→恢复) ❌ 原书未描述
11 "成本高:调用LLM生成摘要本身需要消耗token" 这是指上下文压缩的成本,与成本预警系统间接相关 ✅ 准确(但属于 3.7 范畴)
12 原书描述 tokenBudget.ts 有"预算决策"职责 tokenBudget 只做"继续/停止"决策,成本"预警"由独立的 costHook + Dialog + RateLimit 体系完成 ⚠️ 原书将"预警"和"决策"混为一谈

验证总结

状态 数量 说明
✅ 准确 2 原书描述与代码一致
❌ 原书未描述 8 costHook、$5 对话框、双轨 Billing、DISABLE 开关、速率预警、Overage 通知、FPS 联合保存、会话切换流程
❌ 原书不准确 1 三态决策实际只有两态
⚠️ 偏少 1 将"预警"和"决策"混为一谈

核心发现:原书第3章对成本"预警"机制的描述几乎为空白。原书将"Token Budget 检查"视为唯一的成本控制手段,但实际上 Claude Code 有一套完整的多通道成本预警系统:

  • API Key 用户:退出摘要 + $5 对话框(主动提醒)
  • 订阅用户:5h/7d 速率限制预警 + Overage 通知(实时监控)
  • 所有用户:成本数据始终保存(即使不展示)

这套系统体现了 Claude Code 对不同用户群体的精细化设计------API Key 用户需要关心"花了多少钱",订阅用户需要关心"配额够不够用",两套通知机制完全独立但共享底层成本追踪数据。


附录:源码文件索引

文件 行数 职责
costHook.ts 22 退出钩子:注册 process.on('exit') 回调
utils/billing.ts 78 双轨 Billing Access 控制
components/CostThresholdDialog.tsx 49 $5 阈值对话框组件
commands/cost/cost.ts 24 /cost 命令实现
hooks/notifs/useRateLimitWarningNotification.tsx 113 订阅用户速率限制预警 Hook
services/rateLimitMessages.ts 345 速率限制消息生成
services/claudeAiLimits.ts ~300 速率限制管理与配额状态
utils/fpsTracker.ts ~60 FPS 指标追踪
utils/warningHandler.ts 122 全局 Node.js 警告处理器
utils/envUtils.ts ~120 环境变量工具(isEnvTruthy)
utils/managedEnvConstants.ts ~200 受控环境变量列表
screens/REPL.tsx ~5000 REPL 主屏幕(成本对话框触发与确认)
components/StatusLine.tsx ~200 状态栏(成本数据暴露)
cost-tracker.ts 324 成本追踪核心(详见 3.8 笔记)

下一篇 :按照 SOURCE_CODE_READING_PLAN.md 计划,阶段二剩余 3.10 API 通信(services/api/)。如果准备继续,可以深入分析 API 调用层的流式响应处理、重试机制、错误恢复等。或者直接进入阶段三:工具系统(第4章),重点分析 Tool.ts / tools.ts 工具基类与注册表、BashTool 七层安全防御、FileEditTool 权限检查等。

相关推荐
重庆小透明1 小时前
今日收获(一些个人思考)
java·spring·大模型·springai·prompt注入
Seven凹凸Man2 小时前
WorkBuddy之产品经理原型设计及PRD实战
ai·原型模式
studyrunner2 小时前
GPT-5.5 对比 GPT-5.6 Sol、Terra、Luna:官方性能数据与选型分析
大数据·人工智能·gpt
广州浮点FLOATLIC2 小时前
装备制造企业怎么做许可证高峰冲突分析:设计、仿真与工艺评审为何总在关键节点同时挤占资源
大数据·制造·许可证管理
AllData公司负责人2 小时前
数据同步平台|AIIData数据中台实现MySQL、Hive、Kafka 一键接入Doris
大数据·数据库·hive·mysql·kafka·实时同步
Edward111111112 小时前
一AI名词
java·开发语言·数据库·学习
wuhanzhanhui2 小时前
从芯片到精密制造:2026武汉电子元器件、材料及生产设备展会见证微观世界的跃迁
大数据·人工智能
百胜软件@百胜软件2 小时前
百胜软件推出RT业务:零售技术服务全链路飞轮如何助力品牌商“降本+增收”?
大数据·人工智能·零售
栈溢出了2 小时前
Redis repl_backlog 学习笔记
java·开发语言·redis·学习