知识图谱定位:上一节我们解决了"Agent 能持续工作多久"的问题——四级压缩策略让 Agent 在 200K 上下文窗口中游刃有余。但 Agent 运行时还有大量其他状态需要管理——当前权限模式是什么?MCP 服务器连接了几个?用户偏好设置是什么?费用累积了多少?Claude Code 的巧妙之处在于它用两层状态分别管理不同类型的信息:底层是纯 TypeScript 的进程全局单例,上层是 React 响应式 Store。这个设计模式不仅解决了性能问题,更体现了"按需响应"的工程哲学,对任何复杂前端应用都有深刻的借鉴意义。


一、为什么需要两层状态?

1.1 一个思想实验:如果只有一层?

假设我们把 Claude Code 的所有运行时状态——费用、遥测计数器、会话 ID、权限模式、消息列表、MCP 连接、用户设置——全部塞进一个 React 状态管理器(比如 Redux 或 Zustand),会发生什么?

每次费用更新(每轮 API 调用后)
  → 触发 Redux dispatch
    → 触发所有 connect/useSelector 的组件重新比较
      → 即使 UI 不关心费用,也要做一轮虚拟 DOM diff
        → 性能灾难 💥

Claude Code 每次 API 调用后都会更新费用、Token 消耗、API 耗时等十几个计数器。如果这些更新走 React 状态流,一个 50 轮的任务就意味着 50 次无意义的 UI 重渲染。而 Claude Code 的 TUI(终端 UI)本身就运行在资源受限的终端环境中,每一帧都很宝贵。

反过来,如果把需要驱动 UI 更新的状态(权限模式、消息列表、设置面板数据)放在 React 之外的普通变量中,组件就无法感知状态变化,UI 会"卡死"在旧数据上。

1.2 双层架构的分治逻辑

Claude Code 的答案是一个清晰的分层:

维度 Layer 1:进程状态 Layer 2:应用状态
文件 bootstrap/state.ts(1759 行) src/state/(6 个文件)
技术 模块级变量 + getter/setter createStore + useSyncExternalStore
依赖 纯 TypeScript,零依赖 React 18+
响应式 ❌ 不触发 UI 更新 ✅ 精准触发组件重渲染
典型内容 费用、遥测、OTel 计数器、CWD 消息、权限、设置、MCP、任务
变更频率 极高(每次 API 调用) 中等(用户操作触发)
消费者 非 React 代码(API 层、工具系统) React 组件(94+ 个文件)

判断标准只有一个:这个状态变化需要触发 UI 更新吗? 需要 → Layer 2,不需要 → Layer 1。


1.3 Import DAG 视角:为什么 Layer 1 必须是"叶子节点"

Claude Code 的模块依赖图中,bootstrap/state.ts 被设计为 Import DAG 的叶子节点——它不导入项目中的其他业务模块(除了少量类型和一个 crypto.js 工具)。这个约束至关重要:

                    ┌── main.tsx
                    │     ↓
              ┌── App.tsx ── useAppState() ── store.ts
              │     ↓               ↓
        ┌── REPL.tsx        AppStateStore.ts
        │     ↓                    ↓
  queryLoop.ts ──→ compact.ts    onChangeAppState.ts
        ↓              ↓              ↓
   toolRunner.ts    tokenEst.ts  ─→ bootstrap/state.ts  ← 叶子!
        ↓
   analytics.ts ──→ bootstrap/state.ts

为什么叶子节点如此重要?

  1. 避免循环依赖:如果 state.ts 依赖了 toolRunner.ts,而 toolRunner.ts 又依赖 state.ts,Node.js 的模块加载器会返回不完整的导出对象,导致运行时 undefined 错误。

  2. 启动顺序确定性:叶子节点最先完成初始化,所有依赖它的模块都能安全地读取初始状态。

  3. 构建产物可预测:bundler 的 tree-shaking 不会因为循环引用而失效。

源码中的注释直白地强调了这一点:

// bootstrap/state.ts (line 31)
// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE

// (line 259)
// ALSO HERE - THINK THRICE BEFORE MODIFYING

// (line 428)
// AND ESPECIALLY HERE
const STATE: State = getInitialState()

三个不同位置的警告,语气逐级加重——从"不要加更多状态"到"三思而后行"再到"尤其是这里"——生动地反映了维护者对全局状态膨胀的警惕。

设计洞察:Claude Code 用注释的密度和语气来传达代码的"危险等级"。这是一种轻量但有效的架构治理手段——当新成员看到三处措辞强烈的注释时,自然会更谨慎地评估自己是否真的需要修改这个文件。


二、Layer 1 —— bootstrap/state.ts:进程全局单例

2.1 核心模式:模块级变量 + getter/setter

Layer 1 的实现极其朴素——没有 Proxy、没有 Observable、没有发布-订阅。它就是一个模块级变量加上一组导出的 getter/setter 函数:

// bootstrap/state.ts (line 429)
const STATE: State = getInitialState()

// getter 示例
export function getSessionId(): SessionId {
  return STATE.sessionId
}

export function getTotalCostUSD(): number {
  return STATE.totalCostUSD
}

// setter 示例
export function addToTotalCostState(
  cost: number,
  modelUsage: ModelUsage,
  model: string,
): void {
  STATE.modelUsage[model] = modelUsage
  STATE.totalCostUSD += cost
}

export function setCwdState(cwd: string): void {
  STATE.cwd = cwd.normalize('NFC')
}

关键设计选择:

  1. STATE 不导出:外部代码无法直接 import { STATE } 来读写,必须通过 getter/setter。这确保了所有状态变更都有明确的函数调用可追踪。

  2. getter/setter 是具名函数:不是 getState('sessionId') 这样的动态访问,而是 getSessionId() 这样的具名函数。好处是 IDE 的"查找引用"能精确定位所有读写点。

  3. setter 可以包含业务逻辑:比如 setCwdState 会自动做 NFC 标准化,addToTotalCostState 会同时更新模型使用量和总费用。

2.2 State 类型:200+ 字段的全景图

State 类型定义了进程生命周期内需要维护的所有全局信息,大致可以分为 7 个类别:

type State = {
  // 1. 项目/环境标识(启动时设置,几乎不变)
  originalCwd: string          // 启动时的工作目录
  projectRoot: string          // 稳定的项目根(不随 worktree 变化)
  cwd: string                  // 当前工作目录(可被 cd 改变)
  sessionId: SessionId         // 当前会话 UUID
  parentSessionId: SessionId   // 父会话(plan mode 切换时)
  clientType: string           // 'cli' | 'sdk' | ...
  isInteractive: boolean       // 是否交互模式
  isRemoteMode: boolean        // 是否远程模式
​
  // 2. 费用/性能计数器(每次 API 调用后更新,高频)
  totalCostUSD: number
  totalAPIDuration: number
  totalAPIDurationWithoutRetries: number
  totalToolDuration: number
  totalLinesAdded: number
  totalLinesRemoved: number
  modelUsage: { [modelName: string]: ModelUsage }
​
  // 3. 遥测/OTel(初始化后由遥测系统独占写入)
  meter: Meter | null
  sessionCounter: AttributedCounter | null
  locCounter: AttributedCounter | null
  costCounter: AttributedCounter | null
  tokenCounter: AttributedCounter | null
  // ... 共 ~10 个 counter 字段
​
  // 4. 模型/API 状态
  mainLoopModelOverride: ModelSetting | undefined
  initialMainLoopModel: ModelSetting
  modelStrings: ModelStrings | null
  lastAPIRequest: ... | null
  lastAPIRequestMessages: ... | null
​
  // 5. 会话级标志(生命周期内可能变化一两次)
  hasExitedPlanMode: boolean
  needsPlanModeExitAttachment: boolean
  sessionBypassPermissionsMode: boolean
  sessionTrustAccepted: boolean
  scheduledTasksEnabled: boolean
​
  // 6. Beta Header 锁存器(一旦激活就不再关闭)
  afkModeHeaderLatched: boolean | null
  fastModeHeaderLatched: boolean | null
  cacheEditingHeaderLatched: boolean | null
  thinkingClearLatched: boolean | null
​
  // 7. 缓存/注册表
  registeredHooks: Partial<Record<HookEvent, ...>> | null
  invokedSkills: Map<string, { skillName, content, ... }>
  planSlugCache: Map<string, string>
  systemPromptSectionCache: Map<string, string | null>
}

2.3 锁存器(Latch)模式:只开不关的状态

Layer 1 中最精巧的设计之一是 Beta Header 锁存器。以 afkModeHeaderLatched 为例:

// bootstrap/state.ts
// Sticky-on latch for AFK_MODE_BETA_HEADER. Once auto mode is first
// activated, keep sending the header for the rest of the session so
// Shift+Tab toggles don't bust the ~50-70K token prompt cache.
afkModeHeaderLatched: boolean | null

锁存器的三态:

  • null:尚未激活,由运行时条件决定是否发送 Beta Header

  • true:已激活,此后会话中始终发送该 Header

  • false:不存在(这个锁存器只有 nulltrue 两个有效状态)

为什么需要锁存器?

用户启动 auto mode → 发送 AFK_MODE_BETA_HEADER → API 建立 prompt cache(~50-70K)
用户 Shift+Tab 退出 auto mode → 如果停止发送 header → cache 失效! → 浪费 $$$
                                → 锁存器保持发送 header → cache 继续命中 ✓

这是上一节提到的 prompt cache 优化在状态管理层面的延伸:压缩系统努力保护的 prompt cache,不能因为一个 header 的变化而被白白浪费。

锁存器有统一的重置点:

// bootstrap/state.ts (line 1744)
export function clearBetaHeaderLatches(): void {
  STATE.afkModeHeaderLatched = null
  STATE.fastModeHeaderLatched = null
  STATE.cacheEditingHeaderLatched = null
  STATE.thinkingClearLatched = null
}

只在 /clear/compact 这两个"新对话开始"的时刻才重置——因为此时 prompt cache 已经被清除或重建,锁存器的保护目标不复存在。

2.4 Signal 机制:叶子节点的"回调注册"

bootstrap/state.ts 作为叶子节点,不能 import 其他业务模块。但有些状态变更(如会话切换)需要通知上游模块。怎么办?

答案是 createSignal——一个最小化的发布-订阅机制:

// bootstrap/state.ts (line 481)
const sessionSwitched = createSignal<[id: SessionId]>()

// 状态变更时发射信号
export function switchSession(
  sessionId: SessionId,
  projectDir: string | null = null,
): void {
  STATE.planSlugCache.delete(STATE.sessionId)
  STATE.sessionId = sessionId
  STATE.sessionProjectDir = projectDir
  sessionSwitched.emit(sessionId)  // ← 通知所有订阅者
}

// 暴露订阅接口(不暴露 emit)
export const onSessionSwitch = sessionSwitched.subscribe

上游模块通过 onSessionSwitch 注册回调,比如 concurrentSessions.ts 用它来同步 PID 文件中的会话 ID。这种设计巧妙地在不破坏 Import DAG 的前提下,实现了跨模块通信。

设计模式:这就是经典的 控制反转(IoC)——叶子节点不知道(也不需要知道)谁会订阅自己的事件,它只负责在状态变更时发射信号。订阅者在更高层的初始化代码中注册。

2.5 Turn 级计数器:高频更新的典型案例

来看一组典型的 Layer 1 状态——Turn 级性能计数器:

// bootstrap/state.ts
export function addToToolDuration(duration: number): void {
  STATE.totalToolDuration += duration
  STATE.turnToolDurationMs += duration
  STATE.turnToolCount++
}

export function resetTurnToolDuration(): void {
  STATE.turnToolDurationMs = 0
  STATE.turnToolCount = 0
}

每次工具执行完成后调用 addToToolDuration,每轮用户交互开始时调用 resetTurnToolDuration。一个 50 轮、每轮调用 5 个工具的会话,这些函数会被调用 250+ 次。如果走 React 状态流,意味着 250 次 setState → 250 次订阅者通知 → 250 次潜在的重渲染检查。放在 Layer 1 中,这些更新就是简单的变量赋值——O(1) 时间,零额外开销。


三、Layer 2 —— React 响应式状态

3.1 35 行的极简 Store

Layer 2 的基石是一个仅 35 行的 createStore 工厂函数:

// src/state/store.ts — 完整代码
type Listener = () => void
type OnChange<T> = (args: { newState: T; oldState: T }) => void

export type Store<T> = {
  getState: () => T
  setState: (updater: (prev: T) => T) => void
  subscribe: (listener: Listener) => () => void
}

export function createStore<T>(
  initialState: T,
  onChange?: OnChange<T>,
): Store<T> {
  let state = initialState
  const listeners = new Set<Listener>()

  return {
    getState: () => state,

    setState: (updater: (prev: T) => T) => {
      const prev = state
      const next = updater(prev)
      if (Object.is(next, prev)) return   // ← 浅比较跳过
      state = next
      onChange?.({ newState: next, oldState: prev })  // ← 副作用钩子
      for (const listener of listeners) listener()    // ← 通知订阅者
    },

    subscribe: (listener: Listener) => {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
}

这 35 行代码浓缩了几个关键设计决策:

Object.is 浅比较
if (Object.is(next, prev)) return

如果 updater 返回了与旧状态同一个引用(prev),跳过后续所有逻辑。这是不可变更新模式的标准优化——如果你的 updater 判断"不需要更新",直接返回 prev 即可:

store.setState(prev => {
  if (prev.verbose === newValue) return prev  // 引用不变 → 跳过
  return { ...prev, verbose: newValue }       // 新对象 → 触发更新
})
onChange 副作用钩子
onChange?.({ newState: next, oldState: prev })

状态变更后、通知 React 订阅者之前,先调用 onChange。这个设计将副作用处理从分散的 setState 调用点集中到一个地方——后面会详细分析。

③ 与 useSyncExternalStore 的完美契合

Store 的 API 签名(getStatesubscribe)恰好是 React 18 useSyncExternalStore 的要求。这不是巧合,而是刻意设计——Claude Code 自建 Store 而非使用 Redux/Zustand,正是因为 35 行代码就能满足全部需求。

工程选择辩析:为什么不用 Zustand?Zustand 的核心也是类似的 ~50 行代码,但它带来了额外的依赖(npm 包、版本兼容性、安全审计)。对于 Claude Code 这样一个以终端 UI 为主的应用,35 行自研代码的可维护性远优于引入外部依赖。这体现了"不要为 Demo 级的简单性引入生产级的复杂性"这一原则。

3.2 AppState:100+ 字段的类型城堡

AppState 是 Layer 2 的状态类型,定义在 AppStateStore.ts(570 行)中。它用 DeepImmutable<> 包裹,强制所有字段为只读:

// src/state/AppStateStore.ts (line 89)
export type AppState = DeepImmutable<{
  settings: SettingsJson
  verbose: boolean
  mainLoopModel: ModelSetting
  statusLineText: string | undefined
  expandedView: 'none' | 'tasks' | 'teammates'
  toolPermissionContext: ToolPermissionContext
  kairosEnabled: boolean
  remoteConnectionStatus: 'connecting' | 'connected' | 'reconnecting' | 'disconnected'
  replBridgeEnabled: boolean
  replBridgeConnected: boolean
  // ... 更多字段
}> & {
  // 以下字段排除在 DeepImmutable 之外(包含函数类型)
  tasks: { [taskId: string]: TaskState }
  mcp: {
    clients: MCPServerConnection[]
    tools: Tool[]
    commands: Command[]
    resources: Record<string, ServerResource[]>
    pluginReconnectKey: number
  }
  plugins: {
    enabled: LoadedPlugin[]
    disabled: LoadedPlugin[]
    commands: Command[]
    errors: PluginError[]
    installationStatus: { ... }
    needsRefresh: boolean
  }
  // ... 更多非 DeepImmutable 字段
}

注意 DeepImmutable<{...}> & {...} 这个交叉类型——大部分字段DeepImmutable 包裹(递归地添加 readonly),但包含函数类型或复杂可变结构的字段(tasksmcpplugins)被排除在外。这是因为 TypeScript 的 Readonly 作用于函数类型时会产生不直观的行为。

AppState 的字段大致分为以下几个类别:

类别 典型字段 消费者
UI 显示 verbose, expandedView, statusLineText, spinnerTip 各类 UI 组件
权限 toolPermissionContext (含 mode, isBypassPermissionsMode 等) 权限系统、状态栏
模型/设置 mainLoopModel, settings, thinkingEnabled, effortValue 模型选择 UI、设置面板
MCP 生态 mcp.clients, mcp.tools, mcp.resources 工具系统、MCP 面板
任务/Agent tasks, viewingAgentTaskId, agentNameRegistry 任务面板、Agent 视图
投机执行 speculation, speculationSessionTimeSavedMs 投机引擎、状态显示
远程桥接 replBridgeEnabled/Connected/SessionActive/...(~15 字段) 远程桥接 UI
通知 notifications, elicitation 通知组件

3.3 useAppState(selector):精准订阅的艺术

useAppState 是消费 Layer 2 状态的核心 Hook,基于 React 18 的 useSyncExternalStore

// src/state/AppState.tsx (line 142)
export function useAppState<T>(selector: (state: AppState) => T): T {
  const store = useAppStore()

  const get = () => {
    const state = store.getState()
    const selected = selector(state)
    return selected
  }

  return useSyncExternalStore(store.subscribe, get, get)
}

useSyncExternalStore 的工作原理:

store.subscribe(listener) ← React 注册监听(setState 时触发 listener)
                ↓
listener 触发后,React 调用 get()
                ↓
get() 返回 selector(state) 的新值
                ↓
React 用 Object.is 比较新旧值
                ↓
如果不同 → 触发组件重渲染
如果相同 → 跳过 ✓

这个机制的精妙之处在于 selector 决定了组件对状态变化的敏感度

// 只关心 verbose 字段 — totalCostUSD 变了也不会重渲染
const verbose = useAppState(s => s.verbose)

// 只关心模型 — 权限模式变了也不会重渲染
const model = useAppState(s => s.mainLoopModel)

// 关心权限上下文(返回子对象引用)
const { text, promptId } = useAppState(s => s.promptSuggestion)

源码中的 JSDoc 注释特别强调了一个重要约束:

/**
 * Do NOT return new objects from the selector -- Object.is will always see
 * them as changed. Instead, select an existing sub-object reference:
 *
 * const { text, promptId } = useAppState(s => s.promptSuggestion) // good
 */

为什么不能返回新对象?

// ❌ 反模式:每次都创建新对象
const data = useAppState(s => ({
  verbose: s.verbose,
  model: s.mainLoopModel,
}))
// Object.is({...}, {...}) 永远是 false → 每次 setState 都触发重渲染!

// ✅ 正确做法:多次调用,每次返回原始引用
const verbose = useAppState(s => s.verbose)
const model = useAppState(s => s.mainLoopModel)

使用规模:通过搜索代码库,useAppState94+ 个文件使用。这意味着 Layer 2 的 selector 优化不是"锦上添花",而是"不可或缺"——如果每个组件都因为无关状态变化而重渲染,终端 UI 的帧率会直接崩溃。

3.4 useSetAppState():写入不订阅

useAppState 既订阅又读取,但有些组件只需要写入状态而不关心读取:

// src/state/AppState.tsx (line 170)
export function useSetAppState(): (
  updater: (prev: AppState) => AppState,
) => void {
  return useAppStore().setState
}

返回的是 store.setState 的稳定引用——它永远不会变化,因此使用 useSetAppState 的组件永远不会因为状态变化而重渲染。这对于按钮、输入框等"只触发动作"的组件非常有用。

3.5 useAppStateMaybeOutsideOfProvider:安全降级

还有一个防御性 Hook,用于那些可能在 AppStateProvider 之外渲染的组件:

// src/state/AppState.tsx (line 186)
export function useAppStateMaybeOutsideOfProvider<T>(
  selector: (state: AppState) => T,
): T | undefined {
  const store = useContext(AppStoreContext)
  return useSyncExternalStore(
    store ? store.subscribe : NOOP_SUBSCRIBE,
    () => store ? selector(store.getState()) : undefined,
  )
}

store 不存在时,使用 NOOP_SUBSCRIBE(一个空函数),返回 undefined。这避免了组件因为渲染上下文不匹配而崩溃。

3.6 AppStateProvider:Store 的创建和注入

Store 在 React 树的根部创建,通过 Context 向下传递:

// src/state/AppState.tsx (精简后的源码逻辑)
export function AppStateProvider({
  children,
  initialState,
  onChangeAppState,
}: Props): React.ReactNode {
  // 防止嵌套
  const hasAppStateContext = useContext(HasAppStateContext)
  if (hasAppStateContext) {
    throw new Error('AppStateProvider can not be nested within another AppStateProvider')
  }

  // Store 创建一次,永不变化 — 稳定的 Context 值 → Provider 不触发重渲染
  const [store] = useState(() =>
    createStore<AppState>(
      initialState ?? getDefaultAppState(),
      onChangeAppState,           // ← 传入 onChange 钩子
    ),
  )

  // mount 时检查权限模式(处理远程设置加载的竞态条件)
  useEffect(() => {
    const { toolPermissionContext } = store.getState()
    if (
      toolPermissionContext.isBypassPermissionsModeAvailable &&
      isBypassPermissionsModeDisabled()
    ) {
      store.setState(prev => ({
        ...prev,
        toolPermissionContext: createDisabledBypassPermissionsContext(
          prev.toolPermissionContext,
        ),
      }))
    }
  }, [])

  // 监听外部设置文件变化(文件 watcher)
  const onSettingsChange = useEffectEvent((source: SettingSource) =>
    applySettingsChange(source, store.setState),
  )
  useSettingsChange(onSettingsChange)

  return (
    <HasAppStateContext.Provider value={true}>
      <AppStoreContext.Provider value={store}>
        <MailboxProvider>
          <VoiceProvider>{children}</VoiceProvider>
        </MailboxProvider>
      </AppStoreContext.Provider>
    </HasAppStateContext.Provider>
  )
}

几个关键设计点:

  1. [store] 使用 useState 惰性初始化createStore 只在首次渲染时调用一次,此后 store 引用永远不变。

  2. 双 Context 嵌套HasAppStateContext(布尔值)用于检测嵌套,AppStoreContext(Store 实例)用于传递 Store。

  3. 设置文件变化监听useSettingsChange 监听磁盘上的设置文件变化(由 file watcher 触发),通过 applySettingsChange 同步到 AppState。


四、状态变更的副作用 —— onChangeAppState.ts

4.1 为什么需要集中化的副作用?

想象一下,Claude Code 中有 8 种以上的方式可以改变权限模式:

  • Shift+Tab 快捷键循环

  • /plan 命令

  • 退出 Plan Mode 的确认对话框

  • SDK 的 set_permission_mode 控制请求

  • REPL 桥接的 onSetPermissionMode

  • Rewind 操作

  • headless/SDK 模式的 bespoke wrapper

  • ExitPlanModePermissionRequest 对话框

如果每个变更路径都要手动调用"通知 CCR"、"同步 SDK"、"更新持久化"等副作用,不仅代码重复,更致命的是必然有遗漏。源码注释完美地记录了这个历史教训:

// onChangeAppState.ts (line 50-64)
// toolPermissionContext.mode — single choke point for CCR/SDK mode sync.
//
// Prior to this block, mode changes were relayed to CCR by only 2 of 8+
// mutation paths: a bespoke setAppState wrapper in print.ts (headless/SDK
// mode only) and a manual notify in the set_permission_mode handler.
// Every other path — Shift+Tab cycling, ExitPlanModePermissionRequest
// dialog options, the /plan slash command, rewind, the REPL bridge's
// onSetPermissionMode — mutated AppState without telling
// CCR, leaving external_metadata.permission_mode stale and the web UI out
// of sync with the CLI's actual mode.
//
// Hooking the diff here means ANY setAppState call that changes the mode
// notifies CCR ... The scattered callsites above need zero changes.

这段注释价值连城:它不仅解释了为什么要集中化,还记录了之前分散处理时的具体 bug——8 个变更路径中只有 2 个通知了 CCR,导致 Web UI 与 CLI 不同步。

4.2 onChangeAppState 的完整实现

这个函数作为 createStoreonChange 回调注入,在每次 setState 后、React 订阅者被通知前调用:

// src/state/onChangeAppState.ts (line 43)
export function onChangeAppState({
  newState,
  oldState,
}: {
  newState: AppState
  oldState: AppState
}) {
  // ── 副作用 1:权限模式 → CCR/SDK 同步 ──
  const prevMode = oldState.toolPermissionContext.mode
  const newMode = newState.toolPermissionContext.mode
  if (prevMode !== newMode) {
    const prevExternal = toExternalPermissionMode(prevMode)
    const newExternal = toExternalPermissionMode(newMode)
    if (prevExternal !== newExternal) {
      const isUltraplan = 
        newExternal === 'plan' && 
        newState.isUltraplanMode && 
        !oldState.isUltraplanMode
          ? true : null
      notifySessionMetadataChanged({
        permission_mode: newExternal,
        is_ultraplan_mode: isUltraplan,
      })
    }
    notifyPermissionModeChanged(newMode)
  }

  // ── 副作用 2:模型设置 → 持久化 + Layer 1 同步 ──
  if (newState.mainLoopModel !== oldState.mainLoopModel) {
    if (newState.mainLoopModel === null) {
      updateSettingsForSource('userSettings', { model: undefined })
      setMainLoopModelOverride(null)           // ← Layer 2 → Layer 1
    } else {
      updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
      setMainLoopModelOverride(newState.mainLoopModel)
    }
  }

  // ── 副作用 3:展开视图 → 持久化到 globalConfig ──
  if (newState.expandedView !== oldState.expandedView) {
    const showExpandedTodos = newState.expandedView === 'tasks'
    const showSpinnerTree = newState.expandedView === 'teammates'
    if (
      getGlobalConfig().showExpandedTodos !== showExpandedTodos ||
      getGlobalConfig().showSpinnerTree !== showSpinnerTree
    ) {
      saveGlobalConfig(current => ({
        ...current, showExpandedTodos, showSpinnerTree,
      }))
    }
  }

  // ── 副作用 4:verbose → 持久化 ──
  if (
    newState.verbose !== oldState.verbose &&
    getGlobalConfig().verbose !== newState.verbose
  ) {
    saveGlobalConfig(current => ({ ...current, verbose: newState.verbose }))
  }

  // ── 副作用 5:设置变化 → 清除 Auth 缓存 ──
  if (newState.settings !== oldState.settings) {
    try {
      clearApiKeyHelperCache()
      clearAwsCredentialsCache()
      clearGcpCredentialsCache()
      if (newState.settings.env !== oldState.settings.env) {
        applyConfigEnvironmentVariables()
      }
    } catch (error) {
      logError(toError(error))
    }
  }
}

4.3 副作用的分类与时序

让我们把 5 个副作用按类型分类:

# 触发条件 副作用类型 方向
1 toolPermissionContext.mode 变化 网络通知(CCR/SDK) Layer 2 → 外部系统
2 mainLoopModel 变化 磁盘持久化 + Layer 1 同步 Layer 2 → 磁盘 + Layer 1
3 expandedView 变化 磁盘持久化 Layer 2 → 磁盘
4 verbose 变化 磁盘持久化 Layer 2 → 磁盘
5 settings 变化 缓存清除 + 环境变量重新应用 Layer 2 → 内存缓存

注意副作用 2 中的 setMainLoopModelOverride(newState.mainLoopModel)——这是 Layer 2 → Layer 1 的反向同步。为什么模型设置在两层中都存在?因为 Layer 1 中的模型覆盖被 API 调用层直接读取(高频路径),而 Layer 2 中的模型设置驱动 UI 显示。onChangeAppState 确保两者始终一致。

设计模式onChangeAppState 本质上是 事件溯源(Event Sourcing) 的简化版——状态变更本身就是"事件",onChange 是"事件处理器"。但与经典的事件溯源不同,这里不存储事件历史,只处理最新的状态差分。

4.4 权限模式同步的"外部化"逻辑

副作用 1 中有一个精妙的细节:权限模式的内部名称外部名称是不同的:

const prevExternal = toExternalPermissionMode(prevMode)
const newExternal = toExternalPermissionMode(newMode)
if (prevExternal !== newExternal) {
  // 只在外部名称变化时通知 CCR
  notifySessionMetadataChanged({ permission_mode: newExternal })
}
// 但内部模式变化总是通知 SDK
notifyPermissionModeChanged(newMode)

为什么?因为 Claude Code 内部有些模式(如 bubbleungated_auto)是暂时的过渡态,从 CCR(Claude Code for Replit 等外部集成)的角度看,它们和 default 没有区别。default → bubble → default 的切换对 CCR 来说是"噪音",不应该触发通知。但 SDK 层面需要感知内部模式的变化,所以 notifyPermissionModeChanged 总是被调用。


五、两层交互:数据如何在层间流动

5.1 Layer 1 → Layer 2:启动时的初始化

在应用启动时,getDefaultAppState() 读取初始设置和环境信息来构建 Layer 2 的初始状态:

// src/state/AppStateStore.ts (line 456)
export function getDefaultAppState(): AppState {
  const initialMode: PermissionMode =
    teammateUtils.isTeammate() && teammateUtils.isPlanModeRequired()
      ? 'plan'
      : 'default'

  return {
    settings: getInitialSettings(),    // ← 从磁盘读取
    tasks: {},
    verbose: false,
    mainLoopModel: null,
    toolPermissionContext: {
      ...getEmptyToolPermissionContext(),
      mode: initialMode,
    },
    thinkingEnabled: shouldEnableThinkingByDefault(),
    promptSuggestionEnabled: shouldEnablePromptSuggestion(),
    // ... ~50 个字段的初始值
  }
}

虽然 getDefaultAppState 没有直接读取 Layer 1 的 getter(因为 Layer 2 的状态类型不需要费用、遥测等信息),但它读取的设置和环境检测结果,本质上与 Layer 1 共享同一个进程环境。

5.2 Layer 2 → Layer 1:运行时的反向同步

如上一节分析的,onChangeAppState 是 Layer 2 → Layer 1 的唯一桥梁:

用户在 UI 中切换模型
  → setAppState(prev => ({ ...prev, mainLoopModel: 'claude-sonnet-4-20250514' }))
    → createStore.setState 被调用
      → Object.is 检查通过(新对象 ≠ 旧对象)
        → onChange 被调用(onChangeAppState)
          → 副作用 2:setMainLoopModelOverride('claude-sonnet-4-20250514')  ← 同步到 Layer 1
          → 副作用 2:updateSettingsForSource(...)                         ← 持久化到磁盘
        → React 订阅者被通知
          → 模型显示组件重渲染

5.3 非 React 代码读取 Layer 2

有些非 React 代码(比如 headless 模式、SDK 入口)也需要读写 AppState。它们通过 useAppStateStore() 获取 store 引用,然后直接调用 store.getState()store.setState()

// 非 React 代码的典型用法
const store = getAppStateStore()  // 从某个初始化点获取的引用
const currentMode = store.getState().toolPermissionContext.mode
store.setState(prev => ({ ...prev, verbose: true }))

这种"React 和非 React 共享同一个 Store"的模式,正是 useSyncExternalStore 相比传统 useContext + useState 的优势所在——Store 是一个独立于 React 生命周期的对象。

5.4 externalMetadataToAppState:Layer 2 的外部恢复

当 SDK/REPL 桥接需要恢复会话状态时,onChangeAppState.ts 提供了一个逆向函数:

// src/state/onChangeAppState.ts (line 24)
export function externalMetadataToAppState(
  metadata: SessionExternalMetadata,
): (prev: AppState) => AppState {
  return prev => ({
    ...prev,
    ...(typeof metadata.permission_mode === 'string'
      ? {
          toolPermissionContext: {
            ...prev.toolPermissionContext,
            mode: permissionModeFromString(metadata.permission_mode),
          },
        }
      : {}),
    ...(typeof metadata.is_ultraplan_mode === 'boolean'
      ? { isUltraplanMode: metadata.is_ultraplan_mode }
      : {}),
  })
}

这个函数返回一个 updater,可以直接传给 store.setState。它将外部元数据(如 CCR 的 permission_mode)映射回 AppState 的字段。注意使用了条件展开(...(condition ? {...} : {}))来优雅地处理可选字段。


六、Selector 与 Computed State

6.1 纯选择器:selectors.ts

src/state/selectors.ts 定义了从 AppState 派生计算状态的纯函数:

// src/state/selectors.ts (line 18)
export function getViewedTeammateTask(
  appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>,
): InProcessTeammateTaskState | undefined {
  const { viewingAgentTaskId, tasks } = appState

  if (!viewingAgentTaskId) return undefined
  const task = tasks[viewingAgentTaskId]
  if (!task) return undefined
  if (!isInProcessTeammateTask(task)) return undefined

  return task
}

注意参数类型用了 Pick<AppState, ...> 而非完整的 AppState——这明确了选择器只依赖哪些字段,便于理解和测试。

更复杂的选择器使用判别联合类型(Discriminated Union)

// src/state/selectors.ts (line 46)
export type ActiveAgentForInput =
  | { type: 'leader' }
  | { type: 'viewed'; task: InProcessTeammateTaskState }
  | { type: 'named_agent'; task: LocalAgentTaskState }

export function getActiveAgentForInput(appState: AppState): ActiveAgentForInput {
  const viewedTask = getViewedTeammateTask(appState)
  if (viewedTask) return { type: 'viewed', task: viewedTask }

  const { viewingAgentTaskId, tasks } = appState
  if (viewingAgentTaskId) {
    const task = tasks[viewingAgentTaskId]
    if (task?.type === 'local_agent') return { type: 'named_agent', task }
  }

  return { type: 'leader' }
}

这个选择器决定用户输入应该路由到哪个 Agent——Leader、当前查看的 Teammate、还是命名 Agent。返回判别联合类型让消费者可以用 switch(result.type) 做类型安全的分支处理。

6.2 复杂状态机:teammateViewHelpers.ts

对于涉及多步状态转换的逻辑,Claude Code 将其抽取到独立的 helper 文件中:

// src/state/teammateViewHelpers.ts (line 46)
export function enterTeammateView(
  taskId: string,
  setAppState: (updater: (prev: AppState) => AppState) => void,
): void {
  logEvent('tengu_transcript_view_enter', {})
  setAppState(prev => {
    const task = prev.tasks[taskId]
    const prevId = prev.viewingAgentTaskId
    const prevTask = prevId !== undefined ? prev.tasks[prevId] : undefined
    
    // 判断是否需要释放前一个 agent
    const switching = prevId !== undefined && prevId !== taskId 
      && isLocalAgent(prevTask) && prevTask.retain
    // 判断是否需要 retain 新 agent
    const needsRetain = isLocalAgent(task) 
      && (!task.retain || task.evictAfter !== undefined)
    const needsView = prev.viewingAgentTaskId !== taskId 
      || prev.viewSelectionMode !== 'viewing-agent'
    
    // 如果都不需要变化,返回原引用 → Object.is 跳过更新
    if (!needsRetain && !needsView && !switching) return prev
    
    let tasks = prev.tasks
    if (switching || needsRetain) {
      tasks = { ...prev.tasks }
      if (switching) tasks[prevId] = release(prevTask)  // 释放旧 agent
      if (needsRetain) {
        tasks[taskId] = { ...task, retain: true, evictAfter: undefined }
      }
    }
    return {
      ...prev,
      viewingAgentTaskId: taskId,
      viewSelectionMode: 'viewing-agent',
      tasks,
    }
  })
}

这段代码展示了 Claude Code 如何在不可变更新中处理复杂的状态机转换:

  1. 先计算所有需要变化的条件(switchingneedsRetainneedsView

  2. 如果什么都不变,返回 prev(触发 Object.is 短路)

  3. 只在必要时创建新的 tasks 对象(懒拷贝)

  4. 一次性返回所有变更

这个 helper 还引入了 release() 函数来处理 Agent 的资源释放:

function release(task: LocalAgentTaskState): LocalAgentTaskState {
  return {
    ...task,
    retain: false,
    messages: undefined,          // 清除消息(节省内存)
    diskLoaded: false,
    evictAfter: isTerminalTaskStatus(task.status)
      ? Date.now() + PANEL_GRACE_MS  // 终态任务给 30 秒宽限期
      : undefined,
  }
}

PANEL_GRACE_MS = 30_000——30 秒的宽限期让用户在退出查看后仍能短暂看到 Agent 的行(UX 缓冲),之后自动驱逐。


七、设计模式提炼:判断标准与反模式


7.1 什么时候用全局单例(Layer 1)?

适用场景

  • ✅ 状态变更不需要触发 UI 更新(费用计数器、遥测)

  • ✅ 状态被非 React 代码频繁读取(API 层、工具执行器)

  • ✅ 需要在模块加载时就可用(应用启动前的配置)

  • ✅ 变更频率极高(每次 API 调用、每次工具执行)

  • ✅ 需要作为 Import DAG 的叶子节点(避免循环依赖)

判断公式

if (UI 不关心 && 变更频率高 && 非 React 代码需要读取) → Layer 1

7.2 什么时候用响应式 Store(Layer 2)?

适用场景

  • ✅ 状态变更需要驱动 UI 重渲染(权限模式、消息列表)

  • ✅ 多个组件需要订阅同一状态的不同切片(settings 被设置面板和模型选择器共用)

  • ✅ 需要集中化的副作用处理(onChangeAppState

  • ✅ 需要 DeepImmutable 类型保护(防止组件意外修改状态)

判断公式

if (UI 需要响应变化 || 需要集中化副作用 || 需要类型安全的不可变保证) → Layer 2

7.3 反模式警示

反模式 1:把所有状态塞进 Redux/Zustand

// ❌ 反模式
const store = createStore({
  totalCostUSD: 0,           // 每次 API 调用更新,UI 不关心
  meter: null,                // OTel 对象,UI 完全不需要
  sessionId: '...',           // 启动后几乎不变
  verbose: false,             // UI 需要
  messages: [],               // UI 需要
  toolPermissionContext: {},   // UI 需要
})

前三个字段不需要响应式支持。把它们放在 Redux 中,每次 totalCostUSD 变化都会触发 selector 比较,即使结果是"跳过",也浪费了 CPU。

反模式 2:Layer 1 状态通过 props 传递

// ❌ 反模式:把 Layer 1 的值作为 props 透传
function App() {
  const [cost, setCost] = useState(getTotalCostUSD())
  useInterval(() => setCost(getTotalCostUSD()), 1000)  // 轮询!
  return <StatusBar cost={cost} />
}

如果 UI 确实需要显示费用(比如状态栏),正确做法是在需要的时刻拉取 Layer 1 的值,而不是用 polling 把它"推"进 React 状态。Claude Code 的状态栏在每次渲染时直接调用 getTotalCostUSD(),因为它的渲染已经被其他 Layer 2 状态变化(如消息更新)触发了。

反模式 3:在 selector 中创建新对象

// ❌ 每次 setState 都触发重渲染
const data = useAppState(s => ({
  model: s.mainLoopModel,
  verbose: s.verbose,
}))
​
// ✅ 分别订阅
const model = useAppState(s => s.mainLoopModel)
const verbose = useAppState(s => s.verbose)

八、架构全景图

让我们用一张图来综合这一节的所有内容:

┌─────────────────────────────────────────────────────────────────────┐
│                    Claude Code 双层状态管理                           │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌──── Layer 1: bootstrap/state.ts (1759 行) ────────────────────┐ │
│  │                                                                │ │
│  │   const STATE: State = getInitialState()  // 模块级单例        │ │
│  │                                                                │ │
│  │   ┌─────────┐  ┌──────────┐  ┌──────────┐  ┌──────────────┐  │ │
│  │   │ 项目环境  │  │ 费用计数器 │  │ OTel遥测  │  │ Beta Header │  │ │
│  │   │originalCwd│ │totalCost │  │ meter    │  │  锁存器      │  │ │
│  │   │sessionId │  │modelUsage│  │ counters │  │ *Latched    │  │ │
│  │   └────┬─────┘  └────┬─────┘  └────┬─────┘  └──────┬───────┘  │ │
│  │        │get/set       │add          │set             │set/clear │ │
│  │                                                                │ │
│  │   导出接口: getter/setter 函数 + createSignal (IoC)             │ │
│  │   ⚠️ Import DAG 叶子节点 — 不导入其他业务模块                    │ │
│  └────────────────────────┬───────────────────────────────────────┘ │
│                           │                                         │
│              ┌────────────┼────────────┐                            │
│              │ onChangeAppState()      │ ← Layer 2 → Layer 1 桥梁   │
│              │ • mode → CCR/SDK        │                            │
│              │ • model → 持久化 + L1    │                            │
│              │ • settings → 清缓存      │                            │
│              └────────────┬────────────┘                            │
│                           │                                         │
│  ┌──── Layer 2: src/state/ (6 文件) ─────────────────────────────┐ │
│  │                                                                │ │
│  │   store.ts (35 行)         AppStateStore.ts (570 行)           │ │
│  │   ┌──────────────┐         ┌──────────────────────┐            │ │
│  │   │ createStore() │────────→│ AppState (100+ 字段)  │           │ │
│  │   │ • getState    │         │ • DeepImmutable<>    │            │ │
│  │   │ • setState    │         │ • UI / 权限 / 设置    │            │ │
│  │   │ • subscribe   │         │ • MCP / 任务 / Agent  │            │ │
│  │   │ • Object.is   │         │ • 投机 / 桥接 / 通知  │            │ │
│  │   └──────┬───────┘          └──────────────────────┘            │ │
│  │          │                                                      │ │
│  │   AppState.tsx (200 行)                                         │ │
│  │   ┌──────────────────────────────────────────┐                  │ │
│  │   │ AppStateProvider                          │                  │ │
│  │   │   └→ useState(() => createStore(...))     │                  │ │
│  │   │                                           │                  │ │
│  │   │ useAppState(selector)                     │                  │ │
│  │   │   └→ useSyncExternalStore(subscribe, get) │                  │ │
│  │   │   └→ 94+ 文件使用                          │                  │ │
│  │   │                                           │                  │ │
│  │   │ useSetAppState() → 只写不订阅               │                  │ │
│  │   └──────────────────────────────────────────┘                  │ │
│  │                                                                │ │
│  │   selectors.ts                teammateViewHelpers.ts            │ │
│  │   ┌─────────────────┐        ┌────────────────────┐            │ │
│  │   │ 纯计算选择器      │        │ 复杂状态机转换      │            │ │
│  │   │ • getViewedTask  │        │ • enterTeammateView│            │ │
│  │   │ • getActiveAgent │        │ • exitTeammateView │            │ │
│  │   └─────────────────┘        │ • stopOrDismiss    │            │ │
│  │                              └────────────────────┘            │ │
│  └────────────────────────────────────────────────────────────────┘ │
│                                                                     │
│  ┌──── 消费者 ──────────────────────────────────────────────────┐  │
│  │                                                                │  │
│  │  React 组件 (94+ 文件)          非 React 代码                   │  │
│  │  useAppState(s => s.verbose)    getSessionId()                 │  │
│  │  useAppState(s => s.model)      getTotalCostUSD()              │  │
│  │  useSetAppState()               setMainLoopModelOverride()     │  │
│  │                                 store.getState()               │  │
│  └────────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────┘

九、与其他 Agent 框架的对比

9.1 典型 Agent 框架的状态管理

大多数 Agent 框架(LangChain、AutoGPT、CrewAI)的状态管理相对简单——因为它们通常是 Python 脚本,没有持续运行的 UI:

# 典型 Agent 框架的"状态管理"
class AgentState:
    messages: list[Message]
    tools: list[Tool]
    current_step: int
    total_cost: float

一个 Python 类就够了。这对于批处理式的 Agent 完全合理——运行完就退出,不需要响应式 UI。

9.2 Claude Code 的独特挑战

Claude Code 与这些框架的本质区别在于它是一个长运行的交互式 TUI 应用

特征 典型 Agent 框架 Claude Code
运行模式 批处理/脚本 持续交互式
UI 无或简单日志 丰富的终端 UI (Ink/React)
状态生命周期 分钟级 小时级(长会话)
并发 UI 更新需求 高(消息流、状态栏、任务面板)
状态规模 ~10-20 字段 300+ 字段(两层合计)

这些差异决定了 Claude Code 必须有更精细的状态管理策略。双层架构不是过度设计,而是规模和性能需求的自然产物。

9.3 借鉴意义:给你的 Agent 项目的建议

如果你正在构建 Agent 项目,可以按以下思路评估是否需要双层架构:

  1. 纯后端 Agent(无 UI):单层就够了,用 Python 类或 TypeScript 对象

  2. 有简单 UI 的 Agent:单层 + 手动触发刷新

  3. 有丰富交互 UI 的 Agent:考虑双层——进程状态 + 响应式状态

  4. 多 Agent 协作 + 丰富 UI:强烈推荐双层 + 集中化副作用

关键原则:状态管理的复杂度应该与 UI 的复杂度成正比,而非与业务逻辑的复杂度成正比


十、实战演练:追踪一个权限模式变更

让我们用一个完整的端到端例子来串联本节的所有概念。假设用户在终端中按下 Shift+Tab 切换权限模式:


Step 1: 用户按下 Shift+Tab
  └→ PromptInput 组件的键盘事件处理器
​
Step 2: 事件处理器调用 setAppState
  └→ store.setState(prev => ({
       ...prev,
       toolPermissionContext: {
         ...prev.toolPermissionContext,
         mode: nextMode(prev.toolPermissionContext.mode),
       },
     }))
​
Step 3: createStore.setState 执行
  └→ const prev = state          // 旧状态
     const next = updater(prev)  // 新状态
     Object.is(next, prev)?      // false(新对象)
     state = next                 // 更新内部状态
​
Step 4: onChange 被调用 (onChangeAppState)
  └→ prevMode !== newMode?  // 是
     └→ toExternalPermissionMode(prevMode) !== toExternalPermissionMode(newMode)?
        └→ 是:notifySessionMetadataChanged({ permission_mode: newExternal })
                → CCR 收到通知 → Web UI 更新模式显示
        └→ notifyPermissionModeChanged(newMode)
                → SDK 状态流更新
​
Step 5: React 订阅者被通知
  └→ 所有调用 useAppState(s => s.toolPermissionContext) 的组件
     └→ useSyncExternalStore 调用 get()
        └→ selector 返回新的 toolPermissionContext
           └→ Object.is(newTC, oldTC)?  // false
              └→ 组件重渲染!
  └→ 调用 useAppState(s => s.verbose) 的组件
     └→ Object.is(oldVerbose, newVerbose)?  // true(没变)
        └→ 跳过重渲染 ✓
​
Step 6: 状态栏、权限图标等组件用新 mode 渲染
  └→ 用户看到模式切换的视觉反馈

整个流程中:

  • Layer 1 没有参与(权限模式是 UI 驱动的状态,在 Layer 2 中)

  • 只有关心权限的组件重渲染(selector 过滤)

  • CCR/SDK 通知在 React 重渲染之前完成(onChange 在 listeners 之前调用)

  • 从按键到 UI 更新,中间只有 6 步函数调用,没有异步操作(除了 CCR 网络请求是 fire-and-forget)


十一、本节核心收获

  1. 双层分治是 Claude Code 在性能和响应性之间取得平衡的关键策略:Layer 1(进程单例)处理高频/非 UI 状态,Layer 2(React Store)处理需要驱动 UI 的状态。

  2. 35 行 createStore 证明了"够用就好"的工程哲学——Object.is 浅比较 + useSyncExternalStore 兼容就足够支撑 94+ 文件的使用规模,无需引入 Redux/Zustand。

  3. onChangeAppState 作为集中化的副作用枢纽,解决了"8 个变更路径只有 2 个正确通知外部系统"的历史 bug。这是关注点分离的经典体现——状态变更逻辑和副作用逻辑解耦。

  4. Import DAG 叶子节点约束确保了 bootstrap/state.ts 的启动确定性和循环依赖免疫。三处渐进加强的注释警告体现了"架构治理即代码注释"的实践。

  5. 锁存器模式是状态管理与上一节 prompt cache 优化的交叉——一个 Beta Header 的抖动就可能浪费数万 Token 的缓存。

  6. selector 约束(不返回新对象)是 useSyncExternalStore + Object.is 组合的必然要求——违反它会导致每次 setState 都触发全组件重渲染。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐