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:
2025-12-15 20:18:38 -06:00
commit 81c18c219c
12 changed files with 5287 additions and 0 deletions

725
README.md Executable file
View File

@@ -0,0 +1,725 @@
# Claude Desktop Automation with MCP Control
Automatically send periodic messages to Claude Desktop on macOS, with **Claude controlling its own wake schedule** via MCP and **bidirectional Matrix integration** for AI-powered messaging.
## 🎯 How It Works
**Three-part system:**
1. **Automation Daemon** - Python process that:
- Runs continuously in the background
- Sends messages to Claude Desktop on a timer (with CMD+R refresh)
- Reads wake schedule from shared state file
- Monitors for Matrix wake requests
- Uses AppleScript for reliable message delivery
2. **Wakeup Control MCP** - MCP server that lets Claude:
- Adjust when it wants to be woken next
- Pause/resume automation
- Check status
3. **Matrix Integration** (Optional) - Bidirectional messaging:
- Monitor Matrix rooms for incoming messages
- Wake Claude when messages arrive (2-min rate limit)
- Claude can read messages and respond via MCP tools
- Supports text + images with automatic compression
**The magic:** Claude can call `next_wakeup(30)` to say "wake me in 30 minutes instead of the default 60", and Matrix messages automatically wake Claude when your friends message you!
## Architecture
```
┌──────────────────────────────────────────────┐
│ Matrix Monitor (Python/matrix-nio) │
│ - Listens for Matrix messages │
│ - Queues messages to state file │
│ - Sets matrix_wake_requested flag │
│ - 2-minute rate limiting │
└──────────────┬───────────────────────────────┘
│ (writes Matrix messages)
~/.claude-automation-state.json
│ (reads state + Matrix requests)
┌──────────────┴───────────────────────────────┐
│ Automation Daemon (Python) │
│ - Timer loop (handles own scheduling) │
│ - Monitors for Matrix wake requests │
│ - Sends messages via AppleScript │
└──────────────┬───────────────────────────────┘
│ (sends message)
┌──────────────────────┐
│ Claude Desktop UI │
└────────┬─────────────┘
│ (calls MCP tools)
┌─────────────────────────────────────────────┐
│ MCP Servers (FastMCP) │
│ │
│ Wakeup Control: │
│ - next_wakeup(minutes) │
│ - pause_automation() │
│ - resume_automation() │
│ - get_status() │
│ │
│ Matrix Control: │
│ - get_matrix_messages() │
│ - send_matrix_message(room_id, msg) │
│ - mark_messages_processed(event_ids) │
│ - list_matrix_rooms() │
│ - get_matrix_status() │
└──────────────┬──────────────────────────────┘
│ (writes state)
~/.claude-automation-state.json
```
## Requirements
**Core System:**
- macOS (tested on macOS 13+)
- Python 3 (built-in)
- Claude Desktop
- FastMCP (`pip install fastmcp`)
- AppleScript (built-in to macOS)
- Accessibility permissions (critical - see below)
**Matrix Integration (Optional):**
- Matrix account (matrix.org or self-hosted)
- matrix-nio (`pip install matrix-nio`)
- Pillow (`pip install Pillow`)
## Installation
### Quick Setup
```bash
cd ~/scripts/claude-desktop-automation
./setup.sh
```
The setup script will:
1. Check dependencies
2. Guide you through permissions
3. Install daemon (launchd)
4. Configure MCP server
5. Start everything
### Manual Setup
If you prefer manual installation:
#### 1. Install Dependencies
```bash
# Install all dependencies
pip3 install -r requirements.txt
# Or install individually:
pip3 install fastmcp
```
#### 2. Grant Accessibility Permissions (CRITICAL)
**This is required for AppleScript automation to work!**
1. Open **System Settings****Privacy & Security****Accessibility**
2. Click the **+** button
3. Add **Terminal** (or your Python interpreter)
4. Ensure the checkbox is enabled
**Testing accessibility:**
```bash
python3 send_to_claude.py "Test" --no-refresh
```
If you see "Failed to send message", accessibility permissions are not set correctly.
#### 3. Configure MCP Server
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"wakeup-control": {
"command": "python3",
"args": ["/Users/YOUR_USERNAME/scripts/claude-desktop-automation/wakeup_mcp.py"]
}
}
}
```
#### 4. Install Daemon
```bash
# Copy and customize plist
cp ~/scripts/claude-desktop-automation/com.claude.monitor.plist ~/Library/LaunchAgents/
sed -i '' "s|/Users/alex|$HOME|g" ~/Library/LaunchAgents/com.claude.monitor.plist
# Load daemon
launchctl load ~/Library/LaunchAgents/com.claude.monitor.plist
```
#### 5. Restart Claude Desktop
Close and reopen Claude Desktop to load the MCP server.
## 🔄 Chat Refresh Feature
**All wake messages now include CMD+R refresh before sending!**
This prevents messages from being overwritten when using Claude Desktop on multiple devices:
- **Timed wakes**: CMD+R ensures chat is synced before system check message
- **Matrix wakes**: CMD+R ensures you see the latest conversation context
The daemon waits **10 seconds** after sending CMD+R to ensure the refresh completes before sending the message.
**Why this matters:**
- Multi-device sync: If you're active on mobile/web, desktop chat stays current
- Prevents message overwrites: Chat refreshes before automation sends
- Better context: Claude sees all recent messages, not stale state
**Configurable delay:** Edit `REFRESH_DELAY_SECONDS` in `send_to_claude.py` line 31 if you need more/less time.
## Usage
### The Flow
1. **Daemon wakes** (default: every 60 minutes)
2. **Sends message** to Claude Desktop: "System check at [time] - use next_wakeup() if you want to change interval"
3. **Claude responds** and optionally calls MCP tools
4. **Daemon sleeps** until next wake time (uses MCP-set time or default)
### MCP Tools
#### `next_wakeup(minutes)`
Set when you want to be woken next. **Overrides default for one cycle only.**
```
Claude, use next_wakeup(15) to wake me in 15 minutes
Claude, use next_wakeup(120) to wake me in 2 hours
Claude, use next_wakeup(1440) to skip until tomorrow
```
**Use cases:**
- High activity period → `next_wakeup(15)` for frequent checks
- Low activity → `next_wakeup(180)` for infrequent checks
- One-time skip → `next_wakeup(1440)` for 24 hours
#### `pause_automation()`
Stop wake messages completely.
```
Claude, pause the automation - I don't need monitoring right now
```
#### `resume_automation()`
Resume wake messages.
```
Claude, resume the automation
```
#### `get_status()`
Check current state.
```
Claude, what's the automation status?
```
Returns:
- Running/paused state
- Last wake time
- Next wake time
- Current interval
### Example Conversation
**Daemon:** (sends at 9:00 AM) "System check at 2025-01-15 09:00:00 - please analyze recent activity and use next_wakeup() if you want to change the monitoring interval"
**You:** "Claude, everything looks normal. Check back in 2 hours instead of 1 hour."
**Claude:** "I'll adjust the wake schedule. Using next_wakeup(120)..."
[calls `next_wakeup(120)`]
**Claude:** "✓ Next wake scheduled for 2025-01-15 11:00:00 (in 120 minutes)"
**Daemon:** (wakes at 11:00 AM instead of 10:00 AM)
## Configuration
### Change Default Interval
Edit `automation_daemon.py` line 32:
```python
DEFAULT_INTERVAL_MINUTES = 60 # Change to your preferred default
```
### Change Message Text
Edit `automation_daemon.py` line 166-169:
```python
def generate_message() -> str:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return f"Your custom message template with {timestamp}"
```
### Grace Period
After sending a message, the daemon waits 30 seconds for Claude to call MCP tools. Adjust in `automation_daemon.py` line 246:
```python
grace_period = 30 # seconds
```
## Monitoring
### Logs
```bash
# Daemon logs (timer, messages sent)
tail -f /tmp/claude-automation-daemon.log
# MCP logs (tool calls from Claude)
tail -f /tmp/wakeup-mcp.log
# launchd stdout/stderr
tail -f /tmp/claude-monitor.out
tail -f /tmp/claude-monitor.err
```
### Check Daemon Status
```bash
# Is it running?
launchctl list | grep com.claude.monitor
# View details
launchctl print gui/$(id -u)/com.claude.monitor
# Check process
ps aux | grep automation_daemon.py
```
### State File
The shared state is stored in `~/.claude-automation-state.json`:
```bash
cat ~/.claude-automation-state.json
```
Example:
```json
{
"interval_minutes": 60,
"paused": false,
"last_wake": "2025-01-15T09:00:00",
"next_wake_timestamp": "2025-01-15T11:00:00"
}
```
## Troubleshooting
### Messages not sending
**Check Claude Desktop is running:**
```bash
ps aux | grep Claude
```
**Check daemon is running:**
```bash
launchctl list | grep com.claude.monitor
tail -20 /tmp/claude-automation-daemon.log
```
**Check Accessibility permissions:**
System Settings → Privacy & Security → Accessibility → Terminal should be enabled
**Test AppleScript accessibility:**
```bash
python3 /path/to/send_to_claude.py "Test message" --no-refresh
```
If you see "Failed to send CMD+R" or "Failed to send message", check accessibility permissions:
1. System Settings → Privacy & Security → Accessibility
2. Find Terminal (or Python) in the list
3. Enable it (toggle on)
4. Restart Terminal
5. Test again
**Refresh delay:**
The default 10-second delay after CMD+R should be sufficient for most cases. If your Claude Desktop takes longer to refresh, edit `send_to_claude.py` line 31:
```python
REFRESH_DELAY_SECONDS = 15 # Increase if needed
```
### MCP tools not available
**Restart Claude Desktop** after adding MCP server to config
**Check MCP server config:**
```bash
cat ~/Library/Application\ Support/Claude/claude_desktop_config.json
```
**Check MCP logs:**
```bash
tail -f /tmp/wakeup-mcp.log
```
### `next_wakeup()` not working
**Check grace period** - Daemon waits 30 seconds after sending message before rescheduling
**Check state file** - Should update when you call the tool:
```bash
cat ~/.claude-automation-state.json
```
**Check MCP logs** - Confirms tool was called:
```bash
tail /tmp/wakeup-mcp.log
```
### Daemon keeps restarting
**Check for errors:**
```bash
tail -50 /tmp/claude-automation-daemon.log
```
**Temporarily stop it:**
```bash
launchctl unload ~/Library/LaunchAgents/com.claude.monitor.plist
```
### Timed wake-ups not appearing (but Matrix wakes work)
This typically happens when the **screen saver is active**. Matrix wakes work because they're reactive (screen is likely active), but timed wakes happen on schedule when the screen saver is often running.
**Solution:** The AppleScript now uses `caffeinate -u` to wake the screen before sending keystrokes:
```applescript
-- Wake the screen if screen saver is active
do shell script "caffeinate -u -t 1"
delay 0.5
```
This simulates user activity and dismisses the screen saver, allowing keyboard automation to work.
**To verify it's working:**
```bash
tail -f /tmp/claude-automation-daemon.log
# Look for: "Waking screen if needed..." in AppleScript logs
```
**Alternative:** If you want to prevent screen saver entirely on the Claude Desktop machine:
```bash
# Disable screen saver
defaults -currentHost write com.apple.screensaver idleTime 0
```
## Matrix Integration
The automation supports **bidirectional Matrix messaging** - Claude can receive messages from Matrix rooms and respond to them automatically.
### Setup
```bash
cd ~/scripts/claude-desktop-automation
./setup_matrix.sh
```
The setup script will:
1. Install matrix-nio dependency
2. Authenticate with your Matrix homeserver
3. Save credentials securely (chmod 600)
4. Configure room whitelist (optional)
5. Add Matrix MCP to Claude config
6. Start Matrix monitor
### How It Works
1. **Matrix Monitor** runs in background, listening for messages
2. New messages are queued to `~/.claude-automation-state.json`
3. Monitor sets `matrix_wake_requested` flag (respects 2-min rate limit)
4. **Automation Daemon** detects flag and wakes Claude
5. Claude uses **Matrix MCP tools** to read and respond
6. **Messages auto-marked as processed** when retrieved (won't trigger duplicate wakes)
### Matrix MCP Tools
#### `get_matrix_messages(limit=10, include_processed=False)`
Retrieve queued Matrix messages (text + images).
**✨ Messages are automatically marked as processed when retrieved!**
```
Claude, check my Matrix messages
Claude, get the last 20 Matrix messages
```
Returns messages with:
- Room name and sender
- Message text or inline image
- Timestamp and event ID
- Auto-marked as processed (won't trigger future wakes)
#### `send_matrix_message(room_id, message)`
Send a message to a Matrix room.
```
Claude, send "Hello!" to room !abc123:matrix.org
```
#### `mark_messages_processed(event_ids)` [OPTIONAL]
Manually mark messages as processed (usually not needed).
**Note:** Messages are automatically marked when you call `get_matrix_messages()`,
so you typically don't need to call this tool manually.
```
Claude, mark those messages as processed
```
#### `list_matrix_rooms()`
List all Matrix rooms the bot is in.
```
Claude, show my Matrix rooms
```
#### `get_matrix_status()`
Check Matrix integration status.
```
Claude, check Matrix status
```
Shows:
- Connection status
- Queued messages
- Last wake time
- Rate limit status
### Configuration Files
**Credentials:** `~/.matrix-credentials.json` (chmod 600)
```json
{
"homeserver": "https://matrix.org",
"user_id": "@user:matrix.org",
"access_token": "...",
"device_id": "...",
"room_whitelist": ["!room1:matrix.org", "!room2:matrix.org"]
}
```
**Room Whitelist:** (Optional)
- Empty array = Monitor all rooms
- Specific IDs = Monitor only those rooms
### Rate Limiting
Matrix wakes respect a **2-minute minimum** between wakes to prevent spam:
- Messages arriving within 2 minutes are queued
- Claude processes them in batch at next wake
- Reduces interruptions while ensuring responsiveness
### Image Support
Matrix images are automatically:
1. Downloaded from homeserver
2. Compressed to fit 1MB limit (accounting for base64 overhead)
3. Displayed inline in Claude Desktop
4. Queued just like text messages
### Logs
```bash
# Matrix monitor logs
tail -f /tmp/matrix-integration.log
# Matrix MCP logs
tail -f /tmp/matrix-mcp.log
# Check state file
cat ~/.claude-automation-state.json | jq '.matrix_messages'
```
### Troubleshooting
**Messages not appearing:**
- Check Matrix monitor is running: `ps aux | grep matrix_integration.py`
- Check logs: `tail -20 /tmp/matrix-integration.log`
- Verify credentials: `cat ~/.matrix-credentials.json`
**Claude not waking for Matrix:**
- Check state file has messages: `cat ~/.claude-automation-state.json`
- Check `matrix_wake_requested` flag
- Verify 2-minute rate limit hasn't blocked wake
- Check daemon logs: `tail -20 /tmp/claude-automation-daemon.log`
**Can't send messages:**
- Verify room ID is correct (use `list_matrix_rooms()`)
- Check MCP logs: `tail /tmp/matrix-mcp.log`
- Test credentials with `get_matrix_status()`
### Example Workflow
1. **Friend sends Matrix message**: "Hey, how's it going?"
2. **Matrix Monitor** detects message:
- Queues to state file
- Sets wake flag (if rate limit OK)
3. **Automation Daemon** wakes Claude:
- "Matrix wake at 2025-01-15 14:32:00 - you have 1 new Matrix message(s)"
4. **You tell Claude**: "Check my Matrix messages and respond"
5. **Claude**:
- Calls `get_matrix_messages()`
- Sees friend's message
- Calls `send_matrix_message(room_id, "I'm doing great! Thanks for asking!")`
- Calls `mark_messages_processed([event_id])`
6. **Friend receives** Claude's response in Matrix room
## Advanced Usage
### Multiple Messages Per Day
Use `next_wakeup()` strategically:
```
Morning: next_wakeup(180) # 3 hours (light monitoring)
Workday: next_wakeup(30) # 30 min (active monitoring)
Evening: next_wakeup(360) # 6 hours (minimal monitoring)
```
### Conditional Wake Logic
Modify `generate_message()` to include context:
```python
def generate_message() -> str:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Check system state
disk_usage = get_disk_usage()
cpu_load = get_cpu_load()
return f"System check at {timestamp} - Disk: {disk_usage}%, CPU: {cpu_load}%"
```
### Event-Driven Wakeup
Have other scripts write to the state file to trigger immediate wake:
```bash
# External script
echo '{"next_wake_timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%S)'"}' > ~/.claude-automation-state.json
```
## Uninstalling
```bash
# Stop daemon
launchctl unload ~/Library/LaunchAgents/com.claude.monitor.plist
rm ~/Library/LaunchAgents/com.claude.monitor.plist
# Stop Matrix monitor (if running)
pkill -f matrix_integration.py
# Remove MCP servers from Claude config
# (Edit ~/Library/Application Support/Claude/claude_desktop_config.json)
# Remove both "wakeup-control" and "matrix-control" entries
# Remove scripts (optional)
rm -rf ~/scripts/claude-desktop-automation
# Remove state and credentials
rm ~/.claude-automation-state.json
rm ~/.matrix-credentials.json
rm -rf ~/.matrix-data
# Remove logs (optional)
rm /tmp/claude-automation-daemon.log
rm /tmp/wakeup-mcp.log
rm /tmp/matrix-integration.log
rm /tmp/matrix-mcp.log
```
## Project Structure
```
claude-desktop-automation/
├── automation_daemon.py # Main daemon with timer loop + Matrix integration
├── wakeup_mcp.py # MCP server for wake control
├── matrix_integration.py # Matrix monitor (listens for messages)
├── matrix_mcp.py # MCP server for Matrix control
├── send_to_claude.scpt # AppleScript for UI automation (with screen wake)
├── com.claude.monitor.plist # launchd config (keeps daemon alive)
├── setup.sh # Interactive installation
├── setup_matrix.sh # Matrix integration setup
└── README.md # This file
```
## Why This Architecture?
**Before (launchd scheduling):**
- Fixed intervals
- No flexibility
- launchd does timing
**After (Python timer + MCP control):**
- Dynamic intervals
- Claude has agency
- Can adjust based on activity
- Bidirectional communication
- Much more elegant!
## Limitations
⚠️ **Still fragile** - UI automation breaks if Claude Desktop UI changes significantly
⚠️ **Active chat only** - Sends to whichever chat is currently open (CMD+R refreshes it first)
⚠️ **macOS only** - Uses AppleScript and launchd (Linux port would require xdotool/ydotool)
⚠️ **Requires Accessibility** - Special macOS permissions for UI automation
⚠️ **Screen saver handled** - Uses `caffeinate` to wake screen automatically
⚠️ **Fixed refresh delay** - Waits 10 seconds after CMD+R (configurable if needed)
## Future Plans
🔮 **Linux support** - Port to Linux using xdotool/ydotool for UI automation
🔮 **Windows support** - Port to Windows using pyautogui or AutoIt
🔮 **Wayland support** - Use ydotool for newer Linux distros
🔮 **Smarter refresh** - Detect if refresh is needed before sending CMD+R
## License
Public domain / Use at your own risk
---
**Built with Python + FastMCP + matrix-nio + AppleScript for intelligent automation** 🤖

433
automation_daemon.py Executable file
View 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)

400
automation_daemon_v2.py Normal file
View File

@@ -0,0 +1,400 @@
#!/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
# 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:
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)

51
com.claude.monitor.plist Executable file
View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Label: Unique identifier for this job -->
<key>Label</key>
<string>com.claude.monitor</string>
<!-- Program to run: The automation daemon (handles its own scheduling) -->
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/alex/scripts/claude-desktop-automation/automation_daemon.py</string>
</array>
<!-- Run on login (start daemon when user logs in) -->
<key>RunAtLoad</key>
<true/>
<!-- Keep the daemon running (restart if it crashes) -->
<key>KeepAlive</key>
<true/>
<!-- Standard output and error logs -->
<key>StandardOutPath</key>
<string>/tmp/claude-monitor.out</string>
<key>StandardErrorPath</key>
<string>/tmp/claude-monitor.err</string>
<!-- Working directory -->
<key>WorkingDirectory</key>
<string>/Users/alex/scripts/claude-desktop-automation</string>
<!-- Only run when user is logged in (GUI session required for AppleScript) -->
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<!-- Throttle restart attempts (wait 10 seconds after crash before restart) -->
<key>ThrottleInterval</key>
<integer>10</integer>
<!-- Environment variables (if needed) -->
<!--
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
-->
</dict>
</plist>

780
matrix_integration.py Executable file
View File

@@ -0,0 +1,780 @@
#!/usr/bin/env python3
"""
Matrix Integration for Claude Desktop Automation
Monitors Matrix rooms for messages and images, queuing them for Claude to process.
Integrates with automation_daemon.py to trigger Claude wakes when new messages arrive.
Features:
- Async Matrix sync loop using matrix-nio
- Message and image queuing to shared state file
- 2-minute rate limiting between Matrix-triggered wakes
- Room whitelist for selective monitoring
- Image download and compression (reuses patterns from image-watch-mcp)
- Secure credential management
"""
import asyncio
import json
import logging
import fcntl
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, List, Dict, Any
from io import BytesIO
import hashlib
try:
from nio import (
AsyncClient,
MatrixRoom,
RoomMessageText,
RoomMessageImage,
RoomMessageFile,
RoomMessageAudio,
InviteEvent,
LoginResponse,
SyncResponse,
DownloadResponse,
DownloadError,
)
except ImportError:
raise ImportError(
"matrix-nio not installed. Install with: pip3 install matrix-nio\n"
"Note: Without [e2e] extra, only unencrypted rooms are supported"
)
try:
from PIL import Image
except ImportError:
raise ImportError("Pillow not installed. Install with: pip3 install Pillow")
# Configuration
STATE_FILE = Path.home() / ".claude-automation-state.json"
CREDENTIALS_FILE = Path.home() / ".matrix-credentials.json"
MATRIX_DATA_DIR = Path.home() / ".matrix-data"
LOG_FILE = Path("/tmp/matrix-integration.log")
# Matrix wake rate limiting
MIN_MATRIX_WAKE_INTERVAL_SECONDS = 120 # 2 minutes
# Image compression (same as image-watch-mcp)
MAX_IMAGE_SIZE_MB = 1.0 # Target for base64-encoded size (checked directly, not estimated)
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class MatrixCredentials:
"""Manages Matrix credentials and configuration"""
def __init__(self, credentials_file: Path):
self.credentials_file = credentials_file
def load(self) -> Dict[str, Any]:
"""Load credentials from JSON file"""
if not self.credentials_file.exists():
raise FileNotFoundError(
f"Credentials file not found: {self.credentials_file}\n"
f"Run setup_matrix.sh to create credentials"
)
try:
with open(self.credentials_file, 'r') as f:
creds = json.load(f)
# Validate required fields
required = ['homeserver', 'user_id', 'access_token', 'device_id']
missing = [field for field in required if field not in creds]
if missing:
raise ValueError(f"Missing required credentials: {missing}")
logger.info(f"Loaded credentials for {creds['user_id']}")
return creds
except Exception as e:
logger.error(f"Failed to load credentials: {e}")
raise
def save(self, homeserver: str, user_id: str, access_token: str, device_id: str):
"""Save credentials to JSON file (chmod 600)"""
try:
creds = {
'homeserver': homeserver,
'user_id': user_id,
'access_token': access_token,
'device_id': device_id,
'room_whitelist': [], # Empty by default
}
with open(self.credentials_file, 'w') as f:
json.dump(creds, f, indent=2)
# Secure permissions
self.credentials_file.chmod(0o600)
logger.info(f"Saved credentials for {user_id}")
except Exception as e:
logger.error(f"Failed to save credentials: {e}")
raise
class MatrixState:
"""Manages Matrix state in shared automation state file"""
def __init__(self, state_file: Path):
self.state_file = state_file
def load(self) -> Dict[str, Any]:
"""Load full automation state"""
if not self.state_file.exists():
return self._default_state()
try:
with open(self.state_file, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load state: {e}")
return self._default_state()
def save(self, state: Dict[str, Any]):
"""Save full automation state atomically with file locking"""
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)
except Exception as e:
logger.error(f"Failed to save state: {e}")
def add_message(self, room_id: str, room_name: str, sender: str,
message: str, timestamp: datetime, event_id: str):
"""Add a text message to the queue"""
state = self.load()
if 'matrix_messages' not in state:
state['matrix_messages'] = []
state['matrix_messages'].append({
'type': 'text',
'room_id': room_id,
'room_name': room_name,
'sender': sender,
'message': message,
'timestamp': timestamp.isoformat(),
'event_id': event_id,
'processed': False,
})
self.save(state)
logger.info(f"Queued text message from {sender} in {room_name}")
def add_image(self, room_id: str, room_name: str, sender: str,
image_data: bytes, filename: str, timestamp: datetime, event_id: str):
"""Add an image message to the queue (base64 encoded)"""
import base64
state = self.load()
if 'matrix_messages' not in state:
state['matrix_messages'] = []
# Encode image to base64
image_b64 = base64.b64encode(image_data).decode('utf-8')
state['matrix_messages'].append({
'type': 'image',
'room_id': room_id,
'room_name': room_name,
'sender': sender,
'filename': filename,
'image_data': image_b64,
'timestamp': timestamp.isoformat(),
'event_id': event_id,
'processed': False,
})
self.save(state)
logger.info(f"Queued image from {sender} in {room_name}: {filename}")
def add_audio(self, room_id: str, room_name: str, sender: str,
audio_data: bytes, filename: str, timestamp: datetime, event_id: str):
"""Add an audio/voice message to the queue (base64 encoded)"""
import base64
state = self.load()
if 'matrix_messages' not in state:
state['matrix_messages'] = []
# Encode audio to base64
audio_b64 = base64.b64encode(audio_data).decode('utf-8')
state['matrix_messages'].append({
'type': 'audio',
'room_id': room_id,
'room_name': room_name,
'sender': sender,
'filename': filename,
'audio_data': audio_b64,
'timestamp': timestamp.isoformat(),
'event_id': event_id,
'processed': False,
})
self.save(state)
logger.info(f"Queued audio from {sender} in {room_name}: {filename}")
def add_invite(self, room_id: str, room_name: str, inviter: str, timestamp: datetime):
"""Add a room invite to the queue"""
state = self.load()
if 'matrix_invites' not in state:
state['matrix_invites'] = []
# Check if invite already queued
for invite in state['matrix_invites']:
if invite['room_id'] == room_id and not invite.get('processed', False):
logger.debug(f"Invite to {room_id} already queued")
return
state['matrix_invites'].append({
'room_id': room_id,
'room_name': room_name,
'inviter': inviter,
'timestamp': timestamp.isoformat(),
'processed': False,
})
self.save(state)
logger.info(f"Queued invite to {room_name} from {inviter}")
def add_file(self, room_id: str, room_name: str, sender: str,
filename: str, filesize: int, mimetype: str, mxc_url: str,
timestamp: datetime, event_id: str):
"""Add a file message to the queue"""
state = self.load()
if 'matrix_files' not in state:
state['matrix_files'] = []
state['matrix_files'].append({
'room_id': room_id,
'room_name': room_name,
'sender': sender,
'filename': filename,
'filesize': filesize,
'mimetype': mimetype,
'mxc_url': mxc_url,
'timestamp': timestamp.isoformat(),
'event_id': event_id,
'processed': False,
})
self.save(state)
logger.info(f"Queued file from {sender} in {room_name}: {filename}")
def should_wake_for_matrix(self) -> bool:
"""Check if enough time has passed since last Matrix wake"""
state = self.load()
last_wake_str = state.get('matrix_last_wake')
if not last_wake_str:
return True
try:
last_wake = datetime.fromisoformat(last_wake_str)
elapsed = (datetime.now() - last_wake).total_seconds()
return elapsed >= MIN_MATRIX_WAKE_INTERVAL_SECONDS
except ValueError:
return True
def set_matrix_wake(self):
"""Record that we triggered a Matrix wake"""
state = self.load()
state['matrix_last_wake'] = datetime.now().isoformat()
self.save(state)
logger.info("Set matrix_last_wake timestamp")
def _default_state(self) -> Dict[str, Any]:
"""Default state structure"""
return {
'interval_minutes': 60,
'paused': False,
'last_wake': None,
'next_wake_timestamp': None,
'matrix_messages': [],
'matrix_invites': [],
'matrix_files': [],
'matrix_last_wake': None,
'matrix_wake_requested': False,
}
class MatrixMonitor:
"""
Monitors Matrix rooms for messages and images.
Integrates with automation_daemon.py by:
1. Queuing messages to shared state file
2. Setting matrix_wake_requested flag for daemon to trigger Claude
3. Respecting 2-minute rate limit between wakes
"""
def __init__(self, credentials_file: Path = CREDENTIALS_FILE,
state_file: Path = STATE_FILE):
self.credentials_file = credentials_file
self.state_file = state_file
self.creds_manager = MatrixCredentials(credentials_file)
self.state_manager = MatrixState(state_file)
self.client: Optional[AsyncClient] = None
self.shutdown_flag = False
self.initial_sync_done = False # Skip queueing messages until initial sync completes
# Load credentials
self.creds = self.creds_manager.load()
self.room_whitelist = set(self.creds.get('room_whitelist', []))
# Setup Matrix client
self._setup_client()
def _setup_client(self):
"""Initialize Matrix client with credentials"""
# Create data directory for E2EE store
MATRIX_DATA_DIR.mkdir(exist_ok=True)
self.client = AsyncClient(
homeserver=self.creds['homeserver'],
user=self.creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session from credentials
self.client.access_token = self.creds['access_token']
self.client.device_id = self.creds['device_id']
self.client.user_id = self.creds['user_id'] # CRITICAL: Set user_id for session restore
# Register event callbacks
self.client.add_event_callback(self._on_message_text, RoomMessageText)
self.client.add_event_callback(self._on_message_image, RoomMessageImage)
self.client.add_event_callback(self._on_message_audio, RoomMessageAudio)
self.client.add_event_callback(self._on_message_file, RoomMessageFile)
self.client.add_event_callback(self._on_invite, InviteEvent)
logger.info(f"Matrix client configured for {self.creds['user_id']}")
async def _on_message_text(self, room: MatrixRoom, event: RoomMessageText):
"""Handle incoming text messages"""
# Skip messages from ourselves
logger.debug(f"Message from {event.sender}, bot user_id={self.client.user_id}, match={event.sender == self.client.user_id}")
if event.sender == self.client.user_id:
logger.info(f"✓ Skipping own message: {event.body[:30]}... (sender={event.sender})")
return
# Skip historical messages during initial sync
if not self.initial_sync_done:
logger.debug(f"Skipping historical message during initial sync: {event.body[:30]}...")
return
# Check room whitelist
if self.room_whitelist and room.room_id not in self.room_whitelist:
logger.debug(f"Ignoring message from non-whitelisted room: {room.display_name}")
return
logger.info(f"New message in {room.display_name} from {event.sender}: {event.body[:50]}...")
# Queue message
self.state_manager.add_message(
room_id=room.room_id,
room_name=room.display_name or room.room_id,
sender=event.sender,
message=event.body,
timestamp=datetime.fromtimestamp(event.server_timestamp / 1000),
event_id=event.event_id,
)
# Trigger wake if rate limit allows
await self._maybe_trigger_wake()
async def _on_message_image(self, room: MatrixRoom, event: RoomMessageImage):
"""Handle incoming image messages"""
# Skip messages from ourselves
logger.debug(f"Image from {event.sender}, bot user_id={self.client.user_id}, match={event.sender == self.client.user_id}")
if event.sender == self.client.user_id:
logger.info(f"✓ Skipping own image message (sender={event.sender})")
return
# Skip historical messages during initial sync
if not self.initial_sync_done:
logger.debug(f"Skipping historical image during initial sync")
return
# Check room whitelist
if self.room_whitelist and room.room_id not in self.room_whitelist:
logger.debug(f"Ignoring image from non-whitelisted room: {room.display_name}")
return
logger.info(f"New image in {room.display_name} from {event.sender}: {event.body}")
# Download and compress image
try:
image_data = await self._download_and_compress_image(event.url)
if image_data:
self.state_manager.add_image(
room_id=room.room_id,
room_name=room.display_name or room.room_id,
sender=event.sender,
image_data=image_data,
filename=event.body,
timestamp=datetime.fromtimestamp(event.server_timestamp / 1000),
event_id=event.event_id,
)
# Trigger wake if rate limit allows
await self._maybe_trigger_wake()
else:
logger.warning(f"Failed to download/compress image: {event.body}")
except Exception as e:
logger.error(f"Error processing image: {e}")
async def _on_message_audio(self, room: MatrixRoom, event: RoomMessageAudio):
"""Handle incoming audio/voice messages"""
# Skip messages from ourselves
logger.debug(f"Audio from {event.sender}, bot user_id={self.client.user_id}")
if event.sender == self.client.user_id:
logger.info(f"✓ Skipping own audio message (sender={event.sender})")
return
# Skip historical messages during initial sync
if not self.initial_sync_done:
logger.debug(f"Skipping historical audio during initial sync")
return
# Check room whitelist
if self.room_whitelist and room.room_id not in self.room_whitelist:
logger.debug(f"Ignoring audio from non-whitelisted room: {room.display_name}")
return
logger.info(f"New audio in {room.display_name} from {event.sender}: {event.body}")
# Download audio file
try:
response = await self.client.download(event.url)
if isinstance(response, DownloadError):
logger.error(f"Audio download failed: {response.message}")
return
# Queue audio message (similar to image, but type="audio")
self.state_manager.add_audio(
room_id=room.room_id,
room_name=room.display_name or room.room_id,
sender=event.sender,
audio_data=response.body,
filename=event.body,
timestamp=datetime.fromtimestamp(event.server_timestamp / 1000),
event_id=event.event_id,
)
# Trigger wake if rate limit allows
await self._maybe_trigger_wake()
except Exception as e:
logger.error(f"Error processing audio: {e}")
async def _download_and_compress_image(self, mxc_url: str) -> Optional[bytes]:
"""
Download and compress Matrix image to fit Claude's 1MB limit.
Reuses compression logic from image-watch-mcp.
"""
try:
# Download image from Matrix
response = await self.client.download(mxc_url)
if isinstance(response, DownloadError):
logger.error(f"Download failed: {response.message}")
return None
# Compress image
image_bytes = await self._smart_compress(response.body)
return image_bytes
except Exception as e:
logger.error(f"Failed to download image from {mxc_url}: {e}")
return None
async def _smart_compress(self, image_data: bytes,
target_size_mb: float = MAX_IMAGE_SIZE_MB) -> bytes:
"""
Compress image to fit under target size with quality adaptation.
Args:
image_data: Raw image bytes
target_size_mb: Target size in MB for base64-encoded output (default 1.0MB)
Returns:
Compressed JPEG bytes that will be under target when base64 encoded
NOTE: We check the ACTUAL base64 size, not an estimate.
"""
try:
import base64
# Load image
img = Image.open(BytesIO(image_data))
# Convert RGBA to RGB if needed
if img.mode == 'RGBA':
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])
img = background
elif img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
# Try progressive quality reduction
for quality in [85, 75, 65, 55]:
buffer = BytesIO()
img.save(buffer, format='JPEG', quality=quality, optimize=True)
img_bytes = buffer.getvalue()
# Check ACTUAL base64 size instead of estimating
base64_encoded = base64.b64encode(img_bytes)
base64_size_mb = len(base64_encoded) / (1024 * 1024)
raw_size_mb = len(img_bytes) / (1024 * 1024)
if base64_size_mb <= target_size_mb:
logger.info(
f"✓ Compressed to {img.width}x{img.height} @ quality {quality}: "
f"{raw_size_mb:.2f}MB raw → {base64_size_mb:.2f}MB base64 (actual)"
)
return img_bytes
# If still too large, resize
logger.warning(f"Quality reduction insufficient, resizing from {img.width}x{img.height}")
for scale in [0.75, 0.5, 0.25]:
new_width = int(img.width * scale)
new_height = int(img.height * scale)
resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
buffer = BytesIO()
resized.save(buffer, format='JPEG', quality=75, optimize=True)
img_bytes = buffer.getvalue()
# Check ACTUAL base64 size instead of estimating
base64_encoded = base64.b64encode(img_bytes)
base64_size_mb = len(base64_encoded) / (1024 * 1024)
raw_size_mb = len(img_bytes) / (1024 * 1024)
if base64_size_mb <= target_size_mb:
logger.info(
f"✓ Resized to {new_width}x{new_height}: "
f"{raw_size_mb:.2f}MB raw → {base64_size_mb:.2f}MB base64 (actual)"
)
return img_bytes
# Last resort: use heavily compressed version
logger.warning("Could not compress to target, using minimum quality")
buffer = BytesIO()
final_img = img.resize((800, 600), Image.Resampling.LANCZOS)
final_img.save(buffer, format='JPEG', quality=50, optimize=True)
return buffer.getvalue()
except Exception as e:
logger.error(f"Compression failed: {e}")
raise
async def _on_message_file(self, room: MatrixRoom, event: RoomMessageFile):
"""Handle incoming file messages"""
# Skip messages from ourselves
logger.debug(f"File from {event.sender}, bot user_id={self.client.user_id}, match={event.sender == self.client.user_id}")
if event.sender == self.client.user_id:
logger.info(f"✓ Skipping own file message (sender={event.sender})")
return
# Skip historical messages during initial sync
if not self.initial_sync_done:
logger.debug(f"Skipping historical file during initial sync")
return
# Check room whitelist
if self.room_whitelist and room.room_id not in self.room_whitelist:
logger.debug(f"Ignoring file from non-whitelisted room: {room.display_name}")
return
logger.info(f"New file in {room.display_name} from {event.sender}: {event.body}")
# Queue file metadata (don't download yet)
self.state_manager.add_file(
room_id=room.room_id,
room_name=room.display_name or room.room_id,
sender=event.sender,
filename=event.body,
filesize=event.source.get('content', {}).get('info', {}).get('size', 0),
mimetype=event.source.get('content', {}).get('info', {}).get('mimetype', 'application/octet-stream'),
mxc_url=event.url,
timestamp=datetime.fromtimestamp(event.server_timestamp / 1000),
event_id=event.event_id,
)
# Trigger wake if rate limit allows
await self._maybe_trigger_wake()
async def _on_invite(self, room: MatrixRoom, event: InviteEvent):
"""Handle room invites"""
# Skip historical invites during initial sync
if not self.initial_sync_done:
logger.debug(f"Skipping historical invite during initial sync")
return
logger.info(f"Received invite to {room.display_name or room.room_id} from {event.sender}")
# Queue invite - InviteEvent doesn't have server_timestamp, use current time
self.state_manager.add_invite(
room_id=room.room_id,
room_name=room.display_name or room.room_id,
inviter=event.sender,
timestamp=datetime.now(),
)
# Trigger wake if rate limit allows
await self._maybe_trigger_wake()
async def _maybe_trigger_wake(self):
"""Trigger Claude wake if rate limit allows"""
if self.state_manager.should_wake_for_matrix():
logger.info("Rate limit OK, triggering Claude wake for Matrix activity")
# Set typing indicators in rooms with unprocessed messages
await self._set_typing_indicators()
self.state_manager.set_matrix_wake()
# Set flag that automation_daemon.py will check
state = self.state_manager.load()
state['matrix_wake_requested'] = True
self.state_manager.save(state)
else:
logger.info("Rate limit active, queuing activity without immediate wake")
async def _set_typing_indicators(self):
"""Set typing indicators in rooms with unprocessed messages"""
try:
state = self.state_manager.load()
# Get all rooms with unprocessed activity
rooms_with_activity = set()
# Check messages
for msg in state.get('matrix_messages', []):
if not msg.get('processed', False):
rooms_with_activity.add(msg['room_id'])
# Check invites
for inv in state.get('matrix_invites', []):
if not inv.get('processed', False):
rooms_with_activity.add(inv['room_id'])
# Check files
for file in state.get('matrix_files', []):
if not file.get('processed', False):
rooms_with_activity.add(file['room_id'])
# Set typing indicator for each room (30 seconds)
for room_id in rooms_with_activity:
try:
await self.client.room_typing(room_id, typing_state=True, timeout=30000)
logger.debug(f"Set typing indicator in {room_id}")
except Exception as e:
logger.warning(f"Failed to set typing in {room_id}: {e}")
if rooms_with_activity:
logger.info(f"Set typing indicators in {len(rooms_with_activity)} rooms")
except Exception as e:
logger.error(f"Failed to set typing indicators: {e}")
async def sync_loop(self):
"""Main sync loop - runs continuously"""
logger.info("=" * 60)
logger.info("Matrix Monitor Starting")
logger.info(f"User: {self.creds['user_id']}")
logger.info(f"Homeserver: {self.creds['homeserver']}")
logger.info(f"Room whitelist: {self.room_whitelist or 'All rooms'}")
logger.info("=" * 60)
try:
# Initial sync
logger.info("Performing initial sync (historical messages will be ignored)...")
await self.client.sync(timeout=30000, full_state=True)
self.initial_sync_done = True # Now start queueing new messages
logger.info(f"Initial sync complete - now monitoring for NEW messages only")
logger.info(f"Bot user ID: {self.client.user_id} - messages from this user will be SKIPPED")
# Continuous sync loop
while not self.shutdown_flag:
try:
response = await self.client.sync(timeout=30000)
if isinstance(response, SyncResponse):
# Sync successful, callbacks already fired
# Check if we have queued messages that can now trigger a wake
# (in case messages arrived during cooldown period)
state = self.state_manager.load()
unprocessed_messages = [m for m in state.get('matrix_messages', []) if not m.get('processed', False)]
unprocessed_invites = [i for i in state.get('matrix_invites', []) if not i.get('processed', False)]
if (unprocessed_messages or unprocessed_invites) and not state.get('matrix_wake_requested', False):
# We have queued items and no wake pending, check if rate limit allows wake now
await self._maybe_trigger_wake()
else:
logger.warning(f"Sync returned unexpected type: {type(response)}")
except Exception as e:
logger.error(f"Error in sync loop: {e}")
await asyncio.sleep(10) # Back off on errors
except Exception as e:
logger.exception(f"Fatal error in sync loop: {e}")
finally:
logger.info("Matrix monitor shutting down")
await self.client.close()
def shutdown(self):
"""Request graceful shutdown"""
logger.info("Shutdown requested")
self.shutdown_flag = True
async def main():
"""Main entry point for testing"""
monitor = MatrixMonitor()
try:
await monitor.sync_loop()
except KeyboardInterrupt:
logger.info("Interrupted by user")
monitor.shutdown()
if __name__ == "__main__":
asyncio.run(main())

1853
matrix_mcp.py Executable file

File diff suppressed because it is too large Load Diff

11
requirements.txt Executable file
View File

@@ -0,0 +1,11 @@
# Claude Desktop Automation Dependencies
# Core dependencies
fastmcp>=0.2.0
# Matrix integration (optional)
# Install with: pip3 install -r requirements.txt
# Or selectively: pip3 install fastmcp (core only)
# Note: Without [e2e], only unencrypted Matrix rooms are supported
matrix-nio>=0.20.0
Pillow>=10.0.0

203
send_to_claude.py Executable file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Send message to Claude Desktop using AppleScript
Features:
- CMD+R refresh before sending (with configurable delay)
- Reliable AppleScript-based automation
- Better error handling and state management
Usage:
python3 send_to_claude.py "Your message here"
python3 send_to_claude.py "Message" --no-refresh
python3 send_to_claude.py # Uses default message
"""
import sys
import time
import logging
import subprocess
from datetime import datetime
from pathlib import Path
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Configuration
REFRESH_DELAY_SECONDS = 15 # Wait time after CMD+R to ensure refresh completes
def wake_screen():
"""Wake screen if screen saver is active"""
try:
subprocess.run(['caffeinate', '-u', '-t', '1'], check=True)
time.sleep(0.5)
logger.debug("Screen wake command sent")
except Exception as e:
logger.debug(f"Screen wake failed (non-critical): {e}")
def activate_claude():
"""
Activate Claude Desktop, launching if needed
Returns:
bool: True if successful
"""
try:
# Try to activate if already running
result = subprocess.run([
'osascript', '-e',
'tell application "Claude" to activate'
], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
logger.info("Activated Claude Desktop")
time.sleep(0.5)
return True
except Exception as e:
logger.debug(f"Activation attempt failed: {e}")
# Launch if not running
try:
logger.info("Claude not running, launching...")
subprocess.run(['open', '-a', 'Claude'], check=True, timeout=5)
time.sleep(2)
logger.info("Launched Claude Desktop")
return True
except Exception as e:
logger.error(f"Failed to launch Claude: {e}")
return False
def send_refresh(delay_seconds=REFRESH_DELAY_SECONDS):
"""
Send CMD+R to refresh and wait for completion
Args:
delay_seconds: Time to wait after CMD+R (default: 10)
Returns:
bool: True if successful
"""
try:
logger.info("Sending CMD+R to refresh...")
subprocess.run([
'osascript', '-e',
'tell application "System Events" to keystroke "r" using command down'
], check=True, timeout=2)
logger.info(f"Waiting {delay_seconds}s for refresh to complete...")
time.sleep(delay_seconds)
logger.info("✓ Refresh complete")
return True
except Exception as e:
logger.error(f"Failed to send CMD+R: {e}")
return False
def send_message_applescript(message):
"""
Send message to Claude Desktop using AppleScript
Args:
message: Message text to send
Returns:
bool: True if successful
"""
try:
logger.info(f"Sending message: {message[:50]}...")
# Escape backslashes and quotes for AppleScript
escaped_message = message.replace('\\', '\\\\').replace('"', '\\"')
subprocess.run([
'osascript', '-e',
f'tell application "System Events" to keystroke "{escaped_message}"'
], check=True, timeout=10)
# Wait for Claude app input box to become ready
# (app blocks input briefly after receiving text)
logger.info("Waiting 15s for input box to become ready...")
time.sleep(15)
subprocess.run([
'osascript', '-e',
'tell application "System Events" to keystroke return'
], check=True, timeout=2)
logger.info("✓ Message sent successfully")
return True
except Exception as e:
logger.error(f"Failed to send message: {e}")
return False
def send_to_claude(message, do_refresh=True):
"""
Send message to Claude Desktop
Args:
message: Message text to send
do_refresh: Whether to send CMD+R before sending (default: True)
Returns:
bool: True if successful
"""
try:
# Wake screen
wake_screen()
# Activate Claude
if not activate_claude():
logger.error("Could not activate Claude Desktop")
return False
# Send refresh if requested
if do_refresh:
if not send_refresh():
logger.warning("Refresh failed, continuing anyway...")
# Send message
return send_message_applescript(message)
except Exception as e:
logger.error(f"ERROR: {e}")
import traceback
logger.debug(traceback.format_exc())
return False
def main():
"""Main entry point"""
# Parse arguments
if len(sys.argv) < 2:
message = f"System check at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
else:
message = sys.argv[1]
# Check for --no-refresh flag
do_refresh = '--no-refresh' not in sys.argv
logger.info("=" * 60)
logger.info("Claude Desktop Automation - Send Message")
logger.info(f"Refresh: {'Yes' if do_refresh else 'No'}")
logger.info(f"Refresh delay: {REFRESH_DELAY_SECONDS}s")
logger.info("=" * 60)
success = send_to_claude(message, do_refresh=do_refresh)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()

68
send_to_claude.scpt Executable file
View File

@@ -0,0 +1,68 @@
-- Send Message to Claude Desktop
-- This AppleScript sends a message to the currently active chat in Claude Desktop
--
-- Usage: osascript send_to_claude.scpt "Your message here"
--
-- Requirements:
-- - Claude Desktop must be running
-- - Accessibility permissions must be granted
-- - A chat must be active (any chat - cannot target specific chat)
on run argv
-- Get message from command line argument
if (count of argv) = 0 then
set messageText to "System check at " & (current date) as string
else
set messageText to item 1 of argv
end if
-- Log the message
log "Attempting to send: " & messageText
try
-- Wake the screen if screen saver is active
-- This simulates a brief mouse movement to dismiss screen saver
log "Waking screen if needed..."
do shell script "caffeinate -u -t 1"
delay 0.5
-- Check if Claude is running, launch if not
if not application "Claude" is running then
log "Claude not running, launching..."
tell application "Claude" to activate
delay 2 -- Wait for app to launch
else
-- Bring Claude to front
tell application "Claude" to activate
delay 0.5 -- Brief wait for window activation
end if
-- Send the message using System Events
tell application "System Events"
tell process "Claude"
-- Verify the window exists
if not (exists window 1) then
error "Claude window not found"
end if
-- Type the message
keystroke messageText
-- Wait for Claude app input box to become ready
-- (app blocks input briefly after receiving text)
delay 15
-- Press Enter to send
keystroke return
log "Message sent successfully"
end tell
end tell
return "SUCCESS: Message sent to Claude Desktop"
on error errMsg number errNum
log "ERROR: " & errMsg & " (" & errNum & ")"
return "ERROR: " & errMsg
end try
end run

184
setup.sh Executable file
View File

@@ -0,0 +1,184 @@
#!/bin/bash
#
# Setup Script for Claude Desktop Automation
#
# This script:
# 1. Checks for required files and permissions
# 2. Installs the automation daemon (launchd agent)
# 3. Configures the Wakeup Control MCP server
# 4. Tests the automation
# 5. Provides troubleshooting guidance
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PLIST_NAME="com.claude.monitor.plist"
PLIST_SOURCE="${SCRIPT_DIR}/${PLIST_NAME}"
PLIST_DEST="${HOME}/Library/LaunchAgents/${PLIST_NAME}"
CLAUDE_CONFIG="${HOME}/Library/Application Support/Claude/claude_desktop_config.json"
echo "========================================="
echo "Claude Desktop Automation Setup"
echo "========================================="
echo ""
echo "This will set up:"
echo "1. Automation Daemon (sends periodic messages)"
echo "2. Wakeup Control MCP (lets Claude adjust timing)"
echo ""
# Step 1: Check if files exist
echo "[1/6] Checking files..."
if [[ ! -f "${SCRIPT_DIR}/send_to_claude.scpt" ]]; then
echo "ERROR: send_to_claude.scpt not found"
exit 1
fi
if [[ ! -f "${SCRIPT_DIR}/automation_daemon.py" ]]; then
echo "ERROR: automation_daemon.py not found"
exit 1
fi
if [[ ! -f "${SCRIPT_DIR}/wakeup_mcp.py" ]]; then
echo "ERROR: wakeup_mcp.py not found"
exit 1
fi
if [[ ! -f "${PLIST_SOURCE}" ]]; then
echo "ERROR: ${PLIST_NAME} not found"
exit 1
fi
echo "✓ All files present"
echo ""
# Step 2: Check Python dependencies
echo "[2/6] Checking Python dependencies..."
if ! python3 -c "import fastmcp" 2>/dev/null; then
echo "⚠️ FastMCP not installed"
read -p "Install fastmcp? (y/n) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
pip3 install fastmcp
else
echo "ERROR: FastMCP is required for the MCP server"
exit 1
fi
fi
echo "✓ Dependencies OK"
echo ""
# Step 3: Check permissions
echo "[3/6] Checking Accessibility permissions..."
echo "NOTE: Accessibility permissions are required for UI automation"
echo ""
echo "To grant permissions:"
echo "1. Open System Settings/Preferences"
echo "2. Go to Privacy & Security → Accessibility"
echo "3. Add Terminal (or your terminal app)"
echo "4. Enable the checkbox"
echo ""
read -p "Have you granted Accessibility permissions? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Please grant permissions and run this script again"
exit 1
fi
echo ""
# Step 4: Configure MCP server
echo "[4/6] Configuring Wakeup Control MCP..."
echo "Adding MCP server to Claude Desktop config"
echo ""
# Create config backup
if [[ -f "${CLAUDE_CONFIG}" ]]; then
cp "${CLAUDE_CONFIG}" "${CLAUDE_CONFIG}.backup"
echo "✓ Created backup: ${CLAUDE_CONFIG}.backup"
fi
# Add MCP server config (manual for now)
echo "Add this to your Claude Desktop config (${CLAUDE_CONFIG}):"
echo ""
echo '{'
echo ' "mcpServers": {'
echo ' "wakeup-control": {'
echo ' "command": "python3",'
echo ' "args": ["'${SCRIPT_DIR}'/wakeup_mcp.py"]'
echo ' }'
echo ' }'
echo '}'
echo ""
read -p "Have you added the MCP server to your config? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "You can add it later - continuing with daemon setup..."
fi
echo ""
# Step 5: Install daemon
echo "[5/6] Installing automation daemon..."
# Create LaunchAgents directory if it doesn't exist
mkdir -p "${HOME}/Library/LaunchAgents"
# Update paths in plist to match user's home directory
sed "s|/Users/alex|${HOME}|g" "${PLIST_SOURCE}" > "${PLIST_DEST}"
echo "✓ Installed to ${PLIST_DEST}"
echo ""
# Load the agent
echo "Loading daemon..."
if launchctl list | grep -q "com.claude.monitor"; then
echo "Unloading existing daemon..."
launchctl unload "${PLIST_DEST}" 2>/dev/null || true
sleep 1
fi
launchctl load "${PLIST_DEST}"
echo "✓ Daemon loaded and running"
echo ""
# Step 6: Test (optional)
echo "[6/6] Testing (optional)..."
echo "You can test the automation manually, or let it run on schedule"
echo ""
read -p "Test now? This will send a message to Claude Desktop (y/n) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Make sure Claude Desktop is running with a chat open!"
sleep 2
if python3 "${SCRIPT_DIR}/automation_daemon.py" & then
DAEMON_PID=$!
echo "Daemon started (PID: $DAEMON_PID)"
echo "Watch the logs: tail -f /tmp/claude-automation-daemon.log"
echo ""
read -p "Press Enter to stop the test daemon..."
kill $DAEMON_PID 2>/dev/null || true
fi
else
echo "Skipping test..."
fi
echo ""
echo "========================================="
echo "Setup Complete!"
echo "========================================="
echo ""
echo "The automation is now running:"
echo ""
echo "📡 Daemon: Sends messages every 60 minutes (default)"
echo "🎛️ MCP Server: Lets Claude control wake timing"
echo ""
echo "Useful commands:"
echo "- View daemon logs: tail -f /tmp/claude-automation-daemon.log"
echo "- View MCP logs: tail -f /tmp/wakeup-mcp.log"
echo "- Stop daemon: launchctl unload ${PLIST_DEST}"
echo "- Start daemon: launchctl load ${PLIST_DEST}"
echo "- Restart daemon: launchctl kickstart -k gui/\$(id -u)/com.claude.monitor"
echo ""
echo "In Claude Desktop, use these tools:"
echo "- next_wakeup(minutes) - Adjust next wake time"
echo "- pause_automation() - Pause wake messages"
echo "- resume_automation() - Resume wake messages"
echo "- get_status() - Check automation status"
echo ""
echo "After your first wake message, tell Claude:"
echo '"Use next_wakeup(30) to wake me in 30 minutes instead"'
echo ""

317
setup_matrix.sh Executable file
View File

@@ -0,0 +1,317 @@
#!/bin/bash
#
# Matrix Integration Setup Script
#
# This script:
# 1. Installs Python dependencies (matrix-nio)
# 2. Authenticates with Matrix homeserver
# 3. Saves credentials securely
# 4. Configures room whitelist (optional)
# 5. Adds Matrix MCP to Claude Desktop config
# 6. Tests the integration
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
CREDENTIALS_FILE="${HOME}/.matrix-credentials.json"
STATE_FILE="${HOME}/.claude-automation-state.json"
CLAUDE_CONFIG="${HOME}/Library/Application Support/Claude/claude_desktop_config.json"
echo "========================================="
echo "Matrix Integration Setup"
echo "========================================="
echo ""
# Step 1: Install dependencies
echo "[1/6] Installing Python dependencies..."
if ! python3 -c "import nio" 2>/dev/null; then
echo "Installing matrix-nio (without E2EE - unencrypted rooms only)..."
pip3 install matrix-nio Pillow
else
echo "✓ matrix-nio already installed"
fi
echo ""
# Step 2: Matrix authentication
echo "[2/6] Matrix Authentication"
echo "You'll need:"
echo " - Homeserver URL (e.g., https://matrix.org)"
echo " - Matrix username (e.g., @user:matrix.org)"
echo " - Password"
echo ""
read -p "Homeserver URL: " HOMESERVER
read -p "Matrix User ID: " USER_ID
read -s -p "Password: " PASSWORD
echo ""
echo ""
echo "Authenticating with Matrix..."
# Create temporary Python script to authenticate
AUTH_SCRIPT=$(mktemp /tmp/matrix-auth.XXXXXX.py)
cat > "$AUTH_SCRIPT" << 'EOPYTHON'
#!/usr/bin/env python3
import asyncio
import json
import sys
from nio import AsyncClient, LoginResponse
async def login(homeserver, user_id, password):
client = AsyncClient(homeserver, user_id)
try:
response = await client.login(password)
if isinstance(response, LoginResponse):
print(json.dumps({
'success': True,
'homeserver': homeserver,
'user_id': response.user_id,
'access_token': response.access_token,
'device_id': response.device_id,
}))
else:
print(json.dumps({
'success': False,
'error': str(response)
}))
except Exception as e:
print(json.dumps({
'success': False,
'error': str(e)
}))
finally:
await client.close()
if __name__ == "__main__":
homeserver = sys.argv[1]
user_id = sys.argv[2]
password = sys.argv[3]
asyncio.run(login(homeserver, user_id, password))
EOPYTHON
# Run authentication
AUTH_RESULT=$(python3 "$AUTH_SCRIPT" "$HOMESERVER" "$USER_ID" "$PASSWORD")
rm "$AUTH_SCRIPT"
# Parse result
SUCCESS=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('success', False))")
if [ "$SUCCESS" != "True" ]; then
ERROR=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('error', 'Unknown error'))")
echo "✗ Authentication failed: $ERROR"
exit 1
fi
echo "✓ Authentication successful"
echo ""
# Step 3: Save credentials
echo "[3/6] Saving credentials..."
# Extract credentials
HOMESERVER=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin)['homeserver'])")
USER_ID=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin)['user_id'])")
ACCESS_TOKEN=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin)['access_token'])")
DEVICE_ID=$(echo "$AUTH_RESULT" | python3 -c "import sys, json; print(json.load(sys.stdin)['device_id'])")
# Create credentials file
cat > "$CREDENTIALS_FILE" << EOF
{
"homeserver": "$HOMESERVER",
"user_id": "$USER_ID",
"access_token": "$ACCESS_TOKEN",
"device_id": "$DEVICE_ID",
"room_whitelist": []
}
EOF
# Secure permissions
chmod 600 "$CREDENTIALS_FILE"
echo "✓ Credentials saved to $CREDENTIALS_FILE (chmod 600)"
echo ""
# Step 4: Room whitelist (optional)
echo "[4/6] Room Configuration"
echo ""
echo "Do you want to monitor ALL rooms or only specific rooms?"
echo " 1. All rooms (default)"
echo " 2. Specific rooms only (whitelist)"
read -p "Choice [1/2]: " ROOM_CHOICE
echo ""
if [ "$ROOM_CHOICE" = "2" ]; then
echo "Fetching your Matrix rooms..."
# Create temporary Python script to list rooms
LIST_ROOMS_SCRIPT=$(mktemp /tmp/matrix-rooms.XXXXXX.py)
cat > "$LIST_ROOMS_SCRIPT" << 'EOPYTHON'
#!/usr/bin/env python3
import asyncio
import json
import sys
from nio import AsyncClient
from pathlib import Path
async def list_rooms(homeserver, user_id, access_token, device_id):
store_path = Path.home() / ".matrix-data"
store_path.mkdir(exist_ok=True)
client = AsyncClient(homeserver, user_id, store_path=str(store_path))
client.access_token = access_token
client.device_id = device_id
try:
await client.sync(timeout=30000)
rooms = []
for room_id, room in client.rooms.items():
rooms.append({
'room_id': room_id,
'name': room.display_name or room_id,
'members': len(room.users)
})
print(json.dumps(rooms, indent=2))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(list_rooms(
sys.argv[1],
sys.argv[2],
sys.argv[3],
sys.argv[4]
))
EOPYTHON
ROOMS=$(python3 "$LIST_ROOMS_SCRIPT" "$HOMESERVER" "$USER_ID" "$ACCESS_TOKEN" "$DEVICE_ID")
rm "$LIST_ROOMS_SCRIPT"
echo "Your Matrix rooms:"
echo "$ROOMS" | python3 -c "
import sys, json
rooms = json.load(sys.stdin)
for i, room in enumerate(rooms):
print(f\"{i+1}. {room['name']} ({room['members']} members)\")
print(f\" ID: {room['room_id']}\")
"
echo ""
echo "Enter room numbers to monitor (space-separated, e.g., '1 3 5'):"
read -p "Room numbers: " ROOM_NUMBERS
# Parse selected rooms
SELECTED_ROOMS=$(echo "$ROOMS" | python3 -c "
import sys, json
rooms = json.load(sys.stdin)
selected = '$ROOM_NUMBERS'.split()
whitelist = [rooms[int(num)-1]['room_id'] for num in selected if num.isdigit() and 0 < int(num) <= len(rooms)]
print(json.dumps(whitelist))
")
# Update credentials file
python3 -c "
import json
with open('$CREDENTIALS_FILE', 'r') as f:
creds = json.load(f)
creds['room_whitelist'] = $SELECTED_ROOMS
with open('$CREDENTIALS_FILE', 'w') as f:
json.dump(creds, f, indent=2)
"
echo "✓ Room whitelist configured"
else
echo "✓ Monitoring all rooms"
fi
echo ""
# Step 5: Configure Claude Desktop MCP
echo "[5/6] Configuring Matrix MCP in Claude Desktop..."
echo ""
# Check if config exists
if [ ! -f "$CLAUDE_CONFIG" ]; then
echo "Creating Claude Desktop config..."
mkdir -p "$(dirname "$CLAUDE_CONFIG")"
echo '{"mcpServers": {}}' > "$CLAUDE_CONFIG"
fi
# Backup config
cp "$CLAUDE_CONFIG" "${CLAUDE_CONFIG}.backup"
echo "✓ Created backup: ${CLAUDE_CONFIG}.backup"
echo ""
echo "Add this to your Claude Desktop config (${CLAUDE_CONFIG}):"
echo ""
echo ' "matrix-control": {'
echo ' "command": "python3",'
echo " \"args\": [\"${SCRIPT_DIR}/matrix_mcp.py\"]"
echo ' }'
echo ""
read -p "Have you added the Matrix MCP to your config? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "You can add it later - continuing with setup..."
fi
echo ""
# Step 6: Start Matrix monitor
echo "[6/6] Starting Matrix Monitor"
echo ""
echo "The Matrix monitor will run in the background and queue messages."
echo "The automation daemon will integrate with it to wake Claude."
echo ""
read -p "Start Matrix monitor now? (y/n) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Starting Matrix monitor in background..."
nohup python3 "${SCRIPT_DIR}/matrix_integration.py" > /tmp/matrix-monitor.out 2>&1 &
MONITOR_PID=$!
echo "✓ Matrix monitor started (PID: $MONITOR_PID)"
echo ""
echo "To stop: kill $MONITOR_PID"
echo "Logs: tail -f /tmp/matrix-integration.log"
else
echo "Skipped - you can start it manually later with:"
echo " python3 ${SCRIPT_DIR}/matrix_integration.py &"
fi
echo ""
echo "========================================="
echo "Matrix Integration Setup Complete!"
echo "========================================="
echo ""
echo "Configuration:"
echo " User: $USER_ID"
echo " Homeserver: $HOMESERVER"
echo " Credentials: $CREDENTIALS_FILE (chmod 600)"
echo ""
echo "Next steps:"
echo ""
echo "1. Restart Claude Desktop to load the Matrix MCP"
echo ""
echo "2. The automation daemon will now:"
echo " - Queue Matrix messages to state file"
echo " - Wake Claude when new messages arrive (2-min rate limit)"
echo " - Allow Claude to use Matrix MCP tools"
echo ""
echo "3. In Claude Desktop, use these tools:"
echo " - get_matrix_messages() - Retrieve queued messages"
echo " - send_matrix_message(room_id, message) - Respond to messages"
echo " - mark_messages_processed(event_ids) - Mark as processed"
echo " - get_matrix_status() - Check Matrix status"
echo " - list_matrix_rooms() - List available rooms"
echo ""
echo "Useful commands:"
echo " - View Matrix logs: tail -f /tmp/matrix-integration.log"
echo " - View MCP logs: tail -f /tmp/matrix-mcp.log"
echo " - View daemon logs: tail -f /tmp/claude-automation-daemon.log"
echo " - Check Matrix monitor: ps aux | grep matrix_integration.py"
echo ""

262
wakeup_mcp.py Executable file
View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""
Claude Desktop Automation - Wakeup Control MCP Server
Model Context Protocol server that allows Claude to control the automation daemon's
wake schedule.
Tools:
- next_wakeup(minutes) - Set when to wake next (overrides default 60min)
- pause_automation() - Pause the automation
- resume_automation() - Resume the automation
- get_status() - Check automation status and next wake time
"""
import json
import logging
import fcntl
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from fastmcp import FastMCP
# Configuration
STATE_FILE = Path.home() / ".claude-automation-state.json"
LOG_FILE = Path("/tmp/wakeup-mcp.log")
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Initialize MCP server
mcp = FastMCP("Wakeup Control")
def load_state() -> dict:
"""Load automation state from JSON file"""
if not STATE_FILE.exists():
return {
'interval_minutes': 60,
'paused': False,
'last_wake': None,
'next_wake_timestamp': None,
'matrix_messages': [],
'matrix_invites': [],
'matrix_last_wake': None,
'matrix_wake_requested': False,
}
try:
with open(STATE_FILE, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load state: {e}")
return {}
def save_state(state: dict):
"""Save automation state to JSON file atomically with file locking"""
try:
temp_path = 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(STATE_FILE)
logger.info(f"Saved state atomically")
except Exception as e:
logger.error(f"Failed to save state: {e}")
@mcp.tool()
def next_wakeup(minutes: int) -> str:
"""
Set when the automation should wake up next.
This overrides the default 60-minute interval for the NEXT wake cycle only.
After that wake, it reverts to the default interval unless you call this again.
Use this to adjust monitoring frequency based on current activity:
- High activity: next_wakeup(15) for more frequent checks
- Low activity: next_wakeup(120) for less frequent checks
- Skip next: next_wakeup(1440) to wake tomorrow
Args:
minutes: How many minutes from now to wake up next (min: 1, max: 1440)
Returns:
Confirmation message with next wake time
Examples:
next_wakeup(30) - Wake in 30 minutes
next_wakeup(120) - Wake in 2 hours
next_wakeup(5) - Wake in 5 minutes (urgent check)
"""
# Validate input
if not isinstance(minutes, int):
return f"Error: minutes must be an integer, got {type(minutes)}"
if minutes < 1:
return "Error: minutes must be at least 1"
if minutes > 1440:
return "Error: minutes cannot exceed 1440 (24 hours)"
try:
# Calculate next wake time
next_wake = datetime.now() + timedelta(minutes=minutes)
# Load current state
state = load_state()
# Update next wake timestamp
state['next_wake_timestamp'] = next_wake.isoformat()
# Save state
save_state(state)
logger.info(f"Next wake set to {next_wake.strftime('%Y-%m-%d %H:%M:%S')} ({minutes} minutes)")
return f"✓ Next wake scheduled for {next_wake.strftime('%Y-%m-%d %H:%M:%S')} (in {minutes} minutes)"
except Exception as e:
logger.error(f"Failed to set next wakeup: {e}")
return f"Error setting next wakeup: {str(e)}"
@mcp.tool()
def pause_automation() -> str:
"""
Pause the automation daemon.
The daemon will stop sending wake messages until you call resume_automation().
The daemon process continues running but skips wake cycles.
Use this when:
- You don't need monitoring for a while
- Working on something that shouldn't be interrupted
- Testing or debugging
Call resume_automation() to restart wake messages.
Returns:
Confirmation message
"""
try:
state = load_state()
state['paused'] = True
save_state(state)
logger.info("Automation paused")
return "✓ Automation paused - no more wake messages until you call resume_automation()"
except Exception as e:
logger.error(f"Failed to pause automation: {e}")
return f"Error pausing automation: {str(e)}"
@mcp.tool()
def resume_automation() -> str:
"""
Resume the automation daemon.
Restarts wake messages after being paused. The next wake will happen
according to the configured interval (default 60 minutes).
Returns:
Confirmation message
"""
try:
state = load_state()
state['paused'] = False
save_state(state)
logger.info("Automation resumed")
return "✓ Automation resumed - wake messages will continue on schedule"
except Exception as e:
logger.error(f"Failed to resume automation: {e}")
return f"Error resuming automation: {str(e)}"
@mcp.tool()
def get_status() -> str:
"""
Get current automation status.
Shows:
- Whether automation is running or paused
- When the last wake happened
- When the next wake is scheduled
- Current interval setting
Returns:
Formatted status report
"""
try:
state = load_state()
status_lines = ["Automation Status:"]
status_lines.append("=" * 40)
# Paused status
if state.get('paused'):
status_lines.append("⏸️ State: PAUSED")
else:
status_lines.append("▶️ State: RUNNING")
# Last wake
if state.get('last_wake'):
try:
last_wake = datetime.fromisoformat(state['last_wake'])
time_ago = datetime.now() - last_wake
status_lines.append(f"Last wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)")
except:
status_lines.append(f"Last wake: {state.get('last_wake')}")
else:
status_lines.append("Last wake: Never (daemon just started)")
# Next wake
if state.get('next_wake_timestamp'):
try:
next_wake = datetime.fromisoformat(state['next_wake_timestamp'])
time_until = next_wake - datetime.now()
if time_until.total_seconds() > 0:
status_lines.append(f"Next wake: {next_wake.strftime('%Y-%m-%d %H:%M:%S')} (in {int(time_until.total_seconds()/60)} min)")
else:
status_lines.append(f"Next wake: OVERDUE (was {next_wake.strftime('%Y-%m-%d %H:%M:%S')})")
except:
status_lines.append(f"Next wake: {state.get('next_wake_timestamp')}")
else:
interval = state.get('interval_minutes', 60)
status_lines.append(f"Next wake: Using default interval ({interval} minutes)")
# Interval
interval = state.get('interval_minutes', 60)
status_lines.append(f"Default interval: {interval} minutes")
return "\n".join(status_lines)
except Exception as e:
logger.error(f"Failed to get status: {e}")
return f"Error getting status: {str(e)}"
if __name__ == "__main__":
logger.info("=" * 60)
logger.info("Wakeup Control MCP Server starting...")
logger.info(f"State file: {STATE_FILE}")
logger.info("=" * 60)
# Run MCP server (uses stdio transport by default)
mcp.run()