Skip to content

Instantly share code, notes, and snippets.

@trkaplan
Forked from nibzard/loop.sh
Created September 29, 2025 07:46
Show Gist options
  • Save trkaplan/f4a618515346f025aa94c2392658f728 to your computer and use it in GitHub Desktop.
Save trkaplan/f4a618515346f025aa94c2392658f728 to your computer and use it in GitHub Desktop.
Autonomous AI Task Processor - Claude Code automation loop script
#!/bin/bash
################################################################################
# AUTONOMOUS AI TASK PROCESSOR
# https://gist.github.com/nibzard/a97ef0a1919328bcbc6a224a5d2cfc78
################################################################################
#
# PURPOSE:
# Runs Claude Code in a fully autonomous loop to process tasks from a todo
# file without human intervention. The AI agent will continuously select,
# implement, and commit tasks until reaching limits or completing all work.
#
# SETUP:
# Copy this script and save it as loop.sh wherever you do your work.
# Make it executable: chmod +x loop.sh
#
# USAGE:
# ./loop.sh [-z] [TODO_FILE]
#
# OPTIONS:
# -z Use claude-zhipu CLI instead of claude
#
# ARGUMENTS:
# TODO_FILE Path to the todo file to process (default: todo.md)
#
# EXAMPLES:
# ./loop.sh # Process todo.md with claude
# ./loop.sh -z # Process todo.md with claude-zhipu
# ./loop.sh refactor-todo.md # Process specific todo file with claude
# ./loop.sh -z refactor-todo.md # Process specific todo file with claude-zhipu
# ./loop.sh ~/projects/tasks.md # Process todo file from any path
#
# ENVIRONMENT VARIABLES:
# MAX_ITERATIONS Maximum number of tasks to process (default: 50)
# VERBOSE Set to 1 to show raw JSON stream debug info (default: 0)
#
# EXAMPLE WITH ENV VARS:
# MAX_ITERATIONS=100 VERBOSE=1 ./loop.sh
#
# FEATURES:
# - Fully autonomous operation with --dangerously-skip-permissions
# - Fresh context for each iteration ensuring clean task execution
# - Real-time progress display with live stream parsing
# - Task status tracking and progress reporting
# - Git commits after each completed task
# - Stream-JSON output parsing with tool usage indicators
# - Cost and performance tracking per iteration
#
# REQUIREMENTS:
# - Claude Code CLI installed and in PATH (or claude-zhipu with -z flag)
# - jq command-line JSON processor for parsing stream output
# - Two subagents configured:
# * task-master: For todo file management (~/.claude/agents/task-master.md)
# * git-master: For git operations (~/.claude/agents/git-master.md)
#
# HOW IT WORKS:
# 1. Validates environment and checks for required tools (claude, jq)
# 2. For each iteration:
# a. Uses task-master subagent to select next priority task
# b. Implements the selected task autonomously
# c. Uses git-master subagent to commit completed work
# d. Updates task status in todo file
# e. Streams real-time progress via JSON parsing with live updates
# 3. Stops when:
# - Maximum iterations reached (default 50)
# - User interrupts with Ctrl+C
#
# SUBAGENTS:
# The script relies on two specialized subagents:
# - task-master: Handles todo file parsing, task prioritization, status updates
# Download: https://gist.github.com/nibzard/d4f97d0cade5b7204afe5ed862e42ae4
# - git-master: Manages git operations, creates meaningful commits
# Download: https://gist.github.com/nibzard/1e5266b86c75418ce836106c607e21de
#
# OUTPUT:
# - Shows iteration progress and timestamps
# - Real-time display of AI messages, tool usage, and status updates
# - Fresh execution status for each iteration
# - Task completion status with timing and cost information
# - Color-coded progress indicators (green=success, red=error, cyan=info)
# - Verbose mode shows raw JSON stream for debugging
#
# EXIT CODES:
# 0 - Normal completion (reached max iterations)
# 1 - Missing required tools (claude or jq)
# 130 - User interruption (Ctrl+C)
#
################################################################################
# Color codes for output
GREEN=$'\033[0;32m'
YELLOW=$'\033[1;33m'
RED=$'\033[0;31m'
BLUE=$'\033[0;34m'
CYAN=$'\033[0;36m'
NC=$'\033[0m' # No Color
# Parse command line arguments
USE_ZHIPU=0
while getopts "z" opt; do
case $opt in
z)
USE_ZHIPU=1
;;
\?)
echo "Invalid option: -$OPTARG" >&2
exit 1
;;
esac
done
shift $((OPTIND-1))
# Counter for iterations
iteration=0
# Maximum iterations before stopping (default 50)
MAX_ITERATIONS=${MAX_ITERATIONS:-50}
# Verbose mode (set to 1 for detailed output)
VERBOSE=${VERBOSE:-0}
# Removed: Session ID tracking (each iteration uses fresh context)
# Reset timestamp for rate limit handling (passed between functions via file)
RESET_TIMESTAMP=""
TIMESTAMP_FILE=""
# PID of current claude process
CLAUDE_PID=""
# Consecutive failure tracking
consecutive_failures=0
consecutive_quick_failures=0
last_failure_was_rate_limit=false
# Todo file to process (can be passed as first argument, defaults to todo.md)
TODO_FILE=${1:-todo.md}
# Determine which CLI to use
if [ $USE_ZHIPU -eq 1 ]; then
CLAUDE_CLI="claude-zhipu"
else
CLAUDE_CLI="claude"
fi
# Function to handle script interruption
cleanup() {
echo -e "\n${YELLOW}Script interrupted. Exiting...${NC}"
# Kill any remaining claude processes started by this script
pkill -f "claude.*--dangerously-skip-permissions.*$TODO_FILE" 2>/dev/null || true
exit 130
}
# Set up trap to catch Ctrl+C and other signals
trap cleanup INT TERM
# Function to calculate wait time until rate limit resets
calculate_wait_time() {
local reset_time="$1"
if [ -z "$reset_time" ]; then
return 0
fi
# Convert reset time to 24-hour format and calculate seconds to wait
local current_hour=$(date +%H)
local current_minute=$(date +%M)
local current_seconds=$((current_hour * 3600 + current_minute * 60))
# Parse reset time (e.g., "2pm" -> 14:00)
local reset_hour
if [[ "$reset_time" =~ ([0-9]+)pm ]]; then
reset_hour=$((${BASH_REMATCH[1]} + 12))
[ "$reset_hour" -eq 24 ] && reset_hour=12 # Handle 12pm
elif [[ "$reset_time" =~ ([0-9]+)am ]]; then
reset_hour=${BASH_REMATCH[1]}
[ "$reset_hour" -eq 12 ] && reset_hour=0 # Handle 12am
else
return 0 # Unable to parse, return 0
fi
local reset_seconds=$((reset_hour * 3600))
local wait_seconds=$((reset_seconds - current_seconds))
# If reset time is in the past, add 24 hours
if [ $wait_seconds -le 0 ]; then
wait_seconds=$((wait_seconds + 86400))
fi
echo $wait_seconds
}
# Function to calculate wait time from zhipu timestamp (Beijing time)
calculate_wait_from_timestamp() {
local reset_timestamp="$1"
if [ -z "$reset_timestamp" ]; then
return 0
fi
# Convert Beijing time (UTC+8) to UTC seconds since epoch
# Input format: "2025-09-24 05:17:01" (Beijing time)
local reset_utc_seconds
if command -v gdate >/dev/null 2>&1; then
# macOS with GNU date installed
reset_utc_seconds=$(gdate -d "$reset_timestamp UTC+8" +%s 2>/dev/null)
else
# Linux date - convert manually by adding 8 hours to Beijing time to get UTC
# Beijing time is UTC+8, so to convert Beijing time to UTC, we need to subtract 8 hours from the Beijing timestamp
# But date -d parses the timestamp as local time, so we need to add the difference between Beijing and local
local local_offset_hours=$(date +%z | sed 's/[+-]0*//' | sed 's/..$//')
local beijing_offset_hours=8
local offset_diff=$(( (beijing_offset_hours - local_offset_hours) * 3600 ))
local beijing_seconds=$(date -d "$reset_timestamp" +%s 2>/dev/null)
if [ $? -eq 0 ]; then
reset_utc_seconds=$((beijing_seconds - offset_diff))
else
return 0 # Unable to parse timestamp
fi
fi
if [ -z "$reset_utc_seconds" ] || [ "$reset_utc_seconds" -eq 0 ]; then
return 0
fi
# Get current time in UTC seconds
local current_utc_seconds=$(date -u +%s)
# Calculate wait time
local wait_seconds=$((reset_utc_seconds - current_utc_seconds))
# Return reasonable wait times (0 to 8 hours)
if [ $wait_seconds -le 0 ]; then
echo 0
elif [ $wait_seconds -gt 28800 ]; then # 8 hours
echo 0
else
echo $wait_seconds
fi
}
# Function to wait with countdown display
wait_with_countdown() {
local total_seconds=$1
local reason="$2"
if [ $total_seconds -le 0 ]; then
return 0
fi
echo -e "${YELLOW}Waiting for rate limit to reset... (Press Ctrl+C to interrupt)${NC}"
if [ -n "$reason" ]; then
echo -e "${YELLOW}Reason: $reason${NC}"
fi
local remaining=$total_seconds
while [ $remaining -gt 0 ]; do
local hours=$((remaining / 3600))
local minutes=$(((remaining % 3600) / 60))
local seconds=$((remaining % 60))
printf "\r${CYAN}Time remaining: %02d:%02d:%02d${NC}" $hours $minutes $seconds
sleep 1
((remaining--))
done
echo -e "\n${GREEN}Rate limit should be reset now. Continuing...${NC}"
}
# Validate required commands exist
if ! command -v $CLAUDE_CLI &> /dev/null; then
echo -e "${RED}Error: '$CLAUDE_CLI' command not found in PATH${NC}"
exit 1
fi
if ! command -v jq &> /dev/null; then
echo -e "${RED}Error: 'jq' command not found in PATH${NC}"
echo -e "${YELLOW}jq is required for parsing Claude's JSON output. Install with:${NC}"
echo " sudo apt-get install jq # Ubuntu/Debian"
echo " brew install jq # macOS"
exit 1
fi
# Function to process Claude's stream-json output in real-time
stream_progress() {
local current_task=""
local tool_count=0
local rate_limited=false
local reset_time=""
while IFS= read -r line; do
# Skip empty lines
[ -z "$line" ] && continue
# Skip processing special timestamp lines (they're handled via file now)
if [[ "$line" =~ ^(ZHIPU_RESET_TIMESTAMP|CLAUDE_RESET_TIME): ]]; then
continue
fi
# Parse JSON line by line
local msg_type=$(echo "$line" | jq -r '.type // empty' 2>/dev/null)
case "$msg_type" in
"system")
local subtype=$(echo "$line" | jq -r '.subtype // empty' 2>/dev/null)
if [ "$subtype" = "init" ]; then
echo -e "${CYAN}→ Starting fresh task execution${NC}"
fi
;;
"assistant")
# Extract assistant message content
local content=$(echo "$line" | jq -r '.message.content[0].text // empty' 2>/dev/null)
if [ -n "$content" ] && [ "$content" != "null" ]; then
# Check for rate limiting messages (regular claude and claude-zhipu formats)
if [[ "$content" =~ "5-hour limit reached" ]] || [[ "$content" =~ "rate limit" ]] || [[ "$content" =~ "usage limit" ]] || [[ "$content" =~ "API Error: 429" ]] || [[ "$content" =~ "\"type\":\"1308\"" ]] || [[ "$content" =~ "Usage limit reached for 5 hour" ]]; then
rate_limited=true
# Handle claude-zhipu API Error 429 format
if [[ "$content" =~ API\ Error:\ 429 ]] || [[ "$content" =~ "\"type\":\"1308\"" ]]; then
# Extract JSON error from the content (after "API Error: 429 ")
local error_json=$(echo "$content" | sed -n 's/.*API Error: 429 \(.*\)/\1/p')
if [ -n "$error_json" ]; then
local error_message=$(echo "$error_json" | jq -r '.error.message // empty' 2>/dev/null)
local error_type=$(echo "$error_json" | jq -r '.error.type // empty' 2>/dev/null)
if [ -n "$error_message" ] && [ "$error_message" != "null" ]; then
echo -e "${RED}⚠ Rate limit detected (claude-zhipu): ${error_message:0:100}${NC}"
# Extract timestamp from zhipu error message (Beijing time)
if [[ "$error_message" =~ reset\ at\ ([0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}:[0-9]{2}) ]]; then
RESET_TIMESTAMP="${BASH_REMATCH[1]}"
fi
elif [ "$error_type" = "1308" ]; then
echo -e "${RED}⚠ Rate limit detected (claude-zhipu): Usage limit reached for 5 hour${NC}"
else
echo -e "${RED}⚠ Rate limit detected (claude-zhipu): API Error 429${NC}"
fi
# Handle case where error is directly in content without "API Error: 429" prefix
elif [[ "$content" =~ "Usage limit reached for 5 hour" ]]; then
echo -e "${RED}⚠ Rate limit detected (claude-zhipu): Usage limit reached for 5 hour${NC}"
# Also try to extract timestamp from direct content
if [[ "$content" =~ reset\ at\ ([0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}:[0-9]{2}) ]]; then
RESET_TIMESTAMP="${BASH_REMATCH[1]}"
fi
else
echo -e "${RED}⚠ Rate limit detected (claude-zhipu): API Error 429${NC}"
fi
else
# Extract reset time if present (e.g., "resets 2pm")
if [[ "$content" =~ resets[[:space:]]+([0-9]+[ap]m) ]]; then
reset_time="${BASH_REMATCH[1]}"
fi
echo -e "${RED}⚠ Rate limit detected: ${content:0:100}${NC}"
fi
else
# Show first 100 chars of assistant response
echo -e "${BLUE}AI:${NC} ${content:0:100}$([ ${#content} -gt 100 ] && echo '...')"
fi
fi
;;
"tool_use")
((tool_count++))
local tool_name=$(echo "$line" | jq -r '.tool_name // empty' 2>/dev/null)
if [ -n "$tool_name" ] && [ "$tool_name" != "null" ]; then
echo -e "${CYAN}→ Tool[$tool_count]: ${tool_name}${NC}"
fi
;;
"tool_result")
local is_error=$(echo "$line" | jq -r '.is_error // false' 2>/dev/null)
if [ "$is_error" = "true" ]; then
echo -e "${RED}✗ Tool failed${NC}"
else
echo -e "${GREEN}✓ Tool completed${NC}"
fi
;;
"result")
local is_error=$(echo "$line" | jq -r '.is_error // false' 2>/dev/null)
local duration=$(echo "$line" | jq -r '.duration_ms // 0' 2>/dev/null)
local cost=$(echo "$line" | jq -r '.total_cost_usd // 0' 2>/dev/null)
if [ "$rate_limited" = "true" ]; then
echo -e "${YELLOW}⚠ Rate limited (${duration}ms, \$${cost})${NC}"
if [ -n "$reset_time" ]; then
echo -e "${YELLOW} Limit resets at: $reset_time${NC}"
fi
# Write timestamp info to file for main script to read
if [ -n "$RESET_TIMESTAMP" ] && [ -n "$TIMESTAMP_FILE" ]; then
echo "ZHIPU_RESET_TIMESTAMP:$RESET_TIMESTAMP" >> "$TIMESTAMP_FILE"
fi
if [ -n "$reset_time" ] && [ -n "$TIMESTAMP_FILE" ]; then
echo "CLAUDE_RESET_TIME:$reset_time" >> "$TIMESTAMP_FILE"
fi
return 2 # Special return code for rate limiting
elif [ "$is_error" = "true" ]; then
echo -e "${RED}✗ Task failed (${duration}ms, \$${cost})${NC}"
return 1
else
echo -e "${GREEN}✓ Task completed (${duration}ms, \$${cost})${NC}"
return 0
fi
;;
esac
# Show verbose output if enabled
if [ "$VERBOSE" -eq 1 ]; then
echo -e "${CYAN}[JSON]${NC} $line"
fi
done
# If we exit the loop, the stream ended naturally (EOF)
echo -e "${YELLOW}→ Stream ended${NC}"
return 0
}
echo -e "${GREEN}Starting $CLAUDE_CLI task processing loop${NC}"
echo "Press Ctrl+C to stop the loop"
echo "Processing file: ${YELLOW}$TODO_FILE${NC}"
echo "Max iterations: $MAX_ITERATIONS | Verbose: $VERBOSE"
echo "----------------------------------------"
while true; do
((iteration++))
# Check if we've reached the maximum iterations
if [ $iteration -gt $MAX_ITERATIONS ]; then
echo -e "${YELLOW}Reached maximum iterations ($MAX_ITERATIONS). Exiting...${NC}"
break
fi
echo -e "\n${YELLOW}[Iteration $iteration/$MAX_ITERATIONS - $(date '+%Y-%m-%d %H:%M:%S')]${NC}"
# Reset rate limit variables for this iteration
reset_time=""
# Set up timestamp file for communication with stream_progress
TIMESTAMP_FILE=$(mktemp)
export TIMESTAMP_FILE
# Show failure tracking status if there are recent failures
if [ $consecutive_failures -gt 0 ]; then
if [ "$last_failure_was_rate_limit" = "true" ]; then
echo -e "${YELLOW}Status: $consecutive_failures consecutive rate limit failures${NC}"
else
echo -e "${YELLOW}Status: $consecutive_failures consecutive failures ($consecutive_quick_failures quick failures)${NC}"
fi
fi
# Build claude command with options
CLAUDE_CMD="$CLAUDE_CLI --dangerously-skip-permissions --verbose --output-format stream-json"
# Each iteration starts with fresh context - no session resumption
# Run the claude command with stream processing and capture output
echo -e "${YELLOW}Starting task execution...${NC}"
# Run the claude command with stream processing (output visible to user)
$CLAUDE_CMD \
-p "Use task-master subagent to review $TODO_FILE and select the next task. Implement the task completely. After implementation, use git-master subagent to commit the changes. Document any blockers encountered." \
--append-system-prompt "You are an autonomous coding agent operating without human supervision. CRITICAL BEHAVIORS: 1) Always use task-master subagent for todo file management and task selection. 2) Always use git-master subagent to commit completed work. 3) Never ask for confirmation - make decisions and execute. 4) If blocked, document the blocker and move to next task. 5) Update task status in todo file immediately when starting/completing. 6) Make reasonable assumptions when facing ambiguity. 7) Prioritize tasks that unblock others. 8) Verify changes work before marking complete. 9) Commit frequently with meaningful messages. 10) Focus on implementation over discussion. Your goal: Complete as many tasks as possible autonomously." \
2>&1 | stream_progress
# Store the exit status from the stream_progress function
exit_status=$?
# Extract reset information from the timestamp file
if [ $exit_status -eq 2 ] && [ -f "$TIMESTAMP_FILE" ]; then
# Look for reset timestamp info in the file
if grep -q "ZHIPU_RESET_TIMESTAMP:" "$TIMESTAMP_FILE"; then
RESET_TIMESTAMP=$(grep "ZHIPU_RESET_TIMESTAMP:" "$TIMESTAMP_FILE" | cut -d: -f2-)
fi
if grep -q "CLAUDE_RESET_TIME:" "$TIMESTAMP_FILE"; then
reset_time=$(grep "CLAUDE_RESET_TIME:" "$TIMESTAMP_FILE" | cut -d: -f2-)
fi
fi
# Clean up temp file
rm -f "$TIMESTAMP_FILE"
if [ $exit_status -eq 0 ]; then
echo -e "${GREEN}Task completed successfully${NC}"
consecutive_failures=0
consecutive_quick_failures=0
last_failure_was_rate_limit=false
elif [ $exit_status -eq 2 ]; then
# Rate limit detected
last_failure_was_rate_limit=true
((consecutive_failures++))
echo -e "${YELLOW}Rate limit reached${NC}"
# Calculate wait time based on CLI type and available reset information
wait_seconds=0
wait_reason=""
if [ $USE_ZHIPU -eq 1 ] && [ -n "$RESET_TIMESTAMP" ]; then
# Use zhipu timestamp (Beijing time) to calculate wait time
wait_seconds=$(calculate_wait_from_timestamp "$RESET_TIMESTAMP")
if [ -z "$wait_seconds" ]; then
wait_seconds=0
fi
wait_reason="Reset at $RESET_TIMESTAMP (Beijing time)"
# Clear the timestamp for next iteration
RESET_TIMESTAMP=""
elif [ $USE_ZHIPU -eq 0 ] && [ -n "$reset_time" ]; then
# Use regular claude reset time (local time)
wait_seconds=$(calculate_wait_time "$reset_time")
if [ -z "$wait_seconds" ]; then
wait_seconds=0
fi
wait_reason="Reset at $reset_time (local time)"
fi
# Decide whether to wait or exit
if [ $wait_seconds -gt 0 ] && [ $wait_seconds -le 28800 ]; then # Wait up to 8 hours
wait_hours=$((wait_seconds / 3600))
wait_minutes=$(((wait_seconds % 3600) / 60))
wait_secs=$((wait_seconds % 60))
echo -e "${YELLOW}Calculated wait time: ${wait_hours}h ${wait_minutes}m ${wait_secs}s${NC}"
wait_with_countdown $wait_seconds "$wait_reason"
# Reset failure counters after successful wait
consecutive_failures=0
consecutive_quick_failures=0
last_failure_was_rate_limit=false
else
# Check if we should exit after multiple failures
if [ $consecutive_failures -ge 3 ]; then
echo -e "${RED}Multiple consecutive rate limit failures detected${NC}"
echo -e "${YELLOW}Consider running the loop again later when the rate limit resets${NC}"
echo -e "${YELLOW}You can restart with: $0 $TODO_FILE${NC}"
break
fi
# Skip to next iteration for rate limits without wait time
echo -e "${YELLOW}No wait time available - skipping to next iteration${NC}"
fi
else
# Regular failure
((consecutive_failures++))
# Check if this was a quick failure (< 5 seconds, $0 cost)
# This pattern often indicates persistent issues like rate limits not caught above
if [ "$exit_status" -eq 1 ]; then
((consecutive_quick_failures++))
else
consecutive_quick_failures=0
fi
echo -e "${RED}Task failed with exit code: $exit_status${NC}"
# If we have multiple quick failures, it might be a persistent issue
if [ $consecutive_quick_failures -ge 3 ]; then
echo -e "${RED}Multiple consecutive quick failures detected${NC}"
echo -e "${YELLOW}This often indicates rate limiting or other persistent issues${NC}"
echo -e "${YELLOW}Consider checking Claude CLI status or waiting before retrying${NC}"
# Add a small delay before continuing
echo -e "${YELLOW}Waiting 30 seconds before next attempt...${NC}"
sleep 30
fi
echo -e "${YELLOW}Continuing despite error${NC}"
last_failure_was_rate_limit=false
fi
# Optional: Add a delay between iterations (uncomment and adjust as needed)
# echo "Waiting 5 seconds before next iteration..."
# sleep 5
echo "----------------------------------------"
done
echo -e "${GREEN}Loop completed. Total iterations: $iteration${NC}"
# Final status summary
if [ $consecutive_failures -gt 0 ]; then
if [ "$last_failure_was_rate_limit" = "true" ]; then
echo -e "${YELLOW}Final status: Stopped due to rate limiting${NC}"
echo -e "${YELLOW}Recommendation: Wait for rate limit to reset and run: $0 $TODO_FILE${NC}"
else
echo -e "${YELLOW}Final status: $consecutive_failures consecutive failures${NC}"
echo -e "${YELLOW}Recommendation: Check error logs and todo file status${NC}"
fi
else
echo -e "${GREEN}Final status: Clean completion${NC}"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment