source dump of claude code
at main 551 lines 20 kB view raw
1import { feature } from 'bun:bundle' 2import type { UUID } from 'crypto' 3import { dirname } from 'path' 4import { 5 getMainLoopModelOverride, 6 getSessionId, 7 setMainLoopModelOverride, 8 setMainThreadAgentType, 9 setOriginalCwd, 10 switchSession, 11} from '../bootstrap/state.js' 12import { clearSystemPromptSections } from '../constants/systemPromptSections.js' 13import { restoreCostStateForSession } from '../cost-tracker.js' 14import type { AppState } from '../state/AppState.js' 15import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js' 16import { 17 type AgentDefinition, 18 type AgentDefinitionsResult, 19 getActiveAgentsFromList, 20 getAgentDefinitionsWithOverrides, 21} from '../tools/AgentTool/loadAgentsDir.js' 22import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js' 23import { asSessionId } from '../types/ids.js' 24import type { 25 AttributionSnapshotMessage, 26 ContextCollapseCommitEntry, 27 ContextCollapseSnapshotEntry, 28 PersistedWorktreeSession, 29} from '../types/logs.js' 30import type { Message } from '../types/message.js' 31import { renameRecordingForSession } from './asciicast.js' 32import { clearMemoryFileCaches } from './claudemd.js' 33import { 34 type AttributionState, 35 attributionRestoreStateFromLog, 36 restoreAttributionStateFromSnapshots, 37} from './commitAttribution.js' 38import { updateSessionName } from './concurrentSessions.js' 39import { getCwd } from './cwd.js' 40import { logForDebugging } from './debug.js' 41import type { FileHistorySnapshot } from './fileHistory.js' 42import { fileHistoryRestoreStateFromLog } from './fileHistory.js' 43import { createSystemMessage } from './messages.js' 44import { parseUserSpecifiedModel } from './model/model.js' 45import { getPlansDirectory } from './plans.js' 46import { setCwd } from './Shell.js' 47import { 48 adoptResumedSessionFile, 49 recordContentReplacement, 50 resetSessionFilePointer, 51 restoreSessionMetadata, 52 saveMode, 53 saveWorktreeState, 54} from './sessionStorage.js' 55import { isTodoV2Enabled } from './tasks.js' 56import type { TodoList } from './todo/types.js' 57import { TodoListSchema } from './todo/types.js' 58import type { ContentReplacementRecord } from './toolResultStorage.js' 59import { 60 getCurrentWorktreeSession, 61 restoreWorktreeSession, 62} from './worktree.js' 63 64type ResumeResult = { 65 messages?: Message[] 66 fileHistorySnapshots?: FileHistorySnapshot[] 67 attributionSnapshots?: AttributionSnapshotMessage[] 68 contextCollapseCommits?: ContextCollapseCommitEntry[] 69 contextCollapseSnapshot?: ContextCollapseSnapshotEntry 70} 71 72/** 73 * Scan the transcript for the last TodoWrite tool_use block and return its todos. 74 * Used to hydrate AppState.todos on SDK --resume so the model's todo list 75 * survives session restarts without file persistence. 76 */ 77function extractTodosFromTranscript(messages: Message[]): TodoList { 78 for (let i = messages.length - 1; i >= 0; i--) { 79 const msg = messages[i] 80 if (msg?.type !== 'assistant') continue 81 const toolUse = msg.message.content.find( 82 block => block.type === 'tool_use' && block.name === TODO_WRITE_TOOL_NAME, 83 ) 84 if (!toolUse || toolUse.type !== 'tool_use') continue 85 const input = toolUse.input 86 if (input === null || typeof input !== 'object') return [] 87 const parsed = TodoListSchema().safeParse( 88 (input as Record<string, unknown>).todos, 89 ) 90 return parsed.success ? parsed.data : [] 91 } 92 return [] 93} 94 95/** 96 * Restore session state (file history, attribution, todos) from log on resume. 97 * Used by both SDK (print.ts) and interactive (REPL.tsx, main.tsx) resume paths. 98 */ 99export function restoreSessionStateFromLog( 100 result: ResumeResult, 101 setAppState: (f: (prev: AppState) => AppState) => void, 102): void { 103 // Restore file history state 104 if (result.fileHistorySnapshots && result.fileHistorySnapshots.length > 0) { 105 fileHistoryRestoreStateFromLog(result.fileHistorySnapshots, newState => { 106 setAppState(prev => ({ ...prev, fileHistory: newState })) 107 }) 108 } 109 110 // Restore attribution state (ant-only feature) 111 if ( 112 feature('COMMIT_ATTRIBUTION') && 113 result.attributionSnapshots && 114 result.attributionSnapshots.length > 0 115 ) { 116 attributionRestoreStateFromLog(result.attributionSnapshots, newState => { 117 setAppState(prev => ({ ...prev, attribution: newState })) 118 }) 119 } 120 121 // Restore context-collapse commit log + staged snapshot. Must run before 122 // the first query() so projectView() can rebuild the collapsed view from 123 // the resumed Message[]. Called unconditionally (even with 124 // undefined/empty entries) because restoreFromEntries resets the store 125 // first — without that, an in-session /resume into a session with no 126 // commits would leave the prior session's stale commit log intact. 127 if (feature('CONTEXT_COLLAPSE')) { 128 /* eslint-disable @typescript-eslint/no-require-imports */ 129 ;( 130 require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js') 131 ).restoreFromEntries( 132 result.contextCollapseCommits ?? [], 133 result.contextCollapseSnapshot, 134 ) 135 /* eslint-enable @typescript-eslint/no-require-imports */ 136 } 137 138 // Restore TodoWrite state from transcript (SDK/non-interactive only). 139 // Interactive mode uses file-backed v2 tasks, so AppState.todos is unused there. 140 if (!isTodoV2Enabled() && result.messages && result.messages.length > 0) { 141 const todos = extractTodosFromTranscript(result.messages) 142 if (todos.length > 0) { 143 const agentId = getSessionId() 144 setAppState(prev => ({ 145 ...prev, 146 todos: { ...prev.todos, [agentId]: todos }, 147 })) 148 } 149 } 150} 151 152/** 153 * Compute restored attribution state from log snapshots. 154 * Used for computing initial state before render (e.g., main.tsx --continue). 155 * Returns undefined if attribution feature is disabled or no snapshots exist. 156 */ 157export function computeRestoredAttributionState( 158 result: ResumeResult, 159): AttributionState | undefined { 160 if ( 161 feature('COMMIT_ATTRIBUTION') && 162 result.attributionSnapshots && 163 result.attributionSnapshots.length > 0 164 ) { 165 return restoreAttributionStateFromSnapshots(result.attributionSnapshots) 166 } 167 return undefined 168} 169 170/** 171 * Compute standalone agent context (name/color) for session resume. 172 * Used for computing initial state before render (per CLAUDE.md guidelines). 173 * Returns undefined if no name/color is set on the session. 174 */ 175export function computeStandaloneAgentContext( 176 agentName: string | undefined, 177 agentColor: string | undefined, 178): AppState['standaloneAgentContext'] | undefined { 179 if (!agentName && !agentColor) { 180 return undefined 181 } 182 return { 183 name: agentName ?? '', 184 color: (agentColor === 'default' ? undefined : agentColor) as 185 | AgentColorName 186 | undefined, 187 } 188} 189 190/** 191 * Restore agent setting from a resumed session. 192 * 193 * When resuming a conversation that used a custom agent, this re-applies the 194 * agent type and model override (unless the user specified --agent on the CLI). 195 * Mutates bootstrap state via setMainThreadAgentType / setMainLoopModelOverride. 196 * 197 * Returns the restored agent definition and its agentType string, or undefined 198 * if no agent was restored. 199 */ 200export function restoreAgentFromSession( 201 agentSetting: string | undefined, 202 currentAgentDefinition: AgentDefinition | undefined, 203 agentDefinitions: AgentDefinitionsResult, 204): { 205 agentDefinition: AgentDefinition | undefined 206 agentType: string | undefined 207} { 208 // If user already specified --agent on CLI, keep that definition 209 if (currentAgentDefinition) { 210 return { agentDefinition: currentAgentDefinition, agentType: undefined } 211 } 212 213 // If session had no agent, clear any stale bootstrap state 214 if (!agentSetting) { 215 setMainThreadAgentType(undefined) 216 return { agentDefinition: undefined, agentType: undefined } 217 } 218 219 const resumedAgent = agentDefinitions.activeAgents.find( 220 agent => agent.agentType === agentSetting, 221 ) 222 if (!resumedAgent) { 223 logForDebugging( 224 `Resumed session had agent "${agentSetting}" but it is no longer available. Using default behavior.`, 225 ) 226 setMainThreadAgentType(undefined) 227 return { agentDefinition: undefined, agentType: undefined } 228 } 229 230 setMainThreadAgentType(resumedAgent.agentType) 231 232 // Apply agent's model if user didn't specify one 233 if ( 234 !getMainLoopModelOverride() && 235 resumedAgent.model && 236 resumedAgent.model !== 'inherit' 237 ) { 238 setMainLoopModelOverride(parseUserSpecifiedModel(resumedAgent.model)) 239 } 240 241 return { agentDefinition: resumedAgent, agentType: resumedAgent.agentType } 242} 243 244/** 245 * Refresh agent definitions after a coordinator/normal mode switch. 246 * 247 * When resuming a session that was in a different mode (coordinator vs normal), 248 * the built-in agents need to be re-derived to match the new mode. CLI-provided 249 * agents (from --agents flag) are merged back in. 250 */ 251export async function refreshAgentDefinitionsForModeSwitch( 252 modeWasSwitched: boolean, 253 currentCwd: string, 254 cliAgents: AgentDefinition[], 255 currentAgentDefinitions: AgentDefinitionsResult, 256): Promise<AgentDefinitionsResult> { 257 if (!feature('COORDINATOR_MODE') || !modeWasSwitched) { 258 return currentAgentDefinitions 259 } 260 261 // Re-derive agent definitions after mode switch so built-in agents 262 // reflect the new coordinator/normal mode 263 getAgentDefinitionsWithOverrides.cache.clear?.() 264 const freshAgentDefs = await getAgentDefinitionsWithOverrides(currentCwd) 265 const freshAllAgents = [...freshAgentDefs.allAgents, ...cliAgents] 266 return { 267 ...freshAgentDefs, 268 allAgents: freshAllAgents, 269 activeAgents: getActiveAgentsFromList(freshAllAgents), 270 } 271} 272 273/** 274 * Result of processing a resumed/continued conversation for rendering. 275 */ 276export type ProcessedResume = { 277 messages: Message[] 278 fileHistorySnapshots?: FileHistorySnapshot[] 279 contentReplacements?: ContentReplacementRecord[] 280 agentName: string | undefined 281 agentColor: AgentColorName | undefined 282 restoredAgentDef: AgentDefinition | undefined 283 initialState: AppState 284} 285 286/** 287 * Subset of the coordinator mode module API needed for session resume. 288 */ 289type CoordinatorModeApi = { 290 matchSessionMode(mode?: string): string | undefined 291 isCoordinatorMode(): boolean 292} 293 294/** 295 * The loaded conversation data (return type of loadConversationForResume). 296 */ 297type ResumeLoadResult = { 298 messages: Message[] 299 fileHistorySnapshots?: FileHistorySnapshot[] 300 attributionSnapshots?: AttributionSnapshotMessage[] 301 contentReplacements?: ContentReplacementRecord[] 302 contextCollapseCommits?: ContextCollapseCommitEntry[] 303 contextCollapseSnapshot?: ContextCollapseSnapshotEntry 304 sessionId: UUID | undefined 305 agentName?: string 306 agentColor?: string 307 agentSetting?: string 308 customTitle?: string 309 tag?: string 310 mode?: 'coordinator' | 'normal' 311 worktreeSession?: PersistedWorktreeSession | null 312 prNumber?: number 313 prUrl?: string 314 prRepository?: string 315} 316 317/** 318 * Restore the worktree working directory on resume. The transcript records 319 * the last worktree enter/exit; if the session crashed while inside a 320 * worktree (last entry = session object, not null), cd back into it. 321 * 322 * process.chdir is the TOCTOU-safe existence check — it throws ENOENT if 323 * the /exit dialog removed the directory, or if the user deleted it 324 * manually between sessions. 325 * 326 * When --worktree already created a fresh worktree, that takes precedence 327 * over the resumed session's state. restoreSessionMetadata just overwrote 328 * project.currentSessionWorktree with the stale transcript value, so 329 * re-assert the fresh worktree here before adoptResumedSessionFile writes 330 * it back to disk. 331 */ 332export function restoreWorktreeForResume( 333 worktreeSession: PersistedWorktreeSession | null | undefined, 334): void { 335 const fresh = getCurrentWorktreeSession() 336 if (fresh) { 337 saveWorktreeState(fresh) 338 return 339 } 340 if (!worktreeSession) return 341 342 try { 343 process.chdir(worktreeSession.worktreePath) 344 } catch { 345 // Directory is gone. Override the stale cache so the next 346 // reAppendSessionMetadata records "exited" instead of re-persisting 347 // a path that no longer exists. 348 saveWorktreeState(null) 349 return 350 } 351 352 setCwd(worktreeSession.worktreePath) 353 setOriginalCwd(getCwd()) 354 // projectRoot is intentionally NOT set here. The transcript doesn't record 355 // whether the worktree was entered via --worktree (which sets projectRoot) 356 // or EnterWorktreeTool (which doesn't). Leaving projectRoot stable matches 357 // EnterWorktreeTool's behavior — skills/history stay anchored to the 358 // original project. 359 restoreWorktreeSession(worktreeSession) 360 // The /resume slash command calls this mid-session after caches have been 361 // populated against the old cwd. Cheap no-ops for the CLI-flag path 362 // (caches aren't populated yet there). 363 clearMemoryFileCaches() 364 clearSystemPromptSections() 365 getPlansDirectory.cache.clear?.() 366} 367 368/** 369 * Undo restoreWorktreeForResume before a mid-session /resume switches to 370 * another session. Without this, /resume from a worktree session to a 371 * non-worktree session leaves the user in the old worktree directory with 372 * currentWorktreeSession still pointing at the prior session. /resume to a 373 * *different* worktree fails entirely — the getCurrentWorktreeSession() 374 * guard above blocks the switch. 375 * 376 * Not needed by CLI --resume/--continue: those run once at startup where 377 * getCurrentWorktreeSession() is only truthy if --worktree was used (fresh 378 * worktree that should take precedence, handled by the re-assert above). 379 */ 380export function exitRestoredWorktree(): void { 381 const current = getCurrentWorktreeSession() 382 if (!current) return 383 384 restoreWorktreeSession(null) 385 // Worktree state changed, so cached prompt sections that reference it are 386 // stale whether or not chdir succeeds below. 387 clearMemoryFileCaches() 388 clearSystemPromptSections() 389 getPlansDirectory.cache.clear?.() 390 391 try { 392 process.chdir(current.originalCwd) 393 } catch { 394 // Original dir is gone (rare). Stay put — restoreWorktreeForResume 395 // will cd into the target worktree next if there is one. 396 return 397 } 398 setCwd(current.originalCwd) 399 setOriginalCwd(getCwd()) 400} 401 402/** 403 * Process a loaded conversation for resume/continue. 404 * 405 * Handles coordinator mode matching, session ID setup, agent restoration, 406 * mode persistence, and initial state computation. Called by both --continue 407 * and --resume paths in main.tsx. 408 */ 409export async function processResumedConversation( 410 result: ResumeLoadResult, 411 opts: { 412 forkSession: boolean 413 sessionIdOverride?: string 414 transcriptPath?: string 415 includeAttribution?: boolean 416 }, 417 context: { 418 modeApi: CoordinatorModeApi | null 419 mainThreadAgentDefinition: AgentDefinition | undefined 420 agentDefinitions: AgentDefinitionsResult 421 currentCwd: string 422 cliAgents: AgentDefinition[] 423 initialState: AppState 424 }, 425): Promise<ProcessedResume> { 426 // Match coordinator/normal mode to the resumed session 427 let modeWarning: string | undefined 428 if (feature('COORDINATOR_MODE')) { 429 modeWarning = context.modeApi?.matchSessionMode(result.mode) 430 if (modeWarning) { 431 result.messages.push(createSystemMessage(modeWarning, 'warning')) 432 } 433 } 434 435 // Reuse the resumed session's ID unless --fork-session is specified 436 if (!opts.forkSession) { 437 const sid = opts.sessionIdOverride ?? result.sessionId 438 if (sid) { 439 // When resuming from a different project directory (git worktrees, 440 // cross-project), transcriptPath points to the actual file; its dirname 441 // is the project dir. Otherwise the session lives in the current project. 442 switchSession( 443 asSessionId(sid), 444 opts.transcriptPath ? dirname(opts.transcriptPath) : null, 445 ) 446 // Rename asciicast recording to match the resumed session ID so 447 // getSessionRecordingPaths() can discover it during /share 448 await renameRecordingForSession() 449 await resetSessionFilePointer() 450 restoreCostStateForSession(sid) 451 } 452 } else if (result.contentReplacements?.length) { 453 // --fork-session keeps the fresh startup session ID. useLogMessages will 454 // copy source messages into the new JSONL via recordTranscript, but 455 // content-replacement entries are a separate entry type only written by 456 // recordContentReplacement (which query.ts calls for newlyReplaced, never 457 // the pre-loaded records). Without this seed, `claude -r {newSessionId}` 458 // finds source tool_use_ids in messages but no matching replacement records 459 // → they're classified as FROZEN → full content sent (cache miss, permanent 460 // overage). insertContentReplacement stamps sessionId = getSessionId() = 461 // the fresh ID, so loadTranscriptFile's keyed lookup will match. 462 await recordContentReplacement(result.contentReplacements) 463 } 464 465 // Restore session metadata so /status shows the saved name and metadata 466 // is re-appended on session exit. Fork doesn't take ownership of the 467 // original session's worktree — a "Remove" on the fork's exit dialog 468 // would delete a worktree the original session still references — so 469 // strip worktreeSession from the fork path so the cache stays unset. 470 restoreSessionMetadata( 471 opts.forkSession ? { ...result, worktreeSession: undefined } : result, 472 ) 473 474 if (!opts.forkSession) { 475 // Cd back into the worktree the session was in when it last exited. 476 // Done after restoreSessionMetadata (which caches the worktree state 477 // from the transcript) so if the directory is gone we can override 478 // the cache before adoptResumedSessionFile writes it. 479 restoreWorktreeForResume(result.worktreeSession) 480 481 // Point sessionFile at the resumed transcript and re-append metadata 482 // now. resetSessionFilePointer above nulled it (so the old fresh-session 483 // path doesn't leak), but that blocks reAppendSessionMetadata — which 484 // bails on null — from running in the exit cleanup handler. For fork, 485 // useLogMessages populates a *new* file via recordTranscript on REPL 486 // mount; the normal lazy-materialize path is correct there. 487 adoptResumedSessionFile() 488 } 489 490 // Restore context-collapse commit log + staged snapshot. The interactive 491 // /resume path goes through restoreSessionStateFromLog (REPL.tsx); CLI 492 // --continue/--resume goes through here instead. Called unconditionally 493 // — see the restoreSessionStateFromLog callsite above for why. 494 if (feature('CONTEXT_COLLAPSE')) { 495 /* eslint-disable @typescript-eslint/no-require-imports */ 496 ;( 497 require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js') 498 ).restoreFromEntries( 499 result.contextCollapseCommits ?? [], 500 result.contextCollapseSnapshot, 501 ) 502 /* eslint-enable @typescript-eslint/no-require-imports */ 503 } 504 505 // Restore agent setting from resumed session 506 const { agentDefinition: restoredAgent, agentType: resumedAgentType } = 507 restoreAgentFromSession( 508 result.agentSetting, 509 context.mainThreadAgentDefinition, 510 context.agentDefinitions, 511 ) 512 513 // Persist the current mode so future resumes know what mode this session was in 514 if (feature('COORDINATOR_MODE')) { 515 saveMode(context.modeApi?.isCoordinatorMode() ? 'coordinator' : 'normal') 516 } 517 518 // Compute initial state before render (per CLAUDE.md guidelines) 519 const restoredAttribution = opts.includeAttribution 520 ? computeRestoredAttributionState(result) 521 : undefined 522 const standaloneAgentContext = computeStandaloneAgentContext( 523 result.agentName, 524 result.agentColor, 525 ) 526 void updateSessionName(result.agentName) 527 const refreshedAgentDefs = await refreshAgentDefinitionsForModeSwitch( 528 !!modeWarning, 529 context.currentCwd, 530 context.cliAgents, 531 context.agentDefinitions, 532 ) 533 534 return { 535 messages: result.messages, 536 fileHistorySnapshots: result.fileHistorySnapshots, 537 contentReplacements: result.contentReplacements, 538 agentName: result.agentName, 539 agentColor: (result.agentColor === 'default' 540 ? undefined 541 : result.agentColor) as AgentColorName | undefined, 542 restoredAgentDef: restoredAgent, 543 initialState: { 544 ...context.initialState, 545 ...(resumedAgentType && { agent: resumedAgentType }), 546 ...(restoredAttribution && { attribution: restoredAttribution }), 547 ...(standaloneAgentContext && { standaloneAgentContext }), 548 agentDefinitions: refreshedAgentDefs, 549 }, 550 } 551}