π File detail
utils/sessionRestore.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes restoreSessionStateFromLog, computeRestoredAttributionState, computeStandaloneAgentContext, restoreAgentFromSession, and refreshAgentDefinitionsForModeSwitch (and more) β mainly functions, hooks, or classes. Dependencies touch bun:bundle, crypto, and Node path helpers. It composes internal code from bootstrap, constants, cost-tracker, state, and tools (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { feature } from 'bun:bundle' import type { UUID } from 'crypto' import { dirname } from 'path' import { getMainLoopModelOverride,
π€ Exports (heuristic)
restoreSessionStateFromLogcomputeRestoredAttributionStatecomputeStandaloneAgentContextrestoreAgentFromSessionrefreshAgentDefinitionsForModeSwitchProcessedResumerestoreWorktreeForResumeexitRestoredWorktreeprocessResumedConversation
π External import roots
Package roots from from "β¦" (relative paths omitted).
bun:bundlecryptopath
π₯οΈ Source preview
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,
},
}
}