SLOPSHOPPER

handoff

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…

newbandguardcommandtoasttool
v0.1.0no licenseupdated 2026-10-26Anirach/Anirach.github.io/.claude/skills/handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handoff
› 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 › /handoff ⎿ handoff: Writing the handoff. Watch the panel above the prompt. ⟨Claude Code's own drawing⟩ Refreshing: building your handoff ✓ 1. Reading the conversation ━━━━━━━━━━━━━━━━━━ 2 messages ○ 2. Finding the files you used ━━━━━━━━━━━━━━━━━━ ○ 3. Listing skills, tools and li… ━━━━━━━━━━━━━━━━━━ ○ 4. Writing the handoff ━━━━━━━━━━━━━━━━━━ ○ 5. Saving and checking ━━━━━━━━━━━━━━━━━━ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Refreshing: building your handoff ✓ 1. Reading the conversation ━━━━━━━━━━━━━━━━━━ 2 messages ○ 2. Finding the files you used ━━━━━━━━━━━━━━━━━━ ○ 3. Listing skills, tools and li… ━━━━━━━━━━━━━━━━━━ ○ 4. Writing the handoff ━━━━━━━━━━━━━━━━━━ ○ 5. Saving and checking ━━━━━━━━━━━━━━━━━━
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 768 lines
1import 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}
768
types/index.d.ts 32 lines
1export 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