SLOPSHOPPER

handoff-mod

Hand off unfinished work before a session loses it, and pick it up again at the next start.

newbandcommandpromptprocesstimer
★ 2v0.1.6no licenseupdated 2026-10-08tomwangowa/agent-skills/plugins/handoff-mod
A shopper browsing a rack in a slop shop
README

交接 Mod(handoff-mod)

English

Claude Code Mod。在 session 即將失去狀態之前(context 快滿、/clear、結束)協助你留下一份交接檔;下次在同一個專案開新 session 時,列出還沒做完的交接,讓你一鍵接續。零模型成本:Mod 本身不呼叫模型,只有你自己執行交接 skill 時才花一個回合。

狀態:原型。 marketplace 條目已加入,但 marketplace 讀的是預設分支,這個 PR 合併後才會生效;claude plugin install 的流程我沒有實際跑過。 在作者的 macOS(Claude Code 2.1.292)和 Cloud container(Linux)驗證過,見最下方「驗證狀態」。其他環境、終端機與預設的 60% 門檻實際用起來的感覺,還沒有人驗證。

它做什麼

時機行為
你執行 /handoff-mod:handoff內建 skill:唯讀收集 git 狀態,先把完整草稿給你看、問你對不對,你確認後才寫檔。「已驗證」只列這個回合真的跑過的指令,並標明是 AI 自述。寫完後 Mod 會檢查檔案是否有效,右上角顯示 toast。
context 用量到門檻(預設已用 60%)一個回合結束後,若工作樹有未提交的變更,在輸入框上方問你要不要先交接:「同意」「再多 10% 再問」「這個 session 別再問」。同時在輸入框下方放一行狀態。每個門檻只問一次。
你下 /clear一律問:取消、寫交接檔(寫完再 /clear)、不交接直接清除(預設在「取消」,按 Enter 不會誤清)。選「寫交接檔」會暫停這次清除,不會自動幫你清;交接檔寫完後,請自己再下一次 /clear。
開新 session列出同一專案還沒做完的交接(任務、分支、幾分鐘前、下一步、與現況的差異),可按「接續」;或輸入 /handoff-resume,/handoff-resume 2 接續第 2 筆。超過 3 筆、或建立超過 14 天的交接,會折疊成「還有 N 筆」按鈕,按下去展開;/handoff-resume all 也能列出全部。同一個 branch 較舊的自動筆記預設折在裡面。不要的交接可以用 /handoff-resume drop <編號>(或 drop all)放棄,清單標頭那一行也有這個提示;清單上的「本 session 忽略」只對這個 session 有效,下次開 session 還會再出現。接續只會把「請先讀這份交接、驗證前提再接續」填進輸入框,不會自動送出。
session 結束(/exit、Ctrl-C 兩次、關分頁)若工作樹有未提交的變更,自動留下一份只含事實的筆記(最後一個要求、最後一段回應、branch、HEAD、有改動的檔案)。含對話原文,見下方風險。
/handoff-stats顯示這台機器上交接寫入與接續的次數。

交接檔放在 <git 根目錄>/.claude/handoffs/,並寫進 .git/info/exclude 避免弄髒 git status(如果你的全域 gitignore 已涵蓋就不改)。多個 worktree 會一起列出。不在 git 內時用 session 的根目錄。

安裝

作者測的是 Claude Code 2.1.291 與 2.1.292,最低可用版本沒有驗證。

合併後可從 marketplace 安裝(這條流程沒有驗證過):

claude plugin marketplace add tomwangowa/agent-skills
claude plugin install handoff-mod@tomwangowa --scope user

合併前,或只想試用,用本機載入:

git clone https://github.com/tomwangowa/agent-skills.git
claude --plugin-dir "<clone 路徑>/plugins/handoff-mod"

--plugin-dir 只在那一次 session 有效,不會安裝任何東西。Mod 不在 sandbox 裡執行,會以你的使用者權限跑在 Claude Code 裡,載入前請先看過 hooks/ 的程式碼。

如果同時載入 attention-mod,展開的 inline 面板會把輸入框上方的提示遮住;輸入框下方那一行狀態仍然看得到,/handoff-resume 與 /handoff-mod:handoff 照常可用。

設定

在 /config 裡有三項。改任何設定都會讓 Mod 重新載入,不影響進行中的 session。

設定預設說明
thresholdPct60已用百分比(1 到 99)。百分比是對模型的 context window 算的,同一個數字在不同模型是不同的 token 數。
langzh-TW介面與 skill 草稿的語言:zh-TW 或 en。沒存過這個設定時,skill 看到的是未替換的字面值,會退回 zh-TW。
autoNote開是否在結束時自動留下筆記。

開發與測試用的環境變數(不影響 skill 的語言):HANDOFF_THRESHOLD_PCT、HANDOFF_LANG、HANDOFF_AUTO_NOTE(off 或 on)、HANDOFF_DEBUG(1 時用 $.ui.log 印出偵測過程,行首是 [handoff-mod debug])。

風險與界線

  • 結束筆記含對話原文。 為了讓你在 session 意外結束後還有線索,Mod 會把你最後一個要求與最後一段回應各截斷到 2000 字,寫進交接目錄的 --auto.md(檔案權限 0600,下次啟動標示「自動留下,未經審查」,超過 7 天不再顯示)。
  • 祕密遮蔽只降低風險,不是保證。 只認有特徵的形式(password = …、sk-…、Authorization: Bearer … 等九種)。單獨出現在文字裡的密碼不會被遮蔽。需要時把 autoNote 關掉,或設 HANDOFF_AUTO_NOTE=off。
  • 暫存紀錄。 為了在結束時還有東西可寫,Mod 平時把已遮蔽的最後一組請求與回應放在 Claude Code 的 plugin store(~/.claude/plugins/store/;在 Cloud 查過是明文 JSON)。檔案權限在 macOS 是 0600,但目錄是 0755(同機器的其他使用者看得到檔名,讀不到內容)。session 結束時一律刪除,當機留下的殘餘超過 7 天會在下次啟動時清掉。
  • 交接檔的內容是資料,不是指令。 顯示前會去控制字元與 ANSI,並限制長度;檔案裡的 branch 與 HEAD 在傳給 git 前會先驗證。
  • 「已驗證」是 AI 的自述。 skill 只被要求列這個回合真的跑過的指令,但這是模型的行為,不是強制。
  • 本機計數不外傳。 /handoff-stats 的數字只存在這台機器的 store。
  • 只看 git 狀態判斷有沒有未完成的工作。 乾淨的工作樹(已全部 commit)與非 git 目錄不會被問,也不會自動留下筆記。

已知限制

  • 門檻提示的三個按鈕沒有數字快捷鍵(避免你在空輸入框開頭打數字時誤答),可用 Tab 或滑鼠。
  • 同時開兩個 session 接續同一份交接時,先按的那一個拿到,另一個會看到提示(12 小時內有效)。
  • 壓縮前的攔截(T4)沒有做。
  • 交接檔寫完就不再修改;「已接續」等狀態存在本機 store,不會寫回檔案。

驗證狀態

claude plugin validate --strict .
claude plugin test .
範圍狀態
自動測試原生測試 153 項,另有變異檢查;在 Cloud 跑。
啟動清單、/handoff-resume、按鈕(Tab 與滑鼠)、/clear 的對話框、門檻提示、結束筆記(三種結束方式)、與 attention-mod 同載入、en 介面作者的 macOS 驗證過(Claude Code 2.1.292)。
/handoff-stats 的寫入計數、新 session 啟動清單對自動筆記的標示作者的 macOS 驗證過。
/clear 對話框「打字後再按 Enter」macOS 只確認 Enter 選到預設的取消;是否有先打字沒有記錄。
/handoff-resume <編號> 與接續計數作者的 macOS 驗證過一次(/handoff-resume 2 填入輸入框,接續次數 2 變 3,與 store 紀錄一致)。第一次試(第 1 筆)沒有任何效果也沒有留下紀錄,原因不明、無法重現。
預設 60% 門檻、Warp 以外的終端機、Windows、非 git 目錄、真實 worktree沒有驗證。
交接內容的準確度與成本沒有量測。

測試結果、限制與每項決定的依據在 docs/implementation-results.md、docs/poc-results.md、設計、實作計畫。

Source 12 files
hooks/register.js 745 lines
1import {atom, read, update} from 'claude-code';
2import {resolveConfig} from './config.js';
3import {t, clearOptions} from './i18n.js';
4import {parseHandoff} from './handoff-file.js';
5import {rankHandoffs} from './rank.js';
6import {freshnessFacts} from './freshness.js';
7import {tryClaim, claimView} from './claim.js';
8import {ensureExcluded} from './exclude.js';
9import {cleanForNote} from './sanitize.js';
10import {shouldWriteNote, buildEndNote, withDeadline} from './note.js';
11import {decideTrigger, effectiveThreshold, snooze, onPercentSeen, hasUnfinishedSign} from './trigger.js';
12
13/*
14 * Wiring for the handoff mod. Every pure decision lives in its own module; this file only connects events to them.
15 * Invariants (see docs/superpowers/plans/2026-10-07-handoff-mod.md): never touch a tool call, a permission decision or
16 * Claude's prompt; every hook body is wrapped in try/catch; session-scoped state lives in $.state, not in module variables,
17 * because changing any setting reloads the module; list output goes through $.ui.log, which Claude does not read.
18 */
19
20const SKILL = 'handoff-mod:handoff';
21const SKILL_COMMAND = new RegExp(`^/${SKILL}(\\s|$)`);
22const DIR = '.claude/handoffs';
23const PATTERN = `${DIR}/`;
24const MAX_DIRS = 20;
25const MAX_PER_DIR = 30;
26const GIVE_UP_TURNS = 10;
27const WARN_AT_TURN = 4;
28const GIVE_UP_MS = 60 * 60 * 1000;
29// D4, route B: session.end cannot read the conversation, so the last request and answer are kept (masked) in $.store.
30const LAST_PREFIX = 'last:';
31const QUOTE_MAX = 2000;
32const RECORD_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
33const NOTE_DEADLINE_MS = 1500;
34
35// Session-scoped state, kept in $.state: it survives a module reload and is reset by /clear, /resume and /branch.
36const handoffStartedAt = atom({plugin: 'handoff-mod', key: 'handoffStartedAt'}, 0);
37const handoffTurns = atom({plugin: 'handoff-mod', key: 'handoffTurns'}, 0);
38const listDone = atom({plugin: 'handoff-mod', key: 'listDone'}, false);
39// D16: whether the folded part of the start-up list has been asked for; reset by /clear like the other session state.
40const expanded = atom({plugin: 'handoff-mod', key: 'expanded'}, false);
41const askPending = atom({plugin: 'handoff-mod', key: 'askPending'}, false);
42// T1 (D12): the next threshold after "ask again in 10%", the threshold last asked about, "never ask again", and the
43// percentage the open prompt reports (0 while no prompt is open).
44const nextAt = atom({plugin: 'handoff-mod', key: 'nextAt'}, 0);
45const askedAt = atom({plugin: 'handoff-mod', key: 'askedAt'}, 0);
46const suppressed = atom({plugin: 'handoff-mod', key: 'suppressed'}, false);
47const t1Percent = atom({plugin: 'handoff-mod', key: 't1Percent'}, 0);
48
49// Rebuilt from userConfig and disk whenever the module (re)loads or a session starts.
50let USER_CONFIG = {};
51let config = resolveConfig({});
52let list = null; // {items, total} while the start-up list is showing
53let listing = false;
54let debugOn = false; // HANDOFF_DEBUG=1: trace the post-write detection through $.ui.log (Claude does not read it)
55
56/** Run git without a shell; resolves to {exitCode, stdout} and never throws. */
57async function git($, args) {
58  try {
59    const result = await $.process.run(['git', ...args], {timeoutMs: 5000});
60    return {exitCode: result.exitCode, stdout: String(result.stdout ?? '')};
61  } catch {
62    return {exitCode: -1, stdout: ''};
63  }
64}
65
66/** One diagnostic line, only when HANDOFF_DEBUG is set. Never throws. */
67function trace($, text) {
68  if (!debugOn) return;
69  try { $.ui.log(`[handoff-mod debug] ${text}`); } catch { /* ignore */ }
70}
71
72/** Test-only env overrides (literal names, so validate can list them), then userConfig, then defaults. */
73async function loadConfig($) {
74  try {
75    const env = {
76      HANDOFF_THRESHOLD_PCT: await $.env.get('HANDOFF_THRESHOLD_PCT'),
77      HANDOFF_LANG: await $.env.get('HANDOFF_LANG'),
78      HANDOFF_AUTO_NOTE: await $.env.get('HANDOFF_AUTO_NOTE'),
79    };
80    debugOn = ['1', 'on', 'true'].includes(String((await $.env.get('HANDOFF_DEBUG')) ?? '').toLowerCase());
81    return resolveConfig({env, userConfig: USER_CONFIG});
82  } catch {
83    return resolveConfig({userConfig: USER_CONFIG});
84  }
85}
86
87/** The handoff directory's base: the git toplevel of this working tree, else the session root (D10). */
88async function projectBase($) {
89  const top = await git($, ['rev-parse', '--show-toplevel']);
90  const line = top.exitCode === 0 ? top.stdout.trim() : '';
91  return line || (await $.session.root());
92}
93
94async function repoRoot($) {
95  try {
96    return (await $.session.repo())?.root ?? null;
97  } catch {
98    return null;
99  }
100}
101
102/** The working tree's own directory, the main checkout's, and every other worktree's (D2); no index, bounded. */
103async function candidateDirs($, base, repo) {
104  const dirs = [`${base}/${DIR}`];
105  if (repo) {
106    dirs.push(`${repo}/${DIR}`);
107    const trees = await git($, ['worktree', 'list', '--porcelain']);
108    if (trees.exitCode === 0) {
109      for (const line of trees.stdout.split('\n')) if (line.startsWith('worktree ')) dirs.push(`${line.slice(9).trim()}/${DIR}`);
110    }
111  }
112  return [...new Set(dirs)].slice(0, MAX_DIRS);
113}
114
115/** Newest first by the timestamp in the file name, then by name. */
116const byStamp = (a, b) => {
117  const key = (name) => /--(\d{8}-\d{6})/.exec(name)?.[1] ?? '';
118  return key(b).localeCompare(key(a)) || (a < b ? 1 : a > b ? -1 : 0);
119};
120
121async function readHandoffs($, dirs) {
122  const found = [];
123  for (const dir of dirs) {
124    try {
125      if (!(await $.fs.exists(dir))) continue;
126      const names = (await $.fs.list(dir)).filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md')).map((entry) => entry.name).sort(byStamp).slice(0, MAX_PER_DIR);
127      for (const name of names) {
128        const path = `${dir}/${name}`;
129        try {
130          const parsed = parseHandoff(await $.fs.read(path));
131          if (parsed.ok) found.push({path, meta: parsed.meta, fields: parsed.fields});
132        } catch { /* An unreadable file is skipped. */ }
133      }
134    } catch { /* An unreadable directory counts as no handoffs. */ }
135  }
136  return found;
137}
138
139const age = (lang, ms) => {
140  const minutes = Math.max(1, Math.round(ms / 60000));
141  if (minutes < 60) return t(lang, 'age.minutes', {n: minutes});
142  const hours = Math.round(minutes / 60);
143  return hours < 48 ? t(lang, 'age.hours', {n: hours}) : t(lang, 'age.days', {n: Math.round(hours / 24)});
144};
145
146const factText = (lang, fact) =>
147  fact.kind === 'branchGone' ? t(lang, 'fresh.branchGone', {branch: fact.branch})
148    : fact.kind === 'ahead' ? t(lang, 'fresh.ahead', {branch: fact.branch, count: fact.count})
149      : fact.kind === 'merged' ? t(lang, 'fresh.merged', {base: fact.base})
150        : t(lang, 'fresh.unverifiable');
151
152async function storeGet($, key) {
153  try {
154    return await $.store.get(key);
155  } catch {
156    return undefined;
157  }
158}
159
160/** Collect, rank and describe the unfinished handoffs for this working tree and its repository. */
161async function buildList($) {
162  const lang = config.lang;
163  const sessionId = await $.session.id();
164  const now = await $.clock.now();
165  const base = await projectBase($);
166  const repo = await repoRoot($);
167  const found = await readHandoffs($, await candidateDirs($, base, repo));
168  const items = [];
169  for (const f of found) {
170    const override = await storeGet($, `state:${f.path}`);
171    items.push({
172      path: f.path, meta: f.meta, fields: f.fields,
173      effectiveStatus: override && typeof override.status === 'string' ? override.status : f.meta.status,
174      claimedByOther: claimView(await storeGet($, `claim:${f.path}`), sessionId, now).state === 'other',
175    });
176  }
177  const ranked = rankHandoffs(items, {base, repo, now});
178  const picked = (await read($, expanded)) ? [...ranked.shown, ...ranked.rest] : ranked.shown;
179  const views = [];
180  for (const item of picked) {
181    const facts = await freshnessFacts({meta: item.meta, git: (args) => git($, args)});
182    views.push({
183      id: item.path,
184      title: t(lang, 'list.item', {n: views.length + 1, task: item.fields.task || item.path.split('/').pop(), branch: item.meta.branch ?? '-', age: age(lang, now - item.meta.created)}),
185      next: item.fields.next ? t(lang, 'list.next', {next: item.fields.next}) : '',
186      facts: facts.map((fact) => factText(lang, fact)),
187      auto: item.meta.source === 'auto',
188      older: Boolean(item.olderAuto),
189      claimed: item.claimedByOther,
190    });
191  }
192  return {items: views, total: ranked.shown.length + ranked.collapsed};
193}
194
195/** A plugin has one status line: the start-up list wins, else an open T1 prompt, else nothing. Redraws the band too. */
196async function syncStatus($) {
197  const open = await read($, t1Percent);
198  $.ui.status(list ? t(config.lang, 'status.pending', {count: list.total}) : open > 0 ? t(config.lang, 'status.threshold', {percent: open}) : undefined);
199  $.ui.invalidate('ui.render');
200}
201
202async function showList($, built) {
203  list = built.items.length ? built : null;
204  await syncStatus($);
205}
206
207async function clearList($) {
208  list = null;
209  await syncStatus($);
210}
211
212async function closeT1($) {
213  await update($, t1Percent, () => 0);
214  await syncStatus($);
215}
216
217const readT1 = async ($) => ({
218  nextAt: await read($, nextAt), askedAt: await read($, askedAt), suppressed: await read($, suppressed), askPending: await read($, askPending),
219});
220
221/**
222 * T1: after a turn, ask once per threshold when the context is filling up and there is unfinished work (D1, design
223 * component 2). "Unfinished" is a dirty working tree; every unknown (no percentage, not a git tree) means do not ask.
224 */
225async function checkThreshold($) {
226  const percent = (await $.session.usage())?.context?.percent;
227  const state = await readT1($);
228  const seen = onPercentSeen(state, percent);
229  if (seen !== state) {
230    // Compaction or /clear made the percentage fall: forget which thresholds were asked, and take any open prompt down.
231    await update($, nextAt, () => seen.nextAt);
232    await update($, askedAt, () => seen.askedAt);
233    if ((await read($, t1Percent)) > 0) await closeT1($);
234  }
235  if (typeof percent !== 'number' || percent < effectiveThreshold(seen, config)) return;
236  const dirty = await git($, ['status', '--porcelain']);
237  const decision = decideTrigger({
238    percent, config, state: seen, idle: true,
239    hasUnfinishedSign: hasUnfinishedSign({gitDirty: dirty.exitCode === 0 && dirty.stdout.trim() !== ''}),
240    handoffRunning: (await read($, handoffStartedAt)) > 0,
241  });
242  trace($, `threshold: percent=${percent} at=${decision.at} dirty=${dirty.exitCode === 0 && dirty.stdout.trim() !== ''} -> ${decision.action}`);
243  if (decision.action !== 'ask') return;
244  await update($, askedAt, () => decision.at);
245  await update($, t1Percent, () => Math.max(1, percent));
246  await syncStatus($);
247}
248
249/** The prompt's three answers. Each takes it down; a press on a prompt that is already gone does nothing. */
250async function answerT1($, answer) {
251  const percent = await read($, t1Percent);
252  if (percent <= 0) return;
253  if (answer === 'snooze') {
254    const next = snooze({}, {percent, at: await read($, askedAt)}).nextAt;
255    await update($, nextAt, () => next);
256  }
257  if (answer === 'suppress') await update($, suppressed, () => true);
258  await closeT1($);
259  if (answer === 'agree') startHandoffLater($);
260}
261
262/** Compute the start-up list once per call; `force` ignores "already dismissed" (used by /handoff-resume). */
263async function refreshList($, {force = false} = {}) {
264  if (listing) return;
265  listing = true;
266  try {
267    if (!force && (await read($, listDone))) return;
268    await showList($, await buildList($));
269  } catch {
270    /* The list is a convenience; never let it break the session. */
271  } finally {
272    listing = false;
273  }
274}
275
276/** D16: build the folded part too. Setting the flag first means a press during a rebuild still ends up expanded. */
277async function expandList($) {
278  await update($, expanded, () => true);
279  await refreshList($, {force: true});
280}
281
282async function bumpStat($, field) {
283  try {
284    const stats = (await $.store.get('stats')) ?? {};
285    await $.store.set('stats', {written: 0, resumed: 0, ...stats, [field]: (Number(stats[field]) || 0) + 1});
286  } catch { /* Counts are local and optional. */ }
287}
288
289/** Keep the newest request, masked and cut, for the end-of-session note. One record per session; each prompt replaces it. */
290async function recordRequest($, text) {
291  if (!config.autoNote || !text.trim() || (await $.session.surfaces()).length === 0) return;
292  await $.store.set(`${LAST_PREFIX}${await $.session.id()}`, {request: cleanForNote(text, QUOTE_MAX), response: '', at: await $.clock.now()});
293}
294
295/** Attach the answer to the request it belongs to. With no recorded request there is nothing to attach it to. */
296async function recordResponse($, answer) {
297  if (!config.autoNote) return;
298  const key = `${LAST_PREFIX}${await $.session.id()}`;
299  const record = await storeGet($, key);
300  if (!record || typeof record.request !== 'string') return;
301  await $.store.set(key, {...record, response: cleanForNote(String(answer ?? ''), QUOTE_MAX), at: await $.clock.now()});
302}
303
304/** A crash or kill -9 leaves a record behind; drop any that is a week old. */
305async function sweepOldRecords($) {
306  const now = await $.clock.now();
307  for (const key of await $.store.keys()) {
308    if (!key.startsWith(LAST_PREFIX)) continue;
309    const record = await storeGet($, key);
310    if (!record || !(now - Number(record.at) < RECORD_MAX_AGE_MS)) await $.store.delete(key);
311  }
312}
313
314const dirtyPaths = (porcelain) => porcelain.split('\n').filter((line) => line.trim()).map((line) => line.slice(3).split(' -> ').pop());
315
316/**
317 * D4: at the end of an interactive session that left unfinished work, write the facts-only note (the last request and
318 * answer, git state, changed files) without asking a model. Gives up at the exit's short budget and writes nothing then.
319 * The record is always deleted afterwards: it holds the only copy of that text outside the note.
320 */
321async function writeEndNote($, e, next) {
322  const key = `${LAST_PREFIX}${e.sessionId}`;
323  try {
324    const record = await storeGet($, key);
325    if (!record || typeof record.request !== 'string') return;
326    const remaining = Number(next.budget?.remainingMs);
327    const budget = Math.min(NOTE_DEADLINE_MS, Number.isFinite(remaining) ? remaining - 200 : NOTE_DEADLINE_MS);
328    if (budget < 200) { trace($, `end note skipped: ${remaining} ms left`); return; }
329    let abandoned = false;
330    const outcome = await withDeadline(async () => {
331      const status = await git($, ['status', '--porcelain']);
332      const dirty = status.exitCode === 0 ? dirtyPaths(status.stdout) : [];
333      const write = shouldWriteNote({interactive: true, turns: 1, hasUnfinishedSign: hasUnfinishedSign({gitDirty: dirty.length > 0}), reason: e.reason, config});
334      trace($, `end note: reason=${e.reason} dirty=${dirty.length} write=${write}`);
335      if (!write) return false;
336      const branch = (await git($, ['branch', '--show-current'])).stdout.trim();
337      const head = (await git($, ['rev-parse', '--short', 'HEAD'])).stdout.trim();
338      const base = await projectBase($);
339      let root = base;
340      try { root = (await $.session.root()) || base; } catch { /* The git toplevel stands in. */ }
341      const note = buildEndNote({
342        lastRequest: record.request, lastResponse: record.response, branch, head, dirtyFiles: dirty,
343        root, repo: await repoRoot($), now: await $.clock.now(), lang: config.lang,
344      });
345      if (abandoned) return false;
346      await ensureExcluded({
347        git: (args) => git($, args),
348        fs: {exists: (path) => $.fs.exists(path), read: (path) => $.fs.read(path), write: (path, text) => $.fs.write(path, text)},
349        pattern: PATTERN,
350      });
351      const path = `${base}/${DIR}/${note.fileName}`;
352      await $.fs.write(path, note.content);
353      let chmodExit = -1;
354      try { chmodExit = (await $.process.run(['chmod', '600', path], {timeoutMs: 1000})).exitCode; } catch { /* The trace shows it. */ }
355      trace($, `end note written to ${path} (chmod exit ${chmodExit})`);
356      return true;
357    }, budget, (ms, callback) => $.clock.after(ms, callback));
358    if (outcome.timedOut) { abandoned = true; trace($, 'end note abandoned: out of time'); }
359    if (outcome.error) trace($, `end note failed: ${String(outcome.error?.message ?? outcome.error)}`);
360  } finally {
361    try { await $.store.delete(key); } catch { /* The weekly sweep removes it. */ }
362  }
363}
364
365/** Resume one handoff: claim it, remember that, and put a prompt in the input box for the user to send. */
366async function resume($, item) {
367  const lang = config.lang;
368  const now = await $.clock.now();
369  const sessionId = await $.session.id();
370  const claim = await tryClaim({id: item.id, sessionId, now, store: {get: (key) => $.store.get(key), set: (key, value) => $.store.set(key, value)}});
371  if (!claim.won) {
372    $.ui.toast(t(lang, 'resume.claimedElsewhere'));
373    return;
374  }
375  try { await $.store.set(`state:${item.id}`, {status: 'resumed', at: now, sessionId}); } catch { /* Resuming still works without the record. */ }
376  await bumpStat($, 'resumed');
377  await $.prompt.fill({text: t(lang, 'resume.prompt', {path: item.id})});
378  await update($, listDone, () => true);
379  await clearList($);
380}
381
382/** D17: mark a handoff abandoned on this machine. Only the store record changes; the file is never touched (invariant 6). */
383async function abandon($, item, now, sessionId) {
384  try {
385    await $.store.set(`state:${item.id}`, {status: 'abandoned', at: now, sessionId});
386    return true;
387  } catch {
388    return false;
389  }
390}
391
392/** `/handoff-resume drop <n|all>`: abandon one listed item, or every listed item that no other session is resuming. */
393async function dropCommand($, target) {
394  const lang = config.lang;
395  const isAll = target === 'all';
396  const n = Number(target);
397  if (!isAll && !(target !== undefined && Number.isInteger(n))) {
398    $.ui.log(t(lang, 'drop.usage'));
399    return;
400  }
401  if (!list) {
402    $.ui.log(t(lang, 'list.none'));
403    return;
404  }
405  // A number past the shown items may still be a folded one (D16): expand, then look again.
406  if (!isAll && n > list.items.length && list.total > list.items.length) await expandList($);
407  const picked = isAll ? [...list.items] : [list?.items[n - 1]].filter(Boolean);
408  if (picked.length === 0) {
409    $.ui.log(t(lang, 'list.bad', {n: target}));
410    return;
411  }
412  const skipped = picked.filter((item) => item.claimed);
413  if (!isAll && skipped.length) {
414    $.ui.log(t(lang, 'drop.claimed', {title: skipped[0].title}));
415    return;
416  }
417  const now = await $.clock.now();
418  const sessionId = await $.session.id();
419  const done = [];
420  for (const item of picked) {
421    if (!item.claimed && (await abandon($, item, now, sessionId))) done.push(item);
422  }
423  if (done.length === 0) {
424    for (const item of skipped) $.ui.log(t(lang, 'drop.claimed', {title: item.title}));
425    $.ui.log(t(lang, 'drop.none'));
426    return;
427  }
428  await refreshList($, {force: true});
429  $.ui.log(t(lang, 'drop.header', {count: done.length}));
430  for (const item of done) $.ui.log(item.title);
431  for (const item of skipped) $.ui.log(t(lang, 'drop.claimed', {title: item.title}));
432}
433
434/** A handoff run starts when the skill is expanded, however it was started (typed, or run by this mod). */
435async function markHandoffStarted($) {
436  const already = await read($, handoffStartedAt);
437  if (already > 0) {
438    trace($, `start signal ignored, a run is already pending since ${already}`);
439    return;
440  }
441  const now = Math.max(1, await $.clock.now());
442  await update($, handoffStartedAt, () => now);
443  await update($, handoffTurns, () => 0);
444  trace($, `handoff run started at ${now}`);
445  if ((await read($, t1Percent)) > 0) await closeT1($);
446}
447
448/** The newest valid handoff file written since `since`, or null. Never trusts that the model wrote one. */
449async function findNewHandoff($, since) {
450  const dir = `${await projectBase($)}/${DIR}`;
451  const exists = await $.fs.exists(dir);
452  trace($, `looking in ${dir} (exists=${exists}), since=${since}`);
453  if (!exists) return null;
454  let newest = null;
455  for (const entry of await $.fs.list(dir)) {
456    if (entry.kind !== 'file' || !entry.name.endsWith('.md')) {
457      trace($, `skip ${entry.name}: kind=${entry.kind}`);
458      continue;
459    }
460    const path = `${dir}/${entry.name}`;
461    try {
462      const stat = await $.fs.stat(path);
463      if (stat.mtimeMs < since - 2000 || (newest && stat.mtimeMs <= newest.mtimeMs)) {
464        trace($, `skip ${entry.name}: mtimeMs=${stat.mtimeMs}, older than the run or not newest`);
465        continue;
466      }
467      const parsed = parseHandoff(await $.fs.read(path));
468      trace($, `${entry.name}: mtimeMs=${stat.mtimeMs}, valid=${parsed.ok}${parsed.ok ? '' : `, reason=${String(parsed.reason ?? "?")}`}`);
469      if (parsed.ok) newest = {path, mtimeMs: stat.mtimeMs};
470    } catch (error) {
471      trace($, `${entry.name}: ${String(error?.message ?? error)}`); // Skip files that cannot be read.
472    }
473  }
474  return newest;
475}
476
477/**
478 * After each turn while a handoff is pending: report the file once a valid one exists. The review gate makes a handoff
479 * span at least two turns (draft, then confirm), so "no file yet" is only reported after WARN_AT_TURN turns, once.
480 */
481async function checkHandoff($) {
482  const since = await read($, handoffStartedAt);
483  if (since <= 0) return;
484  const lang = config.lang;
485  const turns = (await read($, handoffTurns)) + 1;
486  await update($, handoffTurns, () => turns);
487  const found = await findNewHandoff($, since);
488  trace($, `check after turn ${turns}: ${found ? `found ${found.path}` : 'nothing valid yet'}`);
489  if (found) {
490    await ensureExcluded({
491      git: (args) => git($, args),
492      fs: {exists: (path) => $.fs.exists(path), read: (path) => $.fs.read(path), write: (path, text) => $.fs.write(path, text)},
493      pattern: PATTERN,
494    });
495    await bumpStat($, 'written');
496    $.ui.toast(t(lang, 'toast.written', {path: found.path}));
497    await update($, handoffStartedAt, () => 0);
498    return;
499  }
500  if (turns === WARN_AT_TURN) $.ui.toast(t(lang, 'toast.invalid'));
501  if (turns >= GIVE_UP_TURNS || (await $.clock.now()) - since > GIVE_UP_MS) await update($, handoffStartedAt, () => 0);
502}
503
504/** Start the handoff skill the way a user would. Must run from a timer: the host refuses it inside a command.run hook. */
505function startHandoffLater($) {
506  $.clock.after(300, async () => {
507    try { await $.command.run({command: SKILL}); } catch { /* The user can still run /handoff-mod:handoff by hand. */ }
508  });
509}
510
511/**
512 * T2: ask what to do with a /clear. The question is the only $.ui.ask this mod makes: it is awaited inside the held command,
513 * so it never outlives the command, and a flag keeps a second /clear from stacking another question.
514 * Returns 'clear', 'handoff', 'cancel' or 'stale' (the session changed while the question was open).
515 */
516async function askBeforeClear($) {
517  if (await read($, askPending)) return 'cancel';
518  const lang = config.lang;
519  const options = clearOptions(lang);
520  const sessionId = await $.session.id();
521  await update($, askPending, () => true);
522  let answer = null;
523  try {
524    answer = await $.ui.ask(t(lang, 'ask.clear.question'), options);
525  } catch {
526    answer = null; // dismissed (Esc) or nobody to ask: treated as cancel
527  } finally {
528    await update($, askPending, () => false);
529  }
530  if ((await $.session.id()) !== sessionId) return 'stale';
531  // Anything but an exact label, including text typed under "Other", cancels: never clear on a guess.
532  return answer === options[2] ? 'clear' : answer === options[1] ? 'handoff' : 'cancel';
533}
534
535/** Register the handoff hooks. */
536export function register(on, options) {
537  USER_CONFIG = options ?? {};
538  config = resolveConfig({userConfig: USER_CONFIG});
539
540  on('session.start', async ($, e, next) => {
541    const result = await next(e);
542    try {
543      config = await loadConfig($);
544      await $.command.register({name: 'handoff-resume', description: t(config.lang, 'cmd.resume'), argumentHint: t(config.lang, 'cmd.resume.hint')});
545    } catch { /* A taken name or a failed read must not stop the session. */ }
546    try {
547      await $.command.register({name: 'handoff-stats', description: t(config.lang, 'cmd.stats')});
548    } catch { /* Same: a taken name must not stop the session, nor the other command. */ }
549    try { await sweepOldRecords($); } catch { /* ignore */ }
550    try {
551      // A reload of this module re-fires session.start; only a session with no prompts yet gets the list.
552      if (e.isInteractive && (await $.session.turns()) === 0) void refreshList($);
553    } catch { /* ignore */ }
554    return result;
555  });
556
557  on('classic.SessionStart', async ($, e, next) => {
558    const result = await next(e);
559    if (e.agent_id) return result;
560    try {
561      // resume, fork and compact keep a conversation that already has its context; only a fresh start or /clear lists handoffs.
562      if (!['startup', 'clear'].includes(e.source)) return result;
563      if ((await $.session.surfaces()).length === 0 || (await $.session.turns()) > 0) return result;
564      config = await loadConfig($);
565      // A fresh conversation: $.state is already reset by the host, say so explicitly (design component 2), and redraw.
566      await update($, nextAt, () => 0);
567      await update($, askedAt, () => 0);
568      await update($, t1Percent, () => 0);
569      void refreshList($);
570    } catch { /* ignore */ }
571    return result;
572  });
573
574  on('prompt.submit', async ($, e, next) => {
575    const result = await next(e);
576    try {
577      if ('drop' in result) return result;
578      const text = result.text ?? e.text ?? '';
579      if (text.startsWith('/')) trace($, `prompt.submit origin=${e.origin?.kind ?? '-'} command=${text.trim().split(/\s/)[0]}`);
580      if (['composer', 'bridge', 'sdk'].includes(e.origin?.kind) && !text.startsWith('/')) await recordRequest($, text);
581      // skill.prompt does not fire for a typed plugin skill in every environment, so the command text starts a run too.
582      if (SKILL_COMMAND.test(text.trim())) await markHandoffStarted($);
583      // The first real prompt ends the start-up list; slash commands such as /handoff-resume do not.
584      if (list && ['composer', 'bridge', 'sdk'].includes(e.origin?.kind) && !text.startsWith('/')) {
585        await update($, listDone, () => true);
586        await clearList($);
587      }
588    } catch { /* ignore */ }
589    return result;
590  });
591
592  // Debug only: show which skill names arrive, to see whether the matcher below would have fired.
593  on('skill.prompt', async ($, e, next) => {
594    trace($, `skill.prompt skill=${String(e.skill)}`);
595    return next(e);
596  });
597
598  on('skill.prompt', {skill: SKILL}, async ($, e, next) => {
599    const result = await next(e);
600    try { await markHandoffStarted($); } catch { /* ignore */ }
601    return result;
602  });
603
604  // A third way to see a run start: the skill is a command, however it was started (typed, or run by this mod).
605  on('command.run', {command: SKILL}, async ($, e, next) => {
606    const result = await next(e);
607    try {
608      trace($, `command.run command=${SKILL}`);
609      await markHandoffStarted($);
610    } catch (error) { trace($, `markHandoffStarted failed: ${String(error?.message ?? error)}`); }
611    return result;
612  });
613
614  on('turn.complete', async ($, e, next) => {
615    const result = await next(e);
616    trace($, `turn.complete agentId=${e.agentId ?? '-'} aborted=${Boolean(e.isAborted)}`);
617    if (e.agentId || e.isAborted) return result;
618    try { await checkHandoff($); } catch (error) { trace($, `checkHandoff failed: ${String(error?.message ?? error)}`); }
619    try { await checkThreshold($); } catch (error) { trace($, `checkThreshold failed: ${String(error?.message ?? error)}`); }
620    try { await recordResponse($, e.answer); } catch { /* ignore */ }
621    return result;
622  });
623
624  on('session.end', async ($, e, next) => {
625    try { await writeEndNote($, e, next); } catch (error) { trace($, `writeEndNote failed: ${String(error?.message ?? error)}`); }
626    return next(e);
627  });
628
629  on('command.run', {command: 'clear'}, async ($, e, next) => {
630    try {
631      // Only a person's own /clear in an interactive session that has had prompts (D3); not another plugin's, not headless.
632      if (e.origin?.kind === 'plugin') return next(e);
633      if ((await $.session.surfaces()).length === 0 || (await $.session.turns()) === 0) return next(e);
634    } catch {
635      return next(e);
636    }
637    let choice = 'clear';
638    try {
639      choice = await askBeforeClear($);
640    } catch {
641      return next(e); // if asking itself fails, do what the user asked rather than swallow the /clear
642    }
643    if (choice === 'clear') return next(e);
644    if (choice === 'stale') return {};
645    if (choice === 'handoff') {
646      startHandoffLater($);
647      return {text: t(config.lang, 'clear.held')};
648    }
649    return {text: t(config.lang, 'clear.cancelled')};
650  });
651
652  // D5: the counts live in $.store on this machine only; this command is the one place they are shown.
653  on('command.run', {command: 'handoff-stats'}, async ($) => {
654    try {
655      const stats = (await storeGet($, 'stats')) ?? {};
656      $.ui.log(t(config.lang, 'stats.line', {written: Number(stats.written) || 0, resumed: Number(stats.resumed) || 0}));
657    } catch { /* ignore */ }
658    return {};
659  });
660
661  on('command.run', {command: 'handoff-resume'}, async ($, e) => {
662    try {
663      config = await loadConfig($);
664      const lang = config.lang;
665      const arg = String(e.args ?? '').trim();
666      const words = arg === '' ? [] : arg.split(/\s+/);
667      const dropping = words[0] === 'drop';
668      if (arg === 'all' || (dropping && words[1] === 'all')) await update($, expanded, () => true);
669      await refreshList($, {force: true});
670      if (dropping) {
671        await dropCommand($, words[1]);
672      } else if (!list) {
673        $.ui.log(t(lang, 'list.none'));
674      } else if (arg === '' || arg === 'all') {
675        $.ui.log(t(lang, 'list.header', {count: list.total}));
676        for (const item of list.items) {
677          const extra = [item.next, ...item.facts, item.auto ? t(lang, 'list.auto') : '', item.older ? t(lang, 'list.older') : '', item.claimed ? t(lang, 'list.claimed') : ''].filter(Boolean);
678          $.ui.log(extra.length ? `${item.title} — ${extra.join(';')}` : item.title);
679        }
680        if (list.total > list.items.length) $.ui.log(t(lang, 'list.moreCmd', {count: list.total - list.items.length}));
681        $.ui.log(t(lang, 'list.dropHint'));
682      } else {
683        const n = Number(arg);
684        // A number past the shown items may still be a folded one (D15, D16): expand, then look again.
685        if (Number.isInteger(n) && n > list.items.length && list.total > list.items.length) await expandList($);
686        const item = Number.isInteger(n) ? list?.items[n - 1] : undefined;
687        if (item) await resume($, item);
688        else $.ui.log(t(lang, 'list.bad', {n: arg}));
689      }
690    } catch { /* ignore */ }
691    return {};
692  });
693
694  on('ui.render', {component: 'AbovePrompt'}, async ($, e, next) => {
695    const open = await read($, t1Percent);
696    if (!list && open <= 0) return next(e);
697    const {Box, Text, Button} = $.ui.resolve(e);
698    const theirs = await next(e);
699    const lang = config.lang;
700    const blocks = [theirs];
701    if (list) {
702      const rows = [];
703      list.items.forEach((item, i) => {
704        rows.push(Text({bold: true, wrap: 'wrap', children: [item.title]}));
705        if (item.next) rows.push(Text({wrap: 'wrap', children: [item.next]}));
706        for (const [j, fact] of item.facts.entries()) rows.push(Text({dimColor: true, wrap: 'wrap', children: [fact]}));
707        if (item.auto) rows.push(Text({dimColor: true, children: [t(lang, 'list.auto')]}));
708        if (item.older) rows.push(Text({dimColor: true, children: [t(lang, 'list.older')]}));
709        rows.push(item.claimed
710          ? Text({dimColor: true, children: [t(lang, 'list.claimed')]})
711          : Button({key: `resume-${i}`, label: t(lang, 'list.resume'), variant: 'primary', onPress: () => resume($, item)}));
712      });
713      // Theme keys, not raw colors, so the borders follow the person's theme. Buttons are not `plain`: plain ones draw as bare text.
714      blocks.push(Box({
715        flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1,
716        children: [
717          // The hint shares the header row (D19); it wraps below the count on a narrow terminal instead of being cut off.
718          Box({flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: [
719            Text({bold: true, children: [t(lang, 'list.header', {count: list.total})]}),
720            Text({dimColor: true, children: [t(lang, 'list.bandHint')]}),
721          ]}),
722          ...rows,
723          ...(list.total > list.items.length ? [Button({key: 'more', label: t(lang, 'list.more', {count: list.total - list.items.length}), variant: 'secondary', onPress: () => expandList($)})] : []),
724          Button({key: 'skip', label: t(lang, 'list.skip'), variant: 'secondary', onPress: async () => { await update($, listDone, () => true); await clearList($); }}),
725        ],
726      }));
727    }
728    if (open > 0) {
729      // No digit hotkeys: a digit typed at the start of a message in an empty prompt would answer the question.
730      blocks.push(Box({
731        flexDirection: 'column', borderStyle: 'round', borderColor: 'warning', paddingX: 1,
732        children: [
733          Text({bold: true, wrap: 'wrap', children: [t(lang, 'band.question', {percent: open})]}),
734          Box({flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, children: [
735            Button({key: 't1-agree', label: t(lang, 'band.agree'), variant: 'primary', onPress: () => answerT1($, 'agree')}),
736            Button({key: 't1-snooze', label: t(lang, 'band.snooze', {step: 10}), variant: 'secondary', onPress: () => answerT1($, 'snooze')}),
737            Button({key: 't1-suppress', label: t(lang, 'band.suppress'), variant: 'secondary', onPress: () => answerT1($, 'suppress')}),
738          ]}),
739        ],
740      }));
741    }
742    return Box({flexDirection: 'column', children: blocks});
743  });
744}
745
hooks/config.js 17 lines
1const DEFAULTS = {thresholdPct: 60, lang: 'zh-TW', autoNote: true};
2const LANGS = ['zh-TW', 'en'];
3
4/** A whole percentage from 1 to 99, given as a number or a digit string; anything else is undefined. */
5const asPct = (value) => {
6  const n = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : value;
7  return Number.isInteger(n) && n >= 1 && n <= 99 ? n : undefined;
8};
9
10/** Resolve settings: test-only env overrides win, then userConfig, then defaults. Never throws. */
11export function resolveConfig({env = {}, userConfig = {}} = {}) {
12  const lang = LANGS.includes(env.HANDOFF_LANG) ? env.HANDOFF_LANG : LANGS.includes(userConfig.lang) ? userConfig.lang : DEFAULTS.lang;
13  const flag = String(env.HANDOFF_AUTO_NOTE ?? '').toLowerCase();
14  const autoNote = flag === 'off' ? false : flag === 'on' ? true : typeof userConfig.autoNote === 'boolean' ? userConfig.autoNote : DEFAULTS.autoNote;
15  return {thresholdPct: asPct(env.HANDOFF_THRESHOLD_PCT) ?? asPct(userConfig.thresholdPct) ?? DEFAULTS.thresholdPct, lang, autoNote};
16}
17
hooks/i18n.js 143 lines
1/** All user-facing strings, in one file (D8, D11). Both languages must define the same keys and placeholders. */
2export const STRINGS = {
3  'zh-TW': {
4    'band.question': 'Context 已用 {percent}%,要先交接嗎?',
5    'band.agree': '同意',
6    'band.snooze': '再多 {step}% 再問',
7    'band.suppress': '這個 session 別再問',
8    'status.threshold': 'Context 已用 {percent}%。需要時輸入 /handoff-mod:handoff 交接',
9    'ask.clear.question': '清除前要留交接檔嗎?',
10    'ask.clear.cancel': '取消',
11    'ask.clear.handoffFirst': '寫交接檔(寫完再 /clear)',
12    'ask.clear.clearNow': '不交接,直接清除',
13    'clear.held': '已暫停清除,交接完成後請再下 /clear。',
14    'clear.cancelled': '已取消清除。',
15    'list.header': '有 {count} 筆未完成交接',
16    'list.bandHint': '(若想放棄交接檔:/handoff-resume drop <編號|all>)',
17    'list.item': '{n}. {task}({branch}・{age})',
18    'list.next': '下一步:{next}',
19    'list.auto': '自動留下,未經審查',
20    'list.more': '還有 {count} 筆',
21    'list.moreCmd': '還有 {count} 筆,輸入 /handoff-resume all 全部列出',
22    'list.older': '同 branch 較舊的自動筆記',
23    'list.claimed': '另一個 session 正在接續',
24    'list.resume': '接續',
25    'list.skip': '本 session 忽略',
26    'list.none': '目前沒有未完成的交接。',
27    'status.pending': '有 {count} 筆未完成交接,輸入 /handoff-resume 查看',
28    'resume.prompt': '請先讀 {path},驗證其中前提是否仍成立,再接續「下一步」。',
29    'resume.claimedElsewhere': '這份交接正由另一個 session 接續。',
30    'toast.written': '交接已寫入 {path}',
31    'toast.invalid': '未偵測到有效交接檔',
32    'fresh.branchGone': 'branch {branch} 已不存在',
33    'fresh.ahead': '交接後 {branch} 多了 {count} 個 commit',
34    'fresh.merged': '交接的 HEAD 已包含在 {base}',
35    'fresh.unverifiable': '無法驗證(不在 git 內或缺少資料)',
36    'age.minutes': '{n} 分鐘前',
37    'age.hours': '{n} 小時前',
38    'age.days': '{n} 天前',
39    'stats.line': '交接寫入 {written} 次,接續 {resumed} 次',
40    'cmd.resume': '列出未完成的交接;/handoff-resume <編號> 接續其中一筆;drop <編號|all> 放棄',
41    'cmd.resume.hint': '[編號|all|drop]',
42    'cmd.stats': '顯示這台機器上交接寫入與接續的次數(只存在本機,不外傳)',
43    'list.bad': '沒有第 {n} 筆。',
44    'list.dropHint': '(若想放棄交接檔:/handoff-resume drop <編號|all>)',
45    'drop.usage': '用法:/handoff-resume drop <編號|all>',
46    'drop.header': '已放棄 {count} 筆,剩下的編號已重排:',
47    'drop.claimed': '另一個 session 正在接續,沒有放棄:{title}',
48    'drop.none': '沒有可以放棄的交接。',
49    'note.task': '## 任務',
50    'note.next': '## 下一步',
51    'note.nextText': '請先看「最後一個要求」與「最後一段回應」,確認現況再接續。',
52    'note.unreviewed': '> 自動留下,未經審查。',
53    'note.lastRequest': '## 最後一個要求',
54    'note.lastResponse': '## 最後一段回應',
55    'note.state': '## 狀態(事實)',
56    'note.branchLine': 'branch:{branch}',
57    'note.headLine': 'HEAD:{head}',
58    'note.changed': '## 有改動的檔案',
59  },
60  en: {
61    'band.question': 'Context is {percent}% full. Write a handoff first?',
62    'band.agree': 'Yes',
63    'band.snooze': 'Ask again in {step}%',
64    'band.suppress': 'Not this session',
65    'status.threshold': 'Context is {percent}% full. Run /handoff-mod:handoff to hand off',
66    'ask.clear.question': 'Create a handoff file before clearing?',
67    'ask.clear.cancel': 'Cancel',
68    'ask.clear.handoffFirst': 'Write a handoff (then run /clear)',
69    'ask.clear.clearNow': 'Clear without a handoff',
70    'clear.held': 'Clear is on hold. Run /clear again once the handoff is written.',
71    'clear.cancelled': 'Clear cancelled.',
72    'list.header': '{count} unfinished handoff(s)',
73    'list.bandHint': '(To abandon a handoff: /handoff-resume drop <n|all>)',
74    'list.item': '{n}. {task} ({branch}, {age})',
75    'list.next': 'Next: {next}',
76    'list.auto': 'Saved automatically, not reviewed',
77    'list.more': '{count} more',
78    'list.moreCmd': '{count} more. Run /handoff-resume all to list them all',
79    'list.older': 'Older automatic note on the same branch',
80    'list.claimed': 'Another session is resuming this',
81    'list.resume': 'Resume',
82    'list.skip': 'Ignore for this session',
83    'list.none': 'No unfinished handoffs.',
84    'status.pending': '{count} unfinished handoff(s). Run /handoff-resume to see them',
85    'resume.prompt': 'Read {path} first, check that its premises still hold, then continue from "Next".',
86    'resume.claimedElsewhere': 'Another session is already resuming this handoff.',
87    'toast.written': 'Handoff written to {path}',
88    'toast.invalid': 'No valid handoff file was found',
89    'fresh.branchGone': 'Branch {branch} no longer exists',
90    'fresh.ahead': '{branch} has {count} new commit(s) since the handoff',
91    'fresh.merged': 'The handoff HEAD is already in {base}',
92    'fresh.unverifiable': 'Cannot verify (not in git, or data missing)',
93    'age.minutes': '{n} min ago',
94    'age.hours': '{n} h ago',
95    'age.days': '{n} d ago',
96    'stats.line': 'Handoffs written: {written}, resumed: {resumed}',
97    'cmd.resume': 'List unfinished handoffs; "/handoff-resume <n>" resumes one; "drop <n|all>" abandons',
98    'cmd.resume.hint': '[n|all|drop]',
99    'cmd.stats': 'Show how many handoffs were written and resumed on this machine (kept local, never sent)',
100    'list.bad': 'There is no item {n}.',
101    'list.dropHint': '(To abandon a handoff: /handoff-resume drop <n|all>)',
102    'drop.usage': 'Usage: /handoff-resume drop <n|all>',
103    'drop.header': 'Abandoned {count}; the remaining items are renumbered:',
104    'drop.claimed': 'Another session is resuming this, not abandoned: {title}',
105    'drop.none': 'Nothing to abandon.',
106    'note.task': '## Task',
107    'note.next': '## Next',
108    'note.nextText': 'Read "Last request" and "Last response" first, check the current state, then continue.',
109    'note.unreviewed': '> Saved automatically; not reviewed.',
110    'note.lastRequest': '## Last request',
111    'note.lastResponse': '## Last response',
112    'note.state': '## State (facts)',
113    'note.branchLine': 'Branch: {branch}',
114    'note.headLine': 'HEAD: {head}',
115    'note.changed': '## Changed files',
116  },
117};
118
119/** Look up a string and fill its {placeholders}. An unknown language falls back to zh-TW; a missing key or variable throws, so mistakes show up in tests. */
120export function t(lang, key, vars = {}) {
121  const table = STRINGS[lang] ?? STRINGS['zh-TW'];
122  if (!Object.hasOwn(table, key)) throw new Error(`i18n: unknown key ${key}`);
123  return table[key].replace(/\{(\w+)\}/g, (_, name) => {
124    if (!Object.hasOwn(vars, name)) throw new Error(`i18n: missing variable ${name} for ${key}`);
125    return String(vars[name]);
126  });
127}
128
129/** The three choices of the /clear prompt, in the order shown: the first one is what a stray Enter picks (D13). */
130export const clearOptions = (lang) => [t(lang, 'ask.clear.cancel'), t(lang, 'ask.clear.handoffFirst'), t(lang, 'ask.clear.clearNow')];
131
132const isWide = (cp) =>
133  (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
134  (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
135  (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd);
136
137/** Terminal columns a string takes: CJK and emoji count as two. */
138export function displayWidth(text) {
139  let width = 0;
140  for (const ch of String(text)) width += isWide(ch.codePointAt(0)) ? 2 : 1;
141  return width;
142}
143
hooks/handoff-file.js 76 lines
1import {stripControl, truncateCodePoints} from './sanitize.js';
2
3const MAX_READ = 64 * 1024;
4export const OPEN_STATUSES = ['in-progress', 'blocked', 'ready-for-review'];
5
6/** Section headings, Chinese or English (D11), matched on the start of the heading text. */
7const HEADINGS = [
8  ['task', /^(任務|task)/i],
9  ['done', /^(已完成|done|completed)/i],
10  ['todo', /^(未完成|remaining|todo|to-do|not done)/i],
11  ['next', /^(下一步|next)/i],
12  ['premises', /^(前提|premises|assumptions)/i],
13  ['rules', /^(規矩|rules|constraints)/i],
14  ['verified', /^(已驗證|verified)/i],
15];
16
17const oneLine = (text, max) => truncateCodePoints(stripControl(String(text)).replace(/\s+/g, ' ').trim(), max);
18const value = (raw) => {
19  const text = String(raw).replace(/\s+#.*$/, '').trim();
20  return /^(["']).*\1$/.test(text) ? text.slice(1, -1) : text;
21};
22const firstLine = (lines = []) => {
23  const line = lines.map((l) => l.trim()).find(Boolean);
24  return line === undefined ? undefined : line.replace(/^(?:[-*+]\s+|\d+[.)]\s+)?(?:\[[ xX]\]\s*)?/, '');
25};
26
27/** Split `---` frontmatter from the body; null when the file has none. */
28function split(text) {
29  const t = text.replace(/^/, '').replace(/\r\n?/g, '\n');
30  if (!t.startsWith('---\n')) return null;
31  let from = 3;
32  for (;;) {
33    const at = t.indexOf('\n---', from);
34    if (at === -1) return null;
35    const after = t[at + 4];
36    if (after === undefined || after === '\n') return {front: t.slice(4, at + 1), body: t.slice(at + 5)};
37    from = at + 4;
38  }
39}
40
41/**
42 * Parse a handoff file. Returns {ok:true, meta, fields} or {ok:false, reason}.
43 * Everything from the file is a plain string: nothing is executed, and branch/head are validated later, before git sees them.
44 */
45export function parseHandoff(input) {
46  const parts = split(String(input ?? '').slice(0, MAX_READ));
47  if (!parts) return {ok: false, reason: 'frontmatter'};
48  const fm = {};
49  for (const line of parts.front.split('\n')) {
50    const m = /^([A-Za-z_][A-Za-z0-9_-]*):[ \t]*(.*)$/.exec(line);
51    if (m && !(m[1] in fm)) fm[m[1]] = value(m[2]);
52  }
53  if (!OPEN_STATUSES.includes(fm.status)) return {ok: false, reason: 'status'};
54  const created = Date.parse(fm.created);
55  if (!Number.isFinite(created)) return {ok: false, reason: 'created'};
56  const text = (v) => (v === undefined || v === '' ? undefined : oneLine(v, 500));
57
58  const sections = {};
59  let current = null;
60  for (const line of parts.body.split('\n')) {
61    if (line.startsWith('## ')) {
62      const heading = line.slice(3).trim();
63      current = HEADINGS.find(([, pattern]) => pattern.test(heading))?.[0] ?? null;
64      if (current && !sections[current]) sections[current] = [];
65    } else if (current) sections[current].push(line);
66  }
67  return {
68    ok: true,
69    meta: {
70      schema: text(fm.schema), root: text(fm.root ?? fm.worktree), repo: text(fm.repo), branch: text(fm.branch),
71      head: text(fm.head), created, status: fm.status, source: text(fm.source),
72    },
73    fields: {task: oneLine(fm.task ?? firstLine(sections.task) ?? '', 80), next: oneLine(firstLine(sections.next) ?? '', 120)},
74  };
75}
76
hooks/rank.js 37 lines
1import {OPEN_STATUSES} from './handoff-file.js';
2
3const DAY = 24 * 3600 * 1000;
4const FOLD_AFTER = 14 * DAY;
5const AUTO_HIDE_AFTER = 7 * DAY;
6const SHOWN = 3;
7
8/** Newest first; the path settles a tie so the order never depends on the input order. */
9const byNewest = (a, b) => b.meta.created - a.meta.created || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
10/** Automatic notes of one working tree and branch; with no branch, of one working tree. */
11const groupKey = (item) => `${item.meta.root ?? ''}\u0000${item.meta.branch ?? ''}`;
12
13/**
14 * Pick what the start-up list shows. `items` are {path, meta, effectiveStatus, claimedByOther}.
15 * Same working tree first, then same repository, then newest; the order never depends on the input order.
16 * Returns {shown, rest, collapsed, hidden}: `rest` is everything not shown (folded by age, by the limit of three, or because
17 * it is an older automatic note on a branch that has a newer one, D15; those carry `olderAuto: true`). `collapsed` is
18 * `rest.length`. `hidden` counts automatic notes older than 7 days, which are neither shown nor in `rest`.
19 */
20export function rankHandoffs(items, {base, repo, now}) {
21  const open = items.filter((item) => OPEN_STATUSES.includes(item.effectiveStatus));
22  const live = open.filter((item) => !(item.meta.source === 'auto' && now - item.meta.created > AUTO_HIDE_AFTER));
23  const hidden = open.length - live.length;
24  const newestAuto = new Map();
25  for (const item of live) {
26    if (item.meta.source !== 'auto') continue;
27    const best = newestAuto.get(groupKey(item));
28    if (!best || byNewest(item, best) < 0) newestAuto.set(groupKey(item), item);
29  }
30  const flagged = live.map((item) => (item.meta.source === 'auto' && newestAuto.get(groupKey(item)) !== item ? {...item, olderAuto: true} : item));
31  const score = (item) => (base && item.meta.root === base ? 0 : repo && item.meta.repo === repo ? 1 : 2);
32  const sorted = flagged.sort((a, b) => score(a) - score(b) || byNewest(a, b));
33  const shown = sorted.filter((item) => !item.olderAuto && now - item.meta.created <= FOLD_AFTER).slice(0, SHOWN);
34  const rest = sorted.filter((item) => !shown.includes(item));
35  return {shown, rest, collapsed: rest.length, hidden};
36}
37
hooks/freshness.js 47 lines
1const HEAD_RE = /^[0-9a-f]{4,40}$/;
2const BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
3
4/** A branch name that cannot be read as an option or a path trick. Values from a handoff file are untrusted. */
5const validBranch = (name) => typeof name === 'string' && BRANCH_RE.test(name) && !name.includes('..') && !name.endsWith('/') && !name.endsWith('.lock');
6const validHead = (head) => typeof head === 'string' && HEAD_RE.test(head);
7
8/**
9 * Facts about how the repository moved since a handoff was written. `git(args)` is injected and resolves to {exitCode, stdout}.
10 * The result is a list of facts, never a verdict; any git command that fails only drops its own line.
11 */
12export async function freshnessFacts({meta, git}) {
13  const branch = validBranch(meta.branch) ? meta.branch : null;
14  const head = validHead(meta.head) ? meta.head : null;
15  if (!meta.repo || (!branch && !head)) return [{kind: 'unverifiable'}];
16
17  const run = async (args) => {
18    try {
19      const result = await git(args);
20      return result && typeof result.exitCode === 'number' ? result : null;
21    } catch {
22      return null;
23    }
24  };
25  const facts = [];
26  let branchExists = false;
27  if (branch) {
28    const found = await run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
29    if (found && found.exitCode !== 0) facts.push({kind: 'branchGone', branch});
30    else if (found) branchExists = true;
31  }
32  if (branch && head && branchExists) {
33    const counted = await run(['rev-list', '--count', `${head}..refs/heads/${branch}`]);
34    const count = counted && counted.exitCode === 0 ? Number.parseInt(String(counted.stdout).trim(), 10) : NaN;
35    if (Number.isFinite(count) && count > 0) facts.push({kind: 'ahead', branch, count});
36  }
37  if (head) {
38    const named = await run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
39    const base = named && named.exitCode === 0 ? String(named.stdout).trim() : '';
40    if (validBranch(base)) {
41      const contained = await run(['merge-base', '--is-ancestor', head, base]);
42      if (contained && contained.exitCode === 0) facts.push({kind: 'merged', base});
43    }
44  }
45  return facts;
46}
47
hooks/claim.js 28 lines
1export const CLAIM_TTL_MS = 12 * 3600 * 1000;
2
3const valid = (entry) => entry && typeof entry === 'object' && typeof entry.sessionId === 'string' && typeof entry.at === 'number';
4
5/** How a stored claim looks to this session: free, mine, other (live) or expired. */
6export function claimView(entry, sessionId, now) {
7  if (!valid(entry)) return {state: 'free'};
8  if (now - entry.at > CLAIM_TTL_MS) return {state: 'expired', holder: entry.sessionId};
9  return entry.sessionId === sessionId ? {state: 'mine'} : {state: 'other', holder: entry.sessionId};
10}
11
12/**
13 * Claim a handoff for this session. A get followed by a set is not atomic, so the claim is read back and the winner is whoever is there.
14 * If the store fails, resuming is still allowed: `degraded` says the duplicate guard is off.
15 */
16export async function tryClaim({id, sessionId, now, store}) {
17  const key = `claim:${id}`;
18  try {
19    const view = claimView(await store.get(key), sessionId, now);
20    if (view.state === 'other') return {won: false, holder: view.holder};
21    await store.set(key, {sessionId, at: now});
22    const back = await store.get(key);
23    return valid(back) && back.sessionId === sessionId ? {won: true} : {won: false, holder: valid(back) ? back.sessionId : undefined};
24  } catch {
25    return {won: true, degraded: true};
26  }
27}
28
hooks/exclude.js 23 lines
1/**
2 * Keep a path pattern (such as ".claude/handoffs/") out of `git status` by adding it to .git/info/exclude.
3 * `git(args)` resolves to {exitCode, stdout}; `fs` has exists/read/write. Idempotent, never throws.
4 * Returns {ok, changed} or {ok:false, skipped:true} outside a git repository.
5 */
6export async function ensureExcluded({git, fs, pattern}) {
7  try {
8    const ignored = await git(['check-ignore', '-q', '--', `${pattern}_probe`]);
9    if (ignored.exitCode === 0) return {ok: true, changed: false};
10    if (ignored.exitCode !== 1) return {ok: false, skipped: true};
11    const located = await git(['rev-parse', '--git-path', 'info/exclude']);
12    const path = located.exitCode === 0 ? String(located.stdout).trim() : '';
13    if (!path) return {ok: false};
14    const current = (await fs.exists(path)) ? await fs.read(path) : '';
15    if (current.split('\n').some((line) => line.trim() === pattern)) return {ok: true, changed: false};
16    const separator = current === '' || current.endsWith('\n') ? '' : '\n';
17    await fs.write(path, `${current}${separator}${pattern}\n`);
18    return {ok: true, changed: true};
19  } catch {
20    return {ok: false};
21  }
22}
23
hooks/sanitize.js 53 lines
1/*
2 * Cleaning for text that came from a file or a conversation before it is shown or stored.
3 *
4 * redactSecrets covers these shapes, no more. It lowers the risk; it is NOT a guarantee that a secret never survives.
5 *   1  private key blocks (terminated, or cut off)      2  Authorization: Bearer/Basic/Token values
6 *   3  user:password@ in URLs                            4  JWTs (three dot-separated base64url parts)
7 *   5  AWS access key ids (AKIA/ASIA)                    6  GitHub tokens (ghp_/gho_/ghu_/ghs_/ghr_, github_pat_)
8 *   7  Slack tokens (xox[baprs]-)                        8  sk- style API keys
9 *   9  name=value / name: value where the name contains api key, secret, token, passwd, password, pwd, access key or private key
10 * Words about secrets without an assignment ("password policy", "token count") are left alone; an assignment such as
11 * "tokens: 1000" is redacted even though it is harmless, which is the safe direction.
12 */
13const REDACTED = '[redacted]';
14const RULES = [
15  [/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, REDACTED],
16  [/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*$/g, REDACTED],
17  [/(\bAuthorization\s*:\s*(?:Bearer|Basic|Token)\s+)[^\s'"]+/gi, `$1${REDACTED}`],
18  [/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, `$1${REDACTED}@`],
19  [/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g, REDACTED],
20  [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, REDACTED],
21  [/\bgh[pousr]_[A-Za-z0-9]{30,}\b/g, REDACTED],
22  [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, REDACTED],
23  [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, REDACTED],
24  [/\bsk-[A-Za-z0-9_-]{20,}\b/g, REDACTED],
25  [/([A-Za-z0-9_-]*(?:api[_-]?key|secret|token|passwd|password|pwd|access[_-]?key|private[_-]?key)[A-Za-z0-9_-]*\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, `$1${REDACTED}`],
26];
27
28/** Remove terminal escapes and control characters; keep newlines, tabs, CJK and emoji. CRLF becomes LF. */
29export function stripControl(text) {
30  return String(text)
31    .replace(/\r\n?/g, '\n')
32    .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '')
33    .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
34    .replace(/\x1b[@-Z\\-_]/g, '')
35    .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
36}
37
38/** Cut to at most `max` Unicode code points, ending with an ellipsis when something was removed. */
39export function truncateCodePoints(text, max) {
40  const value = String(text);
41  const points = [...value];
42  return points.length <= max ? value : `${points.slice(0, Math.max(0, max - 1)).join('')}…`;
43}
44
45export function redactSecrets(text) {
46  let out = String(text);
47  for (const [pattern, replacement] of RULES) out = out.replace(pattern, replacement);
48  return out;
49}
50
51/** The pipeline for quoted text. The order matters: redact before truncating, so a cut can never leave half a secret. */
52export const cleanForNote = (text, max) => truncateCodePoints(redactSecrets(stripControl(text)), max);
53
hooks/note.js 66 lines
1import {cleanForNote} from './sanitize.js';
2import {t} from './i18n.js';
3
4const QUOTE_MAX = 2000;
5const FILES_MAX = 50;
6
7/** The end-of-session note is written only for an interactive session that did something and is not being cleared (T2 handles /clear). */
8export const shouldWriteNote = ({interactive, turns, hasUnfinishedSign, reason, config}) =>
9  Boolean(interactive && turns > 0 && hasUnfinishedSign && reason !== 'clear' && config?.autoNote);
10
11const pad = (n) => String(n).padStart(2, '0');
12const stamp = (ms) => {
13  const d = new Date(ms);
14  return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
15};
16const safeBranch = (branch) => String(branch ?? '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'no-branch';
17const single = (text, max) => cleanForNote(text, max).replace(/\s+/g, ' ').trim();
18/** Quote each line, so a "## heading" inside quoted text can never become a section of the note. */
19const quote = (text) => text.split('\n').map((line) => `> ${line}`).join('\n');
20
21/** Build the facts-only note: the last request, the last response, git state and changed files. No tool output, nothing earlier. */
22export function buildEndNote({lastRequest, lastResponse, branch, head, dirtyFiles = [], root, repo, now, lang}) {
23  const request = cleanForNote(lastRequest ?? '', QUOTE_MAX);
24  const response = cleanForNote(lastResponse ?? '', QUOTE_MAX);
25  const task = single(request.split('\n').find((line) => line.trim()) ?? '', 80);
26  const front = ['schema: 1', `root: ${single(root ?? '', 300)}`];
27  if (repo) front.push(`repo: ${single(repo, 300)}`);
28  if (branch) front.push(`branch: ${single(branch, 200)}`);
29  if (head) front.push(`head: ${single(head, 64)}`);
30  front.push(`created: ${new Date(now).toISOString()}`, `task: ${task}`, 'status: in-progress', 'source: auto');
31
32  const state = [];
33  if (branch) state.push(`- ${t(lang, 'note.branchLine', {branch: single(branch, 200)})}`);
34  if (head) state.push(`- ${t(lang, 'note.headLine', {head: single(head, 64)})}`);
35  const files = dirtyFiles.slice(0, FILES_MAX).map((file) => `- ${single(file, 200)}`);
36
37  const body = [
38    t(lang, 'note.task'), task, '',
39    t(lang, 'note.next'), t(lang, 'note.nextText'), '',
40    t(lang, 'note.unreviewed'), '',
41    t(lang, 'note.lastRequest'), quote(request), '',
42    t(lang, 'note.lastResponse'), quote(response), '',
43    ...(state.length ? [t(lang, 'note.state'), ...state, ''] : []),
44    ...(files.length ? [t(lang, 'note.changed'), ...files, ''] : []),
45  ];
46  return {fileName: `${safeBranch(branch)}--${stamp(now)}--auto.md`, content: `---\n${front.join('\n')}\n---\n\n${body.join('\n')}`};
47}
48
49/**
50 * Run `run()` but give up after `ms`. `after(ms, cb)` is $.clock.after, injected because a hooks module has no timers of its own.
51 * Resolves to {value}, {timedOut: true} or {error}; the caller must write nothing when it timed out.
52 */
53export function withDeadline(run, ms, after) {
54  return new Promise((resolve) => {
55    let settled = false;
56    const finish = (result, timer) => {
57      if (settled) return;
58      settled = true;
59      timer?.cancel?.();
60      resolve(result);
61    };
62    const timer = after(ms, () => finish({timedOut: true}));
63    run().then((value) => finish({value}, timer), (error) => finish({error}, timer));
64  });
65}
66
hooks/trigger.js 29 lines
1/** Session-scoped T1 state (kept in $.state, D12). */
2export const initialState = () => ({nextAt: 0, askedAt: 0, suppressed: false, askPending: false, handoffStartedAt: 0});
3
4/** The threshold in force: a snoozed value if there is one, else the configured one. */
5export const effectiveThreshold = (state, config) => (state.nextAt > 0 ? state.nextAt : config.thresholdPct);
6
7/** Ask only when every condition holds; anything unknown means "do not ask". */
8export function decideTrigger({percent, config, state, idle, hasUnfinishedSign, handoffRunning}) {
9  const at = effectiveThreshold(state, config);
10  if (typeof percent !== 'number' || state.suppressed || state.askPending || handoffRunning) return {action: 'none', at};
11  if (!idle || !hasUnfinishedSign || percent < at || state.askedAt >= at) return {action: 'none', at};
12  return {action: 'ask', at};
13}
14
15export const afterAsk = (state, at) => ({...state, askedAt: at});
16export const suppress = (state) => ({...state, suppressed: true});
17
18/** "Ask again in 10%": the next threshold is ten points above where the user is now (or the asked threshold), at most 99. */
19export const snooze = (state, {percent, at}) => ({...state, nextAt: Math.min(99, Math.max(percent, at) + 10)});
20
21/** Forget which thresholds were asked about; used on compaction and /clear. Suppression stays. */
22export const resetThreshold = (state) => ({...state, nextAt: 0, askedAt: 0});
23
24/** Compaction and /clear make the percentage fall; a value below the last asked threshold restarts the memory. */
25export const onPercentSeen = (state, percent) => (typeof percent === 'number' && state.askedAt > 0 && percent < state.askedAt ? resetThreshold(state) : state);
26
27/** Something worth handing off: a file was edited this session, or the working tree is dirty. */
28export const hasUnfinishedSign = (signs) => Boolean(signs && (signs.editedFile || signs.gitDirty));
29
types/index.d.ts 25 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'handoff-mod': {
4      /** Next percentage at which T1 asks; 0 means "use the configured threshold". */
5      nextAt: number
6      /** The threshold T1 last asked about; 0 means none. */
7      askedAt: number
8      /** "Do not ask again this session". */
9      suppressed: boolean
10      /** A $.ui.ask is open (T2). */
11      askPending: boolean
12      /** When the handoff turn started (ms), 0 when none; used to find the new file. */
13      handoffStartedAt: number
14      /** Turns completed since the handoff started; used to decide when to say that no file appeared. */
15      handoffTurns: number
16      /** The percentage the open T1 prompt reports; 0 while no prompt is open. */
17      t1Percent: number
18      /** The start-up list was skipped, resumed or dismissed in this session. */
19      listDone: boolean
20      /** The folded part of the start-up list was asked for (button or `/handoff-resume all`); D16. */
21      expanded: boolean
22    }
23  }
24}
25