At 250k tokens, offers Refresh: writes a handoff of the whole chat, shows the steps with progress bars, then copies it or starts a new chat with it. Logs every…

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 768 lines1import type { EngineInterface, Register, SessionMessage } from 'claude-code'
2
3import type { Panel, Step } from '../types'
4
5// One key for the whole band: offer, steps, buttons.
6const PANEL = { plugin: 'handoff', key: 'panel' } as const
7
8// The Refresh offer opens at this many tokens. Closing a finished handoff moves it up by SNOOZE.
9// Chats auto-compact near 300k to 324k, so the offer must open well before that.
10const OFFER_AT = 250_000
11const SNOOZE = 100_000
12const POLL_MS = 5000
13const TICK_MS = 110
14// A quick step still shows its bar filling for this long, so the rows read one by one.
15const MIN_STEP_MS = 650
16// The handoff step's bar creeps toward full on this time scale while the model writes.
17const WRITE_SCALE_MS = 40_000
18const BAR = 18
19
20const ACCENT = '#d97757'
21const GOOD = '#16a34a'
22const BAD = '#dc2626'
23const SPIN = ['◐', '◓', '◑', '◒']
24
25const LABELS = [
26 'Reading the conversation',
27 'Finding the files you used',
28 'Listing skills, tools and links',
29 'Writing the handoff',
30 'Saving and checking',
31]
32
33// Module variables reset on a reload; the band itself lives in $.state.
34let sid = ''
35let home = ''
36let petDir = ''
37let busy = false
38// The model asked for a refresh in the middle of its turn: it starts when the turn ends,
39// because a fork while a tool call is open would send a tool use with no result.
40let pending = false
41let snoozedTo = OFFER_AT
42let handoffText = ''
43let handoffPath = ''
44let ticker: { cancel: () => void } | null = null
45let writeStartedAt = 0
46let writeMs = 0
47let running = -1
48let stepStartedAt = 0
49
50// The /mods switchboard turns this mod off in every chat with switches.json.
51// "handoff:hidden": true in the same file stops the automatic Refresh offer in every chat.
52// Show (/refresh, or /mods leaving .open-handoff-<sessionId>) brings the offer up now.
53const NAME = 'handoff'
54const HIDE_KEY = `${NAME}:hidden`
55let off = false
56let hidden = false
57// The offer was asked for (Show or /refresh): it stays whatever the token count.
58let forced = false
59
60const switchesPath = ($: EngineInterface) => `${$.plugin.root.split('/.claude/')[0] ?? ''}/.claude/mods/switches.json`
61
62async function readSwitches($: EngineInterface): Promise<Record<string, unknown>> {
63 const path = switchesPath($)
64 if (!(await $.fs.exists(path))) return {}
65 const json = JSON.parse(String(await $.fs.read(path))) as unknown
66 return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record<string, unknown>) : {}
67}
68
69async function readSwitch($: EngineInterface) {
70 let nowOff = off
71 let nowHidden = hidden
72 try {
73 const json = await readSwitches($)
74 nowOff = json[NAME] === false
75 nowHidden = json[HIDE_KEY] === true
76 } catch {
77 // A broken or missing file changes nothing.
78 }
79 if (nowOff === off && nowHidden === hidden) return
80 off = nowOff
81 const hiding = nowHidden && !hidden
82 hidden = nowHidden
83 if (hiding) await dropOffer($)
84 try {
85 $.ui.invalidate('ui.render')
86 } catch {
87 // The next state change redraws anyway.
88 }
89}
90
91// Hidden: an offer on screen goes; the steps and a finished handoff stay.
92async function dropOffer($: EngineInterface) {
93 forced = false
94 const p = await getPanel($)
95 if (p?.phase === 'offer') await setPanel($, null)
96}
97
98// Read, change, write the whole file, so every chat sees the choice within 3 s.
99async function setHidden($: EngineInterface, value: boolean) {
100 try {
101 const json = await readSwitches($)
102 if (value) json[HIDE_KEY] = true
103 else delete json[HIDE_KEY]
104 await $.process.run(['mkdir', '-p', `${home}/.claude/mods`])
105 await $.fs.write(switchesPath($), `${JSON.stringify(json, null, 2)}\n`)
106 } catch (err) {
107 await log($, `save hidden failed: ${String(err)}`)
108 }
109 hidden = value
110 if (value) await dropOffer($)
111}
112
113// Brings the Refresh offer up now, whatever the token count.
114async function showOffer($: EngineInterface) {
115 const p = await getPanel($)
116 // Steps running, or a handoff ready or failed: that panel is already up.
117 if (busy || (p && p.phase !== 'offer')) return
118 const { context } = await $.session.usage()
119 forced = true
120 await setPanel($, { phase: 'offer', tokens: context.tokens ?? 0, steps: freshSteps(), tick: 0, file: '', words: 0, error: '', note: '' })
121}
122
123// The /mods switchboard asks for the offer by leaving an empty file for this chat.
124async function checkOpen($: EngineInterface) {
125 if (!sid) return
126 const path = `${home}/.claude/mods/.open-${NAME}-${sid}`
127 if (!(await $.fs.exists(path).catch(() => false))) return
128 await $.process.run(['rm', '-f', path])
129 if (!off) await showOffer($)
130}
131
132async function poll($: EngineInterface) {
133 await readSwitch($)
134 await checkOpen($).catch(err => log($, `open request failed: ${String(err)}`))
135}
136
137const short = (tokens: number) => `${Math.round(tokens / 1000)}k`
138const tilde = (p: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p)
139const plural = (n: number, word: string) => `${n.toLocaleString('en-US')} ${word}${n === 1 ? '' : 's'}`
140
141function freshSteps(): Step[] {
142 return LABELS.map(label => ({ label, detail: '', status: 'wait', progress: 0 }))
143}
144
145async function getPanel($: EngineInterface): Promise<Panel | null> {
146 const { value } = await $.state.get(PANEL)
147 return value ?? null
148}
149
150async function setPanel($: EngineInterface, p: Panel | null) {
151 await $.state.set(PANEL, p)
152}
153
154async function patchStep($: EngineInterface, i: number, change: Partial<Step>) {
155 const p = await getPanel($)
156 if (!p) return
157 const steps = p.steps.map((s, k) => (k === i ? { ...s, ...change } : s))
158 await setPanel($, { ...p, steps })
159}
160
161// Moves the spinner and the running step's bar.
162async function tick($: EngineInterface) {
163 const p = await getPanel($)
164 if (!p || p.phase !== 'working') return
165 const now = await $.clock.now()
166 const steps = p.steps.map((s, k) => {
167 if (k !== running || s.status !== 'run') return s
168 if (k === 3) {
169 const t = now - writeStartedAt
170 return { ...s, progress: Math.min(0.95, 1 - Math.exp(-t / WRITE_SCALE_MS)), detail: `${Math.round(t / 1000)} s` }
171 }
172 return { ...s, progress: Math.min(0.9, (now - stepStartedAt) / MIN_STEP_MS) }
173 })
174 await setPanel($, { ...p, steps, tick: p.tick + 1 })
175}
176
177// Runs one step: its work, and at least MIN_STEP_MS of bar.
178async function step<T>($: EngineInterface, i: number, work: () => Promise<T>, detail: (v: T) => string): Promise<T> {
179 running = i
180 stepStartedAt = await $.clock.now()
181 if (i === 3) writeStartedAt = stepStartedAt
182 await patchStep($, i, { status: 'run', progress: 0.02 })
183 const value = await work()
184 const left = MIN_STEP_MS - ((await $.clock.now()) - stepStartedAt)
185 if (left > 0 && i !== 3) await $.clock.sleep(left)
186 await patchStep($, i, { status: 'done', progress: 1, detail: detail(value) })
187 return value
188}
189
190type Gathered = { messages: SessionMessage[]; tools: number; files: string[]; skills: string[]; links: string[] }
191
192const PATH_RE = /(?:~|\/Users)\/[^\s'"`<>()|;,*]+/g
193const URL_RE = /https?:\/\/[^\s'"`<>()\]]+/g
194
195async function gatherFiles($: EngineInterface, messages: SessionMessage[]) {
196 const count = new Map<string, number>()
197 const add = (raw: string, weight: number) => {
198 let p = raw.replace(/[.:]+$/, '')
199 if (p.startsWith('~/')) p = home + p.slice(1)
200 if (p.includes('/.claude/projects/') || p.includes('/tool-results/') || p.startsWith('/private/tmp') || p.startsWith('/tmp')) return
201 count.set(p, (count.get(p) ?? 0) + weight)
202 }
203 for (const m of messages) {
204 for (const u of m.toolUses) {
205 const fp = u.input.file_path ?? u.input.notebook_path ?? u.input.path
206 if (typeof fp === 'string' && fp.startsWith('/')) add(fp, u.tool === 'Read' ? 2 : 3)
207 if (u.tool === 'Bash' && typeof u.input.command === 'string') {
208 for (const hit of u.input.command.match(PATH_RE) ?? []) add(hit, 1)
209 }
210 }
211 }
212 const ranked = [...count.entries()].sort((a, b) => b[1] - a[1]).map(([p]) => p)
213 const kept: string[] = []
214 for (const p of ranked) {
215 if (kept.length >= 30) break
216 if (await $.fs.exists(p).catch(() => false)) kept.push(p)
217 }
218 return kept
219}
220
221function gatherSkills(messages: SessionMessage[]) {
222 const skills = new Set<string>()
223 const servers = new Set<string>()
224 for (const m of messages) {
225 for (const u of m.toolUses) {
226 if (u.tool === 'Skill' && typeof u.input.skill === 'string') skills.add(`/${u.input.skill}`)
227 if (u.tool.startsWith('mcp__')) servers.add(u.tool.split('__')[1] ?? u.tool)
228 }
229 }
230 return [...skills, ...[...servers].map(s => `MCP ${s}`)]
231}
232
233function gatherLinks(messages: SessionMessage[]) {
234 const links = new Set<string>()
235 for (const m of messages) {
236 for (const hit of m.text.match(URL_RE) ?? []) {
237 const url = hit.replace(/[.,:;]+$/, '')
238 if (!/localhost|127\.0\.0\.1|example\.com/.test(url)) links.add(url)
239 }
240 }
241 return [...links].slice(-20)
242}
243
244function forkPrompt(cwd: string, g: Gathered) {
245 const list = (xs: string[]) => (xs.length ? xs.map(x => `- ${x}`).join('\n') : '- none found')
246 return `Write a handoff prompt for a fresh Claude Code session that continues this work. Do not call any tool. Reply with the prompt only: no preamble, no code fence around it.
247
248The reader starts with an empty context. It can open files on this machine, but it cannot see this chat. It loads CLAUDE.md, AGENTS.md and its memory index by itself, so do not repeat those.
249
250Facts the handoff mod pulled from this transcript (check them against what you remember; drop what does not matter for the next step):
251Working directory: ${cwd}
252Files read or changed, most used first:
253${list(g.files.map(tilde))}
254Skills and MCP servers used:
255${list(g.skills)}
256Links in the chat:
257${list(g.links)}
258
259Use this shape in plain, short English. Leave out a section that has nothing in it.
260
261# Handoff: <the goal, in a few words>
262
263Continue work in \`${tilde(cwd)}\`. Goal: <what the user is working toward right now>.
264
265## What the user asked for
266Every request in this chat, in order, with the user's own key words. Mark each one: done, in progress, or not started.
267
268## Where things stand
269What is done and checked, the key decisions and why, and what was tested and what was not.
270
271## Rules the user set
272Standing constraints and preferences given in this chat: words to avoid, things never to do, approval steps, tone.
273
274## Files to open first
275- \`<path>\`: why it matters, in one line. Only files the next step needs, at most 15. Paths only; never copy file content.
276
277## Skills and tools to use
278- \`<skill, CLI or connector>\`: when to use it, and any trap found in this chat.
279
280## Errors already solved
281- <the error>: <the fix>. So the next session does not hit them again.
282
283## Carried-over data
284Only data that exists nowhere on disk: ids, URLs, numbers and decisions that live only in this chat. Verbatim.
285
286## Next steps
2871. The very next action, specific enough to start without asking a question.
288
289Never write an API key, token or password; write where it lives instead (for example "in ~/.zshrc"). Keep it under 1,500 words.`
290}
291
292// Anything shaped like a secret is cut out before the handoff is saved or copied.
293function redact(text: string) {
294 return text
295 .replace(/\b(sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|xox[abp]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,})\b/g, '<secret removed>')
296 .replace(/(Bearer\s+)[A-Za-z0-9._-]{20,}/g, '$1<secret removed>')
297}
298
299function slug(text: string) {
300 const title = /^#\s*Handoff:\s*(.+)$/m.exec(text)?.[1] ?? 'session'
301 return (
302 title
303 .toLowerCase()
304 .replace(/[^a-z0-9]+/g, '-')
305 .replace(/^-|-$/g, '')
306 .slice(0, 48) || 'session'
307 )
308}
309
310async function stamp($: EngineInterface) {
311 const d = new Date(await $.clock.now())
312 const pad = (n: number) => String(n).padStart(2, '0')
313 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
314}
315
316// Every run leaves a line here, so a failure is never silent again.
317async function log($: EngineInterface, line: string) {
318 try {
319 const path = `${home}/.claude/handoffs/handoff.log`
320 const old = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
321 const at = new Date(await $.clock.now()).toISOString()
322 const lines = old ? old.trimEnd().split('\n').slice(-400) : []
323 lines.push(`${at} ${sid.slice(0, 8)} ${line}`)
324 await $.fs.write(path, `${lines.join('\n')}\n`)
325 } catch {
326 // The log must never break a run.
327 }
328}
329
330// A plain handoff built from the transcript alone, saved before the model step,
331// so a failed or slow model call still leaves something to start from.
332function draftText(cwd: string, g: Gathered) {
333 const asks = g.messages
334 .filter(m => m.role === 'user' && m.text.trim() && !m.text.trim().startsWith('<'))
335 .slice(-25)
336 .map(m => `- ${m.text.trim().replace(/\s+/g, ' ').slice(0, 300)}`)
337 const list = (xs: string[]) => (xs.length ? xs.map(x => `- ${x}`).join('\n') : '- none found')
338 return `# Handoff draft (no model step)
339
340Continue work in \`${tilde(cwd)}\`. This draft was saved before the model wrote the full handoff. Read the requests below, open the files, then ask the user what is next.
341
342## What the user asked for (newest last)
343${asks.join('\n') || '- none found'}
344
345## Files to open first
346${list(g.files.slice(0, 15).map(f => `\`${tilde(f)}\``))}
347
348## Skills and tools used
349${list(g.skills)}
350
351## Links in the chat
352${list(g.links)}
353`
354}
355
356async function run($: EngineInterface) {
357 if (busy) return
358 busy = true
359 let draftPath = ''
360 const { context } = await $.session.usage()
361 await setPanel($, { phase: 'working', tokens: context.tokens ?? 0, steps: freshSteps(), tick: 0, file: '', words: 0, error: '', note: '' })
362 ticker?.cancel()
363 ticker = $.clock.every(TICK_MS, () => {
364 void tick($).catch(() => undefined)
365 })
366 try {
367 const cwd = await $.session.cwd()
368 const messages = await step(
369 $,
370 0,
371 () => $.session.messages(),
372 ms => `${plural(ms.length, 'message')}`,
373 )
374 const tools = messages.reduce((n, m) => n + m.toolUses.length, 0)
375 const files = await step($, 1, () => gatherFiles($, messages), fs => plural(fs.length, 'file'))
376 const found = await step(
377 $,
378 2,
379 async () => ({ skills: gatherSkills(messages), links: gatherLinks(messages) }),
380 f => `${plural(f.skills.length, 'skill')}, ${plural(f.links.length, 'link')}`,
381 )
382 const g: Gathered = { messages, tools, files, ...found }
383 await $.process.run(['mkdir', '-p', `${home}/.claude/handoffs`])
384 draftPath = `${home}/.claude/handoffs/${await stamp($)}-draft.md`
385 const draft = redact(draftText(cwd, g))
386 await $.fs.write(draftPath, draft)
387 handoffText = draft
388 handoffPath = draftPath
389 await log($, `start ${short(context.tokens ?? 0)} ${messages.length} messages, draft ${tilde(draftPath)}`)
390 const reply = await step(
391 $,
392 3,
393 async () => {
394 const r = await $.model.fork({ prompt: forkPrompt(cwd, g) })
395 writeMs = (await $.clock.now()) - writeStartedAt
396 return r
397 },
398 r => (r.isAnswered ? `${Math.round(writeMs / 1000)} s` : 'failed'),
399 )
400 if (!reply.isAnswered) {
401 throw new Error(reply.reason === 'api-error' ? `the model call failed (${reply.status ?? '?'})` : `no handoff came back (${reply.reason})`)
402 }
403 const text = redact(reply.text.trim().replace(/^```(?:markdown|md)?\n([\s\S]*)\n```$/, '$1'))
404 const saved = await step(
405 $,
406 4,
407 async () => {
408 const dir = `${home}/.claude/handoffs`
409 await $.process.run(['mkdir', '-p', dir])
410 const path = `${dir}/${await stamp($)}-${slug(text)}.md`
411 await $.fs.write(path, `${text}\n`)
412 const back = String(await $.fs.read(path))
413 if (back.trim() !== text) throw new Error('the saved file does not match')
414 return path
415 },
416 p => tilde(p).split('/').pop() ?? '',
417 )
418 handoffText = text
419 handoffPath = saved
420 const p = await getPanel($)
421 if (p) await setPanel($, { ...p, phase: 'ready', file: tilde(saved), words: text.split(/\s+/).length })
422 if (sid) await $.store.set(`last:${sid}`, { path: saved })
423 await log($, `ok ${tilde(saved)} ${text.split(/\s+/).length} words in ${Math.round(writeMs / 1000)} s`)
424 // The full handoff replaces the draft.
425 if (draftPath) await $.process.run(['rm', '-f', draftPath])
426 } catch (err) {
427 const p = await getPanel($)
428 const steps = (p?.steps ?? freshSteps()).map((s, k) => (k === running && s.status === 'run' ? { ...s, status: 'fail' as const } : s))
429 const why = String(err instanceof Error ? err.message : err).slice(0, 160)
430 await setPanel($, {
431 ...(p as Panel),
432 steps,
433 phase: 'failed',
434 error: why,
435 file: draftPath ? tilde(draftPath) : '',
436 note: draftPath ? 'A basic draft is saved. Open it or copy it, or try again.' : '',
437 })
438 await log($, `failed at step ${running + 1}: ${why}`)
439 } finally {
440 ticker?.cancel()
441 ticker = null
442 running = -1
443 busy = false
444 }
445}
446
447async function note($: EngineInterface, text: string) {
448 const p = await getPanel($)
449 if (p) await setPanel($, { ...p, note: text })
450}
451
452async function copy($: EngineInterface) {
453 if (!handoffText) return
454 const r = await $.process.run(['pbcopy'], { stdin: handoffText })
455 await note($, r.exitCode === 0 ? 'Copied. Paste it into a new chat with ⌘V.' : 'Copy failed. Use Open file instead.')
456}
457
458async function petAlive($: EngineInterface) {
459 try {
460 const j = JSON.parse(String(await $.fs.read(`${petDir}/pet.json`))) as { at?: number }
461 return (await $.clock.now()) - (j.at ?? 0) < 10_000
462 } catch {
463 return false
464 }
465}
466
467// Claude Pet starts the chat in the app (Bypass, its Send button) and stays on it.
468// Without the pet, the app's own link fills a new chat and you press Return.
469async function startNew($: EngineInterface) {
470 if (!handoffText) return
471 const cwd = await $.session.cwd()
472 if (await petAlive($)) {
473 await note($, 'Starting the new chat…')
474 const id = `handoff-${await $.clock.now()}`
475 await $.fs.write(
476 `${petDir}/requests/${id}.json`,
477 JSON.stringify({ id, at: await $.clock.now(), action: 'newchat', text: handoffText, folder: cwd, stay: true }),
478 )
479 const done = `${petDir}/requests/${id}.done`
480 for (let k = 0; k < 120; k += 1) {
481 await $.clock.sleep(500)
482 if (await $.fs.exists(done)) {
483 const answer = String(await $.fs.read(done)).trim()
484 await $.process.run(['rm', '-f', done])
485 await note($, answer)
486 return
487 }
488 }
489 await note($, 'Claude Pet did not answer. Use Copy handoff prompt instead.')
490 return
491 }
492 const url = `claude://code/new?q=${encodeURIComponent(handoffText)}`
493 const r = await $.process.run(['open', url])
494 await note($, r.exitCode === 0 ? 'A new chat is filled in with the handoff. Press Return there to start it.' : 'The new chat did not open. Use Copy handoff prompt instead.')
495}
496
497async function checkOffer($: EngineInterface) {
498 if (busy || off) return
499 // Cache Clock (another mod) asks for a handoff by leaving this file.
500 const request = `${home}/.claude/handoffs/.request-${sid}`
501 if (sid && (await $.fs.exists(request).catch(() => false))) {
502 await $.process.run(['rm', '-f', request])
503 await log($, 'asked by Cache Clock')
504 void run($).catch(() => undefined)
505 return
506 }
507 const p = await getPanel($)
508 if (p && p.phase !== 'offer') return
509 // An offer asked for by Show stays until it is answered.
510 if (forced && p) return
511 forced = false
512 if (hidden) {
513 if (p) await setPanel($, null)
514 return
515 }
516 const { context } = await $.session.usage()
517 const tokens = context.tokens ?? 0
518 // A compaction or /clear brings the count down: the offer starts over.
519 if (tokens < OFFER_AT && snoozedTo > OFFER_AT) {
520 snoozedTo = OFFER_AT
521 if (sid) await $.store.set(`snooze:${sid}`, snoozedTo)
522 }
523 if (tokens >= snoozedTo) {
524 if (!p || Math.abs(p.tokens - tokens) >= 1000) {
525 await setPanel($, { phase: 'offer', tokens, steps: freshSteps(), tick: 0, file: '', words: 0, error: '', note: '' })
526 }
527 } else if (p) {
528 await setPanel($, null)
529 }
530}
531
532async function close($: EngineInterface) {
533 const p = await getPanel($)
534 if (p?.phase === 'offer') {
535 // Hide on the offer: it stops popping up in every chat until Show or /refresh.
536 await setHidden($, true)
537 try {
538 $.ui.toast('Refresh offer hidden. Type /refresh or open /mods to show it.')
539 } catch {
540 // The toast is only a hint.
541 }
542 } else if (p) {
543 // Closing a finished handoff counts as an answer up to here, too.
544 snoozedTo = Math.max(snoozedTo, p.tokens + SNOOZE)
545 if (sid) await $.store.set(`snooze:${sid}`, snoozedTo)
546 }
547 await setPanel($, null)
548}
549
550function bar(progress: number, status: Step['status']) {
551 const filled = Math.round(Math.max(0, Math.min(1, progress)) * BAR)
552 const color = status === 'fail' ? BAD : status === 'done' ? GOOD : ACCENT
553 return { on: '━'.repeat(filled), off: '━'.repeat(BAR - filled), color }
554}
555
556export const register: Register = on => {
557 on('session.start', async ($, e, next) => {
558 sid = await $.session.id()
559 home = $.plugin.root.split('/.claude/')[0] ?? ''
560 petDir = `${home}/.claude/pet`
561 await poll($).catch(() => undefined)
562 $.clock.every(3000, () => {
563 void poll($).catch(() => undefined)
564 })
565 await $.command.register({ name: 'handoff', description: 'Write a handoff of this chat now, then copy it or start a new chat with it' })
566 try {
567 await $.command.register({ name: 'refresh', description: 'Show the Refresh panel now, to move this work to a fresh chat', immediate: true })
568 } catch (err) {
569 await log($, `could not add /refresh: ${String(err)}`)
570 }
571 await $.tool.register({
572 name: 'refresh',
573 description:
574 'Open the Refresh panel above the prompt: it writes a handoff of this whole chat (what was asked, done, decided, files to open, next steps) and offers Copy and Start new chat. Use when the user asks to refresh, hand off, or move this work to a new chat. It starts when your turn ends.',
575 inputSchema: { type: 'object', properties: {} },
576 })
577 const saved = await $.store.get(`snooze:${sid}`)
578 if (typeof saved === 'number') snoozedTo = saved
579 try {
580 const last = (await $.store.get(`last:${sid}`)) as { path?: string } | undefined
581 if (last?.path && (await $.fs.exists(last.path))) {
582 handoffPath = last.path
583 handoffText = String(await $.fs.read(last.path)).trim()
584 }
585 } catch {
586 // No earlier handoff for this chat.
587 }
588 // A reload while the steps ran leaves a dead "working" band behind.
589 const p = await getPanel($)
590 if (p && p.phase === 'working') await setPanel($, null)
591 $.clock.every(POLL_MS, () => {
592 void checkOffer($).catch(() => undefined)
593 })
594 void checkOffer($).catch(() => undefined)
595 return next(e)
596 })
597
598 on('turn.complete', async ($, e, next) => {
599 if (off) return next(e)
600 const result = await next(e)
601 if (pending) {
602 pending = false
603 void run($).catch(() => undefined)
604 } else {
605 void checkOffer($).catch(() => undefined)
606 }
607 return result
608 })
609
610 on('command.run', { command: 'refresh' }, async $ => {
611 if (off) return { text: 'Handoff (Refresh) is off. Turn it on with /mods.' }
612 await setHidden($, false)
613 await showOffer($)
614 return { text: 'Refresh panel shown above the prompt. Press Hide on it, or use /mods, to hide it again.' }
615 })
616
617 on('command.run', { command: 'handoff' }, async $ => {
618 if (off) return { text: 'Handoff (Refresh) is off. Turn it on with /mods.' }
619 void run($).catch(() => undefined)
620 return { text: 'Writing the handoff. Watch the panel above the prompt.' }
621 })
622
623 // One unfiltered hook: a typed tool matcher makes tsc recurse through the connector types.
624 on('tool.call', async ($, e, next) => {
625 if ((e.tool as string) !== 'mcp__handoff__refresh') return next(e)
626 if (!off) pending = true
627 const said = off
628 ? 'Handoff (Refresh) is off. Tell the user to turn it on with /mods.'
629 : 'The Refresh panel opens above the prompt when this turn ends. Tell the user in one short line, then end your turn.'
630 return { result: said } as never
631 }).catch(($, e, next) => next(e))
632
633 // Presses go to these hooks, not to onPress: a closure lives one drawing, and the
634 // band redraws ten times a second while the steps run.
635 on('ui.press', { plugin: 'handoff', element: 'refresh' }, async ($, e) => {
636 void run($).catch(() => undefined)
637 return { element: e.element }
638 })
639 on('ui.press', { plugin: 'handoff', element: 'retry' }, async ($, e) => {
640 void run($).catch(() => undefined)
641 return { element: e.element }
642 })
643 on('ui.press', { plugin: 'handoff', element: 'copy' }, async ($, e) => {
644 await copy($)
645 return { element: e.element }
646 })
647 on('ui.press', { plugin: 'handoff', element: 'start' }, async ($, e) => {
648 void startNew($).catch(() => undefined)
649 return { element: e.element }
650 })
651 on('ui.press', { plugin: 'handoff', element: 'open' }, async ($, e) => {
652 if (handoffPath) await $.process.run(['open', handoffPath])
653 return { element: e.element }
654 })
655 on('ui.press', { plugin: 'handoff', element: 'close' }, async ($, e) => {
656 await close($)
657 return { element: e.element }
658 })
659
660 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
661 if (off) return next(e)
662 const p = await getPanel($)
663 if (!p || e.props.hasSurvey) return next(e)
664 // The offer waits out a running turn; the steps and the result do not.
665 if (p.phase === 'offer' && e.props.isWorking) return next(e)
666 const rest = await next(e)
667 const { Box, Text, Button } = $.ui.resolve(e)
668 const noop = () => undefined
669
670 if (p.phase === 'offer') {
671 return (
672 <Box flexDirection="column">
673 {rest}
674 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
675 <Text>
676 <Text color={ACCENT}>●</Text>
677 <Text dimColor> Context at {short(p.tokens)}. Refresh moves this work to a new chat with a full handoff.</Text>
678 </Text>
679 <Box flexDirection="row" gap={1}>
680 <Button key="refresh" label="Refresh" variant="primary" onPress={noop} />
681 <Button key="close" label="Hide" role="dismiss" onPress={noop} />
682 </Box>
683 </Box>
684 </Box>
685 )
686 }
687
688 const title =
689 p.phase === 'working'
690 ? 'Refreshing: building your handoff'
691 : p.phase === 'ready'
692 ? `Handoff ready: ${p.words.toLocaleString('en-US')} words`
693 : 'The handoff did not finish'
694 const rows = p.steps.map((s, i) => {
695 const icon =
696 s.status === 'done' ? (
697 <Text color={GOOD}>✓</Text>
698 ) : s.status === 'fail' ? (
699 <Text color={BAD}>✗</Text>
700 ) : s.status === 'run' ? (
701 <Text color={ACCENT}>{SPIN[p.tick % SPIN.length]}</Text>
702 ) : (
703 <Text dimColor>○</Text>
704 )
705 const b = bar(s.progress, s.status)
706 return (
707 <Box key={`row${i}`} flexDirection="row" gap={1}>
708 <Box width={2}>{icon}</Box>
709 <Box width={32}>
710 <Text dimColor={s.status === 'wait'} wrap="truncate-end">
711 {i + 1}. {s.label}
712 </Text>
713 </Box>
714 <Text>
715 <Text color={b.color}>{b.on}</Text>
716 <Text dimColor>{b.off}</Text>
717 </Text>
718 <Text dimColor wrap="truncate-end">
719 {s.detail}
720 </Text>
721 </Box>
722 )
723 })
724
725 return (
726 <Box flexDirection="column">
727 {rest}
728 <Box flexDirection="column" gap={0} paddingY={0}>
729 <Text bold color={p.phase === 'failed' ? BAD : p.phase === 'ready' ? GOOD : ACCENT}>
730 {title}
731 </Text>
732 {rows}
733 {p.phase === 'ready' ? (
734 <Box flexDirection="column">
735 <Text dimColor wrap="truncate-middle">
736 Saved to {p.file}
737 </Text>
738 <Box flexDirection="row" gap={1}>
739 <Button key="copy" label="Copy handoff prompt" variant="primary" onPress={noop} />
740 <Button key="start" label="Start new chat with it" variant="secondary" onPress={noop} />
741 <Button key="open" label="Open file" onPress={noop} />
742 <Button key="close" label="Close" role="dismiss" onPress={noop} />
743 </Box>
744 </Box>
745 ) : null}
746 {p.phase === 'failed' ? (
747 <Box flexDirection="column">
748 <Text color={BAD}>{p.error}</Text>
749 {p.file ? (
750 <Text dimColor wrap="truncate-middle">
751 Draft saved to {p.file}
752 </Text>
753 ) : null}
754 <Box flexDirection="row" gap={1}>
755 <Button key="retry" label="Try again" variant="primary" onPress={noop} />
756 {p.file ? <Button key="copy" label="Copy draft" onPress={noop} /> : null}
757 {p.file ? <Button key="open" label="Open draft" onPress={noop} /> : null}
758 <Button key="close" label="Close" role="dismiss" onPress={noop} />
759 </Box>
760 </Box>
761 ) : null}
762 {p.note ? <Text dimColor>{p.note}</Text> : null}
763 </Box>
764 </Box>
765 )
766 })
767}
768types/index.d.ts 32 lines1export type StepStatus = 'wait' | 'run' | 'done' | 'fail'
2
3export type Step = {
4 label: string
5 /** A short result on the right of the bar: "412 messages", "23 s". */
6 detail: string
7 status: StepStatus
8 /** 0 to 1. */
9 progress: number
10}
11
12export type Panel = {
13 /** offer: the Refresh row. working: the steps. ready: the buttons. failed: the error and Try again. */
14 phase: 'offer' | 'working' | 'ready' | 'failed'
15 tokens: number
16 steps: Step[]
17 /** Spinner frame, moved by the ticker while working. */
18 tick: number
19 /** Where the handoff is saved, with ~ for the home folder. */
20 file: string
21 words: number
22 error: string
23 /** A line under the buttons: "Copied", "Starting the new chat", the pet's answer. */
24 note: string
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 handoff: { panel: Panel | null }
30 }
31}
32