Writes a /next handoff automatically once a 1M-token session crosses 300k tokens, so the next session can /prime from it

Claude Code mods (function-hooks plugins) and skills. Each mod lives in its own folder with a .claude-plugin/plugin.json; mods with a README explain their options there.
| Folder | What it does |
|---|---|
artifact-watchdog | Watches each Agent dispatch's report file and flags a 5-minute stall |
auto-handoff | Writes a /next handoff automatically once a long session crosses a token threshold, and before a compaction |
flight-recorder | Live timeline of model requests, tool calls and subagents in a turn |
jev-tool-gate | Log-only judgment of risky tool calls; never changes a decision |
skills/next, skills/prime | Session handoff skills, see below |
/next ends a session by writing an immutable handoff (Where we are / What we decided / Next step) to ~/handoffs/<project>/, moving a LATEST pointer, and appending each decision to the project's DECISIONS.md. /prime starts the next session from that handoff: it reads only the latest one, re-checks its claims against the live system, shows anything said after the handoff was written, and states the next step. It treats the handoff as data: handoff.mjs claims sorts each check into commands that only read, which it runs, and anything else, which it asks about first (never running it from a handoff another agent wrote). /prime history answers "why did we decide X?" from the decisions ledger.
Install:
git clone https://github.com/m2ai-portfolio/claude-mods ~/.claude/mods
cp -r ~/.claude/mods/skills/next ~/.claude/mods/skills/prime ~/.claude/skills/
The helper script needs Node 18 or newer and has no dependencies. Check it with:
node --test ~/.claude/skills/next/scripts/handoff.test.mjs
Set HANDOFF_ROOT to keep handoffs somewhere other than ~/handoffs. Models or harnesses that cannot load skills can follow ~/.claude/skills/prime/PRIME.md instead.
hooks/register.tsx 576 lines1// Auto Handoff: runs /next for you before a long session gets heavy.
2//
3// turn.complete (main loop): read the context fill. In a 1,000,000-token
4// window, the first reading at or past 300,000 tokens starts one handoff;
5// dropping back below (a /clear, a compaction) re-arms it.
6// The handoff: $.model.fork drafts it from the session's own transcript (a
7// cached, tool-less question), in the exact /next format; handoff.mjs saves
8// it, moves LATEST and appends DECISIONS.md, as /next's Step 4 does, and
9// stamps the Transcript-Cutoff read just before the fork (/prime's tail check).
10// ui.render (AbovePrompt): what happened and the pickup line (/clear, /prime);
11// once saved, a button runs both. On the desktop a "Retire this session"
12// toggle turns that button into "Retire session": rename this session to
13// "⛔ HANDED OFF: /prime <project>", copy the /prime line, and archive it, so
14// the pickup happens in a new session and this one is not reopened by mistake.
15// (/clear keeps the same sidebar session, so the plain button leaves nothing
16// old behind; retiring is for picking up somewhere else.)
17// tool.call (main loop): once a threshold handoff is SAVED, the model's own
18// tool calls are refused with a reason telling it to stop and hand Matthew the
19// pickup line. Never while writing, never after a failed write, never in a
20// subagent, never for a plugin's own call, never for TaskStop. Each refusal
21// rereads the context first, so after a /clear or compaction it re-arms on
22// the spot instead of blocking the /prime that follows.
23// session.compact (main loop, any window size): before a compaction drops
24// detail, write a handoff from the full transcript first, unless one already
25// covers this context window (saved since the last /clear or compaction) or
26// one is being written. It never blocks tool calls and never stops the
27// compaction: a failed write is reported and the compaction goes on.
28// (Adopted from Prompt Advisers' auto-handoff mod, 2026-10-08.)
29// /auto-handoff: status; "now" writes one immediately; "unblock" lifts the
30// block for the rest of this window.
31
32import { atom, read, update } from 'claude-code'
33import type { EngineInterface, Register, RenderSurface } from 'claude-code'
34
35import type { HandoffRun } from '../types'
36
37const WINDOW = 1_000_000
38const THRESHOLD = 300_000
39// What the model may still call while blocked. TaskStop: a background agent
40// left running would keep spending, and the artifact-watchdog's stall wake
41// asks for exactly this call. Nothing else is needed to wrap up: the answer
42// that tells Matthew to clear is plain text, and the handoff is already saved.
43const WRAP_UP_TOOLS: ReadonlySet<string> = new Set(['TaskStop'])
44// The desktop app's session tools, reached with the engine's own connection.
45const SESSIONS = 'ccd_session_mgmt'
46
47const isArmed = atom({ plugin: 'auto-handoff', key: 'isArmed' } as const, true)
48const crossings = atom({ plugin: 'auto-handoff', key: 'crossings' } as const, 0)
49const last = atom({ plugin: 'auto-handoff', key: 'last' } as const, null)
50const isDismissed = atom({ plugin: 'auto-handoff', key: 'isDismissed' } as const, false)
51const isBlocked = atom({ plugin: 'auto-handoff', key: 'isBlocked' } as const, false)
52const isLifted = atom({ plugin: 'auto-handoff', key: 'isLifted' } as const, false)
53const isRetiring = atom({ plugin: 'auto-handoff', key: 'isRetiring' } as const, false)
54const isCovered = atom({ plugin: 'auto-handoff', key: 'isCovered' } as const, false)
55
56let isWriting = false
57// A retire in flight: a second press must not rename or archive twice.
58let isRetiringNow = false
59
60export const register: Register = on => {
61 on('session.start', async ($, e, next) => {
62 const result = await next(e)
63 await $.command.register({
64 name: 'auto-handoff',
65 description: 'Auto-handoff status; "now" writes a /next handoff immediately; "unblock" lifts the tool-call block',
66 })
67 return result
68 })
69
70 on('turn.complete', async ($, e, next) => {
71 const result = await next(e)
72 if (e.agentId) {
73 return result
74 }
75 const { context } = await $.session.usage()
76 const tokens = context.tokens ?? 0
77 if (context.window !== WINDOW) {
78 return result
79 }
80 if (tokens < THRESHOLD) {
81 await rearm($)
82 return result
83 }
84 const { value: armed = true } = await $.state.get({ plugin: 'auto-handoff', key: 'isArmed' } as const)
85 if (armed && !isWriting) {
86 await update($, isArmed, () => false)
87 // Outside this dispatch: the fork must not die with the turn's hook.
88 $.clock.after(1, () => {
89 void writeHandoff($, tokens, true)
90 })
91 }
92 return result
93 })
94
95 on('session.compact', async ($, e, next) => {
96 if (e.trigger === 'precompute' || e.agentId !== undefined) {
97 return next(e)
98 }
99 const { context } = await $.session.usage()
100 if (!isWriting && context.tokens !== undefined && !(await read($, isCovered))) {
101 await writeHandoff($, context.tokens, false, `the conversation is about to be compacted (${e.trigger})`)
102 }
103 const result = await next(e)
104 if (!('skip' in result && result.skip)) {
105 await update($, isCovered, () => false)
106 }
107 return result
108 })
109
110 // A /clear ends this conversation (the process goes on): what was saved
111 // covers the old window, not the new one.
112 on('session.end', async ($, e, next) => {
113 if (e.reason === 'clear') {
114 await update($, isCovered, () => false)
115 }
116 return next(e)
117 })
118
119 on('tool.call', async ($, e, next) => {
120 // A subagent's call, a plugin's own (the watchdog's Stop button), or a
121 // wrap-up tool: never the block's business.
122 if (e.agentId !== undefined || next.origin.plugin !== 'engine' || WRAP_UP_TOOLS.has(e.tool)) {
123 return next(e)
124 }
125 const reason = await blockReason($)
126
127 return reason === null ? next(e) : { deny: reason }
128 })
129
130 on('command.run', { command: 'auto-handoff' }, async ($, e) => {
131 if (e.args.trim() === 'unblock') {
132 const wasBlocked = await read($, isBlocked)
133 await update($, isBlocked, () => false)
134 await update($, isLifted, () => true)
135 return {
136 text: wasBlocked
137 ? 'Tool calls unblocked for the rest of this context window. The block re-arms once the context drops below the threshold (a /clear or a compaction) and crosses it again.'
138 : 'Tool calls were not blocked. No block will be set for the rest of this context window.',
139 }
140 }
141 if (e.args.trim() === 'now') {
142 if (isWriting) {
143 return { text: 'An auto-handoff is already being written.' }
144 }
145 const { context } = await $.session.usage()
146 // No tokens means no response yet in this window (a fresh chat, or
147 // right after /clear or a compaction); the fork would only answer
148 // nothing-to-fork, so refuse instead of starting a write that fails.
149 if (context.tokens === undefined) {
150 return { text: 'Nothing to hand off yet: send a message first, then run /auto-handoff now.' }
151 }
152 const { tokens } = context
153 $.clock.after(1, () => {
154 void writeHandoff($, tokens, false)
155 })
156 return { text: 'Writing a handoff now. The band above the prompt shows when it is saved.' }
157 }
158 const { context } = await $.session.usage()
159 const { value: run } = await $.state.get({ plugin: 'auto-handoff', key: 'last' } as const)
160 const tokens = context.tokens ?? 0
161 const lines = [
162 `Context: ${short(tokens)} of ${short(context.window)}.`,
163 context.window === WINDOW
164 ? `Auto-handoff fires at ${short(THRESHOLD)}.`
165 : `Auto-handoff is off for this session: it applies only to a ${short(WINDOW)} window.`,
166 run ? `Last: ${run.status}${run.path ? ` ${run.path}` : ''}${run.detail ? ` (${run.detail})` : ''}` : 'Last: none yet.',
167 (await read($, isCovered))
168 ? 'Before a compaction: no new handoff, the last one covers this window.'
169 : 'Before a compaction: writes a handoff first.',
170 (await read($, isBlocked))
171 ? 'Tool calls: BLOCKED (/auto-handoff unblock lifts it).'
172 : (await read($, isLifted))
173 ? 'Tool calls: unblocked for this window.'
174 : 'Tool calls: not blocked.',
175 ]
176 return { text: lines.join('\n') }
177 })
178
179 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
180 const run = await read($, last)
181 const blocked = await read($, isBlocked)
182 // A block outlives Dismiss: the band is where Matthew learns why tools stopped.
183 if (e.props.hasSurvey || run === null || ((await read($, isDismissed)) && !blocked)) {
184 return next(e)
185 }
186 const { Box, Text, Button } = $.ui.resolve(e)
187 const dismiss = (
188 <Button key="dismiss" label="Dismiss" plain onPress={() => update($, isDismissed, () => true)} />
189 )
190 if (run.status === 'writing') {
191 return (
192 <Box flexDirection="row" paddingX={1}>
193 <Text color="yellow">… Writing auto-handoff at {short(run.tokens)} tokens</Text>
194 </Box>
195 )
196 }
197 if (run.status === 'failed') {
198 return (
199 <Box flexDirection="row" paddingX={1}>
200 <Text color="red">✗ Auto-handoff failed: {run.detail}. Run /next yourself. </Text>
201 {dismiss}
202 </Box>
203 )
204 }
205 // Only the desktop app has a sidebar session to rename and archive.
206 const canRetire = e.surface === 'desktop'
207 const retiring = canRetire && (await read($, isRetiring))
208 return (
209 <Box flexDirection="column" paddingX={1}>
210 <Box flexDirection="row">
211 <Text color="green" bold>
212 ✓ Handoff saved at {short(run.tokens)} tokens{' '}
213 </Text>
214 <Text dimColor>{run.path} </Text>
215 {!blocked && dismiss}
216 </Box>
217 <Box flexDirection="row">
218 {retiring ? (
219 <Text>To continue in a new session: retire this one, then /prime {run.project} there </Text>
220 ) : (
221 <Text>To continue fresh: /clear, then /prime {run.project} </Text>
222 )}
223 <Button
224 key="pickup"
225 label={retiring ? 'Retire session' : `Clear and /prime ${run.project}`}
226 variant="primary"
227 onPress={press => (retiring ? retire($, run.project, press.surface) : clearAndPrime($, run.project))}
228 />
229 {canRetire && (
230 <Button
231 key="retire"
232 label={`${retiring ? '[x]' : '[ ]'} Retire this session`}
233 plain
234 onPress={() => update($, isRetiring, v => !v)}
235 />
236 )}
237 </Box>
238 {blocked && (
239 <Box flexDirection="row">
240 <Text color="yellow" bold>
241 ■ Claude's tool calls are blocked until you clear. /auto-handoff unblock continues here.
242 </Text>
243 </Box>
244 )}
245 </Box>
246 )
247 })
248}
249
250// The fork's instructions: /next Steps 2-3, with the facts a tool-less
251// completion cannot look up handed to it.
252function forkPrompt(facts: {
253 why: string
254 now: string
255 cwd: string
256 project: string | null
257 projects: string
258 sessionId: string
259 model: string
260 tokens: number
261 key: string
262 git: string
263 previous: string
264}): string {
265 return [
266 `[auto-handoff] ${facts.why} (${facts.tokens} tokens).`,
267 'Write the /next handoff for this session now. You have no tools: do not try to run anything,',
268 'and do not do any of the remaining work. Use only this conversation and the facts below.',
269 '',
270 'FACTS',
271 `Date: ${facts.now}`,
272 `Working directory: ${facts.cwd}`,
273 facts.project
274 ? `Project: ${facts.project}`
275 : `Project: undetermined. Pick the existing project this session was about from this list (name, latest handoff), or coin a short kebab-case name:\n${facts.projects}`,
276 `Harness: Claude Code, model ${facts.model}. Session: ${facts.sessionId}`,
277 `git status --short / git diff --stat:\n${facts.git || '(not a git repository, or clean)'}`,
278 facts.previous
279 ? `Previous handoff for this project (do not repeat its settled decisions):\n${facts.previous}`
280 : 'Previous handoff: none.',
281 '',
282 'OUTPUT: exactly these two lines, then the handoff markdown, nothing else:',
283 'PROJECT: <project, kebab-case>',
284 'SLUG: <2-4 word kebab-case slug>',
285 '',
286 '# Handoff: <one-line outcome or active objective>',
287 'Date: <the date above>',
288 'Project: <project>',
289 'Working directory: <absolute path>',
290 'Harness: <harness and model>. Session: <id>',
291 `Attempt-Key: ${facts.key}`,
292 '',
293 '## Where we are',
294 '<What is done and verified, and the current state: uncommitted changes, running processes, partial work.',
295 'Name the checks that ran (command and result) and the checks that did NOT run; never call an unrun test passed.>',
296 '',
297 '## What we decided',
298 '- <Decision. Why: reason.> (one standalone bullet per decision made THIS session, or exactly "- None" with',
299 ' nothing after it; an offer still waiting for approval goes under Needs you, not here)',
300 '- Rejected: <a direction turned down that a later session might retry>. Why: <reason>.',
301 '',
302 '## Next step',
303 '<The single first concrete action for the next session, and why it is next.>',
304 '',
305 '## Remaining',
306 '### Ready',
307 '### Needs you',
308 '### Later',
309 '',
310 '## Claims to verify',
311 '- <A fact the next session depends on> : `<read-only command that checks it>` (3 to 6 items;',
312 ' plain read-only programs such as git, grep, jq, ls: /prime runs those, and asks before anything else)',
313 '',
314 '## References',
315 '<Absolute paths, commits, issue IDs, plans. Point to detail; do not copy it.>',
316 '',
317 'RULES: under 500 words. Mark anything unverified as unverified; distinguish what was checked,',
318 'what Matthew said, and what is assumed. Approvals do not carry over: list what was granted this',
319 'session and what still needs approval. No secrets or tokens (write [REDACTED]). No em dashes.',
320 ].join('\n')
321}
322
323async function exec($: EngineInterface, argv: string[], cwd?: string): Promise<{ ok: boolean; out: string }> {
324 try {
325 const r = await $.process.run(argv, cwd ? { cwd } : undefined)
326 return { ok: r.exitCode === 0, out: (r.exitCode === 0 ? r.stdout : r.stderr || r.stdout).trim() }
327 } catch (err) {
328 return { ok: false, out: String(err) }
329 }
330}
331
332async function setRun($: EngineInterface, value: HandoffRun): Promise<void> {
333 await update($, last, () => value)
334 await update($, isDismissed, () => false)
335}
336
337// Below the threshold again (a /clear, a compaction): the next crossing may
338// write and block again, and an unblock no longer carries.
339async function rearm($: EngineInterface): Promise<void> {
340 await update($, isArmed, () => true)
341 await update($, isBlocked, () => false)
342 await update($, isLifted, () => false)
343}
344
345// Why a main-loop tool call is refused, or null to let it run. The block
346// stands only on a saved threshold handoff in a window still past the
347// threshold; the context is reread here because /clear raises no turn end
348// before /prime's first tool call. Any error lets the call run: a broken gate
349// must not wedge the session.
350async function blockReason($: EngineInterface): Promise<string | null> {
351 try {
352 if (!(await read($, isBlocked))) {
353 return null
354 }
355 const { context } = await $.session.usage()
356 const tokens = context.tokens ?? 0
357 if (context.window !== WINDOW || tokens < THRESHOLD) {
358 await rearm($)
359 return null
360 }
361 const run = await read($, last)
362 if (run === null || run.status !== 'saved') {
363 return null
364 }
365 return (
366 `[auto-handoff] Context is at ${short(tokens)} tokens; a handoff was saved to ${run.path}. ` +
367 'Make no more tool calls. Stop and tell Matthew to press "Clear and /prime ' +
368 `${run.project}" above the prompt (or run /clear, then /prime ${run.project}), ` +
369 'or to run /auto-handoff unblock to continue in this session.'
370 )
371 } catch {
372 return null
373 }
374}
375
376// isThreshold: started by crossing the threshold, so a save blocks tool
377// calls. "/auto-handoff now" and a compaction pass false: those never block.
378// why: the fork's first line, what prompted this handoff.
379async function writeHandoff(
380 $: EngineInterface,
381 tokens: number,
382 isThreshold: boolean,
383 why = 'This session has reached its handoff point in a 1,000,000-token window',
384): Promise<void> {
385 if (isWriting) {
386 return
387 }
388 isWriting = true
389 const fail = async (detail: string, project = '') => {
390 await setRun($, { status: 'failed', tokens, project, path: '', detail })
391 $.ui.toast(`Auto-handoff failed: ${detail}`, { timeoutMs: 10_000 })
392 }
393 try {
394 await setRun($, { status: 'writing', tokens, project: '', path: '', detail: '' })
395 const home = (await $.env.get('HOME')) ?? '/home/apexaipc'
396 const script = `${home}/.claude/skills/next/scripts/handoff.mjs`
397 const cwd = await $.session.cwd()
398 const sessionId = await $.session.id()
399 const crossing = await update($, crossings, n => n + 1)
400 const key = `auto-${sessionId.slice(0, 8)}-${crossing}`
401
402 // /next Step 1: the git root's name, or refused in the home folder.
403 const resolved = await exec($, ['node', script, 'project'], cwd)
404 const projects = resolved.ok ? '' : (await exec($, ['node', script, 'list'], cwd)).out
405
406 // /next Step 2: git state and the previous handoff.
407 const status = await exec($, ['git', '-C', cwd, 'status', '--short'])
408 const stat = await exec($, ['git', '-C', cwd, 'diff', '--stat'])
409 const git = status.ok ? `${status.out}\n${stat.out}`.trim() : ''
410 let previous = ''
411 if (resolved.ok) {
412 const latest = await exec($, ['node', script, 'latest', '--project', resolved.out], cwd)
413 if (latest.ok && latest.out) {
414 previous = ((await $.fs.read(latest.out).catch(() => '')) as string).slice(0, 4000)
415 }
416 }
417
418 // /next Step 3: the draft, from the session's own context.
419 const prompt = forkPrompt({
420 why,
421 now: (await exec($, ['date', '+%Y-%m-%d %H:%M %Z'])).out,
422 cwd,
423 project: resolved.ok ? resolved.out : null,
424 projects,
425 sessionId,
426 model: await $.session.model(),
427 tokens,
428 key,
429 git,
430 previous,
431 })
432 // The Transcript-Cutoff: the last transcript entry the fork replays, read just
433 // before it starts. The session keeps talking while the fork drafts (2026-10-07:
434 // a decision 16 s later never reached the handoff); /prime's tail check reads
435 // everything after this entry. Unreadable: "none", and /prime falls back to Date.
436 const cut = await exec($, ['node', script, 'cutoff', '--session', sessionId, '--cwd', cwd, '--fork'], cwd)
437 const cutoff = cut.ok && cut.out ? cut.out : 'none'
438 const reply = await $.model.fork({ prompt })
439 if (!reply.isAnswered) {
440 await fail(`the fork returned no draft (${reply.reason})`)
441 return
442 }
443 const parsed = parseReply(reply.text, key)
444 if ('error' in parsed) {
445 await fail(parsed.error)
446 return
447 }
448 const project = resolved.ok ? resolved.out : parsed.project
449
450 // /next Step 4: handoff.mjs owns LATEST, Supersedes and DECISIONS.md.
451 const draft = `${home}/.claude/auto-handoff/drafts/${key}.md`
452 await $.fs.write(draft, parsed.markdown)
453 const saved = await exec($, [
454 'node', script, 'save', '--slug', parsed.slug, '--file', draft, '--project', project, '--key', key,
455 '--cutoff', cutoff,
456 ], cwd)
457 if (!saved.ok) {
458 await fail(`handoff.mjs refused the draft: ${saved.out.slice(0, 160)}`, project)
459 return
460 }
461 await setRun($, { status: 'saved', tokens, project, path: saved.out, detail: '' })
462 await update($, isCovered, () => true)
463 if (isThreshold && !(await read($, isLifted))) {
464 await update($, isBlocked, () => true)
465 }
466 $.ui.toast(`Handoff saved: ${saved.out}. Next: /clear, then /prime ${project}`, { timeoutMs: 15_000 })
467 } catch (err) {
468 await fail(String(err).slice(0, 160))
469 } finally {
470 isWriting = false
471 }
472}
473
474// The pickup line in one press. /clear ends the conversation but not this
475// module (no session.start follows it), so /prime still runs from here.
476// Dismissed first, so a second press cannot clear twice.
477async function clearAndPrime($: EngineInterface, project: string): Promise<void> {
478 await update($, isDismissed, () => true)
479 try {
480 await $.command.run({ command: 'clear' })
481 } catch (err) {
482 await update($, isDismissed, () => false)
483 $.ui.toast(`/clear did not run (${String(err).slice(0, 120)}). Run /clear, then /prime ${project}`, {
484 timeoutMs: 15_000,
485 })
486 return
487 }
488 // The window is fresh: /prime must be free to read and verify.
489 await update($, isBlocked, () => false)
490 try {
491 await $.command.run({ command: 'prime', args: project })
492 } catch (err) {
493 $.ui.toast(`Cleared, but /prime did not run (${String(err).slice(0, 120)}). Run /prime ${project}`, {
494 timeoutMs: 15_000,
495 })
496 }
497}
498
499// Retire: mark this session so it is not reopened, then archive it; the pickup
500// happens in a new session. Rename first, so a declined or failed archive
501// still leaves the session labelled. The archive ends the conversation, so
502// nothing after a successful one runs here.
503async function retire($: EngineInterface, project: string, surface: RenderSurface): Promise<void> {
504 if (isRetiringNow) {
505 return
506 }
507 isRetiringNow = true
508 try {
509 const pickup = `/prime ${project}`
510 const renamed = await sessions($, 'set_session_title', { session_id: 'self', title: `⛔ HANDED OFF: ${pickup}` })
511 const copied = await $.ui.copy({ text: pickup, surface }).catch(() => ({ isCopied: false }))
512 const archived = await sessions($, 'archive_session', {
513 session_id: 'self',
514 reason: `Auto-handoff saved; continue with ${pickup} in a new session`,
515 })
516 if (archived.ok) {
517 $.ui.toast(`Session retired. In a new session run ${pickup}${copied.isCopied ? ' (copied)' : ''}`, { timeoutMs: 15_000 })
518 return
519 }
520 $.ui.toast(
521 `${renamed.ok ? 'Renamed' : `Rename failed (${renamed.detail.slice(0, 80)})`}; archive did not happen ` +
522 `(${archived.detail.slice(0, 120)}). Archive it from the sidebar, then ${pickup} in a new session.`,
523 { timeoutMs: 20_000 },
524 )
525 } finally {
526 isRetiringNow = false
527 }
528}
529
530async function sessions(
531 $: EngineInterface,
532 tool: string,
533 args: Record<string, unknown>,
534): Promise<{ ok: boolean; detail: string }> {
535 try {
536 const r = await $.mcp.call(SESSIONS, tool, args)
537 const detail = r.content.map(b => b.text ?? '').join(' ').trim()
538 return { ok: !r.isError, detail: detail || (r.isError ? 'no reason given' : '') }
539 } catch (err) {
540 return { ok: false, detail: String(err) }
541 }
542}
543
544function parseReply(
545 text: string,
546 key: string,
547): { project: string; slug: string; markdown: string } | { error: string } {
548 const project = /^PROJECT:\s*([a-z0-9][a-z0-9-]*)\s*$/m.exec(text)?.[1]
549 const slug = /^SLUG:\s*([a-z0-9][a-z0-9-]*)\s*$/m.exec(text)?.[1]
550 const start = text.search(/^# Handoff:/m)
551 if (!project || !slug || start < 0) {
552 return { error: 'the draft is missing its PROJECT, SLUG or "# Handoff:" line' }
553 }
554 let markdown = text
555 .slice(start)
556 .replace(/\s*—\s*/g, ', ')
557 .replace(/–/g, '-')
558 .replace(/^```\w*\s*$/gm, '')
559 .trim()
560 if (!markdown.includes(`Attempt-Key: ${key}`)) {
561 markdown = markdown.replace(/^(# Handoff:.*)$/m, `$1\nAttempt-Key: ${key}`)
562 }
563 for (const heading of ['## Where we are', '## What we decided', '## Next step']) {
564 if (!markdown.includes(heading)) {
565 return { error: `the draft is missing "${heading}"` }
566 }
567 }
568 return { project, slug, markdown: `${markdown}\n` }
569}
570
571function short(n: number): string {
572 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
573 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
574 return String(n)
575}
576types/index.d.ts 37 lines1// writing: the fork is drafting. saved: handoff.mjs stored it. failed: see detail.
2export type HandoffStatus = 'writing' | 'saved' | 'failed'
3
4export type HandoffRun = {
5 status: HandoffStatus
6 tokens: number
7 project: string
8 path: string
9 detail: string
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'auto-handoff': {
15 // True until the context crosses the threshold; re-armed when it drops
16 // back below (after /clear or a compaction).
17 isArmed: boolean
18 // How many times this session has crossed; part of the idempotency key.
19 crossings: number
20 last: HandoffRun | null
21 isDismissed: boolean
22 // True once a threshold handoff is saved: the model's main-loop tool
23 // calls are refused until the context drops below the threshold again.
24 isBlocked: boolean
25 // /auto-handoff unblock: no block for the rest of this window, even if
26 // a handoff still being written saves after the command.
27 isLifted: boolean
28 // The band's "Retire this session" toggle: the pickup button renames and
29 // archives this session instead of clearing it (desktop only).
30 isRetiring: boolean
31 // A handoff saved since the last /clear or compaction: a compaction
32 // then goes ahead without writing another.
33 isCovered: boolean
34 }
35 }
36}
37