SLOPSHOPPER

handoff-relay

A Handoff button above the prompt: runs /handoff and lights up from 180k tokens of context (the threshold option). Joins cache-meter and next-steps in one band.

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

Draws

Band
[ Handoff ] ⟨Claude Code's own drawing⟩
README

handoff-relay

English | Українська

A Handoff button above the prompt. Pressing it runs /handoff: the session writes a handoff document for the next session. From 180k tokens of context (the threshold option) the button lights up, because by then a fresh session is usually cheaper and sharper than this one.

If you have your own /handoff, the button runs yours. Otherwise it runs the handoff skill this mod brings.

How it works

hooks/register.tsx, function hooks:

  • session.start and session.measure: track how many tokens the context holds.
  • The button: $.command.run with /handoff. While the handoff turn runs, the band says it is writing the document.
  • The handoff is done when, during the handoff turn, a .md file was written and the turn ended with an answer, not a question, an interrupt or an error. Then the button gives way to "Handoff created: <document name>", so a second press does not overwrite the document just written. This works with any /handoff, wherever it saves the document, and in the terminal too.
  • A handoff turn is one started by the button, by /handoff typed by hand, or by the model calling a handoff skill.
  • When the handoff also creates the next session's card (spawn_task in the desktop app), the band says so at once and names the card.
  • With cache-meter installed, the button also lights up when a big cache is about to go cold or already has, with a hint on what that means for the handoff.

The handoff skill

skills/handoff/ writes the document into .claude/handoffs/ under the project root, or into the folder the project's CLAUDE.md names. Handoffs form chains: the session that picks one up is titled <name> H<N>, and each phase gets its own file. scripts/phase.py (python3, standard library only) works out the names; without python3 the skill applies the same rules itself. In the desktop app the skill creates a card that starts the next session; in the terminal it prints the start prompt to paste.

Options

OptionDefaultWhat it does
languageautoauto follows Claude's response language from /config; en or uk set it
threshold180000Tokens of context from which the button lights up

Checks

claude plugin validate .
claude plugin test .
python3 -m unittest discover -s skills/handoff/scripts
Source 6 files
hooks/register.tsx 273 lines
1// SPDX-License-Identifier: MIT
2import { joinBand } from './band.mjs'
3import { isLanguageKey, resolveLanguage } from './i18n.mjs'
4import en from './locales/en.mjs'
5import uk from './locales/uk.mjs'
6import { atom, read, update } from 'claude-code'
7import type { Hook, Register } from 'claude-code'
8
9import type { CacheMeterView, HandedOff, Tokens } from '../types'
10
11// Where the button lights up unless the threshold option says otherwise. Counted in tokens, not
12// percent: model windows differ, while a session grows heavy at roughly the same mark.
13const DEFAULT_THRESHOLD = 180_000
14const thresholdOf = (value: unknown) =>
15  typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_THRESHOLD
16
17// 'run' runs /handoff at once; 'fill' only puts it into the prompt and the person presses Enter.
18const MODE = 'run' as 'run' | 'fill'
19
20// cache-meter's state when that mod is installed. This mod works without it: the value is just absent.
21const cacheMeter = { plugin: 'cache-meter', key: 'cache' } as const
22type Engine = Parameters<Hook<'ui.render'>>[0]
23
24// Text in the language the language option picks (auto: Claude's response language from /config).
25const LOCALES = { en, uk }
26let L = en
27let language: unknown = 'auto'
28let isLanguagePicked = false
29const pickLanguage = async ($: Engine) => {
30  isLanguagePicked = true
31  let rows: readonly { key: string; value: unknown }[] = []
32  if (language !== 'en' && language !== 'uk') {
33    try {
34      rows = await $.config.list()
35    } catch {
36      rows = [] // no /config here (a test, a -p run): English
37    }
38  }
39  L = LOCALES[resolveLanguage(language, rows)]
40}
41// Another mod's key is not in our contract, so the signature is cast where it is called.
42type GetCacheMeter = (ref: typeof cacheMeter) => Promise<{ value?: CacheMeterView }>
43const readCacheMeter = async ($: Engine): Promise<CacheMeterView | null> => {
44  try {
45    return (await ($.state.get as unknown as GetCacheMeter)(cacheMeter)).value ?? null
46  } catch {
47    return null
48  }
49}
50
51// The person's own /handoff wins; otherwise the skill this mod brings. The engine may name a
52// plugin's skill with a prefix (handoff-relay:handoff), so any `<plugin>:handoff` counts.
53const isHandoffName = (name: string) => name === 'handoff' || name.endsWith(':handoff')
54const handoffCommand = async ($: Engine): Promise<string> => {
55  try {
56    const names = (await $.command.list()).map(command => command.name)
57    return names.includes('handoff') ? 'handoff' : (names.find(name => name.endsWith(':handoff')) ?? 'handoff')
58  } catch {
59    return 'handoff'
60  }
61}
62
63const tokens = atom({ plugin: 'handoff-relay', key: 'tokens' } as const, null as Tokens)
64const isPending = atom({ plugin: 'handoff-relay', key: 'isPending' } as const, false)
65const isHandoffTurn = atom({ plugin: 'handoff-relay', key: 'isHandoffTurn' } as const, false)
66const writtenDoc = atom({ plugin: 'handoff-relay', key: 'writtenDoc' } as const, null as string | null)
67const handedOff = atom({ plugin: 'handoff-relay', key: 'handedOff' } as const, null as HandedOff)
68
69const isDone = (result: { deny?: unknown; isError?: boolean }) => result.deny === undefined && result.isError !== true
70
71// Shared by both card tools; each hook is typed by its own tool.
72const markHandedOff = async ($: Engine, title: unknown, result: { deny?: unknown; isError?: boolean }) => {
73  if (isDone(result) && (await read($, isHandoffTurn))) {
74    await update($, handedOff, () => ({ title: typeof title === 'string' && title ? title : L.nextPhase, hasCard: true }))
75  }
76}
77
78// A handoff the button did not start still opens a handoff turn and shows the pending line,
79// so the button is out of the way until the turn ends, just as after a press.
80const startHandoffTurn = async ($: Engine) => {
81  await update($, isHandoffTurn, () => true)
82  await update($, isPending, () => true)
83}
84
85// The document's name for the band: the file name without its folder and `.md`.
86const docTitle = (path: string) => path.split(/[\\/]/).pop()!.replace(/\.md$/i, '')
87
88export const register: Register = (on, options) => {
89  language = options.language
90  const threshold = thresholdOf(options.threshold)
91  if (language === 'en' || language === 'uk') L = LOCALES[language]
92
93  on('session.start', async ($, e, next) => {
94    await pickLanguage($)
95    const usage = await $.session.usage()
96    await update($, tokens, () => usage.context.tokens ?? null)
97
98    return next(e)
99  })
100
101  on('session.measure', async ($, e, next) => {
102    if (e.changed.includes('context')) {
103      await update($, tokens, () => e.context.tokens ?? null)
104    }
105
106    return next(e)
107  })
108
109  // Claude's language changed in /config: under auto the mod follows it.
110  on('config.set', async ($, e, next) => {
111    const result = await next(e)
112    if (isLanguageKey(e.key)) {
113      await pickLanguage($)
114      $.ui.invalidate('ui.render')
115    }
116    return result
117  })
118
119  // A /handoff typed by hand.
120  on('command.run', async ($, e, next) => {
121    if (isHandoffName(e.command)) await startHandoffTurn($)
122
123    return next(e)
124  })
125
126  // So does the handoff skill when the model calls it ("write a handoff").
127  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
128    if (isHandoffName(e.skill)) await startHandoffTurn($)
129
130    return next(e)
131  })
132
133  // A .md file written during the handoff turn is the document. It counts once the turn ends with
134  // an answer: a turn that stopped on a question or an error has not handed anything off.
135  // This is the signal that works with any /handoff and in the terminal, where there is no card.
136  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
137    const result = await next(e)
138    // A write held for review (staged) left the file unchanged.
139    const isWritten = isDone(result) && (result.result as { staged?: boolean } | undefined)?.staged !== true
140    if (isWritten && /\.md$/i.test(e.file_path) && (await read($, isHandoffTurn)) && (await read($, writtenDoc)) === null) {
141      await update($, writtenDoc, () => docTitle(e.file_path))
142    }
143    return result
144  })
145
146  // The next phase's card created during the handoff turn (the desktop app) hands off at once, under
147  // the card's title: pressing again would overwrite the document just written and make a second card.
148  // start_session is there for when an account gets it instead of the card.
149  on('tool.call', { tool: 'mcp__ccd_session__spawn_task' }, async ($, e, next) => {
150    const result = await next(e)
151    await markHandedOff($, e.title, result)
152    return result
153  })
154  on('tool.call', { tool: 'mcp__ccd_session_mgmt__start_session' }, async ($, e, next) => {
155    const result = await next(e)
156    await markHandedOff($, e.title, result)
157    return result
158  })
159
160  // The turn ended: the button is back unless the phase was handed off. Subagent turns do not count.
161  on('turn.complete', async ($, e, next) => {
162    if (e.agentId === undefined) {
163      const doc = await read($, writtenDoc)
164      if (e.reason === 'answer' && doc !== null && (await read($, isHandoffTurn))) {
165        await update($, handedOff, (current) => current ?? { title: doc, hasCard: false })
166      }
167      await update($, writtenDoc, () => null)
168      await update($, isPending, () => false)
169      await update($, isHandoffTurn, () => false)
170    }
171
172    return next(e)
173  })
174
175  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
176    if (e.props.hasSurvey) {
177      return next(e)
178    }
179
180    // Whatever the mods beneath draw (cache-meter's row, say) stays in the band.
181    const below = await next(e)
182    if (!isLanguagePicked) await pickLanguage($)
183    const { Box, Button, Text } = $.ui.resolve(e)
184    const used = await read($, tokens)
185    const pending = await read($, isPending)
186    // Up to 0.2.0 this value was the card's title alone; a hot reload mid-session keeps it.
187    const stored = (await read($, handedOff)) as HandedOff | string
188    const done = typeof stored === 'string' ? { title: stored, hasCard: true } : stored
189    const cache = await readCacheMeter($)
190    // The handoff reads the whole context: cheap while the cache is warm, one rewrite after.
191    // cache-meter's row shows the amount; here only what it means for the handoff.
192    const coldHint =
193      cache?.isBig && cache.kind === 'cooling'
194        ? L.cooling
195        : cache?.kind === 'cold' && cache.isBig
196          ? L.cold
197          : null
198    const isHeavy = (used !== null && used >= threshold) || coldHint !== null
199
200    let mine
201
202    if (done !== null) {
203      // On one line when it fits; otherwise the hint goes to a second line rather than breaking mid-word.
204      const hint = done.hasCard ? L.launchHint : L.continueHint
205      const head = `${L.created}${done.title}.`
206      const isOneLine = head.length + 1 + hint.length <= e.props.bodyColumns
207
208      mine = (
209        <Box key="handed-off" flexDirection={isOneLine ? 'row' : 'column'}>
210          <Text>
211            <Text dimColor>{L.created}</Text>
212            <Text bold>{done.title}</Text>
213            <Text dimColor>.</Text>
214          </Text>
215          <Text dimColor wrap="wrap">
216            {isOneLine ? ' ' : ''}
217            {hint}
218          </Text>
219        </Box>
220      )
221    } else if (pending) {
222      mine = (
223        <Box key="handoff-pending">
224          <Text dimColor>{L.pending}</Text>
225        </Box>
226      )
227    } else {
228      const press = async () => {
229        if ((await read($, isPending)) || (await read($, handedOff)) !== null) {
230          return
231        }
232
233        await update($, isPending, () => true)
234
235        if (MODE === 'fill') {
236          await $.prompt.fill({ text: '/handoff' })
237          await update($, isPending, () => false)
238          return
239        }
240
241        // The plugin's own $.command.run skips its own command.run hook, so the turn is marked here.
242        await update($, isHandoffTurn, () => true)
243
244        try {
245          await $.command.run({ command: await handoffCommand($) })
246        } catch (error) {
247          await update($, isPending, () => false)
248          await update($, isHandoffTurn, () => false)
249          $.ui.toast(L.failed(String(error)))
250        }
251      }
252
253      const hint = coldHint ?? (isHeavy ? L.heavy(Math.round((used ?? 0) / 1000)) : null)
254
255      mine = (
256        <Box key="handoff-row">
257          <Button
258            key="handoff"
259            label={L.button}
260            variant={isHeavy ? 'primary' : undefined}
261            dimColor={!isHeavy}
262            onPress={press}
263          />
264          {hint !== null ? <Text dimColor>{hint}</Text> : null}
265        </Box>
266      )
267    }
268
269    // Our place in the band this repo's mods share, whatever order they loaded in.
270    return joinBand(Box, 'handoff-relay', mine, below)
271  })
272}
273
hooks/band.mjs 49 lines
1// SPDX-License-Identifier: MIT
2// The shared band above the prompt for this repository's mods. The file is the same in
3// every mod (scripts/sync-shared.sh puts the copy there), since a mod is also installed
4// alone, without its neighbours.
5//
6// The engine chains ui.render hooks, and the order of plugins in the chain is not
7// documented: it depends on how and in what order they were installed. So a mod does not
8// put its row "above" or "below" whatever came from further down; it places it in a shared
9// column by its own slot. Whoever is on top of the chain, the band comes out the same:
10// Handoff, cache, "What next?".
11
12export const BAND = 'prompt-band'
13const ROW = 'prompt-band-row:'
14
15// A row's slot in the band. Anything foreign (the engine's row or a mod from elsewhere) goes below ours.
16export const PLACE = { 'handoff-relay': 10, 'cache-meter': 20, 'next-steps': 30 }
17const FOREIGN = 999
18
19/** @param {any} row */
20function placeOf(row) {
21  const key = row && typeof row === 'object' && row.props ? String(row.props.key ?? '') : ''
22  return key.startsWith(ROW) ? Number(key.slice(ROW.length).split(':')[0]) : FOREIGN
23}
24
25// The rows already in the band below us, or whatever someone else drew.
26/** @param {(props: any) => any} Box @param {any} below @returns {any[]} */
27function rowsOf(Box, below) {
28  if (below === null || below === undefined || below === false) return []
29  if (typeof below === 'object' && below.type === 'Box' && below.props && below.props.key === BAND) {
30    return [...(below.children ?? [])]
31  }
32  return [Box({ key: `${ROW}${FOREIGN}:other`, flexDirection: 'column', children: [below] })]
33}
34
35// The band with mod `name`'s row in its slot. Half a line between rows: a whole one looks like an empty paragraph.
36/**
37 * @param {(props: any) => any} Box the column from the surface's table, $.ui.resolve(e).Box
38 * @param {'handoff-relay' | 'cache-meter' | 'next-steps'} name
39 * @param {any} mine this mod's row
40 * @param {any} below what next(e) returned
41 * @returns {any}
42 */
43export function joinBand(Box, name, mine, below) {
44  const rows = rowsOf(Box, below)
45  rows.push(Box({ key: `${ROW}${PLACE[name]}:${name}`, flexDirection: 'column', children: [mine] }))
46  rows.sort((a, b) => placeOf(a) - placeOf(b))
47  return Box({ key: BAND, flexDirection: 'column', rowGap: 0.5, children: rows })
48}
49
hooks/i18n.mjs 42 lines
1// SPDX-License-Identifier: MIT
2// The language of what a mod shows a person. The file is the same in every mod
3// (scripts/sync-shared.sh puts the copy there), since a mod is also installed alone,
4// without its neighbours.
5//
6// The mod option `language`: auto, en or uk. auto takes the language of Claude's replies
7// from /config (the language row), so one Claude Code setting sets the language for all
8// mods at once. A language the mods do not have, or none set, gives English.
9
10export const LANGUAGES = ['en', 'uk']
11
12/**
13 * The mods' language for a /config value: "ukrainian", "uk" or the word in Ukrainian give uk, anything else en.
14 * @param {unknown} value
15 * @returns {'en' | 'uk'}
16 */
17export function languageOf(value) {
18  const v = String(value ?? '').trim().toLowerCase()
19  return /^(uk|ua)\b|ukrain|україн|укр/.test(v) ? 'uk' : 'en'
20}
21
22/**
23 * The language for the mod option: en or uk as is, auto (or empty) by the language of Claude's replies.
24 * The mod reads the /config rows itself ($ is not passed to functions from another file).
25 * @param {unknown} option
26 * @param {readonly { key: string, value: unknown }[]} rows the rows of $.config.list(), or [] when they cannot be read
27 * @returns {'en' | 'uk'}
28 */
29export function resolveLanguage(option, rows) {
30  if (option === 'en' || option === 'uk') return option
31  const row = rows.find((r) => r.key === 'language') ?? rows.find((r) => isLanguageKey(r.key))
32  return languageOf(row?.value)
33}
34
35/**
36 * Whether a change to a /config row can change the mods' language.
37 * @param {string} key
38 */
39export function isLanguageKey(key) {
40  return !key.includes('.') && /language/i.test(key)
41}
42
hooks/locales/en.mjs 16 lines
1// SPDX-License-Identifier: MIT
2// Everything handoff-relay shows a person, in English. Same keys as uk.mjs.
3
4export default {
5  button: 'Handoff',
6  heavy: (k) => ` Context ${k}k, time to hand off`,
7  cooling: ' Cache cools soon: handing off now is cheaper',
8  cold: ' Cache is cold: the handoff costs as much as a regular message',
9  pending: 'Handoff: writing the document…',
10  created: 'Handoff created: ',
11  nextPhase: 'next phase',
12  launchHint: 'Start it with Start locally or another button on the card.',
13  continueHint: 'Continue in a new session from that document.',
14  failed: (error) => `Handoff did not start: ${error}`,
15}
16
hooks/locales/uk.mjs 16 lines
1// SPDX-License-Identifier: MIT
2// Усе, що handoff-relay показує людині, українською. Ключі ті самі, що в en.mjs.
3
4export default {
5  button: 'Handoff',
6  heavy: (k) => ` Контекст ${k}k, час передавати`,
7  cooling: ' Кеш скоро охолоне: передавати зараз дешевше',
8  cold: ' Кеш охолов: хендоф коштуватиме як звичайне повідомлення',
9  pending: 'Handoff: пишу документ…',
10  created: 'Handoff створено: ',
11  nextPhase: 'наступна фаза',
12  launchHint: 'Запускай через Start locally або іншу кнопку на картці.',
13  continueHint: 'Продовжуй у новій сесії з цього документа.',
14  failed: (error) => `Handoff не запустився: ${error}`,
15}
16
types/index.d.ts 29 lines
1// SPDX-License-Identifier: MIT
2export type Tokens = number | null
3
4// The phase handed off: the card's title, or the document's name when no card was made.
5export type HandedOff = { title: string; hasCard: boolean } | null
6
7// What handoff-relay reads from cache-meter's state (`cache-meter.cache`) when that mod is installed.
8// Not declared in PluginState: it is cache-meter's contract, not ours.
9export type CacheMeterView = {
10  kind: 'unknown' | 'warm' | 'cooling' | 'cold' | 'kept'
11  isBig: boolean
12  rewriteUsd: number
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'handoff-relay': {
18      tokens: Tokens
19      isPending: boolean
20      // /handoff runs in this turn (from the button, typed by hand, or called by the model).
21      isHandoffTurn: boolean
22      // The first .md file written during the handoff turn, by name; it hands off once the turn answers.
23      writtenDoc: string | null
24      // Set once the phase is handed off; the button is gone for the rest of the session.
25      handedOff: HandedOff
26    }
27  }
28}
29