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 💕
This commit is contained in:
433
automation_daemon.py
Executable file
433
automation_daemon.py
Executable file
@@ -0,0 +1,433 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user