本章目录In this chapter
- 11 · Persistence and Resume
- 11.1 The Transcript Format: JSONL
- 11.2 The Transcript Is a Tree, Not a List
- 11.3 The Write Queue
- 11.4 Subagent Records: Sidechain Files
- 11.5 Resume: Three Ways
- 11.6 File History: Rolling Back Files the Agent Changed
- 11.7 Multi-Source Settings and Migrations
- 11.8 The Memory Directory
11 · 持久化与恢复
utils/sessionStorage.ts,176 KB。这一章讲对话怎么落盘、怎么恢复,以及文件修改怎么回滚。
11.1 对话记录的格式:JSONL
JSONL 是 JSON Lines 的缩写:一个文本文件,每一行是一个完整的 JSON 对象。
选 JSONL 而不是一个大 JSON 数组的理由很实际:
| 优势 | 说明 |
|---|---|
| 可以追加写 | 新消息直接 append 到文件末尾,不需要读出来、修改、再整个写回 |
| 崩溃安全 | 进程被杀时最多丢最后一行(可能写了一半)。前面的行全部完好。如果是大 JSON 数组,写到一半的文件整个都无法解析 |
| 可以流式读 | 恢复时可以边读边解析,不用把几百 MB 一次性加载进内存 |
| 好排查 | 用 tail、grep 这些标准命令行工具就能查看 |
有一个读取上限保护:
export const MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024 // 50 MB
11.2 对话记录是一棵树,不是一个列表
注意上面每条记录都有 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 往回走,走到某处断了 —— 因为那条链被错误地分叉了,主链的一部分挂到了支链上。
11.3 写入队列
落盘不是每次都直接写文件。中间有一个写入队列,第 2.5 节提到过它的两个特性:
- 100 毫秒的延迟序列化 —— 消息进队列后不立刻转成 JSON 字符串,等一小会儿再统一处理
- 保序 —— 即使调用方用「发射后不管」的方式提交,写入顺序也和提交顺序一致
那个 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> | 直接恢复指定会话 |
恢复时有几个可选修饰:
--fork-session—— 生成新的会话 ID。原会话保持不变,等于「另存为」--resume-session-at <消息ID>—— 只恢复到指定消息,之后的丢弃。等于「回到某个存档点」
恢复时的链条重建
第 2.6 节提到的 applyPreservedSegmentRelinks(应用保留段重新串联)函数负责处理压缩过的会话:
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).
「local」层的存在很重要:它让个人的临时配置(比如「我这台机器上多授权一个目录」)不会污染团队共享的项目配置。
配置迁移
migrations/ 目录有 13 个文件。它们的作用是:程序升级后,把旧格式的配置文件自动转换成新格式。
这是长期维护的产品必须有的东西 —— 否则每次改配置结构都会让老用户的配置失效。而且迁移必须是幂等的、可以反复执行的,因为你不知道用户从哪个版本升上来。
11.8 记忆目录
memdir/,10 个文件。它管理的是跨会话的长期记忆。
召回机制在第 3.9 节讲过:预取 + 模型判断相关性 + 用已读文件状态去重。
注意这个设计的一个特点:记忆是人类可读、可以用 git 管理的纯文本文件。没有向量数据库,没有嵌入模型。
这个选择对「指令性记忆」是正确的。用户偏好、团队约定、项目规范这类内容:
· 用户改了要立刻生效,不能等重新索引
· 用户必须能看到自己写了什么,能审阅、能修正
· 内容会被全量加载,不需要检索
向量检索适合的是「事实性记忆」(几千条事实里找相关的那几条),和这个场景不是一回事。
另外还有 CLAUDE.md 这个特殊文件,它是按目录层级嵌套加载的:在 ~/project/src/utils/ 下工作时,会依次加载 ~/project/CLAUDE.md、~/project/src/CLAUDE.md、~/project/src/utils/CLAUDE.md。越靠近当前目录的规则越具体、优先级越高。
11 · Persistence and Resume
utils/sessionStorage.ts, 176 KB. This chapter covers how conversations get written to disk, how they're resumed, and how file changes get rolled back.
11.1 The Transcript Format: JSONL
JSONL stands for JSON Lines: a text file where every line is a complete JSON object.
The reasons for choosing JSONL over one big JSON array are practical:
| Advantage | Description |
|---|---|
| Append-only writes | New messages are appended straight to the end of the file — no reading it out, modifying, and writing the whole thing back |
| Crash safety | If the process is killed, at most the last line is lost (it may be half-written). Every earlier line is intact. With one big JSON array, a half-written file can't be parsed at all |
| Streaming reads | On resume it can be parsed as it's read, without loading hundreds of MB into memory at once |
| Easy to inspect | Standard command-line tools like tail and grep can read it |
There's a read-size cap as protection:
export const MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024 // 50 MB
11.2 The Transcript Is a Tree, Not a List
Note that every record above has two fields, uuid and parentUuid. That tells you the transcript is structurally a tree.
Why a tree?
| Scenario | Why it needs branching |
|---|---|
--fork-session | Fork a new line from some save point, keeping both lines |
| Compaction | The post-compaction message chain has to attach to the tail of the “preserved segment,” while the compacted-away stretch still physically exists in the file |
| Subagents | A subagent's conversation is a “sidechain” (the source's term) hanging off a node on the main chain |
| Retries | After a failed turn is retried, the failed branch is still in the file, just not on the main chain |
The comment quoted in section 2.5 can now be read in full:
“…the dedup walk freezes startingParentUuid at the wrong message — forking the chain and orphaning the conversation on resume.”
Put plainly: …the dedup walk pinned the “starting parent” to the wrong message — forking off a sidechain and orphaning the conversation on resume.
“Orphaning” means: on resume, walking back from the last message along parentUuid, the chain breaks somewhere — because it was forked by mistake, and part of the main chain got hung off the sidechain.
11.3 The Write Queue
Writes don't go straight to the file every time. In between sits a write queue, whose two properties came up in section 2.5:
- 100 ms deferred serialization — a message entering the queue isn't turned into a JSON string immediately; it waits a moment and gets processed in a batch
- Order preservation — even when callers submit fire-and-forget, the write order matches the submission order
The purpose of that 100 ms delay was covered in section 2.5: it gives “the usage fields on the model's message getting filled in” a time window. Because the API layer emits the message first and receives the usage data afterward.
There's also a flushSessionStorage() function for forcing a drain:
// QueryEngine.ts, right before yielding the final result
// 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()
}
}
Put plainly: drain the buffered transcript writes before yielding the result message. The desktop app kills the CLI process the moment it receives the result message, so any un-drained writes would be lost.
This is a classic “integration boundary problem”: your program is designed to “write asynchronously, drain later,” but the caller is designed to “kill the process as soon as the result arrives.” Each side is reasonable on its own; combined, they lose data.
The fix is having the caller declare its behavior through an environment variable (CLAUDE_CODE_IS_COWORK means “I'm that desktop app that kills the process immediately”), and then switching to a synchronous drain specifically for it.
11.4 Subagent Records: Sidechain Files
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(...)
A subagent's transcript is written to its own file, with a metadata record alongside (task description, status, start time, and so on). The main transcript keeps only “a subagent was spawned” and “here's what it returned.”
The benefit of this design: the main record doesn't balloon with a subagent's hundreds of messages, yet when you need to investigate, you can follow the agentId to the complete sub-record.
Remote agents have a separate set of their own:
export type RemoteAgentMetadata = { ... }
export async function writeRemoteAgentMetadata(taskId, ...)
export async function readRemoteAgentMetadata(taskId)
export async function deleteRemoteAgentMetadata(taskId)
export async function listRemoteAgentMetadata()
11.5 Resume: Three Ways
| Command | Behavior |
|---|---|
claude -c--continue | Continue the most recent conversation in the current directory. No questions asked; picks right up |
claude -r--resume | Opens an interactive picker (ResumeConversation.tsx) listing past sessions for the user to choose from |
claude -r <session-id> | Resume the specified session directly |
Resume takes a few optional modifiers:
--fork-session— generate a new session ID. The original session stays untouched; it's “save as”--resume-session-at <message-id>— resume only up to the specified message, discarding everything after. It's “go back to a save point”
Rebuilding the chain on resume
The applyPreservedSegmentRelinks function mentioned in section 2.6 handles sessions that have been compacted:
11.6 File History: Rolling Back Files the Agent Changed
utils/fileHistory.ts. This is lightweight version control independent of git.
export type FileHistoryBackup = { ... } // one backup
export type FileHistorySnapshot = { ... } // a snapshot at a point in time
export type FileHistoryState = { ... } // overall state
export type DiffStats = ... // diff statistics
export function fileHistoryEnabled(): boolean
export async function fileHistoryTrackEdit(...) // record one edit
export async function fileHistoryMakeSnapshot(...) // take a snapshot
export async function fileHistoryRewind(...) // ★ rewind
export function fileHistoryCanRestore(...) // can it be restored
export async function fileHistoryGetDiffStats(...) // diff statistics
export async function fileHistoryHasAnyChanges(...)
export async function checkOriginFileChanged(...) // ★ detect external modification
export function fileHistoryRestoreStateFromLog(...) // rebuild state from the log
export async function copyFileHistoryForResume(...) // copy history on resume
When snapshots are taken
// QueryEngine.ts
if (fileHistoryEnabled() && persistSession) {
messagesFromUserInput
.filter(messageSelector().selectableUserMessagesFilter)
.forEach(message => {
void fileHistoryMakeSnapshot(
(updater) => { setAppState(prev => ({ ...prev, fileHistory: updater(prev.fileHistory) })) },
message.uuid, // ★ snapshots are anchored to the "user message's uuid"
)
})
}
Every user message gets a snapshot. So the user can say “go back to the state before I asked this question” — the matching command-line flag:
--rewind-files <user-message-id>
Restore files to state at the specified user message and exit (requires --resume)
(i.e. roll the files back to how they stood at that user message, then quit)
Detecting external modifications
checkOriginFileChanged handles this scenario: the agent read a.ts, you changed it in your editor, and now the agent wants to change the same file.
Without detection, the agent would edit based on the old content and overwrite your changes. So it detects and flags — typically by rejecting the edit and requiring the model to re-read first.
Why not just use git? Because:
· The user's working directory may not be a git repo
· The agent's intermediate changes shouldn't pollute the user's git history (imagine a commit for every tool call)
· Snapshots need to be at the granularity of “user message,” not “commit”
This is a textbook case of “the existing tool doesn't quite fit, so build a lightweight version that fits the scenario better.”
11.7 Multi-Source Settings and Migrations
Settings source priority
--setting-sources <sources>
Comma-separated list of setting sources to load (user, project, local).
The existence of the “local” layer matters: it keeps personal, temporary settings (say, “authorize one more directory on this machine of mine”) from polluting the team-shared project settings.
Settings migrations
The migrations/ directory has 13 files. Their job: after the program is upgraded, automatically convert old-format settings files to the new format.
Any product maintained over the long term needs this — otherwise every change to the settings structure would break existing users' configs. And migrations have to be idempotent and safe to run repeatedly, because you don't know which version the user is upgrading from.
11.8 The Memory Directory
memdir/, 10 files. It manages long-term memory across sessions.
The recall mechanism was covered in section 3.9: prefetch + model judges relevance + dedup against the read-file state.
Note a trait of this design: memories are human-readable plain-text files that can be managed with git. No vector database, no embedding model.
That choice is right for “instructional memory.” For content like user preferences, team conventions, and project standards:
· When the user edits it, it must take effect immediately, not wait for re-indexing
· The user must be able to see what they wrote, review it, and correct it
· The content gets loaded in full; no retrieval needed
Vector retrieval suits “factual memory” (finding the relevant few among thousands of facts), which is a different scenario altogether.
There's also the special CLAUDE.md file, which is loaded nested by directory level: when working in ~/project/src/utils/, it loads ~/project/CLAUDE.md, ~/project/src/CLAUDE.md, and ~/project/src/utils/CLAUDE.md in turn. The closer to the current directory, the more specific the rules and the higher their priority.