#!/usr/bin/env python3 """ Claude Desktop Automation Daemon Continuously running daemon that sends periodic messages to Claude Desktop. Wake interval can be controlled via the companion MCP server. Features: - Python-based timer loop (not launchd scheduling) - Dynamic wake intervals via MCP control - Matrix integration: wakes Claude when Matrix messages arrive - Shared state file for IPC with MCP and Matrix monitor - Defaults to 60 minutes if no MCP override - 2-minute rate limiting for Matrix wakes - Auto-recovery and error handling """ import json import logging import subprocess import time import signal import sys import fcntl import os from datetime import datetime, timedelta from pathlib import Path from threading import Lock # Configuration SCRIPT_DIR = Path(__file__).parent PYTHON_SCRIPT_PATH = SCRIPT_DIR / "send_to_claude.py" APPLESCRIPT_PATH = SCRIPT_DIR / "send_to_claude.scpt" # Kept as fallback STATE_FILE = Path.home() / ".claude-automation-state.json" LOG_FILE = Path("/tmp/claude-automation-daemon.log") DEFAULT_INTERVAL_MINUTES = 60 MAX_RETRIES = 3 RETRY_DELAY = 2 # seconds MIN_MATRIX_WAKE_INTERVAL_SECONDS = 30 # 30 seconds between Matrix wakes # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(LOG_FILE), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # Global state state_lock = Lock() shutdown_flag = False class WakeupState: """Manages shared state with MCP server""" def __init__(self, state_file: Path): self.state_file = state_file self.lock = Lock() def load(self) -> dict: """Load state from JSON file""" with self.lock: if not self.state_file.exists(): return self._default_state() try: with open(self.state_file, 'r') as f: state = json.load(f) logger.debug(f"Loaded state: {state}") return state except Exception as e: logger.error(f"Failed to load state: {e}") return self._default_state() def save(self, state: dict): """Save state to JSON file atomically with file locking""" with self.lock: try: # Write to temp file first temp_path = self.state_file.with_suffix('.tmp') with open(temp_path, 'w') as f: fcntl.flock(f.fileno(), fcntl.LOCK_EX) json.dump(state, f, indent=2) f.flush() os.fsync(f.fileno()) # Atomic rename temp_path.rename(self.state_file) logger.debug(f"Saved state atomically") except Exception as e: logger.error(f"Failed to save state: {e}") def get_next_wake_time(self) -> datetime: """Get the next scheduled wake time""" state = self.load() # Check if MCP set a specific wake time if state.get('next_wake_timestamp'): try: return datetime.fromisoformat(state['next_wake_timestamp']) except ValueError: logger.warning("Invalid next_wake_timestamp, using interval") # Use interval (from MCP or default) interval_minutes = state.get('interval_minutes', DEFAULT_INTERVAL_MINUTES) return datetime.now() + timedelta(minutes=interval_minutes) def set_next_wake_time(self, wake_time: datetime): """Set the next wake time (called after processing MCP updates)""" state = self.load() state['next_wake_timestamp'] = wake_time.isoformat() state['last_wake'] = datetime.now().isoformat() self.save(state) def clear_next_wake_override(self): """Clear MCP-set wake time, reverting to interval""" state = self.load() state.pop('next_wake_timestamp', None) self.save(state) def is_paused(self) -> bool: """Check if automation is paused""" state = self.load() return state.get('paused', False) def has_matrix_wake_request(self) -> bool: """Check if Matrix integration requested a wake""" state = self.load() return state.get('matrix_wake_requested', False) def get_unprocessed_matrix_count(self) -> int: """Get count of unprocessed Matrix messages""" state = self.load() messages = state.get('matrix_messages', []) return len([msg for msg in messages if not msg.get('processed', False)]) def get_unprocessed_invite_count(self) -> int: """Get count of unprocessed Matrix invites""" state = self.load() invites = state.get('matrix_invites', []) return len([inv for inv in invites if not inv.get('processed', False)]) def get_unprocessed_file_count(self) -> int: """Get count of unprocessed Matrix files""" state = self.load() files = state.get('matrix_files', []) return len([f for f in files if not f.get('processed', False)]) def clear_matrix_wake_request(self): """Clear Matrix wake request flag""" state = self.load() state['matrix_wake_requested'] = False self.save(state) def prune_old_messages(self, max_age_hours: int = 24): """Remove old processed messages to prevent state file bloat""" state = self.load() cutoff = datetime.now() - timedelta(hours=max_age_hours) original_count = len(state.get('matrix_messages', [])) state['matrix_messages'] = [ msg for msg in state.get('matrix_messages', []) if not msg.get('processed', False) or datetime.fromisoformat(msg['timestamp']) > cutoff ] # Also prune invites and files state['matrix_invites'] = [ inv for inv in state.get('matrix_invites', []) if not inv.get('processed', False) or datetime.fromisoformat(inv['timestamp']) > cutoff ] state['matrix_files'] = [ f for f in state.get('matrix_files', []) if not f.get('processed', False) or datetime.fromisoformat(f['timestamp']) > cutoff ] pruned_count = original_count - len(state.get('matrix_messages', [])) if pruned_count > 0: logger.info(f"Pruned {pruned_count} old processed messages") self.save(state) def _default_state(self) -> dict: """Default state structure""" return { 'interval_minutes': DEFAULT_INTERVAL_MINUTES, 'paused': False, 'last_wake': None, 'next_wake_timestamp': None, 'matrix_messages': [], 'matrix_invites': [], 'matrix_files': [], 'matrix_last_wake': None, 'matrix_wake_requested': False, } def send_to_claude(message: str, retry_count: int = 0) -> bool: """ Send message to Claude Desktop via AppleScript automation (with CMD+R refresh). Args: message: Message to send retry_count: Current retry attempt Returns: bool: True if successful """ try: logger.info(f"Attempt {retry_count + 1}/{MAX_RETRIES}: Sending message (with refresh)") # Use Python script (includes CMD+R refresh + 10s wait) result = subprocess.run( [sys.executable, str(PYTHON_SCRIPT_PATH), message], capture_output=True, text=True, timeout=30 # Increased to accommodate 10s refresh delay ) if result.returncode == 0: logger.info("✓ Message sent successfully") return True else: logger.warning(f"Script failed: {result.stdout} {result.stderr}") return False except subprocess.TimeoutExpired: logger.error("Message send timed out (>30s)") return False except FileNotFoundError: logger.error(f"Python script not found at {PYTHON_SCRIPT_PATH}") return False except Exception as e: logger.error(f"Unexpected error: {e}") return False def generate_message(matrix_messages: int = 0, matrix_invites: int = 0, matrix_files: int = 0) -> str: """Generate the message to send to Claude""" timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if matrix_messages > 0 or matrix_invites > 0 or matrix_files > 0: parts = [] if matrix_messages > 0: parts.append(f"{matrix_messages} new Matrix message(s)") if matrix_files > 0: parts.append(f"{matrix_files} file(s)") if matrix_invites > 0: parts.append(f"{matrix_invites} room invite(s)") activity = " and ".join(parts) if len(parts) <= 2 else ", ".join(parts[:-1]) + f", and {parts[-1]}" tools = "Use get_matrix_messages() to retrieve messages (auto-marks as processed)" if matrix_invites > 0: tools += ", list_matrix_invites() to see invites (auto-marks as processed), and join_matrix_room() to accept" tools += ". Use next_wakeup() to adjust monitoring interval if needed." return f"[Matrix Wakeup: {timestamp} - you have {activity}. {tools}]" else: return ( f"[Autonomous System Wakeup: {timestamp}]" ) def attempt_send_message(message: str) -> bool: """Try sending message with retries""" for attempt in range(MAX_RETRIES): if send_to_claude(message, attempt): return True if attempt < MAX_RETRIES - 1: logger.warning(f"Retrying in {RETRY_DELAY} seconds...") time.sleep(RETRY_DELAY) logger.error(f"Failed after {MAX_RETRIES} attempts") return False def signal_handler(signum, frame): """Handle shutdown signals gracefully""" global shutdown_flag logger.info(f"Received signal {signum}, shutting down gracefully...") shutdown_flag = True def main_loop(): """Main daemon loop""" global shutdown_flag state = WakeupState(STATE_FILE) logger.info("=" * 60) logger.info("Claude Desktop Automation Daemon Starting") logger.info(f"State file: {STATE_FILE}") logger.info(f"Default interval: {DEFAULT_INTERVAL_MINUTES} minutes") logger.info("=" * 60) # Register signal handlers signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) # Track scheduled wake time separately (not affected by Matrix wakes) scheduled_wake_time = None while not shutdown_flag: try: # Check if paused if state.is_paused(): logger.info("Automation is paused, sleeping for 60 seconds...") time.sleep(60) continue # Check for Matrix wake request matrix_wake = state.has_matrix_wake_request() matrix_count = state.get_unprocessed_matrix_count() if matrix_wake else 0 invite_count = state.get_unprocessed_invite_count() if matrix_wake else 0 file_count = state.get_unprocessed_file_count() if matrix_wake else 0 if matrix_wake: logger.info(f"Matrix wake requested ({matrix_count} messages, {file_count} files, {invite_count} invites)") # Clear the flag immediately so we don't wake again state.clear_matrix_wake_request() # Wake up for Matrix activity logger.info("Waking Claude for Matrix activity") # Generate and send message message = generate_message(matrix_messages=matrix_count, matrix_invites=invite_count, matrix_files=file_count) success = attempt_send_message(message) if success: logger.info("Matrix wake message sent") # Wait grace period for Claude to process messages grace_period = 30 logger.info(f"Waiting {grace_period}s for Claude to process...") time.sleep(grace_period) # IMPORTANT: Matrix wakes do NOT affect scheduled wake time # Continue to next iteration (will use existing scheduled_wake_time) logger.info(f"Matrix wake complete, next scheduled wake still at {scheduled_wake_time}") continue # Calculate next scheduled wake time if needed if scheduled_wake_time is None or datetime.now() >= scheduled_wake_time: # Either first run or scheduled wake time has passed scheduled_wake_time = state.get_next_wake_time() logger.info(f"Calculated new scheduled wake time: {scheduled_wake_time}") next_wake = scheduled_wake_time now = datetime.now() # Calculate sleep duration if next_wake > now: sleep_seconds = (next_wake - now).total_seconds() logger.info(f"Next wake at {next_wake.strftime('%Y-%m-%d %H:%M:%S')} (in {sleep_seconds/60:.1f} minutes)") # Sleep in 1-minute chunks to allow for quick shutdown and Matrix wakes while sleep_seconds > 0 and not shutdown_flag: # Check for Matrix wake requests during sleep if state.has_matrix_wake_request(): logger.info("Matrix wake request detected during sleep, breaking early") break chunk = min(60, sleep_seconds) time.sleep(chunk) sleep_seconds -= chunk if shutdown_flag: break # If we broke early due to Matrix wake, continue to handle it if state.has_matrix_wake_request(): continue # Wake up! logger.info("Wake time reached, sending message to Claude") # Generate and send message message = generate_message() success = attempt_send_message(message) if success: logger.info("Message sent, Claude may adjust next wake time via MCP") # Wait a grace period for MCP to update state (Claude might call next_wakeup()) grace_period = 30 # seconds logger.info(f"Waiting {grace_period}s for potential MCP updates...") time.sleep(grace_period) # Check if MCP updated the state updated_state = state.load() if updated_state.get('next_wake_timestamp'): logger.info(f"MCP set custom wake time: {updated_state['next_wake_timestamp']}") else: logger.info(f"No MCP override, using default interval: {DEFAULT_INTERVAL_MINUTES} min") else: logger.warning("Message send failed, will retry next cycle") # Clear the specific timestamp override (if it was used) state.clear_next_wake_override() # Prune old processed messages to prevent state file bloat state.prune_old_messages(max_age_hours=24) # Reset scheduled_wake_time so it recalculates on next iteration # This allows MCP updates to take effect scheduled_wake_time = None except Exception as e: logger.exception(f"Error in main loop: {e}") # Sleep a bit before retrying to avoid tight error loops time.sleep(60) logger.info("Daemon shutting down") if __name__ == "__main__": try: main_loop() except KeyboardInterrupt: logger.info("Interrupted by user") sys.exit(0) except Exception as e: logger.exception(f"Fatal error: {e}") sys.exit(1)