SLOPSHOPPER

handoff

A checklist of the shell commands Claude hands you to run: captured from replies, ticked when you run them, confirmed by Claude.

newpanebandcommandtoastprompt
v0.1.0MITupdated 2026-10-02DominickGiordano/claude-mods/handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handoff
│ ┃ Handoff ✕ › fix the failing auth test and add an audit log call │ ┃ [ Clear done ] │ ┃ Nothing handed off. ⏺ 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 │ │ › /handoff │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Handoff
[ Clear done ] Nothing handed off.
README

handoff

When Claude hands you shell commands to run yourself (a merge auto mode won't do, a login, a delete), they go on a checklist per repo instead of scrolling away.

  • Asked for explicitly. The first prompt of each conversation (and after /clear or a compaction) carries one line asking Claude to put such commands in a ``` `handoff `` block, one per task, with a leading # comment as its label. Only those blocks are captured, never a plain bash block. A user mod can't add to the system prompt (sec-default bypasses prompt.compose and prompt.section`), so the line rides that prompt and stays in history.
  • One item per block, kept whole: Copy gives you the block verbatim, heredocs and loops included. No # comment first: the label is the block's first line.
  • Ran when you paste terminal output that shows the block's first command after a shell prompt (❯ , $ , % , host:/x# ). A command merely mentioned in a prompt doesn't count. Running it with ! in the prompt is not detected: no mod event is documented for bash-mode input.
  • Confirmed when Claude writes [done #id] in its prose (not inside code). While anything is pending, each of your prompts carries the 5 newest pending items under 48 hours old, as #id label, and asks for that tag. Nothing pending, nothing added.
  • Band: handoff ☐ 3 to run ✓ 1 ran with an Open button, hidden when nothing is pending. If the store can't be read or written, the band and pane say handoff: store unavailable.
CommandWhat it does
/handoffPane: To run, Ran, Done, and a collapsed Dismissed. Copy, ✓ and ✕ per item; Copy all to run (c), Clear done (x, also clears dismissed); 1-9 tick an item.
/handoff add <cmd>Adds an item by hand.
/handoff allThe pane across every repo. No Copy all: one shell can't run several repos' commands.

Re-handing a block reopens it unless you dismissed it. Lists live in the plugin's store, keyed by the session's project root, so they survive /clear and new sessions. Done and dismissed items are dropped after 7 days.

Source 1 files
hooks/register.ts 258 lines
1import type { EngineInterface, On, RenderElement, RenderSurface } from 'claude-code'
2
3type Api = EngineInterface
4export type State = 'todo' | 'ran' | 'confirmed' | 'done' | 'dismissed'
5export type Item = { id: string; cmd: string; label: string; repo: string; root: string; createdAt: number; state: State; updatedAt: number }
6type Found = { cmd: string; label: string }
7
8export const PREFIX = 'handoff:'
9export const KEEP_MS = 7 * 24 * 60 * 60_000
10const FRESH_MS = 48 * 60 * 60_000
11const PANE = 'handoff'
12const FINISHED: State[] = ['confirmed', 'done', 'dismissed']
13// sec-default bypasses a user mod's prompt.compose and prompt.section, so this rides the first
14// person prompt of each conversation instead; once in history it is part of the cached prefix.
15const INSTRUCTION = 'When you give the user shell commands to run themselves, put them in a ```handoff fenced block (one block per task, a leading `# comment` line as its label).'
16const FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*handoff[ \t]*\n([\s\S]*?)\n {0,3}\1[`~]*[ \t]*$/gm
17const DONE = /\[done #([a-z0-9]+)\]/gi
18// What a pasted terminal line puts before the command: `❯ `, `$ `, `% `, `user@host:/x# `.
19const SHELL_PROMPT = /[❯$%#➜›]\s+$/
20
21let items: Item[] = []
22let all: Item[] = []
23let isAll = false
24let showDismissed = false
25let storeError: string | null = null
26let isInstructed = false
27let writes: Promise<unknown> = Promise.resolve()
28
29function fnv(text: string, seed: number) {
30  let h = seed
31  for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
32  return (h >>> 0).toString(36).padStart(7, '0')
33}
34
35export const hash = (text: string) => fnv(text, 0x811c9dc5) + fnv(text, 0x050c5d1f)
36
37const isPending = (i: Item) => i.state === 'todo' || i.state === 'ran'
38const unwrap = (text: string) => text.replace(/\\\r?\n\s*/g, ' ')
39
40function isItem(v: unknown): v is Item {
41  const i = v as Record<string, unknown>
42  return typeof v === 'object' && v !== null && ['id', 'cmd', 'label', 'repo', 'root'].every(k => typeof i[k] === 'string') &&
43    Number.isFinite(i.createdAt) && Number.isFinite(i.updatedAt) && ['todo', 'ran', ...FINISHED].includes(i.state as State)
44}
45
46/** ```handoff blocks, each whole; a leading `# comment` is the label. */
47export function handoffs(reply: string): Found[] {
48  return [...reply.matchAll(FENCE)].flatMap(m => {
49    const lines = (m[2] ?? '').replace(/^\s*\n/, '').split('\n')
50    const first = lines[0]?.trim() ?? ''
51    const isLabel = first.startsWith('#') && !first.startsWith('#!')
52    const cmd = (isLabel ? lines.slice(1) : lines).join('\n').trim()
53    const label = isLabel ? first.replace(/^#+\s*/, '') : (cmd.split('\n')[0] ?? '').slice(0, 60)
54    return cmd ? [{ cmd, label }] : []
55  })
56}
57
58/** True when the prompt holds a pasted terminal line: a shell prompt, then the block's first command. */
59export function echoes(prompt: string, cmd: string) {
60  const first = (unwrap(cmd).split('\n').find(l => l.trim() && !l.trim().startsWith('#')) ?? '').replace(/\s+/g, ' ').trim()
61  if (!first) return false
62  return unwrap(prompt).split('\n').some(line => {
63    const l = line.replace(/\s+/g, ' ').trim()
64    return l.endsWith(first) && SHELL_PROMPT.test(l.slice(0, l.length - first.length))
65  })
66}
67
68function failed($: Api, err: unknown) {
69  const why = err instanceof Error ? err.message : String(err)
70  $.ui.log(`handoff: store unavailable: ${why}`)
71  if (storeError === null) $.ui.toast(`handoff: store unavailable: ${why}`)
72  storeError = why
73  $.ui.invalidate('ui.render')
74}
75
76const valid = (v: unknown) => (Array.isArray(v) ? v.filter(isItem) : [])
77const load = async ($: Api, root: string) => valid(await $.store.get(PREFIX + hash(root)))
78
79async function loadAll($: Api) {
80  const keys = (await $.store.keys()).filter(k => k.startsWith(PREFIX))
81  return (await Promise.all(keys.map(k => $.store.get(k)))).flatMap(valid)
82}
83
84async function refresh($: Api) {
85  try {
86    items = await load($, await $.session.root())
87    if (isAll) all = await loadAll($)
88    storeError = null
89    $.ui.invalidate('ui.render')
90  } catch (err) {
91    failed($, err)
92  }
93}
94
95// The store has no transactions: every read-modify-write waits for the one before it.
96function change($: Api, root: string, fn: (list: Item[], now: number) => Item[]) {
97  const run = writes.then(async () => {
98    const now = await $.clock.now()
99    const list = fn(await load($, root), now).filter(i => !FINISHED.includes(i.state) || now - i.updatedAt < KEEP_MS)
100    await $.store.set(PREFIX + hash(root), list)
101    await refresh($)
102  }).catch(err => failed($, err))
103  writes = run
104  return run
105}
106
107function setState($: Api, item: Item, state: State) {
108  return change($, item.root, (list, now) => list.map(i => (i.id === item.id ? { ...i, state, updatedAt: now } : i)))
109}
110
111async function capture($: Api, found: Found[], confirmed: string[]) {
112  const root = await $.session.root()
113  return change($, root, (list, now) => {
114    const out = [...list]
115    for (const f of found) {
116      let id = hash(`${root}\n${f.cmd}`)
117      while (out.some(i => i.id === id && i.cmd !== f.cmd)) id = hash(id)
118      const at = out.findIndex(i => i.id === id)
119      const old = out[at]
120      if (!old) out.push({ id, cmd: f.cmd, label: f.label, repo: root.split('/').filter(Boolean).pop() ?? root, root, createdAt: now, state: 'todo', updatedAt: now })
121      else if (old.state !== 'todo' && old.state !== 'dismissed') out[at] = { ...old, label: f.label, state: 'todo', updatedAt: now }
122    }
123    return out.map(i => (confirmed.includes(i.id) && isPending(i) ? { ...i, state: 'confirmed', updatedAt: now } : i))
124  })
125}
126
127async function open($: Api, everywhere: boolean) {
128  isAll = everywhere
129  await refresh($)
130  await $.ui.open({ id: PANE, title: everywhere ? 'Handoff: all repos' : 'Handoff', focus: true, closeOnEscape: true })
131}
132
133async function copy($: Api, text: string, what: string, surface: RenderSurface) {
134  const copied = await $.ui.copy({ text, surface })
135  $.ui.toast(copied.isCopied ? `handoff: copied ${what}` : `handoff: not copied: ${copied.reason}`)
136}
137
138function act($: Api, work: () => Promise<unknown>) {
139  work().catch(err => $.ui.toast(`handoff: ${err instanceof Error ? err.message : String(err)}`))
140}
141
142function ago(ms: number) {
143  const m = Math.floor(ms / 60_000)
144  return m < 60 ? `${m}m ago` : m < 1440 ? `${Math.floor(m / 60)}h ago` : `${Math.floor(m / 1440)}d ago`
145}
146
147export function register(on: On) {
148  on('session.start', async ($, e, next) => {
149    const result = await next(e)
150    await $.command.register({ name: 'handoff', description: 'Commands Claude handed you to run: open the list, add one, or show every repo', argumentHint: '[add <cmd> | all]', immediate: true })
151    await refresh($)
152    return result
153  })
154
155  on('session.end', ($, e, next) => {
156    isInstructed = false
157    return next(e)
158  })
159
160  on('session.compact', ($, e, next) => {
161    isInstructed = false
162    return next(e)
163  })
164
165  on('turn.complete', async ($, e, next) => {
166    const result = await next(e)
167    if (e.agentId !== undefined || e.reason !== 'answer') return result
168    const found = handoffs(e.answer)
169    // Prose only: a tag inside code is an example, not a confirmation.
170    const prose = e.answer.replace(/^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1[`~]*[ \t]*$/gm, '').replace(/`[^`\n]*`/g, '')
171    const confirmed = [...prose.matchAll(DONE)].map(m => (m[1] ?? '').toLowerCase())
172    if (found.length > 0 || confirmed.length > 0) await capture($, found, confirmed)
173    return result
174  })
175
176  on('prompt.submit', async ($, e, next) => {
177    if (!['composer', 'bridge', 'sdk'].includes(e.origin.kind)) return next(e)
178    await refresh($)
179    const ran = items.filter(i => i.state === 'todo' && echoes(e.text, i.cmd)).map(i => i.id)
180    if (ran.length > 0) await change($, await $.session.root(), (list, now) => list.map(i => (ran.includes(i.id) && i.state === 'todo' ? { ...i, state: 'ran', updatedAt: now } : i)))
181    const now = await $.clock.now()
182    const pending = items.filter(i => isPending(i) && now - i.createdAt < FRESH_MS).sort((a, b) => b.createdAt - a.createdAt).slice(0, 5)
183    const notes = isInstructed ? [] : [INSTRUCTION]
184    isInstructed = true
185    if (pending.length > 0) notes.push([...pending.map(i => `#${i.id} ${i.label}`), 'If you verify a handoff took effect, write [done #id].'].join('\n'))
186    return notes.length === 0 ? next(e) : next({ ...e, context: [...(e.context ?? []), ...notes] })
187  })
188
189  on('command.run', { command: 'handoff' }, async ($, e) => {
190    const args = e.args.trim()
191    if (args === '' || args === 'all') await open($, args === 'all')
192    else if (args.startsWith('add ') && args.slice(4).trim()) {
193      const cmd = args.slice(4).trim()
194      await capture($, [{ cmd, label: cmd.slice(0, 60) }], [])
195      if (storeError === null) $.ui.toast(`handoff: added ${cmd}`)
196    } else $.ui.toast('handoff: usage /handoff [add <cmd> | all]')
197    return {}
198  })
199
200  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
201    const below = await next(e)
202    const [todo, ran] = (['todo', 'ran'] as const).map(s => items.filter(i => i.state === s).length) as [number, number]
203    if (todo + ran === 0 && storeError === null) return below
204    const { Box, Text, Button } = $.ui.resolve(e)
205    const counts = [todo > 0 && `☐ ${todo} to run`, ran > 0 && `✓ ${ran} ran`].filter(Boolean).join('  ')
206    const text = storeError === null ? `handoff  ${counts}` : `handoff: store unavailable${counts ? `  (last seen ${counts})` : ''}`
207    // No hotkey: a bare digit typed into an empty prompt presses a band button.
208    const row = Box({ flexDirection: 'row', columnGap: 2, paddingX: 1, children: [
209      Text({ color: storeError === null ? undefined : 'yellow', children: text }),
210      Button({ key: 'handoff-open', label: 'Open', dimColor: true, onPress: () => act($, () => open($, false)) }),
211    ] })
212    return Box({ flexDirection: 'column', children: [row, below] })
213  })
214
215  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
216    const { Box, Text, Button, Code } = $.ui.resolve(e)
217    const now = await $.clock.now()
218    const list = isAll ? all : items
219    let n = 0
220    const row = (i: Item) => {
221      const buttons: RenderElement[] = [Button({ key: `copy:${i.id}`, label: 'Copy', onPress: press => act($, () => copy($, i.cmd, i.label, press.surface)) })]
222      if (isPending(i)) buttons.push(Button({ key: `check:${i.id}`, label: '✓', ...(++n <= 9 ? { hotkey: String(n) } : {}), onPress: () => act($, () => setState($, i, 'done')) }))
223      if (i.state !== 'dismissed') buttons.push(Button({ key: `dismiss:${i.id}`, label: '✕', onPress: () => act($, () => setState($, i, 'dismissed')) }))
224      return Box({ key: `item:${i.id}`, flexDirection: 'column', children: [
225        Box({ flexDirection: 'row', columnGap: 2, children: [
226          Text({ bold: true, children: i.label }),
227          Text({ dimColor: true, children: `${isAll ? `${i.repo} · ` : ''}#${i.id} · ${ago(now - i.createdAt)}` }),
228        ] }),
229        Box({ paddingLeft: 2, children: [Code({ source: i.cmd, language: 'bash' })] }),
230        Box({ flexDirection: 'row', columnGap: 1, paddingLeft: 2, children: buttons }),
231      ] })
232    }
233    const groups: [string, State[]][] = [['To run', ['todo']], ['Ran (awaiting confirm)', ['ran']], ['Done', ['confirmed', 'done']]]
234    const sections = groups.flatMap(([title, states]) => {
235      const rows = list.filter(i => states.includes(i.state))
236      return rows.length === 0 ? [] : [Box({ key: title, flexDirection: 'column', marginTop: 1, children: [Text({ bold: true, children: `${title} (${rows.length})` }), ...rows.map(row)] })]
237    })
238    const dismissed = list.filter(i => i.state === 'dismissed')
239    if (dismissed.length > 0) {
240      const toggle = Button({ key: 'toggle-dismissed', label: `Dismissed (${dismissed.length}) ${showDismissed ? '▾' : '▸'}`, plain: true, onPress: () => {
241        showDismissed = !showDismissed
242        $.ui.invalidate('ui.render')
243      } })
244      sections.push(Box({ key: 'Dismissed', flexDirection: 'column', marginTop: 1, children: [toggle, ...(showDismissed ? dismissed.map(row) : [])] }))
245    }
246    const todo = list.filter(i => i.state === 'todo')
247    const finished = list.filter(i => FINISHED.includes(i.state))
248    const clear = () => Promise.all([...new Set(finished.map(i => i.root))].map(root => change($, root, l => l.filter(i => !FINISHED.includes(i.state)))))
249    const actions: RenderElement[] = []
250    if (storeError !== null) actions.push(Text({ color: 'yellow', children: `handoff: store unavailable: ${storeError}` }))
251    // Not across repos: pasting several repos' commands into one shell runs them in the wrong place.
252    if (!isAll && todo.length > 0) actions.push(Button({ key: 'copy-all', label: 'Copy all to run', hotkey: 'c', onPress: press => act($, () => copy($, todo.map(i => i.cmd).join('\n'), `${todo.length} blocks`, press.surface)) }))
253    actions.push(Button({ key: 'clear-done', label: 'Clear done', hotkey: 'x', onPress: () => act($, clear) }))
254    const body = sections.length > 0 ? sections : [Text({ dimColor: true, children: 'Nothing handed off.' })]
255    return Box({ flexDirection: 'column', paddingX: 1, children: [Box({ flexDirection: 'row', columnGap: 2, children: actions }), ...body] })
256  })
257}
258