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…

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.
$.session.usage()). It checks every 3 seconds while no turn is running.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.One plain-text markdown prompt with:
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.
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.
/clear plus a submitted prompt.cache-clock mod for the cold-cache trigger./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.
hooks/register.tsx 477 lines1import { 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}
477hooks/facts.ts 187 lines1import 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}
187types/index.d.ts 23 lines1declare 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