Show original
Enjoyed this article?
Use "Request tipping" to ask the author to set up tip receiving.

AI translation
― Cost to stop runaway autopilot
Enjoyed this article?
Use "Request tipping" to ask the author to set up tip receiving.
This is the 8th installment in the "Claude Code Environment" series. In 会話ログを長期記憶に変える話 I introduced unattended jobs, but this time it's about the boss of those — an autopilot that continuously improves your environment in Claude Code with zero user input.
"Autonomous loop" sounds nice, but running an LLM unattended causes 2 scary things. ① Plan quota gets consumed and ② the same task repeats endlessly. I've actually hit both. This article is a record of how I mechanically stopped both.
Launched from launchd at 5:00 AM every morning, it repeats "pick one improvement task and advance just 1 Phase, then finish" in headless mode from `claude -p`.
dry` (prompt generation only) / apply` / `once`The goal is to have it do "smart things little by little" unattended — no major overhauls in a single run.
The first gate is cost. Looking at output tokens in the 5-hour block, if it's at dangerous levels, stop before running.
BUDGET=$(~/.claude/scripts/token-budget-advisor.sh --short)
if echo "$BUDGET" | grep -qE '🔴|critical|cap-near'; then
log "ABORT: budget critical"; exit 0
fiBut label-only judgment had a hole. Cases where `🟡burst` display showed one thing but actual remaining quota was negative slipped through, and a heavy model ran for 606 seconds with −8003 remaining. So separately from the label, I recalculate remaining quota numerically and don't execute if it's 0 or below.
REMAINING=$((800000 - BLOCK_OUT)) # 5h block cap 800k 想定
if [ "$REMAINING" -le 0 ]; then
log "SKIP: block exhausted"; exit 0
fiCost limits should be held as "remaining quota numbers" not "labels". Threshold judgments on colors or strings always slip through at boundaries. In the end, checking `<= 0` with subtraction is reliable.
Furthermore, I make effort and max-turns variable based on remaining quota. If little remains, use lightweight/few turns; if there's plenty, set higher. The default model for unattended jobs is an inexpensive one (the accident of consuming the 5h block with constant use of high-performance models, leaving all subsequent slots as SKIP, taught me this). I only override with environment variables when intentionally running heavy tasks.
MODEL="${AUTOPILOT_MODEL:-claude-sonnet-4-6}" # 既定は安価モデルTurn count alone won't stop it, so I also apply wall-clock timeout. max-turns only constrains turn count, and there's a track record of running for 7.25 hours, so I cap it at `gtimeout 7200` (2h).
This was the most effective fix. I pick task candidates from the "High impact" section of `next-session-todo.md`, but the initial awk was broken and candidates were always empty.
# 旧実装の範囲パターン /^### High impact/,/^###/ は
# 開始行自身が終端条件にも一致して即終了 → 常に空。
# → フォールバックの固定タスクが毎回選ばれ、同一タスクを6日で17回反復。With empty candidates, the fallback fixed task was selected every time, doing the same task 17 times in 6 days. I fixed the range pattern from regex to flag-based to correctly enumerate candidates, and added history-based deduplication.
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") # 連続失敗 → 諦めて次へI keep history as one-record-per-line JSONL, recording task name, exit code, elapsed seconds, model, and effort. This lets me machine-judge "recently done / stuck repeatedly".
When running unattended, the self-reported "recovered N MB" from `claude -p` goes unverified by anyone. So the harness side takes actual measurements and writes them alongside in the result file.
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)At the end of the result file, I write "## Self-Verification (harness actual measurement / not Claude's claim)" with disk delta and changed file count, and add "if the numerical claims in the body diverge from this actual measurement, doubt the body". Always line up numbers that AI spoke and numbers that the OS measured. This was the crux of unattended operation reliability.
To stop runaway, humans also need to watch cost constantly. I display 5h/7d plan usage rate on Claude Code's status line. The nice thing is, this comes directly from stdin. No authentication or endpoint calls needed.
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` "only appears after the first API response on subscription", so until the first turn I fall back to --`. This way "🕐 5h 42% ⏪14:30 / 📅 7d 18%" is always visible — plan quota and reset time. Whatever the autopilot consumed in the background is immediately reflected here.
Across these 8 posts, I've written nearly my entire Claude Code environment: memory, skills, context, launchd, collaboration, security, long-term memory, and autonomous loops. Thank you for reading.
Lily (@bokuwalily) — Individual developer. Building automation infrastructure with Claude Code while mass-producing iOS apps and web services
◼︎ Apps I've made are summarized at **ポートフォリオ**📱
◼︎ New releases and development behind-the-scenes on X **@bokuwalily**🐦
◼︎ OSS: **github.com/bokuwalily**🐙
Your ❤️ and shares are my motivation!
「Claude Code環境」シリーズの第8弾です。会話ログを長期記憶に変える話で無人ジョブを紹介しましたが、今回はその親玉 ―― ユーザー入力ゼロでClaude Codeに自分の環境を改善させ続けるautopilotの話です。
「自律ループ」と言うと聞こえはいいですが、無人でLLMを回すと2つこわいことが起きます。①プラン枠を食い潰す と ②同じタスクを延々と繰り返す。実際どちらも踏みました。この記事は、その2つをどう機械で止めたかの記録です。
launchdから毎朝5:00に起動して、`claude -p` のヘッドレスで「改善タスクを1つ拾って、1 Phaseだけ進めて終わる」を繰り返します。
無人で「賢いことを少しずつ」やらせるのが狙いで、1回で大改造はさせません。
最初のゲートはコストです。5時間ブロックの出力トークンを見て、危険水位なら走る前に止めます。
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以下なら実行しません。
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になった事故の反省)。重いタスクを意図的に回すときだけ環境変数で上書きします。
MODEL="${AUTOPILOT_MODEL:-claude-sonnet-4-6}" # 既定は安価モデルターン数だけでは止まらないので、壁時計のtimeoutも被せます。max-turnsはターン数しか縛らず、7.25時間走った実績があったため、`gtimeout 7200`(2h)で頭打ちにします。
これがいちばん効いた修正です。タスク候補を `next-session-todo.md` の「High impact」セクションから拾うのですが、最初のawkが壊れていて候補が常に空でした。
# 旧実装の範囲パターン /^### High impact/,/^###/ は
# 開始行自身が終端条件にも一致して即終了 → 常に空。
# → フォールバックの固定タスクが毎回選ばれ、同一タスクを6日で17回反復。候補が空だとフォールバックの固定タスクが毎回選ばれ、同じタスクを6日間で17回やっていました。範囲パターンをフラグ方式に直して候補を正しく列挙し、さらに履歴ベースのdedupeを足しました。
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を記録します。これで「最近やった/詰まり続けている」を機械で判定できます。
無人で回すと、`claude -p` の「N MB回収しました」という自己申告を誰も検証しない問題が出ます。そこでharness側で実測を取り、結果ファイルに並記します。
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から直接取れること。認証もエンドポイント呼び出しも不要です。
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が裏で食った分も、ここに即反映されます。
ここまでの8本で、記憶・スキル・コンテキスト・launchd・協業・セキュリティ・長期記憶・自律ループと、私のClaude Code環境のほぼ全体を書きました。読んでくれてありがとうございました。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
◼︎作ったアプリは **ポートフォリオ** にまとめています📱
◼︎新着・開発の裏側は X **@bokuwalily** で発信しています🐦
◼︎OSS: **github.com/bokuwalily**🐙
皆さんの ❤️ やシェアが励みになります!