One-line bar above the prompt with a live prompt-cache countdown, when your 5-hour usage resets, and Compact / Handoff buttons when the cache is about to…

A one-line bar above the prompt in the Claude Desktop Code tab:
Cache 51 min remaining. Usage resets at 5:20 pm, 12% used
<1 min under a minute). Yellow near expiry, red when expired./compact./cache-keeper:session-handoff, then /clear, then pastes the handoff into the prompt box for you to send. It does not clear unless the reply starts with ## Handoff./cache-keeper on | off | status shows or hides the bar. The choice is remembered.
/plugin > Installed > cache-keeper > Configure options)| Setting | Default | Meaning |
|---|---|---|
ttlSeconds | 3600 | Cache lifetime. 3600 (1 hour) on a Claude subscription within plan usage; 300 (5 minutes) with an API key or usage credits. |
warnSeconds | 300 | The bar turns yellow and shows the buttons when this many seconds remain. |
Claude Code's own promptCacheTtl setting or CLAUDE_CODE_PROMPT_CACHE_TTL variable (5m or 1h), when set, overrides ttlSeconds.
claude --version; update with claude update.Cache 60 min remaining counting down.Usage resets at H:MM am/pm, NN% used appears after a reply once a reading exists.warnSeconds to 3590, send a prompt, and wait about 10 seconds. The bar turns yellow and Compact and Handoff appear. Set warnSeconds back to 300 afterwards.## Handoff, the session clears, and the handoff text appears in the prompt box. Press Enter to send it./cache-keeper off hides the bar. /cache-keeper on brings it back.hooks/register.tsx 179 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import {
5 bandStage,
6 bandText,
7 effectiveTtlSeconds,
8 fiveHourUsage,
9 isHandoff,
10 parseSubcommand,
11 secondsLeft,
12} from './model'
13
14const COMMAND = 'cache-keeper'
15const HANDOFF_SKILL = 'cache-keeper:session-handoff'
16
17const now = atom({ plugin: 'cache-keeper', key: 'now' } as const, 0)
18const cacheTouchedAt = atom({ plugin: 'cache-keeper', key: 'cacheTouchedAt' } as const, null)
19const fiveHourPercent = atom({ plugin: 'cache-keeper', key: 'fiveHourPercent' } as const, null)
20const fiveHourResetsAt = atom({ plugin: 'cache-keeper', key: 'fiveHourResetsAt' } as const, null)
21const isHandingOff = atom({ plugin: 'cache-keeper', key: 'isHandingOff' } as const, false)
22const isOn = atom({ plugin: 'cache-keeper', key: 'isOn' } as const, true)
23
24type Engine = EngineInterface
25type Config = { ttlSeconds: number; warnSeconds: number }
26
27let ticker: Timer | undefined
28
29async function refreshLimit($: Engine) {
30 const usage = await $.session.usage()
31 const fiveHour = fiveHourUsage(usage.rateLimits)
32 await update($, fiveHourPercent, () => fiveHour?.percent ?? null)
33 await update($, fiveHourResetsAt, () => fiveHour?.resetsAt ?? null)
34}
35
36async function claudeCodeTtl($: Engine, configured: number) {
37 const fromEnv = await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')
38 const settings: Record<string, unknown> = await $.settings.read()
39 const fromSettings = settings.promptCacheTtl
40
41 return effectiveTtlSeconds(configured, fromEnv ?? (typeof fromSettings === 'string' ? fromSettings : undefined))
42}
43
44async function startTicking($: Engine, cfg: Config) {
45 ticker?.cancel()
46 ticker = $.clock.every(1000, async () => {
47 const at = await $.clock.now()
48 await update($, now, () => at)
49 })
50}
51
52async function turnOn($: Engine, cfg: Config) {
53 await $.store.set('enabled', true)
54 await update($, isOn, () => true)
55 await startTicking($, cfg)
56}
57
58async function turnOff($: Engine) {
59 await $.store.set('enabled', false)
60 await update($, isOn, () => false)
61 ticker?.cancel()
62 ticker = undefined
63}
64
65async function runHandoff($: Engine) {
66 await update($, isHandingOff, () => true)
67 try {
68 await $.command.run({ command: HANDOFF_SKILL })
69 } catch {
70 await $.prompt.submit({ text: `/${HANDOFF_SKILL}`, asUser: true })
71 }
72}
73
74async function finishHandoff($: Engine) {
75 await update($, isHandingOff, () => false)
76 const replies = (await $.session.messages()).filter(m => m.role === 'assistant' && m.text !== '')
77 const text = replies[replies.length - 1]?.text ?? ''
78 if (!isHandoff(text)) {
79 $.ui.toast('Handoff output was not in the expected format. The session was not cleared.')
80 return
81 }
82 await $.store.set('handoff', text)
83 $.clock.after(300, async () => {
84 await $.command.run({ command: 'clear' })
85 await $.prompt.fill({ text, mode: 'replace' })
86 $.ui.toast('Handoff pasted into the new session. Press Enter to send it.')
87 })
88}
89
90export const register: Register = (on, options) => {
91 const cfg: Config = {
92 ttlSeconds: Number(options.ttlSeconds ?? 3600),
93 warnSeconds: Number(options.warnSeconds ?? 300),
94 }
95
96 on('session.start', async ($, e, next) => {
97 await $.command.register({
98 name: COMMAND,
99 description: 'Show or hide the cache countdown bar',
100 argumentHint: 'on | off | status',
101 })
102 cfg.ttlSeconds = await claudeCodeTtl($, cfg.ttlSeconds)
103 const startedAt = await $.clock.now()
104 await update($, now, () => startedAt)
105 if ((await $.store.get('enabled')) === false) {
106 await update($, isOn, () => false)
107 } else {
108 await turnOn($, cfg)
109 }
110
111 return next(e)
112 })
113
114 on('command.run', { command: COMMAND }, async ($, e) => {
115 const word = parseSubcommand(e.args)
116 if (word === 'invalid') return { text: 'Usage: /cache-keeper on | off | status' }
117 if (word === 'on') {
118 await turnOn($, cfg)
119 return { text: 'cache-keeper is on.' }
120 }
121 if (word === 'off') {
122 await turnOff($)
123 return { text: 'cache-keeper is off.' }
124 }
125
126 return { text: (await read($, isOn)) ? 'cache-keeper is on.' : 'cache-keeper is off.' }
127 })
128
129 on('turn.step', async function* ($, e, next) {
130 const stepStartedAt = await $.clock.now()
131 await update($, cacheTouchedAt, () => stepStartedAt)
132
133 return yield* next(e)
134 })
135
136 on('turn.complete', async ($, e, next) => {
137 await refreshLimit($)
138 if (await read($, isHandingOff)) await finishHandoff($)
139
140 return next(e)
141 })
142
143 on('session.measure', async ($, e, next) => {
144 await refreshLimit($)
145
146 return next(e)
147 })
148
149 on('session.end', async ($, e, next) => {
150 if (e.reason === 'clear') {
151 await update($, cacheTouchedAt, () => null)
152 }
153
154 return next(e)
155 })
156
157 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
158 if (e.props.hasSurvey || !(await read($, isOn))) return next(e)
159
160 const { Box, Text, Button } = $.ui.resolve(e)
161 const left = secondsLeft(await read($, cacheTouchedAt), cfg.ttlSeconds, await read($, now))
162 const percent = await read($, fiveHourPercent)
163 const usage = percent === null ? null : { percent, resetsAt: await read($, fiveHourResetsAt) }
164 const stage = bandStage(left, cfg.warnSeconds)
165 const isCold = stage === 'cold'
166 const color = stage === 'expired' ? 'red' : isCold ? 'yellow' : undefined
167
168 return (
169 <Box gap={1}>
170 <Text color={color}>{bandText(left, usage)}</Text>
171 {isCold && (
172 <Button key="compact" label="Compact" onPress={() => $.command.run({ command: 'compact' })} />
173 )}
174 {isCold && <Button key="handoff" label="Handoff" onPress={() => runHandoff($)} />}
175 </Box>
176 )
177 })
178}
179hooks/model.ts 70 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Usage = { percent: number; resetsAt: string | null }
3
4export function secondsLeft(
5 lastRequestAt: number | null,
6 ttlSeconds: number,
7 now: number,
8): number | null {
9 if (lastRequestAt === null) return null
10 const elapsed = Math.floor((now - lastRequestAt) / 1000)
11
12 return Math.max(0, ttlSeconds - elapsed)
13}
14
15export function fiveHourUsage(limits: Limit[]): Usage | null {
16 const fiveHour = limits.find(limit => limit.kind === 'five_hour')
17 if (fiveHour === undefined) return null
18
19 return { percent: Math.round(fiveHour.percentUsed), resetsAt: fiveHour.resetsAt ?? null }
20}
21
22export function formatResetTime(at: Date): string {
23 const hours = at.getHours()
24 const minutes = String(at.getMinutes()).padStart(2, '0')
25
26 return `${hours % 12 || 12}:${minutes} ${hours < 12 ? 'am' : 'pm'}`
27}
28
29export type BandStage = 'waiting' | 'warm' | 'cold' | 'expired'
30
31export function bandStage(left: number | null, warnSeconds: number): BandStage {
32 if (left === null) return 'waiting'
33 if (left === 0) return 'expired'
34
35 return left <= warnSeconds ? 'cold' : 'warm'
36}
37
38export function bandText(left: number | null, usage: Usage | null): string {
39 const cache =
40 left === null
41 ? 'Cache: waiting for first reply'
42 : left === 0
43 ? 'Cache expired'
44 : left < 60
45 ? 'Cache <1 min remaining'
46 : `Cache ${Math.floor(left / 60)} min remaining`
47 if (usage === null) return cache
48
49 const resets = usage.resetsAt === null ? '' : ` resets at ${formatResetTime(new Date(usage.resetsAt))},`
50
51 return `${cache}. Usage${resets} ${usage.percent}% used`
52}
53
54const CACHE_TTL_SECONDS: Record<string, number> = { '5m': 300, '1h': 3600 }
55
56export function effectiveTtlSeconds(configured: number, claudeCodeTtl: string | undefined): number {
57 return (claudeCodeTtl === undefined ? undefined : CACHE_TTL_SECONDS[claudeCodeTtl]) ?? configured
58}
59
60export function parseSubcommand(args: string): 'on' | 'off' | 'status' | 'invalid' {
61 const word = args.trim().toLowerCase()
62 if (word === '') return 'status'
63
64 return word === 'on' || word === 'off' || word === 'status' ? word : 'invalid'
65}
66
67export function isHandoff(reply: string): boolean {
68 return reply.trimStart().startsWith('## Handoff')
69}
70types/index.d.ts 13 lines1declare module 'claude-code' {
2 interface PluginState {
3 'cache-keeper': {
4 now: number
5 cacheTouchedAt: number | null
6 fiveHourPercent: number | null
7 fiveHourResetsAt: string | null
8 isHandingOff: boolean
9 isOn: boolean
10 }
11 }
12}
13