[object Object]

← back to Claude Code Supervisor

claude-code-supervisor: rate-limit-aware Claude Code supervisor + launchd plist

f34594c6da04afb32971db06758025f9e0129f15 · 2026-08-30 12:33:41 -0700 · Steve

Files touched

Diff

commit f34594c6da04afb32971db06758025f9e0129f15
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Aug 30 12:33:41 2026 -0700

    claude-code-supervisor: rate-limit-aware Claude Code supervisor + launchd plist
---
 .gitignore                        |   8 ++
 README.md                         |  71 ++++++++++++++
 com.steve.claude-supervisor.plist |  45 +++++++++
 supervisor.sh                     | 188 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 312 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d3fb526
--- /dev/null
+++ b/README.md
@@ -0,0 +1,71 @@
+# claude-code-supervisor
+
+Persistent, rate-limit-aware supervisor for a **single** Claude Code process on macOS.
+
+## What it does
+- Launches Claude Code as a child and watches its combined stdout/stderr.
+- On a rate-limit indicator (`rate limit`, `429`, `too many requests`, `usage
+  limit`, `overloaded_error`, `quota exceeded`), it terminates **only that
+  child** (and its direct descendants), waits, and restarts it in the same
+  working directory.
+- Consecutive problems back off: **20s → 40s → 60s → 120s → 300s**, pinned at
+  300s until a productive run resets the ladder.
+- A run lasting ≥ `CLAUDE_SUP_SUCCESS_RESET` (default 180s) is "productive" and
+  resets the delay to the bottom of the ladder.
+- Best-effort: if Claude prints a "try again in Ns" / "resets in Ns" hint, the
+  supervisor honors the longer of that vs. the backoff delay.
+- Keeps per-run logs under `~/.claude/supervisor-logs/` for forensics.
+
+## Two independent layers (use both for 24/7)
+1. **This script** = rate-limit-aware restart + backoff of *Claude*.
+2. **launchd (`KeepAlive`)** = restarts *the supervisor itself* after a crash or
+   after the Mac reboots.
+
+## Safety
+- Never pattern-kills `claude`. It only signals the exact PID it spawned and
+  `pkill -P` that PID's children — unrelated Claude sessions are untouched.
+- `ThrottleInterval` + fast-clean-exit backoff prevent hot restart loops.
+
+## Run it in the foreground (test)
+```sh
+cd ~/Projects/claude-code-supervisor
+./supervisor.sh -- <your claude args>
+```
+Tail the logs: `tail -f ~/.claude/supervisor-logs/supervisor.log`
+
+## Install as a launchd LaunchAgent (24/7, survives reboot)
+1. Edit `ProgramArguments` in the plist to add your claude args after the
+   script path (or leave it to supervise bare `claude`).
+2. Install:
+```sh
+cp com.steve.claude-supervisor.plist ~/Library/LaunchAgents/
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.claude-supervisor.plist
+launchctl enable gui/$(id -u)/com.steve.claude-supervisor
+```
+3. Verify: `launchctl list | grep claude-supervisor` (expect a PID, not `-`).
+
+### Uninstall (fully reversible)
+```sh
+launchctl bootout gui/$(id -u)/com.steve.claude-supervisor
+rm ~/Library/LaunchAgents/com.steve.claude-supervisor.plist
+```
+
+## Configuration (env vars)
+| Var | Default | Meaning |
+|-----|---------|---------|
+| `CLAUDE_SUP_WORKDIR` | `$HOME` | working dir the child runs in |
+| `CLAUDE_SUP_BIN` | `claude` | binary to supervise |
+| `CLAUDE_SUP_SUCCESS_RESET` | `180` | seconds a run must last to reset backoff |
+| `CLAUDE_SUP_CLEAN_DELAY` | `5` | restart delay after a productive clean exit |
+| `CLAUDE_SUP_POLL` | `2` | monitor poll interval (s) |
+| `CLAUDE_SUP_PATTERN` | see script | case-insensitive rate-limit regex |
+| `CLAUDE_SUP_LOG_DIR` | `~/.claude/supervisor-logs` | log directory |
+
+## Important caveat
+Restarting does **not** clear an account-level usage window — if you've hit a
+multi-hour usage cap, a restart won't grant more tokens; the supervisor will
+simply back off (and honor a printed reset time) until the window reopens. The
+restart loop is most valuable for transient `429`s, `overloaded_error`s, and
+wedged/stuck states. It also assumes a **headless / long-running** claude
+invocation (e.g. print-mode or a scripted loop); a fully interactive TUI that a
+human is typing into is not the intended target.
diff --git a/com.steve.claude-supervisor.plist b/com.steve.claude-supervisor.plist
new file mode 100644
index 0000000..9c7de29
--- /dev/null
+++ b/com.steve.claude-supervisor.plist
@@ -0,0 +1,45 @@
+<?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>
+  <key>Label</key>
+  <string>com.steve.claude-supervisor</string>
+
+  <!-- /bin/bash <supervisor.sh> [-- <claude args...>]
+       Add your claude args after the `--` (e.g. a project dir command,
+       --dangerously-skip-permissions, etc.). Leave as-is to supervise bare `claude`. -->
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>/Users/macstudio3/Projects/claude-code-supervisor/supervisor.sh</string>
+  </array>
+
+  <!-- launchd restarts the SUPERVISOR itself on crash or reboot;
+       the supervisor restarts CLAUDE on rate-limits. Two independent layers. -->
+  <key>KeepAlive</key>
+  <true/>
+  <key>RunAtLoad</key>
+  <true/>
+
+  <key>WorkingDirectory</key>
+  <string>/Users/macstudio3</string>
+
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/Users/macstudio3/bin:/Users/macstudio3/.npm-global/bin:/Users/macstudio3/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+    <key>CLAUDE_SUP_WORKDIR</key>
+    <string>/Users/macstudio3</string>
+  </dict>
+
+  <!-- Guard against a hot restart loop chewing CPU if something is badly wrong. -->
+  <key>ThrottleInterval</key>
+  <integer>10</integer>
+
+  <key>StandardOutPath</key>
+  <string>/Users/macstudio3/.claude/supervisor-logs/launchd.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/macstudio3/.claude/supervisor-logs/launchd.err.log</string>
+</dict>
+</plist>
diff --git a/supervisor.sh b/supervisor.sh
new file mode 100755
index 0000000..2ceeed8
--- /dev/null
+++ b/supervisor.sh
@@ -0,0 +1,188 @@
+#!/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.
+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
+
+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"
+
+  # --- watcher: tail the log, trip the flag on a rate-limit marker ---
+  (
+    trap - EXIT INT TERM   # don't let this subshell run the supervisor's cleanup
+    tail -n0 -F "$RUN_LOG" 2>/dev/null | while IFS= read -r line; do
+      if printf '%s' "$line" | grep -qiE "$RL_PATTERN"; then
+        touch "$rl_flag"; break
+      fi
+    done
+  ) &
+  WATCHER_PID=$!
+
+  # --- monitor loop ---
+  rate_limited=0
+  while kill -0 "$CHILD_PID" 2>/dev/null; do
+    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=$?
+  kill "$WATCHER_PID" 2>/dev/null; WATCHER_PID=""
+  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

(oldest)  ·  back to Claude Code Supervisor  ·  harden Claude rate-limit supervisor 0d8d11a →