学习工作台文章阅读
工作台/文章/第 33 章:状态管理与跨进程桥 — React 与非 React 世界的状态桥接
返回文章列表
2026年7月11日14 分钟

第 33 章状态管理与跨进程桥React 与非 React 世界的状态桥接

墨圆
墨圆团队发布于 2026年7月11日

来源与授权

本文来自 Claude-Code-Source-Study 原文,固定导入版本为 31b6e07a82d4。Copyright (c) 2026 Yao,依据 MIT License 提供。

本站仅按“github-public-noncommercial-2026-07-11”用于匿名、免费、非商业学习,并调整章节编排、站内链接、重复标题与阅读标记;不代表原作者对本站的合作、认证或背书。如权利方要求删除,我们会立即下架;授权或删除请求请通过本站首页底部公开联系方式联系。

本章是《深入 Claude Code 源码》系列第 33 章。我们将深入分析 Claude Code 如何用一个 35 行的极简 Store 实现,桥接 React UI 与非 React 业务逻辑之间的状态管理,并理解三层状态架构的设计哲学。

为什么状态管理值得单独一篇?

Claude Code 面临一个独特的状态管理难题:它既是一个 React 应用,又不完全是

终端 UI 用 Ink(React for CLI)渲染,组件需要响应式的状态更新。但核心业务逻辑 —— API 调用、工具执行、Agent 编排 —— 运行在 React 树之外。一次工具调用的结果需要同时:

  1. 更新 React 组件(显示在终端 UI 上)
  2. 被非 React 的 query.ts 对话循环读取
  3. 被 Agent 子系统使用(可能运行在隔离的上下文中)

如果用 Redux/Zustand 这类库?太重了。React 内置的 useState/useReducer?无法从 React 树外部访问。模块级全局变量?无法触发 React 重渲染。

Claude Code 的答案是:三层状态架构 + 一个 35 行的自研 Store


本章路线:第一节 三层状态架构全景 → 第二节 第 1 层 bootstrap/state.ts(Session 全局)→ 第三节 第 2 层 Store + AppState(React 与非 React 的桥梁)→ 第四节 第 3 层 ToolUseContext(工具执行运行时)→ 第五节 一次状态变更的完整旅程 → 第六节 可迁移模式 → 第七节 第 4 层 bridge/bridgePointer.ts(跨进程)。前四层是单进程内的状态分层,第七节 把这套机制延伸到「另一台机器接管同一会话」的跨进程场景。

一、三层状态架构全景

在深入代码之前,先建立全局认知。Claude Code 的状态分布在三个层次,各有明确的职责边界:

流程图
图表进入视野后渲染
层次文件生命周期核心用途是否落进 Store
Session 全局bootstrap/state.ts进程级,整个 session 存活sessionId、CWD、成本统计、遥测模块级 module-scoped 变量,不走 Store
AppState Storestate/store.ts + AppStateStore.ts + AppState.tsxREPL 级,跟随 React 树UI 状态、权限、工具、插件、MCP是,走 createStore
ToolUseContextTool.ts:158-254每次交互/工具执行级工具执行所需的全部运行时上下文否,是参数容器而非 Store

前两层是"状态"——一份可被读、可被改、变更需要广播的数据。第三层 ToolUseContext 不是 Store,而是为一次工具执行打包的参数容器:它由调用方在 query 循环或 REPL 路径里构造,按值(含 getter)传给工具,工具调完它就被丢弃,不会被 React 订阅、不会触发 re-render。把它和前两层并列,是因为读者在追"这个字段从哪里来"时必然会撞到它,把它落到第 4 节单独讲清。

这三层的设计原则是向下依赖,向上隔离:ToolUseContext 引用 AppState 的 getter/setter;AppState 可以读取 bootstrap/state 的值;但反过来不成立。


二、第 1 层:bootstrap/state.ts — Session 级全局状态

文件bootstrap/state.ts(1758 行)

这是整个项目最底层的状态模块。文件开头有一条醒目的注释:

TYPESCRIPT
// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE

以及初始化函数前的另一条:

TYPESCRIPT
// ALSO HERE - THINK THRICE BEFORE MODIFYING

这两条注释透露了一个重要的设计决策:严格控制全局状态的规模

2.1 为什么需要 bootstrap/state?

有些状态天然是进程级别的,不属于任何 React 组件,也不属于任何单次 query 循环:

  • sessionId:标识当前会话,从启动到退出不变(除非 resume 另一个 session)
  • CWD / projectRoot:工作目录,全局唯一
  • 成本统计totalCostUSDtotalAPIDuration 等累加器
  • 遥测计数器:OpenTelemetry 的 MeterCounter 实例
  • 模型使用统计modelUsage 按模型名累积 token 用量

2.2 实现方式:模块级单例 + getter/setter

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

export function getSessionId(): SessionId {
  return STATE.sessionId
}

export function getCwdState(): string {
  return STATE.cwd
}

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

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

这是最朴素的状态管理模式 —— 模块级闭包单例STATE 是一个模块私有的对象,通过导出的 getter/setter 函数提供访问。没有发布-订阅,没有响应式,就是纯粹的命令式读写。

2.3 为什么不用 Store?

你可能会问:为什么不把这些状态也放进 AppState Store 里?

答案在于 import DAG(依赖有向无环图)的约束bootstrap/state.ts 处于 import 树的最底部(叶子节点),几乎不 import 其他业务模块。state/store.ts(35 行,无任何 import)和 state/AppStateStore.ts 都不依赖 React —— 真正引入 React 的是 state/AppState.tsx。所以"不能把 bootstrap 状态搬进 Store"和"React 是不是边界"无关,真正的约束是分层:bootstrap 要被所有更高层的模块引用,自己不能反过来引用任何更高层的东西,否则 DAG 就有了环。源码注释也明确点出这一点:

TYPESCRIPT
// bootstrap can't import listeners directly (DAG leaf), so
// callers register themselves.

这是一个很有价值的工程决策:将最基础的状态放在依赖树的叶子节点,让所有人都能安全地引用它,而它不引用任何人。

2.4 State 类型的规模

State 类型定义了约 80+ 个字段,涵盖:

类别代表字段用途
身份标识sessionId, parentSessionId会话追踪
路径信息originalCwd, projectRoot, cwd目录管理
成本与性能totalCostUSD, totalAPIDuration, turnToolCount统计与计费
模型配置mainLoopModelOverride, initialMainLoopModel模型选择
遥测基础设施meter, sessionCounter, loggerProviderOpenTelemetry
Session 标记isInteractive, kairosActive, isRemoteMode运行模式
缓存状态promptCache1hEligible, afkModeHeaderLatchedAPI 优化

三、第 2 层:Store + AppState — React 与非 React 的桥梁

这是整个状态管理系统最精妙的部分。它由三个文件组成,各司其职。

3.1 store.ts — 35 行极简 Store

文件state/store.ts(35 行)

先看完整代码 —— 真的只有 35 行:

TYPESCRIPT
// 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)
    },
  }
}

这个 Store 的 API 只有三个方法:

方法签名用途
getState() => T同步读取当前状态
setState(updater: (prev: T) => T) => void函数式更新(避免 stale closure)
subscribe(listener: () => void) => () => void订阅变更,返回取消函数

几个值得注意的设计细节:

  1. Object.is 相等性检查:如果 updater 返回的是同一个引用,跳过通知。这避免了不必要的重渲染。

  2. onChange 回调:创建 Store 时可以传入一个 onChange,每次状态变更时被调用,携带新旧两个状态。这个回调被用来做全局副作用 —— 比如同步权限模式到外部系统。

  3. updater 函数式更新:不接受直接赋值(setState(newValue)),只接受函数(setState(prev => newValue))。这是故意的 —— 函数式更新确保每次调用都基于最新的状态快照,避免 stale snapshot 问题,也让多次异步更新可以正确组合(后一次 updater 拿到的 prev 是前一次更新后的结果)。

  4. Set<Listener> 而非数组:用 Set 存储 listener,add/delete 操作都是 O(1),且天然去重。

3.2 为什么自研而不用 Zustand?

Zustand 的核心 API 也是 getState/setState/subscribe,看起来很像。但 Claude Code 选择自研有几个原因:

  • 零依赖:35 行代码,不需要引入任何库
  • 完全可控onChange 回调是 Zustand 不直接支持的特性
  • TypeScript 优先:类型定义完全贴合项目需求
  • 不需要中间件:没有 devtools、persist、immer 等需求

这个 Store 的 API 设计恰好匹配了 React 18 的 useSyncExternalStore 要求 —— 这不是巧合,而是为了桥接而精确设计的接口

3.3 AppStateStore.ts — AppState 类型定义

文件state/AppStateStore.ts(约 570 行)

这个文件定义了 AppState 类型和 getDefaultAppState() 工厂函数。AppState 是整个应用 UI 层面的单一状态树

TYPESCRIPT
// state/AppStateStore.ts:89(简化展示核心字段)
export type AppState = DeepImmutable<{
  // 用户配置
  settings: SettingsJson
  verbose: boolean
  mainLoopModel: ModelSetting

  // 权限系统
  toolPermissionContext: ToolPermissionContext

  // MCP 协议
  mcp: {
    clients: MCPServerConnection[]
    tools: Tool[]
    commands: Command[]
    resources: Record<string, ServerResource[]>
  }

  // 插件系统
  plugins: {
    enabled: LoadedPlugin[]
    disabled: LoadedPlugin[]
    commands: Command[]
    errors: PluginError[]
  }

  // UI 状态
  thinkingEnabled: boolean | undefined
  expandedView: 'none' | 'tasks' | 'teammates'
  footerSelection: FooterItem | null

  // ... 还有约 60+ 个字段
}> & {
  // 这些字段排除在 DeepImmutable 之外
  tasks: { [taskId: string]: TaskState }
  agentNameRegistry: Map<string, AgentId>
}

几个关键设计点:

1. DeepImmutable<T> 包装

整个 AppState 被 DeepImmutable<T> 包装,所有属性递归变成 readonly。这强制所有状态变更必须通过 setState 函数进行,防止任何地方直接修改状态对象。

但注意 & { tasks, agentNameRegistry } 被排除在 DeepImmutable 之外。源码对 tasks 的原因给出了明确注释(AppStateStore.ts:159):

TYPESCRIPT
// Unified task state - excluded from DeepImmutable because TaskState contains function types
tasks: { [taskId: string]: TaskState }

TaskState 包含函数类型(如 abortController),而 DeepImmutable 无法正确处理函数类型。agentNameRegistryMap<string, AgentId>)也被放在 DeepImmutable 之外,但源码没有给出同样明确的因果说明 —— 可能是因为 Map 类型与 DeepImmutable 的递归 readonly 转换不兼容,但这属于结构推断,读者应区分对待。

2. 嵌套结构的组织

状态不是扁平的,而是按领域分组:mcp.* 管理 MCP 连接、plugins.* 管理插件、inbox.* 管理收件箱。这种结构让 selector 可以精确地订阅某个子树,只在相关状态变化时触发重渲染。

3. getDefaultAppState() 的初始化

TYPESCRIPT
// state/AppStateStore.ts:456-569
export function getDefaultAppState(): AppState {
  return {
    settings: getInitialSettings(),
    tasks: {},
    agentNameRegistry: new Map(),
    verbose: false,
    mainLoopModel: null,
    toolPermissionContext: {
      ...getEmptyToolPermissionContext(),
      mode: initialMode,
    },
    thinkingEnabled: shouldEnableThinkingByDefault(),
    promptSuggestionEnabled: shouldEnablePromptSuggestion(),
    // ... 70+ 字段的默认值
  }
}

注意有些默认值是动态计算的 —— 如 shouldEnableThinkingByDefault() 会根据当前模型能力决定是否默认开启 thinking。

3.4 AppState.tsx — React Context 桥接

文件state/AppState.tsx

这是桥接的核心。它把非 React 的 Store<AppState> 连接到 React 的组件树中。

Provider 组件

TYPESCRIPT
// state/AppState.tsx(原始 TypeScript 源码,非编译后版本)
export const AppStoreContext = React.createContext<AppStateStore | null>(null)

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 value 意味着
  // Provider 永远不触发重渲染
  const [store] = useState(() =>
    createStore<AppState>(
      initialState ?? getDefaultAppState(),
      onChangeAppState,
    ),
  )

  // 监听外部配置变更并同步到 AppState
  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>
  )
}

核心技巧在这行:const [store] = useState(() => createStore(...))

Provider 的 context value 是 store(Store 实例本身),而不是 store.getState()。Store 实例在创建后永远不变 —— 所以 context value 的稳定性避免了"因 context value 改变而导致的消费者全树重渲染"。当然,Provider 组件本身仍可能因父组件重渲染而重新执行(React 的正常行为),但这不会因 context value 变化而向下传播。真正驱动消费者更新的是 useSyncExternalStore 的 subscription 机制,而非 context 变更。这是一个关键的性能设计。

Consumer hook — useSyncExternalStore 桥接

TYPESCRIPT
// state/AppState.tsx:142-163
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 是 React 18 提供的官方 API,专门用于订阅外部数据源。它需要三个参数:

  • subscribe:注册监听函数
  • getSnapshot:获取当前值
  • getServerSnapshot:SSR 用,这里传同一个函数

store.setState 被调用时,所有 listener 被触发,useSyncExternalStore 内部重新调用 get(),如果 selector 返回值与上次不同(Object.is 比较),组件重渲染。

使用方式

TYPESCRIPT
// 组件中这样使用 — 只在 verbose 变化时重渲染
const verbose = useAppState(s => s.verbose)
const model = useAppState(s => s.mainLoopModel)

// 获取 setter — 永远不触发重渲染
const setAppState = useSetAppState()

// 获取整个 store 实例 — 传给非 React 代码
const store = useAppStateStore()

还有一个容错版本 useAppStateMaybeOutsideOfProvider,在没有 Provider 的上下文中返回 undefined 而不是 throw:

TYPESCRIPT
// state/AppState.tsx:186-199
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,
  )
}

3.5 onChangeAppState — 全局副作用处理

文件state/onChangeAppState.ts(172 行)

还记得 createStore 的第二个参数 onChange 吗?onChangeAppState 就是那个回调。它在每次状态变更后被调用,负责将 AppState 的变更同步到外部系统:

TYPESCRIPT
// state/onChangeAppState.ts:43-92(核心片段)
export function onChangeAppState({
  newState,
  oldState,
}: {
  newState: AppState
  oldState: AppState
}) {
  // 权限模式变更 → 通知 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) {
      notifySessionMetadataChanged({
        permission_mode: newExternal,
      })
    }
    notifyPermissionModeChanged(newMode)
  }

  // 模型变更 → 持久化到 settings
  if (newState.mainLoopModel !== oldState.mainLoopModel) {
    if (newState.mainLoopModel === null) {
      updateSettingsForSource('userSettings', { model: undefined })
    } else {
      updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
    }
  }

  // 配置变更 → 清除认证缓存
  if (newState.settings !== oldState.settings) {
    clearApiKeyHelperCache()
    clearAwsCredentialsCache()
    clearGcpCredentialsCache()
  }
}

这个设计非常巧妙 —— 注释中解释了历史背景:

Prior to this block, mode changes were relayed to CCR by only 2 of 8+ mutation paths... Every other path mutated AppState without telling CCR, leaving external_metadata stale.

过去,权限模式变更需要在每个修改它的地方手动通知外部系统,导致 8 个修改路径中只有 2 个正确同步。现在通过 onChange 回调,任何 setState 调用导致的变更都会被集中处理,零遗漏。


四、第 3 层:ToolUseContext — 工具执行的运行时上下文

文件Tool.ts:158-254

ToolUseContext 不在 Store 里、不被 React 订阅,它是一次工具执行的参数容器:调用方(query 循环最常见,REPL 命令执行 screens/REPL.tsx、权限弹窗流转、side question fallback utils/queryContext.ts:142-170 也会构造)一次性把工具运行所需的所有外部依赖打包进去——abortController、messageId、agentId、readFileState、对 AppState 的若干 getter/setter、以及 options.tools/options.commands 这种 session 级配置的快照。工具拿到它、用完就丢,不会触发 re-render。第 1 节的三层表把它列在 Store 之外、单独占一行,就是这个意思:它和前两层一起"承上启下",但它本身不是状态,是为状态服务的传话人。

TYPESCRIPT
// Tool.ts:158-254(核心字段)
export type ToolUseContext = {
  options: {
    commands: Command[]
    tools: Tools
    mainLoopModel: string
    thinkingConfig: ThinkingConfig
    mcpClients: MCPServerConnection[]
    isNonInteractiveSession: boolean
    agentDefinitions: AgentDefinitionsResult
    // ...
  }
  abortController: AbortController
  readFileState: FileStateCache

  // AppState 的 getter/setter — 连接第 2 层
  getAppState(): AppState
  setAppState(f: (prev: AppState) => AppState): void

  // 任务级 setter — 即使 setAppState 被 no-op,这个也能到达根 Store
  setAppStateForTasks?: (f: (prev: AppState) => AppState) => void

  // 工具执行进度的回调
  setInProgressToolUseIDs: (f: (prev: Set<string>) => Set<string>) => void
  setResponseLength: (f: (prev: number) => number) => void

  // 文件历史和归因追踪
  updateFileHistoryState: (updater: (prev: FileHistoryState) => FileHistoryState) => void
  updateAttributionState: (updater: (prev: AttributionState) => AttributionState) => void

  // Agent 相关
  agentId?: AgentId
  agentType?: string
  messages: Message[]
  // ...
}

4.1 为什么不直接传 Store?

ToolUseContext 明确包含 getAppState()setAppState() 而不是直接传 Store 实例,这是有意为之的。因为不同的调用者需要不同版本的 getAppState/setAppState

  • 主对话循环:直接连接真实 Store
  • 同步 Agent(如 Explore Agent):共享父级的 Store
  • 异步 Agent(如后台 Agent):setAppState 被替换为 no-op,防止干扰 UI

4.2 createSubagentContext — Agent 隔离的关键

文件utils/forkedAgent.ts:345-435

当一个 Agent 工具创建子 Agent 时,不能直接复用父级的 ToolUseContext —— 那会导致状态互相污染。createSubagentContext 负责创建隔离的上下文:

TYPESCRIPT
// utils/forkedAgent.ts:345-435(简化展示)
export function createSubagentContext(
  parentContext: ToolUseContext,
  overrides?: SubagentContextOverrides,
): ToolUseContext {
  // 1. AbortController: 新建子 controller,链接到父级(父级 abort 会传播)
  const abortController = overrides?.shareAbortController
    ? parentContext.abortController
    : createChildAbortController(parentContext.abortController)

  // 2. getAppState: 包装以设置 shouldAvoidPermissionPrompts
  const getAppState = overrides?.shareAbortController
    ? parentContext.getAppState
    : () => ({
        ...parentContext.getAppState(),
        toolPermissionContext: {
          ...parentContext.getAppState().toolPermissionContext,
          shouldAvoidPermissionPrompts: true,
        },
      })

  return {
    // 3. FileStateCache: 克隆,避免相互影响
    readFileState: cloneFileStateCache(
      overrides?.readFileState ?? parentContext.readFileState,
    ),

    // 4. 新的集合实例 — 防止跨 Agent 污染
    nestedMemoryAttachmentTriggers: new Set<string>(),
    loadedNestedMemoryPaths: new Set<string>(),

    // 5. setAppState: 默认 no-op,除非显式 opt-in 共享
    getAppState,
    setAppState: overrides?.shareSetAppState
      ? parentContext.setAppState
      : () => {},

    // 6. setAppStateForTasks: 始终连接到根 Store!
    setAppStateForTasks:
      parentContext.setAppStateForTasks ?? parentContext.setAppState,

    // ... 其他字段
  }
}

这里最精妙的设计是 setAppStateForTasks 的"始终穿透"特性

即使 setAppState 被设为 no-op(异步 Agent 不应该修改 UI 状态),setAppStateForTasks 仍然连接到根 Store。为什么?因为后台 Agent 启动的 bash 任务需要注册到全局任务列表中,否则在进程退出时无法被正确清理 —— 注释中记录了血泪教训:

Task registration/kill must always reach the root store, even when setAppState is a no-op — otherwise async agents' background bash tasks are never registered and never killed (PPID=1 zombie).

4.3 Selectors — 派生状态

文件state/selectors.ts(77 行)

Claude Code 也有 selector 的概念,但非常轻量 —— 只用于从 AppState 派生需要复杂计算的视图状态:

TYPESCRIPT
// state/selectors.ts:59-76
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' }
}

这个 selector 用可区分联合类型(Discriminated Union) 返回结果,调用方可以用 switch(result.type) 做类型安全的分支处理。


五、数据流全景:一次状态变更的完整旅程

让我们追踪一个具体场景:用户通过 /model 命令切换模型

时序图
图表进入视野后渲染
  1. 用户输入 /model claude-3-haiku
  2. 命令处理器调用 store.setState(prev => ({...prev, mainLoopModel: 'claude-3-haiku'}))
  3. Store 内部 Object.is 检查 —— 新旧不同,接受更新
  4. onChangeAppState 被触发:
    • 将模型写入 settings.json(持久化)
    • 更新 bootstrap/statemainLoopModelOverride(进程级)
  5. 所有 React listener 被通知
  6. 使用了 useAppState(s => s.mainLoopModel) 的组件重渲染

注意这个流程中,一次 setState 调用同时完成了三件事:更新内存状态、持久化到磁盘、通知 UI。这就是集中式 onChange 的威力。


六、可迁移的设计模式

模式 1:极简 Store + useSyncExternalStore

用 35 行代码实现一个 Store,通过 React 18 的 useSyncExternalStore 桥接到 React 组件。适用于任何需要在 React 和非 React 代码之间共享状态的场景。

关键要点

  • Context value 放 Store 实例(稳定引用),不放 state 值,避免因 context value 变化导致消费者全树重渲染
  • 用 selector 订阅切片,避免全量重渲染
  • setState 接受 updater 函数,避免 stale snapshot,让多次异步更新可正确组合

适用场景:中小型应用,不想引入 Zustand/Redux 等外部依赖,又需要跨 React/非 React 边界的状态共享。

模式 2:onChange 集中式副作用

在 Store 创建时传入 onChange 回调,集中处理所有状态变更的副作用(持久化、外部通知、缓存清理)。这比在每个 setState 调用处手动触发副作用可靠得多。

适用场景:状态变更需要同步到多个外部系统(数据库、API、其他进程),且修改入口很多(CLI 命令、UI 交互、配置文件变更)。

模式 3:Context 隔离 + 选择性共享

为子系统(如子 Agent)创建隔离的上下文副本,默认所有可变状态都是隔离的(no-op setter、克隆的缓存),只有经过明确 opt-in 的通道(如 setAppStateForTasks)才允许穿透到共享状态。

关键要点

  • 默认隔离,显式共享 —— 比默认共享、显式隔离安全得多
  • 某些关键操作(如任务注册/清理)必须穿透隔离,否则会造成资源泄漏

适用场景:多 Agent/多租户系统、插件系统、任何需要在隔离环境中运行不受信代码的场景。


七、跨进程的第四层:bridge/bridgePointer.ts

前面三层都活在同一个进程里 —— bootstrap/state 是模块单例,AppState 是 React 树的 context,ToolUseContext 是函数调用栈上的容器。它们解决的是"同一个 Claude Code 进程内部,React 与非 React 代码怎么共享状态"。

但 Claude Code 还有一类状态需要跨进程活下来:当一个 bridge 会话(手机/Web 远程控制本地 CLI 的那种会话)正在运行时,进程一旦被 kill -9、终端窗口被叉掉、或者真的崩了,下一次启动 claude remote-control 应该能问读者一句"上次那个会话挂了,要不要恢复?",而不是当作干净启动。

这件事不属于前三层任何一层的职责 —— 它必须把一小撮 ID 写到磁盘上,并且必须在崩溃之前就已经写下去。bridge/bridgePointer.ts(210 行)是这一层的全部实现。

7.1 它写的是什么、为什么够用

文件顶部的注释把整件事的边界讲得很清楚:

TYPESCRIPT
// bridge/bridgePointer.ts:42-50
const BridgePointerSchema = lazySchema(() =>
  z.object({
    sessionId: z.string(),
    environmentId: z.string(),
    source: z.enum(['standalone', 'repl']),
  }),
)

export type BridgePointer = z.infer<ReturnType<typeof BridgePointerSchema>>

整个"崩溃恢复指针"只有三个字段:sessionIdenvironmentIdsource(标识这次 bridge 是独立启动还是从 REPL 里挂起的)。下次启动时只要拿到这三个字段,就能沿用 --session-id 的既有流程把会话续上 —— 不需要把 AppState 整棵树都序列化下来。这是一个最小可恢复集合的设计:写的越少,崩溃前能完整写下去的概率就越高,磁盘上变成半截脏数据的风险也越小。

文件落在哪里也有讲究:

TYPESCRIPT
// bridge/bridgePointer.ts:52-54
export function getBridgePointerPath(dir: string): string {
  return join(getProjectsDir(), sanitizePath(dir), 'bridge-pointer.json')
}

按工作目录 sanitize 之后落盘,每个仓库一个 bridge-pointer.json,和该项目的 transcript JSONL 文件放在一起。这样两个并发的 bridge 在不同仓库下不会互相覆盖,也不需要全局锁。

7.2 mtime 即新鲜度:4 小时滚动 TTL

bridgePointer 没有在文件内容里塞时间戳,而是直接用文件系统的 mtime 作为新鲜度判据:

TYPESCRIPT
// bridge/bridgePointer.ts:40
export const BRIDGE_POINTER_TTL_MS = 4 * 60 * 60 * 1000

// bridge/bridgePointer.ts:88-110
mtimeMs = (await stat(path)).mtimeMs
raw = await readFile(path, 'utf8')
// ...
const ageMs = Math.max(0, Date.now() - mtimeMs)
if (ageMs > BRIDGE_POINTER_TTL_MS) {
  logForDebugging(`[bridge:pointer] stale (>4h mtime), clearing: ${path}`)
  await clearBridgePointer(dir)
  return null
}

4 小时的窗口对齐的是后端 BRIDGE_LAST_POLL_TTL。注释里把这个设计的好处一句话点透:

Staleness is checked against the file's mtime (not an embedded timestamp) so that a periodic re-write with the same content serves as a refresh.

也就是说 —— 长会话不需要每次"刷新"都改写指针的内容,只要原样写一遍同样的三个字段,mtime 就会被更新,新鲜度时钟自动重置。一个轮询了 5 个小时的 bridge 即便此刻崩溃,只要最近的那次"原样重写"落在 4 小时窗口内,指针就仍然是新鲜的。这把"写一次记录会话"和"周期心跳"合并成了同一个 API(writeBridgePointer),调用者不需要区分。

如果指针失效呢?读取路径会顺手把它删掉

TYPESCRIPT
// bridge/bridgePointer.ts:98-103
const parsed = BridgePointerSchema().safeParse(safeJsonParse(raw))
if (!parsed.success) {
  logForDebugging(`[bridge:pointer] invalid schema, clearing: ${path}`)
  await clearBridgePointer(dir)
  return null
}

文件没了、JSON 坏了、schema 对不上、超过 4 小时 —— 任何一种"不可信"都返回 null,并且把磁盘上的脏文件清理掉。clearBridgePointer 自己是幂等的(ENOENT 直接吞掉),所以重复清理不会报错。读取侧不需要先 exists()read()(TOCTOU 反例),是"直接读 + 兜底处理错误"的写法。

7.3 worktree 扇出:把"在哪儿启动 = 在哪儿恢复"打通

bridge 指针的真正硬骨头是:REPL 模式下的 bridge 是写在 getOriginalCwd() 里的,而 EnterWorktreeTool 会把当前工作目录切换到 worktree 子目录;用户下一次 claude remote-control --continue 时是从 shell 当前 CWD 出发的 —— 这两个目录未必是同一个。

readBridgePointerAcrossWorktrees 处理的就是这种"恢复时去哪里找指针"的扇出:

TYPESCRIPT
// bridge/bridgePointer.ts:129-184(节选)
export async function readBridgePointerAcrossWorktrees(
  dir: string,
): Promise<{ pointer: BridgePointer & { ageMs: number }; dir: string } | null> {
  // Fast path: current dir.
  const here = await readBridgePointer(dir)
  if (here) {
    return { pointer: here, dir }
  }

  const worktrees = await getWorktreePathsPortable(dir)
  if (worktrees.length <= 1) return null
  if (worktrees.length > MAX_WORKTREE_FANOUT) {
    logForDebugging(
      `[bridge:pointer] ${worktrees.length} worktrees exceeds fanout cap ${MAX_WORKTREE_FANOUT}, skipping`,
    )
    return null
  }
  // ...parallel stat+read, pick freshest by ageMs
}

设计上有几个细节很值得注意:

  1. 快路径优先:先看当前目录,命中就直接返回 —— 独立启动的 bridge(指针永远在启动目录)和 REPL 没切换过 worktree 的情况都是一次 stat 解决,零 exec
  2. 扇出有上限MAX_WORKTREE_FANOUT = 50 是把"git worktree list 自然有界"再加一道防御,防止病态配置下并行 stat 爆掉。
  3. 挑最新鲜的:所有兄弟 worktree 并行读,最后按 ageMs 升序选最新的那个,并把它所在的目录一起返回 —— 这样调用方在恢复失败时知道该清哪个指针。

7.4 这一层和前三层的关系

回到最开头的三层架构图:bridgePointer 不是 AppState 的一部分,也不进 bootstrap/state。它是一个独立的、单一职责的跨进程小模块,被 bridge/replBridge.tsbridge/bridgeMain.ts 这些 bridge 入口在三个时机调用:

  • bridge 会话刚建立时 → writeBridgePointer(立即写,越早越好);
  • 会话存活期间 → 周期性 writeBridgePointer(同内容刷新 mtime);
  • 会话干净退出时 → clearBridgePointer(让下次启动知道"没什么要恢复")。

只有上面任何一步没走到(崩溃、被 SIGKILL、终端断开),指针才会"漏"在磁盘上 —— 那正是它存在的意义。换句话说,这是一种用"清理不到 = 异常退出"来检测崩溃的设计,比心跳进程或外部 watchdog 都轻得多。

如果你把 Claude Code 的状态管理画成一棵树,前三层(bootstrap / Store / ToolUseContext)是进程内纵向的传递链路;bridgePointer 是从这棵树上横向伸出去的一根针,扎在文件系统里,等下一个进程把它拔出来。


下一章预告

第 34 章:架构模式总结 — 可迁移到你自己项目的设计模式

我们从前 33 章的源码分析中,提炼出 11 个可复用的架构模式——包含 Bridge IPC、Coordinator-Agent、Migration-as-Code、Output-Style-as-Plugin 等模式——汇成一张可迁移到自己项目的设计地图。


全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)