Rate Limits Cost Me a Whole Night of Work — Here's the 46-Line Script That Fixed It Lily, a developer who rebuilt her income to ¥1.2M/month after a layoff, created a 46-line shell script called resume-on-ratelimit.sh to automatically resume Claude Code sessions after rate limit blocks. The script monitors the Claude Code process, waits through rate limits, and retries with claude --continue, preserving context via a PROGRESS.md file. Lily emphasizes that systems, not skills, scale, and her script enables unattended work through rate limits. Every heavy Claude Code user eventually hits the wall: the 5-hour rate limit block. I'm Lily. I lost my income overnight to a company-initiated layoff, rebuilt my setup starting that same month, and got back to ¥1.2M/month in revenue within six months. To most users, a rate limit block looks like a simple "pause." Grab a coffee, wait it out. The reality is far more destructive. Your Claude Code session dies during the block, and thousands of tokens' worth of accumulated working context get wiped . Which files you read and how far, why you picked that architecture, what the next step was — all of it evaporates. When you come back, Claude greets you from a blank slate: "What would you like to do?" Back in university, I grew a ¥100K/month income into ¥600K/month by stacking multiple gigs, and one thing became clear along the way: systems scale, not skills . Skills only grow in proportion to the hours you personally work; systems keep running while you sleep. The same mindset applies to Claude Code: the right answer isn't "I manage the limit," it's "the environment gets past the limit." resume-on-ratelimit.sh is that idea in script form. It watches the exit code of the Claude Code process, and when it detects an abnormal exit caused by a rate limit, it waits a configured number of minutes and then auto-resumes with claude --continue , inheriting the previous session. It retries up to 20 times, which works out to 5 minutes × 20 = 100 minutes of fully unattended retrying . You need to understand why --continue alone isn't enough. --continue is a flag that means "carry over the immediately preceding session." If the session is alive, conversation history is restored. But when the session has been fully severed during a 5-hour block, what --continue can carry over is a record of the last exchange — not the context of intent , meaning "what I was about to do next." Even after resuming the conversation, Claude won't say "I'm in the middle of this task, so next I'll do X." Because the only one who knew that was "the Claude inside the working session," and that instance is already gone. That's exactly why you need PROGRESS.md . PROGRESS.md is a file where you continuously write task progress in human-readable text. If you commit to updating "where I am," "what's done," and "next action" at every step, the Claude that resumes can grasp "what I was doing" instantly just by reading that file. The script's retry prompt is hardcoded to "PROGRESS.mdを読んで中断した作業を続けて。" "Read PROGRESS.md and continue the interrupted work." — line 27. That's a design that presupposes PROGRESS.md exists. When you actually run this setup, the work is finished when you wake up, without a human doing anything during the block . Check the time Claude stopped and you'll sometimes find a log showing it hit the rate limit around 2 AM, retried three times in 5-minute increments at 2:05 and 2:10, resumed on the third attempt, and kept running straight through to 6 AM. Stack up enough experiences like that and the idea of "removing the ceiling on how much you can work" really sinks in. Writing code, writing articles, running a scraper — whatever the task, the biggest bottleneck when using Claude Code is "you being in front of the computer." That's what resume-on-ratelimit.sh solves. Even when you're not at the screen, even when the rate limit hits, the process keeps going on its own . The reason automation revenue makes up a growing share of that ¥1.2M/month is that I stacked up these "runs without human hands" systems one at a time. Current Claude Code lets you skip all permission prompts with the --dangerously-skip-permissions flag. Without this flag, on resume Claude asks "may I write to this file?" and the process stalls with nobody there to answer. This flag is mandatory for unattended continuation used on both line 23 and line 27 . Let's look at the script's structure from above first, then go through each part of the code. bash $ bash resume-on-ratelimit.sh "PROGRESS.mdを読んで作業を再開して" │ ▼ RETRY=0 の判定 │ ▼ RETRY=0 claude --dangerously-skip-permissions \ --continue -p "$TASK" ← 初回: 引数のタスク文を使用 │ ├─ exit 0 ──→ ✅ 完了ログ + macOS通知 → exit 0 │ └─ exit ≠ 0 │ ▼ RETRY++ 1へ "レートリミット検出。5分後にリトライ..." をログ出力 macOS通知: "レートリミット。5分後に再開します" sleep 300 = 5 × 60秒 │ ▼ RETRY=1以降 claude --dangerously-skip-permissions \ --continue \ -p "PROGRESS.mdを読んで中断した作業を続けて。" ← 2回目以降は固定文 │ ├─ exit 0 ──→ ✅ 完了 │ └─ exit ≠ 0 │ ▼ RETRY < 20 なら再びsleep→リトライ RETRY = 20 なら ❌ 最大リトライ超過 + 通知 → exit 1 Using different prompts for the first run and for retries is the core design decision of this script. The first run uses $TASK the instruction passed as an argument . For example, you can hand it a concrete task like "ECサイトのスクレイパーを完成させて" "Finish the e-commerce site scraper" . From the second attempt onward, the argument is ignored and the prompt is fixed to "PROGRESS.mdを読んで中断した作業を続けて。" line 27 . Why fix it? Because the Claude that resumes is already partway through the task . Sending the initial instruction "finish the e-commerce site scraper" again risks Claude "trying to start over from scratch." Wording that makes it read PROGRESS.md and do "the continuation" preserves continuity of intent. bash /usr/bin/env bash レートリミットで止まったら自動で再開するラッパー 使い方: bash resume-on-ratelimit.sh 追加の指示 bash resume-on-ratelimit.sh "PROGRESS.mdを読んで作業を再開して" set -euo pipefail WAIT MINUTES=${WAIT MINUTES:-5} MAX RETRIES=${MAX RETRIES:-20} TASK="${1:-PROGRESS.mdを読んで中断した作業を続けて。作業済みなら何もしない。}" RETRY=0 notify { macOS通知 osascript -e "display notification \"$1\" with title \"Claude Code\"" 2 /dev/null || true } echo " $ date '+%H:%M' 起動: $TASK" while $RETRY -lt $MAX RETRIES ; do if $RETRY -eq 0 ; then 初回は --continue でセッションを引き継ぐ claude --dangerously-skip-permissions --continue -p "$TASK" EXIT=$? else echo " $ date '+%H:%M' リトライ $RETRY / $MAX RETRIES" claude --dangerously-skip-permissions --continue -p "PROGRESS.mdを読んで中断した作業を続けて。" EXIT=$? fi if $EXIT -eq 0 ; then echo " $ date '+%H:%M' 完了" notify "Claude Code: 作業完了" exit 0 fi RETRY=$ RETRY + 1 echo " $ date '+%H:%M' レートリミット検出 exit: $EXIT 。${WAIT MINUTES}分後にリトライ..." notify "Claude Code: レートリミット。${WAIT MINUTES}分後に再開します" sleep $ WAIT MINUTES 60 done echo "最大リトライ回数に達しました" notify "Claude Code: 最大リトライ超過。手動確認してください" exit 1 46 lines. Zero dependencies. No installation. Copy it, chmod +x , done. set -euo pipefail line 8 -e exits immediately on command failure, -u treats references to undefined variables as errors, and -o pipefail propagates errors from the middle of a pipe. Remove these three and you get the "an error happened but it kept going anyway" problem. Rate limit detection is done via exit codes, so a broken exit-code regime causes false detections. WAIT MINUTES=${WAIT MINUTES:-5} line 8 Overridable via environment variable. WAIT MINUTES=10 bash resume-on-ratelimit.sh switches it to a 10-minute wait. It's a design that lets you change behavior without editing the script, which is handy when calling it from launchd or cron. MAX RETRIES=${MAX RETRIES:-20} line 9 Default 20 attempts × 5 minutes = up to 100 minutes of automatic recovery attempts. If it hasn't come back after 100 minutes, it's likely a genuine error or a different problem network outage, full disk, etc. , so at that point it returns exit 1 and notifies the human. The default value of TASK="${1:-...}" line 10 When run without arguments, the default is "PROGRESS.mdを読んで中断した作業を続けて。作業済みなら何もしない。" "Read PROGRESS.md and continue the interrupted work. If it's already done, do nothing." . That trailing "if it's already done, do nothing" matters — it prevents duplicate work if you re-run while PROGRESS.md is already in a completed state. The notify function lines 13–16 A native macOS notification via osascript . It swallows errors with 2 /dev/null || true so the whole script doesn't die on Linux or in environments where notifications are disabled. This is the design judgment that notifications are an optional feature in this script, and a notification failure must not stop the main processing. Exit code check lines 31–34 if $EXIT -eq 0 ; then echo " $ date '+%H:%M' 完了" notify "Claude Code: 作業完了" exit 0 fi Claude Code returns exit 0 on normal completion and non-zero on rate limits or abnormal termination. This script treats "non-zero = rate limit" and retries without question. Strictly speaking, other errors auth failure, file I/O errors can also return non-zero. But in realistic operation, when you run long sessions overnight, the errors you run into are overwhelmingly rate limits, so this simplification causes no practical problems. Timestamped loop logging line 26 echo " $ date '+%H:%M' リトライ $RETRY / $MAX RETRIES" This is what pays off when you check the next morning. Looking at the terminal log leaves you a timeline like 02:05 リトライ 1 / 20 → 02:10 リトライ 2 / 20 → 02:15 完了 , so you can see at a glance "what time it hit the block and what time it recovered." Redirect the log to a file to save it and you can analyze it later. bash resume-on-ratelimit.sh "スクレイパーを完成させて" ~/logs/claude-session.log 2 &1 Once these logs pile up, your own Claude usage patterns become visible which hours you're most likely to hit blocks, which tasks turn into long sessions . Once you can see the data, you can improve it. From here I dig into the deeper design question of "why write it this way." Line-by-line explanation was covered in the previous chapter, so here I'll narrow in on structural intent and the conventions for writing PROGRESS.md . RETRY=0 while $RETRY -lt $MAX RETRIES ; do if $RETRY -eq 0 ; then claude --dangerously-skip-permissions --continue -p "$TASK" EXIT=$? else echo " $ date '+%H:%M' リトライ $RETRY / $MAX RETRIES" claude --dangerously-skip-permissions --continue -p "PROGRESS.mdを読んで中断した作業を続けて。" EXIT=$? fi if $EXIT -eq 0 ; then echo " $ date '+%H:%M' 完了" notify "Claude Code: 作業完了" exit 0 fi RETRY=$ RETRY + 1 ... done Starting from RETRY=0 and judging the exit condition with $RETRY -lt $MAX RETRIES less than is intentional design. If you made it $RETRY -le $MAX RETRIES less than or equal , you'd actually get MAX RETRIES + 1 attempts. With MAX RETRIES=20 , that's 21. When the number and the actual behavior diverge, checking "how many times did it retry" in the logs becomes annoying. Writing it with -lt establishes the intuitive correspondence MAX RETRIES=20 → exactly 20 attempts . There's another important point. The increment equivalent to RETRY++ is only executed after the success check . 試行1 RETRY=0 → 失敗 → RETRY=1, sleep 試行2 RETRY=1 → 失敗 → RETRY=2, sleep ... 試行20 RETRY=19 → 失敗 → RETRY=20, sleep → ループ条件 20<20 が偽 → 脱出 → exit 1 Even if attempt 2 succeeds, RETRY stays at 2 and it does exit 0 . RETRY is a label for "which attempt are we on," and it isn't used for judging success or failure. This simple separation of roles makes reading the logs during debugging easier. EXIT=$? Is Captured Immediately claude --dangerously-skip-permissions --continue -p "$TASK" EXIT=$? $? is the shell's special variable holding "the exit code of the most recently executed command." It gets overwritten the instant the next command runs. Write it like this and it breaks: NG: echo が $? を上書きする claude --dangerously-skip-permissions --continue -p "$TASK" echo "claudeが終わりました" この echo が $? を 0 にする if $? -eq 0 ; then ... 常に 0 になってしまう The rule that EXIT=$? goes on the very next line after the command is Bash basics, but under set -euo pipefail there's an additional consideration. With set -e in effect, the behavior "exit immediately if a command returns non-zero" kicks in. In this script, rather than EXIT=$? executing right after claude returns non-zero, the command is executed inside the evaluation context of the while loop, which suppresses set -e 's instant-death trigger. This is less an intentional design than a byproduct of bash behavior , but as a result it works safely. If you wanted to design it intentionally, you could write set +e; claude ...; EXIT=$?; set -e to explicitly disable e temporarily. My own script prioritizes simplicity and stays as is. By the script's design, the Claude on a retry always goes to read PROGRESS.md because the line 27 prompt is a fixed string . Which means if PROGRESS.md isn't written, or its contents are vague, the Claude that resumes is left at a loss . Here's the minimal format I arrived at: PROGRESS 現在地 スクレイパーのページネーション処理を実装中。 ~/dev/scraper/scraper.py の fetch page 関数、101行目まで書いた。 次のステップ: next page url の抽出ロジックを追加する。 完了済み - x 認証トークンの取得 auth.py - x 1ページ目の商品一覧取得 - x 商品データのCSV書き出し 次のアクション(最重要) 1. fetch page に next page url 抽出を追加 2. ループで全ページ取得 3. 重複URLの除外 注意事項 - APIレートリミットは1秒1リクエスト。 time.sleep 1 必須 - 認証トークンは ~/.env の API TOKEN The key is writing the "next action" section in the most concrete terms . "Continue the implementation" isn't enough — unless you write down "what to do at which line of which file," the resumed Claude hesitates at the very first step. Include the file path and function name and Claude will go open that file first, which is smooth. The timing for updating PROGRESS.md is "every time you complete one thing." If you try to batch two or three updates together, the update can lag behind at the moment you get blocked and leave a stale state behind. Overwriting frequently is the safe approach. WAIT MINUTES and MAX RETRIES can be overridden by environment variables lines 8–9 . This design pays off when calling from launchd macOS's scheduled-execution daemon . When calling the script from a launchd plist, arguments go in the ProgramArguments array, but environment variables go in the EnvironmentVariables section.