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

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.
/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.# comment first: the label is the block's first line.❯ , $ , % , 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.[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.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.| Command | What it does |
|---|---|
/handoff | Pane: 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 all | The 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.
hooks/register.ts 258 lines1import 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