context 用到門檻時寫交接卡、檢查合格後清空對話,由新對話接手;/handoff 手動交接,/handoff cancel 取消

Claude Code 的 mod:對話的 context 快滿時,自動寫交接卡、開新對話接手,不用等 Claude Code 自動壓縮把細節摘要掉。
舊對話不會刪掉,還留在 session 清單裡,隨時可以點回去看。
context 用到門檻(預設 60%)
│
▼
mod 叫 Claude 停手 ──► 工具鎖住,只能讀檔、寫交接卡、git commit
│
▼
Claude 先 commit 改到一半的東西,再寫 HANDOFF.md(交接卡)
│
▼
mod 檢查交接卡 ──不合格──► 列出全部原因,叫 Claude 改(預設最多提醒 3 次)
│
合格
▼
mod 自動 /clear,新對話收到「讀 <交接卡路徑> 續做」,接著做
Windows(PowerShell):
git clone https://github.com/AmiTu1020/mods-context-auto-handoff-public.git "$env:USERPROFILE\.claude\mods\context-auto-handoff"
macOS/Linux(未實測):
git clone https://github.com/AmiTu1020/mods-context-auto-handoff-public.git ~/.claude/mods/context-auto-handoff
打開 <家目錄>\.claude\settings.json,加上 env(已經有 env 就把這一行加進去)。路徑換成你的使用者名稱,用 / 寫:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:/Users/<你的使用者名稱>/.claude/mods/context-auto-handoff"
}
}
⚠️ 一定要寫在家目錄的
.claude\settings.json。寫在專案資料夾的.claude\settings.json不會生效(Claude Code 只從全域設定讀這一項)。
Ctrl+Shift+P → Developer: Restart Extension Host(或 Developer: Reload Window)。 修改 mod 不會跳出「Restart Extensions」按鈕,要用這個指令。claude 再重新開。開一個不重要的對話,打 /handoff 按 Enter:
/handoff cancel 取消就好。VS Code 的
/選單不會列出/handoff,直接把整個指令打完送出就可以用。
git pull,再重新啟動 Claude。settings.json 裡那一行、刪掉 mod 資料夾,再重新啟動 Claude。| 指令 | 作用 |
|---|---|
| (不用做任何事) | context 用到門檻時自動交接 |
/handoff | 不等門檻,現在就交接 |
/handoff cancel | 取消正在進行的交接、解鎖工具;這個對話之後不再自動交接 |
不設定也能用,全部照預設。要調整的話,把 context-auto-handoff.example.ini 複製成:
<家目錄>\.claude\context-auto-handoff.ini
再照需要修改。每次檢查都會重新讀這個檔,改完下一輪就生效,不用重啟。存成 UTF-8(有沒有 BOM 都可以);路徑直接寫,反斜線不用寫兩次。沒寫的項目一律用預設值。
[handoff]:整體設定| 項目 | 意思 | 預設 |
|---|---|---|
threshold | context 用到幾 % 算滿(1~100) | 60 |
contextFullTrigger | 滿了之後什麼時候交接:1=now(下一個動作前就攔下)、2=turnEnd(這一輪回答完)、3=nextMessage(等你下一則訊息,先交接、再把那則訊息交給新對話) | 2 |
userName | 交接卡裡怎麼稱呼你 | 使用者 |
handoffDir | 交接卡一定要放在這個資料夾裡面(mod 會照真實路徑檢查) | 不限制 |
maxChars | 整份交接卡最多幾個字(不算空白;0=不限制) | 500 |
retryTimes | 交接卡不合格時,自動提醒 Claude 重寫幾次 | 3 |
checkItems | 要檢查的大項(逗號分隔) | 不檢查 |
除了 [handoff],每個段落就是交接卡的一個大項,段落名稱用英文:
[startHere]
desc = ★ 先接這件
checkItemDetail = goal, nextSteps
goal = 主旨({userName} 原話)
stoppedAt = 停在哪
nextSteps = 接下來(依序)
[readFirst]
desc = 先讀(依序)
desc:這一項要寫什麼,給 Claude 看的說明(可以不寫)。checkItemDetail:這個大項裡要檢查的子項(逗號分隔)。沒寫這行就不檢查子項。名稱 = 說明:都是子項,交接卡上寫成 - 名稱:內容。{userName} 會換成你設定的稱呼。_、-),不是英文的會被略過並寫 log。「寫」跟「檢查」是分開的:段落裡定義的項目,Claude 都會寫進交接卡;只有列在 checkItems/checkItemDetail 的才會被檢查(要有,而且有內容)。所以你可以先定義很多項目,只檢查其中幾項,不用的不必刪。
INI 裡沒有任何大項段落時,用預設的這一組(不檢查):
| 大項 | 說明 | 子項 |
|---|---|---|
startHere | ★ 先接這件 | goal、taskFolder、stoppedAt、committed、uncommitted、nextSteps |
readFirst | 先讀(依序) | — |
constraints | 不可偏離/不做 | — |
corrections | 跟 {userName} 互動(這次被指正的) | — |
waitingOn | 等 {userName} 一句話 | — |
# login-remember-me 交接(2026-10-20 14:30,context 62%)
- project:my-web-app(C:\Users\me\projects\my-web-app)
## startHere
- goal:「登入頁加一個記住我,勾了 30 天內不用再登入」
- taskFolder:C:\Users\me\work\2026-10-20\login-remember-me
- stoppedAt:前端勾選框做完,後端 token 有效期還沒改
- committed:a1b2c3d(feat: 登入頁加記住我勾選框)
- uncommitted:無
- nextSteps:改 auth/token.ts 的有效期 → 補測試 → 請人 review
## readFirst
plan.md、auth/token.ts
## constraints
不改註冊流程
## corrections
有效期是 30 天,不是 7 天
## waitingOn
無
handoffDir 的話,還要真的在那個資料夾裡面;用 .. 繞出去、名字只差一點的隔壁資料夾都不算)。## 前面有 project 那一行:- project:<repo 名稱,不是 git repo 就寫「無」>(<VS Code 專案資料夾>)。括號裡的資料夾由 mod 告訴 Claude,也會核對(不分大小寫、斜線方向)。maxChars。其他項目照 checkItems/checkItemDetail 檢查。
交接一開始,到對話清空之前,Claude 只能:
| ✅ 可以 | ❌ 會被擋 |
|---|---|
| 讀檔、搜尋(Read、Grep、Glob) | 改其他檔案 |
寫或改 HANDOFF.md(設了 handoffDir 要在裡面) | 跑其他指令(包括 git push) |
git add、git commit、git status、git diff、git log、切換資料夾 | 開 subagent 等其他工具 |
被擋時,Claude 會收到說明:「交接中,工具已鎖:…先把交接卡寫好;要取消請打 /handoff cancel」。
限制:mod 是看指令的文字判斷。把指令包進腳本檔再執行的話,mod 看不出來。
mod 會把所有原因一次列給 Claude,例如:
【交接】交接檔沒過檢查|缺少「- project:…」這一行;缺少項目:readFirst、startHere.nextSteps;
交接卡 612 字,超過上限 500 字,請精簡。改好後,回覆最後一行只寫「交接檔:<HANDOFF.md 完整路徑>」,然後停手。
retryTimes 自動提醒(預設 3 次)。/handoff cancel 取消。| 項目 | 狀態 |
|---|---|
contextFullTrigger = 2(turnEnd,預設) | ✅ Windows 11 + VS Code 實測:76% 自動交接、新對話接手成功 |
/handoff 手動交接 | ✅ Windows 11 + VS Code 實測 |
項目檢查、字數、handoffDir、鎖、提醒次數 | ✅ 自動測試(37 項);Windows 11 + VS Code 載入確認 |
contextFullTrigger = 1(now) | ⚠️ 已實作、還沒實測 |
contextFullTrigger = 3(nextMessage) | ⚠️ 已實作、還沒實測 |
| 終端機版、桌面 App、macOS/Linux | ⚠️ 還沒實測 |
有問題請開 Issue 回報 bug,附上:你的系統、Claude Code 版本、contextFullTrigger 設定、發生了什麼。
/handoff cancel 的對話不會再自動觸發(/handoff 還是可以用)。| 狀況 | 檢查 |
|---|---|
打 /handoff 說找不到指令 | Claude Code 版本 ≥ 2.1.287?env 寫在家目錄的 settings.json?路徑對嗎?有重新啟動嗎? |
| 到門檻沒有交接 | 這個對話是不是已經交接過或 cancel 過?INI 的 threshold 是多少? |
| 一直卡在「交接檔沒過檢查」 | 看提醒裡列的原因;設了 handoffDir 的話,那個資料夾要存在 |
| INI 改了沒效果 | 名稱要用英文;數字超出範圍會用預設值 |
claude plugin validate <mod 資料夾>
claude plugin test <mod 資料夾>
規格:docs/context-auto-handoff-spec.md
hooks/register.ts 399 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const MARK = '【交接】'
4const PATH_LINE = /交接檔[::]\s*`?((?:[A-Za-z]:[\\/]|\/)[^`\r\n]*?\.md)`?\s*$/
5const INI_NAME = 'context-auto-handoff.ini'
6const NAME = /^[A-Za-z][A-Za-z0-9_-]*$/
7const LOG = 'context-auto-handoff:'
8
9type Sub = { key: string; desc: string }
10type Section = { name: string; desc?: string; subs: Sub[]; checkDetail?: string[] }
11type Config = {
12 threshold: number
13 trigger: number // 1=now、2=turnEnd、3=nextMessage
14 userName: string
15 handoffDir?: string
16 retryTimes: number
17 maxChars: number // 0=不限
18 checkItems?: string[]
19 sections: Section[]
20}
21
22// 沒有 INI(或 INI 沒有大項段落)時的預設交接卡項目。
23const DEFAULT_SECTIONS: Section[] = [
24 {
25 name: 'startHere',
26 desc: '★ 先接這件',
27 subs: [
28 { key: 'goal', desc: '主旨({userName} 原話)' },
29 { key: 'taskFolder', desc: '任務資料夾' },
30 { key: 'stoppedAt', desc: '停在哪' },
31 { key: 'committed', desc: '已 commit' },
32 { key: 'uncommitted', desc: '未 commit' },
33 { key: 'nextSteps', desc: '接下來(依序)' },
34 ],
35 },
36 { name: 'readFirst', desc: '先讀(依序)', subs: [] },
37 { name: 'constraints', desc: '不可偏離/不做', subs: [] },
38 { name: 'corrections', desc: '跟 {userName} 互動(這次被指正的)', subs: [] },
39 { name: 'waitingOn', desc: '等 {userName} 一句話', subs: [] },
40]
41
42// 沒寫的設定一律用預設;預設不放任何人的路徑、名字,也不檢查項目。
43const DEFAULTS: Config = { threshold: 60, trigger: 2, userName: '使用者', retryTimes: 3, maxChars: 500, sections: DEFAULT_SECTIONS }
44
45// ---- INI ----
46
47// [段落] 底下 key = value;; 或 # 開頭是註解;段落前的 key 算 [handoff];值照原樣(路徑反斜線不用跳脫)。
48function parseIni(text: string): { name: string; entries: [string, string][] }[] {
49 const sections = [{ name: 'handoff', entries: [] as [string, string][] }]
50 for (const line of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
51 const trimmed = line.trim()
52 if (trimmed === '' || /^[;#]/.test(trimmed)) continue
53 const header = /^\[(.+)\]$/.exec(trimmed)
54 if (header) {
55 sections.push({ name: header[1].trim(), entries: [] })
56 continue
57 }
58 const eq = trimmed.indexOf('=')
59 if (eq > 0) sections[sections.length - 1].entries.push([trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1).trim()])
60 }
61 return sections
62}
63
64const list = (value: string | undefined) => value?.split(',').map(s => s.trim()).filter(Boolean)
65
66const iniPath = (home: string) => {
67 const sep = home.includes('\\') ? '\\' : '/'
68 return `${home}${sep}.claude${sep}${INI_NAME}`
69}
70
71// 整數設定:在範圍內才採用,否則照預設並寫 log。
72function intSetting($: EngineInterface, raw: string | undefined, key: string, min: number, max: number, fallback: number): number {
73 if (raw === undefined || raw === '') return fallback
74 const n = Number(raw)
75 if (Number.isInteger(n) && n >= min && n <= max) return n
76 $.ui.log(`${LOG}${INI_NAME} 的 ${key}「${raw}」不是 ${min}~${max === Infinity ? '' : max} 的整數,照預設 ${fallback}`)
77 return fallback
78}
79
80// 每次用到都重讀 ~/.claude/context-auto-handoff.ini,改了下一輪就生效。
81async function loadConfig($: EngineInterface): Promise<Config> {
82 try {
83 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
84 if (home === undefined) return DEFAULTS
85 const path = iniPath(home)
86 if (!(await $.fs.exists(path))) return DEFAULTS
87 const parsed = parseIni(await $.fs.read(path))
88 const handoff = new Map<string, string>()
89 for (const sec of parsed.filter(s => s.name.toLowerCase() === 'handoff')) for (const [k, v] of sec.entries) handoff.set(k.toLowerCase(), v)
90
91 const sections: Section[] = []
92 for (const sec of parsed.filter(s => s.name.toLowerCase() !== 'handoff')) {
93 if (!NAME.test(sec.name)) {
94 $.ui.log(`${LOG}${INI_NAME} 的大項「${sec.name}」要用英文名稱(英文字母開頭,可含數字、_、-),已略過`)
95 continue
96 }
97 const section: Section = { name: sec.name, subs: [] }
98 for (const [k, v] of sec.entries) {
99 const key = k.toLowerCase()
100 if (key === 'desc') section.desc = v
101 else if (key === 'checkitemdetail') section.checkDetail = list(v)
102 else if (NAME.test(k)) section.subs.push({ key: k, desc: v })
103 else $.ui.log(`${LOG}${INI_NAME} 的 [${sec.name}] 子項「${k}」要用英文名稱(英文字母開頭,可含數字、_、-),已略過`)
104 }
105 for (const d of section.checkDetail ?? []) {
106 if (!section.subs.some(s => s.key.toLowerCase() === d.toLowerCase())) $.ui.log(`${LOG}[${sec.name}] 的 checkItemDetail 有「${d}」,但沒有這個子項的說明(打錯字?);照樣檢查`)
107 }
108 sections.push(section)
109 }
110 const useSections = sections.length > 0 ? sections : DEFAULT_SECTIONS
111 const checkItems = list(handoff.get('checkitems'))
112 for (const name of checkItems ?? []) {
113 if (!useSections.some(s => s.name.toLowerCase() === name.toLowerCase())) $.ui.log(`${LOG}checkItems 有「${name}」,但沒有 [${name}] 段落(打錯字?);照樣檢查`)
114 }
115
116 return {
117 threshold: intSetting($, handoff.get('threshold'), 'threshold', 1, 100, DEFAULTS.threshold),
118 trigger: intSetting($, handoff.get('contextfulltrigger'), 'contextFullTrigger', 1, 3, DEFAULTS.trigger),
119 userName: handoff.get('username') || DEFAULTS.userName,
120 handoffDir: handoff.get('handoffdir') || undefined,
121 retryTimes: intSetting($, handoff.get('retrytimes'), 'retryTimes', 0, Infinity, DEFAULTS.retryTimes),
122 maxChars: intSetting($, handoff.get('maxchars'), 'maxChars', 0, Infinity, DEFAULTS.maxChars),
123 checkItems,
124 sections: useSections,
125 }
126 } catch (err) {
127 $.ui.log(`${LOG}讀 ${INI_NAME} 失敗,照預設(${String(err)})`)
128 return DEFAULTS
129 }
130}
131
132// ---- 交接指示 ----
133
134const fill = (text: string, cfg: Config) => text.replaceAll('{userName}', cfg.userName)
135const where = (cfg: Config) => (cfg.handoffDir ? `${cfg.handoffDir} 底下本任務的資料夾` : '目前任務的資料夾(沒有就用專案根目錄)')
136const LOCK_RULE = '只能讀檔(Read、Grep、Glob)、寫 HANDOFF.md、跑 git add/commit/status/diff/log'
137
138function instruction(cfg: Config, root: string, reason: string) {
139 const format = [
140 '# <任務> 交接(YYYY-MM-DD HH:MM,<交接原因>)',
141 `- project:<repo 專案名,不是 git repo 就寫「無」>(${root})`,
142 ...cfg.sections.flatMap(s => [`## ${s.name}`, ...(s.desc ? [fill(s.desc, cfg)] : []), ...s.subs.map(sub => `- ${sub.key}:${fill(sub.desc, cfg)}`)]),
143 ]
144 return [
145 `${MARK}${reason}|下一步:寫交接檔、停手`,
146 `停止施工;工具已鎖,${LOCK_RULE}。有 worktree 且有未 commit 的改動就先 commit。`,
147 `再寫 HANDOFF.md,放在 ${where(cfg)};已有 HANDOFF.md 就改寫成現況。`,
148 `${cfg.maxChars > 0 ? `整份最多 ${cfg.maxChars} 字(不算空白)。` : ''}只寫還沒做完的,細節寫路徑指過去,不重講經過。`,
149 '大項寫成「## 英文名稱」標題、子項寫成「- 英文名稱:內容」;沒東西就寫「無」。格式:',
150 '```',
151 ...format,
152 '```',
153 '寫完,回覆最後一行只寫「交接檔:<HANDOFF.md 完整路徑>」,然後停手。不要自己開新對話:mod 會清空對話,叫新對話讀交接檔續做。',
154 ].join('\n')
155}
156
157const retryText = (problems: string[]) =>
158 `${MARK}交接檔沒過檢查|${problems.join(';')}。改好後,回覆最後一行只寫「交接檔:<HANDOFF.md 完整路徑>」,然後停手。`
159
160// ---- 檢查 ----
161
162// Windows 路徑不分大小寫、斜線方向;比對前一律正規化。
163const norm = (p: string) => p.trim().replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
164const visible = (text: string) => text.replace(/\s/g, '').length
165
166// 交接卡按「## 名稱」切段:名稱(小寫)→ 該段非空白的行。
167function cardSections(text: string): Map<string, string[]> {
168 const sections = new Map<string, string[]>()
169 let current: string[] | undefined
170 for (const line of text.split(/\r?\n/)) {
171 const heading = /^##\s+(.+?)\s*$/.exec(line)
172 if (heading) sections.set(heading[1].toLowerCase(), (current = []))
173 else if (current && line.trim()) current.push(line.trim())
174 }
175 return sections
176}
177
178// 交接卡不合格的原因,全部列出(空陣列=合格)。
179async function checkHandoff($: EngineInterface, cfg: Config, path: string): Promise<string[]> {
180 let real: string
181 try {
182 const stat = await $.fs.stat(path, { resolve: true })
183 if (stat.kind !== 'file') return [`${path} 不是檔案`]
184 real = stat.realPath ?? path
185 } catch {
186 return [`找不到 ${path}`]
187 }
188
189 const problems: string[] = []
190 if (cfg.handoffDir) {
191 let dir: string | undefined
192 try {
193 const stat = await $.fs.stat(cfg.handoffDir, { resolve: true })
194 if (stat.kind === 'dir') dir = stat.realPath ?? cfg.handoffDir
195 } catch {}
196 if (dir === undefined) {
197 $.ui.log(`${LOG}${INI_NAME} 的 handoffDir「${cfg.handoffDir}」不是存在的資料夾,交接卡不放行`)
198 problems.push(`${INI_NAME} 的 handoffDir「${cfg.handoffDir}」不是存在的資料夾(要請 ${cfg.userName} 改 INI)`)
199 } else if (!norm(real).startsWith(`${norm(dir)}\\`)) {
200 problems.push(`交接檔要放在 ${cfg.handoffDir} 底下,現在在 ${real}`)
201 }
202 }
203
204 const text = await $.fs.read(path)
205 const root = await $.session.root()
206 const head = text.split(/^##\s/m)[0]
207 const project = head.split(/\r?\n/).find(l => /^-\s*project\s*[::]/i.test(l.trim()))
208 const folder = project && /[((]([^()()]+)[))]\s*$/.exec(project.trim())?.[1]
209 if (!project) problems.push(`缺少「- project:<repo 專案名>(${root})」這一行(要在第一個 ## 前面)`)
210 else if (!folder || norm(folder) !== norm(root)) problems.push(`project 括號裡的資料夾要寫 ${root}`)
211
212 const body = cardSections(text)
213 const missing: string[] = []
214 for (const name of cfg.checkItems ?? []) if (!body.get(name.toLowerCase())?.length) missing.push(name)
215 for (const section of cfg.sections) {
216 const lines = body.get(section.name.toLowerCase()) ?? []
217 for (const d of section.checkDetail ?? []) {
218 const pattern = new RegExp(`^-\\s*${d}\\s*[::]\\s*\\S`, 'i')
219 if (!lines.some(l => pattern.test(l))) missing.push(`${section.name}.${d}`)
220 }
221 }
222 if (missing.length > 0) problems.push(`缺少項目:${missing.join('、')}(大項要有「## 名稱」標題和內容,子項要有「- 名稱:內容」)`)
223
224 const count = visible(text)
225 if (cfg.maxChars > 0 && count > cfg.maxChars) problems.push(`交接卡 ${count} 字,超過上限 ${cfg.maxChars} 字,請精簡`)
226 return problems
227}
228
229// ---- 鎖:交接中只放行寫交接卡需要的動作 ----
230
231const READ_TOOLS = new Set(['Read', 'Grep', 'Glob'])
232const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit'])
233const GIT_OK = /^git(\s+-C\s+\S+)?\s+(add|commit|status|diff|log)\b/
234const CD_OK = /^(cd|Set-Location|pushd|popd)\b/
235
236// 指令每一段都只能是允許的 git 或切換資料夾;heredoc 內文與引號內字串不算指令。
237function isGitOnly(command: string): boolean {
238 const flat = command
239 .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\2[ \t]*(?=\n|$)/g, '<<HEREDOC')
240 .replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, '""')
241 if (/[>`]|\$\(/.test(flat)) return false
242 return flat
243 .split(/&&|\|\||[;|\n]/)
244 .map(s => s.trim())
245 .filter(Boolean)
246 .every(s => GIT_OK.test(s) || CD_OK.test(s))
247}
248
249function isHandoffPath(path: unknown, cfg: Config): boolean {
250 if (typeof path !== 'string' || !/(^|[\\/])HANDOFF\.md$/i.test(path)) return false
251 return !cfg.handoffDir || norm(path).startsWith(`${norm(cfg.handoffDir)}\\`)
252}
253
254function isAllowed(e: { tool: string; file_path?: unknown; command?: unknown }, cfg: Config): boolean {
255 if (READ_TOOLS.has(e.tool)) return true
256 if (WRITE_TOOLS.has(e.tool)) return isHandoffPath(e.file_path, cfg)
257 if (e.tool === 'Bash' || e.tool === 'PowerShell') return typeof e.command === 'string' && isGitOnly(e.command)
258 return false
259}
260
261const lockText = (cfg: Config) =>
262 `${MARK}交接中,工具已鎖:${LOCK_RULE}${cfg.handoffDir ? `(HANDOFF.md 要在 ${cfg.handoffDir} 底下)` : ''}。先把交接卡寫好;要取消請${cfg.userName}打 /handoff cancel。`
263
264// ---- 流程 ----
265
266// idle:正常;pending:已滿、等下一則訊息(nextMessage);asked:交接中、工具鎖住;clearing:清空中;done:這個對話不再自動交接
267type Phase = 'idle' | 'pending' | 'asked' | 'clearing' | 'done'
268
269let phase: Phase = 'idle'
270let retries = 0
271let pendingPercent = 0
272let held: string | undefined
273
274function say($: EngineInterface, text: string) {
275 $.ui.log(`${LOG}${text}`)
276 $.ui.toast(text)
277}
278
279async function begin($: EngineInterface, cfg: Config, reason: string): Promise<string> {
280 phase = 'asked'
281 retries = 0
282 return instruction(cfg, await $.session.root(), reason)
283}
284
285async function startManual($: EngineInterface) {
286 const cfg = await loadConfig($)
287 void $.prompt.submit({ text: await begin($, cfg, `${cfg.userName}手動要求交接`) })
288}
289
290async function handOver($: EngineInterface, path: string, message: string) {
291 phase = 'clearing'
292 try {
293 await $.command.run({ command: 'clear' })
294 phase = 'idle'
295 await $.prompt.submit({ text: message })
296 } catch (err) {
297 phase = 'done'
298 say($, `清空對話失敗,請自己開新對話並貼上:讀 ${path} 續做(${String(err)})`)
299 }
300}
301
302async function resubmit($: EngineInterface, text: string) {
303 await $.prompt.submit({ text })
304}
305
306export const register: Register = on => {
307 on('session.start', async ($, e, next) => {
308 const result = await next(e)
309 await $.command.register({ name: 'handoff', description: '寫交接卡,清空對話並由新對話接手;/handoff cancel 取消交接', argumentHint: '[cancel]' })
310 return result
311 })
312
313 on('command.run', { command: 'handoff' }, async ($, e) => {
314 if (e.args.trim().toLowerCase() === 'cancel') {
315 if (phase !== 'asked' && phase !== 'pending') return { text: '目前沒有在交接。' }
316 phase = 'done'
317 const message = held
318 held = undefined
319 // nextMessage 模式被換掉的那則訊息,取消後送回去照常處理。
320 if (message !== undefined) $.clock.after(0, () => void resubmit($, message))
321 return { text: '已取消交接,工具已解鎖;這個對話不再自動交接(要交接可以再打 /handoff)。' }
322 }
323 if (phase === 'clearing') return { text: '正在清空對話,請稍等。' }
324 // 指令的 hook 裡不能直接送提示(會等自己這一輪),交給計時器送。
325 phase = 'asked'
326 $.clock.after(0, () => void startManual($))
327 return { text: '已排入交接:Claude 先寫交接卡,接著清空對話、由新對話接手。' }
328 })
329
330 on('tool.call', async ($, e, next) => {
331 if (phase === 'asked' || phase === 'clearing') {
332 const cfg = await loadConfig($)
333 return isAllowed(e, cfg) ? next(e) : { deny: lockText(cfg) }
334 }
335 if (phase === 'idle') {
336 const cfg = await loadConfig($)
337 if (cfg.trigger === 1) {
338 const { context } = await $.session.usage()
339 if (context.percent !== undefined && context.percent >= cfg.threshold) {
340 return { deny: await begin($, cfg, `context 已 ${context.percent}%`) }
341 }
342 }
343 }
344 return next(e)
345 })
346
347 on('prompt.submit', async ($, e, next) => {
348 const from = e.origin?.kind
349 if (phase === 'pending' && from !== 'plugin' && from !== 'task-notification') {
350 const cfg = await loadConfig($)
351 held = e.text
352 return next({ ...e, text: await begin($, cfg, `context 已 ${pendingPercent}%(${cfg.userName}剛送出的訊息先保留,交給新對話處理)`) })
353 }
354 return next(e)
355 })
356
357 on('turn.complete', async ($, e, next) => {
358 const result = await next(e)
359 if (e.agentId) return result
360
361 try {
362 if (phase === 'idle' && e.reason === 'answer') {
363 const { context } = await $.session.usage()
364 if (context.percent === undefined) return result
365 const cfg = await loadConfig($)
366 if (context.percent < cfg.threshold) return result
367 if (cfg.trigger === 3) {
368 phase = 'pending'
369 pendingPercent = context.percent
370 } else {
371 void $.prompt.submit({ text: await begin($, cfg, `context 已 ${context.percent}%`) })
372 }
373 } else if (phase === 'asked') {
374 const cfg = await loadConfig($)
375 if (e.reason !== 'answer') {
376 say($, `交接被中斷,工具仍鎖著;請叫 Claude 繼續寫交接卡,或打 /handoff cancel 取消`)
377 return result
378 }
379 const lines = e.answer.trim().split(/\r?\n/)
380 const path = PATH_LINE.exec(lines[lines.length - 1] ?? '')?.[1]
381 const problems = path === undefined ? ['回覆最後一行沒有「交接檔:<HANDOFF.md 完整路徑>」'] : await checkHandoff($, cfg, path)
382 if (path !== undefined && problems.length === 0) {
383 const message = held === undefined ? `讀 ${path} 續做` : `讀 ${path} 續做。\n\n${cfg.userName}交接前送出的訊息(接手後回答):\n${held}`
384 held = undefined
385 $.clock.after(0, () => void handOver($, path, message))
386 } else if (retries < cfg.retryTimes) {
387 retries++
388 void $.prompt.submit({ text: retryText(problems) })
389 } else {
390 say($, `交接卡沒過檢查:${problems.join(';')}。工具仍鎖著;請跟 Claude 說哪裡要改,或打 /handoff cancel 取消`)
391 }
392 }
393 } catch (err) {
394 say($, `交接檢查出錯(${String(err)});工具${phase === 'asked' ? '仍鎖著,可打 /handoff cancel 取消' : '沒有鎖'}`)
395 }
396 return result
397 })
398}
399