SLOPSHOPPER

quota-sentry

Stops runaway Claude Code loops before they burn your plan

newcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quota-sentry
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /quota-sentry ⎿ quota-sentry: five_hour window: 31% (resets 1760003600000) ⎿ quota-sentry: this session: 0.42 USD ⎿ quota-sentry: headless runs in the last hour: 0/20 ⎿ quota-sentry: automatic turns in a row: 0/15 ⎿ quota-sentry: blocks: active ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

token-guardrails

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.

modwhat it does
quota-sentryCaps 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-routerSets 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.

Install

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.

Commands

commandeffect
/quota-sentryplan windows, session cost, loop counters
/quota-sentry pause · resumedisable the blocks for one hour, or re-enable them
/effort-routerhow your prompts were classified
/effort-router off · onturn routing off or on
/effort-router subagents claude-sonnet-5-5move subagents on non-hard tasks to that model (subagents off to undo)

All commands answer instantly without a model turn, so they cost nothing.

Tests

cd quota-sentry && claude plugin test    # 4 tests
cd effort-router && claude plugin test   # 4 tests

Honest limits

  • Keyword classification is a heuristic. Effort low on a prompt that needed more will give a weaker answer: /effort-router off if it bothers you.
  • The headless counter is shared through the mod's local store; two sessions writing at the same instant can miss one count.
  • Tested on Claude Code 2.1.292, macOS.

Support

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.

License

MIT

Source 1 files
hooks/register.js 98 lines
1// 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