Shows whether Claude's prompt cache is still warm, with the cold-rewrite cost and a Compact button.

<img width="1096" height="808" alt="Screenshot 2026-10-19 at 8 25 29 PM" src="https://github.com/user-attachments/assets/b22cc708-5a70-4333-b3ba-79517a3b7603" />
Mods for Claude Code that add a band above the prompt, a side pane or a slash command. Each is its own plugin, so you can install only the ones you want.
| Plugin | Band | What it does | Command | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
band-rule | line | Draws a dim line above the bands to separate them from Claude's output. List it before the other mods (see below). | none | |||||||||
cache-clock | CACHE | Shows whether the prompt cache is still warm, the time left, % reused and the cold-rewrite cost, with a Compact button. Toasts shortly before the cache goes cold. | `/cache-clock on \ | off \ | status` | |||||||
usage-gauge | USAGE | Plan usage windows (5h, week, others) with reset times, the session cost and a pace warning. Toasts at 75% and 90%. | /usage-gauge (opens a pane), `on \ | off \ | warn <a> <b> \ | status` | ||||||
usage-limits | footer | Session (5h) and week (7d) rate-limit bars in the prompt footer. | none | |||||||||
auto-handoff | CONTEXT | Watches how full the chat is and saves a HANDOFF.md when it reaches a threshold, then offers to clear and resume. | `/auto-handoff on \ | off \ | now \ | resume \ | threshold <n> \ | mode auto\ | ask \ | hide \ | show \ | status` |
agent-watch | AGENTS | Shows the main chat and helper agents with their models and tasks, plus the skills that ran this turn. Opens an Agents pane. | /agent-watch (opens the Agents pane), `on \ | off \ | clear \ | help` |
Bands stack in the order their plugins appear in enabledPlugins in ~/.claude/settings.json, first on top. To reorder, move the lines and run /reload-plugins; claude plugin install adds new entries at the end. band-rule draws its line above the whole stack only when it is listed first. The author's order, top to bottom: band-rule, agent-watch, auto-handoff, cache-clock, usage-gauge, usage-limits. The last one draws in the footer, so its place doesn't matter. The Band column is the title each mod shows in its band.
Each band is one row. In a narrow terminal the end of the row is cut off with … instead of wrapping.
usage-gauge and usage-limits both show plan usage, so enable only one.
In auto mode, auto-handoff sends the handoff prompt as soon as the threshold is crossed, even while Claude is mid-task. The default ask mode only offers the button.
cache-clock has a cacheMinutes option (5 or 60, default 60). Other settings are set through each mod's commands and persist between sessions.
Install a mod from the marketplace:
/plugin install cache-clock --marketplace Abdallah-Abdelazim/claude-code-mods
Run it once per mod, changing the plugin name. From a local clone, use --marketplace ~/path/to/claude-code-mods instead.
To try one without installing: claude --plugin-dir plugins/<name>.
These mods use Claude Code's function-hook plugin API, which is in early access; they were written against Claude Code 2.1.303. Where an organization manages Claude Code, plugins you install don't run the session.compact, skill.prompt and prompt.compose hooks.
Each folder under plugins/ is a standalone mod: .claude-plugin/plugin.json, hooks/, tsconfig.json, a *.test.ts and, for a mod that keeps state, types/index.d.ts. See CLAUDE.md for the conventions and the release steps.
claude plugin validate plugins/<name>
claude plugin test plugins/<name>
The engine writes .claude-plugin/types/ inside a mod when it loads it; the folder is git-ignored.
hooks/register.tsx 145 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { CacheStep } from '../types'
5import { cachedOf, fmtTokens, fmtUsd, promptOf, rewriteUsd, savedUsd } from './prices'
6
7const lastCacheAt = atom({ plugin: 'cache-clock', key: 'lastCacheAt' } as const, null)
8const lastStep = atom({ plugin: 'cache-clock', key: 'lastStep' } as const, null)
9const totals = atom({ plugin: 'cache-clock', key: 'totals' } as const, {
10 reads: 0, writes: 0, fresh: 0, savedUsd: 0, isUnpriced: false,
11})
12const warnedFor = atom({ plugin: 'cache-clock', key: 'warnedFor' } as const, null)
13const isOn = atom({ plugin: 'cache-clock', key: 'isOn' } as const, true)
14
15const TICK_MS = 30_000
16const KEY = 'cache-clock'
17// Both windows shrink with the cache lifetime, so a 5-minute cache is not "amber" or "about to expire" from the start.
18const amberMs = (ttlMs: number) => ttlMs / 4
19const warnMs = (ttlMs: number) => Math.min(5 * 60_000, ttlMs / 5)
20const BAR = 6
21const TRACK = '#52525b'
22
23// Ms until the cache goes cold; 0 when cold or no main request has cached yet.
24const remaining = async ($: Engine, ttlMs: number) => {
25 const at = await read($, lastCacheAt)
26 return at === null ? 0 : at + ttlMs - (await $.clock.now())
27}
28
29const compact = async ($: Engine) => {
30 const r = await $.session.compact()
31 $.ui.toast('skip' in r ? `Compact skipped: ${r.skip}` : `Compacted: ${r.messages.length} messages kept.`)
32}
33
34const tick = async ($: Engine, ttl: number) => {
35 const at = await read($, lastCacheAt)
36 const step = await read($, lastStep)
37 const ttlMs = ttl * 60_000
38 const left = await remaining($, ttlMs)
39 if (
40 at !== null && step !== null && (await read($, isOn)) &&
41 left > 0 && left <= warnMs(ttlMs) && (await read($, warnedFor)) !== at
42 ) {
43 await update($, warnedFor, () => at)
44 $.ui.toast(
45 `Cache: ${fmtTokens(cachedOf(step))} tokens, cold rewrite ${fmtUsd(rewriteUsd(step, ttl))}. Send anything to keep it warm.`,
46 )
47 }
48 $.ui.invalidate('ui.render')
49}
50
51export const register: Register = (on, options) => {
52 const ttl = options.cacheMinutes === '5' ? 5 : 60
53 const ttlMs = ttl * 60_000
54
55 on('session.start', async ($, e, next) => {
56 await $.command.register({
57 name: 'cache-clock',
58 description: 'Show or toggle the prompt-cache clock.',
59 argumentHint: 'on | off | status',
60 })
61 const saved = (await $.store.get(KEY)) as { isOn?: boolean } | null
62 await update($, isOn, () => saved?.isOn ?? true)
63 $.clock.every(TICK_MS, () => tick($, ttl))
64 return next(e)
65 })
66
67 on('turn.step', async function* ($, e, next) {
68 const startedAt = await $.clock.now()
69 const result = yield* next(e)
70 const u = result.usage
71 if (u) {
72 const step: CacheStep = {
73 model: u.model,
74 reads: u.cache_read_input_tokens,
75 writes: u.cache_creation_input_tokens,
76 fresh: u.input_tokens,
77 }
78 const saved = savedUsd(step)
79 await update($, totals, t => ({
80 reads: t.reads + step.reads,
81 writes: t.writes + step.writes,
82 fresh: t.fresh + step.fresh,
83 savedUsd: t.savedUsd + (saved ?? 0),
84 isUnpriced: t.isUnpriced || saved === undefined,
85 }))
86 if (e.agentId === undefined) {
87 await update($, lastStep, () => step)
88 if (cachedOf(step) > 0) await update($, lastCacheAt, () => startedAt)
89 }
90 }
91 return result
92 })
93
94 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
95 const below = await next(e)
96 if (!(await read($, isOn))) return below
97
98 const { Box, Button, Text } = $.ui.resolve(e)
99 const step = await read($, lastStep)
100 const left = await remaining($, ttlMs)
101 const isCold = left <= 0
102 const tone = isCold ? '#ef4444' : left <= amberMs(ttlMs) ? '#f59e0b' : '#22c55e'
103 const filled = Math.max(0, Math.min(BAR, Math.round((BAR * left) / ttlMs)))
104 const barOn = '▆'.repeat(filled)
105 const barOff = '▆'.repeat(BAR - filled)
106 const state = isCold ? 'COLD' : `warm ${Math.ceil(left / 60_000)}m`
107 const reuse =
108 step && promptOf(step) > 0 ? `${Math.round((100 * step.reads) / promptOf(step))}% reused` : 'no request yet'
109 const cached = step ? fmtTokens(cachedOf(step)) : '0'
110 const rewrite = step ? fmtUsd(rewriteUsd(step, ttl)) : '—'
111
112 return (
113 <Box flexDirection="column">
114 <Box>
115 <Box flexShrink={0}><Text bold color="#0b0b0f" backgroundColor="#7dcfff"> ● CACHE </Text></Box>
116 <Text wrap="truncate-end">
117 <Text color={tone}> {state} {barOn}<Text color={TRACK}>{barOff}</Text></Text>
118 <Text dimColor>{' '}{reuse} · {cached} cached · cold rewrite {rewrite} </Text>
119 </Text>
120 <Box flexShrink={0}><Button key="compact" plain label="[Compact]" onPress={() => compact($)} /></Box>
121 </Box>
122 {below}
123 </Box>
124 )
125 })
126
127 on('command.run', { command: 'cache-clock' }, async ($, e) => {
128 const arg = e.args.trim()
129 if (arg === 'on' || arg === 'off') {
130 await $.store.set(KEY, { isOn: arg === 'on' })
131 await update($, isOn, () => arg === 'on')
132 return { text: `Cache clock ${arg}.` }
133 }
134 if (arg !== 'status') return { text: 'Usage: /cache-clock on | off | status' }
135 const t = await read($, totals)
136 const seen = t.reads + t.writes + t.fresh
137 const pct = seen ? Math.round((100 * t.reads) / seen) : 0
138 return {
139 text:
140 `Reads ${fmtTokens(t.reads)} · writes ${fmtTokens(t.writes)} · ${pct}% reused\n` +
141 `Estimated saved ${fmtUsd(t.isUnpriced ? undefined : t.savedUsd)} (list prices, estimate)`,
142 }
143 })
144}
145hooks/prices.ts 50 lines1import type { CacheStep } from '../types'
2
3// List prices in $ per million tokens, fetched 2026-10-19 from platform.claude.com/docs/en/about-claude/pricing.
4// Cache writes: 5-minute 1.25x base input, 1-hour 2x; reads: per row.
5type Price = { input: number; write5m: number; write1h: number; read: number }
6
7const PRICES: Record<string, Price & { over100k?: Price }> = {
8 'claude-fable-5-1': { input: 10, write5m: 12.5, write1h: 20, read: 0.25 },
9 'claude-mythos-5-1': { input: 10, write5m: 12.5, write1h: 20, read: 0.25 },
10 'claude-fable-5': { input: 10, write5m: 12.5, write1h: 20, read: 1 },
11 'claude-opus-5-5': { input: 4, write5m: 5, write1h: 8, read: 0.2 },
12 'claude-opus-5': { input: 5, write5m: 6.25, write1h: 10, read: 0.5 },
13 'claude-opus-4-8': { input: 5, write5m: 6.25, write1h: 10, read: 0.5 },
14 'claude-opus-4-7': { input: 5, write5m: 6.25, write1h: 10, read: 0.5 },
15 'claude-opus-4-6': { input: 5, write5m: 6.25, write1h: 10, read: 0.5 },
16 'claude-sonnet-5-5': { input: 2, write5m: 2.5, write1h: 4, read: 0.1 },
17 'claude-sonnet-5': { input: 2, write5m: 2.5, write1h: 4, read: 0.2 },
18 'claude-sonnet-4-6': { input: 3, write5m: 3.75, write1h: 6, read: 0.3 },
19 'claude-haiku-5-5': {
20 input: 0.1, write5m: 0.125, write1h: 0.2, read: 0.01,
21 over100k: { input: 0.5, write5m: 0.625, write1h: 1, read: 0.05 },
22 },
23 'claude-haiku-4-5': { input: 1, write5m: 1.25, write1h: 2, read: 0.1 },
24}
25
26export const promptOf = (s: CacheStep) => s.reads + s.writes + s.fresh
27
28// Undefined for a model not in the table: the caller shows "≈ ?".
29function priceFor(s: CacheStep): Price | undefined {
30 const row = PRICES[s.model.replace(/-\d{8}$/, '')]
31 return row?.over100k && promptOf(s) > 100_000 ? row.over100k : row
32}
33
34export function savedUsd(s: CacheStep): number | undefined {
35 const p = priceFor(s)
36 return p && (s.reads * (p.input - p.read)) / 1e6
37}
38
39export function rewriteUsd(s: CacheStep, ttlMinutes: number): number | undefined {
40 const p = priceFor(s)
41 return p && ((s.reads + s.writes) * (ttlMinutes === 5 ? p.write5m : p.write1h)) / 1e6
42}
43
44export const cachedOf = (s: CacheStep) => s.reads + s.writes
45
46export const fmtTokens = (n: number) =>
47 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`
48
49export const fmtUsd = (usd: number | undefined) => (usd === undefined ? '≈ ?' : `≈ $${usd.toFixed(2)}`)
50types/index.d.ts 15 lines1export type CacheStep = { model: string; reads: number; writes: number; fresh: number }
2export type CacheTotals = { reads: number; writes: number; fresh: number; savedUsd: number; isUnpriced: boolean }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'cache-clock': {
7 lastCacheAt: number | null
8 lastStep: CacheStep | null
9 totals: CacheTotals
10 warnedFor: number | null
11 isOn: boolean
12 }
13 }
14}
15