Reminds you of a handoff you left behind, picks it up with /pickup, and deletes it when you're done

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 104 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Waiting } from '../types'
5import { ageOf, handoffTitle, waitingFiles } from './waiting'
6
7const COMMAND = 'pickup'
8const REFRESH_MS = 5 * 60_000
9const ref = { plugin: 'handoff-pickup', key: 'waiting' } as const
10const waiting = atom(ref, null as Waiting | null)
11
12// Module state, not $.state: /clear keeps this module loaded, and the pick belongs to this session.
13let pickedUp: string | undefined
14let cwd = ''
15
16// Kept in $.store so a reload or another session still hides a handoff already being worked from.
17const pickKey = (path: string) => `picked:${cwd}/${path}`
18
19async function markPicked($: EngineInterface, path: string) {
20 pickedUp = path
21 await $.store.set(pickKey(path), true)
22}
23
24async function refresh($: EngineInterface, folder: string) {
25 const entries = await $.fs.list(folder).catch(() => [])
26 const now = await $.clock.now()
27 const picked = new Set(await $.store.keys())
28 const files = waitingFiles(entries, now).filter(e => !picked.has(pickKey(`${folder}/${e.name}`)))
29 const [newest] = files
30 if (!newest) return update($, waiting, () => null)
31 const path = `${folder}/${newest.name}`
32 const title = handoffTitle(newest.name, await $.fs.read(path))
33 await update($, waiting, () => ({ path, title, age: ageOf(newest.mtimeMs, now), more: files.length - 1 }))
34}
35
36async function pickUp($: EngineInterface, folder: string) {
37 const { value: shown } = await $.state.get(ref)
38 if (!shown) return { text: `No handoff waiting in ${folder}.` }
39 await markPicked($, shown.path)
40 await refresh($, folder)
41 return {
42 text: `Picked up: ${shown.title}. Send any message to start.`,
43 context: [`Read ${shown.path} and continue from it. When the work is done, tell the person to run /pickup done to delete it.`],
44 }
45}
46
47async function dismiss($: EngineInterface, folder: string) {
48 const { value: shown } = await $.state.get(ref)
49 if (!shown) return { text: `No handoff waiting in ${folder}.` }
50 await $.store.set(pickKey(shown.path), true)
51 await refresh($, folder)
52 return { text: `Hid ${shown.path}. The file is still there.` }
53}
54
55async function finish($: EngineInterface, folder: string) {
56 const path = pickedUp
57 if (!path) return { text: 'Nothing picked up in this session. Run /pickup first.' }
58 const { exitCode } = await $.process.run(['rm', '--', path])
59 if (exitCode !== 0) return { text: `Could not delete ${path}.` }
60 pickedUp = undefined
61 await $.store.delete(pickKey(path))
62 await refresh($, folder)
63 return { text: `Deleted ${path}.` }
64}
65
66export const register: Register = (on, options) => {
67 const folder = String(options.folder).replace(/\/+$/, '')
68 const resumed = new RegExp(`^Read (${folder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[\\w-][\\w.-]*\\.md) and continue\\.$`)
69
70 on('session.start', async ($, e, next) => {
71 cwd = e.cwd
72 await $.command.register({ name: COMMAND, description: 'Pick up the waiting handoff, "/pickup done" to delete it, "/pickup dismiss" to hide it' })
73 await refresh($, folder)
74 $.clock.every(REFRESH_MS, () => void refresh($, folder))
75 return next(e)
76 })
77
78 on('command.run', { command: COMMAND }, async ($, e) => {
79 const arg = e.args.trim()
80 if (arg === 'done') return finish($, folder)
81 if (arg === 'dismiss') return dismiss($, folder)
82 return pickUp($, folder)
83 })
84
85 // auto-handoff resumes with this exact prompt, so its handoff counts as picked up here.
86 on('prompt.submit', async ($, e, next) => {
87 const path = e.text.match(resumed)?.[1]
88 if (path) await markPicked($, path)
89 return next(e)
90 })
91
92 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
93 const shown = await read($, waiting)
94 if (e.props.hasSurvey || !shown) return next(e)
95 const { Text } = $.ui.resolve(e)
96 const more = shown.more ? ` (+${shown.more} more)` : ''
97 return (
98 <Text>
99 <Text color="yellow">Handoff waiting:</Text> {shown.title} ({shown.age}){more} · <Text bold>/pickup</Text>
100 </Text>
101 )
102 })
103}
104hooks/waiting.ts 23 lines1export type Entry = { name: string; kind: string; mtimeMs: number }
2
3const HOUR = 3_600_000
4const DAY = 24 * HOUR
5
6// A handoff written within the hour is most likely being worked from right now.
7export function waitingFiles(entries: readonly Entry[], now: number): Entry[] {
8 return entries
9 .filter(e => e.kind === 'file' && e.name.endsWith('.md') && now - e.mtimeMs >= HOUR)
10 .sort((a, b) => b.mtimeMs - a.mtimeMs)
11}
12
13export function handoffTitle(name: string, doc: string): string {
14 const subject = doc.match(/^#\s*Handoff\s*[—–-]\s*(.+)$/m)?.[1]?.trim()
15 return subject || name.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '')
16}
17
18export function ageOf(mtimeMs: number, now: number): string {
19 const days = Math.floor((now - mtimeMs) / DAY)
20 if (days === 0) return 'today'
21 return days === 1 ? 'yesterday' : `${days} days ago`
22}
23types/index.d.ts 8 lines1export type Waiting = { path: string; title: string; age: string; more: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'handoff-pickup': { waiting: Waiting | null }
6 }
7}
8