#!/usr/bin/env bash
#
# Ralph Wiggum Loop — Script-Orchestrated Autonomous Agent Iteration
#
# This runtime is for the "script is the orchestrator" model:
# - The shell loop spawns a fresh agent every iteration
# - The shell loop interprets runtime signals and failures
# - The shell loop decides when to retry, stop, or wait for token reset
#
# This is different from the "agent is the orchestrator" model used in
# OpenClaw/manual orchestration, where a supervising agent evaluates results,
# watches execution boards, and decides what to do next.
#
# Usage:
# ./ralph-loop.sh # Build mode (default)
# ./ralph-loop.sh plan # Planning mode
# ./ralph-loop.sh --max 20 # Limit iterations
# ./ralph-loop.sh --agent claude # Use claude (default)
# ./ralph-loop.sh --session-ends 2026-04-09T16:00:00
# ./ralph-loop.sh --retry-wait 1800
# ./ralph-loop.sh --board .harness/foo/execution-board.md
# ./ralph-loop.sh --no-require-pro
#
# Token / rate-limit handling:
# Tier 1 — Anthropic API probe if ANTHROPIC_API_KEY is available
# Tier 2 — Parse "resets 11am (America/New_York)" from agent output
# Tier 3 — Use seeded --session-ends time
# Tier 4 — Fixed fallback sleep
#
set -euo pipefail
MODE="build"
MAX_ITERATIONS=50
AGENT="claude"
PLAN_FILE="IMPLEMENTATION_PLAN.md"
SPEC_FILE="PROJECT-SPEC.md"
AGENT_FILE="AGENT.md"
BOARD_FILE=""
LOG_DIR=".ralph-logs"
SESSION_TS="$(date '+%Y%m%dT%H%M%S')"
RATE_LIMIT_WAIT=1800
SESSION_ENDS=""
REQUIRE_PRO=1
while [[ $# -gt 0 ]]; do
case "$1" in
plan) MODE="plan"; shift ;;
build) MODE="build"; shift ;;
--max) MAX_ITERATIONS="$2"; shift 2 ;;
--agent) AGENT="$2"; shift 2 ;;
--retry-wait) RATE_LIMIT_WAIT="$2"; shift 2 ;;
--session-ends) SESSION_ENDS="$2"; shift 2 ;;
--board) BOARD_FILE="$2"; shift 2 ;;
--no-require-pro) REQUIRE_PRO=0; shift ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
mkdir -p "$LOG_DIR"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${BLUE}[ralph]${NC} $1"; }
success() { echo -e "${GREEN}[ralph]${NC} $1"; }
warn() { echo -e "${YELLOW}[ralph]${NC} $1"; }
error() { echo -e "${RED}[ralph]${NC} $1"; }
info() { echo -e "${CYAN}[ralph]${NC} $1"; }
AGENT_EXIT_CODE=0
get_claude_analysis_auth_json() {
env -u ANTHROPIC_API_KEY bash -ic 'claude auth status' 2>/dev/null | tail -n +1
}
verify_claude_pro_auth() {
local auth_json
auth_json=$(get_claude_analysis_auth_json)
if [[ -z "$auth_json" ]]; then
error "Could not determine Claude analysis auth status."
return 1
fi
AUTH_JSON="$auth_json" python3 - <<'PY'
import json
import os
import sys
data = json.loads(os.environ["AUTH_JSON"])
if data.get("loggedIn") and data.get("subscriptionType") == "pro":
print("ok")
sys.exit(0)
print(json.dumps(data, ensure_ascii=True))
sys.exit(1)
PY
}
log_agent_runtime() {
case "$AGENT" in
claude)
local claude_path claude_version auth_json
claude_path=$(bash -ic 'command -v claude' 2>/dev/null | tail -n 1 || true)
claude_version=$(bash -ic 'claude --version' 2>/dev/null | tail -n 1 || true)
auth_json=$(get_claude_analysis_auth_json)
log "Claude binary: ${claude_path:-not found}"
log "Claude version: ${claude_version:-unknown}"
if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then
log "Claude auth hint: ANTHROPIC_API_KEY is set (API probe enabled)"
else
log "Claude auth hint: ANTHROPIC_API_KEY is not set"
fi
if [[ -n "$auth_json" ]]; then
log "Claude analysis auth: $(AUTH_JSON="$auth_json" python3 - <<'PY'
import json
import os
data = json.loads(os.environ["AUTH_JSON"])
print(f"authMethod={data.get('authMethod')} subscriptionType={data.get('subscriptionType')} apiKeySource={data.get('apiKeySource')}")
PY
)"
fi
;;
esac
}
if [[ ! -f "$SPEC_FILE" ]]; then
error "Missing $SPEC_FILE — create your project spec first."
exit 1
fi
if [[ ! -f "$AGENT_FILE" ]]; then
warn "No $AGENT_FILE found. Using default agent instructions."
fi
probe_rate_limit() {
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
return 1
fi
local headers
headers=$(curl -s -D - -o /dev/null \
--max-time 10 \
-X POST "https://api.anthropic.com/v1/messages" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' \
2>/dev/null) || return 1
local reset_str remaining
reset_str=$(echo "$headers" | grep -i "anthropic-ratelimit-output-tokens-reset:" | awk '{print $2}' | tr -d '\r\n')
remaining=$(echo "$headers" | grep -i "anthropic-ratelimit-output-tokens-remaining:" | awk '{print $2}' | tr -d '\r\n')
if [[ -z "$reset_str" ]]; then
return 1
fi
local reset_epoch
reset_epoch=$(date -d "$reset_str" +%s 2>/dev/null) \
|| reset_epoch=$(python3 -c "
from datetime import datetime, timezone
import sys
s = sys.argv[1].strip()
for fmt in ('%Y-%m-%dT%H:%M:%SZ', '%Y-%m-%dT%H:%M:%S+00:00', '%Y-%m-%dT%H:%M:%S%z'):
try:
dt = datetime.strptime(s, fmt)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
print(int(dt.timestamp()))
break
except Exception:
pass
" "$reset_str" 2>/dev/null) || return 1
echo "${reset_epoch}|${remaining:-unknown}"
}
parse_epoch() {
local ts="$1"
date -d "$ts" +%s 2>/dev/null \
|| python3 -c "
from datetime import datetime, timezone
import sys
s = sys.argv[1]
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%SZ', '%Y-%m-%d %H:%M:%S',
'%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S+00:00'):
try:
dt = datetime.strptime(s, fmt)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
print(int(dt.timestamp()))
break
except Exception:
pass
" "$ts" 2>/dev/null || true
}
format_session_end() {
local epoch="$1"
date -d "@$epoch" +"%Y-%m-%dT%H:%M:%S" 2>/dev/null \
|| date -r "$epoch" +"%Y-%m-%dT%H:%M:%S" 2>/dev/null \
|| echo ""
}
infer_reset_epoch_from_log() {
local logfile="$1"
python3 - "$logfile" <<'PY' 2>/dev/null || true
from datetime import datetime, timedelta
from pathlib import Path
import re
import sys
try:
from zoneinfo import ZoneInfo
except Exception:
ZoneInfo = None
logfile = Path(sys.argv[1])
if not logfile.exists():
raise SystemExit(0)
text = logfile.read_text(encoding="utf-8", errors="ignore")
matches = list(re.finditer(r"resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)", text, re.IGNORECASE))
if not matches:
raise SystemExit(0)
match = matches[-1]
hour = int(match.group(1))
minute = int(match.group(2) or "0")
ampm = match.group(3).lower()
tz_name = match.group(4).strip()
if hour == 12:
hour = 0
if ampm == "pm":
hour += 12
if ZoneInfo is None:
raise SystemExit(0)
tz = ZoneInfo(tz_name)
now = datetime.now(tz)
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if candidate <= now:
candidate += timedelta(days=1)
print(int(candidate.timestamp()))
PY
}
countdown_sleep() {
local target_epoch=$1
local label="${2:-token reset}"
local now
while true; do
now=$(date +%s)
local remaining=$(( target_epoch - now ))
if [[ $remaining -le 0 ]]; then
break
fi
local h=$(( remaining / 3600 ))
local m=$(( (remaining % 3600) / 60 ))
local s=$(( remaining % 60 ))
printf "\r${YELLOW}[ralph]${NC} Waiting for %s... %02dh%02dm%02ds remaining " "$label" "$h" "$m" "$s"
sleep 5
done
echo ""
}
wait_for_tokens() {
local logfile="${1:-}"
warn "Rate limit / token exhaustion detected."
echo ""
local wake_epoch="" wake_source=""
info "Tier 1 — probing Anthropic API for exact reset time..."
local probe_result
if probe_result=$(probe_rate_limit); then
local probe_epoch probe_remaining
probe_epoch="${probe_result%%|*}"
probe_remaining="${probe_result##*|}"
local now
now=$(date +%s)
if [[ -n "$probe_epoch" && "$probe_epoch" -gt "$now" ]]; then
wake_epoch=$probe_epoch
wake_source="API probe"
info "Tokens remaining: ${probe_remaining}. Reset at: $(date -d "@$probe_epoch" 2>/dev/null || date -r "$probe_epoch" 2>/dev/null || echo "$probe_epoch")"
else
info "Probe succeeded but reset time is already past — tokens may have reset. Retrying immediately."
return 0
fi
else
warn "Tier 1 unavailable (no ANTHROPIC_API_KEY or probe failed)."
fi
if [[ -z "$wake_epoch" && -n "$logfile" ]]; then
info "Tier 2 — parsing reset time from agent output..."
local log_epoch
log_epoch=$(infer_reset_epoch_from_log "$logfile") || true
if [[ -n "$log_epoch" ]]; then
wake_epoch=$(( log_epoch + 60 ))
wake_source="agent output"
SESSION_ENDS=$(format_session_end "$log_epoch")
info "Detected reset at: $(date -d "@$log_epoch" 2>/dev/null || date -r "$log_epoch" 2>/dev/null || echo "$log_epoch")"
if [[ -n "$SESSION_ENDS" ]]; then
info "Updated --session-ends seed to $SESSION_ENDS"
fi
else
warn "Could not extract a reset time from $logfile."
fi
fi
if [[ -z "$wake_epoch" && -n "$SESSION_ENDS" ]]; then
info "Tier 3 — using --session-ends $SESSION_ENDS..."
local seed_epoch
seed_epoch=$(parse_epoch "$SESSION_ENDS") || true
if [[ -n "$seed_epoch" ]]; then
local now
now=$(date +%s)
if [[ "$seed_epoch" -gt "$now" ]]; then
wake_epoch=$(( seed_epoch + 60 ))
wake_source="session seed (--session-ends)"
info "Will wake at: $(date -d "@$wake_epoch" 2>/dev/null || date -r "$wake_epoch" 2>/dev/null || echo "$wake_epoch") (+60s buffer)"
else
warn "--session-ends is stale (already past). Ignoring it for this retry."
fi
else
warn "Could not parse --session-ends value: '$SESSION_ENDS'"
fi
fi
if [[ -z "$wake_epoch" ]]; then
warn "Tier 4 — no reset time available. Sleeping ${RATE_LIMIT_WAIT}s ($(( RATE_LIMIT_WAIT / 60 )) min)."
warn "Tip: set ANTHROPIC_API_KEY or pass --session-ends for a smarter wake-up."
wake_epoch=$(( $(date +%s) + RATE_LIMIT_WAIT ))
wake_source="fixed wait"
fi
info "Strategy: $wake_source. Press Ctrl+C to cancel."
countdown_sleep "$wake_epoch" "token reset"
log "Wake-up time reached. Retrying..."
}
run_agent() {
local iteration=$1
local mode=$2
local logfile="$LOG_DIR/${SESSION_TS}-iteration-${iteration}.log"
local prompt=""
if [[ "$mode" == "plan" ]]; then
prompt="Read PROJECT-SPEC.md. Decompose the project into discrete, testable tasks ordered by dependency. Write the plan to IMPLEMENTATION_PLAN.md with checkboxes. Output PLANNED when done."
else
prompt="Read AGENT.md (if it exists) for your instructions. Follow the core loop: orient, pick one task, implement, verify, commit, exit."
fi
log "Iteration $iteration ($mode mode) — starting fresh agent..."
if [[ "$AGENT" == "claude" && "$REQUIRE_PRO" == "1" ]]; then
if ! verify_claude_pro_auth >/tmp/ralph-auth-check.out 2>/tmp/ralph-auth-check.err; then
error "Claude analysis auth is not using Pro. Refusing to run."
if [[ -s /tmp/ralph-auth-check.out ]]; then
error "Auth details: $(tail -n 1 /tmp/ralph-auth-check.out)"
fi
if [[ -s /tmp/ralph-auth-check.err ]]; then
error "Auth check stderr: $(tail -n 1 /tmp/ralph-auth-check.err)"
fi
exit 1
fi
fi
set +e
case "$AGENT" in
claude)
echo "$prompt" | env -u ANTHROPIC_API_KEY claude -p --dangerously-skip-permissions --output-format text 2>&1 | tee "$logfile"
;;
codex)
echo "$prompt" | codex 2>&1 | tee "$logfile"
;;
aider)
aider --message "$prompt" --yes 2>&1 | tee "$logfile"
;;
gemini)
echo "$prompt" | gemini-cli 2>&1 | tee "$logfile"
;;
custom)
if [[ -x "./custom-agent.sh" ]]; then
./custom-agent.sh "$prompt" 2>&1 | tee "$logfile"
else
error "Custom agent selected but ./custom-agent.sh not found or not executable"
set -e
exit 1
fi
;;
*)
error "Unknown agent: $AGENT. Supported: claude, codex, aider, gemini, custom"
set -e
exit 1
;;
esac
AGENT_EXIT_CODE=$?
set -e
return 0
}
check_output() {
local logfile="$1"
if grep -q 'DONE' "$logfile" 2>/dev/null; then
return 0
elif grep -q 'STUCK' "$logfile" 2>/dev/null; then
return 2
elif grep -q 'ERROR' "$logfile" 2>/dev/null; then
return 3
elif grep -Eqi "rate.limit|rate_limit|too many requests|exceeded.*quota|usage limit|out of tokens|overloaded|you'?ve hit your limit|resets [0-9]{1,2}(:[0-9]{2})?(am|pm)" "$logfile" 2>/dev/null; then
return 4
else
return 1
fi
}
plan_has_remaining_work() {
if [[ ! -f "$PLAN_FILE" ]]; then
return 1
fi
if grep -Eq '^- \[ \]' "$PLAN_FILE" 2>/dev/null; then
return 0
fi
return 1
}
board_has_remaining_work() {
if [[ -z "$BOARD_FILE" || ! -f "$BOARD_FILE" ]]; then
return 1
fi
if grep -Eq '\| .*⬜ Pending .* \||\| .*🔄 In Progress .* \|' "$BOARD_FILE" 2>/dev/null; then
return 0
fi
return 1
}
has_remaining_work() {
if board_has_remaining_work; then
return 0
fi
if plan_has_remaining_work; then
return 0
fi
return 1
}
if [[ "$MODE" == "plan" ]]; then
log "Planning mode — creating implementation plan..."
run_agent 0 plan
success "Plan created. Review $PLAN_FILE, then run: ./ralph-loop.sh"
exit 0
fi
log "Starting Ralph Wiggum loop (max $MAX_ITERATIONS iterations)"
log "Runtime model: script-orchestrated"
log "Agent: $AGENT"
log "Spec: $SPEC_FILE"
log "Plan: $PLAN_FILE"
if [[ -n "$BOARD_FILE" ]]; then
log "Board: $BOARD_FILE"
fi
if [[ -n "$SESSION_ENDS" ]]; then
log "Tier 3 (session seed): $SESSION_ENDS"
fi
if [[ "$AGENT" == "claude" ]]; then
log_agent_runtime
if [[ "$REQUIRE_PRO" == "1" ]]; then
log "Pro guard: enabled"
else
warn "Pro guard: disabled (--no-require-pro)"
fi
fi
echo ""
for i in $(seq 1 "$MAX_ITERATIONS"); do
run_agent "$i" build
logfile="$LOG_DIR/${SESSION_TS}-iteration-${i}.log"
status=0; check_output "$logfile" || status=$?
case $status in
0)
if has_remaining_work; then
warn "Agent reported DONE, but the tracking artifacts still show work remaining."
warn "Ignoring false DONE and restarting with fresh context."
echo ""
sleep 2
else
success "All tracked work appears complete after $i iterations."
exit 0
fi
;;
2)
warn "Agent is stuck. Review $logfile and intervene."
exit 1
;;
3)
error "Agent encountered an error. Review $logfile."
exit 1
;;
4)
warn "Token/rate limit hit on iteration $i."
wait_for_tokens "$logfile"
echo ""
;;
1)
if [[ $AGENT_EXIT_CODE -ne 0 ]]; then
warn "Agent exited with code $AGENT_EXIT_CODE but did not emit a recognized promise signal."
if has_remaining_work; then
warn "Tracked work remains. Restarting fresh."
echo ""
sleep 2
else
error "No work remains in tracking artifacts, but agent did not finish cleanly."
error "Review $logfile."
exit 1
fi
else
log "Iteration $i complete. Restarting with fresh context..."
echo ""
sleep 2
fi
;;
esac
done
warn "Reached max iterations ($MAX_ITERATIONS). Review progress in $PLAN_FILE."
exit 1