SLOPSHOPPER

cache-timer

Colored countdown bar above the prompt until the prompt cache expires

newbandstatusprocesstimer
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 ████████████ 60m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
cache ████████████ 60m
README

claude-code-mods

Small mods for Claude Code.

cache-timer

Claude Code caches your conversation prefix between requests. While the cache is warm, the next request reads it cheaply; once it expires, the whole context is written again at the higher cache-write rate. cache-timer shows how long you have left.

cache ███████░░░░░ 35m

The bar is green with half or more of the time left, yellow from 20%, red below that, and reads cold once the cache has expired. Under two minutes the label counts seconds.

Which TTL it counts down

Each API response reports how many tokens it wrote to the cache at each TTL (ephemeral_1h_input_tokens, ephemeral_5m_input_tokens). cache-timer reads the newest main-thread response that wrote to the cache and uses that TTL, so it follows what Claude Code actually did: 1 hour on a subscription within its usage limits, 5 minutes on an API key, Bedrock, Vertex or Foundry, after a promptCacheTtl setting, or once a subscription goes into overage. Subagent requests keep their own cache and are ignored.

To force a TTL instead, set the plugin's ttl option to 5m or 1h (/config), and for the status line script set CACHE_TIMER_TTL=5m or 1h. Until a TTL is known it assumes CLAUDE_CODE_PROMPT_CACHE_TTL if set, otherwise 1 hour.

Status line

plugins/cache-timer/statusline/cache-bar.sh prints the bar for a Claude Code status line, in ANSI color. It needs jq and runs on macOS and Linux.

Use it on its own in ~/.claude/settings.json:

{
  "statusLine": {
    "type": "command",
    "command": "bash /path/to/claude-code-mods/plugins/cache-timer/statusline/cache-bar.sh",
    "refreshInterval": 1
  }
}

Or add it to a status line script you already have, which reads the same JSON on stdin:

input=$(cat)
cache=$(printf '%s' "$input" | bash /path/to/cache-bar.sh)

refreshInterval makes the countdown move while you are idle; without it the status line only redraws on activity.

Bar above the prompt (plugin)

The plugin draws the same bar in the row above the prompt while Claude is idle. It uses Claude Code's plugin hooks module API (hooks/register.tsx), so it needs a Claude Code version that has it.

claude plugin marketplace add ya8282/claude-code-mods
claude plugin install cache-timer@claude-code-mods

Or load a local clone for one session with claude --plugin-dir plugins/cache-timer.

Development

claude plugin validate plugins/cache-timer
claude plugin test plugins/cache-timer

License

MIT

Source 2 files
hooks/register.tsx 109 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { CacheTtl } from '../types'
5
6const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
7const WIDTH = 12
8// Enough for many responses, and under the 4 MiB a host process may hand back.
9const TAIL_BYTES = 2_000_000
10
11// Kept in $.state so a hot reload does not forget the last response.
12const lastAt = atom({ plugin: 'cache-timer', key: 'lastAt' } as const, null as number | null)
13const now = atom({ plugin: 'cache-timer', key: 'now' } as const, 0)
14const tier = atom({ plugin: 'cache-timer', key: 'tier' } as const, null as CacheTtl | null)
15
16/**
17 * The TTL the API applied, from the newest main-thread response in a transcript (JSONL) that
18 * wrote to the cache. A response served wholly from cache writes nothing, so look further back.
19 * Takes the transcript's tail, so a cut-off first line is expected and skipped.
20 */
21export const tierFromTranscript = (jsonl: string): CacheTtl | null => {
22  for (const line of jsonl.split('\n').reverse()) {
23    let entry
24    try {
25      entry = JSON.parse(line)
26    } catch {
27      continue
28    }
29    if (entry?.type !== 'assistant' || entry.isSidechain) continue
30    const written = entry.message?.usage?.cache_creation
31    // The 5m part of a mixed write lapses first, so it decides.
32    if (written?.ephemeral_5m_input_tokens > 0) return '5m'
33    if (written?.ephemeral_1h_input_tokens > 0) return '1h'
34  }
35  return null
36}
37
38export const format = (leftMs: number) => {
39  if (leftMs <= 0) return 'cold'
40  const s = Math.ceil(leftMs / 1000)
41  return s >= 120 ? `${Math.ceil(s / 60)}m` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
42}
43
44export const gauge = (leftMs: number, ttlMs: number) => {
45  const frac = Math.max(0, Math.min(1, leftMs / ttlMs))
46  const filled = leftMs > 0 ? Math.max(1, Math.round(frac * WIDTH)) : 0
47  const color = frac >= 0.5 ? 'green' : frac >= 0.2 ? 'yellow' : 'red'
48  return { filled: '█'.repeat(filled), empty: '░'.repeat(WIDTH - filled), color, label: format(leftMs) }
49}
50
51export const register: Register = (on, options) => {
52  const forced = TTL_MS[options.ttl as CacheTtl] ? (options.ttl as CacheTtl) : null
53
54  on('session.start', async ($, e, next) => {
55    // Earlier versions pinned a status line entry; it stays until cleared.
56    $.ui.status(undefined)
57    $.clock.every(1000, async () => {
58      if ((await read($, lastAt)) === null) return
59      const t = await $.clock.now()
60      await update($, now, () => t)
61    })
62    return next(e)
63  })
64
65  // The cache is refreshed by each request, so the turn's end is the last write. Subagents keep
66  // their own prefix; no usage means no request reached the API (interrupt, error).
67  on('turn.complete', async ($, e, next) => {
68    if (!e.agentId && e.usage) {
69      const t = await $.clock.now()
70      await update($, lastAt, () => t)
71      await update($, now, () => t)
72    }
73    return next(e)
74  })
75
76  // The turn's usage omits the per-TTL split, so read it from the transcript once per turn.
77  // Only its tail: $.fs.read refuses files over 4 MiB, which long sessions pass.
78  on('classic.Stop', async ($, e, next) => {
79    if (!forced) {
80      try {
81        const { stdout } = await $.process.run(['tail', '-c', String(TAIL_BYTES), e.transcript_path])
82        const found = tierFromTranscript(stdout)
83        if (found) await update($, tier, () => found)
84      } catch {
85        // ponytail: no tail (Windows) or no transcript keeps the last known tier
86      }
87    }
88    return next(e)
89  })
90
91  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92    const at = await read($, lastAt)
93    // Mid-turn the cache is being refreshed, so the bar only matters while idle.
94    if (at === null || e.props.isWorking || e.props.hasSurvey) return next(e)
95
96    const ttl = TTL_MS[forced ?? (await read($, tier)) ?? '1h']
97    const g = gauge(at + ttl - (await read($, now)), ttl)
98    const { Box, Text } = $.ui.resolve(e)
99    return (
100      <Box>
101        <Text dimColor>cache </Text>
102        <Text color={g.color}>{g.filled}</Text>
103        <Text dimColor>{g.empty} </Text>
104        <Text color={g.filled ? g.color : 'red'} bold>{g.label}</Text>
105      </Box>
106    )
107  })
108}
109
types/index.d.ts 8 lines
1export type CacheTtl = '5m' | '1h'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'cache-timer': { lastAt: number | null; now: number; tier: CacheTtl | null }
6  }
7}
8