SLOPSHOPPER

chat-handoff

Moves a long chat into a fresh one without losing context: at 250k tokens, when the cache goes cold, or on /handover it writes a detailed handoff prompt and…

newbandcommandtoastmodelprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chat-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 ⎿ chat-handoff: Writing the handoff prompt. A button to start the fresh chat appears above the prompt when it is ready. ⇄ Handoff you asked for it: writing the handoff prompt from the whole chat... ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⇄ Handoff you asked for it: writing the handoff prompt from the whole chat...
README

Chat Handoff

A Claude Code mod that moves a long chat into a fresh one without losing context.

Long chats rot: the fuller the context window, the more details Claude misses. Compacting loses detail through summarizing, and a cold prompt cache makes every new message cost more. Handoff writes a detailed handoff prompt for a fresh chat, and shows a button to start that chat when you choose.

Triggers

  • 250,000 tokens. The size is the one Cache Clock already tracks: the prompt tokens the next request re-sends (input + cache read + cache write + output of the last main-thread reply). If Cache Clock has no figure yet, it falls back to the status line's context figure ($.session.usage()). It checks every 3 seconds while no turn is running.
  • Cold cache. Reads Cache Clock's own state (lastRequestAt and ttlMs, shared through $.state): the cache is cold when the last request is older than the lifetime Cache Clock read from the API. It needs Cache Clock loaded, and only fires above 50,000 tokens, since a cold cache on a small chat costs next to nothing. One cold spell fires once.
  • /handover. Writes it now. /handover start, /handover copy and /handover show do the rest from the keyboard. The mod also tries to register /handoff; if another plugin already owns that name, only /handover answers.

What it writes

One plain-text markdown prompt with:

  • how to use it, then Goal, Context, Decisions and rules, Progress (done / in progress / not started), What was tried and failed, Current state, Next steps, Open questions: written by Claude over the whole chat (a fork of the chat, so the cached prefix is reused where it is still warm).
  • the working tree: branch, uncommitted changes, recent commits.
  • appendices read straight from the transcript, not from memory: files created or changed, files read, skills and slash commands, tools and subagents, links and searches, recent shell commands.

It is also saved to ~/.claude/handoffs/<date>-<session>.md, so you can send the file to a teammate, or paste the text from /handoff show.

The button

A band above the prompt shows Start fresh chat, Copy and ×. Nothing starts until you press Start fresh chat (or type /handover start). Pressing it runs /clear (the old chat stays in /resume) and sends the handoff prompt as the first message of the new chat. If /clear cannot be run from the mod, it copies the prompt and tells you to run /clear and paste.

Assumptions

  • No mod API opens a new chat directly, so "fresh chat" is /clear plus a submitted prompt.
  • The 250k figure is unreachable on a model whose window is smaller than that, because auto-compact comes first.
  • Writing the prompt costs one request over the chat: cheap while the cache is warm, one full re-cache when the trigger was a cold cache.
  • Claude Code only. Depends on the cache-clock mod for the cold-cache trigger.

Install

/plugin install chat-handoff --marketplace FCisco95/claude-code-mods

Or copy plugins/chat-handoff to ~/.claude/skills/chat-handoff (see the repo README).

Needs cache-clock for the cold-cache trigger.

Source 3 files
hooks/register.tsx 477 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Reason } from '../types'
5import { extractFacts, renderFacts } from './facts'
6
7// Hand off once the chat holds this many tokens. Measured as Cache Clock measures it: the prompt tokens
8// the next request re-sends (input + cache read + cache write + output of the last main-thread reply).
9const TOKEN_LIMIT = 250_000
10// A cold cache on a small chat costs next to nothing, so it only counts above this.
11const MIN_COLD_TOKENS = 50_000
12// How often the triggers are checked.
13const TICK_MS = 3000
14// Rendered-transcript fallback (used only where the chat cannot be forked): at most this many characters.
15const FALLBACK_CHARS = 500_000
16
17const GREEN = '#16a34a'
18const AMBER = '#ca8a04'
19const RED = '#dc2626'
20
21const status = atom({ plugin: 'chat-handoff', key: 'status' } as const, 'idle' as 'idle' | 'working' | 'ready' | 'failed')
22const reason = atom({ plugin: 'chat-handoff', key: 'reason' } as const, '')
23const draft = atom({ plugin: 'chat-handoff', key: 'draft' } as const, '')
24const savedPath = atom({ plugin: 'chat-handoff', key: 'savedPath' } as const, '')
25const words = atom({ plugin: 'chat-handoff', key: 'words' } as const, 0)
26const atTurns = atom({ plugin: 'chat-handoff', key: 'atTurns' } as const, 0)
27const error = atom({ plugin: 'chat-handoff', key: 'error' } as const, '')
28const isHidden = atom({ plugin: 'chat-handoff', key: 'isHidden' } as const, false)
29const isLaunching = atom({ plugin: 'chat-handoff', key: 'isLaunching' } as const, false)
30
31const WHY: Record<string, string> = {
32  tokens: 'the chat passed 250k tokens',
33  cold: 'the cache went cold',
34  command: 'you asked for it',
35}
36
37const FORK_PROMPT = `Write a handoff for a fresh Claude Code chat that will continue this work. The reader has NOT seen this conversation: it may be a new Claude session, or a teammate picking the task up. This is not a compaction summary. Keep the detail.
38
39Use exactly these markdown headings, in this order:
40
41## Goal
42What the user is trying to achieve and why, in their terms, plus what "done" means.
43
44## Context
45Everything the reader must know to work here: the project, the setup, the constraints, who and what is involved, relevant background found in the chat.
46
47## Decisions and rules
48Each decision made and the reason, and every preference, rule or correction the user gave (quote exact wording where the wording matters). Mark which are locked.
49
50## Progress
51Three lists: Done (with the evidence: command output, test result, file, id), In progress (exactly how far), Not started. Name files, paths, commands, ids, numbers and outcomes exactly.
52
53## What was tried and failed
54Dead ends and errors, with the cause where known, so the reader does not repeat them.
55
56## Current state
57Where the work stands right now: the last action and its result, anything half-done, what is uncommitted or unverified.
58
59## Next steps
60A numbered, ordered list. Step 1 must be concrete enough to start immediately. Note dependencies between steps.
61
62## Open questions and blockers
63What is unclear, and what needs the user's decision or action.
64
65Rules:
66- Be specific and complete. Never compress a detail into a vague phrase. Keep exact names, paths, numbers, error text and quoted wording.
67- Do not invent anything. If something is uncertain, say it is uncertain.
68- Mention files, skills, tools and links inline where they matter to the story. Do NOT write full lists of them: appendices listing every file, skill, tool, link and shell command are added automatically after your text.
69- Do not call tools. No preamble, no closing remarks, no code fence around the whole answer.
70- Write in the language the user used in this chat.`
71
72let isTurnRunning = false
73let isWorking = false
74let isTokenOffered = false
75// The Cache Clock request time a cold offer was made for, so one cold spell offers once.
76let coldOfferedFor = 0
77
78const wordsOf = (text: string) => text.trim().split(/\s+/).filter(Boolean).length
79
80const stamp = (ms: number) => {
81  const d = new Date(ms)
82  const p = (n: number) => String(n).padStart(2, '0')
83
84  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`
85}
86
87const formatTokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
88
89// Cache Clock keeps these in $.state; any plugin reads any value. 0 where Cache Clock is not loaded.
90const readCache = async ($: EngineInterface) => {
91  const last = (await $.state.get({ plugin: 'cache-clock', key: 'lastRequestAt' } as const)).value ?? 0
92  const ttl = (await $.state.get({ plugin: 'cache-clock', key: 'ttlMs' } as const)).value ?? 0
93  let tokens = (await $.state.get({ plugin: 'cache-clock', key: 'contextTokens' } as const)).value ?? 0
94
95  if (tokens === 0) {
96    try {
97      tokens = (await $.session.usage()).context.tokens ?? 0
98    } catch {
99      tokens = 0
100    }
101  }
102
103  return { last, ttl, tokens }
104}
105
106const git = async ($: EngineInterface, argv: string[]) => {
107  try {
108    const run = await $.process.run(['git', ...argv], { timeoutMs: 8000 })
109
110    return run.exitCode === 0 ? run.stdout.trim() : ''
111  } catch {
112    return ''
113  }
114}
115
116const renderTranscript = async ($: EngineInterface) => {
117  const messages = await $.session.messages()
118  const lines = messages.map(m => {
119    const uses = m.toolUses.map(u => `[tool ${u.tool} ${JSON.stringify(u.input).slice(0, 300)}${u.text ? ` -> ${u.text.slice(0, 400)}` : ''}]`)
120
121    return `${m.role.toUpperCase()}: ${m.text}${uses.length ? `\n${uses.join('\n')}` : ''}`
122  })
123  const all = lines.join('\n\n')
124
125  if (all.length <= FALLBACK_CHARS) {
126    return all
127  }
128
129  const head = Math.floor(FALLBACK_CHARS * 0.2)
130
131  return `${all.slice(0, head)}\n\n[... middle of the chat left out to fit ...]\n\n${all.slice(all.length - (FALLBACK_CHARS - head))}`
132}
133
134const stripFence = (text: string) => text.trim().replace(/^```(?:markdown|md)?\n/, '').replace(/\n```$/, '').trim()
135
136const generate = async ($: EngineInterface, why: Reason) => {
137  if (isWorking) {
138    return
139  }
140
141  isWorking = true
142  await update($, status, () => 'working')
143  await update($, reason, () => why)
144  await update($, error, () => '')
145  await update($, isHidden, () => false)
146
147  try {
148    const messages = await $.session.messages()
149
150    if (messages.length === 0) {
151      // An empty chat is not a failure: nothing to hand off, so no red band.
152      await update($, status, () => 'idle')
153
154      if (why === 'command') {
155        $.ui.toast('Nothing to hand off yet: this chat is empty.')
156      }
157
158      return
159    }
160
161    const facts = extractFacts(messages)
162    const cache = await readCache($)
163    let body = ''
164    const forked = await $.model.fork({ prompt: FORK_PROMPT })
165
166    if (forked.isAnswered) {
167      body = stripFence(forked.text)
168    } else if (forked.reason === 'nothing-to-fork') {
169      // A resumed or fresh chat has no snapshot to fork: hand the rendered transcript to a separate call.
170      const asked = await $.model.complete({
171        model: 'sonnet',
172        system: FORK_PROMPT,
173        prompt: `Here is the whole conversation to hand off:\n\n${await renderTranscript($)}`,
174        effort: 'medium',
175        timeoutMs: 300_000,
176      })
177
178      if (!asked.isAnswered) {
179        throw new Error(`the handoff could not be written (${asked.reason})`)
180      }
181
182      body = stripFence(asked.text)
183    } else {
184      throw new Error(`the handoff could not be written (${forked.reason})`)
185    }
186
187    const now = await $.clock.now()
188    const root = await $.session.root()
189    const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])
190    const dirty = await git($, ['status', '--short'])
191    const log = await git($, ['log', '--oneline', '-8'])
192    const model = await $.session.model()
193    const sessionId = await $.session.id()
194    const header = [
195      '# Handoff: continue this task in a fresh chat',
196      '',
197      `Written ${new Date(now).toISOString()} by Claude Code from a chat of about ${formatTokens(cache.tokens)} tokens (${facts.userTurns} user turns). Why now: ${WHY[why]}.`,
198      `Project folder: \`${root}\` · Model: ${model} · Original session: \`${sessionId}\``,
199      '',
200      '## How to use this prompt',
201      'You are picking up work another Claude Code session (and its user) stopped partway. You have none of that chat, so everything you need is below.',
202      '1. Read it all. Then check the real state before acting: the working tree and files in "Working tree" and the appendices. If this text disagrees with what is on disk, trust the disk and say so.',
203      '2. Do not redo what is under "Done". Honor every rule under "Decisions and rules".',
204      '3. Start at "Next steps", step 1. Ask the user only about what "Open questions and blockers" lists.',
205      '4. Paths are as they were on the original machine; a teammate may need to adapt them.',
206      '',
207    ].join('\n')
208    const tree = [
209      '## Working tree',
210      `- branch: ${branch || 'unknown (not a git folder)'}`,
211      `- uncommitted changes:\n${dirty ? dirty.split('\n').slice(0, 40).map(l => `  ${l}`).join('\n') : '  none'}`,
212      `- recent commits:\n${log ? log.split('\n').map(l => `  ${l}`).join('\n') : '  none'}`,
213    ].join('\n')
214    const text = `${header}\n${body}\n\n${tree}\n\n${renderFacts(facts)}\n`
215
216    let path = ''
217
218    try {
219      const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
220
221      if (home) {
222        path = `${home.replace(/[\\/]$/, '')}/.claude/handoffs/${stamp(now)}-${sessionId.slice(0, 8)}.md`
223        await $.fs.write(path, text)
224      }
225    } catch {
226      path = ''
227    }
228
229    await update($, draft, () => text)
230    await update($, words, () => wordsOf(text))
231    await update($, savedPath, () => path)
232    await update($, atTurns, () => facts.userTurns)
233    await update($, status, () => 'ready')
234    $.ui.toast('Handoff ready. Press "Start fresh chat" when you want to move over.')
235  } catch (e) {
236    await update($, error, () => (e instanceof Error ? e.message : String(e)))
237    await update($, status, () => 'failed')
238  } finally {
239    isWorking = false
240    $.ui.invalidate('ui.render')
241  }
242}
243
244// Ends this chat with /clear (the old one stays in /resume) and sends the handoff as the first message of the new one.
245// Only ever called from the user's click or typed command.
246const startFresh = async ($: EngineInterface, surface?: string): Promise<string> => {
247  const text = await read($, draft)
248
249  if (!text) {
250    return 'No handoff written yet. Type /handover first.'
251  }
252
253  if (await read($, isLaunching)) {
254    return 'Already moving to a fresh chat.'
255  }
256
257  await update($, isLaunching, () => true)
258
259  try {
260    await $.command.run({ command: 'clear' })
261  } catch (e) {
262    await update($, isLaunching, () => false)
263    await $.ui.copy({ text, surface: surface as never })
264
265    return `Could not open a fresh chat from here (${String(e)}). The handoff prompt is on your clipboard: run /clear, then paste it.`
266  }
267
268  try {
269    await $.prompt.submit({ text, asUser: true })
270
271    return 'Fresh chat started with the handoff prompt.'
272  } catch (e) {
273    const box = await $.prompt.fill({ text })
274
275    return box.isFilled
276      ? `Fresh chat opened. The handoff prompt is in the prompt box: press Enter to send it (${String(e)}).`
277      : `Fresh chat opened, but the prompt could not be sent (${String(e)}). It is saved in ${(await read($, savedPath)) || 'the handoff band'}.`
278  }
279}
280
281const check = async ($: EngineInterface) => {
282  if (isWorking || isTurnRunning || (await read($, status)) === 'working') {
283    return
284  }
285
286  const current = await read($, status)
287
288  if (current === 'ready' || current === 'failed') {
289    return
290  }
291
292  // Cache Clock's numbers can outlive the chat they came from: never fire in a chat with no turns yet.
293  if ((await $.session.turns()) === 0) {
294    return
295  }
296
297  const cache = await readCache($)
298
299  if (!isTokenOffered && cache.tokens >= TOKEN_LIMIT) {
300    isTokenOffered = true
301    await generate($, 'tokens')
302
303    return
304  }
305
306  const now = await $.clock.now()
307  const isCold = cache.last > 0 && cache.ttl > 0 && now >= cache.last + cache.ttl
308
309  if (isCold && cache.tokens >= MIN_COLD_TOKENS && coldOfferedFor !== cache.last) {
310    coldOfferedFor = cache.last
311    await generate($, 'cold')
312  }
313}
314
315const runCommand = async ($: EngineInterface, args: string): Promise<{ text: string }> => {
316  const arg = args.trim().toLowerCase()
317
318  if (arg === 'start') {
319    return { text: await startFresh($) }
320  }
321
322  if (arg === 'copy' || arg === 'show') {
323    const text = await read($, draft)
324
325    if (!text) {
326      return { text: 'No handoff written yet. Type /handover first.' }
327    }
328
329    if (arg === 'show') {
330      return { text }
331    }
332
333    const copied = await $.ui.copy({ text })
334
335    return { text: copied.isCopied ? 'Handoff prompt copied.' : `Could not copy (${copied.reason}). Use /handover show.` }
336  }
337
338  if (isWorking) {
339    return { text: 'The handoff is already being written.' }
340  }
341
342  if ((await $.session.turns()) === 0) {
343    return { text: 'Nothing to hand off yet: this chat is empty.' }
344  }
345
346  void generate($, 'command')
347
348  return { text: 'Writing the handoff prompt. A button to start the fresh chat appears above the prompt when it is ready.' }
349}
350
351export const register: Register = on => {
352  on('session.start', async ($, e, next) => {
353    const description = 'Write a detailed handoff prompt, then start a fresh chat from it. Args: start, copy, show'
354
355    // The installed handoff:handoff skill may own /handoff: then the same command answers as /handover.
356    try {
357      await $.command.register({ name: 'handoff', description })
358    } catch (error) {
359      $.ui.log(`chat-handoff: /handoff is taken (${String(error)}); use /handover`, { to: 'debug' })
360    }
361
362    await $.command.register({ name: 'handover', description })
363
364    // A leftover working/failed band from another chat must not greet a new one.
365    const left = await read($, status)
366
367    if (left === 'working' || left === 'failed') {
368      await update($, status, () => 'idle')
369    }
370
371    $.clock.every(TICK_MS, () => {
372      void check($)
373    })
374
375    return next(e)
376  })
377
378  on('command.run', { command: 'handoff' }, ($, e) => runCommand($, e.args))
379  on('command.run', { command: 'handover' }, ($, e) => runCommand($, e.args))
380
381  on('turn.start', async ($, e, next) => {
382    if (e.agentId === undefined) {
383      isTurnRunning = true
384    }
385
386    return next(e)
387  })
388
389  on('turn.complete', async ($, e, next) => {
390    if (e.agentId === undefined) {
391      isTurnRunning = false
392    }
393
394    return next(e)
395  })
396
397  // A new chat starts the triggers over.
398  on('session.end', async ($, e, next) => {
399    if (e.reason === 'clear') {
400      isTokenOffered = false
401      coldOfferedFor = 0
402      isTurnRunning = false
403      await update($, status, () => 'idle')
404      await update($, draft, () => '')
405      await update($, savedPath, () => '')
406      await update($, isLaunching, () => false)
407    }
408
409    return next(e)
410  })
411
412  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
413    const current = await read($, status)
414
415    if (current === 'idle' || e.props.hasSurvey || (await read($, isHidden))) {
416      return next(e)
417    }
418
419    const { Box, Button, Text } = $.ui.resolve(e)
420    const why = WHY[await read($, reason)] ?? ''
421    const hide = <Button key="hide" label="×" onPress={() => update($, isHidden, () => true)} />
422
423    if (current === 'working') {
424      return (
425        <Box paddingX={1} gap={1}>
426          <Text color={AMBER} weight="bold">⇄ Handoff</Text>
427          <Text dimColor>{`${why}: writing the handoff prompt from the whole chat...`}</Text>
428        </Box>
429      )
430    }
431
432    if (current === 'failed') {
433      return (
434        <Box paddingX={1} gap={1}>
435          <Text color={RED} weight="bold">⇄ Handoff failed</Text>
436          <Text dimColor wrap="truncate-end">{await read($, error)}</Text>
437          <Button key="retry" label="Retry" onPress={() => void generate($, 'command')} />
438          {hide}
439        </Box>
440      )
441    }
442
443    const path = await read($, savedPath)
444    const isStale = (await $.session.turns()) > (await read($, atTurns))
445    const launching = await read($, isLaunching)
446
447    return (
448      <Box flexDirection="column" paddingX={1}>
449        <Box gap={1}>
450          <Text color={GREEN} weight="bold">⇄ Handoff ready</Text>
451          <Text dimColor>{`${why} · ${await read($, words)} words`}</Text>
452          <Button
453            key="start"
454            label={launching ? 'Starting...' : 'Start fresh chat'}
455            variant="primary"
456            onPress={async () => {
457              $.ui.toast(await startFresh($, e.surface))
458            }}
459          />
460          <Button
461            key="copy"
462            label="Copy"
463            onPress={async press => {
464              const copied = await $.ui.copy({ text: await read($, draft), surface: press.surface })
465              $.ui.toast(copied.isCopied ? 'Handoff prompt copied.' : `Could not copy (${copied.reason}). Use /handover show.`)
466            }}
467          />
468          {hide}
469        </Box>
470        <Text dimColor wrap="truncate-end">
471          {`${isStale ? 'The chat moved on since: /handover writes a fresh one. ' : ''}${path ? `Saved to ${path} (send this file to a teammate)` : '/handover show prints it as plain text'}`}
472        </Text>
473      </Box>
474    )
475  })
476}
477
hooks/facts.ts 187 lines
1import type { SessionMessage } from 'claude-code'
2
3// What the chat touched, read from the transcript itself (not left to the model's memory): files, skills,
4// slash commands, tools, links and shell commands. The handoff prompt carries this as its appendix.
5export type Facts = {
6  files: { path: string; reads: number; edits: number; writes: number }[]
7  skills: string[]
8  commands: string[]
9  tools: { name: string; count: number }[]
10  agents: string[]
11  searches: string[]
12  links: string[]
13  bash: string[]
14  userTurns: number
15}
16
17const URL_RE = /https?:\/\/[^\s)"'<>\]`}\\]+/g
18const COMMAND_RE = /<command-name>\/?([^<\s]+)<\/command-name>/g
19const MAX_LINKS = 80
20const MAX_BASH = 30
21const MAX_FILES = 120
22
23const asString = (v: unknown) => (typeof v === 'string' ? v : '')
24
25const cleanUrl = (u: string) => u.replace(/[.,;:!?]+$/, '')
26
27const addUnique = (list: string[], value: string) => {
28  if (value && !list.includes(value)) {
29    list.push(value)
30  }
31}
32
33export const extractFacts = (messages: readonly SessionMessage[]): Facts => {
34  const files = new Map<string, { path: string; reads: number; edits: number; writes: number }>()
35  const tools = new Map<string, number>()
36  const skills: string[] = []
37  const commands: string[] = []
38  const agents: string[] = []
39  const searches: string[] = []
40  const links: string[] = []
41  const bash: string[] = []
42  let userTurns = 0
43
44  const touch = (path: string, kind: 'reads' | 'edits' | 'writes') => {
45    if (!path) {
46      return
47    }
48
49    const row = files.get(path) ?? { path, reads: 0, edits: 0, writes: 0 }
50    row[kind] += 1
51    // Re-insert so the map ends in order of last use.
52    files.delete(path)
53    files.set(path, row)
54  }
55
56  const scanText = (text: string) => {
57    for (const m of text.matchAll(URL_RE)) {
58      addUnique(links, cleanUrl(m[0]))
59    }
60  }
61
62  for (const message of messages) {
63    if (message.role === 'user') {
64      if (message.text && !message.toolResults?.length) {
65        userTurns += 1
66      }
67
68      for (const m of message.text.matchAll(COMMAND_RE)) {
69        addUnique(commands, `/${m[1]}`)
70      }
71    }
72
73    scanText(message.text)
74
75    for (const use of message.toolUses) {
76      const input = use.input ?? {}
77
78      tools.set(use.tool, (tools.get(use.tool) ?? 0) + 1)
79
80      switch (use.tool) {
81        case 'Read':
82          touch(asString(input.file_path), 'reads')
83          break
84        case 'Edit':
85        case 'MultiEdit':
86          touch(asString(input.file_path), 'edits')
87          break
88        case 'Write':
89          touch(asString(input.file_path), 'writes')
90          break
91        case 'NotebookEdit':
92          touch(asString(input.notebook_path), 'edits')
93          break
94        case 'Skill':
95          addUnique(skills, [asString(input.skill), asString(input.args)].filter(Boolean).join(' '))
96          break
97        case 'Agent':
98        case 'Task':
99          addUnique(agents, asString(input.subagent_type) || 'general-purpose')
100          break
101        case 'WebSearch':
102          addUnique(searches, asString(input.query))
103          break
104        case 'WebFetch':
105          addUnique(links, cleanUrl(asString(input.url)))
106          break
107        case 'Bash':
108        case 'PowerShell': {
109          const command = asString(input.command).trim().split('\n')[0]?.slice(0, 200) ?? ''
110
111          if (command) {
112            const at = bash.indexOf(command)
113
114            if (at !== -1) {
115              bash.splice(at, 1)
116            }
117
118            bash.push(command)
119          }
120
121          scanText(asString(input.command))
122          break
123        }
124        default:
125          break
126      }
127    }
128  }
129
130  return {
131    files: [...files.values()].slice(-MAX_FILES).reverse(),
132    skills,
133    commands,
134    tools: [...tools.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count),
135    agents,
136    searches,
137    links: links.slice(0, MAX_LINKS),
138    bash: bash.slice(-MAX_BASH).reverse(),
139    userTurns,
140  }
141}
142
143const bullets = (items: string[], empty: string) => (items.length ? items.map(i => `- ${i}`).join('\n') : empty)
144
145const fileLine = (f: Facts['files'][number]) => {
146  const did = [
147    f.writes ? `created or overwritten ${f.writes}x` : '',
148    f.edits ? `edited ${f.edits}x` : '',
149    f.reads ? `read ${f.reads}x` : '',
150  ]
151    .filter(Boolean)
152    .join(', ')
153
154  return `\`${f.path}\` (${did})`
155}
156
157export const renderFacts = (f: Facts): string => {
158  const changed = f.files.filter(x => x.writes || x.edits)
159  const readOnly = f.files.filter(x => !x.writes && !x.edits)
160  const mcp = f.tools.filter(t => t.name.startsWith('mcp__'))
161  const builtin = f.tools.filter(t => !t.name.startsWith('mcp__'))
162  const line = (t: { name: string; count: number }) => `${t.name} x${t.count}`
163
164  return [
165    '## Appendix A: files created or changed in the chat (most recent first)',
166    bullets(changed.map(fileLine), '- none recorded'),
167    '',
168    '## Appendix B: files read only',
169    bullets(readOnly.map(fileLine), '- none recorded'),
170    '',
171    '## Appendix C: skills and slash commands used',
172    bullets([...f.skills.map(s => `skill: ${s}`), ...f.commands.map(c => `command: ${c}`)], '- none recorded'),
173    '',
174    '## Appendix D: tools and subagents used',
175    `- built-in: ${builtin.map(line).join(', ') || 'none'}`,
176    `- MCP: ${mcp.map(line).join(', ') || 'none'}`,
177    `- subagents: ${f.agents.join(', ') || 'none'}`,
178    '',
179    '## Appendix E: links and searches',
180    bullets(f.links, '- no links'),
181    ...(f.searches.length ? ['', 'Web searches:', bullets(f.searches, '')] : []),
182    '',
183    '## Appendix F: recent shell commands (most recent first)',
184    bullets(f.bash.map(c => `\`${c}\``), '- none recorded'),
185  ].join('\n')
186}
187
types/index.d.ts 23 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'chat-handoff': {
4      // 'idle' nothing to show, 'working' the prompt is being written, 'ready' it is written, 'failed' it could not be.
5      status: 'idle' | 'working' | 'ready' | 'failed'
6      // What fired it: 'tokens', 'cold' or 'command'.
7      reason: string
8      // The handoff prompt, plain text; '' until one is written.
9      draft: string
10      // Where a copy was saved, '' when it could not be saved.
11      savedPath: string
12      words: number
13      // How many prompts the user had sent when the draft was written; a later count means the chat moved on.
14      atTurns: number
15      error: string
16      isHidden: boolean
17      isLaunching: boolean
18    }
19  }
20}
21
22export type Reason = 'tokens' | 'cold' | 'command'
23