源码路径:
src/utils/sessionStorage.ts(5105行)、src/utils/sessionRestore.ts(551行)、src/utils/sessionStart.ts(232行)
这是 CC 实现”可中断、可恢复 Agent”的工程核心。
一、Session 管理的核心问题
Agent 执行可能:
- 被用户 Ctrl+C 中断
- 因网络断开而中止
- 被
/clear清空后重新开始 - 通过
--continue或--resume明确续接
CC 的 Session 管理系统保证:无论何种中断,用户都能从中断点继续,不丢失对话历史和执行状态。
二、核心数据结构:JSONL Transcript
每个 Session 的完整历史存储为一个 JSONL 文件(每行一个 JSON 对象):
路径:~/.claude/projects/<project-slug>/<sessionId>.jsonl |
关键设计:parentUuid 链
// 每条消息有 uuid 和 parentUuid,形成链表 |
这个链表结构支持:
- 顺序恢复:按 parentUuid 重建对话链
- 分叉会话(worktree 模式):多条链共存
- 跳过废弃消息:progress 消息不参与链(避免”链断裂”)
三、Session 生命周期
用户运行 claude |
四、Transcript 存储路径
// 标准路径 |
项目目录 Slug 规则:由 getOriginalCwd() 经 sanitizePath() 处理后生成,确保不同项目的 Session 文件隔离。
五、消息写入机制
5.1 追加写入(性能优先)
// 消息以 append 方式写入 JSONL(非覆写) |
5.2 墓碑机制(Tombstone)
当需要”删除”或”修改”历史消息时(如 /undo),不直接修改 JSONL,而是追加一条墓碑记录:
type TombstoneEntry = { |
读取时跳过被标记的 UUID。墓碑重写有 50MB 限制(防止 OOM)。
5.3 进度消息(Ephemeral)
BashTool、PowerShellTool、MCPTool 的实时进度不写入 JSONL:
const EPHEMERAL_PROGRESS_TYPES = new Set([ |
六、Session 恢复(sessionRestore.ts)
--continue 或 --resume <sessionId> 时,执行恢复流程:
async function resumeSession(sessionId: string): Promise<ResumeResult> { |
关键:在 React 渲染前完成状态恢复
// main.tsx 中的关键顺序 |
七、多路恢复触发点
| 触发方式 | 场景 | 恢复范围 |
|---|---|---|
--continue |
继续上次会话 | 最近 session 的完整 transcript |
--resume <id> |
指定会话 ID | 指定 session 的 transcript |
/clear |
清空当前对话 | 不恢复,开新 session |
/compact |
压缩后继续 | 用 summary 替换历史,继续 |
| Swarm 重连 | Teammate 崩溃重启 | 从 TeamFile + 邮箱重建状态 |
八、SessionStart Hooks
每次会话启动(包括恢复)都执行 processSessionStartHooks(source):
type source = 'startup' | 'resume' | 'clear' | 'compact' |
九、并发会话管理
CC 支持多个终端同时运行(并发 Session):
// concurrentSessions.ts |
十、Worktree Session(分叉会话)
Git Worktree 模式下,每个 worktree 有独立的 Session:
type PersistedWorktreeSession = { |
十一、面试要点
Q:CC 如何保证 Agent 可中断后恢复?
每条消息实时 append 写入 JSONL,中断时已写入的内容不丢失。
--resume时读取 JSONL 重建 Message[],恢复 FileHistory、Attribution、Todo 等状态,然后继续 Agentic Loop。关键是”写入先于执行”——消息先写入磁盘,再执行工具,确保 crash 后重放是安全的。
Q:为什么用 JSONL 而不是 SQLite 或普通 JSON?
- **追加写入 O(1)**:JSONL 追加不需要读-改-写,对频繁写入(每个 token 流)性能最佳;2. 流式读取:可以边读边处理,不需要加载整个文件到内存;3. 崩溃安全:只需要 fsync 最后一行,不像 SQLite 需要事务管理;4. 调试友好:纯文本,可直接用
tail -f监控。
Q:进度消息(bash_progress)为什么不写入 JSONL?
进度消息是高频(1/秒)的 UI 状态,恢复 Session 时不需要重放这些中间状态,只需要最终结果。写入会使 JSONL 文件急剧膨胀(bash 执行10分钟就会产生600条进度消息)。它们通过 React state 直接更新 UI,REPL.tsx 对同一 toolUse 的进度消息做”原地替换”而非追加。
十二、JSONL 文件格式深度解析
12.1 文件路径规则的完整推导
~/.claude/projects/<project-slug>/<sessionId>.jsonl |
项目 slug 的生成过程(源自 src/utils/sessionStoragePortable.ts → sanitizePath()):
const MAX_SANITIZED_LENGTH = 200 // 避免超出文件系统 255 字节限制 |
实际例子:
- 项目路径
/Users/alice/myapp→ slug-Users-alice-myapp - 项目路径
/home/bob/very-long-project-name-...→-home-bob-very-long-...-<hash36> - 最终 JSONL 路径:
~/.claude/projects/-Users-alice-myapp/<uuid>.jsonl
注意:CLI 用 Bun.hash,SDK 用 Node.js simpleHash,对超长路径会产生不同 hash 后缀。findProjectDir() 提供前缀扫描兜底逻辑来容忍这种差异。
12.2 Session ID 的生成策略
Session ID 不是 自定义格式,而是标准 UUID v4(随机数生成):
// src/bootstrap/state.ts |
生成时机:
- 进程启动:
getInitialState()调用randomUUID()生成一次 - **
/clear**:调用regenerateSessionId(),生成新 UUID,sessionProjectDir重置为 null - **
--resume <id>**:调用switchSession(asSessionId(customId)),直接使用用户指定的 UUID
碰撞概率:UUID v4 有 122 位随机性,在 $10^{18}$ 次生成前碰撞概率 < 50%,对本地 CLI 场景完全忽略不计。
parentSessionId 追踪:
export function regenerateSessionId( |
12.3 每行 JSON 的完整字段结构
JSONL 中的 TranscriptMessage(user/assistant/system/attachment 类型)的完整序列化结构:
// 一条典型的 user 消息(磁盘上的真实格式) |
assistant 消息(含 ToolUseBlock):
{ |
tool_result(user 消息中的 ToolResultBlock):
{ |
12.4 元数据专用 Entry 类型
除对话消息外,JSONL 还包含多种元数据 Entry(不进入 Anthropic API 调用):
custom-title — 用户通过 /rename 设置的会话标题 |
这些 Entry 被 appendEntry() 路由到不同分支直接写入,无需读取已有内容,因此不依赖 getSessionMessages()。
十三、墓碑机制(Tombstone)深度实现
13.1 为什么用追加写而非覆盖
JSONL 追加写有三个关键优势:
- O(1) 写入:无论文件多大,append 一行的时间恒定
- 崩溃安全:每行独立,写入一行不影响其他行;若在 append 时崩溃,最多丢失最后一行,已有内容完好
- 无读-改-写竞争:不需要先读文件、再完整覆写,避免并发写冲突
但追加写有个问题:已写入的内容无法物理删除。当流式接收 Claude 回复中途出错(如网络断开),已写入的半截 assistant 消息会残留在 JSONL 中,恢复时会产生”孤儿消息”(dangling ref)。
解决方案就是墓碑(Tombstone):通过物理删除目标行来移除特定消息。
13.2 removeMessageByUuid() 的两条路径
// src/utils/sessionStorage.ts |
核心设计决策:
- 搜索
"uuid":"..."而非裸 UUID,避免误匹配parentUuid字段 - UUID 是纯 ASCII,字节级搜索安全(不需要 UTF-8 解码)
- 50MB 上限防止对大文件做全量内存操作(实际会话可达数 GB)
13.3 墓碑 vs 物理删除的权衡
| 维度 | 追加墓碑标记 | 物理行删除(CC 实际采用) |
|---|---|---|
| 写放大 | 低(O(1)追加) | 中(尾部 64KB 读+写) |
| 恢复复杂度 | 高(读时需过滤) | 低(直接不存在) |
| 部分覆盖场景 | 简单 | 需要精确 ftruncate |
| 50MB+ 文件 | 始终可用 | 跳过(接受残留) |
| 并发安全 | 无需锁(append) | 需要文件句柄(r+) |
CC 选择物理行删除(而非逻辑墓碑标记),原因是恢复时无需过滤逻辑,loadTranscriptFile() 直接解析所有行即为有效消息,降低了读取复杂度。
13.4 会话列表中的”已删除”过滤
JSONL 级别没有”整个会话删除”的概念,但 listSessions() 在列出可恢复会话时,会读取每个 .jsonl 文件的元数据尾部(readLiteMetadata)。若某 session 被用户删除,对应的 .jsonl 文件会被整个物理删除,listSessions() 扫描 projects/ 目录时自然不会出现。
十四、写入策略与数据一致性
14.1 异步批量写:enqueueWrite + drainWriteQueue
CC 的 JSONL 写入并非同步阻塞,而是采用异步批量写策略:
class Project { |
写入流程:
appendEntry()→enqueueWrite()→ 推入对应文件的 QueuescheduleDrain()设置 100ms 定时器(已有定时器则跳过)- 100ms 后
drainWriteQueue()一次性appendFile所有积累的行 - 每个
enqueueWrite返回一个Promise,resolve 在其所属 batch 写盘后触发
这种设计将高频写入(每条消息)合并为低频 I/O(每 100ms 一次),大幅减少系统调用次数。
14.2 崩溃时的数据一致性保证
关键问题:如果进程在 drainWriteQueue 执行过程中崩溃,会丢失多少数据?
答案:最多丢失 100ms 内的写入。
CC 的一致性策略:
- 消息先入 Queue,再执行 Tool:
insertMessageChain()写入 Queue 后,Tool 才执行。即使 Tool 执行期间崩溃,消息 Queue 数据已安全 - 实际上是 pending 状态:Queue 中的条目尚未落盘,若进程在 100ms 窗口内崩溃,会丢失这批数据
- 清理时强制刷盘:进程退出时
cleanupRegistry执行flush(),等待所有 pending writes 完成
// 清理处理器(进程退出时调用) |
flush() 的实现:
async flush(): Promise<void> { |
权衡:CC 接受”崩溃时丢失最多 100ms 写入”的代价,换取更高的吞吐量。对于 Claude 对话场景(消息频率远低于 10/s),这几乎不是问题。
14.3 文件锁(history.jsonl 专用)
history.jsonl(prompt 历史,Up-arrow 浏览)使用了文件锁:
// src/history.ts |
而 <sessionId>.jsonl(会话 transcript)不使用锁,因为:
- 每个 session 有唯一的 sessionId + 对应文件
- 单进程顺序写入,无多进程并发写同一文件的场景
- 使用锁反而会增加延迟
十五、会话恢复完整代码路径
15.1 从 --resume Flag 到 Messages 数组
CLI 参数解析 |
15.2 loadTranscriptFile() 的核心逻辑
async function loadTranscriptFile(filePath: string): Promise<{ |
15.3 哪些消息会被跳过或转换
| 消息类型 | 恢复时处理 |
|---|---|
progress(legacy) |
跳过,但修复子节点的 parentUuid 链(progressBridge) |
bash_progress 等 EPHEMERAL 类型 |
直接跳过,不进入 messages Map |
| compact 边界前的消息 | applyCompactPrune() 删除(除非有 SEG 保留段) |
| snip 删除的消息 | applySnipFilter() 从 Map 中移除 |
| isSidechain=true 的消息 | 进入 messages Map,但 buildConversationChain() 默认不选入主链 |
content-replacement |
恢复时替换对应 UUID 的消息内容 |
已被 removeMessageByUuid 删除的行 |
已不在文件中,自然不会被解析 |
compact 边界处理细节(applyCompactPrune):
boundary 之前的消息 → 删除(默认) |
这确保了 /compact 后的会话恢复只加载压缩后的上下文,而不是将压缩前的完整历史全部送给 API(会超出 context window)。
十六、消息类型序列化的特殊处理
16.1 图片内容的处理
在序列化消息时,图片不做截断直接存入 JSONL(base64 编码)。但图片在进入 Anthropic API 之前,有一道大小检查:
// src/utils/sessionStorage.ts(写入路径) |
图片截断发生在会话恢复时的消息传递给 API 之前,而非在 JSONL 写入时。history.ts 中处理 paste 内容时会分层存储:
// history.ts:处理粘贴内容的大小分层 |
16.2 history.jsonl vs <sessionId>.jsonl 的区别
这是两个完全不同的 JSONL 文件,很容易混淆:
| 特征 | history.jsonl |
<sessionId>.jsonl |
|---|---|---|
| 路径 | ~/.claude/history.jsonl |
~/.claude/projects/<slug>/<uuid>.jsonl |
| 内容 | 用户输入的 prompt 历史(Up-arrow) | 完整对话 transcript |
| 用途 | Shell-like 历史搜索(Ctrl+R) | 会话恢复(--resume) |
| 写入策略 | 有文件锁,多 session 共享 | 无锁,单 session 独占 |
| 条目类型 | LogEntry(display + timestamp + project) |
TranscriptMessage + 元数据 Entry |
| 图片处理 | 跳过图片,大文本存 hash 引用 | 全量存储 |
| 读取方向 | 逆序(readLinesReverse)取最近 100 条 |
顺序读全量 |
16.3 ToolUseBlock 的序列化注意点
ToolUseBlock 的 input 字段直接 JSON 序列化,包含工具的完整参数(如文件路径、代码内容)。对于 Write Tool,input.content 可能是几 KB 甚至几百 KB 的代码。
恢复时,这些内容作为 assistant 消息的一部分直接送给 API,参与 context,让模型”记得”自己上次写了什么代码。
十七、多 Agent 场景下的 Session 文件关系
17.1 SubAgent 的文件布局
~/.claude/projects/<project-slug>/ |
17.2 Leader 与 SubAgent 的写入隔离
// 判断是否写入 subagent 文件 |
isSidechain=false:写入 Leader 的.jsonlisSidechain=true+agentId有值:写入subagents/agent-<id>.jsonl
17.3 SubAgent 的元数据持久化
AgentTool 启动 SubAgent 时写入 .meta.json:
// 写入 |
meta.json 不放入 JSONL 的原因:
- 是 session 级别的结构元数据,与对话内容无关
- 避免 JSONL schema 变更带来的向后兼容问题
hydrateSessionFromRemote()会 wipe.jsonl文件内容,但.meta.json以 sidecar 形式存在,不受影响
17.4 SubAgent Resume 的特殊处理
当 Leader Resume 时,已结束的 SubAgent 不会重新恢复;仍在运行的 SubAgent 通过 AgentTool 的恢复逻辑重新接入。resumeAgent() 会:
- 读取
subagents/agent-<id>.jsonl重建 SubAgent 的消息历史 - 读取
.meta.json确定 agentType 和 cwd - 将 SubAgent 以
isSidechain=true重新注入 Leader 的消息链
十八、面试深度题
Q1:为什么选 JSONL 而不是 SQLite 来存储会话历史?
SQLite 的问题:写一条消息需要 BEGIN → INSERT → COMMIT 三步事务,频繁写入(每个 streaming token 到来就可能触发一次 append)下事务开销显著;WAL 模式虽然改善并发,但对单进程顺序写并无必要。另外 SQLite 是 C 库,Bun/Node.js 下需要 native addon,增加打包和跨平台复杂度。
JSONL 的优势:append 是 OS 级原子操作(在多数文件系统上,单次 write syscall < PIPE_BUF 是原子的);崩溃恢复只需检查最后一行是否完整(通过 try-catch JSON.parse);纯文本,可以用任意文本工具分析;不依赖任何 native 库。
真正的权衡:JSONL 不支持随机查询(如”查找所有包含关键词 X 的消息”),但 CC 的查询模式是”读取某 session 的全量历史”,JSONL 顺序读完全胜任。
Q2:墓碑删除 vs 物理删除 — CC 为什么选择物理删除?
CC 选择的其实是物理行删除(通过 ftruncate + 重写尾部),而非追加墓碑标记。这个选择的核心原因是读取路径零成本:
loadTranscriptFile()解析每一行直接进入 messages Map,不需要维护”已删除 UUID 集合”并在解析时过滤。如果用追加墓碑,每次恢复都要 two-pass(第一遍收集 tombstones,第二遍过滤消息),增加了恢复逻辑的复杂度和内存开销。
物理删除的代价是写入时需要随机写(r+ 模式 + ftruncate),但因为目标几乎总是最后一条(失败的流式 assistant 消息),64KB 尾部读即可定位,实际延迟极低(通常 < 1ms)。
Q3:Session ID 碰撞会怎样?如何规避?
UUID v4 有 122 位随机性,在单台机器的本地文件系统上,产生碰撞的概率可以完全忽略(生成 $2.7 \times 10^{18}$ 个 UUID 才有 50% 概率出现一次碰撞)。CC 没有额外的碰撞检测逻辑。
即使理论上碰撞发生,也只是两个 session 写入同一个
.jsonl文件,loadTranscriptFile()会按 parentUuid 链把两个 session 的消息混在一起,导致恢复出错。但这在实践中不可能发生。真正需要处理的是跨平台路径哈希差异:超长路径(> 200 chars)的 project slug 使用 hash 后缀,CLI 用 Bun.hash,SDK 用 Node.js 的 simpleHash,会产生不同的目录名。CC 通过
findProjectDir()的前缀扫描来容忍这种差异。
Q4:如果 drainWriteQueue 执行到一半时进程崩溃,如何保证数据不损坏?
appendFile(即fsAppendFile)在多数 OS 上对于 O_APPEND 的写操作是原子的,前提是单次写入不超过 PIPE_BUF(Linux 上通常 4KB)。CC 的批量写可能远超 4KB,因此理论上存在部分写(partial write)风险。如果发生部分写,最后一行 JSON 会不完整。CC 的
parseJSONL()用 try-catch 包裹每行的解析,忽略无法解析的行(malformed lines)。因此:即使崩溃导致最后一行截断,恢复时该行被跳过,不影响其他消息的恢复。这是接受最终一行丢失换取简单实现的工程权衡,对于 AI 对话场景是合理的(丢失一条 assistant 回复的最后几字符,远比崩溃后无法恢复危害小)。


