Compacts before the 5-hour/weekly usage limit is hit, pauses until it resets, then resumes the task automatically.

A Claude Code mod (plugin of function hooks) that handles usage limits for you:
Early-access API: written against Claude Code 2.1.289+ Mods (
claude-codehooks API). It may need updates as that API changes.
Clone the repo, then use one of these. Use only one: two routes load two plugins both named quota-guard.
Add CLAUDE_CODE_PLUGIN_DIRS to the env block of your user settings file, ~/.claude/settings.json (on Windows C:\Users\<you>\.claude\settings.json), keeping your other keys:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:/Users/you/code/claude-quota-guard"
}
}
; on Windows (: on macOS/Linux).env block of the user settings file. It is never read from project settings (.claude/settings.json or .claude/settings.local.json in a repo); putting it there does nothing.git pull to update. Interactive sessions reload it when a file is saved.CLAUDE_CODE_PLUGIN_DIRS is already set in the environment a session is launched with, that value wins over the settings file.claude --plugin-dir /path/to/claude-quota-guard
~/.claude/skills/quota-guardThis also auto-loads, but it is a copy: it does not follow the repo, so it drifts. Prefer the folder route above.
/quota status (/quota-guard status is an alias). It prints the thresholds and State: IDLE.quota-guard · waiting for first usage reading./quota is an unknown command: the plugin is not loaded. Run claude --debug and look for quota-guard: lines. A healthy start has hooks module quota-guard@inline loaded and $.command.register (quota-guard): /quota listed.env block (not a project file), that the path exists, and that you started a new session.reload failed, the previous version stays loaded (while editing): the message names the offending function or line.~/.claude/skills/quota-guard, a hot-reload copy, plus the settings entry).claude -p, an SDK run with no surface) are never paused: nobody would be there to resume them, so the mod stays out of the way.| Command | What it does |
|---|---|
/quota status (alias /quota-guard) | usage windows, thresholds, state, task on record |
/quota on / off | enable / disable automatic management |
/quota threshold N | 5-hour threshold (50-100) |
/quota weekly N | weekly threshold |
/quota ctxmin N | compact before pausing only if context is above N% (default 30) |
/quota pause [min] | force a pause (compacts per the context rule) for testing |
/quota cancel | cancel the pending auto-resume (holds until you act) |
/quota resume | resume now |
/quota ctx [close] | open / close the full context pane |
No need to burn real quota:
/quota test on simulated usage, dry-run actions
/quota sim 80 heads-up only
/quota sim 94 compact, then pause
/quota sim exhausted
/quota sim reset60 100% used, resets in 60 s
/quota sim reset-ok usage drops after the reset (default)
/quota sim reset-fail reset time slips by 2 minutes once
/quota sim reset-delay first resume attempt fails, second works
/quota sim weekly
/quota sim ctx N simulate context fill (try 20 vs 50 around the 30% rule)
/quota sim unavailable toggle usage data off/on
/quota sim restart drop memory and reload from the store while paused
/quota test actions real do real compaction/prompts under simulated usage
/quota test off
hooks/core.ts is a pure state machine (idle -> armed -> compacting -> paused -> resuming -> idle, plus a manual stopped hold). No engine imports; unit-tested.hooks/register.tsx is the adapter: session.measure for usage, $.session.compact, $.turn.abort, $.prompt.submit, $.store for persistence, $.clock for the heartbeat, ui.render for the bar and pane.claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 885 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { CtxDetail, QuotaBar } from '../types'
5
6import {
7 DEFAULTS,
8 FIVE,
9 WEEK,
10 blocksAutomation,
11 describeWindow,
12 fmtClock,
13 fmtDur,
14 initialState,
15 needsHeartbeat,
16 statusLine,
17 step,
18} from './core'
19import type { Config, Ev, Fx, State, Usage, Win } from './core'
20
21const HEARTBEAT_MS = 30_000
22const USER_KINDS = ['composer', 'bridge', 'sdk', 'slack-ping']
23const PREFIX = 'quota-guard: '
24const bar = atom({ plugin: 'quota-guard', key: 'bar' } as const, null)
25const detail = atom({ plugin: 'quota-guard', key: 'detail' } as const, null)
26const PANE = 'quota-ctx'
27
28const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
29const when = (w: Win | undefined, now: number) => {
30 if (!w?.resetsAt || w.resetsAt <= now) return undefined
31 return w.resetsAt - now > 20 * 3600_000 ? `${DAYS[new Date(w.resetsAt).getDay()]} ${fmtClock(w.resetsAt)}` : fmtClock(w.resetsAt)
32}
33const tok = (n?: number) => (n === undefined ? '' : n >= 1_000_000 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
34const CELLS = 10
35const filled = (pct: number) => Math.max(0, Math.min(CELLS, Math.round((pct / 100) * CELLS)))
36const fmtTok = (n: number) => (n >= 1_000_000 ? `${(n / 1e6).toFixed(2)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n)))
37const tail = (path: string) => path.split(/[\\/]/).slice(-2).join('/')
38
39function buildDetail(b: any, api: any): CtxDetail {
40 const win = b.rawMaxTokens || b.maxTokens || 1
41 const mcp = new Map<string, number>()
42 for (const t of b.mcpTools ?? []) mcp.set(t.serverName, (mcp.get(t.serverName) ?? 0) + (t.isLoaded ? t.tokens : 0))
43 return {
44 model: b.model,
45 total: b.totalTokens,
46 window: win,
47 percent: b.percentage,
48 autoAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
49 categories: (b.categories ?? []).map((c: any) => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
50 memory: (b.memoryFiles ?? []).map((m: any) => ({ path: tail(m.path), tokens: m.tokens })).sort((x: any, y: any) => y.tokens - x.tokens).slice(0, 8),
51 mcp: [...mcp.entries()].map(([server, tokens]) => ({ server, tokens })).sort((x, y) => y.tokens - x.tokens).slice(0, 8),
52 agents: (b.agents ?? []).map((a: any) => ({ name: a.agentType, tokens: a.tokens })).sort((x: any, y: any) => y.tokens - x.tokens).slice(0, 6),
53 skills: b.skills ? { count: b.skills.includedSkills ?? b.skills.totalSkills, total: b.skills.totalSkills, tokens: b.skills.tokens, top: (b.skills.skillFrontmatter ?? []).map((k: any) => ({ name: k.name, tokens: k.tokens })).sort((x: any, y: any) => y.tokens - x.tokens).slice(0, 6) } : undefined,
54 api: api ? { input: api.input_tokens, cacheRead: api.cache_read_input_tokens, cacheWrite: api.cache_creation_input_tokens, output: api.output_tokens } : undefined,
55 }
56}
57
58// one distinct colour per category, assigned by size rank so the bar and the list always agree
59const PALETTE = ['green', 'cyan', 'yellow', 'magenta', 'red', 'blue', 'white']
60const colorAt = (rank: number) => PALETTE[rank % PALETTE.length] ?? 'white'
61// Continuous bar: each segment is as wide as its share of the whole window, so the
62// filled part is always exactly the context percentage. Segments are sized in cells
63// (fractions allowed; the engine refuses fractional percentages).
64// Remote surfaces (desktop, editor, mobile) can draw vectors: a rounded pill with a soft
65// track, segments clipped to it, and a hairline where compaction starts to apply.
66const HEX = ['#3fb950', '#22b8cf', '#e3b341', '#c678dd', '#f0626b', '#5b8def', '#adb5bd']
67const hexAt = (rank: number) => HEX[rank % HEX.length] ?? '#adb5bd'
68
69function ctxPill(Svg: any, segs: { pct: number; hex: string }[], w: number, bh: number, tickPct?: number) {
70 const pad = 2
71 const H = bh + pad * 2
72 let x = 0
73 const bars = segs
74 .filter(sg => sg.pct > 0)
75 .map(sg => {
76 const sw = Math.max(0, Math.min(w - x, (sg.pct / 100) * w))
77 const out = `<rect x="${x.toFixed(2)}" y="${pad}" width="${sw.toFixed(2)}" height="${bh}" fill="${sg.hex}"/>`
78 x += sw
79 return out
80 })
81 .join('')
82 const tickMark = tickPct && tickPct > 0 && tickPct < 100 ? `<rect x="${((tickPct / 100) * w - 0.6).toFixed(2)}" y="0" width="1.2" height="${H}" rx="0.6" fill="#8b949e" opacity="0.85"/>` : ''
83 const source =
84 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${H}" viewBox="0 0 ${w} ${H}">` +
85 `<defs><clipPath id="p"><rect x="0" y="${pad}" width="${w}" height="${bh}" rx="${bh / 2}"/></clipPath></defs>` +
86 `<rect x="0" y="${pad}" width="${w}" height="${bh}" rx="${bh / 2}" fill="#8b949e" fill-opacity="0.28"/>` +
87 `<g clip-path="url(#p)">${bars}</g>${tickMark}</svg>`
88 return <Svg source={source} alt="Context window usage" width={w} height={H} />
89}
90
91function swatch(Svg: any, hex: string) {
92 return (
93 <Svg
94 source={`<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"><rect width="10" height="10" rx="3" fill="${hex}"/></svg>`}
95 alt="category colour"
96 width={10}
97 height={10}
98 />
99 )
100}
101
102function ctxBar(Box: any, segs: { pct: number; color: string }[], width: number) {
103 return (
104 <Box width={width} height={1} flexShrink={0} backgroundColor="gray">
105 {segs
106 .filter(x => x.pct > 0)
107 .map(x => (
108 <Box width={Math.max(0, Math.min(width, (x.pct / 100) * width))} height={1} flexShrink={0} backgroundColor={x.color} />
109 ))}
110 </Box>
111 )
112}
113
114const tone = (pct: number) => (pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green')
115
116type Sim = {
117 five: { percent: number; resetsAt: number }
118 week?: { percent: number; resetsAt: number }
119 mode: 'ok' | 'fail' | 'delay'
120 failedOnce: boolean
121 probeFails: number
122 unavailable: boolean
123 ctx?: number
124}
125
126type Overrides = Partial<Config> & { testMode?: boolean; dryActions?: boolean; legend?: boolean }
127
128let S: State | null = null
129let cfg: Config = { ...DEFAULTS }
130let testMode = false
131let dryActions = true
132let legend = false
133let sim: Sim | null = null
134let sid = ''
135let turnId: string | undefined
136let timer: { cancel: () => void } | undefined
137let loading: Promise<void> | undefined
138let lastText = ''
139let warned = false
140let booted = false
141const STAGGER_MS = 20_000
142const MAX_STAGGER_MS = 120_000
143
144let commandReady = false
145async function registerCommand($: any) {
146 if (commandReady) return
147 const spec = {
148 description: 'Quota guard: status, on/off, threshold, pause, cancel, resume, test',
149 argumentHint: '[status|on|off|threshold N|weekly N|ctxmin N|ctx [close]|pause [min]|cancel|resume|test on|off|sim ...]',
150 immediate: true as const,
151 }
152 try {
153 await $.command.register({ name: 'quota', ...spec })
154 await $.command.register({ name: 'quota-guard', ...spec })
155 commandReady = true
156 } catch (err) {
157 $.ui.log(PREFIX + `could not register /quota: ${String(err)}`)
158 }
159}
160
161let options: Readonly<Record<string, string | number | boolean | readonly string[]>> = {}
162const num = (k: string, d: number) => (typeof options[k] === 'number' ? (options[k] as number) : d)
163
164async function load($: any): Promise<void> {
165 await registerCommand($)
166 sid = await $.session.id()
167 const saved = (await $.store.get('state:' + sid)) as State | undefined
168 S = saved && saved.v === 1 ? saved : initialState()
169 const o = ((await $.store.get('cfg')) ?? {}) as Overrides
170 cfg = {
171 enabled: typeof options.enabled === 'boolean' ? options.enabled : DEFAULTS.enabled,
172 threshold: num('threshold', DEFAULTS.threshold),
173 weeklyThreshold: num('weeklyThreshold', DEFAULTS.weeklyThreshold),
174 abortThreshold: num('abortThreshold', DEFAULTS.abortThreshold),
175 ceiling: DEFAULTS.ceiling,
176 burnMultiplier: DEFAULTS.burnMultiplier,
177 graceSec: num('graceSeconds', DEFAULTS.graceSec),
178 maxResumeAttempts: num('maxResumeAttempts', DEFAULTS.maxResumeAttempts),
179 weeklyPolicy: options.weeklyPolicy === 'notify' ? 'notify' : 'pause',
180 notifyAt: num('notifyAt', DEFAULTS.notifyAt),
181 compactMinContext: num('compactMinContext', DEFAULTS.compactMinContext),
182 }
183 testMode = typeof options.testMode === 'boolean' ? options.testMode : false
184 const { testMode: tm, dryActions: da, legend: lg, ...rest } = o
185 cfg = { ...cfg, ...rest }
186 if (typeof tm === 'boolean') testMode = tm
187 if (typeof da === 'boolean') dryActions = da
188 if (typeof lg === 'boolean') legend = lg
189 sim = ((await $.store.get('sim')) as Sim | undefined) ?? null
190}
191
192// A failure in this mod must never stop prompts or tool calls: hooks catch, log once, and pass through.
193function logOnce($: any, err: unknown) {
194 if (warned) return
195 warned = true
196 try {
197 $.ui.log(PREFIX + `internal error, passing through: ${String(err)}`)
198 } catch {
199 /* nothing more to do */
200 }
201}
202
203async function usable($: any): Promise<boolean> {
204 try {
205 await ensure($)
206 return true
207 } catch (err) {
208 logOnce($, err)
209 return false
210 }
211}
212
213// Headless runs (claude -p, an SDK host with no UI) have no surface attached: never pause those,
214// nobody would be there to resume them. Fails open to "watched" when the engine cannot say.
215async function watched($: any): Promise<boolean> {
216 try {
217 return (await $.session.surfaces()).length > 0
218 } catch {
219 return true
220 }
221}
222
223// Every open session shares the same account windows, so they all pause and wake together.
224// Resume prompts are staggered through the shared store so they do not land in one burst.
225async function claimSlot($: any) {
226 try {
227 const now = await $.clock.now()
228 const last = Number(await $.store.get('lastResume')) || 0
229 let h = 0
230 for (const ch of sid) h = (h * 31 + ch.charCodeAt(0)) % 10
231 const at = Math.min(Math.max(now + h * 1000, last + STAGGER_MS), now + MAX_STAGGER_MS)
232 await $.store.set('lastResume', at)
233 if (at > now) await $.clock.sleep(at - now)
234 } catch {
235 /* staggering is best effort */
236 }
237}
238
239
240function ensure($: any): Promise<void> {
241 if (!commandReady) void registerCommand($)
242 if (S) return Promise.resolve()
243 loading ??= load($).catch(err => {
244 loading = undefined
245 throw err
246 })
247 return loading
248}
249
250const persist = ($: any) => $.store.set('state:' + sid, S).catch(() => {})
251const saveCfg = ($: any) =>
252 $.store.set('cfg', { ...cfg, testMode, dryActions, legend }).catch(() => {})
253const saveSim = ($: any) => $.store.set('sim', sim).catch(() => {})
254
255// ---------- usage ----------
256
257function simUsage(now: number, probing: boolean): Usage | null {
258 if (!sim || sim.unavailable) return null
259 const f = sim.five
260 if (sim.mode === 'delay' && probing && sim.probeFails > 0) {
261 sim.probeFails--
262 f.percent = 100
263 f.resetsAt = now + 120_000
264 } else if (f.resetsAt <= now && !(sim.mode === 'delay' && sim.probeFails > 0)) {
265 if (sim.mode === 'fail' && !sim.failedOnce) {
266 sim.failedOnce = true
267 f.resetsAt = now + 120_000
268 } else if (sim.mode !== 'fail' || sim.failedOnce) {
269 f.percent = 3
270 f.resetsAt = now + 5 * 3600_000
271 }
272 }
273 const windows: Win[] = [{ kind: FIVE, percent: f.percent, resetsAt: f.resetsAt }]
274 if (sim.week) windows.push({ kind: WEEK, percent: sim.week.percent, resetsAt: sim.week.resetsAt })
275 return { windows, contextPercent: sim.ctx }
276}
277
278function mapUsage(u: any): Usage {
279 const windows: Win[] = (u.rateLimits ?? []).map((r: any) => ({
280 kind: r.kind,
281 percent: r.percentUsed,
282 resetsAt: r.resetsAt ? Date.parse(r.resetsAt) : undefined,
283 }))
284 return { windows, contextPercent: u.context?.percent }
285}
286
287async function readUsage($: any): Promise<Usage | null> {
288 try {
289 if (testMode) {
290 const u = simUsage(await $.clock.now(), S?.rec.phase === 'resuming')
291 if (u && u.contextPercent === undefined) {
292 try {
293 u.contextPercent = (await $.session.usage()).context?.percent
294 } catch {
295 /* none */
296 }
297 }
298 return u
299 }
300 const u = mapUsage(await $.session.usage())
301 return u.windows.length ? u : null
302 } catch {
303 return null
304 }
305}
306
307async function publish($: any, usage: Usage | null, now: number) {
308 const st = S as State
309 const five = usage?.windows.find(w => w.kind === FIVE)
310 const week = usage?.windows.find(w => w.kind === WEEK)
311 const rst = (w?: Win) => (w?.resetsAt && w.resetsAt > now ? fmtDur(w.resetsAt - now) : undefined)
312 let ctx = usage?.contextPercent
313 let ctxTokens: number | undefined
314 let ctxWindow: number | undefined
315 let parts: QuotaBar['parts'] = []
316 try {
317 const b = (await $.session.usage({ breakdown: 'summary' })).context
318 ctx ??= b.percent ?? b.breakdown?.percentage
319 ctxTokens = b.tokens ?? b.breakdown?.totalTokens
320 ctxWindow = b.breakdown?.rawMaxTokens ?? b.window
321 const bd = b.breakdown
322 if (bd) await update($, detail, () => buildDetail(bd, bd.apiUsage))
323 if (bd && bd.rawMaxTokens > 0) {
324 parts = bd.categories
325 .filter((c: any) => c.kind === 'used' && c.tokens > 0)
326 .map((c: any) => ({ name: c.name, pct: (c.tokens / bd.rawMaxTokens) * 100 }))
327 .sort((a: { pct: number }, c: { pct: number }) => c.pct - a.pct)
328 }
329 } catch {
330 /* breakdown is cosmetic */
331 }
332 const v: QuotaBar = {
333 ctx,
334 parts,
335 legend,
336 ctxTokens,
337 ctxWindow,
338 five: five?.percent,
339 fiveReset: rst(five),
340 fiveAt: when(five, now),
341 week: week?.percent,
342 weekReset: rst(week),
343 weekAt: when(week, now),
344 threshold: cfg.threshold,
345 ctxMin: cfg.compactMinContext,
346 phase: st.rec.phase,
347 note:
348 st.rec.phase === 'paused' && st.rec.wakeAt
349 ? `paused until ${fmtClock(st.rec.wakeAt)}`
350 : usage ? undefined : 'waiting for first usage reading',
351 }
352 await update($, bar, () => v)
353}
354
355// ---------- dispatch / effects ----------
356
357async function dispatch($: any, ev: Ev): Promise<void> {
358 await ensure($)
359 if ((ev.type === 'measure' || ev.type === 'tick' || ev.type === 'forcePause') && !(await watched($))) return
360 const { state, fx } = step(S as State, ev, cfg)
361 S = state
362 await persist($)
363 sync($)
364 for (const f of fx) if (f.t === 'toast') say($, f.text)
365 const rest = fx.filter(f => f.t !== 'toast')
366 if (rest.length) void runEffects($, rest).catch((err: unknown) => logOnce($, err))
367}
368
369function say($: any, text: string) {
370 const t = (testMode ? '[test] ' : '') + text
371 $.ui.toast(t, { timeoutMs: 9000 })
372 $.ui.log(PREFIX + t)
373}
374
375function sync($: any) {
376 if (needsHeartbeat(S as State)) {
377 timer ??= $.clock.every(HEARTBEAT_MS, () => void tick($).catch((err: unknown) => logOnce($, err)))
378 } else if (timer) {
379 timer.cancel()
380 timer = undefined
381 }
382}
383
384async function tick($: any) {
385 await ensure($)
386 const now = await $.clock.now()
387 const usage = await readUsage($)
388 $.ui.status(statusLine(S as State, usage, now))
389 await dispatch($, { type: 'tick', now, usage })
390 await publish($, usage, now)
391}
392
393async function runEffects($: any, list: Fx[]) {
394 for (const f of list) {
395 try {
396 if (f.t === 'abort') {
397 if (turnId) await $.turn.abort({ turnId })
398 } else if (f.t === 'compact') {
399 await doCompact($, f.instructions)
400 } else if (f.t === 'submit') {
401 await doSubmit($, f.text)
402 } else if (f.t === 'fill') {
403 await $.prompt.fill({ text: f.text })
404 }
405 } catch (err) {
406 $.ui.log(PREFIX + `${f.t} failed: ${String(err)}`)
407 }
408 }
409}
410
411async function doCompact($: any, instructions: string) {
412 let ok = false
413 if (testMode && dryActions) {
414 $.ui.log(PREFIX + '[dry-run] would compact the conversation now')
415 ok = true
416 } else {
417 // compact rejects while a turn runs; the turn.complete hook that got us
418 // here has not returned yet, so retry until the session is idle.
419 for (let i = 0; i < 15 && !ok; i++) {
420 try {
421 const r = await $.session.compact({ instructions })
422 ok = !r || !('skip' in r) || r.skip === undefined
423 break
424 } catch {
425 await $.clock.sleep(1000)
426 }
427 }
428 }
429 await dispatch($, { type: 'compactDone', now: await $.clock.now(), ok })
430}
431
432async function doSubmit($: any, text: string) {
433 if (testMode && dryActions) {
434 $.ui.log(PREFIX + `[dry-run] would submit: ${text.split('\n')[0]}`)
435 $.clock.after(1500, () => void syntheticMeasure($).catch((err: unknown) => logOnce($, err)))
436 return
437 }
438 await claimSlot($)
439 for (let attempt = 0; attempt < 2; attempt++) {
440 try {
441 const r = await $.prompt.submit({ text })
442 if (!r || r.drop === undefined) return
443 } catch {
444 /* retry */
445 }
446 await $.clock.sleep(5000)
447 }
448 await dispatch($, { type: 'submitFailed', now: await $.clock.now(), text })
449}
450
451async function syntheticMeasure($: any) {
452 const now = await $.clock.now()
453 const usage = await readUsage($)
454 if (usage) await dispatch($, { type: 'measure', now, usage })
455 await saveSim($)
456}
457
458async function boot($: any) {
459 await ensure($)
460 if (booted || !(await watched($))) return
461 booted = true
462 const now = await $.clock.now()
463 const usage = await readUsage($)
464 await dispatch($, { type: 'boot', now, usage })
465 $.ui.status(statusLine(S as State, usage, now))
466 await publish($, usage, now)
467}
468
469// ---------- /quota ----------
470
471async function describe($: any): Promise<string> {
472 const now = await $.clock.now()
473 const usage = await readUsage($)
474 const st = S as State
475 const r = st.rec
476 const lines: string[] = []
477 lines.push(`Auto quota management: ${cfg.enabled ? 'ON' : 'OFF'}${testMode ? ` (TEST MODE, ${dryActions ? 'dry-run actions' : 'real actions'})` : ''}`)
478 lines.push(`Thresholds: 5-hour ${cfg.threshold}%, weekly ${cfg.weeklyThreshold}% (${cfg.weeklyPolicy}), compact only if context > ${cfg.compactMinContext}%, abort turn at ${cfg.abortThreshold}%, burn-rate ceiling ${cfg.ceiling}%`)
479 if (!usage) lines.push('Usage: unavailable right now (no rate-limit reading yet; subscriptions report it after the first response).')
480 else {
481 for (const w of usage.windows) lines.push(describeWindow(w, now))
482 if (usage.contextPercent !== undefined) lines.push(`Context window: ${usage.contextPercent}% (handled by normal compaction, never a quota pause)`)
483 }
484 const jump = st.deltas.length ? Math.max(...st.deltas) : 0
485 lines.push(`Recent largest jump: ${jump.toFixed(1)} pts`)
486 lines.push(`State: ${r.phase.toUpperCase()}${r.reason ? ` - ${r.reason}` : ''}`)
487 if (r.phase === 'paused' && r.wakeAt) lines.push(`Paused until approximately ${fmtClock(r.wakeAt)} (in ${fmtDur(r.wakeAt - now)}); resume attempts used: ${r.resumeAttempts}/${cfg.maxResumeAttempts}`)
488 if (r.compactedAt) lines.push(`Compacted at ${fmtClock(r.compactedAt)}${r.compactFailed ? ' (failed)' : ''}`)
489 if (r.task) lines.push(`Task on record: ${r.task.slice(0, 160)}${r.task.length > 160 ? '...' : ''}`)
490 return lines.join('\n')
491}
492
493async function startSim($: any, what: string, arg?: string): Promise<string> {
494 const now = await $.clock.now()
495 sim ??= { five: { percent: 10, resetsAt: now + 5 * 3600_000 }, mode: 'ok', failedOnce: false, probeFails: 0, unavailable: false }
496 const set = (p: number, ms: number) => {
497 sim!.five = { percent: p, resetsAt: now + ms }
498 sim!.failedOnce = false
499 }
500 switch (what) {
501 case '80': set(80, 94 * 60_000); break
502 case '94': set(94, 94 * 60_000); break
503 case 'exhausted': set(100, 90 * 60_000); break
504 case 'reset60': set(100, 60_000); break
505 case 'clear': set(10, 5 * 3600_000); sim.week = undefined; break
506 case 'weekly': sim.week = { percent: 100, resetsAt: now + 3 * 60_000 }; break
507 case 'reset-ok': sim.mode = 'ok'; sim.probeFails = 0; break
508 case 'reset-fail': sim.mode = 'fail'; sim.failedOnce = false; break
509 case 'reset-delay': sim.mode = 'delay'; sim.probeFails = 1; break
510case 'ctx': {
511 const n = Number(arg)
512 sim.ctx = Number.isFinite(n) ? n : undefined
513 await saveSim($)
514 return sim.ctx === undefined ? 'Simulated context cleared (real value used).' : `Simulated context set to ${sim.ctx}%.`
515 }
516 case 'unavailable': sim.unavailable = !sim.unavailable; break
517 case 'restart': {
518 timer?.cancel()
519 timer = undefined
520 S = null
521 loading = undefined
522 booted = false
523 turnId = undefined
524 await boot($)
525 return 'Simulated restart: in-memory state dropped and reloaded from the store.'
526 }
527 default:
528 return 'sim: ctx N | 80 | 94 | exhausted | reset60 | weekly | reset-ok | reset-fail | reset-delay | unavailable | clear | restart'
529 }
530 await saveSim($)
531 if (['80', '94', 'exhausted', 'reset60', 'weekly', 'clear'].includes(what)) await syntheticMeasure($)
532 return `sim ${what} applied. Five-hour: ${sim.five.percent}% (resets ${fmtClock(sim.five.resetsAt)}), mode ${sim.mode}${sim.unavailable ? ', usage unavailable' : ''}.`
533}
534
535async function command($: any, args: string): Promise<string> {
536 const [cmd = 'status', a, b] = args.split(/\s+/)
537 const now = await $.clock.now()
538 switch (cmd.toLowerCase()) {
539 case '':
540 case 'status': return describe($)
541 case 'on':
542 case 'off':
543 cfg.enabled = cmd === 'on'
544 if (!cfg.enabled) await dispatch($, { type: 'cancel', now })
545 await saveCfg($)
546 return `Automatic quota management ${cfg.enabled ? 'enabled' : 'disabled'}.`
547 case 'threshold':
548 case 'weekly': {
549 const n = Number(a)
550 if (!Number.isFinite(n) || n < 50 || n > 100) return `Usage: /quota ${cmd} <50-100>`
551 if (cmd === 'threshold') cfg.threshold = n
552 else cfg.weeklyThreshold = n
553 await saveCfg($)
554 return `${cmd === 'threshold' ? '5-hour' : 'Weekly'} threshold set to ${n}%.`
555 }
556 case 'pause': {
557 const m = a ? Number(a) : 2
558 if (!Number.isFinite(m) || m < 0.1) return 'Usage: /quota pause [minutes]'
559 await dispatch($, { type: 'forcePause', now, minutes: m })
560 return `Forced pause requested for ${m} minute(s)${testMode && dryActions ? ' (dry-run: no real compaction)' : ' (real compaction will run)'}.`
561 }
562 case 'cancel':
563 await dispatch($, { type: 'cancel', now })
564 return 'Cancel processed.'
565 case 'resume':
566 await dispatch($, { type: 'resume', now })
567 return 'Resume processed.'
568 case 'test': {
569 if (a === 'on' || a === 'off') {
570 testMode = a === 'on'
571 await saveCfg($)
572 return `Test mode ${testMode ? 'ON: usage is simulated (/quota sim ...)' : 'OFF: real usage'}.`
573 }
574 if (a === 'actions' && (b === 'real' || b === 'dry')) {
575 dryActions = b === 'dry'
576 await saveCfg($)
577 return `Test-mode actions: ${b}.`
578 }
579 return 'Usage: /quota test on|off or /quota test actions real|dry'
580 }
581 case 'ctx':
582 case 'legend':
583 if (a === 'close') {
584 await $.ui.close({ id: PANE })
585 return 'Context pane closed.'
586 }
587 await publish($, await readUsage($), now)
588 await $.ui.open({ id: PANE, title: 'Context' })
589 return 'Context pane opened (each category has its own colour, largest first).'
590 case 'ctxmin': {
591 const n = Number(a)
592 if (!Number.isFinite(n) || n < 0 || n > 100) return 'Usage: /quota ctxmin <0-100>'
593 cfg.compactMinContext = n
594 await saveCfg($)
595 return `At the quota threshold, compaction now happens only when context is above ${n}%; otherwise it just waits for the reset.`
596 }
597 case 'sim':
598 if (!testMode) return 'Turn on test mode first: /quota test on'
599return startSim($, a ?? '', b)
600 default:
601 return 'Commands: status | on | off | threshold N | weekly N | pause [min] | cancel | resume | test on|off | test actions real|dry | sim <scenario>'
602 }
603}
604
605async function runQuota($: any, e: { args: string }) {
606 if (!(await usable($))) return { text: PREFIX + 'not available (could not load saved state).' }
607 return { text: await command($, e.args.trim()) }
608}
609
610export const register: Register = (on, opts) => {
611 options = opts
612
613 // ---------- hooks (every one fails open) ----------
614
615 on('session.start', async ($, e, next) => {
616 try {
617 await registerCommand($)
618 await boot($)
619 void $.ui.open({ id: PANE, title: 'Context' })
620 } catch (err) {
621 logOnce($, err)
622 }
623 return next(e)
624 })
625
626 // a UI attaching after start (Desktop attaches late): run the startup check then
627 on('session.attach', async ($, e, next) => {
628 try {
629 await boot($)
630 } catch (err) {
631 logOnce($, err)
632 }
633 return next(e)
634 })
635
636 on('session.measure', async ($, e, next) => {
637 if (await usable($)) {
638 try {
639 const now = await $.clock.now()
640 const usage = testMode ? await readUsage($) : mapUsage(e)
641 if (usage && usage.windows.length) {
642 await dispatch($, { type: 'measure', now, usage })
643 $.ui.status(statusLine(S as State, usage, now))
644 await publish($, usage, now)
645 }
646 } catch (err) {
647 logOnce($, err)
648 }
649 }
650 return next(e)
651 })
652
653 on('turn.start', async ($, e, next) => {
654 turnId = e.turnId
655 try {
656 await dispatch($, { type: 'turnStart' })
657 } catch (err) {
658 logOnce($, err)
659 }
660 return next(e)
661 })
662
663 on('turn.complete', async ($, e, next) => {
664 const r = await next(e)
665 if (e.agentId === undefined) {
666 turnId = undefined
667 try {
668 // detached: this hook must return before a compaction can run
669 void dispatch($, { type: 'turnComplete', now: await $.clock.now(), reason: e.reason }).catch((err: unknown) => logOnce($, err))
670 } catch (err) {
671 logOnce($, err)
672 }
673 }
674 return r
675 })
676
677 on('prompt.submit', async ($, e, next) => {
678 if (e.origin.kind === 'plugin' || !(await usable($))) return next(e)
679 try {
680 const isUser = USER_KINDS.includes(e.origin.kind)
681 if (!isUser && cfg.enabled && blocksAutomation(S as State)) {
682 return { drop: PREFIX + 'paused for a usage limit; automatic prompts are held.' }
683 }
684 lastText = e.text
685 await dispatch($, { type: 'userPrompt', now: await $.clock.now(), text: e.text, isUser })
686 } catch (err) {
687 logOnce($, err)
688 }
689 return next(e)
690 }).catch(($, e, next) => next(e))
691
692 on('session.compact', async ($, e, next) => {
693 const r = await next(e)
694 try {
695 if (e.trigger !== 'precompute' && r && r.skip === undefined && (await usable($))) {
696 // our own compaction is reported by doCompact; this catches everyone else's
697 if ((S as State).rec.phase !== 'compacting') {
698 await dispatch($, { type: 'compactDone', now: await $.clock.now(), ok: true, external: true })
699 }
700 }
701 } catch (err) {
702 logOnce($, err)
703 }
704 return r
705 }).catch(($, e, next) => next(e))
706
707 on('tool.call', async ($, e, next) => {
708 try {
709 if ((await usable($)) && cfg.enabled && (S as State).rec.phase === 'paused') {
710 return { deny: PREFIX + 'paused until the usage limit resets.' }
711 }
712 } catch (err) {
713 logOnce($, err)
714 }
715 return next(e)
716 }).catch(($, e, next) => next(e))
717
718 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
719 if (!(await usable($))) return next(e)
720 if (e.props.hasSurvey) return next(e)
721 const v = await read($, bar)
722 const { Box, Text } = $.ui.resolve(e)
723 const Svg = (e.surface !== 'terminal' ? ($.ui.resolve(e) as any).Svg : undefined) as any
724 if (!v) {
725 return (
726 <Box>
727 <Text dimColor>quota-guard · waiting for first usage reading</Text>
728 </Box>
729 )
730 }
731 const cols = e.props.bodyColumns
732 const tier = cols >= 100 ? 'full' : cols >= 64 ? 'mid' : 'min'
733 const T = ({ children, ...rest }: any) => (
734 <Text wrap="truncate" {...rest}>
735 {children}
736 </Text>
737 )
738 const meter = (pct: number, n: number) => {
739 const f = Math.max(0, Math.min(n, Math.round((pct / 100) * n)))
740 return (
741 <T>
742 <Text color={tone(pct)}>{'▰'.repeat(f)}</Text>
743 <Text dimColor>{'▱'.repeat(n - f)}</Text>
744 </T>
745 )
746 }
747 const n = tier === 'min' ? 5 : 10
748 const seg = (name: string, long: string, pct?: number, left?: string, at?: string) => {
749 if (pct === undefined) return null
750 return (
751 <Box gap={1} flexShrink={0}>
752 <T dimColor>{tier === 'full' ? long : name}</T>
753 {meter(pct, n)}
754 <T bold>{Math.round(pct)}%</T>
755 {left ? <T dimColor>{tier === 'full' && at ? `${at} · ${left.replace(/ /g, '')}` : left.replace(/ /g, '')}</T> : null}
756 </Box>
757 )
758 }
759 const parts = [...(v.parts ?? [])].filter(x => x.pct > 0).sort((x, y) => y.pct - x.pct)
760 const segs = parts.length
761 ? parts.map((x, i) => ({ pct: x.pct, color: colorAt(i) }))
762 : v.ctx !== undefined
763 ? [{ pct: v.ctx, color: tone(v.ctx) }]
764 : []
765 const ctx =
766 v.ctx === undefined ? null : (
767 <Box gap={1} flexShrink={0}>
768 <T dimColor>ctx</T>
769 {Svg
770 ? ctxPill(Svg, segs.map((sg, i) => ({ pct: sg.pct, hex: parts.length ? hexAt(i) : sg.color === 'red' ? hexAt(4) : sg.color === 'yellow' ? hexAt(2) : hexAt(0) })), tier === 'min' ? 70 : tier === 'mid' ? 110 : 150, 8, v.ctxMin)
771 : ctxBar(Box, segs, tier === 'min' ? 8 : tier === 'mid' ? 14 : 20)}
772 <T bold>{Math.round(v.ctx)}%</T>
773 </Box>
774 )
775 const alert = !!v.note || v.phase !== 'idle' || testMode
776 const state = v.note ?? (v.phase === 'idle' ? undefined : v.phase)
777 return (
778 <Box gap={3}>
779 {seg('5h', '5h session', v.five, v.fiveReset, v.fiveAt)}
780 {seg('7d', '7d week', v.week, v.weekReset, v.weekAt)}
781 {ctx}
782 {alert ? (
783 <T color="yellow">
784 {testMode ? '[test] ' : ''}
785 {state ?? ''}
786 </T>
787 ) : null}
788 </Box>
789 )
790 })
791
792 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
793 if (!(await usable($))) return next(e)
794 const d = await read($, detail)
795 const { Box, Text } = $.ui.resolve(e)
796 const Svg = (e.surface !== 'terminal' ? ($.ui.resolve(e) as any).Svg : undefined) as any
797 const barState = await read($, bar)
798 const w = Math.max(10, Math.min(36, e.props.bodyColumns - 4))
799 if (!d) return <Text dimColor>Waiting for a context reading…</Text>
800 const T = ({ children, ...rest }: any) => (
801 <Text wrap="truncate" {...rest}>
802 {children}
803 </Text>
804 )
805 const win = d.window || 1
806 const ranked = d.categories.filter(c => c.kind === 'used' && c.tokens > 0).sort((x, y) => y.tokens - x.tokens)
807 const row = (color: string | undefined, name: string, tokens: number, rank?: number) => (
808 <Box gap={1}>
809 {Svg && rank !== undefined ? swatch(Svg, hexAt(rank)) : <T color={color} dimColor={!color}>■</T>}
810 <Box flexGrow={1}>
811 <T>{name}</T>
812 </Box>
813 <T>{fmtTok(tokens)}</T>
814 <T dimColor>{((tokens / win) * 100).toFixed(1)}%</T>
815 </Box>
816 )
817 const heading = (t: string) => (
818 <Box marginTop={1}>
819 <T bold>{t}</T>
820 </Box>
821 )
822 const sub = (items: { name: string; tokens: number }[]) =>
823 items.map(i => (
824 <Box gap={1} paddingLeft={2}>
825 <Box flexGrow={1}>
826 <T dimColor>{i.name}</T>
827 </Box>
828 <T dimColor>{fmtTok(i.tokens)}</T>
829 </Box>
830 ))
831 const free = d.categories.find(c => c.kind === 'free')
832 const buffer = d.categories.find(c => c.kind === 'buffer')
833 const deferred = d.categories.filter(c => c.kind === 'deferred')
834 return (
835 <Box flexDirection="column" paddingX={1}>
836 <T bold>
837 Context · {fmtTok(d.total)} / {fmtTok(win)} · {Math.round(d.percent)}%
838 </T>
839 <T dimColor>{d.model}</T>
840 <Box marginTop={1}>
841 {Svg
842 ? ctxPill(Svg, ranked.map((c, i) => ({ pct: (c.tokens / win) * 100, hex: hexAt(i) })), Math.max(120, Math.min(320, e.props.bodyColumns * 7)), 10, barState?.ctxMin)
843 : ctxBar(Box, ranked.map((c, i) => ({ pct: (c.tokens / win) * 100, color: colorAt(i) })), w)}
844 </Box>
845 {heading('Where it goes')}
846 {ranked.map((c, i) => row(colorAt(i), c.name, c.tokens, i))}
847 {free ? row(undefined, 'Free space', free.tokens) : null}
848 {buffer ? row(undefined, 'Autocompact buffer', buffer.tokens) : null}
849 {d.autoAt ? (
850 <T dimColor>
851 Auto-compact at {fmtTok(d.autoAt)} ({fmtTok(Math.max(0, d.autoAt - d.total))} to go)
852 </T>
853 ) : (
854 <T dimColor>Auto-compact is off</T>
855 )}
856 {d.memory.length ? heading('Memory files') : null}
857 {sub(d.memory.map(m => ({ name: m.path, tokens: m.tokens })))}
858 {d.mcp.length ? heading('MCP servers') : null}
859 {sub(d.mcp.map(m => ({ name: m.server, tokens: m.tokens })))}
860 {d.agents.length ? heading('Agents') : null}
861 {sub(d.agents)}
862 {d.skills ? heading(`Skills · ${d.skills.count}/${d.skills.total} listed · ${fmtTok(d.skills.tokens)}`) : null}
863 {d.skills ? sub(d.skills.top) : null}
864 {deferred.length ? heading('Loaded on demand (not in window)') : null}
865 {sub(deferred.map(c => ({ name: c.name, tokens: c.tokens })))}
866 {d.api ? heading('Last response') : null}
867 {d.api ? (
868 <T dimColor>
869 in {fmtTok(d.api.input ?? 0)} · cache read {fmtTok(d.api.cacheRead ?? 0)} · cache write {fmtTok(d.api.cacheWrite ?? 0)} · out {fmtTok(d.api.output ?? 0)}
870 </T>
871 ) : null}
872 </Box>
873 )
874 })
875
876 on('session.end', ($, e, next) => {
877 timer?.cancel()
878 timer = undefined
879 return next(e)
880 })
881
882 on('command.run', { command: 'quota' }, runQuota).catch(() => ({ text: PREFIX + 'internal error; see the debug log.' }))
883 on('command.run', { command: 'quota-guard' }, runQuota).catch(() => ({ text: PREFIX + 'internal error; see the debug log.' }))
884}
885hooks/core.ts 538 lines1// Pure quota state machine. No engine imports: the adapter (register.ts) feeds
2// it events and executes the effects it returns, so everything here is
3// unit-testable with plain values.
4
5export type Phase = 'idle' | 'armed' | 'compacting' | 'paused' | 'resuming' | 'stopped'
6
7export const FIVE = 'five_hour'
8export const WEEK = 'seven_day'
9
10export type Win = { kind: string; percent: number; resetsAt?: number }
11export type Usage = { windows: Win[]; contextPercent?: number }
12
13export type Config = {
14 enabled: boolean
15 threshold: number // 5-hour compaction/pause threshold, %
16 weeklyThreshold: number // weekly threshold, %
17 abortThreshold: number // abort the running turn at/above this, %
18 ceiling: number // burn-rate ceiling: pause if pct + predicted next jump >= this
19 burnMultiplier: number
20 graceSec: number // wait this long after the reported reset before re-checking
21 maxResumeAttempts: number
22 weeklyPolicy: 'pause' | 'notify'
23 notifyAt: number // one-off heads-up toast at this %, 0 = off
24 compactMinContext: number // compact before pausing only if context is above this %
25}
26
27export const DEFAULTS: Config = {
28 enabled: true,
29 threshold: 94,
30 weeklyThreshold: 97,
31 abortThreshold: 98,
32 ceiling: 99,
33 burnMultiplier: 1.5,
34 graceSec: 30,
35 maxResumeAttempts: 5,
36 weeklyPolicy: 'pause',
37 notifyAt: 80,
38 compactMinContext: 30,
39}
40
41export type Rec = {
42 phase: Phase
43 reason?: string
44 kinds: string[]
45 expectedReset?: number
46 wakeAt?: number
47 task: string
48 pausedAt?: number
49 compactedAt?: number
50 compactFailed?: boolean
51 usage: Win[]
52 resumeAttempts: number
53 delayCount: number
54 lastResumeAt?: number
55 abortRequested?: boolean
56}
57
58export type State = {
59 v: 1
60 rec: Rec
61 busy: boolean
62 deltas: number[] // recent 5-hour jumps per measurement, for burn-rate
63 last?: { pct: number; resetsAt?: number }
64 notified: string[]
65 ctx?: number // last known context-window fill, %
66}
67
68export type Ev =
69 | { type: 'measure'; now: number; usage: Usage }
70 | { type: 'turnStart' }
71 | { type: 'turnComplete'; now: number; reason: string }
72 | { type: 'userPrompt'; now: number; text: string; isUser: boolean }
73 | { type: 'compactDone'; now: number; ok: boolean; external?: boolean }
74 | { type: 'tick'; now: number; usage: Usage | null }
75 | { type: 'boot'; now: number; usage: Usage | null }
76 | { type: 'submitFailed'; now: number; text: string }
77 | { type: 'forcePause'; now: number; minutes: number }
78 | { type: 'cancel'; now: number }
79 | { type: 'resume'; now: number }
80
81export type Fx =
82 | { t: 'toast'; text: string }
83 | { t: 'abort' }
84 | { t: 'compact'; instructions: string }
85 | { t: 'submit'; text: string }
86 | { t: 'fill'; text: string }
87
88export type Result = { state: State; fx: Fx[] }
89
90export const RESUME_PROMPT =
91 'Usage window has reset. Continue the previous task from the compacted context and persisted recovery state. Do not restart completed work.'
92
93const BACKOFF_SEC = [60, 120, 300, 600, 900]
94const RESUME_WATCHDOG_MS = 180_000
95
96export function emptyRec(task = ''): Rec {
97 return { phase: 'idle', kinds: [], task, usage: [], resumeAttempts: 0, delayCount: 0 }
98}
99
100export function initialState(): State {
101 return { v: 1, rec: emptyRec(), busy: false, deltas: [], notified: [] }
102}
103
104// ---------- formatting ----------
105
106const pad = (n: number) => (n < 10 ? '0' + n : String(n))
107
108export function fmtClock(ms: number): string {
109 const d = new Date(ms)
110 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
111}
112
113export function fmtDur(ms: number): string {
114 const m = Math.max(0, Math.round(ms / 60000))
115 if (m < 1) return '<1m'
116 const h = Math.floor(m / 60)
117 if (h >= 24) return `${Math.floor(h / 24)}d ${h % 24}h`
118 return h > 0 ? `${h}h ${pad(m % 60)}m`.replace(/ 0(\d)m/, ' $1m') : `${m}m`
119}
120
121const label = (kind: string) => (kind === FIVE ? '5-hour' : kind === WEEK ? 'Weekly' : kind)
122
123export function describeWindow(w: Win, now: number): string {
124 const reset = w.resetsAt === undefined ? '' : w.resetsAt <= now ? ', reset time passed' : `, resets in ${fmtDur(w.resetsAt - now)}`
125 return `${label(w.kind)} usage: ${Math.round(w.percent)}%${reset}`
126}
127
128// ---------- helpers ----------
129
130const clone = (s: State): State => JSON.parse(JSON.stringify(s))
131
132function limitFor(kind: string, cfg: Config): number | undefined {
133 if (kind === FIVE) return cfg.threshold
134 if (kind === WEEK) return cfg.weeklyPolicy === 'pause' ? cfg.weeklyThreshold : undefined
135 return undefined
136}
137
138export function predictedJump(s: State, cfg: Config): number {
139 const m = s.deltas.length ? Math.max(...s.deltas) : 0
140 return Math.min(25, m * cfg.burnMultiplier)
141}
142
143function breaches(u: Usage, s: State, cfg: Config): Win[] {
144 const out: Win[] = []
145 for (const w of u.windows) {
146 const lim = limitFor(w.kind, cfg)
147 if (lim === undefined) continue
148 if (w.percent >= lim || (w.kind === FIVE && w.percent + predictedJump(s, cfg) >= cfg.ceiling)) out.push(w)
149 }
150 return out
151}
152
153const backoffMs = (n: number) => (BACKOFF_SEC[Math.min(n, BACKOFF_SEC.length - 1)] ?? 900) * 1000
154
155export function blocksAutomation(s: State): boolean {
156 return s.rec.phase === 'paused' || s.rec.phase === 'stopped' || s.rec.phase === 'compacting'
157}
158
159export function compactInstructions(task: string): string {
160 return (
161 'A usage-limit pause is about to start and work will resume automatically afterwards. ' +
162 'In the summary, state verbatim the current task' +
163 (task ? ` ("${task.slice(0, 600)}")` : '') +
164 ', what is already done, what remains, and the exact next step. Do not restart completed work.'
165 )
166}
167
168export function resumePrompt(r: Rec): string {
169 const bits = [RESUME_PROMPT]
170 if (r.task) bits.push(`Task before the pause: ${r.task.slice(0, 1500)}`)
171 return bits.join('\n\n')
172}
173
174// ---------- the machine ----------
175
176export function step(s0: State, ev: Ev, cfg: Config): Result {
177 const s = clone(s0)
178 const fx: Fx[] = []
179 const toast = (text: string) => fx.push({ t: 'toast', text })
180 const r = s.rec
181
182 const settle = (task = r.task) => {
183 s.rec = emptyRec(task)
184 }
185
186 const shouldCompact = () => s.ctx === undefined || s.ctx > cfg.compactMinContext
187
188 const compactOrPause = (now: number) => {
189 if (shouldCompact()) startCompact()
190 else {
191 r.compactedAt = undefined
192 enterPause(now)
193 }
194 }
195
196 const startCompact = () => {
197 r.phase = 'compacting'
198 fx.push({ t: 'compact', instructions: compactInstructions(r.task) })
199 }
200
201 const enterPause = (now: number) => {
202 r.phase = 'paused'
203 r.pausedAt = now
204 const grace = cfg.graceSec * 1000
205 r.wakeAt = r.expectedReset === undefined ? now + backoffMs(r.delayCount) : Math.max(r.expectedReset + grace, now + 5000)
206 if (r.compactFailed) toast('Compaction failed; pausing anyway.')
207 toast(`Paused until approximately ${fmtClock(r.wakeAt)}.`)
208 }
209
210 const arm = (now: number, wins: Win[], all: Usage | null, reasonOverride?: string) => {
211 const resets = wins.map(w => w.resetsAt).filter((x): x is number => typeof x === 'number')
212 const task = r.task
213 const fresh: Rec = {
214 ...emptyRec(task),
215 phase: 'armed',
216 kinds: wins.map(w => w.kind),
217 reason: reasonOverride ?? wins.map(w => describeWindow(w, now)).join('; '),
218 expectedReset: resets.length ? Math.max(...resets) : undefined,
219 usage: all ? all.windows.map(w => ({ ...w })) : wins.map(w => ({ ...w })),
220 }
221 for (const k of Object.keys(r)) delete (r as Record<string, unknown>)[k]
222 Object.assign(r, fresh)
223 const top = wins[0]
224 if (top) {
225 const pct = Math.round(top.percent)
226 toast(
227 shouldCompact()
228 ? `${label(top.kind)} usage reached ${pct}%. Compacting before quota exhaustion.`
229 : `${label(top.kind)} usage reached ${pct}%. Context is only ${Math.round(s.ctx ?? 0)}%, so no compaction needed; waiting for the reset.`,
230 )
231 }
232 if (s.busy) {
233 if (top && top.percent >= cfg.abortThreshold) {
234 r.abortRequested = true
235 fx.push({ t: 'abort' })
236 }
237 } else compactOrPause(now)
238 }
239
240 const resumeNow = (now: number, mode: 'confirmed' | 'assumed' | 'forced') => {
241 if (r.resumeAttempts >= cfg.maxResumeAttempts) {
242 r.phase = 'stopped'
243 r.wakeAt = undefined
244 toast(`Auto-resume gave up after ${r.resumeAttempts} attempts. Use /quota resume or send a prompt.`)
245 return
246 }
247 r.phase = 'resuming'
248 r.resumeAttempts++
249 r.lastResumeAt = now
250 toast(
251 mode === 'confirmed'
252 ? 'Usage reset confirmed. Resuming task.'
253 : mode === 'assumed'
254 ? 'Reset time passed (usage not re-measured yet). Resuming task to verify.'
255 : 'Resuming task.',
256 )
257 fx.push({ t: 'submit', text: resumePrompt(r) })
258 }
259
260 const resumeFailed = (now: number, why: string, nextReset?: number) => {
261 r.delayCount++
262 if (r.resumeAttempts >= cfg.maxResumeAttempts) {
263 r.phase = 'stopped'
264 r.wakeAt = undefined
265 toast(`Auto-resume gave up (${why}). Use /quota resume when the limit has reset.`)
266 return
267 }
268 r.phase = 'paused'
269 if (nextReset !== undefined) r.expectedReset = nextReset
270 r.wakeAt = Math.max(nextReset !== undefined ? nextReset + cfg.graceSec * 1000 : 0, now + backoffMs(r.delayCount))
271 toast(`Resume did not take (${why}). Trying again around ${fmtClock(r.wakeAt)}.`)
272 }
273
274 const wake = (now: number, usage: Usage | null) => {
275 if (!usage || usage.windows.length === 0) {
276 r.delayCount++
277 const lateAndBlind = r.delayCount >= 3 && (r.expectedReset === undefined || r.expectedReset <= now)
278 if (lateAndBlind) return resumeNow(now, 'assumed')
279 r.wakeAt = now + backoffMs(r.delayCount)
280 if (r.delayCount === 1) toast('Usage data unavailable; will retry shortly.')
281 return
282 }
283 const limited: number[] = []
284 let stale = false
285 for (const k of r.kinds) {
286 const w = usage.windows.find(x => x.kind === k)
287 const lim = limitFor(k, cfg) ?? cfg.threshold
288 if (!w) {
289 stale = true
290 continue
291 }
292 if (w.percent >= lim) {
293 if (w.resetsAt !== undefined && w.resetsAt > now) limited.push(w.resetsAt)
294 else stale = true // reading predates the reset; only a fresh response can tell
295 }
296 }
297 if (limited.length) {
298 const next = Math.max(...limited)
299 const moved = r.expectedReset === undefined || Math.abs(next - r.expectedReset) > 60_000
300 r.expectedReset = next
301 r.wakeAt = next + cfg.graceSec * 1000
302 r.delayCount++
303 toast(
304 moved
305 ? `Quota not reset yet; reset time moved to about ${fmtClock(next)}.`
306 : `Quota not reset yet; rechecking around ${fmtClock(r.wakeAt)}.`,
307 )
308 return
309 }
310 resumeNow(now, stale ? 'assumed' : 'confirmed')
311 }
312
313 const tickLike = (now: number, usage: Usage | null) => {
314 if (r.phase === 'paused' && r.wakeAt !== undefined && now >= r.wakeAt) wake(now, usage)
315 else if (r.phase === 'resuming' && r.lastResumeAt !== undefined && now - r.lastResumeAt > RESUME_WATCHDOG_MS) {
316 resumeFailed(now, 'no response to the resume prompt')
317 }
318 }
319
320 const evaluate = (now: number, usage: Usage) => {
321 const hit = breaches(usage, s, cfg)
322 if (hit.length) arm(now, hit, usage)
323 }
324
325 switch (ev.type) {
326 case 'measure': {
327 if (typeof ev.usage.contextPercent === 'number') s.ctx = ev.usage.contextPercent
328 if (!cfg.enabled) break
329 const u = ev.usage
330 const five = u.windows.find(w => w.kind === FIVE)
331 if (five) {
332 const sameWindow = s.last && Math.abs((s.last.resetsAt ?? 0) - (five.resetsAt ?? 0)) < 120_000
333 if (sameWindow && s.last && five.percent > s.last.pct) {
334 s.deltas.push(five.percent - s.last.pct)
335 s.deltas = s.deltas.slice(-5)
336 }
337 s.last = { pct: five.percent, resetsAt: five.resetsAt }
338 }
339 const hit = breaches(u, s, cfg)
340
341 if (r.phase === 'resuming') {
342 const still = u.windows.filter(w => r.kinds.includes(w.kind) && w.percent >= (limitFor(w.kind, cfg) ?? cfg.threshold) && (w.resetsAt ?? Infinity) > ev.now)
343 if (still.length) {
344 const next = Math.max(...still.map(w => w.resetsAt ?? ev.now))
345 resumeFailed(ev.now, 'quota still exhausted', Number.isFinite(next) ? next : undefined)
346 } else {
347 const task = r.task
348 settle(task)
349 toast('Quota reset verified.')
350 }
351 break
352 }
353 if (r.phase === 'idle') {
354 if (hit.length) {
355 arm(ev.now, hit, u)
356 break
357 }
358 if (cfg.notifyAt > 0) {
359 for (const w of u.windows) {
360 if (limitFor(w.kind, cfg) === undefined && w.kind !== WEEK) continue
361 const key = `${w.kind}@${Math.round((w.resetsAt ?? 0) / 60000)}`
362 if (w.percent >= cfg.notifyAt && !s.notified.includes(key)) {
363 s.notified = [...s.notified, key].slice(-10)
364 toast(describeWindow(w, ev.now))
365 }
366 }
367 }
368 break
369 }
370 if (r.phase === 'armed') {
371 r.usage = u.windows.map(w => ({ ...w }))
372 const worst = hit.reduce((m, w) => Math.max(m, w.percent), 0)
373 if (s.busy && !r.abortRequested && worst >= cfg.abortThreshold) {
374 r.abortRequested = true
375 fx.push({ t: 'abort' })
376 }
377 break
378 }
379 if (r.phase === 'paused' || r.phase === 'stopped') {
380 const resets = u.windows.filter(w => r.kinds.includes(w.kind) && w.resetsAt !== undefined && w.resetsAt > ev.now).map(w => w.resetsAt as number)
381 if (r.phase === 'paused' && resets.length) {
382 const next = Math.max(...resets)
383 if (r.expectedReset === undefined || Math.abs(next - r.expectedReset) > 60_000) {
384 r.expectedReset = next
385 r.wakeAt = next + cfg.graceSec * 1000
386 toast(`Reset time changed; now paused until approximately ${fmtClock(r.wakeAt)}.`)
387 }
388 }
389 }
390 break
391 }
392
393 case 'turnStart':
394 s.busy = true
395 break
396
397 case 'turnComplete': {
398 s.busy = false
399 if (r.phase === 'armed') {
400 if (r.compactedAt !== undefined) enterPause(ev.now)
401 else compactOrPause(ev.now)
402 } else if (r.phase === 'resuming') {
403 if (ev.reason === 'error') resumeFailed(ev.now, 'API error')
404 else if (ev.reason === 'aborted') {
405 settle()
406 toast('Resume turn was interrupted; auto-resume finished.')
407 } else {
408 settle()
409 toast('Quota reset verified.')
410 }
411 }
412 break
413 }
414
415 case 'userPrompt': {
416 if (!ev.isUser) break
417 if (r.phase === 'paused' || r.phase === 'stopped') {
418 settle(r.task)
419 toast('Auto-resume cancelled: you resumed manually.')
420 } else if (r.phase === 'resuming') {
421 settle(r.task)
422 }
423 const t = ev.text.trim()
424 if (t && !t.startsWith('/') && (t.length >= 12 || !s.rec.task)) s.rec.task = t.slice(0, 4000)
425 break
426 }
427
428 case 'compactDone': {
429 if (ev.external) {
430 if (r.phase === 'armed' || r.phase === 'compacting') {
431 r.compactedAt = ev.now
432 if (!s.busy) enterPause(ev.now)
433 }
434 break
435 }
436 if (r.phase === 'compacting') {
437 r.compactedAt = ev.ok ? ev.now : undefined
438 r.compactFailed = !ev.ok
439 enterPause(ev.now)
440 }
441 break
442 }
443
444 case 'tick':
445 tickLike(ev.now, ev.usage)
446 break
447
448 case 'boot': {
449 s.busy = false
450 if (ev.usage && typeof ev.usage.contextPercent === 'number') s.ctx = ev.usage.contextPercent
451 if (r.phase === 'compacting' || r.phase === 'armed') {
452 if (r.compactedAt !== undefined) {
453 enterPause(ev.now)
454 } else {
455 settle(r.task)
456 if (cfg.enabled && ev.usage) evaluate(ev.now, ev.usage)
457 }
458 } else if (r.phase === 'paused') {
459 toast(
460 r.wakeAt !== undefined && ev.now < r.wakeAt
461 ? `Restored quota pause; checking again around ${fmtClock(r.wakeAt)}.`
462 : 'Restored quota pause; reset time has passed, checking now.',
463 )
464 tickLike(ev.now, ev.usage)
465 } else if (r.phase === 'resuming') {
466 tickLike(ev.now, ev.usage)
467 } else if (r.phase === 'idle' && cfg.enabled && ev.usage) {
468 evaluate(ev.now, ev.usage)
469 }
470 break
471 }
472
473 case 'submitFailed': {
474 r.phase = 'stopped'
475 r.wakeAt = undefined
476 fx.push({ t: 'fill', text: ev.text })
477 toast('Could not submit the resume prompt automatically. It is in the prompt box: press Enter to continue.')
478 break
479 }
480
481 case 'forcePause': {
482 if (r.phase !== 'idle' && r.phase !== 'stopped') {
483 toast(`Already ${r.phase}.`)
484 break
485 }
486 const reset = ev.now + ev.minutes * 60_000
487 const task = r.task
488 for (const k of Object.keys(r)) delete (r as Record<string, unknown>)[k]
489 Object.assign(r, {
490 ...emptyRec(task),
491 phase: 'armed',
492 kinds: ['forced'],
493 reason: `forced pause for ${ev.minutes}m (testing)`,
494 expectedReset: reset,
495 })
496 toast(`Forced pause: compacting, then waiting until about ${fmtClock(reset)}.`)
497 if (!s.busy) compactOrPause(ev.now)
498 break
499 }
500
501 case 'cancel': {
502 if (r.phase === 'idle') {
503 toast('Nothing pending to cancel.')
504 break
505 }
506 r.phase = 'stopped'
507 r.wakeAt = undefined
508 toast('Auto-resume cancelled. Use /quota resume or send a prompt to continue.')
509 break
510 }
511
512 case 'resume': {
513 if (r.phase === 'paused' || r.phase === 'stopped') {
514 r.resumeAttempts = 0
515 resumeNow(ev.now, 'forced')
516 } else toast('Nothing to resume.')
517 break
518 }
519 }
520
521 return { state: s, fx }
522}
523
524export function needsHeartbeat(s: State): boolean {
525 return s.rec.phase === 'paused' || s.rec.phase === 'resuming'
526}
527
528export function statusLine(s: State, u: Usage | null, now: number): string | undefined {
529 const r = s.rec
530 if (r.phase === 'paused' && r.wakeAt !== undefined) return `quota pause until ${fmtClock(r.wakeAt)}`
531 if (r.phase === 'resuming') return 'quota: resuming'
532 if (r.phase === 'stopped') return 'quota: auto-resume held'
533 if (r.phase === 'compacting' || r.phase === 'armed') return 'quota: compacting'
534 const five = u?.windows.find(w => w.kind === FIVE)
535 if (!five) return undefined
536 return `5h ${Math.round(five.percent)}%${five.resetsAt && five.resetsAt > now ? ` · ${fmtDur(five.resetsAt - now)}` : ''}`
537}
538types/index.d.ts 38 lines1export type QuotaBar = {
2 ctx?: number
3 ctxTokens?: number
4 ctxWindow?: number
5 fiveAt?: string
6 weekAt?: string
7 threshold?: number
8 ctxMin?: number
9 legend?: boolean
10 parts?: { name: string; pct: number }[]
11 five?: number
12 fiveReset?: string
13 week?: number
14 weekReset?: string
15 phase: string
16 note?: string
17}
18
19export type CtxDetail = {
20 model: string
21 total: number
22 window: number
23 percent: number
24 autoAt?: number
25 categories: { name: string; tokens: number; kind: string }[]
26 memory: { path: string; tokens: number }[]
27 mcp: { server: string; tokens: number }[]
28 agents: { name: string; tokens: number }[]
29 skills?: { count: number; total: number; tokens: number; top: { name: string; tokens: number }[] }
30 api?: { input?: number; cacheRead?: number; cacheWrite?: number; output?: number }
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'quota-guard': { bar: QuotaBar | null; detail: CtxDetail | null }
36 }
37}
38