11 · 持久化与恢复

utils/sessionStorage.ts,176 KB。这一章讲对话怎么落盘、怎么恢复,以及文件修改怎么回滚。

11.1 对话记录的格式:JSONL

JSONL 是 JSON Lines 的缩写:一个文本文件,每一行是一个完整的 JSON 对象。

~/.claude/projects/<项目目录哈希>/<会话ID>.jsonl {"type":"user","uuid":"a1b2...","parentUuid":null,"message":{...},"timestamp":"..."} {"type":"assistant","uuid":"c3d4...","parentUuid":"a1b2...","message":{...}} {"type":"user","uuid":"e5f6...","parentUuid":"c3d4...","message":{tool_result}} {"type":"progress","uuid":"...","parentUuid":"e5f6...",...} ...

选 JSONL 而不是一个大 JSON 数组的理由很实际:

优势说明
可以追加写新消息直接 append 到文件末尾,不需要读出来、修改、再整个写回
崩溃安全进程被杀时最多丢最后一行(可能写了一半)。前面的行全部完好。如果是大 JSON 数组,写到一半的文件整个都无法解析
可以流式读恢复时可以边读边解析,不用把几百 MB 一次性加载进内存
好排查tailgrep 这些标准命令行工具就能查看

有一个读取上限保护:

export const MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024   // 50 MB

11.2 对话记录是一棵树,不是一个列表

注意上面每条记录都有 uuidparentUuid 两个字段。这说明对话记录在结构上是一棵树。

为什么需要树?

场景为什么需要分叉
--fork-session从某个存档点分叉出一条新线,两条线都保留
压缩压缩后的消息链要接到「保留段」的尾部,而被压缩掉的那一段仍然物理存在于文件里
子智能体子智能体的对话是一条「支链」(源码里叫 sidechain),挂在主链的某个节点上
重试某轮失败重试后,失败的那条分支仍然在文件里,只是不在主链上

第 2.5 节引用过的那段注释现在可以完全读懂了:

「…the dedup walk freezes startingParentUuid at the wrong message — forking the chain and orphaning the conversation on resume.」

译:……去重遍历把「起始父节点」固定在了错误的消息上 —— 从而分叉出一条支链,让对话在恢复时变成孤儿。

「变成孤儿」的意思是:恢复时从最后一条消息沿 parentUuid 往回走,走到某处断了 —— 因为那条链被错误地分叉了,主链的一部分挂到了支链上。

11.3 写入队列

落盘不是每次都直接写文件。中间有一个写入队列,第 2.5 节提到过它的两个特性:

那个 100 毫秒延迟的作用在第 2.5 节讲过:它给了「模型消息的用量字段被补全」一个时间窗。因为接口层是先吐出消息、后收到用量数据的。

还有一个 flushSessionStorage() 函数用于强制排空:

// QueryEngine.ts,发出最终结果之前
// Flush buffered transcript writes before yielding result.
// The desktop app kills the CLI process immediately after receiving the
// result message, so any unflushed writes would be lost.
if (persistSession) {
  if (isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
      isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)) {
    await flushSessionStorage()
  }
}

译:在发出结果消息之前排空缓冲的对话记录写入。桌面应用在收到结果消息后会立刻杀掉命令行进程,所以任何未排空的写入都会丢失。

这是一个典型的「集成边界问题」:你的程序设计成「异步落盘、稍后排空」,但调用方设计成「收到结果就杀进程」。两边各自都合理,组合起来就丢数据。

解法是让调用方通过环境变量声明自己的行为CLAUDE_CODE_IS_COWORK 表示「我是那个会立刻杀进程的桌面应用」),然后针对性地切换到同步排空。

11.4 子智能体的记录:支链文件

export function setAgentTranscriptSubdir(...)
export function clearAgentTranscriptSubdir(agentId: string): void
export function getAgentTranscriptPath(agentId: AgentId): string

export type AgentMetadata = { ... }
export async function writeAgentMetadata(...)
export async function readAgentMetadata(...)

子智能体的对话记录写在独立的文件里,配一份元数据(任务描述、状态、启动时间等)。主对话记录里只保留「派生了一个子智能体」和「它返回了什么」。

这样设计的好处:主记录不会被子智能体的几百条消息撑爆,而需要排查时又能顺着 agentId 找到完整的子记录。

远程智能体还有一套单独的:

export type RemoteAgentMetadata = { ... }
export async function writeRemoteAgentMetadata(taskId, ...)
export async function readRemoteAgentMetadata(taskId)
export async function deleteRemoteAgentMetadata(taskId)
export async function listRemoteAgentMetadata()

11.5 恢复:三种方式

命令行为
claude -c
--continue
继续当前目录下最近一次对话。不问,直接接上
claude -r
--resume
打开一个交互式选择器(ResumeConversation.tsx),列出历史会话让用户挑
claude -r <会话ID>直接恢复指定会话

恢复时有几个可选修饰:

恢复时的链条重建

第 2.6 节提到的 applyPreservedSegmentRelinks(应用保留段重新串联)函数负责处理压缩过的会话:

磁盘上的文件包含: [压缩前的 200 条消息] + [压缩分界点] + [摘要] + [保留段的 6 条] 恢复时不应该加载全部 206 条,而应该加载: [摘要] + [保留段的 6 条] 做法:从"保留段的尾节点"沿 parentUuid 往回走, 走到分界点为止,把这条链拎出来。 ★ 如果尾节点指向一条"从未被写入磁盘"的消息(因为进程在写入前被杀了), 这个回溯就会失败 → 函数直接返回不做裁剪 → 加载全部 206 条 → 上下文立刻爆掉 这就是第 2.6 节那段"压缩分界点前必须先落盘"逻辑存在的原因。

11.6 文件历史:智能体改过的文件可以回滚

utils/fileHistory.ts。这是一个独立于 git 的轻量版本控制

export type FileHistoryBackup   = { ... }    // 一次备份
export type FileHistorySnapshot = { ... }    // 某个时间点的快照
export type FileHistoryState    = { ... }    // 整体状态
export type DiffStats = ...                  // 差异统计

export function fileHistoryEnabled(): boolean
export async function fileHistoryTrackEdit(...)       // 记录一次编辑
export async function fileHistoryMakeSnapshot(...)    // 打一个快照
export async function fileHistoryRewind(...)          // ★ 回滚
export function fileHistoryCanRestore(...)            // 能否恢复
export async function fileHistoryGetDiffStats(...)    // 差异统计
export async function fileHistoryHasAnyChanges(...)
export async function checkOriginFileChanged(...)     // ★ 检测外部修改
export function fileHistoryRestoreStateFromLog(...)   // 从记录重建状态
export async function copyFileHistoryForResume(...)   // 恢复时复制历史

快照的时机

// QueryEngine.ts
if (fileHistoryEnabled() && persistSession) {
  messagesFromUserInput
    .filter(messageSelector().selectableUserMessagesFilter)
    .forEach(message => {
      void fileHistoryMakeSnapshot(
        (updater) => { setAppState(prev => ({ ...prev, fileHistory: updater(prev.fileHistory) })) },
        message.uuid,      // ★ 快照以"用户消息的 uuid"为锚点
      )
    })
}

每一条用户消息都打一个快照。所以用户可以说「回到我问这个问题之前的状态」—— 对应命令行参数:

--rewind-files <user-message-id>
  Restore files to state at the specified user message and exit (requires --resume)
  译:把文件恢复到指定用户消息时的状态然后退出

外部修改检测

checkOriginFileChanged 处理的场景是:智能体读了 a.ts,你在编辑器里改了它,然后智能体又要改这个文件。

如果不检测,智能体会基于旧内容做编辑,把你的修改覆盖掉。所以要检测并提示 —— 通常是拒绝这次编辑,要求模型先重新读取。

为什么不直接用 git?因为:
· 用户的工作目录可能不是 git 仓库
· 智能体的中间修改不应该污染用户的 git 历史(想象每次工具调用都产生一个提交)
· 需要以「用户消息」为粒度做快照,而不是以「提交」为粒度

这是一个「已有工具不完全适配,所以造了一个更贴合场景的轻量版本」的典型案例。

11.7 配置的多来源与迁移

配置来源优先级

--setting-sources <sources>
  Comma-separated list of setting sources to load (user, project, local).
优先级从高到低: ① 命令行参数(--model、--permission-mode 等) ② --settings 指定的文件或 JSON 字符串 ③ local 项目本地配置(.claude/settings.local.json,通常加进 .gitignore) ④ project 项目配置(.claude/settings.json,提交进仓库,团队共享) ⑤ user 用户全局配置(~/.claude/settings.json)

「local」层的存在很重要:它让个人的临时配置(比如「我这台机器上多授权一个目录」)不会污染团队共享的项目配置。

配置迁移

migrations/ 目录有 13 个文件。它们的作用是:程序升级后,把旧格式的配置文件自动转换成新格式。

这是长期维护的产品必须有的东西 —— 否则每次改配置结构都会让老用户的配置失效。而且迁移必须是幂等的、可以反复执行的,因为你不知道用户从哪个版本升上来。

11.8 记忆目录

memdir/,10 个文件。它管理的是跨会话的长期记忆

~/.claude/projects/<项目>/memory/ ├── MEMORY.md 索引:一行一条记忆的指针 ├── some-fact.md 一条记忆一个文件 ├── another-fact.md └── ... 每个记忆文件的格式: --- name: 短横线命名的标识 description: 一句话摘要,用于判断相关性 metadata: type: user | feedback | project | reference --- 正文……可以用 [[其他记忆的name]] 互相链接

召回机制在第 3.9 节讲过:预取 + 模型判断相关性 + 用已读文件状态去重

注意这个设计的一个特点:记忆是人类可读、可以用 git 管理的纯文本文件。没有向量数据库,没有嵌入模型。

这个选择对「指令性记忆」是正确的。用户偏好、团队约定、项目规范这类内容:
· 用户改了要立刻生效,不能等重新索引
· 用户必须能看到自己写了什么,能审阅、能修正
· 内容会被全量加载,不需要检索

向量检索适合的是「事实性记忆」(几千条事实里找相关的那几条),和这个场景不是一回事。

另外还有 CLAUDE.md 这个特殊文件,它是按目录层级嵌套加载的:在 ~/project/src/utils/ 下工作时,会依次加载 ~/project/CLAUDE.md~/project/src/CLAUDE.md~/project/src/utils/CLAUDE.md。越靠近当前目录的规则越具体、优先级越高。