Bug report from Claude. I am a vibe coder - so not really sure what it means, but I think i fixed my issue.
Cursor on Windows: third-party hook execution — focus theft and a Windows-only input channel
Filed: 2026-08-12
Cursor version: 3.15.6
OS: Windows 11 (build 26200), default console host is Windows Terminal
Agent: Cursor’s own agent (Grok 4.5)
Control: the Claude Code extension running in the same IDE, on the same machine, with the same hook configuration, does not exhibit either issue
Two separate defects are described. They were found while investigating the first; the
second is arguably more serious because it is silent.
Paths in this report are redacted as C:/Users/<user>/... and the project is <project>.
Issue 1 — Hook processes appear to be spawned without CREATE_NO_WINDOW, stealing OS focus
Symptom
With Cursor minimized and another application in the foreground, running the agent would
intermittently pull focus away. The interruption correlated with agent tool calls that
trigger hooks, not with agent activity in general. It did not occur with the Claude Code
extension in the same IDE running the same hooks.
Source-level evidence
All from resources/app/out in the installed build.
1. Cursor executes third-party Claude hook configuration. In the same service object
that loads its own hooks.json:
joinPath(e, ".cursor", "hooks.json"),
this.claudeUserConfigUri = joinPath(e, ".claude", "settings.json"),
this.claudeProjectConfigUris = e.map(t => joinPath(t.uri, ".claude", "settings.json")),
this.claudeProjectLocalConfigUris = ...settings.local.json
this.logger.logInfo(`Claude user config path: ${this.claudeUserConfigUri}`)
This is cursorHooksService (134 references in the bundle). Note that no
.cursor/hooks.json exists on this machine — the hooks being run come entirely from
.claude/settings.json.
2. Hooks dispatch through the general shell service, not a hardened path.
_executeCommandHookScript ends at:
this.shellExecService.executeHookDirect(cmd, cwd, isWindows, env, timeout, payload)
3. windowsHide appears in exactly one file across all of resources/app/out:
main.js (the Electron main process). The workbench and extension-host bundles that
execute hooks never set it. A console-subsystem child spawned from a parent without an
inherited console gets a brand-new visible console window on Windows.
This is the single most checkable line in this report: grep your own build.
Independent measurement on this machine
To characterise what a naive spawn produces, a Node parent was launched with
{detached: true, stdio: 'ignore'} so it had no console of its own (approximating a
spawn from a GUI host), and it then spawned powershell.exe with windowsHide: false.
A Win32 EnumWindows watcher polling every 15ms recorded window creation.
Result: a visible CASCADIA_HOSTING_WINDOW_CLASS window (“Terminal”, retitled to the
executable path, then to “Windows PowerShell”) appeared every time.
Notable for anyone proposing -WindowStyle Hidden as a mitigation: it does not work.
The same A/B with powershell -NoProfile -WindowStyle Hidden -File script.ps1 produced a
visible Terminal window at equivalent timings. On Windows 11 the console host is Windows
Terminal, and PowerShell’s -WindowStyle calls ShowWindow on GetConsoleWindow(),
which under ConPTY is the hidden PseudoConsoleWindow, not the Terminal window. It hides
the wrong window. CREATE_NO_WINDOW on the spawn is the only thing that prevents it.
(Related trap, measured separately: applying -WindowStyle Hidden to a nested PowerShell
call that inherits its parent’s console calls ShowWindow(SW_HIDE) on the caller’s
window and never restores it. Any launcher that “helpfully” injects that flag would hide
the user’s own terminal.)
Current status — important caveat
The symptom is not currently reproducing, after two local changes were made before this
investigation began:
- Four
.sh hooks (which launched Git Bash) were rewritten as Node .js hooks.
-WindowStyle Hidden was added to the remaining PowerShell hook commands.
Three subsequent dense bursts of agent activity — many Reads, Globs, and shell commands,
all of which fire hooks — produced zero console windows and zero focus interruptions.
Given measurement (2) above, change #2 cannot be what fixed it, which points at #1 (Git
Bash / MSYS on the hot path). But this has not been isolated with a controlled A/B, so the
attribution is stated as unproven. The missing CREATE_NO_WINDOW in the spawn path is
still present in the code regardless, and any user whose hooks invoke a console program
directly remains exposed.
Requested fix
Spawn hook processes with shell: false, windowsHide: true (CREATE_NO_WINDOW), and
piped stdio. Resolve the executable explicitly rather than relying on file association —
in particular, a bare .sh path should become [bash.exe, scriptPath, ...args], never an
“open this file” call. If a shell is genuinely required (pipes, redirects, &&), use
cmd.exe /d /s /c <command> with windowsHide: true on that cmd, rather than Node’s
shell: true. Do not activate or foreground the IDE window on hook start or completion.
Issue 2 — On Windows, hook input is delivered by temp file instead of stdin, silently breaking hooks that read stdin
Source-level evidence
In _executeCommandHookScript:
const v = await this._getBackendOS();
const _ = JSON.stringify(r);
let S, k, C;
v === 1 ? (k = _, S = f, C = "windows_temp_file")
: (k = _, S = f, C = "stdin");
On Windows the input channel is labelled windows_temp_file; on every other platform it
is stdin.
Observed in the product
Cursor’s own hook execution log confirms this at runtime: every hook entry in the log
is tagged windows_temp_file. The log shows the full JSON payload under “Input:”, so
Cursor clearly has the payload — the question is how the child process receives it.
Why this matters
The documented Claude Code hook contract is JSON on stdin, JSON on stdout, exit code 2
to deny. Third-party hooks written to that contract read stdin. A well-behaved hook that
finds stdin empty exits 0 silently — which is correct hook behaviour, and precisely what
makes this failure invisible.
On this machine, two PowerShell hooks read stdin via [Console]::In.ReadToEnd() and exit
silently on empty input. They work correctly under the Claude Code extension and produce
their advisory output there. Under Cursor they may never have run to completion, with no
error, no output, and nothing in the UI to indicate it. Their entire purpose — steering the
agent toward the correct knowledge graph before it reads code — would have been silently
absent for every Cursor session.
Minimal reproduction
Add to ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node \"C:/path/to/stdin-probe.js\"",
"timeout": 10
}
]
}
]
}
}
stdin-probe.js:
const fs = require('fs');
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', c => { raw += c; });
process.stdin.on('end', () => {
fs.appendFileSync('C:/path/to/stdin-probe.log',
new Date().toISOString() + ' stdin bytes=' + raw.length + '\n');
process.exit(0);
});
setTimeout(() => {
fs.appendFileSync('C:/path/to/stdin-probe.log',
new Date().toISOString() + ' stdin NEVER ENDED (no data, no EOF)\n');
process.exit(0);
}, 3000);
Trigger a file read in the agent, then inspect the log.
stdin bytes=<n> with n > 0 → stdin is delivered; only the log label differs, and this
issue is cosmetic.
stdin bytes=0 or stdin NEVER ENDED → hooks written to the documented contract receive
nothing on Windows. Compare against the same hook under Claude Code on the same machine.
Requested fix
Deliver the payload on stdin on Windows as on every other platform. If a temp file is
required for an internal reason, keep stdin as the primary channel and treat the file as an
addition (for example an env var pointing at it), so that hooks written to the published
contract continue to work unmodified across hosts.
Open items still to be tested
Listed so nobody assumes they are settled.
| # |
Question |
How to settle it |
Why it matters |
| 1 |
Is stdin actually delivered to hooks on Windows? |
The stdin-probe.js reproduction above, run under Cursor and again under Claude Code on the same machine |
Decides whether Issue 2 is cosmetic or a silent correctness bug |
| 2 |
Do PreToolUse hooks run at all under Cursor? |
Filter the hook execution log for preToolUse. Only postToolUse entries were observed in the samples captured |
If PreToolUse never fires, gating and advisory hooks are inert, which is worse than Issue 2 |
| 3 |
Which local change stopped the focus theft? |
Remove -WindowStyle Hidden from the hook commands, restart Cursor, repeat a dense burst of tool calls |
Measurement says the flag cannot help; if the symptom returns, that measurement does not generalise to Cursor’s spawn context and should be re-examined |
| 4 |
Is focus stolen by a window appearing, or by a direct foreground call? |
A GetForegroundWindow() poller running while the symptom occurs, logging the owning process on every change |
Window creation and focus theft are not the same thing. All measurement so far watched windows, not focus. If nothing visible appears but focus still moves, the cause is a SetForegroundWindow-class call and no spawn flag will fix it |
| 5 |
Are non-GSD plugin hooks a remaining Git Bash entry point? |
Two installed plugins declare bash "${CLAUDE_PLUGIN_ROOT}/hooks/*.sh" hooks. One such entry appeared in the hook log with a warning marker |
These were never rewritten to Node and remain on the hot path. If the symptom returns, this is the first suspect |
| 6 |
Does the hook-log warning marker indicate a failure? |
Expand that entry in the hook execution log and read the error |
An erroring hook on every tool call is its own problem |
| 7 |
Does a SessionStart / Stop hook behave the same? |
Force a new chat or a stop while a window watcher runs. Only PreToolUse and PostToolUse paths have been exercised so far |
Session-lifecycle hooks run heavier scripts and were not covered by any test above |
Local mitigations applied (for reference, not a recommendation)
- Four Git Bash
.sh hooks rewritten as Node .js equivalents, keeping the same base
filenames so the upstream installer still recognises them as configured. This is the
change most likely to have resolved the symptom.
-WindowStyle Hidden left on the PowerShell hook commands. Measurement says it does
nothing; it is retained only because the system is currently working and removing it
without cause would be a worse bet than leaving it. It is not offered as a fix.
- The same flag was removed from nested PowerShell calls inside a local script, where it
was measurably harmful (it hid the calling terminal permanently).