SLOPSHOPPER

context-auto-handoff

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

newguardcommandtoastprompttimer
★ 1v1.0.0MITupdated 2026-10-15AmiTu1020/mods-context-auto-handoff-public
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-auto-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 ⎿ context-auto-handoff: 已排入交接:Claude 先寫交接卡,接著清空對話、由新對話接手。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-auto-handoff

Claude Code 的 mod:對話的 context 快滿時,自動寫交接卡、開新對話接手,不用等 Claude Code 自動壓縮把細節摘要掉。

舊對話不會刪掉,還留在 session 清單裡,隨時可以點回去看。


它會做什麼

context 用到門檻(預設 60%)
        │
        ▼
mod 叫 Claude 停手 ──► 工具鎖住,只能讀檔、寫交接卡、git commit
        │
        ▼
Claude 先 commit 改到一半的東西,再寫 HANDOFF.md(交接卡)
        │
        ▼
mod 檢查交接卡 ──不合格──► 列出全部原因,叫 Claude 改(預設最多提醒 3 次)
        │
       合格
        ▼
mod 自動 /clear,新對話收到「讀 <交接卡路徑> 續做」,接著做
  • 不會讓 Claude 邊交接邊做別的事:交接期間其他工具都會被擋下。
  • 不合格一定說原因:缺哪一項、字數超過多少、放錯資料夾,全部列給 Claude。
  • 什麼都可以設定:門檻、交接時機、交接卡要寫哪些項目、哪些要檢查、字數上限,都寫在一個 INI 檔裡;不設定也能直接用。

需求

  • Claude Code 2.1.287 以上(mod 功能從這版開始)
  • 實測環境:Windows 11 + VS Code 的 Claude Code 擴充套件
  • 終端機版、桌面 App、macOS/Linux 應該也能用,但還沒實測(見「測試狀態」)

安裝

1. 下載到 mods 資料夾

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

2. 在全域設定指定 mod 位置

打開 <家目錄>\.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 只從全域設定讀這一項)。

3. 重新啟動 Claude

  • VS Code:Ctrl+Shift+P → Developer: Restart Extension Host(或 Developer: Reload Window)。 修改 mod 不會跳出「Restart Extensions」按鈕,要用這個指令。
  • 終端機:結束 claude 再重新開。

4. 確認裝好了

開一個不重要的對話,打 /handoff 按 Enter:

  • 出現「已排入交接:Claude 先寫交接卡,接著清空對話、由新對話接手。」→ 裝好了。
  • 不想真的交接,接著打 /handoff cancel 取消就好。

VS Code 的 / 選單不會列出 /handoff,直接把整個指令打完送出就可以用。

更新與移除

  • 更新:在 mod 資料夾執行 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]:整體設定

項目意思預設
thresholdcontext 用到幾 % 算滿(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
無

一定會檢查的(不用設定)

  1. 交接卡檔案真的存在(設了 handoffDir 的話,還要真的在那個資料夾裡面;用 .. 繞出去、名字只差一點的隔壁資料夾都不算)。
  2. 第一個 ## 前面有 project 那一行:- project:<repo 名稱,不是 git repo 就寫「無」>(<VS Code 專案資料夾>)。括號裡的資料夾由 mod 告訴 Claude,也會核對(不分大小寫、斜線方向)。
  3. 字數不超過 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 次)。
  • 提醒用完還是不合格:停止自動提醒,但工具保持鎖住、不清空對話,等你處理。你可以直接跟 Claude 說哪裡要改,或打 /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 還是可以用)。
  • 新對話開在同一個專案裡;舊對話在 session 清單(VS Code:Claude 面板右上角的時鐘圖示)。
  • mod 送出的訊息前面會被加上「The context-auto-handoff plugin sent a message:」,這是 Claude Code 的正常行為。
  • 改 INI 立刻生效;改 mod 程式要重新啟動 Claude。

疑難排解

狀況檢查
打 /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

授權

MIT

Source 1 files
hooks/register.ts 399 lines
1import 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