本文实现分析限定于 2026 年 3 月 31 日的公开镜像快照,它不是 Anthropic 官方源码仓库,也不代表此后产品版本。正文固定到该提交;官方文档用于说明产品概念,功能开关和缺失模块按可见代码分别说明。

上一篇讲 hooks 时,已经看到 resume 会触发 SessionStart hooks。这里继续往后追:resume 到底恢复了什么? 如果只把它看成“加载旧 messages”,你会解释不了很多源码细节: 为什么要 copy plan?为什么要恢复 content replacement?为什么 fork resume 不能 adopt 原 transcript? 为什么 compact 后的消息链要特殊处理 parent uuid?

这篇的结论先说清:transcript 是持久化的恢复记录,不是直接发给模型的 prompt。 Claude Code 写入 transcript 时要维护消息链和附属记录;读取 transcript 时要清洗坏尾巴和未完成 tool_use; 真正 resume 时,还要把 transcript 里的状态重新安装回 AppState、session pointer、worktree、 agent definition 和 content replacement state;context-collapse 则是功能开关控制的可选恢复接口。

阅读目标:本篇只追本地会话恢复过程: recordTranscript() 怎样写记录,loadConversationForResume() 怎样读取和清理记录,processResumedConversation() 和 REPL 的 /resume 怎样恢复运行时状态,以及 --fork-session 为什么不能直接接管原会话文件。 远端会话、分享会话和 remote agent 只在必要处点到为止。

产品概念参考 Claude Code 官方概览; 源码层来自 公开源码镜像。 本文只描述客户端源码可见的本地持久化和恢复逻辑,不推断服务端会话存储或模型内部状态。

transcript rows
  -> loadConversationForResume(sessionId)
  -> clean incomplete tail / tool_use pairs
  -> run SessionStart resume hooks
  -> processResumedConversation()
  -> restore AppState, session pointer, worktree, agent, replacement state
源码形状:以已加载日志的 CLI resume 为例,先修复消息尾部,再安装可用状态;worktree、替换记录和受开关控制的 collapse 记录取决于输入与所走分支。

一、先分清四种数据形态

继续会话为什么复杂?因为同一段工作不只保存为一种数据。 屏幕上看到的是 UI messages;磁盘里保存的是 transcript 和附属 entry; 下一轮运行时使用的是经过恢复、清洗、压缩和工具预算处理后的消息与状态; 真正发给模型的则要进一步转换成 provider API 能接收的 request shape。

这四种数据不能混。UI 可以隐藏进度、折叠工具输出;transcript 要保留消息的前后关系; runtime state 要保证下一轮工具调用引用正确的文件、agent 和替换记录;API request 则必须满足模型和工具协议。 这也是 resume 不能简单等于“把 JSONL 里的 messages 读出来”的原因。

数据形态 负责什么 resume 时的风险
UI 用户看到的消息、进度、工具 JSX 和输入框状态。 看起来续上了,但内部状态可能没恢复。
Transcript 可持久化消息链、sidechain、metadata、content replacement、collapse commit。 坏尾巴、重复消息、parent 链断裂会污染下一轮。
Runtime AppState、session id、worktree、agent、文件历史、工具替换状态。 工具结果、文件快照、agent 定义和工作目录对不上。
API request 进入模型请求的消息、工具 schema 和影响 cache 的字段顺序。 未完成 tool_use、thinking-only 消息或过大工具结果会触发协议错误。

二、写入 transcript:不是全量覆盖,而是维护消息链

transcript 写入的入口之一在 React 侧。 useLogMessages() 会观察当前 messages。正常情况下 messages 是 append-only,所以它只把新增 tail 交给 recordTranscript();如果第一条消息变了,就说明可能经历了 compaction 或 /clear,需要让 recordTranscript() 自己做全量去重和 parent 链处理。

真正落盘在 recordTranscript()。 它先用 cleanMessagesForLogging() 清理可记录消息,再读取当前 session 已有的 message set。 如果一个消息已经写过,并且它还处在新消息之前的 prefix 里,源码会把它当成 parent hint; 如果 compaction 把新的压缩标记和 summary 放在前面,后面的旧 messagesToKeep 就不会被错误拿来当 parent。

2.1 附属记录也要跟着写

transcript 里不只有消息。同一个模块 还会记录 sidechain transcript、queue operation、file history snapshot、attribution snapshot 和 content replacement。这里最容易漏掉的是 content replacement: queryLoop() 在发模型请求前会执行工具结果预算,如果替换了过大的工具结果,就通过 recordContentReplacement() 写入可恢复记录。

这意味着“旧工具结果被压缩或替换过”不是屏幕状态,而是恢复状态。 resume 时如果只加载 messages,不恢复 replacement 记录,下一轮就可能把本该替换的完整工具输出重新送进请求, 造成上下文膨胀,并改变后续请求的缓存前缀。

三、读取 transcript:先清理坏尾巴,再补 resume hooks

读取主入口是 loadConversationForResume()。 它支持几种来源:--continue 时 source 为空,表示找最近会话; --resume 可以给 session id,也可以给已经加载好的 log,或者给 transcript file。 找到 LogOption 后,它会复制 plan、启动文件历史复制、检查 resume consistency,然后进入反序列化。直接传 JSONL 路径则走 loadMessagesFromJsonlPath(),该分支在这个函数内只得到消息与 session id;返回的文件历史、替换记录和 metadata 取自 log?.…,不能把两种入口说成恢复了完全相同的附属状态。--continue 在后台会话功能开启且能读取存活会话时,还会跳过正运行的非交互式会话。

3.1 反序列化不是 JSON.parse

deserializeMessagesWithInterruptDetection() 会先迁移 legacy attachment type,剔除无效 permission mode; 再过滤未解决的 tool_use、thinking-only assistant message、只有空白文本的 assistant message。 如果检测到中断中的 turn,它会追加一个 meta user message:Continue from where you left off.; 如果最后一个有效消息是 user,还会插入一个 synthetic assistant sentinel,保证对话形状合法。

这一步修复的是消息协议形状,不会恢复被中断进程的执行栈,也不会在反序列化时重新执行工具。比如 Bash 已经修改文件但 tool_result 尚未落盘,清理孤立 tool_use 并不撤销磁盘上的修改;后续继续任务前仍需重新检查实际文件和任务状态。加载器还会先从 invoked_skills attachment 恢复技能状态,避免多次 compact 后丢掉已调用技能。

3.2 resume 会重新跑 SessionStart hooks

loadConversationForResume() 还会在消息加载后执行 processSessionStartHooks("resume"), 并把 hook messages 追加到 conversation。 这和上一篇能接上:resume 后的 session 是新的运行时起点,需要让 session 级 hooks 有机会补上下文。

四、恢复 runtime:切 session,再恢复附属状态

CLI 的 --continue 先清缓存,再调用 loadConversationForResume() 和 processResumedConversation()。 --resume 则可以从分享、文件路径或 session id 进入同一套处理路径; 源码里这些分支 最终都会拿到 processedResume,再把 initial messages、file history、content replacements、 agent name/color 交给 REPL。

processResumedConversation() 做的是 runtime 恢复,而不是消息恢复。它会匹配 coordinator/normal mode; 非 fork resume 会 switchSession() 到旧 session id,并按 transcript path 处理跨目录恢复; 然后重命名 asciicast、reset session file pointer、恢复 cost state、恢复 session metadata、 恢复 worktree、adopt 已存在的 transcript file,最后恢复 agent definition 和初始 AppState。context-collapse 的恢复调用受 CONTEXT_COLLAPSE 开关保护;快照缺少被调用的实现模块,只能确认它接收 commit entries 和 staged snapshot,不能据此声称它总会运行或说明其内部恢复算法。

4.1 adoptResumedSessionFile() 决定后续写入哪个文件

adoptResumedSessionFile() 看起来只是把 project.sessionFile 指向 getTranscriptPath(), 但注释解释了它为什么重要:恢复后即使用户还没发下一条消息,退出清理也要能把 metadata 写回原 session file。 如果还按 fresh session 的懒创建逻辑走,命名、tag、agent 等 metadata 就可能只停留在内存里。

换句话说,正常 resume 后的记录继续写入原 session;fork resume 则不能这样做, 因为 fork 应该写入新 session file。

五、交互式 /resume 还要处理当前会话

交互式 /resume 比 CLI 启动时的 resume 更麻烦,因为它发生在一个已经运行的 REPL 里。 REPL.resume() 先反序列化目标 log,再在切换前执行当前 session 的 SessionEnd hooks; 然后对目标 session 执行 SessionStart(resume) hooks、恢复文件历史和 attribution、 恢复 agent、恢复 standalone agent context、恢复 read file state、切 session、重命名录制文件、恢复 metadata 和 worktree。

最后它还会恢复 remote agent tasks,重建 content replacement state,清空当前工具 JSX 和输入框, 再把 setMessages(() => messages) 作为新的 UI 起点。 所以交互式 resume 是一次“从当前 runtime 切到另一个 runtime”的热切换,不是简单替换数组。

5.1 Resume picker 只是入口,恢复逻辑仍然完整

选择器页面也不是只返回一个文件名。 ResumeConversation 会检查跨项目 resume,调用 loadConversationForResume(),必要时切 session, 恢复 cost、agent、metadata、worktree,并按开关调用 context-collapse 恢复接口,再把结果作为 initialMessages、initialFileHistorySnapshots 和 initialContentReplacements 传给 REPL。

这解释了为什么 session 列表也要逐步补齐信息。 enrichLogs() 不是为了展示漂亮列表,而是从轻量 log 扩展出可以 resume 的有效会话,同时过滤掉不该展示的记录。

六、普通 resume 和 fork resume 的区别

最后看一个很实用的分叉:普通 resume 和 fork resume。 普通 resume 要继续原 session,所以会切到旧 session id,恢复原 metadata/worktree,并 adopt 原 transcript。 fork resume 则保留新 session id,不接管原 session file,并把旧消息和必要附属记录写到新会话。恢复 metadata 时会显式去掉 worktreeSession,也不执行原工作树的恢复和接管:否则用户退出 fork 时选择删除,可能误删原会话仍引用的工作树。派生会话不会自动得到独立文件系统;若需要隔离写入,仍应单独选择工作树。

路径 session id transcript file 为什么这么做
普通 --continue / --resume 复用被恢复 session。 adoptResumedSessionFile() 接管原文件。 后续消息、metadata、cost 和 worktree 都属于同一条会话链。
交互式 /resume 切到目标 session。 退出当前 session 后接管目标文件。 这是热切换,必须先收尾当前 session,再恢复目标 runtime。
--fork-session 保留 fresh session。 新文件由 recordTranscript() 写入。 fork 是派生,不应该把新消息写回原 transcript。
带 content replacement 的 fork 保留 fresh session。 重新写 replacement entry 到新 session。 让新 session 里的 tool_use_id 能找到对应替换记录,避免恢复成完整大输出。

这就是第九篇的核心:resume 不是“续聊天”,而是把持久化记录重新变成可运行状态。 消息只是其中一层,旁边还有文件历史、内容替换、collapse commit、metadata、agent、worktree、 hooks 和 session 文件写入目标。各项是否恢复取决于输入来源、持久化记录是否存在和功能开关。判断恢复是否正确,要看下一轮的消息协议、替换决策和写入目标是否一致,而不是要求每个可选模块都参与。

下一篇会把这个问题推进到性能层: 为什么同样是恢复和继续,真正影响速度和成本的,是请求形状、prompt cache、 稳定前缀和 fork-only suffix。

参考源码与文档