[object Object]

← 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

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 →