552 lines
20 KiB
TypeScript
552 lines
20 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
}
|