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
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 to 10% as much. 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 97k tokens cost 5 to 10% as much. 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

anirach.com

Current publication inventory — 2026-10-24: 135 bilingual posts across10 series (7 Tutorials /96 articles,3 Thoughts /39 essays),147 HTML files (146 enumerated), 466 images,135 feed items and146 sitemap URLs. AI-Core Functional Workflows adds twelve lessons paired with Anirach/ai-core-functional-tutorial,108 offline tests and twelve exercises. Earlier dated measurements below retain their historical context.

The personal site of Dr. Anirach Mingkhwan — Associate Professor at King Mongkut's University of Technology North Bangkok (KMUTNB), PhD (Liverpool John Moores University, UK), researcher in Agentic AI, computer networks, and big data analytics.

🌐 Live: anirach.com

A hand-written static site — seven top-level pages, four per-book detail pages, and 135 self-contained blog posts catalogued on two pages: Tutorials (/blog/, seven technical series) and Thoughts (/thoughts/, thirty-nine essays on living well and working well). No build system, no package manager, no dependencies, no JavaScript framework. Push to main and GitHub Pages publishes it.


At a glance

PagesHome · Tutorials (/blog/) · Thoughts (/thoughts/) · Publications · Books · Projects & Apps · News & Updates
Posts135, in 10 series across two catalogs, every one bilingual Thai/English
Files147 HTML files on disk (146 enumerated by the linter; 404.html is deliberately excluded)
StackPlain HTML5 + CSS3, zero JavaScript. Google Fonts is the only external dependency
HostingGitHub Pages (classic Jekyll build) on the custom domain anirach.com, fronted by Cloudflare
Build stepNone
TestsFour scripts, run before every push — check_site.py alone carries 62 cross-file integrity checks. This is the test suite

The seven pages

PageWhat it holds
index.htmlSingle-scroll consultant profile: hero, at-a-glance stats, six services, engagement approach, selected work, about and career, research, free resources, latest news with the featured book, contact
blog/The Tutorials catalog — 96 cards in seven technical series, plus all 135 post files
thoughts/The Thoughts catalog — 39 essay cards in three series, each linking ../blog/<slug>.html
publications/The academic record: the Springer book Libraries in Transformation, 8 book chapters, and a selected-publications table
books/Books & writing: four works — One Day of Light (the free last-lecture event book, EN/TH editions with free PDF downloads served from books/), the published novel Three Old Men: The Last Conversation, and two complete bilingual manuscripts — each with its own detail page
projects/Live apps (each verified working before it ships) and research-code repositories
news/Reverse-chronological timeline of publications, talks and appointments, plus a career timeline

The posts never move. Every post is blog/<slug>.html, including the 39 essays that /thoughts/ catalogues — the 2026-09-08 split separated the two catalogs, not the files.

books/ was one "Books & Writing" page until 2026-08-23, when it split: academic content moved to the new publications/, and books/ became the fiction section with one detail page per book. On 2026-08-24 it gained a fourth work, the Last Lecture companion book One Day of Light, whose EN/TH PDFs are downloadable for free from books/.

script.js is gone — deleted 2026-08-26. The site loads no executable JavaScript at all, and check_site.py INV-38 fails the build on any <script> that is not application/ld+json (schema.org metadata, which the browser parses as data and never executes — 128 pages carry one). Each of the script's four jobs has a CSS replacement in style.css: the scroll-reveal and the nav scroll state are scroll-driven CSS animations (animation-timeline: view() / scroll(root)), the landing page's mobile menu is .nav__links:target, and anchor scrolling is native scroll-behavior: smooth. The other pages' mobile menu is a pure-CSS checkbox toggle — a second mechanism, also without JavaScript.

The two catalogs

Both are static, zero-JavaScript and generated by scripts/reindex_blog.py — re-run it rather than hand-ordering cards. Each is a hero, a sticky chip strip, a "Start here" feature, a row of series tiles, then one numbered-row-card section per series. There is no filtering, sorting or search UI.

Tutorials — /blog/, hero reads 7 Series · 96 Articles

SeriesPostsNavigation
⧉ AI-Core Functional Workflows12generated12-chip strip · GitHub code, tests and exercises
🛡️ Engineering AI-Core Systems10chip strip · generated
💻 Hermes Desktop Hands-On7chip strip · generated
🧭 AI Transformation for Organizations20grouped chip strip (Reframe · Redesign · Engineer · Lead) · generated
⚡ Hermes Agent in Practice10chip strip
🤖 OpenClaw for Organizations13seven are a numbered chip-strip series; six stand alone with no nav
🔧 DevOps & Vibe Coding24a single prev/next chain

Thoughts — /thoughts/, hero reads 3 Series · 39 Essays

SeriesEssaysNavigation
🌱 Wisdom for a Good Life20grouped chip strip, four arcs of five (Self · Mind · Work · Others) · generated
🧭 Working Philosophies10chip strip, one working week Monday to Friday · generated
🌅 Life Thought & Philosophy9chip strip, one day in three parts (Morning · Noon · Twilight)

Five of the nine series are generated from a manifest plus per-post content sheets: python3 scripts/build_series.py --series <name>, with manifests in scripts/series/ (ai-core, ai-transformation, good-life, hermes-desktop, working). For those, the manifest and the sheets are the edit surface — never the emitted post.

Every post is bilingual. All 123 carry a pure-CSS Thai ⇄ English switch: a ไทย · English pill in the hero swaps between two complete tracks of the same article. Thai is the default, there is no JavaScript, and the page-level <html lang="th"> never changes — the English track is lang="en" inside it. The seven top-level pages and the four book detail pages are lang="en" chrome and wrap their Thai passages in <span lang="th">.

The Thoughts essays are written in a different register from the tutorials — literary Thai in the author's own voice, not code-switched technical prose. The brief, with ten rules and worked before/after pairs, is .claude/skills/blog-post/references/thai-essay-voice.md.

Repository structure

.
├── index.html          # Landing page — the ONLY file that loads style.css
├── style.css           # Landing-page styles; carries the canonical :root token block
├── _config.yml         # Jekyll: keeps internal working docs out of the published site
├── CNAME               # anirach.com
├── sitemap.xml         # 134 URLs, generated · feed.xml — 123 items, generated · llms.txt · robots.txt
├── blog/               # index.html (the Tutorials catalog, 96 cards) + all 123 self-contained posts
├── thoughts/           # index.html (the Thoughts catalog — 39 essay cards linking ../blog/)
├── books/              # index.html + 4 per-book detail pages + 3 free PDFs, all self-contained
├── publications/  projects/  news/   # one self-contained index.html each
├── images/             # 431 files — 124 covers (800×800) + 8 book faces, 123 card thumbs (144×144),
│                       #   130 share cards (1200×630), diagrams, series figures, posters, profile photo
├── scripts/            # 13 generators and sweeps (build_series, reindex_blog, gen_feed, gen_sitemap,
│                       #   make_cover, make_figure, bilingualize, check_visibility, …)
├── docs/               # design spec, implementation plan, OpenClaw runbook (not published)
└── .claude/skills/     # the four maintenance skills (not published)

Architecture: every page is an island

The single most important fact before editing:

index.html ──▶ style.css                 (the ONLY consumer of it)

blog/index.html   ──▶ its own <style> block, no JS
blog/<post>.html  ──▶ its own <style> block, no JS     × 123
thoughts/ books/ (index + 4 detail) publications/ projects/ news/ 404.html ──▶ same   × 10

Every page embeds its complete stylesheet. There is no shared partial, template engine or token file, so a "global" change means editing N files by hand — and nothing tells you when file 23 of 135 got missed. That is what the linter is for. The compensating upside: blast radius is exactly one file.

Local development

No install, no build. Serve the repository root:

python3 -m http.server 8000     # → http://localhost:8000

Serve the root, not blog/. Every blog page references images as ../images/….

Known local-dev trap. The eight chip-strip series — 93 posts in all — link to each other with root-absolute, extensionless URLs (/blog/openclaw-101). GitHub Pages resolves those; http.server does not and returns 404. That is not a regression.

Verification

Run all four from the repo root before pushing. All must pass:

python3 .claude/skills/site-check/scripts/check_site.py      # 62 cross-file integrity checks
python3 scripts/check_visibility.py --strict                 # search/AI-visibility, JSON-LD, metadata
python3 docs/openclaw/check-news-sync.py                     # news↔homepage sync, provenance, 4 counters
python3 .claude/skills/blog-post/assets/verify-wiring.py     # blog post wiring

check_site.py runs 62 checks — 43 fail-severity, 17 warn, 2 info — and exits 0 when no fail-severity violation appears. Its baseline of pre-existing debt is empty since 2026-08-26 — the tree is clean (0 new, 0 known), so any violation it prints is new and any fail-severity one blocks the push. INV-25 fails the build if a baseline entry is ever added and goes stale, so the safety net cannot silently rot. INV-26 (added with the books split) ties every section-directory detail page to its own index.html: an orphan detail page, or an index card linking a file that does not exist, fails the build.

check-news-sync.py verifies the homepage strip mirrors the three newest news items, that every news item carries a <!-- source: … --> provenance comment, and that all four hand-typed counters match reality: news/index.html "7 updates", publications/index.html "8 chapters", and books/index.html "1 novel" and "2 complete" (it strips HTML comments first, so a commented-out card cannot satisfy a counter).

feed.xml and sitemap.xml are generated, not hand-written — scripts/gen_feed.py and scripts/gen_sitemap.py. INV-31 and INV-32 fail the build when either drifts from the catalogs. Run the sitemap in the commit after a content change, so the git lastmod dates exist.

Deployment

git push origin main

That is the whole pipeline; GitHub Pages rebuilds in 1–3 minutes. _config.yml excludes docs/ and CLAUDE.md from the published output — they stay in the repository but are not served. Anything beginning with . or _ (including .claude/) is omitted by Jekyll automatically.

Cloudflare caches HTML for 10 minutes and assets for 4 hours. After a deploy, append ?cb=$RANDOM to check the real state before diagnosing a problem that is not there — and remember your own browser holds its copy for the same 10 minutes, so a stale tab needs a hard reload rather than a bug report.

Performance notes

Two measured costs were paid down on 2026-09-10, and both are easy to reintroduce:

  • **Fonts are requested with the variable range syntax. Inter:wght@300..900 gets one variable file; the semicolon form Inter:wght@300;400;…;900 makes the API serve one static file per weight — 7 files and 331 KB for the latin subset against 1 file and 47 KB. Switching saved 189 KB per page, 56% of the font bytes. Sarabun keeps the semicolon form**: it has no variable version, a range request returns HTTP 400, and all five of its static weights are used.
  • Catalog cards ship a 144px derivative through srcset. The row card renders its cover in a 72px box, so an 800×800 source was ~11× oversampled. /blog/ went from 3.63 MB to 0.42 MB, /thoughts/ from 2.11 MB to 0.38 MB.

Both are documented with their measurements in the a11y-perf skill (R8b and R1b).

Adding a new blog post

A post is not one file — it is a file plus a cover plus a share card plus a card plus counters plus its neighbours' nav links plus a feed item, a sitemap entry and an llms.txt line, in two language tracks, with no build step to catch a miss. The full recipe, including which of the four navigation patterns applies, is in .claude/skills/blog-post/SKILL.md. Start from .claude/skills/page-design/assets/post-template.html — the repo's single canonical template. For one of the five generated series, add a manifest row and run build_series.py instead; never hand-edit those posts.

Maintenance

Four skills in .claude/skills/ carry the verified detail:

SkillCovers
page-designThe house visual system — canonical tokens, type scale, component vocabulary, approved hero gradients, and the anti-patterns this repo has been burned by
blog-postAdding, editing or removing a post without breaking the hand-wired navigation; plus the Thai essay voice brief
a11y-perfAccessibility and performance rules with this site's real measured numbers
site-checkThe linter, what every check means, and how to repair each failure

Every number in those skills is load-bearing: a change that invalidates one must update it in the same commit. A confidently wrong measurement is worse than none, because it gets acted on.

News updates have their own runbook at docs/openclaw/latest-updates-runbook.md, written for an autonomous agent. Its gate enforces that every news item carries a verified source — no structural check can tell a true item from an invented one, so instead an item cannot exist without a stated source.

Contact

Open to research collaborations, speaking engagements, and academic partnerships.


No LICENSE file is present. Content and code are © Anirach Mingkhwan; all rights reserved by default.

AI-Core Functional Workflows

Twelve bilingual lessons and the matching code are maintained together: Anirach/ai-core-functional-tutorial. The offline lab ZIP is downloads/ai-core-functional-lab.zip. The series manifest is scripts/series/ai-core-functional.json; article source links pin the reviewed companion commit. Default fixtures require no SDK install, API key or paid calls. Run the companion tests/advertised commands and the four website checks when revising. No additional reuse license is granted by the companion's public availability.

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