Files
cursor-flasher/docs/plans/2026-03-10-cursor-flasher-implementation.md
cottongin 25a775ff4e Add cursor-flasher implementation plan
11 bite-sized tasks covering scaffolding, config, state machine,
accessibility detection, overlay, sound, daemon, CLI, and testing.

Made-with: Cursor
2026-03-10 02:13:26 -04:00

38 KiB

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

[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:

"""Cursor Flasher — flash the Cursor window when the AI agent is waiting for input."""

__version__ = "0.1.0"

src/cursor_flasher/__main__.py:

from cursor_flasher.cli import main

if __name__ == "__main__":
    main()

tests/__init__.py: empty file

tests/conftest.py:

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

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:

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:

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

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:

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:

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

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:

"""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

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:

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:

"""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

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:

"""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:

"""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

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:

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:

"""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

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:

"""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

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:

"""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

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

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

git add README.md
git commit -m "docs: add README with installation and usage instructions"