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 234 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 const { stdout } = await $.process.run(['tail', '-n', '300', transcript])
55 const rows = stdout.split('\n').reverse()
56 for (const row of rows) {
57 if (!row.includes('"cache_creation"')) continue
58 let msg
59 try { msg = JSON.parse(row) } catch { continue }
60 if (msg.type !== 'assistant' || msg.isSidechain) continue
61 const cc = msg.message?.usage?.cache_creation ?? {}
62 if (cc.ephemeral_1h_input_tokens > 0) { cfg.mainTtl = 60 * MIN; ttlSource = 'measured'; return }
63 if (cc.ephemeral_5m_input_tokens > 0) { cfg.mainTtl = 5 * MIN; ttlSource = 'measured'; return }
64 // a pure cache read does not say which bucket; look further back
65 }
66}
67
68// 47:05 or 3:09
69function clock(ms) {
70 const s = Math.max(0, Math.ceil(ms / 1000))
71 return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
72}
73
74function fmtTokens(n) {
75 if (n >= 1_000_000) return +(n / 1_000_000).toFixed(1) + 'M'
76 return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
77}
78
79// Everything the band shows, as plain pieces so /cache-clock can print it too
80function snapshot(now) {
81 const left = mainAt ? mainAt + cfg.mainTtl - now : 0
82 const subLeft = subAt ? subAt + cfg.subTtl - now : 0
83 // Colour: green, then yellow in the last quarter, red inside the warning window
84 let color = 'green'
85 if (!mainAt || left <= 0) color = 'gray'
86 else if (left <= cfg.warn) color = 'red'
87 else if (left <= cfg.mainTtl / 4) color = 'yellow'
88 const ttl = (cfg.mainTtl >= 60 * MIN ? cfg.mainTtl / (60 * MIN) + 'h' : cfg.mainTtl / MIN + 'm') + ' ttl ' + (cfg.pinned ? 'set' : ttlSource)
89 const cache = !mainAt ? 'cache: no request yet' : left > 0 ? 'cache ' + clock(left) + ' left' : 'cache cold'
90 let ctx = 'ctx --'
91 if (context?.tokens) {
92 const pct = context.percent ?? Math.round((context.tokens / context.window) * 100)
93 const filled = Math.round(pct / 10)
94 ctx = 'ctx ' + fmtTokens(context.tokens) + '/' + fmtTokens(context.window) + ' ' + pct + '% ' + '▰'.repeat(filled) + '▱'.repeat(10 - filled)
95 }
96 const sub = subLeft > 0 ? 'sub ' + clock(subLeft) : null
97 return { color, cache, ttl, ctx, sub, left }
98}
99
100function asText(s) {
101 return [s.cache + ' (' + s.ttl + ')', s.ctx, s.sub].filter(Boolean).join(' | ')
102}
103
104// The handoff prompt. The marker lets prompt.submit tell it from the user's own prompts.
105const HANDOFF_MARKER = 'cache-clock handoff'
106
107// One note per session, so parallel sessions in one folder never overwrite
108// each other's. It goes to CACHE_CLOCK_HANDOFF_DIR, else the project root
109// (not the shell's current folder, which a `cd` can move into a nested repo).
110export function handoffPath(dir, sessionId) {
111 return dir.replace(/\/+$/, '') + '/HANDOFF-' + String(sessionId).slice(0, 8) + '.md'
112}
113function handoffText(file) {
114 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.'
115}
116
117// Compact while the cache is warm: the summary request reads the cached
118// conversation, and the next message after the cache goes cold re-sends only
119// the summary instead of the whole context.
120const 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.'
121async function compactWarm($, left) {
122 $.ui.toast('Prompt cache goes cold in ' + clock(left) + '. No reply, so the session is compacting while it is warm.', { timeoutMs: 10_000 })
123 try {
124 const result = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS })
125 if (result.skip) return $.ui.toast('Compaction skipped: ' + result.skip)
126 compacted = { before: result.tokensBefore ?? context?.tokens ?? 0, after: result.tokensAfter ?? 0 }
127 context = (await $.session.usage()).context ?? context
128 $.ui.toast('Compacted ' + fmtTokens(compacted.before) + ' to ' + fmtTokens(compacted.after) + ' tokens while the cache was warm.', { timeoutMs: 10_000 })
129 } catch (err) {
130 handedOff = false // a turn started in the meantime; try again next warning
131 }
132}
133
134// Once a second: warn near expiry, note when it goes cold, redraw the band
135async function tick($) {
136 const s = snapshot(await $.clock.now())
137 if (mainAt && s.left > 0 && s.left <= cfg.warn && !warned) {
138 warned = true
139 // Idle and nothing done since the user's last message: act while the cache is warm
140 if (cfg.onWarning !== 'off' && !working && !handedOff) {
141 handedOff = true
142 if (cfg.onWarning === 'compact') compactWarm($, s.left)
143 else {
144 const file = handoffPath(cfg.handoffDir || (await $.session.root()), await $.session.id())
145 $.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 })
146 $.prompt.submit({ text: handoffText(file) }).catch(() => {}) // waits for idle; don't await in the tick
147 }
148 } else {
149 $.ui.toast('Prompt cache goes cold in ' + clock(s.left) + '. Send something to keep it warm.', { timeoutMs: 10_000 })
150 }
151 if (cfg.sound) $.audio.play({ asset: 'sounds/cooling.wav' }).catch(() => {})
152 }
153 if (mainAt && s.left <= 0 && !wentCold) {
154 wentCold = true
155 $.ui.toast(compacted
156 ? 'Prompt cache is cold, but the session was compacted to ' + fmtTokens(compacted.after) + ', so the next message is a small write.'
157 : 'Prompt cache is cold. The next message re-sends the whole context at full price.')
158 }
159 $.ui.invalidate('ui.render')
160}
161
162export function register(on, options) {
163 on('session.start', async ($, e, next) => {
164 await loadConfig($, options)
165 context = (await $.session.usage()).context
166 await $.command.register({ name: 'cache-clock', description: 'Print the cache countdown and context use' })
167 $.clock.every(1000, () => tick($))
168 return next(e)
169 })
170
171 // On a resume, the session tells us how long ago the last answer was,
172 // so the clock picks up where it left off
173 on('classic.SessionStart', async ($, e, next) => {
174 transcript = e.transcript_path ?? transcript
175 if (e.seconds_since_last_response != null) mainAt = (await $.clock.now()) - e.seconds_since_last_response * 1000
176 await readTtl($)
177 return next(e)
178 }).catch(($, e, next) => (next.called ? undefined : next(e))) // never block the session on our error
179
180 // Track whether a turn is running, so the warning action never queues behind one
181 on('turn.start', async ($, e, next) => { working = true; return next(e) })
182 on('turn.complete', async ($, e, next) => { working = false; return next(e) })
183
184 // A prompt from the user (not our own handoff) re-arms the warning action
185 on('prompt.submit', async ($, e, next) => {
186 if (!String(e.text ?? '').includes(HANDOFF_MARKER)) { handedOff = false; compacted = null }
187 return next(e)
188 })
189
190 // Every request to the model refreshes its cache: restart the right clock
191 on('turn.step', async function* ($, e, next) {
192 const now = await $.clock.now()
193 if (e.agentId) subAt = now
194 else { mainAt = now; warned = false; wentCold = false }
195 yield* next(e)
196 })
197
198 // After each turn, check which TTL the API actually used
199 on('classic.Stop', async ($, e, next) => {
200 transcript = e.transcript_path ?? transcript
201 await readTtl($)
202 return next(e)
203 }).catch(($, e, next) => (next.called ? undefined : next(e)))
204
205 // Context figures, pushed after each turn
206 on('session.measure', async ($, e, next) => {
207 context = e.context
208 return next(e)
209 })
210
211 // The band itself: one line above the prompt
212 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
213 if (e.props.hasSurvey) return next(e)
214 const { Box, Text } = $.ui.resolve(e)
215 const s = snapshot(await $.clock.now())
216 // Pieces in order of importance; drop from the end until it fits one line
217 const pieces = [
218 { text: '◷ ' + s.cache, style: { color: s.color, bold: s.color === 'red' } },
219 { text: s.ctx, style: {} },
220 s.sub && { text: s.sub, style: { dimColor: true } },
221 { text: s.ttl, style: { dimColor: true } },
222 ].filter(Boolean)
223 const SEP = ' · '
224 while (pieces.length > 1 && pieces.map((p) => p.text).join(SEP).length > e.props.bodyColumns) pieces.pop()
225 const parts = pieces.flatMap((p, i) => i ? [h(Text, { dimColor: true }, SEP), h(Text, p.style, p.text)] : [h(Text, p.style, p.text)])
226 return h(Box, {}, h(Text, { wrap: 'truncate-end' }, ...parts))
227 })
228
229 on('command.run', { command: 'cache-clock' }, async ($) => {
230 await readTtl($)
231 return { text: asText(snapshot(await $.clock.now())) }
232 })
233}
234