⏸ レートリミットを踏んでも作業が止まらない自動再開ループの作り方 — リーダー×

⏸ レートリミットを踏んでも作業が止まらない自動再開ループの作り方

#automation#claudecode#副業2026-07-29 · 約30

会社都合解雇で収入ゼロになったその月から環境を作り直し、半年で月商120万まで戻したLilyです。

なぜこの仕組みが効くのか

Claude Codeを使い込んでいると、必ず「あの壁」にぶつかります。5時間のレートリミットブロックです。

普通のユーザーにとってこれは単なる「一時停止」に見えます。コーヒーでも飲んで待てばいい、と。でも実態はもっと壊滅的です。Claude Codeのセッションはブロック中に自然消滅し、それまで積み上げた数千トークン分の作業コンテキストが丸ごとリセットされます。どのファイルをどこまで読んだか、なぜそのアーキテクチャを選んだか、次のステップは何か——すべて揮発します。

再開したとき、Claudeはまっさらな状態で「何をしますか?」と聞いてきます。

大学生時代に月10万の収入を掛け持ちで月60万まで伸ばした過程でわかったことがあります。スケールするのは「スキル」より「仕組み」です。スキルは自分が働く時間に比例してしか伸びませんが、仕組みは自分が寝ている間も動きます。Claude Codeに対しても同じ発想で向き合うべきで、「自分がリミットを管理する」のではなく「環境がリミットを乗り越える」ように作るのが正解です。

resume-on-ratelimit.sh はその発想を実装したスクリプトです。Claude Codeのプロセスの終了コードを見張り、レートリミットによる異常終了を検知したら指定分数だけ待機し、claude --continue で前のセッションを引き継いで自動再開します。最大20回リトライするので、計算上は 5分 × 20回 = 100分間、完全無人で再試行し続けます

「コンテキストが消える」問題の構造

なぜ --continue だけでは不十分かを理解しておく必要があります。

--continue は「直前のセッションを引き継ぐ」フラグです。セッションが生きていれば会話履歴が復元されます。しかし5時間ブロックの間にセッションが完全に切れた場合、--continue で引き継げる履歴は直前のやりとりの記録であって、「次に何をするつもりだったか」という意図のコンテキストではありません。

Claudeは会話を再開しても「このタスクの途中なので次は○○をする」とは言いません。なぜなら、それを知っているのは「作業中のセッション内のClaude」であり、そのインスタンスはすでに消えているからです。

だからこそ PROGRESS.md が要ります

PROGRESS.mdはタスク進捗を人間が読めるテキストで書き続けるファイルです。「現在地」「完了済み」「次のアクション」を毎ステップ更新する約束にしておけば、再開後のClaudeがそのファイルを読むだけで「自分が何をしていたか」を即座に把握できます。スクリプトのリトライ時のプロンプトは "PROGRESS.mdを読んで中断した作業を続けて。" に固定されています(27行目)。これはPROGRESS.mdが存在することを前提とした設計です。

実際にこの構成で動かすと、ブロック中に人間が何もしなくても朝起きたら作業が終わっています。Claudeが止まった時刻を確認すると、深夜2時台にレートリミットに当たり、2時5分・2時10分と5分刻みで3回リトライして3回目で再開、そのまま朝6時まで動き続けたログが残っていることがあります。こういう体験を積み重ねると「自分が働く量の上限を取り払う」という感覚が腑に落ちてきます。

「環境を作る」と何が変わるか

コードを書くとき、記事を書くとき、スクレイパーを回すとき——どの作業でも、Claude Codeを使う場合の最大のボトルネックは「自分がPCの前にいること」です。

resume-on-ratelimit.sh が解決するのはそこです。自分が画面の前にいなくても、レートリミットが来ても、プロセスが自律的に回り続ける。月商120万の内訳のうち自動化収益が占める割合が増えてきたのは、こういった「人手なしで動く仕組み」を一つひとつ積んだ結果です。

現在のClaude Codeは --dangerously-skip-permissions フラグで権限プロンプトをすべてスキップできます。このフラグを使わないと、再開時にClaudeが「このファイルを書き込んでいいですか?」と聞いてきて、誰も返答しない状態でプロセスが止まります。無人継続のためにはこのフラグが必須です(23行目、27行目のいずれでも使用)。


全体の流れ

スクリプトの構造を先に俯瞰してから、コードの各部を見ていきます。

フロー図

$ 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

初回とリトライで異なるプロンプトを使うのがこのスクリプトの核心的な設計です。

初回は $TASK(引数で渡した指示)を使います。例えば "ECサイトのスクレイパーを完成させて" という具体的なタスクを渡せます。一方、2回目以降は引数を無視して "PROGRESS.mdを読んで中断した作業を続けて。" に固定されます(27行目)。なぜ固定にするかというと、再開後のClaudeはすでにタスクの途中にいるからです。「ECサイトのスクレイパーを完成させて」という初期指示を再度送ると、Claudeが「一から始めようとする」リスクがあります。PROGRESS.mdを読ませて「続き」をやらせる文言のほうが、意図の連続性が保たれます。

スクリプトの実コード全文

#!/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行です。依存パッケージゼロ。インストール不要。コピーして chmod +x するだけで動きます。

各部の意図を読む

set -euo pipefail(8行目)

-e はコマンド失敗で即終了、-u は未定義変数の参照をエラー扱い、-o pipefail はパイプ途中のエラーを伝播させます。この3つを外すと「なんかエラーが出たけど続いちゃった」問題が起きます。レートリミット検出は終了コードで行うので、コード体系が壊れていると誤検知します。

WAIT_MINUTES=${WAIT_MINUTES:-5}(8行目)

環境変数で上書きできます。WAIT_MINUTES=10 bash resume-on-ratelimit.sh とすれば10分待機に変わります。スクリプトを書き換えずに挙動を変えられる設計で、launchdやcronから呼ぶ際に便利です。

MAX_RETRIES=${MAX_RETRIES:-20}(9行目)

デフォルト20回 × 5分 = 最大100分間の自動復旧を試みます。100分でも戻らない場合は真のエラーか、別の問題(ネット断絶、ディスク満杯など)である可能性が高いので、その時点で exit 1 を返して人間に通知します。

TASK="${1:-...}" のデフォルト値(10行目)

引数なしで実行したときのデフォルトが "PROGRESS.mdを読んで中断した作業を続けて。作業済みなら何もしない。" です。末尾の「作業済みなら何もしない」が重要で、PROGRESS.mdがすでに完了状態になっているのに再実行しても二重作業にならないようにしています。

notify() 関数(13-16行目)

osascript を使ったmacOSのネイティブ通知です。2>/dev/null || true でエラーを握りつぶしているのは、Linux環境や通知が無効な環境でスクリプト全体が落ちないようにするためです。このスクリプトは通知がオプション機能であり、通知の失敗で本処理を止めてはいけない、という設計判断です。

終了コード判定(31-34行目)

if [ $EXIT -eq 0 ]; then
  echo "[$(date '+%H:%M')] 完了"
  notify "Claude Code: 作業完了"
  exit 0
fi

Claude Codeは正常終了時に exit 0、レートリミットや異常終了時に非ゼロを返します。このスクリプトは「非ゼロ = レートリミット」として扱い、問答無用でリトライします。厳密には他のエラー(認証失敗、ファイルI/Oエラー)でも非ゼロが返ることがあります。ただし現実的な運用では、深夜に長時間回す場合に出くわすエラーはレートリミットが圧倒的多数なので、この単純化は実用上問題ありません。

ループのタイムスタンプログ(26行目)

echo "[$(date '+%H:%M')] リトライ $RETRY / $MAX_RETRIES"

これが翌朝の確認で役立ちます。ターミナルログを見ると [02:05] リトライ 1 / 20[02:10] リトライ 2 / 20[02:15] 完了 のような時系列が残り、「何時にブロックに当たって何時に回復したか」が一目でわかります。ログはリダイレクトして保存しておくと後で分析できます。

bash resume-on-ratelimit.sh "スクレイパーを完成させて" >> ~/logs/claude-session.log 2>&1

このログが積み上がると、自分のClaude使用パターン(何時台にブロックに当たりやすいか、どのタスクで長時間セッションになるか)が見えてきます。データが見えると改善できます。

実装の詳細

ここからは「なぜそう書くのか」という設計の奥を掘ります。コード1行1行の説明は前章で終わったので、ここでは構造の意図PROGRESS.mdの書き方の約束事に絞ります。

ループカウンタが「0始まり」である理由

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

RETRY=0 から始めて $RETRY -lt $MAX_RETRIES(未満)で終了条件を判定しているのは意図的な設計です。

仮に $RETRY -le $MAX_RETRIES(以下)にしてしまうと、実際には MAX_RETRIES + 1 回の試行が発生します。MAX_RETRIES=20 なら21回です。数字と実動作がずれると「何回リトライしたか」のログ確認が面倒になります。-lt で書くことで、MAX_RETRIES=20 → 厳密に20回の試行という直感的な対応が成立します。

もうひとつ重要な点があります。RETRY++ に相当する加算は成功チェックの後にしか実行されません。

試行1(RETRY=0) → 失敗 → RETRY=1, sleep
試行2(RETRY=1) → 失敗 → RETRY=2, sleep
...
試行20(RETRY=19) → 失敗 → RETRY=20, sleep → ループ条件 20<20 が偽 → 脱出 → exit 1

もし試行2で成功したとしても RETRY は2のまま exit 0 します。RETRY は「今何回目か」のラベルであって、成功・失敗の判断には使っていません。このシンプルな役割分担が、デバッグ時のログ読み取りを楽にします。

EXIT=$? をなぜ即座に取るのか

claude --dangerously-skip-permissions --continue -p "$TASK"
EXIT=$?

$? はシェルの「直前に実行したコマンドの終了コード」を保持する特殊変数です。次のコマンドが実行された瞬間に上書きされます

仮にこう書くと壊れます。

# NG: echo が $? を上書きする
claude --dangerously-skip-permissions --continue -p "$TASK"
echo "claudeが終わりました"  # この echo が $? を 0 にする
if [ $? -eq 0 ]; then ...  # 常に 0 になってしまう

EXIT=$? はコマンドの直後の1行目に書くというルールは、Bashスクリプトの基本ですが、set -euo pipefail 環境では追加の注意が必要です。set -e が有効な状態では「コマンドが非ゼロを返したら即終了」という挙動が働きます。このスクリプトでは claude が非ゼロを返した直後に EXIT=$? が実行されるのではなく、while ループの評価文脈の中でコマンドが実行されるため、set -e の即死トリガーが抑制されます。

これは意図した設計というよりbashの挙動の産物ですが、結果として安全に機能しています。意図的に設計するなら set +e; claude ...; EXIT=$?; set -e と明示的にeを一時解除する書き方もあります。自分のスクリプトはシンプルさを優先して現状のままにしています。

PROGRESS.mdの書き方

スクリプトの設計上、リトライ時のClaudeは必ずPROGRESS.mdを読みに行きます(27行目のプロンプトが固定文だからです)。ということは、PROGRESS.mdが書かれていない、または内容が曖昧なと、再開後のClaudeは途方に暮れます

私が行き着いた最小フォーマットはこれです。

# 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`

「次のアクション」セクションを一番具体的に書くのがポイントです。「実装を続ける」では不十分で、「どのファイルの何行目に何をする」まで書いておかないと、再開後のClaudeが最初のステップで迷います。ファイルパスと関数名を入れておくと、Claudeはそのファイルをまず開きに行くのでスムーズです。

PROGRESS.mdを更新するタイミングは「何かひとつ完了するたび」です。一度に2つ3つまとめて更新しようとすると、ブロックされたタイミングで更新が遅れて古い状態が残ることがあります。こまめに上書きしておくのが安全です。

環境変数で挙動を変えるパターン

WAIT_MINUTESMAX_RETRIES は環境変数で上書きできます(8〜9行目)。この設計が活きるのは launchd(macOSの定時実行デーモン)から呼ぶときです。

launchdのplistからスクリプトを呼ぶ場合、引数は ProgramArguments 配列で渡しますが、環境変数は EnvironmentVariables セクションで渡します。

<key>EnvironmentVariables</key>
<dict>
  <key>WAIT_MINUTES</key>
  <string>10</string>
  <key>MAX_RETRIES</key>
  <string>12</string>
  <key>HOME</key>
  <string>/Users/(あなたのユーザー名)</string>
</dict>

スクリプト本体を変えずに待機時間を10分・最大リトライを12回に変更できます。深夜の長期タスクは WAIT_MINUTES=10 のほうがAPI負荷が低く、短時間タスクは WAIT_MINUTES=3 で素早く再試行、という使い分けが設定一行でできます。


私が詰まった話

動かして初めてわかることがあります。「理屈ではそのはず」が壊れる瞬間を、症状→原因→直し方の順で記録します。

1. PROGRESS.mdを書いていなかった → 再開後のClaudeが白紙から始めた

症状: 深夜2時にブロック、自動再開されたが、朝起きると「何から始めればよいか教えてください」と書いて止まっていた。

原因: そのタスクではPROGRESS.mdを作っていませんでした。リトライ時のプロンプトは "PROGRESS.mdを読んで中断した作業を続けて。" ですが、ファイルが存在しない場合、Claudeは「ファイルが見つかりません。どのような作業を続ければよいですか?」と人間に聞き返します。--dangerously-skip-permissions は権限ダイアログをスキップするフラグですが、Claudeが内部で「情報不足で次の判断ができない」と判断したときの停止は防げません。

直し方: 全タスクの起動前に必ずPROGRESS.mdを用意するルールにしました。スクリプトの起動前に [ -f PROGRESS.md ] || echo "PROGRESS.mdがありません" && exit 1 を追加するのも有効ですが、私はそれより「そもそもPROGRESS.mdを最初に書いてからスクリプトを起動する」という習慣のほうが確実だと判断して今もそうしています。

2. launchdから起動したらclaudeが「command not found」で死んだ

症状: ターミナルから手動実行では動くのに、launchdのタイマー経由で実行すると最初の1行で command not found: claude と出てexit 127で落ちた。

原因: launchdが起動するシェルはユーザーの .zshrc.bash_profile を読まないため、PATH が素の状態です。Claude Codeはnvm経由でインストールしているため、~/.nvm/versions/node/v24.13.0/bin/ にしかバイナリがありません。この場所がlaunchdのPATHに含まれていないので「claudeって何」という状態になります。

直し方: plistの EnvironmentVariables に明示的にPATHを書きます。

<key>PATH</key>
<string>/Users/(あなたのユーザー名)/.nvm/versions/node/v24.13.0/bin:/usr/local/bin:/usr/bin:/bin</string>

あるいはスクリプトの先頭に export PATH="$HOME/.nvm/versions/node/v24.13.0/bin:$PATH" を追加する方法もあります。スクリプト側に書くほうがlaunchd依存がなくなるので移植性が高いです。ただしnodejsのバージョンを上げたときにパスが変わるので、その都度更新する必要があります。どちらを選ぶかはトレードオフですが、私はlaunchd側のplistに書く方式を採っています。plistの変更だけで完結するので管理場所が一元化されるからです。

3. TABを含むTASK文でclaudeのコマンドが壊れた

症状: スクリプトを起動すると claude: invalid option -- 't' のようなエラーが出て即死した。

原因: 引数のタスク文をコピー&ペーストしたとき、インデントに使ったタブ文字が混入していました。シェルでは引数展開時にタブがワード分割の区切り文字として扱われることがあり、-p "$TASK"$TASK がタブで分割されてclaudeに複数の引数として渡されていました。

直し方: タスク文を渡すときは必ず"$TASK"とダブルクォートで囲む(スクリプト内では既にそうなっている)ことと、引数として渡す文字列にはタブを含めないことです。コマンドラインから渡す際は $'...' 記法やヒアドキュメントを使うより、改行やタブを含まない1行のシンプルな文にする方が確実です。

複雑な指示はPROGRESS.mdに書いておいて、スクリプトへの引数は "PROGRESS.mdを読んで作業を再開して" という単純な文だけにする運用にすることで、この問題はほぼ発生しなくなりました。複雑な情報はファイルに持つ、引数は単純にするという原則は、スクリプト全体の設計とも一致しています。

4. ログをリダイレクトしていなかったので翌朝何も残っていなかった

症状: 朝起きるとターミナルのウィンドウは閉じられていて、作業が完了したのかリトライを繰り返したのか、何時間動いたのか、まったくわからない状態だった。

原因: スクリプトの出力をファイルに保存していなかったこと、そしてlaunchdのStandardOutPathを指定していなかったことです。ターミナルのセッションログはウィンドウを閉じると消えます(ターミナルの設定にもよりますが)。

直し方:

ターミナルから起動する場合はリダイレクトを付けます。

bash ~/scripts/resume-on-ratelimit.sh "スクレイパーを完成させて" \
  >> ~/logs/claude-$(date '+%Y%m%d').log 2>&1

launchdから起動する場合はplistに追加します。

<key>StandardOutPath</key>
<string>/Users/(あなたのユーザー名)/logs/claude-resume.log</string>
<key>StandardErrorPath</key>
<string>/Users/(あなたのユーザー名)/logs/claude-resume-error.log</string>

ログが残るようになってからは、翌朝の最初のアクションが「ログを確認する」になりました。

[02:03] 起動: スクレイパーを完成させて
[02:47] レートリミット検出 (exit: 1)。5分後にリトライ...
[02:52] リトライ 1 / 20
[03:37] レートリミット検出 (exit: 1)。5分後にリトライ...
[03:42] リトライ 2 / 20
[04:21] 完了

このログから「深夜2時と3時台に2回ブロックされ、3回目の試行で4時台に完了した」という事実が読み取れます。ログが積み上がると「自分のClaude使用は深夜2〜4時台にブロックされやすい」というパターンも見えてきます。次からは深夜1時台に起動して余裕を持たせる、という改善が数字に基づいてできます。

5. --continue が「前のセッション」を引き継がなかった

症状: 再開後のClaudeが --continue フラグを使っているはずなのに、「こんにちは。何を手伝いましょうか?」と新鮮な挨拶から始めた。

原因: Claude Codeの --continue は「最後のセッション」を引き継ぐフラグです。しかし「最後のセッション」はカレントディレクトリに紐づいています。ブロックされたときと再開時でカレントディレクトリが異なっていた場合(launchdのWorkingDirectoryと手動実行のディレクトリが違うなど)、--continue は別のセッション(あるいは存在しないセッション)を参照して新規会話として始まります。

直し方: launchdのplistには必ず WorkingDirectory を指定します。

<key>WorkingDirectory</key>
<string>/Users/(あなたのユーザー名)/dev/(プロジェクト名)</string>

手動実行時も cd でプロジェクトディレクトリに移動してからスクリプトを起動するルールにしました。そして、この問題が起きても最悪ではないのが PROGRESS.mdの存在です。--continue が失敗して新規セッションになっても、PROGRESS.mdが存在すれば "PROGRESS.mdを読んで中断した作業を続けて" というプロンプトでClaudeは正しいコンテキストを取得できます。--continue はあくまで「より素早い復帰のための補助」であり、コンテキスト復元の本命はPROGRESS.mdです。このことを理解してからは、--continue が効かない状況でもパニックしなくなりました。

つまずきポイント

前章で5つの詰まり話を出しました。ここではそれ以外の地雷を一気に並べます。「やってしまった」という項目があれば、その場で対処してください。

  • ターミナルウィンドウを閉じたらプロセスが全滅した ターミナルアプリを終了すると、紐づく子プロセス全員に SIGHUP が飛びます。resume-on-ratelimit.sh はこれを受け取った瞬間に死にます。深夜2時に起動して布団に潜ったのに、翌朝起きると「何も変化していない」という状況がこれです。解決策は2つあり、どちらかを選んでください。tmux new -s claude でtmuxセッションを作り、その中でスクリプトを起動する方法が管理しやすいです。翌朝 tmux attach -t claude でそのまま続きのログを確認できます。もしtmuxが入っていない環境なら nohup bash ~/scripts/resume-on-ratelimit.sh "タスク" >> ~/logs/claude-$(date '+%Y%m%d').log 2>&1 & でバックグラウンド起動することで同等の効果が得られます。

  • Macがスリープに入ってsleepコマンドが止まった macOSはアイドル時間が一定を超えるとシステムスリープに入ります。このとき、実行中の sleep 300(5分 × 60秒)のカウントが凍ります。スリープ解除後にカウントが再開されるため、「5分後にリトライ」が「2時間後にリトライ」に化けていた、という経験をしました。対策は caffeinate -i bash ~/scripts/resume-on-ratelimit.sh "タスク" と前置きすることです。caffeinate -i はコマンド実行中だけシステムスリープを抑制するので、スクリプトが終われば自動解除されます。launchdから起動する場合はlaunchd自体がスリープと共存する設計なので不要です。

  • 複数プロジェクトを同時起動して --continue が混線した claude --continue は「最後に起動したセッション」を引き継ぎます。プロジェクトAとBを別タブで同時に走らせると、一方のClaudeが終了した瞬間に「最後のセッション」が上書きされます。その直後に他方がリトライ起動すると、異なるプロジェクトのセッションを引き継ぐという最悪の事態が起きます。実際に、スクレイパー開発中のセッションがLP制作のコンテキストを拾ってHTML出力を始めた、という経験があります。複数並行する場合は必ずlaunchdでプロジェクトごとに専用plistを用意してWorkingDirectoryを分けるか、直列で実行する設計にしてください。どうしても同時起動したいなら、TASK 引数にタスク全量を詰め込んでPROGRESS.mdへの依存をなくす方向で対処します。

  • exit 0 で「完了」と出たのに作業が途中だった これが最も気づきにくい落とし穴でした。スクリプトは exit 0 = 完了 として扱います(31行目の判定)。しかしClaudeが exit 0 を返すのは「タスクを終えたとき」だけではありません。「指示が曖昧で判断できないため終了します」「確認が必要な事項があります。応答をお待ちしています」と会話を打ち切るときも exit 0 になります。深夜3時14分に [03:14] 完了 とログに出て、朝起きてみるとPROGRESS.mdの「次のアクション」が4つ残っていた、という状況がありました。対策は2段構えです。①TASK引数の末尾に "完了したらPROGRESS.mdの最終行にCOMPLETED: [完了日時]と書いてください" を追加する。②翌朝確認は [HH:MM] 完了 ログだけでなく tail -1 PROGRESS.mdCOMPLETED: の記録を確かめる。この2つで「exit 0の偽陽性」を実用上は防げます。

  • PROGRESS.mdが肥大化してコンテキストを圧迫した 同じプロジェクトを1週間動かし続けると「完了済み」セクションが数百行になります。再開のたびにClaudeはこのファイルを全文読むので、ファイルサイズが増えるほどコンテキストが消費され、その後の作業精度が落ちます。実測で wc -l PROGRESS.md が400行を超えたあたりから再開後の作業精度がほんのり落ちる体感があります。週次で mv PROGRESS.md PROGRESS_archive_$(date '+%Y%m%d').md してリセットし、現在進行分だけを新しいPROGRESS.mdに書き直すことで解消します。アーカイブは読み返すことがほぼないので保存して置いておくだけで十分です。

  • macOS通知が一度も届かなかった osascript -e "display notification..." はmacOSの通知センター経由です。ターミナルアプリの通知許可がオフになっているか、集中モードが有効になっていると届きません。スクリプトの15行目は 2>/dev/null || true でエラーを握りつぶしているため、通知が失敗してもスクリプト本体は止まりません。通知頼りで運用している場合は、システム設定 → 通知 → ターミナルアプリの通知許可を「オン」に変えてください。「レートリミット。5分後に再開します」の通知が来るかどうかで、スクリプトが意図通り動いているかをリアルタイム確認できます。

  • TASK引数にシングルクォートが混入してシェルエラー コピー&ペーストしたタスク文に日本語の「'(シングルクォート)」や英語の ' が含まれていると、シェルの引数解釈が壊れます。bash resume-on-ratelimit.sh "ユーザーの'入力'を" のように引数がダブルクォートで囲まれていても、ダブルクォート内のシングルクォートはシェルによっては問題を起こします。最も安全な設計は引数を短くシンプルにして、複雑な指示はすべてPROGRESS.mdに書くことです。引数は "PROGRESS.mdを読んで作業を再開して" のような記号なし日本語だけにし、コードやコマンドを引数に含める書き方は避けてください。スクリプト10行目のデフォルト値もこの設計に従っています。

  • 起動前にカレントディレクトリを確認しなかった --continue はカレントディレクトリに紐づくセッションを引き継ぎます(前章で詳述)。加えて、PROGRESS.mdをカレントディレクトリからの相対パスで参照しているため、cd し忘れた状態で起動すると「PROGRESS.mdが見つかりません」が起きます。~/scripts/resume-on-ratelimit.sh~/dev/プロジェクト/ にいない状態で実行するのは事故の元です。スクリプトの起動前に pwd を確認する習慣か、スクリプトの先頭に cd ~/dev/プロジェクト名 || exit 1 を追加するかのいずれかを採ってください。


ベストプラクティス

3ヶ月以上の実稼働で定着したルールです。全部を一度に入れる必要はなく、「まず1番と3番だけ」から始めて、詰まったら順に追加するのが現実的です。

1. 起動前にPROGRESS.mdの存在を必ず確認する

これが基点です。PROGRESS.mdのないタスクにこのスクリプトを使っても、2回目以降のリトライ(27行目の固定プロンプト)でClaudeが止まるだけです。起動コマンドを以下の形にしておくと、ファイルがない状態での起動を防げます。

[ -f PROGRESS.md ] && bash ~/scripts/resume-on-ratelimit.sh "$1" || echo "PROGRESS.mdがありません"

あるいは習慣として「スクリプト起動の直前にPROGRESS.mdを書く」という手順を固定するだけでも十分です。

2. PROGRESS.mdの「次のアクション」はファイルパス・関数名・行番号まで書く

「スクレイパーの続き」ではなく「~/dev/scraper/scraper.pyfetch_page() 関数 101行目に next_page_url 抽出ロジックを追加する」まで書きます。Claudeは再開直後にこのファイルを読んで最初のアクションを決めます。ファイルパスがあればそのファイルを開き、関数名があればそこを探します。1行の情報量が多いほど、最初のアクションまでの迷い時間がゼロに近づきます。

3. ログは日付付きファイルに保存して翌朝必ず確認する

bash ~/scripts/resume-on-ratelimit.sh "タスク" \
  >> ~/logs/claude-$(date '+%Y%m%d').log 2>&1

[02:47] レートリミット検出 (exit: 1)。5分後にリトライ...[02:52] リトライ 1 / 20[04:21] 完了 という時系列が残ります。ログが積み上がると「深夜2〜4時台にブロックされやすい」「このタイプのタスクは3時間かかる」というパターンが見えてきて、次の起動計画に活かせます。データのない改善は勘頼みになります。

4. launchdから使う場合はPATH・HOME・WorkingDirectoryをplistに明示する

launchdが起動するシェルは .zshrc を読みません。この3つが欠けているだけで command not found や「セッションが引き継がれない」問題が同時多発します。PATHにはnvmのbinディレクトリを含め、HOMEはユーザーのホームディレクトリを設定してください。WorkingDirectoryはプロジェクトの絶対パスを書くことで、--continue のセッション紐づけも正しく機能します。

5. ターミナルからの長時間実行はtmuxで保護する

tmux new -s claude
# セッション内で起動
bash ~/scripts/resume-on-ratelimit.sh "タスク" >> ~/logs/claude-$(date '+%Y%m%d').log 2>&1
# Ctrl+b d でデタッチして就寝
# 翌朝
tmux attach -t claude

ターミナルウィンドウを閉じても、tmuxセッションはサーバーが生きている間ずっと存在し続けます。朝起きて再アタッチすれば、リアルタイムのログ末尾がそこにあります。

6. caffeinate -i と組み合わせてMacスリープを防ぐ

launchd以外の方法で深夜に動かす場合は必須です。

caffeinate -i bash ~/scripts/resume-on-ratelimit.sh "タスク" \
  >> ~/logs/claude-$(date '+%Y%m%d').log 2>&1

スクリプトが終了すると caffeinate も自動で抜けるので、「スリープ防止をかけっぱなしにした」という事故が起きません。

7. WAIT_MINUTESは5〜10分を使い、2分以下にしない

レートリミットは「一定時間内のAPI使用量」に対するブロックです。ブロック期間中は何回リトライしても失敗し続けます。2分刻みで素早くリトライしたとしても再開は早まらず、失敗ログが増えるだけです。深夜の長期タスクなら WAIT_MINUTES=10 bash ~/scripts/resume-on-ratelimit.sh "タスク" と指定することで、API使用の山が平準化されて翌日のブロック頻度が減ります。日中の短時間タスクなら5分デフォルトで十分です。

8. 完了サインをPROGRESS.mdに書かせる約束にする

TASK引数の末尾に "完了したらPROGRESS.mdの最終行にCOMPLETED: [完了日時]と記録してください" を加えます。翌朝の確認が tail -1 PROGRESS.md の1コマンドで完結します。exit 0 だけを完了の証拠にすると、Claudeが途中で会話を打ち切った場合に気づきが遅れます。PROGRESS.mdの記録とログの [HH:MM] 完了 を突き合わせるのが、確認の最短ルートです。

9. 複数プロジェクト並行はlaunchd + 専用plistで管理する

手動で複数タブを同時起動すると --continue の混線が起きます。launchdのplistをプロジェクトごとに用意して launchctl load するやり方であれば、WorkingDirectoryが独立するので混線しません。plistの命名規則を com.自分の名前.プロジェクト名.plist に統一しておくと、launchctl list | grep 自分の名前 で管理中タスクを一覧できます。

10. PROGRESS.mdは週次でアーカイブして薄く保つ

1週間の蓄積は mv PROGRESS.md PROGRESS_archive_$(date '+%Y%m%d').md でアーカイブして、新しい PROGRESS.md を現在進行分だけで書き直します。目安は50行以下です。400行を超えると再開後の作業精度が体感で落ちます。アーカイブは読み返すことがほぼないので ~/dev/プロジェクト名/archive/ に入れておくだけで十分です。

11. MAX_RETRIESとWAIT_MINUTESを「完了期限」から逆算して設定する

「タスクを朝6時までに終わらせたい」という制約があるなら、以下の計算を事前にします。

  • タスク推定時間: 4時間
  • 起動時刻: 深夜1時
  • 余裕: 1時間(ブロック2回分 = 5分 × 2 = 10分)
  • 設定: MAX_RETRIES=5 WAIT_MINUTES=5(合計25分の自動復旧猶予)

デフォルトの20回 × 5分 = 100分は長時間タスク向けの余裕設計ですが、短時間タスクにとっては「100分待って諦める」という意味になります。タスクの性質に合わせて数値を調整する習慣をつけてください。

12. ログは30日で自動削除する

月に一度cronで古いログを掃除します。

# crontab -e で追加
0 3 1 * * find ~/logs/claude-*.log -mtime +30 -delete

30日分のログが積み上がると数十MB〜数GBになります。30日経過したログを振り返って改善に使うことはまずないので、自動削除で問題ありません。crontab -e で上記の1行を追加すれば、毎月1日の深夜3時に自動実行されます。


まとめ

resume-on-ratelimit.sh は46行です。環境変数2つ、関数1つ、whileループ1つ。依存パッケージはゼロで、コピーして chmod +x するだけで動きます。

それでも、このスクリプトがあるとないとでは仕事のあり方が根本から変わります。「PCの前にいないと進まない」という前提が崩れるからです。

解雇されてゼロになった月に最初に決めたのは「自分が動く量を増やすのではなく、仕組みが動く量を増やす」ことでした。PROGRESS.mdを用意して、スクリプトを起動して、寝る。翌朝ログを確認して、次のタスクを書く。月商120万の内訳のうちClaude Codeが絡む自動化収益の割合は今も増え続けていますが、それは特別なスキルがあったからではなく、「小さいが確実に動く仕組み」を一つひとつ積んできた結果です。

このスクリプトはその入口の一つです。まず動かして、ログを見て、詰まった箇所を直す。その反復に価値があります。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!