sessionRestore.ts
utils/sessionRestore.ts
552
Lines
20406
Bytes
9
Exports
31
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with session-engine. It contains 552 lines, 31 detected imports, and 9 detected exports.
Important relationships
Detected exports
restoreSessionStateFromLogcomputeRestoredAttributionStatecomputeStandaloneAgentContextrestoreAgentFromSessionrefreshAgentDefinitionsForModeSwitchProcessedResumerestoreWorktreeForResumeexitRestoredWorktreeprocessResumedConversation
Keywords
resultsessionworktreeagentresumemessagesmodeagentdefinitionfeaturetodos
Detected imports
bun:bundlecryptopath../bootstrap/state.js../constants/systemPromptSections.js../cost-tracker.js../state/AppState.js../tools/AgentTool/agentColorManager.js../tools/AgentTool/loadAgentsDir.js../tools/TodoWriteTool/constants.js../types/ids.js../types/logs.js../types/message.js./asciicast.js./claudemd.js./commitAttribution.js./concurrentSessions.js./cwd.js./debug.js./fileHistory.js./fileHistory.js./messages.js./model/model.js./plans.js./Shell.js./sessionStorage.js./tasks.js./todo/types.js./todo/types.js./toolResultStorage.js./worktree.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
import { feature } from 'bun:bundle'
import type { UUID } from 'crypto'
import { dirname } from 'path'
import {
getMainLoopModelOverride,
getSessionId,
setMainLoopModelOverride,
setMainThreadAgentType,
setOriginalCwd,
switchSession,
} from '../bootstrap/state.js'
import { clearSystemPromptSections } from '../constants/systemPromptSections.js'
import { restoreCostStateForSession } from '../cost-tracker.js'
import type { AppState } from '../state/AppState.js'
import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'
import {
type AgentDefinition,
type AgentDefinitionsResult,
getActiveAgentsFromList,
getAgentDefinitionsWithOverrides,
} from '../tools/AgentTool/loadAgentsDir.js'
import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js'
import { asSessionId } from '../types/ids.js'
import type {
AttributionSnapshotMessage,
ContextCollapseCommitEntry,
ContextCollapseSnapshotEntry,
PersistedWorktreeSession,
} from '../types/logs.js'
import type { Message } from '../types/message.js'
import { renameRecordingForSession } from './asciicast.js'
import { clearMemoryFileCaches } from './claudemd.js'
import {
type AttributionState,
attributionRestoreStateFromLog,
restoreAttributionStateFromSnapshots,
} from './commitAttribution.js'
import { updateSessionName } from './concurrentSessions.js'
import { getCwd } from './cwd.js'
import { logForDebugging } from './debug.js'
import type { FileHistorySnapshot } from './fileHistory.js'
import { fileHistoryRestoreStateFromLog } from './fileHistory.js'
import { createSystemMessage } from './messages.js'
import { parseUserSpecifiedModel } from './model/model.js'
import { getPlansDirectory } from './plans.js'
import { setCwd } from './Shell.js'
import {
adoptResumedSessionFile,
recordContentReplacement,
resetSessionFilePointer,
restoreSessionMetadata,
saveMode,
saveWorktreeState,
} from './sessionStorage.js'
import { isTodoV2Enabled } from './tasks.js'
import type { TodoList } from './todo/types.js'
import { TodoListSchema } from './todo/types.js'
import type { ContentReplacementRecord } from './toolResultStorage.js'
import {
getCurrentWorktreeSession,
restoreWorktreeSession,
} from './worktree.js'
type ResumeResult = {
messages?: Message[]
fileHistorySnapshots?: FileHistorySnapshot[]
attributionSnapshots?: AttributionSnapshotMessage[]
contextCollapseCommits?: ContextCollapseCommitEntry[]
contextCollapseSnapshot?: ContextCollapseSnapshotEntry
}
/**
* Scan the transcript for the last TodoWrite tool_use block and return its todos.
* Used to hydrate AppState.todos on SDK --resume so the model's todo list
* survives session restarts without file persistence.
*/
function extractTodosFromTranscript(messages: Message[]): TodoList {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg?.type !== 'assistant') continue
const toolUse = msg.message.content.find(
block => block.type === 'tool_use' && block.name === TODO_WRITE_TOOL_NAME,
)
if (!toolUse || toolUse.type !== 'tool_use') continue
const input = toolUse.input
if (input === null || typeof input !== 'object') return []
const parsed = TodoListSchema().safeParse(
(input as Record<string, unknown>).todos,
)
return parsed.success ? parsed.data : []
}
return []
}
/**
* Restore session state (file history, attribution, todos) from log on resume.
* Used by both SDK (print.ts) and interactive (REPL.tsx, main.tsx) resume paths.
*/
export function restoreSessionStateFromLog(
result: ResumeResult,
setAppState: (f: (prev: AppState) => AppState) => void,
): void {
// Restore file history state
if (result.fileHistorySnapshots && result.fileHistorySnapshots.length > 0) {
fileHistoryRestoreStateFromLog(result.fileHistorySnapshots, newState => {
setAppState(prev => ({ ...prev, fileHistory: newState }))
})
}
// Restore attribution state (ant-only feature)
if (
feature('COMMIT_ATTRIBUTION') &&
result.attributionSnapshots &&
result.attributionSnapshots.length > 0
) {
attributionRestoreStateFromLog(result.attributionSnapshots, newState => {
setAppState(prev => ({ ...prev, attribution: newState }))
})
}
// Restore context-collapse commit log + staged snapshot. Must run before
// the first query() so projectView() can rebuild the collapsed view from
// the resumed Message[]. Called unconditionally (even with
// undefined/empty entries) because restoreFromEntries resets the store
// first — without that, an in-session /resume into a session with no
// commits would leave the prior session's stale commit log intact.
if (feature('CONTEXT_COLLAPSE')) {
/* eslint-disable @typescript-eslint/no-require-imports */
;(
require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js')
).restoreFromEntries(
result.contextCollapseCommits ?? [],
result.contextCollapseSnapshot,
)
/* eslint-enable @typescript-eslint/no-require-imports */
}
// Restore TodoWrite state from transcript (SDK/non-interactive only).
// Interactive mode uses file-backed v2 tasks, so AppState.todos is unused there.
if (!isTodoV2Enabled() && result.messages && result.messages.length > 0) {
const todos = extractTodosFromTranscript(result.messages)
if (todos.length > 0) {
const agentId = getSessionId()
setAppState(prev => ({
...prev,
todos: { ...prev.todos, [agentId]: todos },
}))
}
}
}
/**
* Compute restored attribution state from log snapshots.
* Used for computing initial state before render (e.g., main.tsx --continue).
* Returns undefined if attribution feature is disabled or no snapshots exist.
*/
export function computeRestoredAttributionState(
result: ResumeResult,
): AttributionState | undefined {
if (
feature('COMMIT_ATTRIBUTION') &&
result.attributionSnapshots &&
result.attributionSnapshots.length > 0
) {
return restoreAttributionStateFromSnapshots(result.attributionSnapshots)
}
return undefined
}
/**
* Compute standalone agent context (name/color) for session resume.
* Used for computing initial state before render (per CLAUDE.md guidelines).
* Returns undefined if no name/color is set on the session.
*/
export function computeStandaloneAgentContext(
agentName: string | undefined,
agentColor: string | undefined,
): AppState['standaloneAgentContext'] | undefined {
if (!agentName && !agentColor) {
return undefined
}
return {
name: agentName ?? '',
color: (agentColor === 'default' ? undefined : agentColor) as
| AgentColorName
| undefined,
}
}
/**
* Restore agent setting from a resumed session.
*
* When resuming a conversation that used a custom agent, this re-applies the
* agent type and model override (unless the user specified --agent on the CLI).
* Mutates bootstrap state via setMainThreadAgentType / setMainLoopModelOverride.
*
* Returns the restored agent definition and its agentType string, or undefined
* if no agent was restored.
*/
export function restoreAgentFromSession(
agentSetting: string | undefined,
currentAgentDefinition: AgentDefinition | undefined,
agentDefinitions: AgentDefinitionsResult,
): {
agentDefinition: AgentDefinition | undefined
agentType: string | undefined
} {
// If user already specified --agent on CLI, keep that definition
if (currentAgentDefinition) {
return { agentDefinition: currentAgentDefinition, agentType: undefined }
}
// If session had no agent, clear any stale bootstrap state
if (!agentSetting) {
setMainThreadAgentType(undefined)
return { agentDefinition: undefined, agentType: undefined }
}
const resumedAgent = agentDefinitions.activeAgents.find(
agent => agent.agentType === agentSetting,
)
if (!resumedAgent) {
logForDebugging(
`Resumed session had agent "${agentSetting}" but it is no longer available. Using default behavior.`,
)
setMainThreadAgentType(undefined)
return { agentDefinition: undefined, agentType: undefined }
}
setMainThreadAgentType(resumedAgent.agentType)
// Apply agent's model if user didn't specify one
if (
!getMainLoopModelOverride() &&
resumedAgent.model &&
resumedAgent.model !== 'inherit'
) {
setMainLoopModelOverride(parseUserSpecifiedModel(resumedAgent.model))
}
return { agentDefinition: resumedAgent, agentType: resumedAgent.agentType }
}
/**
* Refresh agent definitions after a coordinator/normal mode switch.
*
* When resuming a session that was in a different mode (coordinator vs normal),
* the built-in agents need to be re-derived to match the new mode. CLI-provided
* agents (from --agents flag) are merged back in.
*/
export async function refreshAgentDefinitionsForModeSwitch(
modeWasSwitched: boolean,
currentCwd: string,
cliAgents: AgentDefinition[],
currentAgentDefinitions: AgentDefinitionsResult,
): Promise<AgentDefinitionsResult> {
if (!feature('COORDINATOR_MODE') || !modeWasSwitched) {
return currentAgentDefinitions
}
// Re-derive agent definitions after mode switch so built-in agents
// reflect the new coordinator/normal mode
getAgentDefinitionsWithOverrides.cache.clear?.()
const freshAgentDefs = await getAgentDefinitionsWithOverrides(currentCwd)
const freshAllAgents = [...freshAgentDefs.allAgents, ...cliAgents]
return {
...freshAgentDefs,
allAgents: freshAllAgents,
activeAgents: getActiveAgentsFromList(freshAllAgents),
}
}
/**
* Result of processing a resumed/continued conversation for rendering.
*/
export type ProcessedResume = {
messages: Message[]
fileHistorySnapshots?: FileHistorySnapshot[]
contentReplacements?: ContentReplacementRecord[]
agentName: string | undefined
agentColor: AgentColorName | undefined
restoredAgentDef: AgentDefinition | undefined
initialState: AppState
}
/**
* Subset of the coordinator mode module API needed for session resume.
*/
type CoordinatorModeApi = {
matchSessionMode(mode?: string): string | undefined
isCoordinatorMode(): boolean
}
/**
* The loaded conversation data (return type of loadConversationForResume).
*/
type ResumeLoadResult = {
messages: Message[]
fileHistorySnapshots?: FileHistorySnapshot[]
attributionSnapshots?: AttributionSnapshotMessage[]
contentReplacements?: ContentReplacementRecord[]
contextCollapseCommits?: ContextCollapseCommitEntry[]
contextCollapseSnapshot?: ContextCollapseSnapshotEntry
sessionId: UUID | undefined
agentName?: string
agentColor?: string
agentSetting?: string
customTitle?: string
tag?: string
mode?: 'coordinator' | 'normal'
worktreeSession?: PersistedWorktreeSession | null
prNumber?: number
prUrl?: string
prRepository?: string
}
/**
* Restore the worktree working directory on resume. The transcript records
* the last worktree enter/exit; if the session crashed while inside a
* worktree (last entry = session object, not null), cd back into it.
*
* process.chdir is the TOCTOU-safe existence check — it throws ENOENT if
* the /exit dialog removed the directory, or if the user deleted it
* manually between sessions.
*
* When --worktree already created a fresh worktree, that takes precedence
* over the resumed session's state. restoreSessionMetadata just overwrote
* project.currentSessionWorktree with the stale transcript value, so
* re-assert the fresh worktree here before adoptResumedSessionFile writes
* it back to disk.
*/
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. Override the stale cache so the next
// reAppendSessionMetadata records "exited" instead of re-persisting
// a path that no longer exists.
saveWorktreeState(null)
return
}
setCwd(worktreeSession.worktreePath)
setOriginalCwd(getCwd())
// projectRoot is intentionally NOT set here. The transcript doesn't record
// whether the worktree was entered via --worktree (which sets projectRoot)
// or EnterWorktreeTool (which doesn't). Leaving projectRoot stable matches
// EnterWorktreeTool's behavior — skills/history stay anchored to the
// original project.
restoreWorktreeSession(worktreeSession)
// The /resume slash command calls this mid-session after caches have been
// populated against the old cwd. Cheap no-ops for the CLI-flag path
// (caches aren't populated yet there).
clearMemoryFileCaches()
clearSystemPromptSections()
getPlansDirectory.cache.clear?.()
}
/**
* Undo restoreWorktreeForResume before a mid-session /resume switches to
* another session. Without this, /resume from a worktree session to a
* non-worktree session leaves the user in the old worktree directory with
* currentWorktreeSession still pointing at the prior session. /resume to a
* *different* worktree fails entirely — the getCurrentWorktreeSession()
* guard above blocks the switch.
*
* Not needed by CLI --resume/--continue: those run once at startup where
* getCurrentWorktreeSession() is only truthy if --worktree was used (fresh
* worktree that should take precedence, handled by the re-assert above).
*/
export function exitRestoredWorktree(): void {
const current = getCurrentWorktreeSession()
if (!current) return
restoreWorktreeSession(null)
// Worktree state changed, so cached prompt sections that reference it are
// stale whether or not chdir succeeds below.
clearMemoryFileCaches()
clearSystemPromptSections()
getPlansDirectory.cache.clear?.()
try {
process.chdir(current.originalCwd)
} catch {
// Original dir is gone (rare). Stay put — restoreWorktreeForResume
// will cd into the target worktree next if there is one.
return
}
setCwd(current.originalCwd)
setOriginalCwd(getCwd())
}
/**
* Process a loaded conversation for resume/continue.
*
* Handles coordinator mode matching, session ID setup, agent restoration,
* mode persistence, and initial state computation. Called by both --continue
* and --resume paths in main.tsx.
*/
export async function processResumedConversation(
result: ResumeLoadResult,
opts: {
forkSession: boolean
sessionIdOverride?: string
transcriptPath?: string
includeAttribution?: boolean
},
context: {
modeApi: CoordinatorModeApi | null
mainThreadAgentDefinition: AgentDefinition | undefined
agentDefinitions: AgentDefinitionsResult
currentCwd: string
cliAgents: AgentDefinition[]
initialState: AppState
},
): Promise<ProcessedResume> {
// Match coordinator/normal mode to the resumed session
let modeWarning: string | undefined
if (feature('COORDINATOR_MODE')) {
modeWarning = context.modeApi?.matchSessionMode(result.mode)
if (modeWarning) {
result.messages.push(createSystemMessage(modeWarning, 'warning'))
}
}
// Reuse the resumed session's ID unless --fork-session is specified
if (!opts.forkSession) {
const sid = opts.sessionIdOverride ?? result.sessionId
if (sid) {
// When resuming from a different project directory (git worktrees,
// cross-project), transcriptPath points to the actual file; its dirname
// is the project dir. Otherwise the session lives in the current project.
switchSession(
asSessionId(sid),
opts.transcriptPath ? dirname(opts.transcriptPath) : null,
)
// Rename asciicast recording to match the resumed session ID so
// getSessionRecordingPaths() can discover it during /share
await renameRecordingForSession()
await resetSessionFilePointer()
restoreCostStateForSession(sid)
}
} else if (result.contentReplacements?.length) {
// --fork-session keeps the fresh startup session ID. useLogMessages will
// copy source messages into the new JSONL via recordTranscript, but
// content-replacement entries are a separate entry type only written by
// recordContentReplacement (which query.ts calls for newlyReplaced, never
// the pre-loaded records). Without this seed, `claude -r {newSessionId}`
// finds source tool_use_ids in messages but no matching replacement records
// → they're classified as FROZEN → full content sent (cache miss, permanent
// overage). insertContentReplacement stamps sessionId = getSessionId() =
// the fresh ID, so loadTranscriptFile's keyed lookup will match.
await recordContentReplacement(result.contentReplacements)
}
// Restore session metadata so /status shows the saved name and metadata
// is re-appended on session exit. Fork doesn't take ownership of the
// original session's worktree — a "Remove" on the fork's exit dialog
// would delete a worktree the original session still references — so
// strip worktreeSession from the fork path so the cache stays unset.
restoreSessionMetadata(
opts.forkSession ? { ...result, worktreeSession: undefined } : result,
)
if (!opts.forkSession) {
// Cd back into the worktree the session was in when it last exited.
// Done after restoreSessionMetadata (which caches the worktree state
// from the transcript) so if the directory is gone we can override
// the cache before adoptResumedSessionFile writes it.
restoreWorktreeForResume(result.worktreeSession)
// Point sessionFile at the resumed transcript and re-append metadata
// now. resetSessionFilePointer above nulled it (so the old fresh-session
// path doesn't leak), but that blocks reAppendSessionMetadata — which
// bails on null — from running in the exit cleanup handler. For fork,
// useLogMessages populates a *new* file via recordTranscript on REPL
// mount; the normal lazy-materialize path is correct there.
adoptResumedSessionFile()
}
// Restore context-collapse commit log + staged snapshot. The interactive
// /resume path goes through restoreSessionStateFromLog (REPL.tsx); CLI
// --continue/--resume goes through here instead. Called unconditionally
// — see the restoreSessionStateFromLog callsite above for why.
if (feature('CONTEXT_COLLAPSE')) {
/* eslint-disable @typescript-eslint/no-require-imports */
;(
require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js')
).restoreFromEntries(
result.contextCollapseCommits ?? [],
result.contextCollapseSnapshot,
)
/* eslint-enable @typescript-eslint/no-require-imports */
}
// Restore agent setting from resumed session
const { agentDefinition: restoredAgent, agentType: resumedAgentType } =
restoreAgentFromSession(
result.agentSetting,
context.mainThreadAgentDefinition,
context.agentDefinitions,
)
// Persist the current mode so future resumes know what mode this session was in
if (feature('COORDINATOR_MODE')) {
saveMode(context.modeApi?.isCoordinatorMode() ? 'coordinator' : 'normal')
}
// Compute initial state before render (per CLAUDE.md guidelines)
const restoredAttribution = opts.includeAttribution
? computeRestoredAttributionState(result)
: undefined
const standaloneAgentContext = computeStandaloneAgentContext(
result.agentName,
result.agentColor,
)
void updateSessionName(result.agentName)
const refreshedAgentDefs = await refreshAgentDefinitionsForModeSwitch(
!!modeWarning,
context.currentCwd,
context.cliAgents,
context.agentDefinitions,
)
return {
messages: result.messages,
fileHistorySnapshots: result.fileHistorySnapshots,
contentReplacements: result.contentReplacements,
agentName: result.agentName,
agentColor: (result.agentColor === 'default'
? undefined
: result.agentColor) as AgentColorName | undefined,
restoredAgentDef: restoredAgent,
initialState: {
...context.initialState,
...(resumedAgentType && { agent: resumedAgentType }),
...(restoredAttribution && { attribution: restoredAttribution }),
...(standaloneAgentContext && { standaloneAgentContext }),
agentDefinitions: refreshedAgentDefs,
},
}
}