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.

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.
| 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.
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.
/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 reportsRequires Claude Code 2.1.287 or later, where mods are on by default.
cache-ttl-timer@synced./plugin install cache-ttl-timer --marketplace WQGGSEY/cache-ttl-timerclaude plugin marketplace add WQGGSEY/cache-ttl-timer, then claude plugin install cache-ttl-timer@cache-ttl-timerTo turn it off, disable the plugin in /plugin.
/cache-ttl says so.claude -p, and the Agent SDK: mods run but draw nothing. /cache-ttl still answers.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.
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).~/.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.HOME and CLAUDE_CONFIG_DIR, to find that folder./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.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.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.
MIT
프롬프트 캐시가 만료되기까지 남은 시간을 프롬프트 하단 모델·effort 옆에 링과 시간으로 보여주는 Claude Code mod예요. 만료되면 다음 메시지가 대화 전체를 다시 캐시하므로, 보내기 전에 비용이 커질지 알 수 있어요. 세부 정보는 /cache-ttl, TTL 고정은 /cache-ttl 5m|1h, 자동 감지로 복귀는 /cache-ttl auto.
hooks/register.js 307 lines1// 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}
307types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 'cache-ttl-timer': {
4 lastHit: number | null
5 detectedTtl: '5m' | '1h' | null
6 }
7 }
8}
9