SLOPSHOPPER

auto-handoff

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

newbandguardcommandtoastmodel
v0.1.0no licenseupdated 2026-10-08m2ai-portfolio/claude-mods/auto-handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-handoff
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /auto-handoff ⎿ auto-handoff: Context: 97k of 200k. ⎿ auto-handoff: Auto-handoff is off for this session: it applies only to a 1M window. ⎿ auto-handoff: Last: none yet. ⎿ auto-handoff: Before a compaction: writes a handoff first. ⎿ auto-handoff: Tool calls: not blocked. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

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.

FolderWhat it does
artifact-watchdogWatches each Agent dispatch's report file and flags a 5-minute stall
auto-handoffWrites a /next handoff automatically once a long session crosses a token threshold, and before a compaction
flight-recorderLive timeline of model requests, tool calls and subagents in a turn
jev-tool-gateLog-only judgment of risky tool calls; never changes a decision
skills/next, skills/primeSession handoff skills, see below

Skills: /next and /prime

/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.

Source 2 files
hooks/register.tsx 576 lines
1// 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}
576
types/index.d.ts 37 lines
1// 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