Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
18 kB · 602 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603import * as fs from 'fs/promises'import { homedir } from 'os'import { join } from 'path'import { logEvent } from '../services/analytics/index.js'import { CACHE_PATHS } from './cachePaths.js'import { logForDebugging } from './debug.js'import { getClaudeConfigHomeDir } from './envUtils.js'import { type FsOperations, getFsImplementation } from './fsOperations.js'import { cleanupOldImageCaches } from './imageStore.js'import * as lockfile from './lockfile.js'import { logError } from './log.js'import { cleanupOldVersions } from './nativeInstaller/index.js'import { cleanupOldPastes } from './pasteStore.js'import { getProjectsDir } from './sessionStorage.js'import { getSettingsWithAllErrors } from './settings/allErrors.js'import { getSettings_DEPRECATED, rawSettingsContainsKey,} from './settings/settings.js'import { TOOL_RESULTS_SUBDIR } from './toolResultStorage.js'import { cleanupStaleAgentWorktrees } from './worktree.js'
const DEFAULT_CLEANUP_PERIOD_DAYS = 30
function getCutoffDate(): Date { const settings = getSettings_DEPRECATED() || {} const cleanupPeriodDays = settings.cleanupPeriodDays ?? DEFAULT_CLEANUP_PERIOD_DAYS const cleanupPeriodMs = cleanupPeriodDays * 24 * 60 * 60 * 1000 return new Date(Date.now() - cleanupPeriodMs)}
export type CleanupResult = { messages: number errors: number}
export function addCleanupResults( a: CleanupResult, b: CleanupResult,): CleanupResult { return { messages: a.messages + b.messages, errors: a.errors + b.errors, }}
export function convertFileNameToDate(filename: string): Date { const isoStr = filename .split('.')[0]! .replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z') return new Date(isoStr)}
async function cleanupOldFilesInDirectory( dirPath: string, cutoffDate: Date, isMessagePath: boolean,): Promise<CleanupResult> { const result: CleanupResult = { messages: 0, errors: 0 }
try { const files = await getFsImplementation().readdir(dirPath)
for (const file of files) { try { // Convert filename format where all ':.' were replaced with '-' const timestamp = convertFileNameToDate(file.name) if (timestamp < cutoffDate) { await getFsImplementation().unlink(join(dirPath, file.name)) // Increment the appropriate counter if (isMessagePath) { result.messages++ } else { result.errors++ } } } catch (error) { // Log but continue processing other files logError(error as Error) } } } catch (error: unknown) { // Ignore if directory doesn't exist if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') { logError(error) } }
return result}
export async function cleanupOldMessageFiles(): Promise<CleanupResult> { const fsImpl = getFsImplementation() const cutoffDate = getCutoffDate() const errorPath = CACHE_PATHS.errors() const baseCachePath = CACHE_PATHS.baseLogs()
// Clean up message and error logs let result = await cleanupOldFilesInDirectory(errorPath, cutoffDate, false)
// Clean up MCP logs try { let dirents try { dirents = await fsImpl.readdir(baseCachePath) } catch { return result }
const mcpLogDirs = dirents .filter( dirent => dirent.isDirectory() && dirent.name.startsWith('mcp-logs-'), ) .map(dirent => join(baseCachePath, dirent.name))
for (const mcpLogDir of mcpLogDirs) { // Clean up files in MCP log directory result = addCleanupResults( result, await cleanupOldFilesInDirectory(mcpLogDir, cutoffDate, true), ) await tryRmdir(mcpLogDir, fsImpl) } } catch (error: unknown) { if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') { logError(error) } }
return result}
async function unlinkIfOld( filePath: string, cutoffDate: Date, fsImpl: FsOperations,): Promise<boolean> { const stats = await fsImpl.stat(filePath) if (stats.mtime < cutoffDate) { await fsImpl.unlink(filePath) return true } return false}
async function tryRmdir(dirPath: string, fsImpl: FsOperations): Promise<void> { try { await fsImpl.rmdir(dirPath) } catch { // not empty / doesn't exist }}
export async function cleanupOldSessionFiles(): Promise<CleanupResult> { const cutoffDate = getCutoffDate() const result: CleanupResult = { messages: 0, errors: 0 } const projectsDir = getProjectsDir() const fsImpl = getFsImplementation()
let projectDirents try { projectDirents = await fsImpl.readdir(projectsDir) } catch { return result }
for (const projectDirent of projectDirents) { if (!projectDirent.isDirectory()) continue const projectDir = join(projectsDir, projectDirent.name)
// Single readdir per project directory — partition into files and session dirs let entries try { entries = await fsImpl.readdir(projectDir) } catch { result.errors++ continue }
for (const entry of entries) { if (entry.isFile()) { if (!entry.name.endsWith('.jsonl') && !entry.name.endsWith('.cast')) { continue } try { if ( await unlinkIfOld(join(projectDir, entry.name), cutoffDate, fsImpl) ) { result.messages++ } } catch { result.errors++ } } else if (entry.isDirectory()) { // Session directory — clean up tool-results/<toolDir>/* beneath it const sessionDir = join(projectDir, entry.name) const toolResultsDir = join(sessionDir, TOOL_RESULTS_SUBDIR) let toolDirs try { toolDirs = await fsImpl.readdir(toolResultsDir) } catch { // No tool-results dir — still try to remove an empty session dir await tryRmdir(sessionDir, fsImpl) continue } for (const toolEntry of toolDirs) { if (toolEntry.isFile()) { try { if ( await unlinkIfOld( join(toolResultsDir, toolEntry.name), cutoffDate, fsImpl, ) ) { result.messages++ } } catch { result.errors++ } } else if (toolEntry.isDirectory()) { const toolDirPath = join(toolResultsDir, toolEntry.name) let toolFiles try { toolFiles = await fsImpl.readdir(toolDirPath) } catch { continue } for (const tf of toolFiles) { if (!tf.isFile()) continue try { if ( await unlinkIfOld( join(toolDirPath, tf.name), cutoffDate, fsImpl, ) ) { result.messages++ } } catch { result.errors++ } } await tryRmdir(toolDirPath, fsImpl) } } await tryRmdir(toolResultsDir, fsImpl) await tryRmdir(sessionDir, fsImpl) } }
await tryRmdir(projectDir, fsImpl) }
return result}
/** * Generic helper for cleaning up old files in a single directory * @param dirPath Path to the directory to clean * @param extension File extension to filter (e.g., '.md', '.jsonl') * @param removeEmptyDir Whether to remove the directory if empty after cleanup */async function cleanupSingleDirectory( dirPath: string, extension: string, removeEmptyDir: boolean = true,): Promise<CleanupResult> { const cutoffDate = getCutoffDate() const result: CleanupResult = { messages: 0, errors: 0 } const fsImpl = getFsImplementation()
let dirents try { dirents = await fsImpl.readdir(dirPath) } catch { return result }
for (const dirent of dirents) { if (!dirent.isFile() || !dirent.name.endsWith(extension)) continue try { if (await unlinkIfOld(join(dirPath, dirent.name), cutoffDate, fsImpl)) { result.messages++ } } catch { result.errors++ } }
if (removeEmptyDir) { await tryRmdir(dirPath, fsImpl) }
return result}
export function cleanupOldPlanFiles(): Promise<CleanupResult> { const plansDir = join(getClaudeConfigHomeDir(), 'plans') return cleanupSingleDirectory(plansDir, '.md')}
export async function cleanupOldFileHistoryBackups(): Promise<CleanupResult> { const cutoffDate = getCutoffDate() const result: CleanupResult = { messages: 0, errors: 0 } const fsImpl = getFsImplementation()
try { const configDir = getClaudeConfigHomeDir() const fileHistoryStorageDir = join(configDir, 'file-history')
let dirents try { dirents = await fsImpl.readdir(fileHistoryStorageDir) } catch { return result }
const fileHistorySessionsDirs = dirents .filter(dirent => dirent.isDirectory()) .map(dirent => join(fileHistoryStorageDir, dirent.name))
await Promise.all( fileHistorySessionsDirs.map(async fileHistorySessionDir => { try { const stats = await fsImpl.stat(fileHistorySessionDir) if (stats.mtime < cutoffDate) { await fsImpl.rm(fileHistorySessionDir, { recursive: true, force: true, }) result.messages++ } } catch { result.errors++ } }), )
await tryRmdir(fileHistoryStorageDir, fsImpl) } catch (error) { logError(error as Error) }
return result}
export async function cleanupOldSessionEnvDirs(): Promise<CleanupResult> { const cutoffDate = getCutoffDate() const result: CleanupResult = { messages: 0, errors: 0 } const fsImpl = getFsImplementation()
try { const configDir = getClaudeConfigHomeDir() const sessionEnvBaseDir = join(configDir, 'session-env')
let dirents try { dirents = await fsImpl.readdir(sessionEnvBaseDir) } catch { return result }
const sessionEnvDirs = dirents .filter(dirent => dirent.isDirectory()) .map(dirent => join(sessionEnvBaseDir, dirent.name))
for (const sessionEnvDir of sessionEnvDirs) { try { const stats = await fsImpl.stat(sessionEnvDir) if (stats.mtime < cutoffDate) { await fsImpl.rm(sessionEnvDir, { recursive: true, force: true }) result.messages++ } } catch { result.errors++ } }
await tryRmdir(sessionEnvBaseDir, fsImpl) } catch (error) { logError(error as Error) }
return result}
/** * Cleans up old debug log files from ~/.claude/debug/ * Preserves the 'latest' symlink which points to the current session's log. * Debug logs can grow very large (especially with the infinite logging loop bug) * and accumulate indefinitely without this cleanup. */export async function cleanupOldDebugLogs(): Promise<CleanupResult> { const cutoffDate = getCutoffDate() const result: CleanupResult = { messages: 0, errors: 0 } const fsImpl = getFsImplementation() const debugDir = join(getClaudeConfigHomeDir(), 'debug')
let dirents try { dirents = await fsImpl.readdir(debugDir) } catch { return result }
for (const dirent of dirents) { // Preserve the 'latest' symlink if ( !dirent.isFile() || !dirent.name.endsWith('.txt') || dirent.name === 'latest' ) { continue } try { if (await unlinkIfOld(join(debugDir, dirent.name), cutoffDate, fsImpl)) { result.messages++ } } catch { result.errors++ } }
// Intentionally do NOT remove debugDir even if empty — needed for future logs return result}
const ONE_DAY_MS = 24 * 60 * 60 * 1000
/** * Clean up old npm cache entries for Anthropic packages. * This helps reduce disk usage since we publish many dev versions per day. * Only runs once per day for Ant users. */export async function cleanupNpmCacheForAnthropicPackages(): Promise<void> { const markerPath = join(getClaudeConfigHomeDir(), '.npm-cache-cleanup')
try { const stat = await fs.stat(markerPath) if (Date.now() - stat.mtimeMs < ONE_DAY_MS) { logForDebugging('npm cache cleanup: skipping, ran recently') return } } catch { // File doesn't exist, proceed with cleanup }
try { await lockfile.lock(markerPath, { retries: 0, realpath: false }) } catch { logForDebugging('npm cache cleanup: skipping, lock held') return }
logForDebugging('npm cache cleanup: starting')
const npmCachePath = join(homedir(), '.npm', '_cacache')
const NPM_CACHE_RETENTION_COUNT = 5
const startTime = Date.now() try { const cacache = await import('cacache') const cutoff = startTime - ONE_DAY_MS
// Stream index entries and collect all Anthropic package entries. // Previous implementation used cacache.verify() which does a full // integrity check + GC of the ENTIRE cache — O(all content blobs). // On large caches this took 60+ seconds and blocked the event loop. const stream = cacache.ls.stream(npmCachePath) const anthropicEntries: { key: string; time: number }[] = [] for await (const entry of stream as AsyncIterable<{ key: string time: number }>) { if (entry.key.includes('@anthropic-ai/claude-')) { anthropicEntries.push({ key: entry.key, time: entry.time }) } }
// Group by package name (everything before the last @version separator) const byPackage = new Map<string, { key: string; time: number }[]>() for (const entry of anthropicEntries) { const atVersionIdx = entry.key.lastIndexOf('@') const pkgName = atVersionIdx > 0 ? entry.key.slice(0, atVersionIdx) : entry.key const existing = byPackage.get(pkgName) ?? [] existing.push(entry) byPackage.set(pkgName, existing) }
// Remove entries older than 1 day OR beyond the top N most recent per package const keysToRemove: string[] = [] for (const [, entries] of byPackage) { entries.sort((a, b) => b.time - a.time) // newest first for (let i = 0; i < entries.length; i++) { const entry = entries[i]! if (entry.time < cutoff || i >= NPM_CACHE_RETENTION_COUNT) { keysToRemove.push(entry.key) } } }
await Promise.all( keysToRemove.map(key => cacache.rm.entry(npmCachePath, key)), )
await fs.writeFile(markerPath, new Date().toISOString())
const durationMs = Date.now() - startTime if (keysToRemove.length > 0) { logForDebugging( `npm cache cleanup: Removed ${keysToRemove.length} old @anthropic-ai entries in ${durationMs}ms`, ) } else { logForDebugging(`npm cache cleanup: completed in ${durationMs}ms`) } logEvent('tengu_npm_cache_cleanup', { success: true, durationMs, entriesRemoved: keysToRemove.length, }) } catch (error) { logError(error as Error) logEvent('tengu_npm_cache_cleanup', { success: false, durationMs: Date.now() - startTime, }) } finally { await lockfile.unlock(markerPath, { realpath: false }).catch(() => {}) }}
/** * Throttled wrapper around cleanupOldVersions for recurring cleanup in long-running sessions. * Uses a marker file and lock to ensure it runs at most once per 24 hours, * and does not block if another process is already running cleanup. * The regular cleanupOldVersions() should still be used for installer flows. */export async function cleanupOldVersionsThrottled(): Promise<void> { const markerPath = join(getClaudeConfigHomeDir(), '.version-cleanup')
try { const stat = await fs.stat(markerPath) if (Date.now() - stat.mtimeMs < ONE_DAY_MS) { logForDebugging('version cleanup: skipping, ran recently') return } } catch { // File doesn't exist, proceed with cleanup }
try { await lockfile.lock(markerPath, { retries: 0, realpath: false }) } catch { logForDebugging('version cleanup: skipping, lock held') return }
logForDebugging('version cleanup: starting (throttled)')
try { await cleanupOldVersions() await fs.writeFile(markerPath, new Date().toISOString()) } catch (error) { logError(error as Error) } finally { await lockfile.unlock(markerPath, { realpath: false }).catch(() => {}) }}
export async function cleanupOldMessageFilesInBackground(): Promise<void> { // If settings have validation errors but the user explicitly set cleanupPeriodDays, // skip cleanup entirely rather than falling back to the default (30 days). // This prevents accidentally deleting files when the user intended a different retention period. const { errors } = getSettingsWithAllErrors() if (errors.length > 0 && rawSettingsContainsKey('cleanupPeriodDays')) { logForDebugging( 'Skipping cleanup: settings have validation errors but cleanupPeriodDays was explicitly set. Fix settings errors to enable cleanup.', ) return }
await cleanupOldMessageFiles() await cleanupOldSessionFiles() await cleanupOldPlanFiles() await cleanupOldFileHistoryBackups() await cleanupOldSessionEnvDirs() await cleanupOldDebugLogs() await cleanupOldImageCaches() await cleanupOldPastes(getCutoffDate()) const removedWorktrees = await cleanupStaleAgentWorktrees(getCutoffDate()) if (removedWorktrees > 0) { logEvent('tengu_worktree_cleanup', { removed: removedWorktrees }) } if (process.env.USER_TYPE === 'ant') { await cleanupNpmCacheForAnthropicPackages() }}