SLOPSHOPPER

usage-reporter

Writes your Claude usage limits and credits to ~/.claude/usage-reporter/usage.json so other tools can read them

newnetwork
v0.5.3MITupdated 2026-10-08tksunw/usage-reporter
A shopper browsing a rack in a slop shop
README

usage-reporter

A Claude Code mod that writes your Claude usage limits and credits to a file, so menu bar apps, status lines, scripts, and other mods can read them without each one asking Anthropic.

It writes ~/.claude/usage-reporter/usage.json:

{
  "version": 1,
  "at": "2026-10-03T19:51:50.920Z",
  "windows": [
    { "kind": "session", "percent": 7, "resetsAt": "2026-10-04T00:50:00.000Z", "at": "2026-10-03T19:51:50.920Z" },
    { "kind": "weekly", "percent": 25, "resetsAt": "2026-10-04T23:00:00.000Z", "at": "2026-10-03T19:51:50.920Z" },
    { "kind": "weekly", "label": "Fable", "percent": 48, "resetsAt": "2026-10-04T22:59:59.000Z", "at": "2026-10-03T19:49:51.598Z" }
  ],
  "credits": { "enabled": false, "used": 0, "limit": null, "currency": "USD", "at": "2026-10-03T19:51:50.920Z" },
  "cloudSessionCredits": { "used": 0, "limit": 250, "currency": "USD", "resetsAt": "2026-11-05T07:59:00.000Z", "at": "2026-10-03T19:51:50.920Z" },
  "projectSetupCredit": { "used": 17.993769, "limit": 100, "currency": "USD", "expiresAt": "2026-10-05T17:16:23.346Z", "at": "2026-10-04T18:37:30.984Z" },
  "grants": [
    { "id": "extra_usage", "label": "Extra usage", "used": 0, "limit": 100, "currency": "USD", "at": "2026-10-04T18:37:30.984Z" },
    { "id": "iguana_necktie", "label": "Cloud sessions", "used": 1.864015, "limit": 250, "currency": "USD", "endsAt": "2026-11-05T07:59:00.000Z", "ends": "expiry", "at": "2026-10-04T18:37:30.984Z" },
    { "id": "harbor_lantern", "label": "Project setup", "used": 17.993769, "limit": 100, "currency": "USD", "endsAt": "2026-10-05T17:16:23.346Z", "ends": "expiry", "at": "2026-10-04T18:37:30.984Z" }
  ],
  "weeklyBreakdown": {
    "windowStartedAt": "2026-09-27T23:00:00.902Z",
    "rows": [
      { "key": "claude_code", "label": "Claude Code", "percent": 100 },
      { "key": "chat", "label": "Chats", "percent": 0 },
      { "key": "cowork", "label": "Cowork", "percent": 0 },
      { "key": "other", "label": "Other", "percent": 0 }
    ],
    "at": "2026-10-03T19:51:50.920Z"
  },
  "raw": {}
}

Install

Requires a Claude Code version with mods (Anthropic supports mods on 2.1.287 and later; the mod has also run on 2.1.251) and a Claude subscription login.

git clone https://github.com/tksunw/usage-reporter ~/.claude/mods/usage-reporter

Then point Claude Code at that folder's parent in ~/.claude/settings.json, if it does not already:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods"
  }
}

Claude Code loads every folder under ~/.claude/mods as a mod, which is why it lives there and not in ~/.claude/skills: pointing CLAUDE_CODE_PLUGIN_DIRS at the skills folder would try to load every skill as a mod.

Start a new Claude Code session. The file appears after the session starts. To remove the mod, delete ~/.claude/mods/usage-reporter; the last report stays in ~/.claude/usage-reporter/ until you delete that too.

To try it for one session without installing: claude --plugin-dir /path/to/usage-reporter.

Update

Pull the latest version into the folder you cloned:

git -C ~/.claude/mods/usage-reporter pull

The next Claude Code session you start runs the new version.

Install from the marketplace instead

The tksunw marketplace lists this mod, so Claude Code can fetch and update it for you:

claude plugin marketplace add tksunw/claude-plugins
claude plugin install usage-reporter@tksunw

Turn on auto-update for the tksunw marketplace in /plugin (third-party marketplaces default to off), or run claude plugin marketplace update tksunw by hand. Updates are keyed on version in .claude-plugin/plugin.json, so a release without a version bump is not picked up. Use one install method, not both: remove the clone from ~/.claude/mods before installing this way.

The file format

Format version 1. A reader should check version and stop if it is not one it knows.

FieldMeaning
version1
atWhen the file was last written, ISO 8601 UTC
windows[]One entry per usage window. Empty on an Enterprise login, which has no windows, only a spend budget in credits (and in grants when spending is enabled)
windows[].kindsession (the 5-hour window) or weekly (the 7-day window)
windows[].labelPresent on a weekly window scoped to one model family, for example Fable. Absent on the all-models windows
windows[].percentPercent of the window used, 0 to 100
windows[].resetsAtWhen the window resets, ISO 8601 UTC. Can be absent
windows[].atWhen this window's figure was read. Scoped windows can be older than the others
creditsUsage credits (extra usage). Absent when Anthropic's response carries no credit figures
credits.enabledWhether credits are turned on
credits.usedCredits spent, a number in major units of currency (dollars, not cents)
credits.limitThe spend limit, in the same units. null when no limit is set. Absent when a limit is set in a shape the mod does not know
credits.currencyISO 4217 code, for example USD. Can be absent
credits.atWhen the credit figures were read. Can be older than the file's at
cloudSessionCreditsThe credit grant for cloud sessions. Absent when Anthropic's response does not carry it
cloudSessionCredits.usedDollars spent
cloudSessionCredits.limitDollars granted. Can be absent
cloudSessionCredits.currencyUSD
cloudSessionCredits.resetsAtWhen the credit expires, ISO 8601 UTC. Despite the name it is an expiry, as Claude's usage page shows it; the name stays for existing readers. Can be absent
cloudSessionCredits.atWhen the figures were read. Can be older than the file's at
projectSetupCreditThe one-time Claude Projects setup credit, shown in Claude Desktop as "Project setup credit". Absent when Anthropic's response does not carry it, which includes before it is granted
projectSetupCredit.usedDollars spent
projectSetupCredit.limitDollars granted. Can be absent
projectSetupCredit.currencyUSD
projectSetupCredit.expiresAtWhen the credit expires, ISO 8601 UTC. It does not reset. Can be absent
projectSetupCredit.atWhen the figures were read. Can be older than the file's at
grants[]Every dollar credit in one list, for readers that want to show them all without knowing each kind: extra usage when it is turned on, then every grant in Anthropic's response, including ones this mod has never seen. Absent when there are none. Build on this rather than the three fields above
grants[].idWhere it came from: extra_usage, or Anthropic's codename for the grant (iguana_necktie, harbor_lantern). Stable; use it to tell grants apart
grants[].labelA name to show, for example Cloud sessions. For a grant the mod does not know, the codename itself
grants[].usedDollars spent
grants[].limitDollars available. null when no limit is set. Can be absent
grants[].currencyISO 4217 code, for example USD
grants[].endsAtWhen the grant resets or expires, ISO 8601 UTC. Can be absent
grants[].endsreset or expiry, saying which endsAt is. Absent when not known
grants[].atWhen the figures were read. Can be older than the file's at
weeklyBreakdownThe weekly window's usage split by surface, account-wide (claude.ai chat included). Absent when Anthropic's response does not carry it
weeklyBreakdown.windowStartedAtWhen the weekly window began, ISO 8601 UTC. Can be absent
weeklyBreakdown.rows[]One entry per surface, in Anthropic's order. Keys not listed here are passed through; show them rather than dropping them
weeklyBreakdown.rows[].keySurface id. Seen: claude_code, chat, cowork, other
weeklyBreakdown.rows[].labelAnthropic's display name, for example Chats. Can be absent
weeklyBreakdown.rows[].percentSee below. Not the percent of the weekly limit
weeklyBreakdown.atWhen the figures were read. Can be older than the file's at
rawAnthropic's last usage response, unparsed, for debugging. Its shape is theirs and changes without notice. Do not build on it

What weeklyBreakdown.rows[].percent measures is not settled. Every reading so far had one non-zero row, claude_code at 100, while the weekly window stood at 20% and later 43%. So it is not the weekly percent, and it fits "share of this week's usage, rows summing to 100", but no reading with two non-zero rows has confirmed that. Treat it as a relative share until one does.

A window whose resetsAt has passed has rolled over; treat it as empty until the next report.

Reading it from a shell:

jq -r '.windows[] | "\(.kind) \(.label // "all") \(.percent)%"' ~/.claude/usage-reporter/usage.json

When it updates

Only while a Claude Code session is running. Nothing runs on a timer.

  • On session start, whenever Claude Code reports that a limit moved, and at the end of a turn once five minutes have passed since the last call, the mod has Claude Code call Anthropic's usage endpoint. At most one call per five minutes across all open sessions, ten minutes after a 429.
  • Between those calls it writes the session and weekly percent Claude Code already holds for its status line, merged into the last report. No request is made for those. Model-scoped windows, credits, cloudSessionCredits, projectSetupCredit, grants, and weeklyBreakdown come only from the endpoint, so they carry over unchanged until the next call.
  • The status line figures trail the endpoint by about a point, so inside one window a lower reading never replaces a higher one.

So session and weekly follow each turn, and model-scoped windows and credits update at most every five minutes. Usage from claude.ai chat or Claude Desktop shows up at the next Claude Code turn.

What it touches

  • Your login: the mod never sees it. It calls $.session.authorize(), gets an opaque handle, and passes the handle to $.http.fetch. Claude Code attaches the credential on its side.
  • Network: one request, GET https://api.anthropic.com/api/oauth/usage, made by Claude Code. This is the call behind /usage.
  • Files: writes ~/.claude/usage-reporter/usage.json and reads it back to merge. The file holds percentages, reset times, credit figures, the per-surface split, and raw, Anthropic's last response as given. No token, no prompts. The mod cannot set the file's mode, so it gets your default permissions; on a Mac with other accounts that can reach ~/.claude, they can read your usage and credit figures.
  • Environment: reads HOME, else USERPROFILE.

claude plugin validate . prints the same list from the source. The whole mod is hooks/register.ts.

Limits

  • The usage endpoint is not documented by Anthropic and can change. When it does, the mod falls back to the session and weekly figures, and the fix belongs here, not in the tools that read the file.
  • cloudSessionCredits is read from a key Anthropic names by codename (iguana_necktie), matched to the credit by its amount. If they rename it, the field goes absent until the mod is updated.
  • grants treats any top-level object in Anthropic's response with a numeric used_dollars as a grant, other than the usage windows. A new grant appears there under its codename without a mod update, but with no friendly label or ends until the mod learns it.
  • projectSetupCredit is read from harbor_lantern, another codename, matched to Claude Desktop's "Project setup credit" bar by its limit, spend, and expiry. After the credit expires the key goes back to null, so projectSetupCredit and its grant drop out of the file (seen 2026-10-05).
  • It needs a subscription login. With an API key there are no usage windows and nothing is written.
  • An Enterprise login has no session or weekly windows, only a monthly spend budget. The mod recognizes one by Anthropic's response (an empty limits[] with null five_hour and seven_day) while the status line has no windows either. The file then has an empty windows[] and the budget in credits, and in grants when spending is enabled, refreshed at most every five minutes. Any other response without windows is treated as a shape the mod cannot read, and the last report's windows stay. The response carries no reset date for the budget, so none is written; Claude's settings page shows it resetting at the start of each month.
  • With CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set, Claude Code refuses the call and you get session and weekly only.
  • This is unofficial and not affiliated with Anthropic.

Development

claude plugin validate .
claude plugin test .

CI runs both on every push to main and every pull request, against the latest Claude Code.

License

MIT. See LICENSE.

Source 1 files
hooks/register.ts 266 lines
1import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
2
3// Claude Code makes the usage call with its own login, and this mod writes the answer to a file
4// other tools read. The credential never reaches the mod.
5const ENDPOINT = 'https://api.anthropic.com/api/oauth/usage'
6const FILE = '.claude/usage-reporter/usage.json'
7const FLOOR_MS = 5 * 60_000 // the endpoint rate-limits; one call per five minutes across all sessions
8const BACKOFF_MS = 10 * 60_000 // after a 429
9
10/** One usage window. A weekly window with a `label` is scoped to that model family. */
11type Window = { kind: 'session' | 'weekly'; label?: string; percent: number; resetsAt?: string; at: string }
12
13/**
14 * Usage credits, in major units of `currency` (dollars, not cents). `limit` is null when no limit
15 * is set, and absent when one is set in a shape this mod does not know.
16 */
17type Credits = { enabled: boolean; used: number; limit?: number | null; currency?: string; at: string }
18
19/** Cloud session credits, in dollars. `resetsAt` is when the credit expires (Claude's usage page says "Expires"); the name predates that. */
20type CloudCredits = { used: number; limit?: number; currency: 'USD'; resetsAt?: string; at: string }
21
22/** The one-time Claude Projects setup credit, in dollars. It expires at `expiresAt`; it does not reset. */
23type SetupCredit = { used: number; limit?: number; currency: 'USD'; expiresAt?: string; at: string }
24
25/**
26 * One dollar credit or grant. `id` is the key it came from (`extra_usage`, or Anthropic's codename),
27 * `label` a name for people, the codename itself when the mod does not know it. `limit` is null when
28 * no limit is set. `ends` says whether `endsAt` is a reset or an expiry, and is absent when not known.
29 */
30type Grant = { id: string; label: string; used: number; limit?: number | null; currency: string; endsAt?: string; ends?: 'reset' | 'expiry'; at: string }
31
32/** Codenamed grants the mod knows. Any other top-level object with a numeric `used_dollars` still becomes a grant. */
33const KNOWN_GRANTS: Record<string, { label: string; ends?: Grant['ends'] }> = {
34  iguana_necktie: { label: 'Cloud sessions', ends: 'expiry' },
35  harbor_lantern: { label: 'Project setup', ends: 'expiry' },
36}
37
38/** The weekly window's usage by surface (Claude Code, chat, ...). Rows pass through as given, unknown keys included. */
39type Breakdown = { windowStartedAt?: string; rows: { key: string; label?: string; percent: number }[]; at: string }
40
41/** The file, format version 1. `raw` is Anthropic's last response, unparsed; its shape is theirs. */
42type Report = {
43  version: 1
44  at: string
45  windows: Window[]
46  credits?: Credits
47  cloudSessionCredits?: CloudCredits
48  projectSetupCredit?: SetupCredit
49  grants?: Grant[]
50  weeklyBreakdown?: Breakdown
51  raw?: unknown
52}
53
54export const register: Register = on => {
55  on('session.start', async ($, e, next) => {
56    const started = await next(e)
57    await report($).catch(() => {})
58    return started
59  })
60
61  on('session.measure', async ($, e, next) => {
62    if (e.changed.includes('rateLimits')) await report($, e.rateLimits).catch(() => {})
63    return next(e)
64  })
65
66  // A quiet stretch moves no whole point, so nothing above writes and readers see an old file. Once the
67  // floor has lapsed, a main-conversation turn reports anyway, which stamps the file current.
68  on('turn.complete', async ($, e, next) => {
69    const done = await next(e)
70    if (!e.agentId && (await $.clock.now()) >= Number((await $.store.get('nextFetchAt')) ?? 0)) await report($).catch(() => {})
71    return done
72  })
73}
74
75async function report($: EngineInterface, rateLimits?: readonly SessionRateLimit[]) {
76  // HOME, else USERPROFILE for Windows; status-enhanced resolves it the same way.
77  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
78  if (!home) return
79  const path = `${home}/${FILE}`
80  const now = await $.clock.now()
81  const at = new Date(now).toISOString()
82  const write = (rest: Omit<Report, 'version' | 'at'>) => $.fs.write(path, JSON.stringify({ version: 1, at, ...rest } satisfies Report))
83
84  if (now >= Number((await $.store.get('nextFetchAt')) ?? 0)) {
85    // Claim the slot before the call so a second session starting now skips it.
86    await $.store.set('nextFetchAt', now + FLOOR_MS)
87    try {
88      const auth = await $.session.authorize()
89      if (auth?.kind === 'bearer') {
90        const res = await $.http.fetch(ENDPOINT, { headers: { 'anthropic-beta': 'oauth-2025-04-20' }, auth: auth.handle })
91        if (res.ok) {
92          const raw: unknown = JSON.parse(res.text)
93          const credits = fromCredits(raw, at)
94          const grants = fromGrants(raw, credits, at)
95          const windows = fromUsage(raw, at)
96          // An Enterprise login has no windows, only a spend budget, and is written with `windows: []`.
97          // It is recognized by its shape, not by an empty status line, which a session also has before
98          // its first turn. Any other response without windows is a shape this mod does not read, and
99          // the merge below keeps the last report's windows instead.
100          const enterprise =
101            windows.length === 0 && isWindowless(raw) && fromRateLimits(rateLimits ?? (await $.session.usage()).rateLimits, at).length === 0
102          if (windows.length > 0 || enterprise)
103            return write({
104              windows,
105              credits,
106              cloudSessionCredits: fromCloudCredits(raw, at),
107              projectSetupCredit: fromSetupCredit(raw, at),
108              grants,
109              weeklyBreakdown: fromBreakdown(raw, at),
110              raw,
111            })
112        }
113        if (res.status === 429) await $.store.set('nextFetchAt', now + BACKOFF_MS)
114      }
115    } catch {
116      // Refused or offline: fall through to the figures the session already has.
117    }
118  }
119
120  // Inside the floor, or the call failed: the session and weekly percent Claude Code already holds
121  // for its status line, merged into the last report so the model-scoped windows and credits stay.
122  // With none (an Enterprise login), the last report stands as is.
123  const latest = fromRateLimits(rateLimits ?? (await $.session.usage()).rateLimits, at)
124  if (latest.length === 0) return
125  const last = await $.fs.read(path).then(text => JSON.parse(text) as Partial<Report>, () => ({}) as Partial<Report>)
126  const { windows = [], credits, cloudSessionCredits, projectSetupCredit, grants, weeklyBreakdown } = last.version === 1 ? last : ({} as Partial<Report>)
127  await write({ windows: merge(windows, latest), credits, cloudSessionCredits, projectSetupCredit, grants, weeklyBreakdown, raw: last.raw })
128}
129
130/** The usage response: `limits[]` when present, else the older `five_hour` / `seven_day` objects. */
131function fromUsage(raw: any, at: string): Window[] {
132  const windows: Window[] = []
133  for (const limit of Array.isArray(raw?.limits) ? raw.limits : []) {
134    if (typeof limit?.percent !== 'number') continue
135    const base = { percent: limit.percent, resetsAt: iso(limit.resets_at), at }
136    if (limit.kind === 'session') windows.push({ kind: 'session', ...base })
137    else if (limit.kind === 'weekly_all') windows.push({ kind: 'weekly', ...base })
138    else if (limit.kind === 'weekly_scoped') {
139      const scope = limit.scope
140      windows.push({ kind: 'weekly', label: scope?.model?.display_name ?? scope?.display_name ?? scope?.name ?? 'Scoped', ...base })
141    }
142  }
143  if (windows.some(w => w.label === undefined)) return windows
144
145  const older = (key: string, kind: Window['kind'], label?: string): Window[] =>
146    typeof raw?.[key]?.utilization === 'number' ? [{ kind, label, percent: raw[key].utilization, resetsAt: iso(raw[key].resets_at), at }] : []
147  return [...older('five_hour', 'session'), ...older('seven_day', 'weekly'), ...older('seven_day_opus', 'weekly', 'Opus'), ...older('seven_day_sonnet', 'weekly', 'Sonnet')]
148}
149
150/**
151 * A response that says outright there are no windows: an empty `limits[]` and null `five_hour` and
152 * `seven_day`, as an Enterprise login returns. A missing key is not enough; that may be a renamed one.
153 */
154function isWindowless(raw: any): boolean {
155  return Array.isArray(raw?.limits) && raw.limits.length === 0 && raw.five_hour === null && raw.seven_day === null
156}
157
158/** Credits from the usage response: `spend` when it parses, else `extra_usage`. Undefined when neither does. */
159function fromCredits(raw: any, at: string): Credits | undefined {
160  const spend = raw?.spend
161  const used = major(spend?.used)
162  if (used !== undefined) {
163    // A set limit mirrors `used`. A bare number is unseen; it would be read as minor units at `used`'s exponent.
164    const limit = spend.limit === null ? null : major(spend.limit, spend.used.exponent)
165    return { enabled: spend.enabled === true, used, limit, currency: text(spend.used.currency), at }
166  }
167
168  // `used_credits` and `monthly_limit` are minor units (cents) at `decimal_places`, 2 when absent.
169  // Seen for both: `monthly_limit` 10000 beside a `spend.limit` of 100.00, and a non-zero
170  // `used_credits` equal to `spend.used.amount_minor`.
171  const extra = raw?.extra_usage
172  const places = extra?.decimal_places ?? 2
173  const spent = major(extra?.used_credits, places)
174  if (spent === undefined) return undefined
175  const limit = extra.monthly_limit === null ? null : major(extra.monthly_limit, places)
176  return { enabled: extra.is_enabled === true, used: spent, limit, currency: text(extra.currency), at }
177}
178
179/**
180 * Cloud session credits, from `iguana_necktie`. That key is Anthropic's codename, matched to the
181 * credit by its amount. When they rename it this field goes absent, and the fix is the key here.
182 */
183function fromCloudCredits(raw: any, at: string): CloudCredits | undefined {
184  const grant = raw?.iguana_necktie
185  if (typeof grant?.used_dollars !== 'number') return undefined
186  const limit = typeof grant.limit_dollars === 'number' ? grant.limit_dollars : undefined
187  return { used: grant.used_dollars, limit, currency: 'USD', resetsAt: iso(grant.resets_at), at }
188}
189
190/**
191 * The Projects setup credit, from `harbor_lantern`, another codename. Matched on 2026-10-04 to the
192 * "Project setup credit" bar in Claude Desktop by its $100 limit, used amount, and expiry time.
193 */
194function fromSetupCredit(raw: any, at: string): SetupCredit | undefined {
195  const grant = raw?.harbor_lantern
196  if (typeof grant?.used_dollars !== 'number') return undefined
197  const limit = typeof grant.limit_dollars === 'number' ? grant.limit_dollars : undefined
198  return { used: grant.used_dollars, limit, currency: 'USD', expiresAt: iso(grant.resets_at), at }
199}
200
201/**
202 * Every dollar credit in one list: extra usage when it is on, then each top-level object in the
203 * response with a numeric `used_dollars`, other than the usage windows (`five_hour`, `seven_day*`).
204 * Undefined when there are none.
205 */
206function fromGrants(raw: any, credits: Credits | undefined, at: string): Grant[] | undefined {
207  const grants: Grant[] = []
208  if (credits?.enabled) grants.push({ id: 'extra_usage', label: 'Extra usage', used: credits.used, limit: credits.limit, currency: credits.currency ?? 'USD', at })
209  for (const [id, grant] of Object.entries<any>(raw && typeof raw === 'object' ? raw : {})) {
210    if (id.startsWith('five_hour') || id.startsWith('seven_day') || typeof grant?.used_dollars !== 'number') continue
211    const known = KNOWN_GRANTS[id]
212    const limit = typeof grant.limit_dollars === 'number' ? grant.limit_dollars : undefined
213    grants.push({ id, label: known?.label ?? id, used: grant.used_dollars, limit, currency: 'USD', endsAt: iso(grant.resets_at), ends: known?.ends, at })
214  }
215  return grants.length > 0 ? grants : undefined
216}
217
218/** The weekly breakdown, from `seven_day_breakdown`. Rows without a string key and a numeric percent are dropped. */
219function fromBreakdown(raw: any, at: string): Breakdown | undefined {
220  const breakdown = raw?.seven_day_breakdown
221  const rows = (Array.isArray(breakdown?.rows) ? breakdown.rows : [])
222    .filter((row: any) => typeof row?.key === 'string' && typeof row.percent === 'number')
223    .map((row: any) => ({ key: row.key, label: text(row.display_name), percent: row.percent }))
224  return rows.length > 0 ? { windowStartedAt: iso(breakdown.window_started_at), rows, at } : undefined
225}
226
227/** A money figure in major units: `{ amount_minor, exponent }`, or a bare number of minor units at `exponent`. */
228function major(value: any, exponent?: unknown): number | undefined {
229  const minor = typeof value === 'number' ? value : value?.amount_minor
230  const places = value?.exponent ?? exponent
231  return typeof minor === 'number' && typeof places === 'number' ? minor / 10 ** places : undefined
232}
233
234const text = (value: unknown) => (typeof value === 'string' ? value : undefined)
235
236/** The status line's figures: `five_hour` and `seven_day`, no model-scoped windows. */
237function fromRateLimits(rateLimits: readonly SessionRateLimit[], at: string): Window[] {
238  const kinds: Record<string, Window['kind']> = { five_hour: 'session', seven_day: 'weekly' }
239  return rateLimits.flatMap(limit => {
240    const kind = kinds[limit.kind]
241    return kind ? [{ kind, percent: limit.percentUsed, resetsAt: iso(limit.resetsAt), at }] : []
242  })
243}
244
245/**
246 * Lays newer windows over the previous ones. The status line trails the endpoint by a point, so
247 * inside one window (same reset time) a lower reading never replaces a higher one.
248 */
249function merge(previous: Window[], latest: Window[]): Window[] {
250  const key = (w: Window) => `${w.kind}/${w.label ?? ''}`
251  const merged = previous.map(old => {
252    const next = latest.find(w => key(w) === key(old))
253    if (!next) return old
254    const isSameWindow = old.resetsAt !== undefined && next.resetsAt !== undefined && Math.abs(Date.parse(old.resetsAt) - Date.parse(next.resetsAt)) < 60_000
255    return isSameWindow && next.percent < old.percent ? old : next
256  })
257  return [...merged, ...latest.filter(w => !previous.some(old => key(old) === key(w)))]
258}
259
260/** A timestamp as ISO 8601 UTC, or as given when it does not parse. */
261function iso(value: unknown): string | undefined {
262  if (typeof value !== 'string') return undefined
263  const time = Date.parse(value)
264  return Number.isNaN(time) ? value : new Date(time).toISOString()
265}
266