Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 55 additions & 4 deletions .aios-core/core/synapse/runtime/hook-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Comment on lines +16 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid deleting live .tmp files during concurrent atomic writes.

cleanOrphanTmpFiles() deletes every file containing .tmp. immediately. Concurrent hook processes can still be using fresh temp files, causing rename failures/data loss. Add an age threshold (and stricter filename match) before deletion.

🛠️ Suggested safeguard
+const ORPHAN_TMP_MIN_AGE_MS = 60 * 1000;
@@
 function cleanOrphanTmpFiles(sessionsDir) {
   try {
     const files = fs.readdirSync(sessionsDir);
     let removed = 0;
     for (const f of files) {
-      if (f.includes('.tmp.')) {
+      if (/\.tmp\./.test(f)) {
+        const filePath = path.join(sessionsDir, f);
         try { fs.unlinkSync(path.join(sessionsDir, f)); removed++; }
         catch (_) { /* locked/permissions — skip */ }
+        try {
+          const stat = fs.statSync(filePath);
+          if ((Date.now() - stat.mtimeMs) < ORPHAN_TMP_MIN_AGE_MS) continue;
+          fs.unlinkSync(filePath);
+          removed++;
+        } catch (_) { /* locked/permissions/stat failure — skip */ }
       }
     }
     return removed;
   } catch (_) { return 0; }
 }

As per coding guidelines ".aios-core/core/**: Check for race conditions in orchestration modules (lock-manager, session-state)."

Also applies to: 112-120

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.aios-core/core/synapse/runtime/hook-runtime.js around lines 16 - 27,
cleanOrphanTmpFiles currently unconditionally deletes any filename containing
".tmp." which can remove temp files still in use by concurrent atomic writers;
update the function (cleanOrphanTmpFiles and the similar logic used elsewhere)
to only target strictly-matching temp filenames (e.g., a regex like
/\.tmp\.[^.]+$/ or the project's temp-naming convention) and add an age
threshold check using fs.statSync(path).mtime (skip files younger than a safe
window, e.g., 1–5 minutes) before unlinking; also catch and log stat errors and
continue so locked/permissioned files are skipped safely.

}

/**
* Read stale session TTL from core-config.yaml.
* Falls back to DEFAULT_STALE_TTL_HOURS (168h = 7 days).
Expand All @@ -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 {{
Expand All @@ -46,15 +75,26 @@ 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(
path.join(cwd, '.aios-core', 'core', 'synapse', 'engine.js'),
);

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)
Expand All @@ -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 };
Expand All @@ -87,6 +137,7 @@ function resolveHookRuntime(input) {
function buildHookOutput(xml) {
return {
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: xml || '',
},
};
Expand Down
2 changes: 2 additions & 0 deletions .claude/hooks/precompact-session-digest.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
21 changes: 4 additions & 17 deletions .claude/hooks/synapse-engine.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
26 changes: 1 addition & 25 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Expand Down
23 changes: 10 additions & 13 deletions packages/installer/src/wizard/ide-config-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -703,36 +703,37 @@ 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)
*/
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,
},
};

/** Default event config for unmapped hooks (backwards compatible). */
const DEFAULT_HOOK_CONFIG = {
event: 'UserPromptSubmit',
matcher: null,
timeout: 10,
};

/**
Expand All @@ -759,8 +760,6 @@ async function createClaudeSettingsLocal(projectRoot) {
return null;
}

const isWindows = process.platform === 'win32';

let settings = {};

// Merge with existing settings if present
Expand All @@ -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;

Expand All @@ -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', '');
Expand All @@ -810,7 +808,6 @@ async function createClaudeSettingsLocal(projectRoot) {
{
type: 'command',
command: hookCommand,
timeout: hookConfig.timeout,
},
],
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,23 +174,23 @@ 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', () => {
const config = HOOK_EVENT_MAP['code-intel-pretool.cjs'];
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', () => {
const config = HOOK_EVENT_MAP['precompact-session-digest.cjs'];
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', () => {
Expand All @@ -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
});
});

Expand Down
Loading