SLOPSHOPPER

token-meter

Live pane with tokens read, written and the context window fill

newpanecommandprocesstimer
v0.1.0no licenseupdated 2026-10-07Dumorro/token-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-meter
│ ┃ Tokens ✕ › fix the failing auth test and add an audit log call │ ┃ Current session │ ┃ Read 97.4k ⏺ Read(src/auth.ts) │ ┃ uncached 2.1k ⎿ Read 6 lines │ ┃ cache read 91.0k ⏺ Update(src/auth.ts) │ ┃ cache write 4.3k ⎿ Added 2 lines, removed 1 line │ ┃ Written 1.5k ⏺ Bash(bun test) │ ┃ Total 98.9k ⎿ 3 pass, 1 fail │ ┃ Cost $0.08 (estimated, API pricing) │ ┃ 1 API responses · includes subagents · ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ since the mod loaded │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Weekly │ ┃ calculating… › /tokens │ ┃ ⎿ token-meter: Token meter pane opened. │ ┃ Context window │ ┃ 97.4k / 200.0k (49%) │ ┃ /cost for this process $0.42 │ ┃ │ ┃ Limits │ ┃ 5h window: 69% free (31% used) │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tokens
Current session Read 97.4k uncached 2.1k cache read 91.0k cache write 4.3k Written 1.5k Total 98.9k Cost $0.08 (estimated, API pricing) 1 API responses · includes subagents · since the mod loaded Weekly calculating… Context window 97.4k / 200.0k (49%) /cost for this process $0.42 Limits 5h window: 69% free (31% used)
README

token-meter

A Claude Code mod that opens a pane with the tokens read and written in the current session and over the week (across all projects), the estimated cost at API prices, the context window fill, and the usage limits (5h, weekly) with the time left until each reset.

Install

At the prompt of a Claude Code terminal session:

/plugin install token-meter --marketplace Dumorro/token-meter

Answer y to add the marketplace and pick the user scope. The pane opens when a session starts; reopen it with /tokens.

Requirements

  • jq on the PATH. It reads the transcripts under ~/.claude/projects to total the session (subagents included) and the week. Without jq, the pane shows only what it counted since the mod loaded and the totals saved from earlier sessions.

Notes

  • The cost is an estimate from the API price table in hooks/pricing.ts, not what a plan bills. Models missing from the table are listed as "unpriced" instead of being counted as free.

Development

claude plugin validate .
claude plugin test .
Source 4 files
hooks/register.tsx 335 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, SessionContextUsage } from 'claude-code'
3
4import type { Limit, Source, Totals, Week, Window } from '../types'
5
6import { costOf } from './pricing'
7import type { ModelUsage } from './pricing'
8import { AGGREGATE, EMPTY, summarize } from './usage'
9
10const PANE = 'token-meter'
11const WEEK_MS = 7 * 86_400_000
12const WEEK_EVERY_MS = 10 * 60_000
13
14const totals = atom({ plugin: 'token-meter', key: 'totals' } as const, EMPTY)
15const week = atom({ plugin: 'token-meter', key: 'week' } as const, null as Week | null)
16const source = atom({ plugin: 'token-meter', key: 'source' } as const, 'live' as Source)
17const window = atom({ plugin: 'token-meter', key: 'window' } as const, null)
18const usd = atom({ plugin: 'token-meter', key: 'usd' } as const, null)
19const limits = atom({ plugin: 'token-meter', key: 'limits' } as const, [] as Limit[])
20
21const STORE_PREFIX = 'session:'
22const STORE_WEEK = 'week'
23const STORE_KEEP = 200
24
25const projectKey = (dir: string) => dir.replace(/[^A-Za-z0-9]/g, '-')
26const projectsDir = async ($: EngineInterface) => `${(await $.env.get('HOME')) ?? ''}/.claude/projects`
27
28// Saved totals from before usd existed lack it; treat them as tokens without a price.
29const withDefaults = (t: Partial<Totals>): Totals => ({ ...EMPTY, ...t, unpricedModels: t.unpricedModels ?? [] })
30
31async function transcriptFiles($: EngineInterface, sessionId: string): Promise<string[]> {
32  const projects = await projectsDir($)
33  const hasTranscript = (name: string) => $.fs.exists(`${projects}/${name}/${sessionId}.jsonl`)
34  // The transcript lives under the launch directory's key; search every project only when it moved.
35  let name: string | undefined
36  for (const guess of [projectKey(await $.session.root()), projectKey(await $.session.cwd())]) {
37    if (await hasTranscript(guess)) {
38      name = guess
39      break
40    }
41  }
42  if (name === undefined) {
43    for (const entry of await $.fs.list(projects)) {
44      if (entry.kind === 'dir' && (await hasTranscript(entry.name))) {
45        name = entry.name
46        break
47      }
48    }
49  }
50  if (name === undefined) return []
51  const dir = `${projects}/${name}`
52  const files = [`${dir}/${sessionId}.jsonl`]
53  const subagents = `${dir}/${sessionId}/subagents`
54  if (await $.fs.exists(subagents)) {
55    for (const f of await $.fs.list(subagents)) {
56      if (f.kind === 'file' && f.name.endsWith('.jsonl')) files.push(`${subagents}/${f.name}`)
57    }
58  }
59
60  return files
61}
62
63async function scanSession($: EngineInterface): Promise<Totals | null> {
64  const files = await transcriptFiles($, await $.session.id())
65  if (files.length === 0) return null
66  const r = await $.process.run(['jq', '-ncR', '--arg', 'since', '', AGGREGATE, ...files], { timeoutMs: 60_000 })
67  if (r.exitCode !== 0) return null
68
69  return summarize(JSON.parse(r.stdout) as ModelUsage[])
70}
71
72// Every session of every project since the start of the weekly limit window: files touched in the
73// window, assistant lines only (grep), then the same aggregate filtered by timestamp.
74async function scanWeek($: EngineInterface, since: number, now: number): Promise<Totals | null> {
75  const minutes = String(Math.ceil((now - since) / 60_000) + 60)
76  const script =
77    'find "$1" -name "*.jsonl" -mmin "-$2" -print0 | xargs -0 grep -h -F \'"type":"assistant"\' | jq -ncR --arg since "$3" "$4"'
78  const r = await $.process.run(
79    ['sh', '-c', script, 'sh', await projectsDir($), minutes, new Date(since).toISOString(), AGGREGATE],
80    { timeoutMs: 120_000 },
81  )
82  if (r.exitCode !== 0) return null
83
84  return summarize(JSON.parse(r.stdout) as ModelUsage[])
85}
86
87async function saveSession($: EngineInterface, t: Totals) {
88  await $.store.set(`${STORE_PREFIX}${await $.session.id()}`, t)
89  const keys = (await $.store.keys()).filter(k => k.startsWith(STORE_PREFIX))
90  for (const old of keys.slice(0, Math.max(0, keys.length - STORE_KEEP))) await $.store.delete(old)
91}
92
93let isScanning = false
94let isScanningWeek = false
95
96async function refreshSession($: EngineInterface) {
97  if (isScanning) return
98  isScanning = true
99  try {
100    const t = await scanSession($)
101    if (t) {
102      await update($, totals, () => t)
103      await update($, source, () => 'transcript' as Source)
104      await saveSession($, t)
105    }
106  } catch {
107    // keep what is shown; the next turn retries
108  } finally {
109    isScanning = false
110  }
111}
112
113// The weekly limit's own window when the API reported one, else the last seven days.
114async function weekStart($: EngineInterface, now: number) {
115  const resetsAt = (await read($, limits)).find(l => l.kind === 'seven_day')?.resetsAt
116  const reset = resetsAt === undefined ? NaN : Date.parse(resetsAt)
117
118  return Number.isNaN(reset) ? now - WEEK_MS : reset - WEEK_MS
119}
120
121async function refreshWeek($: EngineInterface) {
122  if (isScanningWeek) return
123  isScanningWeek = true
124  try {
125    const now = await $.clock.now()
126    const since = await weekStart($, now)
127    const t = await scanWeek($, since, now)
128    if (t) {
129      const w: Week = { since, at: now, totals: t }
130      await update($, week, () => w)
131      await $.store.set(STORE_WEEK, w)
132    }
133  } catch {
134    // keep what is shown; the next period retries
135  } finally {
136    isScanningWeek = false
137  }
138}
139
140const fmt = (n: number) =>
141  n >= 1_000_000_000
142    ? `${(n / 1_000_000_000).toFixed(2)}B`
143    : n >= 1_000_000
144      ? `${(n / 1_000_000).toFixed(2)}M`
145      : n >= 1_000
146        ? `${(n / 1_000).toFixed(1)}k`
147        : `${n}`
148
149const money = (n: number) => `$${n.toFixed(2)}`
150
151const ddmm = (ms: number) => {
152  const d = new Date(ms)
153  const two = (n: number) => String(n).padStart(2, '0')
154
155  return `${two(d.getDate())}/${two(d.getMonth() + 1)} ${two(d.getHours())}:${two(d.getMinutes())}`
156}
157
158const LIMIT_LABEL: Record<string, string> = {
159  five_hour: '5h window',
160  seven_day: 'Weekly',
161  spend_limit: 'Spend',
162}
163
164const until = (resetsAt: string | undefined, now: number) => {
165  const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
166  if (Number.isNaN(at)) return ''
167  const min = Math.max(0, Math.round((at - now) / 60_000))
168  const d = Math.floor(min / 1440)
169  const h = Math.floor((min % 1440) / 60)
170  const m = min % 60
171
172  return ` · resets in ${d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`}`
173}
174
175const limitColor = (used: number) => (used >= 85 ? 'red' : used >= 60 ? 'yellow' : 'green')
176
177const toWindow = (c: SessionContextUsage): Window => ({
178  tokens: c.tokens ?? null,
179  size: c.window,
180  percent: c.percent ?? null,
181})
182
183export const register: Register = on => {
184  on('session.start', async ($, e, next) => {
185    await $.command.register({
186      name: 'tokens',
187      description: 'Open the token meter pane',
188    })
189    const saved = (await $.store.get(`${STORE_PREFIX}${await $.session.id()}`)) as Partial<Totals> | undefined
190    if (saved) {
191      await update($, totals, () => withDefaults(saved))
192      await update($, source, () => 'saved' as Source)
193    }
194    const savedWeek = (await $.store.get(STORE_WEEK)) as Week | undefined
195    if (savedWeek) await update($, week, () => ({ ...savedWeek, totals: withDefaults(savedWeek.totals) }))
196    const usage = await $.session.usage()
197    await update($, window, () => toWindow(usage.context))
198    await update($, usd, () => usage.cost?.usd ?? null)
199    await update($, limits, () => usage.rateLimits)
200    void refreshSession($)
201    void refreshWeek($)
202    $.clock.every(WEEK_EVERY_MS, () => void refreshWeek($))
203    void $.ui.open({ id: PANE, title: 'Tokens' })
204
205    return next(e)
206  })
207
208  on('command.run', { command: 'tokens' }, async $ => {
209    await $.ui.open({ id: PANE, title: 'Tokens' })
210
211    return { text: 'Token meter pane opened.' }
212  })
213
214  on('turn.complete', async ($, e, next) => {
215    const u = e.usage
216    if (u) {
217      const turnUsd =
218        costOf({
219          model: u.model,
220          fast: false,
221          input: u.input_tokens,
222          cacheRead: u.cache_read_input_tokens,
223          cacheWrite: u.cache_creation_input_tokens,
224          cacheWrite1h: 0,
225          output: u.output_tokens,
226          responses: 1,
227        }) ?? 0
228      const t = await update($, totals, t => ({
229        ...t,
230        input: t.input + u.input_tokens,
231        cacheRead: t.cacheRead + u.cache_read_input_tokens,
232        cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
233        output: t.output + u.output_tokens,
234        turns: t.turns + 1,
235        usd: t.usd + turnUsd,
236      }))
237      await saveSession($, t)
238    }
239    const ran = await next(e)
240    if (e.agentId === undefined) void refreshSession($)
241
242    return ran
243  })
244
245  on('session.measure', async ($, e, next) => {
246    await update($, window, () => toWindow(e.context))
247    await update($, usd, () => e.cost?.usd ?? null)
248    await update($, limits, () => e.rateLimits)
249
250    return next(e)
251  })
252
253  on('session.end', async ($, e, next) => {
254    if (e.reason === 'clear') {
255      await update($, totals, () => EMPTY)
256      await update($, source, () => 'live' as Source)
257      await update($, window, () => null)
258    }
259
260    return next(e)
261  })
262
263  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
264    const { Box, Text } = $.ui.resolve(e)
265    const t = await read($, totals)
266    const wk = await read($, week)
267    const from = await read($, source)
268    const w = await read($, window)
269    const cost = await read($, usd)
270    const rate = await read($, limits)
271    const now = await $.clock.now()
272
273    const block = (x: Totals, note: RenderChildren) => {
274      const readTotal = x.input + x.cacheRead + x.cacheWrite
275
276      return (
277        <Box flexDirection="column">
278          <Text>  Read     {fmt(readTotal)}</Text>
279          <Text dimColor>    uncached    {fmt(x.input)}</Text>
280          <Text dimColor>    cache read  {fmt(x.cacheRead)}</Text>
281          <Text dimColor>    cache write {fmt(x.cacheWrite)}</Text>
282          <Text>  Written  {fmt(x.output)}</Text>
283          <Text>  Total    {fmt(readTotal + x.output)}</Text>
284          <Text color="cyan">  Cost     {money(x.usd)} (estimated, API pricing)</Text>
285          {x.unpricedModels.length > 0 && (
286            <Text dimColor>    unpriced: {x.unpricedModels.join(', ')}</Text>
287          )}
288          <Text dimColor>
289            {'  '}
290            {x.turns} API responses · {note}
291          </Text>
292        </Box>
293      )
294    }
295
296    return (
297      <Box flexDirection="column">
298        <Text bold>Current session</Text>
299        {block(
300          t,
301          `includes subagents · ${from === 'transcript' ? 'transcript' : from === 'saved' ? 'saved' : 'since the mod loaded'}`,
302        )}
303        <Text> </Text>
304        <Text bold>Weekly{wk ? ` (since ${ddmm(wk.since)})` : ''}</Text>
305        {wk ? (
306          block(wk.totals, `all projects · updated ${ddmm(wk.at)}`)
307        ) : (
308          <Text dimColor>  calculating…</Text>
309        )}
310        <Text> </Text>
311        <Text bold>Context window</Text>
312        {w === null || w.tokens === null ? (
313          <Text dimColor>  waiting for first response</Text>
314        ) : (
315          <Text color={(w.percent ?? 0) >= 85 ? 'red' : (w.percent ?? 0) >= 60 ? 'yellow' : 'green'}>
316            {'  '}
317            {fmt(w.tokens)} / {fmt(w.size)} ({w.percent ?? 0}%)
318          </Text>
319        )}
320        {cost !== null && <Text dimColor>  /cost for this process {money(cost)}</Text>}
321        <Text> </Text>
322        <Text bold>Limits</Text>
323        {rate.length === 0 && <Text dimColor>  no reading yet</Text>}
324        {rate.map(l => (
325          <Text color={limitColor(l.percentUsed)}>
326            {'  '}
327            {LIMIT_LABEL[l.kind] ?? l.kind}: {Math.max(0, Math.round((100 - l.percentUsed) * 10) / 10)}% free (
328            {l.percentUsed}% used){until(l.resetsAt, now)}
329          </Text>
330        ))}
331      </Box>
332    )
333  })
334}
335
hooks/pricing.ts 54 lines
1// US$ per million tokens, Anthropic first-party list prices (claude-api skill, cached 2026-09-25).
2// Cache writes are 1.25x input (5 min) and 2x input (1 h); cache reads are 0.1x input except
3// where a model sets its own rate.
4type Rate = { input: number; output: number; cacheRead: number }
5
6const RATES: [prefix: string, rate: Rate][] = [
7  ['claude-fable-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
8  ['claude-mythos-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
9  ['claude-fable-5', { input: 10, output: 50, cacheRead: 1 }],
10  ['claude-mythos-5', { input: 10, output: 50, cacheRead: 1 }],
11  ['claude-opus-5-5', { input: 4, output: 20, cacheRead: 0.2 }],
12  ['claude-opus-5', { input: 5, output: 25, cacheRead: 0.5 }],
13  ['claude-opus-4-8', { input: 5, output: 25, cacheRead: 0.5 }],
14  ['claude-opus-4-7', { input: 5, output: 25, cacheRead: 0.5 }],
15  ['claude-opus-4-6', { input: 5, output: 25, cacheRead: 0.5 }],
16  ['claude-sonnet-5-5', { input: 2, output: 10, cacheRead: 0.2 }],
17  ['claude-sonnet-5', { input: 2, output: 10, cacheRead: 0.2 }],
18  ['claude-sonnet-4-6', { input: 3, output: 15, cacheRead: 0.3 }],
19  ['claude-haiku-4-5', { input: 1, output: 5, cacheRead: 0.1 }],
20]
21
22// Longest prefix first, so claude-opus-5-5 never matches claude-opus-5.
23const BY_LENGTH = [...RATES].sort((a, b) => b[0].length - a[0].length)
24
25export const rateOf = (model: string): Rate | undefined =>
26  BY_LENGTH.find(([prefix]) => model.startsWith(prefix))?.[1]
27
28export type ModelUsage = {
29  model: string
30  fast: boolean
31  input: number
32  cacheRead: number
33  cacheWrite: number
34  cacheWrite1h: number
35  output: number
36  responses: number
37}
38
39// Estimated cost of one model's usage; undefined when the model has no known price.
40export function costOf(u: ModelUsage): number | undefined {
41  const r = rateOf(u.model)
42  if (!r) return undefined
43  const write5m = Math.max(0, u.cacheWrite - u.cacheWrite1h)
44  const usd =
45    (u.input * r.input +
46      u.cacheRead * r.cacheRead +
47      write5m * r.input * 1.25 +
48      u.cacheWrite1h * r.input * 2 +
49      u.output * r.output) /
50    1_000_000
51
52  return u.fast ? usd * 2 : usd
53}
54
hooks/usage.ts 46 lines
1import type { Totals } from '../types'
2
3import { costOf } from './pricing'
4import type { ModelUsage } from './pricing'
5
6// Every assistant record carries its API response's usage, repeated once per content block:
7// dedupe by message id, keep records at or after $since, and sum per model and speed.
8// Lines a writer left half-written are skipped.
9export const AGGREGATE = [
10  '[inputs | fromjson? | select(.type == "assistant" and .message.usage != null and (.timestamp // "") >= $since)',
11  '| .message as $m | $m.usage as $u',
12  '| {k: ($m.id // .requestId // ""), m: ($m.model // "?"), f: (($u.speed // "standard") == "fast"),',
13  'in: ($u.input_tokens // 0), cr: ($u.cache_read_input_tokens // 0),',
14  'w1: ($u.cache_creation.ephemeral_1h_input_tokens // 0), w: ($u.cache_creation_input_tokens // 0),',
15  'out: ($u.output_tokens // 0)}]',
16  '| unique_by(.k) | group_by([.m, .f])',
17  '| map({model: .[0].m, fast: .[0].f, input: (map(.in) | add), cacheRead: (map(.cr) | add),',
18  'cacheWrite: (map(.w) | add), cacheWrite1h: (map(.w1) | add), output: (map(.out) | add), responses: length})',
19].join(' ')
20
21export const EMPTY: Totals = {
22  input: 0,
23  cacheRead: 0,
24  cacheWrite: 0,
25  output: 0,
26  turns: 0,
27  usd: 0,
28  unpricedModels: [],
29}
30
31export function summarize(rows: readonly ModelUsage[]): Totals {
32  const t: Totals = { ...EMPTY, unpricedModels: [] }
33  for (const r of rows) {
34    t.input += r.input
35    t.cacheRead += r.cacheRead
36    t.cacheWrite += r.cacheWrite
37    t.output += r.output
38    t.turns += r.responses
39    const usd = costOf(r)
40    if (usd !== undefined) t.usd += usd
41    else if (r.input + r.cacheRead + r.cacheWrite + r.output > 0) t.unpricedModels.push(r.model)
42  }
43
44  return t
45}
46
types/index.d.ts 31 lines
1export type Totals = {
2  input: number
3  cacheRead: number
4  cacheWrite: number
5  output: number
6  turns: number
7  usd: number
8  unpricedModels: string[]
9}
10
11export type Week = { since: number; at: number; totals: Totals }
12
13export type Source = 'transcript' | 'saved' | 'live'
14
15export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
16
17export type Window = { tokens: number | null; size: number; percent: number | null }
18
19declare module 'claude-code' {
20  interface PluginState {
21    'token-meter': {
22      totals: Totals
23      week: Week | null
24      source: Source
25      window: Window | null
26      usd: number | null
27      limits: Limit[]
28    }
29  }
30}
31