source dump of claude code
at main 196 lines 6.7 kB view raw
1import { readdir, rm, stat, unlink, writeFile } from 'fs/promises' 2import { join } from 'path' 3import { clearCommandsCache } from '../../commands.js' 4import { clearAllOutputStylesCache } from '../../constants/outputStyles.js' 5import { clearAgentDefinitionsCache } from '../../tools/AgentTool/loadAgentsDir.js' 6import { clearPromptCache } from '../../tools/SkillTool/prompt.js' 7import { resetSentSkillNames } from '../attachments.js' 8import { logForDebugging } from '../debug.js' 9import { getErrnoCode } from '../errors.js' 10import { logError } from '../log.js' 11import { loadInstalledPluginsFromDisk } from './installedPluginsManager.js' 12import { clearPluginAgentCache } from './loadPluginAgents.js' 13import { clearPluginCommandCache } from './loadPluginCommands.js' 14import { 15 clearPluginHookCache, 16 pruneRemovedPluginHooks, 17} from './loadPluginHooks.js' 18import { clearPluginOutputStyleCache } from './loadPluginOutputStyles.js' 19import { clearPluginCache, getPluginCachePath } from './pluginLoader.js' 20import { clearPluginOptionsCache } from './pluginOptionsStorage.js' 21import { isPluginZipCacheEnabled } from './zipCache.js' 22 23const ORPHANED_AT_FILENAME = '.orphaned_at' 24const CLEANUP_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days 25 26export function clearAllPluginCaches(): void { 27 clearPluginCache() 28 clearPluginCommandCache() 29 clearPluginAgentCache() 30 clearPluginHookCache() 31 // Prune hooks from plugins no longer in the enabled set so uninstalled/ 32 // disabled plugins stop firing immediately (gh-36995). Prune-only: hooks 33 // from newly-enabled plugins are NOT added here — they wait for 34 // /reload-plugins like commands/agents/MCP do. Fire-and-forget: old hooks 35 // stay valid until the prune completes (preserves gh-29767). No-op when 36 // STATE.registeredHooks is empty (test/preload.ts beforeEach clears it via 37 // resetStateForTests before reaching here). 38 pruneRemovedPluginHooks().catch(e => logError(e)) 39 clearPluginOptionsCache() 40 clearPluginOutputStyleCache() 41 clearAllOutputStylesCache() 42} 43 44export function clearAllCaches(): void { 45 clearAllPluginCaches() 46 clearCommandsCache() 47 clearAgentDefinitionsCache() 48 clearPromptCache() 49 resetSentSkillNames() 50} 51 52/** 53 * Mark a plugin version as orphaned. 54 * Called when a plugin is uninstalled or updated to a new version. 55 */ 56export async function markPluginVersionOrphaned( 57 versionPath: string, 58): Promise<void> { 59 try { 60 await writeFile(getOrphanedAtPath(versionPath), `${Date.now()}`, 'utf-8') 61 } catch (error) { 62 logForDebugging(`Failed to write .orphaned_at: ${versionPath}: ${error}`) 63 } 64} 65 66/** 67 * Clean up orphaned plugin versions that have been orphaned for more than 7 days. 68 * 69 * Pass 1: Remove .orphaned_at from installed versions (clears stale markers) 70 * Pass 2: For each cached version not in installed_plugins.json: 71 * - If no .orphaned_at exists: create it (handles old CC versions, manual edits) 72 * - If .orphaned_at exists and > 7 days old: delete the version 73 */ 74export async function cleanupOrphanedPluginVersionsInBackground(): Promise<void> { 75 // Zip cache mode stores plugins as .zip files, not directories. readSubdirs 76 // filters to directories only, so removeIfEmpty would see plugin dirs as empty 77 // and delete them (including the ZIPs). Skip cleanup entirely in zip mode. 78 if (isPluginZipCacheEnabled()) { 79 return 80 } 81 try { 82 const installedVersions = getInstalledVersionPaths() 83 if (!installedVersions) return 84 85 const cachePath = getPluginCachePath() 86 87 const now = Date.now() 88 89 // Pass 1: Remove .orphaned_at from installed versions 90 // This handles cases where a plugin was reinstalled after being orphaned 91 await Promise.all( 92 [...installedVersions].map(p => removeOrphanedAtMarker(p)), 93 ) 94 95 // Pass 2: Process orphaned versions 96 for (const marketplace of await readSubdirs(cachePath)) { 97 const marketplacePath = join(cachePath, marketplace) 98 99 for (const plugin of await readSubdirs(marketplacePath)) { 100 const pluginPath = join(marketplacePath, plugin) 101 102 for (const version of await readSubdirs(pluginPath)) { 103 const versionPath = join(pluginPath, version) 104 if (installedVersions.has(versionPath)) continue 105 await processOrphanedPluginVersion(versionPath, now) 106 } 107 108 await removeIfEmpty(pluginPath) 109 } 110 111 await removeIfEmpty(marketplacePath) 112 } 113 } catch (error) { 114 logForDebugging(`Plugin cache cleanup failed: ${error}`) 115 } 116} 117 118function getOrphanedAtPath(versionPath: string): string { 119 return join(versionPath, ORPHANED_AT_FILENAME) 120} 121 122async function removeOrphanedAtMarker(versionPath: string): Promise<void> { 123 const orphanedAtPath = getOrphanedAtPath(versionPath) 124 try { 125 await unlink(orphanedAtPath) 126 } catch (error) { 127 const code = getErrnoCode(error) 128 if (code === 'ENOENT') return 129 logForDebugging(`Failed to remove .orphaned_at: ${versionPath}: ${error}`) 130 } 131} 132 133function getInstalledVersionPaths(): Set<string> | null { 134 try { 135 const paths = new Set<string>() 136 const diskData = loadInstalledPluginsFromDisk() 137 for (const installations of Object.values(diskData.plugins)) { 138 for (const entry of installations) { 139 paths.add(entry.installPath) 140 } 141 } 142 return paths 143 } catch (error) { 144 logForDebugging(`Failed to load installed plugins: ${error}`) 145 return null 146 } 147} 148 149async function processOrphanedPluginVersion( 150 versionPath: string, 151 now: number, 152): Promise<void> { 153 const orphanedAtPath = getOrphanedAtPath(versionPath) 154 155 let orphanedAt: number 156 try { 157 orphanedAt = (await stat(orphanedAtPath)).mtimeMs 158 } catch (error) { 159 const code = getErrnoCode(error) 160 if (code === 'ENOENT') { 161 await markPluginVersionOrphaned(versionPath) 162 return 163 } 164 logForDebugging(`Failed to stat orphaned marker: ${versionPath}: ${error}`) 165 return 166 } 167 168 if (now - orphanedAt > CLEANUP_AGE_MS) { 169 try { 170 await rm(versionPath, { recursive: true, force: true }) 171 } catch (error) { 172 logForDebugging( 173 `Failed to delete orphaned version: ${versionPath}: ${error}`, 174 ) 175 } 176 } 177} 178 179async function removeIfEmpty(dirPath: string): Promise<void> { 180 if ((await readSubdirs(dirPath)).length === 0) { 181 try { 182 await rm(dirPath, { recursive: true, force: true }) 183 } catch (error) { 184 logForDebugging(`Failed to remove empty dir: ${dirPath}: ${error}`) 185 } 186 } 187} 188 189async function readSubdirs(dirPath: string): Promise<string[]> { 190 try { 191 const entries = await readdir(dirPath, { withFileTypes: true }) 192 return entries.filter(d => d.isDirectory()).map(d => d.name) 193 } catch { 194 return [] 195 } 196}