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 {}

View File

@@ -32,7 +32,7 @@ function dirent(name: string): { name: string; isDirectory: () => boolean } {
}
/** Return a stat result with mtime N ms ago */
function staleStats(ageMs: number = 2 * 60 * 60 * 1000) {
function staleStats(ageMs: number = 25 * 60 * 60 * 1000) {
return { mtimeMs: Date.now() - ageMs } as ReturnType<typeof statSync>;
}

View File

@@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, lstatSync, readlinkSync, readdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
const SCRIPT_PATH = join(__dirname, '..', '..', 'scripts', 'session-start.mjs');
const NODE = process.execPath;
/**
* Integration tests for the plugin cache cleanup logic in session-start.mjs.
*
* The script's cleanup block scans ~/.claude/plugins/cache/omc/oh-my-claudecode/
* for version directories, keeps the latest 2 real directories, and replaces
* older versions with symlinks pointing to the latest version. This prevents
* "Cannot find module" errors when a running session's CLAUDE_PLUGIN_ROOT
* still points to an old (now-removed) version directory.
*/
describe('session-start.mjs — plugin cache cleanup uses symlinks', () => {
let tmpDir: string;
let fakeHome: string;
let fakeCacheBase: string;
let fakeProject: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'omc-cache-test-'));
fakeHome = join(tmpDir, 'home');
fakeCacheBase = join(fakeHome, '.claude', 'plugins', 'cache', 'omc', 'oh-my-claudecode');
fakeProject = join(tmpDir, 'project');
// Create fake project directory with .omc
mkdirSync(join(fakeProject, '.omc', 'state'), { recursive: true });
// Create fake cache base
mkdirSync(fakeCacheBase, { recursive: true });
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
function createFakeVersion(version: string) {
const versionDir = join(fakeCacheBase, version);
mkdirSync(join(versionDir, 'scripts'), { recursive: true });
writeFileSync(join(versionDir, 'scripts', 'run.cjs'), '// stub');
writeFileSync(join(versionDir, 'scripts', 'session-start.mjs'), '// stub');
return versionDir;
}
function runSessionStart(env: Record<string, string> = {}) {
// We can't easily run the full session-start.mjs because it reads stdin
// and relies on many env vars. Instead, we test the cleanup logic by
// providing the minimal input it needs.
try {
const result = execFileSync(NODE, [SCRIPT_PATH], {
input: JSON.stringify({
hook_event_name: 'SessionStart',
session_id: 'test-session',
cwd: fakeProject,
}),
encoding: 'utf-8',
env: {
...process.env,
HOME: fakeHome,
USERPROFILE: fakeHome, // Windows compat
CLAUDE_PLUGIN_ROOT: join(fakeCacheBase, '4.4.3'),
...env,
},
timeout: 15000,
});
return result.trim();
} catch (err: any) {
// The script may exit with non-zero but we still want its stdout
return err.stdout?.trim() || '';
}
}
it('replaces old versions (beyond latest 2) with symlinks to the latest', () => {
createFakeVersion('4.4.1');
createFakeVersion('4.4.2');
createFakeVersion('4.4.3');
runSessionStart();
// 4.4.3 (latest) and 4.4.2 (2nd latest) should remain as real directories
const v3Stat = lstatSync(join(fakeCacheBase, '4.4.3'));
expect(v3Stat.isDirectory()).toBe(true);
expect(v3Stat.isSymbolicLink()).toBe(false);
const v2Stat = lstatSync(join(fakeCacheBase, '4.4.2'));
expect(v2Stat.isDirectory()).toBe(true);
expect(v2Stat.isSymbolicLink()).toBe(false);
// 4.4.1 (oldest) should be a symlink to 4.4.3
const v1Stat = lstatSync(join(fakeCacheBase, '4.4.1'));
expect(v1Stat.isSymbolicLink()).toBe(true);
const target = readlinkSync(join(fakeCacheBase, '4.4.1'));
expect(target).toBe('4.4.3');
});
it('with only 2 versions, no symlinks are created', () => {
createFakeVersion('4.4.2');
createFakeVersion('4.4.3');
runSessionStart();
// Both should remain as real directories
const v3Stat = lstatSync(join(fakeCacheBase, '4.4.3'));
expect(v3Stat.isDirectory()).toBe(true);
expect(v3Stat.isSymbolicLink()).toBe(false);
const v2Stat = lstatSync(join(fakeCacheBase, '4.4.2'));
expect(v2Stat.isDirectory()).toBe(true);
expect(v2Stat.isSymbolicLink()).toBe(false);
});
it('symlinked old version still resolves scripts correctly', () => {
createFakeVersion('4.4.1');
createFakeVersion('4.4.2');
createFakeVersion('4.4.3');
runSessionStart();
// Verify that accessing a script through the symlinked old version works
const scriptPath = join(fakeCacheBase, '4.4.1', 'scripts', 'run.cjs');
expect(existsSync(scriptPath)).toBe(true);
});
it('handles 4+ versions, symlinking all but latest 2', () => {
createFakeVersion('4.4.0');
createFakeVersion('4.4.1');
createFakeVersion('4.4.2');
createFakeVersion('4.4.3');
runSessionStart();
// 4.4.3 and 4.4.2: real directories
expect(lstatSync(join(fakeCacheBase, '4.4.3')).isSymbolicLink()).toBe(false);
expect(lstatSync(join(fakeCacheBase, '4.4.2')).isSymbolicLink()).toBe(false);
// 4.4.1 and 4.4.0: symlinks to 4.4.3
expect(lstatSync(join(fakeCacheBase, '4.4.1')).isSymbolicLink()).toBe(true);
expect(readlinkSync(join(fakeCacheBase, '4.4.1'))).toBe('4.4.3');
expect(lstatSync(join(fakeCacheBase, '4.4.0')).isSymbolicLink()).toBe(true);
expect(readlinkSync(join(fakeCacheBase, '4.4.0'))).toBe('4.4.3');
});
it('with only 1 version, no cleanup is needed', () => {
createFakeVersion('4.4.3');
runSessionStart();
// Single version should remain as a real directory
const entries = readdirSync(fakeCacheBase);
expect(entries).toEqual(['4.4.3']);
const v3Stat = lstatSync(join(fakeCacheBase, '4.4.3'));
expect(v3Stat.isDirectory()).toBe(true);
expect(v3Stat.isSymbolicLink()).toBe(false);
});
});

View File

@@ -133,8 +133,10 @@ function stripTrailing(p: string): string {
return toForwardSlash(p).replace(/\/+$/, '');
}
/** Default grace period: skip directories modified within the last hour. */
const STALE_THRESHOLD_MS = 60 * 60 * 1000;
/** Default grace period: skip directories modified within the last 24 hours.
* Extended from 1 hour to 24 hours to avoid deleting cache directories that
* are still referenced by long-running sessions via CLAUDE_PLUGIN_ROOT. */
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
export function purgeStalePluginCacheVersions(): PurgeCacheResult {
const result: PurgeCacheResult = { removed: 0, removedPaths: [], errors: [] };