Created
January 25, 2026 00:21
-
-
Save pliablepixels/bd64ee92404611af7de4822eb48449d3 to your computer and use it in GitHub Desktop.
starter script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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