会话恢复分享
会话管理:中断、恢复与共享
深入 Claude Code 的会话持久化——磁盘存储、/resume 恢复、Teleport 跨设备迁移、/share 分享
问题引入
你正在和 Claude 讨论一个复杂的架构重构,突然笔记本电池耗尽。几分钟后接上电源,你输入 claude --resume ——之前的整段对话完整恢复了,包括 Claude 的分析、已执行的文件修改、未完成的步骤。你甚至可以在另一台机器上通过 session URL 继续这个会话。
这不是魔法——这是 Claude Code 会话持久化系统的工作。它需要解决几个核心问题:
- 如何把实时对话可靠地写入磁盘,不丢失数据?
- 恢复时如何跳过已压缩的历史,只加载必要的上下文?
- 如何在不同项目目录、不同设备间找到并恢复会话?
本文深入分析会话持久化的完整机制。
会话存储架构
存储位置
~/.claude/projects/{sanitized-path}/{session-id}.jsonl
路径安全化
会话文件存储在 ~/.claude/projects/ 下,子目录名称由项目路径转换而来。路径安全化确保任何操作系统都能正确处理:
1export function sanitizePath(name: string): string {
2 const sanitized = name.replace(/[^a-zA-Z0-9]/g, '-')
3 if (sanitized.length <= MAX_SANITIZED_LENGTH) {
4 return sanitized
5 }
6 const hash =
7 typeof Bun !== 'undefined' ? Bun.hash(name).toString(36) : simpleHash(name)
8 return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${hash}`
9}
所有非字母数字字符替换为连字符。对于深层嵌套路径(超过 200 字符),截断并追加哈希后缀确保唯一性。这里有一个微妙的兼容性问题——CLI 在 Bun 运行时使用 Bun.hash,而 SDK 在 Node.js 下使用 djb2Hash,两者对超长路径产生不同的目录后缀。findProjectDir 通过前缀匹配回退来解决这个问题:
1export async function findProjectDir(
2 projectPath: string,
3): Promise<string | undefined> {
4 const exact = getProjectDir(projectPath)
5 try {
6 await readdir(exact)
7 return exact
8 } catch {
9 const sanitized = sanitizePath(projectPath)
10 if (sanitized.length <= MAX_SANITIZED_LENGTH) {
11 return undefined
12 }
13 const prefix = sanitized.slice(0, MAX_SANITIZED_LENGTH)
14 const projectsDir = getProjectsDir()
15 try {
16 const dirents = await readdir(projectsDir, { withFileTypes: true })
17 const match = dirents.find(
18 d => d.isDirectory() && d.name.startsWith(prefix + '-'),
19 )
20 return match ? join(projectsDir, match.name) : undefined
21 } catch {
22 return undefined
23 }
24 }
25}
会话列表:两阶段扫描
列出会话时,系统需要在性能和完整性之间权衡。一个项目目录下可能有上千个会话文件——全部读取内容代价太高。
1export async function listSessionsImpl(
2 options?: ListSessionsOptions,
3): Promise<SessionInfo[]> {
4 const { dir, limit, offset, includeWorktrees } = options ?? {}
5 const off = offset ?? 0
6 const doStat = (limit !== undefined && limit > 0) || off > 0
7
8 const candidates = dir
9 ? await gatherProjectCandidates(dir, includeWorktrees ?? true, doStat)
10 : await gatherAllCandidates(doStat)
11
12 if (!doStat) return readAllAndSort(candidates)
13 return applySortAndLimit(candidates, limit, off)
14}
当有 limit 或 offset 时,采用两阶段策略:
- stat-only 扫描 — 只读文件元数据(mtime),每文件一次 syscall
- 按需内容读取 — 排序后只读取前 N 个候选的 head/tail
这意味着 limit: 20 在 1000 个会话的目录中只做 ~1000 次 stat + ~20 次内容读取,而非 1000 次内容读取。
轻量元数据提取
每个会话文件的元数据通过 head/tail 读取获取——不需要解析整个文件:
1export async function readHeadAndTail(
2 filePath: string,
3 fileSize: number,
4 buf: Buffer,
5): Promise<{ head: string; tail: string }> {
6 try {
7 const fh = await fsOpen(filePath, 'r')
8 try {
9 const headResult = await fh.read(buf, 0, LITE_READ_BUF_SIZE, 0)
10 if (headResult.bytesRead === 0) return { head: '', tail: '' }
11
12 const head = buf.toString('utf8', 0, headResult.bytesRead)
13
14 const tailOffset = Math.max(0, fileSize - LITE_READ_BUF_SIZE)
15 let tail = head
16 if (tailOffset > 0) {
17 const tailResult = await fh.read(buf, 0, LITE_READ_BUF_SIZE, tailOffset)
18 tail = buf.toString('utf8', 0, tailResult.bytesRead)
19 }
20
21 return { head, tail }
22 } finally {
23 await fh.close()
24 }
25 } catch {
26 return { head: '', tail: '' }
27 }
28}
LITE_READ_BUF_SIZE 为 64KB——读文件头获取 session 创建信息,读文件尾获取最新状态(标题、分支、标签等)。元数据字段直接从 JSON 文本中正则提取,不做完整 JSON 解析:
1export function extractJsonStringField(
2 text: string,
3 key: string,
4): string | undefined {
5 const patterns = [`"${key}":"`, `"${key}": "`]
6 for (const pattern of patterns) {
7 const idx = text.indexOf(pattern)
8 if (idx < 0) continue
9 const valueStart = idx + pattern.length
10 let i = valueStart
11 while (i < text.length) {
12 if (text[i] === '\\') { i += 2; continue }
13 if (text[i] === '"') {
14 return unescapeJsonString(text.slice(valueStart, i))
15 }
16 i++
17 }
18 }
19 return undefined
20}
这种"模式匹配而非完整解析"的方式在截断的 JSONL 行(文件被 tail 截断到中间)上也能工作。
会话信息组装
1export function parseSessionInfoFromLite(
2 sessionId: string,
3 lite: LiteSessionFile,
4 projectPath?: string,
5): SessionInfo | null {
6 const { head, tail, mtime, size } = lite
7
8 // 过滤 sidechain 会话(子 Agent 的内部会话)
9 const firstLine = firstNewline >= 0 ? head.slice(0, firstNewline) : head
10 if (firstLine.includes('"isSidechain":true')) {
11 return null
12 }
13
14 // 标题优先级:customTitle > aiTitle > lastPrompt > firstPrompt
15 const customTitle =
16 extractLastJsonStringField(tail, 'customTitle') ||
17 extractLastJsonStringField(head, 'customTitle') ||
18 extractLastJsonStringField(tail, 'aiTitle') ||
19 extractLastJsonStringField(head, 'aiTitle') ||
20 undefined
21
22 const summary =
23 customTitle ||
24 extractLastJsonStringField(tail, 'lastPrompt') ||
25 extractLastJsonStringField(tail, 'summary') ||
26 firstPrompt
27
28 // 没有标题也没有摘要——跳过元数据会话
29 if (!summary) return null
30 // ...
31}
会话恢复:Compact Boundary 截断
对于长时间运行的会话(5MB+),完整加载所有消息效率低下且不必要——auto-compact 已经将旧消息压缩成摘要。readTranscriptForLoad 在文件层面找到最后一个 compact_boundary 标记,只加载其后的消息:
1export async function readTranscriptForLoad(
2 filePath: string,
3 fileSize: number,
4): Promise<{
5 boundaryStartOffset: number
6 postBoundaryBuf: Buffer
7 hasPreservedSegment: boolean
8}> {
9 // ...
10 const chunk = Buffer.allocUnsafe(CHUNK_SIZE)
11 const fd = await fsOpen(filePath, 'r')
12 try {
13 let filePos = 0
14 while (filePos < fileSize) {
15 const { bytesRead } = await fd.read(
16 chunk, 0,
17 Math.min(CHUNK_SIZE, fileSize - filePos),
18 filePos,
19 )
20 if (bytesRead === 0) break
21 filePos += bytesRead
22 // processStraddle + scanChunkLines 处理跨 chunk 的行
23 }
24 finalizeOutput(s)
25 } finally {
26 await fd.close()
27 }
28}
这个函数的设计非常精细:
- 1MB 分块读取 — 避免大文件一次性加载到内存
- attribution-snapshot 过滤 — 在 fd 层面跳过,最终只保留最后一个 snapshot
- compact_boundary 截断 — 遇到新的 boundary 时,丢弃之前所有输出
- preservedSegment 检测 — 保留段是 compact 中标记为重要的消息片段
原始 JSONL 文件 (24MB)
总耗时 ≈ 6 步(并行 = 更快)
跨 Chunk 行处理
JSONL 文件中的单行可能跨越 1MB chunk 的边界。processStraddle 处理这种情况:
1// 简化表示 — processStraddle 逻辑
2// 前一个 chunk 的未完成行保存在 carryBuf 中
3// 当前 chunk 的第一个 \n 完成这一行
4// 然后判断这一行是 attr-snap(跳过)还是 boundary(截断)
这种流式处理确保内存峰值是输出大小而非文件大小——一个 24MB 的会话文件,如果最后一个 boundary 后只有 3MB 数据,那内存使用也只有 ~3MB。
Prompt 历史
与会话消息(记录完整对话)不同,Prompt 历史只记录用户输入——用于 Up-arrow 和 Ctrl+R 搜索。
1let pendingEntries: LogEntry[] = []
2let isWriting = false
3let currentFlushPromise: Promise<void> | null = null
4let cleanupRegistered = false
历史写入是异步批量的——新条目先进入 pendingEntries 缓冲区,后台定期 flush 到 ~/.claude/history.jsonl。
并发安全
多个 Claude 会话可能同时写入同一个历史文件。系统使用文件锁确保安全:
1async function immediateFlushHistory(): Promise<void> {
2 if (pendingEntries.length === 0) return
3
4 let release
5 try {
6 const historyPath = join(getClaudeConfigHomeDir(), 'history.jsonl')
7 await writeFile(historyPath, '', { encoding: 'utf8', mode: 0o600, flag: 'a' })
8
9 release = await lock(historyPath, {
10 stale: 10000,
11 retries: { retries: 3, minTimeout: 50 },
12 })
13
14 const jsonLines = pendingEntries.map(entry => jsonStringify(entry) + '\n')
15 pendingEntries = []
16
17 await appendFile(historyPath, jsonLines.join(''), { mode: 0o600 })
18 } catch (error) {
19 logForDebugging(`Failed to write prompt history: ${error}`)
20 } finally {
21 if (release) { await release() }
22 }
23}
注意文件权限 0o600——只有所有者可读写,保护用户输入隐私。
历史去重与排序
1export async function* getHistory(): AsyncGenerator<HistoryEntry> {
2 const currentProject = getProjectRoot()
3 const currentSession = getSessionId()
4 const otherSessionEntries: LogEntry[] = []
5 let yielded = 0
6
7 for await (const entry of makeLogEntryReader()) {
8 if (!entry || typeof entry.project !== 'string') continue
9 if (entry.project !== currentProject) continue
10
11 if (entry.sessionId === currentSession) {
12 yield await logEntryToHistoryEntry(entry)
13 yielded++
14 } else {
15 otherSessionEntries.push(entry)
16 }
17
18 if (yielded + otherSessionEntries.length >= MAX_HISTORY_ITEMS) break
19 }
20
21 for (const entry of otherSessionEntries) {
22 if (yielded >= MAX_HISTORY_ITEMS) return
23 yield await logEntryToHistoryEntry(entry)
24 yielded++
25 }
26}
当前会话的历史条目优先于其他会话——这样并发会话不会相互插入历史记录。Up-arrow 总是先看到自己本次会话的输入。
撤销历史
当用户按 Esc 在 AI 回复前取消输入时,该输入应该从历史中移除:
1export function removeLastFromHistory(): void {
2 if (!lastAddedEntry) return
3 const entry = lastAddedEntry
4 lastAddedEntry = null
5
6 const idx = pendingEntries.lastIndexOf(entry)
7 if (idx !== -1) {
8 pendingEntries.splice(idx, 1)
9 } else {
10 skippedTimestamps.add(entry.timestamp)
11 }
12}
快速路径从 pending buffer 中直接移除。如果异步 flush 已经将条目写入磁盘(TTFT 通常 >> 磁盘写入延迟,但偶尔会发生竞态),则将时间戳加入 skip-set,在下次读取时过滤。
粘贴内容处理
大段粘贴的文本不适合直接存入历史文件。系统按大小分层:
1for (const [id, content] of Object.entries(entry.pastedContents)) {
2 if (content.type === 'image') continue // 图片单独存储
3
4 if (content.content.length <= MAX_PASTED_CONTENT_LENGTH) {
5 // 小文本(≤1024字符)内联存储
6 storedPastedContents[Number(id)] = {
7 id: content.id, type: content.type,
8 content: content.content,
9 }
10 } else {
11 // 大文本存哈希引用,内容写入 paste store
12 const hash = hashPastedText(content.content)
13 storedPastedContents[Number(id)] = {
14 id: content.id, type: content.type,
15 contentHash: hash,
16 }
17 void storePastedText(hash, content.content)
18 }
19}
跨项目恢复
用户可能在一个目录启动 Claude,然后想恢复另一个项目的会话。crossProjectResume.ts 处理这种场景:
1export function checkCrossProjectResume(
2 log: LogOption,
3 showAllProjects: boolean,
4 worktreePaths: string[],
5): CrossProjectResumeResult {
6 const currentCwd = getOriginalCwd()
7
8 if (!showAllProjects || !log.projectPath || log.projectPath === currentCwd) {
9 return { isCrossProject: false }
10 }
11
12 // 检查是否是同一 Git 仓库的不同 worktree
13 const isSameRepo = worktreePaths.some(
14 wt => log.projectPath === wt || log.projectPath!.startsWith(wt + sep),
15 )
16
17 if (isSameRepo) {
18 return {
19 isCrossProject: true,
20 isSameRepoWorktree: true,
21 projectPath: log.projectPath,
22 }
23 }
24
25 // 不同仓库——生成 cd 命令
26 const sessionId = getSessionIdFromLog(log)
27 const command = `cd ${quote([log.projectPath])} && claude --resume ${sessionId}`
28 return {
29 isCrossProject: true,
30 isSameRepoWorktree: false,
31 command,
32 projectPath: log.projectPath,
33 }
34}
对于同一 Git 仓库的不同 worktree,可以直接恢复(代码库相同)。对于完全不同的项目,系统生成一条 cd + claude --resume 命令供用户执行。
Session URL 解析
--resume 参数支持三种格式:
1export function parseSessionIdentifier(
2 resumeIdentifier: string,
3): ParsedSessionUrl | null {
4 // 1. JSONL 文件路径
5 if (resumeIdentifier.toLowerCase().endsWith('.jsonl')) {
6 return {
7 sessionId: randomUUID() as UUID,
8 ingressUrl: null,
9 isUrl: false,
10 jsonlFile: resumeIdentifier,
11 isJsonlFile: true,
12 }
13 }
14
15 // 2. UUID session ID
16 if (validateUuid(resumeIdentifier)) {
17 return {
18 sessionId: resumeIdentifier as UUID,
19 ingressUrl: null,
20 isUrl: false,
21 jsonlFile: null,
22 isJsonlFile: false,
23 }
24 }
25
26 // 3. Ingress URL(远程恢复)
27 try {
28 const url = new URL(resumeIdentifier)
29 return {
30 sessionId: randomUUID() as UUID,
31 ingressUrl: url.href,
32 isUrl: true,
33 jsonlFile: null,
34 isJsonlFile: false,
35 }
36 } catch {
37 // Not a valid URL
38 }
39
40 return null
41}
三种格式覆盖了不同的使用场景:
- UUID — 最常见,从本地
~/.claude/projects/ 查找
- JSONL 文件 — 直接指定文件路径,用于调试或导入
- URL — 连接远程 session ingress,用于跨设备恢复
会话文件解析
恢复会话时,系统需要在 ~/.claude/projects/ 下找到对应的 JSONL 文件。搜索逻辑考虑了 worktree 场景:
1export async function resolveSessionFilePath(
2 sessionId: string,
3 dir?: string,
4): Promise<...> {
5 const fileName = `${sessionId}.jsonl`
6
7 if (dir) {
8 // 先在当前项目目录找
9 const canonical = await canonicalizePath(dir)
10 const projectDir = await findProjectDir(canonical)
11 if (projectDir) {
12 const filePath = join(projectDir, fileName)
13 // stat 检查 + 零字节过滤
14 }
15
16 // Worktree 回退——会话可能存在于不同的 worktree 根
17 let worktreePaths = await getWorktreePathsPortable(canonical)
18 for (const wt of worktreePaths) {
19 if (wt === canonical) continue
20 // 逐个 worktree 搜索
21 }
22 return undefined
23 }
24
25 // 无 dir——扫描所有项目目录
26 const projectsDir = getProjectsDir()
27 let dirents = await readdir(projectsDir)
28 for (const name of dirents) {
29 // 逐目录搜索
30 }
31 return undefined
32}
零字节文件被视为未找到——这处理了文件被截断但未删除的情况,让搜索继续到兄弟目录中的有效副本。
成本状态恢复
恢复会话不仅恢复对话内容,还恢复成本追踪状态:
1export function restoreCostStateForSession(sessionId: string): boolean {
2 const data = getStoredSessionCosts(sessionId)
3 if (!data) {
4 return false
5 }
6 setCostStateForRestore(data)
7 return true
8}
成本数据保存在项目配置中,按 session ID 关联。恢复时检查 session ID 是否匹配——防止不同会话的成本数据混淆。恢复的数据包括:
- 总 API 费用(USD)
- API 耗时(含重试和不含重试)
- 工具执行耗时
- 代码行变更统计
- 每模型使用量
小结
Claude Code 的会话管理系统解决了 AI Agent 持久化的核心挑战:
- 增量写入 — JSONL 格式支持追加写入,进程崩溃只丢最后一行
- 两阶段列表 — stat-only 预筛选 + 按需内容读取,千级会话目录也快
- Compact Boundary 截断 — 恢复时只加载最后一次压缩后的消息,24MB 文件 → 3MB 内存
- 跨环境兼容 — Bun/Node 哈希差异通过前缀回退解决
- 并发安全 — 文件锁保护历史写入,session-first 排序防止会话交叉
- 完整状态恢复 — 对话、成本、权限上下文一并恢复
这套系统的设计核心是"面对现实"——文件会被截断、进程会崩溃、多个会话会并发运行、用户会在不同目录和设备间切换。每一个 edge case 都有对应的防护措施。