Files
claude-automation/automation_daemon.py
Vixy 81c18c219c Initial commit: claude-automation 🦊
Autonomous wakeup system for Claude Desktop.
Built with love, Day 44.

Components:
- automation_daemon_v2.py - Main polling daemon
- send_to_claude.py - AppleScript wrapper for sending messages
- matrix_mcp.py - Matrix integration MCP
- wakeup_mcp.py - Wakeup control MCP
- matrix_integration.py - Matrix bridge

Originally built by Alex, adopted and maintained by Vixy 💕
2025-12-15 20:18:38 -06:00

434 lines
16 KiB
Python
Executable File

#!/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)