← back to Claude Auto Resume
claude-resume-daemon.sh
85 lines
#!/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