A Session Hand-off button above the prompt, colored by context usage; reads Context clean and cannot be pressed while green. From orange on, one press writes…

A Session Hand-off button above the prompt that shows how full the context window is (green under 45%, orange 45–64%, red from 65%). While green it reads Context clean and cannot be pressed; from orange on it reads Session Hand-off and one press writes a session handoff, copies it to the clipboard, runs /clear, and sends the handoff to the fresh session.
Built on Claude Code 2.1.295. Proven on 2.1.295 (stages: validate, load, test, install-smoke, isolation). Requires Claude Code 2.1.287 or later.
● 30% Context clean <- green: plain text, nothing to press
[ ● 52% Session Hand-off ] <- orange/red: the button
> _
/session-handoff by default). If no command of that name is installed, it sends a built-in handoff prompt instead./clear, then sends the handoff text to the fresh session as your own message. If autoSend is off, it puts the text in the prompt box and you press Enter.When the handoff turn is interrupted, errors, is refused or returns no text, the mod does not run /clear; a toast says why. Presses while a handoff is running are ignored. /resume brings back the old session at any time.
Claude Code's status line (the statusLine setting) is a shell command's output. Mods cannot draw into it, so the button sits in the band directly above the prompt, the closest place that accepts a pressable button. Click it, or focus the band (a click or ctrl+x tab) and press h.
The percentage is the same figure the status line reads as context_window.used_percentage. It shows – in a fresh session until the first response.
❯ ./register.tsx hooks: session.start, session.measure, turn.start, turn.complete, ui.render{component=AbovePrompt} ❯ ./register.tsx calls: $.clock.after, $.command.list (via startHandoff), $.command.run (via startHandoff, switchSession), $.prompt.fill (via switchSession), $.prompt.submit (via startHandoff, switchSession), $.session.usage, $.state.get, $.state.set, $.ui.copy, $.ui.resolve, $.ui.toast
Reach L2 writes or runs or drives Claude (drives Claude, writes the prompt box, persists state, draws).
Threat model for session-handoff-button (reach L2, writes or runs or drives Claude)
1. Reads: context usage figures ($.session.usage, session.measure); the final answer text of main-loop turns, used only for the turn the mod started; the slash command list; state keys session-handoff-button.percent and session-handoff-button.phase; no env vars, no files
2. Runs: no processes; it drives Claude: one skill turn (or the built-in prompt) per press, /clear, and one prompt in the fresh session
3. Sends: nothing leaves the machine beyond the normal model requests of those turns
4. Persists: two $.state keys for this session only (percent, phase); the handoff text on the clipboard; nothing in $.store, no files
5. Hostile input: the handoff text is the model's answer, which can carry text from files or tool results it read; the mod sends it to the fresh session as your own words (asUser), exactly as a manual paste would, so injected instructions in it reach the new session with your authority; set autoSend to false to read it before sending; the skill name option only reaches $.command.run as a command name, never a shell
One session: claude --plugin-dir ./session-handoff-button. To keep it, install it from a marketplace, then run /reload-plugins in an open session. Installed copies are cached by version: bump version before reinstalling.
skill: the slash command that writes the handoff, without the slash. Default session-handoff. If it is not installed, the built-in handoff prompt is used.autoSend: send the handoff to the fresh session automatically. Default true. Off: the text goes into the prompt box and waits for Enter.Stored under pluginConfigs in settings; a change reloads the mod.
claude -p, the Agent SDK, the VS Code chat panel and cloud sessions nothing is drawn, so there is no button./reload-plugins, saving a file in development) while a handoff runs cancels it; nothing is cleared.#22c55e, #f97316, #ef4444) and do not follow your theme.Disable it in /plugin. It leaves nothing behind: no $.store keys, no files.
hooks/register.tsx 139 lines1import type { EngineInterface, Register, RenderSurface } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4type Phase = { step: 'idle' | 'requested' | 'generating' | 'switching'; turnId: string | null }
5type Config = { skill: string; autoSend: boolean }
6
7const IDLE: Phase = { step: 'idle', turnId: null }
8const percent = atom({ plugin: 'session-handoff-button', key: 'percent' } as const, null as number | null)
9const phase = atom({ plugin: 'session-handoff-button', key: 'phase' } as const, IDLE as Phase)
10
11export const GREEN = '#22c55e'
12export const ORANGE = '#f97316'
13export const RED = '#ef4444'
14export const CLEAN_LABEL = 'Context clean'
15
16// Sent only when the configured skill is not installed, so the mod also works for people without it.
17export const FALLBACK_PROMPT = [
18 'Write a session handoff so a fresh session can continue this work without repeating discovery.',
19 'Reply in the language of this conversation, as one message that is itself the continuation prompt, with these sections:',
20 'Goal and immediate next action; User constraints and decisions; Completed work and current state',
21 '(implemented / verified / pending / blocked, with evidence); Essential artifacts (absolute paths, why each matters);',
22 'Open questions and risks; First step for the next session.',
23 'Keep literal paths, commands and error messages. No secrets. Do not start new implementation work.',
24].join(' ')
25
26export function colorFor(value: number): string {
27 if (value >= 65) return RED
28 if (value >= 45) return ORANGE
29 return GREEN
30}
31
32function labelFor(step: Phase['step']): string {
33 if (step === 'switching') return 'Opening fresh session…'
34 if (step === 'idle') return 'Session Hand-off'
35 return 'Writing handoff…'
36}
37
38// The surface of the last press, so the clipboard copy lands where the person pressed.
39let pressedOn: RenderSurface | undefined
40
41async function startHandoff($: EngineInterface, surface: RenderSurface, config: Config): Promise<void> {
42 const current = await read($, phase)
43 if (current.step !== 'idle') {
44 $.ui.toast('Session Hand-off is already running')
45 return
46 }
47 pressedOn = surface
48 await update($, phase, () => ({ step: 'requested', turnId: null }))
49 const names = (await $.command.list()).map(c => c.name)
50 const started = names.includes(config.skill)
51 ? $.command.run({ command: config.skill })
52 : $.prompt.submit({ text: FALLBACK_PROMPT, asUser: true })
53 $.ui.toast(names.includes(config.skill) ? `Running /${config.skill}…` : 'Writing handoff with the built-in prompt…')
54 started.catch(async () => {
55 await update($, phase, () => IDLE)
56 $.ui.toast('Session Hand-off: could not start the handoff turn')
57 })
58}
59
60async function switchSession($: EngineInterface, text: string, config: Config): Promise<void> {
61 try {
62 await $.command.run({ command: 'clear' })
63 } catch {
64 await update($, phase, () => IDLE)
65 $.ui.toast('Session Hand-off: /clear failed; the handoff is on your clipboard')
66 return
67 }
68 await update($, phase, () => IDLE)
69 if (config.autoSend) void $.prompt.submit({ text, asUser: true })
70 else await $.prompt.fill({ text, mode: 'replace' })
71}
72
73export const register: Register = (on, options) => {
74 const config: Config = {
75 skill: typeof options.skill === 'string' && options.skill.trim() ? options.skill.trim().replace(/^\//, '') : 'session-handoff',
76 autoSend: options.autoSend !== false,
77 }
78
79 on('session.start', async ($, e, next) => {
80 const started = await next(e)
81 await update($, phase, () => IDLE)
82 const usage = await $.session.usage()
83 await update($, percent, () => usage.context.percent ?? null)
84 return started
85 })
86
87 on('session.measure', async ($, e, next) => {
88 await update($, percent, () => e.context.percent ?? null)
89 return next(e)
90 })
91
92 on('turn.start', async ($, e, next) => {
93 const current = await read($, phase)
94 if (current.step === 'requested') await update($, phase, () => ({ step: 'generating', turnId: e.turnId }))
95 return next(e)
96 })
97
98 on('turn.complete', async ($, e, next) => {
99 const done = await next(e)
100 if (e.agentId) return done
101 const current = await read($, phase)
102 if (current.step !== 'generating' || current.turnId !== e.turnId) return done
103 const text = e.answer.trim()
104 if (e.reason !== 'answer' || e.isAborted || !text) {
105 await update($, phase, () => IDLE)
106 $.ui.toast(`Session Hand-off stopped: the handoff turn ended with ${e.isAborted ? 'aborted' : e.reason}${text ? '' : ', no text'}; nothing was cleared`)
107 return done
108 }
109 await update($, phase, () => ({ step: 'switching', turnId: e.turnId }))
110 void $.ui.copy({ text, surface: pressedOn })
111 // command.run rejects inside a hook the turn waits on, so /clear starts from a timer.
112 $.clock.after(250, () => { void switchSession($, text, config) })
113 return done
114 })
115
116 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
117 if (e.props.hasSurvey) return next(e)
118 const { Box, Button, Text } = $.ui.resolve(e)
119 const value = await read($, percent)
120 const current = await read($, phase)
121 const beneath = await next(e)
122 const chip = <Text color={value === null ? 'inactive' : colorFor(value)} bold>● {value === null ? '–' : `${value}%`}</Text>
123 // Button has no disabled prop, so a clean context draws plain text: nothing to press, no hotkey.
124 const clean = current.step === 'idle' && value !== null && colorFor(value) === GREEN
125 return (
126 <Box flexDirection="column">
127 <Box flexDirection="row">
128 {clean
129 ? <Text>{chip} <Text dimColor>{CLEAN_LABEL}</Text></Text>
130 : <Button key="session-handoff" hotkey="h" onPress={press => { void startHandoff($, press.surface, config) }}>
131 {chip} {labelFor(current.step)}
132 </Button>}
133 </Box>
134 {beneath}
135 </Box>
136 )
137 })
138}
139types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 'session-handoff-button': {
4 percent: number | null
5 phase: { step: 'idle' | 'requested' | 'generating' | 'switching'; turnId: string | null }
6 }
7 }
8}
9