At 250k tokens or on /refresh <focus>, offers Refresh: writes a handoff of the whole chat with a live-state claims table for /pickup, shows the steps with…

Mods for Claude Code.
| Mod | What it does |
|---|---|
| cache-bar | Status-line bar under your existing status line showing how long the prompt cache stays warm, with a handoff countdown: cache ▰▰▰▱▱▱▱▱ 23m 182k · handoff in 18m. Turns yellow at a quarter of the TTL, red at the handoff threshold, and follows the drop to a 5-minute TTL. /cache-bar:setup installs it. |
| filetree | Fork of data-goblin/claude-code-filetree that shows only the files Claude touched this session; the header's ◆ button toggles to the whole tree. |
| helper-desk | Task list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent. |
| helpin-per | Pane listing open Helpin PER-* tasks; pick one to have Claude start working on it. |
| cache-clock | Countdown above the prompt until the chat's cache goes cold, with a Keep warm button. |
| handoff | When a chat gets very long, writes a handoff prompt and starts a fresh chat with it (/refresh <focus>; the handoff tells the new chat to run /pickup). |
| reflect | When you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder. |
| chat-inventory | Side pane, In this chat, listing the to-do list, connectors and calls per connector, files and skills this chat used. |
| screen-share | /screen-share hides names, emails, phone numbers and money amounts on screen while you record. |
| replay-theater | /replay steps through the file edits Claude made in the last turn, one diff at a time. |
| mods | /mods opens a pane to turn each mod on or off, including mods installed from other marketplaces, and to install mods a marketplace offers that are not installed yet. |
| effort-optimizer | Before Claude starts on a prompt, a quick Sonnet check picks the effort level it needs (Low to Max) and offers a one-click switch, or sets it by itself with /effort-optimizer auto. |
| prompt-refiner | A Refine button in the hint line under the prompt, any time in the chat. Press it and pick a rewrite style for the prompt in the box, like a phone keyboard's suggestions: Clearer, Shorter, Elaborate (fuller, same shape), Structured (the job, the why, the guardrails, done means), Ask me first (up to 3 questions on gaps), or My guidance. The rewrite shows above the prompt with what changed; Use it fills the box, Refine runs another pass. /refine <prompt> does the same. |
The eight mods other than replay-theater, from helper-desk to effort-optimizer, come from Ben AI's benai-mods (benai.co) and are redistributed with permission, with Windows fixes added here. replay-theater (one Ben AI change: /mods switch support) is Anthropic's sample mod from anthropics/claude-code-playground, under the Apache 2.0 license in its LICENSE.
Reflect saves a rule either to ~/.claude/CLAUDE.md (Save everywhere) or as its own note in a folder you choose (Save to vault). Set that folder as rules_folder when you install reflect, or later for reflect in /config. If it is empty, reflect asks for the folder the first time you press Save to vault.
At the prompt of a terminal session:
/plugin install helper-desk --marketplace jz-wilson/claude-mods
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install helper-desk --marketplace jz-wilson/claude-mods
claude plugin install cache-bar --marketplace jz-wilson/claude-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with jz-wilson/claude-mods, then Add plugin.
cache-bar is a status-line script, so it takes over statusLine and runs the previous status line inside it. After installing, run /cache-bar:setup. It:
settings.json to settings.json.bak-cache-bar;statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;statusLine.command at the newest installed cache-bar version, and sets refreshInterval: 60 so the countdown repaints while you are idle.The previous status line prints first, unchanged, and the bar goes on its own line underneath. If the inner command fails, the bar still prints. Before the first request of a session there is no cache yet, so the bar is hidden; after expiry it shows a dim cold.
The same config file sets the handoff threshold and the context floor:
{ "inner": "…", "handoffMinutes": 5, "floorTokens": 80000 }
The threshold is capped at a fifth of the TTL, so it fits the 5-minute TTL too. Under floorTokens of context the bar says no handoff (<floor).
To undo, copy settings.json.bak-cache-bar back over settings.json, or set statusLine.command back to the inner value.
Each mod is a folder with .claude-plugin/plugin.json. CI runs claude plugin validate on every mod for each push and pull request, then claude plugin test for mods with a hooks module and bun test for the rest. A merge to main that bumps a mod's version publishes a GitHub release tagged <mod>-v<version>.
claude plugin validate agent-tree
claude plugin test agent-tree
bun test cache-barhooks/register.tsx 847 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 = 'claude'
21const GOOD = 'success'
22const BAD = 'error'
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// A focus for the next handoff: /refresh <focus> or the refresh tool's focus. Used once.
60let focus = ''
61// The focus of the last run, so Retry writes the same handoff.
62let lastAim = ''
63
64const switchesPath = ($: EngineInterface) => `${$.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''}/.claude/mods/switches.json`
65
66async function readSwitches($: EngineInterface): Promise<Record<string, unknown>> {
67 const path = switchesPath($)
68 if (!(await $.fs.exists(path))) return {}
69 const json = JSON.parse(String(await $.fs.read(path))) as unknown
70 return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record<string, unknown>) : {}
71}
72
73async function readSwitch($: EngineInterface) {
74 let nowOff = off
75 let nowHidden = hidden
76 try {
77 const json = await readSwitches($)
78 nowOff = json[NAME] === false
79 nowHidden = json[HIDE_KEY] === true
80 } catch {
81 // A broken or missing file changes nothing.
82 }
83 if (nowOff === off && nowHidden === hidden) return
84 off = nowOff
85 const hiding = nowHidden && !hidden
86 hidden = nowHidden
87 if (hiding) await dropOffer($)
88 try {
89 $.ui.invalidate('ui.render')
90 } catch {
91 // The next state change redraws anyway.
92 }
93}
94
95// Hidden: an offer on screen goes; the steps and a finished handoff stay.
96async function dropOffer($: EngineInterface) {
97 forced = false
98 focus = ''
99 const p = await getPanel($)
100 if (p?.phase === 'offer') await setPanel($, null)
101}
102
103// Read, change, write the whole file, so every chat sees the choice within 3 s.
104async function setHidden($: EngineInterface, value: boolean) {
105 try {
106 const json = await readSwitches($)
107 if (value) json[HIDE_KEY] = true
108 else delete json[HIDE_KEY]
109 await $.fs.write(switchesPath($), `${JSON.stringify(json, null, 2)}\n`)
110 } catch (err) {
111 await log($, `save hidden failed: ${String(err)}`)
112 }
113 hidden = value
114 if (value) await dropOffer($)
115}
116
117// Brings the Refresh offer up now, whatever the token count.
118async function showOffer($: EngineInterface, replace = false) {
119 const p = await getPanel($)
120 // Steps running, or a handoff ready or failed: that panel is already up, unless a new focus replaces it.
121 if (busy || (p && p.phase !== 'offer' && !replace)) return
122 const { context } = await $.session.usage()
123 forced = true
124 await setPanel($, { phase: 'offer', tokens: context.tokens ?? 0, steps: freshSteps(), tick: 0, file: '', words: 0, error: '', note: '' })
125}
126
127// $.fs has no delete, and Windows has no rm outside Git Bash: del through cmd there.
128async function removeFile($: EngineInterface, path: string) {
129 const windows = (await $.env.get('OS')) === 'Windows_NT'
130 await $.process.run(windows ? ['cmd', '/c', 'del', '/f', '/q', path.replace(/\//g, '\\')] : ['rm', '-f', path])
131}
132
133// The /mods switchboard asks for the offer by leaving an empty file for this chat.
134async function checkOpen($: EngineInterface) {
135 if (!sid) return
136 const path = `${home}/.claude/mods/.open-${NAME}-${sid}`
137 if (!(await $.fs.exists(path).catch(() => false))) return
138 await removeFile($, path)
139 if (!off) await showOffer($)
140}
141
142async function poll($: EngineInterface) {
143 await readSwitch($)
144 await checkOpen($).catch(err => log($, `open request failed: ${String(err)}`))
145}
146
147const short = (tokens: number) => `${Math.round(tokens / 1000)}k`
148const tilde = (p: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p)
149const plural = (n: number, word: string) => `${n.toLocaleString('en-US')} ${word}${n === 1 ? '' : 's'}`
150
151function freshSteps(): Step[] {
152 return LABELS.map(label => ({ label, detail: '', status: 'wait', progress: 0 }))
153}
154
155async function getPanel($: EngineInterface): Promise<Panel | null> {
156 const { value } = await $.state.get(PANEL)
157 return value ?? null
158}
159
160async function setPanel($: EngineInterface, p: Panel | null) {
161 await $.state.set(PANEL, p)
162}
163
164async function patchStep($: EngineInterface, i: number, change: Partial<Step>) {
165 const p = await getPanel($)
166 if (!p) return
167 const steps = p.steps.map((s, k) => (k === i ? { ...s, ...change } : s))
168 await setPanel($, { ...p, steps })
169}
170
171// Moves the spinner and the running step's bar.
172async function tick($: EngineInterface) {
173 const p = await getPanel($)
174 if (!p || p.phase !== 'working') return
175 const now = await $.clock.now()
176 const steps = p.steps.map((s, k) => {
177 if (k !== running || s.status !== 'run') return s
178 if (k === 3) {
179 const t = now - writeStartedAt
180 return { ...s, progress: Math.min(0.95, 1 - Math.exp(-t / WRITE_SCALE_MS)), detail: `${Math.round(t / 1000)} s` }
181 }
182 return { ...s, progress: Math.min(0.9, (now - stepStartedAt) / MIN_STEP_MS) }
183 })
184 await setPanel($, { ...p, steps, tick: p.tick + 1 })
185}
186
187// Runs one step: its work, and at least MIN_STEP_MS of bar.
188async function step<T>($: EngineInterface, i: number, work: () => Promise<T>, detail: (v: T) => string): Promise<T> {
189 running = i
190 stepStartedAt = await $.clock.now()
191 if (i === 3) writeStartedAt = stepStartedAt
192 await patchStep($, i, { status: 'run', progress: 0.02 })
193 const value = await work()
194 const left = MIN_STEP_MS - ((await $.clock.now()) - stepStartedAt)
195 if (left > 0 && i !== 3) await $.clock.sleep(left)
196 await patchStep($, i, { status: 'done', progress: 1, detail: detail(value) })
197 return value
198}
199
200type Gathered = { messages: SessionMessage[]; tools: number; files: string[]; skills: string[]; links: string[] }
201
202const PATH_RE = /(?:~|\/Users|[A-Za-z]:[\\/]Users)[\\/][^\s'"`<>()|;,*]+/g
203const URL_RE = /https?:\/\/[^\s'"`<>()\]]+/g
204
205async function gatherFiles($: EngineInterface, messages: SessionMessage[]) {
206 const count = new Map<string, number>()
207 const add = (raw: string, weight: number) => {
208 let p = raw.replace(/\\/g, '/').replace(/[.:]+$/, '')
209 if (p.startsWith('~/')) p = home + p.slice(1)
210 if (p.includes('/.claude/projects/') || p.includes('/tool-results/') || p.startsWith('/private/tmp') || p.startsWith('/tmp')) return
211 count.set(p, (count.get(p) ?? 0) + weight)
212 }
213 for (const m of messages) {
214 for (const u of m.toolUses) {
215 const fp = u.input.file_path ?? u.input.notebook_path ?? u.input.path
216 if (typeof fp === 'string' && /^(?:\/|[A-Za-z]:[\\/])/.test(fp)) add(fp, u.tool === 'Read' ? 2 : 3)
217 if (u.tool === 'Bash' && typeof u.input.command === 'string') {
218 for (const hit of u.input.command.match(PATH_RE) ?? []) add(hit, 1)
219 }
220 }
221 }
222 const ranked = [...count.entries()].sort((a, b) => b[1] - a[1]).map(([p]) => p)
223 const kept: string[] = []
224 for (const p of ranked) {
225 if (kept.length >= 30) break
226 if (await $.fs.exists(p).catch(() => false)) kept.push(p)
227 }
228 return kept
229}
230
231function gatherSkills(messages: SessionMessage[]) {
232 const skills = new Set<string>()
233 const servers = new Set<string>()
234 for (const m of messages) {
235 for (const u of m.toolUses) {
236 if (u.tool === 'Skill' && typeof u.input.skill === 'string') skills.add(`/${u.input.skill}`)
237 if (u.tool.startsWith('mcp__')) servers.add(u.tool.split('__')[1] ?? u.tool)
238 }
239 }
240 return [...skills, ...[...servers].map(s => `MCP ${s}`)]
241}
242
243function gatherLinks(messages: SessionMessage[]) {
244 const links = new Set<string>()
245 for (const m of messages) {
246 for (const hit of m.text.match(URL_RE) ?? []) {
247 const url = hit.replace(/[.,:;]+$/, '')
248 if (!/localhost|127\.0\.0\.1|example\.com/.test(url)) links.add(url)
249 }
250 }
251 return [...links].slice(-20)
252}
253
254function forkPrompt(cwd: string, g: Gathered, aim: string) {
255 const list = (xs: string[]) => (xs.length ? xs.map(x => `- ${x}`).join('\n') : '- none found')
256 const aimed = aim
257 ? `\nThe user chose a focus for the next session: "${aim}". Build the goal, Files to open first and Next steps around it. Other threads from this chat get one line each under Where things stand, or nothing if they do not bear on the focus.\n`
258 : ''
259 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.
260${aimed}
261The 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. Do not copy what already lives in a plan, PRD, ADR, issue, commit, diff or agent log: point to it by path or URL.
262
263Facts the handoff mod pulled from this transcript (check them against what you remember; drop what does not matter for the next step):
264Working directory: ${cwd}
265Files read or changed, most used first:
266${list(g.files.map(tilde))}
267Skills and MCP servers used:
268${list(g.skills)}
269Links in the chat:
270${list(g.links)}
271
272Use this shape in plain, short English. Leave out a section that has nothing in it.
273
274# Handoff: <the goal, in a few words>
275
276Continue work in \`${tilde(cwd)}\`. Goal: <${aim ? 'the focus above, as a goal' : 'what the user is working toward right now'}>.
277
278## What the user asked for
279Every request in this chat, in order, with the user's own key words. Mark each one: done, in progress, or not started.
280
281## Where things stand
282What is done and checked, the key decisions and why, and what was tested and what was not.
283
284## Live-state claims
285Every claim about state that can change after this chat: a workload is running, a branch or PR exists, a commit is on origin/main, CI is green, a service answers, a version is deployed. Intent, decisions and next steps are not claims.
286| # | Claim | Status | Check to re-run | Evidence or why unverified |
287|---|---|---|---|---|
288Status is VERIFIED only when a check's output for it is in this chat: give the command and the trimmed output, and its time if the chat shows one. Anything inferred, recalled, told, or checked long ago and possibly changed since is UNVERIFIED, with the reason. Every row, VERIFIED too, gets a read-only command the next session can run as written; /pickup re-runs them all before it starts.
289
290## Rules the user set
291Standing constraints and preferences given in this chat: words to avoid, things never to do, approval steps, tone.
292
293## Files to open first
294- \`<path>\`: why it matters, in one line. Only files the next step needs, at most 15. Paths only; never copy file content.
295
296## Suggested skills and tools
297- \`<skill, CLI or connector>\`: when to use it, and any trap found in this chat. Skills the next session should load first come first.
298
299## Errors already solved
300- <the error>: <the fix>. So the next session does not hit them again.
301
302## Carried-over data
303Only data that exists nowhere on disk: ids, URLs, numbers and decisions that live only in this chat. Verbatim.
304
305## Next steps
3061. The very next action, specific enough to start without asking a question. Name any UNVERIFIED claim it builds on.
307
308Never write an API key, token or password; write where it lives instead (for example "in ~/.zshrc"). Keep it under 2,000 words.`
309}
310
311// Anything shaped like a secret is cut out before the handoff is saved or copied.
312function redact(text: string) {
313 return text
314 .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>')
315 .replace(/(Bearer\s+)[A-Za-z0-9._-]{20,}/g, '$1<secret removed>')
316}
317
318function slug(text: string) {
319 const title = /^#\s*Handoff:\s*(.+)$/m.exec(text)?.[1] ?? 'session'
320 return (
321 title
322 .toLowerCase()
323 .replace(/[^a-z0-9]+/g, '-')
324 .replace(/^-|-$/g, '')
325 .slice(0, 48) || 'session'
326 )
327}
328
329async function stamp($: EngineInterface) {
330 const d = new Date(await $.clock.now())
331 const pad = (n: number) => String(n).padStart(2, '0')
332 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
333}
334
335// Every run leaves a line here, so a failure is never silent again.
336async function log($: EngineInterface, line: string) {
337 try {
338 const path = `${home}/.claude/handoffs/handoff.log`
339 const old = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
340 const at = new Date(await $.clock.now()).toISOString()
341 const lines = old ? old.trimEnd().split('\n').slice(-400) : []
342 lines.push(`${at} ${sid.slice(0, 8)} ${line}`)
343 await $.fs.write(path, `${lines.join('\n')}\n`)
344 } catch {
345 // The log must never break a run.
346 }
347}
348
349// A plain handoff built from the transcript alone, saved before the model step,
350// so a failed or slow model call still leaves something to start from.
351function draftText(cwd: string, g: Gathered, aim: string) {
352 const asks = g.messages
353 .filter(m => m.role === 'user' && m.text.trim() && !m.text.trim().startsWith('<'))
354 .slice(-25)
355 .map(m => `- ${m.text.trim().replace(/\s+/g, ' ').slice(0, 300)}`)
356 const list = (xs: string[]) => (xs.length ? xs.map(x => `- ${x}`).join('\n') : '- none found')
357 return `# Handoff draft (no model step)
358
359Continue 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.
360${aim ? `\nFocus for this session: ${aim}\n` : ''}
361## What the user asked for (newest last)
362${asks.join('\n') || '- none found'}
363
364## Files to open first
365${list(g.files.slice(0, 15).map(f => `\`${tilde(f)}\``))}
366
367## Skills and tools used
368${list(g.skills)}
369
370## Links in the chat
371${list(g.links)}
372`
373}
374
375async function run($: EngineInterface) {
376 if (busy) return
377 busy = true
378 const aim = focus
379 focus = ''
380 lastAim = aim
381 let draftPath = ''
382 const { context } = await $.session.usage()
383 await setPanel($, { phase: 'working', tokens: context.tokens ?? 0, steps: freshSteps(), tick: 0, file: '', words: 0, error: '', note: '' })
384 ticker?.cancel()
385 ticker = $.clock.every(TICK_MS, () => {
386 void tick($).catch(() => undefined)
387 })
388 try {
389 const cwd = await $.session.cwd()
390 const messages = await step(
391 $,
392 0,
393 () => $.session.messages(),
394 ms => `${plural(ms.length, 'message')}`,
395 )
396 const tools = messages.reduce((n, m) => n + m.toolUses.length, 0)
397 const files = await step($, 1, () => gatherFiles($, messages), fs => plural(fs.length, 'file'))
398 const found = await step(
399 $,
400 2,
401 async () => ({ skills: gatherSkills(messages), links: gatherLinks(messages) }),
402 f => `${plural(f.skills.length, 'skill')}, ${plural(f.links.length, 'link')}`,
403 )
404 const g: Gathered = { messages, tools, files, ...found }
405 draftPath = `${home}/.claude/handoffs/${await stamp($)}-draft.md`
406 const draft = redact(draftText(cwd, g, aim))
407 await $.fs.write(draftPath, draft)
408 handoffText = draft
409 handoffPath = draftPath
410 await log($, `start ${short(context.tokens ?? 0)} ${messages.length} messages, draft ${tilde(draftPath)}${aim ? `, focus "${aim.slice(0, 80)}"` : ''}`)
411 const reply = await step(
412 $,
413 3,
414 async () => {
415 const r = await $.model.fork({ prompt: forkPrompt(cwd, g, aim) })
416 writeMs = (await $.clock.now()) - writeStartedAt
417 return r
418 },
419 r => (r.isAnswered ? `${Math.round(writeMs / 1000)} s` : 'failed'),
420 )
421 if (!reply.isAnswered) {
422 throw new Error(reply.reason === 'api-error' ? `the model call failed (${reply.status ?? '?'})` : `no handoff came back (${reply.reason})`)
423 }
424 const text = redact(reply.text.trim().replace(/^```(?:markdown|md)?\n([\s\S]*)\n```$/, '$1'))
425 const saved = await step(
426 $,
427 4,
428 async () => {
429 const dir = `${home}/.claude/handoffs`
430 const path = `${dir}/${await stamp($)}-${slug(text)}.md`
431 await $.fs.write(path, `${text}\n`)
432 const back = String(await $.fs.read(path))
433 if (back.trim() !== text) throw new Error('the saved file does not match')
434 return path
435 },
436 p => tilde(p).split('/').pop() ?? '',
437 )
438 handoffText = text
439 handoffPath = saved
440 const p = await getPanel($)
441 if (p) await setPanel($, { ...p, phase: 'ready', file: tilde(saved), words: text.split(/\s+/).length })
442 if (sid) await $.store.set(`last:${sid}`, { path: saved })
443 await log($, `ok ${tilde(saved)} ${text.split(/\s+/).length} words in ${Math.round(writeMs / 1000)} s`)
444 // The full handoff replaces the draft.
445 if (draftPath) await removeFile($, draftPath)
446 } catch (err) {
447 const p = await getPanel($)
448 const steps = (p?.steps ?? freshSteps()).map((s, k) => (k === running && s.status === 'run' ? { ...s, status: 'fail' as const } : s))
449 const why = String(err instanceof Error ? err.message : err).slice(0, 160)
450 await setPanel($, {
451 ...(p as Panel),
452 steps,
453 phase: 'failed',
454 error: why,
455 file: draftPath ? tilde(draftPath) : '',
456 note: draftPath ? 'A basic draft is saved. Open it or copy it, or try again.' : '',
457 })
458 await log($, `failed at step ${running + 1}: ${why}`)
459 } finally {
460 ticker?.cancel()
461 ticker = null
462 running = -1
463 busy = false
464 }
465}
466
467async function note($: EngineInterface, text: string) {
468 const p = await getPanel($)
469 if (p) await setPanel($, { ...p, note: text })
470}
471
472async function copy($: EngineInterface) {
473 if (!handoffText) return
474 const r = await $.ui.copy({ text: handoffText })
475 await note($, r.isCopied ? 'Copied. Paste it into a new chat.' : 'Copy failed. Use Open file instead.')
476}
477
478// Windows has no `open`; rundll32 hands the file or link to its default app without cmd's parsing.
479async function openTarget($: EngineInterface, target: string) {
480 if ((await $.env.get('OS')) !== 'Windows_NT') return $.process.run(['open', target])
481 return $.process.run(['rundll32', 'url.dll,FileProtocolHandler', target.includes('://') ? target : target.replace(/\//g, '\\')])
482}
483
484async function petAlive($: EngineInterface) {
485 try {
486 const j = JSON.parse(String(await $.fs.read(`${petDir}/pet.json`))) as { at?: number }
487 return (await $.clock.now()) - (j.at ?? 0) < 10_000
488 } catch {
489 return false
490 }
491}
492
493// Claude Pet starts the chat in the app (Bypass, its Send button) and stays on it.
494// Without the pet, the app's own link fills a new chat and you press Return.
495async function startNew($: EngineInterface) {
496 if (!handoffText) return
497 const cwd = await $.session.cwd()
498 if (await petAlive($)) {
499 await note($, 'Starting the new chat…')
500 const id = `handoff-${await $.clock.now()}`
501 await $.fs.write(
502 `${petDir}/requests/${id}.json`,
503 JSON.stringify({ id, at: await $.clock.now(), action: 'newchat', text: handoffText, folder: cwd, stay: true }),
504 )
505 const done = `${petDir}/requests/${id}.done`
506 for (let k = 0; k < 120; k += 1) {
507 await $.clock.sleep(500)
508 if (await $.fs.exists(done)) {
509 const answer = String(await $.fs.read(done)).trim()
510 await removeFile($, done)
511 await note($, answer)
512 return
513 }
514 }
515 await note($, 'Claude Pet did not answer. Use Copy handoff prompt instead.')
516 return
517 }
518 const url = `claude://code/new?q=${encodeURIComponent(handoffText)}`
519 // On Windows rundll32 exits 0 whether or not the app opened, and long links can be dropped:
520 // copy the handoff too and say so honestly.
521 if ((await $.env.get('OS')) === 'Windows_NT') {
522 const copied = await $.ui.copy({ text: handoffText })
523 await openTarget($, url.length <= 2000 ? url : 'claude://code/new')
524 await note($, copied.isCopied ? 'Opening a new chat. The handoff is also copied: paste it there if the chat is empty.' : 'Opening a new chat. If it is empty, use Copy handoff prompt.')
525 return
526 }
527 const r = await openTarget($, url)
528 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.')
529}
530
531// The terminal holds one chat and no claude:// handler: clear it here and leave /pickup <file> in the prompt.
532async function startHere($: EngineInterface) {
533 if (!handoffPath) return
534 const path = handoffPath
535 await $.command.run({ command: 'clear' })
536 // From here the old chat is gone: whatever fails, say how to continue.
537 const filled = await setPanel($, null)
538 .then(() => $.prompt.fill({ text: `/pickup ${path}` }))
539 .then(r => r.isFilled, () => false)
540 if (!filled) $.ui.toast(`Chat cleared. Run /pickup ${path} to continue.`)
541}
542
543async function checkOffer($: EngineInterface) {
544 if (busy || off) return
545 // Cache Clock (another mod) asks for a handoff by leaving this file.
546 const request = `${home}/.claude/handoffs/.request-${sid}`
547 if (sid && (await $.fs.exists(request).catch(() => false))) {
548 await removeFile($, request)
549 await log($, 'asked by Cache Clock')
550 void run($).catch(() => undefined)
551 return
552 }
553 const p = await getPanel($)
554 if (p && p.phase !== 'offer') return
555 // An offer asked for by Show stays until it is answered.
556 if (forced && p) return
557 forced = false
558 if (hidden) {
559 if (p) await setPanel($, null)
560 return
561 }
562 const { context } = await $.session.usage()
563 const tokens = context.tokens ?? 0
564 // A compaction or /clear brings the count down: the offer starts over.
565 if (tokens < OFFER_AT && snoozedTo > OFFER_AT) {
566 snoozedTo = OFFER_AT
567 if (sid) await $.store.set(`snooze:${sid}`, snoozedTo)
568 }
569 if (tokens >= snoozedTo) {
570 if (!p || Math.abs(p.tokens - tokens) >= 1000) {
571 await setPanel($, { phase: 'offer', tokens, steps: freshSteps(), tick: 0, file: '', words: 0, error: '', note: '' })
572 }
573 } else if (p) {
574 await setPanel($, null)
575 }
576}
577
578async function close($: EngineInterface) {
579 const p = await getPanel($)
580 if (p?.phase === 'offer') {
581 // Hide on the offer: it stops popping up in every chat until Show or /refresh, and drops its focus.
582 focus = ''
583 await setHidden($, true)
584 try {
585 $.ui.toast('Refresh offer hidden. Type /refresh or open /mods to show it.')
586 } catch {
587 // The toast is only a hint.
588 }
589 } else if (p) {
590 // Closing a finished handoff counts as an answer up to here, too.
591 snoozedTo = Math.max(snoozedTo, p.tokens + SNOOZE)
592 if (sid) await $.store.set(`snooze:${sid}`, snoozedTo)
593 }
594 await setPanel($, null)
595}
596
597// Fixed parts of a step row, sized from the body columns: the bar goes first, then the label narrows.
598export const barColumns = (bodyColumns: number) => (bodyColumns >= 80 ? BAR : bodyColumns >= 60 ? 8 : 0)
599export const labelColumns = (bodyColumns: number) => (bodyColumns >= 80 ? 32 : bodyColumns >= 60 ? 26 : 22)
600
601function bar(progress: number, status: Step['status'], columns: number) {
602 const filled = Math.round(Math.max(0, Math.min(1, progress)) * columns)
603 const color = status === 'fail' ? BAD : status === 'done' ? GOOD : ACCENT
604 return { on: '━'.repeat(filled), off: '━'.repeat(columns - filled), color }
605}
606
607export const register: Register = on => {
608 on('session.start', async ($, e, next) => {
609 sid = await $.session.id()
610 home = $.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''
611 petDir = `${home}/.claude/pet`
612 await poll($).catch(() => undefined)
613 $.clock.every(3000, () => {
614 void poll($).catch(() => undefined)
615 })
616 // No /handoff: the mattpocock-skills plugin owns that name, and a refused register used to stop this whole start.
617 try {
618 await $.command.register({
619 name: 'refresh',
620 description: 'Show the Refresh panel now, to move this work to a fresh chat; text after it is the next session focus',
621 argumentHint: '[focus for the next session]',
622 immediate: true,
623 })
624 } catch (err) {
625 await log($, `could not add /refresh: ${String(err)}`)
626 }
627 try {
628 await $.tool.register({
629 name: 'refresh',
630 description:
631 '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. Pass focus when the user names what the next session should work on.',
632 inputSchema: {
633 type: 'object',
634 properties: { focus: { type: 'string', description: "What the next session should focus on, in the user's words. Leave out when the user named none." } },
635 },
636 })
637 } catch (err) {
638 await log($, `could not add the refresh tool: ${String(err)}`)
639 }
640 const saved = await $.store.get(`snooze:${sid}`)
641 if (typeof saved === 'number') snoozedTo = saved
642 try {
643 const last = (await $.store.get(`last:${sid}`)) as { path?: string } | undefined
644 if (last?.path && (await $.fs.exists(last.path))) {
645 handoffPath = last.path
646 handoffText = String(await $.fs.read(last.path)).trim()
647 }
648 } catch {
649 // No earlier handoff for this chat.
650 }
651 // A reload while the steps ran leaves a dead "working" band behind.
652 const p = await getPanel($)
653 if (p && p.phase === 'working') await setPanel($, null)
654 $.clock.every(POLL_MS, () => {
655 void checkOffer($).catch(() => undefined)
656 })
657 void checkOffer($).catch(() => undefined)
658 return next(e)
659 })
660
661 on('turn.complete', async ($, e, next) => {
662 if (off) return next(e)
663 const result = await next(e)
664 if (pending) {
665 pending = false
666 void run($).catch(() => undefined)
667 } else {
668 void checkOffer($).catch(() => undefined)
669 }
670 return result
671 })
672
673 const focusNote = () => (focus ? ` Focus for the next session: ${focus}` : '')
674
675 on('command.run', { command: 'refresh' }, async ($, e) => {
676 if (off) return { text: 'Handoff (Refresh) is off. Turn it on with /mods.' }
677 if (busy) return { text: 'A handoff is being written now. Run /refresh again when it finishes.' }
678 focus = e.args.trim()
679 await setHidden($, false)
680 // A new focus replaces a finished or failed handoff's panel with a fresh offer.
681 await showOffer($, !!focus)
682 return { text: `Refresh panel shown above the prompt: press Refresh to write the handoff, or Hide.${focusNote()}` }
683 })
684
685 // One unfiltered hook: a typed tool matcher makes tsc recurse through the connector types.
686 on('tool.call', async ($, e, next) => {
687 if ((e.tool as string) !== 'mcp__handoff__refresh') return next(e)
688 if (!off) {
689 pending = true
690 const asked = (e as { focus?: unknown }).focus
691 // A call without a focus keeps one the user gave with /refresh.
692 if (typeof asked === 'string' && asked.trim()) focus = asked.trim()
693 }
694 const said = off
695 ? 'Handoff (Refresh) is off. Tell the user to turn it on with /mods.'
696 : 'The Refresh panel opens above the prompt when this turn ends. Tell the user in one short line, then end your turn.'
697 return { result: said } as never
698 }).catch(($, e, next) => next(e))
699
700 // Presses go to these hooks, not to onPress: a closure lives one drawing, and the
701 // band redraws ten times a second while the steps run.
702 on('ui.press', { plugin: 'handoff', element: 'refresh' }, async ($, e) => {
703 void run($).catch(() => undefined)
704 return { element: e.element }
705 })
706 on('ui.press', { plugin: 'handoff', element: 'retry' }, async ($, e) => {
707 if (!busy) focus = lastAim
708 void run($).catch(() => undefined)
709 return { element: e.element }
710 })
711 on('ui.press', { plugin: 'handoff', element: 'copy' }, async ($, e) => {
712 await copy($)
713 return { element: e.element }
714 })
715 on('ui.press', { plugin: 'handoff', element: 'start' }, async ($, e) => {
716 void (e.surface === 'terminal' ? startHere($) : startNew($))
717 .catch(async err => {
718 await log($, `start new chat failed: ${String(err)}`)
719 await note($, 'The new chat did not start. Use Copy handoff prompt instead.')
720 })
721 // The session can be gone by then (a clear, a reload); the note is only a hint.
722 .catch(() => undefined)
723 return { element: e.element }
724 })
725 on('ui.press', { plugin: 'handoff', element: 'open' }, async ($, e) => {
726 if (handoffPath) await openTarget($, handoffPath)
727 return { element: e.element }
728 })
729 on('ui.press', { plugin: 'handoff', element: 'close' }, async ($, e) => {
730 await close($)
731 return { element: e.element }
732 })
733
734 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
735 if (off) return next(e)
736 const p = await getPanel($)
737 if (!p || e.props.hasSurvey) return next(e)
738 // The offer waits out a running turn; the steps and the result do not.
739 if (p.phase === 'offer' && e.props.isWorking) return next(e)
740 const rest = await next(e)
741 const { Box, Text, Button } = $.ui.resolve(e)
742 const noop = () => undefined
743 const barCols = barColumns(e.props.bodyColumns)
744 const labelCols = labelColumns(e.props.bodyColumns)
745
746 if (p.phase === 'offer') {
747 return (
748 <Box flexDirection="column">
749 {rest}
750 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
751 <Text wrap="truncate-end">
752 <Text color={ACCENT}>●</Text>
753 <Text dimColor> Context at {short(p.tokens)}. Refresh moves this work to a new chat with a full handoff.</Text>
754 {focus ? <Text color={ACCENT}> Focus: {focus}</Text> : null}
755 </Text>
756 <Box flexDirection="row" gap={1}>
757 <Button key="refresh" label="Refresh" variant="primary" onPress={noop} />
758 <Button key="close" label="Hide" role="dismiss" onPress={noop} />
759 </Box>
760 </Box>
761 </Box>
762 )
763 }
764
765 const title =
766 p.phase === 'working'
767 ? 'Refreshing: building your handoff'
768 : p.phase === 'ready'
769 ? `Handoff ready: ${p.words.toLocaleString('en-US')} words`
770 : 'The handoff did not finish'
771 const rows = p.steps.map((s, i) => {
772 const icon =
773 s.status === 'done' ? (
774 <Text color={GOOD}>✓</Text>
775 ) : s.status === 'fail' ? (
776 <Text color={BAD}>✗</Text>
777 ) : s.status === 'run' ? (
778 <Text color={ACCENT}>{SPIN[p.tick % SPIN.length]}</Text>
779 ) : (
780 <Text dimColor>○</Text>
781 )
782 const b = bar(s.progress, s.status, barCols)
783 return (
784 <Box key={`row${i}`} flexDirection="row" gap={1}>
785 <Box width={2}>{icon}</Box>
786 <Box width={labelCols}>
787 <Text dimColor={s.status === 'wait'} wrap="truncate-end">
788 {i + 1}. {s.label}
789 </Text>
790 </Box>
791 {barCols > 0 ? (
792 <Text>
793 <Text color={b.color}>{b.on}</Text>
794 <Text dimColor>{b.off}</Text>
795 </Text>
796 ) : null}
797 <Text dimColor wrap="truncate-end">
798 {s.detail}
799 </Text>
800 </Box>
801 )
802 })
803
804 return (
805 <Box flexDirection="column">
806 {rest}
807 <Box flexDirection="column" gap={0} paddingY={0}>
808 <Text weight="bold" wrap="truncate-end" color={p.phase === 'failed' ? BAD : p.phase === 'ready' ? GOOD : ACCENT}>
809 {title}
810 </Text>
811 {rows}
812 {p.phase === 'ready' ? (
813 <Box flexDirection="column">
814 <Text dimColor wrap="truncate-middle">
815 Saved to {p.file}
816 </Text>
817 <Box flexDirection="row" flexWrap="wrap" gap={1}>
818 <Button key="copy" label="Copy handoff prompt" variant="primary" onPress={noop} />
819 <Button key="start" label="Start new chat with it" variant="secondary" onPress={noop} />
820 <Button key="open" label="Open file" onPress={noop} />
821 <Button key="close" label="Close" role="dismiss" onPress={noop} />
822 </Box>
823 </Box>
824 ) : null}
825 {p.phase === 'failed' ? (
826 <Box flexDirection="column">
827 <Text color={BAD}>{p.error}</Text>
828 {p.file ? (
829 <Text dimColor wrap="truncate-middle">
830 Draft saved to {p.file}
831 </Text>
832 ) : null}
833 <Box flexDirection="row" flexWrap="wrap" gap={1}>
834 <Button key="retry" label="Try again" variant="primary" onPress={noop} />
835 {p.file ? <Button key="copy" label="Copy draft" onPress={noop} /> : null}
836 {p.file ? <Button key="open" label="Open draft" onPress={noop} /> : null}
837 <Button key="close" label="Close" role="dismiss" onPress={noop} />
838 </Box>
839 </Box>
840 ) : null}
841 {p.note ? <Text dimColor>{p.note}</Text> : null}
842 </Box>
843 </Box>
844 )
845 })
846}
847types/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