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…

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.

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.
/more-usage does the same from the prompt.install.sh works on macOS and Linux.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)
hooks/register.tsx 453 lines1import { 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}
453hooks/gauge.ts 66 lines1// 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}
66hooks/motion.ts 39 lines1// 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}
39hooks/admin.ts 62 lines1// 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 })
62hooks/pace.ts 46 lines1import 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))
46hooks/usage.ts 48 lines1import 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, ',')}`
48types/index.d.ts 10 lines1export 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