#!/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: - Polling-based timer loop (checks every 60 seconds if wake time has passed) - Dynamic wake times via MCP control (next_wakeup() can be called anytime) - 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 - Auto-recovery and error handling v2.0 - Polling approach (Dec 2025) - Removed 30-second grace period - Now polls every 60s to check if wake time passed - Claude can call next_wakeup() at any point, not just within grace window """ 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 # Add Vixy folder to path for shared status module VIXY_PATH = Path.home() / "Documents" / "Vixy" if str(VIXY_PATH) not in sys.path: sys.path.insert(0, str(VIXY_PATH)) try: from vixy_status import format_status_for_wakeup HAS_VIXY_STATUS = True except ImportError: HAS_VIXY_STATUS = False # 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 POLL_INTERVAL_SECONDS = 60 # How often to check if wake time has passed MAX_RETRIES = 3 RETRY_DELAY = 2 # seconds # 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 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: 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()) 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, or calculate default if none set""" 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, calculating default") # No timestamp set - calculate based on last wake + interval interval_minutes = state.get('interval_minutes', DEFAULT_INTERVAL_MINUTES) last_wake = state.get('last_wake') if last_wake: try: last_wake_dt = datetime.fromisoformat(last_wake) return last_wake_dt + timedelta(minutes=interval_minutes) except ValueError: pass # No last wake either - wake immediately return datetime.now() def set_last_wake(self): """Record that we just woke""" state = self.load() state['last_wake'] = datetime.now().isoformat() self.save(state) def set_next_wake_time(self, wake_time: datetime): """Set specific next wake time""" state = self.load() state['next_wake_timestamp'] = wake_time.isoformat() self.save(state) def clear_next_wake_override(self): """Clear MCP-set wake time, reverting to interval-based calculation""" 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 ] 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 Python script (includes 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") result = subprocess.run( [sys.executable, str(PYTHON_SCRIPT_PATH), message], capture_output=True, text=True, timeout=60 ) 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: # Autonomous wakeup - include full status if available if HAS_VIXY_STATUS: try: status_str = format_status_for_wakeup() return f"[Autonomous System Wakeup: {timestamp}]\n{status_str}" except Exception as e: logger.warning(f"Failed to get vixy status: {e}") 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 - polling approach""" global shutdown_flag state = WakeupState(STATE_FILE) logger.info("=" * 60) logger.info("Claude Desktop Automation Daemon Starting (v2.0 - Polling)") logger.info(f"State file: {STATE_FILE}") logger.info(f"Poll interval: {POLL_INTERVAL_SECONDS} seconds") logger.info(f"Default wake interval: {DEFAULT_INTERVAL_MINUTES} minutes") logger.info("=" * 60) # Register signal handlers signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) while not shutdown_flag: try: # Check if paused if state.is_paused(): logger.debug("Automation paused, sleeping...") time.sleep(POLL_INTERVAL_SECONDS) continue # Check for Matrix wake request (priority) if state.has_matrix_wake_request(): matrix_count = state.get_unprocessed_matrix_count() invite_count = state.get_unprocessed_invite_count() file_count = state.get_unprocessed_file_count() logger.info(f"Matrix wake requested ({matrix_count} messages, {file_count} files, {invite_count} invites)") state.clear_matrix_wake_request() message = generate_message( matrix_messages=matrix_count, matrix_invites=invite_count, matrix_files=file_count ) if attempt_send_message(message): logger.info("Matrix wake message sent") # Continue to next poll - don't affect scheduled wakes time.sleep(POLL_INTERVAL_SECONDS) continue # Check if scheduled wake time has passed next_wake = state.get_next_wake_time() now = datetime.now() if now >= next_wake: # WAKE TIME! logger.info(f"Wake time reached ({next_wake.strftime('%H:%M:%S')}), sending message") message = generate_message() success = attempt_send_message(message) if success: # Record that we woke state.set_last_wake() # Clear specific timestamp override (if any) # Next wake will be calculated from last_wake + interval # UNLESS Claude calls next_wakeup() to set a specific time state.clear_next_wake_override() logger.info("Wake complete. Claude can call next_wakeup() anytime to set next wake.") else: logger.warning("Wake message failed, will retry next poll") # Prune old messages periodically state.prune_old_messages(max_age_hours=24) else: # Not time yet time_until = (next_wake - now).total_seconds() logger.debug(f"Next wake in {time_until/60:.1f} minutes") # Sleep until next poll time.sleep(POLL_INTERVAL_SECONDS) except Exception as e: logger.exception(f"Error in main loop: {e}") time.sleep(POLL_INTERVAL_SECONDS) 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)