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

这篇的结论先说清:transcript 是恢复账本,不是 model-visible prompt。 Claude Code 写入 transcript 时要维护消息链和附属记录;读取 transcript 时要清洗坏尾巴和未完成 tool_use; 真正 resume 时,还要把 transcript 里的状态重新安装回 AppState、session pointer、worktree、 agent definition、content replacement state 和 context-collapse store。

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

证据边界同前几篇一样:产品概念参考 Claude Code 官方概览; 源码层来自 Rememorio/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
源码形状:resume 先读耐久账本,再把能影响下一轮工具和模型请求的运行时状态装回去。

一、先分清四个表面

继续会话为什么复杂?因为同一段工作并不只有“聊天记录”一个表面。 屏幕上看到的是 UI messages;磁盘里保存的是 transcript 和附属 entry; 下一轮运行时看到的是经过恢复、清洗、压缩和工具预算处理后的 runtime view; 真正发给模型的又是 provider API 能接收的 request shape。

这四层不能混。UI 可以隐藏进度、折叠工具输出;transcript 要能恢复链路; runtime view 要保证下一轮工具调用和上下文投影合法;API view 要满足模型和工具协议。 这也是 resume 不能简单等于“把 JSONL 里的 messages 读出来”的原因。

表面 负责什么 resume 时的风险
UI 用户看到的消息、进度、工具 JSX 和输入框状态。 看起来续上了,但内部状态可能没恢复。
Transcript 可持久化消息链、sidechain、metadata、content replacement、collapse commit。 坏尾巴、重复消息、parent 链断裂会污染下一轮。
Runtime AppState、session id、worktree、agent、文件历史、工具替换状态。 工具结果、文件快照、agent 定义和工作目录对不上。
API view 进入模型请求的消息、工具 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 把新的 compact boundary 和 summary 放在前面,后面的旧 messagesToKeep 就不会被错误拿来当 parent。

Claude Code runtime view 图,展示 history 和 tools 进入 stable prefix、runtime view、API view、compact 与 resume
Transcript 是恢复账本;进入 API 前还会被 runtime 投影成另一种 view。

2.1 附属记录也要跟着写

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

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

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

读取主入口是 loadConversationForResume()。 它支持几种来源:--continue 时 source 为空,表示找最近会话; --resume 可以给 session id,也可以给已经加载好的 log,或者给 transcript file。 找到 log 后,它会复制 plan、复制文件历史、检查 resume consistency,然后进入反序列化。

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,保证对话形状合法。

这一步很像把坏掉的账本尾页修好。不是每条落盘记录都能原样喂回下一轮。 未完成工具调用、孤立 thinking block、用户刚提交但模型没响应的半截 turn,都要先归一化成可恢复形状。

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,最后恢复 context-collapse store、agent definition 和初始 AppState。

Claude Code compact 后生存状态图,展示 stable、reloaded、triggered 三类状态进入 compact 并影响 next turn
恢复不是只读 messages。稳定信息、重新加载信息和触发式信息会以不同方式回到下一轮。

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,再把结果作为 initialMessagesinitialFileHistorySnapshotsinitialContentReplacements 传给 REPL。

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

六、普通 resume 和 fork resume 的边界

最后看一个很实用的分叉:普通 resume 和 fork resume。 普通 resume 要继续原 session,所以会切到旧 session id,恢复原 metadata/worktree,并 adopt 原 transcript。 fork resume 则保留新 session id,不接管原 session file,只复制必要消息和状态。

Claude Code compact 恢复循环图,展示 trigger、pre hooks、summary fork、ledger、restore files memory tools 和 next turn
fork 和 compact 都在提醒同一件事:恢复点不是历史本身,而是下一轮能继续的运行时起点。
路径 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 file ownership。少恢复任何一层,下一轮看起来也许能发出去, 但它已经不是原来那条会话了。

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

参考源码与文档