Claude Code 源码分析第 29 课 · 第 05
第 29 课

会话管理

Claude Code 如何持久、恢复和恢复对话 — 从磁盘上的 JSONL 到云水合和中断轮流自动恢复。

01 概述和心智模型

Claude Code 中的每个对话都是 session。 会话具有 UUID、JSONL 转录文件和元数据条目的集合(标题、代理配置、工作树路径、git 分支等)。 会话管理层主要关注四个问题:

第 1 层

Storage

仅附加 JSONL 转录文件 ~/.claude/projects/。 通过内部的每个文件队列进行批量异步写入 Project singleton.

第 2 层

状态跟踪

进程内三态机(idle / running / requires_action)以及用于 CCR 桥和 SDK 消费者的侦听器挂钩。

第 3 层

恢复/继续

磁盘上的parentUuid链表的链式遍历、反序列化过滤器、工作树重新进入和代理设置重新水化。

第 4 层

Recovery

转弯中断检测:转弯中途碰撞变成合成的“从上次停下的地方继续”。 信息; SDK 可以自动恢复,无需用户输入。

第 5 层

云同步

双远程持久性路径:v1 会话入口 (REST) 和 CCR v2 内部事件。 重新连接时,Hydration 会根据远程数据重写本地 JSONL。

第 6 层

历史API

用于远程会话重放的分页事件获取 — 由 CCR Web/移动 UI 用于向后滚动实时或已完成会话中的事件。

关键文件
utils/sessionStorage.ts — 核心写入路径、项目类、水合作用
utils/sessionState.ts — 空闲/运行/require_action 状态机
utils/sessionRestore.ts — processResumedConversation 协调器
utils/conversationRecovery.ts — loadConversationForResume,中断检测
assistant/sessionHistory.ts — 远程分页事件获取
02 磁盘布局

会话存储在 ~/.claude/projects/ (or CLAUDE_CONFIG_DIR/projects/)。 每个项目目录都是工作目录的经过清理的路径编码表示。 在每个项目目录中,每个会话 UUID 都有一个 JSONL 文件,以及子代理的 sidecar 目录。

~/.claude/projects/ ├── -Users-alice-myproject/ ← sanitizePath(cwd) │ ├── 550e8400-e29b-41d4-a716-446655440000.jsonl ← 主要记录 │ ├── 550e8400-e29b-41d4-a716-446655440000/ │ │ ├── subagents/ │ │ │ ├── agent-<agentId>.jsonl ← 侧链转录本 │ │ │ └── agent-<agentId>.meta.json ← agentType, worktreePath │ │ └── remote-agents/ │ │ └── Remote-agent-<taskId>.meta.json │ └── a1b2c3d4-….jsonl ← 另一个会话 └── -Users-alice-otherproject/ └── …

路径推导逻辑在里面 getProjectDir (memoized):

// utils/sessionStorage.ts
export const getProjectDir = memoize((projectDir: string): string => {
  return join(getProjectsDir(), sanitizePath(projectDir))
})

export function getTranscriptPath(): string {
  const projectDir = getSessionProjectDir() ?? getProjectDir(getOriginalCwd())
  return join(projectDir, `${getSessionId()}.jsonl`)
}
Subtlety
getOriginalCwd() 被使用——不 getCwd() 在模块加载时。 这很重要,因为引导程序通过解析符号链接 realpathSync 模块导入后,过早捕获会产生不同的清理路径,并使会话对简历选择器“不可见”。
03 项目单例和写队列

所有写入都经过一次 Project 类实例通过获取 getProject()。 该类将条目批量放入每个文件队列中,并每 100 毫秒(或当云持久性处于活动状态时为 10 毫秒)耗尽它们。 这可以防止在高吞吐量轮流期间出现雷鸣般的文件 I/O。

写入路径 recordTranscript(messages) │ ▼ Project.insertMessageChain() ← 通过 messageSet Set<UUID> 进行去重 │ ▼ Project.appendEntry(entry) ← 检查 shouldSkipPersistence() │ ├─→ [pending] pendingEntries[] ← sessionFile 实现之前 │ └─→ enqueueWrite(filePath,entry) │ ▼ writeQueues: Map<path, Entry[]> │ ScheduleDrain() ────────────── setTimeout(100ms) │ ▼rainWriteQueue() │ ├─→ fsAppendFile(path, batchedJsonl) ← 本地磁盘 │ └─→ persistToRemote(sessionId, entry) ← CCR v2 或 v1 入口

惰性文件具体化

会话文件是 not 立即创建。 它被推迟到第一个真实用户或助理消息到达为止。 这可以防止从未交换过有意义内容的会话中的仅元数据 shell 污染简历列表。

// utils/sessionStorage.ts — Project class
async insertMessageChain(messages: Transcript, ...) {
  // First user/assistant message materializes the session file.
  // Hook progress/attachment messages alone stay buffered.
  if (
    this.sessionFile === null &&
    messages.some(m => m.type === 'user' || m.type === 'assistant')
  ) {
    await this.materializeSessionFile()
  }
  // … then write entries …
}

尾窗元数据重新附加

元数据如 customTitle, tag, 和 last-prompt 必须保留在文件的最后 64 KB 范围内(“尾部窗口”),因为简历选择器使用快速头+尾读取而不是解析完整的 JSONL。 在每次压缩和会话退出时, reAppendSessionMetadata() 将这些条目移回 EOF。

// utils/sessionStorage.ts
reAppendSessionMetadata(skipTitleRefresh = false): void {
  if (!this.sessionFile) return
  // Sync tail read to absorb any SDK-written fresher title/tag
  const tail = readFileTailSync(this.sessionFile)
  // … merge then re-append last-prompt, customTitle, tag,
  //     agent-name, agent-color, agent-setting, mode, worktree-state, pr-link …
}
04 JSONL 条目类型

JSONL 文件不是简单的消息列表。 它是一个超集——对话条目和边车元数据条目的混合。 这是分类法:

条目类型Purpose参与连锁?
user人类转向消息是(parentUuid)
assistant模型响应Yes
attachment文件、技能、invoked_skillsYes
system系统/紧凑边界消息Yes
custom-title用户重命名的会话名称否 — 仅元数据
last-prompt最后用户文本(用于简历选择器)No
tag会话标签No
agent-name独立代理显示名称No
agent-setting会话中使用的代理类型No
mode协调器/正常模式No
worktree-stateGit 工作树路径和原始 cwdNo
pr-linkGitHub 工作流程的 PR 编号/URLNo
file-history-snapshot每回合的文件差异历史记录No
content-replacement大型刀具产量替代记录No
marble-origami-commit上下文折叠提交(仅限 ant)No
Important
进度条目 (bash_progress, mcp_progress等)是 从未坚持过 到 JSONL。 它们是短暂的 UI 状态。 旧的文字记录可能包含它们—— loadTranscriptFile 在负载期间将链条桥接在它们之间。
05 ParentUuid 链表

消息通过以下方式形成链接列表 parentUuid。 此设计允许分支(子代理的侧链、分叉会话),同时保持 JSONL 仅附加。 加载时,Claude Code 将链从最新的叶子返回到根,然后反转它。

ParentUuid 链(概念上是单链表,反向存储) ROOT LEAF (最新) │ │ ▼ ▼ [user-A] ←── [asst-B] ←── [user-C] ←── [asst-D] ←── [user-E] uuid=A uuid=B uuid=C uuid=D uuid=Eparent=nullparent=Aparent=Bparent=Cparent=D 在磁盘上(追加顺序): 加载时(buildConversationChain): user-A 从 LEAF (E) 开始,遍历parentUuids asst-B E → D → C → B → A → null user-C .reverse() → [A, B, C, D, E] asst-D user-E Sidechains (子代理记录):isSidechain=true,单独的文件 Forks:刷新sessionId,复制源链,重新标记sessionId 紧凑边界:parentUuid=null(在紧凑点截断链)
// utils/sessionStorage.ts
export function buildConversationChain(
  messages: Map<UUID, TranscriptMessage>,
  leafMessage: TranscriptMessage,
): TranscriptMessage[] {
  const transcript: TranscriptMessage[] = []
  const seen = new Set<UUID>()
  let currentMsg = leafMessage
  while (currentMsg) {
    if (seen.has(currentMsg.uuid)) {
      logError(new Error(`Cycle detected in parentUuid chain…`))
      break
    }
    seen.add(currentMsg.uuid)
    transcript.push(currentMsg)
    currentMsg = currentMsg.parentUuid
      ? messages.get(currentMsg.parentUuid)
      : undefined
  }
  transcript.reverse()
  return recoverOrphanedParallelToolResults(messages, transcript, seen)
}
06 会话状态机

The sessionState.ts 模块为当前会话提供一个单例三态机。 状态转换触发侦听器回调,该回调连接到:CCR 桥(用于 requires_action 通知)、SDK 事件流(对于 VS Code / scmuxd)和外部元数据存储(对于待处理操作 UI)。

SessionState三态机 用户发送消息 ┌──────────────────────────────────────────────────┐ │ │ ▼ 模型开始响应 │ [ 闲置的 ] ──────────────────────────────────→ [ 跑步 ] ▲ │ │ 模型完成/错误 │ 工具需要批准 └────────────────────────────────────────────── └──→ [ 需要操作 ] │ 用户批准/拒绝────────────┘ 每个转换的副作用: 运行→stateListener(state)requires_action→stateListener+metadataListener({pending_action:details})空闲→metadataListener({pending_action:null,task_summary:null})+可选SDKsystem:session_state_changed事件
// utils/sessionState.ts
export type SessionState = 'idle' | 'running' | 'requires_action'

export type RequiresActionDetails = {
  tool_name: string
  /** Human-readable summary, e.g. "Editing src/foo.ts" */
  action_description: string
  tool_use_id: string
  request_id: string
  input?: Record<string, unknown>
}

export function notifySessionStateChanged(
  state: SessionState,
  details?: RequiresActionDetails,
): void {
  currentState = state
  stateListener?.(state, details)
  if (state === 'requires_action' && details) {
    hasPendingAction = true
    metadataListener?.({ pending_action: details })
  } else if (hasPendingAction) {
    hasPendingAction = false
    metadataListener?.({ pending_action: null })
  }
  if (state === 'idle') {
    metadataListener?.({ task_summary: null })
  }
}

外部元数据

The SessionExternalMetadata type 是通过 RFC 7396 JSON 合并补丁推送到 CCR 会话记录的字段的抓包。 这 pending_action 字段让 CCR 侧边栏显示 what 该模型在没有 protobuf 模式更改的情况下被阻止 - 它被清除(设置为 null)在每个非阻塞转换上。

// utils/sessionState.ts
export type SessionExternalMetadata = {
  permission_mode?: string | null
  is_ultraplan_mode?: boolean | null
  model?: string | null
  pending_action?: RequiresActionDetails | null
  post_turn_summary?: unknown
  // Mid-turn progress every ~5 steps / 2min so long-running turns
  // show "what's happening now" before post_turn_summary arrives.
  task_summary?: string | null
}
07 会话恢复——中断检测

当 Claude Code 加载恢复记录时,它必须确定上一个会话是否在中途中断。 这很重要,因为:

  • 如果在工具使用过程中中断(tool_result 是最后一个条目),则会显示“从上次中断的位置继续”。 消息被注入。
  • 如果在模型响应用户提示之前中断,则原始用户消息将返回为 interrupted_prompt 让 SDK 自动重试。
  • 如果转弯正常完成,则不添加合成消息。
detectorTurnInterruption() 逻辑(conversationRecovery.ts) FilterUnresolvedToolUses + filterOrphanedThinkingOnlyMessages 之后:lastRelevant = 最后一条消息,其中 type != 'system' && type != 'progress' 而不是 API 错误助手 lastRelevant.type == 'assistant' → { 种类: '无' } (转完成 — stop_reason 在磁盘上始终为 null)lastRelevant.type == 'user' && isMeta → { 种类: '无' } (合成的刻度消息,不是真正的转弯)lastRelevant.type == 'user' && isToolUseResultMessage → isTerminalToolResult? (SendUserFile,Brief 工具)是 → { 种类: '无' } (简略模式:SendUserMessage 结束) 否→ { kind: 'interrupted_turn' } lastRelevant.type == '用户'(纯文本)→ { kind: 'interrupted_prompt', message: lastRelevant } lastRelevant.type == '附件' → { kind: 'interrupted_turn' } 通过附加以下内容将 Interrupted_turn 提升为 Interrupted_Prompt: createUserMessage({ content: “从上次停下的地方继续。”, isMeta: true })
// utils/conversationRecovery.ts
export function deserializeMessagesWithInterruptDetection(
  serializedMessages: Message[],
): DeserializeResult {
  const migratedMessages = serializedMessages.map(migrateLegacyAttachmentTypes)
  // Strip invalid permissionMode values from disk (unvalidated JSON)
  const validModes = new Set<string>(PERMISSION_MODES)
  // … strip invalid modes …

  const filteredToolUses  = filterUnresolvedToolUses(migratedMessages)
  const filteredThinking  = filterOrphanedThinkingOnlyMessages(filteredToolUses)
  const filteredMessages  = filterWhitespaceOnlyAssistantMessages(filteredThinking)

  const internalState = detectTurnInterruption(filteredMessages)

  if (internalState.kind === 'interrupted_turn') {
    const [continuationMessage] = normalizeMessages([
      createUserMessage({
        content: 'Continue from where you left off.',
        isMeta: true,
      }),
    ])
    filteredMessages.push(continuationMessage)
    // turnInterruptionState becomes { kind: 'interrupted_prompt', … }
  }
  // Append synthetic NO_RESPONSE_REQUESTED assistant sentinel after last user
  // so the conversation is API-valid if no resume action is taken.
}
08 loadConversationForResume — 中央恢复功能

loadConversationForResume in conversationRecovery.ts 是所有简历路径的单一入口点 (CLI --continue, CLI --resume <id>, --resume <path.jsonl>,以及互动的 /resume 斜线命令)。 它处理四种源形状:

loadConversationForResume(源,sourceJsonlFile) 来源 === 未定义 → 最近一次会议 (跳过实时后台会话)源是字符串→ 通过会话 UUID 加载 来源是 LogOption → 已加载日志对象 sourceJsonl文件集 → 任意 .jsonl 路径(跨目录恢复) 在所有情况下: 1.resolveLog() / loadMessagesFromJsonlPath() 2.isLiteLog(log) ? loadFullLog(log) :按原样使用 3. copyPlanForResume() ← 将计划文件复制到恢复会话 4. copyFileHistoryForResume() 5. checkResumeConsistency() 6. RestoreSkillStateFromMessages() ← 将 invoked_skills 重新添加到引导状态 7. deserializeMessagesWithInterruptDetection() 8. processSessionStartHooks('resume', { sessionId }) 9. return { messages, TurnInterruptionState, fileHistorySnapshots, attributionSnapshots, contentReplacements, contextCollapseCommits, …会话元数据… }

精简版与完整日志

为了提高性能,会话列表使用“lite”读取:对每个 JSONL 进行快速头+尾读取以提取元数据,而无需解析整个文件。 如果选择精简日志进行恢复, loadFullLog 重新读取完整的文件并构建完整的消息映射。

技能状态恢复

压缩后,调用的技能将重新注入引导状态 invoked_skills 成绩单中的附件条目 - 确保恢复后的第二个压缩周期仍然可以找到技能列表。

// utils/conversationRecovery.ts
export function restoreSkillStateFromMessages(messages: Message[]): void {
  for (const message of messages) {
    if (message.type !== 'attachment') continue
    if (message.attachment.type === 'invoked_skills') {
      for (const skill of message.attachment.skills) {
        if (skill.name && skill.path && skill.content) {
          addInvokedSkill(skill.name, skill.path, skill.content, null)
        }
      }
    }
    if (message.attachment.type === 'skill_listing') {
      suppressNextSkillListing()  // avoid re-announcing ~600 tokens of skills
    }
  }
}
09 processResumedConversation — 完整恢复 Orchestrator

processResumedConversation in sessionRestore.ts 之后被调用 loadConversationForResume 返回。 它处理切换到恢复会话的所有有状态副作用:

processResumedConversation(结果,选项,上下文) 1. 比赛协调员/普通模式 modeApi.matchSessionMode(result.mode) — 如果不匹配 forkSession 会发出警告? 跳过:switchSession(resumeId,transcriptDir)2。 重新标记会话 ID switchSession() — 自动更新引导程序 STATE.sessionId renameRecordingForSession() — 重命名 asciicast 文件以匹配 resetSessionFilePointer() — 空 Project.sessionFile(旧路径) RestoreCostStateForSession(sid) — 恢复 API 成本计数器 3. 恢复会话元数据缓存 RestoreSessionMetadata(result) — 重新填充项目的缓存字段(customTitle、agentName、agentColor、mode、worktreeSession、prNumber…) 4. 恢复worktree工作目录 RestoreWorktreeForResume(result.worktreeSession) → process.chdir(worktreePath) 或如果 dir 消失则覆盖缓存 5. 采用恢复的转录文件 采用ResumedSessionFile() — 将 Project.sessionFile 指向现有 JSONL,以便 reAppendSessionMetadata() 可以在出口 6 上写入正确的文件。 恢复上下文崩溃提交日志 (仅限 ant,CONTEXT_COLLAPSE 功能标志)7. 恢复代理设置 RestoreAgentFromSession() — 重新应用 agentType + 模型覆盖 8. 保持当前模式 — saveMode(coordinator/normal) 用于将来的恢复 9. 计算初始状态 渲染之前computeRestoredAttributionState()computeStandaloneAgentContext()refreshAgentDefinitionsForModeSwitch()updateSessionName()10. 返回已处理的简历 { 消息、fileHistorySnapshots、contentReplacements、agentName、agentColor、restoredAgentDef、initialState }

工作树简历

当会话退出时位于 git 工作树中时, Claude Code 必须 chdir 回到该目录。 实现使用 process.chdir 作为 TOCTOU 安全的存在检查 - 它会抛出 ENOENT 如果工作树在会话之间被删除,并且代码会优雅地回退到在缓存中记录“退出”。

// utils/sessionRestore.ts
export function restoreWorktreeForResume(
  worktreeSession: PersistedWorktreeSession | null | undefined,
): void {
  const fresh = getCurrentWorktreeSession()
  if (fresh) { saveWorktreeState(fresh); return }
  if (!worktreeSession) return

  try {
    process.chdir(worktreeSession.worktreePath)
  } catch {
    // Directory is gone. Record "exited" so next reAppend doesn't persist stale path.
    saveWorktreeState(null)
    return
  }
  setCwd(worktreeSession.worktreePath)
  setOriginalCwd(getCwd())
  restoreWorktreeSession(worktreeSession)
  // Invalidate caches that reference old cwd
  clearMemoryFileCaches()
  clearSystemPromptSections()
  getPlansDirectory.cache.clear?.()
}
10 RestoreSessionStateFromLog — AppState 水合

除了文件/身份级别恢复之外,进程内恢复 AppState (反应状态)必须从成绩单中播种。 这是由 restoreSessionStateFromLog in sessionRestore.ts 并由交互式 (REPL) 和 SDK 恢复路径使用。

// utils/sessionRestore.ts
export function restoreSessionStateFromLog(
  result: ResumeResult,
  setAppState: (f: (prev: AppState) => AppState) => void,
): void {
  // Restore file history (per-turn file diff snapshots)
  if (result.fileHistorySnapshots?.length > 0) {
    fileHistoryRestoreStateFromLog(result.fileHistorySnapshots, newState => {
      setAppState(prev => ({ ...prev, fileHistory: newState }))
    })
  }
  // Restore attribution state (ant-only COMMIT_ATTRIBUTION feature)
  if (feature('COMMIT_ATTRIBUTION') && result.attributionSnapshots?.length > 0) {
    attributionRestoreStateFromLog(result.attributionSnapshots, newState => {
      setAppState(prev => ({ ...prev, attribution: newState }))
    })
  }
  // Restore context-collapse commits (resets store even if empty list)
  if (feature('CONTEXT_COLLAPSE')) {
    contextCollapsePersist.restoreFromEntries(
      result.contextCollapseCommits ?? [],
      result.contextCollapseSnapshot,
    )
  }
  // Restore todo list from transcript (SDK/non-interactive only)
  if (!isTodoV2Enabled() && result.messages?.length > 0) {
    const todos = extractTodosFromTranscript(result.messages)
    if (todos.length > 0) {
      setAppState(prev => ({
        ...prev,
        todos: { ...prev.todos, [getSessionId()]: todos },
      }))
    }
  }
}
11 云持久性 — 双远程路径

对于 Claude.ai 会话 (CCR),记录会镜像到云端。 有两条不同的路径:

远程持久化架构 ┌────────────────────────────────────────────────────┐ │ 本地 JSONL 写入 │ │ (总是在设备上发生) │ └────────────────────┬────────────────────────────────┘ │ ▼ persistToRemote(sessionId, entry) │ ┌────────────┴────────────────┐ │ │ ▼ ▼ CCR v2 路径 v1 会话入口 (internalEventWriter set) (ENABLE_SESSION_PERSISTENCE=1) │ │ ▼ ▼InternalEventWriter( sessionIngress.appendSessionLog() 'transcript',entry, → REST POST 到remoteIngressUrl { isCompaction, agentId } →失败时gracefulShutdownSync(1) ) FLUSH_INTERVAL = 10ms FLUSH_INTERVAL = 10ms (vs 100ms 本地) 会话重新连接时(CCR v2 水合): 水合物FromCCRv2InternalEvents(sessionId) → 通过internalEventReader() 获取前台事件 → 通过internalSubagentEventReader() 获取子代理事件 → 按agent_id 对子代理事件进行分组 → writeFile(sessionFile, content) ← 覆盖每个代理的本地JSONL → writeFile(agentFile, agentContent)
// utils/sessionStorage.ts
export async function hydrateFromCCRv2InternalEvents(
  sessionId: string,
): Promise<boolean> {
  const events = await reader()
  // Write foreground transcript
  const fgContent = events.map(e => jsonStringify(e.payload) + '\n').join('')
  await writeFile(sessionFile, fgContent, { encoding: 'utf8', mode: 0o600 })

  // Group subagent events by agent_id, write per-agent files
  const byAgent = new Map<string, Record<string, unknown>[]>()
  for (const e of subagentEvents) {
    const agentId = e.agent_id || ''
    if (!agentId) continue
    byAgent.set(agentId, [...(byAgent.get(agentId) ?? []), e.payload])
  }
  for (const [agentId, entries] of byAgent) {
    const agentFile = getAgentTranscriptPath(asAgentId(agentId))
    await writeFile(agentFile, entries.map(p => jsonStringify(p) + '\n').join(''))
  }
}
12 远程会话历史API

对于 CCR Web 和移动 UI, assistant/sessionHistory.ts 提供分页事件获取客户端。 这用于通过向后滚动事件(从最旧到最新)来呈现完整的对话历史记录。 API 光标是 before_id,默认锚定到最新事件。

// assistant/sessionHistory.ts
export const HISTORY_PAGE_SIZE = 100

export type HistoryPage = {
  events: SDKMessage[]    // chronological within page
  firstId: string | null  // cursor for next-older page
  hasMore: boolean        // true = older events exist
}

// Newest page: anchor_to_latest=true
export async function fetchLatestEvents(
  ctx: HistoryAuthCtx, limit = HISTORY_PAGE_SIZE,
): Promise<HistoryPage | null>

// Older page: before_id cursor
export async function fetchOlderEvents(
  ctx: HistoryAuthCtx, beforeId: string, limit = HISTORY_PAGE_SIZE,
): Promise<HistoryPage | null>

身份验证通过以下方式准备一次 createHistoryAuthCtx — 它获取 OAuth 访问令牌 + 组织 UUID 并将它们缓存在 HistoryAuthCtx 在所有页面获取中重用对象,每个请求有 15 秒的超时时间, ccr-byoc-2025-07-29 测试版标头。

13 压缩和保留段重新链接

当上下文压缩运行时(第 15 课 课),它会重写会话链。 JSONL 是仅附加的,因此压缩后的消息永远不会从磁盘中删除 - 相反, system compact_boundary 消息与 parentUuid=null 截断逻辑链。 加载从最新的叶子开始,到达边界的空父节点,然后停止——仅生成压缩后的消息。

高级情况是“保留段”——压缩器希望逐字保留的预压缩消息的后缀(对于保留最近的上下文很有用)。 简历上, applyPreservedSegmentRelinks 修补 parentUuid 将该段正确拼接回链中的指针。

简历上保留的段重新链接 压缩前(以 JSONL 形式,物理顺序): [A] → [B] → [C] → [D] → [E(user)] 压缩后(逻辑,在磁盘上): [boundary(parentUuid=null)] → [summary] → [D'] → [E(user)] ← 保留段:[D', E] 锚定于 [summary] applyPreservedSegmentRelinks(): 1. 查找最后一个 Compact_boundary servedSegment 元数据 2. 遍历 tail→head 来验证段是否完好 3. 补丁:head.parentUuid = anchorUuid(摘要) 4. 补丁:锚点的其他子节点 → tail(拼接) 5. 保留的辅助消息上的过时使用令牌为零(磁盘上的 input_tokens 反映预压缩上下文 ~190K → 将在恢复时触发立即自动压缩循环) 6. 修剪之前的所有内容 未保留的绝对最后边界
14 会话列表 - Lite Reads

简历选择器(--continue / /resume UI)需要显示包含标题、最后提示、日期和大小的会话列表 - 无需解析可能有多个 GB 的完整 JSONL 文件。 这是通过以下方式解决的 readSessionLite in sessionStoragePortable.ts,它只读取每个文件的头部(前几 KB)和尾部(最后 64 KB):

会话列表性能架构(listSessionsImpl.ts) 对于项目目录中的每个 .jsonl 文件: ┌────────────────────────────────────────────┐ │ readHeadAndTail(path, LITE_READ_BUF_SIZE=65536) │ │ │ │ HEAD (第一个 4KB): │ │ extractFirstPromptFromHead() ← SKIP_FIRST_PROMPT │ │ 从第一个条目中提取 gitBranch、cwd │ │ │ │ TAIL(最后 64KB): │ │ extractLastJsonStringField('customTitle') │ │ extractLastJsonStringField('tag') │ │ extractLastJsonStringField('lastPrompt') │ │ │ │ stat() → lastModified, fileSize │ └────────────────────────────────────────────────┘ SessionInfo returned: { sessionId,summary,lastModified,fileSize,customTitle,firstPrompt,gitBranch,cwd,tag,createdAt } 会话按lastModified desc排序,可以选择按dir过滤。 默认情况下包含工作树路径(includeWorktrees:true)。
为什么这很重要
尾窗策略的原因 reAppendSessionMetadata 在退出和压缩时将 title/tag/last-prompt 移至 EOF — 它保证这些字段位于列表读取的 64 KB 窗口内。 否则,长时间会话会将元数据推出窗口,并且即使会话具有自定义标题,简历选择器也会显示“无提示”。
15 重复数据删除和 UUID 冲突防护

Because recordTranscript 可以使用相同的消息多次调用(例如,在压缩之后 messagesToKeep),写入路径使用 messageSet: Set<UUID> 每个会话跳过已记录的条目。

// utils/sessionStorage.ts — recordTranscript()
const messageSet = await getSessionMessages(sessionId)  // Set<UUID>
const newMessages: Message[] = []
let seenNewMessage = false

for (const m of cleanedMessages) {
  if (messageSet.has(m.uuid)) {
    // Only track prefix-skipped messages as parent
    if (!seenNewMessage && isChainParticipant(m)) {
      startingParentUuid = m.uuid
    }
  } else {
    newMessages.push(m)
    seenNewMessage = true
  }
}
// Only write truly new messages to JSONL
侧链异常
代理侧链条目是 not 针对主会话进行重复数据删除 messageSet。 他们转到一个单独的文件(agent-<agentId>.jsonl)。 混合 UUID 集会导致主线程消息从代理文件 UUID 链接到 buildConversationChain 在主 JSONL 中找不到 — 生成悬空父引用和孤立的转录分支。
16 完整的数据流程图
完整会话生命周期 STARTUP RUNNING SHUTDOWN │ │ │ ├─ bootstrap/state.ts ├─ query() 调用 ├─ 清理处理程序运行 │ setSessionId(newUUID) │ │ │ ├─ sessionState → 'running' ├─ Project.flush() ├─ --Continue / --resume? │ │ │ loadConversationForResume() ├─ recordTranscript(messages) ├─ reAppendSessionMetadata() │ processResumedConversation() │ enqueueWrite(jsonl) │ (标题、标签、lastPrompt │ RestoreSessionStateFromLog() │ persistToRemote() │ 留在尾部窗口) │ │ ├─ HydroFromCCRv2? ├─ 工具需要批准 │ 获取远程事件 │ sessionState → 'requires_action' │ writeFile(local JSONL) │metadataListener({pending_action}) │ ├─ MaterializeSessionFile() ├─ 工具批准/拒绝 │ (推迟到第 1 课 条消息) │ sessionState → 'running' │ ├─ sessionState → 'idle' ├─ 模型响应 │ sessionState → '空闲' │metadataListener({task_summary: null})
17 持久性守卫和跳过条件

有几种情况会完全禁用转录本的持久性。 这 shouldSkipPersistence() 方法集中了这些检查:

// utils/sessionStorage.ts — Project class
private shouldSkipPersistence(): boolean {
  const allowTestPersistence = isEnvTruthy(
    process.env.TEST_ENABLE_SESSION_PERSISTENCE,
  )
  return (
    (getNodeEnv() === 'test' && !allowTestPersistence) ||
    getSettings_DEPRECATED()?.cleanupPeriodDays === 0 ||
    isSessionPersistenceDisabled() ||   // --no-session-persistence flag
    isEnvTruthy(process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY)
    // ^ set by tmuxSocket.ts for Tungsten test sessions
  )
}

要点

  • 会话是仅附加的 JSONL 文件。 消息通过以下方式形成链接列表 parentUuid。 阅读从最新的叶子到根部,然后反向。
  • The Project singleton 将所有写入批量写入每个文件队列(本地 100 毫秒,云 10 毫秒),并在写入之前按 UUID 进行重复数据删除。
  • 会话文件是在第一条真实用户/助理消息上延迟创建的,以防止仅元数据会话弄乱简历列表。
  • 元数据条目(标题、标签、最后提示)在退出和压缩时重新附加到 EOF,以保留在快速列表读取所使用的 64 KB 尾窗口内。
  • 中断检测将每个恢复的会话分类为 none, interrupted_prompt, 或者 interrupted_turn — 后两者会生成一条合成的继续消息,因此 SDK 可以在无需用户输入的情况下自动恢复。
  • CCR 云会话在重新连接时从远程事件中合并本地 JSONL,用服务器权威的转录本覆盖本地文件。
  • 三个状态机值(idle, running, requires_action) 驱动 CCR 侧边栏 UI、推送通知和 SDK 事件流 — 所有这些都通过单个连接 notifySessionStateChanged 阻塞点。
  • 工作树简历使用 process.chdir 作为 TOCTOU 安全的存在检查; 如果目录消失,缓存将立即被覆盖,以防止过时的路径在退出时重新保留。
深入探讨:孤立消息的墓碑删除

当流响应在飞行中失败时,孤立的助理消息可能会出现在 JSONL 中。 Claude Code 可以通过删除它 removeMessageByUuid — 外科手术就地操作,避免重写完整文件:

  1. 读最后一篇 LITE_READ_BUF_SIZE 文件的 (64 KB) 字节。
  2. 搜索 "uuid":"<targetUuid>" 在缓冲区中(不是裸UUID,以避免匹配 parentUuid 一个孩子的)。
  3. 通过字节扫描定位包含行的边界(安全,因为 0x0a 永远不会出现在 UTF-8 多字节序列中)。
  4. 在行开头截断文件,然后重新附加该行后面的所有字节。
  5. 如果 UUID 不在尾窗口中(罕见 — 需要在孤立项之后写入许多大条目),则回退到完整文件重写 — 但如果文件超过 50 MB,则放弃以防止 OOM。
深入探讨:分叉会话行为

When --fork-session 使用 Claude Code 恢复对话内容但分配一个 新会话 UUID。 这将创建一个不会覆盖原始转录本的独立副本。 棘手的部分:原始会话中的内容替换记录必须在启动时或分叉的第一个会话中播种到新会话的 JSONL 中 loadConversationForResume 将无法找到替换记录并将工具输出错误分类为 FROZEN(未缓存),从而导致令牌过剩。

// utils/sessionRestore.ts
} else if (result.contentReplacements?.length) {
  // Fork keeps fresh startup sessionId. Seed content-replacement records
  // so loadTranscriptFile's keyed lookup matches the new session's ID.
  await recordContentReplacement(result.contentReplacements)
}
深入探讨:代理设置恢复

如果会话使用自定义代理类型运行(例如,来自 ~/.claude/agents/ 目录), restoreAgentFromSession 在恢复时重新应用代理类型和模型覆盖 - 除非用户明确指定不同的代理类型和模型覆盖 --agent 在 CLI 上,优先。 如果代理不再存在(在会话之间删除),则会记录调试警告并使用默认行为。