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