SLOPSHOPPER

Cache TTL Timer

A prompt-cache countdown in the prompt footer, beside the model and effort: how long until your next message has to re-cache the whole conversation.

newbandspinnercommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-ttl-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-ttl-timer: TTL 1h (default) · no request yet · drawn on terminal ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Cache TTL Timer

A Claude Code mod that shows how long the prompt cache of your conversation stays warm, right in the prompt footer beside the model and effort:

+ 🎙 ⌄ Auto        ● 47m  Opus 5.5  High  ◔

Claude caches the conversation's prompt for a time to live (TTL) of 5 minutes or 1 hour after each request. While the cache is warm, your next message is read from it cheaply. Once it lapses, the next message writes the whole conversation to the cache again, which costs more and counts more against your usage. The timer tells you which of the two your next message will be, before you send it.

What it shows

| Footer | Meaning | | :- | :- | | ● 60m → ◕ → ◑ → ◔ | The cache is warm. The ring empties as the TTL runs down, and the time counts down in minutes. | | ◔ 9m in the warning color | Less than a fifth of the TTL is left. | | ○ 0:42 | The last minute, counted in seconds. | | ◌ Cold | The cache has lapsed. Your next message re-caches the conversation. |

Nothing shows before the first request of a session. The label uses the footer's own dim color until the last fifth of the TTL, so it stays quiet until it matters.

How it works

  • When the countdown starts: each time the main conversation sends a request to the model, which is when the cache is read or written. A turn with tool calls sends several requests, and each one restarts the countdown. Requests from subagents are left out, because they cache their own prompts.
  • Which TTL applies: read from the session's transcript, where the API reports how many tokens each request wrote to the cache at the 5-minute and 1-hour TTL. Until a write is seen, the timer assumes 1 hour, the TTL Claude Code uses on the main conversation of a subscription.
  • After a restart or a resume: the countdown picks up from the last request in the transcript, so a session you come back to shows whether its cache is still warm before you type.

The timer is an estimate from the client's side. The service can drop a cache entry early, so treat Cold as certain and the time left as an upper bound.

Commands

  • /cache-ttl: the TTL in use and where it came from, the time since the last request, the time left, and whether an app is drawing the timer in this session
  • /cache-ttl 5m or /cache-ttl 1h: use that TTL in every session, overriding what the transcript reports
  • /cache-ttl auto: go back to the TTL the transcript reports

Install

Requires Claude Code 2.1.287 or later, where mods are on by default.

  • From the Claude directory, once it's listed: add Cache TTL Timer on claude.ai, and Claude Code loads it as cache-ttl-timer@synced.
  • From this repository, in a Claude Code session: /plugin install cache-ttl-timer --marketplace WQGGSEY/cache-ttl-timer
  • From your shell: claude plugin marketplace add WQGGSEY/cache-ttl-timer, then claude plugin install cache-ttl-timer@cache-ttl-timer

To turn it off, disable the plugin in /plugin.

Where it draws

  • Terminal: in the prompt footer, as a one-glyph ring and the time.
  • Claude desktop app, Code tab: in the prompt footer the same way. The desktop footer draws text only, so the ring is a glyph there too. If the app never asks for the footer, the timer moves to the band above the prompt, at the right, where it draws an SVG ring. Tested with app 2.19675.0; app 2.16120.0 drew no mod interface at all. Where the app draws nothing, /cache-ttl says so.
  • VS Code chat panel, claude -p, and the Agent SDK: mods run but draw nothing. /cache-ttl still answers.
  • Remote sessions: the plugin has to be installed where Claude Code runs, such as the SSH host.

What it reads and runs

Everything stays on your machine. The mod makes no network requests, calls no model, and doesn't change prompts, tool calls, or permissions. It only observes requests to time them.

  • Hooks: session.start, classic.SessionStart, classic.Stop and turn.step to follow requests; command.run for /cache-ttl; ui.render for the footer (SessionMode) and the band above the prompt (AbovePrompt).
  • Files: the current session's transcript, found under ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects) by the session's id. It reads the transcript's last 4 MB by running tail -c 4000000 <transcript>, and looks only at request timestamps and cache token counts. On a project path longer than 200 characters it lists ~/.claude/projects to find the session's folder.
  • Environment: HOME and CLAUDE_CONFIG_DIR, to find that folder.
  • Storage: the TTL you choose with /cache-ttl 5m|1h, in the plugin's own key-value store under ~/.claude/plugins/store/; the last request time and the detected TTL, in the session's state.
  • Timer: once a second it checks whether the label or the ring changed, and redraws only then: a few times a minute, and once a second in the last minute.

Limitations

  • On Windows, where tail isn't available, the timer can't read the transcript. It still counts down from each request, on the 1-hour default; set the TTL with /cache-ttl 5m if your sessions use 5 minutes. A resumed session shows the timer only after its next request.
  • The transcript format isn't a public interface. If a release changes it, TTL detection falls back to the default.

Development

claude plugin validate --strict .
claude plugin test

The tests drive the hooks against Claude Code's test kit on both the terminal and desktop surfaces, with a mocked clock. Built and tested with Claude Code 2.1.284 to 2.1.287 and the Claude desktop app 2.19675.0.

License

MIT


한국어 요약

프롬프트 캐시가 만료되기까지 남은 시간을 프롬프트 하단 모델·effort 옆에 링과 시간으로 보여주는 Claude Code mod예요. 만료되면 다음 메시지가 대화 전체를 다시 캐시하므로, 보내기 전에 비용이 커질지 알 수 있어요. 세부 정보는 /cache-ttl, TTL 고정은 /cache-ttl 5m|1h, 자동 감지로 복귀는 /cache-ttl auto.

Source 2 files
hooks/register.js 307 lines
1// cache-ttl-timer: a prompt-cache countdown beside the mode labels in the prompt footer.
2//
3// The main conversation's cache entry lives for its TTL after the last request
4// that used it. The countdown starts when each main-loop request is sent
5// (turn.step), and the TTL (5m or 1h) is read from the transcript, where the
6// API's usage records how many tokens were written at each TTL.
7import { atom, read, update } from 'claude-code'
8
9// When the main conversation last used its cache, in $.clock.now() milliseconds; null before any request
10const lastHit = atom({ plugin: 'cache-ttl-timer', key: 'lastHit' }, null)
11// The TTL the latest cache write used, as the transcript reports it; null until one is seen
12const detectedTtl = atom({ plugin: 'cache-ttl-timer', key: 'detectedTtl' }, null)
13
14const TTL_MS = { '5m': 5 * 60 * 1000, '1h': 60 * 60 * 1000 }
15// What Claude Code asks for on the main thread of a subscription that isn't in overage
16const DEFAULT_TTL = '1h'
17// The share of the TTL left when the countdown turns amber
18const WARN_FRACTION = 0.2
19// How much of the transcript's end to read: enough to hold the last response after a large tool result
20const TAIL_BYTES = '4000000'
21// Claude Code names a project's folder after its path, cut to this length
22const PROJECT_SLUG_MAX = 200
23// How many steps the ring drains in: every 30 seconds of a 1h TTL, so it redraws rarely
24const RING_STEPS = 120
25// The ring's geometry, in the 16x16 viewBox it is drawn in
26const RING_R = 6
27const RING_C = 2 * Math.PI * RING_R
28// The terminal's ring, from full to nearly empty, and the one drawn once the cache is cold
29const GLYPHS = ['●', '◕', '◑', '◔', '○']
30const COLD_GLYPH = '◌'
31// Label colors by level: the footer's own dim gray, the theme's warning, and dim again once cold
32const LABEL_STYLE = { warm: { dimColor: true }, warn: { color: 'warning' }, cold: { dimColor: true } }
33// The desktop ring's arc by level
34const RING_COLOR = { warm: '#9a9a9a', warn: '#d99a2b' }
35
36// The TTL picked with /cache-ttl 5m|1h, shared by every session; null follows the transcript
37let override = null
38// What the indicator last drew, so the ticker redraws only when the label or the ring changes
39let drawnKey = ''
40// Whether the desktop has asked for the footer's mode labels; until it does, the band above the prompt stands in
41let desktopFooterSeen = false
42
43export function register(on) {
44  // Runs before the first prompt, and again after a reload
45  on('session.start', async ($, e, next) => {
46    await $.command.register({
47      name: 'cache-ttl',
48      description: 'Show the prompt cache countdown, or set its TTL: /cache-ttl [5m|1h|auto]',
49      immediate: true,
50    })
51    const saved = await $.store.get('override')
52    override = saved === '5m' || saved === '1h' ? saved : null
53    // After a reload or a resume, pick the countdown up from the transcript
54    try {
55      const path = await transcriptPath($)
56      if (path) await learnFromTranscript($, path)
57    } catch {
58      // No transcript to read yet; the first request starts the countdown
59    }
60    $.clock.every(1000, () => tick($))
61    return next(e)
62  })
63
64  // Runs on startup, /resume, /clear and compaction, with the transcript's path
65  on('classic.SessionStart', async ($, e, next) => {
66    await learnFromTranscript($, e.transcript_path)
67    return next(e)
68  })
69
70  // Runs when Claude finishes answering: the transcript now holds the turn's cache writes
71  on('classic.Stop', async ($, e, next) => {
72    await learnFromTranscript($, e.transcript_path)
73    return next(e)
74  })
75
76  // Runs for each request to the model
77  on('turn.step', async function* ($, e, next) {
78    // A subagent caches its own prefix; the footer follows the main conversation
79    if (e.agentId) return yield* next(e)
80    const sentAt = await $.clock.now()
81    const result = yield* next(e)
82    // A request that got an answer read or wrote the cache, which restarts its TTL
83    if (result.usage) await update($, lastHit, (prev) => Math.max(prev ?? 0, sentAt))
84    return result
85  })
86
87  // Runs when you type /cache-ttl
88  on('command.run', { command: 'cache-ttl' }, async ($, e) => {
89    const arg = (e.args ?? '').trim()
90    if (arg === '5m' || arg === '1h') {
91      override = arg
92      await $.store.set('override', arg)
93    } else if (arg === 'auto') {
94      override = null
95      await $.store.delete('override')
96    } else if (arg) {
97      return { text: 'Use /cache-ttl, /cache-ttl 5m, /cache-ttl 1h, or /cache-ttl auto' }
98    }
99    $.ui.invalidate('ui.render')
100    return { text: await statusText($) }
101  })
102
103  // Runs each time Claude Code draws the mode labels in the prompt footer
104  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
105    if (e.surface === 'desktop' && !desktopFooterSeen) {
106      desktopFooterSeen = true
107      // Take the stand-in above the prompt down
108      $.ui.invalidate('ui.render')
109    }
110    const now = await readout($)
111    if (!now) return next(e)
112    const indicator = await drawIndicator($, e, now)
113    if (e.props.modes.length === 0) return indicator
114    // Keep Claude Code's labels, with the indicator after them
115    const { Box, Text } = $.ui.resolve(e)
116    const theirs = await next(e)
117    return Box({
118      flexDirection: 'row',
119      alignItems: 'center',
120      children: [theirs, Text({ dimColor: true, children: [' · '] }), indicator],
121    })
122  })
123
124  // Runs each time Claude Code draws the band above the prompt. A desktop that never asks for
125  // the footer's mode labels gets the indicator here instead, at the right, above the context ring.
126  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
127    if (e.surface !== 'desktop' || desktopFooterSeen || e.props.hasSurvey) return next(e)
128    const now = await readout($)
129    if (!now) return next(e)
130    const { Box } = $.ui.resolve(e)
131    return Box({
132      flexDirection: 'row',
133      justifyContent: 'flex-end',
134      width: e.props.bodyColumns,
135      children: [await drawIndicator($, e, now)],
136    })
137  })
138}
139
140// The indicator itself: a ring and its label. The footer draws text alone on every surface (the
141// desktop drops images there), so its ring is a glyph; the desktop's band above the prompt draws
142// images, so there the ring is an SVG the size of the context ring.
143async function drawIndicator($, e, now) {
144  const { Box, Text, Svg } = $.ui.resolve(e)
145  if (e.surface === 'desktop' && e.component === 'AbovePrompt') {
146    const ring = Svg({ source: ringSvg(now), alt: 'Prompt cache: ' + now.label, width: 14, height: 14 })
147    const label = Text({ ...LABEL_STYLE[now.level], children: [now.label] })
148    return Box({ flexDirection: 'row', alignItems: 'center', columnGap: 1, children: [ring, label] })
149  }
150  return Text({ ...LABEL_STYLE[now.level], children: [ringGlyph(now) + ' ' + now.label] })
151}
152
153// Where the countdown stands now, or null before the first request
154async function readout($) {
155  const hit = await read($, lastHit)
156  if (hit === null) return null
157  const ttl = await ttlFor($)
158  const total = TTL_MS[ttl]
159  const left = hit + total - (await $.clock.now())
160  const level = left <= 0 ? 'cold' : left <= total * WARN_FRACTION ? 'warn' : 'warm'
161  // The ring moves in steps, so its drawing changes a few times a minute at most
162  const step = left <= 0 ? 0 : Math.ceil((left / total) * RING_STEPS)
163  return { hit, ttl, total, left, level, step, label: labelText(left) }
164}
165
166// 47m while there's time, 0:42 in the last minute, Cold after
167function labelText(left) {
168  if (left <= 0) return 'Cold'
169  if (left < 60 * 1000) return '0:' + String(Math.ceil(left / 1000)).padStart(2, '0')
170  return Math.ceil(left / 60000) + 'm'
171}
172
173// The terminal's ring: one glyph that empties as the cache cools
174function ringGlyph(now) {
175  if (now.level === 'cold') return COLD_GLYPH
176  const emptied = 1 - now.step / RING_STEPS
177  return GLYPHS[Math.min(GLYPHS.length - 1, Math.round(emptied * (GLYPHS.length - 1)))]
178}
179
180// The desktop's ring: a track and an arc that drains clockwise from twelve, dashed once cold.
181// Drawn as an image, so the colors sit in attributes: mid grays and an amber that read on light and dark.
182export function ringSvg(now) {
183  const offset = (RING_C * (1 - now.step / RING_STEPS)).toFixed(2)
184  const track =
185    now.level === 'cold'
186      ? '<circle cx="8" cy="8" r="6" fill="none" stroke="#8a8a8a" stroke-opacity=".6" stroke-width="1.75" stroke-dasharray="1.6 2.1"/>'
187      : '<circle cx="8" cy="8" r="6" fill="none" stroke="#8a8a8a" stroke-opacity=".35" stroke-width="1.75"/>'
188  const arc =
189    now.level === 'cold'
190      ? ''
191      : '<circle cx="8" cy="8" r="6" fill="none" stroke="' + RING_COLOR[now.level] +
192        '" stroke-width="1.75" stroke-linecap="round" stroke-dasharray="' + RING_C.toFixed(2) +
193        '" stroke-dashoffset="' + offset + '" transform="rotate(-90 8 8)"/>'
194  return '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16">' + track + arc + '</svg>'
195}
196
197// The TTL in use: /cache-ttl's choice, else what the transcript reported, else the default
198async function ttlFor($) {
199  return override ?? (await read($, detectedTtl)) ?? DEFAULT_TTL
200}
201
202// Once a second: redraw when the label or the ring has changed
203async function tick($) {
204  const now = await readout($)
205  const key = now ? now.level + now.label + now.step : ''
206  if (key === drawnKey) return
207  drawnKey = key
208  $.ui.invalidate('ui.render')
209}
210
211// The reply to /cache-ttl
212async function statusText($) {
213  const ttl = await ttlFor($)
214  const source = override ? 'set with /cache-ttl' : (await read($, detectedTtl)) ? 'from the transcript' : 'default'
215  const head = 'TTL ' + ttl + ' (' + source + ')'
216  // Where the indicator can show: an app draws a mod's UI only once it attaches to the session
217  const surfaces = await $.session.surfaces()
218  const drawn = surfaces.length > 0 ? 'drawn on ' + surfaces.join(', ') : 'no app draws mod UI in this session'
219  const hit = await read($, lastHit)
220  if (hit === null) return head + ' · no request yet · ' + drawn
221  const now = await $.clock.now()
222  const left = hit + TTL_MS[ttl] - now
223  const ago = 'last request ' + clockText(now - hit) + ' ago'
224  const state = left > 0 ? clockText(left) + ' left' : 'expired, the next message writes the cache again'
225  return head + ' · ' + ago + ' · ' + state + ' · ' + drawn
226}
227
228// Reads the transcript's end and records the TTL of the latest cache write, and the last request if it's newer.
229// Where `tail` is missing (Windows) the countdown still runs, on the default TTL.
230async function learnFromTranscript($, path) {
231  if (!path) return
232  let out
233  try {
234    out = await $.process.run(['tail', '-c', TAIL_BYTES, path])
235  } catch {
236    return
237  }
238  if (out.exitCode !== 0) return
239  const found = parseTail(out.stdout)
240  if (!found) return
241  if (found.ttl) await update($, detectedTtl, () => found.ttl)
242  await update($, lastHit, (prev) => Math.max(prev ?? 0, found.sentAt))
243}
244
245// The session's transcript under ~/.claude/projects, where Claude Code names it by the session id
246async function transcriptPath($) {
247  const projects = ((await $.env.get('CLAUDE_CONFIG_DIR')) || (await $.env.get('HOME')) + '/.claude') + '/projects/'
248  const file = '/' + (await $.session.id()) + '.jsonl'
249  const slug = (await $.session.cwd()).replace(/[^a-zA-Z0-9]/g, '-')
250  if (slug.length <= PROJECT_SLUG_MAX) {
251    return (await $.fs.exists(projects + slug + file)) ? projects + slug + file : null
252  }
253  // A longer slug is cut and given a hash suffix, so look for the folder that holds the session
254  const prefix = slug.slice(0, PROJECT_SLUG_MAX) + '-'
255  for (const entry of await $.fs.list(projects)) {
256    if (entry.kind === 'dir' && entry.name.startsWith(prefix) && (await $.fs.exists(projects + entry.name + file))) {
257      return projects + entry.name + file
258    }
259  }
260  return null
261}
262
263// From transcript lines: when the last main-conversation request was sent, and the TTL of the latest cache write
264export function parseTail(text) {
265  let sentAt = null
266  let ttl = null
267  let lastUserAt = null
268  let lastId = null
269  for (const line of text.split('\n')) {
270    if (!line.startsWith('{')) continue
271    let row
272    try {
273      row = JSON.parse(line)
274    } catch {
275      // The first line of a tail is usually cut
276      continue
277    }
278    if (row.isSidechain) continue
279    const at = Date.parse(row.timestamp)
280    if (Number.isNaN(at)) continue
281    // A prompt or a tool result is written just before the request that carries it
282    if (row.type === 'user') {
283      lastUserAt = at
284      continue
285    }
286    if (row.type !== 'assistant') continue
287    const message = row.message ?? {}
288    const usage = message.usage
289    if (!usage || message.model === '<synthetic>') continue
290    // One response is written as several rows that share its id
291    if (message.id !== lastId) {
292      lastId = message.id
293      sentAt = lastUserAt ?? at
294    }
295    const written = usage.cache_creation ?? {}
296    if (written.ephemeral_1h_input_tokens > 0) ttl = '1h'
297    else if (written.ephemeral_5m_input_tokens > 0) ttl = '5m'
298  }
299  return sentAt === null ? null : { sentAt, ttl }
300}
301
302// Milliseconds as mm:ss, rounded up so the last second shows 00:01
303function clockText(ms) {
304  const s = Math.max(0, Math.ceil(ms / 1000))
305  return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0')
306}
307
types/index.d.ts 9 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'cache-ttl-timer': {
4      lastHit: number | null
5      detectedTtl: '5m' | '1h' | null
6    }
7  }
8}
9