Files
cursor-flasher/src/cursor_flasher/daemon.py
cottongin a5ca7f5d33 fix: tune detection patterns and add state transition logging
- Added "Resume" and "Continue" to approval keywords
- Added state transition logging to daemon for observability
- Guarded signal handler against duplicate SIGTERM delivery
- Verified end-to-end: daemon detects approval prompt, transitions
  to waiting_for_user, overlays 1 window, plays sound

Made-with: Cursor
2026-03-10 02:57:01 -04:00

107 lines
3.5 KiB
Python

"""Main daemon loop that ties detection, state machine, overlay, and sound together."""
import logging
import signal
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 OverlayManager
from cursor_flasher.sound import play_alert
from cursor_flasher.state import FlasherState, StateMachine
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 = OverlayManager(config)
self._running = False
self._waiting_since: float | None = None
def run(self) -> None:
"""Run the main loop. Blocks until stopped."""
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:
result = self.detector.poll()
if result 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=result.signals.agent_working,
approval_needed=result.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:
logger.info("Auto-dismissing after timeout")
self.state_machine.dismiss()
self.overlay.hide()
self._waiting_since = None
return
logger.info("State → %s", self.state_machine.state.value)
match self.state_machine.state:
case FlasherState.WAITING_FOR_USER:
if result.active_windows:
logger.info(
"Showing overlay on %d window(s)", len(result.active_windows)
)
self.overlay.show(result.active_windows)
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):
if not self._running:
return
logger.info("Received signal %d, shutting down", signum)
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()