Lily Posted on Jul 17 • Originally published at dev.to My previous post, Making costs visible by monitoring output tokens, was about stopping overuse. This time it's the opposite problem: how to keep waking up a session that has stalled — the story of resume-on-ratelimit.sh. I got burned more than once by handing a long task to Claude Code, stepping away from my desk, and coming back to find it had halted on a rate limit. Restarting by hand is tedious, and when the restart is delayed, it forgets the earlier context and starts over. This article is a record of how I solved that problem in 20 lines of shell script. The problem: long tasks stall halfway on rate limits When you run a headless task with claude -p, the process exits non-zero the moment it hits your plan's 5h block or 7d block. Two things go wrong here. The record of "how far the task got" disappears — a plain re-run starts over from the beginning You don't notice — if you're not watching the terminal, you don't even know it stopped The pattern I settled on: use PROGRESS.md as a "handoff ticket" and carry the work forward with --continue retries. Using PROGRESS.md as a handoff ticket When I hand a task to Claude, I include an instruction in the first prompt: "update PROGRESS.md at each work boundary." Claude naturally follows this, continuously writing completed steps, remaining steps, and notes into the file. # PROGRESS.md(Claude が自動更新) ## 完了 - [x] 依存パッケージの調査 - [x] ディレクトリ構造の設計 ## 進行中 - [ ] 各モジュールのスケルトン生成 ## 次に実行すること モジュールAのテストを先に書く。設計メモは ./design.md 参照。 Enter fullscreen mode Exit fullscreen mode With this in place, the restart prompt can be a single line: "Read PROGRESS.md and continue the interrupted work". Claude reads the file and resumes from what's left. The full resume-on-ratelimit.sh Here is the complete script quoted from the real file (~/.claude/scripts/resume-on-ratelimit.sh). #!/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() { 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 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 Enter fullscreen mode Exit fullscreen mode The whole design fits in a 20-line loop. The exit code is the only signal for detecting a rate limit The claude command returns exit 0 on a clean finish and non-zero otherwise. As it stands, any exit code other than 0 on a rate limit can be used for the check. I do not strictly separate out "this is the rate-limit-specific code." The script's premise is a deliberate simplification: non-zero = a state that should wait and retry. It has the side effect of also retrying tasks that are completely failing, but capping the count with MAX_RETRIES prevents an infinite loop. --dangerously-skip-permissions is essential for unattended operation. Without it, a confirmation prompt appears on every tool use and blocks. That said, when you use this flag, narrow allowedTools in the prompt or scope it strictly to trusted, task-specific work. The MAX_RETRIES guard With the defaults MAX_RETRIES=20 and WAIT_MINUTES=5, it waits for at most 100 minutes (20 × 5 min). A 7d block resets a week later, so in practice these parameters cover "rate limits within a few hours." They can be overridden via environment variables, so you can tune them to the situation. # 10分ごとに最大5回だけ待つ例 WAIT_MINUTES=10 MAX_RETRIES=5 bash resume-on-ratelimit.sh "重いタスク" Enter fullscreen mode Exit fullscreen mode When MAX_RETRIES is reached, it exits with exit 1 and fires a notification. A parent script or launchd can catch this exit 1 and wire it up to an alert. Combining with macOS notifications notify() fires a macOS system notification via osascript. notify() { osascript -e "display notification \"$1\" with title \"Claude Code\"" 2>/dev/null || true } Enter fullscreen mode Exit fullscreen mode The || true is there so that even under the script's set -e, a failed notification (no notification permission, etc.) doesn't kill the process. Notifications fire at three moments: when a rate limit is detected, when a retry succeeds, and when MAX_RETRIES is exceeded. Even when you're away from your desk, the Dock bounce lets you notice. Integration with autopilot.sh: three launches via launchd autopilot.sh is launched by launchd (com.lily.autopilot.plist) three times a day: at 2:00, 5:00, and 23:00. StartCalendarInterval Hour2Minute0 Hour5Minute0 Hour23Minute0 Enter fullscreen mode Exit fullscreen mode 2 AM and 11 PM are the "set a long task running" hours; 5 AM is meant to "make progress before I wake up." What covers the gaps outside these launch windows is resume-on-ratelimit.sh, which I use as a wrapper when I manually run a long task. autopilot.sh is designed to stop autonomously after one Phase (up to 40 turns), so it doesn't need a retry loop, but resume-on-ratelimit.sh is for the case where the user wants to "keep it running until this process finishes." Pitfalls I hit Re-running without --continue starts a new session → the previous context is gone, and even reading PROGRESS.md, it has no memory of "which files it was touching." Always use --continue together with it. If you forget to instruct it to update PROGRESS.md, the handoff ticket comes up empty → the restart prompt whiffs on "do nothing if already done" and just ends. Always put "update PROGRESS.md at each boundary" in the first prompt. A non-zero exit isn't always a rate limit → if it dies on a syntax error or misconfiguration, it will keep retrying. Once you exceed MAX_RETRIES, manual verification is needed. Watch out for combining set -euo pipefail with || true → if another command sneaks in before you capture EXIT=$?, the exit code gets overwritten. If you modify the script, be careful about when you capture the variable. launchd jobs are skipped while macOS is asleep → resume-on-ratelimit.sh, being a shell sleep loop, resumes after waking even if it was asleep. If you need the autopilot side, configure wake on schedule with pmset. Summary Use PROGRESS.md as a handoff ticket: just instruct Claude to "update it at each boundary," and the restart prompt is a single line Exit-code checks are enough for rate-limit detection: non-zero → wait → resume with --continue, a loop you can build in 20 lines A MAX_RETRIES guard is essential: set an upper bound so a fully stuck task isn't retried forever macOS notifications are a one-liner with osascript: don't forget || true, so a failed notification doesn't kill the process under set -e Its role differs from the launchd autopilot: autopilot is a scheduled, autonomous improvement loop; resume-on-ratelimit is an attended-job wrapper for "just until this task finishes" Next time I'll write about a problem that ironically emerged because this automation foundation grew — the story of how skills and agents multiplied until context injection hit 228 KB, and the audit that trimmed it to 48 KB. Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code. Follow along: Portfolio · X · GitHub*
How I Auto-Resume a Rate-Limited Claude Code Session with PROGRESS.md and Retries
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.