SLOPSHOPPER

handoff

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.

newpanebandcommandtoastmodel
v0.1.0MITupdated 2026-10-03marcmodin/skills/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 │ ┃ Task: : what the new session should do (blan │ ┃ ⏺ Read(src/auth.ts) │ ┃ Context: Brief (selected) Full fork ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Background ] [ Copy brief ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Esc cancels ⎿ 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
Task: : what the new session should do (blank: infer from th Context: Brief (selected) Full fork [ Background ] [ Copy brief ] Esc cancels
README

handoff

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.

Requirements

  • Claude Code with mods (built and tested on 2.1.288)
  • herdr for the pane and tab options. The mod offers them only when Claude Code runs inside a herdr pane (HERDR_PANE_ID is set). Anywhere else you get background sessions only.

Install

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.

Usage

/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:

ControlWhat it does
TaskWhat the child should do. Leave it blank and the brief infers it from the conversation.
BriefThe child gets a handoff brief written from this conversation. Default.
Full forkThe child gets a copy of the whole conversation.
New pane / New tabStarts the child in herdr, in its own worktree
BackgroundStarts a claude --bg session
Copy briefWrites 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.

The band

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 ]
  • The state comes from herdr for pane and tab children and from 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.

Files

  • hooks/register.tsx: the mod
  • types/index.d.ts: the state it keeps
  • tests/register.test.tsx: run with claude plugin test mods/handoff
Source 2 files
hooks/register.tsx 472 lines
1import { 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 lines
1export 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