Shows your plan's 5-hour and weekly usage limits in one line above the prompt, with the time to each reset; /usage-meter opens a pane with your pace and when…

Your Claude subscription has usage limits: a 5-hour window, a weekly window for all models, a weekly window for a model your plan counts on its own (such as Fable) and, on a Claude gateway, a spend limit. Claude Code mentions them only once you are close. usage-meter keeps them on screen the whole session, as one line at the right side above the prompt:
5H 14% 2h 55m │ WK 76% 4d 0h │ Fable 30% 4d 0h
Each entry is a window: how much of it is used, and how long until it resets. A model's own week carries the model's name. The percent turns yellow from 50% and red from 75%. The line appears once Claude has replied for the first time in a subscription session; a session on an API key has no usage limits, so nothing is drawn.
/usage-meter opens a pane with more for each window: a bar drawn to the pane's width, the reset as a clock time and a countdown, your recent pace, and where that pace leads:
5-hour limit · 14% used
█████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
resets 15:00 (in 2h 55m)
pace 6.0%/h over the last 1h 0m
at this pace: lasts until the reset
Run /usage-meter again to close it.
claude plugin marketplace add arafathusayn/cc-mods
claude plugin install usage-meter@cc-mods
session.measure), so the meter asks nothing for them./usage makes, at most every five minutes after a reply and every quarter hour while the session sits idle. It asks with the session's own login through Claude Code, which keeps the credential and sends it to Anthropic alone; the mod never sees it. A session without a Claude login (an API key, a gateway) asks nothing.runs out in ~2h 5m when the window fills before it resets, lasts until the reset when the reset comes first, not rising when use has not moved.claude --plugin-dir mods/usage-meter
hooks/register.tsx is the only file that touches $: it builds the ports the use cases in hooks/actions.ts run on and wires the events. The rest is pure: reading.ts and account-usage.ts decode what the engine and the account's usage report, track.ts and meter.ts keep the history, forecast.ts works out the pace, wording.ts writes the text, band.tsx and pane.tsx draw.
Inspired by quota-meter from claude-mods (MIT); written anew here.
Tested with Claude Code 2.1.289.
hooks/register.tsx 112 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { measureMeter, startMeter, tickMeter, togglePane } from './actions'
5import { fitsBand, meterBand, meterEntriesOf } from './band'
6import { ACCOUNT_USAGE_URL } from './account-usage'
7import { attempt, attemptAsync, describeCause, err, ok } from './kernel/result'
8import { EMPTY_METER } from './meter'
9import { meterPane } from './pane'
10import { failed, type MeterPorts } from './ports'
11
12// The composition root: the one file that touches `$` (the engine follows `$`
13// into no other). It builds the ports the use cases run on, each `$` call
14// wrapped once, and wires the events to the use cases and the views.
15//
16// The engine pushes the rate-limit figures (`session.measure`, after each turn
17// and whenever a window moves a point), so nothing polls them; a minute tick
18// moves the countdowns while the session sits idle. The weekly windows that
19// count one model alone (Fable's) are in the account's usage only, read at most
20// every five minutes after a reply and every quarter hour while idle.
21
22const TICK_MS = 60_000
23
24/**
25 * The Meter the band and the pane draw from. The shape names the Meter's
26 * fields: a reload whose code names another reads the old value as absent and
27 * starts from an empty Meter rather than misread it.
28 */
29const meterState = atom({ plugin: 'usage-meter', key: 'meter' } as const, EMPTY_METER, { shape: 'meter/2' })
30
31const portsOf = ($: EngineInterface): MeterPorts => ({
32 readMeter: () => attemptAsync(() => read($, meterState), failed('state.get')),
33 updateMeter: step => attemptAsync(() => update($, meterState, step), failed('state.set')),
34 now: () => attemptAsync(() => $.clock.now(), failed('clock.now')),
35 rateLimits: async () => {
36 const usage = await attemptAsync(() => $.session.usage(), failed('session.usage'))
37 return usage.ok ? ok(usage.value.rateLimits) : usage
38 },
39 // The session's credential stays on the host: `authorize` answers a handle,
40 // and the engine sets the header for a first-party host alone.
41 accountUsage: async () => {
42 const authorized = await attemptAsync(() => $.session.authorize(), failed('session.authorize'))
43 if (!authorized.ok) return authorized
44 if (authorized.value === null || authorized.value.kind !== 'bearer') return ok(undefined)
45 const handle = authorized.value.handle
46 const answered = await attemptAsync(() => $.http.fetch(ACCOUNT_USAGE_URL, { auth: handle }), failed('http.fetch'))
47 if (!answered.ok) return answered
48 return answered.value.ok
49 ? ok(answered.value.text)
50 : err(failed('http.fetch')(`the account's usage answered HTTP ${answered.value.status}`))
51 },
52 loadStored: () => attemptAsync(() => $.store.get('tracks'), failed('store.get')),
53 saveStored: stored => attemptAsync(() => $.store.set('tracks', stored), failed('store.set')),
54 registerCommand: async () => {
55 const listed = await attemptAsync(
56 () =>
57 $.command.register({
58 name: 'usage-meter',
59 description: 'Open or close the usage pane: each limit, its reset time and your pace',
60 }),
61 failed('command.register'),
62 )
63 return listed.ok ? ok(undefined) : listed
64 },
65 isPaneOpen: async () => {
66 const panes = await attemptAsync(() => $.ui.panes(), failed('ui.panes'))
67 return panes.ok ? ok(panes.value.some(pane => pane.id === 'usage-meter')) : panes
68 },
69 openPane: async rows => {
70 const opened = await attemptAsync(() => $.ui.open({ id: 'usage-meter', title: 'Usage', rows }), failed('ui.open'))
71 return opened.ok ? ok(opened.value.isPlaced) : opened
72 },
73 closePane: () => attemptAsync(() => $.ui.close({ id: 'usage-meter' }), failed('ui.close')),
74 debug: line => $.ui.log(line, { to: 'debug' }),
75})
76
77export const register: Register = on => {
78 // A reload runs register again and cancels the old module's timers; a session
79 // that starts over (/clear) without a reload must not start a second tick.
80 let ticking: Timer | undefined
81
82 on('session.start', async ($, e, next) => {
83 const started = await next(e)
84 const ports = portsOf($)
85 await startMeter(ports)
86 ticking?.cancel()
87 const timer = attempt(() => $.clock.every(TICK_MS, () => void tickMeter(ports)), describeCause)
88 if (timer.ok) ticking = timer.value
89 else ports.debug(`usage-meter: no minute tick (${timer.error}); the meter moves with each reply`)
90 return started
91 })
92
93 on('session.measure', async ($, e, next) => {
94 const measured = await next(e)
95 if (e.changed.includes('rateLimits')) await measureMeter(portsOf($), e.rateLimits)
96 return measured
97 })
98
99 on('command.run', { command: 'usage-meter' }, async $ => ({ text: await togglePane(portsOf($)) }))
100
101 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
102 if (e.props.hasSurvey) return next(e)
103 const entries = meterEntriesOf(await read($, meterState))
104 if (!fitsBand(entries, e.props, e.surface)) return next(e)
105 return meterBand($.ui.resolve(e), entries, await next(e), e.surface)
106 })
107
108 on('ui.render', { component: 'Pane', requestId: 'usage-meter' }, async ($, e) =>
109 meterPane($.ui.resolve(e), await read($, meterState)),
110 )
111}
112hooks/actions.ts 141 lines1// The mod's use cases: what happens when the session starts, when the engine
2// measures, when the clock ticks and when the person runs the command. Each
3// moves the Meter one pure step through the ports; the drawings that read it
4// redraw on their own. Failures go to the debug log; the meter keeps what it had.
5import type { SessionRateLimit } from 'claude-code'
6
7import type { Meter } from '../types'
8import { decodeModelWeeks, describeAccountUsageError } from './account-usage'
9import { describeDecodeError } from './kernel/decode'
10import type { AsyncResult } from './kernel/result'
11import {
12 areModelWeeksDue,
13 markModelWeeksRead,
14 MODEL_WEEKS_AFTER_REPLY_MS,
15 MODEL_WEEKS_WHILE_IDLE_MS,
16 observe,
17 observeModelWeeks,
18 restore,
19 tick,
20} from './meter'
21import {
22 decodeStoredTracks,
23 describeFailure,
24 encodeStoredTracks,
25 type EngineError,
26 type MeterPorts,
27} from './ports'
28import { decodeReadings } from './reading'
29import { paneRowsFor } from './wording'
30
31const logged = (ports: MeterPorts, error: EngineError): void => ports.debug(`usage-meter: ${describeFailure(error)}`)
32
33const advance = async (ports: MeterPorts, step: (meter: Meter) => Meter): AsyncResult<Meter, EngineError> => {
34 const moved = await ports.updateMeter(step)
35 if (!moved.ok) logged(ports, moved.error)
36 return moved
37}
38
39/** Decodes the engine's windows and takes them in at the clock's time now. */
40const takeIn = async (ports: MeterPorts, windows: readonly SessionRateLimit[]): AsyncResult<Meter, EngineError> => {
41 const now = await ports.now()
42 if (!now.ok) {
43 logged(ports, now.error)
44 return now
45 }
46 const { readings, refused } = decodeReadings(windows)
47 for (const error of refused) ports.debug(`usage-meter: a window was left out: ${describeDecodeError(error)}`)
48 return advance(ports, meter => observe(meter, readings, now.value))
49}
50
51/** Stores the tracks, so a restart inside a window keeps its pace. */
52const keepTracks = async (ports: MeterPorts, meter: Meter): Promise<void> => {
53 const saved = await ports.saveStored(encodeStoredTracks(meter.tracks))
54 if (!saved.ok) logged(ports, saved.error)
55}
56
57/**
58 * Reads the model weeks from the account's usage once they are older than
59 * `maxAgeMs`. The turn is claimed in the Meter before the request goes out, so
60 * a second trigger meanwhile does not ask again; a read that fails keeps the
61 * weeks the meter had until the next turn.
62 */
63const refreshModelWeeks = async (ports: MeterPorts, maxAgeMs: number): Promise<void> => {
64 const now = await ports.now()
65 if (!now.ok) return logged(ports, now.error)
66 const nowMs = now.value
67 // The step may run again on a concurrent write; its last run says whether this call holds the turn.
68 let isClaimed = false
69 const claimed = await advance(ports, meter => {
70 isClaimed = areModelWeeksDue(meter, nowMs, maxAgeMs)
71 return isClaimed ? markModelWeeksRead(meter, nowMs) : meter
72 })
73 if (!claimed.ok || !isClaimed) return
74
75 const body = await ports.accountUsage()
76 if (!body.ok) return logged(ports, body.error)
77 if (body.value === undefined) return
78 const weeks = decodeModelWeeks(body.value)
79 if (!weeks.ok) return ports.debug(`usage-meter: the account's usage was set aside: ${describeAccountUsageError(weeks.error)}`)
80 for (const error of weeks.value.refused) {
81 ports.debug(`usage-meter: a model week was left out: ${describeDecodeError(error)}`)
82 }
83 const moved = await advance(ports, meter => observeModelWeeks(meter, weeks.value.readings, nowMs))
84 if (moved.ok) await keepTracks(ports, moved.value)
85}
86
87/**
88 * The session started (or the module reloaded): list the command, put back the
89 * stored tracks, take in whatever windows the engine already has and read the
90 * model weeks.
91 */
92export const startMeter = async (ports: MeterPorts): Promise<void> => {
93 const [listed, stored, windows] = await Promise.all([
94 ports.registerCommand(),
95 ports.loadStored(),
96 ports.rateLimits(),
97 ])
98 if (!listed.ok) logged(ports, listed.error)
99 if (!windows.ok) logged(ports, windows.error)
100 if (stored.ok) {
101 const tracks = decodeStoredTracks(stored.value)
102 if (tracks.ok) await advance(ports, meter => restore(meter, tracks.value))
103 else ports.debug(`usage-meter: ${describeFailure(tracks.error)}`)
104 } else logged(ports, stored.error)
105 await takeIn(ports, windows.ok ? windows.value : [])
106 await refreshModelWeeks(ports, 0)
107}
108
109/** The engine measured new rate-limit figures: take them in, store the tracks, and read the model weeks when due. */
110export const measureMeter = async (ports: MeterPorts, windows: readonly SessionRateLimit[]): Promise<void> => {
111 const moved = await takeIn(ports, windows)
112 if (!moved.ok) return
113 await keepTracks(ports, moved.value)
114 await refreshModelWeeks(ports, MODEL_WEEKS_AFTER_REPLY_MS)
115}
116
117/** A minute passed: move the countdowns and paces on, and read the model weeks when due. */
118export const tickMeter = async (ports: MeterPorts): Promise<void> => {
119 const now = await ports.now()
120 if (!now.ok) return logged(ports, now.error)
121 await advance(ports, meter => tick(meter, now.value))
122 await refreshModelWeeks(ports, MODEL_WEEKS_WHILE_IDLE_MS)
123}
124
125/** `/usage-meter`: opens the pane, or closes it when it is open. Answers the line the transcript shows. */
126export const togglePane = async (ports: MeterPorts): Promise<string> => {
127 const isOpen = await ports.isPaneOpen()
128 if (!isOpen.ok) return `usage-meter: ${describeFailure(isOpen.error)}; the pane was left as it was`
129 if (isOpen.value) {
130 const closed = await ports.closePane()
131 return closed.ok ? 'Usage pane closed.' : `usage-meter: the pane did not close (${closed.error.cause})`
132 }
133 const meter = await ports.readMeter()
134 const windows = meter.ok ? meter.value.readings.length + meter.value.modelReadings.length : 0
135 const placed = await ports.openPane(paneRowsFor(Math.max(1, windows)))
136 if (!placed.ok) return `usage-meter: the pane did not open (${placed.error.cause})`
137 return placed.value
138 ? 'Usage pane open. Run /usage-meter again to close it.'
139 : 'Usage pane opened; it shows once the terminal is wide enough.'
140}
141hooks/band.tsx 93 lines1// The meter above the prompt: one line at the band's right end, each window's
2// entry beside the next (`5H 14% 2h 55m │ WK 76% 4d 0h`). Pure: drawn from the
3// surface's element table and the Meter's entries.
4import type { Elements, RenderElement, RenderSurface } from 'claude-code'
5
6import type { Meter } from '../types'
7import { forecast } from './forecast'
8import {
9 METER_ENTRY_GAP,
10 METER_HEIGHT,
11 METER_WINDOW_GAP,
12 meterEntryOf,
13 meterWidthOf,
14 type MeterEntry,
15 type Tone,
16} from './wording'
17
18export type Ui = Pick<Elements[RenderSurface], 'Box' | 'Text'>
19
20const PERCENT_COLOR: Readonly<Record<Tone, { readonly color?: string }>> = {
21 calm: {},
22 warm: { color: 'yellow' },
23 hot: { color: 'red' },
24}
25
26/**
27 * Empty rows above the meter, by surface: the terminal draws the band flush
28 * against the transcript, so one row sets the meter apart; the other surfaces
29 * space the band themselves.
30 */
31const TOP_PADDING: Readonly<Record<RenderSurface, number>> = { terminal: 1, desktop: 0, vscode: 0, mobile: 0 }
32
33/** Rows the meter takes on `surface`: its line and the padding above it. */
34export const meterHeightOn = (surface: RenderSurface): number => METER_HEIGHT + TOP_PADDING[surface]
35
36export const meterEntriesOf = (meter: Meter): readonly MeterEntry[] => forecast(meter).map(meterEntryOf)
37
38/** True when there is a window to show and the band on `surface` has room for the meter. */
39export const fitsBand = (
40 entries: readonly MeterEntry[],
41 band: { readonly bodyColumns: number; readonly maxRows: number },
42 surface: RenderSurface,
43): boolean =>
44 entries.length > 0 && meterWidthOf(entries) <= band.bodyColumns && meterHeightOn(surface) <= band.maxRows
45
46const entryOf = ({ Box, Text }: Ui, entry: MeterEntry): RenderElement => (
47 <Box key={entry.kind} flexDirection="row" columnGap={METER_ENTRY_GAP}>
48 <Text dimColor>{entry.badge}</Text>
49 <Text bold={!entry.isStale} dimColor={entry.isStale} {...PERCENT_COLOR[entry.tone]}>
50 {entry.percent}
51 </Text>
52 {entry.countdown.length > 0 && <Text dimColor>{entry.countdown}</Text>}
53 </Box>
54)
55
56/**
57 * The meter at the band's right end, under what the plugins beneath drew.
58 *
59 * What `next(e)` answers may be the engine's own drawing, which the engine
60 * refuses under a Box that sizes itself (`width`), so it sits in a plain
61 * column; the column stretches the meter's row across, and the row pushes the
62 * meter to the right end, below the surface's top padding.
63 */
64export const meterBand = (
65 ui: Ui,
66 entries: readonly MeterEntry[],
67 beneath: RenderElement,
68 surface: RenderSurface,
69): RenderElement => {
70 const { Box, Text } = ui
71 const paddingTop = TOP_PADDING[surface]
72 const line = entries.flatMap((entry, index) =>
73 index === 0
74 ? [entryOf(ui, entry)]
75 : [
76 <Text key={`gap-${entry.kind}`} dimColor>
77 │
78 </Text>,
79 entryOf(ui, entry),
80 ],
81 )
82 return (
83 <Box flexDirection="column">
84 {beneath}
85 <Box key="usage-meter-row" flexDirection="row" justifyContent="flex-end" {...(paddingTop > 0 ? { paddingTop } : {})}>
86 <Box key="usage-meter" flexDirection="row" columnGap={METER_WINDOW_GAP}>
87 {line}
88 </Box>
89 </Box>
90 </Box>
91 )
92}
93hooks/account-usage.ts 71 lines1// The boundary where the account's usage (what `/usage` reads from the API)
2// becomes Readings of the weekly windows that count one model alone. The API
3// responses carry only the 5-hour and the all-models week; this is the one
4// place a model's own week, such as Fable's, is reported.
5//
6// The endpoint is the one Claude Code itself reads, not a published API, so
7// every entry is decoded strictly and one that does not decode is left out.
8import type { Reading } from '../types'
9import { array, describeDecodeError, isPlainObject, literal, nullable, number, object, optional, refine, string, unknown, type DecodeError } from './kernel/decode'
10import { attempt, err, ok, partition, type Result } from './kernel/result'
11import { modelWeekKind } from './limit-window'
12import { isoTime } from './reading'
13
14/** Where Claude Code reads the account's usage; a first-party host, so the session's credential rides. */
15export const ACCOUNT_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
16
17const decodeModelWeek = object({
18 kind: literal('weekly_scoped'),
19 percent: refine(number, percent => percent >= 0, 'a percent of 0 or more'),
20 resets_at: optional(nullable(isoTime)),
21 scope: object({
22 model: object({ display_name: refine(string, name => name.trim().length > 0, 'a model name') }),
23 }),
24})
25
26const decodeBody = object({ limits: array(unknown) })
27
28/** A weekly window scoped to a model: the entries this decoder is for; the rest are someone else's. */
29const isModelWeek = (entry: unknown): boolean =>
30 isPlainObject(entry) && entry.kind === 'weekly_scoped' && isPlainObject(entry.scope) && entry.scope.model != null
31
32export type AccountUsageError =
33 | { readonly kind: 'account-usage/not-json'; readonly cause: string }
34 | DecodeError
35
36export const describeAccountUsageError = (error: AccountUsageError): string => {
37 switch (error.kind) {
38 case 'account-usage/not-json':
39 return `not JSON (${error.cause})`
40 case 'decode/invalid':
41 return describeDecodeError(error)
42 }
43}
44
45export type ModelWeeks = {
46 readonly readings: readonly Reading[]
47 /** Model weeks that did not decode, each left out; for the debug log. */
48 readonly refused: readonly DecodeError[]
49}
50
51/** The model weeks in the endpoint's answer; an answer of another shape is an error. */
52export const decodeModelWeeks = (body: string): Result<ModelWeeks, AccountUsageError> => {
53 const parsed = attempt(
54 () => JSON.parse(body) as unknown,
55 cause => ({ kind: 'account-usage/not-json' as const, cause: String(cause) }),
56 )
57 if (!parsed.ok) return parsed
58 const decoded = decodeBody(parsed.value)
59 if (!decoded.ok) return err(decoded.error)
60 const { values, errors } = partition(
61 decoded.value.limits.filter(isModelWeek).map(entry => {
62 const week = decodeModelWeek(entry)
63 if (!week.ok) return week
64 const { percent, resets_at: resetsAtMs, scope } = week.value
65 const kind = modelWeekKind(scope.model.display_name.trim())
66 return ok<Reading>(resetsAtMs == null ? { kind, percentUsed: percent } : { kind, percentUsed: percent, resetsAtMs })
67 }),
68 )
69 return ok({ readings: values, refused: errors })
70}
71hooks/kernel/result.ts 103 lines1// Railway-oriented results: the shared kernel of this repository.
2//
3// A fallible step returns `Result<T, E>` instead of throwing, so every failure
4// is a typed value on the error track and a pipeline reads top to bottom.
5// Exceptions are reserved for defects; `attempt` and `attemptAsync` are the
6// one place a thrown error from the outside world is turned into a value.
7//
8// Canonical copy: kernel/result.ts at the repository root. Every mod carries a
9// byte-identical copy at hooks/kernel/result.ts (a hooks module may import
10// only from its own folder); `bun run check` fails on drift.
11//
12// Plain frozen-shape objects, no classes: cheap to create, structurally typed,
13// and safe to cross the hooks runtime's boundary.
14
15export type Ok<T> = { readonly ok: true; readonly value: T }
16export type Err<E> = { readonly ok: false; readonly error: E }
17export type Result<T, E> = Ok<T> | Err<E>
18export type AsyncResult<T, E> = Promise<Result<T, E>>
19
20export const ok = <T>(value: T): Ok<T> => ({ ok: true, value })
21export const err = <E>(error: E): Err<E> => ({ ok: false, error })
22
23/** The `ok` value of a void step. */
24export const unit: Ok<undefined> = ok(undefined)
25
26export const map = <T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> =>
27 result.ok ? ok(fn(result.value)) : result
28
29export const mapErr = <T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> =>
30 result.ok ? result : err(fn(result.error))
31
32/** Continues on the success track with a step that may itself fail. */
33export const andThen = <T, U, E, F>(
34 result: Result<T, E>,
35 fn: (value: T) => Result<U, F>,
36): Result<U, E | F> => (result.ok ? fn(result.value) : result)
37
38/** `andThen` for asynchronous steps. */
39export const andThenAsync = async <T, U, E, F>(
40 result: Result<T, E> | AsyncResult<T, E>,
41 fn: (value: T) => Result<U, F> | AsyncResult<U, F>,
42): AsyncResult<U, E | F> => {
43 const settled = await result
44 return settled.ok ? fn(settled.value) : settled
45}
46
47export const match = <T, E, R>(
48 result: Result<T, E>,
49 arms: { readonly ok: (value: T) => R; readonly err: (error: E) => R },
50): R => (result.ok ? arms.ok(result.value) : arms.err(result.error))
51
52/** The value, or `fallback` on the error track. */
53export const unwrapOr = <T, E>(result: Result<T, E>, fallback: T): T =>
54 result.ok ? result.value : fallback
55
56/** All values in order, or the first error. */
57export const all = <T, E>(results: readonly Result<T, E>[]): Result<T[], E> => {
58 const values: T[] = []
59 for (const result of results) {
60 if (!result.ok) return result
61 values.push(result.value)
62 }
63 return ok(values)
64}
65
66/** Splits results into their values and every error, keeping order. */
67export const partition = <T, E>(
68 results: readonly Result<T, E>[],
69): { readonly values: T[]; readonly errors: E[] } => {
70 const values: T[] = []
71 const errors: E[] = []
72 for (const result of results) {
73 if (result.ok) values.push(result.value)
74 else errors.push(result.error)
75 }
76 return { values, errors }
77}
78
79/** Runs code that may throw, putting a thrown value on the error track. */
80export const attempt = <T, E>(fn: () => T, onThrow: (cause: unknown) => E): Result<T, E> => {
81 try {
82 return ok(fn())
83 } catch (cause) {
84 return err(onThrow(cause))
85 }
86}
87
88/** Awaits work that may reject, putting the rejection on the error track. */
89export const attemptAsync = async <T, E>(
90 fn: () => Promise<T>,
91 onThrow: (cause: unknown) => E,
92): AsyncResult<T, E> => {
93 try {
94 return ok(await fn())
95 } catch (cause) {
96 return err(onThrow(cause))
97 }
98}
99
100/** A human-readable message for an unknown thrown value. */
101export const describeCause = (cause: unknown): string =>
102 cause instanceof Error ? cause.message : String(cause)
103hooks/meter.ts 67 lines1// The Meter: the latest readings, every window's track and the time it last
2// read. Each change is a pure step from one Meter to the next.
3import type { Meter, Reading, Track } from '../types'
4import { limitWindowOf, lookbackMsOf, MINUTE_MS } from './limit-window'
5import { hasLapsed, recordReading } from './track'
6
7export const EMPTY_METER: Meter = { readings: [], modelReadings: [], modelsReadAtMs: 0, tracks: {}, nowMs: 0 }
8
9/**
10 * Each reading joins its window's track; the track of a window not reported
11 * now is kept until its window resets, so a reading that briefly leaves one
12 * out costs nothing.
13 */
14const recordAll = (
15 tracks: Readonly<Record<string, Track>>,
16 readings: readonly Reading[],
17 nowMs: number,
18): Readonly<Record<string, Track>> => {
19 const kept: Record<string, Track> = {}
20 for (const [kind, track] of Object.entries(tracks)) {
21 if (!hasLapsed(track, nowMs)) kept[kind] = track
22 }
23 for (const reading of readings) {
24 kept[reading.kind] = recordReading(kept[reading.kind], reading, nowMs, lookbackMsOf(limitWindowOf(reading.kind)))
25 }
26 return kept
27}
28
29/** Takes in the windows an API response reported at `nowMs`. */
30export const observe = (meter: Meter, readings: readonly Reading[], nowMs: number): Meter => ({
31 ...meter,
32 readings,
33 tracks: recordAll(meter.tracks, readings, nowMs),
34 nowMs: Math.max(meter.nowMs, nowMs),
35})
36
37/** Takes in the model weeks the account's usage reported at `nowMs`. */
38export const observeModelWeeks = (meter: Meter, modelReadings: readonly Reading[], nowMs: number): Meter => ({
39 ...meter,
40 modelReadings,
41 modelsReadAtMs: nowMs,
42 tracks: recordAll(meter.tracks, modelReadings, nowMs),
43 nowMs: Math.max(meter.nowMs, nowMs),
44})
45
46/** Notes a read of the account's usage that came to nothing, so the next one waits its turn. */
47export const markModelWeeksRead = (meter: Meter, nowMs: number): Meter => ({ ...meter, modelsReadAtMs: nowMs })
48
49/** Moves the meter's clock on, so countdowns and paces read from the new time. */
50export const tick = (meter: Meter, nowMs: number): Meter => (nowMs > meter.nowMs ? { ...meter, nowMs } : meter)
51
52/** Puts back the tracks a previous session stored, under any this session already holds. */
53export const restore = (meter: Meter, stored: Readonly<Record<string, Track>>): Meter => ({
54 ...meter,
55 tracks: { ...stored, ...meter.tracks },
56})
57
58// How fresh the model weeks are kept. The account's usage is one request
59// the API answers for every session of the account, so it is asked at most every
60// five minutes after a reply, and every quarter hour while the session sits idle.
61export const MODEL_WEEKS_AFTER_REPLY_MS = 5 * MINUTE_MS
62export const MODEL_WEEKS_WHILE_IDLE_MS = 15 * MINUTE_MS
63
64/** True once the model weeks were read longer than `maxAgeMs` ago, or never. */
65export const areModelWeeksDue = (meter: Meter, nowMs: number, maxAgeMs: number): boolean =>
66 meter.modelsReadAtMs === 0 || nowMs - meter.modelsReadAtMs >= maxAgeMs
67hooks/pane.tsx 62 lines1// The /usage-meter pane: per window a bar across the pane, the reset time, the
2// pace and where it leads. Pure: drawn from the surface's element table and the
3// Meter.
4import type { RenderElement } from 'claude-code'
5
6import type { Meter } from '../types'
7import type { Ui } from './band'
8import { forecast } from './forecast'
9import { barFillOf, toneOf, windowLinesOf, type Tone } from './wording'
10
11const BAR_COLOR: Readonly<Record<Tone, string>> = { calm: 'green', warm: 'yellow', hot: 'red' }
12const TRACK_COLOR = 'gray'
13
14export const meterPane = ({ Box, Text }: Ui, meter: Meter): RenderElement => {
15 const forecasts = forecast(meter)
16
17 if (forecasts.length === 0) {
18 return (
19 <Box flexDirection="column" paddingX={1}>
20 <Text bold>No usage limits reported yet.</Text>
21 <Text dimColor wrap="wrap">
22 A subscription reports them once Claude has replied; a session on an API key has none.
23 </Text>
24 </Box>
25 )
26 }
27
28 // A bar is two boxes the surface lays out: the filled share, then the track.
29 // Glyphs (█░) would be measured in cells, which a desktop draws wider, so
30 // a bar of them would wrap onto a second line there.
31 return (
32 <Box flexDirection="column" paddingX={1}>
33 {forecasts.map(one => {
34 const lines = windowLinesOf(one, meter.nowMs)
35 return (
36 <Box key={one.kind} flexDirection="column" marginBottom={1}>
37 <Text bold wrap="truncate-end">
38 {lines.heading}
39 </Text>
40 <Box key="bar" flexDirection="row" width="100%" height={1}>
41 <Box key="used" width={barFillOf(one.percentUsed)} height={1} backgroundColor={BAR_COLOR[toneOf(one.percentUsed)]} />
42 <Box key="left" flexGrow={1} height={1} backgroundColor={TRACK_COLOR} />
43 </Box>
44 <Text dimColor wrap="truncate-end">
45 {lines.reset}
46 </Text>
47 <Text dimColor wrap="truncate-end">
48 {lines.pace}
49 </Text>
50 <Text dimColor wrap="truncate-end">
51 {lines.outlook}
52 </Text>
53 </Box>
54 )
55 })}
56 <Text dimColor wrap="truncate-end">
57 Updates after each reply and every minute. /usage-meter closes this pane.
58 </Text>
59 </Box>
60 )
61}
62hooks/ports.ts 83 lines1// The ports the use cases reach the engine through, and the shape the tracks
2// are stored in. Every `$` call lives in register.tsx (the engine follows `$`
3// into no other file): it builds these ports, each wrapping its call once so a
4// rejection comes back as a Result. Pure.
5import type { SessionRateLimit } from 'claude-code'
6
7import type { Meter, Track } from '../types'
8import { describeDecodeError, literal, object, record, type DecodeError } from './kernel/decode'
9import { describeCause, ok, type AsyncResult, type Result } from './kernel/result'
10import { decodeTrack } from './track'
11
12export type EngineError = {
13 readonly kind: 'engine/call-failed'
14 readonly call: string
15 readonly cause: string
16}
17
18/** The error of a `$` call that rejected, for `attemptAsync`'s error track. */
19export const failed =
20 (call: string) =>
21 (cause: unknown): EngineError => ({ kind: 'engine/call-failed', call, cause: describeCause(cause) })
22
23/** Why the stored tracks could not be read back. */
24export type StoreReadError = EngineError | DecodeError
25
26export const describeFailure = (error: StoreReadError): string => {
27 switch (error.kind) {
28 case 'engine/call-failed':
29 return `${error.call} failed: ${error.cause}`
30 case 'decode/invalid':
31 return `the stored tracks were set aside: ${describeDecodeError(error)}`
32 }
33}
34
35export type MeterPorts = {
36 /** The Meter as it stands. */
37 readonly readMeter: () => AsyncResult<Meter, EngineError>
38 /** Applies a pure step to the Meter (again on a concurrent write) and answers the new Meter. */
39 readonly updateMeter: (step: (meter: Meter) => Meter) => AsyncResult<Meter, EngineError>
40 readonly now: () => AsyncResult<number, EngineError>
41 /** The windows the latest API response reported. */
42 readonly rateLimits: () => AsyncResult<readonly SessionRateLimit[], EngineError>
43 /**
44 * The account's usage as `/usage` reads it, the body undecoded; undefined
45 * when the session has no first-party login to ask with (an API key, a
46 * gateway, another provider). A status other than 2xx is an error.
47 */
48 readonly accountUsage: () => AsyncResult<string | undefined, EngineError>
49 /** What the store holds for the tracks, undecoded; undefined when nothing is stored. */
50 readonly loadStored: () => AsyncResult<unknown, EngineError>
51 readonly saveStored: (stored: StoredTracks) => AsyncResult<void, EngineError>
52 readonly registerCommand: () => AsyncResult<void, EngineError>
53 readonly isPaneOpen: () => AsyncResult<boolean, EngineError>
54 /** Opens the pane `rows` tall; answers whether it is drawn now. */
55 readonly openPane: (rows: number) => AsyncResult<boolean, EngineError>
56 readonly closePane: () => AsyncResult<void, EngineError>
57 /** A line for the debug log. */
58 readonly debug: (line: string) => void
59}
60
61// The tracks persist in $.store so a restart inside a window keeps its pace.
62// The version names the shape; a stored value of another shape is set aside.
63const STORE_VERSION = 1
64
65export type StoredTracks = {
66 readonly version: typeof STORE_VERSION
67 readonly tracks: Readonly<Record<string, Track>>
68}
69
70const decodeStored = object({ version: literal(STORE_VERSION), tracks: record(decodeTrack) })
71
72export const encodeStoredTracks = (tracks: Readonly<Record<string, Track>>): StoredTracks => ({
73 version: STORE_VERSION,
74 tracks,
75})
76
77/** The tracks a stored value holds; nothing stored is no tracks. */
78export const decodeStoredTracks = (stored: unknown): Result<Readonly<Record<string, Track>>, DecodeError> => {
79 if (stored === undefined) return ok({})
80 const decoded = decodeStored(stored)
81 return decoded.ok ? ok(decoded.value.tracks) : decoded
82}
83hooks/kernel/decode.ts 163 lines1// Runtime decoding at trust boundaries: parse, don't validate.
2//
3// Anything that crosses into the program untyped (JSON from disk, $.store,
4// $.fs, process output, a model's text, the network, userConfig options) goes
5// through a decoder once, at the edge. What comes out is typed, and branded
6// types carry that proof inward, so the domain never checks the same thing twice.
7//
8// Cost: decoders are plain closures built once at module load. The success
9// path allocates nothing but the decoded value; the path to a failure is
10// assembled only while the error unwinds.
11//
12// Canonical copy: kernel/decode.ts; every mod mirrors it at hooks/kernel/decode.ts.
13import { err, ok, type Result } from './result'
14
15export type DecodeError = {
16 readonly kind: 'decode/invalid'
17 /** Keys and indexes from the root to the offending value. */
18 readonly path: readonly (string | number)[]
19 readonly expected: string
20 readonly received: string
21}
22
23export type Decoder<T> = (value: unknown) => Result<T, DecodeError>
24export type Decoded<D> = D extends Decoder<infer T> ? T : never
25
26const typeOf = (value: unknown): string =>
27 value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value
28
29const fail = (expected: string, value: unknown): Result<never, DecodeError> =>
30 err({ kind: 'decode/invalid', path: [], expected, received: typeOf(value) })
31
32const at = (key: string | number, error: DecodeError): DecodeError => ({ ...error, path: [key, ...error.path] })
33
34export const isPlainObject = (value: unknown): value is Readonly<Record<string, unknown>> =>
35 typeof value === 'object' && value !== null && !Array.isArray(value)
36
37export const unknown: Decoder<unknown> = value => ok(value)
38
39export const string: Decoder<string> = value => (typeof value === 'string' ? ok(value) : fail('a string', value))
40
41export const boolean: Decoder<boolean> = value => (typeof value === 'boolean' ? ok(value) : fail('a boolean', value))
42
43/** A finite number: NaN and the infinities are refused. */
44export const number: Decoder<number> = value =>
45 typeof value === 'number' && Number.isFinite(value) ? ok(value) : fail('a finite number', value)
46
47export const integer: Decoder<number> = value =>
48 Number.isSafeInteger(value) ? ok(value as number) : fail('an integer', value)
49
50export const literal = <const L extends readonly (string | number | boolean | null)[]>(
51 ...values: L
52): Decoder<L[number]> => {
53 const allowed: ReadonlySet<unknown> = new Set(values)
54 const expected = `one of ${values.map(value => JSON.stringify(value)).join(', ')}`
55 return value => (allowed.has(value) ? ok(value as L[number]) : fail(expected, value))
56}
57
58export const optional =
59 <T>(decoder: Decoder<T>): Decoder<T | undefined> =>
60 value =>
61 value === undefined ? ok(undefined) : decoder(value)
62
63export const nullable =
64 <T>(decoder: Decoder<T>): Decoder<T | null> =>
65 value =>
66 value === null ? ok(null) : decoder(value)
67
68export const array =
69 <T>(item: Decoder<T>): Decoder<T[]> =>
70 value => {
71 if (!Array.isArray(value)) return fail('an array', value)
72 const items = new Array<T>(value.length)
73 for (let index = 0; index < value.length; index += 1) {
74 const decoded = item(value[index])
75 if (!decoded.ok) return err(at(index, decoded.error))
76 items[index] = decoded.value
77 }
78 return ok(items)
79 }
80
81/** A string-keyed map whose every value decodes. */
82export const record =
83 <T>(item: Decoder<T>): Decoder<Record<string, T>> =>
84 value => {
85 if (!isPlainObject(value)) return fail('an object', value)
86 const entries: Record<string, T> = {}
87 for (const key of Object.keys(value)) {
88 const decoded = item(value[key])
89 if (!decoded.ok) return err(at(key, decoded.error))
90 entries[key] = decoded.value
91 }
92 return ok(entries)
93 }
94
95type Shape = Readonly<Record<string, Decoder<unknown>>>
96type OptionalKeys<S extends Shape> = {
97 [K in keyof S]: undefined extends Decoded<S[K]> ? K : never
98}[keyof S]
99export type ObjectOf<S extends Shape> = {
100 readonly [K in Exclude<keyof S, OptionalKeys<S>>]: Decoded<S[K]>
101} & { readonly [K in OptionalKeys<S>]?: Decoded<S[K]> }
102
103/**
104 * An object with the shape's fields, decoded; fields the shape does not name
105 * are dropped from the result (the input is left as it was).
106 */
107export const object = <S extends Shape>(shape: S): Decoder<ObjectOf<S>> => {
108 const keys = Object.keys(shape)
109 return value => {
110 if (!isPlainObject(value)) return fail('an object', value)
111 const decoded: Record<string, unknown> = {}
112 for (const key of keys) {
113 const field = (shape[key] as Decoder<unknown>)(value[key])
114 if (!field.ok) return err(at(key, field.error))
115 if (field.value !== undefined) decoded[key] = field.value
116 }
117 return ok(decoded as ObjectOf<S>)
118 }
119}
120
121/** The first decoder that accepts the value. */
122export const oneOf = <const D extends readonly Decoder<unknown>[]>(
123 expected: string,
124 ...decoders: D
125): Decoder<Decoded<D[number]>> =>
126 value => {
127 for (const decoder of decoders) {
128 const decoded = decoder(value)
129 if (decoded.ok) return decoded as Result<Decoded<D[number]>, DecodeError>
130 }
131 return fail(expected, value)
132 }
133
134/** Narrows a decoded value by a predicate, such as a range or a pattern. */
135export const refine =
136 <T, U extends T = T>(decoder: Decoder<T>, check: ((value: T) => value is U) | ((value: T) => boolean), expected: string): Decoder<U> =>
137 value => {
138 const decoded = decoder(value)
139 if (!decoded.ok) return decoded
140 return check(decoded.value) ? ok(decoded.value as U) : fail(expected, decoded.value)
141 }
142
143/** Turns a decoded value into another, which may itself be refused. */
144export const transform =
145 <T, U>(decoder: Decoder<T>, fn: (value: T) => Result<U, string>): Decoder<U> =>
146 value => {
147 const decoded = decoder(value)
148 if (!decoded.ok) return decoded
149 const transformed = fn(decoded.value)
150 return transformed.ok ? transformed : fail(transformed.error, decoded.value)
151 }
152
153/** `$.a.b[0]`, the JSONPath-like spelling of an error's path. */
154export const formatPath = (path: readonly (string | number)[]): string =>
155 path.reduce<string>(
156 (spelled, key) =>
157 typeof key === 'number' ? `${spelled}[${key}]` : /^[A-Za-z_$][\w$]*$/.test(key) ? `${spelled}.${key}` : `${spelled}[${JSON.stringify(key)}]`,
158 '$',
159 )
160
161export const describeDecodeError = (error: DecodeError): string =>
162 `${formatPath(error.path)} must be ${error.expected} (got ${error.received})`
163hooks/reading.ts 37 lines1// The boundary where the engine's rate-limit windows become Readings: each is
2// decoded once, its reset time parsed to milliseconds, so nothing inward checks
3// them again.
4import type { SessionRateLimit } from 'claude-code'
5
6import type { Reading } from '../types'
7import { number, object, optional, refine, string, transform, type DecodeError } from './kernel/decode'
8import { err, ok, partition } from './kernel/result'
9
10/** An ISO 8601 time, as milliseconds since the epoch. */
11export const isoTime = transform(string, text => {
12 const ms = Date.parse(text)
13 return Number.isFinite(ms) ? ok(ms) : err('an ISO 8601 time')
14})
15
16const decodeWindow = object({
17 kind: refine(string, kind => kind.length > 0, 'a window name'),
18 percentUsed: refine(number, percent => percent >= 0, 'a percent of 0 or more'),
19 resetsAt: optional(isoTime),
20})
21
22const decodeReading = transform(decodeWindow, ({ kind, percentUsed, resetsAt }) =>
23 ok<Reading>(resetsAt === undefined ? { kind, percentUsed } : { kind, percentUsed, resetsAtMs: resetsAt }),
24)
25
26export type DecodedReadings = {
27 readonly readings: readonly Reading[]
28 /** Windows that did not decode, each left out; for the debug log. */
29 readonly refused: readonly DecodeError[]
30}
31
32/** Decodes each window on its own, so one the engine got wrong costs only itself. */
33export const decodeReadings = (windows: readonly SessionRateLimit[]): DecodedReadings => {
34 const { values, errors } = partition(windows.map(decodeReading))
35 return { readings: values, refused: errors }
36}
37hooks/wording.ts 135 lines1// The words and glyphs the meter draws: the compact rows above the prompt and
2// the pane's lines for each window. Pure: times come in as arguments.
3import type { Forecast } from './forecast'
4import { unreachable } from './kernel/invariant'
5import { DAY_MS, HOUR_MS, MINUTE_MS } from './limit-window'
6
7/** `14%`, or `23.5%` when the engine reports a fraction. */
8export const percentText = (percent: number): string =>
9 `${Number.isInteger(percent) ? percent : percent.toFixed(1)}%`
10
11/** `42m`, `2h 55m`, `4d 0h`: a length of time, never negative, to the minute. */
12export const durationText = (ms: number): string => {
13 const minutes = Math.max(0, Math.round(ms / MINUTE_MS))
14 const hours = Math.floor(minutes / 60)
15 if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`
16 if (hours > 0) return `${hours}h ${minutes % 60}m`
17 return `${minutes}m`
18}
19
20const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
21
22/**
23 * `15:30` in local time, or `Sun 02:00` when it is most of a day or more from
24 * `nowMs`; to the nearest minute, as reset times jitter by a second either way.
25 */
26export const clockText = (atMs: number, nowMs: number): string => {
27 const at = new Date(Math.round(atMs / MINUTE_MS) * MINUTE_MS)
28 const time = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
29 return Math.abs(atMs - nowMs) < DAY_MS - HOUR_MS ? time : `${WEEKDAYS[at.getDay()]} ${time}`
30}
31
32/** How much of a bar is filled, as a width the surfaces lay out: `14%`, never past `100%`. */
33export const barFillOf = (percent: number): `${number}%` => `${Math.min(100, Math.max(0, percent))}%`
34
35export type Tone = 'calm' | 'warm' | 'hot'
36
37/** Calm while there is room, warm from half, hot from three quarters. */
38export const toneOf = (percent: number): Tone => (percent >= 75 ? 'hot' : percent >= 50 ? 'warm' : 'calm')
39
40/** One window's entry in the meter above the prompt: `5H 14% 2h 55m`. */
41export type MeterEntry = {
42 readonly kind: string
43 readonly badge: string
44 readonly percent: string
45 /** The time to the reset; `reset` once it passed; empty when none is reported. */
46 readonly countdown: string
47 readonly tone: Tone
48 /** True once the window has reset and the percent is the old window's. */
49 readonly isStale: boolean
50}
51
52export const meterEntryOf = (one: Forecast): MeterEntry => {
53 const isStale = one.outlook.kind === 'reset'
54 return {
55 kind: one.kind,
56 badge: one.window.badge,
57 percent: percentText(one.percentUsed),
58 countdown: isStale ? 'reset' : one.untilResetMs === undefined ? '' : durationText(one.untilResetMs),
59 tone: toneOf(one.percentUsed),
60 isStale,
61 }
62}
63
64/** Cells between a window's badge, percent and countdown. */
65export const METER_ENTRY_GAP = 1
66
67/** Cells on each side of the `│` between two windows. */
68export const METER_WINDOW_GAP = 2
69
70/** The cells one window takes on the meter's line: `5H 14% 2h 55m`. */
71const entryWidthOf = (entry: MeterEntry): number =>
72 entry.badge.length +
73 METER_ENTRY_GAP +
74 entry.percent.length +
75 (entry.countdown.length === 0 ? 0 : METER_ENTRY_GAP + entry.countdown.length)
76
77/** Cells the meter takes across: every window on one line, a `│` with a gap on each side between two of them. */
78export const meterWidthOf = (entries: readonly MeterEntry[]): number => {
79 let width = 0
80 for (const [index, entry] of entries.entries()) {
81 width += entryWidthOf(entry) + (index === 0 ? 0 : 1 + 2 * METER_WINDOW_GAP)
82 }
83 return width
84}
85
86/** Rows the meter takes down: one line. */
87export const METER_HEIGHT = 1
88
89/** The lines the pane draws for one window, under its bar. */
90export type WindowLines = {
91 readonly heading: string
92 readonly reset: string
93 readonly pace: string
94 readonly outlook: string
95}
96
97const resetLineOf = (one: Forecast, nowMs: number): string => {
98 if (one.resetsAtMs === undefined) return 'no reset time reported'
99 const at = clockText(one.resetsAtMs, nowMs)
100 return one.outlook.kind === 'reset' ? `reset at ${at}` : `resets ${at} (in ${durationText(one.untilResetMs ?? 0)})`
101}
102
103const outlookLineOf = (one: Forecast, nowMs: number): string => {
104 const { outlook } = one
105 switch (outlook.kind) {
106 case 'reset':
107 return 'the next reply reads the new window'
108 case 'at-limit':
109 return 'limit reached'
110 case 'measuring':
111 return `at this pace: known in ${durationText(outlook.remainingMs)}`
112 case 'steady':
113 return 'at this pace: not rising'
114 case 'lasts':
115 return 'at this pace: lasts until the reset'
116 case 'runs-out':
117 return `at this pace: runs out in ~${durationText(outlook.fullInMs)}, at ${clockText(nowMs + outlook.fullInMs, nowMs)}`
118 default:
119 return unreachable(outlook)
120 }
121}
122
123export const windowLinesOf = (one: Forecast, nowMs: number): WindowLines => ({
124 heading: `${one.window.title} · ${percentText(one.percentUsed)} used`,
125 reset: resetLineOf(one, nowMs),
126 pace:
127 one.pace === undefined
128 ? 'pace: measuring'
129 : `pace ${one.pace.percentPerHour.toFixed(1)}%/h over the last ${durationText(one.pace.overMs)}`,
130 outlook: outlookLineOf(one, nowMs),
131})
132
133/** The rows the pane asks for while it sits inline above the prompt: six a window, two more. */
134export const paneRowsFor = (windows: number): number => Math.min(26, Math.max(7, windows * 6 + 2))
135hooks/forecast.ts 89 lines1// What the meter says about each window: how full it is, when it resets, the
2// pace it is filling at and what that pace leads to before the reset. Pure.
3import type { Meter, Reading, Track } from '../types'
4import { HOUR_MS, limitWindowOf, lookbackMsOf, measuringMsOf, type LimitWindow } from './limit-window'
5import { firstSeenMs, percentAt } from './track'
6
7/** How fast a window fills: percent per hour, read over the last `overMs`. */
8export type Pace = {
9 readonly percentPerHour: number
10 readonly overMs: number
11}
12
13/** Where the pace leads before the window resets. */
14export type Outlook =
15 /** The reset time has passed; the reading is the old window's until the next reply. */
16 | { readonly kind: 'reset' }
17 /** The window is full. */
18 | { readonly kind: 'at-limit' }
19 /** Not watched long enough for a pace yet. */
20 | { readonly kind: 'measuring'; readonly remainingMs: number }
21 /** Not rising. */
22 | { readonly kind: 'steady' }
23 /** Rising, but the window resets before it is full. */
24 | { readonly kind: 'lasts'; readonly fullInMs: number }
25 /** At this pace the window is full before it resets. */
26 | { readonly kind: 'runs-out'; readonly fullInMs: number }
27
28export type Forecast = {
29 readonly kind: string
30 readonly window: LimitWindow
31 readonly percentUsed: number
32 readonly resetsAtMs?: number
33 readonly untilResetMs?: number
34 readonly pace?: Pace
35 readonly outlook: Outlook
36}
37
38const FULL = 100
39
40/** A pace, or how much longer the window must be watched before it has one. */
41type Measurement =
42 | { readonly kind: 'paced'; readonly pace: Pace }
43 | { readonly kind: 'measuring'; readonly remainingMs: number }
44
45const measure = (track: Track | undefined, percentNow: number, window: LimitWindow, nowMs: number): Measurement => {
46 const neededMs = measuringMsOf(window)
47 if (track === undefined) return { kind: 'measuring', remainingMs: neededMs }
48 const firstMs = firstSeenMs(track)
49 const watchedMs = nowMs - firstMs
50 if (watchedMs < neededMs) return { kind: 'measuring', remainingMs: neededMs - watchedMs }
51 // The pace over the lookback, or over all the meter has seen when that is shorter.
52 const fromMs = Math.max(firstMs, nowMs - lookbackMsOf(window))
53 const percentThen = percentAt(track, fromMs) ?? percentNow
54 const overMs = nowMs - fromMs
55 return { kind: 'paced', pace: { percentPerHour: ((percentNow - percentThen) / overMs) * HOUR_MS, overMs } }
56}
57
58const outlookOf = (percentUsed: number, untilResetMs: number | undefined, measured: Measurement): Outlook => {
59 if (untilResetMs === 0) return { kind: 'reset' }
60 if (percentUsed >= FULL) return { kind: 'at-limit' }
61 if (measured.kind === 'measuring') return measured
62 if (measured.pace.percentPerHour <= 0) return { kind: 'steady' }
63 const fullInMs = ((FULL - percentUsed) / measured.pace.percentPerHour) * HOUR_MS
64 return untilResetMs !== undefined && fullInMs >= untilResetMs
65 ? { kind: 'lasts', fullInMs }
66 : { kind: 'runs-out', fullInMs }
67}
68
69const forecastOf = (reading: Reading, track: Track | undefined, nowMs: number): Forecast => {
70 const window = limitWindowOf(reading.kind)
71 const untilResetMs = reading.resetsAtMs === undefined ? undefined : Math.max(0, reading.resetsAtMs - nowMs)
72 const measured = measure(track, reading.percentUsed, window, nowMs)
73 return {
74 kind: reading.kind,
75 window,
76 percentUsed: reading.percentUsed,
77 ...(reading.resetsAtMs === undefined ? {} : { resetsAtMs: reading.resetsAtMs }),
78 ...(untilResetMs === undefined ? {} : { untilResetMs }),
79 ...(measured.kind === 'paced' ? { pace: measured.pace } : {}),
80 outlook: outlookOf(reading.percentUsed, untilResetMs, measured),
81 }
82}
83
84/** Every reported window's forecast, the model weeks among them; the soonest-hit windows first. */
85export const forecast = (meter: Meter): readonly Forecast[] =>
86 [...meter.readings, ...meter.modelReadings]
87 .map(reading => forecastOf(reading, meter.tracks[reading.kind], meter.nowMs))
88 .sort((a, b) => a.window.rank - b.window.rank || a.kind.localeCompare(b.kind))
89