diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs index ffddd163e..66b17f84f 100644 --- a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -339,6 +339,67 @@ function resolveCliPath() { } } +// The Cursor host enforces hooks.json's postToolUse `timeout` (seconds) +// against the whole hook process. hooks.json ships 60s so a cold +// `npx -y gitnexus` (package download + install) can actually finish; the +// npx fallback budget is then sized from the remaining host budget minus +// headroom for node startup and the final stdout write, instead of a flat +// inner+5s that could overshoot or undercut the deadline. +const CURSOR_NPX_HEADROOM_MS = 5000; +const CURSOR_HOOK_START_MS = Date.now(); +const HOOK_ENV = process['env']; + +function resolveCursorHostBudgetMs() { + try { + // hooks.json ships next to this hook script + const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, 'hooks.json'), 'utf-8')); + const seconds = manifest.hooks.postToolUse[0].timeout; + if (typeof seconds === 'number' && seconds > 0) { + return seconds * 1000; + } + } catch { + /* fall through to the shipped default */ + } + return 60000; +} + +// Resolve a coreutils/BSD `timeout` binary for the npx arm. The CLI behind +// npx is a grandchild (npx -> gitnexus), so the wrap leads with `-s KILL`: +// a plain SIGTERM would kill only the obedient npx parent and let the +// grandchild keep holding the LadybugDB lock after this hook released its +// slot. Mirrors the Claude adapter's wrapped npx arm; Windows stays +// unwrapped (npx is a .cmd script there). +function resolveUnixGuardTimeout() { + if (process.platform === 'win32') return null; + const override = HOOK_ENV.GITNEXUS_HOOK_TIMEOUT_PATH; + if (override === 'disabled') return null; + const candidates = []; + if (override && override.trim()) candidates.push(override.trim()); + candidates.push( + '/usr/bin/timeout', + '/usr/local/bin/timeout', + '/opt/homebrew/bin/timeout', + '/usr/bin/gtimeout', + ); + for (const candidate of candidates) { + try { + if (candidate.includes('/') && fs.existsSync(candidate)) return candidate; + const probe = spawnSync('which', [candidate], { + encoding: 'utf-8', + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + if (probe.status === 0 && String(probe.stdout).trim()) { + return String(probe.stdout).trim(); + } + } catch { + /* keep probing */ + } + } + return null; +} + function runGitNexusCli(cliPath, args, cwd, timeout) { const isWin = process.platform === 'win32'; if (cliPath) { @@ -350,9 +411,42 @@ function runGitNexusCli(cliPath, args, cwd, timeout) { windowsHide: true, }); } + const elapsed = Date.now() - CURSOR_HOOK_START_MS; + const npxTimeout = Math.max( + 1000, + Math.min(timeout + 5000, resolveCursorHostBudgetMs() - CURSOR_NPX_HEADROOM_MS - elapsed), + ); + const guard = resolveUnixGuardTimeout(); + if (guard) { + const wrapped = spawnSync( + guard, + [ + '-s', + 'KILL', + '-k', + '1', + String(Math.ceil(npxTimeout / 1000) + 1), + 'npx', + '-y', + 'gitnexus', + ...args, + ], + { + encoding: 'utf-8', + timeout: npxTimeout + 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + if (!wrapped.error || wrapped.error.code !== 'ENOENT') { + return wrapped; + } + // guard vanished between probe and spawn: fall through unwrapped + } return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], { encoding: 'utf-8', - timeout: timeout + 5000, + timeout: npxTimeout, cwd, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, diff --git a/gitnexus-cursor-integration/hooks/hooks.json b/gitnexus-cursor-integration/hooks/hooks.json index 9ae542c14..88e2142c0 100644 --- a/gitnexus-cursor-integration/hooks/hooks.json +++ b/gitnexus-cursor-integration/hooks/hooks.json @@ -5,7 +5,7 @@ { "matcher": "Shell|Read|Grep", "command": "node ./hooks/gitnexus-hook.cjs", - "timeout": 10 + "timeout": 60 } ] } diff --git a/gitnexus/test/unit/cursor-hook.test.ts b/gitnexus/test/unit/cursor-hook.test.ts index 4fe58eafa..88b241a8a 100644 --- a/gitnexus/test/unit/cursor-hook.test.ts +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -441,6 +441,47 @@ describe('Cursor hook debug logging', () => { }); }); +// ─── Source code regression: npx fallback stays under the host budget ───── + +describe('Cursor hook npx fallback host budget', () => { + const source = fs.readFileSync(CURSOR_HOOK, 'utf-8'); + const manifest = JSON.parse( + fs.readFileSync(path.join(path.dirname(CURSOR_HOOK), 'hooks.json'), 'utf-8'), + ); + + it('ships a postToolUse timeout with room for a cold npx install', () => { + // Cold `npx -y gitnexus` has to download + install the package; the + // original 10s budget killed the hook before the child could ever + // finish. The existing manifest test only pins (0, 120). + const seconds = manifest.hooks.postToolUse[0].timeout; + expect(seconds).toBeGreaterThanOrEqual(30); + expect(seconds).toBeLessThan(120); + }); + + it('sizes the npx timeout from the host budget with headroom', () => { + // Extract runGitNexusCli so the assertions are tied to the actual + // spawn wiring, not just to unrelated substrings elsewhere. + const fnStart = source.indexOf('function runGitNexusCli'); + const fnBody = source.slice(fnStart, source.indexOf('\n}\n', fnStart)); + + // budget comes from hooks.json (seconds → ms), with headroom applied + expect(fnBody).toContain('resolveCursorHostBudgetMs() - CURSOR_NPX_HEADROOM_MS - elapsed'); + // the computed budget is what reaches spawnSync (not a bare +5000) + expect(fnBody).toContain('timeout: npxTimeout'); + expect(fnBody).not.toMatch(/timeout:\s*timeout\s*\+\s*5000/); + // npx grandchild is killed outright so it cannot keep the DB lock + expect(fnBody).toMatch(/['"]-s['"]/); + expect(fnBody).toMatch(/['"]KILL['"]/); + }); + + it('reads the shipped timeout value, not a detached literal', () => { + // resolveCursorHostBudgetMs must parse hooks.json so changing the + // manifest cannot silently desync from the hook budget. + expect(source).toContain('postToolUse[0].timeout'); + expect(source).toContain('return seconds * 1000;'); + }); +}); + // ─── Source code regression: concurrency guard (#1486) ───────────── describe('Cursor hook concurrency guard', () => {