一鍵交接:上下文用量到門檻時在輸入框上方出現交接按鈕,寫摘要、清空對話、把摘要送進新對話並改標題(續N)

Ricky 的 Claude Code mods。
上下文快滿時,把目前的工作整理成交接摘要,清空對話後當成第一句送出,標題自動加「(續N)」。雲端對話改成開一個新的雲端對話送出摘要、再封存舊的(原地清空會在容器重啟時被還原)。
/handoff/handoff 送出);不要了就說「取消」(或 /handoff 取消);想改內容就直接說要改什麼/clear、改標題、把摘要送出卡住的話用 /handoff 重來。
/config 裡的 handoff 項目)thresholdTokens:門檻 token 數,預設 150000。用 token 數而不是百分比,是因為本機上限約 20 萬、雲端 100 萬,同一個百分比差很多autoSend:跳過預覽直接交接,預設關讀最近的對話紀錄(本機 ~/.claude/projects/ 加上雲端帳號的其他對話),找出一直重複交代的事、讓你緊張的動作、每次檢查成果的固定動作,排除 Hook 或 Skill 就能解決的,提出最多 5 個 Mod 提案,每個附原話證據。
/mod-scout:開始探勘。先由程式把你親手打的字整理成 ~/.claude/mod-scout/digest-日期.md(金鑰、token 先遮蔽),再自動送出分析要求/mod-scout 做 N:之後再做上次報告的第 N 個/config 裡的 mod-scout 項目)sessions:讀最近幾次對話,預設 30remindDays:幾天提醒一次,預設 90,0 表示不提醒本機讀紀錄要有 python3;沒有的話只讀雲端對話。
/plugin marketplace add ricky94511/claude-mods
/plugin install handoff@claude-mods
/plugin install mod-scout@claude-mods
桌面 app:點輸入框旁的 + → Plugins → Add marketplace,輸入 ricky94511/claude-mods,再 Add plugin。
雲端環境會跳過 marketplace,所以改用 setup script 把 repo clone 進容器,再用 CLAUDE_CODE_PLUGIN_DIRS 載入。 在環境設定的 Setup script 最後加上這段(失敗也不會讓 session 起不來):
if [ -d /home/user/plugins/claude-mods/.git ]; then
git -C /home/user/plugins/claude-mods fetch -q --depth 1 origin main && git -C /home/user/plugins/claude-mods reset -q --hard origin/main || true
else
GIT_TERMINAL_PROMPT=0 git clone -q --depth 1 https://github.com/ricky94511/claude-mods /home/user/plugins/claude-mods || true
fi
if [ -d /home/user/plugins/claude-mods ]; then
python3 - <<'PY'
import json, pathlib
root = pathlib.Path("/home/user/plugins/claude-mods")
p = pathlib.Path.home() / ".claude/settings.json"
d = json.loads(p.read_text()) if p.exists() else {}
env = d.setdefault("env", {})
dirs = [x for x in env.get("CLAUDE_CODE_PLUGIN_DIRS", "").split(":") if x]
# repo 裡每個有 plugin.json 的資料夾都載入,之後加新 mod 不用再改 setup script
for mod in sorted(root.glob("*/.claude-plugin/plugin.json")):
path = str(mod.parent.parent)
if path not in dirs:
dirs.append(path)
env["CLAUDE_CODE_PLUGIN_DIRS"] = ":".join(dirs)
cmd = "git -C /home/user/plugins/claude-mods pull -q --ff-only origin main 2>/dev/null || true"
starts = d.setdefault("hooks", {}).setdefault("SessionStart", [])
if not any(h.get("command") == cmd for s in starts for h in s.get("hooks", [])):
starts.append({"hooks": [{"type": "command", "command": cmd}]})
p.write_text(json.dumps(d, indent=2))
PY
fi
exit 0
改完開新的雲端對話才會生效。
hooks/register.tsx 401 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pending, Phase } from '../types'
5
6const phase = atom({ plugin: 'handoff', key: 'phase' } as const, 'idle' as Phase)
7// 上下文用了多少 token(最近一次回應的輸入量)
8const used = atom({ plugin: 'handoff', key: 'usedTokens' } as const, null as number | null)
9const isHidden = atom({ plugin: 'handoff', key: 'isHidden' } as const, false)
10const pending = atom({ plugin: 'handoff', key: 'pending' } as const, null as Pending | null)
11// 用到這麼多 token 時跳出詢問;null 表示用設定的門檻
12const askAt = atom({ plugin: 'handoff', key: 'nextAskTokens' } as const, null as number | null)
13// 交接後要保住的新標題,第一輪結束時檢查一次
14const retitle = atom({ plugin: 'handoff', key: 'retitle' } as const, null as string | null)
15
16const START = '<<<交接開始>>>'
17const END = '<<<交接結束>>>'
18const REMOTE = 'claude-code-remote'
19
20const SUMMARY_PROMPT = `請為這段對話寫一份交接摘要,接下來會清空對話,把摘要當成新對話的第一句,所以要讓沒看過前文的你也能直接接手。
21
22照下面這個格式輸出,前面不要加任何文字:
23
24${START}
25標題:[類別] 主題(沿用這個對話的主題,不要加「續」)
26## 目標
27## 已完成
28## 還沒做的
29(依優先順序,第一項就是新對話要先做的事)
30## 改過的檔案
31(完整路徑,每個附一句改了什麼)
32## 要注意的地方
33(踩過的坑、已經做的決定和原因、使用者的偏好)
34${END}
35
36在 ${END} 後面另起一行,原樣寫:「👆 這是交接預覽。沒問題就說「送出」(或輸入 /handoff 送出),要取消就說「取消」。」
37
38要求:精簡但不要漏掉關鍵細節(指令、路徑、錯誤訊息照原文),全部用繁體中文。`
39
40const SEND = '✅ 送出並交接'
41const CANCEL = '❌ 取消'
42const NOW = '🔄 現在交接'
43const LATER = '稍後(再多用 1.5 萬 token 時再問)'
44const NEVER = '這個對話不要再問'
45
46const USAGE = '交接指令:/handoff(開始)、/handoff 送出、/handoff 取消、/handoff 重來'
47
48// 改標題要走 app 自己的工具:/rename 只改 Claude Code 記的名稱,桌機 app 側邊欄不會跟著變
49const DESKTOP = 'ccd_session_mgmt'
50
51type Session = { server: string; id: string; title: string } | null
52
53// 雲端 session 用 claude-code-remote,桌機 app 用 ccd_session_mgmt;都連不到(例如純終端機)回傳 null
54async function currentSession($: EngineInterface): Promise<Session> {
55 const servers: [string, Record<string, unknown>][] = [
56 [REMOTE, {}],
57 [DESKTOP, { session_id: 'self' }],
58 ]
59 for (const [server, args] of servers) {
60 try {
61 const { content, isError } = await $.mcp.call(server, 'get_session', args)
62 if (isError) continue
63 const text = content.find(c => c.type === 'text' && c.text)?.text
64 if (!text) continue
65 const data = JSON.parse(text) as Record<string, unknown>
66 const session = (data.ccr ?? data.session ?? data) as Record<string, unknown>
67 const id = String(session.id ?? session.session_id ?? session.sessionId ?? '')
68 if (id) return { server, id: server === DESKTOP ? 'self' : id, title: String(session.title ?? '') }
69 } catch {
70 // 這個環境沒有這個 server,換下一個
71 }
72 }
73 return null
74}
75
76// 有 app 的改名工具就用它,沒有才退回 /rename;回傳 false 表示改名失敗
77async function setTitle($: EngineInterface, title: string): Promise<boolean> {
78 const session = await currentSession($)
79 if (session) {
80 try {
81 const { isError } = await $.mcp.call(session.server, 'set_session_title', { session_id: session.id, title })
82 if (!isError) return true
83 } catch {
84 // 退回 /rename
85 }
86 }
87 try {
88 await $.command.run({ command: 'rename', args: title })
89 return !session
90 } catch {
91 return false
92 }
93}
94
95// 「[Mod] 主題(續3)」→ 主題「[Mod] 主題」、下一段是 4;沒有「續」的是第 1 段
96function nextTitle(base: string): { title: string; segment: number } {
97 const match = base.match(/(續(\d*))\s*$/)
98 const segment = match ? Number(match[1] || 1) + 1 : 2
99 const plain = base.replace(/(續\d*)\s*$/, '').trim()
100 return { title: `${plain}(續${segment})`, segment }
101}
102
103function extract(answer: string): { title: string; body: string } | null {
104 const start = answer.indexOf(START)
105 const end = answer.indexOf(END, start + START.length)
106 if (start < 0 || end < 0) return null
107 const inner = answer.slice(start + START.length, end).trim()
108 const [first = '', ...rest] = inner.split('\n')
109 const titled = first.match(/^標題[::]\s*(.+)$/)
110 return titled ? { title: titled[1]!.trim(), body: rest.join('\n').trim() } : { title: '', body: inner }
111}
112
113
114// 指令和這一輪結束的 hook 裡不能等人回答或送出 prompt:
115// 指令還沒結束時送出會被擋;turn.complete 裡的工作超過 60 秒就會被丟掉(等人選對話框常常超過)。
116// 所以都等 hook 結束後,用計時器另外開始。
117function detach($: EngineInterface, work: () => Promise<void>) {
118 $.clock.after(50, () => void work().catch(() => $.ui.toast('交接中途出錯,可以用 /handoff 重來')))
119}
120
121async function start($: EngineInterface) {
122 await update($, phase, () => 'writing')
123 $.ui.status('交接:正在寫摘要…')
124 void $.prompt.submit({ text: SUMMARY_PROMPT })
125}
126
127// 摘要寫好後先停在預覽:對話裡看得到摘要,輸入框上方也有按鈕(有畫面時);確認了才清空並交接
128async function finish($: EngineInterface, answer: string, autoSend: boolean) {
129 const summary = extract(answer)
130 if (!summary) {
131 await reset($)
132 $.ui.toast('交接失敗:找不到摘要格式,可以用 /handoff 重來')
133 return
134 }
135
136 const session = await currentSession($)
137 const { title, segment } = nextTitle(session?.title || summary.title || '未命名對話')
138 const text = `【交接】接續上一段對話(第 ${segment} 段)。以下是交接摘要,請先確認理解,再從「還沒做的」第一項開始。標題已經改好,不用再改。\n\n${summary.body}`
139
140 await $.store.set('last', { title, text, at: await $.clock.now() })
141 await update($, pending, () => ({ title, text }))
142 $.ui.status(undefined)
143
144 if (autoSend) {
145 await handOver($)
146 } else {
147 await update($, phase, () => 'preview')
148 await askConfirm($, title)
149 }
150}
151
152// 用系統自己的詢問對話框確認(跟 fupan 一樣,雲端用桌機 app 看也會跳出來);
153// 對話框被關掉時,還是可以在對話裡說「送出」或用 /handoff 送出
154async function askConfirm($: EngineInterface, title: string) {
155 let choice: string
156 try {
157 choice = await $.ui.ask(`交接預覽在上面,新標題會是「${title}」。要送出嗎?想修改就在下方直接寫要改什麼。`, {
158 header: '交接',
159 options: [SEND, CANCEL],
160 })
161 } catch {
162 return
163 }
164 if ((await read($, phase)) !== 'preview') return
165 if (choice === SEND) return handOver($)
166 if (choice === CANCEL) {
167 await reset($)
168 $.ui.toast('已取消交接')
169 return
170 }
171 // 其他文字就是修改意見:請 Claude 照著重寫,寫完會再問一次
172 await update($, phase, () => 'writing')
173 void $.prompt.submit({
174 text: `請依照這個意見修改交接預覽,重新輸出完整格式(從 ${START} 到 ${END},最後一樣加上確認提示):${choice}`,
175 })
176}
177
178// 用量到門檻時自動跳出詢問
179// 15 萬 → 「15 萬」、123456 → 「12.3 萬」
180const wan = (tokens: number) => `${Number((tokens / 10000).toFixed(1))} 萬`
181
182async function askStart($: EngineInterface, current: number, step: number) {
183 await update($, askAt, () => Number.MAX_SAFE_INTEGER)
184 let choice: string
185 try {
186 choice = await $.ui.ask(`上下文已用 ${wan(current)} token。要現在把工作交接到新對話嗎?`, {
187 header: '交接',
188 options: [NOW, LATER, NEVER],
189 })
190 } catch {
191 await update($, askAt, () => current + step)
192 return
193 }
194 if (choice === NOW) return start($)
195 if (choice === NEVER) return
196 await update($, askAt, () => current + step)
197}
198
199// 雲端對話不能原地 /clear:容器重啟時會接回清空前的對話,交接等於白做。
200// 改成開一個新的雲端對話送出摘要,再封存舊的;開不成才退回 /clear
201async function openNew($: EngineInterface, session: NonNullable<Session>, ready: Pending): Promise<boolean> {
202 try {
203 const { content, isError } = await $.mcp.call(REMOTE, 'create_session', { prompt: ready.text, title: ready.title })
204 if (isError) return false
205 const text = content.find(c => c.type === 'text' && c.text)?.text ?? ''
206 const data = JSON.parse(text) as Record<string, unknown>
207 const created = (data.ccr ?? data.session ?? data) as Record<string, unknown>
208 if (!String(created.id ?? created.session_id ?? '')) return false
209 } catch {
210 return false
211 }
212 await reset($)
213 $.ui.toast(`已開新對話「${ready.title}」,請到對話清單切過去;這個對話會封存`)
214 // 封存會收掉這個容器,放在最後
215 try {
216 await $.mcp.call(REMOTE, 'archive_session', { session_id: session.id })
217 } catch {
218 $.ui.toast('舊對話沒封存成功,請手動封存')
219 }
220 return true
221}
222
223async function handOver($: EngineInterface) {
224 const ready = await read($, pending)
225 if (!ready) return
226 await update($, phase, () => 'switching')
227 const session = await currentSession($)
228 if (session?.server === REMOTE && (await openNew($, session, ready))) return
229 await $.command.run({ command: 'clear' })
230
231 // 先送出再改標題:app 會依新對話的第一句自動取標題,先改的話可能被蓋掉
232 void $.prompt.submit({ text: ready.text, asUser: true })
233 if (!(await setTitle($, ready.title))) $.ui.toast(`標題沒改成功,請手動改成:${ready.title}`)
234 // 這一輪結束時再檢查一次,被自動標題蓋掉就改回來
235 await update($, retitle, () => ready.title)
236 await reset($)
237 $.ui.toast(`已交接到「${ready.title}」`)
238}
239
240async function reset($: EngineInterface) {
241 await update($, phase, () => 'idle')
242 await update($, pending, () => null)
243 await update($, used, () => null)
244 await update($, isHidden, () => false)
245 $.ui.status(undefined)
246}
247
248export const register: Register = (on, options) => {
249 const threshold = Number(options.thresholdTokens ?? 150000)
250 const step = Math.round(threshold / 10)
251 const autoSend = options.autoSend === true
252
253 on('session.start', async ($, e, next) => {
254 await $.command.register({ name: 'handoff', description: '一鍵交接:寫摘要 → 預覽 → 清空對話並送出', argumentHint: '[送出|取消|重來]' })
255 await $.tool.register({
256 name: 'start',
257 description:
258 '使用者要求把目前工作交接到新對話時呼叫:各種說法都算,例如「幫我交接」「交接一下」「開新對話接續」「換個對話繼續」「context 快滿了換一個」。' +
259 '只是在討論交接功能本身(例如寫或修改這個 mod)時不要呼叫。呼叫後,照回傳的格式把交接摘要寫在這一輪最後的回覆裡。',
260 })
261 await $.tool.register({
262 name: 'confirm',
263 description:
264 '只在剛寫完交接預覽、正在等使用者確認時呼叫。使用者同意送出(「送出」「好」「OK」「可以」「沒問題」「交接吧」等)用 action=send;' +
265 '使用者不要了(「取消」「先不要」「算了」「不用了」等)用 action=cancel。使用者是要修改摘要內容時不要呼叫,直接照他說的重寫一份完整的交接預覽。',
266 inputSchema: {
267 type: 'object',
268 properties: { action: { type: 'string', enum: ['send', 'cancel'] } },
269 required: ['action'],
270 },
271 })
272 return next(e)
273 })
274
275 // 使用者用自然語言說要交接:由 Claude 判斷意圖後呼叫,摘要直接寫在同一輪的回覆
276 on('tool.call', { tool: 'mcp__handoff__start' }, async $ => {
277 try {
278 const state = await read($, phase)
279 if (state === 'preview') return { result: '已經有一份交接預覽在等確認。請提醒使用者說「送出」或「取消」,或用 /handoff 重來。' }
280 if (state !== 'idle' && state !== 'writing') return { result: '交接正在進行中,不用再呼叫。' }
281 await update($, phase, () => 'writing')
282 $.ui.status('交接:正在寫摘要…')
283 return { result: SUMMARY_PROMPT }
284 } catch {
285 return { isError: true, result: '交接啟動失敗,請改用 /handoff' }
286 }
287 })
288
289 on('tool.call', { tool: 'mcp__handoff__confirm' }, async ($, e) => {
290 try {
291 if ((await read($, phase)) !== 'preview') return { result: '現在沒有等確認的交接預覽,不用呼叫。' }
292 if (e.action === 'cancel') {
293 await reset($)
294 return { result: '已取消交接,對話保持原樣。請用一句話告訴使用者已取消。' }
295 }
296 // 這一輪結束後才清空:turn.complete 會接手
297 await update($, phase, () => 'confirmed')
298 return { result: '已確認。這一輪結束後會清空對話、改標題並送出摘要。請只回一句「好,交接中…」。' }
299 } catch {
300 return { isError: true, result: '確認失敗,請改用 /handoff 送出' }
301 }
302 })
303
304 on('command.run', { command: 'handoff' }, async ($, e) => {
305 const arg = (e.args ?? '').trim()
306 const state = await read($, phase)
307
308 if (/^(送出|確認|好|ok|send)$/i.test(arg)) {
309 if (state !== 'preview') return { text: `現在沒有等確認的交接預覽。${USAGE}` }
310 detach($, () => handOver($))
311 return { text: '好,正在清空對話並送出摘要…' }
312 }
313 if (/^(取消|cancel)$/i.test(arg)) {
314 await reset($)
315 return { text: '已取消交接,對話保持原樣。' }
316 }
317 if (/^(重來|重新|reset)$/i.test(arg) || state === 'idle') {
318 await reset($)
319 detach($, () => start($))
320 return { text: '開始交接,正在請 Claude 寫摘要…' }
321 }
322 if (state === 'preview') return { text: `上面已經有一份交接預覽在等確認。${USAGE}` }
323 return { text: `交接正在進行中(${state})。卡住的話用 /handoff 重來。` }
324 })
325
326 on('turn.complete', async ($, e, next) => {
327 const result = await next(e)
328 if (e.agentId) return result
329 const state = await read($, phase)
330
331 const wanted = await read($, retitle)
332 if (wanted && state === 'idle') {
333 await update($, retitle, () => null)
334 detach($, async () => {
335 const session = await currentSession($)
336 if (session && session.title !== wanted) await setTitle($, wanted)
337 })
338 }
339
340 if (state === 'writing') {
341 if (e.isAborted) await reset($)
342 // 等這一輪真正結束再處理,hook 裡不能直接跑 /clear
343 else detach($, () => finish($, e.answer, autoSend))
344 return result
345 }
346 if (state === 'confirmed') {
347 detach($, () => handOver($))
348 return result
349 }
350
351 const { context } = await $.session.usage()
352 const current = context.tokens ?? null
353 await update($, used, () => current)
354 const due = (await read($, askAt)) ?? threshold
355 if (current !== null && current >= due && !e.isAborted) {
356 detach($, () => askStart($, current, step))
357 }
358 return result
359 })
360
361 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
362 const current = await read($, used)
363 const state = await read($, phase)
364 const isQuiet =
365 e.props.hasSurvey ||
366 (state === 'idle' && (current === null || current < threshold || (await read($, isHidden))))
367
368 if (isQuiet) return next(e)
369
370 const { Box, Button, Text } = $.ui.resolve(e)
371
372 if (state === 'preview') {
373 return (
374 <Box>
375 <Text>交接預覽在上面 </Text>
376 <Button key="send" label="✅ 送出並交接" variant="primary" onPress={() => handOver($)} />
377 <Text> </Text>
378 <Button key="cancel" label="取消" onPress={() => reset($)} />
379 </Box>
380 )
381 }
382
383 if (state !== 'idle') {
384 return (
385 <Box>
386 <Text dimColor>{state === 'writing' ? '交接中:Claude 正在寫摘要…' : '交接中:正在切換到新對話…'}</Text>
387 </Box>
388 )
389 }
390
391 return (
392 <Box>
393 <Text dimColor>上下文已用 {wan(current ?? 0)} token </Text>
394 <Button key="go" label="🔄 交接到新對話" variant="primary" onPress={() => start($)} />
395 <Text> </Text>
396 <Button key="later" label="稍後" onPress={() => update($, isHidden, () => true)} />
397 </Box>
398 )
399 })
400}
401types/index.d.ts 17 lines1export type Phase = 'idle' | 'writing' | 'preview' | 'confirmed' | 'switching'
2
3export type Pending = { title: string; text: string }
4
5declare module 'claude-code' {
6 interface PluginState {
7 handoff: {
8 phase: Phase
9 usedTokens: number | null
10 isHidden: boolean
11 pending: Pending | null
12 nextAskTokens: number | null
13 retitle: string | null
14 }
15 }
16}
17