Skip to content

Instantly share code, notes, and snippets.

@pliablepixels
Created January 25, 2026 00:21
Show Gist options
  • Select an option

  • Save pliablepixels/bd64ee92404611af7de4822eb48449d3 to your computer and use it in GitHub Desktop.

Select an option

Save pliablepixels/bd64ee92404611af7de4822eb48449d3 to your computer and use it in GitHub Desktop.
starter script
#!/bin/bash
# claude-runner.sh - Runs Claude CLI with automatic rate-limit handling
# Uses tmux to maintain proper terminal environment
set -euo pipefail
SESSION_NAME="claude-worker"
RESUME_FILE="$HOME/.claude-session-id"
STATE_FILE="$HOME/.claude-runner-state"
CHECK_INTERVAL=30
WAIT_AFTER_LIMIT=3600 # 1 hour, adjust as needed
PARSED_RESET_EPOCH="" # Set by monitor_loop before killing session
# Rate limit patterns to detect (case insensitive)
RATE_PATTERNS="rate.limit|quota.exceeded|limit.reached|try.again.later|usage.limit|out.of.tokens|you've.hit.your.limit"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
cleanup() {
log "Cleaning up..."
tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
exit 0
}
trap cleanup SIGINT SIGTERM
start_claude() {
local claude_args="$*"
local is_resuming=false
# Check for existing session to resume
if [[ -f "$RESUME_FILE" ]]; then
local session_id
session_id=$(cat "$RESUME_FILE")
if [[ -n "$session_id" ]]; then
log "Resuming session: $session_id"
claude_args="--resume $session_id $claude_args"
is_resuming=true
fi
fi
# Kill any existing tmux session
tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
# Start new tmux session with Claude
tmux new-session -d -s "$SESSION_NAME" -x 120 -y 40
tmux send-keys -t "$SESSION_NAME" "claude $claude_args" Enter
log "Started Claude in tmux session '$SESSION_NAME'"
log "Attach with: tmux attach -t $SESSION_NAME"
log "Or run: $0 attach"
# If resuming, wait for claude to initialize then send "continue work" command
if [[ "$is_resuming" == true ]]; then
log "Waiting for Claude to initialize before sending continue command..."
sleep 10 # Give claude time to load the session
tmux send-keys -t "$SESSION_NAME" "continue work" Enter
log "Sent 'continue work' command"
fi
}
get_pane_content() {
# Capture last 500 lines of scrollback
tmux capture-pane -t "$SESSION_NAME" -p -S -500 2>/dev/null || echo ""
}
check_rate_limited() {
local content
content=$(get_pane_content)
echo "$content" | grep -qiE "$RATE_PATTERNS"
}
check_session_alive() {
tmux has-session -t "$SESSION_NAME" 2>/dev/null
}
check_claude_running() {
# Check if claude process is still running inside tmux
local pane_pid
pane_pid=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1)
if [[ -n "$pane_pid" ]]; then
pgrep -P "$pane_pid" -f "claude" >/dev/null 2>&1
else
return 1
fi
}
save_session() {
# First, try to get session ID by sending /status command
log "Requesting session status..."
tmux send-keys -t "$SESSION_NAME" "/status" Enter
sleep 3 # Give time for /status output
local content
content=$(get_pane_content)
# Try to extract session ID - Claude CLI shows it in various formats
# Adjust these patterns based on actual CLI output
local session_id=""
# Try to match "Session ID: <uuid>" format from /status output
session_id=$(echo "$content" | grep -oE "Session ID: [a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}" | tail -1 | grep -oE "[a-f0-9-]{36}" || true)
if [[ -z "$session_id" ]]; then
# Try other patterns - session IDs sometimes shown differently
session_id=$(echo "$content" | grep -oE "session[: ]+[a-f0-9-]{36}" | tail -1 | grep -oE "[a-f0-9-]{36}" || true)
fi
if [[ -z "$session_id" ]]; then
# Last resort - any UUID pattern
session_id=$(echo "$content" | grep -oE "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}" | tail -1 || true)
fi
if [[ -n "$session_id" ]]; then
echo "$session_id" > "$RESUME_FILE"
log "Saved session ID: $session_id"
return 0
else
log "Warning: Could not extract session ID from output"
# Keep existing resume file if we have one
return 1
fi
}
graceful_exit() {
log "Sending exit command to Claude..."
# Try Escape first to exit any mode
tmux send-keys -t "$SESSION_NAME" Escape
sleep 1
# Try /quit command
tmux send-keys -t "$SESSION_NAME" "/quit" Enter
sleep 3
if check_session_alive && check_claude_running; then
# If still alive, try Ctrl-C
log "Trying Ctrl-C..."
tmux send-keys -t "$SESSION_NAME" C-c
sleep 2
fi
if check_session_alive && check_claude_running; then
# Last resort - Ctrl-D
log "Trying Ctrl-D..."
tmux send-keys -t "$SESSION_NAME" C-d
sleep 2
fi
# Kill tmux session
if check_session_alive; then
log "Killing tmux session..."
tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
fi
}
monitor_loop() {
log "Monitoring for rate limits (interval: ${CHECK_INTERVAL}s)..."
while true; do
# Check if tmux session exists
if ! check_session_alive; then
log "Tmux session ended"
return 0
fi
# Check if claude is still running
if ! check_claude_running; then
log "Claude process ended"
return 0
fi
# Check for rate limiting
if check_rate_limited; then
log "Rate limit detected!"
save_session
# Parse reset time BEFORE killing session (pane content will be gone after)
PARSED_RESET_EPOCH=$(parse_reset_time)
graceful_exit
return 1
fi
sleep "$CHECK_INTERVAL"
done
}
parse_reset_time() {
# Parse reset time from rate limit message
# Example: "You've hit your limit · resets Jan 26 at 4pm (America/New_York)"
local content
content=$(get_pane_content)
# Extract reset time pattern: "resets <Month> <Day> at <Time> (<Timezone>)"
local reset_match
reset_match=$(echo "$content" | grep -oiE "resets [A-Za-z]+ [0-9]+ at [0-9]+([:.][0-9]+)?(am|pm)? \([A-Za-z_/]+\)" | tail -1 || true)
if [[ -z "$reset_match" ]]; then
echo ""
return
fi
# Parse components
local month day time ampm timezone
# Extract: resets Jan 26 at 4pm (America/New_York)
month=$(echo "$reset_match" | sed -E 's/resets ([A-Za-z]+) .*/\1/')
day=$(echo "$reset_match" | sed -E 's/resets [A-Za-z]+ ([0-9]+) .*/\1/')
# Extract time with optional am/pm - e.g., "4pm" or "4:30pm" or "16:00"
time=$(echo "$reset_match" | sed -E 's/.*at ([0-9]+([:.][0-9]+)?)(am|pm)?.*/\1/')
# Extract am/pm separately using grep (more reliable than sed for optional groups)
ampm=$(echo "$reset_match" | grep -oiE '(am|pm)' | tail -1 | tr '[:upper:]' '[:lower:]' || echo "")
timezone=$(echo "$reset_match" | sed -E 's/.*\(([^)]+)\)/\1/')
# Handle time format - convert to 24h if needed
local hour minute
if [[ "$time" == *":"* ]] || [[ "$time" == *"."* ]]; then
hour=$(echo "$time" | cut -d':' -f1 | cut -d'.' -f1)
minute=$(echo "$time" | cut -d':' -f2 | cut -d'.' -f2)
else
hour="$time"
minute="00"
fi
# Convert to 24-hour format
hour=$((10#$hour)) # Remove leading zeros
if [[ "$ampm" == "pm" && $hour -lt 12 ]]; then
hour=$((hour + 12))
elif [[ "$ampm" == "am" && $hour -eq 12 ]]; then
hour=0
fi
# Get current year (handle year rollover for dates in the past)
local year
year=$(date +%Y)
# Build date string for parsing
local date_str="${month} ${day} ${year} ${hour}:${minute}:00"
log "Parsed reset time: $date_str (timezone: $timezone)"
# Convert to epoch using the specified timezone
local reset_epoch
if date --version >/dev/null 2>&1; then
# GNU date (Linux)
reset_epoch=$(TZ="$timezone" date -d "$date_str" +%s 2>/dev/null || echo "")
else
# BSD date (macOS) - need to convert month name to number
local month_num
case $(echo "$month" | tr '[:upper:]' '[:lower:]') in
jan*) month_num=01 ;; feb*) month_num=02 ;; mar*) month_num=03 ;;
apr*) month_num=04 ;; may*) month_num=05 ;; jun*) month_num=06 ;;
jul*) month_num=07 ;; aug*) month_num=08 ;; sep*) month_num=09 ;;
oct*) month_num=10 ;; nov*) month_num=11 ;; dec*) month_num=12 ;;
*) month_num="" ;;
esac
if [[ -n "$month_num" ]]; then
# Format: YYYY-MM-DD HH:MM:SS
local formatted_date
formatted_date=$(printf "%04d-%02d-%02d %02d:%02d:00" "$year" "$month_num" "$day" "$hour" "$minute")
reset_epoch=$(TZ="$timezone" date -j -f "%Y-%m-%d %H:%M:%S" "$formatted_date" +%s 2>/dev/null || echo "")
fi
fi
# If reset time is in the past, it's probably next year
local now_epoch
now_epoch=$(date +%s)
if [[ -n "$reset_epoch" && $reset_epoch -lt $now_epoch ]]; then
year=$((year + 1))
date_str="${month} ${day} ${year} ${hour}:${minute}:00"
if date --version >/dev/null 2>&1; then
reset_epoch=$(TZ="$timezone" date -d "$date_str" +%s 2>/dev/null || echo "")
else
local formatted_date
formatted_date=$(printf "%04d-%02d-%02d %02d:%02d:00" "$year" "$month_num" "$day" "$hour" "$minute")
reset_epoch=$(TZ="$timezone" date -j -f "%Y-%m-%d %H:%M:%S" "$formatted_date" +%s 2>/dev/null || echo "")
fi
fi
echo "$reset_epoch"
}
calculate_wait_time() {
# Use pre-parsed reset time (set by monitor_loop before killing session)
local reset_epoch="${PARSED_RESET_EPOCH:-}"
if [[ -n "$reset_epoch" && "$reset_epoch" =~ ^[0-9]+$ ]]; then
local now_epoch wait_seconds
now_epoch=$(date +%s)
wait_seconds=$((reset_epoch - now_epoch))
# Add a small buffer (60 seconds) to ensure we're past the reset
wait_seconds=$((wait_seconds + 60))
if [[ $wait_seconds -gt 0 ]]; then
log "Calculated wait time from reset message: ${wait_seconds}s"
echo "$wait_seconds"
return
fi
fi
# Fallback to configured wait time
log "Could not parse reset time, using default: ${WAIT_AFTER_LIMIT}s"
echo "$WAIT_AFTER_LIMIT"
}
show_help() {
cat <<EOF
Claude Runner - Automatic rate-limit handling for Claude CLI
Usage: $0 [command] [claude args...]
Commands:
(none) Start Claude with monitoring (pass additional claude args)
attach Attach to running Claude tmux session
detach Detach from session (or press Ctrl-B, D)
status Show current status
stop Gracefully stop Claude and exit
help Show this help
Examples:
$0 # Start Claude normally
$0 --prompt "do the task" # Start with a prompt
$0 attach # Attach to watch/interact
$0 status # Check if running
Environment variables:
CHECK_INTERVAL Seconds between rate limit checks (default: 30)
WAIT_AFTER_LIMIT Seconds to wait after rate limit (default: 3600)
Files:
~/.claude-session-id Stored session ID for resume
~/.claude-runner-state Current runner state
EOF
}
cmd_attach() {
if check_session_alive; then
log "Attaching to session (detach with Ctrl-B, D)..."
tmux attach -t "$SESSION_NAME"
else
log "No active Claude session to attach to"
exit 1
fi
}
cmd_status() {
echo "=== Claude Runner Status ==="
if check_session_alive; then
echo "Tmux session: ACTIVE ($SESSION_NAME)"
if check_claude_running; then
echo "Claude process: RUNNING"
else
echo "Claude process: NOT RUNNING (shell may be idle)"
fi
else
echo "Tmux session: NOT ACTIVE"
fi
if [[ -f "$RESUME_FILE" ]]; then
echo "Saved session ID: $(cat "$RESUME_FILE")"
else
echo "Saved session ID: (none)"
fi
if [[ -f "$STATE_FILE" ]]; then
echo "Waiting until: $(cat "$STATE_FILE")"
fi
echo ""
echo "To attach: $0 attach"
}
cmd_stop() {
if check_session_alive; then
save_session
graceful_exit
log "Stopped"
else
log "No active session to stop"
fi
rm -f "$STATE_FILE"
}
main() {
local claude_args="$*"
log "Claude Runner starting"
log "Rate limit patterns: $RATE_PATTERNS"
log "Check interval: ${CHECK_INTERVAL}s"
log "Wait after limit: ${WAIT_AFTER_LIMIT}s"
while true; do
start_claude "$claude_args"
# Give it time to initialize
sleep 5
if monitor_loop; then
log "Session completed normally"
rm -f "$RESUME_FILE"
break
else
local wait_time
wait_time=$(calculate_wait_time)
PARSED_RESET_EPOCH="" # Clear after use
# Calculate resume time (macOS and Linux compatible)
local resume_time
# Ensure wait_time is a valid positive integer
if ! [[ "$wait_time" =~ ^[0-9]+$ ]] || [[ "$wait_time" -le 0 ]]; then
log "Invalid wait time '$wait_time', using default"
wait_time=$WAIT_AFTER_LIMIT
fi
if date --version >/dev/null 2>&1; then
# GNU date (Linux)
resume_time=$(date -d "+${wait_time} seconds" '+%Y-%m-%d %H:%M:%S' 2>/dev/null) || resume_time="unknown"
else
# BSD date (macOS) - note: -v+NNS adds NN seconds
resume_time=$(date -v "+${wait_time}S" '+%Y-%m-%d %H:%M:%S' 2>/dev/null) || resume_time="unknown"
fi
log "Rate limited. Waiting ${wait_time}s (until $resume_time)..."
echo "$resume_time" > "$STATE_FILE"
sleep "$wait_time"
log "Wait complete, resuming..."
fi
done
log "Claude Runner finished"
rm -f "$STATE_FILE"
}
# Parse command
case "${1:-run}" in
attach)
cmd_attach
;;
status)
cmd_status
;;
stop)
cmd_stop
;;
help|--help|-h)
show_help
;;
run)
shift 2>/dev/null || true
main "$@"
;;
*)
# Assume it's claude args
main "$@"
;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment