/ utils / plugins / cacheUtils.ts
cacheUtils.ts
  1  import { readdir, rm, stat, unlink, writeFile } from 'fs/promises'
  2  import { join } from 'path'
  3  import { clearCommandsCache } from '../../commands.js'
  4  import { clearAllOutputStylesCache } from '../../constants/outputStyles.js'
  5  import { clearAgentDefinitionsCache } from '../../tools/AgentTool/loadAgentsDir.js'
  6  import { clearPromptCache } from '../../tools/SkillTool/prompt.js'
  7  import { resetSentSkillNames } from '../attachments.js'
  8  import { logForDebugging } from '../debug.js'
  9  import { getErrnoCode } from '../errors.js'
 10  import { logError } from '../log.js'
 11  import { loadInstalledPluginsFromDisk } from './installedPluginsManager.js'
 12  import { clearPluginAgentCache } from './loadPluginAgents.js'
 13  import { clearPluginCommandCache } from './loadPluginCommands.js'
 14  import {
 15    clearPluginHookCache,
 16    pruneRemovedPluginHooks,
 17  } from './loadPluginHooks.js'
 18  import { clearPluginOutputStyleCache } from './loadPluginOutputStyles.js'
 19  import { clearPluginCache, getPluginCachePath } from './pluginLoader.js'
 20  import { clearPluginOptionsCache } from './pluginOptionsStorage.js'
 21  import { isPluginZipCacheEnabled } from './zipCache.js'
 22  
 23  const ORPHANED_AT_FILENAME = '.orphaned_at'
 24  const CLEANUP_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
 25  
 26  export 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  
 44  export 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   */
 56  export 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   */
 74  export 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  
118  function getOrphanedAtPath(versionPath: string): string {
119    return join(versionPath, ORPHANED_AT_FILENAME)
120  }
121  
122  async 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  
133  function 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  
149  async 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  
179  async 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  
189  async 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  }