Compare commits
3 Commits
730f6ec1cf
...
392183692e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392183692e
|
||
|
|
6610919a58
|
||
|
|
23fe6ac101
|
@@ -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)
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -6,7 +6,10 @@ import signal
|
||||
import socket
|
||||
import time
|
||||
|
||||
from Cocoa import NSApplication, NSRunLoop, NSDate
|
||||
from Cocoa import (
|
||||
NSApplication, NSRunLoop, NSDate,
|
||||
NSNotificationCenter, NSObject, NSScreen, NSWorkspace,
|
||||
)
|
||||
from Quartz import (
|
||||
CGEventSourceSecondsSinceLastEventType,
|
||||
kCGEventSourceStateHIDSystemState,
|
||||
@@ -34,6 +37,26 @@ SOCKET_PATH = os.path.join(SOCKET_DIR, "flasher.sock")
|
||||
INPUT_DISMISS_GRACE = 0.5
|
||||
|
||||
|
||||
class _DisplayObserver(NSObject):
|
||||
"""Listens for macOS display-change and wake notifications.
|
||||
|
||||
Registering for NSApplicationDidChangeScreenParametersNotification forces
|
||||
AppKit to keep NSScreen.screens() current in long-running daemon processes.
|
||||
Without this, the screen list can go stale after sleep/wake cycles, causing
|
||||
modes like "allscreens" to miss external displays.
|
||||
"""
|
||||
|
||||
def screenParametersChanged_(self, notification):
|
||||
screens = NSScreen.screens()
|
||||
count = len(screens) if screens else 0
|
||||
logger.info("Display configuration changed — %d screen(s) detected", count)
|
||||
|
||||
def workspaceDidWake_(self, notification):
|
||||
screens = NSScreen.screens()
|
||||
count = len(screens) if screens else 0
|
||||
logger.info("System woke from sleep — %d screen(s) detected", count)
|
||||
|
||||
|
||||
def _get_system_appearance() -> str:
|
||||
"""Return "dark" or "light" based on the current macOS appearance."""
|
||||
app = NSApplication.sharedApplication()
|
||||
@@ -71,11 +94,13 @@ class FlasherDaemon:
|
||||
self._pending_approvals: dict[str, _PendingApproval] = {}
|
||||
self._active_pulses: dict[str, _ActivePulse] = {}
|
||||
self._cursor_was_frontmost: bool = False
|
||||
self._display_observer: NSObject | None = None
|
||||
|
||||
def run(self) -> None:
|
||||
NSApplication.sharedApplication()
|
||||
self._running = True
|
||||
self._setup_socket()
|
||||
self._setup_display_notifications()
|
||||
|
||||
signal.signal(signal.SIGTERM, self._handle_signal)
|
||||
signal.signal(signal.SIGINT, self._handle_signal)
|
||||
@@ -111,6 +136,34 @@ class FlasherDaemon:
|
||||
self._server.listen(5)
|
||||
self._server.setblocking(False)
|
||||
|
||||
def _setup_display_notifications(self) -> None:
|
||||
"""Subscribe to macOS display-change and wake events.
|
||||
|
||||
This is required for NSScreen.screens() to stay current in a
|
||||
long-running daemon. Without these observers, AppKit may not process
|
||||
screen-configuration changes after sleep/wake, leaving the screen
|
||||
list stale until the process is restarted.
|
||||
"""
|
||||
self._display_observer = _DisplayObserver.alloc().init()
|
||||
|
||||
NSNotificationCenter.defaultCenter().addObserver_selector_name_object_(
|
||||
self._display_observer,
|
||||
"screenParametersChanged:",
|
||||
"NSApplicationDidChangeScreenParametersNotification",
|
||||
None,
|
||||
)
|
||||
|
||||
NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_(
|
||||
self._display_observer,
|
||||
"workspaceDidWake:",
|
||||
"NSWorkspaceDidWakeNotification",
|
||||
None,
|
||||
)
|
||||
|
||||
screens = NSScreen.screens()
|
||||
count = len(screens) if screens else 0
|
||||
logger.info("Display notifications registered — %d screen(s) currently", count)
|
||||
|
||||
def _check_socket(self) -> None:
|
||||
if self._server is None:
|
||||
return
|
||||
@@ -250,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:
|
||||
@@ -304,15 +363,28 @@ class FlasherDaemon:
|
||||
self._last_flash[workspace] = now
|
||||
|
||||
def _resolve_frames(self, window_frame: tuple) -> list[tuple]:
|
||||
"""Return frame(s) based on flash_mode config."""
|
||||
"""Return frame(s) based on flash_mode config.
|
||||
|
||||
In "window" mode, falls back to screen frame when Cursor is not
|
||||
frontmost (e.g. different Space or behind fullscreen app) to avoid
|
||||
drawing a floating rectangle at stale coordinates.
|
||||
"""
|
||||
mode = self.config.flash_mode
|
||||
if mode == "allscreens":
|
||||
return all_screen_frames()
|
||||
if mode == "screen":
|
||||
return [screen_frame_for_window(window_frame)]
|
||||
if not is_cursor_frontmost():
|
||||
return [screen_frame_for_window(window_frame)]
|
||||
return [window_frame]
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
if self._display_observer is not None:
|
||||
NSNotificationCenter.defaultCenter().removeObserver_(self._display_observer)
|
||||
NSWorkspace.sharedWorkspace().notificationCenter().removeObserver_(
|
||||
self._display_observer
|
||||
)
|
||||
self._display_observer = None
|
||||
self.overlay.hide()
|
||||
if self._server is not None:
|
||||
self._server.close()
|
||||
|
||||
@@ -11,6 +11,8 @@ from Cocoa import (
|
||||
NSView,
|
||||
NSBezierPath,
|
||||
NSTimer,
|
||||
NSWindowCollectionBehaviorCanJoinAllSpaces,
|
||||
NSWindowCollectionBehaviorFullScreenAuxiliary,
|
||||
)
|
||||
from Foundation import NSInsetRect
|
||||
|
||||
@@ -161,6 +163,10 @@ class OverlayManager:
|
||||
window.setOpaque_(False)
|
||||
window.setBackgroundColor_(NSColor.clearColor())
|
||||
window.setLevel_(2147483631)
|
||||
window.setCollectionBehavior_(
|
||||
NSWindowCollectionBehaviorCanJoinAllSpaces
|
||||
| NSWindowCollectionBehaviorFullScreenAuxiliary
|
||||
)
|
||||
window.setIgnoresMouseEvents_(True)
|
||||
window.setHasShadow_(False)
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ class TestFlasherDaemon:
|
||||
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.play_alert"), \
|
||||
patch(PATCH_APPEARANCE, return_value="light"):
|
||||
daemon._handle_message(
|
||||
@@ -132,6 +133,46 @@ class TestFlasherDaemon:
|
||||
"/path", [((0, 0), (800, 600))], daemon.config.light.completed
|
||||
)
|
||||
|
||||
def test_window_mode_falls_back_to_screen_when_cursor_not_frontmost(self):
|
||||
"""Window mode falls back to screen frame when Cursor isn't frontmost."""
|
||||
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window")
|
||||
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||
screen = ((0, 0), (1920, 1080))
|
||||
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.play_alert"), \
|
||||
patch(PATCH_APPEARANCE, return_value="dark"):
|
||||
daemon._check_pending()
|
||||
|
||||
daemon.overlay.add_pulse.assert_called_once_with(
|
||||
"/path", [screen], daemon.config.dark.running
|
||||
)
|
||||
|
||||
def test_stop_falls_back_to_screen_when_cursor_not_frontmost(self):
|
||||
"""Stop flash in window mode falls back to screen when Cursor isn't frontmost."""
|
||||
daemon = self._make_daemon(flash_mode="window")
|
||||
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||
screen = ((0, 0), (1920, 1080))
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.play_alert"), \
|
||||
patch(PATCH_APPEARANCE, return_value="dark"):
|
||||
daemon._handle_message(
|
||||
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||
)
|
||||
|
||||
daemon.overlay.add_flash.assert_called_once_with(
|
||||
"/path", [screen], daemon.config.dark.completed
|
||||
)
|
||||
|
||||
def test_allscreens_mode_uses_all_screens(self):
|
||||
daemon = self._make_daemon(flash_mode="allscreens", approval_delay=0.0)
|
||||
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
||||
@@ -380,7 +421,7 @@ class TestFlasherDaemon:
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.play_alert") as mock_alert, \
|
||||
patch(PATCH_APPEARANCE, return_value="dark"):
|
||||
daemon._check_pending()
|
||||
@@ -395,6 +436,7 @@ class TestFlasherDaemon:
|
||||
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.play_alert") as mock_alert, \
|
||||
patch(PATCH_APPEARANCE, return_value="dark"):
|
||||
daemon._handle_message(
|
||||
@@ -411,6 +453,7 @@ class TestFlasherDaemon:
|
||||
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.play_alert") as mock_alert, \
|
||||
patch(PATCH_APPEARANCE, return_value="dark"):
|
||||
daemon._handle_message(
|
||||
@@ -434,7 +477,7 @@ class TestFlasherDaemon:
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.play_alert"), \
|
||||
patch(PATCH_APPEARANCE, return_value="dark"):
|
||||
daemon._check_pending()
|
||||
@@ -448,6 +491,7 @@ class TestFlasherDaemon:
|
||||
daemon._active_pulses.clear()
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.play_alert"), \
|
||||
patch(PATCH_APPEARANCE, return_value="dark"):
|
||||
daemon._handle_message(
|
||||
@@ -475,7 +519,7 @@ class TestFlasherDaemon:
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.play_alert"), \
|
||||
patch(PATCH_APPEARANCE, return_value="light"):
|
||||
daemon._check_pending()
|
||||
@@ -500,7 +544,7 @@ class TestFlasherDaemon:
|
||||
)
|
||||
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", side_effect=[win_a, win_b]), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.play_alert"), \
|
||||
patch(PATCH_APPEARANCE, return_value="dark"):
|
||||
daemon._check_pending()
|
||||
@@ -533,6 +577,7 @@ class TestFlasherDaemon:
|
||||
|
||||
window = {"title": "project-a", "frame": ((0, 0), (800, 600))}
|
||||
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||
patch("cursor_flasher.daemon.play_alert"), \
|
||||
patch(PATCH_APPEARANCE, return_value="dark"):
|
||||
daemon._handle_message(
|
||||
@@ -562,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user