SLOPSHOPPER

usage-meter

An always-visible usage meter above the prompt with live 5-hour, weekly and usage-credit bars that climb smoothly as usage rises, and a Request more usage…

newbandguardcommandtoastnetwork
v0.8.0Apache-2.0updated 2026-10-09shaheershoaib/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ usage-meter │ ⏺ Read(src/auth.ts) │ Couldn't find your organization to send │ ⎿ Read 6 lines │ the request to (HTTP 0). Nothing was sent. │ ⏺ 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 › /more-usage ⎿ usage-meter: Couldn't find your organization to send the request to (HTTP 0). Nothing was sent. ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Credits [ Request more usage ] │ │ │ │ unavailable (HTTP 0) [ Copy admin note ] usage settings https://claude.ai/settings/usage │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Credits [ Request more usage ] │ │ │ │ unavailable (HTTP 0) [ Copy admin note ] usage settings https://claude.ai/settings/usage │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

usage-meter

A Claude Code mod (Desktop and terminal) by Shaheer Shoaib: your live 5-hour, weekly and usage-credit usage above the prompt, and a button that sends a usage-credit request to your organization's admins.

usage-meter above the prompt

Install

Paste this in a terminal:

curl -fsSL https://raw.githubusercontent.com/shaheershoaib/usage-meter/main/install.sh | bash

Then open a new Claude Code session. That's it. Run the same line again any time to update.

It downloads the mod to ~/.claude/mods/usage-meter and adds it to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (your other settings are kept, and a backup is made first). Needs python3 or node, which most machines have.

To remove it:

curl -fsSL https://raw.githubusercontent.com/shaheershoaib/usage-meter/main/uninstall.sh | bash

In Claude Code:

/plugin marketplace add shaheershoaib/usage-meter
/plugin install usage-meter@usage-meter

Claude Code is still rolling out mods for marketplace plugins. If nothing appears after a new session, use the install line above instead.

What it does

  • 5-hour, Weekly and Credits bars ease up as your usage rises, refreshed after each turn and every minute while you work. Every open session shares one lookup, so they never hammer Anthropic's usage endpoint.
  • Request more usage asks you to confirm, then sends a usage-credit request to your organization's admins (Team and Enterprise). It checks first, so a request that is already pending is never sent twice. /more-usage does the same from the prompt.
  • Copy admin note puts a ready-to-send message about your usage on the clipboard.

Good to know

  • Requires a Claude Code build with mods (2.1.286 or later) and a claude.ai sign-in. Tested on macOS; install.sh works on macOS and Linux.
  • usage-meter reads the same usage and admin-request endpoints Claude Code itself uses. They are not a published API, so a future Claude Code release could change them.
  • Everything runs locally in Claude Code. Nothing is sent anywhere except those Anthropic requests, made with your own sign-in.
  • Pairs with agent-warehouse, which shows your session's agents as robots in a warehouse.

License

Apache 2.0: free to use, modify, and share, including commercially. Keep the NOTICE file with any redistribution (§4(d)), and don't market a fork under the usage-meter name (§6). The patent grant in §3 means adopting this doesn't expose you to a patent claim over it.

Required Notice: Copyright Shaheer Shoaib (https://github.com/shaheershoaib)

Source 7 files
hooks/register.tsx 453 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Credits, Limit } from '../types'
5import { barColors, levelOf, runsOf } from './gauge'
6import { SWEEP_TICK_MS, isMoving, isSweeping, shownOf } from './motion'
7import { FILE_BODY, fileUrl, mineUrl, orgOf, pendingOf, profileUrl, reasonOf } from './admin'
8import { asBlock, asCached, backoff, decide, minutes } from './pace'
9import { money, parseUsage } from './usage'
10
11const SETTINGS_URL = 'https://claude.ai/settings/usage'
12const USAGE_API = 'https://api.anthropic.com/api/oauth/usage'
13// One read per 15 seconds at most, one at a time, one after every turn (credits move
14// with each turn once the weekly limit is spent) and one on a timer so an idle session stays current. A failed read is not retried at once: the next turn
15// of the timer or a usage change is the retry.
16// This session asks at most this often; every session together asks about twice a minute (pace.ts).
17const REFRESH_MS = 10_000
18// While you are working, usage is read this often, so the bars climb as you spend.
19const ACTIVE_POLL_MS = 60_000
20const ACTIVE_FOR_MS = 45_000
21// How often a session looks at the reading the other sessions share (no request is made).
22const ADOPT_MS = 15_000
23const POLL_MS = 5 * 60_000
24const FETCH_TIMEOUT_MS = 10_000
25const LABELS: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
26const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
27
28type Tile = { key: string; label: string; percent: number | null; detail: string }
29
30const windows = atom({ plugin: 'usage-meter', key: 'windows' } as const, [])
31const credits = atom({ plugin: 'usage-meter', key: 'credits' } as const, null)
32const failure = atom({ plugin: 'usage-meter', key: 'error' } as const, null)
33
34const resetOf = (limit: Limit) => {
35  if (!limit.resetsAt) {
36    return ''
37  }
38  const date = new Date(limit.resetsAt)
39  const hours = date.getHours()
40  const time = `${hours % 12 || 12}:${String(date.getMinutes()).padStart(2, '0')} ${hours < 12 ? 'AM' : 'PM'}`
41
42  return limit.kind === 'seven_day' ? `${DAYS[date.getDay()]} ${time}` : time
43}
44
45const adminNote = (tiles: Tile[]) => {
46  const figures = tiles
47    .filter(tile => tile.percent !== null)
48    .map(tile => `${tile.label} ${Math.round(tile.percent ?? 0)}%`)
49    .join(', ')
50
51  return (
52    `Hi, I am running low on Claude usage${figures ? ` (${figures})` : ''} and need more to keep working. ` +
53    'Could you add usage credits for my account? You can approve it in ' +
54    'Organization settings > Usage > Review requests. Thanks!'
55  )
56}
57
58
59const withTimeout = <T,>($: EngineInterface, work: Promise<T>, ms: number) =>
60  new Promise<T>((resolve, reject) => {
61    const timer = $.clock.after(ms, () => reject(new Error('timeout')))
62    work.then(
63      value => {
64        timer.cancel()
65        resolve(value)
66      },
67      cause => {
68        timer.cancel()
69        reject(cause)
70      },
71    )
72  })
73
74let lastRead = 0
75let isReading = false
76let activeAt = 0
77// The request to the admins: idle, asking you to confirm, sending, sent, or already pending.
78let asking: 'idle' | 'confirm' | 'sending' | 'sent' | 'pending' = 'idle'
79// When the numbers on screen were read, and the newest shared reading this session has taken.
80let goodAt = 0
81let adoptedAt = 0
82let redrawQueued = false
83
84const clip = (text: string, size: number) => (text.length > size ? `${text.slice(0, size - 1)}…` : text)
85
86// A write that changes nothing still redraws everything that reads it, so none is made.
87const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
88
89const CACHE_KEY = 'usage-cache'
90const BLOCK_KEY = 'usage-blocked'
91
92const setFailure = async ($: EngineInterface, label: string | null) => {
93  if ((await read($, failure)) !== label) {
94    await update($, failure, () => label)
95  }
96}
97
98const apply = async ($: EngineInterface, usage: { windows: Limit[]; credits: Credits | null }) => {
99  if (!same(await read($, windows), usage.windows)) {
100    await update($, windows, () => usage.windows)
101  }
102  if (!same(await read($, credits), usage.credits)) {
103    await update($, credits, () => usage.credits)
104  }
105}
106
107// Reads the account's usage card, credits included. Every open session shares one cached
108// reading and one "slow down" notice, so the request is made about twice a minute however
109// many sessions there are; a failure leaves the numbers already drawn in place.
110const refresh = async ($: EngineInterface) => {
111  const now = Date.now()
112  if (isReading || (lastRead && now - lastRead < REFRESH_MS)) {
113    return
114  }
115  lastRead = now
116  isReading = true
117  try {
118    const cached = asCached(await $.store.get(CACHE_KEY))
119    const block = asBlock(await $.store.get(BLOCK_KEY))
120    const action = decide(now, cached, block)
121    if (cached && action !== 'ask') {
122      await apply($, cached)
123      goodAt = Math.max(goodAt, cached.at)
124      adoptedAt = Math.max(adoptedAt, cached.at)
125    }
126    if (action === 'adopt') {
127      await setFailure($, null)
128
129      return
130    }
131    if (action === 'wait' && block) {
132      await setFailure($, 'rate limited')
133
134      return
135    }
136    const auth = await $.session.authorize()
137    if (!auth) {
138
139      return
140    }
141    const reply = await withTimeout(
142      $,
143      $.http.fetch(USAGE_API, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20' } }),
144      FETCH_TIMEOUT_MS,
145    )
146    if (reply.status === 429) {
147      const next = backoff(now, block, reply.headers['retry-after'])
148      await $.store.set(BLOCK_KEY, next)
149      if (cached) {
150        await apply($, cached)
151      }
152      await setFailure($, 'rate limited')
153
154      return
155    }
156    if (!reply.ok) {
157      await setFailure($, `HTTP ${reply.status}`)
158
159      return
160    }
161    let usage: ReturnType<typeof parseUsage>
162    try {
163      usage = parseUsage(reply.text)
164    } catch {
165      await setFailure($, 'unreadable reply')
166
167      return
168    }
169    await $.store.set(CACHE_KEY, { at: now, windows: usage.windows, credits: usage.credits })
170    await $.store.delete(BLOCK_KEY)
171    await apply($, usage)
172    goodAt = now
173    adoptedAt = now
174    await setFailure($, null)
175  } catch (cause) {
176    const label = cause instanceof Error && cause.message === 'timeout' ? 'timed out' : 'offline'
177    await setFailure($, label)
178  } finally {
179    isReading = false
180  }
181}
182
183// Takes up a newer reading another session made, without asking Anthropic: a store read only.
184const adopt = async ($: EngineInterface) => {
185  const cached = asCached(await $.store.get(CACHE_KEY))
186  if (cached && cached.at > adoptedAt) {
187    adoptedAt = cached.at
188    goodAt = Math.max(goodAt, cached.at)
189    await apply($, cached)
190    await setFailure($, null)
191  }
192}
193
194const say = ($: EngineInterface, text: string) => {
195  $.ui.toast(text, { timeoutMs: 6_000 })
196
197  return text
198}
199
200// Files one usage-credit request with your organization's admins. A request already pending is
201// not sent twice; a post that times out is reported as unknown, not as failed, and is not retried.
202const fileRequest = async ($: EngineInterface): Promise<string> => {
203  asking = 'sending'
204  $.ui.invalidate('ui.render')
205  try {
206    const auth = await $.session.authorize()
207    if (!auth) {
208      asking = 'idle'
209
210      return say($, 'This session has no Claude sign-in to send the request with. Use claude.ai/settings/usage instead.')
211    }
212    const headers = { 'anthropic-beta': 'oauth-2025-04-20', 'content-type': 'application/json' }
213    const profile = await withTimeout($, $.http.fetch(profileUrl(), { auth: auth.handle, headers }), FETCH_TIMEOUT_MS)
214    const org = profile.ok ? orgOf(profile.text) : null
215    if (!org) {
216      asking = 'idle'
217
218      return say($, `Couldn't find your organization to send the request to (HTTP ${profile.status}). Nothing was sent.`)
219    }
220    const withOrg = { ...headers, 'x-organization-uuid': org }
221    const mine = await withTimeout($, $.http.fetch(mineUrl(org), { auth: auth.handle, headers: withOrg }), FETCH_TIMEOUT_MS)
222    if (mine.ok && pendingOf(mine.text) > 0) {
223      asking = 'pending'
224
225      return say($, "You've already sent a usage credit request to your admin. It's still pending.")
226    }
227    let sent
228    try {
229      sent = await withTimeout(
230        $,
231        $.http.fetch(fileUrl(org), { method: 'POST', auth: auth.handle, headers: withOrg, body: FILE_BODY }),
232        FETCH_TIMEOUT_MS,
233      )
234    } catch {
235      asking = 'idle'
236
237      return say($, "The request didn't answer in time, so it may or may not have reached your admin. Press again: if it did, you'll be told it's pending.")
238    }
239    if (!sent.ok) {
240      asking = 'idle'
241
242      return say($, reasonOf(sent.text) ?? `Your admin request was not accepted (HTTP ${sent.status}). Nothing was sent; try again later.`)
243    }
244    asking = 'sent'
245
246    return say($, 'Request sent to your admin for usage credits.')
247  } catch (cause) {
248    asking = 'idle'
249
250    return say($, "Couldn't reach Anthropic to send the request. Nothing was sent; try again in a moment.")
251  } finally {
252    $.ui.invalidate('ui.render')
253  }
254}
255
256export const register: Register = on => {
257  on('session.start', async ($, e, next) => {
258    await $.command.register({
259      name: 'more-usage',
260      description: 'Send a usage-credit request to your organization admins',
261    })
262    void refresh($)
263    $.clock.every(POLL_MS, () => void refresh($))
264    $.clock.every(ADOPT_MS, () => void adopt($))
265    $.clock.every(ACTIVE_POLL_MS, () => {
266      if (Date.now() - activeAt < ACTIVE_FOR_MS) {
267        void refresh($)
268      }
269    })
270
271    return next(e)
272  })
273
274  on('command.run', { command: 'more-usage' }, async $ => ({ text: await fileRequest($) }))
275
276  // Any tool call means work is going on, and usage is moving with it.
277  on('tool.call', ($, e, next) => {
278    activeAt = Date.now()
279
280    return next(e)
281  })
282
283  // An agent's turn ending is another moment usage has moved.
284  on('turn.complete', ($, e, next) => {
285    void refresh($)
286
287    return next(e)
288  })
289
290  on('session.measure', async ($, e, next) => {
291    if (e.changed.includes('rateLimits')) {
292      const known = e.rateLimits.filter(limit => limit.kind in LABELS)
293      if (known.length > 0 && !same(await read($, windows), known)) {
294        await update($, windows, () => known)
295      }
296    }
297    // A turn that cost something is when credits and limits have moved.
298    if (e.changed.includes('rateLimits') || e.changed.includes('cost')) {
299      void refresh($)
300    }
301
302    return next(e)
303  })
304
305  // Always drawn, so the request is one click away at any usage level.
306  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
307    // Another mod may draw in the band too: keep what is beneath this card.
308    const below = await next(e)
309    if (e.props.hasSurvey) {
310      return below
311    }
312
313    const ui = $.ui.resolve(e)
314    const { Box, Button, Link, Text } = ui
315    const tiles: Tile[] = []
316    for (const limit of await read($, windows)) {
317      const reset = resetOf(limit)
318      tiles.push({
319        key: limit.kind,
320        label: LABELS[limit.kind] ?? limit.kind,
321        percent: Math.round(limit.percentUsed),
322        detail: reset ? `resets ${reset}` : '',
323      })
324    }
325    const balance = await read($, credits)
326    const problem = await read($, failure)
327    // Numbers more than two minutes old say how old they are; a wait for Anthropic is not an error.
328    const age = goodAt ? Date.now() - goodAt : 0
329    const stale = age > 120_000 ? ` · as of ${minutes(age)} min ago` : problem && problem !== 'rate limited' ? ' · not up to date' : ''
330    if (balance?.isEnabled) {
331      tiles.push({
332        key: 'credits',
333        label: 'Credits',
334        // One decimal: near the cap, whole percents hide that it is still moving.
335        percent: Math.round(balance.percentUsed * 10) / 10,
336        detail: `${money(balance.spent, balance.currency)} of ${money(balance.limit, balance.currency)}${stale}`,
337      })
338    } else if (balance) {
339      tiles.push({ key: 'credits', label: 'Credits', percent: null, detail: 'not enabled' })
340    } else if (problem) {
341      tiles.push({
342        key: 'credits',
343        label: 'Credits',
344        percent: null,
345        detail: problem === 'rate limited' ? 'loading…' : `unavailable (${problem})`,
346      })
347    }
348    const now = Date.now()
349    const top = Math.max(0, ...tiles.map(tile => tile.percent ?? 0))
350    const { accent, isLow } = levelOf(top)
351
352    const card = (
353      <Box
354        key="card"
355        borderStyle="round"
356        borderColor={accent}
357        borderDimColor={!isLow}
358        hover={{ borderColor: accent, borderDimColor: false }}
359        paddingX={1}
360        gap={3}
361        alignItems="center"
362        flexWrap="wrap"
363      >
364        <Box gap={3} flexGrow={1} flexWrap="wrap">
365          {tiles.length === 0 && <Text dimColor>Need more? Request it in one click.</Text>}
366          {tiles.map(tile => {
367            const shown = shownOf(tile.key, tile.percent, now)
368            const level = levelOf(shown ?? 0)
369            const figure = shown === null ? '' : tile.key === 'credits' ? (Math.round(shown * 10) / 10).toFixed(1) : String(Math.round(shown))
370
371            return (
372              <Box key={`tile-${tile.key}`} flexDirection="column">
373                <Box gap={1}>
374                  <Text bold>{tile.label}</Text>
375                  {shown !== null && (
376                    <Text bold color={level.accent}>
377                      {figure}%
378                    </Text>
379                  )}
380                </Box>
381                <Box>
382                  {runsOf(barColors(shown, level, isMoving(tile.key, now))).map(run => (
383                    <Box width={run.n} height={1} backgroundColor={run.color} />
384                  ))}
385                </Box>
386                <Text dimColor>{tile.detail}</Text>
387              </Box>
388            )
389          })}
390        </Box>
391        <Box flexDirection="column" gap={1} alignItems="flex-start">
392          {asking === 'idle' && (
393            <Button
394              key="request"
395              variant={isLow ? 'primary' : 'secondary'}
396              label="Request more usage"
397              onPress={() => {
398                asking = 'confirm'
399                $.ui.invalidate('ui.render')
400              }}
401            />
402          )}
403          {asking === 'confirm' && (
404            <Box gap={1} alignItems="center">
405              <Text>Send to your admins?</Text>
406              <Button key="send" variant="primary" label="Send request" onPress={() => void fileRequest($)} />
407              <Button
408                key="cancel"
409                label="Cancel"
410                onPress={() => {
411                  asking = 'idle'
412                  $.ui.invalidate('ui.render')
413                }}
414              />
415            </Box>
416          )}
417          {asking === 'sending' && <Text dimColor>Sending your request…</Text>}
418          {asking === 'sent' && <Text color="#34d399">✓ Request sent to your admin</Text>}
419          {asking === 'pending' && <Text color="#fbbf24">Request pending with your admin</Text>}
420          <Box gap={1}>
421            <Button
422              key="copy"
423              label="Copy admin note"
424              onPress={async press => {
425                const { isCopied } = await $.ui.copy({ text: adminNote(tiles), surface: press.surface })
426                $.ui.toast(isCopied ? 'Note for your admin copied.' : 'Could not copy the note.')
427              }}
428            />
429            <Link href={SETTINGS_URL} label="usage settings" />
430          </Box>
431        </Box>
432      </Box>
433    )
434
435    if (isSweeping(now) && !redrawQueued) {
436      redrawQueued = true
437      $.clock.after(SWEEP_TICK_MS, () => {
438        redrawQueued = false
439        $.ui.invalidate('ui.render')
440      })
441    }
442
443    return below.type === 'engine' ? (
444      card
445    ) : (
446      <Box flexDirection="column">
447        {card}
448        {below}
449      </Box>
450    )
451  })
452}
453
hooks/gauge.ts 66 lines
1// A gauge made of colored cells. Boxes and text update in place when the UI
2// redraws, where a picture would be reloaded: nothing here can flicker.
3export type Level = { accent: string; from: string; to: string; isLow: boolean }
4
5export const BAR_CELLS = 22
6const TRACK = '#2b303b'
7
8export const levelOf = (percent: number): Level => {
9  if (percent >= 100) {
10    return { accent: '#f87171', from: '#fca5a5', to: '#dc2626', isLow: true }
11  }
12  if (percent >= 80) {
13    return { accent: '#fb923c', from: '#fdba74', to: '#ef4444', isLow: true }
14  }
15  if (percent >= 60) {
16    return { accent: '#fbbf24', from: '#fde68a', to: '#f59e0b', isLow: false }
17  }
18
19  return { accent: '#34d399', from: '#6ee7b7', to: '#22d3ee', isLow: false }
20}
21
22const part = (c: string, at: number) => parseInt(c.slice(at, at + 2), 16)
23const two = (n: number) => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0')
24const mix = (a: string, b: string, k: number) =>
25  `#${two(part(a, 1) * (1 - k) + part(b, 1) * k)}${two(part(a, 3) * (1 - k) + part(b, 3) * k)}${two(part(a, 5) * (1 - k) + part(b, 5) * k)}`
26
27// The color of each cell. Filled cells shade along the bar; the cell where the fill ends is
28// blended by how much of it is covered, so the edge slides smoothly instead of jumping a cell
29// at a time; and while the bar is moving (`glow`) its leading edge shines.
30export const barColors = (percent: number | null, level: Level, glow: boolean): string[] => {
31  const filled = (Math.min(100, Math.max(0, percent ?? 0)) / 100) * BAR_CELLS
32
33  return Array.from({ length: BAR_CELLS }, (_, i) => {
34    const shaded = mix(level.from, level.to, i / (BAR_CELLS - 1))
35    let color = TRACK
36    if (percent !== null) {
37      if (i + 1 <= filled) {
38        color = shaded
39      } else if (i < filled) {
40        color = mix(TRACK, shaded, filled - i)
41      }
42      if (glow && i < filled) {
43        const behind = filled - i
44        color = behind <= 1.5 ? mix(color, '#ffffff', 0.5) : behind <= 3 ? mix(color, '#ffffff', 0.18) : color
45      }
46    }
47
48    return color
49  })
50}
51
52// Neighbours of one color share a box, so a bar is a handful of boxes, not twenty-two.
53export const runsOf = (colors: string[]): { color: string; n: number }[] => {
54  const runs: { color: string; n: number }[] = []
55  for (const color of colors) {
56    const last = runs[runs.length - 1]
57    if (last && last.color === color) {
58      last.n += 1
59    } else {
60      runs.push({ color, n: 1 })
61    }
62  }
63
64  return runs
65}
66
hooks/motion.ts 39 lines
1// A gauge moves from where it was to where it is now, in a short ease-out. The motion is
2// drawn by redrawing the cells in quick bursts, and only while something is moving: when
3// every gauge is still, nothing redraws at all.
4export const SWEEP_MS = 700
5export const SWEEP_TICK_MS = 55
6
7type Move = { from: number; to: number; at: number }
8const anim = new Map<string, Move>()
9const ease = (k: number) => 1 - (1 - k) ** 3
10const valueOf = (a: Move, now: number) => a.from + (a.to - a.from) * ease(Math.min(1, (now - a.at) / SWEEP_MS))
11
12// What a gauge shows at `now`: its first appearance fills from empty, a new value eases from
13// wherever the gauge is at that moment, and an unchanged value stands still.
14export const shownOf = (key: string, percent: number | null, now: number): number | null => {
15  if (percent === null) {
16    anim.delete(key)
17
18    return null
19  }
20  let a = anim.get(key)
21  if (!a) {
22    a = { from: 0, to: percent, at: now }
23    anim.set(key, a)
24  } else if (a.to !== percent) {
25    a = { from: valueOf(a, now), to: percent, at: now }
26    anim.set(key, a)
27  }
28
29  return valueOf(a, now)
30}
31
32export const isSweeping = (now: number) => [...anim.values()].some(a => now - a.at < SWEEP_MS)
33
34export const isMoving = (key: string, now: number) => {
35  const a = anim.get(key)
36
37  return a !== undefined && now - a.at < SWEEP_MS
38}
39
hooks/admin.ts 62 lines
1// Filing a usage-credit request with the organization's admins, the way Claude Code does
2// once its own confirm is answered: find the organization, check nothing is already
3// pending, then post one `limit_increase` request.
4export const API = 'https://api.anthropic.com'
5
6const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
7
8const parse = (text: string): unknown => {
9  try {
10    return JSON.parse(text) as unknown
11  } catch {
12    return null
13  }
14}
15
16// The signed-in account's organization, from /api/oauth/profile.
17export const orgOf = (text: string): string | null => {
18  const data = parse(text) as { organization?: { uuid?: unknown }; organization_uuid?: unknown } | null
19  const uuid = data?.organization?.uuid ?? data?.organization_uuid
20
21  return typeof uuid === 'string' && UUID.test(uuid) ? uuid : null
22}
23
24// How many of my requests are still pending, from admin_requests/me (a list, or a list under a key).
25export const pendingOf = (text: string): number => {
26  const data = parse(text)
27  if (Array.isArray(data)) {
28    return data.length
29  }
30  const holder = data as Record<string, unknown> | null
31  for (const key of ['data', 'requests', 'admin_requests', 'items', 'results']) {
32    const list = holder?.[key]
33    if (Array.isArray(list)) {
34      return list.length
35    }
36  }
37
38  return 0
39}
40
41// The server's own words for a refusal, when it gives any.
42export const reasonOf = (text: string): string | null => {
43  const data = parse(text) as Record<string, unknown> | null
44  const error = (data?.error ?? null) as Record<string, unknown> | null
45  for (const holder of [error, data]) {
46    for (const key of ['message', 'detail']) {
47      const value = holder?.[key]
48      if (typeof value === 'string' && value.length > 0) {
49        return value
50      }
51    }
52  }
53
54  return null
55}
56
57export const profileUrl = () => `${API}/api/oauth/profile`
58export const mineUrl = (org: string) =>
59  `${API}/api/oauth/organizations/${org}/admin_requests/me?request_type=limit_increase&statuses=pending`
60export const fileUrl = (org: string) => `${API}/api/oauth/organizations/${org}/admin_requests`
61export const FILE_BODY = JSON.stringify({ request_type: 'limit_increase', details: null })
62
hooks/pace.ts 46 lines
1import type { Credits, Limit } from '../types'
2
3// How often the usage card may ask Anthropic, shared by every open session so five
4// sessions make one request, not five, and how long to wait once it says "slow down".
5export const FRESH_MS = 60_000
6// The server answers 429 with retry-after 0 ("try again now"), so the first wait is short.
7const BACKOFF_FIRST_MS = 30_000
8const BACKOFF_MAX_MS = 5 * 60_000
9
10export type Cached = { at: number; windows: Limit[]; credits: Credits | null }
11export type Block = { until: number; ms: number }
12
13export const asCached = (value: unknown): Cached | undefined => {
14  const v = value as Partial<Cached> | null | undefined
15
16  return v && typeof v.at === 'number' && Array.isArray(v.windows) ? (v as Cached) : undefined
17}
18
19export const asBlock = (value: unknown): Block | undefined => {
20  const v = value as Partial<Block> | null | undefined
21
22  return v && typeof v.until === 'number' && typeof v.ms === 'number' ? (v as Block) : undefined
23}
24
25// What to do now: use what another session already read, wait out a rate limit, or ask.
26export const decide = (now: number, cached: Cached | undefined, block: Block | undefined): 'adopt' | 'wait' | 'ask' => {
27  if (block && now < block.until) {
28    return 'wait'
29  }
30
31  return cached && now - cached.at < FRESH_MS ? 'adopt' : 'ask'
32}
33
34// A 429's new wait: what the server asked for (`retry-after`, in seconds), else 30 s and then twice the last, up to 5 min.
35export const backoff = (now: number, before: Block | undefined, retryAfter: string | undefined): Block => {
36  const asked = Number(retryAfter)
37  const ms =
38    Number.isFinite(asked) && asked > 0
39      ? Math.min(BACKOFF_MAX_MS, asked * 1000)
40      : Math.min(BACKOFF_MAX_MS, before ? before.ms * 2 : BACKOFF_FIRST_MS)
41
42  return { until: now + ms, ms }
43}
44
45export const minutes = (ms: number) => Math.max(1, Math.round(ms / 60_000))
46
hooks/usage.ts 48 lines
1import type { Credits, Limit } from '../types'
2
3// The reply of GET /api/oauth/usage, the endpoint behind the app's usage card.
4type Window = { utilization?: number | null; resets_at?: string | null } | null | undefined
5type Reply = {
6  five_hour?: Window
7  seven_day?: Window
8  extra_usage?: {
9    is_enabled: boolean
10    monthly_limit?: number | null
11    used_credits?: number | null
12    utilization?: number | null
13    currency?: string | null
14  } | null
15}
16
17const tenth = (value: number) => Math.round(value * 10) / 10
18
19const windowOf = (kind: string, raw: Window): Limit[] =>
20  raw && typeof raw.utilization === 'number'
21    ? [{ kind, percentUsed: tenth(raw.utilization), ...(raw.resets_at ? { resetsAt: raw.resets_at } : {}) }]
22    : []
23
24export const parseUsage = (text: string): { windows: Limit[]; credits: Credits | null } => {
25  const reply = JSON.parse(text) as Reply
26  const extra = reply.extra_usage
27  let credits: Credits | null = null
28  if (extra) {
29    // Amounts are minor units of the currency, cents for USD.
30    const limit = (extra.monthly_limit ?? 0) / 100
31    const spent = (extra.used_credits ?? 0) / 100
32    credits = {
33      isEnabled: extra.is_enabled,
34      spent,
35      limit,
36      percentUsed: tenth(limit > 0 ? (spent / limit) * 100 : (extra.utilization ?? 0)),
37      currency: extra.currency ?? 'USD',
38    }
39  }
40
41  return { windows: [...windowOf('five_hour', reply.five_hour), ...windowOf('seven_day', reply.seven_day)], credits }
42}
43
44const SYMBOLS: Record<string, string> = { USD: '$', EUR: '€', GBP: '£' }
45
46export const money = (amount: number, currency: string) =>
47  `${SYMBOLS[currency] ?? `${currency} `}${String(Math.round(amount)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
48
types/index.d.ts 10 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2// Dollars, as the usage card shows them (the API sends minor units).
3export type Credits = { isEnabled: boolean; spent: number; limit: number; percentUsed: number; currency: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'usage-meter': { windows: Limit[]; credits: Credits | null; error: string | null }
8  }
9}
10