A one-line band above the prompt: context used, and a countdown until the prompt cache goes cold

A Claude Code mod that puts one line above the prompt: how long until the prompt cache goes cold, how full the context window is, and a separate clock for subagents while one is warm.

Every request to the model writes the conversation to the prompt cache. If the next request arrives before the cache expires, everything so far is read back at a fraction of the price. If it arrives after, the whole context is sent again at full price. Claude Code never shows you which side of that line you are on. This does.
sub clock.cache cold, and a toast tells you the next message is a full-price write.measured once it has seen one.onWarning):handoff (the default): Claude writes a handoff note, HANDOFF-<first 8 characters of the session id>.md, in the project root, in case you don't come back. Each session has its own file, so parallel sessions in one folder don't overwrite each other.compact: the session compacts, as /compact does. The summary request reads the warm cache, so it is cheap, and your next message after the cache goes cold re-sends a few thousand tokens instead of the whole context.off: just the toast.It runs once per idle stretch and re-arms when you send a message.
/cache-clock prints the same line as text.The handoff is the only thing it sends to Claude: one short turn on a warm cache. Compact sends no turn; it is the same compaction /compact runs.
claude plugin marketplace add ray-amjad/claude-code-mods
claude plugin install cache-clock@claude-code-mods
Or inside Claude Code: /plugin marketplace add ray-amjad/claude-code-mods, then /plugin install cache-clock@claude-code-mods.
onWarning is a plugin setting: set it with /plugin configure cache-clock@claude-code-mods. The rest are optional environment variables. Minutes may be fractional.
| Variable | Default | What it does | | :- | :- | :- | | CACHE_CLOCK_MAIN_TTL_MIN | 60 | Main session TTL. Setting it pins it; otherwise the mod reads it from the transcript. | | CACHE_CLOCK_SUB_TTL_MIN | 5 | Subagent TTL | | CACHE_CLOCK_WARN_MIN | 5 | Toast, the onWarning action and the colour red when this much is left | | CACHE_CLOCK_ON_WARNING | the setting | handoff, compact or off; overrides the plugin setting | | CACHE_CLOCK_AUTO_HANDOFF | on | 0 means off (kept from older versions) | | CACHE_CLOCK_HANDOFF_DIR | project root | Folder the handoff notes go to | | CACHE_CLOCK_SOUND | off | 1 plays sounds/cooling.wav with the warning toast |
To watch the whole cycle in two minutes:
CACHE_CLOCK_MAIN_TTL_MIN=2 CACHE_CLOCK_WARN_MIN=1.5 claude
turn.step (one per model request) restarts the main clock, or the sub clock when the step carries an agentId. A one second $.clock.every redraws the band and checks the warning.usage.cache_creation: tokens in ephemeral_1h mean 1 hour, ephemeral_5m mean 5 minutes. On --resume it seeds the clock from the session's seconds_since_last_response.$.session.usage() at start, then the pushed session.measure figures.turn.start/turn.complete) and nothing has run since your last prompt, handoff calls $.prompt.submit and compact calls $.session.compact, then reads the smaller context back from $.session.usage(). A prompt.submit hook re-arms it on your next prompt.assumed.sub, then ctx) instead of wrapping.claude --plugin-dir ./cache-clock
claude plugin validate .
claude plugin test .hooks/register.js 261 lines1// cache-clock: one line above the prompt that shows how full the context is
2// and how long until the prompt cache goes cold.
3
4// Defaults, in minutes. Each can be changed with an environment variable.
5const DEFAULT_MAIN_TTL_MIN = 60 // main session cache (matches what the transcripts record)
6const DEFAULT_SUB_TTL_MIN = 5 // subagent cache
7const DEFAULT_WARN_MIN = 5 // toast (and the onWarning action) when this much is left
8
9const MIN = 60_000
10
11// State shared by the hooks below
12const cfg = { mainTtl: DEFAULT_MAIN_TTL_MIN * MIN, subTtl: DEFAULT_SUB_TTL_MIN * MIN, warn: DEFAULT_WARN_MIN * MIN, sound: false, pinned: false, onWarning: 'handoff', handoffDir: '' }
13let ttlSource = 'assumed' // 'measured' once we read it from the transcript
14let mainAt = 0 // when the main session last sent a request (ms)
15let subAt = 0 // when a subagent last sent one
16let warned = false // toast already shown for this warm period
17let wentCold = false // "went cold" already shown
18let working = false // a turn is running
19let handedOff = false // handoff or compaction done since the user last sent something
20let compacted = null // { before, after } tokens, when the warning compacted the session
21let context = null // { tokens, window, percent } from $.session.usage()
22let transcript = null // path to this session's .jsonl
23
24// Read a number of minutes from an env var, or keep the default
25function minutes(value, fallback) {
26 const n = parseFloat(value ?? '')
27 return Number.isFinite(n) && n > 0 ? n * MIN : fallback
28}
29
30const ACTIONS = ['handoff', 'compact', 'off']
31
32// What the warning does: the env var, then the old CACHE_CLOCK_AUTO_HANDOFF=0, then the setting
33export function pickAction(envAction, envAutoHandoff, setting) {
34 const action = envAction ?? (envAutoHandoff === '0' ? 'off' : setting)
35 return ACTIONS.includes(action) ? action : 'handoff'
36}
37
38async function loadConfig($, options) {
39 const main = await $.env.get('CACHE_CLOCK_MAIN_TTL_MIN')
40 cfg.mainTtl = minutes(main, DEFAULT_MAIN_TTL_MIN * MIN)
41 cfg.pinned = main !== undefined // a value you set wins over the transcript
42 cfg.subTtl = minutes(await $.env.get('CACHE_CLOCK_SUB_TTL_MIN'), DEFAULT_SUB_TTL_MIN * MIN)
43 cfg.warn = minutes(await $.env.get('CACHE_CLOCK_WARN_MIN'), DEFAULT_WARN_MIN * MIN)
44 cfg.sound = (await $.env.get('CACHE_CLOCK_SOUND')) === '1'
45 cfg.handoffDir = (await $.env.get('CACHE_CLOCK_HANDOFF_DIR')) ?? ''
46 // The plugin setting, unless an env var overrides it. CACHE_CLOCK_AUTO_HANDOFF=0 still means off.
47 cfg.onWarning = pickAction(await $.env.get('CACHE_CLOCK_ON_WARNING'), await $.env.get('CACHE_CLOCK_AUTO_HANDOFF'), options?.onWarning)
48}
49
50// The real TTL: each assistant row in the transcript says which cache bucket
51// it wrote to (ephemeral_1h or ephemeral_5m). Read the newest main-thread one.
52async function readTtl($) {
53 if (!transcript || cfg.pinned) return
54 for (const row of (await lastRows($, transcript)).reverse()) {
55 if (!row.includes('"cache_creation"')) continue
56 let msg
57 try { msg = JSON.parse(row) } catch { continue }
58 if (msg.type !== 'assistant' || msg.isSidechain) continue
59 const cc = msg.message?.usage?.cache_creation ?? {}
60 if (cc.ephemeral_1h_input_tokens > 0) { cfg.mainTtl = 60 * MIN; ttlSource = 'measured'; return }
61 if (cc.ephemeral_5m_input_tokens > 0) { cfg.mainTtl = 5 * MIN; ttlSource = 'measured'; return }
62 // a pure cache read does not say which bucket; look further back
63 }
64}
65
66// The transcript's last 300 rows. A long session's transcript is past the
67// 4 MiB $.fs.read takes, so a command reads its end: `tail` on Mac and Linux,
68// PowerShell on Windows, which has no tail (the path goes in an env var, not
69// the script). If the command fails, $.fs.read is the last resort.
70export function tailCommand(isWindows, path) {
71 if (!isWindows) return [['tail', '-n', '300', path], {}]
72 const script =
73 '$r = New-Object IO.StreamReader((New-Object IO.FileStream($env:CACHE_CLOCK_FILE, "Open", "Read", "ReadWrite")), [Text.Encoding]::UTF8); ' +
74 '$q = New-Object System.Collections.Generic.Queue[string]; ' +
75 'while ($null -ne ($l = $r.ReadLine())) { $q.Enqueue($l); if ($q.Count -gt 300) { [void]$q.Dequeue() } }; ' +
76 '$b = [Text.Encoding]::UTF8.GetBytes(($q -join "`n") + "`n"); [Console]::OpenStandardOutput().Write($b, 0, $b.Length)'
77 return [['powershell', '-NoProfile', '-NonInteractive', '-Command', script], { env: { CACHE_CLOCK_FILE: path } }]
78}
79async function lastRows($, path) {
80 try {
81 const [argv, init] = tailCommand((await $.env.get('OS')) === 'Windows_NT', path)
82 const r = await $.process.run(argv, init)
83 if (r.exitCode === 0) return lastLines(r.stdout, 300)
84 } catch {}
85 return lastLines(await $.fs.read(path).catch(() => ''), 300)
86}
87
88// The last n lines of a file's text, CRLF or LF
89export function lastLines(text, n) {
90 const lines = String(text).split(/\r?\n/)
91 if (lines[lines.length - 1] === '') lines.pop()
92 return lines.slice(-n)
93}
94
95// 47:05 or 3:09
96function clock(ms) {
97 const s = Math.max(0, Math.ceil(ms / 1000))
98 return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
99}
100
101function fmtTokens(n) {
102 if (n >= 1_000_000) return +(n / 1_000_000).toFixed(1) + 'M'
103 return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
104}
105
106// Everything the band shows, as plain pieces so /cache-clock can print it too
107function snapshot(now) {
108 const left = mainAt ? mainAt + cfg.mainTtl - now : 0
109 const subLeft = subAt ? subAt + cfg.subTtl - now : 0
110 // Colour: green, then yellow in the last quarter, red inside the warning window
111 let color = 'green'
112 if (!mainAt || left <= 0) color = 'gray'
113 else if (left <= cfg.warn) color = 'red'
114 else if (left <= cfg.mainTtl / 4) color = 'yellow'
115 const ttl = (cfg.mainTtl >= 60 * MIN ? cfg.mainTtl / (60 * MIN) + 'h' : cfg.mainTtl / MIN + 'm') + ' ttl ' + (cfg.pinned ? 'set' : ttlSource)
116 const cache = !mainAt ? 'cache: no request yet' : left > 0 ? 'cache ' + clock(left) + ' left' : 'cache cold'
117 let ctx = 'ctx --'
118 if (context?.tokens) {
119 const pct = context.percent ?? Math.round((context.tokens / context.window) * 100)
120 const filled = Math.round(pct / 10)
121 ctx = 'ctx ' + fmtTokens(context.tokens) + '/' + fmtTokens(context.window) + ' ' + pct + '% ' + '▰'.repeat(filled) + '▱'.repeat(10 - filled)
122 }
123 const sub = subLeft > 0 ? 'sub ' + clock(subLeft) : null
124 return { color, cache, ttl, ctx, sub, left }
125}
126
127function asText(s) {
128 return [s.cache + ' (' + s.ttl + ')', s.ctx, s.sub].filter(Boolean).join(' | ')
129}
130
131// The handoff prompt. The marker lets prompt.submit tell it from the user's own prompts.
132const HANDOFF_MARKER = 'cache-clock handoff'
133
134// One note per session, so parallel sessions in one folder never overwrite
135// each other's. It goes to CACHE_CLOCK_HANDOFF_DIR, else the project root
136// (not the shell's current folder, which a `cd` can move into a nested repo).
137export function handoffPath(dir, sessionId) {
138 return dir.replace(/[\\/]+$/, '') + '/HANDOFF-' + String(sessionId).slice(0, 8) + '.md'
139}
140function handoffText(file) {
141 return '(' + HANDOFF_MARKER + ') The prompt cache goes cold soon and the user has not replied. While the cache is still warm, write a short handoff note to ' + file + ': the goal, what is done, what is next, open questions, and the key files. Keep it brief, and write it even if the session has been short. Do nothing else. A fresh session will start from it if the user does not come back.'
142}
143
144// Compact while the cache is warm: the summary request reads the cached
145// conversation, and the next message after the cache goes cold re-sends only
146// the summary instead of the whole context.
147const COMPACT_INSTRUCTIONS = 'The user stepped away. Keep the goal, what is done, what is next, open questions, and the key files, so the work can pick up from here.'
148async function compactWarm($, left) {
149 $.ui.toast('Prompt cache goes cold in ' + clock(left) + '. No reply, so the session is compacting while it is warm.', { timeoutMs: 10_000 })
150 try {
151 const result = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS })
152 if (result.skip) return $.ui.toast('Compaction skipped: ' + result.skip)
153 compacted = { before: result.tokensBefore ?? context?.tokens ?? 0, after: result.tokensAfter ?? 0 }
154 context = (await $.session.usage()).context ?? context
155 $.ui.toast('Compacted ' + fmtTokens(compacted.before) + ' to ' + fmtTokens(compacted.after) + ' tokens while the cache was warm.', { timeoutMs: 10_000 })
156 } catch (err) {
157 handedOff = false // a turn started in the meantime; try again next warning
158 }
159}
160
161// Once a second: warn near expiry, note when it goes cold, redraw the band
162async function tick($) {
163 const s = snapshot(await $.clock.now())
164 if (mainAt && s.left > 0 && s.left <= cfg.warn && !warned) {
165 warned = true
166 // Idle and nothing done since the user's last message: act while the cache is warm
167 if (cfg.onWarning !== 'off' && !working && !handedOff) {
168 handedOff = true
169 if (cfg.onWarning === 'compact') compactWarm($, s.left)
170 else {
171 const file = handoffPath(cfg.handoffDir || (await $.session.root()), await $.session.id())
172 $.ui.toast('Prompt cache goes cold in ' + clock(s.left) + '. No reply, so Claude is writing ' + file.split(/[\\/]/).pop() + ' while it is warm.', { timeoutMs: 10_000 })
173 $.prompt.submit({ text: handoffText(file) }).catch(() => {}) // waits for idle; don't await in the tick
174 }
175 } else {
176 $.ui.toast('Prompt cache goes cold in ' + clock(s.left) + '. Send something to keep it warm.', { timeoutMs: 10_000 })
177 }
178 if (cfg.sound) $.audio.play({ asset: 'sounds/cooling.wav' }).catch(() => {})
179 }
180 if (mainAt && s.left <= 0 && !wentCold) {
181 wentCold = true
182 $.ui.toast(compacted
183 ? 'Prompt cache is cold, but the session was compacted to ' + fmtTokens(compacted.after) + ', so the next message is a small write.'
184 : 'Prompt cache is cold. The next message re-sends the whole context at full price.')
185 }
186 $.ui.invalidate('ui.render')
187}
188
189export function register(on, options) {
190 on('session.start', async ($, e, next) => {
191 await loadConfig($, options)
192 context = (await $.session.usage()).context
193 await $.command.register({ name: 'cache-clock', description: 'Print the cache countdown and context use' })
194 $.clock.every(1000, () => tick($))
195 return next(e)
196 })
197
198 // On a resume, the session tells us how long ago the last answer was,
199 // so the clock picks up where it left off
200 on('classic.SessionStart', async ($, e, next) => {
201 transcript = e.transcript_path ?? transcript
202 if (e.seconds_since_last_response != null) mainAt = (await $.clock.now()) - e.seconds_since_last_response * 1000
203 await readTtl($)
204 return next(e)
205 }).catch(($, e, next) => (next.called ? undefined : next(e))) // never block the session on our error
206
207 // Track whether a turn is running, so the warning action never queues behind one
208 on('turn.start', async ($, e, next) => { working = true; return next(e) })
209 on('turn.complete', async ($, e, next) => { working = false; return next(e) })
210
211 // A prompt from the user (not our own handoff) re-arms the warning action
212 on('prompt.submit', async ($, e, next) => {
213 if (!String(e.text ?? '').includes(HANDOFF_MARKER)) { handedOff = false; compacted = null }
214 return next(e)
215 })
216
217 // Every request to the model refreshes its cache: restart the right clock
218 on('turn.step', async function* ($, e, next) {
219 const now = await $.clock.now()
220 if (e.agentId) subAt = now
221 else { mainAt = now; warned = false; wentCold = false }
222 yield* next(e)
223 })
224
225 // After each turn, check which TTL the API actually used
226 on('classic.Stop', async ($, e, next) => {
227 transcript = e.transcript_path ?? transcript
228 await readTtl($)
229 return next(e)
230 }).catch(($, e, next) => (next.called ? undefined : next(e)))
231
232 // Context figures, pushed after each turn
233 on('session.measure', async ($, e, next) => {
234 context = e.context
235 return next(e)
236 })
237
238 // The band itself: one line above the prompt
239 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
240 if (e.props.hasSurvey) return next(e)
241 const { Box, Text } = $.ui.resolve(e)
242 const s = snapshot(await $.clock.now())
243 // Pieces in order of importance; drop from the end until it fits one line
244 const pieces = [
245 { text: '◷ ' + s.cache, style: { color: s.color, bold: s.color === 'red' } },
246 { text: s.ctx, style: {} },
247 s.sub && { text: s.sub, style: { dimColor: true } },
248 { text: s.ttl, style: { dimColor: true } },
249 ].filter(Boolean)
250 const SEP = ' · '
251 while (pieces.length > 1 && pieces.map((p) => p.text).join(SEP).length > e.props.bodyColumns) pieces.pop()
252 const parts = pieces.flatMap((p, i) => i ? [h(Text, { dimColor: true }, SEP), h(Text, p.style, p.text)] : [h(Text, p.style, p.text)])
253 return h(Box, {}, h(Text, { wrap: 'truncate-end' }, ...parts))
254 })
255
256 on('command.run', { command: 'cache-clock' }, async ($) => {
257 await readTtl($)
258 return { text: asText(snapshot(await $.clock.now())) }
259 })
260}
261