SLOPSHOPPER

pace

/pace: what this chat has used, and when your 5-hour and weekly limits run out at your current pace.

newpanespinnercommandtoasttimer
v0.1.0no licenseupdated 2026-10-25eshin087/claude-code-mods/pace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pace
│ ┃ Pace ✕ › fix the failing auth test and add an audit log call │ ┃ Pace [ Close ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ No reading yet: the limits mod shows one ⎿ Read 6 lines │ ┃ after the first reply. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ This chat ⏺ Bash(bun test) │ ┃ Tokens 6k fresh · 91k cached · 1k out ⎿ 3 pass, 1 fail │ ┃ Cost $0.42 at API prices · 1 turn │ ┃ Used 5-hour +0% · weekly +0% ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Pace: the 5-hour window's over the last ✻ Worked for 42s · done 4:20 PM │ ┃ hour, the weekly one's over the last day. │ ┃ "Used" counts any chat that ran at the same › /pace │ ┃ time. ⎿ pace: Pace opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts 🔹P ⟨Claude Code's own drawing⟩

Draws

Pane · Pace
Pace [ Close ] No reading yet: the limits mod shows one after the first reply. This chat Tokens 6k fresh · 91k cached · 1k out Cost $0.42 at API prices · 1 turn Used 5-hour +0% · weekly +0% Pace: the 5-hour window's over the last hour, the weekly one's over the last day. "Used" counts any chat that ran at the same time.
README

Pace

What it does: shows what this chat has used, and when your 5-hour and weekly limits run out if you keep going at your current pace.

Where you see it:

  • Open it with /pace on the desktop: its footer slot is narrow and the limits and the timings button use it.
  • In the terminal, where there is room, a 🔹P button follows the limits line: a click opens the panel, another closes it.
  • In the terminal, where there's room, a 🔹P button follows the limits line: a click opens the panel, another closes it.
  • 🔹P! in the terminal instead of P while the pace would run a limit out before it resets. On the desktop, the toast warns.
  • No hover summary: the desktop app can't show something above the prompt only while the pointer is on P (it drew it all the time), so the details are in the panel.

The panel:

Pace  updated 7:43p                                              Close

Window   Used              Resets in  Pace      At this pace
5-hour   ██████░░░░  64%   2h13m      +24%/h    runs out ~9:13p, before the reset
Weekly   ███████░░░  76%   1d 4h      +6%/day   lasts until the reset

This chat
Tokens   10k fresh · 1.2M cached · 30k out
Cost     $3.40 at API prices · 14 turns
Worked   1h42m over 12 prompts · each one in /timings
Used     5-hour +9% · weekly +2%
  • The table: one row per window. The bar and % turn amber from 60% and red from 85%. Resets in is the time left. At this pace is amber when the window would run out before it resets, green when it lasts.
  • This chat: labels on the left, values beside them (Worked comes from the timings mod's file for this chat, and is left out without it): tokens over every reply, subagents' included (fresh input, cached input, output), the cost as /cost counts it, and your turns. The rise in each window is counted between this chat's replies, so another chat working at the same time adds to it.
  • At this pace: the 5-hour pace is the last hour's and the weekly pace the last day's, so a burst or a pause shows quickly. It says not enough history yet until the window has 20 minutes (5-hour) or 6 hours (weekly) of readings, so a point or two right after a reset isn't taken for a pace, and not rising when the window hasn't moved.
  • Warning: when the pace would run a limit out at least 5 minutes before it resets, a toast says when, once per window across all your chats.

Where the numbers come from: the readings the limits mod keeps in ~/.claude/mods-data/limits/readings.json, plus this chat's own replies. Without the limits mod the panel has no history to work from. The windows already warned about are in ~/.claude/mods-data/pace/warned.json.

Cost: no model calls.

Source 2 files
hooks/register.tsx 382 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, SessionRateLimit } from 'claude-code'
3
4import type { PaceSession } from '../types'
5
6// What this chat has used, and when your 5-hour and weekly limits run out at
7// your current pace, in a panel: /pace, or the P button in the footer (P! while
8// the pace would run a limit out before it resets). The pace is recent: the last
9// hour for the 5-hour window, the last day for the weekly one, worked out from the readings
10// the limits mod keeps in ~/.claude/mods-data/limits/readings.json. A toast
11// warns once per window when the pace would run a limit out before it resets.
12
13const PANE = 'pace'
14// A pace needs this much history in the window: a point or two of rise over a
15// short span (right after a reset, say) is no pace to project from.
16const FIVE = { lookbackMs: 3_600_000, minSpanMs: 20 * 60_000 } as const
17const WEEK = { lookbackMs: 24 * 3_600_000, minSpanMs: 6 * 3_600_000 } as const
18// Only a run-out at least this long before the reset is worth a warning.
19const WARN_MARGIN_MS = 5 * 60_000
20// Readings of one window name the same reset, give or take how the endpoint and the headers round it.
21const SAME_RESET_MS = 5 * 60_000
22const REFRESH_MS = 30_000
23
24const GREEN = '#4ade80'
25const AMBER = '#facc15'
26const RED = '#f87171'
27const LABEL = '#9ca3af'
28const DIM = '#71717a'
29const ACCENT = '#22d3ee'
30
31type Win = { pct: number; resetsAt: string | null }
32type Reading = { at: number; five: Win | null; week: Win | null }
33type Readings = { latest: Reading | null; history: Reading[] }
34type Kind = 'five' | 'week'
35
36/** Where a window stands and where it's heading. `perHour` null: too little history yet. */
37type Projection = { pct: number; resetsAt: number | null; perHour: number | null; runsOutAt: number | null; isBeforeReset: boolean }
38
39const EMPTY_SESSION: PaceSession = { tokensIn: 0, tokensCached: 0, tokensOut: 0, turns: 0, usd: null, fiveUp: 0, weekUp: 0 }
40
41const isOpen = atom({ plugin: 'pace', key: 'isOpen' } as const, false)
42const session = atom({ plugin: 'pace', key: 'session' } as const, EMPTY_SESSION)
43
44// The limits mod's readings, re-read every REFRESH_MS and after each reply.
45let readings: Readings = { latest: null, history: [] }
46// This chat's last reading of each window, to count how far it rose.
47let seen: Partial<Record<Kind, Win>> = {}
48
49// Claude's folder: CLAUDE_CONFIG_DIR when set (it may live on another drive), else ~/.claude
50const home = async ($: EngineInterface) => ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'}/.claude`).replace(/\\/g, '/')
51
52// This chat's working time and prompts, from the timings mod's file (none without it).
53const timingsOf = async ($: EngineInterface): Promise<{ workedMs: number; prompts: number } | null> => {
54  try {
55    const cwd = await $.session.cwd()
56    const main = cwd.replace(/[\/]\.claude[\/]worktrees[\/].*$/i, '')
57    const project = main.split(/[\/]/).filter(Boolean).pop() ?? main
58    const file = JSON.parse(await $.fs.read(`${await home($)}/mods-data/timings/${project}/${await $.session.id()}.json`)) as { prompts?: { durationMs: number }[] }
59    const prompts = file.prompts ?? []
60    return { workedMs: prompts.reduce((n, p) => n + p.durationMs, 0), prompts: prompts.length }
61  } catch {
62    return null
63  }
64}
65
66const loadReadings = async ($: EngineInterface) => {
67  try {
68    const raw = JSON.parse(await $.fs.read(`${await home($)}/mods-data/limits/readings.json`)) as Partial<Readings>
69    readings = { latest: raw.latest ?? null, history: Array.isArray(raw.history) ? raw.history : [] }
70  } catch {
71    readings = { latest: null, history: [] }
72  }
73}
74
75const timeOf = (iso: string | null) => (iso === null ? NaN : Date.parse(iso))
76const isSameWindow = (a: Win, b: Win) => Math.abs(timeOf(a.resetsAt) - timeOf(b.resetsAt)) < SAME_RESET_MS
77
78// 4:10p, 11p: local time, minutes left out on the hour; the weekday when it isn't today.
79const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
80const when = (t: number, now: number) => {
81  const d = new Date(Math.round(t / 60_000) * 60_000)
82  const h = d.getHours()
83  const m = d.getMinutes()
84  const clock = `${h % 12 || 12}${m === 0 ? '' : `:${String(m).padStart(2, '0')}`}${h < 12 ? 'a' : 'p'}`
85  return d.toDateString() === new Date(now).toDateString() ? clock : `${DAYS[d.getDay()]} ${clock}`
86}
87
88const project = (r: Readings, kind: Kind, now: number, span: { lookbackMs: number; minSpanMs: number }): Projection | null => {
89  const latest = r.latest?.[kind]
90  if (!r.latest || !latest) return null
91  const reset = timeOf(latest.resetsAt)
92  const resetsAt = Number.isFinite(reset) ? reset : null
93  // A window whose reset has passed has started over.
94  if (resetsAt !== null && resetsAt <= now) return { pct: 0, resetsAt: null, perHour: null, runsOutAt: null, isBeforeReset: false }
95  const first = r.history.find(h => h.at >= now - span.lookbackMs && h[kind] && isSameWindow(h[kind]!, latest))
96  const base = { pct: latest.pct, resetsAt, perHour: null, runsOutAt: null, isBeforeReset: false }
97  if (!first || r.latest.at - first.at < span.minSpanMs) return base
98  const perMs = (latest.pct - first[kind]!.pct) / (r.latest.at - first.at)
99  if (perMs <= 0) return { ...base, perHour: 0 }
100  const runsOutAt = r.latest.at + (100 - latest.pct) / perMs
101  return { ...base, perHour: perMs * 3_600_000, runsOutAt, isBeforeReset: resetsAt !== null && runsOutAt < resetsAt - WARN_MARGIN_MS }
102}
103
104const NAME: Record<Kind, string> = { five: '5-hour', week: 'weekly' }
105
106// Once per window, for every chat: the windows already warned about live beside the readings.
107const warnIfRunningOut = async ($: EngineInterface) => {
108  const now = await $.clock.now()
109  const file = `${await home($)}/mods-data/pace/warned.json`
110  let warned: string[] = []
111  try {
112    warned = (JSON.parse(await $.fs.read(file)) as { keys?: string[] }).keys ?? []
113  } catch {
114    warned = []
115  }
116  for (const [kind, span] of [['five', FIVE], ['week', WEEK]] as const) {
117    const p = project(readings, kind, now, span)
118    if (!p?.isBeforeReset || p.runsOutAt === null || p.resetsAt === null) continue
119    const key = `${kind}@${Math.round(p.resetsAt / 60_000)}`
120    if (warned.includes(key)) continue
121    warned = [...warned, key].slice(-20)
122    await $.fs.write(file, JSON.stringify({ keys: warned }))
123    $.ui.toast(`At this pace your ${NAME[kind]} limit runs out around ${when(p.runsOutAt, now)}, before it resets at ${when(p.resetsAt, now)}. /pace for details.`, { timeoutMs: 12_000 })
124  }
125}
126
127const togglePane = async ($: EngineInterface) => {
128  if ((await $.ui.panes()).some(p => p.id === PANE)) {
129    await $.ui.close({ id: PANE })
130    // A plugin's own close does not reach its own ui.close hook: reset the flag here.
131    await update($, isOpen, () => false)
132    return 'Pace closed.'
133  }
134  await loadReadings($)
135  const opened = await $.ui.open({ id: PANE, title: 'Pace' })
136  if (opened.isPlaced) await update($, isOpen, () => true)
137  else $.ui.toast(`The pace panel could not open (${opened.reason}).`)
138  return opened.isPlaced ? 'Pace opened.' : `Could not open the pane (${opened.reason}).`
139}
140
141// The rise of each window between this chat's readings, counted while the window stays the same.
142const tally = async ($: EngineInterface, limits: readonly SessionRateLimit[]) => {
143  const rise: Record<Kind, number> = { five: 0, week: 0 }
144  for (const [kind, name] of [['five', 'five_hour'], ['week', 'seven_day']] as const) {
145    const l = limits.find(x => x.kind === name)
146    if (!l) continue
147    const now: Win = { pct: l.percentUsed, resetsAt: l.resetsAt ?? null }
148    const before = seen[kind]
149    if (before && isSameWindow(before, now) && now.pct > before.pct) rise[kind] = now.pct - before.pct
150    seen = { ...seen, [kind]: now }
151  }
152  if (rise.five > 0 || rise.week > 0) {
153    await update($, session, s => ({ ...s, fiveUp: s.fiveUp + rise.five, weekUp: s.weekUp + rise.week }))
154  }
155}
156
157const tokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
158const pct = (n: number) => `${Math.round(n * 10) / 10}%`
159// How long until a time: 45m, 3h27m, 4d 3h.
160const left = (t: number, now: number) => {
161  const m = Math.max(0, Math.round((t - now) / 60_000))
162  if (m < 60) return `${m}m`
163  if (m < 24 * 60) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
164  return `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
165}
166// A 10-cell bar of how much of a window is used.
167const bar = (n: number) => {
168  const full = Math.max(0, Math.min(10, Math.round(n / 10)))
169  return { used: '█'.repeat(full), rest: '░'.repeat(10 - full) }
170}
171
172export const register: Register = on => {
173  on('session.start', async ($, e, next) => {
174    await $.command.register({ name: 'pace', description: 'What this chat has used, and when your 5-hour and weekly limits run out at this pace' })
175    await loadReadings($)
176    $.clock.every(REFRESH_MS, () => {
177      void (async () => {
178        await loadReadings($)
179        // The panel and P (or P!) both draw from the readings.
180        $.ui.invalidate('ui.render')
181      })().catch(() => undefined)
182    })
183    return next(e)
184  })
185
186  on('command.run', { command: 'pace' }, async $ => ({ text: await togglePane($) }))
187
188  on('ui.close', { id: PANE }, async ($, e, next) => {
189    const done = await next(e)
190    await update($, isOpen, () => false)
191    return done
192  })
193
194  // Every reply, subagents' too: its tokens. Your turns: one each.
195  on('turn.complete', async ($, e, next) => {
196    const done = await next(e)
197    const u = done.usage ?? e.usage
198    if (u || e.agentId === undefined) {
199      await update($, session, s => ({
200        ...s,
201        tokensIn: s.tokensIn + (u ? u.input_tokens + u.cache_creation_input_tokens : 0),
202        tokensCached: s.tokensCached + (u?.cache_read_input_tokens ?? 0),
203        tokensOut: s.tokensOut + (u?.output_tokens ?? 0),
204        turns: s.turns + (e.agentId === undefined ? 1 : 0),
205      }))
206    }
207    return done
208  })
209
210  // The cost so far, how far the windows rose, and whether this pace runs one out.
211  on('session.measure', async ($, e, next) => {
212    const done = await next(e)
213    if (e.changed.includes('cost') && e.cost) {
214      const usd = e.cost.usd
215      await update($, session, s => ({ ...s, usd }))
216    }
217    if (e.changed.includes('rateLimits')) {
218      await tally($, e.rateLimits)
219      await loadReadings($)
220      await warnIfRunningOut($)
221    }
222    return done
223  })
224
225  // P in the footer, after the limits and before what the mods beneath add (the
226  // timings mod's T): a press opens or closes the panel; P! while the pace would
227  // run a limit out before it resets.
228  // The terminal's P after the limits line. The desktop's footer slot is narrow
229  // and the limits and the timings button use it: /pace opens the panel there.
230  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
231    if (e.surface === 'desktop') return next(e)
232    const below = await next(e)
233    const { Box, Button } = $.ui.resolve(e)
234    const now = await $.clock.now()
235    const isAtRisk = [project(readings, 'five', now, FIVE), project(readings, 'week', now, WEEK)].some(p => p?.isBeforeReset === true)
236    return (
237      <Box flexDirection="row" gap={1}>
238        <Button key="pace:p" label={isAtRisk ? '🔹P!' : '🔹P'} plain dimColor={!isAtRisk} onPress={() => togglePane($)} />
239        {below}
240      </Box>
241    )
242  })
243
244  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
245    const { Box, Text, Button } = $.ui.resolve(e)
246    const s = await read($, session)
247    const now = await $.clock.now()
248    const timed = await timingsOf($)
249    const latest = readings.latest
250    const five = project(readings, 'five', now, FIVE)
251    const week = project(readings, 'week', now, WEEK)
252    const colorOf = (n: number) => (n >= 85 ? RED : n >= 60 ? AMBER : GREEN)
253
254    // The limits table: one row per window, fixed columns, the outlook takes the rest.
255    //   Window   Used              Resets in  Pace      At this pace
256    //   5-hour   ██████░░░░  64%   2h13m      +24%/h    runs out ~9:13p
257    const cell = (key: string, width: number, child: RenderChildren) => (
258      <Box key={key} width={width} flexShrink={0}>
259        {child}
260      </Box>
261    )
262    const head = (
263      <Box key="head" flexDirection="row" gap={1}>
264        {cell('window', 8, <Text color={LABEL}>Window</Text>)}
265        {cell('used', 16, <Text color={LABEL}>Used</Text>)}
266        {cell('resets', 10, <Text color={LABEL}>Resets in</Text>)}
267        {cell('pace', 9, <Text color={LABEL}>Pace</Text>)}
268        <Text color={LABEL} wrap="truncate-end">
269          At this pace
270        </Text>
271      </Box>
272    )
273    const limitRow = (key: string, label: string, p: Projection | null, unit: 'h' | 'day') => {
274      if (!p) return null
275      const b = bar(p.pct)
276      const rate = p.perHour === null ? null : unit === 'h' ? p.perHour : p.perHour * 24
277      const outlook =
278        rate === null
279          ? { text: 'not enough history yet', color: DIM }
280          : rate <= 0 || p.runsOutAt === null
281            ? { text: 'not rising', color: DIM }
282            : p.isBeforeReset
283              ? { text: `runs out ~${when(p.runsOutAt, now)}, before the reset`, color: AMBER }
284              : { text: 'lasts until the reset', color: GREEN }
285      return (
286        <Box key={key} flexDirection="row" gap={1}>
287          {cell('window', 8, <Text bold>{label}</Text>)}
288          {cell(
289            'used',
290            16,
291            <Text>
292              <Text color={colorOf(p.pct)}>{b.used}</Text>
293              <Text color={DIM}>{b.rest}</Text>
294              <Text bold color={colorOf(p.pct)}>{` ${pct(p.pct)}`}</Text>
295            </Text>,
296          )}
297          {cell('resets', 10, <Text>{p.resetsAt === null ? '—' : left(p.resetsAt, now)}</Text>)}
298          {cell('pace', 9, <Text color={rate !== null && rate > 0 ? undefined : DIM}>{rate === null ? '—' : rate <= 0 ? '0' : `+${pct(rate)}/${unit}`}</Text>)}
299          <Text color={outlook.color} wrap="truncate-end">
300            {outlook.text}
301          </Text>
302        </Box>
303      )
304    }
305    // This chat's figures: a grey label column, the value beside it.
306    const detail = (key: string, label: string, value: RenderChildren) => (
307      <Box key={key} flexDirection="row" gap={1}>
308        {cell('label', 8, <Text color={LABEL}>{label}</Text>)}
309        <Box flexGrow={1} flexShrink={1} minWidth={0}>
310          {value}
311        </Box>
312      </Box>
313    )
314
315    return (
316      <Box flexDirection="column" gap={1}>
317        <Box flexDirection="row" gap={1}>
318          <Text bold color={ACCENT}>
319            Pace
320          </Text>
321          {latest && <Text color={DIM}>{`updated ${when(latest.at, now)}`}</Text>}
322          <Box flexGrow={1} />
323          <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
324        </Box>
325        {latest ? (
326          <Box flexDirection="column">
327            {head}
328            {limitRow('limit:five', '5-hour', five, 'h')}
329            {limitRow('limit:week', 'Weekly', week, 'day')}
330          </Box>
331        ) : (
332          <Text color={DIM}>No reading yet: the limits mod shows one after the first reply.</Text>
333        )}
334        <Box flexDirection="column">
335          <Text bold>This chat</Text>
336          {detail(
337            'tokens',
338            'Tokens',
339            <Text wrap="truncate-end">
340              {tokens(s.tokensIn)}
341              <Text color={LABEL}> fresh · </Text>
342              {tokens(s.tokensCached)}
343              <Text color={LABEL}> cached · </Text>
344              {tokens(s.tokensOut)}
345              <Text color={LABEL}> out</Text>
346            </Text>,
347          )}
348          {detail(
349            'cost',
350            'Cost',
351            <Text wrap="truncate-end">
352              {s.usd === null ? '$0.00' : `$${s.usd.toFixed(2)}`}
353              <Text color={LABEL}>{` at API prices · ${s.turns} ${s.turns === 1 ? 'turn' : 'turns'}`}</Text>
354            </Text>,
355          )}
356          {timed !== null && timed.prompts > 0 &&
357            detail(
358              'worked',
359              'Worked',
360              <Text wrap="truncate-end">
361                {left(timed.workedMs, 0)}
362                <Text color={LABEL}>{` over ${timed.prompts} ${timed.prompts === 1 ? 'prompt' : 'prompts'} · each one in /timings`}</Text>
363              </Text>,
364            )}
365          {detail(
366            'used',
367            'Used',
368            <Text wrap="truncate-end">
369              {`5-hour +${pct(s.fiveUp)}`}
370              <Text color={LABEL}> · </Text>
371              {`weekly +${pct(s.weekUp)}`}
372            </Text>,
373          )}
374        </Box>
375        <Text color={DIM}>
376          Pace: the 5-hour window's over the last hour, the weekly one's over the last day. "Used" counts any chat that ran at the same time.
377        </Text>
378      </Box>
379    )
380  })
381}
382
types/index.d.ts 24 lines
1/** What this chat has used so far. */
2export type PaceSession = {
3  /** Input tokens read fresh (cache writes included), read from the cache, and written out: every reply, subagents' too. */
4  tokensIn: number
5  tokensCached: number
6  tokensOut: number
7  /** Your turns in this chat. */
8  turns: number
9  /** Dollars, as /cost totals it; null before the first priced reply. */
10  usd: number | null
11  /** How far the 5-hour and weekly % rose between this chat's replies (other chats running meanwhile count too). */
12  fiveUp: number
13  weekUp: number
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    pace: {
19      isOpen: boolean
20      session: PaceSession
21    }
22  }
23}
24