A CACHE band above the prompt: how long the prompt cache stays warm, how much of the last request it served, and what rewriting it would cost (estimate); a…

A ● CACHE row above the prompt: how long the prompt cache stays warm, and what letting it go cold would cost.
● CACHE warm 52m ▰▰▰▰▰▱ 94% reused · 180k cached · rewrite ≈ $1.44 [ Compact ]
● CACHE COLD ▱▱▱▱▱▱ 180k to rewrite ≈ $1.44 on next send [ Compact ]
COLD at 0. The row ticks every 30 seconds while warm and stops once cold.≈ ?.$.session.compact(), the same as /compact) and toasts the result. The row then hides until the next cache request, as it does after your own /compact or a /clear./cache-clock status prints.Dollar figures are estimates: list prices from the claude-api skill (cached 2026-10-06), not your bill. On a subscription plan nothing is billed per token; the figure is what the same tokens would cost on the API.
The Compact button works only while the band has focus (ctrl+x tab, or a click in Claude Code's fullscreen renderer; the classic renderer reports no clicks), so it has a command too:
| Button | Command |
|---|---|
Compact | /cache-clock compact |
/cache-clock on | off | status | compact
on, off: show or hide the row and its toast; kept in the mod's store and winning over the band option.status: the window, warm or cold, the last main request's figures, and the session's cache reads, writes, % reused and estimated dollars saved (reads × (write price − read price)), main loop and subagents apart.compact: as the button; the result comes as a toast.| Option | Values | Default |
|---|---|---|
band | on, off | on |
cacheMinutes | 5, 60 | 60 |
Why 60 minutes: this account's prompt cache lives for 1 hour (the 1-hour TTL), so a reply within the hour reads the conversation from cache. When usage runs on overage, the TTL drops to 5 minutes; set cacheMinutes to 5 by hand then. In the 5-minute window the row turns amber at 2 minutes left and the toast comes at 1 minute (15 and 5 would fire at once on every request).
From the local marketplace folder (~/.claude/mods):
claude plugin install cache-clock@zehui-mods --scope user
turn.step); nothing is fetched and no model is called.zm-40-cache, it takes its slot in the zehui-mods stack order (SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70, NEXT 80); other bands follow. It yields to a survey. Terminal only. English only.hooks/register.tsx 251 lines1// The cache-clock mod: the ● CACHE row (row.tsx draws it), the countdown of
2// the prompt cache's window, the one toast before it lapses, the Compact
3// action and /cache-clock. The figures come from each model request's usage
4// (turn.step): the main loop's drive the row; every request, subagents' too,
5// adds to the session totals that /cache-clock status prints.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
8
9import type { CacheStep, CacheTotals } from '../types'
10import {
11 COMMAND,
12 STORE_BAND,
13 TICK_MS,
14 addStep,
15 cachedOf,
16 dollarText,
17 emptyBucket,
18 fmtTokens,
19 fmtUsd,
20 hhmm,
21 isBandOn,
22 leftMs,
23 minutesText,
24 reusedPct,
25 rewriteUsd,
26 warnAtMs,
27 warnText,
28 windowMinutes,
29} from './kit'
30import { drawRow } from './row'
31
32const lastCacheAt = atom({ plugin: 'cache-clock', key: 'lastCacheAt' } as const, 0)
33const last = atom({ plugin: 'cache-clock', key: 'last' } as const, null)
34const totals = atom({ plugin: 'cache-clock', key: 'totals' } as const, { main: emptyBucket(), agents: emptyBucket() })
35const now = atom({ plugin: 'cache-clock', key: 'now' } as const, 0)
36const warnedFor = atom({ plugin: 'cache-clock', key: 'warnedFor' } as const, 0)
37const band = atom({ plugin: 'cache-clock', key: 'band' } as const, '')
38
39// The countdown's tick, running only while the cache is warm. A reload drops
40// it with the environment; session.start (raised again for a fresh load)
41// starts it again while the cache is still warm.
42let ticker: Timer | null = null
43
44const stopTick = () => {
45 ticker?.cancel()
46 ticker = null
47}
48
49function startTick($: EngineInterface, options: PluginOptions): void {
50 if (ticker) return
51 ticker = $.clock.every(TICK_MS, () => {
52 void tick($, options).catch(() => {})
53 })
54}
55
56/** Moves `now` so the row redraws; one toast at the warning mark; stops once cold. */
57async function tick($: EngineInterface, options: PluginOptions): Promise<void> {
58 const at = await $.clock.now()
59 await update($, now, () => at)
60 const since = await read($, lastCacheAt)
61 const minutes = windowMinutes(options.cacheMinutes)
62 const left = leftMs(since, at, minutes)
63 if (!since || left <= 0) {
64 stopTick()
65 return
66 }
67 if (left > warnAtMs(minutes) || (await read($, warnedFor)) === since) return
68 if (!isBandOn(await read($, band), options.band)) return
69 const step = await read($, last)
70 if (!step) return
71 await update($, warnedFor, () => since)
72 $.ui.toast(warnText(step, minutes))
73}
74
75/** The conversation was compacted or cleared: the cache it held is not the next request's prefix. */
76async function resetCache($: EngineInterface): Promise<void> {
77 stopTick()
78 await update($, last, () => null)
79 await update($, lastCacheAt, () => 0)
80}
81
82/**
83 * Compacts the conversation, from a timer (never inside the press or the
84 * command's hook), and toasts the result naming the command.
85 */
86function compactNow($: EngineInterface): void {
87 $.clock.after(10, () => {
88 void (async () => {
89 try {
90 const r = await $.session.compact()
91 if (r.skip !== undefined) {
92 $.ui.toast(`Compact skipped: ${r.skip} (/${COMMAND} compact)`)
93 return
94 }
95 await resetCache($)
96 const sizes = r.tokensBefore && r.tokensAfter ? ` ${fmtTokens(r.tokensBefore)} → ${fmtTokens(r.tokensAfter)}` : ''
97 $.ui.toast(`Compacted${sizes}; the next send writes a fresh cache (/${COMMAND} compact)`)
98 } catch (err) {
99 const why = err instanceof Error ? err.message : String(err)
100 $.ui.toast(`Compact did not run: ${why} (/${COMMAND} compact, between turns)`)
101 }
102 })().catch(() => {})
103 })
104}
105
106const sum = (t: CacheTotals) => ({
107 reads: t.main.reads + t.agents.reads,
108 writes: t.main.writes + t.agents.writes,
109 input: t.main.input + t.agents.input,
110 savedUsd: t.main.savedUsd + t.agents.savedUsd,
111 unpricedReads: t.main.unpricedReads + t.agents.unpricedReads,
112})
113
114async function statusText($: EngineInterface, options: PluginOptions): Promise<string> {
115 const minutes = windowMinutes(options.cacheMinutes)
116 const on = isBandOn(await read($, band), options.band)
117 const since = await read($, lastCacheAt)
118 const step = await read($, last)
119 const t = await read($, totals)
120 const at = await $.clock.now()
121 const lines = [`CACHE line: ${on ? 'on' : 'off'} · cache window ${minutes} min (dollar figures are estimates)`]
122 if (!since || !step) {
123 lines.push('Cache: no cache request in the main loop yet')
124 } else {
125 const left = leftMs(since, at, minutes)
126 lines.push(
127 left > 0
128 ? `Cache: warm, ${minutesText(left)} left (last cache request ${hhmm(since)})`
129 : `Cache: cold since ${hhmm(since + minutes * 60_000)} (last cache request ${hhmm(since)})`,
130 )
131 lines.push(
132 `Last main request: ${step.model} · ${reusedPct(step.reads, step.writes, step.input)}% reused · ${fmtTokens(cachedOf(step))} cached · rewrite ${dollarText(rewriteUsd(step, minutes))}`,
133 )
134 }
135 const s = sum(t)
136 const unpriced = s.unpricedReads > 0 ? ` (plus ${fmtTokens(s.unpricedReads)} reads on unpriced models)` : ''
137 lines.push(
138 `Session: reads ${fmtTokens(s.reads)} · writes ${fmtTokens(s.writes)} · ${reusedPct(s.reads, s.writes, s.input)}% reused · saved ≈ ${fmtUsd(s.savedUsd)}${unpriced}`,
139 )
140 lines.push(
141 ` main loop: reads ${fmtTokens(t.main.reads)} · writes ${fmtTokens(t.main.writes)} · ${t.main.steps} requests; subagents: reads ${fmtTokens(t.agents.reads)} · writes ${fmtTokens(t.agents.writes)} · ${t.agents.steps} requests`,
142 )
143 return lines.join('\n')
144}
145
146const USAGE = `Usage: /${COMMAND} on | off | status | compact`
147
148export const register: Register = (on, options) => {
149 on('session.start', async ($, e, next) => {
150 const started = await next(e)
151 await $.command.register({
152 name: 'cache-clock',
153 description: 'CACHE row: prompt-cache status (reads, writes, % reused, dollars saved), compact now, or turn the row on or off',
154 argumentHint: 'on | off | status | compact',
155 })
156 try {
157 const stored = await $.store.get(STORE_BAND)
158 await update($, band, () => (stored === 'on' || stored === 'off' ? stored : ''))
159 } catch {
160 // No store: the option applies.
161 }
162 const at = await $.clock.now()
163 await update($, now, () => at)
164 const since = await read($, lastCacheAt)
165 stopTick()
166 if (since && leftMs(since, at, windowMinutes(options.cacheMinutes)) > 0) startTick($, options)
167 return started
168 })
169
170 // Each model request: its cache figures into the session totals; a main-loop
171 // request that read or wrote the cache restarts the countdown from its start.
172 on('turn.step', async function* ($, e, next) {
173 let startedAt = 0
174 try {
175 startedAt = await $.clock.now()
176 } catch {
177 // Best effort: the row only.
178 }
179 const result = yield* next(e)
180 try {
181 const u = result.usage
182 if (!u) return result
183 const step: CacheStep = {
184 model: u.model || e.model,
185 reads: u.cache_read_input_tokens || 0,
186 writes: u.cache_creation_input_tokens || 0,
187 input: u.input_tokens || 0,
188 }
189 const minutes = windowMinutes(options.cacheMinutes)
190 const isMain = e.agentId === undefined
191 await update($, totals, t =>
192 isMain ? { ...t, main: addStep(t.main, step, minutes) } : { ...t, agents: addStep(t.agents, step, minutes) },
193 )
194 if (!isMain || cachedOf(step) === 0) return result
195 await update($, last, () => step)
196 await update($, lastCacheAt, () => startedAt)
197 await update($, now, () => startedAt)
198 // A fresh interval on each cache step: a refused period ends the old
199 // one, and `ticker` would still hold it.
200 stopTick()
201 startTick($, options)
202 } catch {
203 // The books are best effort: the step goes on whatever happens here.
204 }
205 return result
206 })
207
208 // A compaction of the main conversation (the person's /compact or the
209 // engine's): the cached prefix is gone. Never changes the compaction.
210 on('session.compact', async ($, e, next) => {
211 const result = await next(e)
212 if (e.agentId === undefined && e.trigger !== 'precompute' && result && result.skip === undefined) {
213 await resetCache($).catch(() => {})
214 }
215 return result
216 }).catch(($, e, next) => (next.called ? undefined : next(e)))
217
218 // A /clear or an in-process resume puts another conversation in this
219 // process (no session.start fires for it): nothing of the old prefix is read
220 // again, and the session totals start over. A compaction keeps the totals.
221 on('session.end', async ($, e, next) => {
222 const result = await next(e)
223 if (e.reason === 'clear' || e.reason === 'resume') {
224 await resetCache($).catch(() => {})
225 await update($, totals, () => ({ main: emptyBucket(), agents: emptyBucket() })).catch(() => {})
226 }
227 return result
228 })
229
230 // The command and its matcher are spelled literally, so the host reads it as answering its own command.
231 on('command.run', { command: 'cache-clock' }, async ($, e) => {
232 const [verb = 'status'] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
233 switch (verb) {
234 case 'on':
235 case 'off':
236 await $.store.set(STORE_BAND, verb)
237 await update($, band, () => verb)
238 return { text: verb === 'on' ? 'CACHE line on.' : `CACHE line off (no row, no toast; /${COMMAND} status and compact still work).` }
239 case 'status':
240 return { text: await statusText($, options) }
241 case 'compact':
242 compactNow($)
243 return { text: 'Compacting the conversation; the result comes as a toast.' }
244 default:
245 return { text: USAGE }
246 }
247 })
248
249 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => drawRow($, e, () => next(e), options, () => compactNow($)))
250}
251hooks/kit.ts 160 lines1// Shared by register.tsx and row.tsx: the look, the countdown rules, the
2// figures of one request and the small formatters. The state atoms are
3// declared again in each file that reads or writes them: the engine reads a
4// state source only from a const of the calling file.
5import type { CacheBucket, CacheStep } from '../types'
6import { cachePriceOf } from './prices'
7import type { RenderElement, RenderNode } from 'claude-code'
8
9export const CHIP = ' ● CACHE ' // padded to the widest zehui-mods chip (HANDOFF) so the colour bars line up
10export const CHIP_FG = '#0b0b0f'
11export const CHIP_BG = '#22d3ee'
12export const GREEN = '#22c55e'
13export const AMBER = '#f59e0b'
14export const RED = '#ef4444'
15export const BAR_CELLS = 6
16/** The countdown's tick while the cache is warm. */
17export const TICK_MS = 30_000
18export const COMMAND = 'cache-clock'
19export const STORE_BAND = 'cache-clock.band'
20
21const MINUTE = 60_000
22
23/** The `cacheMinutes` option: 5 or 60 (anything but 5 is 60, the default). */
24export const windowMinutes = (option: unknown): number => (String(option) === '5' ? 5 : 60)
25
26/**
27 * The row turns amber with this much left: 15 minutes of the 1-hour window.
28 * The 5-minute window would be amber from its start at 15, so it turns at 2.
29 */
30export const amberAtMs = (minutes: number): number => (minutes >= 60 ? 15 * MINUTE : 2 * MINUTE)
31
32/**
33 * The one toast comes with this much left: 5 minutes of the 1-hour window.
34 * The 5-minute window would toast on every request at 5, so it toasts at 1.
35 */
36export const warnAtMs = (minutes: number): number => (minutes >= 60 ? 5 * MINUTE : 1 * MINUTE)
37
38/** Time left before the cache entry lapses; 0 or less: cold. */
39export const leftMs = (lastCacheAt: number, at: number, minutes: number): number => lastCacheAt + minutes * MINUTE - at
40
41/** Green above the amber mark, amber at or under it, red when cold. */
42export const leftColor = (left: number, minutes: number): string => (left <= 0 ? RED : left <= amberAtMs(minutes) ? AMBER : GREEN)
43
44/** `52m`: whole minutes left, rounded up, so 14m30s reads `15m`. */
45export const minutesText = (left: number): string => `${Math.max(0, Math.ceil(left / MINUTE))}m`
46
47/** Filled cells of the 6-cell bar: the share of the window left, at least one while warm. */
48export const barCells = (left: number, minutes: number, cells = BAR_CELLS): number =>
49 left <= 0 ? 0 : Math.min(cells, Math.max(1, Math.round((left / (minutes * MINUTE)) * cells)))
50
51/** The tokens the cache holds for the conversation: the last request's reads plus writes. */
52export const cachedOf = (s: CacheStep): number => s.reads + s.writes
53
54/** reads / (reads + writes + fresh input) of one request, a whole percentage. */
55export const reusedPct = (reads: number, writes: number, input: number): number => {
56 const all = reads + writes + input
57 return all > 0 ? Math.round((reads / all) * 100) : 0
58}
59
60/** What writing the cached tokens again would cost, USD; null for a model with no price. */
61export function rewriteUsd(s: CacheStep, minutes: number): number | null {
62 const price = cachePriceOf(s.model, s.reads + s.writes + s.input, minutes)
63 return price ? (cachedOf(s) * price.write) / 1e6 : null
64}
65
66export const emptyBucket = (): CacheBucket => ({ reads: 0, writes: 0, input: 0, steps: 0, savedUsd: 0, unpricedReads: 0 })
67
68/** One request added to a bucket; its reads saved (write price - read price) each, when priced. */
69export function addStep(b: CacheBucket, s: CacheStep, minutes: number): CacheBucket {
70 const price = cachePriceOf(s.model, s.reads + s.writes + s.input, minutes)
71 return {
72 reads: b.reads + s.reads,
73 writes: b.writes + s.writes,
74 input: b.input + s.input,
75 steps: b.steps + 1,
76 savedUsd: b.savedUsd + (price ? (s.reads * (price.write - price.read)) / 1e6 : 0),
77 unpricedReads: b.unpricedReads + (price ? 0 : s.reads),
78 }
79}
80
81/** The row's switch: /cache-clock on|off when set, else the `band` option (anything but `off` is on). */
82export const isBandOn = (stored: '' | 'on' | 'off', option: unknown): boolean => (stored ? stored === 'on' : option !== 'off')
83
84/** `180k`, `1.0M`: rounded to thousands first, so 999.6k reads `1.0M`, never `1000k`. */
85export const fmtTokens = (n: number): string => {
86 const k = Math.round(n / 1e3)
87 return k >= 1000 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${k}k` : `${Math.round(n)}`
88}
89
90export const fmtUsd = (usd: number): string => (usd > 0 && usd < 0.01 ? '<$0.01' : `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`)
91
92/** `≈ $1.12`, or `≈ ?` for a model with no price. */
93export const dollarText = (usd: number | null): string => (usd === null ? '≈ ?' : `≈ ${fmtUsd(usd)}`)
94
95/** `14:32`, local time. */
96export const hhmm = (ms: number): string => {
97 const d = new Date(ms)
98 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
99}
100
101/** The five-minutes-left toast. */
102export const warnText = (s: CacheStep, minutes: number): string =>
103 `${fmtTokens(cachedOf(s))} cached, rewrite ${dollarText(rewriteUsd(s, minutes))}. Send anything to keep it warm.`
104
105// Stack order of the zehui-mods band rows (plan 2026-10-20-boards-codemap-recap,
106// section 0): SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70,
107// NEXT 80. Copied unchanged from the canonical
108// ~/.claude/skills/building-claude-code-mods/references/stack-row.tsx; change it
109// there first, then in every band mod.
110
111/** The rows each zehui-mods band draws, by stack order. */
112export const STACK = { sys: 10, plan: 20, handoff: 30, cache: 40, agents: 50, goal: 60, here: 70, next: 80 } as const
113
114/** A row's key: `zm-<order>-<name>`. */
115export const stackKey = (name: keyof typeof STACK): string => `zm-${STACK[name]}-${name}`
116
117type Loose = { type?: unknown; props?: Record<string, unknown> | null; part?: unknown; children?: unknown }
118
119/** The order of a zehui-mods row (`zm-<order>-<name>`), or null for any other node. */
120export const stackOrderOf = (node: unknown): number | null => {
121 const key = node && typeof node === 'object' ? (node as Loose).props?.key : undefined
122 const m = typeof key === 'string' ? /^zm-(\d+)-/.exec(key) : null
123 return m ? Number(m[1]) : null
124}
125
126/**
127 * The column a band hook beneath built: a Box whose only prop is
128 * `flexDirection: 'column'` and whose direct children hold a zm row. A Box with
129 * other props (a border, a padding) is another mod's own drawing, kept whole.
130 */
131const isStack = (node: Loose): node is Loose & { children: unknown[] } =>
132 node.type === 'Box' &&
133 node.props?.flexDirection === 'column' &&
134 Object.keys(node.props).every(k => k === 'flexDirection') &&
135 Array.isArray(node.children) &&
136 node.children.some(c => stackOrderOf(c) !== null)
137
138/**
139 * `row` put in its slot among the zm rows of `below` (what `next(e)` drew):
140 * zm rows sorted by their number, then every other child in its original order.
141 * Nothing beneath (null, a failed next, the engine placeholder): the row alone.
142 */
143export function stackRow(row: RenderElement, below: unknown): RenderElement {
144 const tree = below && typeof below === 'object' && (below as Loose).type !== 'engine' ? (below as Loose) : null
145 if (!tree) return row
146 let items: unknown[] = [tree]
147 if (isStack(tree)) {
148 items = [...tree.children]
149 // The column's mark (whose drawing started there) moves to the one row
150 // that carries none, its own; the engine lets a mark move, never multiply.
151 const bare = items.filter(c => c && typeof c === 'object' && !(c as Loose).part)
152 if (tree.part && bare.length === 1) items = items.map(c => (c === bare[0] ? { ...(c as object), part: tree.part } : c))
153 }
154 const rows = items.filter(c => stackOrderOf(c) !== null)
155 const others = items.filter(c => stackOrderOf(c) === null)
156 // A stable sort: two rows of one number keep their order, this row after.
157 const sorted = [...rows, row].sort((a, b) => (stackOrderOf(a) ?? 0) - (stackOrderOf(b) ?? 0))
158 return { type: 'Box', props: { flexDirection: 'column' }, children: [...sorted, ...others] as RenderNode[] }
159}
160hooks/row.tsx 131 lines1// The ● CACHE row above the prompt: how long the prompt cache stays warm (a
2// 6-cell bar of the time left in its window, traffic-light colours), how much
3// of the last main request it served, how many tokens it holds and what
4// writing them again would cost, then a framed Compact button. Cold, the row
5// turns red and says what the next send rewrites. Its row sits in the
6// zehui-mods stack by its order (stackRow); other mods' bands follow.
7import { atom, read } from 'claude-code'
8import type { EngineInterface, PluginOptions, RenderElement, RenderInput } from 'claude-code'
9
10import {
11 BAR_CELLS,
12 CHIP,
13 CHIP_BG,
14 CHIP_FG,
15 RED,
16 barCells,
17 cachedOf,
18 dollarText,
19 fmtTokens,
20 isBandOn,
21 leftColor,
22 leftMs,
23 minutesText,
24 reusedPct,
25 rewriteUsd,
26 windowMinutes,
27 stackKey,
28 stackRow,
29} from './kit'
30
31const lastCacheAt = atom({ plugin: 'cache-clock', key: 'lastCacheAt' } as const, 0)
32const last = atom({ plugin: 'cache-clock', key: 'last' } as const, null)
33const now = atom({ plugin: 'cache-clock', key: 'now' } as const, 0)
34const band = atom({ plugin: 'cache-clock', key: 'band' } as const, '')
35
36/** One run of text in the row: its words and how they are drawn. */
37type Seg = { text: string; color?: string; bg?: string; isBold?: boolean; isDim?: boolean }
38
39const widthOf = (segs: readonly Seg[]): number => segs.reduce((w, s) => w + [...s.text].length, 0)
40
41/** The framed button's width beside the line: `[ Compact ]` and a one-column gap. */
42const BUTTON_W = 'Compact'.length + 4 + 1
43
44export async function drawRow(
45 $: EngineInterface,
46 e: RenderInput<'AbovePrompt'>,
47 pass: () => Promise<RenderElement>,
48 options: PluginOptions,
49 onCompact: () => void,
50) {
51 if (e.surface !== 'terminal' || e.props.hasSurvey) return pass()
52 if (!isBandOn(await read($, band), options.band)) return pass()
53 const step = await read($, last)
54 const since = await read($, lastCacheAt)
55 if (!step || !since) return pass()
56 const minutes = windowMinutes(options.cacheMinutes)
57 const at = Math.max(await read($, now), since)
58 const left = leftMs(since, at, minutes)
59 const color = leftColor(left, minutes)
60 const cached = fmtTokens(cachedOf(step))
61 const rewrite = dollarText(rewriteUsd(step, minutes))
62
63 // Other mods' bands beneath this row; stackRow puts this row in its slot
64 // among the zehui-mods rows and never nests the engine's own placeholder.
65 const below = await pass().catch(() => null)
66
67 const head: Seg[] = [{ text: CHIP, color: CHIP_FG, bg: CHIP_BG, isBold: true }, { text: ' ' }]
68 const room = (e.props.bodyColumns > 0 ? e.props.bodyColumns : 80) - BUTTON_W
69 let segs: Seg[]
70 if (left <= 0) {
71 // Short of columns: drop ' on next send', then '180k to '; the rewrite cost stays.
72 const tails = [` ${cached} to rewrite ${rewrite} on next send`, ` ${cached} to rewrite ${rewrite}`, ` rewrite ${rewrite}`]
73 const build = (tail: string): Seg[] => [
74 ...head,
75 { text: 'COLD', color: RED, isBold: true },
76 { text: ' ' },
77 { text: '▱'.repeat(BAR_CELLS), isDim: true },
78 { text: tail, color: RED },
79 ]
80 const fits = tails.find(t => widthOf(build(t)) <= room)
81 segs = build(fits ?? tails[tails.length - 1]!)
82 } else {
83 const n = barCells(left, minutes)
84 const bar: Seg[] = [
85 ...(n > 0 ? [{ text: '▰'.repeat(n), color }] : []),
86 ...(n < BAR_CELLS ? [{ text: '▱'.repeat(BAR_CELLS - n), isDim: true }] : []),
87 ]
88 // The figures, dropped from the first when the row is short of columns:
89 // % reused, then cached; the rewrite cost stays.
90 let parts: Seg[][] = [
91 [{ text: `${reusedPct(step.reads, step.writes, step.input)}% reused` }],
92 [{ text: `${cached} cached` }],
93 [{ text: `rewrite ${rewrite}` }],
94 ]
95 const build = (): Seg[] => [
96 ...head,
97 { text: `warm ${minutesText(left)}`, color, isBold: true },
98 { text: ' ' },
99 ...bar,
100 ...parts.flatMap((p, i) => [{ text: i === 0 ? ' ' : ' · ', isDim: i > 0 }, ...p]),
101 ]
102 while (parts.length > 1 && widthOf(build()) > room) parts = parts.slice(1)
103 segs = build()
104 }
105
106 const { Box, Text, Button } = $.ui.resolve(e)
107 const row = (
108 <Box key={stackKey('cache')} flexDirection="row" gap={1}>
109 <Box key="cache-line" flexGrow={1} flexShrink={1}>
110 <Text wrap="truncate-end">
111 {segs.map((g, i) => (
112 <Text
113 key={`seg-${i}`}
114 {...(g.color ? { color: g.color } : {})}
115 {...(g.bg ? { backgroundColor: g.bg } : {})}
116 {...(g.isBold ? { bold: true } : {})}
117 {...(g.isDim ? { dimColor: true } : {})}
118 >
119 {g.text}
120 </Text>
121 ))}
122 </Text>
123 </Box>
124 <Box flexShrink={0}>
125 <Button key="cache-compact" label="Compact" onPress={() => onCompact()} />
126 </Box>
127 </Box>
128 )
129 return stackRow(row, below)
130}
131hooks/prices.ts 65 lines1// Prompt-cache prices per model, for the CACHE row's rewrite estimate and the
2// saved-dollars figure of /cache-clock status. Estimates only.
3//
4// Another copy of this table lives in ~/.claude/mods/agents/hooks/kit.ts (the
5// agents mod's run costs). Plugins cannot import across mod folders, so the
6// table is duplicated: change both when prices change.
7
8// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
9// From the claude-api skill bundled with Claude Code 2.1.303 (model table
10// cached 2026-10-06; read 2026-10-19): cache reads 0.025x input on Fable 5.1 /
11// Mythos 5.1, 0.05x on Opus 5.5, 0.1x elsewhere; 5-minute cache writes 1.25x
12// input; 1-hour cache writes 2x input (WRITE_1H_X below), e.g. Opus 5.5 $8,
13// Sonnet 5.5 $4, Fable 5.1 $20.
14type Price = readonly [input: number, output: number, cacheRead: number, cacheWrite: number]
15
16const PRICES: Record<string, Price> = {
17 'fable-5.1': [10, 50, 0.25, 12.5],
18 'mythos-5.1': [10, 50, 0.25, 12.5],
19 'fable-5': [10, 50, 1, 12.5],
20 'mythos-5': [10, 50, 1, 12.5],
21 'opus-5.5': [4, 20, 0.2, 5],
22 'opus-5': [5, 25, 0.5, 6.25],
23 'opus-4.8': [5, 25, 0.5, 6.25],
24 'opus-4.7': [5, 25, 0.5, 6.25],
25 'opus-4.6': [5, 25, 0.5, 6.25],
26 'sonnet-5.5': [2, 10, 0.2, 2.5],
27 'sonnet-5': [2, 10, 0.2, 2.5],
28 'sonnet-4.6': [3, 15, 0.3, 3.75],
29 'haiku-5.5': [0.1, 0.5, 0.01, 0.125],
30 'haiku-4.5': [1, 5, 0.1, 1.25],
31}
32// Haiku 5.5 is priced by prompt length: a request whose prompt (input and cache
33// reads and writes) is over 100K tokens pays these instead.
34const HAIKU_55_LONG: Price = [0.5, 2.5, 0.05, 0.625]
35const HAIKU_55_LONG_PROMPT = 100_000
36
37/** A 1-hour cache write costs this many times the input price. */
38export const WRITE_1H_X = 2
39
40const MODEL_RE = /(fable|mythos|opus|sonnet|haiku)(?:-(\d+)(?:-(\d{1,2})(?!\d))?)?/i
41
42/** `opus` for `claude-opus-5-5`; '' for an unknown model. */
43export const familyOf = (model: string): string => MODEL_RE.exec(model)?.[1]?.toLowerCase() ?? ''
44
45/** `5.5` for `claude-opus-5-5`; '' when the id names no version. */
46export const versionOf = (model: string): string => {
47 const m = MODEL_RE.exec(model)
48 return m?.[2] ? `${m[2]}${m[3] ? '.' + m[3] : ''}` : ''
49}
50
51/** The cache prices for one request, USD per million tokens; null for an unknown model. */
52export type CachePrice = { read: number; write: number }
53
54/**
55 * The cache read and write prices of `model` for a prompt of `promptTokens`,
56 * the write at the rate of the cache's TTL (`minutes`: 5 or 60).
57 */
58export function cachePriceOf(model: string, promptTokens: number, minutes: number): CachePrice | null {
59 const key = `${familyOf(model)}-${versionOf(model)}`
60 const base = PRICES[key]
61 if (!base) return null
62 const [input, , read, write5m] = key === 'haiku-5.5' && promptTokens > HAIKU_55_LONG_PROMPT ? HAIKU_55_LONG : base
63 return { read, write: minutes >= 60 ? input * WRITE_1H_X : write5m }
64}
65types/index.d.ts 52 lines1// The cache-clock mod's state contract.
2
3/** The last main-loop request that read or wrote the prompt cache. */
4export type CacheStep = {
5 /** The model that answered, by the id the API reports. */
6 model: string
7 /** cache_read_input_tokens. */
8 reads: number
9 /** cache_creation_input_tokens. */
10 writes: number
11 /** input_tokens: the uncached part of the prompt. */
12 input: number
13}
14
15/** Prompt-cache tokens summed over a session's requests. */
16export type CacheBucket = {
17 reads: number
18 writes: number
19 input: number
20 /** Requests counted. */
21 steps: number
22 /** Estimated USD the reads saved: reads x (write price - read price). */
23 savedUsd: number
24 /** Cache reads on models the price table does not know (not in savedUsd). */
25 unpricedReads: number
26}
27
28export type CacheTotals = {
29 /** The main loop's requests. */
30 main: CacheBucket
31 /** Every subagent's requests (never shown on the row). */
32 agents: CacheBucket
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'cache-clock': {
38 /** When the last main-loop request that touched the cache started; 0: none yet. */
39 lastCacheAt: number
40 /** That request's cache figures; null until one is seen (and after a compaction or /clear). */
41 last: CacheStep | null
42 totals: CacheTotals
43 /** The tick's last time, so the row redraws as the countdown moves. */
44 now: number
45 /** The lastCacheAt the five-minutes-left toast was shown for. */
46 warnedFor: number
47 /** /cache-clock on|off as kept in the store; '' while the plugin option applies. */
48 band: '' | 'on' | 'off'
49 }
50 }
51}
52