SLOPSHOPPER

limit-guard

Stops long autonomous Claude Code tasks at a 5-hour usage threshold you pick — per chat, zero tokens spent on checks

bandspinnerguardcommandtoastprompt
★ 1v1.0.0MITupdated 2026-10-19egoricon/claude-limit-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-guard
› 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 › /limit-guard ⎿ limit-guard: Limit Guard: выключен, порог 70%, фоновые запуски выше порога блокируются. ○ Limit Guard · выключен · только этот чат 5-часовой лимит: 31% · сброс в 09:53 · стоп на 70% [ Включить ]Стоп на: 70% ▾[ Закрыть ] Фоновые запуски выше порога:[ блокируются ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩━━━○───┃───31% / 70%

Draws

Band
○ Limit Guard · выключен · только этот чат 5-часовой лимит: 31% · сброс в 09:53 · стоп на 70% [ Включить ]Стоп на: 70% ▾[ Закрыть ] Фоновые запуски выше порога:[ блокируются ]
README

🟠 Limit Guard

Leave Claude Code working while you're away, and come back to some of your 5-hour limit still left.

Limit Guard is a Claude Code mod. It stops a long autonomous task when your 5-hour usage reaches a threshold you pick (70% by default). It only applies to the chat where you switched it on.

<img src="assets/preview.svg" alt="Limit Guard: a small usage bar in the prompt footer" width="760">


Why

You give Claude a big task: "refactor the project, write the tests, fix everything". Then you go to class, to sleep, or to make coffee. When you come back, the task finished hours ago and your whole 5-hour window is gone, so you can't use Claude for anything else.

Limit Guard sets a ceiling on autonomous work. When the threshold is reached it stops the task. To keep going, you just reply "continue".

Features

  • A mini bar in the prompt footer. It shows how much of the 5-hour window you've used, with a tick at your threshold. Click it to open the settings.
  • The dot shows the state:
  • 🟠 watching
  • 🔴 stopped at the threshold
  • ⚪ off, or you said "continue" and it now runs to 100%
  • Per chat. Turn it on in one chat and your other chats run as normal.
  • "Continue" overrides the stop. If you write anything after a stop, the task runs to 100% until the window resets.
  • Background launches setting. You choose whether scheduled tasks, background notifications and other agents' messages are blocked or allowed once you're over the threshold.
  • No tokens spent on checks. Claude Code pushes usage updates to the mod whenever the number moves a whole point. Nothing is polled and the model is never called.
  • Adjustable threshold from 30% to 95%, in the panel or with a command.

Install

In Claude Code:

/plugin install limit-guard --marketplace egoricon/claude-limit-guard

Answer y to add the marketplace, then pick a scope (just you, or the project).

Requires a Claude Code version with mod support, signed in with a Pro / Max / Team subscription. Usage numbers only exist on a subscription.

Usage

WhatHow
Open the settingsclick 55% / 70% in the prompt footer, or run /limit-guard
Turn on / offEnable button, or /limit-guard on / /limit-guard off
Set the thresholddropdown in the panel, or /limit-guard 80
Background launches over the thresholdblocked / allowed button, or /limit-guard bg on / /limit-guard bg off
Continue after a stopjust write "continue"

How it works

Claude Code ──(usage moved by 1 point)──▶ session.measure ──▶ percent ≥ threshold?
                                                                 │ yes, and a task is running
                                                                 ▼
                                                 stop the turn + notice in chat 🔴
your next message ──▶ lifts the stop, runs to 100% ──▶ new 5-hour window resets this
  • Settings and state live in the session ($.state), so each chat has its own.
  • Only autonomous work gets stopped. A message you write yourself is never blocked.
  • A stop can land a point or two past the threshold if Claude was in the middle of a long action.

Development

claude plugin validate .
claude plugin test .

The code is in hooks/register.tsx and the tests are in tests/.


Limit Guard — мод для Claude Code. Он останавливает длинную автономную задачу, когда 5-часовой лимит доходит до выбранного порога (по умолчанию 70%). Работает только в том чате, где ты его включил.

  • Внизу у поля ввода появляется мини-ползунок: сколько лимита израсходовано и где стоит порог. Если нажать на него, откроются настройки.
  • 🟠 мод следит · 🔴 задача остановлена · ⚪ мод выключен или разрешено работать до 100%.
  • Если после остановки написать «продолжи», задача пойдёт дальше до 100%.
  • Есть настройка: пускать ли выше порога фоновые запуски (задачи по расписанию, уведомления).
  • На проверки мод не тратит ни одного токена.

Установка: /plugin install limit-guard --marketplace egoricon/claude-limit-guard

If Limit Guard saved your limit, please give it a ⭐

MIT License

Source 2 files
hooks/register.tsx 335 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { Reading } from '../types'
5
6/*
7 * Limit Guard: останавливает автономную задачу в ЭТОМ чате, когда
8 * 5-часовой лимит доходит до выбранного порога.
9 *
10 * - Всё состояние живёт в $.state, то есть отдельно для каждого чата:
11 *   включил в одном чате, остальные работают как обычно.
12 * - Процент лимита движок присылает сам (session.measure), когда он сдвинулся
13 *   на целый пункт. Мод ничего не опрашивает и не тратит токены: сравнение
14 *   двух чисел происходит вне модели.
15 * - После остановки любое твоё сообщение ("продолжи") снимает стоп,
16 *   и задача идёт до 100% лимита. Новый 5-часовой период сбрасывает это.
17 */
18
19const P = 'limit-guard'
20const enabled = atom({ plugin: 'limit-guard', key: 'enabled' } as const, false)
21const threshold = atom({ plugin: 'limit-guard', key: 'threshold' } as const, 70)
22const reading = atom({ plugin: 'limit-guard', key: 'reading' } as const, null as Reading | null)
23const tripped = atom({ plugin: 'limit-guard', key: 'tripped' } as const, false)
24const override = atom({ plugin: 'limit-guard', key: 'override' } as const, false)
25const isOpen = atom({ plugin: 'limit-guard', key: 'isOpen' } as const, false)
26/** true: фоновые запуски (расписание, уведомления, другие агенты) идут и выше порога. */
27const allowBackground = atom({ plugin: 'limit-guard', key: 'allowBackground' } as const, false)
28
29const ORANGE = '#E8803A'
30const RED = '#D93F3F'
31const GREY = '#9A9A9A'
32const STEPS = [30, 40, 50, 60, 65, 70, 75, 80, 85, 90, 95]
33const HUMAN = new Set(['composer', 'bridge', 'sdk'])
34
35type Look = { color: string; isLit: boolean; label: string }
36
37function look(on: boolean, isTripped: boolean, isOverride: boolean): Look {
38  if (!on) return { color: GREY, isLit: false, label: 'выключен' }
39  if (isTripped) return { color: RED, isLit: true, label: 'остановлено' }
40  if (isOverride) return { color: GREY, isLit: false, label: 'до 100%' }
41  return { color: ORANGE, isLit: true, label: 'следит' }
42}
43
44function clamp(n: number) {
45  return Math.max(0, Math.min(100, n))
46}
47
48/** Мини-ползунок: дорожка, заполнение до текущего %, засечка порога, кружок. */
49function barSvg(percent: number | null, limit: number, l: Look) {
50  const W = 96
51  const x0 = 6
52  const span = W - 12
53  const p = clamp(percent ?? 0)
54  const fx = x0 + (span * p) / 100
55  const tx = x0 + (span * clamp(limit)) / 100
56  const fill = l.isLit ? l.color : GREY
57  const dot = l.isLit
58    ? `<circle cx="${fx}" cy="8" r="5" fill="${l.color}"/>`
59    : `<circle cx="${fx}" cy="8" r="4.5" fill="none" stroke="${GREY}" stroke-width="1.5"/>`
60  return (
61    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="16" viewBox="0 0 ${W} 16">` +
62    `<rect x="${x0}" y="6" width="${span}" height="4" rx="2" fill="${GREY}" opacity="0.35"/>` +
63    `<rect x="${x0}" y="6" width="${fx - x0}" height="4" rx="2" fill="${fill}" opacity="0.85"/>` +
64    `<rect x="${tx - 0.75}" y="2" width="1.5" height="12" rx="0.75" fill="${l.isLit ? l.color : GREY}"/>` +
65    dot +
66    `</svg>`
67  )
68}
69
70/** Текстовая версия для терминала: ●━━━━━┃───. */
71function barText(percent: number | null, limit: number, l: Look) {
72  const cells = 10
73  const at = Math.round((clamp(percent ?? 0) / 100) * cells)
74  const mark = Math.round((clamp(limit) / 100) * cells)
75  let s = ''
76  for (let i = 0; i <= cells; i++) {
77    if (i === at) s += l.isLit ? '●' : '○'
78    else if (i === mark) s += '┃'
79    else s += i < at ? '━' : '─'
80  }
81  return s
82}
83
84function resetTime(r: Reading | null) {
85  if (!r?.resetsAt) return ''
86  const d = new Date(r.resetsAt)
87  if (isNaN(d.getTime())) return ''
88  const hh = String(d.getHours()).padStart(2, '0')
89  const mm = String(d.getMinutes()).padStart(2, '0')
90  return `${hh}:${mm}`
91}
92
93async function stop($: EngineInterface, running: string | undefined, why: string) {
94  await update($, tripped, () => true)
95  $.ui.toast(why)
96  try {
97    await $.session.append({
98      message: { type: 'system', content: [{ type: 'text', text: why }] },
99    })
100  } catch {}
101  if (running) {
102    try {
103      await $.turn.abort({ turnId: running })
104    } catch {}
105  }
106}
107
108/** Сравнить процент с порогом. Вызывается только когда процент сдвинулся. */
109async function check($: EngineInterface, running: string | undefined) {
110  if (!running) return
111  if (!(await read($, enabled))) return
112  if ((await read($, override)) || (await read($, tripped))) return
113  const r = await read($, reading)
114  const limit = await read($, threshold)
115  if (!r || r.percent < limit) return
116  const at = resetTime(r)
117  await stop(
118    $,
119    running,
120    `Limit Guard: использовано ${Math.round(r.percent)}% 5-часового лимита (порог ${limit}%). ` +
121      `Задача остановлена${at ? `, лимит сбросится в ${at}` : ''}. ` +
122      `Напиши «продолжи», чтобы работать дальше до 100%.`,
123  )
124}
125
126
127export const register: Register = on => {
128  /** Id главного хода, который сейчас идёт (подагенты не в счёт). */
129  let running: string | undefined
130
131  on('session.start', async ($, e, next) => {
132    await $.command.register({
133      name: 'limit-guard',
134      description: 'Limit Guard: /limit-guard [on | off | <процент> | bg on | bg off]',
135    })
136    return next(e)
137  })
138
139  on('command.run', { command: 'limit-guard' }, async ($, e) => {
140    const a = e.args.trim().toLowerCase()
141    const bg = /^(bg|фон)\s+(on|off|вкл|выкл)$/.exec(a)
142    if (bg) {
143      const allow = bg[2] === 'on' || bg[2] === 'вкл'
144      await update($, allowBackground, () => allow)
145    } else if (a === 'on' || a === 'вкл') {
146      await update($, enabled, () => true)
147      await update($, tripped, () => false)
148      await update($, override, () => false)
149    } else if (a === 'off' || a === 'выкл') {
150      await update($, enabled, () => false)
151      await update($, tripped, () => false)
152    } else if (/^\d{1,3}%?$/.test(a)) {
153      const n = Math.max(1, Math.min(100, parseInt(a, 10)))
154      await update($, threshold, () => n)
155      await update($, tripped, () => false)
156    }
157    await update($, isOpen, () => true)
158    const on_ = await read($, enabled)
159    const t = await read($, threshold)
160    const allowBg = await read($, allowBackground)
161    return {
162      text:
163        `Limit Guard: ${on_ ? 'включён' : 'выключен'}, порог ${t}%, ` +
164        `фоновые запуски выше порога ${allowBg ? 'разрешены' : 'блокируются'}.`,
165    }
166  })
167
168  on('turn.start', ($, e, next) => {
169    if (e.agentId === undefined) running = e.turnId
170    return next(e)
171  })
172
173  on('turn.complete', ($, e, next) => {
174    if (e.agentId === undefined && e.turnId === running) running = undefined
175    return next(e)
176  })
177
178  // Движок сам присылает свежие цифры лимита, когда они сдвинулись.
179  on('session.measure', async ($, e, next) => {
180    const w = e.rateLimits.find(r => r.kind === 'five_hour')
181    if (w) {
182      const prev = await read($, reading)
183      const isNewWindow =
184        prev !== null && !!prev.resetsAt && !!w.resetsAt && prev.resetsAt !== w.resetsAt
185      if (isNewWindow) {
186        await update($, override, () => false)
187        await update($, tripped, () => false)
188      }
189      await update($, reading, () => ({ percent: w.percentUsed, resetsAt: w.resetsAt ?? null }))
190      await check($, running)
191    }
192    return next(e)
193  })
194
195  // Страховка: пока стоит стоп, новые действия не выполняются.
196  on('tool.call', async ($, e, next) => {
197    if ((await read($, enabled)) && (await read($, tripped)) && !(await read($, override))) {
198      return { deny: 'Limit Guard: порог лимита достигнут, задача остановлена.' }
199    }
200    return next(e)
201  })
202
203  on('prompt.submit', async ($, e, next) => {
204    if (e.command || !(await read($, enabled))) return next(e)
205    const isHuman = HUMAN.has(e.origin.kind) && !e.isMeta
206    if (isHuman) {
207      // Ты сам написал после остановки: снимаем стоп, дальше до 100%.
208      if (await read($, tripped)) {
209        await update($, tripped, () => false)
210        await update($, override, () => true)
211        $.ui.toast('Limit Guard: продолжаю до 100% лимита в этом чате.')
212      }
213      return next(e)
214    }
215    // Автономный запуск (расписание, фоновое уведомление, другой агент).
216    if (await read($, allowBackground)) return next(e)
217    const r = await read($, reading)
218    const limit = await read($, threshold)
219    if (!(await read($, override)) && r && r.percent >= limit) {
220      await update($, tripped, () => true)
221      return { drop: `Limit Guard: ${Math.round(r.percent)}% лимита ≥ порога ${limit}%.` }
222    }
223    return next(e)
224  })
225
226  // Мини-ползунок в нижней строке поля ввода.
227  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
228    const base = await next(e)
229    // Svg есть на десктопе и в мобильном приложении; в терминале рисуем текстом.
230    const { Box, Button, Svg, Text } = $.ui.resolve(e) as unknown as ElementTable<'desktop'>
231    const on_ = await read($, enabled)
232    const l = look(on_, await read($, tripped), await read($, override))
233    const r = await read($, reading)
234    const limit = await read($, threshold)
235    const pct = r ? `${Math.round(r.percent)}%` : '—'
236    const alt = `Limit Guard ${l.label}: использовано ${pct}, стоп на ${limit}%`
237    const toggle = () => update($, isOpen, v => !v)
238
239    return (
240      <Box flexDirection="row" alignItems="center" gap="xs">
241        {base}
242        {e.surface === 'terminal' ? (
243          <Text color={l.isLit ? l.color : undefined} dimColor={!l.isLit}>
244            {barText(r?.percent ?? null, limit, l)}
245          </Text>
246        ) : (
247          <Svg source={barSvg(r?.percent ?? null, limit, l)} alt={alt} width={96} height={16} />
248        )}
249        <Button key="lg-open" plain dimColor onPress={toggle} label={`${pct} / ${limit}%`} />
250      </Box>
251    )
252  })
253
254  // Панель настроек над полем ввода (по нажатию на ползунок или /limit-guard).
255  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
256    if (e.props.hasSurvey || !(await read($, isOpen))) return next(e)
257    // Select есть в терминале и на десктопе; в мобильном приложении — кнопки −5/+5.
258    const { Box, Button, Select, Text } = $.ui.resolve(e) as unknown as ElementTable<'desktop'>
259    const on_ = await read($, enabled)
260    const isTripped = await read($, tripped)
261    const isOverride = await read($, override)
262    const l = look(on_, isTripped, isOverride)
263    const r = await read($, reading)
264    const limit = await read($, threshold)
265    const at = resetTime(r)
266    const allowBg = await read($, allowBackground)
267    const isBelow = on_ && r !== null && r.percent >= limit && !isTripped && !isOverride
268
269    const setLimit = async (n: number) => {
270      await update($, threshold, () => Math.max(1, Math.min(100, n)))
271      await update($, tripped, () => false)
272    }
273    const toggleOn = async () => {
274      const next_ = !on_
275      await update($, enabled, () => next_)
276      await update($, tripped, () => false)
277      if (next_) await update($, override, () => false)
278    }
279
280    return (
281      <Box flexDirection="column" gap="xs">
282        <Text weight="semibold">
283          <Text color={l.isLit ? l.color : undefined}>{l.isLit ? '● ' : '○ '}</Text>
284          Limit Guard · {l.label} · только этот чат
285        </Text>
286        <Text dimColor>
287          5-часовой лимит: {r ? `${Math.round(r.percent)}%` : 'ещё нет данных'}
288          {at ? ` · сброс в ${at}` : ''} · стоп на {limit}%
289        </Text>
290        <Box flexDirection="row" alignItems="center" gap="xs">
291          <Button
292            key="lg-toggle"
293            variant="primary"
294            label={on_ ? 'Выключить' : 'Включить'}
295            onPress={toggleOn}
296          />
297          {e.surface !== 'mobile' ? (
298            <Select
299              key="lg-threshold"
300              label="Стоп на"
301              value={String(limit)}
302              options={(STEPS.includes(limit) ? STEPS : [...STEPS, limit].sort((a, b) => a - b)).map(
303                n => ({ value: String(n), label: `${n}%` }),
304              )}
305              onSelect={v => void setLimit(parseInt(v, 10))}
306            />
307          ) : (
308            <Box flexDirection="row" gap="xs">
309              <Button key="lg-minus" label="−5" onPress={() => setLimit(limit - 5)} />
310              <Text>{limit}%</Text>
311              <Button key="lg-plus" label="+5" onPress={() => setLimit(limit + 5)} />
312            </Box>
313          )}
314          <Button key="lg-close" role="dismiss" label="Закрыть" onPress={() => update($, isOpen, () => false)} />
315        </Box>
316        <Box flexDirection="row" alignItems="center" gap="xs">
317          <Text dimColor>Фоновые запуски выше порога:</Text>
318          <Button
319            key="lg-bg"
320            label={allowBg ? 'разрешены' : 'блокируются'}
321            onPress={() => update($, allowBackground, v => !v)}
322          />
323        </Box>
324        {isTripped && (
325          <Text color={RED}>Задача остановлена. Напиши «продолжи», чтобы работать до 100%.</Text>
326        )}
327        {isOverride && on_ && <Text dimColor>Ты разрешил работать до 100% до сброса лимита.</Text>}
328        {isBelow && (
329          <Text color={ORANGE}>Порог ниже текущего расхода: задача остановится при первом же обновлении.</Text>
330        )}
331      </Box>
332    )
333  })
334}
335
types/index.d.ts 16 lines
1export type Reading = { percent: number; resetsAt: string | null }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'limit-guard': {
6      enabled: boolean
7      threshold: number
8      reading: Reading | null
9      tripped: boolean
10      override: boolean
11      isOpen: boolean
12      allowBackground: boolean
13    }
14  }
15}
16