Writes a cold-start handoff to handoff/ before the context fills up or compacts, and offers to resume from it in the next session.

Writes a summary of progress, decisions and next steps into the project so a fresh conversation can continue with that summary.
See the complete guide and creation prompt.
Test from the kit root:
claude plugin validate plugins/auto-handoff
claude plugin test plugins/auto-handoffhooks/auto-handoff.mjs 400 lines1// Auto Handoff: writes a cold-start handoff to <project>/handoff/ before the
2// context fills up or compacts, and offers to resume from it next session.
3
4const CWD = { plugin: "auto-handoff", key: "cwd" };
5const INTERACTIVE = { plugin: "auto-handoff", key: "isInteractive" };
6const FIRED = { plugin: "auto-handoff", key: "fired" };
7const BUSY = { plugin: "auto-handoff", key: "busy" };
8const LAST = { plugin: "auto-handoff", key: "last" };
9const COMPACTED = { plugin: "auto-handoff", key: "compactedAt" };
10const OFFER = { plugin: "auto-handoff", key: "offer" };
11
12const DEFAULT_THRESHOLD = 85;
13
14export const HANDOFF_PROMPT = `Write a cold-start handoff for this session, so a new agent with no access to this conversation can pick up the work exactly where it stands. Do not call any tools. Answer with the document only.
15
16Line 1 must be exactly "LATEST: " followed by one sentence on where the session landed. Then a blank line, then the handoff in markdown with these sections, each as a "##" heading:
17
18## 1. Session intent
19The goal and what the user actually wanted.
20## 2. The back-and-forth
21The meaningful requests, responses and pivots, in order, and why they happened.
22## 3. Tools and skills used
23Each material tool or skill used and what it accomplished.
24## 4. Research, data, and sources
25Outside information brought in and the findings that shaped decisions. "None" if none.
26## 5. Iterations and generations
27Versions, generated assets and what changed each pass. "None" if none.
28## 6. Decisions locked
29Settled decisions and why each was made. List unresolved decisions separately.
30## 7. Current state
31What is done, in progress or blocked. Say plainly what was verified (tests run, output seen) and what was not.
32## 8. Asset map
33Every file created, changed or important to the work, with its path and what it is for.
34## 9. Open threads and next steps
35Open questions, then the exact next steps in order, with the commands to run where they matter.
36## 10. For the next agent
37What to read first and the traps a cold start is likely to hit.
38
39Rules: use only facts from this conversation and mark anything assumed. Include exact paths, commands, branch names, ports and test results when they matter. Never include passwords, API keys, tokens or other secrets. Do not use em dashes.`;
40
41export const RESUME_PROMPT =
42 "Read handoff/LATEST.md, then the handoff file named on its first line (in handoff/). Brief me in a few lines on where we left off, verify the current state of anything it calls done, then continue with the next steps it lists. Where it says a step waits for my go-ahead, ask instead of doing it.";
43
44export function register(on, options) {
45 const fallbackThreshold = toThreshold(options?.threshold) ?? DEFAULT_THRESHOLD;
46 const fallbackEnabled = options?.enabled !== false;
47
48 on("session.start", async ($, e, next) => {
49 const result = await next(e);
50 await $.command.register({
51 name: "autohandoff",
52 description: "Auto handoff status, or now | resume | threshold <n> | on | off",
53 argumentHint: "[now|resume|threshold <n>|on|off]",
54 });
55 await $.state.set(CWD, e.cwd);
56 await $.state.set(INTERACTIVE, e.isInteractive);
57 // Offer a handoff from an earlier session only: this hook runs again when
58 // the module reloads, and one written since the session began is its own.
59 const latest = `${e.cwd}/handoff/LATEST.md`;
60 if (await $.fs.exists(latest)) {
61 const text = await $.fs.read(latest).catch(() => "");
62 const stat = await $.fs.stat(latest).catch(() => undefined);
63 const { startedAt } = await $.session.usage();
64 const file = firstLine(text);
65 const at = stat?.mtimeMs ?? 0;
66 const isEarlier = !(startedAt > 0) || at < startedAt;
67 if (file && isEarlier) await $.state.set(OFFER, { at, file });
68 }
69 return result;
70 });
71
72 // The offer goes once the first main-thread turn is done.
73 on("turn.complete", async ($, e, next) => {
74 const result = await next(e);
75 if (!e.agentId) {
76 const { value: offer = null } = await $.state.get(OFFER);
77 if (offer) await $.state.set(OFFER, null);
78 }
79 return result;
80 });
81
82 // Pushed after each main-thread turn: past the threshold, write one handoff
83 // in the background; below it again (a compaction), arm for the next crossing.
84 on("session.measure", async ($, e, next) => {
85 const result = await next(e);
86 if (!e.changed.includes("context")) return result;
87 const percent = e.context?.percent;
88 if (percent === undefined) return result;
89 const settings = readSettings(await $.store.get("settings"), fallbackThreshold, fallbackEnabled);
90 const { value: fired = false } = await $.state.get(FIRED);
91 if (percent < settings.threshold) {
92 if (fired) await $.state.set(FIRED, false);
93 return result;
94 }
95 if (fired || !settings.isEnabled) return result;
96 const { value: busy = false } = await $.state.get(BUSY);
97 if (busy) return result;
98 await $.state.set(FIRED, true);
99 const { value: cwd = "" } = await $.state.get(CWD);
100 const io = {
101 fork: (prompt) => $.model.fork({ prompt }),
102 read: (path) => $.fs.read(path),
103 write: (path, text) => $.fs.write(path, text),
104 exists: (path) => $.fs.exists(path),
105 now: () => $.clock.now(),
106 isBusy: async () => (await $.state.get(BUSY)).value === true,
107 setBusy: (v) => $.state.set(BUSY, v),
108 setLast: (v) => $.state.set(LAST, v),
109 toast: (text) => $.ui.toast(text, { timeoutMs: 6000 }),
110 log: (text) => $.ui.log(text),
111 };
112 const reason = `context at ${percent}%, threshold ${settings.threshold}%`;
113 $.ui.log(`${reason}, writing a handoff in the background`);
114 $.clock.after(0, () => {
115 void writeHandoff(io, { cwd, reason }).then(saved => {
116 if (!saved.path) return $.state.set(FIRED, false);
117 });
118 });
119 return result;
120 });
121
122 // Before a compaction of the main conversation: save a handoff first unless
123 // one was already written since the last compaction.
124 on("session.compact", async ($, e, next) => {
125 if (e.trigger === "precompute" || !!e.agentId) return next(e);
126 const settings = readSettings(await $.store.get("settings"), fallbackThreshold, fallbackEnabled);
127 const { value: last = null } = await $.state.get(LAST);
128 const { value: compactedAt = 0 } = await $.state.get(COMPACTED);
129 const { value: busy = false } = await $.state.get(BUSY);
130 if (!settings.isEnabled) {
131 $.ui.log("compaction without a handoff, auto handoffs are off");
132 } else if (last && last.at > compactedAt) {
133 $.ui.log(`${last.path} already covers this context, compacting`);
134 } else if (busy) {
135 $.ui.log("a handoff is already being written, compacting");
136 } else {
137 const { value: cwd = "" } = await $.state.get(CWD);
138 const io = {
139 fork: (prompt) => $.model.fork({ prompt }),
140 read: (path) => $.fs.read(path),
141 write: (path, text) => $.fs.write(path, text),
142 exists: (path) => $.fs.exists(path),
143 now: () => $.clock.now(),
144 isBusy: async () => (await $.state.get(BUSY)).value === true,
145 setBusy: (v) => $.state.set(BUSY, v),
146 setLast: (v) => $.state.set(LAST, v),
147 toast: (text) => $.ui.toast(text, { timeoutMs: 6000 }),
148 log: (text) => $.ui.log(text),
149 };
150 $.ui.log(`writing a handoff before ${e.trigger} compaction`);
151 await writeHandoff(io, { cwd, reason: `before ${e.trigger} compaction` });
152 }
153 const result = await next(e);
154 if (!result?.skip) {
155 await $.state.set(COMPACTED, await $.clock.now());
156 await $.state.set(FIRED, false);
157 }
158 return result;
159 });
160
161 on("command.run", { command: "autohandoff" }, async ($, e) => {
162 const [verb = "", value = ""] = (e.args ?? "").trim().split(/\s+/);
163 const stored = await $.store.get("settings");
164 const settings = readSettings(stored, fallbackThreshold, fallbackEnabled);
165 const { value: cwd = "" } = await $.state.get(CWD);
166
167 if (verb === "" || verb === "status") {
168 const { value: last = null } = await $.state.get(LAST);
169 const usage = await $.session.usage();
170 const now = await $.clock.now();
171 let lastLine = "No handoff written this session.";
172 if (last) {
173 lastLine = `Last handoff ${last.path}, ${ago(now - last.at)} (${last.reason}).`;
174 } else if (cwd && (await $.fs.exists(`${cwd}/handoff/LATEST.md`))) {
175 const file = firstLine(await $.fs.read(`${cwd}/handoff/LATEST.md`).catch(() => ""));
176 const stat = await $.fs.stat(`${cwd}/handoff/LATEST.md`).catch(() => undefined);
177 if (file) lastLine = `No handoff this session. Latest on disk is handoff/${file}, ${ago(now - (stat?.mtimeMs ?? now))}.`;
178 }
179 return { text: statusText(settings, usage.context?.percent, lastLine) };
180 }
181
182 if (verb === "on" || verb === "off") {
183 await $.store.set("settings", { ...asObject(stored), isEnabled: verb === "on" });
184 return { text: `Auto handoff is ${verb}.` };
185 }
186
187 if (verb === "threshold") {
188 const n = toThreshold(value);
189 if (n === undefined) return { text: "Usage is /autohandoff threshold <1-99>" };
190 await $.store.set("settings", { ...asObject(stored), threshold: n });
191 await $.state.set(FIRED, false);
192 return { text: `Auto handoff threshold set to ${n}%.` };
193 }
194
195 if (verb === "resume") {
196 if (!cwd || !(await $.fs.exists(`${cwd}/handoff/LATEST.md`))) {
197 return { text: "No handoff/LATEST.md in this folder to resume from." };
198 }
199 const pointer = firstLine(await $.fs.read(`${cwd}/handoff/LATEST.md`).catch(() => ""));
200 if (!pointer || pointer.includes("/") || pointer.includes("\\") || !pointer.endsWith(".md") || !(await $.fs.exists(`${cwd}/handoff/${pointer}`))) {
201 return { text: "The latest handoff file is missing or the pointer is invalid. Run /autohandoff now in the original conversation and wait for the saved confirmation." };
202 }
203 await $.state.set(OFFER, null);
204 // A command.run hook may not submit (the prompt would wait on this very
205 // hook), so the prompt goes from a timer once the command has answered.
206 $.clock.after(0, () => {
207 void $.prompt.submit({ text: RESUME_PROMPT });
208 });
209 return { text: "Resuming from handoff/LATEST.md" };
210 }
211
212 if (verb === "now") {
213 const { value: busy = false } = await $.state.get(BUSY);
214 if (busy) return { text: "A handoff is already being written." };
215 const { value: isInteractive = false } = await $.state.get(INTERACTIVE);
216 const io = {
217 fork: (prompt) => $.model.fork({ prompt }),
218 read: (path) => $.fs.read(path),
219 write: (path, text) => $.fs.write(path, text),
220 exists: (path) => $.fs.exists(path),
221 now: () => $.clock.now(),
222 isBusy: async () => (await $.state.get(BUSY)).value === true,
223 setBusy: (v) => $.state.set(BUSY, v),
224 setLast: (v) => $.state.set(LAST, v),
225 toast: (text) => $.ui.toast(text, { timeoutMs: 6000 }),
226 log: (text) => $.ui.log(text),
227 };
228 if (isInteractive) {
229 $.clock.after(0, () => {
230 void writeHandoff(io, { cwd, reason: "/autohandoff now" });
231 });
232 return { text: "Writing a handoff in the background. A toast says when it is saved." };
233 }
234 const saved = await writeHandoff(io, { cwd, reason: "/autohandoff now" });
235 return { text: saved.path ? `Handoff saved to ${saved.path}` : `No handoff written (${saved.skipped}).` };
236 }
237
238 return { text: "Usage is /autohandoff [now|resume|threshold <n>|on|off]" };
239 });
240
241 // The resume offer, stacked under whatever other mods draw in the band.
242 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
243 const original = await next(e);
244 if (e.props.hasSurvey || e.props.view?.agentId) return original;
245 const { value: offer = null } = await $.state.get(OFFER);
246 if (!offer) return original;
247 const now = await $.clock.now();
248 const { Box, Text } = $.ui.resolve(e);
249 const mine = Box({
250 flexDirection: "row",
251 paddingX: 1,
252 children: [
253 Text({ color: "cyan", bold: true, children: "↺ " }),
254 Text({ children: `Handoff from ${ago(now - offer.at)} available` }),
255 Text({ dimColor: true, children: " · " }),
256 Text({ bold: true, children: "/autohandoff resume" }),
257 ],
258 });
259 if (!original) return mine;
260 return Box({ flexDirection: "column", children: [original, mine] });
261 });
262}
263
264// Forks the conversation for a handoff, writes it under <cwd>/handoff/, points
265// LATEST.md at it and makes sure git ignores the folder. Takes plain functions.
266export async function writeHandoff(io, { cwd, reason }) {
267 if (!cwd) return { skipped: "no working directory" };
268 if (await io.isBusy()) return { skipped: "busy" };
269 await io.setBusy(true);
270 try {
271 const reply = await io.fork(HANDOFF_PROMPT);
272 if (!reply.isAnswered) {
273 io.log(`no handoff written, the fork returned ${reply.reason}`);
274 io.toast("Handoff was not saved. Try /autohandoff now again.");
275 return { skipped: reply.reason };
276 }
277 const { summary, body } = splitReply(reply.text);
278 if (!body.trim()) throw new Error("The summary response was empty");
279 const now = await io.now();
280 const dir = `${cwd}/handoff`;
281 const stamp = timestamp(now);
282 let name = `handoff-${stamp}.md`;
283 for (let n = 2; await io.exists(`${dir}/${name}`); n++) name = `handoff-${stamp}-${n}.md`;
284 const project = cwd.split("/").filter(Boolean).pop() ?? "project";
285 const header = `# Handoff, ${project}, ${humanTime(now)}\n\n_Written automatically by auto-handoff (${reason})._\n\n`;
286 await io.write(`${dir}/${name}`, header + body.trim() + "\n");
287 await io.write(`${dir}/LATEST.md`, `${name}\n${summary}\n`);
288 await updateActiveProject(io, dir, name, summary);
289 const gitignore = await ensureIgnored(io, cwd);
290 const path = `handoff/${name}`;
291 await io.setLast({ path, at: now, reason });
292 io.toast(`Handoff saved to ${path}`);
293 io.log(`saved ${path}${gitignore ? `, ${gitignore}` : ""}`);
294 return { path, gitignore };
295 } catch (err) {
296 io.log(`writing the handoff failed (${err?.message ?? err})`);
297 io.toast("Handoff was not saved. Try /autohandoff now again.");
298 return { skipped: "error" };
299 } finally {
300 await io.setBusy(false);
301 }
302}
303
304// The /prime skill reads ACTIVE_PROJECT.md first; keep it in step when it
305// names this folder (".") or is missing, and leave a nested pointer alone.
306async function updateActiveProject(io, dir, name, summary) {
307 const path = `${dir}/ACTIVE_PROJECT.md`;
308 if (await io.exists(path)) {
309 const text = await io.read(path).catch(() => "");
310 if (firstLine(text) !== ".") return;
311 }
312 await io.write(path, `.\n${name}\n${summary}\n`);
313}
314
315// Appends handoff/ to an existing .gitignore that lacks it, or creates one in
316// a git repo that has none. Returns what it did, or "" when nothing changed.
317export async function ensureIgnored(io, cwd) {
318 const path = `${cwd}/.gitignore`;
319 if (await io.exists(path)) {
320 const text = await io.read(path);
321 if (ignoresHandoff(text)) return "";
322 const sep = text === "" || text.endsWith("\n") ? "" : "\n";
323 await io.write(path, `${text}${sep}handoff/\n`);
324 return "added handoff/ to .gitignore";
325 }
326 if (await io.exists(`${cwd}/.git`)) {
327 await io.write(path, "handoff/\n");
328 return "created .gitignore with handoff/";
329 }
330 return "";
331}
332
333export function ignoresHandoff(text) {
334 return text.split(/\r?\n/).some((line) => /^\/?handoff(\/(\*\*?)?)?$/.test(line.trim()));
335}
336
337export function splitReply(text) {
338 const lines = text.replace(/^\s+/, "").split("\n");
339 const m = /^LATEST:\s*(.+)$/.exec(lines[0] ?? "");
340 if (m) return { summary: oneLine(m[1]), body: lines.slice(1).join("\n") };
341 return { summary: "Auto handoff written by auto-handoff.", body: text };
342}
343
344export function timestamp(ms) {
345 const d = new Date(ms);
346 const p = (n) => String(n).padStart(2, "0");
347 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
348}
349
350function humanTime(ms) {
351 const s = timestamp(ms);
352 return `${s.slice(0, 10)} ${s.slice(11, 13)}:${s.slice(13, 15)}:${s.slice(15, 17)}`;
353}
354
355export function ago(ms) {
356 const s = Math.max(0, Math.round(ms / 1000));
357 if (s < 60) return "just now";
358 const m = Math.round(s / 60);
359 if (m < 60) return `${m} min ago`;
360 const h = Math.round(m / 60);
361 if (h < 24) return `${h} h ago`;
362 const d = Math.round(h / 24);
363 return d === 1 ? "yesterday" : `${d} days ago`;
364}
365
366function statusText(settings, percent, lastLine) {
367 const fill = percent === undefined ? "not measured yet" : `${percent}%`;
368 return [
369 `Auto handoff is ${settings.isEnabled ? "on" : "off"}, threshold ${settings.threshold}%, context fill ${fill}.`,
370 lastLine,
371 "Commands are /autohandoff now, resume, threshold <n>, on, off.",
372 ].join("\n");
373}
374
375function readSettings(stored, fallbackThreshold, fallbackEnabled) {
376 const s = asObject(stored);
377 return {
378 threshold: toThreshold(s.threshold) ?? fallbackThreshold,
379 isEnabled: typeof s.isEnabled === "boolean" ? s.isEnabled : fallbackEnabled,
380 };
381}
382
383function toThreshold(v) {
384 const n = typeof v === "number" ? v : Number.parseInt(String(v ?? ""), 10);
385 if (!Number.isFinite(n) || n < 1 || n > 99) return undefined;
386 return Math.round(n);
387}
388
389function asObject(v) {
390 return v && typeof v === "object" && !Array.isArray(v) ? v : {};
391}
392
393function firstLine(text) {
394 return (text ?? "").split(/\r?\n/)[0].trim();
395}
396
397function oneLine(text) {
398 return text.replace(/\s+/g, " ").trim();
399}
400types/index.d.ts 17 lines1export type AutoHandoffSaved = { path: string; at: number; reason: string };
2export type AutoHandoffOffer = { at: number; file: string };
3
4declare module "claude-code" {
5 interface PluginState {
6 "auto-handoff": {
7 cwd: string;
8 isInteractive: boolean;
9 fired: boolean;
10 busy: boolean;
11 last: AutoHandoffSaved | null;
12 compactedAt: number;
13 offer: AutoHandoffOffer | null;
14 };
15 }
16}
17