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

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 copiesclaude-mem:handoff: goal, current state, files in play, failed attempts and why they failed, next steps, and claude-mem memory pointers/handoff-all [focus]). Your draft becomes the "next session focus" and the box is cleared./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.
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.
Everything happens inside your Claude Code session. The plugin makes no network requests, starts no processes and reads no files itself.
/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.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.
MIT
hooks/register.tsx 151 lines1import { 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}
151types/index.d.ts 9 lines1/** 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