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

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。
| 設定 | 預設 | 說明 |
|---|---|---|
thresholdPct | 60 | 已用百分比(1 到 99)。百分比是對模型的 context window 算的,同一個數字在不同模型是不同的 token 數。 |
lang | zh-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])。
--auto.md(檔案權限 0600,下次啟動標示「自動留下,未經審查」,超過 7 天不再顯示)。password = …、sk-…、Authorization: Bearer … 等九種)。單獨出現在文字裡的密碼不會被遮蔽。需要時把 autoNote 關掉,或設 HANDOFF_AUTO_NOTE=off。~/.claude/plugins/store/;在 Cloud 查過是明文 JSON)。檔案權限在 macOS 是 0600,但目錄是 0755(同機器的其他使用者看得到檔名,讀不到內容)。session 結束時一律刪除,當機留下的殘餘超過 7 天會在下次啟動時清掉。/handoff-stats 的數字只存在這台機器的 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、設計、實作計畫。
hooks/register.js 745 lines1import {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}
745hooks/config.js 17 lines1const 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}
17hooks/i18n.js 143 lines1/** 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}
143hooks/handoff-file.js 76 lines1import {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}
76hooks/rank.js 37 lines1import {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}
37hooks/freshness.js 47 lines1const 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}
47hooks/claim.js 28 lines1export 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}
28hooks/exclude.js 23 lines1/**
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}
23hooks/sanitize.js 53 lines1/*
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);
53hooks/note.js 66 lines1import {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}
66hooks/trigger.js 29 lines1/** 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));
29types/index.d.ts 25 lines1declare 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