diff --git a/scripts/templates/heartbeat/HEARTBEAT.md b/scripts/templates/heartbeat/HEARTBEAT.md new file mode 100644 index 0000000..103b952 --- /dev/null +++ b/scripts/templates/heartbeat/HEARTBEAT.md @@ -0,0 +1,26 @@ +# Heartbeat: {{AGENT_NAME}} + +Read this file on each heartbeat. Follow it strictly. Do not infer or +repeat old tasks from prior chats. If nothing needs attention, reply +HEARTBEAT_OK. + +## Tasks + +tasks: + - name: {{TASK_1_NAME}} + interval: {{TASK_1_INTERVAL}} + prompt: "{{TASK_1_PROMPT}}" + - name: {{TASK_2_NAME}} + interval: {{TASK_2_INTERVAL}} + prompt: "{{TASK_2_PROMPT}}" + +## Context + +{{CONTEXT_NOTES}} + +## Rules + +- Only perform tasks listed above +- Respect the interval — do not run a task before its next due time +- If a task fails, log the error and continue to the next task +- Respond with HEARTBEAT_OK if no tasks are due diff --git a/scripts/templates/heartbeat/README.md b/scripts/templates/heartbeat/README.md new file mode 100644 index 0000000..b7e9b86 --- /dev/null +++ b/scripts/templates/heartbeat/README.md @@ -0,0 +1,63 @@ +# Heartbeat Scheduling + +Combines OpenClaw's HEARTBEAT.md task format with Paperclip's interval-based +heartbeat model. Designed for Claude Code agents running on a schedule. + +## How it works + +1. A scheduler (cron/launchd/systemd) runs `heartbeat-runner.sh` at a fixed + interval (e.g., every 30 minutes) +2. The runner reads `HEARTBEAT.md` for task definitions +3. **Emptiness detection**: if the file has no real tasks, skip the API call + entirely (saves cost — from OpenClaw) +4. For each task: check if it's due based on its interval and last-run time +5. Run due tasks via `claude -p` with the task's prompt +6. Suppress short acknowledgment responses (<300 chars containing HEARTBEAT_OK) +7. Update `.heartbeat-state.json` with last-run timestamps + +## Two execution types (from OpenClaw) + +### systemEvent +Injects a text event into an existing session. Lightweight, no new session. +Use for: notifications, status checks, simple updates. +Template: `scripts/templates/cron/system-event.sh` + +### agentTurn +Fires a full agent turn with its own session. Full context, full tool access. +Use for: background autonomous work, complex tasks, multi-step operations. +Template: `scripts/templates/cron/agent-turn.sh` + +## Startup catchup (OpenClaw pattern) + +When the runner starts after downtime (e.g., machine was off): +- Run `heartbeat-runner.sh --catchup` +- Processes up to 5 missed tasks +- 5-second stagger between tasks (prevents thundering herd) + +## Cost optimization + +- **Emptiness detection**: No API call if HEARTBEAT.md has no real content +- **ackMaxChars suppression**: Responses under 300 chars with HEARTBEAT_OK + are logged but not displayed (saves downstream processing) +- **Interval-based**: Only run tasks when actually due, not every heartbeat + +## Example cron entries + +```bash +# Run heartbeat every 30 minutes +*/30 * * * * /path/to/heartbeat-runner.sh >> /tmp/heartbeat.log 2>&1 + +# Run heartbeat every hour with catchup on restart +@reboot /path/to/heartbeat-runner.sh --catchup >> /tmp/heartbeat.log 2>&1 +0 * * * * /path/to/heartbeat-runner.sh >> /tmp/heartbeat.log 2>&1 +``` + +## State file format + +`.heartbeat-state.json`: +```json +{ + "email-check": { "last_run": 1712847600 }, + "report-generation": { "last_run": 1712844000 } +} +``` diff --git a/scripts/templates/heartbeat/heartbeat-runner.sh b/scripts/templates/heartbeat/heartbeat-runner.sh new file mode 100644 index 0000000..132cca2 --- /dev/null +++ b/scripts/templates/heartbeat/heartbeat-runner.sh @@ -0,0 +1,201 @@ +#!/bin/bash +# Heartbeat runner for Claude Code agents. +# Reads HEARTBEAT.md, checks which tasks are due, invokes claude -p for each. +# +# Bash 3.2 compatible: no associative arrays, no mapfile, no |& +# Uses python3 for all JSON/YAML/date operations. +# +# Usage: ./heartbeat-runner.sh [--catchup] +# --catchup: run missed tasks on first invocation (max 5, 5s stagger) +# +# Placeholders: +# {{AGENT_NAME}} - name of the agent +# {{WORKING_DIR}} - absolute path to project directory +# {{MAX_TURNS}} - max turns per heartbeat (default: 10) +# {{ACK_MAX_CHARS}} - suppress responses shorter than this (default: 300) + +AGENT_NAME="{{AGENT_NAME}}" +WORKING_DIR="{{WORKING_DIR}}" +MAX_TURNS="${MAX_TURNS:-10}" +ACK_MAX_CHARS="${ACK_MAX_CHARS:-300}" +HEARTBEAT_FILE="$WORKING_DIR/HEARTBEAT.md" +STATE_FILE="$WORKING_DIR/.heartbeat-state.json" +LOG_DIR="$WORKING_DIR/logs" +CATCHUP_MODE=false + +if [ "$1" = "--catchup" ]; then + CATCHUP_MODE=true +fi + +# Ensure directories exist +mkdir -p "$LOG_DIR" + +# --- Emptiness detection (OpenClaw pattern) --- +# Skip API calls if heartbeat file has only headers/empty items +HEARTBEAT_FILE_ACTUAL="$HEARTBEAT_FILE" +EMPTY_CHECK=$(HEARTBEAT_FILE="$HEARTBEAT_FILE_ACTUAL" python3 -c " +import sys, re, os +hf = os.environ.get('HEARTBEAT_FILE', '') +try: + content = open(hf).read() +except: + print('true'); sys.exit(0) +stripped = re.sub(r'^#+.*$', '', content, flags=re.MULTILINE) +stripped = re.sub(r'^\s*$', '', stripped, flags=re.MULTILINE).strip() +print('true' if len(stripped) < 20 else 'false') +" 2>/dev/null) + +if [ "$EMPTY_CHECK" = "true" ]; then + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | heartbeat | SKIP (empty heartbeat file)" >> "$LOG_DIR/heartbeat.log" + exit 0 +fi + +# --- Parse tasks and check due times --- +DUE_TASKS=$(python3 << PYEOF +import json, re, os, time + +heartbeat_file = "$HEARTBEAT_FILE_ACTUAL" +state_file = "$STATE_FILE" +catchup = "$CATCHUP_MODE" == "true" + +# Parse tasks from HEARTBEAT.md +try: + content = open(heartbeat_file).read() +except FileNotFoundError: + print("[]") + exit(0) + +# Simple YAML-like task parsing +tasks = [] +current_task = {} +for line in content.split('\n'): + line = line.strip() + m_name = re.match(r'-\s*name:\s*(.+)', line) + m_interval = re.match(r'interval:\s*(.+)', line) + m_prompt = re.match(r'prompt:\s*"(.+)"', line) + if m_name: + if current_task.get('name'): + tasks.append(current_task) + current_task = {'name': m_name.group(1).strip()} + elif m_interval and current_task: + current_task['interval'] = m_interval.group(1).strip() + elif m_prompt and current_task: + current_task['prompt'] = m_prompt.group(1).strip() +if current_task.get('name'): + tasks.append(current_task) + +# Load state +try: + state = json.load(open(state_file)) +except: + state = {} + +# Parse interval to seconds +def parse_interval(s): + s = s.strip() + m = re.match(r'(\d+)\s*(m|min|h|hr|d)', s) + if not m: + return 3600 # default 1 hour + val, unit = int(m.group(1)), m.group(2) + if unit in ('m', 'min'): + return val * 60 + elif unit in ('h', 'hr'): + return val * 3600 + elif unit == 'd': + return val * 86400 + return 3600 + +# Check which tasks are due +now = time.time() +due = [] +for task in tasks: + name = task.get('name', '') + interval_sec = parse_interval(task.get('interval', '1h')) + last_run = state.get(name, {}).get('last_run', 0) + if now - last_run >= interval_sec: + due.append(task) + elif catchup and last_run == 0: + due.append(task) + +# Limit catchup to 5 tasks +if catchup: + due = due[:5] + +print(json.dumps(due)) +PYEOF +) + +# --- Run due tasks --- +TASK_COUNT=$(echo "$DUE_TASKS" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) + +if [ "$TASK_COUNT" = "0" ]; then + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | heartbeat | HEARTBEAT_OK (no tasks due)" >> "$LOG_DIR/heartbeat.log" + exit 0 +fi + +echo "$DUE_TASKS" | python3 -c " +import sys, json, subprocess, time, os + +tasks = json.load(sys.stdin) +state_file = '$STATE_FILE' +log_dir = '$LOG_DIR' +working_dir = '$WORKING_DIR' +max_turns = '$MAX_TURNS' +ack_max = int('$ACK_MAX_CHARS') +catchup = '$CATCHUP_MODE' == 'true' + +# Load state +try: + state = json.load(open(state_file)) +except: + state = {} + +for i, task in enumerate(tasks): + name = task.get('name', 'unknown') + prompt = task.get('prompt', '') + + if not prompt: + continue + + # Stagger catchup tasks + if catchup and i > 0: + time.sleep(5) + + ts = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) + print('{} | heartbeat | RUNNING: {}'.format(ts, name)) + + try: + result = subprocess.run( + ['claude', '-p', prompt, '--output-format', 'text', '--max-turns', str(max_turns)], + capture_output=True, text=True, timeout=600, + cwd=working_dir + ) + output = result.stdout.strip() + + # Suppress short ack responses (OpenClaw ackMaxChars pattern) + if len(output) <= ack_max and 'HEARTBEAT_OK' in output: + log_line = '{} | heartbeat | {} | HEARTBEAT_OK (suppressed)'.format(ts, name) + else: + log_line = '{} | heartbeat | {} | completed ({} chars)'.format(ts, name, len(output)) + # Save full output + log_path = os.path.join(log_dir, 'heartbeat-{}-{}.log'.format(name, time.strftime('%Y-%m-%d'))) + with open(log_path, 'a') as f: + f.write('--- {} ---\n{}\n\n'.format(ts, output)) + + except subprocess.TimeoutExpired: + log_line = '{} | heartbeat | {} | TIMEOUT'.format(ts, name) + except Exception as e: + log_line = '{} | heartbeat | {} | ERROR: {}'.format(ts, name, str(e)) + + with open(os.path.join(log_dir, 'heartbeat.log'), 'a') as f: + f.write(log_line + '\n') + + # Update state + state[name] = {'last_run': time.time()} + +# Save state +with open(state_file, 'w') as f: + json.dump(state, f, indent=2) +" + +echo "Heartbeat complete: $TASK_COUNT tasks processed"