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

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.
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.
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.
Each mod has its own page with the full story.
| Mod | What it does |
|---|---|
| branch-status | A 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-handoff | When the context gets too full, it warns you, counts down, then hands the work to a fresh session. |
| handoff-pickup | Reminds you of a handoff you left behind. /pickup picks it up, /pickup done deletes it. |
| blocked-run | When 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-pluginshooks/register.tsx 124 lines1import { 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}
124hooks/handoff.ts 12 lines1// 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}
12types/index.d.ts 8 lines1export 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