Prototype: /handoff opens a picker, writes a brief from a fork of the conversation, and starts the child in a herdr pane, tab or background session.

Claude Code mod that hands a task to a new Claude Code session, so you can split work without filling your current session's context.
The new session (the child) starts in a herdr pane, a herdr tab or as a background session. It gets either a brief written from your conversation or a full fork of it.
HERDR_PANE_ID is set). Anywhere else you get background sessions only.From the marketplace in this repo:
claude plugin marketplace add marcmodin/skills
claude plugin install handoff@marcmodin
To run it from a checkout instead, start Claude Code with claude --plugin-dir mods/handoff and run /reload-plugins after each edit.
/handoff <task>
Starts the child at once with the defaults: a brief, in a new herdr pane (or a background session outside herdr).
/handoff
Opens the picker:
| Control | What it does |
|---|---|
| Task | What the child should do. Leave it blank and the brief infers it from the conversation. |
| Brief | The child gets a handoff brief written from this conversation. Default. |
| Full fork | The child gets a copy of the whole conversation. |
| New pane / New tab | Starts the child in herdr, in its own worktree |
| Background | Starts a claude --bg session |
| Copy brief | Writes the brief and copies it to the clipboard, starts nothing |
Children are named ho-<task>-<4 chars>. Your session gets one hidden line saying the child exists, so it won't work on the same task.
While a child is starting or running, a box above the prompt shows one row per child:
handoff ho-try-redis-a1b2 idle 4m · w5:p6 [ jump ] [ compare ]
claude agents for background ones. It refreshes every 15 seconds.jump focuses the child's pane. For a background child it opens claude attach in a new herdr pane, or copies the command outside herdr.compare shows once the child is idle or done. It asks this session to message the child for its result and compare it with the work here.A row goes away when its child exits, or 30 seconds after a failed launch. With no rows the box is gone. To hide it while children run, use the [-] in its corner or ctrl+x ctrl+a.
You get a toast when a child finishes, waits on you (a permission prompt, say) or exits.
hooks/register.tsx: the modtypes/index.d.ts: the state it keepstests/register.test.tsx: run with claude plugin test mods/handoffhooks/register.tsx 472 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Child, Mode, Picker } from '../types'
5
6type Where = 'pane' | 'tab' | 'bg' | 'copy'
7
8const PANE = 'handoff'
9const picker = atom({ plugin: 'handoff', key: 'picker' } as const, {
10 task: '',
11 mode: 'brief',
12 phase: 'pick',
13 status: '',
14} as Picker)
15// The children this session started, drawn in the band above the prompt. Kept in
16// $.state so the list outlives a mod reload; `checkedAt` redraws the ages each poll.
17const children = atom({ plugin: 'handoff', key: 'children' } as const, [] as Child[])
18const checkedAt = atom({ plugin: 'handoff', key: 'checkedAt' } as const, 0)
19
20const POLL_MS = 15_000
21const FAILED_MS = 30_000
22
23const BRIEF_PROMPT = `Write a handoff brief for a new Claude Code session that will take over one piece of this conversation's work. The user is splitting work: this session keeps its own thread, the new one gets the task below.
24
25Task from the user: {TASK}
26
27Start your reply with one line \`slug: {2-4 lowercase words joined by hyphens, naming the task}\`, then the brief as markdown, no preamble. Use this template. Drop a section that would be empty. Link files by absolute path instead of copying their content. Never include secrets, tokens or personal data.
28
29# Handoff: {one-line title}
30
31You are a new Claude Code session started from another session. The user is the same. The parent session keeps working on its own thread, so don't touch the files it owns.
32
33## Task
34{verb-led task, one or two sentences}
35
36## The split
37- Options discussed: {A: one line} / {B: one line}, or "none" when this isn't a split
38- The parent is doing: {A}. Don't implement it.
39- You are doing: {B}
40- Compare on: {criteria the two results will be judged by}
41
42## Context
43{3-5 sentences: what the parent was doing and how this task connects}
44
45## Decisions already made
46- {decision}: {why}
47
48## Constraints and user feedback
49- {rule, quoting the user where possible}
50
51## What was tried
52- {approach}: {result, why dropped}
53
54## Files
55- \`{absolute path}\`: {why it matters}
56
57## References
58- {spec, issue, commit or URL}
59
60## First action
61{one concrete step}
62
63## When done
64End with: what you built, branch and worktree path, what worked, what didn't, and how it does on the criteria under The split.`
65
66const FORK_PROMPT = `You were forked from another session. That session keeps working on its own thread; don't touch the files it owns. Your task: {TASK}`
67
68const COMPARE_PROMPT = `Use SendMessage to ask the handoff session {NAME} for its result: what it built, the branch and worktree, what worked and what didn't. When its reply arrives, compare it with the work in this session on the criteria from the split.`
69
70let poller: { cancel(): void } | undefined
71
72export const register: Register = on => {
73 // The engine may register another name than the one asked for (one an installed
74 // skill already takes), so serve the name register returns.
75 let commandName = 'handoff'
76
77 on('session.start', async ($, e, next) => {
78 const registered = await $.command.register({
79 name: 'handoff',
80 description: 'Split work to a new Claude Code session (herdr pane, tab or background)',
81 argumentHint: '<task>',
82 })
83 commandName = registered.command
84
85 // A reload drops the module's timers and any launch in flight; pick the
86 // running children back up from $.state and drop the rest.
87 await update($, picker, s => s.phase === 'working' ? { ...s, phase: 'pick' as const, status: '' } : s)
88 await update($, children, list => list.filter(c => c.phase === 'running'))
89 poller = undefined
90 ensurePolling($)
91 return next(e)
92 })
93
94 // `/handoff <task>` launches at once with the defaults; bare `/handoff` opens the picker.
95 on('command.run', async ($, e, next) => {
96 if (e.command !== commandName) return next(e)
97 const task = e.args.trim()
98 await update($, picker, () => ({ task, mode: 'brief' as Mode, phase: 'pick' as const, status: '' }))
99 if (task !== '') {
100 const inHerdr = (await $.env.get('HERDR_PANE_ID')) !== undefined
101 void launch($, inHerdr ? 'pane' : 'bg')
102 return {}
103 }
104 await $.ui.open({ id: PANE, title: 'Handoff', focus: true, closeOnEscape: true, holdToasts: true, rows: 12 })
105 return {}
106 })
107
108 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
109 const ui = $.ui.resolve(e)
110 const { Box, Text, Button } = ui
111 const Input = 'Input' in ui ? ui.Input : undefined
112 const p = await read($, picker)
113 const inHerdr = (await $.env.get('HERDR_PANE_ID')) !== undefined
114 const busy = p.phase === 'working'
115
116 const go = (where: Where) => () => {
117 void launch($, where)
118 }
119
120 return (
121 <Box flexDirection="column" gap={1}>
122 {Input && <Input
123 key="task"
124 label="Task: "
125 placeholder="what the new session should do (blank: infer from the conversation)"
126 value={p.task}
127 onInput={(value: string) => void update($, picker, s => ({ ...s, task: value }))}
128 onSubmit={go(inHerdr ? 'pane' : 'bg')}
129 submitLabel={inHerdr ? 'new pane' : 'background'}
130 />}
131 <Box flexDirection="row" gap={2}>
132 <Text dimColor>Context:</Text>
133 <Button key="mode-brief" plain dimColor={p.mode !== 'brief'}
134 onPress={() => void update($, picker, s => ({ ...s, mode: 'brief' as Mode }))}>
135 {p.mode === 'brief' ? 'Brief (selected)' : 'Brief'}
136 </Button>
137 <Button key="mode-fork" plain dimColor={p.mode !== 'fork'}
138 onPress={() => void update($, picker, s => ({ ...s, mode: 'fork' as Mode }))}>
139 {p.mode === 'fork' ? 'Full fork (selected)' : 'Full fork'}
140 </Button>
141 </Box>
142 {busy ? (
143 <Text>{p.status}</Text>
144 ) : (
145 <Box flexDirection="row" gap={2} flexWrap="wrap">
146 {inHerdr && <Button key="pane" variant="primary" autoFocus onPress={go('pane')}>New pane</Button>}
147 {inHerdr && <Button key="tab" onPress={go('tab')}>New tab</Button>}
148 <Button key="bg" autoFocus={inHerdr ? undefined : true} onPress={go('bg')}>Background</Button>
149 <Button key="copy" onPress={go('copy')}>Copy brief</Button>
150 </Box>
151 )}
152 {!busy && p.status !== '' && <Text color={p.phase === 'error' ? 'red' : undefined}>{p.status}</Text>}
153 <Text dimColor>Esc cancels</Text>
154 </Box>
155 )
156 })
157
158 // One row per child: launch progress while it starts, then its live state,
159 // with jump and compare. Rows leave on their own (the child exits, a failed
160 // launch after FAILED_MS), so the band is gone when no child is left.
161 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162 const kids = await read($, children)
163 if (e.props.hasSurvey || kids.length === 0) return next(e)
164 await read($, checkedAt)
165 const now = await $.clock.now()
166 const { Box, Text, Button } = $.ui.resolve(e)
167
168 // Click only, no hotkeys: a band hotkey would take a digit typed into an
169 // empty prompt. Plain and dim, underlined under the pointer (plain wins over variant).
170 const link = (scope: string) => ({ plain: true as const, dimColor: true, variant: 'primary' as const, hover: { scope, underline: true } })
171
172 return (
173 <Box flexDirection="column" borderStyle="round" paddingX={1}>
174 {kids.map(c => {
175 const isRunning = c.phase === 'running'
176 const canCompare = isRunning && (c.state === 'idle' || c.state === 'done')
177 const label = c.phase === 'running' ? c.state : c.step
178 const color = c.phase === 'failed' ? 'red' : c.state.startsWith('waiting') || c.state === 'blocked' ? 'yellow' : c.state === 'done' ? 'green' : undefined
179 const place = c.where === 'bg' ? `background ${c.id ?? ''}` : c.pane ?? ''
180 return (
181 <Box flexDirection="row" gap={1}>
182 <Text color="gray">handoff</Text>
183 <Text wrap="truncate-end">{c.name}</Text>
184 <Text color={color} wrap="truncate-end">{label}</Text>
185 {isRunning && <Text color="gray">{age(now - c.startedAt)} · {place}</Text>}
186 {isRunning && <Button key={`jump:${c.key}`} {...link(`jump:${c.key}`)} onPress={() => void jump($, c)}>jump</Button>}
187 {canCompare && <Button key={`compare:${c.key}`} {...link(`compare:${c.key}`)} onPress={() => void compare($, c)}>compare</Button>}
188 </Box>
189 )
190 })}
191 </Box>
192 )
193 })
194}
195
196async function launch($: EngineInterface, where: Where) {
197 const p = await read($, picker)
198 if (p.phase === 'working') return
199 const isFork = p.mode === 'fork' && where !== 'copy'
200 const suffix = randomSuffix()
201 // Copy starts no child, so it gets no row; the rest show progress in the band.
202 const row = where === 'copy' ? undefined : suffix
203
204 const setStatus = async (status: string, phase: Picker['phase'] = 'working') => {
205 await update($, picker, s => ({ ...s, status, phase }))
206 if (!row) return
207 await patchChild($, row, phase === 'error' ? { phase: 'failed', step: status } : { step: status })
208 if (phase === 'error') $.clock.after(FAILED_MS, () => update($, children, list => list.filter(c => c.key !== row)))
209 }
210
211 try {
212 if (row && where !== 'copy') {
213 const child: Child = {
214 key: row, name: p.task === '' && !isFork ? 'new handoff' : childName(p.task, suffix), where,
215 phase: 'starting', step: 'starting...', state: '', startedAt: await $.clock.now(),
216 }
217 await update($, children, list => [...list, child])
218 }
219
220 const sid = await $.session.id()
221 const cwd = await $.session.cwd()
222 const task = p.task || 'the alternative the user did not pick, as discussed in this conversation'
223
224 // The brief writer runs while the child starts. A name is needed to start the
225 // child, so with no task typed the brief comes first and names it.
226 const writeBrief = async () => {
227 await setStatus('writing the brief...')
228 const r = await $.model.fork({ prompt: BRIEF_PROMPT.replace('{TASK}', task) })
229 if (!r.isAnswered) throw new Error(`brief writer failed: ${r.reason}`)
230 const m = r.text.trim().match(/^slug:\s*(.*)\n+([\s\S]*)$/)
231 const brief = (m ? m[2]! : r.text.trim()) +
232 `\n\n## Parent\nSession \`${sid}\`. Its transcript is under ~/.claude/projects; grep it only if this brief leaves a gap.`
233 return { slug: m ? m[1]! : '', brief }
234 }
235
236 let briefJob: Promise<{ slug: string; brief: string }> | undefined
237 let name: string
238 if (isFork) {
239 name = childName(p.task, suffix)
240 } else if (p.task !== '' || where === 'copy') {
241 briefJob = writeBrief()
242 name = childName(p.task, suffix)
243 } else {
244 const first = await writeBrief()
245 briefJob = Promise.resolve(first)
246 name = childName(first.slug, suffix)
247 }
248 if (row) await patchChild($, row, { name })
249 const getPrompt = async () => isFork ? FORK_PROMPT.replace('{TASK}', task) : (await briefJob!).brief
250
251 if (where === 'copy') {
252 const copied = await $.ui.copy({ text: await getPrompt() })
253 await finish($, copied.isCopied ? 'Brief copied to the clipboard' : `Couldn't copy the brief (${copied.reason})`)
254 $.ui.toast(copied.isCopied ? 'Brief copied to the clipboard' : `Couldn't copy the brief (${copied.reason})`)
255 return
256 }
257
258 if (where === 'bg') {
259 const prompt = await getPrompt()
260 await setStatus('starting a background session...')
261 const argv = isFork
262 ? ['claude', '--bg', '--resume', sid, '--fork-session', '--name', name, prompt]
263 : ['claude', '--bg', '--name', name, prompt]
264 const out = await run($, argv, cwd, 60_000)
265 const id = out.match(/backgrounded\s*·\s*(\S+)/)?.[1]
266 await patchChild($, row!, { phase: 'running', step: '', state: 'working', id })
267 await finish($, `${name} running · ${id ?? '?'}`)
268 await noteParent($, `${name} as background session ${id ?? '?'}`, task)
269 ensurePolling($)
270 return
271 }
272
273 const agentArgs = isFork
274 ? ['--resume', sid, '--fork-session', '--name', name, '--worktree', name]
275 : ['--name', name, '--worktree', name]
276 const startChild = async () => {
277 const pane = where === 'tab' ? await newTab($, cwd, name) : await newPane($, cwd)
278 await patchChild($, row!, { pane })
279 await setStatus(`starting Claude Code in ${pane}...`)
280 try {
281 await run($, ['herdr', 'agent', 'start', name, '--kind', 'claude', '--pane', pane, '--timeout', '90000', '--', ...agentArgs], cwd, 100_000)
282 } catch (err) {
283 if (String(err).includes('agent_not_ready')) return { pane, isReady: false }
284 throw err
285 }
286 return { pane, isReady: true }
287 }
288
289 const [child, prompt] = await Promise.all([startChild(), getPrompt()])
290 if (!child.isReady) {
291 await $.ui.copy({ text: prompt })
292 throw new Error(`Claude Code in ${child.pane} is waiting on a dialog (folder trust?). Answer it there and paste: the ${isFork ? 'task' : 'brief'} is on the clipboard.`)
293 }
294
295 await setStatus(`sending the ${isFork ? 'task' : 'brief'}...`)
296 try {
297 await run($, ['herdr', 'agent', 'prompt', name, prompt], cwd, 60_000)
298 } catch (err) {
299 // Never resend: the paste may have landed. Check the pane before calling it a failure.
300 const seen = await $.process.run(['herdr', 'agent', 'read', name, '--source', 'recent-unwrapped', '--lines', '40'], { cwd })
301 if (!seen.stdout.includes((prompt.split('\n')[0] ?? '').slice(0, 40))) throw err
302 }
303
304 await patchChild($, row!, { phase: 'running', step: '', state: 'working' })
305 await finish($, `${name} running in pane ${child.pane}`)
306 await noteParent($, `${name} in herdr pane ${child.pane}`, task)
307 ensurePolling($)
308 } catch (err) {
309 const message = String(err instanceof Error ? err.message : err)
310 await setStatus(message, 'error')
311 $.ui.toast(`handoff failed: ${message}`)
312 }
313}
314
315async function finish($: EngineInterface, line: string) {
316 await update($, picker, s => ({ ...s, phase: 'done' as const, status: line }))
317 if ((await $.ui.panes()).some(pane => pane.id === PANE)) await $.ui.close({ id: PANE })
318}
319
320async function patchChild($: EngineInterface, key: string, patch: Partial<Child>) {
321 await update($, children, list => list.map(c => c.key === key ? { ...c, ...patch } : c))
322}
323
324// One line the parent's model reads and the person doesn't see, so the parent
325// knows the child exists without carrying the brief.
326async function noteParent($: EngineInterface, where: string, task: string) {
327 await $.session.append({
328 message: { type: 'user', content: [{ type: 'text', text: `<handoff>The user handed off a task to a new Claude Code session, ${where}: ${task}. Don't work on it here.</handoff>` }] },
329 })
330}
331
332async function newPane($: EngineInterface, cwd: string, focus = false): Promise<string> {
333 const self = (await $.env.get('HERDR_PANE_ID')) ?? ''
334 const layout = JSON.parse(await run($, ['herdr', 'pane', 'layout', '--pane', self], cwd))
335 const me = layout.result.layout.panes.find((x: { pane_id: string }) => x.pane_id === self)
336 const rect = me?.rect ?? layout.result.layout.area
337 const direction = rect.width >= 2 * rect.height ? 'right' : 'down'
338 const argv = ['herdr', 'pane', 'split', '--current', '--direction', direction, '--cwd', cwd]
339 const out = JSON.parse(await run($, focus ? argv : [...argv, '--no-focus'], cwd))
340 return out.result.pane.pane_id
341}
342
343async function newTab($: EngineInterface, cwd: string, name: string): Promise<string> {
344 const out = JSON.parse(await run($, ['herdr', 'tab', 'create', '--cwd', cwd, '--label', name, '--no-focus'], cwd))
345 const root = out.result.root_pane
346 return typeof root === 'string' ? root : root.pane_id
347}
348
349// One timer for all children, running only while one is starting or running.
350function ensurePolling($: EngineInterface) {
351 if (poller) return
352 void read($, children).then(list => {
353 if (poller || !list.some(c => c.phase === 'running')) return
354 poller = $.clock.every(POLL_MS, () => poll($))
355 })
356}
357
358type AgentRow = { name?: string; status?: string; state?: string; waitingFor?: string }
359
360// herdr's state for herdr children (it tells done from idle), `claude agents`
361// for background ones, and `waitingFor` from `claude agents` for both.
362async function poll($: EngineInterface) {
363 const list = await read($, children)
364 const live = list.filter(c => c.phase === 'running')
365 if (!list.some(c => c.phase === 'starting') && live.length === 0) {
366 poller?.cancel()
367 poller = undefined
368 return
369 }
370 if (live.length === 0) return
371
372 const agents = await listAgents($)
373 const seen = new Map<string, { state: string; pane?: string } | null>()
374 for (const c of live) {
375 const a = agents.find(x => x.name === c.name)
376 let state: string | null = null
377 let pane = c.pane
378 if (c.where === 'bg') {
379 if (a) state = a.state ?? a.status ?? 'unknown'
380 } else {
381 const r = await $.process.run(['herdr', 'agent', 'get', c.name])
382 if (r.exitCode === 0) {
383 const agent = JSON.parse(r.stdout).result?.agent
384 state = agent?.agent_status ?? 'unknown'
385 pane = agent?.pane_id ?? pane
386 }
387 }
388 if (state !== null && a?.status === 'waiting') state = `waiting: ${a.waitingFor ?? 'input'}`
389 seen.set(c.key, state === null ? null : { state, pane })
390 }
391
392 for (const c of live) {
393 const s = seen.get(c.key)
394 if (s === null) $.ui.toast(`handoff: ${c.name} has exited`)
395 else if (s && s.state !== c.state && (s.state === 'done' || s.state === 'blocked' || s.state.startsWith('waiting'))) {
396 $.ui.toast(`handoff: ${c.name} is ${s.state}`)
397 }
398 }
399
400 const now = await $.clock.now()
401 await update($, children, current => current.flatMap(c => {
402 const s = seen.get(c.key)
403 if (s === undefined) return [c]
404 if (s === null) return []
405 return [{ ...c, state: s.state, pane: s.pane }]
406 }))
407 await update($, checkedAt, () => now)
408}
409
410async function listAgents($: EngineInterface): Promise<AgentRow[]> {
411 const r = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 10_000 })
412 if (r.exitCode !== 0) return []
413 try {
414 const rows = JSON.parse(r.stdout)
415 return Array.isArray(rows) ? rows : []
416 } catch {
417 return []
418 }
419}
420
421// A herdr child: focus its pane. A background child: attach in a new herdr pane,
422// or copy the attach command outside herdr.
423async function jump($: EngineInterface, c: Child) {
424 try {
425 const cwd = await $.session.cwd()
426 if (c.where !== 'bg') {
427 await run($, ['herdr', 'agent', 'focus', c.name], cwd)
428 return
429 }
430 const command = `claude attach ${c.id ?? c.name}`
431 if ((await $.env.get('HERDR_PANE_ID')) !== undefined) {
432 const pane = await newPane($, cwd, true)
433 await run($, ['herdr', 'pane', 'run', pane, command], cwd)
434 return
435 }
436 const copied = await $.ui.copy({ text: command })
437 $.ui.toast(copied.isCopied ? `Copied: ${command}` : `Run: ${command}`)
438 } catch (err) {
439 $.ui.toast(`handoff: couldn't jump to ${c.name}: ${String(err instanceof Error ? err.message : err)}`)
440 }
441}
442
443// The mod can't call SendMessage; the parent's model can. The reply comes back
444// as a turn of its own, so the child's result enters this context only on request.
445async function compare($: EngineInterface, c: Child) {
446 await $.prompt.submit({ text: COMPARE_PROMPT.replace('{NAME}', c.name) })
447}
448
449async function run($: EngineInterface, argv: string[], cwd: string, timeoutMs = 30_000): Promise<string> {
450 const r = await $.process.run(argv, { cwd, timeoutMs })
451 if (r.exitCode !== 0) throw new Error(`${argv.slice(0, 3).join(' ')} failed: ${(r.stderr || r.stdout).trim().slice(0, 300)}`)
452 return r.stdout
453}
454
455function age(ms: number): string {
456 const s = Math.max(0, Math.round(ms / 1000))
457 if (s < 60) return `${s}s`
458 if (s < 3600) return `${Math.floor(s / 60)}m`
459 return `${Math.floor(s / 3600)}h`
460}
461
462// `ho-{slug}-{4 random}`: readable in herdr and `claude agents`, unique without a lookup,
463// and inside herdr's name rule ([a-z][a-z0-9_-]{0,31}).
464function childName(text: string, suffix: string): string {
465 const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24).replace(/-+$/, '')
466 return slug === '' ? `ho-${suffix}` : `ho-${slug}-${suffix}`
467}
468
469function randomSuffix(): string {
470 const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
471 return Array.from(crypto.getRandomValues(new Uint8Array(4)), b => alphabet[b % alphabet.length]).join('')
472}types/index.d.ts 24 lines1export type Mode = 'brief' | 'fork'
2export type Phase = 'pick' | 'working' | 'done' | 'error'
3export type Picker = { task: string; mode: Mode; phase: Phase; status: string }
4
5// One row of the band above the prompt: a child this session started.
6// `phase` is the launch; `state` is the child's live state once it runs.
7export type Child = {
8 key: string
9 name: string
10 where: 'pane' | 'tab' | 'bg'
11 phase: 'starting' | 'running' | 'failed'
12 step: string
13 state: string
14 pane?: string
15 id?: string
16 startedAt: number
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'handoff': { picker: Picker; children: Child[]; checkedAt: number }
22 }
23}
24