SLOPSHOPPER

cache-clock

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…

newbandcommandtoasttimer
v0.1.1MITupdated 2026-10-20ZH-L1N/agent-window-mods/cache-clock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-clock
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cache-clock ⎿ cache-clock: CACHE line: on · cache window 60 min (dollar figures are estimates) ⎿ cache-clock: Cache: no cache request in the main loop yet ⎿ cache-clock: Session: reads 0 · writes 0 · 0% reused · saved ≈ $0.00 ⎿ cache-clock: main loop: reads 0 · writes 0 · 0 requests; subagents: reads 0 · writes 0 · 0 requests ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cache-clock

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 ]
  • Countdown: every main-loop request that reads or writes the prompt cache restarts it from that request's start. The 6-cell bar is the time left out of the cache window. Green above 15 minutes, amber at 15 or under, red COLD at 0. The row ticks every 30 seconds while warm and stops once cold.
  • % reused: cache reads ÷ (reads + writes + fresh input) of the last main request that touched the cache.
  • cached: that request's cache reads plus writes: the tokens the next send reads cheaply while warm, or writes again once cold.
  • rewrite: cached × the cache-write price of the model that answered, at the rate of the window's TTL (1-hour write, 2× input, for 60; 5-minute write, 1.25× input, for 5). A model the price table does not know shows ≈ ?.
  • Toast once, at 5 minutes left: "180k cached, rewrite ≈ $1.44. Send anything to keep it warm."
  • Compact compacts the conversation ($.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.
  • Subagents' requests never move the row; they count only into the session totals that /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:

ButtonCommand
Compact/cache-clock compact

Command

/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.

Options

OptionValuesDefault
bandon, offon
cacheMinutes5, 6060

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).

Install

From the local marketplace folder (~/.claude/mods):

claude plugin install cache-clock@zehui-mods --scope user

Notes

  • The figures come from each request's usage as the API reported it (turn.step); nothing is fetched and no model is called.
  • The row composes with the other bands: keyed 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.
Source 5 files
hooks/register.tsx 251 lines
1// 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}
251
hooks/kit.ts 160 lines
1// 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}
160
hooks/row.tsx 131 lines
1// 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}
131
hooks/prices.ts 65 lines
1// 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}
65
types/index.d.ts 52 lines
1// 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