# How I Auto-Resume a Rate-Limited Claude Code Session with PROGRESS.md and Retries

> Source: <https://dev.to/bokuwalily/how-i-auto-resume-a-rate-limited-claude-code-session-with-progressmd-and-retries-5cl5>
> Published: 2026-07-17 05:00:05+00:00

My previous post, [Making costs visible by monitoring output tokens](https://zenn.dev/bokuwalily/articles/output-token-sentinel), 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.

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 pattern I settled on: **use PROGRESS.md as a "handoff ticket" and carry the work forward with --continue retries.**

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 参照。
```

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.

Here is the complete script quoted from the real file (`~/.claude/scripts/resume-on-ratelimit.sh`

).

``` 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() {
  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
```

The whole design fits in a 20-line loop.

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.

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 "重いタスク"
```

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.

`notify()`

fires a macOS system notification via `osascript`

.

```
notify() {
  osascript -e "display notification \"$1\" with title \"Claude Code\"" 2>/dev/null || true
}
```

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.

`autopilot.sh`

is launched by launchd (`com.lily.autopilot.plist`

) three times a day: at **2:00, 5:00, and 23:00**.

```
<key>StartCalendarInterval</key>
<array>
  <dict><key>Hour</key><integer>2</integer><key>Minute</key><integer>0</integer></dict>
  <dict><key>Hour</key><integer>5</integer><key>Minute</key><integer>0</integer></dict>
  <dict><key>Hour</key><integer>23</integer><key>Minute</key><integer>0</integer></dict>
</array>
```

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."

`--continue`

starts a new session`--continue`

together with it.`set -euo pipefail`

with `|| true`

`EXIT=$?`

, the exit code gets overwritten. If you modify the script, be careful about `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`

.`--continue`

, a loop you can build in 20 lines`osascript`

`|| true`

, so a failed notification doesn't kill the process under `set -e`

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](https://zenn.dev/bokuwalily/articles/context-slimming).

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
