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 #!/usr/bin/env python3
"""Cursor hook script that notifies the cursor-flasher daemon via Unix socket. """Cursor hook script that notifies the cursor-flasher daemon via Unix socket.
Installed as a Cursor hook (preToolUse, stop) to trigger a window flash Installed as a Cursor hook to trigger a window flash when the agent needs
when the agent needs user attention. Reads hook JSON from stdin, extracts user attention. Reads hook JSON from stdin, extracts workspace and event
workspace and event info, and sends it to the daemon's socket. 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 json
import os import os
@@ -12,6 +18,11 @@ import sys
SOCKET_PATH = os.path.expanduser("~/.cursor-flasher/flasher.sock") SOCKET_PATH = os.path.expanduser("~/.cursor-flasher/flasher.sock")
_SHELL_EVENT_MAP = {
"beforeShellExecution": "shellApproved",
"afterShellExecution": "shellCompleted",
}
def main() -> None: def main() -> None:
try: try:
@@ -22,8 +33,12 @@ def main() -> None:
workspace_roots = data.get("workspace_roots") or [] workspace_roots = data.get("workspace_roots") or []
workspace = workspace_roots[0] if workspace_roots else "" workspace = workspace_roots[0] if workspace_roots else ""
event = data.get("hook_event_name", "") event = data.get("hook_event_name", "")
tool = data.get("tool_name", "")
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}) msg = json.dumps({"workspace": workspace, "event": event, "tool": tool})
try: try:

View File

@@ -23,11 +23,27 @@ HOOKS_CONFIG = {
"postToolUseFailure": [ "postToolUseFailure": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"} {"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": [ "stop": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"} {"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: def _write_pid() -> None:
PID_FILE.parent.mkdir(parents=True, exist_ok=True) PID_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -110,7 +126,7 @@ def cmd_uninstall(args: argparse.Namespace) -> None:
hooks = config.get("hooks", {}) hooks = config.get("hooks", {})
changed = False changed = False
for event in HOOKS_CONFIG: for event in _ALL_HOOK_EVENTS:
if event in hooks: if event in hooks:
before = len(hooks[event]) before = len(hooks[event])
hooks[event] = [ hooks[event] = [

View File

@@ -303,6 +303,12 @@ class FlasherDaemon:
self._handle_approval(workspace, tool) self._handle_approval(workspace, tool)
elif event in ("postToolUse", "postToolUseFailure"): elif event in ("postToolUse", "postToolUseFailure"):
self._handle_dismiss(workspace, event, tool) 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": elif event == "stop":
self._handle_stop(workspace) self._handle_stop(workspace)
else: else:

View File

@@ -607,3 +607,45 @@ class TestFlasherDaemon:
) )
assert "/a" not in daemon._pending_approvals assert "/a" not in daemon._pending_approvals
assert "/b" 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) os.unlink(sock_path)
assert received[0]["workspace"] == "" 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"