Stops runaway Claude Code loops before they burn your plan

Two Claude Code mods that stop your plan from being burned by accident.
Built after a real incident: a script that called claude -p every 60 seconds made about 800 Opus calls in 13 hours, emptied two full plan windows, and produced nothing but "hold". Nothing in Claude Code stopped it. These mods would have stopped it after 20 minutes.
| mod | what it does |
|---|---|
| quota-sentry | Caps headless claude -p runs at 20 per hour across your whole machine. After 15 automatic turns in a row with no message from you, it asks before going on. Warns at 80% and 95% of your 5-hour and 7-day windows. Your own prompts are never blocked. |
| effort-router | Sets reasoning effort per prompt: low for "ok, go on", high for real work. It does not switch the main model, because the prompt cache belongs to one model and switching re-reads your whole context. Optionally moves subagents (which start with an empty context) to a cheaper model. Above 80% of your 5-hour window, effort steps down one level. |
Classification is local keyword matching: zero tokens. No network calls, no telemetry.
Requires Claude Code 2.1.287 or later (mods).
claude plugin marketplace add amiralimardani-art/token-guardrails
claude plugin install quota-sentry@token-guardrails
claude plugin install effort-router@token-guardrails
Start a new session, then try /quota-sentry and /effort-router.
| command | effect |
|---|---|
/quota-sentry | plan windows, session cost, loop counters |
/quota-sentry pause · resume | disable the blocks for one hour, or re-enable them |
/effort-router | how your prompts were classified |
/effort-router off · on | turn routing off or on |
/effort-router subagents claude-sonnet-5-5 | move subagents on non-hard tasks to that model (subagents off to undo) |
All commands answer instantly without a model turn, so they cost nothing.
cd quota-sentry && claude plugin test # 4 tests
cd effort-router && claude plugin test # 4 tests
low on a prompt that needed more will give a weaker answer: /effort-router off if it bothers you.Free and MIT licensed. If it saved you a plan window, you can pay what you want on Gumroad.
Need it set up on your team, or a custom guardrail for your workflow? Open an issue.
MIT
hooks/register.js 98 lines1// quota-sentry — stops runaway Claude Code loops before they burn your plan.
2//
3// 1. Headless runs (`claude -p`): more than MAX_HEADLESS_PER_HOUR in one hour, counted
4// across every session on this machine, and the next one is dropped. A script that
5// calls `claude -p` every minute is stopped after 20 minutes, not after 13 hours.
6// 2. Inside a session: after MAX_AUTO_STREAK automatic turns in a row (notifications,
7// messages from other sessions, prompts from other plugins) with no message from you,
8// it asks before continuing. With nobody to ask, it stops.
9// 3. Warns when your 5-hour or 7-day plan window passes 80% and 95%.
10// Your own prompts are never blocked. `/quota-sentry` shows the counters;
11// `/quota-sentry pause` disables the blocks for one hour.
12
13const MAX_HEADLESS_PER_HOUR = 20
14const MAX_AUTO_STREAK = 15
15const HOUR = 3_600_000
16const HUMAN = ['composer', 'bridge']
17
18let autoStreak = 0
19const warned = {}
20
21async function headlessRuns($) {
22 const now = Date.now()
23 return ((await $.store.get('headless_runs')) || []).filter((t) => now - t < HOUR)
24}
25
26async function paused($) {
27 const until = await $.store.get('paused_until')
28 return typeof until === 'number' && until > Date.now()
29}
30
31export function register(on) {
32 on('session.start', async ($, e, next) => {
33 try { await $.command.register({ name: 'quota-sentry', description: 'Loop-guard counters and plan usage. Args: pause | resume', argumentHint: '[pause|resume]', immediate: true }) } catch {}
34 return next(e)
35 })
36
37 on('prompt.submit', async ($, e, next) => {
38 const kind = e.origin ? e.origin.kind : 'unclassified'
39 if (HUMAN.includes(kind)) {
40 autoStreak = 0
41 return next(e)
42 }
43 const isPaused = await paused($)
44
45 if (kind === 'sdk') {
46 const runs = await headlessRuns($)
47 if (runs.length >= MAX_HEADLESS_PER_HOUR && !isPaused) {
48 $.ui.log('quota-sentry: blocked a headless run (' + runs.length + ' in the last hour)')
49 return { drop: 'quota-sentry: ' + runs.length + ' headless Claude runs in the last hour (limit ' + MAX_HEADLESS_PER_HOUR + '). This looks like a loop, so it was stopped to protect your plan. To allow more for an hour, run /quota-sentry pause in an interactive session.' }
50 }
51 runs.push(Date.now())
52 await $.store.set('headless_runs', runs)
53 return next(e)
54 }
55
56 autoStreak += 1
57 if (autoStreak >= MAX_AUTO_STREAK && !isPaused) {
58 let answer = 'Stop'
59 try {
60 answer = await $.ui.ask(autoStreak + ' automatic turns in a row without a message from you. This may be a loop that is spending your plan. Continue?', ['Continue', 'Stop'])
61 } catch {}
62 if (answer !== 'Continue') {
63 return { drop: 'quota-sentry: stopped after ' + autoStreak + ' automatic turns in a row without a message from the user.' }
64 }
65 autoStreak = 0
66 }
67 return next(e)
68 })
69
70 on('session.measure', async ($, e, next) => {
71 for (const l of e.rateLimits || []) {
72 for (const level of [80, 95]) {
73 const key = l.kind + level
74 if (l.percentUsed >= level && !warned[key]) {
75 warned[key] = true
76 $.ui.toast('Claude plan: ' + l.kind + ' window at ' + l.percentUsed + '%' + (l.resetsAt ? ', resets ' + l.resetsAt.slice(11, 16) + ' UTC' : ''))
77 }
78 }
79 }
80 return next(e)
81 })
82
83 on('command.run', { command: 'quota-sentry' }, async ($, e) => {
84 const arg = (e.args || '').trim()
85 if (arg === 'pause') { await $.store.set('paused_until', Date.now() + HOUR); return { text: 'quota-sentry paused for one hour.' } }
86 if (arg === 'resume') { await $.store.set('paused_until', 0); return { text: 'quota-sentry active again.' } }
87 const u = await $.session.usage()
88 const runs = await headlessRuns($)
89 const lines = (u.rateLimits || []).map((l) => l.kind + ' window: ' + l.percentUsed + '%' + (l.resetsAt ? ' (resets ' + l.resetsAt + ')' : ''))
90 if (!lines.length) lines.push('plan windows: no reading yet (arrives after the first model reply)')
91 if (u.cost) lines.push('this session: ' + u.cost.usd.toFixed(2) + ' USD')
92 lines.push('headless runs in the last hour: ' + runs.length + '/' + MAX_HEADLESS_PER_HOUR)
93 lines.push('automatic turns in a row: ' + autoStreak + '/' + MAX_AUTO_STREAK)
94 lines.push('blocks: ' + ((await paused($)) ? 'PAUSED' : 'active'))
95 return { text: lines.join('\n') }
96 })
97}
98