SLOPSHOPPER

session-handoff

Band above the prompt with a session timer, a handoff writer and a fresh-start button that loads the handoff

newbandcommandtoastmodelprocess
v0.1.0no licenseupdated 2026-10-09ptsnac/claude-session-handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-handoff
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ session-handoff │ ⏺ Read(src/auth.ts) │ Handoff saved to │ ⎿ Read 6 lines │ /Users/dev/.claude/handoffs/-work-app/2025-10-09-085320-preview-.md. │ ⏺ Update(src/auth.ts) │ Press "Start fresh with handoff" above the │ ⎿ 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 ⎿ session-handoff: Handoff saved to /Users/dev/.claude/handoffs/-work-app/2025-10-09-085320-preview-.md. Press "Start fresh with session 30m 00s context 49% Handoff ready (just now) [ Start fresh with handoff ] [ Regenerate ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
session 30m 00s context 49% Handoff ready (just now) [ Start fresh with handoff ] [ Regenerate ]
README

claude-session-handoff

A Claude Code mod that adds a row above the prompt with a session timer and a one-click handoff to a fresh context window.

session 1h 23m 07s   context 64%   [ Create handoff ]

Once a handoff is written:

session 1h 31m 40s   context 66%   Handoff ready (2m ago)   [ Start fresh with handoff ]   [ Regenerate ]

Why not /compact?

/compact replaces your conversation with a summary, so anything the summary misses is gone. This mod leaves the conversation alone and instead:

  1. Forks the current conversation (tool-less, served from the prompt cache, so it is quick and cheap) and asks it to write a structured handoff: goal, current state, decisions and constraints, files and commands, dead ends, open questions, next steps and how to verify.
  2. Appends a real git status, diff stat and recent log, captured by the mod rather than recalled by the model.
  3. Saves it as a Markdown file whose YAML front matter records the old session's ID and the claude --resume <id> command, so the full transcript is always one step away if the handoff missed something.

Start fresh with handoff then runs /clear and submits the handoff as the first message of the new context, asking Claude to check the current state and confirm the next step before changing anything.

Requirements

  • Claude Code with function-hook mods (tested on 2.1.288). The mod API is early access and may change between releases.
  • git on your PATH (optional; the git snapshot is skipped outside a repo).

Install

Pick a permanent folder for the mod and clone it there. Set SESSION_HANDOFF_DIR to wherever you keep your code:

SESSION_HANDOFF_DIR="$HOME/code/claude-session-handoff"
git clone https://github.com/ptsnac/claude-session-handoff.git "$SESSION_HANDOFF_DIR"

Then load it in one of two ways.

Every session - add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. It takes an absolute path or one starting with ~, but not $HOME or other variables, so print the line to paste from the same shell:

echo "\"CLAUDE_CODE_PLUGIN_DIRS\": \"$SESSION_HANDOFF_DIR\""
{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "<the path printed above>"
  }
}

Separate several folders with : on macOS and Linux.

One session - pass the folder when starting Claude Code:

claude --plugin-dir "$SESSION_HANDOFF_DIR"

Use

ActionHow
Write a handoffClick Create handoff, or type /handoff
Start a clean context with itClick Start fresh with handoff
Rewrite a stale handoffClick Regenerate
Use the keyboardctrl+x tab to focus the row, then h (handoff) or n (start fresh)

Handoffs are saved to:

~/.claude/handoffs/<project-path-slug>/<YYYY-MM-DD-HHMMSS>-<session-id>.md

The timer counts from the start of the current context and resets on /clear.

Good to know

  • A handoff made while Claude is replying covers the conversation up to its last completed message, not the reply in progress.
  • The handoff goes stale as you keep working. The row shows its age; regenerate before starting fresh if you have done more since.
  • Nothing is sent anywhere except the usual model request to Anthropic, made through your own Claude Code session.

Develop

claude plugin validate .   # checks the manifest and what the module calls
claude plugin test .       # runs tests/*.test.tsx against the engine

The engine writes type declarations into .claude-plugin/types/ the first time it loads the mod (ignored by git); after that tsc -p . type-checks it. Loaded with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS, saving a file hot-reloads the mod in the running session.

FileRole
hooks/register.tsxEverything that calls Claude Code: the hooks, the handoff state machine, writing the file, fresh start and the row's UI
hooks/prompts.tsThe handoff prompt and the fresh-start message. Edit here to change what a handoff contains
hooks/document.tsPure helpers that build the handoff file: path, YAML front matter, git snapshot section
hooks/time.tsTime formatting
types/index.d.tsThe handoff state the row draws from
tests/handoff.test.tsxTests for the timer, handoff, fresh start, failure and concurrent requests

The engine only follows $ (the mod's handle on Claude Code) within a single file, so every call through it stays in register.tsx; the other modules are plain functions.

Source 5 files
hooks/register.tsx 222 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Handoff, SettledHandoff, WrittenHandoff } from '../types'
5import { GIT_COMMANDS, formatGitSnapshot, handoffDocument, handoffPath } from './document'
6import { freshStartPrompt, handoffPrompt } from './prompts'
7import { formatAge, formatElapsed } from './time'
8
9const handoff = atom({ plugin: 'session-handoff', key: 'handoff' } as const, {
10  phase: 'idle',
11})
12
13const setHandoff = ($: EngineInterface, value: Handoff) => update($, handoff, () => value)
14
15const reasonOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
16
17const gitSnapshot = async ($: EngineInterface, cwd: string) => {
18  const git = (args: readonly string[]) =>
19    $.process.run(['git', ...args], { cwd, timeoutMs: 10_000 }).catch(() => undefined)
20
21  if ((await git(['rev-parse', '--is-inside-work-tree']))?.exitCode !== 0) {
22    return undefined
23  }
24  const outputs = await Promise.all(GIT_COMMANDS.map(git))
25
26  return formatGitSnapshot(outputs.map(output => output?.stdout))
27}
28
29// Forks the conversation into a handoff file. Throws, with a reason fit to show, when it cannot.
30const writeHandoff = async ($: EngineInterface): Promise<WrittenHandoff> => {
31  const [root, sessionId, model, usage, home] = await Promise.all([
32    $.session.root(),
33    $.session.id(),
34    $.session.model(),
35    $.session.usage(),
36    $.env.get('HOME'),
37  ])
38  if (home === undefined) {
39    throw new Error('HOME is not set')
40  }
41
42  const git = await gitSnapshot($, root)
43  const reply = await $.model.fork({ prompt: handoffPrompt(git, root) })
44  if (!reply.isAnswered) {
45    throw new Error(
46      reply.reason === 'nothing-to-fork'
47        ? 'nothing to hand off yet'
48        : `the model did not answer (${reply.reason})`,
49    )
50  }
51
52  const at = await $.clock.now()
53  const path = handoffPath(home, root, sessionId, at)
54  await $.fs.write(
55    path,
56    handoffDocument({
57      at,
58      root,
59      sessionId,
60      model,
61      startedAt: usage.startedAt,
62      contextPercent: usage.context.percent,
63      body: reply.text,
64      git,
65    }),
66  )
67
68  return { path, sessionId, at }
69}
70
71const describe = (result: SettledHandoff) =>
72  result.phase === 'ready'
73    ? `Handoff saved to ${result.path}. Press "Start fresh with handoff" above the prompt to load it into a clean context.`
74    : `Handoff failed: ${result.reason}`
75
76// What the row shows for each phase: a status line and the handoff button's label.
77const view = (state: Handoff, now: number) => {
78  switch (state.phase) {
79    case 'idle':
80      return { action: { key: 'create', label: 'Create handoff' } }
81    case 'working':
82      return { status: { color: 'yellow', text: `Writing handoff... ${formatElapsed(now - state.since)}` } }
83    case 'ready':
84      return {
85        status: { color: 'green', text: `Handoff ready (${formatAge(now - state.at)})` },
86        action: { key: 'regenerate', label: 'Regenerate' },
87      }
88    case 'failed':
89      return {
90        status: { color: 'red', text: `Handoff failed: ${state.reason}` },
91        action: { key: 'retry', label: 'Retry handoff' },
92      }
93  }
94}
95
96// Set synchronously before the first await, so a press and /handoff arriving
97// together share one fork instead of racing past a read-then-write guard.
98let inFlight: Promise<SettledHandoff> | undefined
99
100const createHandoff = ($: EngineInterface) => {
101  inFlight ??= (async () => {
102    await setHandoff($, { phase: 'working', since: await $.clock.now() })
103    const result = await writeHandoff($).then(
104      (written): SettledHandoff => ({ phase: 'ready', ...written }),
105      (error): SettledHandoff => ({ phase: 'failed', reason: reasonOf(error) }),
106    )
107    await setHandoff($, result)
108    $.ui.toast(describe(result))
109
110    return result
111  })().finally(() => {
112    inFlight = undefined
113  })
114
115  return inFlight
116}
117
118const startFresh = async ($: EngineInterface) => {
119  const current = await read($, handoff)
120  if (current.phase !== 'ready') {
121    return
122  }
123
124  // Read back from disk rather than state, so edits made to the file before
125  // starting fresh are what the new context receives.
126  const text = await $.fs.read(current.path).catch(() => undefined)
127  if (text === undefined) {
128    $.ui.toast(`Cannot read ${current.path}`)
129    return
130  }
131
132  // The session.end hook resets the handoff state once /clear has run.
133  const cleared = await $.command.run({ command: 'clear' }).then(
134    () => true,
135    error => {
136      $.ui.toast(`/clear failed: ${reasonOf(error)}`)
137      return false
138    },
139  )
140  if (!cleared) {
141    return
142  }
143
144  await $.prompt
145    .submit({ asUser: true, text: freshStartPrompt({ ...current, text }) })
146    .catch(error =>
147      $.ui.toast(`Context cleared but the handoff was not loaded (${reasonOf(error)}). It is saved at ${current.path}`),
148    )
149}
150
151export const register: Register = on => {
152  on('session.start', async ($, e, next) => {
153    const started = await next(e)
154
155    // A reload drops the old module's fork mid-flight; do not leave the row stuck on "Writing".
156    if ((await read($, handoff)).phase === 'working') {
157      await setHandoff($, { phase: 'failed', reason: 'interrupted by a reload of the mod' })
158    }
159    await $.command.register({
160      name: 'handoff',
161      description: 'Write a handoff for a fresh context window (session-handoff mod)',
162    })
163    $.clock.every(1000, () => $.ui.invalidate('ui.render'))
164
165    return started
166  })
167
168  on('session.end', async ($, e, next) => {
169    if (e.reason === 'clear') {
170      await setHandoff($, { phase: 'idle' })
171    }
172
173    return next(e)
174  })
175
176  on('command.run', { command: 'handoff' }, async $ => ({ text: describe(await createHandoff($)) }))
177
178  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179    if (e.props.hasSurvey) {
180      return next(e)
181    }
182
183    const [usage, now, state] = await Promise.all([
184      $.session.usage(),
185      $.clock.now(),
186      read($, handoff),
187    ])
188    const { status, action } = view(state, now)
189    const { Box, Button, Text } = $.ui.resolve(e)
190
191    return (
192      <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
193        <Text>
194          <Text dimColor>session </Text>
195          <Text bold>{formatElapsed(now - usage.startedAt)}</Text>
196        </Text>
197        {usage.context.percent !== undefined && (
198          <Text dimColor>context {usage.context.percent}%</Text>
199        )}
200        {status && <Text color={status.color}>{status.text}</Text>}
201        {state.phase === 'ready' && (
202          <Button
203            key="fresh"
204            label="Start fresh with handoff"
205            hotkey="n"
206            variant="primary"
207            onPress={() => void startFresh($)}
208          />
209        )}
210        {action && (
211          <Button
212            key={action.key}
213            label={action.label}
214            hotkey="h"
215            onPress={() => void createHandoff($)}
216          />
217        )}
218      </Box>
219    )
220  })
221}
222
hooks/document.ts 51 lines
1// Pure pieces of a handoff file. The engine follows `$` only within one file,
2// so everything that calls it stays in register.tsx and hands its results here.
3import { fileStamp, formatElapsed } from './time'
4
5export const GIT_COMMANDS = [
6  ['status', '--short', '--branch'],
7  ['diff', '--stat', 'HEAD'],
8  ['log', '--oneline', '-10'],
9]
10
11export const formatGitSnapshot = (outputs: readonly (string | undefined)[]) =>
12  GIT_COMMANDS.map((args, i) => {
13    const text = outputs[i]?.trim() || '(empty)'
14    return `### git ${args.join(' ')}\n\n\`\`\`\n${text}\n\`\`\``
15  }).join('\n\n')
16
17export const handoffPath = (home: string, root: string, sessionId: string, at: number) =>
18  `${home}/.claude/handoffs/${root.replace(/[^A-Za-z0-9]/g, '-')}/${fileStamp(at)}-${sessionId.slice(0, 8)}.md`
19
20// Each value as a JSON string, which is also a valid YAML double-quoted scalar.
21const frontMatter = (fields: Record<string, string>) =>
22  ['---', ...Object.entries(fields).map(([k, v]) => `${k}: ${JSON.stringify(v)}`), '---'].join(
23    '\n',
24  )
25
26export const handoffDocument = (handoff: {
27  at: number
28  root: string
29  sessionId: string
30  model: string
31  startedAt: number
32  contextPercent: number | undefined
33  body: string
34  git: string | undefined
35}) => {
36  const header = frontMatter({
37    generated: new Date(handoff.at).toISOString(),
38    project: handoff.root,
39    session: handoff.sessionId,
40    resume: `claude --resume ${handoff.sessionId}`,
41    model: handoff.model,
42    context_used: handoff.contextPercent === undefined ? 'unknown' : `${handoff.contextPercent}%`,
43    session_age: formatElapsed(handoff.at - handoff.startedAt),
44  })
45  const gitSection = handoff.git
46    ? `\n\n## Git snapshot (captured by the mod)\n\n${handoff.git}\n`
47    : '\n'
48
49  return `${header}\n\n${handoff.body.trim()}${gitSection}`
50}
51
hooks/prompts.ts 49 lines
1export const HANDOFF_PROMPT = `Stop working on the task. Your only job now is to write a handoff document for a brand-new Claude Code session that will have NONE of this conversation's context. Nothing else carries over, so favour completeness and precision over brevity: a missing detail costs the next session far more than an extra line.
2
3Output the markdown document only: no preamble, no closing remarks, no tool calls.
4
5Use exactly these sections:
6
7# Handoff: <short title of the work>
8
9## Goal
10What the user is ultimately trying to achieve, in their terms, and what "done" looks like.
11
12## Current state
13What is complete and verified, what is in progress (and how far), and what is broken or unverified.
14
15## Decisions and constraints
16Each decision made and why, options rejected and why, and every preference or constraint the user stated in this conversation. Quote the user verbatim where the wording matters.
17
18## Files, commands and references
19Every file path, directory, command, URL, ID, host, branch, config key and value that matters, each with one line on its role.
20
21## Gotchas and dead ends
22Approaches tried that failed and why, errors hit and how they were resolved, and anything that looks right but is not.
23
24## Open questions
25Unresolved decisions waiting on the user.
26
27## Next steps
28A numbered list, in order. The first step must be concrete enough to act on immediately.
29
30## How to verify
31Commands, tests or checks that confirm the work is correct.
32
33Rules:
34- Prefer exact names, paths, numbers and error text over paraphrase.
35- Never invent anything that is not in the conversation. If something is uncertain, say so.
36- Write "None" under a section that is genuinely empty.
37- Use British English and never use the em dash character.`
38
39export const handoffPrompt = (git: string | undefined, root: string) =>
40  git ? `${HANDOFF_PROMPT}\n\nCurrent git state of ${root}, captured just now:\n\n${git}` : HANDOFF_PROMPT
41
42export const freshStartPrompt = (handoff: { path: string; sessionId: string; text: string }) =>
43  `I'm continuing work from a previous Claude Code session. Its handoff is below ` +
44  `(saved at ${handoff.path}). If something important is missing, the full earlier ` +
45  `transcript is session ${handoff.sessionId}.\n\n` +
46  `Read the handoff, briefly check the current state against it (files, git status, ` +
47  `whatever applies), then tell me where things stand and confirm the next step ` +
48  `before making changes.\n\n---\n\n${handoff.text}`
49
hooks/time.ts 26 lines
1const pad = (n: number) => String(n).padStart(2, '0')
2
3export const formatElapsed = (ms: number) => {
4  const total = Math.max(0, Math.floor(ms / 1000))
5  const h = Math.floor(total / 3600)
6  const m = Math.floor((total % 3600) / 60)
7  const s = total % 60
8
9  return h > 0 ? `${h}h ${pad(m)}m ${pad(s)}s` : `${m}m ${pad(s)}s`
10}
11
12export const formatAge = (ms: number) => {
13  const minutes = Math.floor(ms / 60_000)
14
15  return minutes < 1 ? 'just now' : `${minutes}m ago`
16}
17
18export const fileStamp = (ms: number) => {
19  const d = new Date(ms)
20
21  return (
22    `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
23    `-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
24  )
25}
26
types/index.d.ts 16 lines
1export type WrittenHandoff = { path: string; sessionId: string; at: number }
2
3export type Handoff =
4  | { phase: 'idle' }
5  | { phase: 'working'; since: number }
6  | ({ phase: 'ready' } & WrittenHandoff)
7  | { phase: 'failed'; reason: string }
8
9export type SettledHandoff = Extract<Handoff, { phase: 'ready' | 'failed' }>
10
11declare module 'claude-code' {
12  interface PluginState {
13    'session-handoff': { handoff: Handoff }
14  }
15}
16