SLOPSHOPPER

handoff

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…

newbandguardcommandtoasttool
v0.2.3no licenseupdated 2026-10-23jz-wilson/claude-mods/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 › /refresh ⎿ handoff: Refresh panel shown above the prompt: press Refresh to write the handoff, or Hide. ⟨Claude Code's own drawing⟩ ● Context at 97k. Refresh moves this work to a new chat with a full handoff. [ Refresh ] [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ● Context at 97k. Refresh moves this work to a new chat with a full handoff. [ Refresh ] [ Hide ]
README

claude-mods

Mods for Claude Code.

ModWhat it does
cache-barStatus-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.
filetreeFork 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-deskTask list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent.
helpin-perPane listing open Helpin PER-* tasks; pick one to have Claude start working on it.
cache-clockCountdown above the prompt until the chat's cache goes cold, with a Keep warm button.
handoffWhen 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).
reflectWhen you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder.
chat-inventorySide 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-optimizerBefore 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-refinerA 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 setting

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.

Install

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 and claude-hud

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:

  • backs up settings.json to settings.json.bak-cache-bar;
  • moves the current statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;
  • points 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.

Development

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-bar
Source 2 files
hooks/register.tsx 847 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 = '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}
847
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