SLOPSHOPPER

rayyan-handoff-button

One-click handoff: runs mattpocock-skills:handoff and claude-mem:handoff together into a single HANDOFF.md

newbandcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rayyan-handoff-button
› fix the failing auth test and add an audit log call ⏺ 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-all ⎿ rayyan-handoff-button: Neither /mattpocock-skills:handoff nor /claude-mem:handoff is installed. [ Handoff ] prompt-box text becomes the next session's focus ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ Handoff ] prompt-box text becomes the next session's focus
README

rayyan-handoff-button

A Handoff button above the Claude Code prompt. One click writes a single HANDOFF.md in your project root, so a fresh session can pick up exactly where this one left off.

It runs two existing handoff skills as one:

  • mattpocock-skills:handoff: suggested skills for the next agent, redaction of secrets and personal data, and links to existing docs instead of copies
  • claude-mem:handoff: goal, current state, files in play, failed attempts and why they failed, next steps, and claude-mem memory pointers

Usage

  1. Optionally type what the next session is for in the prompt box.
  2. Click Handoff (or run /handoff-all [focus]). Your draft becomes the "next session focus" and the box is cleared.
  3. When the turn finishes, the band shows HANDOFF.md ready. Click Start fresh session to run /clear and pre-fill Read HANDOFF.md and continue from where we left off., then press Enter.

Reset clears a band stuck on "Writing combined handoff..." and Dismiss hides the ready state.

Requirements

Install both skill plugins for the combined handoff:

If only one is installed, the button runs that one alone and says so. If neither is, it shows a message and does nothing.

What it runs and what it sends

Everything happens inside your Claude Code session. The plugin makes no network requests, starts no processes and reads no files itself.

  • Reads: the list of available slash commands (to check which handoff skills are installed) and the text in your prompt box when you click.
  • Runs: the /mattpocock-skills:handoff or /claude-mem:handoff slash command in your session, which starts a normal Claude turn; and, only when you click Start fresh session, /clear.
  • Writes: it clears the prompt box when you click, and fills it with the resume line after Start fresh session. It keeps one value, the button's state, in session memory.
  • Files: HANDOFF.md is written by Claude during the handoff turn, under your usual permission settings, in your project root. It stays on your machine. Delete it once the new session has read it, or add it to .gitignore.

The handoff document is a summary of your conversation; the instructions ask Claude to redact secrets and personal data, but review it before sharing it.

License

MIT

Source 2 files
hooks/register.tsx 151 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const MATT = 'mattpocock-skills:handoff'
5const MEM = 'claude-mem:handoff'
6const RESUME = 'Read HANDOFF.md and continue from where we left off.'
7
8const phase = atom({ plugin: 'rayyan-handoff-button', key: 'phase' } as const, 'idle')
9
10// The next turn to start after a click is the handoff's (a command queued
11// behind a running turn waits for it), and only its end marks the band done.
12let armed = false
13let handoffTurn: string | null = null
14
15// The args handed to the slash command: they fold the other skill in, so one
16// turn produces one document instead of two competing ones.
17function combinedArgs(focus: string, other: string | null): string {
18  const lines = [
19    `Next session focus: ${focus || 'continue the current work where it stands'}.`,
20    '',
21    'This is a COMBINED handoff, written as ONE document:',
22  ]
23  if (other) {
24    lines.push(
25      `- Before writing anything, invoke the \`${other}\` skill with the Skill tool and follow its instructions as well.`,
26    )
27  }
28  lines.push(
29    "- Merge both skills' requirements: claude-mem's sections (Goal, Current State, Files in Play, What Has Been Tried, Current Best Theory, Next Steps, Key Constraints, Memory Pointers) plus a \"Suggested skills\" section.",
30    '- Reference existing specs, plans, issues, commits and PRs by path or URL instead of duplicating them. Redact secrets and PII.',
31    '- Write it to `HANDOFF.md` in the project root (this overrides any temp-dir location). Do not commit it.',
32  )
33  return lines.join('\n')
34}
35
36async function runHandoff($: EngineInterface): Promise<string> {
37  const names = new Set((await $.command.list()).map(c => c.name))
38  const hasMatt = names.has(MATT)
39  const hasMem = names.has(MEM)
40  if (!hasMatt && !hasMem) {
41    return `Neither /${MATT} nor /${MEM} is installed.`
42  }
43
44  // Whatever is drafted in the prompt box becomes the next session's focus.
45  const draft = (await $.prompt.read()).text.trim()
46  if (draft) {
47    await $.prompt.fill({ text: '' })
48  }
49
50  await update($, phase, () => 'running')
51  armed = true
52  try {
53    // mattpocock's handoff can't be model-invoked, so it is the command that
54    // runs; claude-mem's can, so the args ask the model to load it too.
55    if (hasMatt) {
56      await $.command.run({ command: MATT, args: combinedArgs(draft, hasMem ? MEM : null) })
57    } else {
58      await $.command.run({ command: MEM, args: combinedArgs(draft, null) })
59    }
60  } catch (error) {
61    armed = false
62    await update($, phase, () => 'idle')
63    return `Handoff failed to start: ${error instanceof Error ? error.message : String(error)}`
64  }
65  return hasMatt && hasMem
66    ? 'Writing combined handoff to HANDOFF.md...'
67    : `Only one handoff skill is installed; running /${hasMatt ? MATT : MEM} alone.`
68}
69
70async function startFresh($: EngineInterface): Promise<void> {
71  await update($, phase, () => 'idle')
72  await $.command.run({ command: 'clear' })
73  await $.prompt.fill({ text: RESUME })
74}
75
76export const register: Register = on => {
77  on('session.start', async ($, e, next) => {
78    await $.command.register({
79      name: 'handoff-all',
80      description: `Combined handoff (${MATT} + ${MEM}) into HANDOFF.md`,
81      argumentHint: '[what the next session is for]',
82    })
83    return next(e)
84  })
85
86  on('command.run', { command: 'handoff-all' }, async ($, e) => {
87    if (e.args.trim()) {
88      await $.prompt.fill({ text: e.args.trim() })
89    }
90    return { text: await runHandoff($) }
91  })
92
93  on('turn.start', ($, e, next) => {
94    if (armed) {
95      armed = false
96      handoffTurn = e.turnId
97    }
98    return next(e)
99  })
100
101  on('turn.complete', async ($, e, next) => {
102    if (e.turnId === handoffTurn) {
103      handoffTurn = null
104      const ok = e.reason === 'answer'
105      await update($, phase, () => (ok ? 'done' : 'idle'))
106      $.ui.toast(ok ? 'Handoff written to HANDOFF.md' : 'Handoff turn did not finish')
107    }
108    return next(e)
109  })
110
111  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
112    if (e.props.hasSurvey) {
113      return next(e)
114    }
115    const { Box, Button, Text } = $.ui.resolve(e)
116    const now = await read($, phase)
117
118    if (now === 'running') {
119      return (
120        <Box gap={1}>
121          <Text dimColor>Writing combined handoff...</Text>
122          <Button key="reset" label="Reset" dimColor onPress={() => update($, phase, () => 'idle')} />
123        </Box>
124      )
125    }
126
127    if (now === 'done') {
128      return (
129        <Box gap={1}>
130          <Text color="green">HANDOFF.md ready</Text>
131          <Button key="fresh" label="Start fresh session" onPress={() => startFresh($)} />
132          <Button key="dismiss" label="Dismiss" dimColor onPress={() => update($, phase, () => 'idle')} />
133        </Box>
134      )
135    }
136
137    return (
138      <Box gap={1}>
139        <Button
140          key="handoff"
141          label="Handoff"
142          onPress={async () => {
143            $.ui.toast(await runHandoff($))
144          }}
145        />
146        <Text dimColor>prompt-box text becomes the next session's focus</Text>
147      </Box>
148    )
149  })
150}
151
types/index.d.ts 9 lines
1/** idle: button shown; running: handoff turn queued or in progress; done: HANDOFF.md written. */
2export type Phase = 'idle' | 'running' | 'done'
3
4declare module 'claude-code' {
5  interface PluginState {
6    'rayyan-handoff-button': { phase: Phase }
7  }
8}
9