← 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