SLOPSHOPPER

cache-timer

Prompt cache hit rate for the last request, and whether the cache is still warm with a countdown, above the prompt

newbandcommandtimer
v0.2.0no licenseupdated 2026-10-10katipally/modz/mods/ui/cache-timer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-timer
› 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-ttl ⎿ cache-timer: Cache ttl is 1h. Use /cache-ttl 5m or /cache-ttl 1h. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cache-timer

Prompt cache hit rate for the main agent's last request, and how long until the cache goes cold.

cache 98% · 54m

Install

/plugin install cache-timer --marketplace katipally/modz
  • The time is green while the cache is warm and counts down from the last request; it turns red (2m ago) once the cache has gone cold.
  • The percentage turns yellow under 90% and red under 70%.
  • Claude Code does not report the cache lifetime: set it with /cache-ttl 5m or /cache-ttl 1h (default 1h, remembered).
  • Subagent requests are ignored.
Source 2 files
hooks/register.tsx 126 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, BoxProps, ElementConstructor, EngineInterface, RenderElement, RenderNode, Timer } from 'claude-code'
3
4// Claude Code's prompt cache lives 5 minutes or 1 hour depending on the account; the engine does not say which.
5const TTL_CHOICES: Record<string, number> = { '5m': 5, '1h': 60 }
6const TINT = 'suggestion'
7const TICK_MS = 5000
8
9const last = atom({ plugin: 'cache-timer', key: 'last' } as const, null)
10const now = atom({ plugin: 'cache-timer', key: 'now' } as const, 0)
11const twinkle = atom({ plugin: 'cache-timer', key: 'twinkle' } as const, 0)
12
13// A request that reads the cache twinkles for a moment, counting frames down to nothing.
14const SPARKLE = ['✦', '✧', '⋆', '✧']
15const TWINKLE_MS = 180
16const TWINKLE_FRAMES = 8
17let twinkler: Timer | undefined
18const sparkle = async ($: EngineInterface) => {
19  twinkler?.cancel()
20  await update($, twinkle, () => TWINKLE_FRAMES)
21  twinkler = $.clock.every(TWINKLE_MS, async () => {
22    await update($, twinkle, n => Math.max(0, n - 1))
23    if ((await read($, twinkle)) === 0) twinkler?.cancel()
24  })
25}
26const ttlMin = atom({ plugin: 'cache-timer', key: 'ttlMin' } as const, 60)
27
28
29// "1h 5m" past an hour, "58m" past two minutes, then "1m 40s" and "40s" for the last stretch.
30const span = (ms: number) => {
31  const s = Math.max(0, Math.round(ms / 1000))
32  const [h, m] = [Math.floor(s / 3600), Math.floor((s % 3600) / 60)]
33  return h ? `${h}h ${m}m` : m >= 2 ? `${m}m` : m ? `${m}m ${s % 60}s` : `${s}s`
34}
35
36// Every modz mod places its tile in one shared band: a lead column (the bot), then a column of tiles, then an optional
37// right column. A column with no tiles is not drawn, and columns wrap under each other when the width runs out.
38const BAND = 'modz-band'
39const SLOTS: Record<string, 'lead' | 'left' | 'right'> = {
40  buddy: 'lead',
41  'usage-limits': 'left',
42  'cache-timer': 'left',
43  'model-info': 'left',
44  orchestrator: 'left',
45}
46const ORDER = Object.keys(SLOTS)
47const keyOf = (n: RenderNode) => (typeof n !== 'string' && n.type === 'Box' ? String(n.props?.key ?? '') : '')
48const rank = (n: RenderNode) => {
49  const i = ORDER.indexOf(keyOf(n))
50  return i < 0 ? ORDER.length : i
51}
52const pack = (Box: ElementConstructor<BoxProps>, below: RenderElement, tile: RenderElement) => {
53  const isEmpty = below.type === 'Box' && !below.children?.length
54  const tiles = keyOf(below) === BAND
55    ? (below.type === 'Box' ? below.children ?? [] : []).flatMap(col => (typeof col !== 'string' && col.type === 'Box' ? col.children ?? [] : [col]))
56    : isEmpty ? [] : [below]
57  const all = [...tiles, tile].sort((a, b) => rank(a) - rank(b))
58  const [lead, left, right] = (['lead', 'left', 'right'] as const).map(slot => all.filter(n => (SLOTS[keyOf(n)] ?? 'left') === slot))
59  return (
60    <Box key={BAND} flexDirection="row" flexWrap="wrap" columnGap={3}>
61      {lead!.length > 0 && <Box key="modz-lead" flexDirection="column" justifyContent="center">{lead}</Box>}
62      {left!.length > 0 && <Box key="modz-left" flexDirection="column" flexGrow={right!.length > 0 ? 1 : 0}>{left}</Box>}
63      {right!.length > 0 && <Box key="modz-right" flexDirection="column" alignItems="flex-end">{right}</Box>}
64    </Box>
65  )
66}
67
68
69export const register: Register = on => {
70  on('session.start', async ($, e, next) => {
71    await $.command.register({ name: 'cache-ttl', description: 'Set how long the prompt cache stays warm: 5m or 1h', argumentHint: '5m | 1h' })
72    const ttl = (await $.store.get('ttlMin')) as number | undefined
73    if (ttl) await update($, ttlMin, () => ttl)
74    await update($, now, () => Date.now())
75    $.clock.every(TICK_MS, () => void update($, now, () => Date.now()))
76    return next(e)
77  })
78
79  on('command.run', { command: 'cache-ttl' }, async ($, e) => {
80    const ttl = TTL_CHOICES[e.args.trim()]
81    const current = (await read($, ttlMin)) === 60 ? '1h' : '5m'
82    if (!ttl) return { text: `Cache ttl is ${current}. Use /cache-ttl 5m or /cache-ttl 1h.` }
83    await update($, ttlMin, () => ttl)
84    await $.store.set('ttlMin', ttl)
85    return { text: `Cache ttl set to ${e.args.trim()}.` }
86  })
87
88  on('turn.step', async function* ($, e, next) {
89    const result = yield* next(e)
90    const u = result.usage
91    if (e.agentId === undefined && u) {
92      const at = Date.now()
93      await update($, last, () => ({ at, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, fresh: u.input_tokens }))
94      await update($, now, () => at)
95      if (u.cache_read_input_tokens > 0) await sparkle($)
96    }
97    return result
98  })
99
100  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
101    const below = await next(e)
102    if (e.props.hasSurvey) return below
103    const [r, , ttl, tw] = await Promise.all([read($, last), read($, now), read($, ttlMin), read($, twinkle)])
104    const t = Date.now()
105    if (!r) return below
106    const { Box, Text } = $.ui.resolve(e)
107
108    const total = r.read + r.write + r.fresh
109    const hit = total ? Math.floor((r.read * 100) / total) : 0
110    const left = r.at + ttl * 60_000 - t
111    const isWarm = left > 0
112
113    return pack(Box, below, (
114      <Box key="cache-timer" flexDirection="row">
115        <Text>
116          <Text color={TINT}>{'cache'.padEnd(5)} </Text>
117          <Text bold color={hit >= 90 ? TINT : hit >= 70 ? 'warning' : 'error'}>{hit}%</Text>
118          {tw > 0 && <Text color={TINT}> {SPARKLE[tw % SPARKLE.length]}</Text>}
119          <Text dimColor> · </Text>
120          <Text color={isWarm ? 'success' : 'error'}>{isWarm ? span(left) : `${span(-left)} ago`}</Text>
121        </Text>
122      </Box>
123    ))
124  })
125}
126
types/index.d.ts 8 lines
1export type Request = { at: number; read: number; write: number; fresh: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'cache-timer': { last: Request | null; now: number; ttlMin: number; twinkle: number }
6  }
7}
8