Band above the prompt with a session timer, a handoff writer and a fresh-start button that loads the 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 ]
/compact?/compact replaces your conversation with a summary, so anything the summary misses is gone. This mod leaves the conversation alone and instead:
git status, diff stat and recent log, captured by the mod rather than recalled by the model.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.
git on your PATH (optional; the git snapshot is skipped outside a repo).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"
| Action | How |
|---|---|
| Write a handoff | Click Create handoff, or type /handoff |
| Start a clean context with it | Click Start fresh with handoff |
| Rewrite a stale handoff | Click Regenerate |
| Use the keyboard | ctrl+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.
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.
| File | Role |
|---|---|
hooks/register.tsx | Everything that calls Claude Code: the hooks, the handoff state machine, writing the file, fresh start and the row's UI |
hooks/prompts.ts | The handoff prompt and the fresh-start message. Edit here to change what a handoff contains |
hooks/document.ts | Pure helpers that build the handoff file: path, YAML front matter, git snapshot section |
hooks/time.ts | Time formatting |
types/index.d.ts | The handoff state the row draws from |
tests/handoff.test.tsx | Tests 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.
hooks/register.tsx 222 lines1import { 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}
222hooks/document.ts 51 lines1// 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}
51hooks/prompts.ts 49 lines1export 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}`
49hooks/time.ts 26 lines1const 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}
26types/index.d.ts 16 lines1export 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