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.

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.
hooks/register.tsx, function hooks:
session.start and session.measure: track how many tokens the context holds.$.command.run with /handoff. While the handoff turn runs, the band says it is writing the document..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./handoff typed by hand, or by the model calling a handoff skill.spawn_task in the desktop app), the band says so at once and names the card.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.
| Option | Default | What it does |
|---|---|---|
language | auto | auto follows Claude's response language from /config; en or uk set it |
threshold | 180000 | Tokens of context from which the button lights up |
claude plugin validate .
claude plugin test .
python3 -m unittest discover -s skills/handoff/scriptshooks/register.tsx 273 lines1// 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}
273hooks/band.mjs 49 lines1// 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}
49hooks/i18n.mjs 42 lines1// 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}
42hooks/locales/en.mjs 16 lines1// 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}
16hooks/locales/uk.mjs 16 lines1// 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}
16types/index.d.ts 29 lines1// 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