会话管理
Claude Code 如何持久、恢复和恢复对话 — 从磁盘上的 JSONL 到云水合和中断轮流自动恢复。
Claude Code 中的每个对话都是 session。 会话具有 UUID、JSONL 转录文件和元数据条目的集合(标题、代理配置、工作树路径、git 分支等)。 会话管理层主要关注四个问题:
Storage
仅附加 JSONL 转录文件 ~/.claude/projects/。 通过内部的每个文件队列进行批量异步写入 Project singleton.
状态跟踪
进程内三态机(idle / running / requires_action)以及用于 CCR 桥和 SDK 消费者的侦听器挂钩。
恢复/继续
磁盘上的parentUuid链表的链式遍历、反序列化过滤器、工作树重新进入和代理设置重新水化。
Recovery
转弯中断检测:转弯中途碰撞变成合成的“从上次停下的地方继续”。 信息; SDK 可以自动恢复,无需用户输入。
云同步
双远程持久性路径:v1 会话入口 (REST) 和 CCR v2 内部事件。 重新连接时,Hydration 会根据远程数据重写本地 JSONL。
历史API
用于远程会话重放的分页事件获取 — 由 CCR Web/移动 UI 用于向后滚动实时或已完成会话中的事件。
utils/sessionState.ts — 空闲/运行/require_action 状态机
utils/sessionRestore.ts — processResumedConversation 协调器
utils/conversationRecovery.ts — loadConversationForResume,中断检测
assistant/sessionHistory.ts — 远程分页事件获取
会话存储在 ~/.claude/projects/ (or CLAUDE_CONFIG_DIR/projects/)。 每个项目目录都是工作目录的经过清理的路径编码表示。 在每个项目目录中,每个会话 UUID 都有一个 JSONL 文件,以及子代理的 sidecar 目录。
路径推导逻辑在里面 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`)
}
getOriginalCwd() 被使用——不 getCwd() 在模块加载时。 这很重要,因为引导程序通过解析符号链接 realpathSync 模块导入后,过早捕获会产生不同的清理路径,并使会话对简历选择器“不可见”。
所有写入都经过一次 Project 类实例通过获取 getProject()。 该类将条目批量放入每个文件队列中,并每 100 毫秒(或当云持久性处于活动状态时为 10 毫秒)耗尽它们。 这可以防止在高吞吐量轮流期间出现雷鸣般的文件 I/O。
惰性文件具体化
会话文件是 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 …
}
JSONL 文件不是简单的消息列表。 它是一个超集——对话条目和边车元数据条目的混合。 这是分类法:
| 条目类型 | Purpose | 参与连锁? |
|---|---|---|
user | 人类转向消息 | 是(parentUuid) |
assistant | 模型响应 | Yes |
attachment | 文件、技能、invoked_skills | Yes |
system | 系统/紧凑边界消息 | Yes |
custom-title | 用户重命名的会话名称 | 否 — 仅元数据 |
last-prompt | 最后用户文本(用于简历选择器) | No |
tag | 会话标签 | No |
agent-name | 独立代理显示名称 | No |
agent-setting | 会话中使用的代理类型 | No |
mode | 协调器/正常模式 | No |
worktree-state | Git 工作树路径和原始 cwd | No |
pr-link | GitHub 工作流程的 PR 编号/URL | No |
file-history-snapshot | 每回合的文件差异历史记录 | No |
content-replacement | 大型刀具产量替代记录 | No |
marble-origami-commit | 上下文折叠提交(仅限 ant) | No |
bash_progress, mcp_progress等)是 从未坚持过 到 JSONL。 它们是短暂的 UI 状态。 旧的文字记录可能包含它们—— loadTranscriptFile 在负载期间将链条桥接在它们之间。
消息通过以下方式形成链接列表 parentUuid。 此设计允许分支(子代理的侧链、分叉会话),同时保持 JSONL 仅附加。 加载时,Claude Code 将链从最新的叶子返回到根,然后反转它。
// 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)
}
The sessionState.ts 模块为当前会话提供一个单例三态机。 状态转换触发侦听器回调,该回调连接到:CCR 桥(用于 requires_action 通知)、SDK 事件流(对于 VS Code / scmuxd)和外部元数据存储(对于待处理操作 UI)。
// 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
}
当 Claude Code 加载恢复记录时,它必须确定上一个会话是否在中途中断。 这很重要,因为:
- 如果在工具使用过程中中断(tool_result 是最后一个条目),则会显示“从上次中断的位置继续”。 消息被注入。
- 如果在模型响应用户提示之前中断,则原始用户消息将返回为
interrupted_prompt让 SDK 自动重试。 - 如果转弯正常完成,则不添加合成消息。
// 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.
}
loadConversationForResume in conversationRecovery.ts 是所有简历路径的单一入口点 (CLI --continue, CLI --resume <id>, --resume <path.jsonl>,以及互动的 /resume 斜线命令)。 它处理四种源形状:
精简版与完整日志
为了提高性能,会话列表使用“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
}
}
}
processResumedConversation in sessionRestore.ts 之后被调用 loadConversationForResume 返回。 它处理切换到恢复会话的所有有状态副作用:
工作树简历
当会话退出时位于 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?.()
}
除了文件/身份级别恢复之外,进程内恢复 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 },
}))
}
}
}
对于 Claude.ai 会话 (CCR),记录会镜像到云端。 有两条不同的路径:
// 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(''))
}
}
对于 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 测试版标头。
当上下文压缩运行时(第 15 课 课),它会重写会话链。 JSONL 是仅附加的,因此压缩后的消息永远不会从磁盘中删除 - 相反, system compact_boundary 消息与 parentUuid=null 截断逻辑链。 加载从最新的叶子开始,到达边界的空父节点,然后停止——仅生成压缩后的消息。
高级情况是“保留段”——压缩器希望逐字保留的预压缩消息的后缀(对于保留最近的上下文很有用)。 简历上, applyPreservedSegmentRelinks 修补 parentUuid 将该段正确拼接回链中的指针。
简历选择器(--continue / /resume UI)需要显示包含标题、最后提示、日期和大小的会话列表 - 无需解析可能有多个 GB 的完整 JSONL 文件。 这是通过以下方式解决的 readSessionLite in sessionStoragePortable.ts,它只读取每个文件的头部(前几 KB)和尾部(最后 64 KB):
reAppendSessionMetadata 在退出和压缩时将 title/tag/last-prompt 移至 EOF — 它保证这些字段位于列表读取的 64 KB 窗口内。 否则,长时间会话会将元数据推出窗口,并且即使会话具有自定义标题,简历选择器也会显示“无提示”。
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
messageSet。 他们转到一个单独的文件(agent-<agentId>.jsonl)。 混合 UUID 集会导致主线程消息从代理文件 UUID 链接到 buildConversationChain 在主 JSONL 中找不到 — 生成悬空父引用和孤立的转录分支。
有几种情况会完全禁用转录本的持久性。 这 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
Projectsingleton 将所有写入批量写入每个文件队列(本地 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 — 外科手术就地操作,避免重写完整文件:
- 读最后一篇
LITE_READ_BUF_SIZE文件的 (64 KB) 字节。 - 搜索
"uuid":"<targetUuid>"在缓冲区中(不是裸UUID,以避免匹配parentUuid一个孩子的)。 - 通过字节扫描定位包含行的边界(安全,因为
0x0a永远不会出现在 UTF-8 多字节序列中)。 - 在行开头截断文件,然后重新附加该行后面的所有字节。
- 如果 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 上,优先。 如果代理不再存在(在会话之间删除),则会记录调试警告并使用默认行为。