第七节:双层状态管理 —— 进程状态 vs React 状态
上一节我们解决了"Agent 能持续工作多久"的问题——四级压缩策略让 Agent 在 200K 上下文窗口中游刃有余,Agent 运行时还有大量其他状态需要管理——当前权限模式是什么?MCP 服务器连接了几个?用户偏好设置是什么?费用累积了多少?Claude Code 的巧妙之处在于它用两层状态分别管理不同类型的信息:底层是纯 TypeScript 的进程全局单例,上层是 React 响应式 S
知识图谱定位:上一节我们解决了"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
为什么叶子节点如此重要?
-
避免循环依赖:如果
state.ts依赖了toolRunner.ts,而toolRunner.ts又依赖state.ts,Node.js 的模块加载器会返回不完整的导出对象,导致运行时undefined错误。 -
启动顺序确定性:叶子节点最先完成初始化,所有依赖它的模块都能安全地读取初始状态。
-
构建产物可预测: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')
}
关键设计选择:
-
STATE不导出:外部代码无法直接import { STATE }来读写,必须通过 getter/setter。这确保了所有状态变更都有明确的函数调用可追踪。 -
getter/setter 是具名函数:不是
getState('sessionId')这样的动态访问,而是getSessionId()这样的具名函数。好处是 IDE 的"查找引用"能精确定位所有读写点。 -
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:不存在(这个锁存器只有null和true两个有效状态)
为什么需要锁存器?
用户启动 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 签名(getState、subscribe)恰好是 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),但包含函数类型或复杂可变结构的字段(tasks、mcp、plugins)被排除在外。这是因为 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)
使用规模:通过搜索代码库,useAppState 被 94+ 个文件使用。这意味着 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>
)
}
几个关键设计点:
-
[store]使用useState惰性初始化:createStore只在首次渲染时调用一次,此后store引用永远不变。 -
双 Context 嵌套:
HasAppStateContext(布尔值)用于检测嵌套,AppStoreContext(Store 实例)用于传递 Store。 -
设置文件变化监听:
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 的完整实现
这个函数作为 createStore 的 onChange 回调注入,在每次 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 内部有些模式(如 bubble、ungated_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 如何在不可变更新中处理复杂的状态机转换:
-
先计算所有需要变化的条件(
switching、needsRetain、needsView) -
如果什么都不变,返回
prev(触发Object.is短路) -
只在必要时创建新的
tasks对象(懒拷贝) -
一次性返回所有变更
这个 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 项目,可以按以下思路评估是否需要双层架构:
-
纯后端 Agent(无 UI):单层就够了,用 Python 类或 TypeScript 对象
-
有简单 UI 的 Agent:单层 + 手动触发刷新
-
有丰富交互 UI 的 Agent:考虑双层——进程状态 + 响应式状态
-
多 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)
十一、本节核心收获
-
双层分治是 Claude Code 在性能和响应性之间取得平衡的关键策略:Layer 1(进程单例)处理高频/非 UI 状态,Layer 2(React Store)处理需要驱动 UI 的状态。
-
35 行
createStore证明了"够用就好"的工程哲学——Object.is浅比较 +useSyncExternalStore兼容就足够支撑 94+ 文件的使用规模,无需引入 Redux/Zustand。 -
onChangeAppState作为集中化的副作用枢纽,解决了"8 个变更路径只有 2 个正确通知外部系统"的历史 bug。这是关注点分离的经典体现——状态变更逻辑和副作用逻辑解耦。 -
Import DAG 叶子节点约束确保了
bootstrap/state.ts的启动确定性和循环依赖免疫。三处渐进加强的注释警告体现了"架构治理即代码注释"的实践。 -
锁存器模式是状态管理与上一节 prompt cache 优化的交叉——一个 Beta Header 的抖动就可能浪费数万 Token 的缓存。
-
selector 约束(不返回新对象)是
useSyncExternalStore+Object.is组合的必然要求——违反它会导致每次setState都触发全组件重渲染。
更多推荐


所有评论(0)