← back to Claude Auto Resume
Initial: claude-auto-resume tooling (watcher + launchd daemon + plist)
23f89dcd0a951ffc6633b9120e5a296e223aea02 · 2026-05-30 23:12:36 -0700 · Steve Abrams
Files touched
A .gitignoreA README.mdA claude-auto-resume.shA claude-resume-daemon.shA com.steve.claude-auto-resume.plist
Diff
commit 23f89dcd0a951ffc6633b9120e5a296e223aea02
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 30 23:12:36 2026 -0700
Initial: claude-auto-resume tooling (watcher + launchd daemon + plist)
---
.gitignore | 4 ++
README.md | 60 +++++++++++++++++++
claude-auto-resume.sh | 115 +++++++++++++++++++++++++++++++++++++
claude-resume-daemon.sh | 84 +++++++++++++++++++++++++++
com.steve.claude-auto-resume.plist | 31 ++++++++++
5 files changed, 294 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1bcdbdc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.env*
+*.log
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4902ec6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,60 @@
+# claude-auto-resume
+
+Hands-free recovery from Claude Code's transient **"Server is temporarily limiting
+requests · Rate limited"** API error (the server-side limiter, *not* your usage cap).
+Watches a tmux pane; when the limiter error appears it waits a few seconds and types
+`RESUME` + Enter into that pane.
+
+Built 2026-05-30.
+
+## Files
+
+| File | Live location | What it is |
+|------|---------------|------------|
+| `claude-auto-resume.sh` | `~/bin/` | Per-session foreground watcher. Run on demand in a **separate** pane. Supports `--idle` (also resume when the pane sits unchanged, e.g. Claude stopped at a question). |
+| `claude-resume-daemon.sh` | `~/bin/` | Always-on supervisor. Scans **every** tmux pane; resumes any that show the limiter. Run via launchd. |
+| `com.steve.claude-auto-resume.plist` | `~/Library/LaunchAgents/` | launchd unit for the daemon. |
+
+> **This repo is the versioned source.** The live copies above are what actually run.
+> After editing here, copy back to `~/bin` / `~/Library/LaunchAgents` (or vice-versa) —
+> there is no auto-sync.
+
+## Per-session watcher (no launchd)
+
+```sh
+# rate-limiter only, exits on recovery:
+nohup ~/bin/claude-auto-resume.sh >/tmp/claude-resume.log 2>&1 & disown
+# also resume on idle (Claude waiting at a question):
+nohup ~/bin/claude-auto-resume.sh --idle >/tmp/claude-resume.log 2>&1 & disown
+```
+Run it in a **different** pane than the one running `claude` — it drives that pane
+via `tmux send-keys`.
+
+## Always-on daemon (launchd)
+
+```sh
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.claude-auto-resume.plist
+launchctl print gui/$(id -u)/com.steve.claude-auto-resume | grep -E 'state|pid'
+tail -f ~/Library/Logs/claude-auto-resume.log
+```
+Kill-switch (no unload needed):
+```sh
+touch ~/.claude-resume-disabled # pause
+rm ~/.claude-resume-disabled # resume
+```
+Uninstall: `launchctl bootout gui/$(id -u)/com.steve.claude-auto-resume`
+
+## Tunables (env, set in the plist)
+
+`RESUME_DELAY` (3s) · `RESUME_COOLDOWN` (per-pane, 25–30s) · `RESUME_POLL` ·
+`RESUME_TAIL` (bottom-most screen lines scanned) · `RESUME_MSG` (default `RESUME`).
+
+## Design notes
+
+- The TUI **hard-wraps** the error mid-word, so the daemon matches against a
+ whitespace-stripped, lowercased capture (`temporarilylimitingrequests|…`) — a
+ naive regex with spaces never matches.
+- Per-pane cooldown via a state file in `$TMPDIR/claude-resume/` stops it spamming
+ while a retry is in flight.
+- The daemon never exits on transient tmux errors (no tmux server yet → idle + retry),
+ so launchd doesn't crash-loop.
diff --git a/claude-auto-resume.sh b/claude-auto-resume.sh
new file mode 100755
index 0000000..4efcd62
--- /dev/null
+++ b/claude-auto-resume.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+# claude-auto-resume.sh — local (NO launchd) auto-resume watcher for a single
+# claude tmux pane. Run it in a SEPARATE pane from the one running claude.
+#
+# It types "RESUME"+Enter into the target pane when EITHER:
+# • the rate-limiter error shows up (always on), or
+# • --idle is set AND the pane has sat unchanged for (opt-in)
+# --idle-after seconds (claude is waiting at a question / idle prompt).
+#
+# Per-pane cooldown stops it spamming. In --idle mode it runs until you kill it;
+# otherwise it watches for the limiter and exits once recovery is detected.
+#
+# Usage:
+# claude-auto-resume.sh [pane] [--idle] [--delay 3] [--cooldown 25]
+# [--idle-after 8] [--poll 2] [--max N] [--once]
+# pane : tmux pane id like %3 (omit = auto-detect the claude pane)
+# --idle : ALSO resume when the pane is idle, not just on the error
+# --idle-after : seconds of no-change that counts as idle (default 8)
+# --max N : stop after N sends (default: 0=forever in --idle, else 40)
+# --once : wait --delay then send RESUME a single time, exit
+# Override the typed word: RESUME_MSG="continue" claude-auto-resume.sh
+#
+# Examples:
+# nohup ~/bin/claude-auto-resume.sh --idle >/tmp/claude-resume.log 2>&1 & disown
+# ~/bin/claude-auto-resume.sh %4 --once
+set -uo pipefail
+
+DELAY=3
+COOLDOWN=25
+POLL=2
+IDLE_AFTER=8
+IDLE=0
+ONCE=0
+MAX=""
+PANE=""
+MSG="${RESUME_MSG:-RESUME}"
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --idle) IDLE=1; shift;;
+ --idle-after) IDLE_AFTER="$2"; shift 2;;
+ --delay) DELAY="$2"; shift 2;;
+ --cooldown) COOLDOWN="$2"; shift 2;;
+ --poll) POLL="$2"; shift 2;;
+ --max) MAX="$2"; shift 2;;
+ --once) ONCE=1; shift;;
+ -*) echo "unknown flag: $1" >&2; exit 2;;
+ *) PANE="$1"; shift;;
+ esac
+done
+[ -n "$MAX" ] || { [ "$IDLE" = "1" ] && MAX=0 || MAX=40; }
+
+command -v tmux >/dev/null 2>&1 || { echo "[auto-resume] tmux not found" >&2; exit 1; }
+
+SIG='temporarily limiting requests|server is temporarily|Rate limited|API Error.*[Rr]ate.?limit'
+
+detect_pane() {
+ tmux list-panes -a -F '#{pane_id} #{pane_current_command} #{pane_title}' 2>/dev/null \
+ | awk 'tolower($0) ~ /claude/ {print $1; exit}'
+}
+[ -n "$PANE" ] || PANE="$(detect_pane || true)"
+[ -n "$PANE" ] || { echo "[auto-resume] no claude pane found — pass one explicitly (e.g. %3)" >&2; exit 1; }
+
+now() { date +%s; }
+send_resume() { tmux send-keys -t "$PANE" "$MSG" Enter; }
+
+echo "[auto-resume] pane=$PANE msg='$MSG' idle=$IDLE idle_after=${IDLE_AFTER}s delay=${DELAY}s cooldown=${COOLDOWN}s max=$MAX"
+
+if [ "$ONCE" = "1" ]; then
+ sleep "$DELAY"; send_resume
+ echo "[auto-resume] sent '$MSG' once to $PANE"; exit 0
+fi
+
+tries=0
+last_fire=0
+prev_hash=""
+last_change=$(now)
+clear_streak=0
+
+fire() { # $1 = reason
+ local t; t=$(now)
+ [ $(( t - last_fire )) -ge "$COOLDOWN" ] || return 0
+ echo "$(date '+%T') [auto-resume] $1 — waiting ${DELAY}s then '$MSG' (#$((tries+1)))"
+ sleep "$DELAY"; send_resume
+ last_fire=$(now); tries=$((tries+1)); last_change=$(now); prev_hash=""
+}
+
+while [ "$MAX" -eq 0 ] || [ "$tries" -lt "$MAX" ]; do
+ full="$(tmux capture-pane -p -t "$PANE" 2>/dev/null || true)"
+ tailtxt="$(printf '%s' "$full" | grep -v '^[[:space:]]*$' | tail -n 6)"
+
+ if printf '%s' "$tailtxt" | grep -qiE "$SIG"; then
+ clear_streak=0
+ fire "limiter error"
+ else
+ # idle detection — has the whole pane stopped changing?
+ h="$(printf '%s' "$full" | cksum | awk '{print $1}')"
+ if [ "$h" = "$prev_hash" ]; then
+ if [ "$IDLE" = "1" ] && [ $(( $(now) - last_change )) -ge "$IDLE_AFTER" ]; then
+ fire "pane idle ${IDLE_AFTER}s+"
+ fi
+ else
+ prev_hash="$h"; last_change=$(now)
+ fi
+ # non-idle mode: exit once the limiter has been gone for two scans
+ if [ "$IDLE" = "0" ]; then
+ clear_streak=$((clear_streak+1))
+ if [ "$clear_streak" -ge 2 ]; then
+ echo "[auto-resume] limiter cleared — exiting"; exit 0
+ fi
+ fi
+ fi
+ sleep "$POLL"
+done
+echo "[auto-resume] reached max=$MAX sends, exiting" >&2
diff --git a/claude-resume-daemon.sh b/claude-resume-daemon.sh
new file mode 100755
index 0000000..003b31a
--- /dev/null
+++ b/claude-resume-daemon.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# claude-resume-daemon.sh — always-on supervisor (run via launchd) that auto-
+# recovers ANY claude tmux pane from the transient "temporarily limiting
+# requests" API error (the server-side limiter, NOT your usage cap).
+#
+# Every POLL seconds it scans all tmux panes; for any pane whose last few lines
+# show the limiter signature, it waits DELAY then types RESUME + Enter into that
+# pane, with a per-pane COOLDOWN so it never spams while a retry is in flight.
+# Stays alive across tmux-server restarts; never exits on transient errors.
+#
+# Tunables via env (set in the plist): RESUME_DELAY RESUME_COOLDOWN RESUME_POLL
+# RESUME_TAIL RESUME_MSG. Disable wholesale: touch ~/.claude-resume-disabled
+set -uo pipefail # NOT -e: a transient tmux hiccup must not kill the daemon
+
+DELAY="${RESUME_DELAY:-3}" # wait before sending RESUME
+COOLDOWN="${RESUME_COOLDOWN:-30}" # min seconds between RESUMEs to the same pane
+POLL="${RESUME_POLL:-4}" # scan cadence
+TAIL="${RESUME_TAIL:-25}" # bottom-most N screen lines (was 4 — far too few:
+ # the error sits ABOVE the input-box chrome, and
+ # the TUI wraps it across several lines)
+MSG="${RESUME_MSG:-RESUME}"
+DISABLE_FLAG="$HOME/.claude-resume-disabled"
+STATE_DIR="${TMPDIR:-/tmp}/claude-resume"; mkdir -p "$STATE_DIR" 2>/dev/null || true
+
+# Limiter signature — matched against a WHITESPACE-STRIPPED, lowercased capture
+# (see below). The TUI hard-wraps the error mid-word ("temporar\nily limiting"),
+# so phrases must be written with their spaces removed to survive wrapping.
+SIG='temporarilylimitingrequests|serveristemporarily|apierror.*rate.?limit'
+
+log() { printf '%s %s\n' "$(date '+%F %T')" "$*"; } # -> stdout (ledger-quiet)
+err() { printf '%s %s\n' "$(date '+%F %T')" "$*" >&2; } # -> stderr (real failures)
+now() { date +%s; }
+
+while :; do
+ # Master kill-switch — keep the process alive but idle so launchd doesn't
+ # crash-loop and you can re-enable without a reload.
+ if [ -e "$DISABLE_FLAG" ]; then sleep "$POLL"; continue; fi
+
+ # No tmux binary / no server yet → idle and retry, never exit.
+ if ! command -v tmux >/dev/null 2>&1; then sleep 15; continue; fi
+
+ panes="$(tmux list-panes -a -F '#{pane_id}' 2>/dev/null || true)"
+ if [ -n "$panes" ]; then
+ while IFS= read -r pid; do
+ [ -n "$pid" ] || continue
+ cap="$(tmux capture-pane -p -t "$pid" 2>/dev/null | tail -n "$TAIL" || true)"
+ [ -n "$cap" ] || continue
+ # Normalize: strip ALL whitespace + lowercase, so the terminal's hard-wrap
+ # (which splits the error phrase mid-word) can't defeat the signature.
+ capn="$(printf '%s' "$cap" | tr -d '[:space:]' | tr 'A-Z' 'a-z')"
+ printf '%s' "$capn" | grep -qE "$SIG" || continue
+
+ key="$STATE_DIR/$(printf '%s' "$pid" | tr -dc 'A-Za-z0-9')"
+ lf="$(cat "$key" 2>/dev/null || echo 0)"
+ [ $(( $(now) - lf )) -ge "$COOLDOWN" ] || continue
+
+ # STUCK-PANE confirmation (2026-05-30): a REAL limiter FREEZES the pane;
+ # a session merely QUOTING the phrase (e.g. pasting the error, or a claude
+ # discussing it) keeps producing output. Snapshot now, wait DELAY, snapshot
+ # again — only RESUME if the pane is UNCHANGED (genuinely stuck) AND still
+ # shows the signature. This excludes phrase-quoting ACTIVE sessions
+ # fleet-wide, with no per-pane identification, so the daemon can run for all
+ # panes without self-triggering on sessions that just talk about the error.
+ log "[resume] $pid limiter seen — confirming stuck for ${DELAY}s"
+ sleep "$DELAY"
+ [ -e "$DISABLE_FLAG" ] && continue
+ cap2="$(tmux capture-pane -p -t "$pid" 2>/dev/null | tail -n "$TAIL" || true)"
+ capn2="$(printf '%s' "$cap2" | tr -d '[:space:]' | tr 'A-Z' 'a-z')"
+ if [ "$capn2" != "$capn" ] || ! printf '%s' "$capn2" | grep -qE "$SIG"; then
+ log "[resume] $pid pane changed/cleared in ${DELAY}s — active or recovered, skip (false positive)"
+ continue
+ fi
+ if tmux send-keys -t "$pid" "$MSG" Enter 2>/dev/null; then
+ log "[resume] $pid stuck-confirmed — sent '$MSG'"
+ printf '%s' "$(now)" > "$key"
+ else
+ err "[resume] send-keys failed for $pid"
+ fi
+ done <<EOF
+$panes
+EOF
+ fi
+ sleep "$POLL"
+done
diff --git a/com.steve.claude-auto-resume.plist b/com.steve.claude-auto-resume.plist
new file mode 100644
index 0000000..8085bf3
--- /dev/null
+++ b/com.steve.claude-auto-resume.plist
@@ -0,0 +1,31 @@
+<?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-auto-resume</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/Users/stevestudio2/bin/claude-resume-daemon.sh</string>
+ </array>
+ <key>RunAtLoad</key>
+ <true/>
+ <key>KeepAlive</key>
+ <true/>
+ <key>ThrottleInterval</key>
+ <integer>10</integer>
+ <key>EnvironmentVariables</key>
+ <dict>
+ <key>PATH</key>
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+ <key>HOME</key>
+ <string>/Users/stevestudio2</string>
+ <key>RESUME_TAIL</key>
+ <string>20</string>
+ </dict>
+ <key>StandardOutPath</key>
+ <string>/Users/stevestudio2/Library/Logs/claude-auto-resume.log</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/stevestudio2/Library/Logs/claude-auto-resume.err.log</string>
+</dict>
+</plist>
(oldest)
·
back to Claude Auto Resume
·
chore: macstudio3 migration — reconcile from mac2 + repoint bc47d63 →