状态管理机制:Claude Code 如何组织运行时状态

这篇文档只回答一个问题:

src/ 里的运行时状态,到底是怎样被定义、初始化、订阅、更新并扩散副作用的?

先给结论:

  • 这套实现不是 Redux/Zustand 一类第三方状态库方案
  • 也不是纯 React useState / useReducer 驱动的组件内状态方案
  • 它的核心模式是:自研外部 store + Context 注入 + useSyncExternalStore 切片订阅
  • 在整体上,项目采用的是:一个主 AppState + 多个领域专用 store / 轻量 Context 的组合式架构

可以先把主链路记成下面这条:

初始化默认状态
  -> 创建 store
  -> Provider 注入 / Headless 直连
  -> 组件按 selector 订阅
  -> setState 更新
  -> onChangeAppState 扩散副作用

1. 先判断:这不是“单一状态容器包打天下”

如果只看目录,很容易以为这个项目会使用 Redux、Zustand、MobX 之类的现成方案,但实际源码不是这样。

围绕状态管理的核心文件主要有:

  • src/state/store.ts
  • src/state/AppState.tsx
  • src/state/AppStateStore.ts
  • src/state/onChangeAppState.ts
  • src/components/App.tsx
  • src/main.tsx

它们共同构成了一个很明确的分层:

  1. store.ts 提供最小外部 store 原语
  2. AppStateStore.ts 定义全局状态结构和默认值
  3. AppState.tsx 把 store 接到 React 树里,并提供读写 hook
  4. onChangeAppState.ts 负责状态变化后的副作用扩散
  5. 其他领域再按需要选择:
    • 进入主 AppState
    • 自己维护专用 store
    • 或只用一个轻量 Context 做局部依赖注入

这意味着它并不追求“所有状态都进一个 reducer”,而是追求:

  • 全局会话态统一
  • 局部领域态按需隔离
  • React 组件和非 React 代码都能共享同一份状态心智模型

2. 第 1 层:最底层的 store 原语

src/state/store.ts 是整个状态体系最底层的基石。它没有引入任何复杂框架,只定义了一个很小的 store 接口:

  • getState()
  • setState(updater)
  • subscribe(listener)

这份实现有几个特点:

2.1 setState 是同步的

调用 setState 时,会立刻:

  1. 读取旧状态 prev
  2. 用 updater 计算新状态 next
  3. 如果 Object.is(next, prev) 为真就直接跳过
  4. 否则写入新状态并通知订阅者

这让它既能服务 React,也能服务命令式逻辑。
很多异步流程会在更新后立刻调用 getState() 读取最新值,而不用等待下一轮渲染。

2.2 它是“外部 store”,不是组件 state

这里的状态不挂在某个组件实例上,而是一个独立对象。
这正是后面可以接 useSyncExternalStore 的原因。

2.3 它自带 onChange

createStore(initialState, onChange?) 支持在每次状态变化后执行回调。
这个能力后面会被 AppState 用来接 onChangeAppState,形成“状态更新”和“副作用扩散”分离的结构。

3. 第 2 层:全局应用状态 AppState

如果说 store.ts 提供的是“状态引擎”,那么 AppState 就是这个项目的主状态平面。

3.1 AppStateStore.ts 是全局 schema 中心

src/state/AppStateStore.ts 负责两件事:

  • 定义 AppState 的完整类型
  • 提供 getDefaultAppState() 作为默认状态装配点

这里可以把它理解成“全局运行时状态表”。

状态域非常多,但典型的核心区域包括:

  • tasks:后台任务、子代理、远程任务等统一任务状态
  • mcp:MCP clients / tools / commands / resources
  • plugins:插件启用状态、命令、错误、安装状态
  • notifications:当前通知与通知队列
  • toolPermissionContext:工具权限模式和权限上下文
  • promptSuggestion:Prompt 建议的展示与接受状态
  • teamContext:swarm / teammate 的团队上下文

除此之外,还有文件历史、bridge 状态、远程会话状态、overlay 集合、plan mode 相关状态等。
这说明 AppState 不是 UI 小状态集合,而是整个会话 runtime 的控制平面。

3.2 默认状态从 getDefaultAppState() 开始

getDefaultAppState() 负责初始化整个会话的默认值,例如:

  • 空的 tasks
  • 空的 mcp.clients/tools/commands/resources
  • 初始 toolPermissionContext
  • 初始 notifications
  • 各种 bridge / remote / prompt suggestion / overlay 的默认状态

这里不是简单返回一个“空对象”,而是把运行时真正需要的初始结构一次性装配好。
后面无论交互态还是无头态,都是从这份默认结构继续扩展。

4. 第 3 层:React 如何消费这份全局状态

src/state/AppState.tsx 负责把外部 store 接进 React。

4.1 AppStateProvider 做了什么

AppStateProvider 的核心逻辑很直接:

  1. 用 createStore(initialState ?? getDefaultAppState(), onChangeAppState) 创建 store
  2. 用 AppStoreContext 把 store 注入到 React 树
  3. 暴露给子组件统一消费

它还做了两件补充工作:

  • 挂接 settings 变化监听,把外部 settings 文件变化同步回 AppState
  • 在 provider 内层再包 MailboxProvider 和可选 VoiceProvider

一个重要细节是:store 在 provider 生命周期里只创建一次。
这让 context value 保持稳定,provider 本身不会因为每次状态更新而触发整棵树重渲染。

4.2 useAppState 是“按切片订阅”

useAppState(selector) 内部用的是:

  • store.getState() 读取快照
  • useSyncExternalStore(store.subscribe, get, get) 建立订阅

它的语义不是“拿整个 state”,而是“订阅某个切片”。

例如:

const verbose = useAppState(s => s.verbose)
const permissionMode = useAppState(s => s.toolPermissionContext.mode)

这样组件只会在选中的值变化时重渲染,而不会因为其他字段变化被动刷新。

4.3 useSetAppState 是“只写不订阅”

useSetAppState() 直接返回 store.setState。
这意味着:

  • 调用方可以更新全局状态
  • 但不会因为状态变化而重渲染

这种模式特别适合:

  • 命令按钮
  • 事件处理器
  • 只负责发起状态变更的 hooks

4.4 useAppStateStore 是给非 React / 异步逻辑的命令式入口

useAppStateStore() 直接返回整个 store。
这样调用方就能使用:

  • store.getState()
  • store.setState()

这在很多异步流程里很关键,因为它可以避免 stale closure。

典型场景是:

  • useCancelRequest.ts
  • useInboxPoller.ts
  • useTypeahead.tsx
  • useReplBridge.tsx

这些逻辑经常需要在定时器、异步回调或事件处理中读取“此刻最新”的状态,而不是依赖某次渲染时捕获的旧快照。

4.5 交互态和无头态共享同一套 store 心智模型

交互态入口在 src/components/App.tsx:

  • 外层挂 FpsMetricsProvider
  • 再挂 StatsProvider
  • 再挂 AppStateProvider initialState={initialState} onChangeAppState={onChangeAppState}

无头态则在 src/main.tsx 里直接创建:

const headlessStore = createStore(headlessInitialState, onChangeAppState)

这说明两种模式虽然 UI 不同,但底层状态组织方式是一致的:

  • 都从 AppState 出发
  • 都使用同一个 store 原语
  • 都复用同一个 onChangeAppState

也就是说,项目并没有把“交互式 UI 状态”和“headless/SDK 状态”拆成两套完全不同的机制。

5. 第 4 层:状态更新之后,副作用往哪里走

src/state/onChangeAppState.ts 是理解这套系统的关键文件。

它不是 reducer,而是“状态变化后的副作用汇聚点”。

换句话说:

  • setState 只负责把状态从 A 变到 B
  • onChangeAppState 负责观察 oldState -> newState 的变化,并把需要的副作用同步出去

5.1 它负责哪些同步

从当前实现看,主要包括这几类:

  • 权限模式变化:向 CCR / SDK 状态通道同步 permission mode
  • 模型配置变化:把 mainLoopModel 写回 settings / bootstrap override
  • 全局配置持久化:例如 expandedView、verbose、tungstenPanelVisible
  • settings 变化后的刷新:清理 auth 相关 cache,并重新应用环境变量

这带来一个很重要的好处:

  • 状态更新逻辑可以尽量保持纯粹
  • 副作用不必散落在每个 setState 调用点

对于大型项目来说,这种“单一副作用出口”比到处手写同步逻辑更稳。

6. 为什么要避免 selector 返回新对象

useAppState 订阅切片时,比较依据是 Object.is。
因此它天然偏向“引用稳定”的子对象或标量值。

这也是 AppState.tsx 里明确强调的约束:

  • 不要让 selector 直接返回新对象
  • 要尽量选择已有引用

例如推荐:

const promptSuggestion = useAppState(s => s.promptSuggestion)

而不是:

const value = useAppState(s => ({ text: s.promptSuggestion.text }))

原因很简单:后一种写法每次都会生成新对象,Object.is 一定判定为变化,订阅优化就失效了。

7. 不可变更新约定:为什么 Set / Map 要新建引用

这套状态体系默认遵循不可变更新约定。
也就是说,更新时通常会:

  • 返回新的顶层对象
  • 对被修改的子对象创建新引用

这一点在普通对象上很好理解,在 Set / Map 上尤其重要。

例如:

  • AppState 里有 agentNameRegistry: Map<string, AgentId>
  • activeOverlays: ReadonlySet<string>

如果直接原地修改这些结构,外层引用不变,订阅方就可能感知不到变化。
所以像 src/context/overlayContext.tsx 这类代码会显式:

  1. 用旧的 Set 构造一个新的 Set
  2. 在新引用上 add / delete
  3. 再把新引用写回 state

这和 React 社区常见的不可变更新原则是一致的,只是这里不依赖 Immer 之类工具,而是手工维持引用边界。

8. 局部专用 store:不是所有状态都进 AppState

这个仓库没有把所有状态都塞进全局 AppState,而是明确保留了一些领域专用状态容器。

这不是分裂,而是分层。

8.1 src/context/voice.tsx:局部专用 store

voice.tsx 的实现几乎就是 AppState 模式的缩小版:

  • 仍然用 createStore(DEFAULT_STATE)
  • 仍然用 Context 注入 store
  • 仍然用 useSyncExternalStore 做切片订阅

它提供了:

  • useVoiceState(selector):响应式读取
  • useSetVoiceState():只写不订阅
  • useGetVoiceState():命令式读取最新语音状态

之所以不把这些字段直接并入 AppState,是因为它只服务语音域,独立维护更清晰,也避免让全局状态继续膨胀。

8.2 src/context/stats.tsx:指标收集 store

stats.tsx 维护的是另一种完全不同的状态:

  • counter
  • gauge
  • histogram
  • set

它的目标不是驱动 UI 响应式刷新,而是收集指标,并在进程退出时 flush 到配置中。
因此它虽然也是一个 store,但职责更像 telemetry accumulator,而不是应用会话状态中心。

8.3 src/hooks/useTasksV2.ts:模块级单例 store

useTasksV2.ts 更有代表性。
它既不走 AppState,也不是简单 Context,而是一个模块级单例 store:

  • 内部维护 TasksV2Store
  • 用 fs.watch、fallback poll、debounce timer 管理任务列表
  • 用 useSyncExternalStore 暴露快照

这么做的直接收益是:

  • 多个组件共享同一份 watcher
  • 避免 spinner / footer / REPL 分别监听同一目录
  • 让“文件系统驱动的状态”独立于主 AppState

这类状态更适合“模块自管理 + React 订阅”,而不是硬塞进全局 store。

9. 轻量 Context:只做局部依赖注入,不做全局状态总线

还有一类状态更轻,只需要局部作用域,不值得进 AppState,也不需要专门 store。

9.1 src/context/modalContext.tsx

ModalContext 提供的是 modal 内可用的:

  • rows
  • columns
  • scrollRef

这是纯布局上下文。
它解决的是“组件当前是否在 modal slot 内渲染、可用空间多大”的问题,不属于会话全局状态。

9.2 src/context/mailbox.tsx

MailboxProvider 只是创建一个稳定的 Mailbox 实例并通过 Context 分发。
这里的重点是共享一个对象实例,而不是建立一个可订阅的全局状态树。

9.3 src/context/promptOverlayContext.tsx

这个 Context 更像 prompt 上层浮层的 portal 协调器。

它把状态拆成:

  • 数据 Context
  • setter Context
  • dialog Context
  • dialog setter Context

这样写的目的不是做复杂状态管理,而是:

  • 让读取者拿到当前 overlay 数据
  • 让写入者拿到稳定 setter
  • 避免写入者因为自己的写入再次重渲染

这是一种非常典型的“局部 UI 协调 Context”用法。

10. selectors.ts 说明:项目允许“派生读取”,但保持简单

src/state/selectors.ts 里已经开始出现一些显式 selector,例如:

  • getViewedTeammateTask(...)
  • getActiveAgentForInput(...)

这说明项目并不反对派生状态,但它对 selector 的要求很明确:

  • 保持纯函数
  • 只做数据提取和轻量判断
  • 不承担副作用

因此这套体系更接近“轻量 selector + 外部 store + hook 订阅”,而不是完整的 Redux selector 生态。

11. 回答三个最核心的问题

11.1 全局状态存在哪里

全局状态定义在 src/state/AppStateStore.ts 的 AppState 中,运行时 store 由 src/state/AppState.tsx 或 src/main.tsx 基于 createStore(...) 创建。

11.2 组件和非组件代码分别怎么读写状态

组件里主要用:

  • useAppState(selector):读切片
  • useSetAppState():写状态

非组件或异步逻辑里主要用:

  • useAppStateStore() 拿到 store
  • 再通过 store.getState() / store.setState() 命令式读写

11.3 哪些状态为什么没有放进 AppState

主要有三类:

  • 领域专用 store:如 voice.tsx,只服务单个能力域
  • 模块级单例 store:如 useTasksV2.ts,更适合自己管理 watcher / timer / snapshot
  • 轻量 Context:如 modalContext.tsx、mailbox.tsx、promptOverlayContext.tsx,只做局部依赖注入,不需要全局总线

12. 最后的架构判断

把这套实现压缩成一句话,就是:

Claude Code 的状态管理不是“一个框架接管全部状态”,而是“用最小外部 store 承担全局会话状态,再用专用 store 和轻量 Context 承担局部领域状态”。

这种设计的实际收益是:

  • 组件订阅粒度细
  • 非 React 逻辑也能稳定读写状态
  • 副作用出口集中
  • 大量局部状态不必挤进全局容器

对应的代价也很明显:

  • 需要团队自觉维护不可变更新约定
  • 需要手动控制 selector 粒度与引用稳定性
  • 需要清楚区分“该进 AppState”和“该局部隔离”的边界

但从当前仓库实现看,这种取舍是明确而一致的。