Claude Code mod: one line above the prompt with the prompt-cache read rate of the last turn, this session and the whole Mac over the last hour. macOS only; the…

A Claude Code mod that draws one line above the prompt with the prompt-cache read rate: of the last turn, of this session, and of every Claude Code session on this Mac over the last hour.
cache last 99% session 84% (read 244k / write 46k) │ all 60m 97% (read 19.1M / write 548k)
Built for one person's Mac and published as a reference. It works anywhere the requirements below are met, and every requirement is checked by a command.
cache-band is the companion of Mini System Monitor (a macOS app with its own window). The monitor works without the band; the band needs the monitor for two of its three figures.
| Your situation | What to do | What you get |
|---|---|---|
| You already use Mini System Monitor | Install steps 2, 3, 4. If step 2 opens the monitor's window instead of printing JSON, your build is older than the flag: update it first (step 1, with --replace) | All three figures. The monitor's window and settings are not changed |
| You do not use it yet | Install steps 1 to 4 | All three figures. You also get the monitor's window (CPU, memory, Codex and Claude quota), but it does not have to be open for the band |
| You do not want the monitor | Only step 3 | last only, with nothing said about the monitor |
The monitor's README has the same split from its side: Use it alone or with cache-band.
| Needed for | What | Check |
|---|---|---|
| everything | macOS (Mini System Monitor needs macOS 14 or later) | sw_vers -productVersion |
| everything | Claude Code with mods (the plugin hooks API). Tested on 2.1.299, in the terminal and the desktop app's Code tab | claude --version |
session and all 60m | Mini System Monitor at commit 1f92771 (2026-10-17) or later, installed at /Applications/Mini System Monitor.app | see Install, step 2 |
| building the monitor | The Rust toolchain pinned by the monitor's rust-toolchain.toml, and Xcode Command Line Tools | cargo --version, xcode-select -p |
last needs nothing but Claude Code. With no monitor installed, the band shows last alone and says nothing about the monitor: leaving it out is a choice, not a fault. A monitor that is installed but too old or failing is named in the band (see Troubleshooting), since its owner expects the figures.
The monitor has no prebuilt download; it is built from source. Why the mod needs it: a mod's file reads stop at 4 MiB, and a long session's transcript is larger (30 MiB and more), so the mod cannot count transcripts itself. The monitor already counts them for its own window and prints the same totals as JSON, so the band and the monitor always agree.
Run these in order. Each step says what success looks like.
Skip to step 2 if it is already installed.
git clone https://github.com/mlabo-org/mini-system-monitor-rs.git
cd mini-system-monitor-rs && cargo fetch --locked
Scripts/install-app.sh --destination "/Applications/Mini System Monitor.app"
Success: the last line is Installed and verified: /Applications/Mini System Monitor.app. The installer refuses to overwrite an existing app; to update one, quit it and add --replace (the old app is kept beside it as Mini System Monitor.previous-*.app).
--claude-cache-json"/Applications/Mini System Monitor.app/Contents/MacOS/mini-system-monitor-rs" --claude-cache-json
Success: one line of JSON that starts with {"window": and exit code 0, and no window opens. If the monitor's window opens instead, that build is older than the flag: quit it and update it as in step 1 with --replace.
In a Claude Code session:
/plugin install cache-band --marketplace mlabo-org/cache-band
Or from your shell:
claude plugin marketplace add mlabo-org/cache-band
claude plugin install cache-band@cache-band
Success: Successfully installed plugin: cache-band@cache-band. The note 2 userConfig options not yet set that follows is expected: both options have defaults (see Options), so nothing needs configuring unless the monitor is installed somewhere other than /Applications.
The band appears with the first reply of a new session, not on the empty new-session screen: mods run once the session has started.
The rate is cache reads ÷ all input, where all input is uncached input + cache reads + cache writes; write is cache creation. Green from 90%, yellow from 70%, red below. * after a rate means some records could not be read, so the total may be low.
It refreshes when each turn ends and every 30 seconds. The bands of other mods stay beneath it.
Reading the numbers: a new session's first turn is often around 50%, because its own context is written to the cache once. The rate then climbs turn by turn. A sudden drop of last in the middle of a conversation means the cache was not reused, for example after a pause of more than 5 minutes, a model switch, or a compaction.
Set them in the /config panel, or with claude plugin install cache-band@cache-band --config KEY=VALUE.
| Key | Default | Meaning |
|---|---|---|
language | auto | The language of the band's words (see Language); ja or en fixes it. |
monitorPath | /Applications/Mini System Monitor.app/Contents/MacOS/mini-system-monitor-rs | The monitor's executable, inside its app bundle. Change it only when the app is installed elsewhere. |
The band's own words (last, session, all 60m, read, write and the monitor messages) follow the language you use. The cache label stays English. With language at auto, the default:
locale in the app's own ~/Library/Application Support/Claude/config.json: an internal file, not a documented interface, which also holds the app's sign-in cache, so the mod reads it again only when its modification time moves and keeps nothing but locale.language setting (the language Claude replies in), then the locale (LC_ALL, LC_MESSAGES, LANG). With neither, it is English.Japanese and English are written into the mod. Any other language is translated from English the first time it appears, by one small model call (haiku, through the session's own client, so it counts against your usage). Until it lands, the band shows English. The translation is kept in the plugin's $.store per language and reused by every session, and translated again only when the English words change. A translation that loses a {placeholder}, runs too long for the band, or is not well-formed is thrown away, and that language then stays in English, with no further call, until the English words change.
A switch shows without a new session: a change of Claude Code's language in /config at once, any other change within 3 seconds. This is the same rule as context-git-band.
| The band shows | Cause | Fix |
|---|---|---|
no cache line at all | The session has not started yet, or the mod is not installed | Send a message; then check claude plugin list for cache-band |
session/all -- (Mini System Monitor too old; update it) | The executable does not contain --claude-cache-json | Quit the monitor and reinstall it from the latest source with --replace |
session/all -- (Mini System Monitor failed) | The monitor ran but exited with an error or printed something that is not its JSON | Run the command in step 2 yourself and read its error |
last only, never session or all 60m | No monitor at monitorPath (shown this way on purpose) | Install the monitor (step 1), or set monitorPath to where it is |
The mod never runs a monitor that lacks the flag: before the first run it checks that the executable contains the text --claude-cache-json, because an older monitor ignores its arguments and opens its window.
/usr/bin/grep on the monitor's executable (the check above) until it passes, and again after a failed run; then the monitor with --claude-cache-json --session <session id>. With no monitor at monitorPath, it runs nothing.projects/**/*.jsonl under CLAUDE_CONFIG_DIR (default ~/.claude) and prints token counts only. Nothing is written, sent or kept beyond the current session's band.LC_ALL, LC_MESSAGES, LANG, and locale from the desktop app's config.json. Only for a language other than Japanese and English, one haiku call translates the band's English words, kept in the plugin's store.claude plugin validate /path/to/cache-band
claude plugin test /path/to/cache-band
hooks/register.tsx 367 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnUsage } from 'claude-code'
3
4import type { CacheProblem, CacheReading, CacheTotals, Words } from '../types'
5
6const reading = atom({ plugin: 'cache-band', key: 'reading' } as const, null)
7const lastTurn = atom({ plugin: 'cache-band', key: 'lastTurn' } as const, null)
8const problem = atom({ plugin: 'cache-band', key: 'problem' } as const, null)
9
10// Mini System Monitor owns the counting: the same code that fills its Claude view
11// prints it as JSON, so the band and the monitor never disagree
12export const DEFAULT_MONITOR = '/Applications/Mini System Monitor.app/Contents/MacOS/mini-system-monitor-rs'
13const FLAG = '--claude-cache-json'
14
15// The monitor reads every transcript touched in the last hour; a few hundred ms
16const REFRESH_EVERY_MS = 30_000
17
18// The language the band's words are in, as a key (`ja`, `en`, `fr`, `zh-tw`, or a
19// language name): Claude Code's (every surface), and the desktop app's display
20// language, which the desktop Code tab prefers
21const language = atom({ plugin: 'cache-band', key: 'language' } as const, null)
22const appLanguage = atom({ plugin: 'cache-band', key: 'appLanguage' } as const, null)
23// Tables translated from EN, by language key
24const translations = atom({ plugin: 'cache-band', key: 'translations' } as const, null)
25
26// How often a change of either is picked up
27const LANGUAGE_CHECK_MS = 3_000
28
29// The band's own words as templates; `{name}` is filled in. Japanese and English
30// are written here; any other language is translated from English once (see
31// ensureWords). The `cache` label stays English.
32const EN: Words = {
33 lastTurn: 'last', session: 'session', window: 'all {minutes}m', read: 'read', write: 'write',
34 outdated: 'session/all -- (Mini System Monitor too old; update it)',
35 failed: 'session/all -- (Mini System Monitor failed)',
36}
37
38const JA: Words = {
39 lastTurn: '直近', session: 'セッション', window: '全体{minutes}分', read: '読', write: '書',
40 outdated: 'セッション・全体 -- (Mini System Monitor が古い。更新が必要)',
41 failed: 'セッション・全体 -- (Mini System Monitor の実行に失敗)',
42}
43
44const WRITTEN: Record<string, Words> = { en: EN, ja: JA }
45
46export const wordsFor = (key: string | null, translated: Record<string, Words> | null) =>
47 (key === null ? undefined : (WRITTEN[key] ?? translated?.[key])) ?? EN
48
49type MonitorTotals = { input: number; cache_read: number; cache_write: number; partial: boolean }
50
51const totalsOf = (raw: MonitorTotals | null | undefined): CacheTotals | null =>
52 raw == null ? null : { input: raw.input, cacheRead: raw.cache_read, cacheWrite: raw.cache_write, partial: raw.partial }
53
54export const parseMonitor = (stdout: string): CacheReading => {
55 const json = JSON.parse(stdout) as {
56 window_minutes: number
57 window: MonitorTotals | null
58 session?: (MonitorTotals & { found: boolean }) | null
59 }
60 return {
61 window: totalsOf(json.window),
62 session: json.session?.found ? totalsOf(json.session) : null,
63 windowMinutes: json.window_minutes,
64 }
65}
66
67// A turn's usage in the monitor's terms: input is uncached + cache reads + writes
68export const turnTotals = (usage: TurnUsage): CacheTotals => ({
69 input: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
70 cacheRead: usage.cache_read_input_tokens,
71 cacheWrite: usage.cache_creation_input_tokens,
72 partial: false,
73})
74
75export const rate = (totals: CacheTotals) =>
76 totals.input === 0 ? null : Math.floor((totals.cacheRead / totals.input) * 100)
77
78export const rateColor = (percent: number) => (percent >= 90 ? 'green' : percent >= 70 ? 'yellow' : 'red')
79
80export const short = (n: number) =>
81 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
82
83export const monitorPathOf = (option: unknown) =>
84 typeof option === 'string' && option.trim() !== '' ? option.trim() : DEFAULT_MONITOR
85
86// The monitor path whose executable is known to have the flag. A monitor from
87// before the flag ignores its arguments and opens its window, so it is never run
88// until its file is seen to contain the flag's name.
89let supported: string | null = null
90
91async function check($: EngineInterface, monitor: string): Promise<CacheProblem | null> {
92 if (supported === monitor) return null
93 if (!(await $.fs.exists(monitor))) return 'missing'
94 const { exitCode } = await $.process.run(['/usr/bin/grep', '-q', '-a', '-F', '--', FLAG, monitor], { timeoutMs: 10_000 })
95 if (exitCode !== 0) return 'outdated'
96 supported = monitor
97 return null
98}
99
100async function refresh($: EngineInterface, option: unknown) {
101 const monitor = monitorPathOf(option)
102 try {
103 const found = await check($, monitor)
104 if (found !== null) {
105 await update($, problem, () => found)
106 return
107 }
108 const id = await $.session.id()
109 const { exitCode, stdout } = await $.process.run([monitor, FLAG, '--session', id], { timeoutMs: 10_000 })
110 if (exitCode !== 0) throw new Error(`exit ${exitCode}`)
111 const next = parseMonitor(stdout)
112 await update($, reading, () => next)
113 await update($, problem, () => null)
114 } catch {
115 // Check again next time: the monitor may have been removed or replaced
116 supported = null
117 await update($, problem, () => 'failed')
118 }
119}
120
121// A language as a key: `ja` and `en` for the written tables, else a lowercased
122// locale tag (`fr`, `pt-br`, `zh-tw`, `sr-latin`) or language name (`french`);
123// null when it names no language (`C`, `POSIX`, a bare `UTF-8`)
124export const languageKey = (raw: string): string | null => {
125 const lowered = raw.trim().toLowerCase()
126 // A locale's modifier names its script (sr_RS@latin); its encoding names nothing
127 const modifier = /@([a-z]+)/.exec(lowered)?.[1]
128 const text = lowered.replace(/[.@].*$/, '').replace(/_/g, '-')
129 if (text === '' || text === 'c' || text === 'posix' || /^utf-?8$/.test(text)) return null
130 if (/^(japanese|日本語)/.test(text) || /^ja(?:$|-)/.test(text)) return 'ja'
131 if (/^english/.test(text) || /^en(?:$|-)/.test(text)) return 'en'
132 const tag = /^([a-z]{2,3})(?:-([a-z0-9]+))?$/.exec(text)
133 if (tag === null) return text
134 const [, base = text, region] = tag
135 // Keep what changes the written language: zh-tw/zh-hant, pt-br, a script modifier
136 if (modifier !== undefined) return `${base}-${modifier}`
137 return region !== undefined && (base === 'zh' || base === 'pt') ? `${base}-${region}` : base
138}
139
140// `auto` follows Claude Code's own `language` setting (the language Claude replies
141// in), then the locale variables; English with nothing to go by
142async function claudeLanguage($: EngineInterface): Promise<string> {
143 const { language: replyLanguage } = (await $.settings.read()) as { language?: unknown }
144 const fromSetting = typeof replyLanguage === 'string' ? languageKey(replyLanguage) : null
145 if (fromSetting !== null) return fromSetting
146 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG')) || ''
147 return languageKey(locale) ?? 'en'
148}
149
150// The desktop app keeps its display language as `locale` in its own config.json,
151// an internal file that also holds its sign-in cache: read again only when its
152// mtime moves, and nothing but `locale` kept. Null where there is no such app.
153let appConfigMtime = -1
154
155async function readAppLanguage($: EngineInterface): Promise<string | null | undefined> {
156 const home = await $.env.get('HOME')
157 if (home === undefined) return null
158 const path = `${home}/Library/Application Support/Claude/config.json`
159 try {
160 const { mtimeMs } = await $.fs.stat(path)
161 if (mtimeMs === appConfigMtime) return undefined
162 appConfigMtime = mtimeMs
163 const { locale } = JSON.parse(String(await $.fs.read(path))) as { locale?: unknown }
164 return typeof locale === 'string' ? languageKey(locale) : null
165 } catch {
166 return null
167 }
168}
169
170// A translated table is kept in the store per language, stamped with the English
171// it came from, so a change of EN translates again
172const hashText = (text: string) => {
173 let hash = 5381
174 for (let i = 0; i < text.length; i += 1) hash = ((hash * 33) ^ text.charCodeAt(i)) >>> 0
175 return hash.toString(36)
176}
177const EN_VERSION = hashText(JSON.stringify(EN))
178const TRANSLATE_CLAIM_MS = 60_000
179const translating = new Set<string>()
180
181const placeholders = (template: string) => (template.match(/\{\w+\}/g) ?? []).sort().join()
182
183// A translation is taken whole or not at all: every key, every placeholder, one
184// line each, and no longer than the band can hold
185export const checkWords = (value: unknown): Words | null => {
186 if (typeof value !== 'object' || value === null) return null
187 const table = value as Record<string, unknown>
188 for (const key of Object.keys(EN) as (keyof Words)[]) {
189 const text = table[key]
190 if (typeof text !== 'string' || text.trim() === '' || /[\n\[\]]/.test(text)) return null
191 if (placeholders(text) !== placeholders(EN[key])) return null
192 if (text.length > Math.max(24, EN[key].length * 2.5)) return null
193 }
194 return Object.fromEntries((Object.keys(EN) as (keyof Words)[]).map(key => [key, String(table[key])])) as Words
195}
196
197// A language's table as kept: its words, or `failed` when its translation did not
198// hold, which keeps that language in English
199type StoredWords = { version: string; words?: Words; failed?: true }
200
201// Makes the table for `key` ready: written, already translated, kept in the
202// store, or translated now by one small model call (the band shows English
203// until it lands). A failure fixes English for that language, in every session,
204// until the English words change; nothing is tried again before then.
205async function ensureWords($: EngineInterface, key: string | null) {
206 if (key === null || key in WRITTEN || translating.has(key)) return
207 translating.add(key)
208 let claimKey: string | undefined
209 let isDone = false
210 const storeKey = `words:${key}`
211 try {
212 if ((await read($, translations))?.[key] !== undefined) return
213 const stored = (await $.store.get(storeKey)) as Partial<StoredWords> | undefined
214 if (stored?.version === EN_VERSION && stored.failed === true) return
215 const kept = stored?.version === EN_VERSION ? checkWords(stored.words) : null
216 if (kept !== null) {
217 await update($, translations, current => ({ ...current, [key]: kept }))
218 return
219 }
220 const now = await $.clock.now()
221 const claim = `translating:${key}`
222 if (Number((await $.store.get(claim)) ?? 0) > now) return
223 await $.store.set(claim, now + TRANSLATE_CLAIM_MS)
224 claimKey = claim
225 const result = await $.model.complete({
226 model: 'haiku',
227 system: 'You translate the user interface strings of a one-line status bar in a developer tool.',
228 prompt:
229 `Translate the values of this JSON object into the language "${key}" (a language name or a locale tag). ` +
230 'Keep every key. Keep each {placeholder} exactly as written. Keep "--" and the name Mini System Monitor ' +
231 'as they are. Make each value as short as a status bar needs. ' +
232 'Answer with the JSON object only.\n\n' +
233 JSON.stringify(EN),
234 maxTokens: 1024,
235 timeoutMs: 30_000,
236 })
237 if (!result.isAnswered) return
238 const json = /\{[\s\S]*\}/.exec(result.text)?.[0]
239 const words = json === undefined ? null : checkWords(JSON.parse(json))
240 if (words === null) return
241 await $.store.set(storeKey, { version: EN_VERSION, words })
242 await update($, translations, current => ({ ...current, [key]: words }))
243 isDone = true
244 } catch {
245 // English stays
246 } finally {
247 translating.delete(key)
248 if (claimKey !== undefined) {
249 await $.store.delete(claimKey).catch(() => undefined)
250 if (!isDone) await $.store.set(storeKey, { version: EN_VERSION, failed: true }).catch(() => undefined)
251 }
252 }
253}
254
255// Settles both languages and makes their tables ready; `ja` or `en` in the
256// option fixes them on every surface
257async function refreshLanguage($: EngineInterface, option: unknown) {
258 if (option === 'ja' || option === 'en') {
259 await update($, language, () => option)
260 await update($, appLanguage, () => null)
261 return
262 }
263 const claude = await claudeLanguage($)
264 await update($, language, current => (current === claude ? current : claude))
265 const app = await readAppLanguage($)
266 if (app !== undefined) await update($, appLanguage, current => (current === app ? current : app))
267 void ensureWords($, claude)
268 void ensureWords($, await read($, appLanguage))
269}
270
271export const register: Register = (on, options) => {
272 on('session.start', async ($, e, next) => {
273 await refreshLanguage($, options.language)
274 $.clock.every(LANGUAGE_CHECK_MS, () => void refreshLanguage($, options.language).catch(() => undefined))
275 void refresh($, options.monitorPath).catch(() => undefined)
276 // A reload drops the old timer with the old module
277 $.clock.every(REFRESH_EVERY_MS, () => void refresh($, options.monitorPath).catch(() => undefined))
278
279 return next(e)
280 })
281
282 // A `/config` change of Claude Code's language shows at once
283 on('config.set', async ($, e, next) => {
284 const result = await next(e)
285 await refreshLanguage($, options.language).catch(() => undefined)
286
287 return result
288 }).catch(($, e, next) => next(e))
289
290 on('turn.complete', async ($, e, next) => {
291 const result = await next(e)
292 // Subagent turns count in the session and window figures, not as "last"
293 if (e.agentId === undefined && e.usage !== undefined) {
294 const totals = turnTotals(e.usage)
295 await update($, lastTurn, () => totals)
296 }
297 void refresh($, options.monitorPath).catch(() => undefined)
298
299 return result
300 }).catch(($, e, next) => next(e))
301
302 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
303 if (e.props.hasSurvey) {
304 return next(e)
305 }
306
307 const { Box, Text } = $.ui.resolve(e)
308 const app = e.surface === 'desktop' ? await read($, appLanguage) : null
309 const t = wordsFor(app ?? (await read($, language)), await read($, translations))
310 const current = await read($, reading)
311 const turn = await read($, lastTurn)
312 // The band is one site shared by every plugin: keep what the ones beneath draw
313 const below = await next(e)
314
315 const part = (label: string, totals: CacheTotals | null, withCounts: boolean) => {
316 const percent = totals === null ? null : rate(totals)
317 if (totals === null || percent === null) return null
318 return (
319 <Box key={label}>
320 <Text dimColor>{label} </Text>
321 <Text color={rateColor(percent)}>{percent}%</Text>
322 {totals.partial ? <Text dimColor>*</Text> : null}
323 {withCounts ? (
324 <Text dimColor>
325 {' '}
326 ({t.read} {short(totals.cacheRead)} / {t.write} {short(totals.cacheWrite)})
327 </Text>
328 ) : null}
329 <Text> </Text>
330 </Box>
331 )
332 }
333
334 // The last turn needs no monitor, so it shows whatever the monitor's state.
335 // No monitor at all is a choice, not a fault: its figures are left out without
336 // a word. A monitor that is there but too old or failing is named, since its
337 // owner expects the figures.
338 const trouble = await read($, problem)
339 const last = part(t.lastTurn, turn, false)
340 const rest =
341 trouble === 'missing' ? null : trouble !== null ? (
342 <Text color="yellow">{t[trouble]}</Text>
343 ) : (
344 <Box>
345 {part(t.session, current?.session ?? null, true)}
346 {current?.window == null ? null : <Text dimColor>│ </Text>}
347 {part(t.window.replace('{minutes}', String(current?.windowMinutes ?? 60)), current?.window ?? null, true)}
348 </Box>
349 )
350 const row =
351 last === null && (rest === null || (trouble === null && current === null)) ? null : (
352 <Box>
353 <Text dimColor>cache </Text>
354 {last}
355 {rest}
356 </Box>
357 )
358
359 return (
360 <Box flexDirection="column">
361 {row ?? null}
362 {below ?? null}
363 </Box>
364 )
365 })
366}
367types/index.d.ts 37 lines1// One count of input tokens, as Mini System Monitor reports it: `input` is all
2// input (uncached + cache reads + cache writes), `cacheRead` and `cacheWrite`
3// are parts of it
4export type CacheTotals = {
5 input: number
6 cacheRead: number
7 cacheWrite: number
8 partial: boolean
9}
10
11export type CacheReading = {
12 session: CacheTotals | null
13 window: CacheTotals | null
14 windowMinutes: number
15}
16
17// Why the session and Mac-wide figures are missing: no executable at the monitor
18// path, a monitor from before --claude-cache-json, or a run that failed
19export type CacheProblem = 'missing' | 'outdated' | 'failed'
20
21// The band's own words, as templates with {placeholders}
22export type Words = {
23 lastTurn: string
24 session: string
25 window: string
26 read: string
27 write: string
28 outdated: string
29 failed: string
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'cache-band': { reading: CacheReading | null; lastTurn: CacheTotals | null; problem: CacheProblem | null; language: string | null; appLanguage: string | null; translations: Record<string, Words> | null }
35 }
36}
37