utils/sessionStorage.ts,176 KB。这一章讲对话怎么落盘、怎么恢复,以及文件修改怎么回滚。
JSONL 是 JSON Lines 的缩写:一个文本文件,每一行是一个完整的 JSON 对象。
选 JSONL 而不是一个大 JSON 数组的理由很实际:
| 优势 | 说明 |
|---|---|
| 可以追加写 | 新消息直接 append 到文件末尾,不需要读出来、修改、再整个写回 |
| 崩溃安全 | 进程被杀时最多丢最后一行(可能写了一半)。前面的行全部完好。如果是大 JSON 数组,写到一半的文件整个都无法解析 |
| 可以流式读 | 恢复时可以边读边解析,不用把几百 MB 一次性加载进内存 |
| 好排查 | 用 tail、grep 这些标准命令行工具就能查看 |
有一个读取上限保护:
export const MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024 // 50 MB
注意上面每条记录都有 uuid 和 parentUuid 两个字段。这说明对话记录在结构上是一棵树。
为什么需要树?
| 场景 | 为什么需要分叉 |
|---|---|
--fork-session | 从某个存档点分叉出一条新线,两条线都保留 |
| 压缩 | 压缩后的消息链要接到「保留段」的尾部,而被压缩掉的那一段仍然物理存在于文件里 |
| 子智能体 | 子智能体的对话是一条「支链」(源码里叫 sidechain),挂在主链的某个节点上 |
| 重试 | 某轮失败重试后,失败的那条分支仍然在文件里,只是不在主链上 |
第 2.5 节引用过的那段注释现在可以完全读懂了:
「…the dedup walk freezes startingParentUuid at the wrong message — forking the chain and orphaning the conversation on resume.」
译:……去重遍历把「起始父节点」固定在了错误的消息上 —— 从而分叉出一条支链,让对话在恢复时变成孤儿。
「变成孤儿」的意思是:恢复时从最后一条消息沿 parentUuid 往回走,走到某处断了 —— 因为那条链被错误地分叉了,主链的一部分挂到了支链上。
落盘不是每次都直接写文件。中间有一个写入队列,第 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 表示「我是那个会立刻杀进程的桌面应用」),然后针对性地切换到同步排空。
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()
| 命令 | 行为 |
|---|---|
claude -c--continue | 继续当前目录下最近一次对话。不问,直接接上 |
claude -r--resume | 打开一个交互式选择器(ResumeConversation.tsx),列出历史会话让用户挑 |
claude -r <会话ID> | 直接恢复指定会话 |
恢复时有几个可选修饰:
--fork-session —— 生成新的会话 ID。原会话保持不变,等于「另存为」--resume-session-at <消息ID> —— 只恢复到指定消息,之后的丢弃。等于「回到某个存档点」第 2.6 节提到的 applyPreservedSegmentRelinks(应用保留段重新串联)函数负责处理压缩过的会话:
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 历史(想象每次工具调用都产生一个提交)
· 需要以「用户消息」为粒度做快照,而不是以「提交」为粒度
这是一个「已有工具不完全适配,所以造了一个更贴合场景的轻量版本」的典型案例。
--setting-sources <sources>
Comma-separated list of setting sources to load (user, project, local).
「local」层的存在很重要:它让个人的临时配置(比如「我这台机器上多授权一个目录」)不会污染团队共享的项目配置。
migrations/ 目录有 13 个文件。它们的作用是:程序升级后,把旧格式的配置文件自动转换成新格式。
这是长期维护的产品必须有的东西 —— 否则每次改配置结构都会让老用户的配置失效。而且迁移必须是幂等的、可以反复执行的,因为你不知道用户从哪个版本升上来。
memdir/,10 个文件。它管理的是跨会话的长期记忆。
召回机制在第 3.9 节讲过:预取 + 模型判断相关性 + 用已读文件状态去重。
注意这个设计的一个特点:记忆是人类可读、可以用 git 管理的纯文本文件。没有向量数据库,没有嵌入模型。
这个选择对「指令性记忆」是正确的。用户偏好、团队约定、项目规范这类内容:
· 用户改了要立刻生效,不能等重新索引
· 用户必须能看到自己写了什么,能审阅、能修正
· 内容会被全量加载,不需要检索
向量检索适合的是「事实性记忆」(几千条事实里找相关的那几条),和这个场景不是一回事。
另外还有 CLAUDE.md 这个特殊文件,它是按目录层级嵌套加载的:在 ~/project/src/utils/ 下工作时,会依次加载 ~/project/CLAUDE.md、~/project/src/CLAUDE.md、~/project/src/utils/CLAUDE.md。越靠近当前目录的规则越具体、优先级越高。