/ src / utils / plans.ts
plans.ts
  1  import { randomUUID } from 'crypto'
  2  import { copyFile, writeFile } from 'fs/promises'
  3  import memoize from 'lodash-es/memoize.js'
  4  import { join, resolve, sep } from 'path'
  5  import type { AgentId, SessionId } from 'src/types/ids.js'
  6  import type { LogOption } from 'src/types/logs.js'
  7  import type {
  8    AssistantMessage,
  9    AttachmentMessage,
 10    SystemFileSnapshotMessage,
 11    UserMessage,
 12  } from 'src/types/message.js'
 13  import { getPlanSlugCache, getSessionId } from '../bootstrap/state.js'
 14  import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../tools/ExitPlanModeTool/constants.js'
 15  import { getCwd } from './cwd.js'
 16  import { logForDebugging } from './debug.js'
 17  import { getClaudeConfigHomeDir } from './envUtils.js'
 18  import { isENOENT } from './errors.js'
 19  import { getEnvironmentKind } from './filePersistence/outputsScanner.js'
 20  import { getFsImplementation } from './fsOperations.js'
 21  import { logError } from './log.js'
 22  import { getInitialSettings } from './settings/settings.js'
 23  import { generateWordSlug } from './words.js'
 24  
 25  const 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   */
 32  export 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   */
 54  export 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   */
 62  export 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   */
 71  export 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).
 79  export 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   */
119  export 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   */
135  export 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   */
149  function 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   */
164  export 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   */
239  export 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   */
279  function 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   */
332  function 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   */
360  export 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  }