长对话无损交接:上下文到 25 万 token、缓存变冷或输入 /handoff 时,写出比官方摘要详细得多的交接提示词,一键带去全新对话

十个给 Claude Code 用的 Mod。只能装在 Claude Code(终端和桌面 App 的 Code 页)。Codex、Anti-Gravity 没有 Mod 接口,装不了。
先装 switchboard(总控台),其余随意挑。
终端里:
/plugin install switchboard --marketplace weizhongl/claude-code-mods
第一次会问 Add marketplace?,回 y,再选 user scope。其余 Mod 把名字换掉即可:usage-gauge、brain-dump、helper-desk、cache-clock、handoff、reflect、chat-inventory、screen-share、replay-theater。
在 shell 里:
claude plugin install switchboard --marketplace weizhongl/claude-code-mods
桌面 App(/plugin 在那里不能用):点输入框旁的 +,选 Plugins → Add marketplace,填 weizhongl/claude-code-mods,再逐个 Add plugin。
| Mod | 怎么用 | 注意 |
|---|---|---|
| switchboard | 输入 /mods 打开总控台。每个 Mod 一行:On/Off、Hide UI Panel。输入框上方那一行条(额度、缓存倒计时、任务进度、👁 按钮)也是它画的:平时只占一行,别的 Mod 有事才在它旁边多出一行。条的右端有按钮:「收起」变成一小行(只剩几个百分比和倒计时),再点「隐藏」只留一个「📊 展开」按钮;也可以输入 /bar(或 /bar full、/bar mini、/bar off)切换,你的选择会记住。进度条在桌面版是细线,在终端里是小方块。也可以 /mods off 名字、/mods on 名字、/mods hide 名字、/mods show 名字。新放进同一个文件夹的 Mod 会自动出现。 | Off = 该 Mod 的钩子立刻停手;Hide = 后台照跑,只收起面板。 |
| usage-gauge | 自动。显示在总控台那一行条里:对话上下文用了多少、5 小时(会话)额度、本周额度,各有短进度条、颜色(绿 / 黄 / 红)和重置时间,最后是本次对话花费。/gauge 看文字版。 | 读的是引擎自己的数字(和状态栏同一份),零 Token。额度数字要等发过一条消息、引擎读到后才出现。 |
| brain-dump | 直接打字或语音说一大段(默认 180 字以上、没有排版)。它会拦住,先问你 2–3 个问题;你在输入框直接回答(回「跳过」= 不补充),然后重写成 The Task / The Why / Guardrails / What Done Looks Like 再发给 Claude。输入框上方有 跳过提问 / 原话直接发 / 放弃 三个按钮,也可用 /dump skip、/dump raw、/dump cancel。消息开头加 >> 可以跳过拦截。 | 会用到两次小的模型调用(提问用 haiku,重写用 sonnet)。 |
| helper-desk | 自动。Claude 有待办清单时,一行条里显示「任务 n/m」和短进度条;Claude 派出子代理时,侧边栏出现 Helper Desk,每个子代理一张卡(进度、正在做什么、读入/输出 token、花费)。/helpers 手动打开。 | 子代理花费是按公开标价估算;整段对话总花费取自引擎账本。零额外 Token。 |
| cache-clock | 自动。第一条回复之后,一行条里显示「⏱ mm:ss」。点旁边的 Keep Warm 或输入 /keep warm,快冷之前会自动发一条极小的心跳续命。你发下一条真实消息,或连续保温满 2 小时,会自动关闭。 | 拿不到服务器的缓存时间,所以倒计时是推算(订阅登入 60 分钟,API 5 分钟,并按真实缓存命中自动校正)。设置里的「缓存有效分钟数」可固定。 |
| handoff | 对话累计到 250,000 token、或缓存变冷时,输入框上方提醒你。按「生成交接」或输入 /handoff,会写出一份详细的交接提示词(存在 ~/.claude/handoffs/,可直接发给同事),再按 Start Fresh Chat with Handoff 带去新对话。 | 生成会花 Token,默认只提醒、不自动生成(设置里「触发后做什么」选 generate 才会自动生成)。桌面 App 不允许自动开新对话时,会改成复制到剪贴板。 |
| reflect | 你在对话里说「以后永远不要…」「每次都要…」「记住…」这类话,输入框上方弹出小卡片,一键存进:内置 Memory / 项目 CLAUDE.md(没有就 AGENTS.md)/ 你指定的文件 / 仅本次忽略。 | 只在句子里有规则关键词时才调用一次小模型判断。设置里可填默认的第二大脑文件路径。 |
| chat-inventory | 自动。侧边栏「In this chat」:目标与待办、调用过的连接器(含次数)、用过的技能、读过和新建/修改过的文件(点文件在文件管理器里定位)。/inventory 手动打开。 | 纯追踪,零 Token。 |
| screen-share | 输入 /screen share 开或关,或点一行条右端的 👁 按钮。开启后,画面上的邮箱、电话、金额、API key、公司名、人名遮成灰条。 | 只改画面,不改真实数据;不遮输入框里正在打的字。人名只遮设置里填的名单和「客户:xxx」「陈小姐」这类写法,没有真正的人名识别。 |
| replay-theater | 自动录下 Claude 用 Edit / Write / NotebookEdit 对文件做的每次修改。输入 /replay 打开:选文件、播放/暂停、上一步/下一步、点步骤号跳转,看每一步的 diff。Revert 按钮按一次变成「再按一次确认」,第二次才会写回文件。/replay clear 清空。 | 用 Bash 改文件(如 sed)录不到;单个文件超过 20 万字不录。记录只在当前会话。 |
每个 Mod 一个文件夹:.claude-plugin/plugin.json、hooks/hooks.json、hooks/register.tsx,有状态的还有 types/index.d.ts。switchboard、screen-share、replay-theater 带自动测试(claude plugin test <文件夹>)。
各 Mod 里的 hooks/flag.ts 是同一小段代码(读总控台的开关),Mod 之间不能共享文件,所以每个放一份。
hooks/register.tsx 310 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { flagOf } from './flag'
5
6const MOD = 'handoff'
7const PANE = 'handoff'
8const MAX_LEDGER = 12_000
9
10const reason = atom({ plugin: 'handoff', key: 'reason' } as const, '')
11const status = atom({ plugin: 'handoff', key: 'status' } as const, 'idle')
12const prompt = atom({ plugin: 'handoff', key: 'prompt' } as const, '')
13const path = atom({ plugin: 'handoff', key: 'path' } as const, '')
14const error = atom({ plugin: 'handoff', key: 'error' } as const, '')
15const contextTokens = atom({ plugin: 'handoff', key: 'contextTokens' } as const, 0)
16
17// Read-only views of other mods; each owner alone writes its own.
18const clockEnd = { plugin: 'cache-clock', key: 'expiresAt' } as const
19const invGoal = { plugin: 'chat-inventory', key: 'goal' } as const
20const invRead = { plugin: 'chat-inventory', key: 'filesRead' } as const
21const invChanged = { plugin: 'chat-inventory', key: 'filesChanged' } as const
22const invSkills = { plugin: 'chat-inventory', key: 'skills' } as const
23const invConnectors = { plugin: 'chat-inventory', key: 'connectors' } as const
24
25let limit = 250_000
26let isAuto = false
27let isDismissed = false
28
29const INSTRUCTIONS = `You are writing a HANDOFF for a fresh conversation that will continue this exact work with no memory of this one. Be ten times more detailed than a normal summary: nothing the next session needs may be lost. Write in the language the user has been using in this conversation.
30
31Cover, in this order, as markdown sections:
321. 项目总体目标 (the real goal, who it is for, why it matters)
332. 已完成的里程碑 (each finished step, with the outcome and how it was verified)
343. 所有涉及文件的最新状态与路径 (every file read, created or edited: absolute path, what it holds now, what is unfinished in it)
354. 已确立的规则 / 避坑指南 (decisions made, the user's stated preferences and hard rules, mistakes made and how they were fixed, things that must not be repeated)
365. 用到的 Skills / 工具 / 连接器 (what was called, what for, any setup needed)
376. 外部链接与资料 (every URL, document, ticket or account the work depends on)
387. 接下来要立刻做的事 Next Steps (ordered, each concrete enough to start without asking)
398. 未解决的问题与风险 (open questions, things waiting on the user)
40
41Use exact names, paths, numbers and quotes from the conversation. Do not invent anything. If something is unknown, say it is unknown. Output only the handoff, no preamble.`
42
43async function homeDir($: EngineInterface): Promise<string> {
44 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '.'
45}
46
47async function ledger($: EngineInterface): Promise<string> {
48 const changed = (await $.state.get(invChanged)).value ?? {}
49 const files = (await $.state.get(invRead)).value ?? []
50 const skills = (await $.state.get(invSkills)).value ?? []
51 const servers = (await $.state.get(invConnectors)).value ?? {}
52 const goal = (await $.state.get(invGoal)).value ?? ''
53
54 const messages = await $.session.messages()
55 const urls = new Set<string>()
56 if (Array.isArray(messages)) {
57 for (const message of messages) {
58 const text = `${message.text} ${JSON.stringify(message.toolUses ?? [])}`
59 for (const url of text.match(/https?:\/\/[^\s"'<>)\]]+/g) ?? []) urls.add(url)
60 }
61 }
62
63 const lines = [
64 `首个任务:${goal || '(未记录)'}`,
65 `新建/修改的文件(${Object.keys(changed).length}):`,
66 ...Object.entries(changed).map(([file, kind]) => `- [${kind === 'created' ? '新建' : '修改'}] ${file}`),
67 `读取过的文件(${files.length}):`,
68 ...files.map(file => `- ${file}`),
69 `用过的 Skills:${skills.join('、') || '无'}`,
70 `调用过的连接器:${Object.entries(servers).map(([name, n]) => `${name}×${n}`).join('、') || '无'}`,
71 `出现过的链接(${urls.size}):`,
72 ...[...urls].slice(0, 80).map(url => `- ${url}`),
73 ]
74
75 return lines.join('\n').slice(0, MAX_LEDGER)
76}
77
78async function save($: EngineInterface, text: string): Promise<string> {
79 const home = await homeDir($)
80 const at = new Date(await $.clock.now())
81 const two = (n: number) => String(n).padStart(2, '0')
82 const stamp = `${at.getFullYear()}${two(at.getMonth() + 1)}${two(at.getDate())}-${two(at.getHours())}${two(at.getMinutes())}`
83 const dir = `${home}/.claude/handoffs`
84 const file = `${dir}/handoff-${stamp}.md`
85 try {
86 await $.fs.write(file, text)
87 } catch {
88 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
89 await $.process
90 .run(isWindows ? ['cmd', '/c', 'mkdir', dir.replaceAll('/', '\\')] : ['mkdir', '-p', dir])
91 .catch(() => undefined)
92 try {
93 await $.fs.write(file, text)
94 } catch {
95 const fallback = `${home}/.claude/handoff-${stamp}.md`
96 await $.fs.write(fallback, text)
97
98 return fallback
99 }
100 }
101
102 return file
103}
104
105async function generate($: EngineInterface) {
106 if ((await read($, status)) === 'working') return
107 await update($, status, () => 'working')
108 await update($, error, () => '')
109 try {
110 const facts = await ledger($)
111 const ask = `${INSTRUCTIONS}\n\n--- AUTOMATIC INVENTORY (from tool-call records, exact) ---\n${facts}`
112 let reply = await $.model.fork({ prompt: ask })
113 if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
114 const messages = await $.session.messages()
115 const transcript = Array.isArray(messages)
116 ? messages.map(m => `${m.role}: ${m.text}`).join('\n\n').slice(-120_000)
117 : ''
118 reply = await $.model.complete({
119 model: await $.session.model(),
120 prompt: `${INSTRUCTIONS}\n\n--- AUTOMATIC INVENTORY ---\n${facts}\n\n--- CONVERSATION ---\n${transcript}`,
121 maxTokens: 16_000,
122 })
123 }
124 if (!reply.isAnswered) {
125 throw new Error(`没有生成成功(${reply.reason})`)
126 }
127 const day = new Date(await $.clock.now()).toISOString().slice(0, 10)
128 const text = [
129 `# 交接提示词 Handoff · ${day}`,
130 '',
131 '你是接手这项工作的新对话,没有上一个对话的任何记忆。先完整读完下面的交接,再用三句话复述你理解的目标和下一步,等我确认后再动手。',
132 '',
133 reply.text.trim(),
134 '',
135 '---',
136 '## 附录:自动盘点(来自工具调用记录,不是模型转述)',
137 facts,
138 '',
139 ].join('\n')
140 const file = await save($, text)
141 await update($, prompt, () => text)
142 await update($, path, () => file)
143 await update($, status, () => 'ready')
144 await update($, reason, () => '')
145 await $.ui.open({ id: PANE, title: 'Handoff', focus: true })
146 } catch (failure) {
147 await update($, error, () => (failure instanceof Error ? failure.message : String(failure)))
148 await update($, status, () => 'error')
149 }
150}
151
152async function startFresh($: EngineInterface) {
153 const text = await read($, prompt)
154 if (text === '') return
155 try {
156 await $.command.run({ command: 'clear', args: '' })
157 await update($, status, () => 'idle')
158 await $.prompt.submit({ text })
159 } catch {
160 await $.ui.copy({ text })
161 $.ui.toast('这里不能自动开新对话:交接提示词已复制,开一个新对话直接贴上就行。')
162 }
163}
164
165async function copyPrompt($: EngineInterface) {
166 const result = await $.ui.copy({ text: await read($, prompt) })
167 $.ui.toast(result.isCopied ? '交接提示词已复制' : '复制失败,文件在:' + (await read($, path)))
168}
169
170async function check($: EngineInterface) {
171 if (!(await flagOf($, MOD)).isOn) return
172 if ((await read($, status)) === 'working') return
173 const usage = await $.session.usage()
174 const tokens = usage.context.tokens ?? 0
175 await update($, contextTokens, () => tokens)
176
177 let why = ''
178 if (tokens >= limit) {
179 why = `对话已累计 ${Math.round(tokens / 1000)}k tokens,开始容易变笨`
180 } else {
181 const end = (await $.state.get(clockEnd)).value
182 const at = await $.clock.now()
183 if (end !== null && end !== undefined && at > end && tokens > 20_000) {
184 why = '提示词缓存已经变冷,继续聊会按全价重读整段对话'
185 }
186 }
187 if (why === '') {
188 isDismissed = false
189 if ((await read($, reason)) !== '') await update($, reason, () => '')
190
191 return
192 }
193 if (isDismissed) return
194 await update($, reason, () => why)
195 if (isAuto) await generate($)
196}
197
198export const register: Register = (on, options) => {
199 limit = Math.max(20_000, Number(options.limitTokens ?? 250_000))
200 isAuto = options.autoMode === 'generate'
201
202 on('session.start', async ($, e, next) => {
203 await $.command.register({
204 name: 'handoff',
205 description: '生成无损交接提示词,一键带去全新对话',
206 })
207 // A reload ends any generation that was running: never leave it showing as busy.
208 if ((await read($, status)) === 'working') await update($, status, () => 'idle')
209 $.clock.every(10_000, () => void check($).catch(() => undefined))
210
211 return next(e)
212 })
213
214 on('session.end', async ($, e, next) => {
215 if (e.reason === 'clear') {
216 await update($, status, () => 'idle')
217 await update($, reason, () => '')
218 }
219
220 return next(e)
221 })
222
223 on('command.run', { command: 'handoff' }, async $ => {
224 void generate($)
225
226 return { text: '正在生成交接提示词,完成后会弹出 Handoff 面板(会花一点 Token)。' }
227 })
228
229 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
230 const below = await next(e)
231 const flag = await flagOf($, MOD)
232 const why = await read($, reason)
233 const state = await read($, status)
234 if (!flag.isOn || flag.isHidden) return below
235 if (state === 'idle' && why === '') return below
236
237 const { Box, Text, Button } = $.ui.resolve(e)
238 const dot = (color: string) => <Text color={color}>●</Text>
239
240 return (
241 <Box flexDirection="column">
242 {below}
243 <Box columnGap={2} flexWrap="wrap">
244 {state === 'working' && (
245 <Box gap={1}>
246 {dot('yellow')}
247 <Text>正在生成交接文档…</Text>
248 </Box>
249 )}
250 {state === 'error' && (
251 <Box gap={1}>
252 {dot('red')}
253 <Text>交接失败:{await read($, error)}</Text>
254 <Button key="ok" label="知道了" onPress={() => update($, status, () => 'idle')} />
255 </Box>
256 )}
257 {state === 'ready' && (
258 <Box gap={1}>
259 {dot('green')}
260 <Text>交接文档已生成</Text>
261 <Button key="fresh" variant="primary" onPress={() => startFresh($)}>
262 Start Fresh Chat with Handoff
263 </Button>
264 <Button key="close" label="收起" onPress={() => update($, status, () => 'idle')} />
265 </Box>
266 )}
267 {(state === 'idle' || state === undefined) && why !== '' && (
268 <Box gap={1}>
269 {dot('yellow')}
270 <Text>{why}</Text>
271 <Button key="make" variant="primary" label="生成交接" onPress={() => generate($)} />
272 <Button
273 key="later"
274 label="稍后"
275 onPress={() => {
276 isDismissed = true
277
278 return update($, reason, () => '')
279 }}
280 />
281 </Box>
282 )}
283 </Box>
284 </Box>
285 )
286 })
287
288 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
289 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
290 const state = await read($, status)
291 const file = await read($, path)
292 if (state !== 'ready') {
293 return <Text dimColor>还没有交接文档。输入 /handoff 生成。</Text>
294 }
295
296 return (
297 <Box flexDirection="column" gap={1}>
298 <Box gap={1} flexWrap="wrap">
299 <Button key="fresh" variant="primary" onPress={() => startFresh($)}>
300 Start Fresh Chat with Handoff
301 </Button>
302 <Button key="copy" label="复制交接提示词" onPress={() => copyPrompt($)} />
303 </Box>
304 <Text dimColor>已存档:{file}(可直接发给同事)</Text>
305 <Markdown text={await read($, prompt)} />
306 </Box>
307 )
308 })
309}
310hooks/flag.ts 14 lines1import type { EngineInterface } from 'claude-code'
2
3const FLAGS = { plugin: 'switchboard', key: 'flags' } as const
4
5export type Flag = { isOn: boolean; isHidden: boolean }
6
7// Switchboard owns the flags. A mod it has never heard of counts as on, shown.
8export async function flagOf($: EngineInterface, mod: string): Promise<Flag> {
9 const { value } = await $.state.get(FLAGS)
10 const own = value?.[mod]
11
12 return { isOn: own?.isOn ?? true, isHidden: own?.isHidden ?? false }
13}
14types/index.d.ts 24 lines1declare module 'claude-code' {
2 interface PluginState {
3 handoff: {
4 // why the reminder shows; '' = no reminder
5 reason: string
6 status: 'idle' | 'working' | 'ready' | 'error'
7 prompt: string
8 path: string
9 error: string
10 contextTokens: number
11 }
12 // Read-only views of other mods' values (each owner alone writes its own).
13 switchboard: { flags: Record<string, { isOn: boolean; isHidden: boolean }> }
14 'cache-clock': { expiresAt: number | null }
15 'chat-inventory': {
16 goal: string
17 filesRead: string[]
18 filesChanged: Record<string, 'created' | 'edited'>
19 skills: string[]
20 connectors: Record<string, number>
21 }
22 }
23}
24