← back to Claude Code Supervisor

supervisor.sh

191 lines

#!/bin/bash
#
# claude-code-supervisor
# ----------------------
# Persistent, rate-limit-aware supervisor for a single Claude Code process.
#
# Launches Claude Code as a child, watches its combined stdout/stderr for
# rate-limit indicators, and on a hit terminates ONLY that child (and its
# direct descendants), waits, and restarts it in the same working directory.
# Repeated rate-limits back off progressively; a productive run resets the
# delay. Designed to run unattended for days; pair with the launchd plist so
# macOS restarts the supervisor itself after a crash or reboot.
#
# Usage:
#   ./supervisor.sh [-- <claude args...>]
#   CLAUDE_SUP_WORKDIR=/path ./supervisor.sh -- --dangerously-skip-permissions
#
# Everything unrelated to our own child is left untouched. We never pattern-kill
# "claude" — we only signal the exact PID we spawned and `pkill -P` its children.
#
set -uo pipefail

# ---- configuration (override via environment) -------------------------------
WORKDIR="${CLAUDE_SUP_WORKDIR:-$HOME}"
LOG_DIR="${CLAUDE_SUP_LOG_DIR:-$HOME/.claude/supervisor-logs}"
# The command to supervise. Default is bare `claude`; anything after `--` on the
# command line is appended as arguments.
CLAUDE_BIN="${CLAUDE_SUP_BIN:-claude}"

# Backoff ladder (seconds) applied on consecutive problems. After the last entry
# it stays pinned at that value until a productive run resets the index. The
# override exists so the real control flow can be exercised quickly in tests.
IFS=',' read -r -a BACKOFF <<< "${CLAUDE_SUP_BACKOFF:-20,40,60,120,300}"

# A run lasting at least this long (seconds) is treated as "productive" and
# resets the backoff index to 0.
SUCCESS_RESET_SECONDS="${CLAUDE_SUP_SUCCESS_RESET:-180}"

# Delay before restarting after a *clean* exit that followed a productive run.
CLEAN_RESTART_DELAY="${CLAUDE_SUP_CLEAN_DELAY:-5}"

# Poll interval while watching the child (seconds).
POLL="${CLAUDE_SUP_POLL:-2}"

# Case-insensitive regex of rate-limit / usage-cap indicators.
RL_PATTERN="${CLAUDE_SUP_PATTERN:-rate limit|rate-limit|429|too many requests|usage limit|overloaded_error|quota exceeded}"

# ---- setup ------------------------------------------------------------------
mkdir -p "$LOG_DIR"
RUN_LOG="$LOG_DIR/claude-current.log"          # rolling log of the live child
SUP_LOG="$LOG_DIR/supervisor.log"              # the supervisor's own event log

# Parse optional `-- <args...>`
CHILD_ARGS=()
if [ "${1:-}" = "--" ]; then
  shift
  CHILD_ARGS=("$@")
fi

# A bare launchd job has no task to resume. Holding idle is deliberate: it
# prevents a hot loop of `claude` invocations that immediately fail for lack of
# a prompt, while keeping the LaunchAgent healthy and ready for explicit use.
if [ "${#CHILD_ARGS[@]}" -eq 0 ]; then
  log_line="$(date '+%Y-%m-%d %H:%M:%S') [supervisor] inactive: no Claude arguments supplied; use supervisor.sh -- -p '<prompt>'"
  echo "$log_line"
  echo "$log_line" >>"$SUP_LOG"
  while true; do sleep 300; done
fi

log() {
  # timestamped line to both the supervisor log and stdout (captured by launchd)
  local line
  line="$(date '+%Y-%m-%d %H:%M:%S') [supervisor] $*"
  echo "$line"
  echo "$line" >>"$SUP_LOG"
}

CHILD_PID=""
WATCHER_PID=""

# Kill ONLY our child subtree. Never a broad pattern match.
kill_child() {
  [ -n "$CHILD_PID" ] || return 0
  if kill -0 "$CHILD_PID" 2>/dev/null; then
    pkill -TERM -P "$CHILD_PID" 2>/dev/null   # direct descendants first
    kill -TERM "$CHILD_PID" 2>/dev/null
    # grace period, then SIGKILL if still alive
    local n=0
    while kill -0 "$CHILD_PID" 2>/dev/null && [ "$n" -lt 10 ]; do
      sleep 1; n=$((n+1))
    done
    if kill -0 "$CHILD_PID" 2>/dev/null; then
      pkill -KILL -P "$CHILD_PID" 2>/dev/null
      kill -KILL "$CHILD_PID" 2>/dev/null
    fi
  fi
}

cleanup() {
  [ -n "$WATCHER_PID" ] && kill "$WATCHER_PID" 2>/dev/null
  kill_child
  log "supervisor exiting"
}
trap cleanup EXIT
trap 'log "received TERM/INT — shutting down"; exit 0' TERM INT

# Best-effort: if a line says "try again in Ns" / "resets in N seconds", honor it.
parse_reset_seconds() {
  # reads the last ~40 lines of the run log, returns a seconds value or empty
  local secs
  secs="$(tail -n 40 "$RUN_LOG" 2>/dev/null \
    | grep -ioE 'try again in [0-9]+ ?s|resets? in [0-9]+ ?(second|s)' \
    | grep -oE '[0-9]+' | tail -n 1)"
  echo "$secs"
}

log "starting; workdir=$WORKDIR bin=$CLAUDE_BIN args=[${CHILD_ARGS[*]:-}]"
cd "$WORKDIR" || { log "FATAL: cannot cd to $WORKDIR"; exit 1; }

idx=0
while true; do
  : > "$RUN_LOG"                 # fresh log per run (rotated copy kept below)
  rl_flag="$LOG_DIR/.ratelimited.$$"
  rm -f "$rl_flag"

  # --- launch the child, combined output to RUN_LOG ---
  # ${arr[@]+"${arr[@]}"} = the bash-3.2-safe way to expand a possibly-empty
  # array under `set -u` (a bare "${arr[@]}" on an empty array errors here).
  "$CLAUDE_BIN" ${CHILD_ARGS[@]+"${CHILD_ARGS[@]}"} >>"$RUN_LOG" 2>&1 &
  CHILD_PID=$!
  start=$(date +%s)
  log "launched claude pid=$CHILD_PID"

  # --- monitor loop ---
  rate_limited=0
  while kill -0 "$CHILD_PID" 2>/dev/null; do
    if grep -qiE "$RL_PATTERN" "$RUN_LOG" 2>/dev/null; then touch "$rl_flag"; fi
    if [ -f "$rl_flag" ]; then
      rate_limited=1
      log "rate-limit indicator detected — terminating pid=$CHILD_PID"
      kill_child
      break
    fi
    sleep "$POLL"
  done

  # reap exit status (0 if we killed it ourselves)
  wait "$CHILD_PID" 2>/dev/null
  rc=$?
  # Catch a marker printed by a child that exited between monitor polls.
  if grep -qiE "$RL_PATTERN" "$RUN_LOG" 2>/dev/null; then rate_limited=1; fi
  dur=$(( $(date +%s) - start ))

  # keep a timestamped copy of this run's tail for forensics
  cp "$RUN_LOG" "$LOG_DIR/run-$(date '+%Y%m%d-%H%M%S').log" 2>/dev/null

  # productive run resets the backoff ladder
  if [ "$dur" -ge "$SUCCESS_RESET_SECONDS" ]; then
    if [ "$idx" -ne 0 ]; then log "productive run (${dur}s) — backoff reset"; fi
    idx=0
  fi

  # decide reason + delay
  if [ "$rate_limited" -eq 1 ]; then
    reason="rate-limit"
    delay="${BACKOFF[$idx]}"
    resets="$(parse_reset_seconds)"
    if [ -n "$resets" ] && [ "$resets" -gt "$delay" ] 2>/dev/null; then
      log "child reported reset in ${resets}s — honoring it"
      delay="$resets"
    fi
    [ "$idx" -lt $(( ${#BACKOFF[@]} - 1 )) ] && idx=$((idx+1))
  elif [ "$rc" -eq 0 ]; then
    reason="clean-exit(rc=0)"
    if [ "$dur" -ge "$SUCCESS_RESET_SECONDS" ]; then
      delay="$CLEAN_RESTART_DELAY"
    else
      # exited cleanly but very fast — treat as a flap, back off
      delay="${BACKOFF[$idx]}"
      [ "$idx" -lt $(( ${#BACKOFF[@]} - 1 )) ] && idx=$((idx+1))
    fi
  else
    reason="crash(rc=$rc)"
    delay="${BACKOFF[$idx]}"
    [ "$idx" -lt $(( ${#BACKOFF[@]} - 1 )) ] && idx=$((idx+1))
  fi

  log "run ended: ${reason} after ${dur}s — restarting in ${delay}s (backoff idx=$idx)"
  sleep "$delay"
done