# Cursor Flasher Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a macOS daemon that monitors Cursor's accessibility tree and shows a pulsing border overlay when the agent is waiting for user input. **Architecture:** A Python background process polls Cursor's a11y tree via pyobjc, runs a state machine to detect "waiting for user" states, and manages a native macOS overlay window with Core Animation for the pulse effect. A CLI provides start/stop/status commands. **Tech Stack:** Python 3.10+, pyobjc-framework-Cocoa, pyobjc-framework-Quartz, PyYAML --- ### Task 1: Project Scaffolding **Files:** - Create: `pyproject.toml` - Create: `requirements.txt` - Create: `src/cursor_flasher/__init__.py` - Create: `src/cursor_flasher/__main__.py` - Create: `tests/__init__.py` - Create: `tests/conftest.py` **Step 1: Create `pyproject.toml`** ```toml [build-system] requires = ["setuptools>=68.0", "wheel"] build-backend = "setuptools.backends._legacy:_Backend" [project] name = "cursor-flasher" version = "0.1.0" description = "Flash Cursor's window when the AI agent is waiting for input" requires-python = ">=3.10" dependencies = [ "pyobjc-framework-Cocoa", "pyobjc-framework-Quartz", "PyYAML", ] [project.optional-dependencies] dev = ["pytest", "pytest-mock"] [project.scripts] cursor-flasher = "cursor_flasher.cli:main" [tool.setuptools.packages.find] where = ["src"] ``` **Step 2: Create `requirements.txt`** ``` pyobjc-framework-Cocoa pyobjc-framework-Quartz PyYAML pytest pytest-mock ``` **Step 3: Create package files** `src/cursor_flasher/__init__.py`: ```python """Cursor Flasher — flash the Cursor window when the AI agent is waiting for input.""" __version__ = "0.1.0" ``` `src/cursor_flasher/__main__.py`: ```python from cursor_flasher.cli import main if __name__ == "__main__": main() ``` `tests/__init__.py`: empty file `tests/conftest.py`: ```python import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent / "src")) ``` **Step 4: Install in dev mode** Run: `pip install -e ".[dev]"` Expected: installs successfully, `cursor-flasher` command is available (will fail since cli module doesn't exist yet — that's fine) **Step 5: Commit** ```bash git add -A git commit -m "feat: project scaffolding with pyproject.toml and package structure" ``` --- ### Task 2: Configuration Module **Files:** - Create: `src/cursor_flasher/config.py` - Create: `tests/test_config.py` **Step 1: Write the failing tests** `tests/test_config.py`: ```python import pytest from pathlib import Path from cursor_flasher.config import Config, load_config, DEFAULT_CONFIG class TestDefaultConfig: def test_has_pulse_settings(self): cfg = Config() assert cfg.pulse_color == "#FF9500" assert cfg.pulse_width == 4 assert cfg.pulse_speed == 1.5 assert cfg.pulse_opacity_min == 0.3 assert cfg.pulse_opacity_max == 1.0 def test_has_sound_settings(self): cfg = Config() assert cfg.sound_enabled is True assert cfg.sound_name == "Glass" assert cfg.sound_volume == 0.5 def test_has_detection_settings(self): cfg = Config() assert cfg.poll_interval == 0.5 assert cfg.cooldown == 3.0 def test_has_timeout_settings(self): cfg = Config() assert cfg.auto_dismiss == 300 class TestLoadConfig: def test_loads_from_yaml(self, tmp_path): config_file = tmp_path / "config.yaml" config_file.write_text( "pulse:\n" ' color: "#00FF00"\n' " width: 8\n" "sound:\n" " enabled: false\n" ) cfg = load_config(config_file) assert cfg.pulse_color == "#00FF00" assert cfg.pulse_width == 8 assert cfg.sound_enabled is False # Unset values use defaults assert cfg.pulse_speed == 1.5 assert cfg.sound_name == "Glass" def test_missing_file_returns_defaults(self, tmp_path): cfg = load_config(tmp_path / "nonexistent.yaml") assert cfg.pulse_color == "#FF9500" def test_empty_file_returns_defaults(self, tmp_path): config_file = tmp_path / "config.yaml" config_file.write_text("") cfg = load_config(config_file) assert cfg.pulse_color == "#FF9500" ``` **Step 2: Run tests to verify they fail** Run: `pytest tests/test_config.py -v` Expected: FAIL — `ModuleNotFoundError: No module named 'cursor_flasher.config'` **Step 3: Implement config module** `src/cursor_flasher/config.py`: ```python from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml @dataclass class Config: pulse_color: str = "#FF9500" pulse_width: int = 4 pulse_speed: float = 1.5 pulse_opacity_min: float = 0.3 pulse_opacity_max: float = 1.0 sound_enabled: bool = True sound_name: str = "Glass" sound_volume: float = 0.5 poll_interval: float = 0.5 cooldown: float = 3.0 auto_dismiss: int = 300 FIELD_MAP: dict[str, dict[str, str]] = { "pulse": { "color": "pulse_color", "width": "pulse_width", "speed": "pulse_speed", "opacity_min": "pulse_opacity_min", "opacity_max": "pulse_opacity_max", }, "sound": { "enabled": "sound_enabled", "name": "sound_name", "volume": "sound_volume", }, "detection": { "poll_interval": "poll_interval", "cooldown": "cooldown", }, "timeout": { "auto_dismiss": "auto_dismiss", }, } DEFAULT_CONFIG_PATH = Path.home() / ".cursor-flasher" / "config.yaml" def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: """Load config from YAML, falling back to defaults for missing values.""" if not path.exists(): return Config() with open(path) as f: raw = yaml.safe_load(f) if not raw or not isinstance(raw, dict): return Config() overrides: dict[str, Any] = {} for section, mapping in FIELD_MAP.items(): section_data = raw.get(section, {}) if not isinstance(section_data, dict): continue for yaml_key, field_name in mapping.items(): if yaml_key in section_data: overrides[field_name] = section_data[yaml_key] return Config(**overrides) ``` **Step 4: Run tests to verify they pass** Run: `pytest tests/test_config.py -v` Expected: all PASS **Step 5: Commit** ```bash git add -A git commit -m "feat: add configuration module with YAML loading and defaults" ``` --- ### Task 3: State Machine **Files:** - Create: `src/cursor_flasher/state.py` - Create: `tests/test_state.py` **Step 1: Write the failing tests** `tests/test_state.py`: ```python import pytest from cursor_flasher.state import FlasherState, StateMachine class TestStateMachine: def test_initial_state_is_idle(self): sm = StateMachine() assert sm.state == FlasherState.IDLE def test_idle_to_agent_working(self): sm = StateMachine() changed = sm.update(agent_working=True, approval_needed=False) assert sm.state == FlasherState.AGENT_WORKING assert changed is True def test_agent_working_to_waiting(self): sm = StateMachine() sm.update(agent_working=True, approval_needed=False) changed = sm.update(agent_working=False, approval_needed=False) assert sm.state == FlasherState.WAITING_FOR_USER assert changed is True def test_approval_needed_triggers_waiting(self): sm = StateMachine() sm.update(agent_working=True, approval_needed=False) changed = sm.update(agent_working=False, approval_needed=True) assert sm.state == FlasherState.WAITING_FOR_USER assert changed is True def test_idle_does_not_jump_to_waiting(self): sm = StateMachine() changed = sm.update(agent_working=False, approval_needed=False) assert sm.state == FlasherState.IDLE assert changed is False def test_waiting_to_user_interacting(self): sm = StateMachine() sm.update(agent_working=True, approval_needed=False) sm.update(agent_working=False, approval_needed=False) assert sm.state == FlasherState.WAITING_FOR_USER changed = sm.dismiss() assert sm.state == FlasherState.IDLE assert changed is True def test_waiting_to_agent_working(self): sm = StateMachine() sm.update(agent_working=True, approval_needed=False) sm.update(agent_working=False, approval_needed=False) changed = sm.update(agent_working=True, approval_needed=False) assert sm.state == FlasherState.AGENT_WORKING assert changed is True def test_no_change_returns_false(self): sm = StateMachine() sm.update(agent_working=True, approval_needed=False) changed = sm.update(agent_working=True, approval_needed=False) assert changed is False def test_cooldown_prevents_immediate_retrigger(self): sm = StateMachine(cooldown=5.0) sm.update(agent_working=True, approval_needed=False) sm.update(agent_working=False, approval_needed=False) assert sm.state == FlasherState.WAITING_FOR_USER sm.dismiss() # Immediately go back through AGENT_WORKING -> done sm.update(agent_working=True, approval_needed=False) changed = sm.update(agent_working=False, approval_needed=False) # Should stay IDLE due to cooldown (unless we mock time) # This test validates cooldown is stored; actual time-based test below assert sm.cooldown == 5.0 def test_direct_approval_from_idle(self): """If we detect approval buttons without seeing agent_working first, still transition to WAITING_FOR_USER.""" sm = StateMachine() changed = sm.update(agent_working=False, approval_needed=True) assert sm.state == FlasherState.WAITING_FOR_USER assert changed is True ``` **Step 2: Run tests to verify they fail** Run: `pytest tests/test_state.py -v` Expected: FAIL — `ModuleNotFoundError` **Step 3: Implement state machine** `src/cursor_flasher/state.py`: ```python import enum import time class FlasherState(enum.Enum): IDLE = "idle" AGENT_WORKING = "agent_working" WAITING_FOR_USER = "waiting_for_user" class StateMachine: def __init__(self, cooldown: float = 3.0): self.state = FlasherState.IDLE self.cooldown = cooldown self._last_dismiss_time: float = 0 def update(self, *, agent_working: bool, approval_needed: bool) -> bool: """Update state based on detected signals. Returns True if state changed.""" old = self.state if approval_needed and self.state != FlasherState.WAITING_FOR_USER: if not self._in_cooldown(): self.state = FlasherState.WAITING_FOR_USER return self.state != old match self.state: case FlasherState.IDLE: if agent_working: self.state = FlasherState.AGENT_WORKING case FlasherState.AGENT_WORKING: if not agent_working: if not self._in_cooldown(): self.state = FlasherState.WAITING_FOR_USER else: self.state = FlasherState.IDLE case FlasherState.WAITING_FOR_USER: if agent_working: self.state = FlasherState.AGENT_WORKING return self.state != old def dismiss(self) -> bool: """User interaction detected — dismiss the flash.""" if self.state == FlasherState.WAITING_FOR_USER: self.state = FlasherState.IDLE self._last_dismiss_time = time.monotonic() return True return False def _in_cooldown(self) -> bool: if self._last_dismiss_time == 0: return False return (time.monotonic() - self._last_dismiss_time) < self.cooldown ``` **Step 4: Run tests to verify they pass** Run: `pytest tests/test_state.py -v` Expected: all PASS **Step 5: Commit** ```bash git add -A git commit -m "feat: add state machine for agent activity detection" ``` --- ### Task 4: Accessibility Tree Explorer (Dev Tool) This is a development utility to understand Cursor's a11y tree before writing the detector. **Files:** - Create: `scripts/dump_a11y_tree.py` **Step 1: Write the exploration script** `scripts/dump_a11y_tree.py`: ```python """Dump the accessibility tree of the Cursor application. Usage: python scripts/dump_a11y_tree.py [--depth N] Requires Accessibility permissions for the running terminal. """ import argparse import sys from ApplicationServices import ( AXUIElementCreateApplication, AXUIElementCopyAttributeNames, AXUIElementCopyAttributeValue, ) from Cocoa import NSWorkspace def find_cursor_pid() -> int | None: """Find the PID of the running Cursor application.""" workspace = NSWorkspace.sharedWorkspace() for app in workspace.runningApplications(): name = app.localizedName() bundle = app.bundleIdentifier() or "" if name == "Cursor" or "cursor" in bundle.lower(): return app.processIdentifier() return None def dump_element(element, depth: int = 0, max_depth: int = 5) -> None: """Recursively print an AXUIElement's attributes.""" if depth > max_depth: return indent = " " * depth names_err, attr_names = AXUIElementCopyAttributeNames(element, None) if names_err or not attr_names: return role = "" title = "" value = "" description = "" for name in attr_names: err, val = AXUIElementCopyAttributeValue(element, name, None) if err: continue if name == "AXRole": role = str(val) elif name == "AXTitle": title = str(val) if val else "" elif name == "AXValue": value_str = str(val)[:100] if val else "" value = value_str elif name == "AXDescription": description = str(val) if val else "" label = role if title: label += f' title="{title}"' if description: label += f' desc="{description}"' if value: label += f' value="{value}"' print(f"{indent}{label}") # Recurse into children err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None) if not err and children: for child in children: dump_element(child, depth + 1, max_depth) def main(): parser = argparse.ArgumentParser(description="Dump Cursor's accessibility tree") parser.add_argument("--depth", type=int, default=8, help="Max depth to traverse") args = parser.parse_args() pid = find_cursor_pid() if pid is None: print("Cursor is not running.", file=sys.stderr) sys.exit(1) print(f"Found Cursor at PID {pid}") app_element = AXUIElementCreateApplication(pid) dump_element(app_element, max_depth=args.depth) if __name__ == "__main__": main() ``` **Step 2: Run it with Cursor open** Run: `python scripts/dump_a11y_tree.py --depth 6` Expected: prints a tree of accessibility elements. Look for button elements with titles like "Accept", "Reject", "Stop", etc. Note the tree path to these elements. **Step 3: Document findings** After running, note the element paths and patterns in a comment at the top of the detector module (next task). This is exploratory — the actual patterns will depend on what we find. **Step 4: Commit** ```bash git add scripts/ git commit -m "feat: add accessibility tree explorer script for development" ``` --- ### Task 5: Accessibility Detector Module **Files:** - Create: `src/cursor_flasher/detector.py` - Create: `tests/test_detector.py` **Step 1: Write the failing tests** We can't fully test accessibility APIs without a running Cursor instance, so we test the logic layer with mocked a11y data. `tests/test_detector.py`: ```python import pytest from unittest.mock import patch, MagicMock from cursor_flasher.detector import ( CursorDetector, parse_ui_signals, UISignals, ) class TestParseUISignals: def test_no_elements_means_no_signals(self): signals = parse_ui_signals([]) assert signals.agent_working is False assert signals.approval_needed is False def test_stop_button_means_agent_working(self): elements = [{"role": "AXButton", "title": "Stop"}] signals = parse_ui_signals(elements) assert signals.agent_working is True def test_accept_button_means_approval_needed(self): elements = [{"role": "AXButton", "title": "Accept"}] signals = parse_ui_signals(elements) assert signals.approval_needed is True def test_reject_button_means_approval_needed(self): elements = [{"role": "AXButton", "title": "Reject"}] signals = parse_ui_signals(elements) assert signals.approval_needed is True def test_both_signals(self): elements = [ {"role": "AXButton", "title": "Stop"}, {"role": "AXButton", "title": "Accept"}, ] signals = parse_ui_signals(elements) assert signals.agent_working is True assert signals.approval_needed is True def test_irrelevant_buttons_ignored(self): elements = [{"role": "AXButton", "title": "Settings"}] signals = parse_ui_signals(elements) assert signals.agent_working is False assert signals.approval_needed is False class TestCursorDetector: def test_returns_none_when_cursor_not_running(self): detector = CursorDetector() with patch.object(detector, "_find_cursor_pid", return_value=None): signals = detector.poll() assert signals is None ``` **Step 2: Run tests to verify they fail** Run: `pytest tests/test_detector.py -v` Expected: FAIL — `ModuleNotFoundError` **Step 3: Implement detector module** `src/cursor_flasher/detector.py`: ```python """Accessibility-based detection of Cursor's agent state.""" from dataclasses import dataclass from ApplicationServices import ( AXUIElementCreateApplication, AXUIElementCopyAttributeNames, AXUIElementCopyAttributeValue, ) from Cocoa import NSWorkspace AGENT_WORKING_TITLES = {"Stop", "Cancel generating"} APPROVAL_TITLES = {"Accept", "Reject", "Accept All", "Deny"} @dataclass class UISignals: agent_working: bool = False approval_needed: bool = False def parse_ui_signals(elements: list[dict]) -> UISignals: """Parse flattened UI elements into detection signals.""" agent_working = False approval_needed = False for el in elements: if el.get("role") != "AXButton": continue title = el.get("title", "") if title in AGENT_WORKING_TITLES: agent_working = True if title in APPROVAL_TITLES: approval_needed = True return UISignals(agent_working=agent_working, approval_needed=approval_needed) class CursorDetector: """Polls Cursor's accessibility tree for agent state signals.""" def __init__(self): self._pid: int | None = None def poll(self) -> UISignals | None: """Poll Cursor's a11y tree and return detected signals, or None if Cursor isn't running.""" pid = self._find_cursor_pid() if pid is None: self._pid = None return None self._pid = pid app_element = AXUIElementCreateApplication(pid) elements = self._collect_buttons(app_element, max_depth=8) return parse_ui_signals(elements) def _find_cursor_pid(self) -> int | None: workspace = NSWorkspace.sharedWorkspace() for app in workspace.runningApplications(): name = app.localizedName() bundle = app.bundleIdentifier() or "" if name == "Cursor" or "cursor" in bundle.lower(): return app.processIdentifier() return None def _collect_buttons( self, element, max_depth: int = 8, depth: int = 0 ) -> list[dict]: """Walk the a11y tree collecting button elements.""" if depth > max_depth: return [] results = [] err, attr_names = AXUIElementCopyAttributeNames(element, None) if err or not attr_names: return results role = "" title = "" for name in attr_names: val_err, val = AXUIElementCopyAttributeValue(element, name, None) if val_err: continue if name == "AXRole": role = str(val) elif name == "AXTitle": title = str(val) if val else "" if role == "AXButton" and title: results.append({"role": role, "title": title}) err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None) if not err and children: for child in children: results.extend(self._collect_buttons(child, max_depth, depth + 1)) return results ``` **Step 4: Run tests to verify they pass** Run: `pytest tests/test_detector.py -v` Expected: all PASS (mocked tests pass; the `CursorDetector` test with mocked `_find_cursor_pid` passes) **Step 5: Commit** ```bash git add -A git commit -m "feat: add accessibility-based agent state detector" ``` --- ### Task 6: Overlay Window **Files:** - Create: `src/cursor_flasher/overlay.py` This module is heavily macOS-native and difficult to unit test. We build it and test manually. **Step 1: Implement the overlay** `src/cursor_flasher/overlay.py`: ```python """Native macOS overlay window that draws a pulsing border around a target window.""" import objc from Cocoa import ( NSApplication, NSWindow, NSBorderlessWindowMask, NSColor, NSView, NSBezierPath, NSTimer, NSScreen, ) from Quartz import ( CGWindowListCopyWindowInfo, kCGWindowListOptionOnScreenOnly, kCGNullWindowID, kCGWindowOwnerPID, kCGWindowBounds, kCGWindowLayer, ) import math from cursor_flasher.config import Config def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor: """Convert a hex color string to NSColor.""" hex_str = hex_str.lstrip("#") r = int(hex_str[0:2], 16) / 255.0 g = int(hex_str[2:4], 16) / 255.0 b = int(hex_str[4:6], 16) / 255.0 return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha) class PulseBorderView(NSView): """Custom view that draws a pulsing border rectangle.""" def initWithFrame_config_(self, frame, config): self = objc.super(PulseBorderView, self).initWithFrame_(frame) if self is None: return None self._config = config self._phase = 0.0 return self def drawRect_(self, rect): opacity_range = self._config.pulse_opacity_max - self._config.pulse_opacity_min alpha = self._config.pulse_opacity_min + opacity_range * ( 0.5 + 0.5 * math.sin(self._phase) ) color = hex_to_nscolor(self._config.pulse_color, alpha) color.setStroke() width = self._config.pulse_width inset = width / 2.0 path = NSBezierPath.bezierPathWithRect_(self.bounds().insetBy(inset, inset)) path.setLineWidth_(width) path.stroke() def setPhase_(self, phase): self._phase = phase self.setNeedsDisplay_(True) # Helper for insetBy since NSRect doesn't have it def bounds(self): b = objc.super(PulseBorderView, self).bounds() return b class OverlayWindow: """Manages the overlay window lifecycle.""" def __init__(self, config: Config): self._config = config self._window: NSWindow | None = None self._view: PulseBorderView | None = None self._timer: NSTimer | None = None self._phase = 0.0 self._target_pid: int | None = None def show(self, pid: int) -> None: """Show the pulsing overlay around the window belonging to `pid`.""" self._target_pid = pid frame = self._get_window_frame(pid) if frame is None: return if self._window is None: self._create_window(frame) else: self._window.setFrame_display_(frame, True) self._window.orderFrontRegardless() self._start_animation() def hide(self) -> None: """Hide and clean up the overlay.""" self._stop_animation() if self._window is not None: self._window.orderOut_(None) def update_position(self) -> None: """Reposition overlay to match current Cursor window position.""" if self._target_pid is None or self._window is None: return frame = self._get_window_frame(self._target_pid) if frame is not None: self._window.setFrame_display_(frame, True) def _create_window(self, frame) -> None: self._window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered ) self._window.setOpaque_(False) self._window.setBackgroundColor_(NSColor.clearColor()) self._window.setLevel_(25) # NSStatusWindowLevel — above normal windows self._window.setIgnoresMouseEvents_(True) self._window.setHasShadow_(False) self._view = PulseBorderView.alloc().initWithFrame_config_( frame, self._config ) self._window.setContentView_(self._view) def _start_animation(self) -> None: if self._timer is not None: return interval = 1.0 / 30.0 # 30 fps self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( interval, self, "_tick:", None, True ) def _stop_animation(self) -> None: if self._timer is not None: self._timer.invalidate() self._timer = None self._phase = 0.0 @objc.python_method def _tick_impl(self): speed = self._config.pulse_speed step = (2.0 * math.pi) / (speed * 30.0) self._phase += step if self._view is not None: self._view.setPhase_(self._phase) self.update_position() def _tick_(self, timer) -> None: self._tick_impl() def _get_window_frame(self, pid: int): """Get the screen frame of the main window for the given PID.""" window_list = CGWindowListCopyWindowInfo( kCGWindowListOptionOnScreenOnly, kCGNullWindowID ) if not window_list: return None screen_height = NSScreen.mainScreen().frame().size.height for info in window_list: if info.get(kCGWindowOwnerPID) != pid: continue if info.get(kCGWindowLayer, 999) != 0: continue bounds = info.get(kCGWindowBounds) if bounds is None: continue # Convert from CG coordinates (top-left origin) to NS coordinates (bottom-left origin) x = bounds["X"] y = screen_height - bounds["Y"] - bounds["Height"] w = bounds["Width"] h = bounds["Height"] return ((x, y), (w, h)) return None ``` **Step 2: Create a quick manual test script** `scripts/test_overlay.py`: ```python """Manual test: shows a pulsing border around Cursor for 10 seconds.""" import sys import time from Cocoa import NSApplication, NSRunLoop, NSDate from cursor_flasher.config import Config from cursor_flasher.overlay import OverlayWindow from cursor_flasher.detector import CursorDetector app = NSApplication.sharedApplication() config = Config() overlay = OverlayWindow(config) detector = CursorDetector() pid = detector._find_cursor_pid() if pid is None: print("Cursor not running") sys.exit(1) print(f"Showing overlay for PID {pid} for 10 seconds...") overlay.show(pid) end_time = time.time() + 10 while time.time() < end_time: NSRunLoop.currentRunLoop().runUntilDate_( NSDate.dateWithTimeIntervalSinceNow_(0.1) ) overlay.hide() print("Done.") ``` **Step 3: Test manually** Run: `python scripts/test_overlay.py` Expected: A pulsing amber border appears around the Cursor window for 10 seconds. **Step 4: Commit** ```bash git add -A git commit -m "feat: add native macOS overlay window with pulsing border" ``` --- ### Task 7: Sound Module **Files:** - Create: `src/cursor_flasher/sound.py` - Create: `tests/test_sound.py` **Step 1: Write the failing test** `tests/test_sound.py`: ```python import pytest from unittest.mock import patch, MagicMock from cursor_flasher.sound import play_alert from cursor_flasher.config import Config class TestPlayAlert: def test_does_nothing_when_disabled(self): config = Config(sound_enabled=False) # Should not raise play_alert(config) @patch("cursor_flasher.sound.NSSound") def test_plays_named_sound(self, mock_nssound): mock_sound_obj = MagicMock() mock_nssound.soundNamed_.return_value = mock_sound_obj config = Config(sound_enabled=True, sound_name="Glass", sound_volume=0.7) play_alert(config) mock_nssound.soundNamed_.assert_called_once_with("Glass") mock_sound_obj.setVolume_.assert_called_once_with(0.7) mock_sound_obj.play.assert_called_once() ``` **Step 2: Run tests to verify they fail** Run: `pytest tests/test_sound.py -v` Expected: FAIL — `ModuleNotFoundError` **Step 3: Implement sound module** `src/cursor_flasher/sound.py`: ```python """System sound playback.""" from Cocoa import NSSound from cursor_flasher.config import Config def play_alert(config: Config) -> None: """Play the configured alert sound if enabled.""" if not config.sound_enabled: return sound = NSSound.soundNamed_(config.sound_name) if sound is None: return sound.setVolume_(config.sound_volume) sound.play() ``` **Step 4: Run tests to verify they pass** Run: `pytest tests/test_sound.py -v` Expected: all PASS **Step 5: Commit** ```bash git add -A git commit -m "feat: add system sound alert module" ``` --- ### Task 8: Main Daemon Loop **Files:** - Create: `src/cursor_flasher/daemon.py` **Step 1: Implement the daemon** `src/cursor_flasher/daemon.py`: ```python """Main daemon loop that ties detection, state machine, overlay, and sound together.""" import logging import signal import sys import time from Cocoa import NSApplication, NSRunLoop, NSDate from cursor_flasher.config import Config, load_config from cursor_flasher.detector import CursorDetector from cursor_flasher.overlay import OverlayWindow from cursor_flasher.sound import play_alert from cursor_flasher.state import StateMachine, FlasherState logger = logging.getLogger("cursor_flasher") class FlasherDaemon: def __init__(self, config: Config): self.config = config self.state_machine = StateMachine(cooldown=config.cooldown) self.detector = CursorDetector() self.overlay = OverlayWindow(config) self._running = False self._waiting_since: float | None = None def run(self) -> None: """Run the main loop. Blocks until stopped.""" app = NSApplication.sharedApplication() self._running = True signal.signal(signal.SIGTERM, self._handle_signal) signal.signal(signal.SIGINT, self._handle_signal) logger.info("Cursor Flasher daemon started") while self._running: self._tick() NSRunLoop.currentRunLoop().runUntilDate_( NSDate.dateWithTimeIntervalSinceNow_(self.config.poll_interval) ) self.overlay.hide() logger.info("Cursor Flasher daemon stopped") def stop(self) -> None: self._running = False def _tick(self) -> None: signals = self.detector.poll() if signals is None: if self.state_machine.state == FlasherState.WAITING_FOR_USER: self.state_machine.dismiss() self.overlay.hide() self._waiting_since = None return changed = self.state_machine.update( agent_working=signals.agent_working, approval_needed=signals.approval_needed, ) if not changed: if ( self.state_machine.state == FlasherState.WAITING_FOR_USER and self._waiting_since is not None ): elapsed = time.monotonic() - self._waiting_since if elapsed > self.config.auto_dismiss: self.state_machine.dismiss() self.overlay.hide() self._waiting_since = None return match self.state_machine.state: case FlasherState.WAITING_FOR_USER: pid = self.detector._pid if pid is not None: self.overlay.show(pid) play_alert(self.config) self._waiting_since = time.monotonic() case FlasherState.AGENT_WORKING | FlasherState.IDLE: self.overlay.hide() self._waiting_since = None def _handle_signal(self, signum, frame): logger.info(f"Received signal {signum}, shutting down") self.stop() def run_daemon() -> None: """Entry point for the daemon.""" logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", ) config = load_config() daemon = FlasherDaemon(config) daemon.run() ``` **Step 2: Verify it loads without errors** Run: `python -c "from cursor_flasher.daemon import FlasherDaemon; print('OK')"` Expected: `OK` **Step 3: Commit** ```bash git add -A git commit -m "feat: add main daemon loop wiring detection, overlay, and sound" ``` --- ### Task 9: CLI **Files:** - Create: `src/cursor_flasher/cli.py` **Step 1: Implement the CLI** `src/cursor_flasher/cli.py`: ```python """CLI for starting/stopping the cursor-flasher daemon.""" import argparse import logging import os import signal import sys from pathlib import Path PID_FILE = Path.home() / ".cursor-flasher" / "daemon.pid" def _write_pid() -> None: PID_FILE.parent.mkdir(parents=True, exist_ok=True) PID_FILE.write_text(str(os.getpid())) def _read_pid() -> int | None: if not PID_FILE.exists(): return None try: pid = int(PID_FILE.read_text().strip()) os.kill(pid, 0) # Check if process is alive return pid except (ValueError, ProcessLookupError, PermissionError): PID_FILE.unlink(missing_ok=True) return None def _remove_pid() -> None: PID_FILE.unlink(missing_ok=True) def cmd_start(args: argparse.Namespace) -> None: existing = _read_pid() if existing is not None: print(f"Daemon already running (PID {existing})") sys.exit(1) if args.foreground: _write_pid() try: from cursor_flasher.daemon import run_daemon run_daemon() finally: _remove_pid() else: pid = os.fork() if pid > 0: print(f"Daemon started (PID {pid})") return # Child process os.setsid() _write_pid() try: from cursor_flasher.daemon import run_daemon run_daemon() finally: _remove_pid() def cmd_stop(args: argparse.Namespace) -> None: pid = _read_pid() if pid is None: print("Daemon is not running") sys.exit(1) os.kill(pid, signal.SIGTERM) _remove_pid() print(f"Daemon stopped (PID {pid})") def cmd_status(args: argparse.Namespace) -> None: pid = _read_pid() if pid is None: print("Daemon is not running") else: print(f"Daemon is running (PID {pid})") def main() -> None: parser = argparse.ArgumentParser( prog="cursor-flasher", description="Flash the Cursor window when the AI agent is waiting for input", ) sub = parser.add_subparsers(dest="command") start_parser = sub.add_parser("start", help="Start the daemon") start_parser.add_argument( "--foreground", "-f", action="store_true", help="Run in the foreground (don't daemonize)" ) start_parser.set_defaults(func=cmd_start) stop_parser = sub.add_parser("stop", help="Stop the daemon") stop_parser.set_defaults(func=cmd_stop) status_parser = sub.add_parser("status", help="Check daemon status") status_parser.set_defaults(func=cmd_status) args = parser.parse_args() if not hasattr(args, "func"): parser.print_help() sys.exit(1) args.func(args) ``` **Step 2: Reinstall and test CLI** Run: `pip install -e ".[dev]"` then `cursor-flasher --help` Expected: prints usage info with start/stop/status commands **Step 3: Test foreground mode briefly** Run: `cursor-flasher start --foreground` (then Ctrl+C after a few seconds) Expected: starts monitoring, logs output, stops cleanly on Ctrl+C **Step 4: Commit** ```bash git add -A git commit -m "feat: add CLI with start/stop/status commands" ``` --- ### Task 10: Integration Testing & Tuning **Files:** - Modify: `src/cursor_flasher/detector.py` (tune button titles based on a11y dump) **Step 1: Run the a11y tree dump** Run: `python scripts/dump_a11y_tree.py --depth 8 > a11y_dump.txt` Expected: dumps Cursor's full accessibility tree to a file **Step 2: Search for relevant buttons** Examine `a11y_dump.txt` for button titles. Look for patterns like "Accept", "Reject", "Stop", "Cancel", or any loading/thinking indicators. Update the `AGENT_WORKING_TITLES` and `APPROVAL_TITLES` sets in `detector.py` based on findings. **Step 3: Run end-to-end test** Run: `cursor-flasher start --foreground` Then trigger an agent action in Cursor and wait for it to finish. Verify: - Overlay appears when agent finishes - Sound plays - Overlay disappears when you interact **Step 4: Tune and fix any issues** Iterate on detection patterns, overlay positioning, and timing based on real-world testing. **Step 5: Commit** ```bash git add -A git commit -m "fix: tune detection patterns based on accessibility tree analysis" ``` --- ### Task 11: README **Files:** - Create: `README.md` **Step 1: Write the README** `README.md` should cover: - What cursor-flasher does (one sentence) - Prerequisites (macOS, Python 3.10+, Accessibility permission) - Installation steps - Usage (start/stop/status) - Configuration (link to config.yaml format) - Troubleshooting (Accessibility permissions) **Step 2: Commit** ```bash git add README.md git commit -m "docs: add README with installation and usage instructions" ```