* fix(hooks): cap concurrent augment subprocesses to prevent runaway process spawn (#1486) When Claude Code fires PreToolUse hooks for parallel Grep/Glob/Bash tool calls, each invocation spawned its own `gitnexus augment` subprocess — a Node + LadybugDB cold start that holds resources for several seconds. Under heavy parallel search load (issue #1486: 180+ piled-up processes, load avg > 100), these accumulated faster than they completed because nothing capped concurrent in-flight augments. Add a lockfile-based concurrency guard under `<.gitnexus>/.hook-locks/`: each running hook claims a `<pid>.lock`, the guard counts live PIDs and prunes stale entries (>30s mtime or pid no longer alive), and bails silently when MAX_INFLIGHT (3) is reached. Augment is best-effort enrichment — missing a few fires under burst load is preferable to melting the system. Applied to all three hook variants that spawn augment: - gitnexus/hooks/claude/gitnexus-hook.cjs (npm-installed Claude hook) - gitnexus-claude-plugin/hooks/gitnexus-hook.js (plugin Claude hook) - gitnexus-cursor-integration/hooks/gitnexus-hook.cjs (Cursor hook) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(hooks): make augment concurrency cap a hard cap via atomic slot files Address Claude's review of #1510. The original count-then-claim guard had a TOCTOU window: N hooks could each read `active < MAX_INFLIGHT` between readdirSync and the per-pid `wx` write and all proceed, briefly exceeding the cap. The PR title's "cap" language overstated this. Replace with fixed-name `slot-0.lock` ... `slot-N.lock` under `.hook-locks/`. `O_CREAT|O_EXCL` on a fixed path is OS-atomic — exactly one process wins each slot, so the cap is hard regardless of burst arrival timing. Each slot file contains the owning PID so stale-takeover still works when a hook crashes without releasing. PID liveness is checked before age (Claude's Finding 3): a slow-but-alive hook is never wrongly evicted. The 30s age window only kicks in to defend against PID reuse on a long-abandoned slot, well above the 7s augment timeout so a healthy run never hits it. Also adds the missing concurrency-guard tests to cursor-hook.test.ts (Claude's Finding 2): source-level wiring + dead-PID reclaim + 3-slots-full bail. Previously only the CJS and Plugin variants had test coverage for the guard; the Cursor variant was validated only by code inspection. Tests: 5726 passing, +9 from baseline (1 hard-cap burst test + 4 source regressions in hooks.test.ts; 3 source + 2 integration in cursor-hook.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(hooks): inspect slot mtime + content via single fd (codeql TOCTOU) CodeQL flagged the stale-takeover path in acquireHookSlot as a potential filesystem race (js/file-system-race): statSync(slotPath) followed by readFileSync(slotPath) gives a TOCTOU window where the file could be swapped between the metadata check and the content read. Replace the two separate path-based calls with a single openSync + fstatSync + readSync + closeSync sequence. Both mtime and owner PID now come from the same file descriptor, so the operations are atomic on one inode. No behavioral change beyond closing the race. Applied to all three hook variants (CJS, Plugin, Cursor). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(hooks): distinguish EPERM from ESRCH in PID liveness check Cursor Bugbot caught a contradiction with the stated design: the bare `catch` after `process.kill(owner, 0)` was treating EPERM (process exists but owned by another user) the same as ESRCH (process gone), which would evict a live slot whenever the lock dir straddled user boundaries. Inspect the error code: ESRCH → dead, evict; EPERM → still alive, keep the slot; anything else → assume alive (be conservative under unexpected failure rather than over-evict). Applied to all three hook variants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(hooks): fail closed when lock dir cannot be created Previously the mkdirSync catch in acquireHookSlot returned `() => {}` (a truthy no-op). The caller checks `if (!release) return;` to skip augment when the guard can't be established — but a truthy no-op slipped through that check and let augment spawn unguarded. On a cross-user shared `.gitnexus/` or read-only filesystem, N concurrent hooks would each take that branch and reintroduce the #1486 fan-out the guard exists to prevent. Return `null` instead so the caller's `if (!release) return;` skips augment cleanly. Augment is best-effort enrichment — skipping it when the guard fails is strictly safer than running unguarded. Also clarify the stale-slot comment: PID-liveness wins for slots younger than HOOK_LOCK_STALE_MS, but age is the final arbiter beyond 30s (PID-reuse defense). The previous wording said "PID-liveness wins over age" without qualifying it, which contradicted the >30s branch. Add source-level regression tests in hooks.test.ts and cursor-hook.test.ts asserting acquireHookSlot returns null (not () => {}) on lock-dir failure. Note in the Cursor test file that the 10-spawner burst test is not duplicated because the algorithm is byte-for-byte identical to the CJS hook and already covered there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(hooks): extract lock guard into helper modules Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/04dd20c5-28fd-433a-83cf-ad83fd03fb32 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> |
||
|---|---|---|
| .. | ||
| hooks | ||
| skills | ||
| README.md | ||
GitNexus — Cursor integration
Static config that adds GitNexus knowledge-graph augmentation and skill files to Cursor.
Hooks require Cursor 2.4+. Earlier versions don't expose
postToolUseand the hook will silently no-op.
What you get
| Layer | What it does | How it's installed |
|---|---|---|
| MCP | gitnexus MCP server with 16 tools (query, context, impact, detect_changes, rename, …) |
npx gitnexus setup writes ~/.cursor/mcp.json automatically. |
| Skills | /gitnexus-exploring, /gitnexus-debugging, /gitnexus-impact-analysis, /gitnexus-refactoring, /gitnexus-pr-review markdown skills |
npx gitnexus setup copies them to ~/.cursor/skills/gitnexus/. |
| Hooks (this README) | postToolUse hook that enriches Shell / Read / Grep tool calls with graph context — same augmentation Claude Code gets |
Manual — copy the files described below into your project's .cursor/. |
Hook install
Cursor 2.4+ reads .cursor/hooks.json from the project root and runs hook commands with the project root as the working directory (docs).
From this repo's gitnexus-cursor-integration/hooks/, copy the files below into your project root:
<your-project>/
├── .cursor/
│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json
└── hooks/
├── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
└── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs
Equivalent shell commands (run from your project root, with $GITNEXUS_REPO pointing at a clone of this repo):
mkdir -p .cursor hooks
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hook-lock.cjs" hooks/hook-lock.cjs
If you already have a .cursor/hooks.json, merge the hooks.postToolUse array rather than overwriting.
Verify
- Index the project:
npx gitnexus analyze - Reload the Cursor window so it picks up the new hook config.
- Ask the agent something that triggers
Read/Grep/Shell rg. You should see a[GitNexus]block appended to the tool result. - Diagnose silent no-ops by setting
GITNEXUS_DEBUG=1in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names.
What's installed manually vs. automated
| Step | Automated by gitnexus setup? |
|---|---|
~/.cursor/mcp.json |
✅ |
~/.cursor/skills/gitnexus/* |
✅ |
<project>/.cursor/hooks.json + <project>/hooks/gitnexus-hook.cjs + <project>/hooks/hook-lock.cjs |
❌ — copy manually (see above) |
Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global.
Hook contract
The hook receives a JSON event on stdin matching Cursor 2.4's postToolUse shape:
{
"tool_name": "Grep" | "Read" | "Shell",
"tool_input": { /* tool-specific */ },
"tool_output": { /* optional */ },
"cwd": "/absolute/path/to/project"
}
It writes augmentation context to stdout as:
{ "additional_context": "[GitNexus] …" }
Empty stdout means "no augmentation, continue normally" — the hook never blocks the tool.
Pattern extraction per tool
| Tool | Pattern source | Notes |
|---|---|---|
Grep |
tool_input.query (also pattern, regex, q, search, searchQuery) |
Last-resort fallback: longest string value in tool_input (≥ 3 chars). |
Read |
basename of tool_input.target_file (also file_path, filePath, path, file), stripped to identifier characters |
auth/handler.ts → handler. |
Shell |
First positional argument after rg / grep in tool_input.command |
Best-effort tokenizer; quoted multi-word patterns (rg "User Service") extract the first word only. |
Troubleshooting
- Nothing happens — Confirm Cursor is on 2.4+ and the project root has
.cursor/hooks.jsonplus both hook files athooks/gitnexus-hook.cjsandhooks/hook-lock.cjs. Thennpx gitnexus listto confirm the project is indexed. gitnexusnot found — The hook prefers a locally-resolvablegitnexus/dist/cli/index.jsand falls back tonpx -y gitnexus. Install globally withnpm i -g gitnexusto skip the npx cold-start latency.- Wrong pattern extracted — Set
GITNEXUS_DEBUG=1and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actualtool_inputfield names against the table above. If they differ, file an issue with the captured payload.