1717 */
1818
1919import { join , resolve } from "node:path" ;
20- import { readdirSync , readFileSync , rmSync } from "node:fs" ;
20+ import { readdirSync , readFileSync , rmSync , openSync , closeSync , unlinkSync , statSync } from "node:fs" ;
2121import { randomUUID } from "node:crypto" ;
2222import { ensureDir , writeJson , readJson , pathExists , atomicWrite , removeFile , readSafe } from "./engine.js" ;
2323import { logSessionStart } from "./worklog.js" ;
@@ -478,6 +478,54 @@ export function clearLegacyActiveSession(projectPath: string): void {
478478 removeFile ( legacyActiveSessionPath ( projectPath ) ) ;
479479}
480480
481+ /**
482+ * Filesystem lock for session creation to prevent parallel hooks from
483+ * creating duplicate AXME sessions for the same Claude session.
484+ *
485+ * Uses O_EXCL (atomic create-or-fail) as a cross-process mutex.
486+ * On contention: spin-wait up to 500ms, then re-read the mapping.
487+ * Stale lock (>5s) auto-cleaned to handle crashed lock holders.
488+ */
489+ const LOCK_STALE_MS = 5_000 ;
490+ const LOCK_WAIT_MS = 500 ;
491+ const LOCK_POLL_MS = 50 ;
492+
493+ // Exported for testing
494+ export function sessionLockPath ( projectPath : string , claudeSessionId : string ) : string {
495+ return join ( activeSessionsDir ( projectPath ) , `${ claudeSessionId } .lock` ) ;
496+ }
497+
498+ export function acquireLock ( projectPath : string , claudeSessionId : string ) : boolean {
499+ const lp = sessionLockPath ( projectPath , claudeSessionId ) ;
500+ ensureDir ( activeSessionsDir ( projectPath ) ) ;
501+ try {
502+ // Clean stale lock from crashed process
503+ try {
504+ const st = statSync ( lp ) ;
505+ if ( Date . now ( ) - st . mtimeMs > LOCK_STALE_MS ) unlinkSync ( lp ) ;
506+ } catch { }
507+ const fd = openSync ( lp , "wx" ) ;
508+ closeSync ( fd ) ;
509+ return true ;
510+ } catch {
511+ return false ; // EEXIST or other - graceful degradation
512+ }
513+ }
514+
515+ export function releaseLock ( projectPath : string , claudeSessionId : string ) : void {
516+ try { unlinkSync ( sessionLockPath ( projectPath , claudeSessionId ) ) ; } catch { }
517+ }
518+
519+ function waitForLock ( projectPath : string , claudeSessionId : string ) : boolean {
520+ const deadline = Date . now ( ) + LOCK_WAIT_MS ;
521+ while ( Date . now ( ) < deadline ) {
522+ if ( acquireLock ( projectPath , claudeSessionId ) ) return true ;
523+ const start = Date . now ( ) ;
524+ while ( Date . now ( ) - start < LOCK_POLL_MS ) { /* spin */ }
525+ }
526+ return false ;
527+ }
528+
481529/**
482530 * Ensure an AXME session exists for the given Claude session. Lazy-created
483531 * on the first hook call that knows its Claude session_id.
@@ -507,6 +555,7 @@ export function ensureAxmeSessionForClaude(
507555 * the existing session id instead of creating a fresh empty-tail session. */
508556 toolName ?: string ,
509557) : string {
558+ // Fast path: live mapping exists, just reuse it (no lock needed).
510559 const existing = readClaudeSessionMapping ( projectPath , claudeSessionId ) ;
511560 if ( existing ) {
512561 const existingSession = loadSession ( projectPath , existing ) ;
@@ -515,43 +564,56 @@ export function ensureAxmeSessionForClaude(
515564 existingSession . auditedAt != null ||
516565 ( existingSession . pid != null && ! isPidAlive ( existingSession . pid ) ) ;
517566 if ( ! isStale ) {
518- // Live mapping — attach transcript and reuse.
519567 attachClaudeSession ( projectPath , existing , {
520568 id : claudeSessionId ,
521569 transcriptPath,
522570 role : "main" ,
523571 } ) ;
524- // Always refresh ownerPpid — after VS Code reload the Claude Code
525- // PID changes but the old process may still be alive (different
526- // window or zombie). The MCP server matches by ownerPpid === OWN_PPID,
527- // so the mapping must point to the current Claude Code instance.
528572 writeClaudeSessionMapping ( projectPath , claudeSessionId , existing ) ;
529573 return existing ;
530574 }
531- // Read-only tools (Read/Glob/Grep) should not create fresh sessions from
532- // stale mappings — that produces empty "tail" sessions with 0 extractions.
533- // Return the stale id instead; the next mutation tool will create a fresh one.
575+ // Read-only tools should not create fresh sessions from stale mappings.
534576 const READ_ONLY_TOOLS = [ "Read" , "Glob" , "Grep" ] ;
535- if ( toolName && READ_ONLY_TOOLS . includes ( toolName ) && existing ) {
577+ if ( toolName && READ_ONLY_TOOLS . includes ( toolName ) ) {
536578 return existing ;
537579 }
538- // Stale mapping: log once and fall through to create a fresh session,
539- // which will overwrite the mapping file below.
540- process . stderr . write (
541- `AXME: stale mapping for Claude session ${ claudeSessionId } → ` +
542- `AXME ${ existing } (audited=${ existingSession ?. auditedAt ?? "no" } , ` +
543- `pid=${ existingSession ?. pid ?? "?" } ). Creating fresh AXME session.\n` ,
544- ) ;
545580 }
546- const axmeSession = createSession ( projectPath ) ;
547- try { logSessionStart ( projectPath , axmeSession . id ) ; } catch { }
548- writeClaudeSessionMapping ( projectPath , claudeSessionId , axmeSession . id ) ;
549- attachClaudeSession ( projectPath , axmeSession . id , {
550- id : claudeSessionId ,
551- transcriptPath,
552- role : "main" ,
553- } ) ;
554- return axmeSession . id ;
581+
582+ // Slow path: need to create a new session (stale mapping OR first time).
583+ // Acquire filesystem lock to prevent parallel hooks from each creating one.
584+ const gotLock = waitForLock ( projectPath , claudeSessionId ) ;
585+ try {
586+ // Re-check inside lock — another process may have won the race.
587+ // If a DIFFERENT mapping appeared (created by the lock winner), use it
588+ // unconditionally — the winner just created it, so it's fresh by definition.
589+ // Do NOT re-run stale checks here: the winner's process may have already
590+ // exited (test workers, short-lived hooks), making the pid look dead.
591+ const recheck = readClaudeSessionMapping ( projectPath , claudeSessionId ) ;
592+ if ( recheck && recheck !== existing ) {
593+ attachClaudeSession ( projectPath , recheck , { id : claudeSessionId , transcriptPath, role : "main" } ) ;
594+ return recheck ;
595+ }
596+ // We won the race (or lock timed out) — create fresh session.
597+ if ( existing ) {
598+ const existingSession = loadSession ( projectPath , existing ) ;
599+ process . stderr . write (
600+ `AXME: stale mapping for Claude session ${ claudeSessionId } → ` +
601+ `AXME ${ existing } (audited=${ existingSession ?. auditedAt ?? "no" } , ` +
602+ `pid=${ existingSession ?. pid ?? "?" } ). Creating fresh AXME session.\n` ,
603+ ) ;
604+ }
605+ const axmeSession = createSession ( projectPath ) ;
606+ try { logSessionStart ( projectPath , axmeSession . id ) ; } catch { }
607+ writeClaudeSessionMapping ( projectPath , claudeSessionId , axmeSession . id ) ;
608+ attachClaudeSession ( projectPath , axmeSession . id , {
609+ id : claudeSessionId ,
610+ transcriptPath,
611+ role : "main" ,
612+ } ) ;
613+ return axmeSession . id ;
614+ } finally {
615+ if ( gotLock ) releaseLock ( projectPath , claudeSessionId ) ;
616+ }
555617}
556618
557619// --- Legacy single-file API (DEPRECATED, kept for backward compatibility) ---
0 commit comments