mirror of
https://fastgit.cc/github.com/Yeachan-Heo/oh-my-claudecode
synced 2026-04-21 21:30:47 +08:00
If stalePath's parent directory was removed (deleted config tree), symlinkSync throws ENOENT and the repair is silently skipped. Create the parent directory recursively before creating the temp symlink. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
712 lines
26 KiB
JavaScript
712 lines
26 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* OMC Session Start Hook (Node.js)
|
|
* Restores persistent mode states when session starts
|
|
* Cross-platform: Windows, macOS, Linux
|
|
*/
|
|
|
|
import { existsSync, readFileSync, readdirSync, rmSync, mkdirSync, writeFileSync, symlinkSync, lstatSync, readlinkSync, unlinkSync, renameSync } from 'fs';
|
|
import { join, dirname, basename } from 'path';
|
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
import { getClaudeConfigDir } from './lib/config-dir.mjs';
|
|
import { resolveOmcStateRoot } from './lib/state-root.mjs';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
/** Claude config directory (respects CLAUDE_CONFIG_DIR env var) */
|
|
const configDir = getClaudeConfigDir();
|
|
|
|
// Import timeout-protected stdin reader (prevents hangs on Linux/Windows, see issue #240, #524)
|
|
let readStdin;
|
|
try {
|
|
const mod = await import(pathToFileURL(join(__dirname, 'lib', 'stdin.mjs')).href);
|
|
readStdin = mod.readStdin;
|
|
} catch {
|
|
// Fallback: inline timeout-protected readStdin if lib module is missing
|
|
readStdin = (timeoutMs = 5000) => new Promise((resolve) => {
|
|
const chunks = [];
|
|
let settled = false;
|
|
const timeout = setTimeout(() => {
|
|
if (!settled) { settled = true; process.stdin.removeAllListeners(); process.stdin.destroy(); resolve(Buffer.concat(chunks).toString('utf-8')); }
|
|
}, timeoutMs);
|
|
process.stdin.on('data', (chunk) => { chunks.push(chunk); });
|
|
process.stdin.on('end', () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(Buffer.concat(chunks).toString('utf-8')); } });
|
|
process.stdin.on('error', () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(''); } });
|
|
if (process.stdin.readableEnded) { if (!settled) { settled = true; clearTimeout(timeout); resolve(Buffer.concat(chunks).toString('utf-8')); } }
|
|
});
|
|
}
|
|
|
|
// Read JSON file safely
|
|
function readJsonFile(path) {
|
|
try {
|
|
if (!existsSync(path)) return null;
|
|
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getRuntimeBaseDir() {
|
|
return process.env.CLAUDE_PLUGIN_ROOT || join(__dirname, '..');
|
|
}
|
|
|
|
async function loadProjectMemoryModules() {
|
|
try {
|
|
const runtimeBase = getRuntimeBaseDir();
|
|
const [
|
|
projectMemoryStorage,
|
|
projectMemoryDetector,
|
|
projectMemoryFormatter,
|
|
rulesFinder,
|
|
] = await Promise.all([
|
|
import(pathToFileURL(join(runtimeBase, 'dist', 'hooks', 'project-memory', 'storage.js')).href),
|
|
import(pathToFileURL(join(runtimeBase, 'dist', 'hooks', 'project-memory', 'detector.js')).href),
|
|
import(pathToFileURL(join(runtimeBase, 'dist', 'hooks', 'project-memory', 'formatter.js')).href),
|
|
import(pathToFileURL(join(runtimeBase, 'dist', 'hooks', 'rules-injector', 'finder.js')).href),
|
|
]);
|
|
|
|
return {
|
|
loadProjectMemory: projectMemoryStorage.loadProjectMemory,
|
|
saveProjectMemory: projectMemoryStorage.saveProjectMemory,
|
|
shouldRescan: projectMemoryStorage.shouldRescan,
|
|
detectProjectEnvironment: projectMemoryDetector.detectProjectEnvironment,
|
|
formatContextSummary: projectMemoryFormatter.formatContextSummary,
|
|
findProjectRoot: rulesFinder.findProjectRoot,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function hasProjectMemoryContent(memory) {
|
|
return Boolean(
|
|
memory &&
|
|
(
|
|
memory.userDirectives?.length ||
|
|
memory.customNotes?.length ||
|
|
memory.hotPaths?.length ||
|
|
memory.techStack?.languages?.length ||
|
|
memory.techStack?.frameworks?.length ||
|
|
memory.build?.buildCommand ||
|
|
memory.build?.testCommand
|
|
)
|
|
);
|
|
}
|
|
|
|
async function resolveProjectMemorySummary(directory, projectMemoryModules) {
|
|
const {
|
|
detectProjectEnvironment,
|
|
findProjectRoot,
|
|
formatContextSummary,
|
|
loadProjectMemory,
|
|
saveProjectMemory,
|
|
shouldRescan,
|
|
} = projectMemoryModules;
|
|
|
|
const projectRoot = findProjectRoot?.(directory);
|
|
if (!projectRoot) {
|
|
return '';
|
|
}
|
|
|
|
let memory = await loadProjectMemory?.(projectRoot);
|
|
|
|
if ((!memory || shouldRescan?.(memory)) && detectProjectEnvironment && saveProjectMemory) {
|
|
const existing = memory;
|
|
memory = await detectProjectEnvironment(projectRoot);
|
|
|
|
if (existing) {
|
|
memory.customNotes = existing.customNotes;
|
|
memory.userDirectives = existing.userDirectives;
|
|
}
|
|
|
|
await saveProjectMemory(projectRoot, memory);
|
|
}
|
|
|
|
if (!hasProjectMemoryContent(memory)) {
|
|
return '';
|
|
}
|
|
|
|
return formatContextSummary(memory)?.trim() || '';
|
|
}
|
|
|
|
// Semantic version comparison (for cache cleanup sorting)
|
|
function semverCompare(a, b) {
|
|
const pa = a.replace(/^v/, '').split('.').map(s => parseInt(s, 10) || 0);
|
|
const pb = b.replace(/^v/, '').split('.').map(s => parseInt(s, 10) || 0);
|
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
const na = pa[i] || 0;
|
|
const nb = pb[i] || 0;
|
|
if (na !== nb) return na - nb;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Extract OMC version from CLAUDE.md content
|
|
function extractOmcVersion(content) {
|
|
const match = content.match(/<!-- OMC:VERSION:(\d+\.\d+\.\d+[^\s]*?) -->/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
// Get plugin version from CLAUDE_PLUGIN_ROOT
|
|
function getPluginVersion() {
|
|
try {
|
|
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
if (!pluginRoot) return null;
|
|
const pkg = readJsonFile(join(pluginRoot, 'package.json'));
|
|
return pkg?.version || null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
// Get npm global package version
|
|
function getNpmVersion() {
|
|
try {
|
|
const versionFile = join(configDir, '.omc-version.json');
|
|
const data = readJsonFile(versionFile);
|
|
return data?.version || null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
// Get CLAUDE.md version
|
|
function getClaudeMdVersion() {
|
|
try {
|
|
const claudeMdPath = join(configDir, 'CLAUDE.md');
|
|
if (!existsSync(claudeMdPath)) return null; // File doesn't exist
|
|
const content = readFileSync(claudeMdPath, 'utf-8');
|
|
const version = extractOmcVersion(content);
|
|
return version || 'unknown'; // File exists but no marker = 'unknown'
|
|
} catch { return null; }
|
|
}
|
|
|
|
// Detect version drift between components
|
|
function detectVersionDrift() {
|
|
const pluginVersion = getPluginVersion();
|
|
const npmVersion = getNpmVersion();
|
|
const claudeMdVersion = getClaudeMdVersion();
|
|
|
|
// Need at least plugin version to detect drift
|
|
if (!pluginVersion) return null;
|
|
|
|
const drift = [];
|
|
|
|
if (npmVersion && npmVersion !== pluginVersion) {
|
|
drift.push({ component: 'npm package (omc CLI)', current: npmVersion, expected: pluginVersion });
|
|
}
|
|
|
|
if (claudeMdVersion === 'unknown') {
|
|
drift.push({
|
|
component: 'CLAUDE.md instructions',
|
|
current: 'unknown (needs migration)',
|
|
expected: pluginVersion
|
|
});
|
|
} else if (claudeMdVersion && claudeMdVersion !== pluginVersion) {
|
|
drift.push({
|
|
component: 'CLAUDE.md instructions',
|
|
current: claudeMdVersion,
|
|
expected: pluginVersion
|
|
});
|
|
}
|
|
|
|
if (drift.length === 0) return null;
|
|
|
|
return { pluginVersion, npmVersion, claudeMdVersion, drift };
|
|
}
|
|
|
|
// Check if we should notify (once per unique drift combination)
|
|
function shouldNotifyDrift(driftInfo) {
|
|
const stateFile = join(configDir, '.omc', 'update-state.json');
|
|
const driftKey = `plugin:${driftInfo.pluginVersion}-npm:${driftInfo.npmVersion}-claude:${driftInfo.claudeMdVersion}`;
|
|
|
|
try {
|
|
if (existsSync(stateFile)) {
|
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
if (state.lastNotifiedDrift === driftKey) return false;
|
|
}
|
|
} catch {}
|
|
|
|
// Save new drift state
|
|
try {
|
|
const dir = join(configDir, '.omc');
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
writeFileSync(stateFile, JSON.stringify({
|
|
lastNotifiedDrift: driftKey,
|
|
lastNotifiedAt: new Date().toISOString()
|
|
}));
|
|
} catch {}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Check npm registry for available update (with 24h cache)
|
|
async function checkNpmUpdate(currentVersion) {
|
|
const cacheFile = join(configDir, '.omc', 'update-check.json');
|
|
const CACHE_DURATION = 24 * 60 * 60 * 1000;
|
|
const now = Date.now();
|
|
|
|
// Check cache
|
|
try {
|
|
if (existsSync(cacheFile)) {
|
|
const cached = JSON.parse(readFileSync(cacheFile, 'utf-8'));
|
|
if (cached.timestamp && (now - cached.timestamp) < CACHE_DURATION) {
|
|
return (cached.updateAvailable && semverCompare(cached.latestVersion, currentVersion) > 0)
|
|
? { currentVersion, latestVersion: cached.latestVersion }
|
|
: null;
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Fetch from npm registry with 2s timeout
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
try {
|
|
const response = await fetch('https://registry.npmjs.org/oh-my-claude-sisyphus/latest', {
|
|
signal: controller.signal
|
|
});
|
|
if (!response.ok) return null;
|
|
|
|
const data = await response.json();
|
|
const latestVersion = data.version;
|
|
const updateAvailable = semverCompare(latestVersion, currentVersion) > 0;
|
|
|
|
// Update cache
|
|
try {
|
|
const dir = join(configDir, '.omc');
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
writeFileSync(cacheFile, JSON.stringify({ timestamp: now, latestVersion, currentVersion, updateAvailable }));
|
|
} catch {}
|
|
|
|
return updateAvailable ? { currentVersion, latestVersion } : null;
|
|
} catch { return null; } finally { clearTimeout(timeoutId); }
|
|
}
|
|
|
|
// Check if HUD is properly installed (with retry for race conditions)
|
|
async function checkHudInstallation(retryCount = 0) {
|
|
const hudDir = join(configDir, 'hud');
|
|
// Support current and legacy script names
|
|
const hudScriptOmc = join(hudDir, 'omc-hud.mjs');
|
|
const hudScriptLegacy = join(hudDir, 'omc-hud.js');
|
|
const settingsFile = join(configDir, 'settings.json');
|
|
|
|
const MAX_RETRIES = 2;
|
|
const RETRY_DELAY_MS = 100;
|
|
|
|
// Check if HUD script exists (either naming convention)
|
|
const hudScriptExists = existsSync(hudScriptOmc) || existsSync(hudScriptLegacy);
|
|
if (!hudScriptExists) {
|
|
return { installed: false, reason: 'HUD script missing' };
|
|
}
|
|
|
|
// Check if statusLine is configured (with retry for race conditions)
|
|
try {
|
|
if (existsSync(settingsFile)) {
|
|
const content = readFileSync(settingsFile, 'utf-8');
|
|
// Handle empty or whitespace-only content (race condition during write)
|
|
if (!content || !content.trim()) {
|
|
if (retryCount < MAX_RETRIES) {
|
|
// Sleep and retry (non-blocking)
|
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
|
return checkHudInstallation(retryCount + 1);
|
|
}
|
|
return { installed: false, reason: 'settings.json empty (possible race condition)' };
|
|
}
|
|
const settings = JSON.parse(content);
|
|
if (!settings.statusLine) {
|
|
// Retry once if statusLine not found (could be mid-write)
|
|
if (retryCount < MAX_RETRIES) {
|
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
|
return checkHudInstallation(retryCount + 1);
|
|
}
|
|
return { installed: false, reason: 'statusLine not configured' };
|
|
}
|
|
|
|
const statusLineCommand = typeof settings.statusLine === 'string'
|
|
? settings.statusLine
|
|
: (typeof settings.statusLine === 'object' && settings.statusLine && typeof settings.statusLine.command === 'string'
|
|
? settings.statusLine.command
|
|
: null);
|
|
|
|
// If OMC HUD wrapper is configured, ensure at least one plugin cache version is built.
|
|
if (statusLineCommand?.includes('omc-hud')) {
|
|
const pluginCacheBase = join(configDir, 'plugins', 'cache', 'omc', 'oh-my-claudecode');
|
|
if (existsSync(pluginCacheBase)) {
|
|
const versions = readdirSync(pluginCacheBase)
|
|
.filter(version => !version.startsWith('.'))
|
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
.reverse();
|
|
if (versions.length > 0) {
|
|
const hasBuiltHud = versions.some(version =>
|
|
existsSync(join(pluginCacheBase, version, 'dist', 'hud', 'index.js'))
|
|
);
|
|
if (!hasBuiltHud) {
|
|
const latestVersionDir = join(pluginCacheBase, versions[0]);
|
|
return {
|
|
installed: false,
|
|
reason: `HUD plugin cache is not built. Run: cd "${latestVersionDir}" && npm install && npm run build`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
return { installed: false, reason: 'settings.json missing' };
|
|
}
|
|
} catch (err) {
|
|
// JSON parse error - could be mid-write, retry
|
|
if (retryCount < MAX_RETRIES) {
|
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
|
return checkHudInstallation(retryCount + 1);
|
|
}
|
|
console.error('HUD check error:', err.message);
|
|
return { installed: false, reason: 'Could not read settings' };
|
|
}
|
|
|
|
return { installed: true };
|
|
}
|
|
|
|
// Main
|
|
async function main() {
|
|
try {
|
|
const input = await readStdin();
|
|
let data = {};
|
|
try { data = JSON.parse(input); } catch {}
|
|
|
|
const directory = data.cwd || data.directory || process.cwd();
|
|
const sessionId = data.session_id || data.sessionId || '';
|
|
const omcRoot = await resolveOmcStateRoot(directory);
|
|
const messages = [];
|
|
const projectMemoryModules = await loadProjectMemoryModules();
|
|
|
|
// Check for version drift between components
|
|
const driftInfo = detectVersionDrift();
|
|
if (driftInfo && shouldNotifyDrift(driftInfo)) {
|
|
let driftMsg = `[OMC VERSION DRIFT DETECTED]\n\nPlugin version: ${driftInfo.pluginVersion}\n`;
|
|
for (const d of driftInfo.drift) {
|
|
driftMsg += `${d.component}: ${d.current} (expected ${d.expected})\n`;
|
|
}
|
|
driftMsg += `\nRun 'omc update' to sync all components.`;
|
|
|
|
messages.push(`<session-restore>\n\n${driftMsg}\n\n</session-restore>\n\n---\n`);
|
|
}
|
|
|
|
// Check npm registry for available update (with 24h cache)
|
|
try {
|
|
const pluginVersion = getPluginVersion();
|
|
if (pluginVersion) {
|
|
const updateInfo = await checkNpmUpdate(pluginVersion);
|
|
if (updateInfo) {
|
|
messages.push(`<session-restore>\n\n[OMC UPDATE AVAILABLE]\n\nA new version of oh-my-claudecode is available: v${updateInfo.latestVersion} (current: v${updateInfo.currentVersion})\n\nTo update, run: omc update\n(This syncs plugin, npm package, and CLAUDE.md together)\n\n</session-restore>\n\n---\n`);
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Warn if silentAutoUpdate is enabled but running in plugin mode (#1773)
|
|
if (process.env.CLAUDE_PLUGIN_ROOT) {
|
|
try {
|
|
const omcConfigPath = join(configDir, '.omc-config.json');
|
|
const omcConfig = readJsonFile(omcConfigPath);
|
|
if (omcConfig?.silentAutoUpdate) {
|
|
messages.push(`<session-restore>\n\n[OMC] silentAutoUpdate is enabled in .omc-config.json but has no effect in plugin mode.\nTo update, use: /plugin marketplace update omc && /omc-setup\nOr run manually: omc update\n\n</session-restore>\n\n---\n`);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Check HUD installation (one-time setup guidance)
|
|
const hudCheck = await checkHudInstallation();
|
|
if (!hudCheck.installed) {
|
|
messages.push(`<system-reminder>
|
|
[OMC] HUD not configured (${hudCheck.reason}). Run /hud setup then restart Claude Code.
|
|
</system-reminder>`);
|
|
}
|
|
|
|
// Check for ultrawork state - only restore if session matches (issue #311)
|
|
// Session-scoped ONLY when session_id exists — no legacy fallback
|
|
let ultraworkState = null;
|
|
if (sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)) {
|
|
// Session-scoped ONLY — no legacy fallback
|
|
ultraworkState = readJsonFile(join(omcRoot, 'state', 'sessions', sessionId, 'ultrawork-state.json'));
|
|
// Validate session identity
|
|
if (ultraworkState && ultraworkState.session_id && ultraworkState.session_id !== sessionId) {
|
|
ultraworkState = null;
|
|
}
|
|
} else {
|
|
// No session_id — legacy behavior for backward compat
|
|
ultraworkState = readJsonFile(join(omcRoot, 'state', 'ultrawork-state.json'));
|
|
}
|
|
|
|
if (ultraworkState?.active) {
|
|
messages.push(`<session-restore>
|
|
|
|
[ULTRAWORK MODE RESTORED]
|
|
|
|
You have an active ultrawork session from ${ultraworkState.started_at}.
|
|
Original task: ${ultraworkState.original_prompt}
|
|
|
|
Treat this as prior-session context only. Prioritize the user's newest request, and resume ultrawork only if the user explicitly asks to continue it.
|
|
|
|
</session-restore>
|
|
|
|
---
|
|
`);
|
|
}
|
|
|
|
// Check for ralph loop state
|
|
// Session-scoped ONLY when session_id exists — no legacy fallback
|
|
let ralphState = null;
|
|
if (sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)) {
|
|
// Session-scoped ONLY — no legacy fallback
|
|
ralphState = readJsonFile(join(omcRoot, 'state', 'sessions', sessionId, 'ralph-state.json'));
|
|
// Validate session identity
|
|
if (ralphState && ralphState.session_id && ralphState.session_id !== sessionId) {
|
|
ralphState = null;
|
|
}
|
|
} else {
|
|
// No session_id — legacy behavior for backward compat
|
|
ralphState = readJsonFile(join(omcRoot, 'state', 'ralph-state.json'));
|
|
if (!ralphState) {
|
|
ralphState = readJsonFile(join(omcRoot, 'ralph-state.json'));
|
|
}
|
|
}
|
|
if (ralphState?.active) {
|
|
messages.push(`<session-restore>
|
|
|
|
[RALPH LOOP RESTORED]
|
|
|
|
You have an active ralph-loop session.
|
|
Original task: ${ralphState.prompt || 'Task in progress'}
|
|
Iteration: ${ralphState.iteration || 1}/${ralphState.max_iterations || 10}
|
|
|
|
Treat this as prior-session context only. Prioritize the user's newest request, and resume the ralph loop only if the user explicitly asks to continue it.
|
|
|
|
</session-restore>
|
|
|
|
---
|
|
`);
|
|
}
|
|
|
|
// Check for incomplete todos (project-local only, not global
|
|
// [$CLAUDE_CONFIG_DIR|~/.claude]/todos/)
|
|
// NOTE: We intentionally do NOT scan the global
|
|
// [$CLAUDE_CONFIG_DIR|~/.claude]/todos/ directory.
|
|
// That directory accumulates todo files from ALL past sessions across all
|
|
// projects, causing phantom task counts in fresh sessions (see issue #354).
|
|
const localTodoPaths = [
|
|
join(omcRoot, 'todos.json'),
|
|
join(directory, '.claude', 'todos.json')
|
|
];
|
|
let incompleteCount = 0;
|
|
for (const todoFile of localTodoPaths) {
|
|
if (existsSync(todoFile)) {
|
|
try {
|
|
const data = readJsonFile(todoFile);
|
|
const todos = data?.todos || (Array.isArray(data) ? data : []);
|
|
incompleteCount += todos.filter(t => t.status !== 'completed' && t.status !== 'cancelled').length;
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
if (incompleteCount > 0) {
|
|
messages.push(`<session-restore>
|
|
|
|
[PENDING TASKS DETECTED]
|
|
|
|
You have ${incompleteCount} incomplete tasks from a previous session.
|
|
Treat this as prior-session context only. Prioritize the user's newest request, and resume these tasks only if the user explicitly asks to continue them.
|
|
|
|
</session-restore>
|
|
|
|
---
|
|
`);
|
|
}
|
|
|
|
if (projectMemoryModules) {
|
|
try {
|
|
const summary = await resolveProjectMemorySummary(directory, projectMemoryModules);
|
|
if (summary) {
|
|
messages.push(`<project-memory-context>
|
|
|
|
[PROJECT MEMORY]
|
|
|
|
${summary}
|
|
|
|
</project-memory-context>
|
|
|
|
---
|
|
`);
|
|
}
|
|
} catch {
|
|
// Project memory is additive only; never break session start.
|
|
}
|
|
}
|
|
|
|
// Check for notepad Priority Context
|
|
const notepadPath = join(omcRoot, 'notepad.md');
|
|
if (existsSync(notepadPath)) {
|
|
try {
|
|
const notepadContent = readFileSync(notepadPath, 'utf-8');
|
|
const priorityMatch = notepadContent.match(/## Priority Context\n([\s\S]*?)(?=## |$)/);
|
|
if (priorityMatch && priorityMatch[1].trim()) {
|
|
const priorityContext = priorityMatch[1].trim();
|
|
// Only inject if there's actual content (not just the placeholder comment)
|
|
const cleanContent = priorityContext.replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
if (cleanContent) {
|
|
messages.push(`<notepad-context>
|
|
[NOTEPAD - Priority Context]
|
|
${cleanContent}
|
|
</notepad-context>`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Silently ignore notepad read errors
|
|
}
|
|
}
|
|
|
|
// 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');
|
|
let versions = [];
|
|
if (existsSync(cacheBase)) {
|
|
versions = readdirSync(cacheBase)
|
|
.filter(v => /^\d+\.\d+\.\d+/.test(v))
|
|
.sort(semverCompare)
|
|
.reverse();
|
|
|
|
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);
|
|
|
|
const isWin = process.platform === 'win32';
|
|
const symlinkTarget = isWin ? join(cacheBase, latest) : latest;
|
|
|
|
if (stat.isSymbolicLink()) {
|
|
// Already a symlink — update only if pointing to wrong target.
|
|
// Use atomic temp-symlink + rename to avoid a window where
|
|
// the path doesn't exist (fixes race in issue #1007).
|
|
const target = readlinkSync(versionPath);
|
|
if (target === latest || target === join(cacheBase, latest)) continue;
|
|
try {
|
|
const tmpLink = versionPath + '.tmp.' + process.pid;
|
|
symlinkSync(symlinkTarget, tmpLink, isWin ? 'junction' : undefined);
|
|
try {
|
|
renameSync(tmpLink, versionPath);
|
|
} catch {
|
|
// rename failed (e.g. cross-device) — fall back to unlink+symlink
|
|
try { unlinkSync(tmpLink); } catch {}
|
|
unlinkSync(versionPath);
|
|
symlinkSync(symlinkTarget, versionPath, isWin ? 'junction' : undefined);
|
|
}
|
|
} catch (swapErr) {
|
|
if (swapErr?.code !== 'EEXIST') {
|
|
// Leave as-is rather than losing it
|
|
}
|
|
}
|
|
} else if (stat.isDirectory()) {
|
|
// Directory → symlink: cannot be atomic, but run.cjs now
|
|
// handles missing targets gracefully (issue #1007).
|
|
rmSync(versionPath, { recursive: true, force: true });
|
|
try {
|
|
symlinkSync(symlinkTarget, versionPath, isWin ? 'junction' : undefined);
|
|
} catch (symlinkErr) {
|
|
// EEXIST: another session raced us — safe to ignore.
|
|
if (symlinkErr?.code !== 'EEXIST') {
|
|
// Symlink genuinely failed. Leave the path as-is.
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// lstatSync / rmSync / unlinkSync failure — leave old directory as-is.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Guard against CLAUDE_PLUGIN_ROOT pointing to a stale/deleted version.
|
|
// When an old version directory is removed during upgrade but a running
|
|
// session still has the old CLAUDE_PLUGIN_ROOT in its environment, the
|
|
// directory won't exist. Create a symlink so subsequent hook invocations
|
|
// via run.cjs resolve correctly.
|
|
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT?.replace(/[\/\\]+$/, ''); // strip trailing separators
|
|
if (pluginRoot && !existsSync(pluginRoot)) {
|
|
const pluginRootVersion = basename(pluginRoot);
|
|
if (/^\d+\.\d+\.\d+/.test(pluginRootVersion) && versions.length > 0) {
|
|
const latest = versions[0];
|
|
const stalePath = pluginRoot;
|
|
const isWin = process.platform === 'win32';
|
|
// Always use absolute path to avoid symlink target resolution issues
|
|
// when stalePath is not under cacheBase (e.g., after config-dir move)
|
|
const symlinkTarget = join(cacheBase, latest);
|
|
try {
|
|
// Atomic: create temp symlink then rename over stale path
|
|
const tmpLink = stalePath + '.tmp.' + process.pid;
|
|
// Ensure parent dir exists (stalePath may reference a deleted config tree)
|
|
const parentDir = dirname(stalePath);
|
|
if (!existsSync(parentDir)) {
|
|
try { mkdirSync(parentDir, { recursive: true }); } catch {}
|
|
}
|
|
symlinkSync(symlinkTarget, tmpLink, isWin ? 'junction' : undefined);
|
|
try {
|
|
renameSync(tmpLink, stalePath);
|
|
} catch {
|
|
try { unlinkSync(tmpLink); } catch {}
|
|
// Remove any pre-existing dangling symlink/junction at stalePath
|
|
// before recreating, otherwise symlinkSync throws EEXIST
|
|
try { unlinkSync(stalePath); } catch {}
|
|
symlinkSync(symlinkTarget, stalePath, isWin ? 'junction' : undefined);
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Send session-start notification (non-blocking, fire-and-forget)
|
|
try {
|
|
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
if (pluginRoot) {
|
|
const { notify } = await import(pathToFileURL(join(pluginRoot, 'dist', 'notifications', 'index.js')).href);
|
|
// Fire and forget - don't await, don't block session start
|
|
notify('session-start', {
|
|
sessionId,
|
|
projectPath: directory,
|
|
timestamp: new Date().toISOString(),
|
|
}).catch(() => {}); // swallow errors silently
|
|
|
|
// Start reply listener daemon if notification reply config is available
|
|
try {
|
|
const { startReplyListener, buildDaemonConfig } = await import(pathToFileURL(join(pluginRoot, 'dist', 'notifications', 'reply-listener.js')).href);
|
|
const replyConfig = await buildDaemonConfig();
|
|
if (replyConfig) {
|
|
startReplyListener(replyConfig);
|
|
}
|
|
} catch {
|
|
// Reply listener not available or not configured, skip silently
|
|
}
|
|
}
|
|
} catch {
|
|
// Notification module not available, skip silently
|
|
}
|
|
|
|
if (messages.length > 0) {
|
|
console.log(JSON.stringify({
|
|
continue: true,
|
|
hookSpecificOutput: {
|
|
hookEventName: 'SessionStart',
|
|
additionalContext: messages.join('\n')
|
|
}
|
|
}));
|
|
} else {
|
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
}
|
|
} catch (error) {
|
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
}
|
|
}
|
|
|
|
main();
|