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…

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.
| Pages | Home · Tutorials (/blog/) · Thoughts (/thoughts/) · Publications · Books · Projects & Apps · News & Updates |
| Posts | 135, in 10 series across two catalogs, every one bilingual Thai/English |
| Files | 147 HTML files on disk (146 enumerated by the linter; 404.html is deliberately excluded) |
| Stack | Plain HTML5 + CSS3, zero JavaScript. Google Fonts is the only external dependency |
| Hosting | GitHub Pages (classic Jekyll build) on the custom domain anirach.com, fronted by Cloudflare |
| Build step | None |
| Tests | Four scripts, run before every push — check_site.py alone carries 62 cross-file integrity checks. This is the test suite |
| Page | What it holds |
|---|---|
index.html | Single-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.
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
| Series | Posts | Navigation |
|---|---|---|
| ⧉ AI-Core Functional Workflows | 12 | generated12-chip strip · GitHub code, tests and exercises |
| 🛡️ Engineering AI-Core Systems | 10 | chip strip · generated |
| 💻 Hermes Desktop Hands-On | 7 | chip strip · generated |
| 🧭 AI Transformation for Organizations | 20 | grouped chip strip (Reframe · Redesign · Engineer · Lead) · generated |
| ⚡ Hermes Agent in Practice | 10 | chip strip |
| 🤖 OpenClaw for Organizations | 13 | seven are a numbered chip-strip series; six stand alone with no nav |
| 🔧 DevOps & Vibe Coding | 24 | a single prev/next chain |
Thoughts — /thoughts/, hero reads 3 Series · 39 Essays
| Series | Essays | Navigation |
|---|---|---|
| 🌱 Wisdom for a Good Life | 20 | grouped chip strip, four arcs of five (Self · Mind · Work · Others) · generated |
| 🧭 Working Philosophies | 10 | chip strip, one working week Monday to Friday · generated |
| 🌅 Life Thought & Philosophy | 9 | chip 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.
.
├── 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)
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.
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.serverdoes not and returns 404. That is not a regression.
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.
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=$RANDOMto 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.
Two measured costs were paid down on 2026-09-10, and both are easy to reintroduce:
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.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).
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.
Four skills in .claude/skills/ carry the verified detail:
| Skill | Covers |
|---|---|
| page-design | The house visual system — canonical tokens, type scale, component vocabulary, approved hero gradients, and the anti-patterns this repo has been burned by |
| blog-post | Adding, editing or removing a post without breaking the hand-wired navigation; plus the Thai essay voice brief |
| a11y-perf | Accessibility and performance rules with this site's real measured numbers |
| site-check | The 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.
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.
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.
hooks/register.tsx 473 lines1import 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}
473types/index.d.ts 42 lines1export 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