{"slug": "rate-limits-cost-me-a-whole-night-of-work-here-s-the-46-line-script-that-fixed", "title": "Rate Limits Cost Me a Whole Night of Work — Here's the 46-Line Script That Fixed It", "summary": "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.", "body_md": "Every heavy Claude Code user eventually hits the wall: the 5-hour rate limit block.\n\nI'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.\n\nTo 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.\n\nWhen you come back, Claude greets you from a blank slate: \"What would you like to do?\"\n\nBack 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.\"**\n\n`resume-on-ratelimit.sh`\n\nis 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`\n\n, inheriting the previous session. It retries up to 20 times, which works out to **5 minutes × 20 = 100 minutes of fully unattended retrying**.\n\nYou need to understand why `--continue`\n\nalone isn't enough.\n\n`--continue`\n\nis 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`\n\ncan carry over is a **record of the last exchange** — not the **context of intent**, meaning \"what I was about to do next.\"\n\nEven 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.\n\nThat's exactly why **you need PROGRESS.md**.\n\nPROGRESS.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を読んで中断した作業を続けて。\"`\n\n(\"Read PROGRESS.md and continue the interrupted work.\") — line 27. That's a design that presupposes PROGRESS.md exists.\n\nWhen 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.\n\nWriting code, writing articles, running a scraper — whatever the task, the biggest bottleneck when using Claude Code is \"you being in front of the computer.\"\n\nThat's what `resume-on-ratelimit.sh`\n\nsolves. 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.\n\nCurrent Claude Code lets you skip all permission prompts with the `--dangerously-skip-permissions`\n\nflag. 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).\n\nLet's look at the script's structure from above first, then go through each part of the code.\n\n``` bash\n$ bash resume-on-ratelimit.sh \"PROGRESS.mdを読んで作業を再開して\"\n        │\n        ▼\n   RETRY=0 の判定\n        │\n        ▼ (RETRY=0)\nclaude --dangerously-skip-permissions \\\n       --continue -p \"$TASK\"           ← 初回: 引数のタスク文を使用\n        │\n        ├─ exit 0 ──→ ✅ 完了ログ + macOS通知 → exit 0\n        │\n        └─ exit ≠ 0\n              │\n              ▼\n         RETRY++ (1へ)\n         \"レートリミット検出。5分後にリトライ...\" をログ出力\n         macOS通知: \"レートリミット。5分後に再開します\"\n         sleep 300   (= 5 × 60秒)\n              │\n              ▼ (RETRY=1以降)\nclaude --dangerously-skip-permissions \\\n       --continue \\\n       -p \"PROGRESS.mdを読んで中断した作業を続けて。\"  ← 2回目以降は固定文\n              │\n              ├─ exit 0 ──→ ✅ 完了\n              │\n              └─ exit ≠ 0\n                    │\n                    ▼\n               RETRY < 20 なら再びsleep→リトライ\n               RETRY = 20 なら ❌ 最大リトライ超過 + 通知 → exit 1\n```\n\n**Using different prompts for the first run and for retries** is the core design decision of this script.\n\nThe first run uses `$TASK`\n\n(the instruction passed as an argument). For example, you can hand it a concrete task like `\"ECサイトのスクレイパーを完成させて\"`\n\n(\"Finish the e-commerce site scraper\"). From the second attempt onward, the argument is ignored and the prompt is fixed to `\"PROGRESS.mdを読んで中断した作業を続けて。\"`\n\n(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.\n\n``` bash\n#!/usr/bin/env bash\n# レートリミットで止まったら自動で再開するラッパー\n# 使い方: bash resume-on-ratelimit.sh [追加の指示]\n#         bash resume-on-ratelimit.sh \"PROGRESS.mdを読んで作業を再開して\"\n\nset -euo pipefail\n\nWAIT_MINUTES=${WAIT_MINUTES:-5}\nMAX_RETRIES=${MAX_RETRIES:-20}\nTASK=\"${1:-PROGRESS.mdを読んで中断した作業を続けて。作業済みなら何もしない。}\"\nRETRY=0\n\nnotify() {\n  # macOS通知\n  osascript -e \"display notification \\\"$1\\\" with title \\\"Claude Code\\\"\" 2>/dev/null || true\n}\n\necho \"[$(date '+%H:%M')] 起動: $TASK\"\n\nwhile [ $RETRY -lt $MAX_RETRIES ]; do\n  if [ $RETRY -eq 0 ]; then\n    # 初回は --continue でセッションを引き継ぐ\n    claude --dangerously-skip-permissions --continue -p \"$TASK\"\n    EXIT=$?\n  else\n    echo \"[$(date '+%H:%M')] リトライ $RETRY / $MAX_RETRIES\"\n    claude --dangerously-skip-permissions --continue -p \"PROGRESS.mdを読んで中断した作業を続けて。\"\n    EXIT=$?\n  fi\n\n  if [ $EXIT -eq 0 ]; then\n    echo \"[$(date '+%H:%M')] 完了\"\n    notify \"Claude Code: 作業完了\"\n    exit 0\n  fi\n\n  RETRY=$((RETRY + 1))\n  echo \"[$(date '+%H:%M')] レートリミット検出 (exit: $EXIT)。${WAIT_MINUTES}分後にリトライ...\"\n  notify \"Claude Code: レートリミット。${WAIT_MINUTES}分後に再開します\"\n  sleep $((WAIT_MINUTES * 60))\ndone\n\necho \"最大リトライ回数に達しました\"\nnotify \"Claude Code: 最大リトライ超過。手動確認してください\"\nexit 1\n```\n\n46 lines. Zero dependencies. No installation. Copy it, `chmod +x`\n\n, done.\n\n`set -euo pipefail`\n\n(line 8)\n\n`-e`\n\nexits immediately on command failure, `-u`\n\ntreats references to undefined variables as errors, and `-o pipefail`\n\npropagates 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.\n\n`WAIT_MINUTES=${WAIT_MINUTES:-5}`\n\n(line 8)\n\nOverridable via environment variable. `WAIT_MINUTES=10 bash resume-on-ratelimit.sh`\n\nswitches 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.\n\n`MAX_RETRIES=${MAX_RETRIES:-20}`\n\n(line 9)\n\nDefault 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`\n\nand notifies the human.\n\n**The default value of TASK=\"${1:-...}\" (line 10)**\n\nWhen run without arguments, the default is `\"PROGRESS.mdを読んで中断した作業を続けて。作業済みなら何もしない。\"`\n\n(\"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.\n\n**The notify() function (lines 13–16)**\n\nA native macOS notification via `osascript`\n\n. It swallows errors with `2>/dev/null || true`\n\nso 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.\n\n**Exit code check (lines 31–34)**\n\n```\nif [ $EXIT -eq 0 ]; then\n  echo \"[$(date '+%H:%M')] 完了\"\n  notify \"Claude Code: 作業完了\"\n  exit 0\nfi\n```\n\nClaude Code returns `exit 0`\n\non 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.\n\n**Timestamped loop logging (line 26)**\n\n```\necho \"[$(date '+%H:%M')] リトライ $RETRY / $MAX_RETRIES\"\n```\n\nThis is what pays off when you check the next morning. Looking at the terminal log leaves you a timeline like `[02:05] リトライ 1 / 20`\n\n→ `[02:10] リトライ 2 / 20`\n\n→ `[02:15] 完了`\n\n, 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.\n\n```\nbash resume-on-ratelimit.sh \"スクレイパーを完成させて\" >> ~/logs/claude-session.log 2>&1\n```\n\nOnce 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.\n\nFrom 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**.\n\n```\nRETRY=0\n\nwhile [ $RETRY -lt $MAX_RETRIES ]; do\n  if [ $RETRY -eq 0 ]; then\n    claude --dangerously-skip-permissions --continue -p \"$TASK\"\n    EXIT=$?\n  else\n    echo \"[$(date '+%H:%M')] リトライ $RETRY / $MAX_RETRIES\"\n    claude --dangerously-skip-permissions --continue -p \"PROGRESS.mdを読んで中断した作業を続けて。\"\n    EXIT=$?\n  fi\n\n  if [ $EXIT -eq 0 ]; then\n    echo \"[$(date '+%H:%M')] 完了\"\n    notify \"Claude Code: 作業完了\"\n    exit 0\n  fi\n\n  RETRY=$((RETRY + 1))\n  ...\ndone\n```\n\nStarting from `RETRY=0`\n\nand judging the exit condition with `$RETRY -lt $MAX_RETRIES`\n\n(less than) is intentional design.\n\nIf you made it `$RETRY -le $MAX_RETRIES`\n\n(less than or equal), you'd actually get `MAX_RETRIES + 1`\n\nattempts. With `MAX_RETRIES=20`\n\n, 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`\n\nestablishes the intuitive correspondence ** MAX_RETRIES=20 → exactly 20 attempts**.\n\nThere's another important point. The increment equivalent to `RETRY++`\n\nis only executed **after the success check**.\n\n```\n試行1(RETRY=0) → 失敗 → RETRY=1, sleep\n試行2(RETRY=1) → 失敗 → RETRY=2, sleep\n...\n試行20(RETRY=19) → 失敗 → RETRY=20, sleep → ループ条件 20<20 が偽 → 脱出 → exit 1\n```\n\nEven if attempt 2 succeeds, `RETRY`\n\nstays at 2 and it does `exit 0`\n\n. `RETRY`\n\nis 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.\n\n`EXIT=$?`\n\nIs Captured Immediately\n\n```\nclaude --dangerously-skip-permissions --continue -p \"$TASK\"\nEXIT=$?\n```\n\n`$?`\n\nis the shell's special variable holding \"the exit code of the most recently executed command.\" **It gets overwritten the instant the next command runs.**\n\nWrite it like this and it breaks:\n\n```\n# NG: echo が $? を上書きする\nclaude --dangerously-skip-permissions --continue -p \"$TASK\"\necho \"claudeが終わりました\"  # この echo が $? を 0 にする\nif [ $? -eq 0 ]; then ...  # 常に 0 になってしまう\n```\n\nThe rule that `EXIT=$?`\n\ngoes on the very next line after the command is Bash basics, but under `set -euo pipefail`\n\nthere's an additional consideration. With `set -e`\n\nin effect, the behavior \"exit immediately if a command returns non-zero\" kicks in. In this script, rather than `EXIT=$?`\n\nexecuting right after `claude`\n\nreturns non-zero, the command is executed inside the evaluation context of the `while`\n\nloop, which suppresses `set -e`\n\n's instant-death trigger.\n\nThis 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`\n\nto explicitly disable `e`\n\ntemporarily. My own script prioritizes simplicity and stays as is.\n\nBy 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**.\n\nHere's the minimal format I arrived at:\n\n```\n# PROGRESS\n\n## 現在地\nスクレイパーのページネーション処理を実装中。\n`~/dev/scraper/scraper.py` の `fetch_page()` 関数、101行目まで書いた。\n次のステップ: `next_page_url` の抽出ロジックを追加する。\n\n## 完了済み\n- [x] 認証トークンの取得 (`auth.py`)\n- [x] 1ページ目の商品一覧取得\n- [x] 商品データのCSV書き出し\n\n## 次のアクション（最重要）\n1. `fetch_page()` に `next_page_url` 抽出を追加\n2. ループで全ページ取得\n3. 重複URLの除外\n\n## 注意事項\n- APIレートリミットは1秒1リクエスト。`time.sleep(1)` 必須\n- 認証トークンは `~/.env` の `API_TOKEN`\n```\n\nThe 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.\n\nThe 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.\n\n`WAIT_MINUTES`\n\nand `MAX_RETRIES`\n\ncan be overridden by environment variables (lines 8–9). This design pays off **when calling from launchd (macOS's scheduled-execution daemon)**.\n\nWhen calling the script from a launchd plist, arguments go in the `ProgramArguments`\n\narray, but environment variables go in the `EnvironmentVariables`\n\nsection.\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>WAIT_MINUTES</key>\n  <string>10</string>\n  <key>MAX_RETRIES</key>\n  <string>12</string>\n  <key>HOME</key>\n  <string>/Users/（あなたのユーザー名）</string>\n</dict>\n```\n\nYou can change the wait to 10 minutes and max retries to 12 without touching the script itself. \"For long overnight tasks, `WAIT_MINUTES=10`\n\nputs less load on the API; for short tasks, `WAIT_MINUTES=3`\n\nretries quickly\" — that split is one config line away.\n\nSome things you only learn by running it. Here I record the moments when \"it should work in theory\" broke, in the order of symptom → cause → fix.\n\n**Symptom**: Blocked at 2 AM, auto-resumed, but when I woke up it had stopped after writing \"please tell me what to start with.\"\n\n**Cause**: I hadn't created PROGRESS.md for that task. The retry prompt is `\"PROGRESS.mdを読んで中断した作業を続けて。\"`\n\n, but when the file doesn't exist, Claude asks the human back: \"I can't find the file. What work should I continue?\" `--dangerously-skip-permissions`\n\nis a flag that skips permission dialogs, but it can't prevent a stop when Claude internally decides \"I lack the information to make the next judgment.\"\n\n**Fix**: I made it a rule to always prepare PROGRESS.md before launching any task. Adding `[ -f PROGRESS.md ] || echo \"PROGRESS.mdがありません\" && exit 1`\n\nbefore the script launch is also effective, but I judged the habit of \"write PROGRESS.md first, then start the script\" to be more reliable, and that's still what I do.\n\n**Symptom**: It works when run manually from the terminal, but running it via a launchd timer fails at the first line with `command not found: claude`\n\nand exits with code 127.\n\n**Cause**: The shell launchd starts doesn't read the user's `.zshrc`\n\nor `.bash_profile`\n\n, so `PATH`\n\nis bare. Claude Code was installed via nvm, so the binary only exists at `~/.nvm/versions/node/v24.13.0/bin/`\n\n. That location isn't in launchd's PATH, hence \"what's claude?\"\n\n**Fix**: Explicitly write PATH in the plist's `EnvironmentVariables`\n\n.\n\n```\n<key>PATH</key>\n<string>/Users/（あなたのユーザー名）/.nvm/versions/node/v24.13.0/bin:/usr/local/bin:/usr/bin:/bin</string>\n```\n\nAlternatively, you can add `export PATH=\"$HOME/.nvm/versions/node/v24.13.0/bin:$PATH\"`\n\nat the top of the script. Writing it on the script side removes the launchd dependency, so it's more portable. However, the path changes when you bump the Node.js version, so you have to update it each time. Which one you choose is a tradeoff, but I use the launchd-side plist approach, because it's self-contained in plist changes and keeps management in one place.\n\n**Symptom**: Launching the script produced an error like `claude: invalid option -- 't'`\n\nand it died instantly.\n\n**Cause**: When I copy-pasted the task string for the argument, tab characters used for indentation slipped in. In the shell, tabs can be treated as word-splitting delimiters during argument expansion, so `$TASK`\n\nin `-p \"$TASK\"`\n\nwas being split on tabs and passed to claude as multiple arguments.\n\n**Fix**: Always wrap the task string in double quotes as `\"$TASK\"`\n\nwhen passing it (already done inside the script), and don't include tabs in strings passed as arguments. When passing from the command line, rather than using `$'...'`\n\nnotation or heredocs, a simple one-line string with no newlines or tabs is more reliable.\n\nBy adopting the practice of writing complex instructions in PROGRESS.md and keeping the script argument to a simple string like `\"PROGRESS.mdを読んで作業を再開して\"`\n\n, this problem essentially stopped happening. The principle of **keep complex information in files, keep arguments simple** also matches the overall design of the script.\n\n**Symptom**: I woke up, the terminal window was closed, and I had absolutely no idea whether the work completed, whether it kept retrying, or how many hours it ran.\n\n**Cause**: I hadn't saved the script's output to a file, and I hadn't specified launchd's StandardOutPath. Terminal session logs disappear when you close the window (depending on terminal settings).\n\n**Fix**:\n\nWhen launching from the terminal, add a redirect.\n\n```\nbash ~/scripts/resume-on-ratelimit.sh \"スクレイパーを完成させて\" \\\n  >> ~/logs/claude-$(date '+%Y%m%d').log 2>&1\n```\n\nWhen launching from launchd, add this to the plist.\n\n```\n<key>StandardOutPath</key>\n<string>/Users/（あなたのユーザー名）/logs/claude-resume.log</string>\n<key>StandardErrorPath</key>\n<string>/Users/（あなたのユーザー名）/logs/claude-resume-error.log</string>\n```\n\nEver since logs started being kept, my first action in the morning became \"check the log.\"\n\n```\n[02:03] 起動: スクレイパーを完成させて\n[02:47] レートリミット検出 (exit: 1)。5分後にリトライ...\n[02:52] リトライ 1 / 20\n[03:37] レートリミット検出 (exit: 1)。5分後にリトライ...\n[03:42] リトライ 2 / 20\n[04:21] 完了\n```\n\nFrom this log you can read off the fact that \"it was blocked twice, around 2 AM and in the 3 o'clock hour, and completed on the third attempt in the 4 o'clock hour.\" As logs accumulate, a pattern like \"my Claude usage tends to get blocked between 2 and 4 AM\" also becomes visible. Next time you can make a data-based improvement: start at 1 AM to give yourself more headroom.\n\n`--continue`\n\ndidn't carry over \"the previous session\"\n**Symptom**: The resumed Claude was supposedly using the `--continue`\n\nflag, yet it started with a fresh greeting: \"Hello. What can I help you with?\"\n\n**Cause**: Claude Code's `--continue`\n\ncarries over \"the last session.\" But \"the last session\" is **tied to the current directory**. If the current directory differed between when it got blocked and when it resumed (e.g., launchd's WorkingDirectory differing from the directory of a manual run), `--continue`\n\nreferences a different session (or a nonexistent one) and starts as a new conversation.\n\n**Fix**: Always specify `WorkingDirectory`\n\nin the launchd plist.\n\n```\n<key>WorkingDirectory</key>\n<string>/Users/（あなたのユーザー名）/dev/（プロジェクト名）</string>\n```\n\nFor manual runs, I made it a rule to `cd`\n\ninto the project directory before launching the script. And what keeps this problem from being catastrophic is **the existence of PROGRESS.md**. Even if `--continue`\n\nfails and it becomes a new session, as long as PROGRESS.md exists, the prompt `\"PROGRESS.mdを読んで中断した作業を続けて\"`\n\nlets Claude acquire the correct context. `--continue`\n\nis ultimately \"an aid for faster recovery\"; the real workhorse for context restoration is PROGRESS.md. Once I understood this, I stopped panicking even in situations where `--continue`\n\ndoesn't work.\n\nThe previous chapter covered five sticking points. Here I'll line up the rest of the landmines in one go. If you spot an item you've already done, deal with it on the spot.\n\n**Closing the terminal window wiped out every process**\n\nWhen you quit the terminal app, `SIGHUP`\n\ngoes out to all of its child processes. `resume-on-ratelimit.sh`\n\ndies the instant it receives that. This is the situation where you launch at 2 AM, crawl into bed, and wake up to \"nothing changed.\" There are two solutions; pick one. Creating a tmux session with `tmux new -s claude`\n\nand launching the script inside it is easier to manage. The next morning, `tmux attach -t claude`\n\nputs you right back into the continuing log. If tmux isn't installed in your environment, launching in the background with `nohup bash ~/scripts/resume-on-ratelimit.sh \"タスク\" >> ~/logs/claude-$(date '+%Y%m%d').log 2>&1 &`\n\ngets you an equivalent effect.\n\n**The Mac went to sleep and the sleep command stopped**\n\nmacOS enters system sleep once idle time exceeds a threshold. When that happens, the count of a running `sleep 300`\n\n(5 minutes × 60 seconds) freezes. Because the count resumes after waking, I experienced \"retry in 5 minutes\" turning into \"retry in 2 hours.\" The countermeasure is to prefix it: `caffeinate -i bash ~/scripts/resume-on-ratelimit.sh \"タスク\"`\n\n. `caffeinate -i`\n\nsuppresses system sleep only while the command is running, so it releases automatically when the script ends. When launching from launchd it's unnecessary, since launchd itself is designed to coexist with sleep.\n\n**Launching multiple projects at once crossed the wires on --continue**\n\n`claude --continue`\n\ncarries over \"the most recently launched session.\" If you run project A and project B concurrently in separate tabs, \"the last session\" gets overwritten the instant one of the Claudes exits. If the other one then launches a retry right after, you get the worst-case scenario: it inherits a session from a different project. I actually experienced a session that was in the middle of scraper development picking up landing-page-production context and starting to output HTML. When running several in parallel, either prepare a dedicated plist per project in launchd and separate the WorkingDirectory, or design it to run serially. If you absolutely must launch concurrently, handle it by cramming the full task into the `TASK`\n\nargument and eliminating the dependency on PROGRESS.md.**It logged \"完了\" with exit 0, but the work was only half done**\n\n`exit 0 = complete`\n\n(the check on line 31). But `exit 0`\n\nisn't only what Claude returns \"when it finished the task.\" It's also `exit 0`\n\nwhen it cuts off the conversation with \"the instructions are ambiguous and I can't make a judgment, so I'm ending here\" or \"there are items requiring confirmation. I'm waiting for your response.\" I had a situation where the log said `[03:14] 完了`\n\nat 3:14 AM, and when I woke up, four items remained in PROGRESS.md's \"next action.\" The countermeasure is two-layered. ① Append `\"完了したらPROGRESS.mdの最終行にCOMPLETED: [完了日時]と書いてください\"`\n\n(\"When done, write COMPLETED: [completion timestamp] on the last line of PROGRESS.md\") to the end of the TASK argument. ② For the morning check, don't rely only on the `[HH:MM] 完了`\n\nlog — verify the `COMPLETED:`\n\nrecord with `tail -1 PROGRESS.md`\n\n. These two prevent \"exit 0 false positives\" for practical purposes.**PROGRESS.md bloated and squeezed the context**\n\nRun the same project for a week and the \"completed\" section reaches several hundred lines. Claude reads this file in full on every resume, so the bigger the file gets, the more context is consumed and the more the accuracy of subsequent work drops. In practice, I feel a slight drop in post-resume work accuracy once `wc -l PROGRESS.md`\n\ngoes past 400 lines. Resolve it by resetting weekly with `mv PROGRESS.md PROGRESS_archive_$(date '+%Y%m%d').md`\n\nand rewriting a new PROGRESS.md with only the currently in-progress portion. You almost never re-read the archives, so just saving and leaving them is enough.\n\n**macOS notifications never arrived once**\n\n`osascript -e \"display notification...\"`\n\ngoes through the macOS Notification Center. They won't arrive if the terminal app's notification permission is off, or if Focus mode is enabled. Line 15 of the script swallows errors with `2>/dev/null || true`\n\n, so the script body doesn't stop even if a notification fails. If you rely on notifications operationally, go to System Settings → Notifications → and turn the terminal app's notification permission \"on.\" Whether the \"レートリミット。5分後に再開します\" notification arrives lets you confirm in real time that the script is working as intended.\n\n**A single quote slipped into the TASK argument and caused a shell error**\n\nIf a copy-pasted task string contains a Japanese-style '（single quote）' or an English `'`\n\n, the shell's argument interpretation breaks. Even when the argument is wrapped in double quotes, like `bash resume-on-ratelimit.sh \"ユーザーの'入力'を\"`\n\n, single quotes inside double quotes can cause problems depending on the shell. The safest design is to **keep arguments short and simple, and write all complex instructions in PROGRESS.md**. Keep the argument to symbol-free Japanese like `\"PROGRESS.mdを読んで作業を再開して\"`\n\n, and avoid writing code or commands into the argument. The default value on line 10 of the script follows this design too.\n\n**I didn't check the current directory before launching**\n\n`--continue`\n\ncarries over the session tied to the current directory (detailed in the previous chapter). On top of that, since PROGRESS.md is referenced by a path relative to the current directory, launching while you forgot to `cd`\n\nproduces \"PROGRESS.md not found.\" Running `~/scripts/resume-on-ratelimit.sh`\n\nwhile not in `~/dev/プロジェクト/`\n\nis a recipe for accidents. Adopt either the habit of checking `pwd`\n\nbefore launching the script, or adding `cd ~/dev/プロジェクト名 || exit 1`\n\nat the top of the script.\n\nThese are the rules that stuck after 3+ months of production use. You don't need to adopt all of them at once — starting with \"just #1 and #3\" and adding the rest in order as you hit problems is the realistic approach.\n\n**1. Always verify PROGRESS.md exists before launching**\n\nThis is the base. Using this script on a task with no PROGRESS.md just means Claude stops on the second attempt onward (the fixed prompt on line 27). Putting your launch command in the following form prevents launching without the file.\n\n```\n[ -f PROGRESS.md ] && bash ~/scripts/resume-on-ratelimit.sh \"$1\" || echo \"PROGRESS.mdがありません\"\n```\n\nAlternatively, just fixing the habit of \"write PROGRESS.md immediately before launching the script\" is sufficient.\n\n**2. Write PROGRESS.md's \"next action\" down to file path, function name, and line number**\n\nNot \"continue the scraper\" but \"add `next_page_url`\n\nextraction logic at line 101 of the `fetch_page()`\n\nfunction in `~/dev/scraper/scraper.py`\n\n.\" Claude reads this file right after resuming and decides its first action. Given a file path it opens that file; given a function name it looks for it. The more information per line, the closer the hesitation time before the first action gets to zero.\n\n**3. Save logs to date-stamped files and always check them the next morning**\n\n```\nbash ~/scripts/resume-on-ratelimit.sh \"タスク\" \\\n  >> ~/logs/claude-$(date '+%Y%m%d').log 2>&1\n```\n\nThis leaves a timeline like `[02:47] レートリミット検出 (exit: 1)。5分後にリトライ...`\n\n→ `[02:52] リトライ 1 / 20`\n\n→ `[04:21] 完了`\n\n. As logs pile up, patterns emerge — \"blocks tend to happen between 2 and 4 AM,\" \"this type of task takes 3 hours\" — and you can apply them to your next launch plan. Improvement without data is guesswork.\n\n**4. When using launchd, state PATH, HOME, and WorkingDirectory explicitly in the plist**\n\nThe shell launchd starts doesn't read `.zshrc`\n\n. Missing just these three triggers `command not found`\n\nand \"the session isn't carried over\" problems simultaneously. Include nvm's bin directory in PATH, and set HOME to the user's home directory. Writing the project's absolute path in WorkingDirectory makes `--continue`\n\n's session binding work correctly too.\n\n**5. Protect long terminal runs with tmux**\n\n```\ntmux new -s claude\n# セッション内で起動\nbash ~/scripts/resume-on-ratelimit.sh \"タスク\" >> ~/logs/claude-$(date '+%Y%m%d').log 2>&1\n# Ctrl+b d でデタッチして就寝\n# 翌朝\ntmux attach -t claude\n```\n\nEven if you close the terminal window, the tmux session persists as long as the server is alive. Re-attach in the morning and the real-time tail of the log is right there.\n\n**6. Combine with caffeinate -i to prevent Mac sleep**\n\nMandatory if you're running overnight by any method other than launchd.\n\n```\ncaffeinate -i bash ~/scripts/resume-on-ratelimit.sh \"タスク\" \\\n  >> ~/logs/claude-$(date '+%Y%m%d').log 2>&1\n```\n\nWhen the script exits, `caffeinate`\n\nexits automatically too, so you never have the accident of \"leaving sleep prevention switched on.\"\n\n**7. Use 5–10 minutes for WAIT_MINUTES; never go below 2**\n\nA rate limit is a block against \"API usage within a given time window.\" During the block period, it keeps failing no matter how many times you retry. Even if you retry rapidly at 2-minute intervals, resumption doesn't come any sooner — you just accumulate failure logs. For long overnight tasks, specifying `WAIT_MINUTES=10 bash ~/scripts/resume-on-ratelimit.sh \"タスク\"`\n\nflattens the peaks of API usage and reduces block frequency the next day. For short daytime tasks, the 5-minute default is plenty.\n\n**8. Make it a convention to have the completion sign written into PROGRESS.md**\n\nAppend `\"完了したらPROGRESS.mdの最終行にCOMPLETED: [完了日時]と記録してください\"`\n\nto the end of the TASK argument. Then the morning check is one command: `tail -1 PROGRESS.md`\n\n. If you use `exit 0`\n\nalone as evidence of completion, you'll be slow to notice when Claude cut the conversation short. Cross-checking PROGRESS.md's record against the log's `[HH:MM] 完了`\n\nis the shortest verification route.\n\n**9. Manage parallel projects with launchd + dedicated plists**\n\nLaunching multiple tabs manually at the same time causes `--continue`\n\ncrosstalk. If you prepare a launchd plist per project and `launchctl load`\n\nthem, the WorkingDirectory is independent, so no crosstalk. Standardizing the plist naming convention as `com.自分の名前.プロジェクト名.plist`\n\nlets you list managed tasks with `launchctl list | grep 自分の名前`\n\n.\n\n**10. Archive PROGRESS.md weekly to keep it thin**\n\nArchive a week's accumulation with `mv PROGRESS.md PROGRESS_archive_$(date '+%Y%m%d').md`\n\nand rewrite a new `PROGRESS.md`\n\nwith only the in-progress portion. Aim for under 50 lines. Past 400 lines, post-resume work accuracy noticeably drops. You almost never re-read the archives, so just dropping them into `~/dev/プロジェクト名/archive/`\n\nis enough.\n\n**11. Set MAX_RETRIES and WAIT_MINUTES by working backward from your completion deadline**\n\nIf you have a constraint like \"I want the task done by 6 AM,\" do this calculation in advance.\n\n`MAX_RETRIES=5`\n\n`WAIT_MINUTES=5`\n\n(25 minutes of automatic recovery grace in total)The default 20 × 5 minutes = 100 minutes is generous headroom designed for long tasks, but for a short task it means \"wait 100 minutes, then give up.\" Build the habit of adjusting the numbers to the nature of the task.\n\n**12. Auto-delete logs after 30 days**\n\nClean up old logs once a month with cron.\n\n```\n# crontab -e で追加\n0 3 1 * * find ~/logs/claude-*.log -mtime +30 -delete\n```\n\nThirty days of accumulated logs adds up to tens of MB to a few GB. You're essentially never going to look back at logs older than 30 days for improvement purposes, so auto-deletion is fine. Add the single line above with `crontab -e`\n\nand it runs automatically at 3 AM on the 1st of every month.\n\n`resume-on-ratelimit.sh`\n\nis 46 lines. Two environment variables, one function, one while loop. Zero dependencies — copy it and `chmod +x`\n\nand it runs.\n\nEven so, having this script versus not having it fundamentally changes the shape of your work. Because the premise that \"nothing progresses unless you're in front of the computer\" collapses.\n\nThe first thing I decided in the month I got laid off and went to zero was: \"don't increase the amount I move, increase the amount the system moves.\" Prepare PROGRESS.md, launch the script, sleep. Check the log the next morning, write the next task. The share of the ¥1.2M/month that comes from Claude Code-related automation revenue is still growing, and that's not because I had special skills — it's the result of stacking up \"small but reliably working systems\" one at a time.\n\nThis script is one of the entrances to that. Run it first, look at the logs, fix where you got stuck. The value is in that repetition.\n\nI've put the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure into a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/rate-limits-cost-me-a-whole-night-of-work-here-s-the-46-line-script-that-fixed", "canonical_source": "https://dev.to/bokuwalily/rate-limits-cost-me-a-whole-night-of-work-heres-the-46-line-script-that-fixed-it-7o3", "published_at": "2026-08-23 05:00:06+00:00", "updated_at": "2026-08-23 05:13:09.764307+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-agents", "artificial-intelligence"], "entities": ["Lily", "Claude Code"], "alternates": {"html": "https://wpnews.pro/news/rate-limits-cost-me-a-whole-night-of-work-here-s-the-46-line-script-that-fixed", "markdown": "https://wpnews.pro/news/rate-limits-cost-me-a-whole-night-of-work-here-s-the-46-line-script-that-fixed.md", "text": "https://wpnews.pro/news/rate-limits-cost-me-a-whole-night-of-work-here-s-the-46-line-script-that-fixed.txt", "jsonld": "https://wpnews.pro/news/rate-limits-cost-me-a-whole-night-of-work-here-s-the-46-line-script-that-fixed.jsonld"}}