Tsukutta

🤖Claude Codeを無人で自律改善させる

― コストで暴走を止めるautopilot

Lily2026年6月20日公開
About 10 min read4

「Claude Code環境」シリーズの第8弾です。会話ログを長期記憶に変える話で無人ジョブを紹介しましたが、今回はその親玉 ―― ユーザー入力ゼロでClaude Codeに自分の環境を改善させ続けるautopilotの話です。

「自律ループ」と言うと聞こえはいいですが、無人でLLMを回すと2つこわいことが起きます。①プラン枠を食い潰す②同じタスクを延々と繰り返す。実際どちらも踏みました。この記事は、その2つをどう機械で止めたかの記録です。

全体像:毎朝1 Phaseだけ進める

launchdから毎朝5:00に起動して、`claude -p` のヘッドレスで「改善タスクを1つ拾って、1 Phaseだけ進めて終わる」を繰り返します。

  • 対象は `~/.claude/` 配下だけ(個人プロジェクトやVaultの個人情報には触らせない)
  • `dry`(プロンプト生成のみ)/ `apply` / `once` の3モード
  • 走らせる前にコスト枠をチェックし、枠が危なければ即skip

無人で「賢いことを少しずつ」やらせるのが狙いで、1回で大改造はさせません。

暴走対策1:コスト枠で先に止める

最初のゲートはコストです。5時間ブロックの出力トークンを見て、危険水位なら走る前に止めます。

text
BUDGET=$(~/.claude/scripts/token-budget-advisor.sh --short)
if echo "$BUDGET" | grep -qE '🔴|critical|cap-near'; then
  log "ABORT: budget critical"; exit 0
fi

ただラベル判定だけだと穴がありました。`🟡burst` 表示のまま実際の残量がマイナス、というケースが素通りして、残量−8003で重いモデルが606秒走ったことがあります。なのでラベルとは別に、残量を数値で再計算して0以下なら実行しません。

text
REMAINING=$((800000 - BLOCK_OUT))    # 5h block cap 800k 想定
if [ "$REMAINING" -le 0 ]; then
  log "SKIP: block exhausted"; exit 0
fi
:コスト上限は「ラベル」ではなく「残量の数値」で持つべきでした。色や文字列のしきい値判定は、境界で必ずすり抜けます。最後は引き算で `<= 0` を見るのが確実です。

さらに残量に応じて effort と max-turns を可変にします。残りが少なければ軽量・少ターン、余裕があれば高めに。無人ジョブの既定モデルは安価なものにしています(高性能モデルの常用で5hブロックを食い潰し、以降の全スロットがSKIPになった事故の反省)。重いタスクを意図的に回すときだけ環境変数で上書きします。

text
MODEL="${AUTOPILOT_MODEL:-claude-sonnet-4-6}"   # 既定は安価モデル

ターン数だけでは止まらないので、壁時計のtimeoutも被せます。max-turnsはターン数しか縛らず、7.25時間走った実績があったため、`gtimeout 7200`(2h)で頭打ちにします。

暴走対策2:同じタスクを繰り返させない

これがいちばん効いた修正です。タスク候補を `next-session-todo.md` の「High impact」セクションから拾うのですが、最初のawkが壊れていて候補が常に空でした。

text
# 旧実装の範囲パターン /^### High impact/,/^###/ は
# 開始行自身が終端条件にも一致して即終了 → 常に空。
# → フォールバックの固定タスクが毎回選ばれ、同一タスクを6日で17回反復。

候補が空だとフォールバックの固定タスクが毎回選ばれ、同じタスクを6日間で17回やっていました。範囲パターンをフラグ方式に直して候補を正しく列挙し、さらに履歴ベースのdedupeを足しました。

  • 直近48hに `exit 0` で完了済みのタスクはskip
  • 直近2回連続で失敗しているタスクもskip(無理なものを叩き続けない)
text
if any(r.get("exit_code") == 0 for r in recent):
    print("done-recently")          # 最近やった → 次の候補へ
if len(recent) >= 2 and all(r["exit_code"] != 0 for r in recent[-2:]):
    print("failing-repeatedly")     # 連続失敗 → 諦めて次へ

履歴は1行1レコードのJSONLで持ち、タスク名・exit code・所要秒・モデル・effortを記録します。これで「最近やった/詰まり続けている」を機械で判定できます。

自己検証:AIの自己申告を実測で照合する

無人で回すと、`claude -p` の「N MB回収しました」という自己申告を誰も検証しない問題が出ます。そこでharness側で実測を取り、結果ファイルに並記します。

text
DISK_BEFORE_KB=$(du -sk "$HOME/.claude" | awk '{print $1}')
# ...claude -p 実行...
DISK_AFTER_KB=$(du -sk "$HOME/.claude" | awk '{print $1}')
DISK_DELTA_KB=$(( DISK_AFTER_KB - DISK_BEFORE_KB ))
FILES_TOUCHED=$(find "$HOME/.claude" ... -newer "$RUNSTART_REF" | wc -l)

結果ファイルの末尾に「## 自己検証(harness実測 / claudeの主張ではない)」として disk delta と変更ファイル数を書き、「本文の数値主張がこの実測と乖離する場合は本文を疑う」と添えます。AIに語らせた数字と、OSが計った数字を必ず並べる。これが無人運用の信頼性の肝でした。

監視:ステータスラインにプラン枠を出す

暴走を止めるには、人間側もコストを常時見たい。Claude Codeのステータスラインに、5h/7dのプラン使用率を出しています。嬉しいのは、これがstdinから直接取れること。認証もエンドポイント呼び出しも不要です。

text
H5=$(j '.rate_limits.five_hour.used_percentage')
H5R=$(j '.rate_limits.five_hour.resets_at')
D7=$(j '.rate_limits.seven_day.used_percentage')
CTX=$(j '.context_window.used_percentage')

`rate_limits` は「サブスクで最初のAPI応答後にだけ現れる」ので、初回ターンまでは `--` にフォールバックします。これで「🕐 5h 42% ⏪14:30 / 📅 7d 18%」のように、プラン枠とリセット時刻が常に見えます。autopilotが裏で食った分も、ここに即反映されます。

踏んだ落とし穴

  • ラベル判定だけで残量マイナスが素通り → 残量を数値で再計算し `<= 0` で止める
  • max-turnsだけでは7時間走る → `gtimeout` で壁時計cap
  • 高性能モデル常用で枠を食い潰し全SKIP → 無人既定は安価モデル、重い時だけ上書き
  • awkの範囲パターンが空で固定タスクを17回反復 → フラグ方式+履歴dedupe
  • AIの自己申告を誰も検証しない → harnessで実測し結果に並記
  • launchdの最小PATHでclaudeが見つからない → PATH→`.local/bin`→nvmの順でフォールバック

まとめ

  • 無人ループはコスト枠を数値で先にチェックしてから走らせる(ラベル判定は境界で漏れる)
  • 暴走は残量cap・壁時計timeout・安価モデル既定の三重で止める
  • 同タスク反復は履歴JSONLのdedupe(最近完了/連続失敗をskip)で潰す
  • AIの自己申告は必ずharness実測と並記して、盛り・誤読を検出可能にする
  • プラン枠はstatuslineのstdinから取れる。常時見える化が監視の土台

ここまでの8本で、記憶・スキル・コンテキスト・launchd・協業・セキュリティ・長期記憶・自律ループと、私のClaude Code環境のほぼ全体を書きました。読んでくれてありがとうございました。

この記事が良かったら

「チップをリクエスト」で著者にチップの受け取り設定をお願いできます

シェア