SLOPSHOPPER

handoff-nudge

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

newbandguardpromptmodel
A shopper browsing a rack in a slop shop
README

handoff-nudge

Nudges you to /handoff before a long conversation runs out of room, and offers to resume from a recent handoff in a new one.

Handoff band

Once the context passes the Nudge threshold, a band shows above the prompt.

Handoff band above the prompt

  • Suggest handoff asks the session's model what the next session should focus on and fills /handoff <focus> into the prompt.
  • The band shows earlier when the threshold would sit past auto-compaction or the model's window: at 90% of whichever comes first, so it never misses its chance.
  • If /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.

Resume band

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

Resume band listing recent handoff docs

  • Resume fills the doc's path into the prompt so you can add what to do next.
  • A doc stops being offered once you send a prompt containing its path or Claude reads it.
  • Works on macOS, Linux, and Windows: it looks for docs in $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 /.
  • Terminal only: the desktop app starts Claude Code with the first message, so a new conversation there is never empty when the band could draw.

Settings

SettingDefaultOptions
Nudge threshold200k100k, 150k, 200k, 300k, 500k

Change it in /config.

Install

/plugin install handoff-nudge --marketplace thitiwats/claude-mods

Develop

claude plugin validate plugins/handoff-nudge
claude plugin test plugins/handoff-nudge
Source 2 files
hooks/register.tsx 342 lines
1import { 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}
342
types/index.d.ts 27 lines
1export 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