「cron で claude -p を回しているけれど、失敗してリトライが重なって朝に請求を見るのが怖い」
——この感覚は正しいです。
対話で使っている間は、人間が止められます。ところが claude -p をスケジューラから呼び始めると、止める人がいなくなります。失敗を検知してリトライするコードを書いた瞬間、そのリトライがコストを増やす側に回ることもあります。
この記事では、claude -p を無人で回すときに必要な「止める仕組み」を、動くコードで順番に作ります。完成するのは次の4層を持つ TypeScript のランナーです。
- 1回の実行にかける予算上限(
--max-budget-usd) - 1日の累計予算(実行ごとに
total_cost_usdを台帳へ記録して合算) - Kill-Switch(ファイル1枚で全停止)
- サーキットブレーカー(連続失敗で自動的に開く)
なぜ「無人実行」は対話と別の設計が要るのか
公式ドキュメントによると、claude -p は成功時に終了コード 0、実行中の失敗では非0で終了します。--output-format json を付けると、結果のペイロードに total_cost_usd とモデル別のコスト内訳が入ります。--resume で続きを実行した場合は、会話全体の累計が返ります。
コストを機械で読めることが、このあとの設計の前提です。ただし、これらの数値はクライアント側の推定値で、実際の請求とは差が出ることがあります。この記事の予算ガードは「請求額の保証」ではなく「暴走の検知」です。上限には余裕を持たせてください。
Step 1 | 1回の実行を安全に呼ぶ
まず、claude -p を子プロセスとして起動する関数を作ります。ポイントは3つです。
-
--bareで、ホストの hooks・プラグイン・CLAUDE.md を読み込まない。スクリプト用途では公式も推奨している -
--max-budget-usdで、1回あたりの上限を渡す -
--permission-prompts noneで、承認待ちの固着を防ぐ(v2.1.259 以降)
--bare はサブスクリプションのログインを使わないため、ANTHROPIC_API_KEY が必要です。サブスク枠で回したい場合は --bare を外します。そのときは、手元の ~/.claude の hooks や MCP が無人実行にも効く点に注意してください。
// runner.ts
import { spawn } from "node:child_process";
export type RunResult = {
ok: boolean;
costUsd: number;
exitCode: number | null;
subtype?: string;
result?: string;
};
type ClaudeJson = {
is_error?: boolean;
subtype?: string;
total_cost_usd?: number;
result?: string;
};
export function runClaude(
prompt: string,
opts: { maxBudgetUsd: number; timeoutMs: number; cwd: string },
): Promise<RunResult> {
return new Promise((resolve) => {
const child = spawn(
"claude",
[
"--bare",
"-p",
prompt,
"--output-format",
"json",
"--max-budget-usd",
String(opts.maxBudgetUsd),
"--permission-mode",
"dontAsk",
"--permission-prompts",
"none",
"--allowedTools",
"Read,Grep,Glob",
],
{ cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] },
);
let stdout = "";
child.stdout.on("data", (d) => (stdout += d));
// SIGINT でターンを終わらせてから、残っていれば SIGTERM(143 で終了する)
const soft = setTimeout(() => child.kill("SIGINT"), opts.timeoutMs);
const hard = setTimeout(() => child.kill("SIGTERM"), opts.timeoutMs + 15_000);
child.on("close", (code) => {
clearTimeout(soft);
clearTimeout(hard);
let parsed: ClaudeJson = {};
try {
parsed = JSON.parse(stdout) as ClaudeJson;
} catch {
// JSON が取れない失敗は、コスト不明のまま失敗として返す
}
resolve({
ok: code === 0 && parsed.is_error !== true,
costUsd: parsed.total_cost_usd ?? 0,
exitCode: code,
subtype: parsed.subtype,
result: parsed.result,
});
});
});
}
権限は読み取り系だけに絞り、dontAsk で「許可リストにないものは拒否」にしています。無人実行では、広い許可より狭い許可のほうが事故の範囲が小さくなります。
--max-budget-usd に達した場合、公式ドキュメントでは残りのバックグラウンド作業を待たずに止めると書かれています。このときの subtype の値は私の側で確認できていないため、コードでは分岐に使わず、記録だけしています。実際の値は、手元で一度わざと小さい上限で走らせて確かめてください。
Step 2 | 1日の累計予算を台帳で管理する
1回の上限だけでは、100回呼ばれたら止まりません。実行ごとに JSONL へ1行追記し、今日の合計が日次上限を超えたら起動しない関数を作ります。
// ledger.ts
import { appendFileSync, existsSync, readFileSync } from "node:fs";
const LEDGER = ".local/spend-ledger.jsonl";
type Entry = { at: string; costUsd: number; ok: boolean; exitCode: number | null };
export function record(e: Omit<Entry, "at">): void {
appendFileSync(LEDGER, `${JSON.stringify({ at: new Date().toISOString(), ...e })}\n`);
}
function readAll(): Entry[] {
if (!existsSync(LEDGER)) return [];
return readFileSync(LEDGER, "utf8")
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l) as Entry);
}
// JST の日付境界で合算する(UTC のまま比べると午前9時にリセットされる)
function jstDay(iso: string): string {
return new Date(new Date(iso).getTime() + 9 * 3600_000).toISOString().slice(0, 10);
}
export function spentToday(): number {
const today = jstDay(new Date().toISOString());
return readAll()
.filter((e) => jstDay(e.at) === today)
.reduce((sum, e) => sum + e.costUsd, 0);
}
export function recentFailures(): number {
// 末尾から数えた連続失敗の回数
const all = readAll();
let n = 0;
for (let i = all.length - 1; i >= 0 && !all[i].ok; i--) n++;
return n;
}
日付境界を UTC のままにすると、日本時間の午前9時に「新しい1日」が始まってしまいます。total_cost_usd が0で返る失敗(JSON が取れなかった場合など)も、台帳には必ず書いてください。次のサーキットブレーカーが、その行を数えます。
Step 3 | Kill-Switch をファイル1枚で作る
全停止の手段は、考えずに使える形が必要です。一番単純なのは「特定のファイルが存在したら起動しない」方式です。
// guard.ts
import { existsSync, statSync } from "node:fs";
import { recentFailures, spentToday } from "./ledger";
const KILL_FILE = ".local/KILL";
const DAILY_LIMIT_USD = 20;
const BREAKER_THRESHOLD = 3;
const BREAKER_COOLDOWN_MS = 30 * 60_000;
const LEDGER = ".local/spend-ledger.jsonl";
export type Verdict = { allow: true } | { allow: false; reason: string };
export function check(): Verdict {
if (existsSync(KILL_FILE)) {
return { allow: false, reason: "kill-switch: .local/KILL が存在する" };
}
const spent = spentToday();
if (spent >= DAILY_LIMIT_USD) {
return { allow: false, reason: `日次予算超過: $${spent.toFixed(2)} / $${DAILY_LIMIT_USD}` };
}
if (recentFailures() >= BREAKER_THRESHOLD && existsSync(LEDGER)) {
const sinceLast = Date.now() - statSync(LEDGER).mtimeMs;
if (sinceLast < BREAKER_COOLDOWN_MS) {
return { allow: false, reason: "circuit-breaker: 連続失敗のため冷却中" };
}
}
return { allow: true };
}
止めるときは touch .local/KILL、戻すときは rm .local/KILL だけです。サーキットブレーカーは、3回連続で失敗したら30分は起動しません。冷却が明けたあとの最初の1回が成功すれば連続失敗が0に戻り、失敗すれば再び冷却に入ります。この「半開き」の挙動のおかげで、無限リトライが構造的に起きません。
Step 4 | 3つをつなげて入口を1本にする
呼び出し側が runClaude を直接叩かないように、入口を1本にします。ガードに止められた場合は理由を残して終了します。
// main.ts
import { check } from "./guard";
import { record } from "./ledger";
import { runClaude } from "./runner";
async function main() {
const verdict = check();
if (!verdict.allow) {
console.error(`skipped: ${verdict.reason}`);
process.exit(0); // スケジューラ側で失敗扱いにしない
}
const res = await runClaude("src/ 配下の TODO コメントを一覧にして", {
maxBudgetUsd: 1,
timeoutMs: 5 * 60_000,
cwd: process.cwd(),
});
record({ costUsd: res.costUsd, ok: res.ok, exitCode: res.exitCode });
console.log(res.ok ? res.result : `failed: exit=${res.exitCode} subtype=${res.subtype}`);
}
main();
ガードで止まったときの終了コードを0にしているのは意図的です。非0にすると、スケジューラ側がリトライを始めて、ガードの外側で再び暴走する形になりかねません。
動作確認
1日の上限に達する様子を、実際に叩かずに確かめます。台帳へ直接ダミー行を足します。
mkdir -p .local
# 日次上限(20ドル)を超える行を足す
echo '{"at":"'"$(date -u +%FT%TZ)"'","costUsd":25,"ok":true,"exitCode":0}' >> .local/spend-ledger.jsonl
pnpm exec tsx main.ts
# => skipped: 日次予算超過: $25.00 / $20
# Kill-Switch
touch .local/KILL && pnpm exec tsx main.ts
# => skipped: kill-switch: .local/KILL が存在する
確認が終わったら、ダミー行を含む台帳と KILL を消してください。台帳に残したままだと、本番の最初の実行が止まります。
運用で効く追加の2点
1. 停止中を「故障」に見せない。 止められたときの理由をログに残さないと、後で「動いていない」と誤診します。上のコードで skipped: <理由> を標準エラーに出しているのはそのためです。
2. 再起動やプロセスが変わっても状態が消えない形にする。 サーキットブレーカーの状態をメモリに持つと、プロセスの再起動で0に戻り、ブレーカーが無効になります。台帳をファイルに置いたのは、これを避けるためです。私が運用している環境でも、メモリ上の throttle が再起動で戻っていた事故があり、状態は必ず永続化する方針にしています。
まとめ
無人実行の暴走防止は、4つの小さな部品に分けると実装できます。
- 1回の上限は
--max-budget-usd、制限時間は SIGINT→SIGTERM の二段で止める - 1日の上限は、
total_cost_usdを JSONL の台帳に積み、JST の日付境界で合算する - 全停止は、ファイルの有無で判定する Kill-Switch にする
- 連続失敗はサーキットブレーカーで受け、ガードで止めた場合は終了コード0で抜ける
total_cost_usd は推定値なので、請求と完全には一致しません。まずは日次上限を小さく置いて数日回し、台帳の実績を見てから緩めるのが安全です。