#!/usr/bin/env node /** * enforce-parallel-session-lock — PreToolUse wrapper around the pure * parallel-session-lock module (router-gate v4 Stream H Task 7). * * Prevents two Claude sessions on the same workspace from concurrently * mutating files. When session B tries a mutating tool while session A * holds a fresh (non-stale) lock, B is blocked with a message naming A's * pid for human triage. * * Activation: settings.json registration is deferred to Phase H-α/H-β * batch step. main() is a no-op (exit 0) until then. */ import { acquire, release, computeWorkspaceHash, isProcessAlive } from './parallel-session-lock.mjs'; import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs'; /** * Pure decision: given an acquire() result, decide block/allow. * * @param {object} args * @param {object|null|undefined} args.acquireResult - from parallel-session-lock.acquire() * @param {string} args.sessionId - current session id * @returns {{block: boolean, reason?: string}} */ export function decide({ acquireResult, sessionId }) { // Fail-open if no acquire result (treat as internal error — never lockout). if (!acquireResult || typeof acquireResult !== 'object') return { block: false }; if (acquireResult.acquired) return { block: false }; const holder = acquireResult.holder || {}; return { block: true, reason: `parallel session lock held by ${holder.session_id || 'unknown'} (pid ${holder.pid || '?'}) — wait or close that session first`, }; } /** * PreToolUse wiring: acquire (or same-session refresh / stale takeover) the lock, * then decide block/allow. I/O injected for testability. * * @returns {{block: boolean, reason?: string}} */ export function runAcquireDecision({ event, now, pid, cwd, readLock, writeLock, isPidAlive = isProcessAlive }) { const sessionId = event && event.session_id; const workspaceHash = computeWorkspaceHash(cwd); // 7.5 (дыра 5): pid-liveness — мёртвый держатель свежего лока перехватывается, не блокирует TTL. const acquireResult = acquire({ sessionId, pid, workspaceHash, now, readLock, writeLock, isPidAlive }); return decide({ acquireResult, sessionId }); } /** * Stop wiring: release the lock if this session owns it (no-op otherwise). * * @returns {{released: boolean}} */ export function runReleaseAction({ event, cwd, readLock, deleteLock }) { const sessionId = event && event.session_id; const workspaceHash = computeWorkspaceHash(cwd); release({ sessionId, workspaceHash, readLock, deleteLock }); return { released: true }; } function lockPathFor(cwd) { return join(runtimeDir(), `session-lock-${computeWorkspaceHash(cwd)}.json`); } function realReadLock(p) { try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; } } function realWriteLock(p, rec) { try { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(rec)); } catch { /* fail-open */ } } function realDeleteLock(p) { try { unlinkSync(p); } catch { /* already gone */ } } async function main() { // Live wiring (point 2, 2026-05-31). PreToolUse (mutating tool) → acquire/refresh // the workspace lock; Stop (no tool_name) → release it. Fail-open on any error so // a lock bug can NEVER wedge the user out of their own session. try { const event = parseEventJson(await readStdin()); const cwd = process.cwd(); const p = lockPathFor(cwd); // Stop event carries no tool_name → release path. if (!event.tool_name) { runReleaseAction({ event, cwd, readLock: () => realReadLock(p), deleteLock: () => realDeleteLock(p) }); return exitDecision({ block: false }); } // PreToolUse on a mutating tool → acquire/refresh, then block/allow. const r = runAcquireDecision({ event, now: Date.now(), pid: process.pid, cwd, readLock: () => realReadLock(p), writeLock: (rec) => realWriteLock(p, rec), }); return exitDecision({ block: r.block, message: r.block ? `[parallel-session-lock] ${r.reason}` : undefined }); } catch { return exitDecision({ block: false }); // fail-open — never lock out } } if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` || (process.argv[1] || '').endsWith('enforce-parallel-session-lock.mjs')) { main().catch(() => process.exit(0)); }