Disable shell execution hooks to fix broken notifications

beforeShellExecution in hooks.json required a JSON response we never
provided, likely causing Cursor to silently break the entire hook
pipeline. Commenting out those entries (and afterShellExecution) from
HOOKS_CONFIG restores reliable preToolUse/postToolUse delivery. All
Python handler code is retained as dead code for reference.

Also reverts the is_cursor_frontmost() gate in _check_pending — pulses
should fire unconditionally when the approval delay expires.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-11 15:24:43 -04:00
parent 6610919a58
commit 392183692e
5 changed files with 156 additions and 6 deletions

View File

@@ -1,9 +1,15 @@
#!/usr/bin/env python3
"""Cursor hook script that notifies the cursor-flasher daemon via Unix socket.
Installed as a Cursor hook (preToolUse, stop) to trigger a window flash
when the agent needs user attention. Reads hook JSON from stdin, extracts
workspace and event info, and sends it to the daemon's socket.
Installed as a Cursor hook to trigger a window flash when the agent needs
user attention. Reads hook JSON from stdin, extracts workspace and event
info, and sends it to the daemon's socket.
Shell-specific hook mapping (beforeShellExecution -> shellApproved,
afterShellExecution -> shellCompleted) is retained but currently dead
code — those hooks are disabled in HOOKS_CONFIG / hooks.json because
beforeShellExecution fires pre-approval and Cursor expects a JSON
response we don't provide.
"""
import json
import os
@@ -12,6 +18,11 @@ import sys
SOCKET_PATH = os.path.expanduser("~/.cursor-flasher/flasher.sock")
_SHELL_EVENT_MAP = {
"beforeShellExecution": "shellApproved",
"afterShellExecution": "shellCompleted",
}
def main() -> None:
try:
@@ -22,9 +33,13 @@ def main() -> None:
workspace_roots = data.get("workspace_roots") or []
workspace = workspace_roots[0] if workspace_roots else ""
event = data.get("hook_event_name", "")
tool = data.get("tool_name", "")
msg = json.dumps({"workspace": workspace, "event": event, "tool": tool})
mapped_event = _SHELL_EVENT_MAP.get(event)
if mapped_event:
msg = json.dumps({"workspace": workspace, "event": mapped_event, "tool": "Shell"})
else:
tool = data.get("tool_name", "")
msg = json.dumps({"workspace": workspace, "event": event, "tool": tool})
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)

View File

@@ -23,11 +23,27 @@ HOOKS_CONFIG = {
"postToolUseFailure": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
],
# DEAD CODE: shell execution hooks disabled — Cursor expects a JSON
# response from beforeShellExecution that we don't provide, and the
# daemon handlers (shellApproved/shellCompleted) are no-ops or redundant
# with postToolUse. Re-enable by uncommenting if a use is found.
# "beforeShellExecution": [
# {"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
# ],
# "afterShellExecution": [
# {"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
# ],
"stop": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
],
}
# Events to clean from hooks.json on uninstall, including disabled ones.
_ALL_HOOK_EVENTS = list(HOOKS_CONFIG.keys()) + [
"beforeShellExecution",
"afterShellExecution",
]
def _write_pid() -> None:
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -110,7 +126,7 @@ def cmd_uninstall(args: argparse.Namespace) -> None:
hooks = config.get("hooks", {})
changed = False
for event in HOOKS_CONFIG:
for event in _ALL_HOOK_EVENTS:
if event in hooks:
before = len(hooks[event])
hooks[event] = [

View File

@@ -303,6 +303,12 @@ class FlasherDaemon:
self._handle_approval(workspace, tool)
elif event in ("postToolUse", "postToolUseFailure"):
self._handle_dismiss(workspace, event, tool)
# DEAD CODE: shell execution hooks are disabled in hooks.json.
# shellApproved (beforeShellExecution) fires pre-approval so it
# can't distinguish "waiting for user" from "auto-approved".
# shellCompleted (afterShellExecution) is redundant with postToolUse.
elif event in ("shellApproved", "shellCompleted"):
logger.debug("Shell execution hook (disabled): event=%s workspace=%s", event, workspace)
elif event == "stop":
self._handle_stop(workspace)
else:

View File

@@ -607,3 +607,45 @@ class TestFlasherDaemon:
)
assert "/a" not in daemon._pending_approvals
assert "/b" in daemon._pending_approvals
# --- shellApproved / shellCompleted (disabled, debug-logged only) ---
def test_shellApproved_does_not_cancel_pending(self):
"""shellApproved is a no-op — pending approval must survive."""
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "shellApproved", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
def test_shellCompleted_does_not_dismiss(self):
"""shellCompleted is a no-op — active pulse must survive."""
daemon = self._make_daemon()
daemon._active_pulses["/path"] = MagicMock()
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "shellCompleted", "tool": "Shell"}).encode()
)
daemon.overlay.dismiss_tag.assert_not_called()
assert "/path" in daemon._active_pulses
def test_shellCompleted_does_not_cancel_pending(self):
"""shellCompleted is a no-op — pending approval must survive."""
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "shellCompleted", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals

View File

@@ -103,3 +103,74 @@ class TestHookNotify:
os.unlink(sock_path)
assert received[0]["workspace"] == ""
def test_beforeShellExecution_mapped_to_shellApproved(self):
sock_path = _short_sock_path()
received = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
def accept():
conn, _ = server.accept()
data = conn.recv(4096)
received.append(json.loads(data))
conn.close()
t = threading.Thread(target=accept)
t.start()
try:
hook_input = json.dumps({
"workspace_roots": ["/Users/me/project"],
"hook_event_name": "beforeShellExecution",
"command": "npm install",
})
_run_hook_main(hook_input, sock_path)
t.join(timeout=2)
finally:
server.close()
if os.path.exists(sock_path):
os.unlink(sock_path)
assert len(received) == 1
assert received[0]["event"] == "shellApproved"
assert received[0]["tool"] == "Shell"
assert received[0]["workspace"] == "/Users/me/project"
def test_afterShellExecution_mapped_to_shellCompleted(self):
sock_path = _short_sock_path()
received = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
def accept():
conn, _ = server.accept()
data = conn.recv(4096)
received.append(json.loads(data))
conn.close()
t = threading.Thread(target=accept)
t.start()
try:
hook_input = json.dumps({
"workspace_roots": ["/Users/me/project"],
"hook_event_name": "afterShellExecution",
"command": "npm install",
"output": "added 100 packages",
"duration": 5432,
})
_run_hook_main(hook_input, sock_path)
t.join(timeout=2)
finally:
server.close()
if os.path.exists(sock_path):
os.unlink(sock_path)
assert len(received) == 1
assert received[0]["event"] == "shellCompleted"
assert received[0]["tool"] == "Shell"