SLOPSHOPPER

auto-handoff

Hands the work to a fresh, named session when the context gets too full, using your /handoff command

newbandtoastprompttimer
★ 1v0.2.2MITupdated 2026-10-08Spardutti/claude-mods/plugins/auto-handoff
A shopper browsing a rack in a slop shop
README

claude-mods

Little mods for Claude Code: panels that keep the stuff you'd normally go check next to the chat, and helpers that do the chores for you.

Claude Code 2.1.287+ mods: 4 License: MIT

Why

You spend all day in Claude Code. But the small stuff keeps pulling you out of it: checking git, rescuing a session that got too long, doing the same cleanup by hand.

These mods put that stuff inside Claude Code. Some draw a live panel next to the chat. Some do a chore for you at the right moment. You keep your eyes on the work.

Each one installs on its own, so you take only what you want.

Get started

Add the marketplace once, then install a mod:

/plugin marketplace add spardutti/claude-mods
/plugin install branch-status@spardutti-mods
/plugin install auto-handoff@spardutti-mods
/plugin install handoff-pickup@spardutti-mods
/plugin install blocked-run@spardutti-mods

Install any you like. Each one starts working on the next session start.

The mods

Each mod has its own page with the full story.

ModWhat it does
branch-statusA panel with your branch, develop and main as a tiny git-flow graph, so you see what's waiting to ship without running git log.
auto-handoffWhen the context gets too full, it warns you, counts down, then hands the work to a fresh session.
handoff-pickupReminds you of a handoff you left behind. /pickup picks it up, /pickup done deletes it.
blocked-runWhen auto mode blocks a command, it shows above the prompt with a button to run it yourself.
/plugin marketplace update spardutti-mods
/plugin update branch-status@spardutti-mods
/plugin update auto-handoff@spardutti-mods
/plugin update handoff-pickup@spardutti-mods
/plugin update blocked-run@spardutti-mods
/reload-plugins
Source 3 files
hooks/register.tsx 124 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInputOf, RenderSurface, Timer } from 'claude-code'
3
4import type { Notice } from '../types'
5import { handoffPath, sessionName } from './handoff'
6
7type Phase = 'idle' | 'writing' | 'busy'
8type BandInput = { [S in RenderSurface]: RenderInputOf<'AbovePrompt', S> }[RenderSurface]
9
10const WARN_POINTS = 5
11const COUNTDOWN_SECONDS = 10
12const notice = atom({ plugin: 'auto-handoff', key: 'notice' } as const, null as Notice | null)
13
14// Module state, not $.state: it must survive /clear, which keeps this module loaded.
15let phase: Phase = 'idle'
16let countdown: Timer | undefined
17
18const show = ($: EngineInterface, next: Notice | null) => update($, notice, () => next)
19
20function stopCountdown($: EngineInterface) {
21  if (!countdown) return
22  countdown.cancel()
23  countdown = undefined
24  void show($, null)
25}
26
27async function findHandoffCommand($: EngineInterface) {
28  const commands = await $.command.list()
29  return commands.find(c => c.name === 'handoff' || c.name.endsWith(':handoff'))?.name
30}
31
32async function compact($: EngineInterface) {
33  phase = 'busy'
34  try {
35    await $.command.run({ command: 'compact' })
36  } finally {
37    phase = 'idle'
38  }
39}
40
41async function start($: EngineInterface, percent: number) {
42  const command = await findHandoffCommand($)
43  if (!command) {
44    $.ui.toast(`Context at ${percent}%. Compacting.`)
45    return compact($)
46  }
47  $.ui.toast(`Context at ${percent}%. Handing off to a fresh session.`)
48  phase = 'writing'
49  await $.command.run({ command })
50}
51
52async function countDown($: EngineInterface, percent: number, threshold: number) {
53  if (countdown) return
54  let secondsLeft = COUNTDOWN_SECONDS
55  await show($, { percent, threshold, secondsLeft })
56  countdown = $.clock.every(1000, () => {
57    secondsLeft -= 1
58    if (secondsLeft > 0) return void show($, { percent, threshold, secondsLeft })
59    stopCountdown($)
60    void start($, percent)
61  })
62}
63
64async function resumeFresh($: EngineInterface, answer: string) {
65  const path = handoffPath(answer)
66  if (!path) return compact($)
67  phase = 'busy'
68  try {
69    const name = sessionName(path, await $.fs.read(path))
70    await $.command.run({ command: 'clear' })
71    await $.command.run({ command: 'rename', args: name })
72    await $.prompt.submit({ text: `Read ${path} and continue.`, asUser: true })
73  } finally {
74    phase = 'idle'
75  }
76}
77
78async function drawNotice($: EngineInterface, e: BandInput) {
79  const shown = await read($, notice)
80  if (e.props.hasSurvey || !shown) return undefined
81  const { Box, Button, Text } = $.ui.resolve(e)
82  if (shown.secondsLeft === undefined) {
83    return <Text color="yellow">Context at {shown.percent}%. Handoff starts at {shown.threshold}%.</Text>
84  }
85  return (
86    <Box>
87      <Text color="yellow">Context at {shown.percent}%. Handing off in {shown.secondsLeft}s. </Text>
88      <Button key="not-now" label="Not now" onPress={() => stopCountdown($)} />
89    </Box>
90  )
91}
92
93async function checkUsage($: EngineInterface, threshold: number) {
94  const percent = (await $.session.usage()).context.percent ?? 0
95  if (percent >= threshold) return countDown($, percent, threshold)
96  await show($, percent >= threshold - WARN_POINTS ? { percent, threshold } : null)
97}
98
99export const register: Register = (on, options) => {
100  const threshold = Number(options.threshold)
101
102  on('turn.complete', async ($, e, next) => {
103    const done = await next(e)
104    if (e.agentId || phase === 'busy') return done
105
106    if (phase === 'writing') {
107      if (e.reason === 'answer') void resumeFresh($, e.answer)
108      else phase = 'idle'
109      return done
110    }
111
112    if (e.reason === 'answer') await checkUsage($, threshold)
113    return done
114  })
115
116  // A message sent during the countdown wins; the countdown starts again when that reply ends.
117  on('prompt.submit', async ($, e, next) => {
118    stopCountdown($)
119    return next(e)
120  })
121
122  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => (await drawNotice($, e)) ?? next(e))
123}
124
hooks/handoff.ts 12 lines
1// Only a file the handoff skill writes is accepted: .claude/handoffs/<name>.md, no slashes or dots up.
2export function handoffPath(answer: string): string | undefined {
3  return answer.match(/Handoff written:\s*`?(\.claude\/handoffs\/[\w-][\w.-]*\.md)\b/)?.[1]
4}
5
6export function sessionName(path: string, doc: string): string {
7  const subject = doc.match(/^#\s*Handoff\s*[—–-]\s*(.+)$/m)?.[1]?.trim()
8  if (subject) return subject
9  const file = path.split('/').pop() ?? path
10  return file.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '')
11}
12
types/index.d.ts 8 lines
1export type Notice = { percent: number; threshold: number; secondsLeft?: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'auto-handoff': { notice: Notice | null }
6  }
7}
8