SLOPSHOPPER

session-handoff-button

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…

newbandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-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 [ session-handoff ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ session-handoff ] ⟨Claude Code's own drawing⟩
README

session-handoff-button

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
> _

What one press does

  1. Runs your handoff skill as a slash command (/session-handoff by default). If no command of that name is installed, it sends a built-in handoff prompt instead.
  2. When that turn ends with an answer, the mod copies the answer to the clipboard as a backup.
  3. It runs /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.

Where the button is

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.

What it can reach

❯ ./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

Install

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.

Options

  • 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.

Limitations

  • The button is in the band above the prompt, not inside the status line (see above).
  • Draws only in the terminal and the Desktop app's Code tab. In claude -p, the Agent SDK, the VS Code chat panel and cloud sessions nothing is drawn, so there is no button.
  • If you queue another prompt yourself between the press and the start of the handoff turn, the mod can pick up that turn as the handoff. Press when the session is idle.
  • Reloading the mod (/reload-plugins, saving a file in development) while a handoff runs cancels it; nothing is cleared.
  • The colors are fixed hex values (#22c55e, #f97316, #ef4444) and do not follow your theme.

Uninstall

Disable it in /plugin. It leaves nothing behind: no $.store keys, no files.

Source 2 files
hooks/register.tsx 139 lines
1import 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}
139
types/index.d.ts 9 lines
1declare 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