本章总览
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。
建议学习步骤
- 阅读 isTranscriptMessage / isChainParticipant 类型守卫
- 阅读 getTranscriptPath 与 agent 子目录路径
- 阅读 recordTranscript 去重与 parent 提示
- 阅读 flushSessionStorage 与 hydrateRemoteSession
- 在磁盘打开一个 .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 增量的主入口:
cleanMessagesForLogging剥离不应落盘字段getSessionMessages得到已记录 uuid 集合- 遍历切片:已存在 uuid 若在未见过新消息 之前 且是 chain participant,则更新 startingParentUuid(前缀跟踪)
- compact 后 messagesToKeep 出现在 新 compact boundary 之后,不会被当作前缀 → 不会错误地把 parent 指到 pre-compact 消息(避免孤儿链)
- 新消息调用 insertMessageChain
- 返回最后一条 新写入 的 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 之前。
动手练习
- 开启新会话,发送两条消息,用文本编辑器打开 jsonl,确认每行 type 与 uuid
- 执行 compact,观察 recordTranscript 注释中的前缀逻辑是否防止 parent 指到旧 uuid
- 启动子 Agent,查找 subagents/agent-*.jsonl 与 .meta.json
- 对照 hooks.ts createBaseHookInput 的 transcript_path 与 getTranscriptPathForSession 返回值
- 模拟 switchSession,确认 resetSessionFilePointer 后新消息写入新 sessionId 文件
本章小结与延伸
sessionStorage = 会话时间轴的持久化引擎。改写入逻辑前务必理解 compact 与 rewind 场景。 继续学习: