SLOPSHOPPER

hourglass

KOZMOS Hourglass: the limit oracle. For the 5-hour, weekly and spend windows: percent used, reset countdown, a burn rate learned across sessions, the time to…

newpanecommandtoaststatustimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/hourglass
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hourglass
│ ┃ KOZMOS · Hourglass ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀ 5h ███████▍░░░░░░░░░░░░░░░░ 31% │ ┃ ▀▀▀ ↻ — ⏺ Read(src/auth.ts) │ ┃ ▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀ … learning your pace ⏺ 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 │ │ › /hourglass │ ⎿ hourglass: Hourglass open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Hourglass
▀▀▀▀▀▀▀ 5h ███████▍░░░░░░░░░░░░░░░░ 31% ▀▀▀ ↻ — ▀▀▀ ▀▀▀▀▀▀▀ … learning your pace
README

KOZMOS Hourglass

The limit oracle, as a sidebar. /hourglass toggles it; autoOpen opens it on start; statusLine pins ⏳ 5h 42% · 7d 18% under the prompt.

For each rate-limit window the engine reports (five_hour, seven_day, a gateway's spend_limit), read from $.session.usage() and the session.measure hook:

  • % used and the reset countdown.
  • Burn rate in percentage points per hour, measured from timestamped samples kept in $.store, so it keeps learning across sessions inside the same window (5-hour window: the last 90 minutes; the others: the last 24 hours). Until the samples span 10 minutes it falls back to the window's average since it began.
  • Reset detection: a lower percent or a moved resetsAt starts the window's memory over.
  • ETA to 100 % and a verdict: ✓ safe — resets 1h40m before you run dry or ⚠ dry in 52m, 38m before reset.
  • Toasts once per window at 80 % and 95 %.

With no windows (an API key, no subscription) it says so kindly. Terminal: a small half-block hourglass per window in a Raster, a bar, a sparkline and the verdict. Desktop: an SVG hourglass per window whose sand matches the percentage, with sand falling while Claude works.

Türkçe

Limit kâhini, yan panel olarak. /hourglass paneli açar/kapatır; autoOpen oturum başında açar; statusLine istemin altına ⏳ 5h 42% · 7d 18% satırını sabitler.

Motorun bildirdiği her limit penceresi için (five_hour, seven_day, ağ geçidi spend_limit), $.session.usage() ve session.measure kancasından:

  • Kullanım yüzdesi ve sıfırlanma geri sayımı.
  • Yakım hızı (saatte yüzde puan): $.store içinde zaman damgalı örneklerden ölçülür, böylece aynı pencerede oturumlar arasında öğrenmeye devam eder. Örnekler 10 dakikayı kapsayana kadar pencerenin başından beri ortalamaya döner.
  • Sıfırlanma tespiti: yüzde düşerse ya da resetsAt değişirse pencere hafızası sıfırlanır.
  • %100'e kalan süre ve bir hüküm: ✓ safe — resets 1h40m before you run dry ya da ⚠ dry in 52m, 38m before reset.
  • Her pencerede %80 ve %95'te birer kez bildirim.

Pencere yoksa (API anahtarı, abonelik yok) bunu nazikçe söyler. Terminal: her pencere için Raster içinde küçük bir kum saati, çubuk, kıvılcım grafiği ve hüküm. Masaüstü: kumu yüzdeye göre dolan SVG kum saati; Claude çalışırken kum akar.

Source 4 files
hooks/register.tsx 356 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { HgSnap, HgWindow } from '../types'
5import { Canvas, KZ, clamp01, fmtPct, fmtSpan, heat, pxOf, sparkline, svg, svgBar, svgText, untilReset } from './lib/kz.ts'
6import { averageRate, crossings, forecast, lookbackOf, measuredRate, observe, statusText, verdictText } from './calc.ts'
7import type { WinMemory } from './calc.ts'
8
9const PANE = 'kz-hourglass'
10const TITLE = 'KOZMOS · Hourglass'
11const snapAtom = atom({ plugin: 'hourglass', key: 'snap' } as const, null)
12const STORE_KEY = 'windows'
13
14const blank = (now: number): HgSnap => ({ now, isWorking: false, windows: [] })
15
16function label(kind: string): string {
17  if (kind === 'five_hour') return '5h'
18  if (kind === 'seven_day') return '7d'
19  if (kind === 'spend_limit') return 'spend'
20  return kind.replace(/_/g, ' ')
21}
22
23function longLabel(kind: string): string {
24  if (kind === 'five_hour') return '5-HOUR WINDOW'
25  if (kind === 'seven_day') return 'WEEKLY WINDOW'
26  if (kind === 'spend_limit') return 'SPEND LIMIT'
27  return kind.replace(/_/g, ' ').toUpperCase()
28}
29
30const verdictColor = (v: HgWindow['verdict']): string =>
31  v === 'full' ? KZ.red : v === 'dry' ? KZ.amber : v === 'learning' ? KZ.mist : KZ.green
32
33// ---------------------------------------------------------------------------
34
35async function isOpen($: EngineInterface): Promise<boolean> {
36  return (await $.ui.panes()).some(p => p.id === PANE)
37}
38
39async function toggle($: EngineInterface): Promise<boolean> {
40  if (await isOpen($)) {
41    await $.ui.close({ id: PANE })
42    return false
43  }
44  await $.ui.open({ id: PANE, title: TITLE })
45  return true
46}
47
48let live: HgSnap = blank(0)
49let limits: SessionRateLimit[] = []
50let lastPublished = ''
51let lastStatus: string | undefined
52let wantsStatus = false
53
54async function publish($: EngineInterface): Promise<void> {
55  const key = JSON.stringify({ ...live, now: 0, windows: live.windows.map(w => ({ ...w, resetInMs: Math.round((w.resetInMs ?? 0) / 60_000), etaMs: Math.round((w.etaMs ?? 0) / 60_000), marginMs: Math.round((w.marginMs ?? 0) / 60_000) })) })
56  if (key === lastPublished) return
57  await update($, snapAtom, () => JSON.parse(JSON.stringify(live)) as HgSnap)
58  // Noted once written, so a refused write is tried again on the next sample.
59  lastPublished = key
60}
61
62async function readMemory($: EngineInterface): Promise<Record<string, WinMemory>> {
63  try {
64    const v = await $.store.get(STORE_KEY)
65    return v && typeof v === 'object' ? (v as Record<string, WinMemory>) : {}
66  } catch {
67    return {}
68  }
69}
70
71/** Takes the latest readings into the store, toasts, and rebuilds the snapshot. */
72async function digest($: EngineInterface): Promise<void> {
73  const now = await $.clock.now()
74  live.now = now
75  const mem = await readMemory($)
76  const before = JSON.stringify(mem)
77  const windows: HgWindow[] = []
78  for (const l of limits) {
79    const w = observe(mem[l.kind], l.kind, l.percentUsed, l.resetsAt, now)
80    for (const t of crossings(w.toasted, l.percentUsed)) {
81      w.toasted.push(t)
82      const left = untilReset(l, now)
83      $.ui.toast(`⏳ ${label(l.kind)} limit at ${Math.round(l.percentUsed)}%${left !== undefined ? ` · resets in ${fmtSpan(left)}` : ''}`, { timeoutMs: 8000 })
84    }
85    mem[l.kind] = w
86    const resetInMs = untilReset(l, now)
87    const measured = measuredRate(w.samples, now, lookbackOf(l.kind))
88    const avg = measured === undefined ? averageRate(l.kind, l.percentUsed, l.resetsAt, now) : undefined
89    const rate = measured ?? avg
90    const f = forecast(l.percentUsed, rate, resetInMs)
91    windows.push({
92      kind: l.kind,
93      pct: l.percentUsed,
94      resetsAt: l.resetsAt,
95      resetInMs,
96      rate: rate === undefined ? undefined : Math.round(rate * 10) / 10,
97      rateSource: measured !== undefined ? 'measured' : avg !== undefined ? 'average' : undefined,
98      etaMs: f.etaMs,
99      verdict: f.verdict,
100      marginMs: f.marginMs,
101      history: w.samples.slice(-32).map(([, p]) => p),
102    })
103  }
104  if (JSON.stringify(mem) !== before) await $.store.set(STORE_KEY, mem).catch(() => undefined)
105  const order = ['five_hour', 'seven_day', 'spend_limit']
106  windows.sort((a, b) => (order.indexOf(a.kind) + 9) % 9 - (order.indexOf(b.kind) + 9) % 9)
107  live.windows = windows
108  if (wantsStatus) {
109    const text = statusText(windows, label)
110    if (text !== lastStatus) {
111      lastStatus = text
112      $.ui.status(text)
113    }
114  }
115  await publish($)
116}
117
118async function sample($: EngineInterface): Promise<void> {
119  try {
120    limits = (await $.session.usage()).rateLimits
121  } catch {
122    // Keep the last readings.
123  }
124  await digest($)
125}
126
127export const register: Register = (on, options) => {
128  wantsStatus = options.statusLine === true
129
130  on('session.start', async ($, e, next) => {
131    const started = await next(e)
132    live = blank(await $.clock.now())
133    limits = []
134    lastPublished = ''
135    lastStatus = undefined
136    await $.command.register({ name: 'hourglass', description: 'KOZMOS: toggle the Hourglass limit oracle sidebar', immediate: true })
137    await sample($).catch(() => undefined)
138    $.clock.every(15_000, () => void sample($).catch(() => undefined))
139    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
140    return started
141  })
142
143  on('command.run', { command: 'hourglass' }, async $ => {
144    const opened = await toggle($)
145    if (opened) await sample($).catch(() => undefined)
146    return { text: opened ? 'Hourglass open.' : 'Hourglass closed.' }
147  })
148
149  on('session.measure', async ($, e, next) => {
150    if (e.changed.includes('rateLimits')) {
151      limits = e.rateLimits
152      await digest($).catch(() => undefined)
153    }
154    return next(e)
155  }).catch(($, e, next) => next(e))
156
157  on('turn.start', async ($, e, next) => {
158    live.isWorking = true
159    void publish($)
160    return next(e)
161  }).catch(($, e, next) => next(e))
162
163  on('turn.complete', async ($, e, next) => {
164    if (e.agentId === undefined) {
165      live.isWorking = false
166      void sample($).catch(() => undefined)
167    }
168    return next(e)
169  }).catch(($, e, next) => next(e))
170
171  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
172    const s = (await read($, snapAtom)) ?? blank(await $.clock.now())
173
174    if (e.surface !== 'terminal') {
175      const { Box, Svg } = $.ui.resolve(e)
176      const W = pxOf(e.props.bodyColumns, 44)
177      const cards = s.windows.length ? s.windows.map(w => windowCard(w, s.isWorking, W)) : [emptyCard(W)]
178      return (
179        <Box flexDirection="column">
180          {cards.map((c, i) => <Svg key={`w${i}`} source={c.source} alt={c.alt} width={W} height={c.height} />)}
181        </Box>
182      )
183    }
184
185    const { Box, Text, Raster } = $.ui.resolve(e)
186    const cols = Math.max(28, e.props.bodyColumns || 40)
187    if (s.windows.length === 0) {
188      return (
189        <Box flexDirection="column">
190          <Raster key="hg-empty" columns={7} rows={4} cells={glass(0, false).encode()} />
191          <Text color={KZ.violet} bold>No rate-limit windows yet.</Text>
192          <Text dimColor wrap="wrap">They appear after the first reply on a Claude subscription. With an API key or a gateway without limits there is nothing to watch: spend freely, Tokenomics counts the dollars.</Text>
193        </Box>
194      )
195    }
196    const textW = Math.max(18, cols - 9)
197    const barW = Math.max(6, Math.min(24, textW - 16))
198    return (
199      <Box flexDirection="column">
200        {s.windows.map(w => {
201          const c = heat(w.pct / 100)
202          return (
203            <Box key={`w-${w.kind}`} flexDirection="row" marginBottom={1}>
204              <Raster key={`hg-${w.kind}`} columns={7} rows={4} cells={glass(w.pct / 100, s.isWorking).encode()} />
205              <Box flexDirection="column" marginLeft={2} width={textW}>
206                <Text wrap="truncate-end">
207                  <Text bold color={c}>{label(w.kind).padEnd(5)}</Text>
208                  <Text color={c}>{barOf(w.pct / 100, barW)}</Text>
209                  <Text bold> {fmtPct(w.pct)}</Text>
210                </Text>
211                <Text wrap="truncate-end" dimColor>
212                  {w.resetInMs !== undefined ? `↻ ${fmtSpan(w.resetInMs)}` : '↻ —'}
213                  {w.rate !== undefined ? ` · ${w.rate.toFixed(1)}%/h${w.rateSource === 'average' ? ' avg' : ''}` : ''}
214                  {w.etaMs !== undefined && w.verdict !== 'full' ? ` · 100% in ${fmtSpan(w.etaMs)}` : ''}
215                </Text>
216                <Text wrap="truncate-end">
217                  <Text color={KZ.violet}>{w.history.length > 1 ? sparkline(w.history, Math.min(16, textW - 2), 100) : ''}</Text>
218                </Text>
219                <Text wrap="truncate-end" color={verdictColor(w.verdict)}>{verdictText({ verdict: w.verdict, etaMs: w.etaMs, marginMs: w.marginMs }, w.resetInMs)}</Text>
220              </Box>
221            </Box>
222          )
223        })}
224      </Box>
225    )
226  })
227}
228
229function barOf(ratio: number, width: number): string {
230  const w = Math.max(1, Math.floor(width))
231  const exact = clamp01(ratio) * w
232  const full = Math.floor(exact)
233  // No glyph for a remainder under an eighth: charAt(-1) is ''.
234  const part = '▏▎▍▌▋▊▉'.charAt(Math.floor((exact - full) * 8) - 1)
235  return '█'.repeat(full) + part + '░'.repeat(Math.max(0, w - full - (part ? 1 : 0)))
236}
237
238/**
239 * A 7×8-pixel hourglass in half blocks (7 columns, 4 rows): the upper bulb
240 * holds what is left, the lower what is used; a grain in the neck while working.
241 */
242function glass(used: number, isWorking: boolean): Canvas {
243  const c = new Canvas(7, 4)
244  const FRAME = KZ.clay
245  const GLASS = '#3f3f46'
246  const sand = heat(used)
247  for (let x = 0; x < 7; x++) {
248    c.pixel(x, 0, FRAME)
249    c.pixel(x, 7, FRAME)
250  }
251  // Interior rows: upper bulb rows 1..3 (widths 5,3,1), lower rows 4..6 (1,3,5).
252  const rows: [number, number][] = [[1, 5], [2, 3], [3, 1], [4, 1], [5, 3], [6, 5]]
253  for (const [y, w] of rows) for (let x = 0; x < 7; x++) if (Math.abs(x - 3) <= (w - 1) / 2) c.pixel(x, y, GLASS)
254  // Upper bulb fills from the neck up; lower bulb from the floor up.
255  const order = (ys: number[]): [number, number][] => ys.flatMap(y => {
256    const w = rows.find(r => r[0] === y)![1]
257    const xs = Array.from({ length: w }, (_, i) => 3 - (w - 1) / 2 + i).sort((a, b) => Math.abs(a - 3) - Math.abs(b - 3))
258    return xs.map(x => [x, y] as [number, number])
259  })
260  const top = order([3, 2, 1])
261  const bottom = order([6, 5, 4])
262  const left = Math.round(clamp01(1 - used) * top.length)
263  const fell = Math.round(clamp01(used) * bottom.length)
264  for (const [x, y] of top.slice(0, left)) c.pixel(x, y, sand)
265  for (const [x, y] of bottom.slice(0, fell)) c.pixel(x, y, sand)
266  if (isWorking && used < 1 && fell < bottom.length) c.pixel(3, 4, sand)
267  return c
268}
269
270// ---------------------------------------------------------------------------
271// Desktop.
272
273type Card = { source: string; alt: string; height: number }
274
275function hourglassSvg(id: string, x: number, y: number, w: number, h: number, used: number, isWorking: boolean): string {
276  const cx = x + w / 2
277  const top = y + 6
278  const bot = y + h - 6
279  const neck = (top + bot) / 2
280  const x0 = x + 4
281  const x1 = x + w - 4
282  const bulb = (bot - top) * 0.32
283  const d = `M${x0} ${top}H${x1}C${x1} ${top + bulb} ${cx + 3} ${neck - 8} ${cx + 2} ${neck}C${cx + 3} ${neck + 8} ${x1} ${bot - bulb} ${x1} ${bot}H${x0}C${x0} ${bot - bulb} ${cx - 3} ${neck + 8} ${cx - 2} ${neck}C${cx - 3} ${neck - 8} ${x0} ${top + bulb} ${x0} ${top}Z`
284  const u = clamp01(used)
285  const sand = heat(u)
286  const half = neck - top
287  const topLevel = neck - (1 - u) * half
288  const botLevel = bot - u * half
289  const p: string[] = []
290  p.push(`<defs><clipPath id="${id}"><path d="${d}"/></clipPath><linearGradient id="${id}s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${sand}" stop-opacity=".95"/><stop offset="1" stop-color="${sand}" stop-opacity=".7"/></linearGradient></defs>`)
291  p.push(`<path d="${d}" class="k"/>`)
292  p.push(`<g clip-path="url(#${id})">`)
293  if (u < 1) p.push(`<rect x="${x}" y="${topLevel.toFixed(1)}" width="${w}" height="${(neck - topLevel).toFixed(1)}" fill="url(#${id}s)" class="${isWorking ? 'hgdrain' : ''}"/>`)
294  if (u > 0) p.push(`<rect x="${x}" y="${botLevel.toFixed(1)}" width="${w}" height="${(bot - botLevel).toFixed(1)}" fill="url(#${id}s)"/>`)
295  if (isWorking && u < 1) {
296    p.push(`<line x1="${cx}" y1="${neck}" x2="${cx}" y2="${botLevel.toFixed(1)}" stroke="${sand}" stroke-width="1.6" stroke-dasharray="2 3" class="hgfall"/>`)
297    p.push(`<circle cx="${cx}" cy="${botLevel.toFixed(1)}" r="2.2" fill="${sand}" class="pulse"/>`)
298  }
299  p.push('</g>')
300  p.push(`<path d="${d}" fill="none" stroke="${KZ.mist}" stroke-opacity=".55" stroke-width="1.2"/>`)
301  p.push(`<path d="M${x0 + 4} ${top + 4}C${x0 + 4} ${top + bulb} ${cx - 4} ${neck - 10} ${cx - 3} ${neck - 4}" fill="none" stroke="#fff" stroke-opacity=".35" stroke-width="1.4" stroke-linecap="round"/>`)
302  p.push(`<rect x="${x}" y="${top - 6}" width="${w}" height="6" rx="3" fill="${KZ.clay}"/>`)
303  p.push(`<rect x="${x}" y="${bot}" width="${w}" height="6" rx="3" fill="${KZ.clay}"/>`)
304  return p.join('')
305}
306
307const HG_CSS = `
308.hgfall{animation:hgf .5s linear infinite}@keyframes hgf{to{stroke-dashoffset:-10}}
309.hgdrain{animation:hgd 2.4s ease-in-out infinite}@keyframes hgd{50%{opacity:.8}}
310`
311
312function windowCard(w: HgWindow, isWorking: boolean, W: number): Card {
313  const pad = 14
314  const H = 132
315  const p: string[] = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`]
316  p.push(hourglassSvg(`hg${w.kind.replace(/\W/g, '')}`, pad, 14, 58, H - 28, w.pct / 100, isWorking))
317  const tx = pad + 74
318  const tw = W - tx - pad
319  const vc = verdictColor(w.verdict)
320  p.push(svgText(tx, 24, longLabel(w.kind), { cls: 's', size: 10.5, weight: 650 }))
321  p.push(svgText(W - pad, 24, w.resetInMs !== undefined ? `↻ ${fmtSpan(w.resetInMs)}` : '', { cls: 'm', size: 11, anchor: 'end' }))
322  p.push(svgText(tx, 56, fmtPct(w.pct), { size: 28, weight: 760, fill: heat(w.pct / 100) }))
323  if (w.history.length > 1) {
324    const gx = tx + 84
325    const gw = Math.max(20, tw - 84)
326    const step = gw / (w.history.length - 1)
327    const pts = w.history.map((v, i) => `${(gx + i * step).toFixed(1)},${(52 - (clamp01(v / 100) * 22)).toFixed(1)}`)
328    p.push(`<path d="M${pts.join('L')}" fill="none" stroke="${KZ.violet}" stroke-width="1.5" stroke-linejoin="round"/>`)
329  }
330  p.push(svgBar(tx, 66, tw, 8, w.pct / 100, heat(w.pct / 100)))
331  const rateLine = [
332    w.rate !== undefined ? `${w.rate.toFixed(1)}%/h ${w.rateSource === 'average' ? '(window avg)' : 'measured'}` : 'measuring burn…',
333    w.etaMs !== undefined && w.verdict !== 'full' ? `100% in ${fmtSpan(w.etaMs)}` : '',
334  ].filter(Boolean).join(' · ')
335  p.push(svgText(tx, 92, rateLine, { cls: 's', size: 11 }))
336  p.push(`<rect x="${tx}" y="102" width="${tw}" height="22" rx="11" fill="${vc}" opacity=".14"/>`)
337  p.push(svgText(tx + 10, 117, verdictText({ verdict: w.verdict, etaMs: w.etaMs, marginMs: w.marginMs }, w.resetInMs), { size: 11.5, weight: 650, fill: vc }))
338  return {
339    source: svg(W, H, p.join(''), HG_CSS),
340    height: H,
341    alt: `${longLabel(w.kind)} ${fmtPct(w.pct)} used; ${verdictText({ verdict: w.verdict, etaMs: w.etaMs, marginMs: w.marginMs }, w.resetInMs)}`,
342  }
343}
344
345function emptyCard(W: number): Card {
346  const pad = 14
347  const H = 112
348  const p: string[] = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>`]
349  p.push(hourglassSvg('hgempty', pad, 12, 50, H - 24, 0, false))
350  p.push(svgText(pad + 66, 34, 'No rate-limit windows yet', { size: 14, weight: 700 }))
351  p.push(svgText(pad + 66, 56, 'They appear after the first reply on a', { cls: 's', size: 11 }))
352  p.push(svgText(pad + 66, 72, 'Claude subscription. On an API key there', { cls: 's', size: 11 }))
353  p.push(svgText(pad + 66, 88, 'is nothing to ration: spend freely.', { cls: 's', size: 11 }))
354  return { source: svg(W, H, p.join(''), HG_CSS), height: H, alt: 'No rate-limit windows reported: an API key or no subscription.' }
355}
356
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/calc.ts 111 lines
1// Hourglass math: pure functions, no `$`.
2import { fmtSpan, windowMs } from './lib/kz.ts'
3import type { HgVerdict } from '../types'
4
5/** What the store keeps per window, across sessions. */
6export type WinMemory = {
7  resetsAt?: string
8  /** [time ms, percent used], oldest first. */
9  samples: [number, number][]
10  /** Thresholds already toasted in this window. */
11  toasted: number[]
12}
13
14export const THRESHOLDS = [80, 95] as const
15
16const MAX_SAMPLES = 240
17const RESAMPLE_MS = 5 * 60_000
18
19/** Two reset times that differ by more than this are two windows. */
20const RESET_SLACK_MS = 10 * 60_000
21
22/** Whether the window started over since `w` was recorded. */
23export function isReset(w: WinMemory | undefined, pct: number, resetsAt: string | undefined): boolean {
24  if (!w) return false
25  if (w.resetsAt && resetsAt) {
26    const a = Date.parse(w.resetsAt)
27    const b = Date.parse(resetsAt)
28    if (Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) > RESET_SLACK_MS) return true
29  }
30  const last = w.samples[w.samples.length - 1]
31  return last !== undefined && pct < last[1] - 0.5
32}
33
34/** Records a reading: starts the window over on a reset, samples on change or every 5 min, prunes. */
35export function observe(w: WinMemory | undefined, kind: string, pct: number, resetsAt: string | undefined, now: number): WinMemory {
36  const fresh: WinMemory = { resetsAt, samples: [], toasted: [] }
37  const base = !w || isReset(w, pct, resetsAt) ? fresh : { resetsAt: resetsAt ?? w.resetsAt, samples: [...w.samples], toasted: [...w.toasted] }
38  const last = base.samples[base.samples.length - 1]
39  if (!last || last[1] !== pct || now - last[0] >= RESAMPLE_MS) base.samples.push([now, pct])
40  const span = windowMs(kind) ?? 7 * 86_400_000
41  base.samples = base.samples.filter(([t]) => t >= now - span).slice(-MAX_SAMPLES)
42  return base
43}
44
45/** How far back the measured rate looks, per window. */
46export function lookbackOf(kind: string): number {
47  return kind === 'five_hour' ? 90 * 60_000 : 24 * 3600_000
48}
49
50/**
51 * Percentage points per hour from the samples inside the lookback: the
52 * slope from the first to the last reading there, once they span 10 min.
53 */
54export function measuredRate(samples: readonly (readonly [number, number])[], now: number, lookbackMs: number, minSpanMs = 10 * 60_000): number | undefined {
55  const recent = samples.filter(([t]) => t >= now - lookbackMs)
56  const first = recent[0]
57  const last = recent[recent.length - 1]
58  if (!first || !last || last[0] - first[0] < minSpanMs) return undefined
59  return Math.max(0, ((last[1] - first[1]) / (last[0] - first[0])) * 3600_000)
60}
61
62/** The window's average burn since it began (reset time minus its length). */
63export function averageRate(kind: string, pct: number, resetsAt: string | undefined, now: number): number | undefined {
64  const len = windowMs(kind)
65  const end = resetsAt ? Date.parse(resetsAt) : NaN
66  if (len === undefined || !Number.isFinite(end)) return undefined
67  const elapsed = now - (end - len)
68  if (elapsed < 10 * 60_000) return undefined
69  return Math.max(0, (pct / elapsed) * 3600_000)
70}
71
72export type Forecast = { verdict: HgVerdict; etaMs?: number; marginMs?: number }
73
74/** When the window runs dry at `rate` %/h, and whether the reset comes first. */
75export function forecast(pct: number, rate: number | undefined, resetInMs: number | undefined): Forecast {
76  if (pct >= 100) return { verdict: 'full', etaMs: 0, marginMs: resetInMs }
77  if (rate === undefined) return { verdict: 'learning' }
78  if (rate <= 0.01) return { verdict: 'idle', marginMs: resetInMs }
79  const etaMs = ((100 - pct) / rate) * 3600_000
80  if (resetInMs === undefined) return { verdict: 'safe', etaMs }
81  if (etaMs > resetInMs) return { verdict: 'safe', etaMs, marginMs: etaMs - resetInMs }
82  return { verdict: 'dry', etaMs, marginMs: resetInMs - etaMs }
83}
84
85/** The verdict as one line. */
86export function verdictText(f: Forecast, resetInMs: number | undefined): string {
87  switch (f.verdict) {
88    case 'full':
89      return resetInMs !== undefined ? `✖ limit reached — resets in ${fmtSpan(resetInMs)}` : '✖ limit reached'
90    case 'dry':
91      return `⚠ dry in ${fmtSpan(f.etaMs ?? 0)}, ${fmtSpan(f.marginMs ?? 0)} before reset`
92    case 'safe':
93      return f.marginMs !== undefined ? `✓ safe — resets ${fmtSpan(f.marginMs)} before you run dry` : `✓ safe — 100 % in ${fmtSpan(f.etaMs ?? 0)}`
94    case 'idle':
95      return resetInMs !== undefined ? `✓ safe — idle, resets in ${fmtSpan(resetInMs)}` : '✓ safe — idle'
96    default:
97      return '… learning your pace'
98  }
99}
100
101/** Thresholds crossed now that were not toasted yet in this window. */
102export function crossings(toasted: readonly number[], pct: number): number[] {
103  return THRESHOLDS.filter(t => pct >= t && !toasted.includes(t))
104}
105
106/** `⏳ 5h 42% · 7d 18%`. */
107export function statusText(windows: readonly { kind: string; pct: number }[], label: (kind: string) => string): string | undefined {
108  if (windows.length === 0) return undefined
109  return '⏳ ' + windows.map(w => `${label(w.kind)} ${Math.round(w.pct)}%`).join(' · ')
110}
111
types/index.d.ts 34 lines
1export type HgVerdict = 'safe' | 'idle' | 'dry' | 'full' | 'learning'
2
3export type HgWindow = {
4  kind: string
5  /** Percent used, 0..100 (past 100 on an exceeded spend limit). */
6  pct: number
7  resetsAt?: string
8  /** Milliseconds until the window resets, when known. */
9  resetInMs?: number
10  /** Measured burn in percentage points per hour. */
11  rate?: number
12  /** Where the rate came from: recent samples, or the window's average since it began. */
13  rateSource?: 'measured' | 'average'
14  /** Milliseconds until 100 % at the current rate. */
15  etaMs?: number
16  verdict: HgVerdict
17  /** Safe: how long before running dry the window resets; dry: how long before the reset it runs dry. */
18  marginMs?: number
19  /** Recent percent readings, oldest first, for a sparkline. */
20  history: number[]
21}
22
23export type HgSnap = {
24  now: number
25  isWorking: boolean
26  windows: HgWindow[]
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    hourglass: { snap: HgSnap | null }
32  }
33}
34