279 lines
9.8 KiB
JavaScript
279 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Claude Flow Hook Handler (Cross-Platform)
|
|
* Dispatches hook events to the appropriate helper modules.
|
|
*
|
|
* Usage: node hook-handler.cjs <command> [args...]
|
|
*
|
|
* Commands:
|
|
* route - Route a task to optimal agent (reads PROMPT from env/stdin)
|
|
* pre-bash - Validate command safety before execution
|
|
* post-edit - Record edit outcome for learning
|
|
* session-restore - Restore previous session state
|
|
* session-end - End session and persist state
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
const helpersDir = __dirname;
|
|
|
|
// Safe require with stdout suppression - the helper modules have CLI
|
|
// sections that run unconditionally on require(), so we mute console
|
|
// during the require to prevent noisy output.
|
|
function safeRequire(modulePath) {
|
|
try {
|
|
if (fs.existsSync(modulePath)) {
|
|
const origLog = console.log;
|
|
const origError = console.error;
|
|
console.log = () => {};
|
|
console.error = () => {};
|
|
try {
|
|
const mod = require(modulePath);
|
|
return mod;
|
|
} finally {
|
|
console.log = origLog;
|
|
console.error = origError;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// silently fail
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const router = safeRequire(path.join(helpersDir, 'router.js'));
|
|
const session = safeRequire(path.join(helpersDir, 'session.js'));
|
|
const memory = safeRequire(path.join(helpersDir, 'memory.js'));
|
|
const intelligence = safeRequire(path.join(helpersDir, 'intelligence.cjs'));
|
|
|
|
// ── Intelligence timeout protection (fixes #1530, #1531) ───────────────────
|
|
const INTELLIGENCE_TIMEOUT_MS = 3000;
|
|
function runWithTimeout(fn, label) {
|
|
// For synchronous blocking calls, we use a global safety timer.
|
|
// The readJSON file-size guard prevents loading huge files, but this
|
|
// is an additional safety net.
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
process.stderr.write("[WARN] " + label + " timed out after " + INTELLIGENCE_TIMEOUT_MS + "ms, skipping\n");
|
|
resolve(null);
|
|
}, INTELLIGENCE_TIMEOUT_MS);
|
|
try {
|
|
const result = fn();
|
|
clearTimeout(timer);
|
|
resolve(result);
|
|
} catch (e) {
|
|
clearTimeout(timer);
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// Get the command from argv
|
|
const [,, command, ...args] = process.argv;
|
|
|
|
// Read stdin with timeout — Claude Code sends hook data as JSON via stdin.
|
|
// Timeout prevents hanging when stdin is not properly closed (common on Windows).
|
|
async function readStdin() {
|
|
if (process.stdin.isTTY) return '';
|
|
return new Promise((resolve) => {
|
|
let data = '';
|
|
const timer = setTimeout(() => {
|
|
process.stdin.removeAllListeners();
|
|
process.stdin.pause();
|
|
resolve(data);
|
|
}, 500);
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
process.stdin.on('end', () => { clearTimeout(timer); resolve(data); });
|
|
process.stdin.on('error', () => { clearTimeout(timer); resolve(data); });
|
|
process.stdin.resume();
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
// Global safety timeout: hooks must NEVER hang (#1530, #1531)
|
|
const safetyTimer = setTimeout(() => {
|
|
process.stderr.write("[WARN] Hook handler global timeout (5s), forcing exit\n");
|
|
process.exit(0);
|
|
}, 5000);
|
|
safetyTimer.unref(); // don't keep process alive just for this timer
|
|
|
|
let stdinData = '';
|
|
try { stdinData = await readStdin(); } catch (e) { /* ignore stdin errors */ }
|
|
|
|
let hookInput = {};
|
|
if (stdinData.trim()) {
|
|
try { hookInput = JSON.parse(stdinData); } catch (e) { /* ignore parse errors */ }
|
|
}
|
|
|
|
// Normalize snake_case/camelCase: Claude Code sends tool_input/tool_name (snake_case)
|
|
const toolInput = hookInput.toolInput || hookInput.tool_input || {};
|
|
const toolName = hookInput.toolName || hookInput.tool_name || '';
|
|
|
|
// Merge stdin data into prompt resolution: prefer stdin fields, then env, then argv
|
|
const prompt = hookInput.prompt || hookInput.command || toolInput
|
|
|| process.env.PROMPT || process.env.TOOL_INPUT_command || args.join(' ') || '';
|
|
|
|
const handlers = {
|
|
'route': () => {
|
|
// Inject ranked intelligence context before routing
|
|
if (intelligence && intelligence.getContext) {
|
|
try {
|
|
const ctx = intelligence.getContext(prompt);
|
|
if (ctx) console.log(ctx);
|
|
} catch (e) { /* non-fatal */ }
|
|
}
|
|
if (router && router.routeTask) {
|
|
const result = router.routeTask(prompt);
|
|
// Format output for Claude Code hook consumption — real data only
|
|
const output = [
|
|
`[INFO] Routing task: ${prompt.substring(0, 80) || '(no prompt)'}`,
|
|
'',
|
|
'+------------------- Primary Recommendation -------------------+',
|
|
`| Agent: ${result.agent.padEnd(53)}|`,
|
|
`| Confidence: ${(result.confidence * 100).toFixed(1)}%${' '.repeat(44)}|`,
|
|
`| Reason: ${(result.reason || '').substring(0, 53).padEnd(53)}|`,
|
|
'+--------------------------------------------------------------+',
|
|
];
|
|
console.log(output.join('\n'));
|
|
} else {
|
|
console.log('[INFO] Router not available, using default routing');
|
|
}
|
|
},
|
|
|
|
'pre-bash': () => {
|
|
// Basic command safety check — prefer stdin command data from Claude Code
|
|
const cmd = (hookInput.command || prompt).toLowerCase();
|
|
const dangerous = ['rm -rf /', 'format c:', 'del /s /q c:\\', ':(){:|:&};:'];
|
|
for (const d of dangerous) {
|
|
if (cmd.includes(d)) {
|
|
console.error(`[BLOCKED] Dangerous command detected: ${d}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
console.log('[OK] Command validated');
|
|
},
|
|
|
|
'post-edit': () => {
|
|
// Record edit for session metrics
|
|
if (session && session.metric) {
|
|
try { session.metric('edits'); } catch (e) { /* no active session */ }
|
|
}
|
|
// Record edit for intelligence consolidation — prefer stdin data from Claude Code
|
|
if (intelligence && intelligence.recordEdit) {
|
|
try {
|
|
const file = hookInput.file_path || toolInput.file_path
|
|
|| process.env.TOOL_INPUT_file_path || args[0] || '';
|
|
intelligence.recordEdit(file);
|
|
} catch (e) { /* non-fatal */ }
|
|
}
|
|
console.log('[OK] Edit recorded');
|
|
},
|
|
|
|
'session-restore': async () => {
|
|
if (session) {
|
|
// Try restore first, fall back to start
|
|
const existing = session.restore && session.restore();
|
|
if (!existing) {
|
|
session.start && session.start();
|
|
}
|
|
} else {
|
|
// Minimal session restore output
|
|
const sessionId = `session-${Date.now()}`;
|
|
console.log(`[INFO] Restoring session: %SESSION_ID%`);
|
|
console.log('');
|
|
console.log(`[OK] Session restored from %SESSION_ID%`);
|
|
console.log(`New session ID: ${sessionId}`);
|
|
console.log('');
|
|
console.log('Restored State');
|
|
console.log('+----------------+-------+');
|
|
console.log('| Item | Count |');
|
|
console.log('+----------------+-------+');
|
|
console.log('| Tasks | 0 |');
|
|
console.log('| Agents | 0 |');
|
|
console.log('| Memory Entries | 0 |');
|
|
console.log('+----------------+-------+');
|
|
}
|
|
// Initialize intelligence graph after session restore (with timeout — #1530)
|
|
if (intelligence && intelligence.init) {
|
|
const initResult = await runWithTimeout(() => intelligence.init(), 'intelligence.init()');
|
|
if (initResult && initResult.nodes > 0) {
|
|
console.log(`[INTELLIGENCE] Loaded ${initResult.nodes} patterns, ${initResult.edges} edges`);
|
|
}
|
|
}
|
|
},
|
|
|
|
'session-end': async () => {
|
|
// Consolidate intelligence before ending session (with timeout — #1530)
|
|
if (intelligence && intelligence.consolidate) {
|
|
const consResult = await runWithTimeout(() => intelligence.consolidate(), 'intelligence.consolidate()');
|
|
if (consResult && consResult.entries > 0) {
|
|
console.log(`[INTELLIGENCE] Consolidated: ${consResult.entries} entries, ${consResult.edges} edges${consResult.newEntries > 0 ? `, ${consResult.newEntries} new` : ''}, PageRank recomputed`);
|
|
}
|
|
}
|
|
if (session && session.end) {
|
|
session.end();
|
|
} else {
|
|
console.log('[OK] Session ended');
|
|
}
|
|
},
|
|
|
|
'pre-task': () => {
|
|
if (session && session.metric) {
|
|
try { session.metric('tasks'); } catch (e) { /* no active session */ }
|
|
}
|
|
// Route the task if router is available
|
|
if (router && router.routeTask && prompt) {
|
|
const result = router.routeTask(prompt);
|
|
console.log(`[INFO] Task routed to: ${result.agent} (confidence: ${result.confidence})`);
|
|
} else {
|
|
console.log('[OK] Task started');
|
|
}
|
|
},
|
|
|
|
'post-task': () => {
|
|
// Implicit success feedback for intelligence
|
|
if (intelligence && intelligence.feedback) {
|
|
try {
|
|
intelligence.feedback(true);
|
|
} catch (e) { /* non-fatal */ }
|
|
}
|
|
console.log('[OK] Task completed');
|
|
},
|
|
|
|
'stats': () => {
|
|
if (intelligence && intelligence.stats) {
|
|
intelligence.stats(args.includes('--json'));
|
|
} else {
|
|
console.log('[WARN] Intelligence module not available. Run session-restore first.');
|
|
}
|
|
},
|
|
};
|
|
|
|
// Execute the handler
|
|
if (command && handlers[command]) {
|
|
try {
|
|
await Promise.resolve(handlers[command]());
|
|
} catch (e) {
|
|
// Hooks should never crash Claude Code - fail silently
|
|
console.log(`[WARN] Hook ${command} encountered an error: ${e.message}`);
|
|
}
|
|
} else if (command) {
|
|
// Unknown command - pass through without error
|
|
console.log(`[OK] Hook: ${command}`);
|
|
} else {
|
|
console.log('Usage: hook-handler.cjs <route|pre-bash|post-edit|session-restore|session-end|pre-task|post-task|stats>');
|
|
}
|
|
}
|
|
|
|
// Hooks must ALWAYS exit 0 — Claude Code treats non-zero as "hook error"
|
|
// and skips all subsequent hooks for the event.
|
|
process.exitCode = 0;
|
|
main().catch((e) => {
|
|
try { console.log(`[WARN] Hook handler error: ${e.message}`); } catch (_) {}
|
|
}).finally(() => {
|
|
process.exit(0);
|
|
});
|