One line above the prompt: context tokens, session cost, active time, a prompt-cache countdown and your plan limits with pace

A Claude Code mod that draws one line above the prompt, so the figures you would otherwise open /usage for are always in view:
ctx 143K $4.21 active 17m 6s cache ◕ 50:00 5h ◑ 62% +13% empty in 1h 30m 7d ◑ 44% −6%
/usage formats it.expired after that./usage shows it, read from the usage endpoint /usage reads, shared by all your sessions. When that reading is a few minutes old, a newer response's rate-limit headers stand in. A window whose reset time has passed reads 0% until the next reading. Beside it is the pace: how far ahead (+13%, red) or behind (−6%, green) you are of an even burn through the window. When you are off pace, it estimates when the window runs out.It stays on one line: when the window is too narrow, it leaves out the active time, then the cost, the time-to-empty, any per-model week and the context, in that order. /pace hides the band for the session and brings it back (/pace off, /pace on).
The desktop Code tab draws real rings; the terminal draws ○◔◑◕● glyphs. The pace model follows CodexBar by Peter Steinberger: the delta is actual minus expected use, with stages at 2, 6 and 12 points.
Claude Code with mods (the hooks/hooks.json modules entry), on macOS or Linux. The Active time and the cache lifetime read the transcript with sh, awk, tail and ps; where those are missing, as on Windows without a POSIX shell, Active counts only turns seen since the mod loaded and the cache lifetime is inferred from your plan.
Add it from the plugin directory: /plugin, then Discover, or from the directory on claude.ai. To try it from a clone first:
claude --plugin-dir ./frontier-pacer
The band appears in sessions started after the install.
Everything the mod touches, in full:
GET https://api.anthropic.com/api/oauth/usage, the endpoint /usage reads, to get the plan percentages: after a response or once a minute while idle, at most once a minute across all your sessions. It sends Claude Code's User-Agent (claude-code/<version>), which the endpoint requires: without it every request is refused with a 429 and an hour's Retry-After. After a 429 every session waits for its Retry-After or 5 minutes, whichever is longer, doubling with each 429 in a row up to an hour. The request carries the session's own Claude login through the engine's $.session.authorize(). The mod never sees or stores the token. Nothing is sent anywhere else. While it is refused, the rate-limit headers of each response keep the figures current.~/.claude/projects/ (or $CLAUDE_CONFIG_DIR/projects/). It reads only the last 1 MiB to learn the cache lifetime and only the row timestamps to rebuild the Active time after a restart. It writes no files./bin/sh -c with a one-line loop that looks for <session id>.jsonl under projects/ and prints its path, since the engine does not expose the transcript path;tail -c 1048576 <transcript> to read the end of the transcript for the cache lifetime of the last response;awk with a fixed program over the transcript that prints two lines per turn, the prompt's time and the time of the last work after it (P <time>, A <time>), for the Active time;/bin/sh -c with a one-line loop that walks up the parent processes with ps -o comm= and ps -o etime= until it finds claude, to learn when this Claude Code process started, so Active counts only turns run by this process, as the app's own usage panel does. The output of these programs is only parsed into numbers for the band. The mod never runs a command it receives from the network, the model or the conversation./pace, which only hides or shows the band.session.start, session.measure, turn.start, turn.step, turn.complete, PostModelSwitch and session.end and passes each one on unchanged with next(e), so it alters no setting, instruction, prompt, tool or response. Its one drawing hook, ui.render on AbovePrompt, adds the band, and its command.run hook answers only /pace.claude plugin test .
MIT. The pace model is ported from CodexBar, also MIT; its notice is in LICENSE.
hooks/register.tsx 951 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 Register,
5 RenderChildren,
6 SessionContextUsage,
7 SessionRateLimit,
8} from 'claude-code'
9
10import type { Active, Limit, Meter, Reading, Shared } from '../types'
11
12const meter = atom({ plugin: 'frontier-pacer', key: 'meter' } as const, null)
13const cache = atom({ plugin: 'frontier-pacer', key: 'cache' } as const, null)
14const transcript = atom({ plugin: 'frontier-pacer', key: 'transcript' } as const, null)
15const readings = atom({ plugin: 'frontier-pacer', key: 'readings' } as const, [])
16const model = atom({ plugin: 'frontier-pacer', key: 'model' } as const, null)
17const active = atom({ plugin: 'frontier-pacer', key: 'active' } as const, null)
18const tick = atom({ plugin: 'frontier-pacer', key: 'tick' } as const, 0)
19const hidden = atom({ plugin: 'frontier-pacer', key: 'hidden' } as const, false)
20
21type Ttl = '5m' | '1h'
22
23const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
24
25// The windows /usage draws, in its order, with CodexBar's wording for running out.
26const LANES = [
27 { kind: 'five_hour', label: '5h', minutes: 300, runsOut: 'empty in' },
28 { kind: 'seven_day', label: '7d', minutes: 10_080, runsOut: 'out in' },
29 { kind: 'seven_day_sonnet', label: '7d Sonnet', minutes: 10_080, runsOut: 'out in' },
30] as const
31
32type Lane = { kind: string; label: string; minutes: number; runsOut: string; model?: string }
33
34// Every session of this plugin shares its readings through the plugin's store, so each band shows
35// the newest figure of each window that any session read: from a response's rate-limit headers
36// (every response) or from the usage endpoint. The endpoint rate-limits hard and its limit is shared
37// with Claude Code's own polling, so all sessions together ask it at most once a minute, honour
38// its Retry-After, and double the wait after each 429 in a row.
39const USAGE_EVERY_MS = 60_000
40const USAGE_MIN_GAP_MS = 60_000
41const USAGE_BACKOFF_MS = 5 * 60_000
42const USAGE_BACKOFF_MAX_MS = 60 * 60_000
43const SHARED_EVERY_MS = 5_000
44// Version 0.2 kept one reading under `usage`; sessions still running it rewrite that key, so the
45// readings live under their own.
46const SHARED_KEY = 'readings'
47
48// CodexBar's Claude tint, and SwiftUI's .red and .green for its pace colours.
49const CLAUDE = '#CC7C5E'
50const RED = '#FF3B30'
51const GREEN = '#34C759'
52
53const TICK_MS = 1000
54const TAIL_BYTES = 1024 * 1024
55const RING_PX = 16
56const RING_STROKE = 2.5
57// Columns between two figures on the line.
58const GAP = 2
59const COMMAND = 'pace'
60// Partial circles from empty to full, for the terminal, which draws no SVG.
61const GLYPHS = ['○', '◔', '◑', '◕', '●'] as const
62
63// Finds this session's transcript under every project folder, whatever the cwd was sanitised to.
64const FIND_TRANSCRIPT =
65 'for f in "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/projects/*/"$1".jsonl; do [ -f "$f" ] && { echo "$f"; break; }; done'
66
67// Whether this load of the module has started its timers: a hot reload mid-session raises no
68// session.start, so the first event after one starts them.
69let isRunning = false
70
71function run($: EngineInterface): void {
72 if (isRunning) {
73 return
74 }
75
76 isRunning = true
77 $.clock.after(1, () => void boot($))
78 $.clock.every(TICK_MS, () => void pulse($))
79 $.clock.every(USAGE_EVERY_MS, () => void fetchUsage($))
80 $.clock.every(SHARED_EVERY_MS, () => void adoptShared($))
81}
82
83export const register: Register = on => {
84 on('session.start', async ($, e, next) => {
85 const started = await next(e)
86 run($)
87
88 return started
89 })
90
91 on('session.measure', async ($, e, next) => {
92 run($)
93 const m = toMeter(e.context, e.rateLimits, e.cost?.usd)
94 await update($, meter, () => m)
95 // A response landed (it cost something, or moved a window): its headers are the newest reading.
96 if (e.changed.includes('cost') || e.changed.includes('rateLimits')) {
97 const at = await $.clock.now()
98 await share($, m.limits.map(limit => ({ ...limit, at, source: 'headers' as const })))
99 void fetchUsage($)
100 }
101
102 return next(e)
103 })
104
105 // A prompt starts the model working; the turn's end adds it to the total.
106 on('turn.start', async ($, e, next) => {
107 run($)
108 const at = await $.clock.now()
109 await update($, active, a => ({ ...(a ?? EMPTY_ACTIVE), since: at }))
110
111 return next(e)
112 })
113
114 // A main-thread request reads the cached prefix, which starts its lifetime over.
115 on('turn.step', async function* ($, e, next) {
116 if (e.agentId === undefined) {
117 const at = await $.clock.now()
118 await update($, cache, mark => ({ at, ttl: mark?.ttl ?? null }))
119 await update($, model, () => e.model)
120 }
121
122 return yield* next(e)
123 })
124
125 // The response records which lifetime its cache write used.
126 on('turn.complete', async ($, e, next) => {
127 const done = await next(e)
128 if (e.agentId === undefined) {
129 // The engine's own length of the turn, to the millisecond, as its "Cogitated for" line reads.
130 await update($, active, a => {
131 const base = a ?? EMPTY_ACTIVE
132 const doneMs = baseOf(base) + e.durationMs
133
134 return { ...base, doneMs, closedMs: doneMs, lastPromptAt: null, since: null }
135 })
136 await learnTtl($, false)
137 void fetchUsage($)
138 }
139
140 return done
141 })
142
143 on('classic.PostModelSwitch', async ($, e, next) => {
144 await update($, cache, () => null)
145 void learnModel($)
146
147 return next(e)
148 })
149
150 on('session.end', async ($, e, next) => {
151 if (e.reason === 'clear') {
152 await update($, cache, () => null)
153 await update($, transcript, () => null)
154 await update($, meter, m => (m === null ? m : { ...m, tokens: null }))
155 // /clear starts the panel's count over, in the same process.
156 const at = await $.clock.now()
157 await update($, active, () => ({ ...EMPTY_ACTIVE, from: at }))
158 }
159
160 return next(e)
161 })
162
163 // /pace hides the band or brings it back; /pace on and /pace off set it.
164 on('command.run', { command: COMMAND }, async ($, e) => {
165 const arg = e.args.trim().toLowerCase()
166 const isHidden = arg === 'off' || arg === 'hide' ? true : arg === 'on' || arg === 'show' ? false : !(await read($, hidden))
167 await update($, hidden, () => isHidden)
168
169 return { text: isHidden ? 'Usage band hidden. /pace shows it again.' : 'Usage band shown. /pace hides it.' }
170 })
171
172 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
173 if (e.props.hasSurvey || (await read($, hidden))) {
174 return next(e)
175 }
176
177 await read($, tick)
178 const m = await read($, meter)
179 const mark = await read($, cache)
180 const held = await read($, readings)
181 const modelId = (await read($, model))?.toLowerCase() ?? ''
182 const worked = await read($, active)
183 if (m === null && mark === null) {
184 return next(e)
185 }
186
187 const now = await $.clock.now()
188 const { Box, Text } = $.ui.resolve(e)
189 // Only the desktop draws SVG; the terminal gets partial-circle glyphs.
190 const Svg = e.surface === 'desktop' ? $.ui.resolve(e).Svg : undefined
191 const ringColumns = Svg !== undefined ? 2 : 1
192 const ring = (key: string, gauge: Gauge, alt: string) =>
193 Svg !== undefined ? (
194 <Svg key={key} source={ringSvg(gauge)} alt={alt} width={RING_PX} height={RING_PX} />
195 ) : (
196 <Text key={key} color={gauge.color} bold>
197 {glyphOf(gauge.fraction)}
198 </Text>
199 )
200 const items: Item[] = []
201
202 if (m?.tokens != null) {
203 const text = tokensText(m.tokens)
204 items.push({ key: 'context', rank: 4, columns: 4 + text.length, node: (
205 <Box key="context" flexDirection="row" columnGap={1}>
206 <Text dimColor>ctx</Text>
207 <Text bold>{text}</Text>
208 </Box>
209 ) })
210 }
211 if (m?.usd != null) {
212 const text = usdText(m.usd)
213 items.push({ key: 'cost', rank: 6, columns: text.length, node: <Text key="cost" bold>{text}</Text> })
214 }
215 const activeMs = activeOf(worked, e.props.isWorking, now)
216 if (activeMs !== null) {
217 const text = durationText(activeMs)
218 items.push({ key: 'active', rank: 7, columns: 7 + text.length, node: (
219 <Box key="active" flexDirection="row" columnGap={1}>
220 <Text dimColor>active</Text>
221 <Text bold>{text}</Text>
222 </Box>
223 ) })
224 }
225
226 const ttl = mark === null ? null : (mark.ttl ?? guessTtl(m))
227 // While Claude works each request reads the cache again, so the timer holds full until the turn ends.
228 const remaining =
229 mark === null || ttl === null ? null : e.props.isWorking ? TTL_MS[ttl] : mark.at + TTL_MS[ttl] - now
230 if (ttl !== null && remaining !== null) {
231 // The last tenth of the lifetime (6 minutes of the hour) is when a prompt still lands warm.
232 const isLastTenth = remaining < TTL_MS[ttl] / 10
233 const gauge: Gauge = { fraction: clamp(remaining / TTL_MS[ttl], 0, 1), color: isLastTenth ? RED : CLAUDE }
234 const text = remaining <= 0 ? 'expired' : timerText(remaining)
235 items.push({ key: 'cache', rank: 3, columns: 6 + ringColumns + 1 + text.length, node: (
236 <Box key="cache" flexDirection="row" alignItems="center" columnGap={1}>
237 <Text dimColor>cache</Text>
238 {ring('cache-ring', gauge, remaining > 0 ? `cache warm for ${text}` : 'cache expired')}
239 {remaining <= 0 || isLastTenth ? <Text color={RED} bold>{text}</Text> : <Text bold>{text}</Text>}
240 </Box>
241 ) })
242 }
243
244 // The newest reading of each window; until any, the headers the engine held at load.
245 const limits = (held.length > 0 ? held : (m?.limits ?? [])).map(limit => current(limit, now))
246 for (const limit of limits) {
247 const lane = laneOf(limit.kind)
248 // A week scoped to one model shows while the session runs that model.
249 if (lane === null || (lane.model !== undefined && !modelId.includes(lane.model.toLowerCase()))) {
250 continue
251 }
252
253 // /usage floors: 7.9% reads 7% used.
254 const used = Math.floor(clamp(limit.percentUsed, 0, 100))
255 const pace = shownPace(limit, lane.minutes, now)
256 const paceColor = pace === null || pace.stage === 'onTrack' ? undefined : pace.delta > 0 ? RED : GREEN
257 const delta = pace === null ? null : paceText(pace.delta)
258 // The ring shows the figure printed beside it.
259 const gauge: Gauge = { fraction: used / 100, color: used >= 100 ? RED : CLAUDE }
260 const alt = `${lane.label} ${used}% used${delta === null ? '' : `, ${delta} against pace`}`
261 const usedText = `${used}%`
262 items.push({
263 key: lane.kind,
264 rank: lane.model === undefined ? (lane.kind === 'five_hour' ? 1 : 2) : 5,
265 columns: lane.label.length + 1 + ringColumns + 1 + usedText.length + (delta === null ? 0 : 1 + delta.length),
266 node: (
267 <Box key={lane.kind} flexDirection="row" alignItems="center" columnGap={1}>
268 <Text dimColor>{lane.label}</Text>
269 {ring(`${lane.kind}-ring`, gauge, alt)}
270 <Text bold>{usedText}</Text>
271 {delta !== null &&
272 (paceColor === undefined ? <Text dimColor>{delta}</Text> : <Text color={paceColor}>{delta}</Text>)}
273 </Box>
274 ),
275 })
276
277 // Within 2 points of an even pace the projection is noise (two hours into a week), so it stays quiet.
278 if (pace !== null && pace.stage !== 'onTrack' && !pace.lastsToReset && pace.etaMs !== null) {
279 const eta = pace.etaMs === 0 ? `${lane.runsOut} now` : `${lane.runsOut} ${countdown(pace.etaMs)}`
280 items.push({ key: `${lane.kind}-eta`, rank: 8, columns: eta.length, node: <Text key={`${lane.kind}-eta`} color={RED}>{eta}</Text> })
281 }
282 }
283
284 if (items.length === 0) {
285 return next(e)
286 }
287
288 return (
289 <Box flexDirection="row" flexWrap="nowrap" alignItems="center" columnGap={GAP}>
290 {fit(items, e.props.bodyColumns).map(item => item.node)}
291 </Box>
292 )
293 })
294}
295
296type Item = {
297 key: string
298 /** Which figure goes first when the line is short: 1 stays longest. */
299 rank: number
300 /** How many columns it takes. */
301 columns: number
302 node: RenderChildren
303}
304
305// Keeps the figures in their order on one line, leaving out the least needed until the line fits.
306function fit(items: readonly Item[], columns: number): Item[] {
307 const width = (kept: readonly Item[]) => kept.reduce((sum, item) => sum + item.columns, 0) + GAP * Math.max(0, kept.length - 1)
308 let kept = [...items]
309 const byNeed = [...items].sort((a, b) => b.rank - a.rank)
310 for (const item of byNeed) {
311 if (!(columns > 0) || width(kept) <= columns || kept.length === 1) {
312 break
313 }
314 kept = kept.filter(k => k !== item)
315 }
316
317 return kept
318}
319
320// Redraws each second: the cache timer and the active clock both count seconds.
321async function pulse($: EngineInterface): Promise<void> {
322 await update($, tick, n => n + 1)
323}
324
325async function boot($: EngineInterface): Promise<void> {
326 try {
327 await $.command.register({ name: COMMAND, description: 'Hide or show the usage band (on, off)', argumentHint: '[on|off]', immediate: true })
328 } catch {
329 // A host without slash commands: the band always shows.
330 }
331 const usage = await $.session.usage()
332 await update($, meter, () => toMeter(usage.context, usage.rateLimits, usage.cost?.usd))
333 await learnTtl($, true)
334 await learnActive($)
335 await learnModel($)
336 await fetchUsage($)
337}
338
339const PROMPT_SLACK_MS = 5_000
340const EMPTY_ACTIVE: Active = { doneMs: 0, closedMs: 0, lastPromptAt: null, since: null, from: null }
341
342// Prints how long the Claude Code process above this one has run, as ps's elapsed time.
343const PROCESS_AGE =
344 'p=$PPID; while [ "$p" -gt 1 ]; do case "$(ps -o comm= -p "$p")" in *claude|*claude.exe) ps -o etime= -p "$p"; exit 0;; esac; p=$(ps -o ppid= -p "$p" | tr -d " "); done'
345
346// Prints each main-thread turn of the transcript as `P <time>` for the prompt the person sent and
347// `A <time>` for the last row of the model's work after it (a response, a tool result); the rest of
348// each row is dropped, so a long transcript prints two short lines a turn.
349const TURN_ROWS = `
350function flush() { if (work != "") print "A " work; work = "" }
351/"isSidechain":true/ { next }
352{
353 if (!match($0, /"timestamp":"[^"]*"/)) next
354 ts = substr($0, RSTART + 13, RLENGTH - 14)
355 if ($0 ~ /"role":"assistant"/) { work = ts; next }
356 if ($0 ~ /"role":"user"/) {
357 if ($0 ~ /"tool_result"/) { work = ts; next }
358 if ($0 ~ /"isMeta":true/ || $0 ~ /"isCompactSummary":true/) next
359 flush()
360 print "P " ts
361 }
362}
363END { flush() }`
364
365// Reads every turn the transcript holds: each runs from a prompt to the last row of work before
366// the next prompt, so turns from before this plugin loaded (a resume, a reload) count too.
367async function learnActive($: EngineInterface): Promise<void> {
368 try {
369 const path = await transcriptPath($)
370 if (path === null) {
371 await update($, active, a => a ?? EMPTY_ACTIVE)
372
373 return
374 }
375
376 const from = (await read($, active))?.from ?? (await processStart($))
377 const { stdout } = await $.process.run(['awk', TURN_ROWS, path])
378 const turns = turnsOf(stdout).filter(t => from === null || t.at >= from)
379 const doneMs = turns.reduce((sum, t) => sum + t.ms, 0)
380 const last = turns[turns.length - 1]
381 await update($, active, a => ({
382 doneMs,
383 closedMs: doneMs - (last?.ms ?? 0),
384 lastPromptAt: last?.at ?? null,
385 since: a?.since ?? null,
386 from,
387 }))
388 } catch {
389 await update($, active, a => a ?? EMPTY_ACTIVE)
390 }
391}
392
393// When this Claude Code process started: the panel counts the turns since.
394async function processStart($: EngineInterface): Promise<number | null> {
395 try {
396 const { stdout } = await $.process.run(['/bin/sh', '-c', PROCESS_AGE])
397 const match = /^\s*(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)\s*$/.exec(stdout)
398 if (match === null) {
399 return null
400 }
401
402 const [, days = '0', hours = '0', minutes = '0', seconds = '0'] = match
403 const ageMs = (((Number(days) * 24 + Number(hours)) * 60 + Number(minutes)) * 60 + Number(seconds)) * 1000
404
405 return (await $.clock.now()) - ageMs
406 } catch {
407 return null
408 }
409}
410
411function turnsOf(rows: string): Array<{ at: number; ms: number }> {
412 const turns: Array<{ at: number; ms: number }> = []
413 let prompt: number | null = null
414 let last: number | null = null
415 const close = () => {
416 if (prompt !== null && last !== null && last > prompt) {
417 turns.push({ at: prompt, ms: last - prompt })
418 }
419 }
420 for (const row of rows.split('\n')) {
421 const at = Date.parse(row.slice(2))
422 if (Number.isNaN(at)) {
423 continue
424 }
425 if (row.startsWith('P ')) {
426 close()
427 prompt = at
428 last = null
429 } else if (prompt !== null) {
430 last = at
431 }
432 }
433 close()
434
435 return turns
436}
437
438// While a turn runs it counts from its start: the one this plugin saw begin, or else (loaded
439// mid-turn) the transcript's last prompt, whose partial length is then left out of the total.
440function activeOf(worked: Active | null, isWorking: boolean, now: number): number | null {
441 if (worked === null) {
442 return null
443 }
444 if (!isWorking) {
445 return worked.doneMs
446 }
447 const start = worked.since ?? worked.lastPromptAt
448
449 return start === null ? worked.doneMs : baseOf(worked) + Math.max(0, now - start)
450}
451
452// The finished turns before the running one. A transcript read after the running turn began holds
453// part of it as its last turn; loaded mid-turn (no start seen), that last turn is the running one.
454function baseOf(worked: Active): number {
455 if (worked.lastPromptAt === null) {
456 return worked.doneMs
457 }
458 if (worked.since === null) {
459 return worked.closedMs
460 }
461
462 return worked.lastPromptAt >= worked.since - PROMPT_SLACK_MS ? worked.closedMs : worked.doneMs
463}
464
465async function learnModel($: EngineInterface): Promise<void> {
466 try {
467 const id = await $.session.model()
468 await update($, model, () => id)
469 } catch {
470 // The next main-thread request names it.
471 }
472}
473
474function laneOf(kind: string): Lane | null {
475 const known = LANES.find(lane => lane.kind === kind)
476 if (known !== undefined) {
477 return known
478 }
479 if (kind.startsWith('weekly:')) {
480 const name = kind.slice('weekly:'.length)
481
482 return { kind, label: `7d ${name}`, minutes: 10_080, runsOut: 'out in', model: name }
483 }
484
485 return null
486}
487
488type UsageWindow = { utilization?: number | null; resets_at?: string | null } | null | undefined
489
490type UsageBody = {
491 five_hour?: UsageWindow
492 seven_day?: UsageWindow
493 seven_day_sonnet?: UsageWindow
494 limits?: Array<{ kind?: string; percent?: number | null; resets_at?: string | null; scope?: { model?: { display_name?: string } } }>
495}
496
497// A window whose reset has passed starts over at nothing used, until a reading says otherwise.
498function current(limit: Limit, now: number): Limit {
499 const resetsAt = limit.resetsAt === null ? NaN : Date.parse(limit.resetsAt)
500
501 return resetsAt <= now ? { ...limit, percentUsed: 0, resetsAt: null } : limit
502}
503
504// A response's headers can trail /usage by a point, so they stand in only once the endpoint's
505// reading is this much older than theirs.
506const HEADERS_LAG_MS = 3 * 60_000
507
508function weight(reading: Reading): number {
509 return reading.source === 'headers' ? reading.at - HEADERS_LAG_MS : reading.at
510}
511
512// The better reading of each window from both lists (the second on a tie); a window either lacks is kept.
513function merge(mine: readonly Reading[], theirs: readonly Reading[]): Reading[] {
514 const byKind = new Map<string, Reading>()
515 for (const reading of [...mine, ...theirs]) {
516 const held = byKind.get(reading.kind)
517 if (held === undefined || weight(reading) >= weight(held)) {
518 byKind.set(reading.kind, reading)
519 }
520 }
521
522 return [...byKind.values()]
523}
524
525function isReading(value: unknown): value is Reading {
526 const r = value as Reading | null
527 return r != null && typeof r.kind === 'string' && typeof r.percentUsed === 'number' && typeof r.at === 'number'
528}
529
530// What every session shares; empty when nothing is stored.
531async function readShared($: EngineInterface): Promise<Shared> {
532 try {
533 const value = (await $.store.get(SHARED_KEY)) as Shared | undefined
534 if (value == null) {
535 return { readings: [] }
536 }
537
538 return {
539 readings: Array.isArray(value.readings) ? value.readings.filter(isReading) : [],
540 askedAt: typeof value.askedAt === 'number' ? value.askedAt : undefined,
541 retryAt: typeof value.retryAt === 'number' ? value.retryAt : undefined,
542 strikes: typeof value.strikes === 'number' ? value.strikes : undefined,
543 }
544 } catch {
545 return { readings: [] }
546 }
547}
548
549// Re-reads the store just before writing, so a reading another session stored a moment ago survives.
550async function writeShared($: EngineInterface, change: (shared: Shared) => Shared): Promise<void> {
551 try {
552 await $.store.set(SHARED_KEY, change(await readShared($)))
553 } catch {
554 // No store (a headless host): this session keeps its own readings.
555 }
556}
557
558// Takes this session's new readings and every newer one stored by another session.
559async function share($: EngineInterface, fresh: readonly Reading[]): Promise<void> {
560 await update($, readings, mine => merge(mine, fresh))
561 await writeShared($, shared => ({ ...shared, readings: merge(shared.readings, fresh) }))
562 await adoptShared($)
563}
564
565async function adoptShared($: EngineInterface): Promise<Shared> {
566 const shared = await readShared($)
567 if (shared.readings.length > 0) {
568 await update($, readings, mine => merge(mine, shared.readings))
569 }
570
571 return shared
572}
573
574// Reads the windows /usage shows: the session, the week across models, Sonnet's week, and each week
575// scoped to one model. A failure keeps the last readings, which the next response's headers refresh.
576async function fetchUsage($: EngineInterface): Promise<void> {
577 try {
578 const now = await $.clock.now()
579 const shared = await adoptShared($)
580 if (shared.retryAt !== undefined && now < shared.retryAt) {
581 return
582 }
583 if (shared.askedAt !== undefined && now - shared.askedAt < USAGE_MIN_GAP_MS) {
584 return
585 }
586
587 const auth = await $.session.authorize()
588 if (auth === null || auth.kind !== 'bearer') {
589 return
590 }
591
592 // Claims the turn before asking, so the other sessions wait.
593 await writeShared($, s => ({ ...s, askedAt: now }))
594 // The account usage endpoint /usage reads; the engine's own credential rides the request.
595 // It answers only Claude Code: without its User-Agent every request gets 429 with an hour's
596 // Retry-After, however rarely it is asked.
597 const { base, version } = await $.session.version()
598 const response = await $.http.fetch('https://api.anthropic.com/api/oauth/usage', {
599 auth: auth.handle,
600 headers: {
601 'anthropic-beta': 'oauth-2025-04-20',
602 accept: 'application/json',
603 'content-type': 'application/json',
604 'user-agent': `claude-code/${base ?? version}`,
605 },
606 })
607 if (response.status === 429) {
608 // Rate limited: every session waits, as long as the endpoint asks or longer each time.
609 await writeShared($, s => {
610 const strikes = (s.strikes ?? 0) + 1
611 const backoff = Math.min(USAGE_BACKOFF_MAX_MS, USAGE_BACKOFF_MS * 2 ** (strikes - 1))
612 const asked = retryAfterMs(response.headers['retry-after'])
613
614 return { ...s, strikes, retryAt: now + Math.max(backoff, asked) }
615 })
616
617 return
618 }
619 if (!response.ok) {
620 return
621 }
622
623 const limits = officialLimits(JSON.parse(response.text) as UsageBody)
624 await writeShared($, s => ({ ...s, retryAt: undefined, strikes: undefined }))
625 if (limits.length > 0) {
626 await share($, limits.map(limit => ({ ...limit, at: now, source: 'endpoint' as const })))
627 }
628 } catch {
629 // Offline, logged out, or a body of another shape: the readings held stand.
630 }
631}
632
633// Retry-After in seconds; 0 when absent or unreadable (the endpoint has sent `0` while still refusing).
634function retryAfterMs(value: string | undefined): number {
635 const seconds = Number(value)
636
637 return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0
638}
639
640function officialLimits(body: UsageBody): Limit[] {
641 const out: Limit[] = []
642 for (const kind of ['five_hour', 'seven_day', 'seven_day_sonnet'] as const) {
643 const window = body[kind]
644 if (window != null && typeof window.utilization === 'number') {
645 out.push({ kind, percentUsed: window.utilization, resetsAt: window.resets_at ?? null })
646 }
647 }
648 for (const scoped of body.limits ?? []) {
649 const name = scoped.scope?.model?.display_name
650 if (scoped.kind === 'weekly_scoped' && typeof name === 'string' && typeof scoped.percent === 'number') {
651 out.push({ kind: `weekly:${name}`, percentUsed: scoped.percent, resetsAt: scoped.resets_at ?? null })
652 }
653 }
654
655 return out
656}
657
658function toMeter(context: SessionContextUsage, limits: readonly SessionRateLimit[], usd: number | undefined): Meter {
659 return {
660 tokens: context.tokens ?? null,
661 window: context.window,
662 limits: limits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt ?? null })),
663 usd: usd ?? null,
664 }
665}
666
667// Reads the last main-thread response from the transcript: when it landed, and its cache lifetime.
668// `seed` sets the anchor from it when this process has not seen a request yet (a resume, a reload).
669async function learnTtl($: EngineInterface, seed: boolean): Promise<void> {
670 try {
671 const path = await transcriptPath($)
672 if (path === null) {
673 return
674 }
675
676 const { stdout } = await $.process.run(['tail', '-c', String(TAIL_BYTES), path])
677 const last = lastResponse(stdout)
678 if (last === null) {
679 return
680 }
681
682 await update($, cache, mark => {
683 if (mark !== null) {
684 return { ...mark, ttl: last.ttl ?? mark.ttl }
685 }
686
687 return seed ? { at: last.at, ttl: last.ttl } : null
688 })
689 } catch {
690 // No transcript to read (a headless host, a moved file): the lifetime stays a guess.
691 }
692}
693
694async function transcriptPath($: EngineInterface): Promise<string | null> {
695 const known = await read($, transcript)
696 if (known !== null) {
697 return known
698 }
699
700 const id = await $.session.id()
701 const { stdout } = await $.process.run(['/bin/sh', '-c', FIND_TRANSCRIPT, 'sh', id])
702 const found = stdout.trim()
703 if (found === '') {
704 return null
705 }
706
707 await update($, transcript, () => found)
708
709 return found
710}
711
712function lastResponse(text: string): { at: number; ttl: Ttl | null } | null {
713 const lines = text.split('\n')
714 for (let i = lines.length - 1; i >= 0; i--) {
715 const line = lines[i]
716 if (line === undefined || !line.includes('"assistant"')) {
717 continue
718 }
719
720 let row: unknown
721 try {
722 row = JSON.parse(line)
723 } catch {
724 continue
725 }
726
727 const entry = row as {
728 type?: string
729 isSidechain?: boolean
730 timestamp?: string
731 message?: { model?: string; usage?: unknown }
732 }
733 if (entry.type !== 'assistant' || entry.isSidechain === true) {
734 continue
735 }
736 if (entry.message?.usage == null || entry.message.model === '<synthetic>') {
737 continue
738 }
739
740 const at = Date.parse(entry.timestamp ?? '')
741 if (Number.isNaN(at)) {
742 continue
743 }
744
745 return { at, ttl: ttlOf(entry.message.usage) }
746 }
747
748 return null
749}
750
751// The engine's own reading of a response's cache write.
752function ttlOf(usage: unknown): Ttl | null {
753 const split = (usage as { cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number } })
754 .cache_creation
755 if ((split?.ephemeral_1h_input_tokens ?? 0) > 0) {
756 return '1h'
757 }
758 if ((split?.ephemeral_5m_input_tokens ?? 0) > 0) {
759 return '5m'
760 }
761
762 return null
763}
764
765// Before any response says: subscribers get the hour unless they are past a limit, everyone else five minutes.
766function guessTtl(m: Meter | null): Ttl {
767 if (m === null || m.limits.length === 0) {
768 return '5m'
769 }
770
771 return m.limits.some(l => l.percentUsed >= 100) ? '5m' : '1h'
772}
773
774type Stage = 'onTrack' | 'slightlyAhead' | 'ahead' | 'farAhead' | 'slightlyBehind' | 'behind' | 'farBehind'
775
776type Pace = {
777 stage: Stage
778 delta: number
779 expected: number
780 actual: number
781 etaMs: number | null
782 lastsToReset: boolean
783}
784
785// UsagePace.weekly from steipete/CodexBar (Sources/CodexBarCore/UsagePace.swift), without the work-day split.
786function paceOf(limit: Limit, minutes: number, now: number): Pace | null {
787 if (limit.resetsAt === null) {
788 return null
789 }
790
791 const resetsAt = Date.parse(limit.resetsAt)
792 const duration = minutes * 60_000
793 const untilReset = resetsAt - now
794 if (Number.isNaN(resetsAt) || untilReset <= 0 || untilReset > duration) {
795 return null
796 }
797
798 const elapsed = clamp(duration - untilReset, 0, duration)
799 const expected = clamp((elapsed / duration) * 100, 0, 100)
800 const actual = clamp(limit.percentUsed, 0, 100)
801 if (elapsed === 0 && actual > 0) {
802 return null
803 }
804
805 let etaMs: number | null = null
806 let lastsToReset = false
807 if (actual >= 100) {
808 etaMs = 0
809 } else if (elapsed > 0 && actual > 0) {
810 const toEmpty = (100 - actual) / (actual / elapsed)
811 if (toEmpty >= untilReset) {
812 lastsToReset = true
813 } else {
814 etaMs = toEmpty
815 }
816 } else if (elapsed > 0) {
817 lastsToReset = true
818 }
819
820 const delta = actual - expected
821
822 return { stage: stageOf(delta), delta, expected, actual, etaMs, lastsToReset }
823}
824
825// A pace shows while quota is left (CodexBar also waits for 3% of the window; here every window shows one).
826function shownPace(limit: Limit, minutes: number, now: number): Pace | null {
827 if (limit.percentUsed >= 100) {
828 return null
829 }
830
831 const pace = paceOf(limit, minutes, now)
832 if (pace === null) {
833 return null
834 }
835
836 return pace
837}
838
839function stageOf(delta: number): Stage {
840 const size = Math.abs(delta)
841 if (size <= 2) {
842 return 'onTrack'
843 }
844 if (size <= 6) {
845 return delta >= 0 ? 'slightlyAhead' : 'slightlyBehind'
846 }
847 if (size <= 12) {
848 return delta >= 0 ? 'ahead' : 'behind'
849 }
850
851 return delta >= 0 ? 'farAhead' : 'farBehind'
852}
853
854type Gauge = {
855 /** How much of the circle is drawn, 0 to 1, clockwise from twelve o'clock. */
856 fraction: number
857 color: string
858}
859
860// A ring in CodexBar's colours: a 22% grey track and the arc over it.
861function ringSvg(gauge: Gauge): string {
862 const size = RING_PX
863 const center = size / 2
864 const radius = (size - RING_STROKE) / 2 - 0.5
865 const circumference = 2 * Math.PI * radius
866 const arc = circumference * clamp(gauge.fraction, 0, 1)
867 const circle = (attrs: string) =>
868 `<circle cx="${center}" cy="${center}" r="${radius}" fill="none" stroke-width="${RING_STROKE}" ${attrs}/>`
869
870 return (
871 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">` +
872 circle('stroke="#8E8E93" stroke-opacity="0.22"') +
873 (arc > 0
874 ? circle(
875 `stroke="${gauge.color}" stroke-linecap="round" stroke-dasharray="${arc.toFixed(2)} ${circumference.toFixed(2)}" transform="rotate(-90 ${center} ${center})"`,
876 )
877 : '') +
878 `</svg>`
879 )
880}
881
882// CodexBar's compact pace: + is quota spent ahead of an even pace, − is quota in reserve.
883function paceText(delta: number): string {
884 const size = Math.round(Math.abs(delta))
885
886 return size === 0 ? '0%' : `${delta > 0 ? '+' : '−'}${size}%`
887}
888
889// Empty and full only when exactly so; anything between shows at least a quarter, at most three.
890function glyphOf(fraction: number): string {
891 const index = fraction <= 0 ? 0 : fraction >= 1 ? 4 : clamp(Math.round(fraction * 4), 1, 3)
892
893 return GLYPHS[index] ?? GLYPHS[0]
894}
895
896function tokensText(tokens: number): string {
897 const thousands = Math.round(tokens / 100) / 10
898 if (thousands >= 1000) {
899 return `${(tokens / 1_000_000).toFixed(2)}M`
900 }
901
902 // Three digits are enough once past 100K: 239K, 42.5K.
903 return thousands >= 100 ? `${Math.round(thousands)}K` : `${thousands.toFixed(1)}K`
904}
905
906// UsageFormatter.resetCountdownDescription, without its "in ".
907function countdown(ms: number): string {
908 const totalMinutes = Math.max(1, Math.ceil(ms / 60_000))
909 const days = Math.floor(totalMinutes / (24 * 60))
910 const hours = Math.floor(totalMinutes / 60) % 24
911 const minutes = totalMinutes % 60
912 if (days > 0) {
913 return hours > 0 ? `${days}d ${hours}h` : minutes > 0 ? `${days}d ${minutes}m` : `${days}d`
914 }
915 if (hours > 0) {
916 return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`
917 }
918
919 return `${totalMinutes}m`
920}
921
922// A kitchen timer in minutes and seconds (the longest lifetime is an hour, 60:00), rounded up
923// so it reads 0:01 until the last second goes.
924function timerText(ms: number): string {
925 const seconds = Math.max(0, Math.ceil(ms / 1000))
926
927 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
928}
929
930// Active time as the desktop's usage panel prints it: 42s, 17m 6s, 1h 4m.
931function durationText(ms: number): string {
932 const seconds = Math.floor(ms / 1000)
933 const hours = Math.floor(seconds / 3600)
934 const minutes = Math.floor(seconds / 60) % 60
935 if (hours > 0) {
936 return `${hours}h ${minutes}m`
937 }
938
939 return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`
940}
941
942// The engine's formatCost, as /usage prints the session total (cache reads and writes priced in):
943// cents above half a dollar, four places below it.
944function usdText(usd: number): string {
945 return usd > 0.5 ? `$${(Math.round(usd * 100) / 100).toFixed(2)}` : `$${usd.toFixed(4)}`
946}
947
948function clamp(value: number, low: number, high: number): number {
949 return Math.min(high, Math.max(low, value))
950}
951types/index.d.ts 72 lines1/**
2 * One rate-limit window. `kind` is the engine's (`five_hour`, `seven_day`, `seven_day_sonnet`),
3 * or `weekly:<model>` for a week scoped to one model, as /usage lists it.
4 */
5export type Limit = { kind: string; percentUsed: number; resetsAt: string | null }
6
7/**
8 * One window as last read, from either source: the account usage endpoint /usage reads, or the
9 * rate-limit headers of a response. `at` is when it was read, in milliseconds since the epoch.
10 * The endpoint's figure is /usage's own, so a header reading wins only once it is minutes newer.
11 */
12export type Reading = Limit & { at: number; /** Absent means the endpoint. */ source?: 'endpoint' | 'headers' }
13
14/**
15 * The usage figures every session of this plugin shares through the plugin's store: the newest
16 * reading of each window, and when the endpoint may next be asked.
17 */
18export type Shared = {
19 readings: Reading[]
20 /** When a session last asked the endpoint, so the others wait their turn. */
21 askedAt?: number
22 /** Not before this time: the endpoint answered 429. */
23 retryAt?: number
24 /** 429s in a row, which doubles the wait each time. */
25 strikes?: number
26}
27
28/** The session's figures, as `session.measure` pushes them. */
29export type Meter = {
30 /** Input tokens the last main-thread response was answered over; null before the first. */
31 tokens: number | null
32 /** The model's context window, in tokens. */
33 window: number
34 limits: Limit[]
35 /** What the session has cost so far in US dollars, as /cost totals it; null where the host keeps no ledger. */
36 usd: number | null
37}
38
39/** When the main thread's cached prefix was last read, and for how long the API keeps it. */
40export type CacheMark = {
41 /** When the last main-thread request went out, in milliseconds since the epoch. */
42 at: number
43 /** The lifetime the last response's cache write used; null until one is seen. */
44 ttl: '5m' | '1h' | null
45}
46
47/**
48 * Time the model spent working, as the desktop's usage panel counts it: the turns this Claude Code
49 * process has run (from a prompt to the last thing the turn wrote), the running one included.
50 * `doneMs` sums those the transcript holds; `closedMs` all but the last, which may still run;
51 * `from` is when the process started, or null where it could not be read (then every turn counts).
52 */
53export type Active = { doneMs: number; closedMs: number; lastPromptAt: number | null; since: number | null; from: number | null }
54
55declare module 'claude-code' {
56 interface PluginState {
57 'frontier-pacer': {
58 meter: Meter | null
59 cache: CacheMark | null
60 transcript: string | null
61 /** The newest reading of each window this session holds. */
62 readings: Reading[]
63 /** The main thread's model id, as the last request named it. */
64 model: string | null
65 active: Active | null
66 tick: number
67 /** Whether /pace has hidden the band in this session. */
68 hidden: boolean
69 }
70 }
71}
72