来源与授权
本文来自 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 树之外。一次工具调用的结果需要同时:
- 更新 React 组件(显示在终端 UI 上)
- 被非 React 的
query.ts对话循环读取 - 被 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 Store | state/store.ts + AppStateStore.ts + AppState.tsx | REPL 级,跟随 React 树 | UI 状态、权限、工具、插件、MCP | 是,走 createStore |
| ToolUseContext | Tool.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 行)
这是整个项目最底层的状态模块。文件开头有一条醒目的注释:
// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE
以及初始化函数前的另一条:
// ALSO HERE - THINK THRICE BEFORE MODIFYING
这两条注释透露了一个重要的设计决策:严格控制全局状态的规模。
2.1 为什么需要 bootstrap/state?
有些状态天然是进程级别的,不属于任何 React 组件,也不属于任何单次 query 循环:
- sessionId:标识当前会话,从启动到退出不变(除非 resume 另一个 session)
- CWD / projectRoot:工作目录,全局唯一
- 成本统计:
totalCostUSD、totalAPIDuration等累加器 - 遥测计数器:OpenTelemetry 的
Meter、Counter实例 - 模型使用统计:
modelUsage按模型名累积 token 用量
2.2 实现方式:模块级单例 + getter/setter
// 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 就有了环。源码注释也明确点出这一点:
// 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, loggerProvider | OpenTelemetry |
| Session 标记 | isInteractive, kairosActive, isRemoteMode | 运行模式 |
| 缓存状态 | promptCache1hEligible, afkModeHeaderLatched | API 优化 |
三、第 2 层:Store + AppState — React 与非 React 的桥梁
这是整个状态管理系统最精妙的部分。它由三个文件组成,各司其职。
3.1 store.ts — 35 行极简 Store
文件:state/store.ts(35 行)
先看完整代码 —— 真的只有 35 行:
// 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 | 订阅变更,返回取消函数 |
几个值得注意的设计细节:
-
Object.is相等性检查:如果 updater 返回的是同一个引用,跳过通知。这避免了不必要的重渲染。 -
onChange回调:创建 Store 时可以传入一个onChange,每次状态变更时被调用,携带新旧两个状态。这个回调被用来做全局副作用 —— 比如同步权限模式到外部系统。 -
updater函数式更新:不接受直接赋值(setState(newValue)),只接受函数(setState(prev => newValue))。这是故意的 —— 函数式更新确保每次调用都基于最新的状态快照,避免 stale snapshot 问题,也让多次异步更新可以正确组合(后一次 updater 拿到的prev是前一次更新后的结果)。 -
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 层面的单一状态树。
// 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):
// Unified task state - excluded from DeepImmutable because TaskState contains function types
tasks: { [taskId: string]: TaskState }
TaskState 包含函数类型(如 abortController),而 DeepImmutable 无法正确处理函数类型。agentNameRegistry(Map<string, AgentId>)也被放在 DeepImmutable 之外,但源码没有给出同样明确的因果说明 —— 可能是因为 Map 类型与 DeepImmutable 的递归 readonly 转换不兼容,但这属于结构推断,读者应区分对待。
2. 嵌套结构的组织
状态不是扁平的,而是按领域分组:mcp.* 管理 MCP 连接、plugins.* 管理插件、inbox.* 管理收件箱。这种结构让 selector 可以精确地订阅某个子树,只在相关状态变化时触发重渲染。
3. getDefaultAppState() 的初始化
// 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 组件:
// 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 桥接:
// 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 比较),组件重渲染。
使用方式:
// 组件中这样使用 — 只在 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:
// 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 的变更同步到外部系统:
// 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 之外、单独占一行,就是这个意思:它和前两层一起"承上启下",但它本身不是状态,是为状态服务的传话人。
// 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 负责创建隔离的上下文:
// 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 派生需要复杂计算的视图状态:
// 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 命令切换模型。
- 用户输入
/model claude-3-haiku - 命令处理器调用
store.setState(prev => ({...prev, mainLoopModel: 'claude-3-haiku'})) - Store 内部
Object.is检查 —— 新旧不同,接受更新 onChangeAppState被触发:- 将模型写入
settings.json(持久化) - 更新
bootstrap/state的mainLoopModelOverride(进程级)
- 将模型写入
- 所有 React listener 被通知
- 使用了
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 它写的是什么、为什么够用
文件顶部的注释把整件事的边界讲得很清楚:
// 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>>
整个"崩溃恢复指针"只有三个字段:sessionId、environmentId、source(标识这次 bridge 是独立启动还是从 REPL 里挂起的)。下次启动时只要拿到这三个字段,就能沿用 --session-id 的既有流程把会话续上 —— 不需要把 AppState 整棵树都序列化下来。这是一个最小可恢复集合的设计:写的越少,崩溃前能完整写下去的概率就越高,磁盘上变成半截脏数据的风险也越小。
文件落在哪里也有讲究:
// 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 作为新鲜度判据:
// 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),调用者不需要区分。
如果指针失效呢?读取路径会顺手把它删掉:
// 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 处理的就是这种"恢复时去哪里找指针"的扇出:
// 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
}
设计上有几个细节很值得注意:
- 快路径优先:先看当前目录,命中就直接返回 —— 独立启动的 bridge(指针永远在启动目录)和 REPL 没切换过 worktree 的情况都是一次
stat解决,零exec。 - 扇出有上限:
MAX_WORKTREE_FANOUT = 50是把"git worktree list 自然有界"再加一道防御,防止病态配置下并行stat爆掉。 - 挑最新鲜的:所有兄弟 worktree 并行读,最后按
ageMs升序选最新的那个,并把它所在的目录一起返回 —— 这样调用方在恢复失败时知道该清哪个指针。
7.4 这一层和前三层的关系
回到最开头的三层架构图:bridgePointer 不是 AppState 的一部分,也不进 bootstrap/state。它是一个独立的、单一职责的跨进程小模块,被 bridge/replBridge.ts、bridge/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 (求一颗免费的小星星)