fix(hooks): symlink old plugin cache versions instead of deleting them

Stop hooks fail with "Cannot find module" when a plugin is updated
mid-session because session-start.mjs aggressively deletes old cache
directories while running sessions still reference them via the
resolved CLAUDE_PLUGIN_ROOT environment variable.

Changes:
- session-start.mjs: Replace rmSync with symlinkSync for old versions
  (beyond latest 2), so stale CLAUDE_PLUGIN_ROOT paths follow the
  symlink to the current version's scripts
- paths.ts: Increase purgeStalePluginCacheVersions grace period from
  1 hour to 24 hours to avoid premature deletion during long sessions
- Add integration tests for the symlink cleanup behavior
- Update existing purge-stale-cache test staleStats default to match
  the new 24-hour threshold

Closes #970
This commit is contained in:
ZhuoHao Ou
2026-02-24 14:49:44 +08:00
parent a42a02f26b
commit affd960f71
4 changed files with 198 additions and 10 deletions

View File

@@ -6,7 +6,7 @@
* Cross-platform: Windows, macOS, Linux
*/
import { existsSync, readFileSync, readdirSync, rmSync, mkdirSync, writeFileSync } from 'fs';
import { existsSync, readFileSync, readdirSync, rmSync, mkdirSync, writeFileSync, symlinkSync, lstatSync, readlinkSync, unlinkSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { fileURLToPath, pathToFileURL } from 'url';
@@ -414,7 +414,10 @@ ${cleanContent}
}
}
// Cleanup old plugin cache versions (keep latest 2)
// Cleanup old plugin cache versions (keep latest 2, symlink the rest)
// Instead of deleting old versions, replace them with symlinks to the latest.
// This prevents "Cannot find module" errors for sessions started before a
// plugin update whose CLAUDE_PLUGIN_ROOT still points to the old version.
try {
const cacheBase = join(configDir, 'plugins', 'cache', 'omc', 'oh-my-claudecode');
if (existsSync(cacheBase)) {
@@ -422,11 +425,31 @@ ${cleanContent}
.filter(v => /^\d+\.\d+\.\d+/.test(v))
.sort(semverCompare)
.reverse();
const toRemove = versions.slice(2);
for (const version of toRemove) {
try {
rmSync(join(cacheBase, version), { recursive: true, force: true });
} catch {}
if (versions.length > 2) {
const latest = versions[0];
const toSymlink = versions.slice(2);
for (const version of toSymlink) {
try {
const versionPath = join(cacheBase, version);
const stat = lstatSync(versionPath);
if (stat.isSymbolicLink()) {
// Already a symlink — update only if pointing to wrong target
const target = readlinkSync(versionPath);
if (target === latest) continue;
unlinkSync(versionPath);
} else if (stat.isDirectory()) {
rmSync(versionPath, { recursive: true, force: true });
}
// Create relative symlink: e.g. 4.4.1 -> 4.4.3
symlinkSync(latest, versionPath);
} catch {
// If symlink creation fails (e.g. Windows without dev mode),
// leave the old directory in place — safer than deleting it.
}
}
}
}
} catch {}