Claude Code 源码分析第 12 课 · 第 01
第 12 课

状态管理

从35行定制店到400个领域 AppState — Claude Code 如何在没有 Redux、Zustand 或上下文抖动的情况下保持每个组件同步。

01 Overview

大多数 React 应用程序在其状态需要跨越组件树时都会使用库(Redux、Zustand、Jotai)。 Claude Code 用 35 行 TypeScript 从头开始​​构建自己的存储,并将其直接插入到 React 的内置中 useSyncExternalStore 钩。 了解原因以及最重要的内容对于应对 REPL 的任何重要更改至关重要。

覆盖源文件
state/store.tsstate/AppStateStore.tsstate/AppState.tsxstate/onChangeAppState.tsstate/selectors.tsstate/teammateViewHelpers.ts

该架构具有三个不同的层:

第 1 层 — 原始层

createStore<T>

通用的、无框架的商店。 35行。 对 React 或 AppState 一无所知。

第 2 层 — 域

应用状态 + 应用状态存储

完整的州形态(400+ 场)及其工厂。 与 React 分开,所以 .ts 调用者不会拉入 React。

第三层——React

AppStateProvider + 钩子

将存储连接到上下文的提供者。 useAppState, useSetAppState, useAppStateStore.

Side-Effect

onChangeAppState

单个差异观察者传递为 onChange to createStore。 每次状态转换时都会触发。

导出状态

selectors.ts

AppState 切片上的纯函数 — 无副作用,无存储访问权限。

转换逻辑

teammateViewHelpers.ts

用于队友视图转换的状态更新器 - 与它们所服务的功能位于同一位置。

02 创建商店模式

整个商店原语位于 state/store.ts。 三个概念,35行:

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) => {
      const prev = state
      const next = updater(prev)
      if (Object.is(next, prev)) return   // bail if no change
      state = next
      onChange?.({ newState: next, oldState: prev })  // side-effect hook
      for (const listener of listeners) listener()  // notify React
    },

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

为什么不使用useState/useReducer?

React 自己的钩子将状态生命周期与组件树联系起来。 Claude Code 需要可从非 React 代码读取的状态:无头模式、SDK 打印层、每个进程的队友会话、 onChangeAppState 副作用链。 一个普通的 JS 对象,带有 Set 的侦听器在 React 之外传递不需要任何成本。

为什么不声明/Jotai?

零额外的捆绑依赖。 接口 Claude Code 实际上需要 — getState, setState, subscribe — 准确映射到什么 useSyncExternalStore 需要。 没有什么可买的了。

键不变量
setState 需要一个 更新功能 (prev) => next,绝不是部分对象。 这强制了调用站点的不变性:调用者必须传播先前的状态并返回新的引用。 Object.is 相等性检查意味着仅当引用实际更改时才会触发重新渲染。
深入探讨:useSyncExternalStore 如何适合

React 18 介绍 useSyncExternalStore 正是针对像这样的商店。 它需要三个参数: subscribe, getSnapshot,以及一个可选的 getServerSnapshot。 合同:

  • subscribe — 注册回调,返回取消订阅函数。 火柴 store.subscribe exactly.
  • getSnapshot — 同步返回当前值。 这是选择器包装的 store.getState().

响应调用 getSnapshot 在渲染期间读取当前值,并在存储更新时调用订阅的回调 - 仅当快照更改时才触发重新渲染。 这提供了无撕裂的更新,无需商店需要任何 React 内部调度程序合作。

// From AppState.tsx — the full useAppState implementation:
export function useAppState(selector) {
  const store = useAppStore()
  const get = () => selector(store.getState())
  return useSyncExternalStore(store.subscribe, get, get)
}

The get 如果满足以下条件,则在每次渲染时重新创建闭包 selector 改变身份——这就是编译输出的原因(React Compiler的 _c 备忘录缓存)通过以下方式缓存它 [selector, store]。 将内联箭头作为选择器传递,您将在每次渲染时击败缓存。

03 AppState 形状

AppState 定义于 state/AppStateStore.ts。 它很大——超过 90 个不同的领域——但它有清晰的内部结构。 类型是 DeepImmutable<{...}> 对于可序列化部分,有少量字段可以转义不变性包装器(函数类型、 Map, Set)明确列在 &.

逃生舱口
tasks, agentNameRegistry, sessionHooks, activeOverlays, 和 replContext 没有包裹在 DeepImmutable 因为它们包含 TypeScript 的递归只读转换无法处理的函数类型。 即使类型允许突变,也将它们视为逻辑上不可变的(始终在突变之前传播)。

这些字段分为六个逻辑类别:

会话核心

型号及设置

  • 设置,详细
  • mainLoopModel、mainLoopModelForSession
  • 思维启用、努力值、快速模式
  • kairosEnabled、代理、authVersion
UI 状态

查看和导航

  • 扩展视图,仅是简短的
  • 页脚选择、微调器提示
  • activeOverlays、statusLineText
  • viewSelectionMode、coordinatorTaskIndex
Permissions

工具与拒绝

  • toolPermissionContext(模式,绕过标志)
  • denialTracking
  • 初始消息(模式覆盖)
  • pendingPlanVerification
代理和任务

Concurrency

  • 任务(以taskId为键)
  • agentNameRegistry(名称 → AgentId)
  • foregroundedTaskId、viewingAgentTaskId
  • teamContext、standaloneAgentContext
远程和桥接

Connectivity

  • 远程会话 URL、远程连接状态
  • replBridgeEnabled/Connected/Active/...
  • ultraplanSessionUrl, isUltraplanMode
  • workerSandboxPermissions
子系统状态

Features

  • mcp(客户端、工具、命令、资源)
  • 插件(启用、禁用、安装状态)
  • 推测、提示建议
  • 通知、启发、待办事项、收件箱
  • tungstenActive*(tmux 面板)、百吉饼*(浏览器)
  • computerUseMcpState、replContext、文件历史记录

选定的字段参考

FieldTypePurpose
settingsSettingsJson完整的 settings.json 内容 — 几乎每个子系统都会读取
mainLoopModelModelSetting活动模型覆盖; null = 使用默认值。 通过 onChangeAppState 写入更改设置
toolPermissionContextToolPermissionContext当前权限模式(默认/计划/自动/yolo)加上旁路可用性标志
tasks{ [taskId]: 任务状态 }所有正在进行的代理任务的实时状态(local_agent、in_process_teammate 等)
agentNameRegistry地图<字符串,代理 ID>名称→由Agent工具填充的AgentId路由表; 最新的碰撞胜利
speculationSpeculationState具有边界、中止和流水线建议状态的空闲或主动预测完成
expandedView'无' | '任务' | ‘队友’控制展开哪个面板; 通过 onChangeAppState 持久化到 globalConfig
notifications{ 当前,队列 }优先排队通知系统; useNotifications 管理转换
replBridgeEnabledboolean始终在线桥的所需状态(由 /config 或页脚切换控制)
mcp.pluginReconnectKeynumber通过 /reload-plugins 单调递增; 效果将其视为依赖触发器
initialMessage{ 消息、模式、... } | 无效的设置以编程方式触发 REPL 查询(CLI args,计划模式退出)
activeOverlaysReadonlySet<string>打开的选择对话框的注册表 - 执行操作之前使用 Esc 键检查此项
fileHistoryFileHistoryState快照+跟踪文件以支持撤消/倒回
attributionAttributionState提交 git 操作的作者身份跟踪
tungstenActiveSession{ 会话名称, 套接字名称, 目标 } | 未定义活动的 tmux 集成会话(仅限 ant)
computerUseMcpState{ allowedApps, grantFlags, ... } | 未定义每个会话计算机使用允许列表和显示状态 (chicago MCP)
深入探讨:DeepImmutable 和逃生舱口

The AppState 类型声明的结构如下:

export type AppState = DeepImmutable<{
  settings: SettingsJson
  verbose: boolean
  // ... ~60 serializable fields ...
}> & {
  // Excluded from DeepImmutable — contain function types
  tasks: { [taskId: string]: TaskState }
  agentNameRegistry: Map<string, AgentId>
  mcp: { clients: MCPServerConnection[]; /* ... */ }
  // ...
}

DeepImmutable 是一种递归条件类型,它将每个嵌套对象包装在 Readonly<>。 交叉点(&) 将可变类型字段追加回来,而不使用不变性包装器。 TypeScript 编译器接受这一点,因为交集合并了属性集: Readonly 当字段名称冲突时,前半部分每个字段的版本实际上会被后半部分的原始类型覆盖 - 但目的是单独列出它们,以便它们在结构上可见。

实际上,没有什么可以阻止调用者发生变异 tasks[id].someField = x 在 JS 运行时级别。 该纪律来自团队惯例和更新者模式 setState.

深入探讨:SpeculationState——最复杂的领域

speculation 是一个有两条手臂的受歧视联盟:

type SpeculationState =
  | { status: 'idle' }
  | {
      status: 'active'
      id: string
      abort: () => void
      startTime: number
      messagesRef: { current: Message[] }     // mutable ref — no array copy per msg
      writtenPathsRef: { current: Set<string> } // relative paths in overlay
      boundary: CompletionBoundary | null
      suggestionLength: number
      toolUseCount: number
      isPipelined: boolean
      contextRef: { current: REPLHookContext }
      pipelinedSuggestion?: { text: string; promptId: ...; generationRequestId: ... } | null
    }

The messagesRef and writtenPathsRef 字段是 故意可变的 ——它们逃避了不变性合约,这样推测就可以将消息附加到正在进行的预测中,而不会触发每个代币的完整存储更新(和重新渲染)。 ref直接变异; 仅进行状态转换(空闲 ↔ 活动) setState.

04 onChangeAppState — 副作用阻塞点

第二个参数 createStore 是一个可选的 onChange 打回来。 Claude Code 通过 onChangeAppState 这里 - 触发的单个函数 every 状态转换和差异相关字段以驱动副作用。

为什么这很重要
在引入此模式之前,权限模式更改仅通过 8 个以上突变路径中的 2 个转发到远程仪表板 (CCR)。 其他 6 条路径默默地改变了 AppState,使 Web UI 变得陈旧。 在这里集中差异意味着 any setState 更改模式的呼叫会自动同步 - 对各个呼叫站点的更改为零。

当前的 diff 块位于 onChangeAppState:

export function onChangeAppState({ newState, oldState }) {

  // 1. Permission mode — sync to CCR external_metadata + SDK status stream
  const prevMode = oldState.toolPermissionContext.mode
  const newMode = newState.toolPermissionContext.mode
  if (prevMode !== newMode) {
    const prevExternal = toExternalPermissionMode(prevMode)
    const newExternal  = toExternalPermissionMode(newMode)
    if (prevExternal !== newExternal) {
      // Guard: internal-only modes (bubble, ungated-auto) don't pollute CCR.
      // is_ultraplan_mode set null per RFC 7396 (removes key from JSON patch).
      notifySessionMetadataChanged({ permission_mode: newExternal, ... })
    }
    notifyPermissionModeChanged(newMode)  // SDK channel — passes raw mode
  }

  // 2. mainLoopModel — persist to settings + bootstrap override
  if (newState.mainLoopModel !== oldState.mainLoopModel) {
    if (newState.mainLoopModel === null) {
      updateSettingsForSource('userSettings', { model: undefined })
      setMainLoopModelOverride(null)
    } else {
      updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
      setMainLoopModelOverride(newState.mainLoopModel)
    }
  }

  // 3. expandedView — persist to globalConfig (showExpandedTodos / showSpinnerTree)
  if (newState.expandedView !== oldState.expandedView) {
    saveGlobalConfig(current => ({
      ...current,
      showExpandedTodos: newState.expandedView === 'tasks',
      showSpinnerTree:   newState.expandedView === 'teammates',
    }))
  }

  // 4. verbose — persist to globalConfig
  if (newState.verbose !== oldState.verbose) {
    saveGlobalConfig(current => ({ ...current, verbose: newState.verbose }))
  }

  // 5. tungstenPanelVisible — ant-only, persist to globalConfig
  if (process.env.USER_TYPE === 'ant' && newState.tungstenPanelVisible !== oldState.tungstenPanelVisible) {
    saveGlobalConfig(current => ({ ...current, tungstenPanelVisible: newState.tungstenPanelVisible }))
  }

  // 6. settings — clear auth caches + re-apply env vars
  if (newState.settings !== oldState.settings) {
    clearApiKeyHelperCache()
    clearAwsCredentialsCache()
    clearGcpCredentialsCache()
    if (newState.settings.env !== oldState.settings.env) {
      applyConfigEnvironmentVariables()
    }
  }
}

外化守卫

并非所有内部权限模式都有外部等效模式。 这 toExternalPermissionMode 调用折叠了仅限内部的名称,例如 'bubble' and 'ungated-auto' to 'default' 在发送至 CCR 之前。 如果没有这个,远程仪表板将收到无意义的模式名称,并可能在内部转换上循环(default → bubble → default)从外部是看不见的。

深入探讨:externalMetadataToAppState — 逆过程

该文件还导出 externalMetadataToAppState,这是 inverse 权限模式推送:当工作进程重新启动并拉取时 SessionExternalMetadata 从 CCR 会话存储中,它称之为水合 AppState 与持久模式。

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 }
      : {}),
  })
}

注意它返回一个更新函数 (prev) => AppState — 它被设计为直接传递给 store.setState()。 这是所有 AppState 突变的常规形状。

05 React Hooks 层

state/AppState.tsx 是商店的 React 面孔。 它导出三个钩子和一个提供程序。 该文件是 React 编译的(带有 _c 来自 React 编译器),所以原始源代码读起来有点奇怪——但语义很干净。

// Read a slice — re-renders only when the selected value changes
const verbose = useAppState(s => s.verbose)
const model   = useAppState(s => s.mainLoopModel)

// Write without subscribing — stable reference, never causes re-renders
const setAppState = useSetAppState()
setAppState(prev => ({ ...prev, verbose: true }))

// Get the raw store — for passing to non-React helpers
const store = useAppStateStore()
doSomethingOutsideReact(store.getState, store.setState)
选择器规则
不要从选择器返回新的对象或数组。 useSyncExternalStore 与快照进行比较 Object.is。 内联 s => ({ a: s.a, b: s.b }) 在每次渲染时创建一个新对象,这会触发无限的重新渲染循环。 返回单个子对象引用或基元:
// Good — returns existing reference
const { text, promptId } = useAppState(s => s.promptSuggestion)

// Bad — new object every render
const { text, promptId } = useAppState(s => ({ text: s.promptSuggestion.text, promptId: s.promptSuggestion.promptId }))

AppStateProvider 设置

AppStateProvider 创建商店一次(通过 useState 惰性初始化器),电线 onChangeAppState 如果在安装组件之前加载了远程设置,则应用一次性修复:

const [store] = useState(() =>
  createStore(
    initialState ?? getDefaultAppState(),
    onChangeAppState,        // side-effect hook wired here
  )
)

// One-time remote-settings fixup on mount
useEffect(() => {
  const { toolPermissionContext } = store.getState()
  if (toolPermissionContext.isBypassPermissionsModeAvailable
      && isBypassPermissionsModeDisabled()) {
    store.setState(prev => ({
      ...prev,
      toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext)
    }))
  }
}, [])
06 选择器和转换助手

selectors.ts — 纯派生

state/selectors.ts 包含从中导出计算值的函数 AppState 切片而不访问商店或产生副作用。 他们接受一个 Pick<AppState, ...> (不是完整状态),以便调用者可以单独测试它们。

/**
 * Get the currently viewed teammate task, if any.
 * Takes a Pick — not the full AppState — so tests don't need the whole object.
 */
export function getViewedTeammateTask(
  appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>
): InProcessTeammateTaskState | undefined { ... }

/**
 * Discriminated union — tells input routing exactly where to send a message.
 */
export type ActiveAgentForInput =
  | { type: 'leader' }
  | { type: 'viewed';     task: InProcessTeammateTaskState }
  | { type: 'named_agent'; task: LocalAgentTaskState       }

export function getActiveAgentForInput(appState: AppState): ActiveAgentForInput { ... }

teammateViewHelpers.ts — 共置状态转换

队友视图功能的状态转换更加复杂 state/teammateViewHelpers.ts。 这些不是 React hooks——它们需要 setAppState 作为一个参数,使它们可以在任何上下文中进行测试和使用。

// Enter a teammate's transcript view — retain: true blocks eviction, loads from disk
export function enterTeammateView(
  taskId: string,
  setAppState: (updater: (prev: AppState) => AppState) => void,
): void

// Exit back to leader's view — releases retain, schedules eviction if terminal
export function exitTeammateView(
  setAppState: (updater: (prev: AppState) => AppState) => void,
): void

// Context-sensitive x button: abort if running, dismiss if terminal
export function stopOrDismissAgent(
  taskId: string,
  setAppState: (updater: (prev: AppState) => AppState) => void,
): void
设计模式
The release(task) 里面的帮手 teammateViewHelpers.ts 是本地助手卫生的一个很好的例子:它不是导出的(文件之外没有人需要它),它是纯粹的(接受一个任务,返回一个任务),并且它在一个地方编码一个策略决策——“释放”一个任务意味着 retain: false, messages: undefined,并设置 evictAfter 如果任务处于终止状态。
深入探讨:代理任务的保留/驱逐生命周期

后台面板中的代理任务行遵循保留/驱逐生命周期:

  • 存根形式: retain: false, messages: undefined。 行显示在面板中,但未加载任何转录本。
  • 保留形式: retain: true, 消息已加载。 触发于 enterTeammateView。 阻止驱逐并启用流式传输。
  • 驱逐待决: 任务已结束, evictAfter = Date.now() + 30_000。 行徘徊 30 秒 (PANEL_GRACE_MS),以便用户看到它完成,然后过滤器将其删除。
  • 立即解雇: evictAfter = 0。 过滤器立即隐藏行。 由终端任务上的 x 按钮触发。

The PANEL_GRACE_MS = 30_000 常量内联在两者中 framework.ts and teammateViewHelpers.ts 带有指示它们保持同步的注释 - 这是一个有意的选择,以避免跨模块边界导入,这会通过以下方式创建循环依赖项 BackgroundTasksDialog.

07 完整数据流程图

状态更新如何从组件通过系统移动:

flowchart TD A["Component calls\nuseSetAppState()"] -->|"returns store.setState"| B["store.setState(updater)"] B --> C{"Object.is(next, prev)?"} C -->|"same"| Z["Return — no-op"] C -->|"changed"| D["state = next"] D --> E["onChangeAppState({ newState, oldState })"] E --> F1["permission mode changed?"] E --> F2["mainLoopModel changed?"] E --> F3["expandedView changed?"] E --> F4["settings changed?"] F1 -->|"yes"| G1["notifySessionMetadataChanged\nnotifyPermissionModeChanged"] F2 -->|"yes"| G2["updateSettingsForSource\nsetMainLoopModelOverride"] F3 -->|"yes"| G3["saveGlobalConfig"] F4 -->|"yes"| G4["clearAuthCaches\napplyConfigEnvironmentVariables"] D --> H["for listener of listeners: listener()"] H --> I["useSyncExternalStore\ntriggers re-render"] I --> J["selector(store.getState())\ncompares with Object.is"] J -->|"changed"| K["Component re-renders"] J -->|"same"| L["Render skipped"]
08 上下文与状态:上下文所在的位置

并非所有内容都在 context/ 是传统意义上的React Context。 该目录包含多种模式:

反应上下文(薄)

modalContext、overlayContext、promptOverlayContext

真正的 React Context——在组件树中共享的值,而不是存储在 AppState 中。

挂钩 AppState

notifications.tsx

Reads/writes AppState.notifications via useAppState + useSetAppState。 没有当地的州。

外部存储模式

fps指标、统计数据

在 AppState 之外拥有他们的数据(性能指标不需要成为主要差异的一部分)。 可以使用自己的存储或模块级状态。

副作用管理器

邮箱、语音、QueuedMessage

管理 WebSocket 或 IPC 连接。 可以读取 AppState 但主要是驱动效果。

context.ts 不同
顶层 context.ts (not context/) 与 React Context 完全无关。 它构建了注入每个 Claude API 调用的系统提示符: getSystemContext() (git 状态、缓存断路器)和 getUserContext() (CLAUDE.md 文件,当前日期)。 两者都是 memoize()d 为对话的生命周期。

要点

  • createStore<T> 35 行 TypeScript 完全实现了该接口 useSyncExternalStore 需要——不需要图书馆。
  • setState 采用更新程序功能,而不是部分功能。 调用者总是传播之前的状态。 这 Object.is 救助可以防止虚假的重新渲染。
  • AppState 是故意的——它是整个会议的唯一事实来源。 替代方案(分散的模块单例)将更难以测试和重置。
  • The AppState 类型用途 DeepImmutable<...> & { mutables } 使可序列化核心只读,同时保持函数类型字段可用。
  • onChangeAppState 是架构的关键:将状态变化的所有副作用集中在单个差异观察者中意味着新的突变路径会自动免费获得正确的行为。
  • 选择器采用 Pick<AppState, ...> 不是完整的状态——使它们可以在不构建完整的默认状态的情况下进行测试。
  • 过渡助手如 enterTeammateView receive setAppState 作为一个论点——让它们与框架无关并且可以在 React 之外进行测试。
  • 代理任务行的保留/驱逐生命周期完全通过 AppState 字段进行管理(retain, evictAfter)而不是组件本地状态。

测验

1. 什么是 store.setState 当更新程序返回与收到的完全相同的引用时该怎么办?
2. 为什么会 AppStateStore.ts 住在一个 .ts 文件而不是 .tsx 文件?
3. 写作有什么危险 useAppState(s => ({ a: s.a, b: s.b }))?
4. Before onChangeAppState 引入后,权限模式同步的主要问题是什么?
5. 发生了什么 onChangeAppState 当权限模式从 'default' to 'bubble' (仅限内部模式)?
6. 为什么会 teammateViewHelpers.ts inline PANEL_GRACE_MS = 30_000 而不是从导入它 framework.ts?
0/6