Hooks intermittently non-functional on Windows — preToolUse worked then stopped after hooks.json edit

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

Summary: I have a project-level .cursor/hooks.json with 8 hooks for a build enforcement system. The preToolUse hook was confirmed working at the start of a session (it blocked file writes and returned {permission: “deny”}). During the session, I added a second preToolUse entry with a different matcher. After that modification — and across 4 Cursor restarts — no hooks fire at all, including the originally working preToolUse.

Timeline:

Session started with hooks.json containing one preToolUse entry (matcher: “Write|StrReplace|Delete”). This hook worked — it blocked writes to implementation files and returned {permission: “deny”} with messages visible to the agent.

During the session, I added a second preToolUse entry (matcher: “Shell|CallMcpTool”) for evidence logging. After this change, neither preToolUse entry fired.

I tried multiple approaches:

Reverted hooks.json to original (single preToolUse entry) — no hooks fire
Wrapped all commands in powershell -NoProfile -ExecutionPolicy Bypass -Command “node script.js” — no hooks fire
Created a .ps1 wrapper script and used powershell -File .cursor/hooks/wrapper.ps1 — this worked once (received full JSON payload with conversation_id, model, tool_name, tool_input) but stopped working after the next hooks.json edit
Restarted Cursor 4 times — no recovery
Current state: hooks.json is restored to original format. No hooks fire. Writing to a file that should be blocked by preToolUse (with failClosed: true) goes through without intervention.

How I confirmed hooks aren’t firing:

Added fs.appendFileSync at module level (line 3, before any function) in hook scripts — file never created by Cursor, but IS created when script is run manually via echo ‘{}’ | node .cursor/hooks/post-tool-nudge.js
Wrote to src/lib/fund/test.ts which the preToolUse gate should block (no active gate file exists, failClosed: true). Write was allowed through.
All scripts pass node -c syntax check
All scripts run correctly when invoked manually and produce valid JSON output
The .cursor/hooks/state/ directory exists and is writable
Current hooks.json:

{
“version”: 1,
“hooks”: {
“sessionStart”: [
{ “command”: “node .cursor/hooks/session-init.js”, “timeout”: 5 }
],
“preToolUse”: [
{
“command”: “node .cursor/hooks/pre-dev-gate.js”,
“matcher”: “Write|StrReplace|Delete”,
“failClosed”: true,
“timeout”: 5
}
],
“postToolUse”: [
{ “command”: “node .cursor/hooks/post-tool-nudge.js”, “timeout”: 3 }
],
“preCompact”: [
{ “command”: “node .cursor/hooks/pre-compact.js”, “timeout”: 5 }
],
“beforeShellExecution”: [
{ “command”: “node .cursor/hooks/shell-gate.js”, “timeout”: 5 }
],
“afterShellExecution”: [
{ “command”: “node .cursor/hooks/evidence-logger.js”, “timeout”: 3 }
],
“afterMCPExecution”: [
{ “command”: “node .cursor/hooks/browser-evidence.js”, “timeout”: 3 }
],
“stop”: [
{ “command”: “node .cursor/hooks/completion-checker.js”, “loop_limit”: 3, “timeout”: 10 }
]
}
}

What I found during debugging:

The one time a hook DID work after the initial breakage was using a native .ps1 script (not a Node wrapper):

{
“command”: “powershell -NoProfile -ExecutionPolicy Bypass -File .cursor/hooks/test-hook.ps1”,
“matcher”: “Shell”,
“timeout”: 3
}

The .ps1 script used [Console]::In.ReadToEnd() to read stdin, logged the payload, and returned {“permission”:“allow”}. The log showed the full Cursor payload:

input=n++{“conversation_id”:“a0cae581-…”,“model”:“claude-4.6-opus-max”,“tool_name”:“Shell”,“tool_input”:{“command”:"cd c:\Us…
Note the n++ prefix before the JSON — this may be relevant. After editing hooks.json again to apply this pattern to other hooks, even this approach stopped working.

File structure:

.cursor/
├── hooks.json
└── hooks/
├── session-init.js
├── pre-dev-gate.js
├── post-tool-nudge.js
├── pre-compact.js
├── shell-gate.js
├── evidence-logger.js
├── browser-evidence.js
├── completion-checker.js
├── shared-checks.js
├── run-node-hook.ps1
├── pre-tool-evidence.js
└── state/
└── .gitkeep
Questions:

  1. Can modifying hooks.json during a session put the hooks system into a permanently broken state that persists across restarts?
  2. What does the n++ prefix in the stdin payload mean? Is that expected, and could it be breaking JSON parsing in Node scripts?
  3. Where can I see hook loading errors? I haven’t checked Help > Toggle Developer Tools > Console or Settings > Hooks tab or View > Output > Cursor > Hooks yet — are those the right places to look?
  4. Is there a known issue with node commands in hooks on Windows? The docs show shell scripts (./hooks/script.sh) in examples — is Node invocation via node .cursor/hooks/script.js officially supported on Windows?
  5. Does adding/removing entries in hooks.json require anything beyond a file save for Cursor to reload? The docs say Cursor watches hooks.json and reloads automatically.

Steps to Reproduce

See Description

Expected Behavior

Hooks should fire when expected

Operating System

Windows 10/11

Version Information

Version: 2.6.18 (system setup)
VSCode Version: 1.105.1
Commit: 68fbec5aed9da587d1c6a64172792f505bafa250
Date: 2026-03-10T02:01:17.430Z
Build Type: Stable
Release Track: Default
Electron: 39.6.0
Chromium: 142.0.7444.265
Node.js: 22.22.0
V8: 14.2.231.22-electron.0
OS: Windows_NT x64 10.0.26200

For AI issues: which model did you use?

Opus 4.6 Max

Does this stop you from using Cursor

No - Cursor works, but with this issue

Hey, thanks for the detailed report. It really helps with debugging.

Hooks on Windows are a known tricky area, and your case (hooks break after editing hooks.json mid-session and don’t recover) helps narrow it down. The team is aware, and your report adds visibility.

A few things that should help us investigate and maybe fix it:

  1. Hooks logs
    Open View > Output, then select Hooks in the dropdown. You should see any errors about loading or parsing hooks.json. Also check Help > Toggle Developer Tools > Console for errors. Please paste what you see here, it’s the key for debugging.

  2. Workaround with cmd /c
    On Windows, hook commands sometimes need an explicit shell prefix. Try:

"command": "cmd /c node .cursor/hooks/pre-dev-gate.js"

This fixed a similar issue in this thread (confirmed by staff): Hooks not working on Windows

  1. Full hooks reset
    Instead of editing hooks.json, try:
  • Fully close Cursor
  • Delete .cursor/hooks.json
  • Reopen Cursor
  • Recreate hooks.json from scratch (start with one hook for testing)
  1. About the n++ prefix
    That looks suspicious and could be a PowerShell pipeline artifact or a BOM issue. If you’re using Node scripts, the cmd /c prefix (step 2) might bypass it.

  2. Test with a minimal hook
    For isolation, try a hooks.json with just one hook:

{
  "version": 1,
  "hooks": {
    "preToolUse": [
      {
        "command": "cmd /c node .cursor/hooks/pre-dev-gate.js",
        "matcher": "Write|StrReplace|Delete",
        "failClosed": true,
        "timeout": 5
      }
    ]
  }
}

On your questions, yes, node .cursor/hooks/script.js should work on Windows, and Cursor should auto-pick changes to hooks.json. But it looks like there’s an edge case with mid-session edits where the file watcher loses track.

Let me know what you see in Output > Hooks. Next steps depend on that.

Update: Root cause found for hooks producing no output on Windows

Following up on Dean’s suggestions — cmd /c prefix resolved the hook loading issue, but we found a deeper problem: hooks fire but produce no output because workspace_roots uses a Unix-style path that’s invalid on Windows.

The evidence:

View > Output > Hooks showed all hooks firing successfully (exit code 0), but every hook returned OUTPUT: (empty). The hooks ran but couldn’t write state files (evidence.jsonl, heartbeat, nudge context).

Root cause:

Cursor sends workspace_roots as:

“/c:/Users/dougr/Projects/investlyft”

On Windows, Node.js fs.existsSync('/c:/Users/...') returns false and fs.appendFileSync('/c:/Users/...') silently fails. Every hook that used workspace_roots[0] to construct file paths was silently failing.

The fix (two parts):

  1. Path normalization — strip the leading / from workspace_roots:

function normalizeRoot(root) {

if (!root) return process.cwd();

if (/^\/[a-zA-Z]:/.test(root)) return root.slice(1);

return root;

}

  1. Use __dirname for state files — for files the hook script writes to its own directory (heartbeat, evidence), __dirname is always a valid Windows path and avoids the workspace_roots issue entirely:

// Line 2 of every hook script:

try { require(‘fs’).appendFileSync(

require(‘path’).join(__dirname, ‘state’, ‘heartbeat.jsonl’),

JSON.stringify({hook:‘preToolUse’,ts:new Date().toISOString()})+‘\n’

); } catch(e) {}

Verified working: After applying both fixes, hook health check shows preToolUse, postToolUse, beforeShellExecution, and afterShellExecution all firing and logging heartbeats consistently.

Note: Hook scripts edited mid-session won’t take effect until the script is next invoked — Cursor doesn’t cache scripts between invocations, but the require() cache within a single Node process means changes to shared modules (like shared-checks.js) may not be picked up. Restarting Cursor ensures clean state.

Summary of all Windows fixes:

  • cmd /c prefix on all hook commands in hooks.json (Dean’s suggestion — works)

  • normalizeRoot() to handle /c:/ workspace_roots paths

  • __dirname for hook state file paths instead of workspace_roots

  • Never edit hooks.json mid-session (file watcher loses track)

Still monitoring and will update if problems are seen.