Band above the prompt suggesting /handoff once context passes a set size (200k by default), and offering to resume a recent handoff in a new conversation

Nudges you to /handoff before a long conversation runs out of room, and offers to resume from a recent handoff in a new one.
Once the context passes the Nudge threshold, a band shows above the prompt.
/handoff <focus> into the prompt./handoff is not installed, the band offers Install /handoff instead. It fills the skills.sh install command (mattpocock/skills) into the prompt for you to run: as a ! shell line in the terminal, or as a request for Claude to run it in the desktop app, which has no ! shell mode.In a new or cleared conversation, a Resume from a handoff band lists up to three handoff docs /handoff wrote in this project during the last week. "This project" means its folder, a subfolder, or a folder above it other than your home folder or /.
$TMPDIR or /tmp, and on Windows in %TEMP% or %TMP%, where your user folder (%USERPROFILE%) and drive roots such as C:\ count like home and /.| Setting | Default | Options |
|---|---|---|
| Nudge threshold | 200k | 100k, 150k, 200k, 300k, 500k |
Change it in /config.
/plugin install handoff-nudge --marketplace thitiwats/claude-mods
claude plugin validate plugins/handoff-nudge
claude plugin test plugins/handoff-nudgehooks/register.tsx 342 lines1import { atom, read, update } from 'claude-code'
2import type { Register, RenderChildren } from 'claude-code'
3
4import type { Handoff, Suggestion } from '../types'
5
6// Room left below auto-compaction or a full window, so the band shows while there is still time to hand off.
7const HEADROOM = 0.9
8
9// What the band compares the context to: the chosen threshold, pulled below where compaction runs or the window ends.
10const effectiveThreshold = (chosen: number, window: number, autoCompactAt: number | undefined) =>
11 Math.floor(Math.min(chosen, ...[window, autoCompactAt ?? 0].filter(n => n > 0).map(n => n * HEADROOM)))
12
13const HANDOFFS_KEY = 'handoffs'
14const MAX_KEPT = 20
15const MAX_SHOWN = 3
16const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
17
18// Windows paths use backslashes and ignore case; compare every path in one form.
19const normalize = (path: string) => {
20 const slashed = path.replace(/\\/g, '/')
21 return /^[a-z]:\//i.test(slashed) ? slashed.toLowerCase() : slashed
22}
23
24const withSlash = (folder: string) => (folder.endsWith('/') ? folder : `${folder}/`)
25
26// /handoff saves to $TMPDIR (else /tmp); macOS resolves /tmp through /private/tmp; Windows sets %TEMP% and %TMP%.
27const TMP_ROOTS = ['/tmp/', '/private/tmp/']
28
29const isHandoffDoc = (path: string, tmpdirs: (string | undefined)[]) => {
30 const doc = normalize(path)
31 const roots = [...TMP_ROOTS, ...tmpdirs.filter(dir => dir !== undefined && dir !== '').map(dir => withSlash(normalize(dir)))]
32 return /handoff/i.test(doc) && doc.endsWith('.md') && roots.some(root => doc.startsWith(root))
33}
34
35const isUnder = (path: string, folder: string) => normalize(path).startsWith(withSlash(normalize(folder)))
36
37// A doc written in a subfolder of this project, or above it, still belongs here; home, / and a drive root hold every project, so they never count.
38const isNearby = (docCwd: string, cwd: string, home: string | undefined) => {
39 const isRoot = (folder: string) =>
40 /^(?:[a-z]:)?\/?$/.test(normalize(folder)) || (home !== undefined && normalize(folder) === normalize(home))
41 return (
42 normalize(docCwd) === normalize(cwd) ||
43 (isUnder(cwd, docCwd) && !isRoot(docCwd)) ||
44 (isUnder(docCwd, cwd) && !isRoot(cwd))
45 )
46}
47
48const asHandoffs = (value: unknown) => (Array.isArray(value) ? (value as Handoff[]) : [])
49
50// Returns the list with matching docs marked resumed, or undefined when none changed.
51const markResumed = (all: Handoff[], isMatch: (path: string) => boolean) =>
52 all.some(doc => !doc.isResumed && isMatch(doc.path))
53 ? all.map(doc => (isMatch(doc.path) ? { ...doc, isResumed: true } : doc))
54 : undefined
55
56const formatAge = (ms: number) => {
57 const minutes = Math.max(1, Math.round(ms / 60_000))
58 if (minutes < 60) return `${minutes}m ago`
59 const hours = Math.round(minutes / 60)
60 return hours < 48 ? `${hours}h ago` : `${Math.round(hours / 24)}d ago`
61}
62
63const basename = (path: string) => path.split(/[\\/]/).filter(Boolean).slice(-2).join('/')
64
65// Installs /handoff from skills.sh when it is missing: `universal` keeps the files in ~/.agents/skills and symlinks Claude Code's copy to them.
66const INSTALL_COMMAND = 'npx -y skills add mattpocock/skills -s handoff -g -a universal claude-code -y'
67
68// The terminal runs a `!` line as shell; the desktop app sends it to Claude as a message, so ask Claude there.
69const installPrompt = (surface: string) =>
70 surface === 'terminal'
71 ? `! ${INSTALL_COMMAND}`
72 : `Install the /handoff skill by running this shell command: ${INSTALL_COMMAND}`
73
74// The reply is only the /handoff argument; it must stay one line because it is filled into the prompt.
75const SUGGEST_PROMPT = [
76 'This conversation is about to be handed off via /handoff to a fresh agent that has no memory of it.',
77 'Your reply becomes the /handoff argument: it tells the handoff document what to focus on.',
78 "State, from this conversation only: the user's current goal (their latest intent, not superseded ones);",
79 'what is done and what is not; the exact next step; decisions or constraints the user set; approaches already ruled out.',
80 'Name concrete files, commands and identifiers. Do not guess: leave out anything the conversation does not establish,',
81 'and if the work is finished, say so instead of inventing a next step. Never include secrets.',
82 'Reply with ONE line, at most 60 words, in the language the user writes in, parts separated by semicolons.',
83 'No preamble, no quotes, no markdown.',
84].join(' ')
85
86const IDLE: Suggestion = { status: 'idle' }
87
88const BAR_WIDTH = 20
89
90const bar = (current: number, window: number) => {
91 const filled = Math.min(BAR_WIDTH, Math.round((current / window) * BAR_WIDTH))
92 return { filled: '▰'.repeat(filled), empty: '▱'.repeat(BAR_WIDTH - filled) }
93}
94
95// Desktop draws the fill as a real bar; the terminal has no Svg.
96const barSvg = (current: number, window: number) => {
97 const fill = Math.min(100, (current / window) * 100).toFixed(1)
98 // No viewBox, so rx stays in pixels and both ends are true semicircles at any width.
99 return [
100 '<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="6">',
101 '<rect width="100%" height="6" rx="3" fill="#8883" />',
102 `<rect width="${fill}%" height="6" rx="3" fill="#e0af68" />`,
103 '</svg>',
104 ].join('')
105}
106
107const formatK = (n: number) => (n >= 1_000_000 ? `${n / 1_000_000}M` : `${Math.round(n / 1000)}k`)
108
109const tokens = atom({ plugin: 'handoff-nudge', key: 'tokens' } as const, null)
110const threshold = atom({ plugin: 'handoff-nudge', key: 'threshold' } as const, 0)
111const contextWindow = atom({ plugin: 'handoff-nudge', key: 'window' } as const, 0)
112const isHidden = atom({ plugin: 'handoff-nudge', key: 'isHidden' } as const, false)
113const suggestion = atom({ plugin: 'handoff-nudge', key: 'suggestion' } as const, IDLE)
114const hasHandoff = atom({ plugin: 'handoff-nudge', key: 'hasHandoff' } as const, true)
115const isResumeHidden = atom({ plugin: 'handoff-nudge', key: 'isResumeHidden' } as const, false)
116const isConversationEmpty = atom({ plugin: 'handoff-nudge', key: 'isConversationEmpty' } as const, true)
117
118export const register: Register = (on, options) => {
119 const chosen = Number.parseInt(String(options.nudgeThreshold), 10) * 1000
120 // Remember each handoff doc this session writes, so the next session in this cwd can offer it.
121 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
122 const ran = await next(e)
123 const tmpdirs = await Promise.all([$.env.get('TMPDIR'), $.env.get('TEMP'), $.env.get('TMP')])
124 if (ran.deny !== undefined || ran.isError === true || !isHandoffDoc(e.file_path, tmpdirs)) {
125 return ran
126 }
127 const entry: Handoff = { path: e.file_path, cwd: await $.session.cwd(), at: await $.clock.now() }
128 const kept = asHandoffs(await $.store.get(HANDOFFS_KEY)).filter(doc => normalize(doc.path) !== normalize(entry.path))
129 await $.store.set(HANDOFFS_KEY, [entry, ...kept].slice(0, MAX_KEPT))
130 return ran
131 })
132
133 // A doc counts as resumed once Claude reads it, however its path got into the prompt.
134 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
135 const ran = await next(e)
136 if (ran.deny === undefined && ran.isError !== true) {
137 const marked = markResumed(asHandoffs(await $.store.get(HANDOFFS_KEY)), path => normalize(path) === normalize(e.file_path))
138 if (marked) await $.store.set(HANDOFFS_KEY, marked)
139 }
140 return ran
141 })
142
143 // Re-arm the resume band for the next empty conversation (/clear keeps this session's state).
144 on('prompt.submit', async ($, e, next) => {
145 // Pressing Resume only fills the prompt; the doc counts as resumed once that prompt is sent.
146 const marked = markResumed(asHandoffs(await $.store.get(HANDOFFS_KEY)), path => e.text.includes(path))
147 if (marked) await $.store.set(HANDOFFS_KEY, marked)
148 await update($, isResumeHidden, () => false)
149 await update($, isConversationEmpty, () => false)
150 return next(e)
151 })
152
153 // `claude --resume` / `--continue` start with turns already in the transcript.
154 on('session.start', async ($, e, next) => {
155 const isEmpty = (await $.session.turns()) === 0
156 await update($, isConversationEmpty, () => isEmpty)
157 return next(e)
158 })
159
160 // The band's drawing is cached; this write is what redraws it after /clear.
161 // turns() can't tell: the cleared transcript starts with the /clear command, a user turn.
162 on('session.end', async ($, e, next) => {
163 await update($, isConversationEmpty, () => e.reason === 'clear')
164 return next(e)
165 })
166
167 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
168 const rest = await next(e)
169 // Only an empty conversation (a new session or after /clear) is starting from a handoff.
170 if (e.props.hasSurvey || (await read($, isResumeHidden)) || !(await read($, isConversationEmpty))) {
171 return rest
172 }
173
174 const cwd = await $.session.cwd()
175 const now = await $.clock.now()
176 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
177 const candidates = asHandoffs(await $.store.get(HANDOFFS_KEY)).filter(
178 doc => isNearby(doc.cwd, cwd, home) && !doc.isResumed && now - doc.at < MAX_AGE_MS,
179 )
180 const shown: Handoff[] = []
181 for (const doc of candidates) {
182 if (shown.length === MAX_SHOWN) break
183 // The OS clears $TMPDIR on its own schedule; skip docs that are gone.
184 if (await $.fs.exists(doc.path)) shown.push(doc)
185 }
186 if (shown.length === 0) {
187 return rest
188 }
189
190 const { Box, Button, Text } = $.ui.resolve(e)
191 const frame =
192 e.surface === 'desktop'
193 ? ({ rowGap: 1, paddingX: 1, paddingY: 1 } as const)
194 : ({ borderStyle: 'round', borderColor: 'suggestion', paddingX: 1 } as const)
195
196 const resume = async (doc: Handoff) => {
197 await update($, isResumeHidden, () => true)
198 // Trailing space: the user usually adds what to do next after the path.
199 await $.prompt.fill({ text: `${doc.path} ` })
200 }
201
202 return (
203 <Box flexDirection="column">
204 {rest}
205 <Box flexDirection="column" {...frame}>
206 <Box justifyContent="space-between" alignItems="center" columnGap={2}>
207 <Text color="suggestion" bold>
208 ↻ Resume from a handoff
209 </Text>
210 <Button key="resume-hide" label="Hide" onPress={() => update($, isResumeHidden, () => true)} />
211 </Box>
212 {shown.map((doc, i) => (
213 <Box key={doc.path} justifyContent="space-between" alignItems="center" columnGap={2}>
214 <Text>
215 {basename(doc.path)} <Text dimColor>· {formatAge(now - doc.at)}</Text>
216 </Text>
217 <Button key={`resume-${i}`} variant={i === 0 ? 'primary' : undefined} label="Resume" onPress={() => resume(doc)} />
218 </Box>
219 ))}
220 </Box>
221 </Box>
222 )
223 })
224
225 on('session.measure', async ($, e, next) => {
226 const current = e.context.tokens ?? null
227 await update($, tokens, () => current)
228 await update($, contextWindow, () => e.context.window)
229 // Only the breakdown says where auto-compaction runs; `summary` counts locally, sending nothing.
230 const { context } = await $.session.usage({ breakdown: 'summary' })
231 const at = effectiveThreshold(chosen, e.context.window, context.breakdown?.autoCompactThreshold)
232 await update($, threshold, () => at)
233
234 // Re-arm the band once context drops back under the threshold (compact, /clear).
235 if (current === null || current < at) {
236 await update($, isHidden, () => false)
237 await update($, suggestion, () => IDLE)
238 } else {
239 const found = (await $.command.list()).some(c => c.name === 'handoff')
240 await update($, hasHandoff, () => found)
241 }
242
243 return next(e)
244 })
245
246 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
247 // Keep what Claude Code and other mods draw here; the card goes below it.
248 const rest = await next(e)
249 const current = await read($, tokens)
250 const isQuiet =
251 e.props.hasSurvey || current === null || current < (await read($, threshold)) || (await read($, isHidden))
252
253 if (isQuiet) {
254 return rest
255 }
256
257 const { Box, Button, Text } = $.ui.resolve(e)
258 const window = await read($, contextWindow)
259 const s = await read($, suggestion)
260 const isMissing = !(await read($, hasHandoff))
261 const percent = window > 0 ? Math.round((current / window) * 100) : undefined
262 const { filled, empty } = bar(current, window)
263
264 let meter: RenderChildren
265 if (e.surface === 'desktop') {
266 const { Svg } = $.ui.resolve(e)
267 meter = <Svg source={barSvg(current, window)} alt={`${percent}% of the context window used`} height={6} />
268 } else {
269 meter = (
270 <Text>
271 <Text color="warning">{filled}</Text>
272 <Text dimColor>{empty}</Text>
273 </Text>
274 )
275 }
276
277 // The desktop band already sits in a panel: space the rows out instead of framing them.
278 const frame =
279 e.surface === 'desktop'
280 ? ({ rowGap: 1, paddingX: 1, paddingY: 1 } as const)
281 : ({ borderStyle: 'round', borderColor: 'warning', paddingX: 1 } as const)
282
283 const card = (body: RenderChildren, buttons: RenderChildren, footer?: RenderChildren) => (
284 <Box flexDirection="column">
285 {rest}
286 <Box flexDirection="column" {...frame}>
287 <Box justifyContent="space-between">
288 <Text color="warning" bold>
289 ⚠ Context {formatK(current)}
290 {window > 0 && <Text dimColor> / {formatK(window)}</Text>}
291 </Text>
292 {percent !== undefined && <Text dimColor>{percent}%</Text>}
293 </Box>
294 {window > 0 && meter}
295 <Box justifyContent="space-between" alignItems="center" columnGap={2}>
296 {body}
297 <Box columnGap={1}>{buttons}</Box>
298 </Box>
299 {footer}
300 </Box>
301 </Box>
302 )
303
304 const hide = <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
305
306 if (isMissing) {
307 return card(
308 <Text dimColor>/handoff skill is not installed.</Text>,
309 [<Button key="install" label="Install /handoff" onPress={press => $.prompt.fill({ text: installPrompt(press.surface) })} />, hide],
310 )
311 }
312
313 const suggest = async () => {
314 await update($, suggestion, () => ({ status: 'loading' }))
315 const reply = await $.model.fork({ prompt: SUGGEST_PROMPT })
316 const focus = reply.isAnswered ? reply.text.trim().split('\n')[0]?.trim() : undefined
317 const result: Suggestion = focus
318 ? { status: 'ready', focus }
319 : { status: 'failed', reason: reply.isAnswered ? 'empty reply' : reply.reason }
320 await update($, suggestion, () => result)
321 if (focus) {
322 await $.prompt.fill({ text: `/handoff ${focus}` })
323 }
324 }
325
326 return card(
327 <Text dimColor>Long session — hand off to a fresh one.</Text>,
328 [
329 <Button
330 key="suggest"
331 variant="primary"
332 label={s.status === 'loading' ? 'Suggesting…' : 'Suggest handoff'}
333 onPress={() => (s.status === 'loading' ? undefined : suggest())}
334 />,
335 hide,
336 ],
337 // A ready focus is already in the prompt; only a failure needs saying here.
338 s.status === 'failed' ? <Text color="error">↳ Could not suggest: {s.reason}</Text> : undefined,
339 )
340 })
341}
342types/index.d.ts 27 lines1export type Tokens = number | null
2
3/** The fork's handoff suggestion: idle, running, a focus line to pass to /handoff, or why it failed. */
4export type Suggestion =
5 | { status: 'idle' }
6 | { status: 'loading' }
7 | { status: 'ready'; focus: string }
8 | { status: 'failed'; reason: string }
9
10/** A handoff document /handoff wrote, kept in $.store so a later session in the same cwd can resume it. */
11export type Handoff = { path: string; cwd: string; at: number; isResumed?: boolean }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'handoff-nudge': {
16 tokens: Tokens
17 threshold: number
18 window: number
19 isHidden: boolean
20 suggestion: Suggestion
21 hasHandoff: boolean
22 isResumeHidden: boolean
23 isConversationEmpty: boolean
24 }
25 }
26}
27