From cd5329bfc951b36612313ae5ae6f1195231650ba Mon Sep 17 00:00:00 2001 From: luyua9 Date: Tue, 15 Sep 2026 12:35:11 +0800 Subject: [PATCH 1/4] fix(cursor-hook): keep npx fallback under the postToolUse host budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hooks.json grants postToolUse a 10s budget. The npx fallback branch added a flat +5s on top of the inner 7s spawnSync budget, reaching 12s — past the host deadline. When the local gitnexus package is absent and npx cold-starts, Cursor kills the hook before the child finishes, so the augmentation result is always lost on exactly the machines the fallback exists for. Cap the fallback at 10000 - 2000 = 8000ms (host budget minus headroom for node startup and the final stdout write) via Math.min, and add a source-level regression test asserting the cap wiring so the fallback can never exceed the host budget again. --- .../hooks/gitnexus-hook.cjs | 16 +++++++++++++++- gitnexus/test/unit/cursor-hook.test.ts | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs index ffddd163e..f97cd2d56 100644 --- a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -339,6 +339,16 @@ function resolveCliPath() { } } +// The Cursor host enforces hooks.json's postToolUse `timeout` (10s) against +// the whole hook process. The npx fallback used to add a flat +5s on top of +// the inner budget (7000 → 12000ms), past the host deadline, so Cursor killed +// the hook before the child finished and the augmentation was always lost on +// cold-start machines — the exact scenario the fallback exists for. Cap the +// fallback under the host budget, leaving headroom for node startup and the +// final stdout write. +const CURSOR_HOST_BUDGET_MS = 10000; +const CURSOR_NPX_HEADROOM_MS = 2000; + function runGitNexusCli(cliPath, args, cwd, timeout) { const isWin = process.platform === 'win32'; if (cliPath) { @@ -350,9 +360,13 @@ function runGitNexusCli(cliPath, args, cwd, timeout) { windowsHide: true, }); } + const npxTimeout = Math.min( + timeout + 5000, + CURSOR_HOST_BUDGET_MS - CURSOR_NPX_HEADROOM_MS, + ); 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/test/unit/cursor-hook.test.ts b/gitnexus/test/unit/cursor-hook.test.ts index 4fe58eafa..2bac8a643 100644 --- a/gitnexus/test/unit/cursor-hook.test.ts +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -441,6 +441,25 @@ 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'); + + it('caps the npx fallback timeout below the hooks.json postToolUse budget', () => { + // hooks.json grants postToolUse 10s. The fallback branch adds +5s on top + // of the inner 7s budget, which used to reach 12s — past the host + // deadline, so Cursor killed the hook and cold-start users never saw + // augmentation. Assert the cap wiring exists and the effective fallback + // budget (7000 + 5000, clamped by 10000 - 2000) can never exceed the + // host budget again. + expect(source).toContain('CURSOR_HOST_BUDGET_MS = 10000'); + expect(source).toContain('CURSOR_NPX_HEADROOM_MS = 2000'); + expect(source).toContain('Math.min('); + expect(source).not.toMatch(/timeout:\s*timeout \+ 5000/); + }); +}); + // ─── Source code regression: concurrency guard (#1486) ───────────── describe('Cursor hook concurrency guard', () => { From db14131e87660b5e2c0158b1a0ce9e40510d2960 Mon Sep 17 00:00:00 2001 From: luyua9 Date: Tue, 15 Sep 2026 13:52:01 +0800 Subject: [PATCH 2/4] style: collapse Math.min call to satisfy prettier formatting --- gitnexus-cursor-integration/hooks/gitnexus-hook.cjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs index f97cd2d56..7e4f2e384 100644 --- a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -360,10 +360,7 @@ function runGitNexusCli(cliPath, args, cwd, timeout) { windowsHide: true, }); } - const npxTimeout = Math.min( - timeout + 5000, - CURSOR_HOST_BUDGET_MS - CURSOR_NPX_HEADROOM_MS, - ); + const npxTimeout = Math.min(timeout + 5000, CURSOR_HOST_BUDGET_MS - CURSOR_NPX_HEADROOM_MS); return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], { encoding: 'utf-8', timeout: npxTimeout, From 6667b9096cad1cb82ffd095533d3b2421a7533fa Mon Sep 17 00:00:00 2001 From: luyua9 Date: Tue, 15 Sep 2026 18:32:34 +0800 Subject: [PATCH 3/4] fix(cursor-hook): size npx fallback from the real host budget and KILL the npx tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #3292 (P1 + 2xP2): - hooks.json now ships postToolUse timeout 60s: a cold `npx -y gitnexus` (download + install) can never fit inside 10s, so shrinking the child timer could only convert a late success into a guaranteed failure. - runGitNexusCli derives the npx budget at call time: hooks.json seconds x1000, minus 5s headroom for node startup and the stdout write, minus the hook's own elapsed time — and floors at 1s. - The npx grandchild (npx -> gitnexus) is wrapped in a coreutils `timeout -s KILL -k 1` guard resolved from standard locations (GITNEXUS_HOOK_TIMEOUT_PATH override / disabled honored), mirroring the Claude adapter: a plain SIGTERM killed only the obedient npx parent and left the grandchild holding the LadybugDB lock after the hook released its slot. Windows stays unwrapped (npx is a .cmd there). - The regression tests now read the shipped hooks.json, assert the budget derivation is actually wired into the spawn (sliced function body), and assert the -s KILL wrap instead of detached substring checks. --- .../hooks/gitnexus-hook.cjs | 103 ++++++++++++++++-- gitnexus-cursor-integration/hooks/hooks.json | 2 +- gitnexus/test/unit/cursor-hook.test.ts | 44 ++++++-- 3 files changed, 127 insertions(+), 22 deletions(-) diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs index 7e4f2e384..66b17f84f 100644 --- a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -339,15 +339,66 @@ function resolveCliPath() { } } -// The Cursor host enforces hooks.json's postToolUse `timeout` (10s) against -// the whole hook process. The npx fallback used to add a flat +5s on top of -// the inner budget (7000 → 12000ms), past the host deadline, so Cursor killed -// the hook before the child finished and the augmentation was always lost on -// cold-start machines — the exact scenario the fallback exists for. Cap the -// fallback under the host budget, leaving headroom for node startup and the -// final stdout write. -const CURSOR_HOST_BUDGET_MS = 10000; -const CURSOR_NPX_HEADROOM_MS = 2000; +// 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'; @@ -360,7 +411,39 @@ function runGitNexusCli(cliPath, args, cwd, timeout) { windowsHide: true, }); } - const npxTimeout = Math.min(timeout + 5000, CURSOR_HOST_BUDGET_MS - CURSOR_NPX_HEADROOM_MS); + 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: npxTimeout, 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 2bac8a643..9e875cb3f 100644 --- a/gitnexus/test/unit/cursor-hook.test.ts +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -445,18 +445,40 @@ describe('Cursor hook debug logging', () => { 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('caps the npx fallback timeout below the hooks.json postToolUse budget', () => { - // hooks.json grants postToolUse 10s. The fallback branch adds +5s on top - // of the inner 7s budget, which used to reach 12s — past the host - // deadline, so Cursor killed the hook and cold-start users never saw - // augmentation. Assert the cap wiring exists and the effective fallback - // budget (7000 + 5000, clamped by 10000 - 2000) can never exceed the - // host budget again. - expect(source).toContain('CURSOR_HOST_BUDGET_MS = 10000'); - expect(source).toContain('CURSOR_NPX_HEADROOM_MS = 2000'); - expect(source).toContain('Math.min('); - expect(source).not.toMatch(/timeout:\s*timeout \+ 5000/); + 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).toContain('"-s"'); + expect(fnBody).toContain('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;'); }); }); From 190ab0d4f62a275b327b429116bf10ff3ef599a7 Mon Sep 17 00:00:00 2001 From: luyua9 Date: Tue, 15 Sep 2026 19:50:04 +0800 Subject: [PATCH 4/4] test(cursor-hook): accept either quote style in the -s KILL assertions CI runs prettier before vitest, which normalizes the wrapped spawnSync arguments to single quotes; the double-quoted substring assertions then fail on all platforms. Match both quote styles instead. --- gitnexus/test/unit/cursor-hook.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gitnexus/test/unit/cursor-hook.test.ts b/gitnexus/test/unit/cursor-hook.test.ts index 9e875cb3f..88b241a8a 100644 --- a/gitnexus/test/unit/cursor-hook.test.ts +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -470,8 +470,8 @@ describe('Cursor hook npx fallback host budget', () => { 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).toContain('"-s"'); - expect(fnBody).toContain('KILL'); + expect(fnBody).toMatch(/['"]-s['"]/); + expect(fnBody).toMatch(/['"]KILL['"]/); }); it('reads the shipped timeout value, not a detached literal', () => {