diff --git a/.aios-core/core/synapse/runtime/hook-runtime.js b/.aios-core/core/synapse/runtime/hook-runtime.js index ad125862bb..6e1a81a669 100644 --- a/.aios-core/core/synapse/runtime/hook-runtime.js +++ b/.aios-core/core/synapse/runtime/hook-runtime.js @@ -5,6 +5,28 @@ const fs = require('fs'); const DEFAULT_STALE_TTL_HOURS = 168; // 7 days +/** + * Clean orphaned .tmp files from the sessions directory. + * These are leftovers from atomic-write operations that crashed + * between write-to-tmp and rename-to-target. + * + * @param {string} sessionsDir - Path to .synapse/sessions/ + * @returns {number} Number of files removed + */ +function cleanOrphanTmpFiles(sessionsDir) { + try { + const files = fs.readdirSync(sessionsDir); + let removed = 0; + for (const f of files) { + if (f.includes('.tmp.')) { + try { fs.unlinkSync(path.join(sessionsDir, f)); removed++; } + catch (_) { /* locked/permissions — skip */ } + } + } + return removed; + } catch (_) { return 0; } +} + /** * Read stale session TTL from core-config.yaml. * Falls back to DEFAULT_STALE_TTL_HOURS (168h = 7 days). @@ -28,8 +50,15 @@ function getStaleSessionTTL(cwd) { /** * Resolve runtime dependencies for Synapse hook execution. * - * On the first prompt of a session (prompt_count === 0), runs - * cleanStaleSessions() fire-and-forget to remove expired sessions. + * On the first prompt of a session: + * - Creates the session file via createSession() if it does not exist (BUG-FIX) + * - Runs cleanStaleSessions() fire-and-forget to remove expired sessions + * + * BUG-FIX: Prior to this fix, loadSession() returned null for new sessions + * and the fallback `{ prompt_count: 0 }` was an in-memory-only object. + * updateSession() in synapse-engine.cjs then called loadSession() internally, + * got null again, and returned null — so the session was NEVER persisted. + * Fix: call createSession() when loadSession() returns null. * * @param {{cwd?: string, session_id?: string, sessionId?: string}} input * @returns {{ @@ -46,7 +75,7 @@ function resolveHookRuntime(input) { if (!fs.existsSync(synapsePath)) return null; try { - const { loadSession, cleanStaleSessions } = require( + const { loadSession, createSession, cleanStaleSessions } = require( path.join(cwd, '.aios-core', 'core', 'synapse', 'session', 'session-manager.js'), ); const { SynapseEngine } = require( @@ -54,7 +83,18 @@ function resolveHookRuntime(input) { ); const sessionsDir = path.join(synapsePath, 'sessions'); - const session = loadSession(sessionId, sessionsDir) || { prompt_count: 0 }; + + // BUG-FIX: Create session file on first prompt if it doesn't exist. + // Without this, updateSession() in synapse-engine.cjs silently fails + // because it calls loadSession() internally which returns null. + let session = loadSession(sessionId, sessionsDir); + if (!session && sessionId) { + session = createSession(sessionId, cwd, sessionsDir); + } + if (!session) { + session = { prompt_count: 0 }; + } + const engine = new SynapseEngine(synapsePath); // AC3: Run cleanup on first prompt only (fire-and-forget) @@ -68,6 +108,16 @@ function resolveHookRuntime(input) { } catch (_cleanupErr) { // Fire-and-forget: never block hook execution } + + // Clean orphaned .tmp files from atomic-write crashes + try { + const tmpRemoved = cleanOrphanTmpFiles(sessionsDir); + if (tmpRemoved > 0 && process.env.DEBUG === '1') { + console.error(`[hook-runtime] Cleaned ${tmpRemoved} orphaned .tmp file(s)`); + } + } catch (_tmpErr) { + // Fire-and-forget: never block hook execution + } } return { engine, session, sessionId, sessionsDir, cwd }; @@ -87,6 +137,7 @@ function resolveHookRuntime(input) { function buildHookOutput(xml) { return { hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', additionalContext: xml || '', }, }; diff --git a/.claude/hooks/precompact-session-digest.cjs b/.claude/hooks/precompact-session-digest.cjs index 57b21d9517..34d5008862 100644 --- a/.claude/hooks/precompact-session-digest.cjs +++ b/.claude/hooks/precompact-session-digest.cjs @@ -57,6 +57,8 @@ async function main() { // Same pattern as synapse-engine.cjs — robust against incorrect cwd const runnerPath = path.join( PROJECT_ROOT, + 'node_modules', + 'aios-core', '.aios-core', 'hooks', 'unified', diff --git a/.claude/hooks/synapse-engine.cjs b/.claude/hooks/synapse-engine.cjs index a35811b760..ef6520df04 100644 --- a/.claude/hooks/synapse-engine.cjs +++ b/.claude/hooks/synapse-engine.cjs @@ -66,32 +66,19 @@ async function main() { const output = JSON.stringify(buildHookOutput(result.xml)); - // Write and flush stdout (callback may not exist in mocked environments) const flushed = process.stdout.write(output); if (!flushed) { await new Promise((resolve) => process.stdout.once('drain', resolve)); } } -/** - * Safely exit the process — no-op inside Jest workers to prevent worker crashes. - * @param {number} code - Exit code - */ -function safeExit(code) { - if (process.env.JEST_WORKER_ID) return; - process.exit(code); -} - -/** Entry point runner — sets safety timeout and executes main(). */ +/** Entry point runner — lets Node exit naturally (no process.exit). */ function run() { - const timer = setTimeout(() => safeExit(0), HOOK_TIMEOUT_MS); + const timer = setTimeout(() => {}, HOOK_TIMEOUT_MS); timer.unref(); main() - .then(() => safeExit(0)) - .catch(() => { - // Silent exit — stderr output triggers "hook error" in Claude Code UI - safeExit(0); - }); + .then(() => {}) + .catch(() => {}); } if (require.main === module) run(); diff --git a/.claude/settings.json b/.claude/settings.json index 247e908459..150ce5a364 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,31 +5,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/synapse-engine.cjs\"", - "timeout": 10 - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/code-intel-pretool.cjs\"", - "timeout": 10 - } - ] - } - ], - "PreCompact": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/precompact-session-digest.cjs\"", - "timeout": 10 + "command": "node .claude/hooks/synapse-engine.cjs" } ] } diff --git a/packages/installer/src/wizard/ide-config-generator.js b/packages/installer/src/wizard/ide-config-generator.js index da0c4fdcba..ba54ad389c 100644 --- a/packages/installer/src/wizard/ide-config-generator.js +++ b/packages/installer/src/wizard/ide-config-generator.js @@ -703,10 +703,15 @@ async function copyClaudeHooksFolder(projectRoot) { } /** - * Hook event mapping: fileName → { event, matcher, timeout } + * Hook event mapping: fileName → { event, matcher } * Maps each .cjs hook file to its correct Claude Code event. * Extensible: add new hooks here as they are created. * + * NOTE: timeout is intentionally omitted — Claude Code manages hook timeouts + * natively (default 60s). Overriding with low values (e.g. 10) caused + * premature kills on Windows. Each hook has its own internal safety timeout + * (e.g. synapse-engine.cjs uses 5s via setTimeout + unref). + * * @see Story MIS-3.1 - Fix Session-Digest Hook Registration * @see https://code.claude.com/docs/en/hooks (Claude Code Hooks Documentation) */ @@ -714,17 +719,14 @@ const HOOK_EVENT_MAP = { 'synapse-engine.cjs': { event: 'UserPromptSubmit', matcher: null, - timeout: 10, }, 'code-intel-pretool.cjs': { event: 'PreToolUse', matcher: 'Write|Edit', - timeout: 10, }, 'precompact-session-digest.cjs': { event: 'PreCompact', matcher: null, - timeout: 10, }, }; @@ -732,7 +734,6 @@ const HOOK_EVENT_MAP = { const DEFAULT_HOOK_CONFIG = { event: 'UserPromptSubmit', matcher: null, - timeout: 10, }; /** @@ -759,8 +760,6 @@ async function createClaudeSettingsLocal(projectRoot) { return null; } - const isWindows = process.platform === 'win32'; - let settings = {}; // Merge with existing settings if present @@ -781,7 +780,6 @@ async function createClaudeSettingsLocal(projectRoot) { // Register each .cjs hook file under its correct event for (const hookFileName of hookFiles) { - const hookFilePath = path.join(hooksDir, hookFileName); const hookConfig = HOOK_EVENT_MAP[hookFileName] || DEFAULT_HOOK_CONFIG; const eventName = hookConfig.event; @@ -790,10 +788,10 @@ async function createClaudeSettingsLocal(projectRoot) { settings.hooks[eventName] = []; } - // Windows workaround: $CLAUDE_PROJECT_DIR has known bug on Windows (GH #6023/#5814) - const hookCommand = isWindows - ? `node "${hookFilePath.replace(/\\/g, '\\\\')}"` // Absolute path with escaped backslashes - : `node "$CLAUDE_PROJECT_DIR/.claude/hooks/${hookFileName}"`; + // Use relative path — works on all platforms, no $CLAUDE_PROJECT_DIR bugs + // (GH #6023/#5814), no fragile absolute Windows paths with escaped backslashes. + // Claude Code resolves relative paths from the project root (cwd). + const hookCommand = `node .claude/hooks/${hookFileName}`; // Check if this hook is already registered under this event const hookBaseName = hookFileName.replace('.cjs', ''); @@ -810,7 +808,6 @@ async function createClaudeSettingsLocal(projectRoot) { { type: 'command', command: hookCommand, - timeout: hookConfig.timeout, }, ], }; diff --git a/packages/installer/tests/unit/artifact-copy-pipeline/artifact-copy-pipeline.test.js b/packages/installer/tests/unit/artifact-copy-pipeline/artifact-copy-pipeline.test.js index cb440cd09a..0e87dedfed 100644 --- a/packages/installer/tests/unit/artifact-copy-pipeline/artifact-copy-pipeline.test.js +++ b/packages/installer/tests/unit/artifact-copy-pipeline/artifact-copy-pipeline.test.js @@ -174,7 +174,7 @@ describe('artifact-copy-pipeline (Story INS-4.3)', () => { expect(config).toBeDefined(); expect(config.event).toBe('UserPromptSubmit'); expect(config.matcher).toBeNull(); - expect(config.timeout).toBe(10); + expect(config.timeout).toBeUndefined(); // Claude Code manages timeouts }); test('maps code-intel-pretool.cjs to PreToolUse with Write|Edit matcher', () => { @@ -182,7 +182,7 @@ describe('artifact-copy-pipeline (Story INS-4.3)', () => { expect(config).toBeDefined(); expect(config.event).toBe('PreToolUse'); expect(config.matcher).toBe('Write|Edit'); - expect(config.timeout).toBe(10); + expect(config.timeout).toBeUndefined(); // Claude Code manages timeouts }); test('maps precompact-session-digest.cjs to PreCompact', () => { @@ -190,7 +190,7 @@ describe('artifact-copy-pipeline (Story INS-4.3)', () => { expect(config).toBeDefined(); expect(config.event).toBe('PreCompact'); expect(config.matcher).toBeNull(); - expect(config.timeout).toBe(10); + expect(config.timeout).toBeUndefined(); // Claude Code manages timeouts }); test('covers all 3 known hooks', () => { @@ -204,7 +204,7 @@ describe('artifact-copy-pipeline (Story INS-4.3)', () => { test('DEFAULT_HOOK_CONFIG falls back to UserPromptSubmit', () => { expect(DEFAULT_HOOK_CONFIG.event).toBe('UserPromptSubmit'); expect(DEFAULT_HOOK_CONFIG.matcher).toBeNull(); - expect(DEFAULT_HOOK_CONFIG.timeout).toBe(10); + expect(DEFAULT_HOOK_CONFIG.timeout).toBeUndefined(); // Claude Code manages timeouts }); });