How full the context window and the quota are, and what today cost, above the prompt.

Claude Code mods, shipped as plugins. Requires Claude Code 2.1.287 or later.
One line above the prompt that shows how full your context window and your plan's usage limits are, and what today's usage would cost at API prices.
https://github.com/user-attachments/assets/d3748a88-43b6-4c02-a343-623e2c3aecdd

◔ 36% of context │ ◔ 12% of session ↻ 2h 46m │ ◌ 8% of weekly │ $42.17 today · 118M tokens
/compact or /clear; the quota, countdown and today's total refresh every 30 seconds.• behind.Session and weekly only appear on a Claude subscription, once the first reply of the session has arrived. With an API key there are no usage limits to show, so only the context is drawn.
Today's figure is read from Claude Code's local transcripts (~/.claude/projects, ~/.config/claude/projects, every entry of CLAUDE_CONFIG_DIR, and the desktop app's Cowork sessions), so it includes sessions that ran before the plugin loaded. It follows ccusage's rules and matches its claude daily output to the cent:
It is an API-equivalent figure: on a subscription you are not billed this amount. Models it has no price for count toward tokens but not cost. The scan reads only new bytes each time and keeps its offsets in ~/.token-bar/. It runs through osascript, so today's figure is macOS only; elsewhere the rest of the band works as before.
In Claude Code:
/plugin marketplace add santosli/claude-mods
/plugin install token-bar@santos-mods
Or from a shell:
claude plugin marketplace add santosli/claude-mods
claude plugin install token-bar@santos-mods --scope user
Then start a new session (or restart the desktop app).
claude --plugin-dir ./token-bar # loads it and reloads on every save
claude plugin validate ./token-bar
claude plugin test ./token-bar
token-bar 在 Claude Code 输入框上方显示一行用量(30 秒宣传片):
颜色跟手机电量一样:60% 以下绿色,60–79% 黄色,80% 及以上红色。每轮回复后、/compact 和 /clear 后更新;额度、倒计时和今日用量每 30 秒刷新一次。session 和 weekly 只在订阅账号下显示(会话收到第一条回复后出现)。
今日用量读取本地对话记录,所以插件加载之前的会话也算在内。计算规则和 ccusage 一致(按回复去重、按本地日期统计、按官方价格计费),结果与 ccusage claude daily 逐日一致。金额是按 API 价格的折算值,订阅用户并不会被这样扣费。今日用量依赖 osascript,仅支持 macOS。
安装:在 Claude Code 里运行
/plugin marketplace add santosli/claude-mods
/plugin install token-bar@santos-mods
然后新开一个会话(桌面端重启一下 app)。
MIT
hooks/register.tsx 401 lines1// Token Bar: how full the context window and the quota are, above the prompt.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { TokenBarLimit, TokenBarReading, TokenBarToday } from '../types'
6import { bars, barsWidth, ring, week } from './draw'
7import { SCAN_SCRIPT, SCAN_STATE, scanRoots } from './scan'
8
9const HISTORY = 12
10const TICK_MS = 30_000
11const BARS = '▁▂▃▄▅▆▇█'
12// Colored by how much is used, as a phone's battery is: green, then yellow, then red.
13// `color` is the terminal's name; `hex` the desktop's, chosen to read on a light card and a dark one.
14const LEVELS: readonly { upTo: number; color: string; hex: string }[] = [
15 { upTo: 60, color: 'green', hex: '#30a14e' },
16 { upTo: 80, color: 'yellow', hex: '#d99a00' },
17 { upTo: Infinity, color: 'red', hex: '#e5484d' },
18]
19// The rule between two groups on the desktop.
20const RULE = 'rgba(128,128,128,0.45)'
21// The turns chart and last-turn delta: off for now; the history is still kept.
22const SHOW_TURNS = false
23
24// Held by the host, so they survive a hot reload of this file.
25const readings = atom({ plugin: 'token-bar', key: 'readings' } as const, [] as TokenBarReading[])
26const limits = atom({ plugin: 'token-bar', key: 'limits' } as const, [] as TokenBarLimit[])
27// The clock the countdown is drawn against; ticked so the band redraws.
28const now = atom({ plugin: 'token-bar', key: 'now' } as const, 0)
29const today = atom({ plugin: 'token-bar', key: 'today' } as const, null as TokenBarToday | null)
30
31export const register: Register = on => {
32 on('session.start', async ($, e, next) => {
33 const result = await next(e)
34 // The quota is the account's: show the last one known until a response reports it again.
35 const stored = await $.store.get('limits').catch(() => undefined)
36 if (Array.isArray(stored)) await saveLimits($, stored as TokenBarLimit[])
37 await takeReading($)
38 await askApp($)
39 await scanToday($)
40 // Quota moves between turns too, and the countdown runs down: refresh both.
41 $.clock.every(TICK_MS, () => refresh($))
42 return result
43 })
44
45 on('turn.complete', async ($, e, next) => {
46 const result = await next(e)
47 if (!e.agentId) {
48 await takeReading($) // main-loop turns only, not subagents
49 }
50 if (!e.agentId) await scanToday($)
51 return result
52 })
53
54 // The engine's own measurements: the context's fill and the quota, whenever either moves.
55 on('session.measure', async ($, e, next) => {
56 if (e.changed.includes('rateLimits')) await saveLimits($, e.rateLimits, true)
57 if (e.changed.includes('context') && e.context.tokens !== undefined) await addReading($, e.context.tokens, e.context.window)
58 return next(e)
59 })
60
61 // A compaction is not a turn, and no response has measured the window since: estimate it,
62 // system prompt and tools included, as /context does. The size the compaction reports
63 // counts the conversation alone.
64 on('session.compact', async ($, e, next) => {
65 const result = await next(e)
66 if (e.agentId || e.trigger === 'precompute' || 'skip' in result) return result
67 const after = 'tokensAfter' in result ? result.tokensAfter : undefined
68 await setContext($, (await estimate($)) ?? after)
69 return result
70 })
71
72 // A /clear starts the conversation over, with no session.start.
73 on('session.end', async ($, e, next) => {
74 if (e.reason !== 'clear') return next(e)
75 await setContext($, 0)
76 const result = await next(e)
77 // Once the conversation is gone, what is left is the system prompt and the tools.
78 $.clock.after(1_000, () => void estimate($).then(t => setContext($, t)))
79 return result
80 })
81
82 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83 const history = await read($, readings)
84 const last = history.at(-1)
85 if (e.props.hasSurvey || !last) {
86 return next(e)
87 }
88
89 const quota = await read($, limits)
90 const at = await read($, now)
91 const lv = level(last.percent)
92 const fiveHour = quota.find(l => l.kind === 'five_hour')
93 const sevenDay = quota.find(l => l.kind === 'seven_day')
94 const columns = e.props.bodyColumns
95 const spent = await read($, today)
96
97 if (e.surface !== 'terminal') {
98 const { Box, Text, Svg } = $.ui.resolve(e)
99 // Each group's width in columns, so a narrow band drops the right-most first.
100 const groups: { key: string; color: string; cells: number; draw: () => JSX.Element }[] = [
101 {
102 key: 'context',
103 color: lv.hex,
104 cells: fit(2, `${last.percent}%`, 'of context'),
105 draw: () => (
106 <Box key="context" flexDirection="row" alignItems="center" gap={1}>
107 <Svg source={ring(last.percent, lv.hex)} alt={`${last.percent}% of context used`} width={18} height={18} />
108 <Text color={lv.hex} bold>{`${last.percent}%`}</Text>
109 <Text dimColor>of context</Text>
110 </Box>
111 ),
112 },
113 ]
114 if (fiveHour) {
115 const q = level(fiveHour.percentUsed)
116 const qUsed = used(fiveHour.percentUsed)
117 groups.push({
118 key: 'five-hour',
119 color: q.hex,
120 cells: fit(2, `${qUsed}%`, 'of session', fiveHour.resetsAt ? `↻ ${countdown(fiveHour.resetsAt, at)}` : ''),
121 draw: () => (
122 <Box key="five-hour" flexDirection="row" alignItems="center" gap={1}>
123 <Svg source={ring(qUsed, q.hex)} alt={`${qUsed}% of session used`} width={18} height={18} />
124 <Text color={q.hex} bold>{`${qUsed}%`}</Text>
125 <Text dimColor>of session</Text>
126 {fiveHour.resetsAt && <Text dimColor>{`↻ ${countdown(fiveHour.resetsAt, at)}`}</Text>}
127 </Box>
128 ),
129 })
130 }
131 if (sevenDay) {
132 const q = level(sevenDay.percentUsed)
133 const qUsed = used(sevenDay.percentUsed)
134 groups.push({
135 key: 'seven-day',
136 color: q.hex,
137 cells: fit(2, `${qUsed}%`, 'of weekly'),
138 draw: () => (
139 <Box key="seven-day" flexDirection="row" alignItems="center" gap={1}>
140 <Svg source={week(qUsed, q.hex)} alt={`${qUsed}% of weekly used`} width={18} height={18} />
141 <Text color={q.hex} bold>{`${qUsed}%`}</Text>
142 <Text dimColor>of weekly</Text>
143 </Box>
144 ),
145 })
146 }
147 if (spent) {
148 groups.push({
149 key: 'today',
150 color: '#8a8a8a',
151 cells: fit(0, money(spent.usd), `today · ${short(spent.tokens)} tokens`),
152 draw: () => (
153 <Box key="today" flexDirection="row" alignItems="center" gap={1}>
154 <Text bold>{money(spent.usd)}</Text>
155 <Text dimColor>{`today · ${short(spent.tokens)} tokens`}</Text>
156 </Box>
157 ),
158 })
159 }
160 if (SHOW_TURNS && history.length > 1) {
161 groups.push({
162 key: 'trend',
163 color: lv.hex,
164 cells: 22,
165 draw: () => (
166 <Box key="trend" flexDirection="row" alignItems="center" gap={1}>
167 <Svg source={bars(history.map(r => r.tokens), lv.hex)} alt={`Context after each of the last ${history.length} turns`} width={barsWidth(history.length)} height={14} />
168 <Text dimColor>{trend(history).trim()}</Text>
169 </Box>
170 ),
171 })
172 }
173 let room = columns - 2
174 // Groups sit a rule apart: a column of space, the rule, and a column again; three in all.
175 const shown = groups.filter((g, i) => (room -= g.cells + (i > 0 ? 3 : 0)) >= 0 || i === 0)
176 const hidden = groups.slice(shown.length)
177
178 return (
179 <Box flexDirection="row" alignItems="center" paddingX={1} gap={1}>
180 {shown.flatMap((g, i) => [
181 ...(i > 0 ? [<Text key={`rule-${g.key}`} color={RULE}>│</Text>] : []),
182 g.draw(),
183 ])}
184 {hidden.length > 0 && (
185 <Box key="hidden" flexDirection="row" gap={1}>
186 {hidden.map(g => (
187 <Text key={g.key} color={g.color}>•</Text>
188 ))}
189 </Box>
190 )}
191 </Box>
192 )
193 }
194
195 const { Box, Text } = $.ui.resolve(e)
196 const q5 = fiveHour && level(fiveHour.percentUsed)
197 const q7 = sevenDay && level(sevenDay.percentUsed)
198
199 return (
200 <Box flexDirection="row" paddingX={1}>
201 <Text color={lv.color}>{gauge(last.percent)}</Text>
202 <Text color={lv.color} bold>{` ${last.percent}%`}</Text>
203 <Text dimColor>{' of context'}</Text>
204 {columns >= 80 && fiveHour && <Text color={q5!.color} bold>{` ${used(fiveHour.percentUsed)}%`}</Text>}
205 {columns >= 80 && fiveHour && <Text dimColor>{' of session'}</Text>}
206 {columns >= 80 && fiveHour?.resetsAt && <Text dimColor>{` ↻ ${countdown(fiveHour.resetsAt, at)}`}</Text>}
207 {columns >= 80 && sevenDay && <Text color={q7!.color} bold>{` ${used(sevenDay.percentUsed)}%`}</Text>}
208 {columns >= 80 && sevenDay && <Text dimColor>{' of weekly'}</Text>}
209 {columns >= 110 && spent && <Text bold>{` ${money(spent.usd)}`}</Text>}
210 {columns >= 110 && spent && <Text dimColor>{` today · ${short(spent.tokens)} tokens`}</Text>}
211 {SHOW_TURNS && columns >= 120 && <Text dimColor>{' last turns '}</Text>}
212 {SHOW_TURNS && columns >= 120 && <Text color={lv.color}>{sparkline(history)}</Text>}
213 {SHOW_TURNS && columns >= 120 && history.length > 1 && <Text dimColor>{trend(history)}</Text>}
214 </Box>
215 )
216 })
217}
218
219// The desktop's text is proportional: a character takes about 0.8 of a column there
220// (measured on the band: `of context` is 123px, a column 16px). Rounded up for bold.
221const CHAR = 0.85
222
223// A group's width in columns: its icon's, its texts' and one between each.
224function fit(icon: number, ...texts: string[]) {
225 const parts = texts.filter(Boolean)
226 return Math.ceil(icon + parts.reduce((n, t) => n + t.length * CHAR, 0) + parts.length - (icon ? 0 : 1))
227}
228
229async function takeReading($: EngineInterface) {
230 const { context, rateLimits } = await $.session.usage()
231 await saveLimits($, rateLimits, true)
232 if (!context?.window) return
233 // Before a response has measured the window (a new session, or one just compacted) there
234 // is no fill: estimate it rather than draw an empty window.
235 const tokens = context.tokens ?? (await estimate($))
236 if (tokens !== undefined) await addReading($, tokens, context.window)
237}
238
239// The window's fill as /context estimates it locally, without a request.
240async function estimate($: EngineInterface) {
241 try {
242 const { context } = await $.session.usage({ breakdown: 'summary' })
243 return context.breakdown?.totalTokens
244 } catch {
245 return undefined
246 }
247}
248
249// A reading of the size given, against the window the last reading had.
250async function setContext($: EngineInterface, tokens: number | undefined) {
251 if (tokens === undefined) return
252 const last = (await read($, readings)).at(-1)
253 const window = last?.window ?? (await $.session.usage()).context?.window
254 if (window) await addReading($, tokens, window)
255}
256
257async function addReading($: EngineInterface, tokens: number, window: number) {
258 const percent = Math.round((tokens / window) * 100)
259 await update($, readings, history => {
260 const last = history.at(-1)
261 if (last && last.tokens === tokens && last.window === window) return history
262 return [...history, { tokens, window, percent }].slice(-HISTORY)
263 })
264}
265
266async function refresh($: EngineInterface) {
267 const { rateLimits } = await $.session.usage()
268 await saveLimits($, rateLimits, true)
269 await askApp($)
270 await scanToday($) // other sessions add to today too
271 const at = await $.clock.now()
272 await update($, now, () => at)
273}
274
275// Whether the engine has reported the quota in this session. The desktop app's sessions
276// may not pass it on; the app's own usage card is asked then.
277let engineReports = false
278let askedAt = -Infinity
279const APP_USAGE = 'mcp__ccd_session_mgmt__get_usage'
280const ASK_EVERY_MS = 120_000
281
282async function askApp($: EngineInterface) {
283 if (engineReports) return
284 const at = await $.clock.now()
285 if (at - askedAt < ASK_EVERY_MS) return
286 askedAt = at
287 try {
288 if (!(await $.tool.list()).some(t => t.name === APP_USAGE)) return
289 const answer = await $.tool.call({ tool: APP_USAGE } as Parameters<EngineInterface['tool']['call']>[0])
290 if (answer.isError || answer.deny !== undefined) return
291 const text = answer.text ?? textOf(answer.result)
292 const plan = JSON.parse(text.slice(text.indexOf('{'))).plan as
293 | { status: string; windows?: { label: string; percentUsed: number; resetsAt?: string }[] }
294 | undefined
295 if (plan?.status !== 'ok' || !plan.windows) return
296 const found: TokenBarLimit[] = []
297 for (const w of plan.windows) {
298 const kind = /^5-hour/i.test(w.label) ? 'five_hour' : /^weekly · all/i.test(w.label) ? 'seven_day' : undefined
299 if (kind && typeof w.percentUsed === 'number') found.push({ kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })
300 }
301 if (!engineReports) await saveLimits($, found)
302 } catch {
303 // No app to ask, or it could not answer: keep what is shown.
304 }
305}
306
307// An MCP tool's output as text: a string, or its content blocks' text joined.
308function textOf(result: unknown): string {
309 if (typeof result === 'string') return result
310 if (Array.isArray(result)) return result.map(b => (b && typeof b.text === 'string' ? b.text : '')).join('')
311 return ''
312}
313
314async function saveLimits($: EngineInterface, rateLimits: readonly TokenBarLimit[], fromEngine = false) {
315 if (fromEngine && rateLimits.length > 0) engineReports = true
316 const at = await $.clock.now()
317 // A response may report only some windows: keep each window's last reading until it resets.
318 await update($, limits, kept => {
319 const byKind = new Map(kept.map(l => [l.kind, l]))
320 for (const { kind, percentUsed, resetsAt } of rateLimits) byKind.set(kind, { kind, percentUsed, resetsAt })
321 return [...byKind.values()].filter(l => !l.resetsAt || Date.parse(l.resetsAt) > at)
322 })
323 // Kept across sessions too, for the next one to start from.
324 if (rateLimits.length > 0) await $.store.set('limits', await read($, limits)).catch(() => {})
325 await update($, now, () => at)
326}
327
328// The local calendar day, as YYYY-MM-DD.
329function dayOf(ms: number) {
330 const d = new Date(ms)
331 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
332}
333
334// One scan at a time per session; a request while one runs is folded into it.
335let scanning: Promise<void> | null = null
336
337function scanToday($: EngineInterface) {
338 scanning ??= runScan($).finally(() => (scanning = null))
339 return scanning
340}
341
342// Counts today from the transcripts. Off macOS, or when the scan fails, the band
343// simply goes without today's group.
344async function runScan($: EngineInterface) {
345 try {
346 const day = dayOf(await $.clock.now())
347 const roots = scanRoots(await $.env.get('CLAUDE_CONFIG_DIR'))
348 const { exitCode, stdout } = await $.process.run(
349 ['osascript', '-l', 'JavaScript', '-e', SCAN_SCRIPT, roots, SCAN_STATE, day],
350 { timeoutMs: 60_000 },
351 )
352 if (exitCode !== 0) return
353 const r = JSON.parse(stdout) as { day: string; tokens: number; usd: number }
354 if (r.day !== day || typeof r.tokens !== 'number' || typeof r.usd !== 'number') return
355 await update($, today, () => ({ day: r.day, tokens: r.tokens, usd: r.usd }))
356 } catch {
357 // Leave the last count in place.
358 }
359}
360
361function money(usd: number) {
362 return usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`
363}
364
365function level(percentUsed: number) {
366 return LEVELS.find(l => percentUsed < l.upTo) ?? LEVELS[LEVELS.length - 1]!
367}
368
369function used(percentUsed: number) {
370 return Math.round(percentUsed)
371}
372
373// The terminal's gauge: five cells, filled as far as the context is used.
374function gauge(used: number) {
375 const full = Math.round((Math.min(Math.max(used, 0), 100) / 100) * 5)
376 return `[${'█'.repeat(full)}${'░'.repeat(5 - full)}]`
377}
378
379function countdown(resetsAt: string, at: number) {
380 const minutes = Math.max(0, Math.floor((Date.parse(resetsAt) - at) / 60_000))
381 if (minutes >= 60) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
382 return `${minutes}m`
383}
384
385function sparkline(history: TokenBarReading[]) {
386 const top = Math.max(...history.map(r => r.tokens), 1)
387 return history.map(r => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join('')
388}
389
390function trend(history: TokenBarReading[]) {
391 const delta = (history.at(-1)?.tokens ?? 0) - (history.at(-2)?.tokens ?? 0)
392 if (delta === 0) return ' steady'
393 return delta > 0 ? ` ▲ +${short(delta)} last turn` : ` ▼ ${short(-delta)} last turn`
394}
395
396function short(n: number) {
397 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
398 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
399 return String(n)
400}
401hooks/draw.ts 73 lines1// The drawings the surfaces with Svg show, as plain SVG markup.
2// Each takes how much is used (0 to 100) and the level's color.
3
4// A mid-tone that reads on the desktop's light card and on its dark one.
5const TRACK = 'rgba(128,128,128,0.28)'
6const QUIET = 'rgba(128,128,128,0.55)'
7
8// A ring that fills clockwise from twelve as the share is used.
9export function ring(used: number, color: string) {
10 const r = 7
11 const c = 2 * Math.PI * r
12 return svg(
13 18,
14 18,
15 `<circle cx="9" cy="9" r="${r}" fill="none" stroke="${TRACK}" stroke-width="3"/>` +
16 (used > 0
17 ? `<circle cx="9" cy="9" r="${r}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round" ` +
18 `stroke-dasharray="${Math.max(share(used) * c, 0.1).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 9 9)"/>`
19 : ''),
20 )
21}
22
23// The week as a ring of seven arcs, one a day, filled clockwise as the quota is used.
24export function week(used: number, color: string) {
25 const filled = share(used) * 7
26 const span = 360 / 7
27 const gap = 9
28 let body = ''
29 for (let i = 0; i < 7; i++) {
30 const from = i * span + gap / 2
31 const to = (i + 1) * span - gap / 2
32 body += `<path d="${arc(from, to)}" fill="none" stroke="${TRACK}" stroke-width="3"/>`
33 const part = Math.min(Math.max(filled - i, 0), 1)
34 // A started day shows at least a quarter of its arc, so a sliver does not read as a glitch.
35 if (part > 0) body += `<path d="${arc(from, from + (to - from) * Math.max(part, 0.25))}" fill="none" stroke="${color}" stroke-width="3"/>`
36 }
37 return svg(18, 18, body)
38}
39
40// An arc of the ring, clockwise from twelve, between two angles in degrees.
41function arc(from: number, to: number) {
42 const at = (deg: number) => {
43 const rad = ((deg - 90) * Math.PI) / 180
44 return `${(9 + 7 * Math.cos(rad)).toFixed(2)} ${(9 + 7 * Math.sin(rad)).toFixed(2)}`
45 }
46 return `M ${at(from)} A 7 7 0 0 1 ${at(to)}`
47}
48
49// The context after each of the last turns, as bars; the newest in the level's color.
50export function bars(values: number[], color: string) {
51 const top = Math.max(...values, 1)
52 const body = values
53 .map((v, i) => {
54 const h = Math.max((v / top) * 14, 1.5)
55 const fill = i === values.length - 1 ? color : QUIET
56 return `<rect x="${i * 5}" y="${(14 - h).toFixed(1)}" width="3.5" height="${h.toFixed(1)}" rx="1" fill="${fill}"/>`
57 })
58 .join('')
59 return svg(barsWidth(values.length), 14, body)
60}
61
62export function barsWidth(count: number) {
63 return Math.max(count * 5 - 1.5, 1)
64}
65
66function share(percent: number) {
67 return Math.min(Math.max(percent, 0), 100) / 100
68}
69
70function svg(w: number, h: number, body: string) {
71 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">${body}</svg>`
72}
73hooks/scan.ts 188 lines1// Today's tokens and their API-price cost, counted from Claude Code's own transcripts, so
2// every session of the day counts, those from before the plugin loaded included.
3// Run by osascript as JavaScript for Automation (macOS); a run reads only what the
4// transcripts gained since the last, from the offsets it keeps in its state directory.
5// Counting rules follow ccusage and OpenUsage (see the script's header).
6export const SCAN_STATE = '~/.token-bar'
7
8// The transcript roots, as ccusage and OpenUsage find them: CLAUDE_CONFIG_DIR's entries
9// (comma-separated, each a config directory or its projects directory), or else both
10// default homes. The script adds the desktop app's Cowork sessions itself.
11export function scanRoots(configDir: string | undefined) {
12 const dirs = configDir?.split(',').map(d => d.trim()).filter(Boolean) ?? []
13 if (dirs.length === 0) return '~/.config/claude/projects,~/.claude/projects'
14 return dirs.map(d => (/\/projects\/?$/.test(d) ? d : `${d.replace(/\/+$/, '')}/projects`)).join(',')
15}
16
17export const SCAN_SCRIPT = String.raw`ObjC.import('Foundation')
18
19// Today's tokens and API-price cost, from Claude Code's transcripts.
20// argv: transcript roots ('projects' directories, comma-separated, '~' allowed),
21// state directory ('~' allowed), day (YYYY-MM-DD, local).
22// Also reads the Claude desktop app's Cowork sessions. Incremental: the state file keeps
23// each transcript's read offset and what each reply was counted at, so a run reads only
24// what was appended since, and a fuller copy of a reply corrects its count.
25// The rules follow ccusage and OpenUsage: one count per reply (message id + request id),
26// the fullest copy wins, a sidechain replay of a parent reply is the same reply.
27
28// $ per million tokens: input, output, cache read, fast-mode multiplier. Cache writes
29// cost 1.25x input (5 minutes) or 2x (1 hour).
30const PRICES = [
31 ['claude-fable-5-1', 10, 50, 0.25, 2], ['claude-mythos-5-1', 10, 50, 0.25, 2],
32 ['claude-fable-5', 10, 50, 1, 2], ['claude-mythos-5', 10, 50, 1, 2],
33 ['claude-opus-5-5', 4, 20, 0.2, 2], ['claude-opus-5', 5, 25, 0.5, 2],
34 ['claude-opus-4-8', 5, 25, 0.5, 2], ['claude-opus-4-7', 5, 25, 0.5, 6], ['claude-opus-4-6', 5, 25, 0.5, 6],
35 ['claude-sonnet-5-5', 2, 10, 0.2, 2], ['claude-sonnet-5', 2, 10, 0.2, 2], ['claude-sonnet-4-6', 3, 15, 0.3, 2],
36 ['claude-haiku-4-5', 1, 5, 0.1, 2],
37]
38
39function priceOf(model) {
40 // The longest matching id wins ('claude-opus-5-5' before 'claude-opus-5'); a dated or
41 // provider-spelled id ('claude-opus-4-8@20260101', 'anthropic.claude-...') still matches.
42 const id = model.replace(/^(anthropic\.|us\.anthropic\.|eu\.anthropic\.)/, '').replace(/[@.]/g, '-')
43 let best = null
44 for (const p of PRICES) if (id === p[0] || id.indexOf(p[0] + '-') === 0) if (!best || p[0].length > best[0].length) best = p
45 return best
46}
47
48function tokensOf(u) {
49 return (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
50}
51
52function costOf(model, u) {
53 const p = priceOf(model)
54 if (!p) return null
55 const [, input, output, read, fast] = p
56 const cc = u.cache_creation || {}
57 const w1h = cc.ephemeral_1h_input_tokens || 0
58 const w5m = cc.ephemeral_5m_input_tokens !== undefined ? cc.ephemeral_5m_input_tokens : Math.max((u.cache_creation_input_tokens || 0) - w1h, 0)
59 const usd = ((u.input_tokens || 0) * input + (u.output_tokens || 0) * output + (u.cache_read_input_tokens || 0) * read
60 + w5m * input * 1.25 + w1h * input * 2) / 1e6
61 return u.speed === 'fast' ? usd * fast : usd
62}
63
64function dayOf(ms) {
65 const d = new Date(ms)
66 return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
67}
68
69function run(argv) {
70 const home = $.NSHomeDirectory().js
71 const fm = $.NSFileManager.defaultManager
72 const isDir = path => { const flag = Ref(); return fm.fileExistsAtPathIsDirectory(path, flag) && flag[0] }
73 const roots = []
74 for (const r of argv[0].split(',')) {
75 const path = r.trim().replace(/^~/, home).replace(/\/+$/, '')
76 if (path && isDir(path) && roots.indexOf(path) === -1) roots.push(path)
77 }
78 // Cowork sessions keep a Claude Code home of their own, deep under the desktop app's data.
79 const cowork = home + '/Library/Application Support/Claude/local-agent-mode-sessions'
80 if (isDir(cowork)) {
81 const walker = fm.enumeratorAtPath(cowork)
82 let rel
83 while (!(rel = walker.nextObject).isNil()) {
84 if (/(^|\/)\.claude\/projects$/.test(rel.js)) { roots.push(cowork + '/' + rel.js); walker.skipDescendants }
85 }
86 }
87 const stateDir = argv[1].replace(/^~/, home)
88 const day = argv[2]
89 fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(stateDir, true, $(), $())
90 const statePath = stateDir + '/scan-' + day + '.json'
91
92 // counted: key -> [tokens, usd, sidechain, message id]; byMessage: message id -> key
93 let state = { version: 2, day: day, files: {}, counted: {}, byMessage: {}, tokens: 0, usd: 0, unpriced: {} }
94 const old = $.NSString.stringWithContentsOfFileEncodingError(statePath, $.NSUTF8StringEncoding, $())
95 if (!old.isNil()) { try { const s = JSON.parse(old.js); if (s.day === day && s.version === 2) state = s } catch (e) {} }
96
97 // A reply's count, replacing what it was counted at before.
98 function count(key, entry) {
99 const was = state.counted[key]
100 if (was) { state.tokens -= was[0]; state.usd -= was[1] }
101 state.counted[key] = entry
102 state.tokens += entry[0]
103 state.usd += entry[1]
104 }
105
106 // Midnight today, local: a transcript last written before then holds nothing of today's.
107 const parts = day.split('-').map(Number)
108 const startOfDay = new Date(parts[0], parts[1] - 1, parts[2]).getTime()
109
110 for (const root of roots) {
111 const walker = fm.enumeratorAtPath(root)
112 let rel
113 while (!(rel = walker.nextObject).isNil()) {
114 if (!rel.js.endsWith('.jsonl')) continue
115 const path = root + '/' + rel.js
116 const attrs = fm.attributesOfItemAtPathError(path, $())
117 if (attrs.isNil()) continue
118 if (attrs.objectForKey($.NSFileModificationDate).timeIntervalSince1970 * 1000 < startOfDay) continue
119 const size = Number(attrs.objectForKey($.NSFileSize).longLongValue)
120 let offset = state.files[path] || 0
121 if (size < offset) offset = 0 // rewritten: read again; replies already counted stay counted once
122 if (size === offset) continue
123
124 const fh = $.NSFileHandle.fileHandleForReadingAtPath(path)
125 if (fh.isNil()) continue
126 fh.seekToFileOffset(offset)
127 const data = fh.readDataToEndOfFile
128 fh.closeFile
129 const text = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js || ''
130 // Whole lines only; a line still being written waits for the next run.
131 const end = text.lastIndexOf('\n') + 1
132 const rest = text.slice(end)
133 state.files[path] = offset + Number(data.length) - (rest ? $(rest).lengthOfBytesUsingEncoding($.NSUTF8StringEncoding) : 0)
134
135 for (const line of text.slice(0, end).split('\n')) {
136 if (line.indexOf('"usage"') === -1 || line.indexOf('"assistant"') === -1) continue
137 let d
138 try { d = JSON.parse(line) } catch (e) { continue }
139 const m = d.message
140 if (d.type !== 'assistant' || !m || !m.usage || !d.timestamp) continue
141 if (dayOf(Date.parse(d.timestamp)) !== day) continue
142 const u = m.usage
143 const model = m.model || ''
144 const tokens = tokensOf(u)
145 const usd = model === '<synthetic>' ? 0 : costOf(model, u)
146 if (usd === null) state.unpriced[model] = (state.unpriced[model] || 0) + 1
147 const sidechain = !!d.isSidechain
148 const entry = [tokens, usd || 0, sidechain, m.id || '']
149
150 // A reply is its message id and request id; without a request id, its session and time.
151 const key = !m.id ? 'line:' + (d.uuid || d.sessionId + ':' + d.timestamp)
152 : d.requestId ? m.id + ':' + d.requestId : m.id + ':' + d.sessionId + ':' + d.timestamp
153 // A sidechain replaying a parent reply under a new request id is that reply.
154 let same = state.counted[key] ? key : null
155 if (!same && m.id && state.byMessage[m.id]) {
156 const other = state.counted[state.byMessage[m.id]]
157 if (other && (other[2] || sidechain)) same = state.byMessage[m.id]
158 }
159 if (!same) {
160 count(key, entry)
161 if (m.id && !state.byMessage[m.id]) state.byMessage[m.id] = key
162 continue
163 }
164 // Keep the better copy: the parent over a replay, then the fullest.
165 const was = state.counted[same]
166 const better = was[2] && !sidechain ? true : !was[2] && sidechain ? false : tokens > was[0]
167 if (better) {
168 if (same !== key) { state.tokens -= was[0]; state.usd -= was[1]; delete state.counted[same] }
169 count(key, entry)
170 if (m.id) state.byMessage[m.id] = key
171 }
172 }
173 }
174 }
175
176 const tmp = statePath + '.' + $.NSProcessInfo.processInfo.processIdentifier + '.tmp'
177 $(JSON.stringify(state)).writeToFileAtomicallyEncodingError(tmp, true, $.NSUTF8StringEncoding, $())
178 fm.removeItemAtPathError(statePath, $())
179 fm.moveItemAtPathToPathError(tmp, statePath, $())
180 const names = fm.contentsOfDirectoryAtPathError(stateDir, $())
181 if (!names.isNil()) for (let i = 0; i < names.count; i++) {
182 const n = names.objectAtIndex(i).js
183 if (/^scan-\d{4}-\d{2}-\d{2}\.json$/.test(n) && n !== 'scan-' + day + '.json') fm.removeItemAtPathError(stateDir + '/' + n, $())
184 }
185 return JSON.stringify({ day: day, tokens: state.tokens, usd: Math.round(state.usd * 1e4) / 1e4, unpriced: state.unpriced, roots: roots.length })
186}
187`
188types/index.d.ts 16 lines1export type TokenBarReading = { tokens: number; window: number; percent: number }
2export type TokenBarLimit = { kind: string; percentUsed: number; resetsAt?: string }
3// What today has used across every session, as the transcript scan last counted it.
4export type TokenBarToday = { day: string; tokens: number; usd: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'token-bar': {
9 readings: TokenBarReading[]
10 limits: TokenBarLimit[]
11 now: number
12 today: TokenBarToday | null
13 }
14 }
15}
16