SLOPSHOPPER

cache-clock

Counts down until the chat's prompt cache goes cold, above the prompt. When a big chat goes cold, offers a handoff to a fresh chat. Keep warm pings the cache…

newbandcommandtoastpromptmodel
v0.3.2no licenseupdated 2026-10-23jz-wilson/claude-mods/cache-clock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-clock
› 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 › /cacheclock ⎿ cache-clock: Cache Clock hidden in every chat. Type /cacheclock or open /mods to show it. ⟨Claude Code's own drawing⟩ ◷ Cache warm 5:00 ━━━━━━━━━━━━━━━━━━━━ 5 min cache, a guess until the first reply. Reply now and 97k tokens cost 5 … Keeping warm · first ping in 4:15 Each ping reads the chat from cache for a small part of the price (5 to 10%). Letting it go cold means writing it all again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ◷ Cache warm 5:00 ━━━━━━━━━━━━━━━━━━━━ 5 min cache, a guess until the first reply. Reply now and 97… Keeping warm · first ping in 4:15 Each ping reads the chat from cache for a small part of the price (5 to 10%). Letting it go cold means writing it all again.
README

claude-mods

Mods for Claude Code.

ModWhat it does
cache-barStatus-line bar under your existing status line showing how long the prompt cache stays warm, with a handoff countdown: cache ▰▰▰▱▱▱▱▱ 23m 182k · handoff in 18m. Turns yellow at a quarter of the TTL, red at the handoff threshold, and follows the drop to a 5-minute TTL. /cache-bar:setup installs it.
filetreeFork of data-goblin/claude-code-filetree that shows only the files Claude touched this session; the header's ◆ button toggles to the whole tree.
helper-deskTask list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent.
helpin-perPane listing open Helpin PER-* tasks; pick one to have Claude start working on it.
cache-clockCountdown above the prompt until the chat's cache goes cold, with a Keep warm button.
handoffWhen a chat gets very long, writes a handoff prompt and starts a fresh chat with it (/refresh <focus>; the handoff tells the new chat to run /pickup).
reflectWhen you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder.
chat-inventorySide pane, In this chat, listing the to-do list, connectors and calls per connector, files and skills this chat used.
screen-share/screen-share hides names, emails, phone numbers and money amounts on screen while you record.
replay-theater/replay steps through the file edits Claude made in the last turn, one diff at a time.
mods/mods opens a pane to turn each mod on or off, including mods installed from other marketplaces, and to install mods a marketplace offers that are not installed yet.
effort-optimizerBefore Claude starts on a prompt, a quick Sonnet check picks the effort level it needs (Low to Max) and offers a one-click switch, or sets it by itself with /effort-optimizer auto.
prompt-refinerA Refine button in the hint line under the prompt, any time in the chat. Press it and pick a rewrite style for the prompt in the box, like a phone keyboard's suggestions: Clearer, Shorter, Elaborate (fuller, same shape), Structured (the job, the why, the guardrails, done means), Ask me first (up to 3 questions on gaps), or My guidance. The rewrite shows above the prompt with what changed; Use it fills the box, Refine runs another pass. /refine <prompt> does the same.

The eight mods other than replay-theater, from helper-desk to effort-optimizer, come from Ben AI's benai-mods (benai.co) and are redistributed with permission, with Windows fixes added here. replay-theater (one Ben AI change: /mods switch support) is Anthropic's sample mod from anthropics/claude-code-playground, under the Apache 2.0 license in its LICENSE.

Reflect setting

Reflect saves a rule either to ~/.claude/CLAUDE.md (Save everywhere) or as its own note in a folder you choose (Save to vault). Set that folder as rules_folder when you install reflect, or later for reflect in /config. If it is empty, reflect asks for the folder the first time you press Save to vault.

Install

At the prompt of a terminal session:

/plugin install helper-desk --marketplace jz-wilson/claude-mods

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install helper-desk --marketplace jz-wilson/claude-mods
claude plugin install cache-bar --marketplace jz-wilson/claude-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with jz-wilson/claude-mods, then Add plugin.

cache-bar and claude-hud

cache-bar is a status-line script, so it takes over statusLine and runs the previous status line inside it. After installing, run /cache-bar:setup. It:

  • backs up settings.json to settings.json.bak-cache-bar;
  • moves the current statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;
  • points statusLine.command at the newest installed cache-bar version, and sets refreshInterval: 60 so the countdown repaints while you are idle.

The previous status line prints first, unchanged, and the bar goes on its own line underneath. If the inner command fails, the bar still prints. Before the first request of a session there is no cache yet, so the bar is hidden; after expiry it shows a dim cold.

The same config file sets the handoff threshold and the context floor:

{ "inner": "…", "handoffMinutes": 5, "floorTokens": 80000 }

The threshold is capped at a fifth of the TTL, so it fits the 5-minute TTL too. Under floorTokens of context the bar says no handoff (<floor).

To undo, copy settings.json.bak-cache-bar back over settings.json, or set statusLine.command back to the inner value.

Development

Each mod is a folder with .claude-plugin/plugin.json. CI runs claude plugin validate on every mod for each push and pull request, then claude plugin test for mods with a hooks module and bun test for the rest. A merge to main that bumps a mod's version publishes a GitHub release tagged <mod>-v<version>.

claude plugin validate agent-tree
claude plugin test agent-tree
bun test cache-bar
Source 2 files
hooks/register.tsx 476 lines
1import type { EngineInterface, ModelForkResult, Register } from 'claude-code'
2
3import type { Clock, Warm } from '../types'
4
5const CLOCK = { plugin: 'cache-clock', key: 'clock' } as const
6const WARM = { plugin: 'cache-clock', key: 'warm' } as const
7
8const FIVE_MIN = 5 * 60_000
9const ONE_HOUR = 60 * 60_000
10const DEMO_MS = 60_000
11// A cold chat this big gets the handoff button: re-reading it costs the most.
12const BIG = 150_000
13const BAR = 20
14
15// The progress bar is the first thing to go when the band is narrow; the countdown and buttons stay.
16export const barColumns = (bodyColumns: number) => (bodyColumns >= 100 ? BAR : bodyColumns >= 60 ? 10 : 0)
17
18// Keep warm: one small background ping about 45 s before the cache would go cold.
19// $.model.fork repeats the main thread's last request (same model, system prompt,
20// tools, messages) with this one line after it, so the API reads the chat from its
21// cache and the cache's timer starts again. The fork never writes the transcript.
22const LEAD = 45_000
23const MAX_PINGS = 30
24const MAX_MS = 2 * 60 * 60_000
25const MAX_FAILS = 3
26const RETRY_MS = 20_000
27const PING_PROMPT = 'Reply with the single word: ok'
28const TIP = 'Each ping reads the chat from cache for a small part of the price (5 to 10%). Letting it go cold means writing it all again.'
29// Turns that are not the person typing: they do not count as "they are back".
30const BACKGROUND = new Set(['task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'projects-relay', 'channel', 'coordinator', 'observer', 'observer-activity', 'auto-continuation'])
31
32// Module variables reset on a reload; the clock itself lives in $.state.
33let sid = ''
34let home = ''
35let demo = false
36let detected: 'unknown' | '5m' | '1h' = 'unknown'
37let working = false
38let pinging = false
39
40// The /mods switchboard turns this mod off in every chat with switches.json.
41// "cache-clock:hidden": true in the same file hides the clock in every chat.
42const NAME = 'cache-clock'
43const HIDE_KEY = `${NAME}:hidden`
44let off = false
45let hidden = false
46
47const switchesPath = ($: EngineInterface) => `${$.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''}/.claude/mods/switches.json`
48
49async function readSwitches($: EngineInterface): Promise<Record<string, unknown>> {
50  const path = switchesPath($)
51  if (!(await $.fs.exists(path))) return {}
52  const json = JSON.parse(String(await $.fs.read(path))) as unknown
53  return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record<string, unknown>) : {}
54}
55
56async function readSwitch($: EngineInterface) {
57  let nowOff = off
58  let nowHidden = hidden
59  try {
60    const json = await readSwitches($)
61    nowOff = json[NAME] === false
62    nowHidden = json[HIDE_KEY] === true
63  } catch {
64    // A broken or missing file changes nothing.
65  }
66  if (nowOff === off && nowHidden === hidden) return
67  off = nowOff
68  hidden = nowHidden
69  if (off) await stopWarm($, 'Keep warm stopped: Cache Clock was turned off in /mods.')
70  else if (hidden) await stopWarm($, 'Keep warm stopped: Cache Clock is hidden.')
71  await redraw($)
72}
73
74async function redraw($: EngineInterface) {
75  try {
76    const c = await getClock($)
77    if (c) await setClock($, { ...c, now: await $.clock.now() })
78    $.ui.invalidate('ui.render')
79  } catch {
80    // The next tick redraws anyway.
81  }
82}
83
84// Read, change, write the whole file, so every chat sees the choice within 3 s.
85async function setHidden($: EngineInterface, value: boolean) {
86  try {
87    const json = await readSwitches($)
88    if (value) json[HIDE_KEY] = true
89    else delete json[HIDE_KEY]
90    await $.fs.write(switchesPath($), `${JSON.stringify(json, null, 2)}\n`)
91  } catch (err) {
92    await log($, `save hidden failed: ${String(err)}`)
93  }
94  hidden = value
95  if (value) await stopWarm($, 'Keep warm stopped: Cache Clock is hidden.')
96  await redraw($)
97}
98
99const short = (tokens: number) => `${Math.round(tokens / 1000)}k`
100const mmss = (ms: number) => {
101  const s = Math.max(0, Math.ceil(ms / 1000))
102  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
103}
104const ago = (ms: number) => {
105  const m = Math.floor(ms / 60_000)
106  return m < 1 ? 'just now' : m === 1 ? '1 min ago' : m < 60 ? `${m} min ago` : `${Math.floor(m / 60)} h ago`
107}
108
109/** Where the cache length comes from: Anthropic's own reply, the demo, or a guess before the first reply. */
110function lengthLabel(c: Clock) {
111  if (c.ttlSource === 'demo') return '60 s demo cache, this chat only'
112  if (c.ttlSource === 'detected') return `${c.ttlMs === ONE_HOUR ? '1 hour' : '5 min'} cache, as Anthropic's last reply set it`
113  return '5 min cache, a guess until the first reply'
114}
115
116function ttl(): { ttlMs: number; ttlSource: Clock['ttlSource'] } {
117  if (demo) return { ttlMs: DEMO_MS, ttlSource: 'demo' }
118  if (detected === '1h') return { ttlMs: ONE_HOUR, ttlSource: 'detected' }
119  if (detected === '5m') return { ttlMs: FIVE_MIN, ttlSource: 'detected' }
120  return { ttlMs: FIVE_MIN, ttlSource: 'default' }
121}
122
123async function getClock($: EngineInterface): Promise<Clock | null> {
124  const { value } = await $.state.get(CLOCK)
125  return value ?? null
126}
127
128async function setClock($: EngineInterface, c: Clock | null) {
129  await $.state.set(CLOCK, c)
130}
131
132async function getWarm($: EngineInterface): Promise<Warm | null> {
133  const { value } = await $.state.get(WARM)
134  return value ?? null
135}
136
137async function setWarm($: EngineInterface, w: Warm | null) {
138  await $.state.set(WARM, w)
139  try {
140    $.ui.invalidate('ui.render')
141  } catch {
142    // The next tick redraws anyway.
143  }
144}
145
146function toast($: EngineInterface, text: string) {
147  try {
148    $.ui.toast(text)
149  } catch {
150    // The toast is only a hint.
151  }
152}
153
154// Turns keep warm on. 'none' when there is no reply yet whose cache could be kept.
155async function startWarm($: EngineInterface): Promise<'on' | 'none'> {
156  const c = await getClock($)
157  if (!c || !c.lastEnd) return 'none'
158  // Keep warm never runs out of sight: it shows the clock first.
159  if (hidden) await setHidden($, false)
160  await setWarm($, { startedAt: await $.clock.now(), pings: 0, read: 0, write: 0, cold: false, error: '', sending: false, failures: 0, retryAt: 0 })
161  await log($, `keep warm on (${c.ttlMs / 1000} s cache, ${short(c.tokens)} tokens)`)
162  return 'on'
163}
164
165async function stopWarm($: EngineInterface, why: string, shout = true) {
166  const w = await getWarm($)
167  if (!w) return
168  await setWarm($, null)
169  await log($, `keep warm off after ${w.pings} pings: ${why}`)
170  if (shout) toast($, why)
171}
172
173// Once a second: sends the ping when the cache is 45 s from going cold, and keeps the caps.
174async function warmTick($: EngineInterface) {
175  const w = await getWarm($)
176  if (!w || pinging || working) return
177  if (off) return stopWarm($, 'Keep warm stopped: Cache Clock was turned off in /mods.')
178  const now = await $.clock.now()
179  if (now - w.startedAt >= MAX_MS) return stopWarm($, 'Keep warm stopped after 2 hours. Type /keepwarm to start it again.')
180  if (w.pings >= MAX_PINGS) return stopWarm($, `Keep warm stopped after ${MAX_PINGS} pings. Type /keepwarm to start it again.`)
181  if (now < w.retryAt) return
182  const c = await getClock($)
183  if (!c || !c.lastEnd) return
184  if (c.lastEnd + c.ttlMs - now > LEAD) return
185  pinging = true
186  void ping($, now).catch(async err => {
187    pinging = false
188    await log($, `ping failed: ${String(err)}`)
189  })
190}
191
192async function ping($: EngineInterface, start: number) {
193  const w0 = await getWarm($)
194  if (w0) await setWarm($, { ...w0, sending: true })
195  let r: ModelForkResult | null = null
196  let thrown = ''
197  try {
198    r = await $.model.fork({ prompt: PING_PROMPT })
199  } catch (err) {
200    thrown = String(err)
201  }
202  pinging = false
203  const n = (w0?.pings ?? 0) + 1
204  if (r && !r.isAnswered && r.reason === 'nothing-to-fork') {
205    await log($, `ping ${n}: nothing to fork yet`)
206    return stopWarm($, 'Keep warm stopped: there is no reply to keep warm yet.')
207  }
208  const usage = r && 'usage' in r ? r.usage : null
209  const read = usage?.cache_read_input_tokens ?? 0
210  const write = usage?.cache_creation_input_tokens ?? 0
211  // An answer, or a reply with no words, still read the chat: the cache timer restarted.
212  const ok = r !== null && (r.isAnswered || r.reason === 'empty-reply')
213  const error = ok ? '' : thrown ? 'error' : r && !r.isAnswered && r.reason === 'api-error' ? `${r.error}${r.status ? ` ${r.status}` : ''}` : r && !r.isAnswered ? r.reason : 'error'
214  await log($, `ping ${n}: cache read ${read}, cache write ${write}, error ${error || 'none'}${thrown ? ` (${thrown.slice(0, 200)})` : ''}`)
215  const w = await getWarm($)
216  // Stopped while the ping was out (a prompt, /keepwarm off): only the log keeps it.
217  if (!w) return
218  const now = await $.clock.now()
219  if (ok) {
220    await setWarm($, { ...w, pings: n, read, write, cold: write > read, error: '', sending: false, failures: 0, retryAt: 0 })
221    const c = await getClock($)
222    if (c) await setClock($, { ...c, lastEnd: Math.max(c.lastEnd, start), now })
223  } else {
224    const failures = w.failures + 1
225    await setWarm($, { ...w, pings: n, error, sending: false, failures, retryAt: now + RETRY_MS })
226    if (failures >= MAX_FAILS) return stopWarm($, `Keep warm stopped: ${MAX_FAILS} pings in a row failed (${error}).`)
227  }
228  if (n >= MAX_PINGS) await stopWarm($, `Keep warm stopped after ${MAX_PINGS} pings. Type /keepwarm to start it again.`)
229}
230
231function warmLine(w: Warm, left: number): string {
232  const parts = ['Keeping warm']
233  if (w.sending) parts.push(`sending ping ${w.pings + 1}`)
234  else if (w.pings === 0) parts.push(`first ping in ${mmss(left - LEAD)}`)
235  else parts.push(`ping ${w.pings}`, `next in ${mmss(left - LEAD)}`)
236  if (w.error) parts.push(`last ping failed (${w.error}), trying again`)
237  else if (w.pings > 0 && w.cold) parts.push(`the cache had gone cold, so ping ${w.pings} wrote ${short(w.write)} tokens to it again`)
238  else if (w.pings > 0) parts.push(`last ping read ${short(w.read)} cached tokens`)
239  return parts.join(' · ')
240}
241
242async function log($: EngineInterface, line: string) {
243  try {
244    const path = `${home}/.claude/mods-logs/cache-clock.log`
245    const old = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
246    const lines = old ? old.trimEnd().split('\n').slice(-200) : []
247    lines.push(`${new Date(await $.clock.now()).toISOString()} ${sid.slice(0, 8)} ${line}`)
248    await $.fs.write(path, `${lines.join('\n')}\n`)
249  } catch {
250    // The log must never break the clock.
251  }
252}
253
254// Reads which cache length this chat's replies were written with (5 min or 1 hour).
255async function detect($: EngineInterface) {
256  if (!sid) return
257  // Windows installs often have only `python`; a missing command throws rather than exiting non-zero.
258  const py = (bin: string) => $.process.run([bin, `${$.plugin.root}/bin/ttl.py`, sid]).catch(() => null)
259  let r = await py('python3')
260  if (!r || r.exitCode !== 0) r = (await py('python')) ?? r
261  if (!r) return log($, 'ttl check failed: no python3 or python')
262  const out = r.stdout.trim()
263  if (out === '5m' || out === '1h') detected = out
264  else if (r.exitCode !== 0) await log($, `ttl check failed: ${r.stderr.slice(0, 200)}`)
265}
266
267// A main-chat reply just ended: the cache is warm again from now.
268async function restart($: EngineInterface) {
269  const now = await $.clock.now()
270  const { context } = await $.session.usage()
271  await detect($).catch(() => undefined)
272  await setClock($, { lastEnd: now, ...ttl(), tokens: context.tokens ?? 0, now, note: '' })
273}
274
275// Once a second: moves the countdown while the band shows.
276async function tick($: EngineInterface) {
277  const c = await getClock($)
278  // While a turn runs the band is not drawn, so there is nothing to count.
279  if (!c || off || hidden || working || !c.lastEnd) return
280  const now = await $.clock.now()
281  // A cold clock only needs a redraw once a minute, for its "went cold" line.
282  const cold = now - c.lastEnd >= c.ttlMs
283  if (cold && Math.floor((now - c.lastEnd) / 60_000) === Math.floor((c.now - c.lastEnd) / 60_000) && c.now - c.lastEnd >= c.ttlMs) return
284  await setClock($, { ...c, now })
285}
286
287async function handoff($: EngineInterface) {
288  const c = await getClock($)
289  // The handoff mod picks this file up within 5 seconds.
290  await $.fs.write(`${home}/.claude/handoffs/.request-${sid}`, String(await $.clock.now()))
291  await log($, `asked for a handoff at ${short(c?.tokens ?? 0)}`)
292  if (c) await setClock($, { ...c, note: 'Handoff asked for. Watch the Refresh panel.' })
293}
294
295export const register: Register = on => {
296  on('session.start', async ($, e, next) => {
297    sid = await $.session.id()
298    home = $.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''
299    // The 60 s demo is for one chat only. Older versions saved it for every chat, so clear that.
300    demo = false
301    if ((await $.store.get('demo').catch(() => undefined)) === true) {
302      await $.store.set('demo', false).catch(() => undefined)
303      await log($, 'cleared a demo setting saved by an older version')
304    }
305    await readSwitch($).catch(() => undefined)
306    $.clock.every(3000, () => {
307      void readSwitch($).catch(() => undefined)
308    })
309    await $.command.register({
310      name: 'cacheclock',
311      description: 'Cache Clock: show or hide it. "/cacheclock hide" hides it, "/cacheclock demo" uses a 60 s cache for filming, "/cacheclock auto" goes back',
312      argumentHint: '[hide|demo|auto]',
313      immediate: true,
314    })
315    await $.command.register({
316      name: 'keepwarm',
317      description: 'Cache Clock: keep the cache warm with a small background ping 45 s before it goes cold. "/keepwarm off" stops it',
318      argumentHint: '[off]',
319      immediate: true,
320    })
321    $.clock.every(1000, () => {
322      void tick($).catch(() => undefined)
323      void warmTick($).catch(err => log($, `keep warm tick failed: ${String(err)}`))
324    })
325    void detect($).catch(() => undefined)
326    return next(e)
327  })
328
329  on('turn.start', async ($, e, next) => {
330    if (off) return next(e)
331    // Should the engine ever raise a turn for the ping's own fork, it is not the person's turn.
332    // A helper's turn does not stop the main chat's band, and only a main-loop complete clears this.
333    if (e.agentId === undefined && !(pinging && e.text === PING_PROMPT)) working = true
334    return next(e)
335  })
336
337  on('turn.complete', async ($, e, next) => {
338    // Clear before anything can return early or throw, so the countdown never stays frozen.
339    if (e.agentId === undefined) working = false
340    if (off) return next(e)
341    const result = await next(e)
342    // A helper's turn does not touch the main chat's cache.
343    if (e.agentId === undefined) void restart($).catch(err => log($, `restart failed: ${String(err)}`))
344    return result
345  })
346
347  // The person is back: a prompt of theirs ends keep warm. Its own commands do not.
348  on('prompt.submit', async ($, e, next) => {
349    if (off) return next(e)
350    const text = e.text.trim().toLowerCase()
351    const own = text.startsWith('/keepwarm') || text.startsWith('/cacheclock') || text === PING_PROMPT.toLowerCase()
352    if (!own && !BACKGROUND.has(e.origin?.kind ?? '')) {
353      void stopWarm($, 'Keep warm stopped: you sent a message.').catch(() => undefined)
354    }
355    return next(e)
356  })
357
358  on('command.run', { command: 'keepwarm' }, async ($, e) => {
359    if (off) return { text: 'Cache Clock is off. Turn it on with /mods.' }
360    const arg = e.args.trim().toLowerCase()
361    const w = await getWarm($)
362    if (arg === 'off' || (arg !== 'on' && w)) {
363      await stopWarm($, 'Keep warm is off.', false)
364      return { text: 'Keep warm is off. Type /keepwarm to start it again.' }
365    }
366    if (w) return { text: 'Keep warm is already on. Type /keepwarm off to stop it.' }
367    if ((await startWarm($)) === 'none') return { text: "Nothing to keep warm yet. Keep warm works after Claude's first reply." }
368    return { text: 'Keep warm is on: one small background ping about 45 s before the cache goes cold. It stops after 2 hours, 30 pings, or when you send a message. Type /keepwarm off to stop it.' }
369  })
370
371  on('ui.press', { plugin: 'cache-clock', element: 'keepwarm' }, async ($, e) => {
372    if (off) return { element: e.element }
373    if (await getWarm($)) await stopWarm($, 'Keep warm is off.')
374    else if ((await startWarm($)) === 'on') toast($, 'Keep warm is on. Type /keepwarm off to stop it.')
375    return { element: e.element }
376  })
377
378  on('command.run', { command: 'cacheclock' }, async ($, e) => {
379    if (off) return { text: 'Cache Clock is off. Turn it on with /mods.' }
380    const arg = e.args.trim().toLowerCase()
381    const hide = arg === 'hide' || (arg !== 'demo' && arg !== 'auto' && arg !== 'show' && !hidden)
382    if (arg === 'demo' || arg === 'auto') {
383      // This chat only: a new chat starts with the real cache length again.
384      demo = arg === 'demo'
385    }
386    await setHidden($, hide)
387    const c = await getClock($)
388    if (c) await setClock($, { ...c, ...ttl(), now: await $.clock.now() })
389    if (hide) return { text: 'Cache Clock hidden in every chat. Type /cacheclock or open /mods to show it.' }
390    const t = ttl()
391    const how = lengthLabel({ ttlMs: t.ttlMs, ttlSource: t.ttlSource } as Clock)
392    return { text: `Cache Clock is on: ${how}. Type /cacheclock hide or open /mods to hide it.` }
393  })
394
395  on('ui.press', { plugin: 'cache-clock', element: 'handoff' }, async ($, e) => {
396    if (off) return { element: e.element }
397    await handoff($)
398    return { element: e.element }
399  })
400  on('ui.press', { plugin: 'cache-clock', element: 'hide' }, async ($, e) => {
401    const wasWarm = (await getWarm($)) !== null
402    await stopWarm($, 'Keep warm stopped: Cache Clock is hidden.', false)
403    await setHidden($, true)
404    toast($, `Cache Clock hidden${wasWarm ? ' and keep warm stopped' : ''}. Type /cacheclock or open /mods to show it.`)
405    return { element: e.element }
406  })
407
408  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
409    if (off) return next(e)
410    const c = await getClock($)
411    if (hidden || !c || !c.lastEnd || working || e.props.isWorking || e.props.hasSurvey) return next(e)
412    const rest = await next(e)
413    const { Box, Text, Button } = $.ui.resolve(e)
414    const noop = () => undefined
415    const left = c.lastEnd + c.ttlMs - c.now
416    const bar = barColumns(e.props.bodyColumns)
417    const cost = `${short(c.tokens)} tokens`
418    const w = await getWarm($)
419    const warmButton = w ? <Button key="keepwarm" label="Stop keeping warm" onPress={noop} /> : <Button key="keepwarm" label="Keep warm" onPress={noop} />
420    const warmRow = w ? <Text color={w.cold || w.error ? 'error' : 'suggestion'} wrap="truncate-end">{warmLine(w, left)}</Text> : null
421    const tip = <Text dimColor>{TIP}</Text>
422
423    if (left > 0) {
424      const share = left / c.ttlMs
425      const filled = Math.max(1, Math.round(share * bar))
426      const color = left <= 60_000 ? 'error' : share < 0.5 ? 'warning' : 'success'
427      return (
428        <Box flexDirection="column">
429          {rest}
430          <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
431            <Text wrap="truncate-end">
432              <Text color={color}>◷ Cache warm </Text>
433              <Text weight="bold" color={color}>
434                {mmss(left)}
435              </Text>
436              <Text> </Text>
437              {bar > 0 ? <Text color={color}>{'━'.repeat(filled)}</Text> : null}
438              {bar > 0 ? <Text dimColor>{'━'.repeat(bar - filled)}</Text> : null}
439              <Text dimColor>{` ${lengthLabel(c)}.`}</Text>
440              <Text dimColor> Reply now and {cost} cost 5 to 10% as much.</Text>
441            </Text>
442            <Box flexDirection="row" gap={1}>
443              {warmButton}
444              <Button key="hide" label="Hide" role="dismiss" onPress={noop} />
445            </Box>
446          </Box>
447          {warmRow}
448          {tip}
449        </Box>
450      )
451    }
452
453    // The 60 s demo always shows the button, so it can be filmed in a small chat.
454    const big = c.tokens >= BIG || c.ttlSource === 'demo'
455    return (
456      <Box flexDirection="column">
457        {rest}
458        <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
459          <Text wrap="truncate-end">
460            <Text color="suggestion">❄ Cache cold</Text>
461            <Text dimColor> since {ago(-left)}. Your next message re-reads {cost} at full cost.</Text>
462          </Text>
463          <Box flexDirection="row" gap={1}>
464            {big ? <Button key="handoff" label="Hand off to a fresh chat" variant="primary" onPress={noop} /> : null}
465            {warmButton}
466            <Button key="hide" label="Hide" role="dismiss" onPress={noop} />
467          </Box>
468        </Box>
469        {warmRow}
470        {c.note ? <Text dimColor>{c.note}</Text> : null}
471        {tip}
472      </Box>
473    )
474  })
475}
476
types/index.d.ts 42 lines
1export type Clock = {
2  /** When the last main-chat reply ended, in ms. 0 before the first reply. */
3  lastEnd: number
4  /** How long the cache stays warm after a reply, in ms. */
5  ttlMs: number
6  /** detected: read from this chat's log. default: 5 min guess. demo: 60 s for filming. */
7  ttlSource: 'detected' | 'default' | 'demo'
8  /** Context tokens the next message re-sends. */
9  tokens: number
10  /** The clock's last tick, in ms, so the band redraws every second. */
11  now: number
12  /** A line under the band after a button press. */
13  note: string
14}
15
16export type Warm = {
17  /** When keep warm was turned on, in ms: it stops by itself 2 hours later. */
18  startedAt: number
19  /** Pings sent so far: it stops by itself after 30. */
20  pings: number
21  /** Cache read tokens of the last ping. */
22  read: number
23  /** Cache write tokens of the last ping. */
24  write: number
25  /** True when the last ping wrote the chat to the cache again: it had gone cold. */
26  cold: boolean
27  /** Why the last ping failed, or ''. */
28  error: string
29  /** True while a ping is out. */
30  sending: boolean
31  /** Failed pings in a row: it stops after 3. */
32  failures: number
33  /** No new ping before this time, in ms (after a failure). */
34  retryAt: number
35}
36
37declare module 'claude-code' {
38  interface PluginState {
39    'cache-clock': { clock: Clock | null; warm: Warm | null }
40  }
41}
42