#!/usr/bin/env sh # ============================================================================ # Claude Code Overnight Automation — Loop Script # ============================================================================ # Runs Claude headlessly in a loop, executing tasks from plan.md. # Rate-limit aware with automatic retry and macOS launchd scheduling. # # Usage: # ./loop/loop.sh # Run with defaults # ITERATION_COUNT=20 ./loop/loop.sh # 20 iterations # CLAUDE_AUTONOMOUS=0 ./loop/loop.sh # Allow Claude to stop freely # # Environment variables (all optional): # CLAUDE_AUTONOMOUS — 1 to block stops until plan complete (default: 1) # ITERATION_COUNT — Max loop iterations (default: 10) # ITERATION_DELAY — Seconds between iterations (default: 30) # RATE_LIMIT_WAIT — Seconds to wait on rate limit (default: 3600) # MAX_RATE_LIMIT_RETRIES — Max retries before launchd (default: 5) # CLAUDE_BIN — Path to claude binary (default: claude) # PROMPT_FILE — Path to prompt file (default: loop/prompt.md) # LOG_FILE — Path to log file (default: loop/loop.log) # ============================================================================ set -u PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" PROMPT_FILE="${PROMPT_FILE:-$PROJECT_DIR/loop/prompt.md}" LOG_FILE="${LOG_FILE:-$PROJECT_DIR/loop/loop.log}" ITERATION_COUNT="${ITERATION_COUNT:-10}" ITERATION_DELAY="${ITERATION_DELAY:-30}" CLAUDE_BIN="${CLAUDE_BIN:-claude}" RATE_LIMIT_WAIT="${RATE_LIMIT_WAIT:-3600}" MAX_RATE_LIMIT_RETRIES="${MAX_RATE_LIMIT_RETRIES:-5}" CLAUDE_EXIT=0 cd "$PROJECT_DIR" # ── Logging helpers ────────────────────────────────────────────────────────── log() { echo "$1" | tee -a "$LOG_FILE" } banner() { log "" log "════════════════════════════════════════════════════════════════" log " $1" log " $(date '+%Y-%m-%d %H:%M:%S')" log "════════════════════════════════════════════════════════════════" log "" } section() { log "" log "────────────────────────────────────────" log " $1" log "────────────────────────────────────────" log "" } # ── Task helpers ───────────────────────────────────────────────────────────── plan_has_tasks() { grep -q '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null } remaining_tasks() { grep -c '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null || echo "0" } next_task() { grep -m1 '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null | sed 's/^- \[ \] //' || echo "(none)" } # ── Rate limit detection ──────────────────────────────────────────────────── check_rate_limit() { [ "${CLAUDE_EXIT:-0}" -eq 0 ] && return 1 tail -50 "$LOG_FILE" 2>/dev/null \ | grep -v "^Rate limit" | grep -v "^Sleeping" | grep -v "^═" | grep -v "^─" \ | grep -qi \ -e "rate.limit" \ -e "too.many.requests" \ -e "429" \ -e "quota.exceeded" \ -e "usage.limit" \ -e "limit.reached" 2>/dev/null } # ── Main loop ──────────────────────────────────────────────────────────────── banner "OVERNIGHT AUTOMATION STARTED" log " Project: $PROJECT_DIR" log " Prompt: $PROMPT_FILE" log " Autonomous: ${CLAUDE_AUTONOMOUS:-0}" log " Iterations: $ITERATION_COUNT (${ITERATION_DELAY}s between each)" log " Rate limit: wait ${RATE_LIMIT_WAIT}s, retry up to ${MAX_RATE_LIMIT_RETRIES}x" log " Tasks left: $(remaining_tasks)" log " Next task: $(next_task)" log "" i=1 rate_limit_retries=0 while [ "$i" -le "$ITERATION_COUNT" ]; do # Stop if no tasks remain if ! plan_has_tasks; then banner "ALL TASKS COMPLETE" log " No remaining tasks in plan.md. Stopping." break fi section "ITERATION $i/$ITERATION_COUNT" log " Tasks remaining: $(remaining_tasks)" log " Next task: $(next_task)" log "" export CLAUDE_PROJECT_DIR="$PROJECT_DIR" export CLAUDE_AUTONOMOUS="${CLAUDE_AUTONOMOUS:-1}" # Run Claude in headless mode with prompt from file if [ -f "$PROMPT_FILE" ]; then log " Starting Claude session..." log "" "$CLAUDE_BIN" -p --dangerously-skip-permissions \ < "$PROMPT_FILE" 2>&1 | tee -a "$LOG_FILE" CLAUDE_EXIT=$? log "" log " Claude exited with code: $CLAUDE_EXIT" else log " ERROR: $PROMPT_FILE not found" exit 1 fi # ── Rate limit handling ────────────────────────────────────────────────── if check_rate_limit; then rate_limit_retries=$((rate_limit_retries + 1)) if [ "$rate_limit_retries" -ge "$MAX_RATE_LIMIT_RETRIES" ]; then section "RATE LIMITED — SCHEDULING RETRY" log " Hit rate limit $rate_limit_retries times." # Schedule retry via macOS launchd (self-destructing plist) PLIST_LABEL="com.claude-loop.overnight-retry" PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist" RETRY_TIME=$(date -v+${RATE_LIMIT_WAIT}S '+%H:%M' 2>/dev/null \ || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M') RETRY_HOUR=$(echo "$RETRY_TIME" | cut -d: -f1) RETRY_MIN=$(echo "$RETRY_TIME" | cut -d: -f2) cat > "$PLIST_PATH" < Label ${PLIST_LABEL} ProgramArguments /bin/sh -c cd ${PROJECT_DIR} && caffeinate -i ./loop/loop.sh >> ${LOG_FILE} 2>&1; launchctl unload ${PLIST_PATH}; rm -f ${PLIST_PATH} StartCalendarInterval Hour ${RETRY_HOUR} Minute ${RETRY_MIN} EnvironmentVariables CLAUDE_AUTONOMOUS 1 CLAUDE_PROJECT_DIR ${PROJECT_DIR} PATH /usr/local/bin:/usr/bin:/bin:$HOME/.local/bin StandardOutPath ${LOG_FILE} StandardErrorPath ${LOG_FILE} PLIST launchctl load "$PLIST_PATH" 2>/dev/null || true log " Scheduled retry at ~${RETRY_TIME}" log " Plist: $PLIST_PATH (auto-removes after running)" exit 0 fi section "RATE LIMITED — WAITING" log " Attempt $rate_limit_retries/$MAX_RATE_LIMIT_RETRIES" log " Sleeping ${RATE_LIMIT_WAIT}s..." sleep "$RATE_LIMIT_WAIT" if ! plan_has_tasks; then banner "ALL TASKS COMPLETE (during rate limit wait)" break fi log " Retrying..." continue fi # Reset rate limit counter on successful run rate_limit_retries=0 section "ITERATION $i COMPLETE" log " Tasks remaining: $(remaining_tasks)" log " Next task: $(next_task)" i=$((i + 1)) if [ "$i" -le "$ITERATION_COUNT" ] && [ "$ITERATION_DELAY" -gt 0 ]; then log " Pausing ${ITERATION_DELAY}s before next iteration..." sleep "$ITERATION_DELAY" fi done banner "LOOP FINISHED" log " Completed $((i - 1)) iterations" log " Tasks remaining: $(remaining_tasks)" log ""