SLOPSHOPPER

quota-guard

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-07catsmonster/claude-quota-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quota-guard
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting for a context reading… │ ⏺ 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 │ ⎿ quota-guard: Auto quota management: ON │ ⎿ quota-guard: Thresholds: 5-hour 94%, weekly 97% (pause), compact │ ⎿ quota-guard: 5-hour usage: 31%, resets in NaNm │ ⎿ quota-guard: Context window: 49% (handled by normal compaction, │ ⎿ quota-guard: Recent largest jump: 0.0 pts │ ⎿ quota-guard: State: IDLE │ │ 5h session ▰▰▰▱▱▱▱▱▱▱ 31% ctx 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ quota-guard: 5h 31%

Draws

Band
5h session ▰▰▰▱▱▱▱▱▱▱ 31% ctx 49%
Pane · Context
Waiting for a context reading…
README

quota-guard

A Claude Code mod (plugin of function hooks) that handles usage limits for you:

  • watches the 5-hour and weekly usage windows, kept separate from context-window pressure
  • at a threshold (default 94%, lowered automatically when recent turns jump a lot) it finishes the current turn, then compacts (only if context is above 30%) and pauses
  • waits until the reported reset time, re-checks, and resumes the task automatically
  • survives restarts and reloads: pause state is persisted, and a 30-second heartbeat compares the wall clock, so sleep or a late timer only delays the resume
  • shows a usage bar above the prompt (5h, 7d, context breakdown) and a full context pane on wide windows

Early-access API: written against Claude Code 2.1.289+ Mods (claude-code hooks API). It may need updates as that API changes.

Install

Clone the repo, then use one of these. Use only one: two routes load two plugins both named quota-guard.

Every session (Desktop Code tab and terminal)

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"
  }
}
  • Use an absolute path. Forward slashes work on Windows. For several folders, separate them with ; on Windows (: on macOS/Linux).
  • The variable is read only from the process environment or the 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.
  • The folder is loaded in place, so it runs the working copy of the repo: git pull to update. Interactive sessions reload it when a file is saved.
  • Sessions that are already open keep what they started with. The Desktop app picks the change up in new sessions, or after you restart the app. If CLAUDE_CODE_PLUGIN_DIRS is already set in the environment a session is launched with, that value wins over the settings file.

One terminal session

claude --plugin-dir /path/to/claude-quota-guard

A copy in ~/.claude/skills/quota-guard

This also auto-loads, but it is a copy: it does not follow the repo, so it drifts. Prefer the folder route above.

Verify

  1. Start a new session and run /quota status (/quota-guard status is an alias). It prints the thresholds and State: IDLE.
  2. You should also see the usage bar above the prompt. Before the first usage reading it says quota-guard · waiting for first usage reading.

Troubleshooting

  • /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.
  • Nothing loads: check the variable is in the user settings 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.
  • Two copies loading: remove the duplicate route (a stale ~/.claude/skills/quota-guard, a hot-reload copy, plus the settings entry).

Several sessions, and headless runs

  • The 5-hour and weekly windows belong to your account, so every open session pauses and wakes at the same reset. Resume prompts are staggered about 20 seconds apart through the plugin's shared store, so they do not all land at once.
  • Sessions with no UI attached (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.
  • If any part of the mod fails to start (for example unreadable saved state), prompts and tool calls pass through untouched and the error is logged once.

Commands

CommandWhat it does
/quota status (alias /quota-guard)usage windows, thresholds, state, task on record
/quota on / offenable / disable automatic management
/quota threshold N5-hour threshold (50-100)
/quota weekly Nweekly threshold
/quota ctxmin Ncompact before pausing only if context is above N% (default 30)
/quota pause [min]force a pause (compacts per the context rule) for testing
/quota cancelcancel the pending auto-resume (holds until you act)
/quota resumeresume now
/quota ctx [close]open / close the full context pane

Dry-run / test mode

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

How it works

  • 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.
  • Usage readings only update after an API response, so after a reset the old reading is stale. The mod resumes with the continuation prompt as the probe and confirms (or rolls back to paused with backoff) from the next real measurement. It gives up after 5 attempts per pause rather than looping.

Limitations

  • A mod only runs while its session is loaded; nothing resumes a session that is not reopened.
  • Pause records are keyed by session id.
  • Weekly pauses can last days and only resume while the session stays loaded.
  • Compaction needs an API call, so at 100% it can fail; the mod then pauses anyway.

Develop

claude plugin validate .
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 885 lines
1import { 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}
885
hooks/core.ts 538 lines
1// 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}
538
types/index.d.ts 38 lines
1export 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