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

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">
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".
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.
| What | How |
|---|---|
| Open the settings | click 55% / 70% in the prompt footer, or run /limit-guard |
| Turn on / off | Enable button, or /limit-guard on / /limit-guard off |
| Set the threshold | dropdown in the panel, or /limit-guard 80 |
| Background launches over the threshold | blocked / allowed button, or /limit-guard bg on / /limit-guard bg off |
| Continue after a stop | just write "continue" |
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
$.state), so each chat has its own.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%). Работает только в том чате, где ты его включил.
Установка: /plugin install limit-guard --marketplace egoricon/claude-limit-guard
If Limit Guard saved your limit, please give it a ⭐
MIT License
hooks/register.tsx 335 lines1import { 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}
335types/index.d.ts 16 lines1export 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