Claude Code 会话存储与 rewind 原理
实测环境:Claude Code v2.1.263 / Windows 11 / 会话
3c056032所有数据均为本机实际观测,非文档转述。
一、磁盘布局
~/.claude/
├── projects/<项目路径编码>/ # 会话记录(transcript)
│ ├── <session-id>.jsonl # 一个会话一个文件
│ └── <session-id>/tool-results/ # 超大工具输出溢写到这里
├── file-history/<session-id>/ # checkpoint 的文件快照实体
│ └── <路径哈希>@v<N>
├── sessions/ shell-snapshots/ tasks/ telemetry/ ...项目路径编码规则:C:\Users\21145 → C--Users-21145。
二、transcript 文件的本质
2.1 格式
.jsonl = JSON Lines,一行一个 JSON 对象。只追加,从不删改。
2.2 它不是线性日志,是一棵树
关键字段:
| 字段 | 含义 |
|---|---|
uuid | 本节点唯一 id |
parentUuid | 父节点 id,根节点为 None |
leafUuid | 出现在 last-prompt 记录里,标记当前活跃叶子 |
sessionId | 所属会话 |
isSidechain | 是否是子 agent 的旁链 |
timestamp / version / cwd / gitBranch | 环境快照 |
「当前对话」的定义:从
leafUuid沿parentUuid一路回溯到根的那条路径。 只有这条路径上的节点会被拼进 prompt 发给模型。文件里其余节点一律不发送。
2.3 记录类型(实测分布)
| type | 条数 | 说明 |
|---|---|---|
assistant | 174 | 模型回复(含 tool_use) |
attachment | 101 | 系统注入的上下文块 |
user | 93 | 用户消息 + 工具结果 |
system | 33 | 系统事件/警告 |
last-prompt | 31 | checkpoint 标记,携带 leafUuid |
mode | 26 | 权限模式切换 |
ai-title | 26 | 自动生成的会话标题 |
file-history-snapshot | 21 | 文件快照索引 |
atis-latch | 7 | 内部状态 |
file-history-delta | 2 | 快照增量 |
queue-operation / cost-state | 2 / 2 | 队列、成本累计 |
attachment 的子类型(这些就是”上下文占用”里看不见的部分):
total_tokens_reminder(78) task_reminder(8) deferred_tools_delta(2)
mcp_instructions_delta(2) skill_listing(2) environment(2)三、rewind 的原理:改指针,不删数据
3.1 机制
/rewind(或输入框为空时连按两次Esc)列出本会话所有 prompt —— 数据来源就是那些last-prompt记录- 你选中某条,它对应一个
uuid - 之后新发的消息,
parentUuid写成那个 uuid - 原来的后续链就变成兄弟分支,物理上仍在文件里,只是脱离了活跃路径
- 下次请求只把活跃路径喂给模型
3.2 实测证据(本会话,未做过显式 rewind)
文件里 uuid 节点总数 : 411
活跃路径上的节点数 : 375
不在活跃路径上(旁支) : 36
分叉点数量 : 20节点 c8558227 有 4 个子节点:
2dd28fd1 user [旁支/已弃] "看了这些发现我基础并不牢固…"
1ea50ff2 user [旁支/已弃] "看了这些发现我基础并不牢固…"
91f50bf7 user [旁支/已弃] "看了这些发现我基础并不牢固…"
5a2e41ec system [活跃路径]同一句话三个兄弟分支全部留存 —— 重发/回滚过的内容一条没丢。
3.3 git 类比
| Claude Code | git |
|---|---|
/rewind → Restore conversation | git checkout <老commit> 后长出新分支,旧 commit 仍在 object 库 |
/fork | git branch —— 但另开一个 jsonl 文件 |
| 活跃路径 | 从 HEAD 回溯的那条链 |
| 旁支节点 | dangling / 未被引用的 commit |
结论:rewind 之后,被”丢掉”的上下文仍然完整躺在 transcript 文件里,只是不再发送给模型。
四、checkpoint(代码回滚)是正交的另一套
4.1 存储方式
transcript 里的 file-history-snapshot 只是索引,文件实体在 ~/.claude/file-history/<session-id>/。
实测快照记录结构:
{
"type": "file-history-snapshot",
"messageId": "...",
"snapshot": {
"messageId": "...",
"timestamp": "...",
"trackedFileBackups": {
".claude\settings.json": {
"backupFileName": "6a385dba1459677a@v7",
"version": 7,
"backupTime": "2026-09-08T07:09:06.103Z",
"realParentDir": "C:\Users\21145\.claude"
}
}
}
}备份文件命名:<16位路径哈希>@v<递增版本号>。同一文件每次被改就 +1 个版本。
4.2 /rewind 三个 restore 选项分别动什么
| 选项 | 改 parentUuid 指针 | 覆写磁盘文件 |
|---|---|---|
| Restore conversation | ✅ | ❌ |
| Restore code | ❌ | ✅ |
| Restore code and conversation | ✅ | ✅ |
⚠️ 只想清上下文就选
Restore conversation。带 code 的两个会按快照把被追踪的文件写回旧版本。 本会话被追踪的是~/.claude/settings.json(v7)和 Windows Terminal 的settings.json(v2)—— 误选会把 statusline、fullscreen、字体、配色全部还原。
4.3 两个 summarize 选项
Summarize from here—— 把选中点往后压缩成摘要Summarize up to here—— 把选中点之前压缩,保留后面
摘要不改磁盘文件,原始消息也仍留在 transcript 里,只是不再逐条发送。等价于定点 /compact。
五、checkpoint 的四个盲区
- bash 改的文件不追踪 ——
rm/mv/cp/ 重定向写文件都不进快照,只有 Edit/Write 类工具的改动才追踪 - 子 agent 的改动不还原 —— 后台 subagent、
/code-review --fix的编辑要靠 git 回退(前台 forked skill 除外) - 软链接/硬链接跳过 —— 会提示
Restored the code, but skipped N files - 会话外的改动不追踪 —— 你手动改的、别的会话改的,都不在快照里
六、保留期限
transcript 和快照都受 cleanupPeriodDays 管,默认约 30 天。
- 快照被清理后再 rewind 会报
No files were restored - 每个文件的第一份快照会保留(VS Code 扩展拿它当 session diff 基线)
- 每个会话最多保留 100 个 checkpoint 的快照
想长期留:调大 cleanupPeriodDays,或者把 jsonl 拷走。
七、实用脚本
7.1 列出所有会话及主题
cd ~/.claude/projects/<项目编码>
ls -1t *.jsonl | while read f; do
echo "=== ${f%.jsonl} $(du -h "$f"|cut -f1) $(wc -l <"$f")行"
grep -aco '关键词' "$f"
done7.2 提取活跃路径 / 找出旁支
import json, collections
p = r'C:\Users\<你>\.claude\projects\<项目编码>\<session-id>.jsonl'
rows = [json.loads(l) for l in open(p, encoding='utf-8') if l.strip()]
byuuid = {r['uuid']: r for r in rows if r.get('uuid')}
# 当前活跃叶子 = 最后一条 last-prompt 的 leafUuid
leaf = [r['leafUuid'] for r in rows if r.get('leafUuid')][-1]
# 回溯出活跃路径
path, cur = [], leaf
while cur and cur in byuuid:
path.append(cur)
cur = byuuid[cur].get('parentUuid')
path = set(path)
print('总节点', len(byuuid), '活跃', len(path), '旁支', len(byuuid) - len(path))
# 打印旁支上的用户消息(被 rewind 丢掉的内容)
for u, r in byuuid.items():
if u in path or r.get('type') != 'user':
continue
c = r.get('message', {}).get('content')
t = c if isinstance(c, str) else ' '.join(
x.get('text', '') for x in (c or []) if isinstance(x, dict))
if t.strip():
print('[旁支]', t.strip()[:100])7.3 找分叉点
kids = collections.defaultdict(list)
for r in rows:
if r.get('uuid'):
kids[r.get('parentUuid')].append(r['uuid'])
for k, v in kids.items():
if k and len(v) > 1:
print('分叉点', k[:8], '->', len(v), '个子节点')八、几个命令的区别速查
| 命令 | 上下文 | 磁盘文件 | 会话文件 |
|---|---|---|---|
/rewind → Restore conversation | 回退到选中点 | 不动 | 同一个,加旁支 |
/rewind → Restore code | 不动 | 按快照覆写 | 同一个 |
/rewind → Summarize | 压缩成摘要 | 不动 | 同一个 |
/compact | 全量压缩 | 不动 | 同一个 |
/clear | 全部丢弃 | 不动 | 同一个(旧的可从 rewind 菜单顶部恢复) |
/fork | 继承当前 | 不动 | 新建 jsonl,标题带 ⑂ |
/resume | 载入选中会话 | 不动 | 切到那个文件 |
实测:fork 出的 jsonl 不含指向父会话 id 的字段,它有自己的根节点和 sessionId, 仅在
aiTitle上带⑂标记(如MySQL索引和优化基础学习 ⑂)。
九、一句话总结
transcript 是只追加的树,rewind 只是把新消息挂到更早的父节点上 —— 数据从不删除,只是脱离活跃路径。
代码回滚是完全独立的快照机制,存在 file-history/ 下,只有带 code 的选项才会碰它。