mirror of
https://fastgit.cc/github.com/Yeachan-Heo/oh-my-claudecode
synced 2026-04-21 21:30:47 +08:00
run.cjs delegated hook execution without applying hooks.json timeouts, so a slow stop hook could stall far past its configured 5s/10s budget. The fix resolves the target hook timeout from hooks.json and enforces it in the runner, while context-guard-stop now skips git worktree probes unless a local .git marker exists and applies a short timeout to the remaining rev-parse calls. Focused regressions cover both the runner timeout enforcement and the non-git guard path. Constraint: Stop hooks must fail open rather than deadlock session shutdown Constraint: Keep the fix narrow to runner enforcement and non-git probing Rejected: Refactor persistent-mode idle path in this patch | wider behavior change than needed for the timeout bug Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep run.cjs timeout resolution aligned with hooks/hooks.json if stop-hook timeout values change Tested: vitest run src/__tests__/run-cjs-graceful-fallback.test.ts src/__tests__/context-guard-stop.test.ts Tested: eslint src/__tests__/run-cjs-graceful-fallback.test.ts src/__tests__/context-guard-stop.test.ts Tested: tsc --noEmit Tested: node --check scripts/run.cjs Tested: node --check scripts/context-guard-stop.mjs Not-tested: End-to-end Claude hook runtime outside local reproduction/targeted tests
163 lines
5.7 KiB
JavaScript
163 lines
5.7 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
/**
|
|
* OMC Cross-platform hook runner (run.cjs)
|
|
*
|
|
* Uses process.execPath (the Node binary already running this script) to spawn
|
|
* the target .mjs hook, bypassing PATH / shell discovery issues.
|
|
*
|
|
* Replaces the `sh + find-node.sh` chain that fails on Windows because
|
|
* /usr/bin/sh is a PE32+ binary the OS refuses to execute natively.
|
|
* Fixes issues #909, #899, #892, #869.
|
|
*
|
|
* Usage (from hooks.json, after setup patches the absolute node path in):
|
|
* /abs/path/to/node "${CLAUDE_PLUGIN_ROOT}/scripts/run.cjs" \
|
|
* "${CLAUDE_PLUGIN_ROOT}/scripts/<hook>.mjs" [args...]
|
|
*
|
|
* During post-install setup, the leading `node` token is replaced with
|
|
* process.execPath so nvm/fnm users and Windows users all get the right binary.
|
|
*/
|
|
|
|
const { spawnSync } = require('child_process');
|
|
const { existsSync, readFileSync, realpathSync } = require('fs');
|
|
const { join, basename, dirname } = require('path');
|
|
|
|
const target = process.argv[2];
|
|
if (!target) {
|
|
// Nothing to run — exit cleanly so Claude Code hooks are never blocked.
|
|
process.exit(0);
|
|
}
|
|
|
|
/**
|
|
* Resolve the hook script target path, handling stale CLAUDE_PLUGIN_ROOT.
|
|
*
|
|
* When a plugin update replaces an old version directory with a symlink (or
|
|
* deletes it entirely), sessions that still reference the old version via
|
|
* CLAUDE_PLUGIN_ROOT will fail with MODULE_NOT_FOUND.
|
|
*
|
|
* Resolution strategy:
|
|
* 1. Use the target as-is if it exists.
|
|
* 2. Try resolving through realpathSync (follows symlinks).
|
|
* 3. Scan the plugin cache for the latest available version that has the
|
|
* same script name and use that instead.
|
|
* 4. If all else fails, return null (caller exits cleanly).
|
|
*
|
|
* See: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/1007
|
|
*/
|
|
function resolveTarget(targetPath) {
|
|
// Fast path: target exists (common case)
|
|
if (existsSync(targetPath)) return targetPath;
|
|
|
|
// Try realpath resolution (handles broken symlinks that resolve elsewhere)
|
|
try {
|
|
const resolved = realpathSync(targetPath);
|
|
if (existsSync(resolved)) return resolved;
|
|
} catch {
|
|
// realpathSync throws if the path doesn't exist at all — expected
|
|
}
|
|
|
|
// Fallback: scan plugin cache for the same script in the latest version.
|
|
// CLAUDE_PLUGIN_ROOT is e.g. ~/.claude/plugins/cache/omc/oh-my-claudecode/4.2.14
|
|
// We look one level up for sibling version directories.
|
|
try {
|
|
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
if (!pluginRoot) return null;
|
|
|
|
const cacheBase = dirname(pluginRoot); // .../oh-my-claudecode/
|
|
const scriptRelative = targetPath.slice(pluginRoot.length); // /scripts/persistent-mode.cjs
|
|
|
|
if (!scriptRelative || !existsSync(cacheBase)) return null;
|
|
|
|
// Find version directories (real dirs or valid symlinks), pick latest
|
|
const { readdirSync, lstatSync, readlinkSync } = require('fs');
|
|
const entries = readdirSync(cacheBase).filter(v => /^\d+\.\d+\.\d+/.test(v));
|
|
|
|
// Sort descending by semver
|
|
entries.sort((a, b) => {
|
|
const pa = a.split('.').map(Number);
|
|
const pb = b.split('.').map(Number);
|
|
for (let i = 0; i < 3; i++) {
|
|
if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
|
|
}
|
|
return 0;
|
|
});
|
|
|
|
for (const version of entries) {
|
|
const candidate = join(cacheBase, version) + scriptRelative;
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
} catch {
|
|
// Any error in fallback scan — give up gracefully
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function escapeRegex(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function flattenHookEntries(rawHooks) {
|
|
if (!rawHooks || typeof rawHooks !== 'object') return [];
|
|
return Object.values(rawHooks).flatMap((entries) => Array.isArray(entries) ? entries : []);
|
|
}
|
|
|
|
function resolveHookTimeoutMs(targetPath, extraArgs) {
|
|
const pluginRoot = dirname(dirname(targetPath));
|
|
const hooksJsonPath = join(pluginRoot, 'hooks', 'hooks.json');
|
|
if (!existsSync(hooksJsonPath)) return null;
|
|
|
|
try {
|
|
const hooksJson = JSON.parse(readFileSync(hooksJsonPath, 'utf-8'));
|
|
const scriptName = basename(targetPath);
|
|
const scriptPattern = new RegExp(`[/\\\\]scripts[/\\\\]${escapeRegex(scriptName)}(?:\\s|$)`);
|
|
const argNeedles = extraArgs.filter((arg) => typeof arg === 'string' && arg.length > 0);
|
|
|
|
for (const entry of flattenHookEntries(hooksJson?.hooks)) {
|
|
const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
for (const hook of hooks) {
|
|
const command = typeof hook?.command === 'string' ? hook.command : '';
|
|
const timeout = Number(hook?.timeout);
|
|
if (!scriptPattern.test(command)) continue;
|
|
if (!Number.isFinite(timeout) || timeout <= 0) continue;
|
|
if (!argNeedles.every((arg) => command.includes(` ${arg}`) || command.endsWith(` ${arg}`))) continue;
|
|
return Math.floor(timeout * 1000);
|
|
}
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const resolved = resolveTarget(target);
|
|
if (!resolved) {
|
|
// Target not found anywhere — exit cleanly so hooks are never blocked.
|
|
// This is the graceful fallback for stale CLAUDE_PLUGIN_ROOT paths.
|
|
process.exit(0);
|
|
}
|
|
|
|
const timeoutMs = resolveHookTimeoutMs(resolved, process.argv.slice(3));
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[resolved, ...process.argv.slice(3)],
|
|
{
|
|
stdio: 'inherit',
|
|
env: process.env,
|
|
windowsHide: true,
|
|
...(timeoutMs ? {
|
|
timeout: timeoutMs,
|
|
killSignal: process.platform === 'win32' ? 'SIGTERM' : 'SIGKILL',
|
|
} : {}),
|
|
}
|
|
);
|
|
|
|
if (result.error?.code === 'ETIMEDOUT' && timeoutMs) {
|
|
process.stderr.write(`[run.cjs] Hook ${basename(resolved)} timed out after ${timeoutMs}ms; exiting fail-open.\n`);
|
|
}
|
|
|
|
// Propagate the child exit code (null → 0 to avoid blocking hooks).
|
|
process.exit(result.status ?? 0);
|