source dump of claude code
at main 397 lines 12 kB view raw
1import { randomUUID } from 'crypto' 2import { copyFile, writeFile } from 'fs/promises' 3import memoize from 'lodash-es/memoize.js' 4import { join, resolve, sep } from 'path' 5import type { AgentId, SessionId } from 'src/types/ids.js' 6import type { LogOption } from 'src/types/logs.js' 7import type { 8 AssistantMessage, 9 AttachmentMessage, 10 SystemFileSnapshotMessage, 11 UserMessage, 12} from 'src/types/message.js' 13import { getPlanSlugCache, getSessionId } from '../bootstrap/state.js' 14import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../tools/ExitPlanModeTool/constants.js' 15import { getCwd } from './cwd.js' 16import { logForDebugging } from './debug.js' 17import { getClaudeConfigHomeDir } from './envUtils.js' 18import { isENOENT } from './errors.js' 19import { getEnvironmentKind } from './filePersistence/outputsScanner.js' 20import { getFsImplementation } from './fsOperations.js' 21import { logError } from './log.js' 22import { getInitialSettings } from './settings/settings.js' 23import { generateWordSlug } from './words.js' 24 25const MAX_SLUG_RETRIES = 10 26 27/** 28 * Get or generate a word slug for the current session's plan. 29 * The slug is generated lazily on first access and cached for the session. 30 * If a plan file with the generated slug already exists, retries up to 10 times. 31 */ 32export function getPlanSlug(sessionId?: SessionId): string { 33 const id = sessionId ?? getSessionId() 34 const cache = getPlanSlugCache() 35 let slug = cache.get(id) 36 if (!slug) { 37 const plansDir = getPlansDirectory() 38 // Try to find a unique slug that doesn't conflict with existing files 39 for (let i = 0; i < MAX_SLUG_RETRIES; i++) { 40 slug = generateWordSlug() 41 const filePath = join(plansDir, `${slug}.md`) 42 if (!getFsImplementation().existsSync(filePath)) { 43 break 44 } 45 } 46 cache.set(id, slug!) 47 } 48 return slug! 49} 50 51/** 52 * Set a specific plan slug for a session (used when resuming a session) 53 */ 54export function setPlanSlug(sessionId: SessionId, slug: string): void { 55 getPlanSlugCache().set(sessionId, slug) 56} 57 58/** 59 * Clear the plan slug for the current session. 60 * This should be called on /clear to ensure a fresh plan file is used. 61 */ 62export function clearPlanSlug(sessionId?: SessionId): void { 63 const id = sessionId ?? getSessionId() 64 getPlanSlugCache().delete(id) 65} 66 67/** 68 * Clear ALL plan slug entries (all sessions). 69 * Use this on /clear to free sub-session slug entries. 70 */ 71export function clearAllPlanSlugs(): void { 72 getPlanSlugCache().clear() 73} 74 75// Memoized: called from render bodies (FileReadTool/FileEditTool/FileWriteTool UI.tsx) 76// and permission checks. Inputs (initial settings + cwd) are fixed at startup, so the 77// mkdirSync result is stable for the session. Without memoization, each rendered tool 78// message triggers a mkdirSync syscall (regressed in #20005). 79export const getPlansDirectory = memoize(function getPlansDirectory(): string { 80 const settings = getInitialSettings() 81 const settingsDir = settings.plansDirectory 82 let plansPath: string 83 84 if (settingsDir) { 85 // Settings.json (relative to project root) 86 const cwd = getCwd() 87 const resolved = resolve(cwd, settingsDir) 88 89 // Validate path stays within project root to prevent path traversal 90 if (!resolved.startsWith(cwd + sep) && resolved !== cwd) { 91 logError( 92 new Error(`plansDirectory must be within project root: ${settingsDir}`), 93 ) 94 plansPath = join(getClaudeConfigHomeDir(), 'plans') 95 } else { 96 plansPath = resolved 97 } 98 } else { 99 // Default 100 plansPath = join(getClaudeConfigHomeDir(), 'plans') 101 } 102 103 // Ensure directory exists (mkdirSync with recursive: true is a no-op if it exists) 104 try { 105 getFsImplementation().mkdirSync(plansPath) 106 } catch (error) { 107 logError(error) 108 } 109 110 return plansPath 111}) 112 113/** 114 * Get the file path for a session's plan 115 * @param agentId Optional agent ID for subagents. If not provided, returns main session plan. 116 * For main conversation (no agentId), returns {planSlug}.md 117 * For subagents (agentId provided), returns {planSlug}-agent-{agentId}.md 118 */ 119export function getPlanFilePath(agentId?: AgentId): string { 120 const planSlug = getPlanSlug(getSessionId()) 121 122 // Main conversation: simple filename with word slug 123 if (!agentId) { 124 return join(getPlansDirectory(), `${planSlug}.md`) 125 } 126 127 // Subagents: include agent ID 128 return join(getPlansDirectory(), `${planSlug}-agent-${agentId}.md`) 129} 130 131/** 132 * Get the plan content for a session 133 * @param agentId Optional agent ID for subagents. If not provided, returns main session plan. 134 */ 135export function getPlan(agentId?: AgentId): string | null { 136 const filePath = getPlanFilePath(agentId) 137 try { 138 return getFsImplementation().readFileSync(filePath, { encoding: 'utf-8' }) 139 } catch (error) { 140 if (isENOENT(error)) return null 141 logError(error) 142 return null 143 } 144} 145 146/** 147 * Extract the plan slug from a log's message history. 148 */ 149function getSlugFromLog(log: LogOption): string | undefined { 150 return log.messages.find(m => m.slug)?.slug 151} 152 153/** 154 * Restore plan slug from a resumed session. 155 * Sets the slug in the session cache so getPlanSlug returns it. 156 * If the plan file is missing, attempts to recover it from a file snapshot 157 * (written incrementally during the session) or from message history. 158 * Returns true if a plan file exists (or was recovered) for the slug. 159 * @param log The log to restore from 160 * @param targetSessionId The session ID to associate the plan slug with. 161 * This should be the ORIGINAL session ID being resumed, 162 * not the temporary session ID from before resume. 163 */ 164export async function copyPlanForResume( 165 log: LogOption, 166 targetSessionId?: SessionId, 167): Promise<boolean> { 168 const slug = getSlugFromLog(log) 169 if (!slug) { 170 return false 171 } 172 173 // Set the slug for the target session ID (or current if not provided) 174 const sessionId = targetSessionId ?? getSessionId() 175 setPlanSlug(sessionId, slug) 176 177 // Attempt to read the plan file directly — recovery triggers on ENOENT. 178 const planPath = join(getPlansDirectory(), `${slug}.md`) 179 try { 180 await getFsImplementation().readFile(planPath, { encoding: 'utf-8' }) 181 return true 182 } catch (e: unknown) { 183 if (!isENOENT(e)) { 184 // Don't throw — called fire-and-forget (void copyPlanForResume(...)) with no .catch() 185 logError(e) 186 return false 187 } 188 // Only attempt recovery in remote sessions (CCR) where files don't persist 189 if (getEnvironmentKind() === null) { 190 return false 191 } 192 193 logForDebugging( 194 `Plan file missing during resume: ${planPath}. Attempting recovery.`, 195 ) 196 197 // Try file snapshot first (written incrementally during session) 198 const snapshotPlan = findFileSnapshotEntry(log.messages, 'plan') 199 let recovered: string | null = null 200 if (snapshotPlan && snapshotPlan.content.length > 0) { 201 recovered = snapshotPlan.content 202 logForDebugging( 203 `Plan recovered from file snapshot, ${recovered.length} chars`, 204 { level: 'info' }, 205 ) 206 } else { 207 // Fall back to searching message history 208 recovered = recoverPlanFromMessages(log) 209 if (recovered) { 210 logForDebugging( 211 `Plan recovered from message history, ${recovered.length} chars`, 212 { level: 'info' }, 213 ) 214 } 215 } 216 217 if (recovered) { 218 try { 219 await writeFile(planPath, recovered, { encoding: 'utf-8' }) 220 return true 221 } catch (writeError) { 222 logError(writeError) 223 return false 224 } 225 } 226 logForDebugging( 227 'Plan file recovery failed: no file snapshot or plan content found in message history', 228 ) 229 return false 230 } 231} 232 233/** 234 * Copy a plan file for a forked session. Unlike copyPlanForResume (which reuses 235 * the original slug), this generates a NEW slug for the forked session and 236 * writes the original plan content to the new file. This prevents the original 237 * and forked sessions from clobbering each other's plan files. 238 */ 239export async function copyPlanForFork( 240 log: LogOption, 241 targetSessionId: SessionId, 242): Promise<boolean> { 243 const originalSlug = getSlugFromLog(log) 244 if (!originalSlug) { 245 return false 246 } 247 248 const plansDir = getPlansDirectory() 249 const originalPlanPath = join(plansDir, `${originalSlug}.md`) 250 251 // Generate a new slug for the forked session (do NOT reuse the original) 252 const newSlug = getPlanSlug(targetSessionId) 253 const newPlanPath = join(plansDir, `${newSlug}.md`) 254 try { 255 await copyFile(originalPlanPath, newPlanPath) 256 return true 257 } catch (error) { 258 if (isENOENT(error)) { 259 return false 260 } 261 logError(error) 262 return false 263 } 264} 265 266/** 267 * Recover plan content from the message history. Plan content can appear in 268 * three forms depending on what happened during the session: 269 * 270 * 1. ExitPlanMode tool_use input — normalizeToolInput injects the plan content 271 * into the tool_use input, which persists in the transcript. 272 * 273 * 2. planContent field on user messages — set during the "clear context and 274 * implement" flow when ExitPlanMode is approved. 275 * 276 * 3. plan_file_reference attachment — created by auto-compact to preserve the 277 * plan across compaction boundaries. 278 */ 279function recoverPlanFromMessages(log: LogOption): string | null { 280 for (let i = log.messages.length - 1; i >= 0; i--) { 281 const msg = log.messages[i] 282 if (!msg) { 283 continue 284 } 285 286 if (msg.type === 'assistant') { 287 const { content } = (msg as AssistantMessage).message 288 if (Array.isArray(content)) { 289 for (const block of content) { 290 if ( 291 block.type === 'tool_use' && 292 block.name === EXIT_PLAN_MODE_V2_TOOL_NAME 293 ) { 294 const input = block.input as Record<string, unknown> | undefined 295 const plan = input?.plan 296 if (typeof plan === 'string' && plan.length > 0) { 297 return plan 298 } 299 } 300 } 301 } 302 } 303 304 if (msg.type === 'user') { 305 const userMsg = msg as UserMessage 306 if ( 307 typeof userMsg.planContent === 'string' && 308 userMsg.planContent.length > 0 309 ) { 310 return userMsg.planContent 311 } 312 } 313 314 if (msg.type === 'attachment') { 315 const attachmentMsg = msg as AttachmentMessage 316 if (attachmentMsg.attachment?.type === 'plan_file_reference') { 317 const plan = (attachmentMsg.attachment as { planContent?: string }) 318 .planContent 319 if (typeof plan === 'string' && plan.length > 0) { 320 return plan 321 } 322 } 323 } 324 } 325 return null 326} 327 328/** 329 * Find a file entry in the most recent file-snapshot system message in the transcript. 330 * Scans backwards to find the latest snapshot. 331 */ 332function findFileSnapshotEntry( 333 messages: LogOption['messages'], 334 key: string, 335): { key: string; path: string; content: string } | undefined { 336 for (let i = messages.length - 1; i >= 0; i--) { 337 const msg = messages[i] 338 if ( 339 msg?.type === 'system' && 340 'subtype' in msg && 341 msg.subtype === 'file_snapshot' && 342 'snapshotFiles' in msg 343 ) { 344 const files = msg.snapshotFiles as Array<{ 345 key: string 346 path: string 347 content: string 348 }> 349 return files.find(f => f.key === key) 350 } 351 } 352 return undefined 353} 354 355/** 356 * Persist a snapshot of session files (plan, todos) to the transcript. 357 * Called incrementally whenever these files change. Only active in remote 358 * sessions (CCR) where local files don't persist between sessions. 359 */ 360export async function persistFileSnapshotIfRemote(): Promise<void> { 361 if (getEnvironmentKind() === null) { 362 return 363 } 364 try { 365 const snapshotFiles: SystemFileSnapshotMessage['snapshotFiles'] = [] 366 367 // Snapshot plan file 368 const plan = getPlan() 369 if (plan) { 370 snapshotFiles.push({ 371 key: 'plan', 372 path: getPlanFilePath(), 373 content: plan, 374 }) 375 } 376 377 if (snapshotFiles.length === 0) { 378 return 379 } 380 381 const message: SystemFileSnapshotMessage = { 382 type: 'system', 383 subtype: 'file_snapshot', 384 content: 'File snapshot', 385 level: 'info', 386 isMeta: true, 387 timestamp: new Date().toISOString(), 388 uuid: randomUUID(), 389 snapshotFiles, 390 } 391 392 const { recordTranscript } = await import('./sessionStorage.js') 393 await recordTranscript([message]) 394 } catch (error) { 395 logError(error) 396 } 397}