Claude Code 源码分析Claude Code 源码分析
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
  • 概览

    • Claude Code 源码分析
    • 源码统计
    • CodeGraph 图谱
  • 架构

    • 系统架构
    • UML 图表索引
    • 查询引擎
    • 核心流程
    • 消息系统
    • 状态管理
  • 功能模块

    • 工具系统
    • 斜杠命令
    • 服务层
    • MCP 协议
    • Skills 技能
    • 子代理系统
  • 分层深度

    • 入口层
    • UI / Ink 层
    • utils 基础设施
    • 桥接 / 远程
    • 上下文压缩
  • 原理与安全

    • 底层原理
    • 技术难点
    • 权限与安全
    • 内部机制
    • 遥测与分析
  • 深度专题

    • Hooks 系统
    • 插件系统
    • 记忆系统
    • API 通信层
    • Ink 终端 UI
    • 认证系统
    • 构建与发布
    • 术语表
  • 调用分析

    • 调用链分析
    • 核心文件索引
  • 模块详解

    • utils

      • 模块: utils
      • messages · 消息工厂与规范化
      • session-storage · JSONL 会话持久化
      • permissions · 工具权限决策
      • shell-hooks · 用户 Shell Hook 系统
    • components

      • 模块: components
      • REPL · 主屏编排
      • messages · 消息行渲染
      • PermissionRequest · 权限弹窗
      • PromptInput · 底部输入
    • services

      • 模块: services
      • api-claude · Anthropic API 流式与重试
      • mcp-client · MCP 连接与工具调用
      • compact · 上下文压缩与自动触发
      • analytics · GrowthBook、Datadog 与 1P 事件
    • tools

      • 模块: tools
      • tool-interface · Tool 契约与注册表
      • bash-tool · Shell 执行与权限
      • streaming-executor · 流式工具并发调度
      • agent-tool · 子 Agent 委派
    • commands

      • 模块: commands
      • command-registry · commands.ts 注册与分派
      • model-command · /model 模型选择
      • mcp-commands · /mcp 服务器管理
      • compact-memory-commands · /compact 与 /memory
    • ink

      • 模块: ink
      • Ink 渲染管线 · Screen 与终端输出
      • 终端事件 · resize、paste、stdin
      • Ink Hooks · 输入、搜索、终端状态
      • Ink 组件 · Box、Text、ScrollBox 原语
    • hooks

      • 模块: hooks
      • useCanUseTool · 权限 UI 接缝
      • 输入与快捷键 Hook
      • 合并态 Hook(MCP + 本地)
      • notifs 通知 Hook
    • bridge

      • 模块: bridge
      • repl-bridge · REPL 桥初始化与传输
      • bridge-messaging · 桥消息路由与入站处理
      • remote-bridge-core · env-less 核心与守护主循环
      • bridge-permissions-ui · 权限、API 与 TUI
    • cli

      • 模块: cli
      • Structured IO · NDJSON SDK 协议
      • CLI Transports · Session Ingress 传输层
      • CLI Handlers · 子命令懒加载实现
      • Update & Upload · 自更新与串行上传原语
    • screens

      • 模块: screens
      • REPL 屏 · Screen 类型与顶层路由
      • ResumeConversation · 会话恢复选择器
      • Doctor · 安装诊断全屏
    • entrypoints

      • 模块: entrypoints
      • cli-entrypoint · Bootstrap 与快路径
      • sdk-types · core / control / runtime 类型体系
      • mcp-entrypoint · MCP stdio 服务器
      • sandbox-types · 沙箱配置单一真相源
    • skills

      • 模块: skills
      • skills-loading · 磁盘加载与 bundled 注册表
      • bundled-skills · 内置 skill 与 initBundledSkills
      • mcp-skills · MCP prompt 转 skill
      • skill-tool-integration · SkillTool 与命令注册
    • types

      • 模块: types
      • message-types · Message 联合与 content blocks
      • tool-permission-types · Tool、Permission、Command 类型
      • api-sdk-types · API 与 Hooks 协议类型
      • misc-types · ids、plugin、generated 与其余类型
    • tasks

      • 模块: tasks
      • local-agent-task · 本地 Agent 与主会话后台化
      • remote-agent-task · 远程 CCR 与 In-Process Teammate
      • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
      • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybindings

      • 模块: keybindings
      • keybinding-registry · 注册、Provider 与 useKeybinding
      • default-bindings · 默认键位表与平台差异
      • command-bindings · command:* 动态斜杠命令绑定
      • vim-bindings · Vim 模式与 keybindings 边界
    • memdir

      • 模块: memdir
      • memdir-core · 路径、加载与 MEMORY.md
      • memory-extraction · extractMemories 与 SessionMemory
      • memdir-commands · /memory、/remember 与命令集成
    • state

      • 模块: state
      • app-state-core · store、AppState 类型与 Provider
      • app-state-selectors · selectors 与 onChangeAppState
      • teammate-state · 队友视图与 swarm 状态
      • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query

      • 模块: query
      • query config 与 deps · 配置快照与依赖注入
      • query tokenBudget · +500k 自动续跑
      • query transitions · Continue / Terminal 状态机
      • query stopHooks · Stop 事件与 turn 结束编排
  • 模块详解(扩展)

    • messages · 消息工厂与规范化
    • session-storage · JSONL 会话持久化
    • permissions · 工具权限决策
    • shell-hooks · 用户 Shell Hook 系统
    • REPL · 主屏编排
    • messages · 消息行渲染
    • PermissionRequest · 权限弹窗
    • PromptInput · 底部输入
    • api-claude · Anthropic API 流式与重试
    • mcp-client · MCP 连接与工具调用
    • compact · 上下文压缩与自动触发
    • analytics · GrowthBook、Datadog 与 1P 事件
    • tool-interface · Tool 契约与注册表
    • bash-tool · Shell 执行与权限
    • streaming-executor · 流式工具并发调度
    • agent-tool · 子 Agent 委派
    • command-registry · commands.ts 注册与分派
    • model-command · /model 模型选择
    • mcp-commands · /mcp 服务器管理
    • compact-memory-commands · /compact 与 /memory
    • Ink 渲染管线 · Screen 与终端输出
    • 终端事件 · resize、paste、stdin
    • Ink Hooks · 输入、搜索、终端状态
    • Ink 组件 · Box、Text、ScrollBox 原语
    • useCanUseTool · 权限 UI 接缝
    • 输入与快捷键 Hook
    • 合并态 Hook(MCP + 本地)
    • notifs 通知 Hook
    • repl-bridge · REPL 桥初始化与传输
    • bridge-messaging · 桥消息路由与入站处理
    • remote-bridge-core · env-less 核心与守护主循环
    • bridge-permissions-ui · 权限、API 与 TUI
    • Structured IO · NDJSON SDK 协议
    • CLI Transports · Session Ingress 传输层
    • CLI Handlers · 子命令懒加载实现
    • Update & Upload · 自更新与串行上传原语
    • REPL 屏 · Screen 类型与顶层路由
    • ResumeConversation · 会话恢复选择器
    • Doctor · 安装诊断全屏
    • cli-entrypoint · Bootstrap 与快路径
    • sdk-types · core / control / runtime 类型体系
    • mcp-entrypoint · MCP stdio 服务器
    • sandbox-types · 沙箱配置单一真相源
    • skills-loading · 磁盘加载与 bundled 注册表
    • bundled-skills · 内置 skill 与 initBundledSkills
    • mcp-skills · MCP prompt 转 skill
    • skill-tool-integration · SkillTool 与命令注册
    • message-types · Message 联合与 content blocks
    • tool-permission-types · Tool、Permission、Command 类型
    • api-sdk-types · API 与 Hooks 协议类型
    • misc-types · ids、plugin、generated 与其余类型
    • local-agent-task · 本地 Agent 与主会话后台化
    • remote-agent-task · 远程 CCR 与 In-Process Teammate
    • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
    • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybinding-registry · 注册、Provider 与 useKeybinding
    • default-bindings · 默认键位表与平台差异
    • command-bindings · command:* 动态斜杠命令绑定
    • vim-bindings · Vim 模式与 keybindings 边界
    • memdir-core · 路径、加载与 MEMORY.md
    • memory-extraction · extractMemories 与 SessionMemory
    • memdir-commands · /memory、/remember 与命令集成
    • app-state-core · store、AppState 类型与 Provider
    • app-state-selectors · selectors 与 onChangeAppState
    • teammate-state · 队友视图与 swarm 状态
    • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query config 与 deps · 配置快照与依赖注入
    • query tokenBudget · +500k 自动续跑
    • query transitions · Continue / Terminal 状态机
    • query stopHooks · Stop 事件与 turn 结束编排
  • 工具详解

    • tool-interface · Tool 契约与注册表
    • tool-permission-types · Tool、Permission、Command 类型
    • 工具: Bash
    • 工具: PowerShell
    • 工具: Agent
    • 工具: LSP
    • 工具: FileEdit
    • 工具: FileRead
    • 工具: Skill
    • 工具: WebFetch
    • 工具: MCP
    • 工具: SendMessage
    • 工具: FileWrite
    • 工具: Config
    • 工具: Grep
    • 工具: Brief
    • 工具: ExitPlanMode
    • 工具: ToolSearch
    • 工具: NotebookEdit
    • 工具: TaskOutput
    • 工具: WebSearch
    • 工具: ScheduleCron

本章总览

utils/sessionStorage.ts(约 5000 行)管理 Claude Code 的本地会话真相源:每个项目在 ~/.claude/projects// 下以 .jsonl 追加写入转录条目,维护 parentUuid 链、子 Agent 旁路文件、远程 hydrate、compact 边界与文件历史快照。本章聚焦「哪些消息会落盘」「路径如何与 bootstrap 状态一致」「recordTranscript 如何避免 compact 后链断裂」。

学完本章你应该能

  • 解释 isTranscriptMessage 与 isChainParticipant 的差异
  • 说出 getTranscriptPath 与 getSessionProjectDir 为何必须成对使用
  • 描述 recordTranscript 的前缀跳过逻辑如何服务 compaction
  • 理解 progress 消息为何不进入 parentUuid 链
  • 能定位子 Agent transcript 与 .meta.json 的写入点

核心概念(先读懂这些)

JSONL 不是简单的聊天日志

每一行是一个 Entry(user/assistant/attachment/system、file_history、queue_operation 等)。loadTranscriptFile 只把 isTranscriptMessage 为真的行装入内存 Message 链,其余元数据用于恢复边界、归因或队列。写入路径由 getProject() 单例缓冲,flushSessionStorage 把内存队列刷到磁盘。远程 ingress 可整文件替换本地 JSONL(hydrateRemoteSession)。

sessionProjectDir 与 symlink 陷阱

模块注释反复强调:不要在 import 时缓存 cwd。bootstrap 的 realpath 解析后,getOriginalCwd() 与 import 时 getCwd() 可能不同,导致「写入目录」与「读取目录」分裂,/resume 找不到会话。getTranscriptPath 对当前 sessionId 优先用 getSessionProjectDir(),hooks 的 transcript_path 也依赖这条逻辑(gh-30217)。

progress 是 UI 状态,不是 transcript

isTranscriptMessage 排除 progress。若 progress 参与 parentUuid 链,resume 会产生 fork,孤儿化真实消息(#14373、#23537)。recordTranscript 返回的 last UUID 用 findLast(isChainParticipant),明确 progress 可被写入 JSONL 但不被后续消息引用为 parent。

建议学习步骤

  1. 阅读 isTranscriptMessage / isChainParticipant 类型守卫
  2. 阅读 getTranscriptPath 与 agent 子目录路径
  3. 阅读 recordTranscript 去重与 parent 提示
  4. 阅读 flushSessionStorage 与 hydrateRemoteSession
  5. 在磁盘打开一个 .jsonl 对照 Entry 类型

常见误区

注意

MAX_TRANSCRIPT_READ_BYTES(50MB)以上读取会 bail,避免 OOM

注意

不要用 progress 消息的 uuid 作为 parentUuid 提示

注意

子 Agent 的 sessionId 与主会话相同,靠 subagents/ 子目录区分文件

存储布局与路径 API

典型路径结构:

~/.claude/projects/<projectHash>/
  <sessionId>.jsonl          # 主转录
  <sessionId>/subagents/
    agent-<agentId>.jsonl    # 子 Agent 转录
    workflows/<runId>/...    # 可选分组(setAgentTranscriptSubdir)

getProjectsDir() 返回 projects 根。getTranscriptPath() 对 当前 会话解析目录:getSessionProjectDir() ?? getProjectDir(getOriginalCwd()),再拼接 sessionId。

getTranscriptPathForSession(id) 在 id 等于当前 session 时与上一致;否则只能用 originalCwd 猜测——注释要求调用方传 fullPath 若需精确跨会话路径。

MAX_TRANSCRIPT_READ_BYTES 限制 tombstone 重写等全文件操作,防止 GB 级 JSONL OOM(inc-3930)。

源码引用: src/utils/sessionStorage.ts · 第 139–146 行(共 5106 行)

 139| export function isTranscriptMessage(entry: Entry): entry is TranscriptMessage {
 140|   return (
 141|     entry.type === 'user' ||
 142|     entry.type === 'assistant' ||
 143|     entry.type === 'attachment' ||
 144|     entry.type === 'system'
 145|   )
 146| }

源码引用: src/utils/sessionStorage.ts · 第 198–258 行(共 5106 行)

 198| export function getProjectsDir(): string {
 199|   return join(getClaudeConfigHomeDir(), 'projects')
 200| }
 201| 
 202| export function getTranscriptPath(): string {
 203|   const projectDir = getSessionProjectDir() ?? getProjectDir(getOriginalCwd())
 204|   return join(projectDir, `${getSessionId()}.jsonl`)
 205| }
 206| 
 207| export function getTranscriptPathForSession(sessionId: string): string {
 208|   // When asking for the CURRENT session's transcript, honor sessionProjectDir
 209|   // the same way getTranscriptPath() does. Without this, hooks get a
 210|   // transcript_path computed from originalCwd while the actual file was
 211|   // written to sessionProjectDir (set by switchActiveSession on resume/branch)
 212|   // — different directories, so the hook sees MISSING (gh-30217). CC-34
 213|   // made sessionId + sessionProjectDir atomic precisely to prevent this
 214|   // kind of drift; this function just wasn't updated to read both.
 215|   //
 216|   // For OTHER session IDs we can only guess via originalCwd — we don't
 217|   // track a sessionId→projectDir map. Callers wanting a specific other
 218|   // session's path should pass fullPath explicitly (most save* functions
 219|   // already accept this).
 220|   if (sessionId === getSessionId()) {
 221|     return getTranscriptPath()
 222|   }
 223|   const projectDir = getProjectDir(getOriginalCwd())
 224|   return join(projectDir, `${sessionId}.jsonl`)
 225| }
 226| 
 227| // 50 MB — session JSONL can grow to multiple GB (inc-3930). Callers that
 228| // read the raw transcript must bail out above this threshold to avoid OOM.
 229| export const MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024
 230| 
 231| // In-memory map of agentId → subdirectory for grouping related subagent
 232| // transcripts (e.g. workflow runs write to subagents/workflows/<runId>/).
 233| // Populated before the agent runs; consulted by getAgentTranscriptPath.
 234| const agentTranscriptSubdirs = new Map<string, string>()
 235| 
 236| export function setAgentTranscriptSubdir(
 237|   agentId: string,
 238|   subdir: string,
 239| ): void {
 240|   agentTranscriptSubdirs.set(agentId, subdir)
 241| }
 242| 
 243| export function clearAgentTranscriptSubdir(agentId: string): void {
 244|   agentTranscriptSubdirs.delete(agentId)
 245| }
 246| 
 247| export function getAgentTranscriptPath(agentId: AgentId): string {
 248|   // Same sessionProjectDir consistency as getTranscriptPathForSession —
 249|   // subagent transcripts live under the session dir, so if the session
 250|   // transcript is at sessionProjectDir, subagent transcripts are too.
 251|   const projectDir = getSessionProjectDir() ?? getProjectDir(getOriginalCwd())
 252|   const sessionId = getSessionId()
 253|   const subdir = agentTranscriptSubdirs.get(agentId)
 254|   const base = subdir
 255|     ? join(projectDir, sessionId, 'subagents', subdir)
 256|     : join(projectDir, sessionId, 'subagents')
 257|   return join(base, `agent-${agentId}.jsonl`)
 258| }

Transcript 类型守卫与链参与者

isTranscriptMessage:entry.type 为 user | assistant | attachment | system。注释声明这是 唯一真相源,loadTranscriptFile 据此过滤。

isChainParticipant:在写入路径(insertMessageChain、useLogMessages)决定谁可成为 parentUuid 目标。progress 不是 chain participant,但可能仍写入文件供 UI 恢复。

isEphemeralToolProgress 识别工具进度条目,避免污染链。

对比 messages 章: messages.ts 构造内存 Message;sessionStorage 决定哪些 Message 进入 JSONL 以及 uuid 链接顺序。

源码引用: src/utils/sessionStorage.ts · 第 128–170 行(共 5106 行)

 128| /**
 129|  * Type guard to check if an entry is a transcript message.
 130|  * Transcript messages include user, assistant, attachment, and system messages.
 131|  * IMPORTANT: This is the single source of truth for what constitutes a transcript message.
 132|  * loadTranscriptFile() uses this to determine which messages to load into the chain.
 133|  *
 134|  * Progress messages are NOT transcript messages. They are ephemeral UI state
 135|  * and must not be persisted to the JSONL or participate in the parentUuid
 136|  * chain. Including them caused chain forks that orphaned real conversation
 137|  * messages on resume (see #14373, #23537).
 138|  */
 139| export function isTranscriptMessage(entry: Entry): entry is TranscriptMessage {
 140|   return (
 141|     entry.type === 'user' ||
 142|     entry.type === 'assistant' ||
 143|     entry.type === 'attachment' ||
 144|     entry.type === 'system'
 145|   )
 146| }
 147| 
 148| /**
 149|  * Entries that participate in the parentUuid chain. Used on the write path
 150|  * (insertMessageChain, useLogMessages) to skip progress when assigning
 151|  * parentUuid. Old transcripts with progress already in the chain are handled
 152|  * by the progressBridge rewrite in loadTranscriptFile.
 153|  */
 154| export function isChainParticipant(m: Pick<Message, 'type'>): boolean {
 155|   return m.type !== 'progress'
 156| }
 157| 
 158| type LegacyProgressEntry = {
 159|   type: 'progress'
 160|   uuid: UUID
 161|   parentUuid: UUID | null
 162| }
 163| 
 164| /**
 165|  * Progress entries in transcripts written before PR #24099. They are not
 166|  * in the Entry type union anymore but still exist on disk with uuid and
 167|  * parentUuid fields. loadTranscriptFile bridges the chain across them.
 168|  */
 169| function isLegacyProgressEntry(entry: unknown): entry is LegacyProgressEntry {
 170|   return (

子 Agent 元数据与旁路文件

writeAgentMetadata / readAgentMetadata 在 .jsonl 旁写入 .meta.json,保存 agentType、worktreePath、description。resume 时若用户未传 subagent_type,靠元数据路由到正确内置 Agent,避免静默降级为 general-purpose(4KB 提示词、无历史)。

setAgentTranscriptSubdir 把同一 session 下的多个 Agent 文件分组到 workflows 等子目录,便于运维扫描。

远程 Agent 另有 readRemoteAgentMetadata / listRemoteAgentMetadata,与 background remote 任务联动。

源码引用: src/utils/sessionStorage.ts · 第 264–291 行(共 5106 行)

 264| export type AgentMetadata = {
 265|   agentType: string
 266|   /** Worktree path if the agent was spawned with isolation: "worktree" */
 267|   worktreePath?: string
 268|   /** Original task description from the AgentTool input. Persisted so a
 269|    * resumed agent's notification can show the original description instead
 270|    * of a placeholder. Optional — older metadata files lack this field. */
 271|   description?: string
 272| }
 273| 
 274| /**
 275|  * Persist the agentType used to launch a subagent. Read by resume to
 276|  * route correctly when subagent_type is omitted — without this, resuming
 277|  * a fork silently degrades to general-purpose (4KB system prompt, no
 278|  * inherited history). Sidecar file avoids JSONL schema changes.
 279|  *
 280|  * Also stores the worktreePath when the agent was spawned with worktree
 281|  * isolation, enabling resume to restore the correct cwd.
 282|  */
 283| export async function writeAgentMetadata(
 284|   agentId: AgentId,
 285|   metadata: AgentMetadata,
 286| ): Promise<void> {
 287|   const path = getAgentMetadataPath(agentId)
 288|   await mkdir(dirname(path), { recursive: true })
 289|   await writeFile(path, JSON.stringify(metadata))
 290| }
 291| 

recordTranscript:去重、前缀 parent 与 compact

recordTranscript 是 REPL / QueryEngine 增量的主入口:

  1. cleanMessagesForLogging 剥离不应落盘字段
  2. getSessionMessages 得到已记录 uuid 集合
  3. 遍历切片:已存在 uuid 若在未见过新消息 之前 且是 chain participant,则更新 startingParentUuid(前缀跟踪)
  4. compact 后 messagesToKeep 出现在 新 compact boundary 之后,不会被当作前缀 → 不会错误地把 parent 指到 pre-compact 消息(避免孤儿链)
  5. 新消息调用 insertMessageChain
  6. 返回最后一条 新写入 的 chain participant uuid,或前缀跟踪值

注释中的 startingParentUuidHint 让 useLogMessages 避免 O(n) 扫描。progress 写入但不作为返回值。

源码引用: src/utils/sessionStorage.ts · 第 1408–1449 行(共 5106 行)

1408| export async function recordTranscript(
1409|   messages: Message[],
1410|   teamInfo?: TeamInfo,
1411|   startingParentUuidHint?: UUID,
1412|   allMessages?: readonly Message[],
1413| ): Promise<UUID | null> {
1414|   const cleanedMessages = cleanMessagesForLogging(messages, allMessages)
1415|   const sessionId = getSessionId() as UUID
1416|   const messageSet = await getSessionMessages(sessionId)
1417|   const newMessages: typeof cleanedMessages = []
1418|   let startingParentUuid: UUID | undefined = startingParentUuidHint
1419|   let seenNewMessage = false
1420|   for (const m of cleanedMessages) {
1421|     if (messageSet.has(m.uuid as UUID)) {
1422|       // Only track skipped messages that form a prefix. After compaction,
1423|       // messagesToKeep appear AFTER new CB/summary, so this skips them.
1424|       if (!seenNewMessage && isChainParticipant(m)) {
1425|         startingParentUuid = m.uuid as UUID
1426|       }
1427|     } else {
1428|       newMessages.push(m)
1429|       seenNewMessage = true
1430|     }
1431|   }
1432|   if (newMessages.length > 0) {
1433|     await getProject().insertMessageChain(
1434|       newMessages,
1435|       false,
1436|       undefined,
1437|       startingParentUuid,
1438|       teamInfo,
1439|     )
1440|   }
1441|   // Return the last ACTUALLY recorded chain-participant's UUID, OR the
1442|   // prefix-tracked UUID if no new chain participants were recorded. This lets
1443|   // callers (useLogMessages) maintain the correct parent chain even when the
1444|   // slice is all-recorded (rewind, /resume scenarios where every message is
1445|   // already in messageSet). Progress is skipped — it's written to the JSONL
1446|   // but nothing chains TO it (see isChainParticipant).
1447|   const lastRecorded = newMessages.findLast(isChainParticipant)
1448|   return (lastRecorded?.uuid as UUID | undefined) ?? startingParentUuid ?? null
1449| }

侧链、队列与快照条目

recordSidechainTranscript:第二个参数 agentId,insertMessageChain 的 sidechain 标志为 true,用于子 Agent 并行转录。

recordQueueOperation:持久化队列操作消息,供恢复调度状态。

recordFileHistorySnapshot / recordAttributionSnapshot / recordContentReplacement:在特定 messageId 或独立条目上挂快照,支持 rewind、归因与内容替换审计。

removeTranscriptMessage:tombstone 收到时按 uuid 删除孤儿行。

这些 API 说明 JSONL 是 多种 Entry 的混流,不仅是聊天。

源码引用: src/utils/sessionStorage.ts · 第 1451–1499 行(共 5106 行)

1451| export async function recordSidechainTranscript(
1452|   messages: Message[],
1453|   agentId?: string,
1454|   startingParentUuid?: UUID | null,
1455| ) {
1456|   await getProject().insertMessageChain(
1457|     cleanMessagesForLogging(messages),
1458|     true,
1459|     agentId,
1460|     startingParentUuid,
1461|   )
1462| }
1463| 
1464| export async function recordQueueOperation(queueOp: QueueOperationMessage) {
1465|   await getProject().insertQueueOperation(queueOp)
1466| }
1467| 
1468| /**
1469|  * Remove a message from the transcript by UUID.
1470|  * Used when a tombstone is received for an orphaned message.
1471|  */
1472| export async function removeTranscriptMessage(targetUuid: UUID): Promise<void> {
1473|   await getProject().removeMessageByUuid(targetUuid)
1474| }
1475| 
1476| export async function recordFileHistorySnapshot(
1477|   messageId: UUID,
1478|   snapshot: FileHistorySnapshot,
1479|   isSnapshotUpdate: boolean,
1480| ) {
1481|   await getProject().insertFileHistorySnapshot(
1482|     messageId,
1483|     snapshot,
1484|     isSnapshotUpdate,
1485|   )
1486| }
1487| 
1488| export async function recordAttributionSnapshot(
1489|   snapshot: AttributionSnapshotMessage,
1490| ) {
1491|   await getProject().insertAttributionSnapshot(snapshot)
1492| }
1493| 
1494| export async function recordContentReplacement(
1495|   replacements: ContentReplacementRecord[],
1496|   agentId?: AgentId,
1497| ) {
1498|   await getProject().insertContentReplacement(replacements, agentId)
1499| }

flush、reset 与远程 hydrate

flushSessionStorage 委托 getProject().flush(),确保崩溃前缓冲区落盘。

resetSessionFilePointer 在 switchSession / regenerateSessionId 后重置懒创建指针,下一批 user/assistant 才创建新文件。

hydrateRemoteSession:

  • switchSession 到目标 id
  • sessionIngress.getSessionLogs 拉远程
  • writeFile 截断替换本地 JSONL
  • finally 设置 remote ingress URL,保证先同步再开启持久化

失败返回 false 并记诊断日志,不抛到 UI 顶层。

源码引用: src/utils/sessionStorage.ts · 第 1505–1507 行(共 5106 行)

1505| export async function resetSessionFilePointer() {
1506|   getProject().resetSessionFile()
1507| }

源码引用: src/utils/sessionStorage.ts · 第 1583–1619 行(共 5106 行)

1583| export async function flushSessionStorage(): Promise<void> {
1584|   await getProject().flush()
1585| }
1586| 
1587| export async function hydrateRemoteSession(
1588|   sessionId: string,
1589|   ingressUrl: string,
1590| ): Promise<boolean> {
1591|   switchSession(asSessionId(sessionId))
1592| 
1593|   const project = getProject()
1594| 
1595|   try {
1596|     const remoteLogs =
1597|       (await sessionIngress.getSessionLogs(sessionId, ingressUrl)) || []
1598| 
1599|     // Ensure the project directory and session file exist
1600|     const projectDir = getProjectDir(getOriginalCwd())
1601|     await mkdir(projectDir, { recursive: true, mode: 0o700 })
1602| 
1603|     const sessionFile = getTranscriptPathForSession(sessionId)
1604| 
1605|     // Replace local logs with remote logs. writeFile truncates, so no
1606|     // unlink is needed; an empty remoteLogs array produces an empty file.
1607|     const content = remoteLogs.map(e => jsonStringify(e) + '\n').join('')
1608|     await writeFile(sessionFile, content, { encoding: 'utf8', mode: 0o600 })
1609| 
1610|     logForDebugging(`Hydrated ${remoteLogs.length} entries from remote`)
1611|     return remoteLogs.length > 0
1612|   } catch (error) {
1613|     logForDebugging(`Error hydrating session from remote: ${error}`)
1614|     logForDiagnosticsNoPII('error', 'hydrate_remote_session_fail')
1615|     return false
1616|   } finally {
1617|     // Set remote ingress URL after hydrating the remote session
1618|     // to ensure we've always synced with the remote session
1619|     // prior to enabling persistence

与 sessionStoragePortable 的分工

sessionStoragePortable.ts 提供 LITE_READ_BUF_SIZE、readHeadAndTail、readTranscriptForLoad 等 大文件安全读 原语。主文件在 SKIP_FIRST_PROMPT_PATTERN 等处与其保持同步注释。

首条 prompt 提取用 SKIP_FIRST_PROMPT_PATTERN 跳过 IDE 上下文、hook 输出等 XML 开头行,避免把系统噪声当成用户首问。

读源码时:portable = 算法与阈值;sessionStorage.ts = 会话生命周期与 Project 单例。

源码引用: src/utils/sessionStorage.ts · 第 114–126 行(共 5106 行)

 114| /**
 115|  * Pre-compiled regex to skip non-meaningful messages when extracting first prompt.
 116|  * Matches anything starting with a lowercase XML-like tag (IDE context, hook
 117|  * output, task notifications, channel messages, etc.) or a synthetic interrupt
 118|  * marker. Kept in sync with sessionStoragePortable.ts — generic pattern avoids
 119|  * an ever-growing allowlist that falls behind as new notification types ship.
 120|  */
 121| // 50MB — prevents OOM in the tombstone slow path which reads + rewrites the
 122| // entire session file. Session files can grow to multiple GB (inc-3930).
 123| const MAX_TOMBSTONE_REWRITE_BYTES = 50 * 1024 * 1024
 124| 
 125| const SKIP_FIRST_PROMPT_PATTERN =
 126|   /^(?:\s*<[a-z][\w-]*[\s>]|\[Request interrupted by user[^\]]*\])/

源码引用: src/utils/sessionStoragePortable.ts · 第 1–40 行(共 794 行)

   1| /**
   2|  * Portable session storage utilities.
   3|  *
   4|  * Pure Node.js — no internal dependencies on logging, experiments, or feature
   5|  * flags. Shared between the CLI (src/utils/sessionStorage.ts) and the VS Code
   6|  * extension (packages/claude-vscode/src/common-host/sessionStorage.ts).
   7|  */
   8| 
   9| import type { UUID } from 'crypto'
  10| import { open as fsOpen, readdir, realpath, stat } from 'fs/promises'
  11| import { join } from 'path'
  12| import { getClaudeConfigHomeDir } from './envUtils.js'
  13| import { getWorktreePathsPortable } from './getWorktreePathsPortable.js'
  14| import { djb2Hash } from './hash.js'
  15| 
  16| /** Size of the head/tail buffer for lite metadata reads. */
  17| export const LITE_READ_BUF_SIZE = 65536
  18| 
  19| // ---------------------------------------------------------------------------
  20| // UUID validation
  21| // ---------------------------------------------------------------------------
  22| 
  23| const uuidRegex =
  24|   /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
  25| 
  26| export function validateUuid(maybeUuid: unknown): UUID | null {
  27|   if (typeof maybeUuid !== 'string') return null
  28|   return uuidRegex.test(maybeUuid) ? (maybeUuid as UUID) : null
  29| }
  30| 
  31| // ---------------------------------------------------------------------------
  32| // JSON string field extraction — no full parse, works on truncated lines
  33| // ---------------------------------------------------------------------------
  34| 
  35| /**
  36|  * Unescape a JSON string value extracted as raw text.
  37|  * Only allocates a new string when escape sequences are present.
  38|  */
  39| export function unescapeJsonString(raw: string): string {
  40|   if (!raw.includes('\\')) return raw

源码目录与调试建议

调试 checklist:

  • ls ~/.claude/projects/*/*.jsonl 确认 sessionProjectDir 是否与 getTranscriptPath 一致
  • 搜索 parentUuid 断点:compact 前后是否出现双 CB
  • 对比 loadTranscriptFile 过滤前后条数,确认 progress 未进入链

loadTranscript 与并发会话

loadTranscriptFromFile / loadTranscriptFile(约 2294 行起)负责 读 路径:在 MAX_TRANSCRIPT_READ_BYTES 内解析 JSONL,重建 Message 数组与 customTitles、compact 边界元数据。resume 时若文件超过阈值,会走 head/tail 或 portable 轻量读,避免把 GB 级文件一次性读入内存。

concurrentSessions.ts 的 updateSessionName 与 sessionStorage 协作:用户重命名会话标题时,元数据写回 project 目录而不改写整条 uuid 链。switchSession(bootstrap/state.ts)切换活动 sessionId 后必须调用 resetSessionFilePointer,否则 insertMessageChain 仍指向旧文件句柄。

常见故障: /continue 后消息顺序错乱,优先检查 loadTranscriptFile 是否把 progress 误标为 transcript;其次检查 compact 后 recordTranscript 是否把 parent 指到 summary 之前。

动手练习

  1. 开启新会话,发送两条消息,用文本编辑器打开 jsonl,确认每行 type 与 uuid
  2. 执行 compact,观察 recordTranscript 注释中的前缀逻辑是否防止 parent 指到旧 uuid
  3. 启动子 Agent,查找 subagents/agent-*.jsonl 与 .meta.json
  4. 对照 hooks.ts createBaseHookInput 的 transcript_path 与 getTranscriptPathForSession 返回值
  5. 模拟 switchSession,确认 resetSessionFilePointer 后新消息写入新 sessionId 文件

本章小结与延伸

sessionStorage = 会话时间轴的持久化引擎。改写入逻辑前务必理解 compact 与 rewind 场景。 继续学习:

  • messages
  • permissions
Prev
messages · 消息工厂与规范化
Next
permissions · 工具权限决策