本章总览
AppState 不是 Claude Code 状态的全部。bootstrap/state.ts 持有进程级 sessionId、cost、model override;utils/sessionStorage.ts 把 transcript 写入 JSONL;utils/fileStateCache.ts 在 Tool 层缓存读文件内容供 Edit/Write 与 compact 使用。本章画出三条边界如何与 onChangeAppState、getDefaultAppState 接缝,以及为何「不要在 bootstrap 再加 STATE 字段」与「FileStateCache 不进 AppState」是 deliberate 架构选择。
学完本章你应该能
- 区分 bootstrap STATE 与 AppState 的职责
- 解释 switchSession 与 getSessionProjectDir 的原子性
- 描述 sessionStorage 与 bootstrap sessionId 的对齐要求
- 说明 FileStateCache 为何放在 ToolUseContext 而非 AppState
- 理解 compact 时 cloneFileStateCache / mergeFileStateCaches 流程
- 列举 mainLoopModel 从 AppState 到 bootstrap override 的同步链
核心概念(先读懂这些)
bootstrap 是 DAG 叶节点
bootstrap/state.ts 注释 DO NOT ADD MORE STATE HERE——文件已 1700+ 行。新 session 级状态应优先 AppState 或专用模块;bootstrap 仅保留 query/telemetry 必须在 React 外可读的数据。import 规则 bootstrap-isolation 阻止 bootstrap 从 src/utils 拉重型依赖。
sessionStorage 读 bootstrap,不写 AppState
sessionStorage import getSessionId、getOriginalCwd、switchSession。resume 加载 transcript 后由 REPL setMessages,AppState.tasks 由 task 系统填充——JSONL 不是 AppState 镜像,而是 audit / resume 真相源。
FileStateCache 是 Tool 会话缓存
REPL 创建 createFileStateCacheWithSizeLimit,传入 QueryEngine / ToolUseContext.readFileState。compact 快照 cacheToObject 进 JSONL,恢复时 load。与 AppState.fileHistory(undo 快照)互补而非重复。
建议学习步骤
- 阅读 bootstrap getInitialState 与 sessionId API
- 阅读 switchSession / onSessionSwitch
- 阅读 sessionStorage getTranscriptPath 与 bootstrap 注释
- 阅读 FileStateCache LRU 构造
- 阅读 REPL mergeFileStateCaches 用法
- 追踪 onChangeAppState → setMainLoopModelOverride
常见误区
注意
import 时缓存 cwd 会导致 session 路径 split-brain——sessionStorage 注释反复强调
注意
isSessionPersistenceDisabled 跳磁盘写入但不阻止内存 messages
注意
FileStateCache normalize 路径——Windows / vs \ 混用仍命中
注意
bootstrap regenerateSessionId 清 planSlugCache 条目防泄漏
三层状态全景
┌─────────────────────────────────────────────────────────┐
│ AppState (React store) │
│ UI、tasks、mcp、permissions、viewingAgentTaskId │
└───────────────┬─────────────────────┬───────────────────┘
│ onChangeAppState │ getState 快照
▼ ▼
┌───────────────────────┐ ┌─────────────────────────────┐
│ bootstrap/state.ts │ │ ToolUseContext │
│ sessionId, cost, cwd │ │ readFileState: FileStateCache│
│ modelOverride │ └─────────────────────────────┘
└───────────┬───────────┘
│ getSessionId / getOriginalCwd
▼
┌───────────────────────────────────────────────────────────┐
│ utils/sessionStorage.ts → ~/.claude/projects/.../id.jsonl │
└───────────────────────────────────────────────────────────┘
读代码时先问:这个字段重启后要不要保留?要 → JSONL 或 settings;仅 session → bootstrap 或 AppState;仅 turn → Tool context / local variable。
bootstrap/state.ts 核心 API
State 类型含 80+ 字段(cost、telemetry、session flags、beta header latches…)。
关键 session API:
| 函数 | 作用 |
|---|---|
| getSessionId | 当前 UUID |
| regenerateSessionId | 新 session;可选 setCurrentAsParent |
| switchSession(id, projectDir) | 原子切换 session + 项目目录 |
| onSessionSwitch | 订阅 switch(concurrentSessions PID 同步) |
| getSessionProjectDir | 跨项目 resume 的 transcript 目录 |
| getOriginalCwd / getProjectRoot | 项目身份 vs 当前文件 cwd |
getMainLoopModelOverride / setMainLoopModelOverride 与 AppState.mainLoopModel 通过 onChange 同步。
注释:DO NOT ADD MORE STATE HERE — 新功能默认不进 bootstrap。
源码引用: src/bootstrap/state.ts · 第 31–50 行(共 1759 行)
31| // DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE
32|
33| // dev: true on entries that came via --dangerously-load-development-channels.
34| // The allowlist gate checks this per-entry (not the session-wide
35| // hasDevChannels bit) so passing both flags doesn't let the dev dialog's
36| // acceptance leak allowlist-bypass to the --channels entries.
37| export type ChannelEntry =
38| | { kind: 'plugin'; name: string; marketplace: string; dev?: boolean }
39| | { kind: 'server'; name: string; dev?: boolean }
40|
41| export type AttributedCounter = {
42| add(value: number, additionalAttributes?: Attributes): void
43| }
44|
45| type State = {
46| originalCwd: string
47| // Stable project root - set once at startup (including by --worktree flag),
48| // never updated by mid-session EnterWorktreeTool.
49| // Use for project identity (history, skills, sessions) not file operations.
50| projectRoot: string
源码引用: src/bootstrap/state.ts · 第 431–478 行(共 1759 行)
431| export function getSessionId(): SessionId {
432| return STATE.sessionId
433| }
434|
435| export function regenerateSessionId(
436| options: { setCurrentAsParent?: boolean } = {},
437| ): SessionId {
438| if (options.setCurrentAsParent) {
439| STATE.parentSessionId = STATE.sessionId
440| }
441| // Drop the outgoing session's plan-slug entry so the Map doesn't
442| // accumulate stale keys. Callers that need to carry the slug across
443| // (REPL.tsx clearContext) read it before calling clearConversation.
444| STATE.planSlugCache.delete(STATE.sessionId)
445| // Regenerated sessions live in the current project: reset projectDir to
446| // null so getTranscriptPath() derives from originalCwd.
447| STATE.sessionId = randomUUID() as SessionId
448| STATE.sessionProjectDir = null
449| return STATE.sessionId
450| }
451|
452| export function getParentSessionId(): SessionId | undefined {
453| return STATE.parentSessionId
454| }
455|
456| /**
457| * Atomically switch the active session. `sessionId` and `sessionProjectDir`
458| * always change together — there is no separate setter for either, so they
459| * cannot drift out of sync (CC-34).
460| *
461| * @param projectDir — directory containing `<sessionId>.jsonl`. Omit (or
462| * pass `null`) for sessions in the current project — the path will derive
463| * from originalCwd at read time. Pass `dirname(transcriptPath)` when the
464| * session lives in a different project directory (git worktrees,
465| * cross-project resume). Every call resets the project dir; it never
466| * carries over from the previous session.
467| */
468| export function switchSession(
469| sessionId: SessionId,
470| projectDir: string | null = null,
471| ): void {
472| // Drop the outgoing session's plan-slug entry so the Map stays bounded
473| // across repeated /resume. Only the current session's slug is ever read
474| // (plans.ts getPlanSlug defaults to getSessionId()).
475| STATE.planSlugCache.delete(STATE.sessionId)
476| STATE.sessionId = sessionId
477| STATE.sessionProjectDir = projectDir
478| sessionSwitched.emit(sessionId)
源码引用: src/bootstrap/state.ts · 第 838–854 行(共 1759 行)
838| export function getMainLoopModelOverride(): ModelSetting | undefined {
839| return STATE.mainLoopModelOverride
840| }
841|
842| export function getInitialMainLoopModel(): ModelSetting {
843| return STATE.initialMainLoopModel
844| }
845|
846| export function setMainLoopModelOverride(
847| model: ModelSetting | undefined,
848| ): void {
849| STATE.mainLoopModelOverride = model
850| }
851|
852| export function setInitialMainLoopModel(model: ModelSetting): void {
853| STATE.initialMainLoopModel = model
854| }
bootstrap 与 cost / telemetry
Cost 与 API 用量在 bootstrap 累加:
- addToTotalCostState、getTotalCostUSD
- modelUsage 字典 per model
- turnToolDurationMs / turnHookDurationMs 分 turn 统计
- setCostStateForRestore — resume 时从 JSONL metadata 恢复
AppState 不 mirror cost——StatusLine、/cost 读 bootstrap。UI spinner 读 AppState.tasks 与 queryGuard。
markPostCompaction / consumePostCompaction 供 analytics 标记 compaction 后首次 API。
这与 AppState 分工:bootstrap = 计量与 session 身份;AppState = 交互态。
源码引用: src/bootstrap/state.ts · 第 557–575 行(共 1759 行)
557| export function addToTotalCostState(
558| cost: number,
559| modelUsage: ModelUsage,
560| model: string,
561| ): void {
562| STATE.modelUsage[model] = modelUsage
563| STATE.totalCostUSD += cost
564| }
565|
566| export function getTotalCostUSD(): number {
567| return STATE.totalCostUSD
568| }
569|
570| export function getTotalAPIDuration(): number {
571| return STATE.totalAPIDuration
572| }
573|
574| export function getTotalDuration(): number {
575| return Date.now() - STATE.startTime
源码引用: src/bootstrap/state.ts · 第 769–781 行(共 1759 行)
769| /** Mark that a compaction just occurred. The next API success event will
770| * include isPostCompaction=true, then the flag auto-resets. */
771| export function markPostCompaction(): void {
772| STATE.pendingPostCompaction = true
773| }
774|
775| /** Consume the post-compaction flag. Returns true once after compaction,
776| * then returns false until the next compaction. */
777| export function consumePostCompaction(): boolean {
778| const was = STATE.pendingPostCompaction
779| STATE.pendingPostCompaction = false
780| return was
781| }
sessionStorage 与 bootstrap 对齐
sessionStorage.ts(约 5000 行)import bootstrap:
- getSessionId() — 当前写入的 jsonl 文件名
- getOriginalCwd() — 项目 hash 路径(每 call site 读取,不在 module init 缓存)
- getSessionProjectDir() — 跨 worktree / cross-project resume
- switchSession — /resume 切换活跃 session
- isSessionPersistenceDisabled — --no-session-persistence
模块注释解释 split-brain 陷阱:import 时 getCwd() 可能早于 bootstrap realpathSync,导致写入目录与读取目录不一致,/resume 找不到文件。
isTranscriptMessage / isChainParticipant 决定 JSONL 链——progress 不进 parentUuid(#14373)。
AppState messages 与 JSONL 通过 useLogMessages / recordTranscript 异步同步,非镜像复制。
源码引用: src/utils/sessionStorage.ts · 第 20–32 行(共 5106 行)
20| import {
21| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
22| logEvent,
23| } from 'src/services/analytics/index.js'
24| import {
25| getOriginalCwd,
26| getPlanSlugCache,
27| getPromptId,
28| getSessionId,
29| getSessionProjectDir,
30| isSessionPersistenceDisabled,
31| switchSession,
32| } from '../bootstrap/state.js'
源码引用: src/utils/sessionStorage.ts · 第 108–112 行(共 5106 行)
108| // Use getOriginalCwd() at each call site instead of capturing at module load
109| // time. getCwd() at import time may run before bootstrap resolves symlinks via
110| // realpathSync, causing a different sanitized project directory than what
111| // getOriginalCwd() returns after bootstrap. This split-brain made sessions
112| // saved under one path invisible when loaded via the other.
源码引用: src/utils/sessionStorage.ts · 第 128–146 行(共 5106 行)
128| /**
129| * Type guard to check if an entry is a transcript message.
130| * Transcript messages include user, assistant, attachment, and system messages.
131| * IMPORTANT: This is the single source of truth for what constitutes a transcript message.
132| * loadTranscriptFile() uses this to determine which messages to load into the chain.
133| *
134| * Progress messages are NOT transcript messages. They are ephemeral UI state
135| * and must not be persisted to the JSONL or participate in the parentUuid
136| * chain. Including them caused chain forks that orphaned real conversation
137| * messages on resume (see #14373, #23537).
138| */
139| export function isTranscriptMessage(entry: Entry): entry is TranscriptMessage {
140| return (
141| entry.type === 'user' ||
142| entry.type === 'assistant' ||
143| entry.type === 'attachment' ||
144| entry.type === 'system'
145| )
146| }
FileStateCache 设计与 API
FileState 条目:content、timestamp、offset、limit、可选 isPartialView(CLAUDE.md 注入仅 partial 时 Edit 须先 Read)。
FileStateCache 包装 LRUCache:
- 构造:max entries + maxSize bytes(默认 25MB)
- normalize(key) 于 get/set — 路径 canonical
- dump/load — compact 序列化
- cloneFileStateCache / mergeFileStateCaches — 按 timestamp 合并
READ_FILE_STATE_CACHE_SIZE = 100 entries。
工厂 createFileStateCacheWithSizeLimit 用于 REPL、MCP entrypoint、QueryEngine。
不进 AppState 的原因:体积大、Turn 局部、fork subagent 需 clone——放 ToolUseContext 随 query 生命周期。
源码引用: src/utils/fileStateCache.ts · 第 4–39 行(共 143 行)
4| export type FileState = {
5| content: string
6| timestamp: number
7| offset: number | undefined
8| limit: number | undefined
9| // True when this entry was populated by auto-injection (e.g. CLAUDE.md) and
10| // the injected content did not match disk (stripped HTML comments, stripped
11| // frontmatter, truncated MEMORY.md). The model has only seen a partial view;
12| // Edit/Write must require an explicit Read first. `content` here holds the
13| // RAW disk bytes (for getChangedFiles diffing), not what the model saw.
14| isPartialView?: boolean
15| }
16|
17| // Default max entries for read file state caches
18| export const READ_FILE_STATE_CACHE_SIZE = 100
19|
20| // Default size limit for file state caches (25MB)
21| // This prevents unbounded memory growth from large file contents
22| const DEFAULT_MAX_CACHE_SIZE_BYTES = 25 * 1024 * 1024
23|
24| /**
25| * A file state cache that normalizes all path keys before access.
26| * This ensures consistent cache hits regardless of whether callers pass
27| * relative vs absolute paths with redundant segments (e.g. /foo/../bar)
28| * or mixed path separators on Windows (/ vs \).
29| */
30| export class FileStateCache {
31| private cache: LRUCache<string, FileState>
32|
33| constructor(maxEntries: number, maxSizeBytes: number) {
34| this.cache = new LRUCache<string, FileState>({
35| max: maxEntries,
36| maxSize: maxSizeBytes,
37| sizeCalculation: value => Math.max(1, Buffer.byteLength(value.content)),
38| })
39| }
源码引用: src/utils/fileStateCache.ts · 第 101–142 行(共 143 行)
101| export function createFileStateCacheWithSizeLimit(
102| maxEntries: number,
103| maxSizeBytes: number = DEFAULT_MAX_CACHE_SIZE_BYTES,
104| ): FileStateCache {
105| return new FileStateCache(maxEntries, maxSizeBytes)
106| }
107|
108| // Helper function to convert cache to object (used by compact.ts)
109| export function cacheToObject(
110| cache: FileStateCache,
111| ): Record<string, FileState> {
112| return Object.fromEntries(cache.entries())
113| }
114|
115| // Helper function to get all keys from cache (used by several components)
116| export function cacheKeys(cache: FileStateCache): string[] {
117| return Array.from(cache.keys())
118| }
119|
120| // Helper function to clone a FileStateCache
121| // Preserves size limit configuration from the source cache
122| export function cloneFileStateCache(cache: FileStateCache): FileStateCache {
123| const cloned = createFileStateCacheWithSizeLimit(cache.max, cache.maxSize)
124| cloned.load(cache.dump())
125| return cloned
126| }
127|
128| // Merge two file state caches, with more recent entries (by timestamp) overriding older ones
129| export function mergeFileStateCaches(
130| first: FileStateCache,
131| second: FileStateCache,
132| ): FileStateCache {
133| const merged = cloneFileStateCache(first)
134| for (const [filePath, fileState] of second.entries()) {
135| const existing = merged.get(filePath)
136| // Only override if the new entry is more recent
137| if (!existing || fileState.timestamp > existing.timestamp) {
138| merged.set(filePath, fileState)
139| }
140| }
141| return merged
142| }
REPL / QueryEngine 中的缓存流
REPL.tsx 启动:
readFileState = createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE)
→ 传入 query options.readFileState
→ Tool 执行更新 cache
compact 边界:
cacheToObject(readFileState) 写入 JSONL file_history / snapshot
resume / fork:
mergeFileStateCaches(old, restored)
QueryEngine 构造参数 readFileCache,getReadFileState() 暴露给 compact microCompact。
Tool.ts ToolUseContext 类型含 readFileState: FileStateCache——所有 Bash/Read/Edit 共享同一 cache,保证 getChangedFiles 与 permission 一致。
源码引用: src/QueryEngine.ts · 第 56–58 行(共 1296 行)
56| cloneFileStateCache,
57| type FileStateCache,
58| } from './utils/fileStateCache.js'
源码引用: src/QueryEngine.ts · 第 1255–1260 行(共 1296 行)
1255| canUseTool,
1256| getAppState,
1257| setAppState,
1258| initialMessages: mutableMessages,
1259| readFileCache: cloneFileStateCache(getReadFileCache()),
1260| customSystemPrompt,
源码引用: src/Tool.ts · 第 59–59 行(共 793 行)
59| import type { FileStateCache } from './utils/fileStateCache.js'
onChangeAppState 跨边界同步
AppState → bootstrap / 磁盘 的集中出口:
| AppState 字段 | 边界目标 |
|---|---|
| mainLoopModel | userSettings + setMainLoopModelOverride |
| toolPermissionContext.mode | CCR metadata + SDK stream |
| expandedView | globalConfig todos/spinner flags |
| verbose | globalConfig.verbose |
| settings | auth cache clear + env re-apply |
无 AppState → sessionStorage 直接路径——messages 由 logging 层写 JSONL。
无 AppState → FileStateCache——compact 读 Tool context。
新增跨边界 sync 时优先扩展 onChangeAppState,而非在 UI 组件写磁盘。
源码引用: src/state/onChangeAppState.ts · 第 94–112 行(共 172 行)
94| // mainLoopModel: remove it from settings?
95| if (
96| newState.mainLoopModel !== oldState.mainLoopModel &&
97| newState.mainLoopModel === null
98| ) {
99| // Remove from settings
100| updateSettingsForSource('userSettings', { model: undefined })
101| setMainLoopModelOverride(null)
102| }
103|
104| // mainLoopModel: add it to settings?
105| if (
106| newState.mainLoopModel !== oldState.mainLoopModel &&
107| newState.mainLoopModel !== null
108| ) {
109| // Save to settings
110| updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
111| setMainLoopModelOverride(newState.mainLoopModel)
112| }
调试边界问题 checklist
| 症状 | 先查 |
|---|---|
| /resume 空会话 | getSessionProjectDir 与 jsonl 路径;originalCwd symlink |
| 模型切换不生效 | AppState.mainLoopModel vs bootstrap override vs settings.json |
| Edit 报未读文件 | FileStateCache isPartialView;cache 是否 compact 后丢失 |
| CCR mode 不同步 | onChange permission diff;toExternalPermissionMode |
| 队友 session 错项目 | switchSession projectDir 是否传入 dirname(transcript) |
| cost 显示 0 | bootstrap restoreCostStateForSession 是否 resume 调用 |
边界设计目标:AppState 可整树替换(/clear)而不泄漏 bootstrap sessionId;bootstrap 可在无 React 的 SDK 路径运行;JSONL 是可审计长线;FileStateCache 是可丢弃热缓存。
本章小结与延伸
state-boundaries = AppState 外的三条持久化/进程边界。回到 app-state-core 对照 Provider initialState 如何从 resume hydrate。 继续学习: