SLOPSHOPPER

frontier-pacer

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

newbandcommandprocessnetworktimer
v0.4.0MITupdated 2026-10-09n0ah37/frontier-pacer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · frontier-pacer
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 ctx 97.4K $0.4200 active 42s 5h ◔ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 97.4K $0.4200 active 42s 5h ◔ 31%
README

frontier-pacer

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%

What it shows

  • ctx: the number of tokens in the context window, such as 143K (one decimal below 100K).
  • $: the session's API-equivalent cost, cache reads and writes included, formatted as /usage formats it.
  • active: how long the model has worked in this session, the sum of each turn's duration, the running turn included.
  • cache: a countdown to when the prompt cache expires (5 minutes or 1 hour, read from the last response). It holds at full while Claude is responding, turns red in its last tenth (the last 6 minutes of an hour, the last 30 seconds of 5 minutes) and reads expired after that.
  • 5h, 7d and per-model weekly limits: a ring per plan window with the percentage used as /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.

Requirements

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.

Install

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.

What it runs, reads and sends

Everything the mod touches, in full:

  • Network: it calls 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.
  • Files: it reads the current session's transcript under ~/.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.
  • Processes: it runs these programs, each with arguments written in the source, and nothing else:
  • /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.
  • Commands: it adds /pace, which only hides or shows the band.
  • Hooks: it observes 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.
  • Storage: the newest reading of each plan window (its percentage, reset time and when it was read), when a session last asked the endpoint, and any 429 wait are kept in the plugin's own store, a JSON file Claude Code keeps under its configuration directory, so every session shows the same, newest figures. Everything else lives in the session's state only.

Tests

claude plugin test .

License

MIT. The pace model is ported from CodexBar, also MIT; its notice is in LICENSE.

Source 2 files
hooks/register.tsx 951 lines
1import { 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}
951
types/index.d.ts 72 lines
1/**
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