EXPERIMENTAL: run /handoff or /handoffplan, then replace the conversation with the handoff and continue in the same terminal. Needs compact-handoff-answer…

<img src="https://github.com/REMvisual/claude-handoff/releases/download/v1.6.0/banner.png" alt="claude-handoff" width="900"/>
Never lose context between AI coding sessions.
Two skills for Claude Code that capture decisions, failed approaches, measurements, and next steps — so your next session picks up exactly where you left off. Stop wasting 20-40% of each session rediscovering what was already tried.
Now a Claude Code mod: /compact-handoff writes the handoff, swaps the conversation for it, and keeps working in the same terminal. No new window, no paste. Jump to the mod ↓
git clone https://github.com/REMvisual/claude-handoff.git
cp -r claude-handoff/skills/handoff ~/.claude/skills/
cp -r claude-handoff/skills/handoffplan ~/.claude/skills/
Verify: type /handoff in Claude Code — it should appear in autocomplete.
/handoff # Capture session context — next session explores
/handoffplan # Capture context + write a plan — next session executes
No arguments needed. The skill mines your full conversation, gathers git state, validates the output, and gives you a ready-to-paste resume prompt:
Read `plans/handoffs/HANDOFF_fix-auth-bug_2026-03-19.md` (seq 2, PROJ-abc1)
and continue from "Where We're Going".
Paste that into a fresh Claude Code session. It picks up the chain and starts working.
Every handoff captures a structured snapshot of your session:
┌─────────────────────────────────────────────────────┐
│ HANDOFF_fix-auth-bug_2026-03-19.md │
├─────────────────────────────────────────────────────┤
│ The Goal — what we're solving and why │
│ Where We Are — 15-25 bullets of current state│
│ What We Tried — every approach, chronological │
│ Key Decisions — what was chosen AND rejected │
│ Evidence & Data — real numbers, not summaries │
│ User Feedback — preferences, corrections, tone│
│ Where We're Going — ordered next steps │
│ Quick Start — exact commands for next session│
└─────────────────────────────────────────────────────┘
"What We Tried" is the most valuable section. Failed approaches are the most expensive thing to rediscover. This section captures every attempt — what was tried, what happened, why it was kept or abandoned — so the next session never repeats work.
/handoff vs /handoffplanBoth capture your session. The difference is what the next session does:
/handoff | /handoffplan | |
|---|---|---|
| When to use | Pausing mid-work | Done with research, ready to build |
| What it writes | Handoff file | Handoff + phased plan + tracked tasks |
| Next session | Reads handoff, onboards, explores | Reads plan, claims Phase 1, starts coding |
| Paste prompt | "Continue from Where We're Going" | "Execute the plan starting at Phase 1" |
/handoffplan creates beads (or tasks in your tracker) with dependency chains between phases, anti-goals from failed approaches, rollback strategies, and success criteria tied to real numbers from the session.
Handoffs link across sessions automatically:
HANDOFF_fix-auth_2026-03-17.md (seq 1)
└─ HANDOFF_fix-auth_2026-03-18.md (seq 2)
└─ HANDOFF_fix-auth_2026-03-19.md (seq 3)
Your third session on a feature knows about the first two.
Claude already generates session summaries when context compresses. They look like handoffs — but they lack chain tracking, self-validation, evidence mining, and comprehension gates. In controlled A/B testing (same bug, same codebase, fresh sessions), the skill-based session:
Without this, Claude may generate freeform "handoffs" when sessions end — documents that look right but skip the process. Add to your CLAUDE.md:
When ending a session or saving progress:
- ALWAYS use the /handoff skill. NEVER generate handoff summaries without it.
The skills work standalone. They unlock more when paired with:
bd (beads) as default — swap in Linear, Jira CLI, or GitHub Issues.Runs the handoff, then replaces the conversation with it and keeps going in the same terminal, with no new session and no paste. It works like /compact, but the summary is your validated handoff.
claude plugin marketplace add REMvisual/claude-handoff
claude plugin install compact-handoff@claude-handoff
This also installs its small companion, compact-handoff-answer (see below). The skills above must be installed too, from v2.1.0 or later.
/compact-handoff [note] # /handoff, then continue from "Where We're Going"
/compact-handoffplan [note] # /handoffplan, then start executing Phase 1
/compact-handoff cancel # drop a run that's stuck
What happens:
compact-mode: it asks nothing, commits the session's work, and writes the handoff (and plan).The mod also shows a one-time toast when context passes 70%, suggesting /compact-handoff.
Why two plugins: Claude Code doesn't let a plugin answer a compaction it started itself. compact-handoff-answer is about 20 lines. It answers only compactions marked compact-handoff:v1 and leaves every other compaction alone.
Caveats:
PreCompact hooks (including the one below) don't run on these compactions. SessionStart hooks with the compact matcher still do.Safety net that runs before context compaction, capturing ~50 lines of git state and active tasks.
cp claude-handoff/hooks/precompact-handoff.sh ~/.claude/hooks/
chmod +x ~/.claude/hooks/precompact-handoff.sh
rm -rf ~/.claude/skills/handoff ~/.claude/skills/handoffplan
claude plugin uninstall compact-handoff@claude-handoff
claude plugin prune # removes compact-handoff-answer, installed as its dependency
MIT — If this saved you time, give it a star.
hooks/register.ts 197 lines1// compact-handoff: /compact-handoff and /compact-handoffplan run the handoff skills in compact mode,
2// then replace the conversation with the handoff and continue in the same session.
3//
4// Order of events (each step is the one the engine allows; see the README):
5// command -> timer -> $.command.run(handoff|handoffplan, "compact-mode ...") (no prompt from inside a command hook)
6// tool.call Write/Edit records the handoff/plan files the skill writes
7// turn.complete -> verify the files on disk -> queue /compact-handoff-go (no compaction while a turn runs)
8// compact-handoff-go -> timer -> $.session.compact(marker + stub) (answered by compact-handoff-answer;
9// a plugin cannot answer its own compaction)
10// -> $.prompt.submit(the skill's own paste prompt)
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { Mode, Run } from '../types'
15import {
16 buildInstructions,
17 classifyPath,
18 extractPastePrompt,
19 fallbackPrompt,
20 parseSentinel,
21 resolvePath,
22 samePath,
23 stubText,
24} from './logic'
25
26const runAtom = atom({ plugin: 'compact-handoff', key: 'run' } as const, null as Run | null)
27const nudgedAtom = atom({ plugin: 'compact-handoff', key: 'nudged' } as const, false)
28
29const GO = 'compact-handoff-go'
30const NUDGE_PERCENT = 70
31const MAX_COMPACT_TRIES = 40
32
33function say($: EngineInterface, text: string) {
34 $.ui.toast(`compact-handoff: ${text}`)
35}
36
37async function abort($: EngineInterface, why: string) {
38 await update($, runAtom, () => null)
39 say($, `${why}. Context unchanged.`)
40}
41
42async function start($: EngineInterface, mode: Mode, args: string) {
43 args = (args ?? '').trim()
44 if (args === 'cancel') {
45 await update($, runAtom, () => null)
46 return { text: 'cancelled.' }
47 }
48 const running = await read($, runAtom)
49 if (running) {
50 return { text: 'a run is already in progress (`/compact-handoff cancel` to drop it).' }
51 }
52 await update($, runAtom, () => ({ mode, stage: 'handoff' as const, startedAt: Date.now(), files: [] }))
53 const skill = mode === 'plan' ? 'handoffplan' : 'handoff'
54 const skillArgs = `compact-mode ${args}`.trim()
55 // A prompt or command raised from inside this hook would wait on the turn the hook holds.
56 $.clock.after(100, () => runSkill($, skill, skillArgs))
57 return { text: `Running /${skill} in compact mode. The conversation is replaced when it finishes.` }
58}
59
60async function runSkill($: EngineInterface, skill: string, args: string) {
61 try {
62 await $.command.run({ command: skill, args })
63 } catch (err) {
64 try {
65 await $.prompt.submit({ text: `Run the ${skill} skill with these arguments: ${args}` })
66 } catch (err2) {
67 await abort($, `could not start /${skill} (${String(err2 ?? err)})`)
68 }
69 }
70}
71
72async function finishHandoff($: EngineInterface, reason: string, text: string) {
73 const run = await read($, runAtom)
74 if (!run || run.stage !== 'handoff') return
75 if (reason !== 'answer') return abort($, `the handoff turn ended early (${reason})`)
76
77 const sentinel = parseSentinel(text)
78 if (sentinel && !sentinel.ok) return abort($, `the skill reported: ${sentinel.reason}`)
79
80 const reported = sentinel?.ok ? sentinel.files : []
81 const pick = (kind: 'HANDOFF' | 'PLAN') =>
82 reported.find(f => classifyPath(f) === kind) ?? [...run.files].reverse().find(f => classifyPath(f) === kind)
83 const handoff = pick('HANDOFF')
84 const plan = run.mode === 'plan' ? pick('PLAN') : undefined
85 if (!handoff) return abort($, 'no handoff file was written')
86 if (run.mode === 'plan' && !plan) return abort($, 'no plan file was written')
87 if (sentinel?.ok && run.files.length && !run.files.some(f => samePath(f, handoff))) {
88 say($, `the skill named ${handoff}, which no Write/Edit in this run touched; checking it on disk anyway`)
89 }
90
91 const cwd = await $.session.cwd()
92 for (const file of [handoff, plan].filter((f): f is string => !!f)) {
93 let content = ''
94 try {
95 content = await $.fs.read(resolvePath(cwd, file))
96 } catch {
97 return abort($, `cannot read ${file}`)
98 }
99 if (content.trim().length < 200) return abort($, `${file} looks empty`)
100 }
101
102 const pastePrompt = extractPastePrompt(text) ?? fallbackPrompt(run.mode, handoff, plan)
103 await update($, runAtom, r => (r ? { ...r, stage: 'compacting' as const, handoff, plan, pastePrompt } : r))
104 // Compaction is refused while a turn runs; a queued command runs once the session is idle.
105 $.command.run({ command: GO }).catch(err => abort($, `could not queue the compaction (${String(err)})`))
106}
107
108function compactLoop($: EngineInterface, attempt: number) {
109 void (async () => {
110 const run = await read($, runAtom)
111 if (!run || run.stage !== 'compacting' || !run.handoff) return
112 const instructions = buildInstructions(stubText(run.mode, run.handoff, run.plan))
113 let result
114 try {
115 result = await $.session.compact({ instructions })
116 } catch (err) {
117 if (attempt >= MAX_COMPACT_TRIES) return abort($, `the compaction kept being refused (${String(err)})`)
118 $.clock.after(500, () => compactLoop($, attempt + 1))
119 return
120 }
121 if (result.skip !== undefined) return abort($, `the compaction was skipped: ${result.skip}`)
122 if (result.usage) say($, 'compact-handoff-answer is not installed, so the engine summarized instead')
123 await update($, runAtom, () => null)
124 await update($, nudgedAtom, () => false)
125 try {
126 await $.prompt.submit({ text: run.pastePrompt ?? fallbackPrompt(run.mode, run.handoff, run.plan), asUser: true })
127 } catch (err) {
128 say($, `context replaced, but the continue-prompt could not be sent (${String(err)}). Paste it from the handoff.`)
129 }
130 })()
131}
132
133async function nudge($: EngineInterface) {
134 if (await read($, nudgedAtom)) return
135 const usage = await $.session.usage()
136 if ((usage.context.percent ?? 0) < NUDGE_PERCENT) return
137 await update($, nudgedAtom, () => true)
138 say($, `context at ${Math.round(usage.context.percent ?? 0)}%. /compact-handoff is recommended.`)
139}
140
141export const register: Register = on => {
142 on('session.start', async ($, e, next) => {
143 await $.command.register({
144 name: 'compact-handoff',
145 description: 'Run /handoff, then replace the conversation with it and continue (experimental)',
146 })
147 await $.command.register({
148 name: 'compact-handoffplan',
149 description: 'Run /handoffplan, then replace the conversation with it and start Phase 1 (experimental)',
150 })
151 await $.command.register({ name: GO, description: 'Internal step of /compact-handoff' })
152 return next(e)
153 })
154
155 // The internal step stays runnable but out of the typeahead and /help.
156 on('command.describe', { command: GO }, async ($, e, next) => ({ ...(await next(e)), isHidden: true }))
157
158 on('command.run', { command: 'compact-handoff' }, async ($, e) => start($, 'resume', e.args))
159 on('command.run', { command: 'compact-handoffplan' }, async ($, e) => start($, 'plan', e.args))
160 on('command.run', { command: GO }, async $ => {
161 const run = await read($, runAtom)
162 if (!run || run.stage !== 'compacting') return { text: 'nothing to compact.' }
163 // Compacting from inside this hook is refused too; a timer runs after it returns.
164 $.clock.after(300, () => compactLoop($, 0))
165 return { text: 'replacing the conversation with the handoff.' }
166 }).catch(() => ({ text: 'internal step failed. Context unchanged.' }))
167
168 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
169 const res = await next(e)
170 if (!e.agentId && !res.deny && !res.isError && classifyPath(e.file_path)) {
171 await update($, runAtom, r =>
172 r && r.stage === 'handoff' && !r.files.includes(e.file_path) ? { ...r, files: [...r.files, e.file_path] } : r,
173 )
174 }
175 return res
176 }).catch(($, e, next) => next(e))
177
178 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
179 const res = await next(e)
180 if (!e.agentId && !res.deny && !res.isError && classifyPath(e.file_path)) {
181 await update($, runAtom, r =>
182 r && r.stage === 'handoff' && !r.files.includes(e.file_path) ? { ...r, files: [...r.files, e.file_path] } : r,
183 )
184 }
185 return res
186 }).catch(($, e, next) => next(e))
187
188 on('turn.complete', async ($, e, next) => {
189 const res = await next(e)
190 if (e.agentId) return res
191 const run = await read($, runAtom)
192 if (run) await finishHandoff($, e.reason, res.text)
193 else await nudge($)
194 return res
195 }).catch(($, e, next) => next(e))
196}
197hooks/logic.ts 95 lines1// Pure helpers for the compact-handoff mod: no `$`, so the tests can call them directly.
2import type { Mode } from '../types'
3
4/**
5 * First line of the compaction instructions. compact-handoff-answer answers only a
6 * compaction whose instructions start with it, and returns the text after it.
7 */
8export const MARKER = 'compact-handoff:v1'
9
10const HANDOFF_FILE = /(?:^|[\\/])(?:plans|\.claude)[\\/]handoffs[\\/](HANDOFF|PLAN)_[^\\/]*\.md$/
11
12/** `HANDOFF` or `PLAN` for a file the handoff skills write, else null. */
13export function classifyPath(path: string): 'HANDOFF' | 'PLAN' | null {
14 const m = HANDOFF_FILE.exec(path)
15 if (!m) return null
16 return m[1] === 'PLAN' ? 'PLAN' : 'HANDOFF'
17}
18
19export type Sentinel = { ok: true; files: string[] } | { ok: false; reason: string }
20
21/** Reads the skill's last `COMPACT-HANDOFF-READY:` / `COMPACT-HANDOFF-FAILED:` line; null when there is none. */
22export function parseSentinel(text: string): Sentinel | null {
23 const lines = text.split(/\r?\n/).map(l => l.trim().replace(/^`+|`+$/g, '').trim())
24 for (let i = lines.length - 1; i >= 0; i--) {
25 const line = lines[i] ?? ''
26 const ready = /^COMPACT-HANDOFF-READY:\s*(.+)$/.exec(line)
27 if (ready) {
28 const files = (ready[1] ?? '')
29 .split('|')
30 .map(f => f.trim().replace(/^`+|`+$/g, ''))
31 .filter(Boolean)
32 return { ok: true, files }
33 }
34 const failed = /^COMPACT-HANDOFF-FAILED:\s*(.*)$/.exec(line)
35 if (failed) return { ok: false, reason: failed[1] || 'no reason given' }
36 }
37 return null
38}
39
40/** The prompt between the dashes after `PASTE THIS INTO YOUR NEXT SESSION:`, or null. */
41export function extractPastePrompt(text: string): string | null {
42 const lines = text.split(/\r?\n/)
43 const head = lines.findIndex(l => l.includes('PASTE THIS INTO YOUR NEXT SESSION:'))
44 if (head < 0) return null
45 const isRule = (l: string) => /^\s*-{10,}\s*$/.test(l)
46 let start = head + 1
47 if (isRule(lines[start] ?? '')) start++
48 const end = lines.findIndex((l, i) => i >= start && isRule(l))
49 if (end < 0) return null
50 const body = lines
51 .slice(start, end)
52 .map(l => l.replace(/^ {3}/, ''))
53 .join('\n')
54 .trim()
55 return body || null
56}
57
58/** Used only when the skill's own paste prompt could not be extracted. */
59export function fallbackPrompt(mode: Mode, handoff: string, plan?: string): string {
60 if (mode === 'plan' && plan) {
61 return `Read \`${plan}\` and \`${handoff}\`. Execute the plan starting at Phase 1: claim its bead, read the Quick Start files, then start coding Phase 1's first concrete action.`
62 }
63 return `Read \`${handoff}\` and continue from "Where We're Going". Summarize what you understand, then wait for my go-ahead before executing.`
64}
65
66/** The one message the conversation is replaced with. */
67export function stubText(mode: Mode, handoff: string, plan?: string): string {
68 const files = plan ? `Handoff: \`${handoff}\`. Plan: \`${plan}\`.` : `Handoff: \`${handoff}\`.`
69 return (
70 `The conversation was replaced by compact-handoff (${mode} mode) after a full handoff. ${files} ` +
71 'Everything earlier in this session is in those files; the next message says what to do.'
72 )
73}
74
75/**
76 * The compaction's instructions: the marker line, then the stub. Without the answering
77 * plugin the engine's summarizer reads them instead, so they must make sense to it too.
78 */
79export function buildInstructions(stub: string): string {
80 return `${MARKER}\n${stub}`
81}
82
83/** Makes a skill-reported relative path absolute against the session's directory. */
84export function resolvePath(cwd: string, path: string): string {
85 if (/^([a-zA-Z]:[\\/]|[\\/])/.test(path)) return path
86 const sep = cwd.includes('\\') ? '\\' : '/'
87 return `${cwd.replace(/[\\/]+$/, '')}${sep}${path.replace(/^\.[\\/]/, '')}`
88}
89
90/** Same file, whatever the separators or case of a Windows path. */
91export function samePath(a: string, b: string): boolean {
92 const norm = (p: string) => p.replace(/\\/g, '/').toLowerCase()
93 return norm(a) === norm(b) || norm(a).endsWith('/' + norm(b)) || norm(b).endsWith('/' + norm(a))
94}
95types/index.d.ts 22 lines1export type Mode = 'resume' | 'plan'
2
3/** One /compact-handoff run, from the command until the continue-prompt is sent. */
4export type Run = {
5 mode: Mode
6 /** `handoff`: the skill's turn is running. `compacting`: waiting for the compaction. */
7 stage: 'handoff' | 'compacting'
8 startedAt: number
9 /** Handoff/plan files written during the run, as the tools reported them. */
10 files: string[]
11 /** Set once the turn ended with verified files. */
12 handoff?: string
13 plan?: string
14 pastePrompt?: string
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'compact-handoff': { run: Run | null; nudged: boolean }
20 }
21}
22