A HANDOFF band above the prompt: the context bar stacked by /context category, fill against the compaction point, and at the threshold a save-now button that…

A ⇄ HANDOFF row above the prompt: how full the chat is, and a one-key way to save a handoff before it compacts.
⇄ HANDOFF ▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱░░ 62% Messages 40% · System prompt 12% · System tools 8% [ h: save now ]
/context draws, each used category ▰ in its colour, free space dim ▱, the auto-compact buffer ░. A category under half a cell folds into its neighbour; deferred tool schemas are left out.name n% of the window, in their colours; dropped first (third first) when the row is short of columns.[ h: save now ] button. In auto mode the save starts by itself./handoff command if one exists; otherwise it sends Claude a prompt to write HANDOFF.md in the session root (objective, done / in progress / next, decisions and why, open questions, exact next step). The row shows writing HANDOFF.md…, then ✓ saved 14:32 with [ c: clear & continue ], which runs /clear. If you keep working instead and the fill crosses the threshold again (after a compaction), the old save is done with: the row offers [ h: save now ] again and auto mode saves again. /handoff-line now while a save is still being written says so instead of asking twice./clear) with a HANDOFF.md in the root: a toast "Handoff from 2d ago available (/handoff-line resume)" and the row shows from 2d ago: <objective> with [ r: resume ], which sends "Read HANDOFF.md and continue from its exact next step". The offer goes once the first turn ends.The buttons work only while the band has focus (ctrl+x tab, or a click in Claude Code's fullscreen renderer; the classic renderer reports no clicks), so every one has a command:
| Button | Command |
|---|---|
h: save now | /handoff-line now |
c: clear & continue | /clear, then /handoff-line resume |
r: resume | /handoff-line resume |
/handoff-line on | off | now | resume | threshold <50-95> | mode auto|ask | status | reset
on, off, threshold and mode are kept in the mod's store and win over the plugin options; reset drops them so the options apply again. off hides the row and its toasts; now and resume still work. status prints the settings, the context reading and where it came from, HANDOFF.md's time and which save path now takes.
| Option | Values | Default |
|---|---|---|
band | on, off | on |
mode | ask, auto | ask |
threshold | 50 to 95 (% of the compaction point) | 80 |
From the local marketplace folder (~/.claude/mods):
claude plugin install handoff@zehui-mods --scope user
token-weather.context, read once per turn there). Without token-weather this mod reads the same breakdown itself.HANDOFF.md goes in the session root ($.session.root(): the repo root in a repo). Gitignore it if you do not want it committed.zm-30-handoff, it takes its slot in the zehui-mods stack order (SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70, NEXT 80); other bands follow. It yields to a survey. Terminal only. English only.hooks/register.tsx 401 lines1// The handoff mod: the ⇄ HANDOFF row (row.tsx draws it), the save, clear and
2// resume flow around HANDOFF.md in the session root, the compaction warning
3// and /handoff-line. The context reading is token-weather's (its
4// token-weather.context state); while token-weather publishes none, this mod
5// reads $.session.usage itself on the same events.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
8
9import type { HandoffContext, HandoffSave, HandoffSettings } from '../types'
10import {
11 COMMAND,
12 FRESH_MS,
13 OWN_COMMAND,
14 RESUME_PROMPT,
15 WATCH_FOR_MS,
16 WATCH_MS,
17 ageText,
18 fillOf,
19 fmtTokens,
20 handoffPath,
21 hhmm,
22 isThreshold,
23 newest,
24 objectiveOf,
25 readingOf,
26 rootKey,
27 savePrompt,
28 settingsOf,
29} from './kit'
30import { drawRow } from './row'
31
32const context = atom({ plugin: 'handoff', key: 'context' } as const, null)
33const save = atom({ plugin: 'handoff', key: 'save' } as const, { phase: 'idle', requestedAt: 0, baseMtime: 0, savedAt: 0, via: '' })
34const offer = atom({ plugin: 'handoff', key: 'offer' } as const, null)
35const isOver = atom({ plugin: 'handoff', key: 'isOver' } as const, false)
36const clearedAt = atom({ plugin: 'handoff', key: 'clearedAt' } as const, 0)
37const SETTINGS = { plugin: 'handoff', key: 'settings' } as const
38const TW_CONTEXT = { plugin: 'token-weather', key: 'context' } as const
39
40const STORE_BAND = 'handoff.band'
41const STORE_MODE = 'handoff.mode'
42const STORE_THRESHOLD = 'handoff.threshold'
43const lastSaveKey = (root: string) => `handoff.lastSave.${rootKey(root)}`
44
45/** After an event, the reading and the crossing are looked at this much later, once every plugin's hook has run. */
46const SETTLE_MS = 250
47/** After a /clear, the fresh session's reading is taken this much later. */
48const CLEAR_SETTLE_MS = 500
49
50// The watch on HANDOFF.md while a save is being written. A reload drops it
51// with the environment; session.start (raised again for a fresh load) starts
52// it again while a save is still being written.
53let watch: Timer | null = null
54
55const stopWatch = () => {
56 watch?.cancel()
57 watch = null
58}
59
60async function settingsNow($: EngineInterface, options: PluginOptions): Promise<HandoffSettings> {
61 return (await $.state.get(SETTINGS)).value ?? settingsOf(options)
62}
63
64/** The options, overridden by what /handoff-line kept in the store. */
65async function loadSettings($: EngineInterface, options: PluginOptions): Promise<HandoffSettings> {
66 const stored = { band: await $.store.get(STORE_BAND), mode: await $.store.get(STORE_MODE), threshold: await $.store.get(STORE_THRESHOLD) }
67 const next = settingsOf(options, stored)
68 await $.state.set(SETTINGS, next)
69 return next
70}
71
72async function mtimeOf($: EngineInterface, path: string): Promise<number> {
73 try {
74 const st = await $.fs.stat(path)
75 return st.kind === 'file' ? st.mtimeMs : 0
76 } catch {
77 return 0
78 }
79}
80
81/** The reading the row draws: token-weather's or this mod's, whichever is newer since the last /clear. */
82async function readingNow($: EngineInterface): Promise<HandoffContext | null> {
83 return (await pickReading($)).r
84}
85
86/** The reading and whether it is token-weather's. */
87async function pickReading($: EngineInterface): Promise<{ r: HandoffContext | null; isTw: boolean }> {
88 const tw = (await $.state.get(TW_CONTEXT)).value
89 const r = newest(tw, await read($, context), await read($, clearedAt))
90 return { r, isTw: r !== null && r === tw }
91}
92
93/** This mod's own reading; a `summary` breakdown is estimated locally and sends no request. */
94async function ownReading($: EngineInterface): Promise<void> {
95 try {
96 const r = readingOf(await $.session.usage({ breakdown: 'summary' }), await $.clock.now())
97 if (r) await update($, context, () => r)
98 } catch {
99 // The row keeps the last reading it had.
100 }
101}
102
103/** Takes a reading of its own while token-weather has none since the last /clear, then checks the crossing. */
104async function refresh($: EngineInterface, options: PluginOptions): Promise<void> {
105 const tw = (await $.state.get(TW_CONTEXT)).value
106 if (!tw || tw.readAt < (await read($, clearedAt))) await ownReading($)
107 await crossing($, options)
108}
109
110/** One toast per crossing of the threshold; in `auto` mode the save starts by itself. */
111async function crossing($: EngineInterface, options: PluginOptions): Promise<void> {
112 const r = await readingNow($)
113 if (!r) return
114 const s = await settingsNow($, options)
115 const isAbove = fillOf(r) >= s.threshold
116 const was = await read($, isOver)
117 if (isAbove === was) return
118 await update($, isOver, () => isAbove)
119 if (!isAbove) return
120 // A save from an earlier crossing is done with: the row offers save now
121 // again and auto mode saves again. savedAt stays for the record.
122 await update($, save, (v): HandoffSave => (v.phase === 'saved' ? { ...v, phase: 'idle' } : v))
123 if (s.band === 'off') return
124 if (s.mode === 'auto' && (await read($, save)).phase === 'idle') {
125 $.ui.toast(`Chat is ${s.threshold}% full, saving a handoff (/handoff-line status)`)
126 await requestSave($)
127 } else {
128 $.ui.toast(`Chat is ${s.threshold}% full, save a handoff (/handoff-line now)`)
129 }
130}
131
132async function hasOwnCommand($: EngineInterface): Promise<boolean> {
133 try {
134 return (await $.command.list()).some(c => c.name === OWN_COMMAND)
135 } catch {
136 return false
137 }
138}
139
140/**
141 * Asks for a handoff: the person's /handoff command when there is one, else a
142 * prompt asking Claude to write HANDOFF.md in the session root. The run is
143 * made from a timer, never inside a hook; then HANDOFF.md is watched.
144 */
145async function requestSave($: EngineInterface): Promise<{ via: 'command' | 'prompt'; root: string }> {
146 const root = await $.session.root()
147 const baseMtime = await mtimeOf($, handoffPath(root))
148 const via = (await hasOwnCommand($)) ? 'command' : 'prompt'
149 const requestedAt = await $.clock.now()
150 await update($, save, () => ({ phase: 'saving', requestedAt, baseMtime, savedAt: 0, via }))
151 await update($, offer, () => null)
152 $.clock.after(10, () => {
153 const run = via === 'command' ? $.command.run({ command: OWN_COMMAND, args: '' }) : $.prompt.submit({ text: savePrompt(root) })
154 void Promise.resolve(run).catch(() => {})
155 })
156 startWatch($)
157 return { via, root }
158}
159
160function startWatch($: EngineInterface): void {
161 stopWatch()
162 watch = $.clock.every(WATCH_MS, () => {
163 void checkSaved($).catch(() => {})
164 })
165}
166
167/** While a save is being written: HANDOFF.md newer than when it was asked marks it saved. */
168async function checkSaved($: EngineInterface): Promise<void> {
169 const s = await read($, save)
170 if (s.phase !== 'saving') {
171 stopWatch()
172 return
173 }
174 const root = await $.session.root()
175 const m = await mtimeOf($, handoffPath(root))
176 if (m > s.baseMtime) {
177 stopWatch()
178 // The watch tick and turn.complete can both get here: only the one whose
179 // write moves 'saving' to 'saved' records it and toasts.
180 let isMine = false as boolean
181 await update($, save, (v): HandoffSave => {
182 isMine = v.phase === 'saving'
183 return isMine ? { ...v, phase: 'saved', savedAt: m } : v
184 })
185 if (!isMine) return
186 await $.store.set(lastSaveKey(root), m)
187 $.ui.toast(`Handoff saved ${hhmm(m)} (clear & continue: /clear, then /handoff-line resume)`)
188 return
189 }
190 if ((await $.clock.now()) - s.requestedAt > WATCH_FOR_MS) {
191 stopWatch()
192 await update($, save, (v): HandoffSave => ({ ...v, phase: 'idle' }))
193 $.ui.toast('No HANDOFF.md was written (/handoff-line now to try again)')
194 }
195}
196
197/** A handoff saved in the last ten minutes: by this mod's record or by HANDOFF.md's own time. */
198async function isFresh($: EngineInterface): Promise<boolean> {
199 const root = await $.session.root()
200 const recorded = Number(await $.store.get(lastSaveKey(root))) || 0
201 const last = Math.max(recorded, await mtimeOf($, handoffPath(root)))
202 return last > 0 && (await $.clock.now()) - last < FRESH_MS
203}
204
205/** The resume offer from HANDOFF.md in the session root; false when there is none. */
206async function makeOffer($: EngineInterface, isToasted: boolean): Promise<boolean> {
207 const root = await $.session.root()
208 const path = handoffPath(root)
209 const mtimeMs = await mtimeOf($, path)
210 if (!mtimeMs) {
211 await update($, offer, () => null)
212 return false
213 }
214 let objective = ''
215 try {
216 const text = await $.fs.read(path)
217 objective = typeof text === 'string' ? objectiveOf(text) : ''
218 } catch {
219 // An offer with no objective still resumes.
220 }
221 const age = ageText((await $.clock.now()) - mtimeMs)
222 await update($, offer, () => ({ mtimeMs, age, objective }))
223 if (isToasted) $.ui.toast(`Handoff from ${age} available (/handoff-line resume)`)
224 return true
225}
226
227function resume($: EngineInterface): void {
228 void update($, offer, () => null).catch(() => {})
229 $.clock.after(10, () => {
230 void $.prompt.submit({ text: RESUME_PROMPT }).catch(() => {})
231 })
232}
233
234function clearAndContinue($: EngineInterface): void {
235 // The fresh session's resume offer comes from the session.end hook, as for any /clear.
236 $.clock.after(10, () => {
237 void $.command.run({ command: 'clear', args: '' }).catch(() => {})
238 })
239}
240
241/** After a /clear: the old reading is stale, the save is done with, and the new session gets the offer. */
242async function afterClear($: EngineInterface, options: PluginOptions): Promise<void> {
243 stopWatch()
244 const at = await $.clock.now()
245 await update($, clearedAt, () => at)
246 await update($, isOver, () => false)
247 await update($, save, () => ({ phase: 'idle', requestedAt: 0, baseMtime: 0, savedAt: 0, via: '' }))
248 await ownReading($)
249 const s = await settingsNow($, options)
250 if (s.band === 'on') await makeOffer($, true)
251}
252
253async function statusText($: EngineInterface, options: PluginOptions): Promise<string> {
254 const s = await settingsNow($, options)
255 const { r, isTw } = await pickReading($)
256 const root = await $.session.root()
257 const path = handoffPath(root)
258 const m = await mtimeOf($, path)
259 const now = await $.clock.now()
260 const sv = await read($, save)
261 const lines = [`HANDOFF line: ${s.band} · mode ${s.mode} · threshold ${s.threshold}% of the compaction point`]
262 if (r) {
263 const at = r.autoCompactThreshold && r.autoCompactThreshold > 0 ? r.autoCompactThreshold : r.maxTokens
264 const source = isTw ? 'token-weather' : 'its own reading'
265 lines.push(
266 `Context: ${fillOf(r)}% of the compaction point (${fmtTokens(r.totalTokens)} of ${fmtTokens(at)}; ${r.percent}% of the ${fmtTokens(r.maxTokens)} window), from ${source}`,
267 )
268 } else {
269 lines.push('Context: no reading yet')
270 }
271 const state = sv.phase === 'saving' ? ' (being written)' : ''
272 lines.push(m ? `HANDOFF.md: ${path}, saved ${hhmm(m)} (${ageText(now - m)})${state}` : `HANDOFF.md: none in ${root}${state}`)
273 lines.push(`Save now: ${(await hasOwnCommand($)) ? 'runs your /handoff command' : 'asks Claude to write HANDOFF.md'}`)
274 return lines.join('\n')
275}
276
277const USAGE = `Usage: /${COMMAND} on | off | now | resume | threshold <50-95> | mode auto|ask | status | reset`
278
279export const register: Register = (on, options) => {
280 on('session.start', async ($, e, next) => {
281 const started = await next(e)
282 await $.command.register({
283 name: 'handoff-line',
284 description: 'HANDOFF row: save a handoff to HANDOFF.md now, resume from one, or set the threshold and mode',
285 argumentHint: 'on | off | now | resume | threshold <n> | mode auto|ask | status',
286 })
287 const s = await loadSettings($, options)
288 const sv = await read($, save)
289 if (sv.phase === 'saving') startWatch($)
290 // A fresh session (no prompt yet) with a HANDOFF.md in its root gets the offer.
291 if (s.band === 'on' && sv.phase === 'idle' && (await $.session.turns()) === 0) await makeOffer($, true)
292 $.clock.after(SETTLE_MS, () => {
293 void refresh($, options).catch(() => {})
294 })
295 return started
296 })
297
298 on('turn.complete', async ($, e, next) => {
299 const result = await next(e)
300 if (e.agentId !== undefined) return result
301 $.clock.after(SETTLE_MS, () => {
302 void (async () => {
303 if ((await read($, save)).phase === 'saving') await checkSaved($)
304 // The person moved on: the offer was for the session's start.
305 if ((await read($, offer)) !== null) await update($, offer, () => null)
306 await refresh($, options)
307 })().catch(() => {})
308 })
309 return result
310 })
311
312 // Before a compaction of the main conversation: a warning when no handoff is fresh. Never a veto.
313 on('session.compact', async ($, e, next) => {
314 const isMain = e.agentId === undefined && e.trigger !== 'precompute'
315 if (isMain && (await settingsNow($, options)).band === 'on' && !(await isFresh($))) {
316 $.ui.toast('Compacting without a fresh handoff')
317 }
318 const result = await next(e)
319 if (isMain && result && typeof (result as { skip?: unknown }).skip !== 'string') {
320 $.clock.after(SETTLE_MS, () => {
321 void refresh($, options).catch(() => {})
322 })
323 }
324 return result
325 }).catch(($, e, next) => next(e)) // A failed warning never changes the compaction.
326
327 // A /clear ends the conversation and starts another with no session.start.
328 on('session.end', async ($, e, next) => {
329 const result = await next(e)
330 if (e.reason === 'clear') {
331 $.clock.after(CLEAR_SETTLE_MS, () => {
332 void afterClear($, options).catch(() => {})
333 })
334 }
335 return result
336 })
337
338 // The command and its matcher are spelled literally, so the host reads it as answering its own command.
339 on('command.run', { command: 'handoff-line' }, async ($, e) => {
340 const [verb = 'status', arg = ''] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
341 const setSettings = async (change: Partial<HandoffSettings>) => {
342 const s = await settingsNow($, options)
343 await $.state.set(SETTINGS, { ...s, ...change })
344 }
345 switch (verb) {
346 case 'on':
347 case 'off':
348 await $.store.set(STORE_BAND, verb)
349 await setSettings({ band: verb })
350 return { text: verb === 'on' ? 'HANDOFF line on.' : 'HANDOFF line off (no row, no toasts; /handoff-line now still saves).' }
351 case 'now': {
352 // A save still inside its watch window is not asked twice; past it (a
353 // stalled save) a new one may be asked.
354 const sv = await read($, save)
355 if (sv.phase === 'saving' && (await $.clock.now()) - sv.requestedAt <= WATCH_FOR_MS) {
356 return { text: 'A handoff is already being written (/handoff-line status).' }
357 }
358 const { via, root } = await requestSave($)
359 return { text: via === 'command' ? 'Saving a handoff: running /handoff.' : `Saving a handoff: asked Claude to write ${handoffPath(root)}.` }
360 }
361 case 'resume': {
362 if (!(await makeOffer($, false))) return { text: `No ${'HANDOFF.md'} in ${await $.session.root()}.` }
363 const o = await read($, offer)
364 resume($)
365 return { text: `Resuming from HANDOFF.md (${o?.age ?? 'saved earlier'}).` }
366 }
367 case 'threshold': {
368 const n = Number(arg)
369 if (!isThreshold(n)) return { text: 'Threshold must be a whole number from 50 to 95 (percent of the compaction point).' }
370 await $.store.set(STORE_THRESHOLD, n)
371 await setSettings({ threshold: n })
372 await crossing($, options)
373 return { text: `Threshold ${n}% of the compaction point.` }
374 }
375 case 'mode':
376 if (arg !== 'auto' && arg !== 'ask') return { text: 'Mode is auto or ask.' }
377 await $.store.set(STORE_MODE, arg)
378 await setSettings({ mode: arg })
379 return { text: arg === 'auto' ? 'Mode auto: a handoff is saved by itself at the threshold.' : 'Mode ask: the row turns red and offers save now at the threshold.' }
380 case 'status':
381 return { text: await statusText($, options) }
382 case 'reset':
383 for (const k of [STORE_BAND, STORE_MODE, STORE_THRESHOLD]) await $.store.delete(k)
384 await loadSettings($, options)
385 return { text: 'HANDOFF line settings back to the plugin options.' }
386 default:
387 return { text: USAGE }
388 }
389 })
390
391 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) =>
392 drawRow($, e, () => next(e), options, {
393 save: () => {
394 void requestSave($).catch(() => {})
395 },
396 clearAndContinue: () => clearAndContinue($),
397 resume: () => resume($),
398 }),
399 )
400}
401hooks/kit.ts 329 lines1// The handoff mod's pure helpers: the stacked bar, the fill against the
2// compaction point and its colours, the top three categories, the reading
3// from $.session.usage, HANDOFF.md's objective, and small formatters.
4// No state here: each file that reads or writes state declares its own refs
5// (the engine reads a state source only from a const of the calling file).
6import type { PluginOptions, SessionUsage } from 'claude-code'
7
8import type { HandoffCategory, HandoffContext, HandoffSettings } from '../types'
9import type { RenderElement, RenderNode } from 'claude-code'
10
11export const CHIP = ' ⇄ HANDOFF ' // the widest zehui-mods chip; the others pad to it
12export const CHIP_FG = '#0b0b0f'
13export const CHIP_BG = '#a78bfa'
14export const GREEN = '#22c55e'
15export const AMBER = '#f59e0b'
16export const RED = '#ef4444'
17export const BAR_CELLS = 20
18/** Under this percent of the compaction point the fill is green; amber from it to the threshold. */
19export const AMBER_FROM = 60
20export const TOP_COUNT = 3
21export const FILE_NAME = 'HANDOFF.md'
22/** A save this recent counts as fresh at a compaction. */
23export const FRESH_MS = 10 * 60_000
24/** How often HANDOFF.md is looked at while a save is being written, and for how long. */
25export const WATCH_MS = 3_000
26export const WATCH_FOR_MS = 20 * 60_000
27export const COMMAND = 'handoff-line'
28/** The person's own command, run in place of the prompt when it exists. */
29export const OWN_COMMAND = 'handoff'
30export const RESUME_PROMPT = 'Read HANDOFF.md and continue from its exact next step'
31
32export const DEFAULTS: HandoffSettings = { band: 'on', mode: 'ask', threshold: 80 }
33
34/** The prompt that asks Claude to write the handoff, naming where. */
35export const savePrompt = (root: string): string =>
36 [
37 `Write a handoff note to ${handoffPath(root)} (replace the file if it exists) so a fresh session can pick up this work without this conversation.`,
38 'Use these sections, short and specific (file paths, commands, names):',
39 '## Objective (one line first)',
40 '## Done',
41 '## In progress',
42 '## Next',
43 '## Decisions and why',
44 '## Open questions',
45 '## Exact next step (one concrete action)',
46 'Write only that file; do not change anything else.',
47 ].join('\n')
48
49export const handoffPath = (root: string): string => `${root.replace(/[\\/]+$/, '')}/${FILE_NAME}`
50
51/** A threshold the row accepts: a whole number from 50 to 95. */
52export const isThreshold = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 50 && n <= 95
53
54/** The options as settings, anything unknown falling back to the default. */
55export function settingsOf(options: PluginOptions, stored: { band?: unknown; mode?: unknown; threshold?: unknown } = {}): HandoffSettings {
56 const pick = <T extends string>(a: unknown, b: unknown, allowed: readonly T[], d: T): T =>
57 allowed.includes(a as T) ? (a as T) : allowed.includes(b as T) ? (b as T) : d
58 const t = isThreshold(stored.threshold) ? stored.threshold : isThreshold(options.threshold) ? options.threshold : DEFAULTS.threshold
59 return {
60 band: pick(stored.band, options.band, ['on', 'off'] as const, DEFAULTS.band),
61 mode: pick(stored.mode, options.mode, ['ask', 'auto'] as const, DEFAULTS.mode),
62 threshold: t,
63 }
64}
65
66// --- the reading
67
68// The breakdown names each category's colour by its theme key: the dark
69// theme's values, approximately; a key not listed draws grey. The same table
70// is in token-weather's hooks/token-weather.mjs (local change 3), whose
71// reading this mod prefers; this copy serves only when token-weather is absent.
72const THEME_HEX: Record<string, string> = {
73 text: '#ffffff',
74 inactive: '#999999',
75 subtle: '#505050',
76 suggestion: '#b1b9f9',
77 remember: '#b1b9f9',
78 success: '#4eba65',
79 error: '#ff6b80',
80 warning: '#ffc107',
81 merged: '#af87ff',
82 claude: '#d77757',
83 permission: '#b1b9f9',
84 planMode: '#48968c',
85 autoAccept: '#af87ff',
86 promptBorder: '#888888',
87 bashBorder: '#fd5db1',
88 ide: '#4782c8',
89 red_FOR_SUBAGENTS_ONLY: '#dc2626',
90 blue_FOR_SUBAGENTS_ONLY: '#2563eb',
91 green_FOR_SUBAGENTS_ONLY: '#16a34a',
92 yellow_FOR_SUBAGENTS_ONLY: '#ca8a04',
93 purple_FOR_SUBAGENTS_ONLY: '#9333ea',
94 orange_FOR_SUBAGENTS_ONLY: '#ea580c',
95 pink_FOR_SUBAGENTS_ONLY: '#db2777',
96 cyan_FOR_SUBAGENTS_ONLY: '#0891b2',
97}
98const GREY = '#9a9a96'
99
100export const hexFor = (color: unknown): string => {
101 if (typeof color !== 'string') return GREY
102 if (/^#[0-9a-f]{6}$/i.test(color)) return color
103 return Object.hasOwn(THEME_HEX, color) ? (THEME_HEX[color] ?? GREY) : GREY
104}
105
106/** `$.session.usage({ breakdown: 'summary' })` as a reading, in token-weather's shape; null with no window. */
107export function readingOf(usage: SessionUsage, at: number): HandoffContext | null {
108 const c = usage.context
109 if (!c || !c.window) return null
110 const bd = c.breakdown
111 // Before any response, with no breakdown to estimate from, there is nothing to draw.
112 if (c.tokens == null && !bd) return null
113 const categories: HandoffCategory[] = (bd?.categories ?? [])
114 .filter(x => x.kind !== 'deferred' && !x.isDeferred && Number(x.tokens) > 0)
115 .map(x => ({ name: String(x.name), tokens: Number(x.tokens), color: hexFor(x.color), kind: String(x.kind) }))
116 const tokens = c.tokens ?? (bd ? Number(bd.totalTokens) || 0 : 0)
117 const threshold = bd && bd.isAutoCompactEnabled !== false ? Number(bd.autoCompactThreshold) || 0 : 0
118 return {
119 categories,
120 totalTokens: tokens,
121 maxTokens: c.window,
122 ...(threshold ? { autoCompactThreshold: threshold } : {}),
123 percent: Math.round(c.percent ?? (tokens / c.window) * 100),
124 readAt: at,
125 }
126}
127
128/** The newer of two readings; one taken before the last /clear does not count. */
129export function newest(a: HandoffContext | null | undefined, b: HandoffContext | null | undefined, clearedAt = 0): HandoffContext | null {
130 const ok = (r: HandoffContext | null | undefined): r is HandoffContext => !!r && r.readAt >= clearedAt && r.maxTokens > 0
131 if (ok(a) && ok(b)) return a.readAt >= b.readAt ? a : b
132 return ok(a) ? a : ok(b) ? b : null
133}
134
135/** The fill as a whole percent of the compaction point (the window when auto-compact is off). */
136export const fillOf = (r: HandoffContext): number => {
137 const at = r.autoCompactThreshold && r.autoCompactThreshold > 0 ? r.autoCompactThreshold : r.maxTokens
138 return at > 0 ? Math.round((r.totalTokens / at) * 100) : 0
139}
140
141/** Green under 60% of the compaction point, amber to the threshold, red from it. */
142export const fillColor = (fill: number, threshold: number): string =>
143 fill >= threshold ? RED : fill >= Math.min(AMBER_FROM, threshold) ? AMBER : GREEN
144
145// --- the bar
146
147export type Segment = HandoffCategory & { cells: number }
148
149/**
150 * The bar as segments of whole cells, in the breakdown's order. A row under
151 * half a cell folds into its neighbour (the one before it, else the one
152 * after); cells are shared out by largest remainder so they sum to `cells`.
153 * The rule token-weather's PLAN row drew by until local change 6.
154 */
155export function stackBar(categories: readonly HandoffCategory[], cells = BAR_CELLS): Segment[] {
156 const total = categories.reduce((sum, c) => sum + c.tokens, 0)
157 if (total <= 0) return []
158 const unit = total / cells
159 const kept: HandoffCategory[] = []
160 let carry = 0
161 for (const c of categories) {
162 const seg = { ...c, tokens: c.tokens + carry }
163 carry = 0
164 if (seg.tokens < unit / 2) {
165 const last = kept[kept.length - 1]
166 if (last) last.tokens += seg.tokens
167 else carry = seg.tokens
168 continue
169 }
170 kept.push(seg)
171 }
172 const tail = kept[kept.length - 1]
173 if (carry > 0 && tail) tail.tokens += carry
174 const exact = kept.map(s => s.tokens / unit)
175 const out: Segment[] = kept.map((s, i) => ({ ...s, cells: Math.floor(exact[i] ?? 0) }))
176 let left = cells - out.reduce((sum, s) => sum + s.cells, 0)
177 const byRemainder = exact.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
178 for (const [, i] of byRemainder) {
179 if (left <= 0) break
180 const s = out[i]
181 if (s) s.cells += 1
182 left -= 1
183 }
184 return out.filter(s => s.cells > 0)
185}
186
187/** One run of the bar: its glyphs and how they are drawn. */
188export type BarRun = { text: string; color?: string; isDim?: boolean }
189
190/** The stacked bar's runs: `▰` per used cell in its colour, dim `▱` free, dim `░` buffer; a two-colour fill without categories. */
191export function barRuns(r: HandoffContext, color: string, cells = BAR_CELLS): BarRun[] {
192 const segs = stackBar(r.categories, cells)
193 if (segs.length === 0) {
194 const used = Math.max(0, Math.min(cells, Math.round((r.percent / 100) * cells)))
195 const out: BarRun[] = []
196 if (used > 0) out.push({ text: '▰'.repeat(used), color })
197 if (used < cells) out.push({ text: '▱'.repeat(cells - used), isDim: true })
198 return out
199 }
200 return segs.map(s =>
201 s.kind === 'free'
202 ? { text: '▱'.repeat(s.cells), isDim: true }
203 : s.kind === 'buffer'
204 ? { text: '░'.repeat(s.cells), isDim: true }
205 : { text: '▰'.repeat(s.cells), color: s.color },
206 )
207}
208
209/** The three used categories holding the most tokens, as `name n%` of the window, in their colours. */
210export function topThree(categories: readonly HandoffCategory[]): { text: string; color: string }[] {
211 const total = categories.reduce((sum, c) => sum + c.tokens, 0)
212 return categories
213 .filter(c => c.kind === 'used')
214 .sort((a, b) => b.tokens - a.tokens)
215 .slice(0, TOP_COUNT)
216 .map(c => {
217 const pct = total > 0 ? Math.round((c.tokens / total) * 100) : 0
218 return { text: `${c.name} ${pct > 0 ? pct : '<1'}%`, color: c.color }
219 })
220}
221
222// --- HANDOFF.md
223
224/** The objective's first line: the line under an `Objective` heading, else the first line of prose, else the first heading. */
225export function objectiveOf(text: string): string {
226 const lines = text.split(/\r?\n/).map(l => l.trim())
227 const clean = (l: string) => l.replace(/^#+\s*/, '').replace(/^[-*+]\s+/, '').replace(/^\*\*(.*)\*\*$/, '$1').trim()
228 const at = lines.findIndex(l => /^#+\s*objective\b/i.test(l))
229 if (at >= 0) {
230 const inline = clean(lines[at] ?? '').replace(/^objective\s*[:—-]?\s*/i, '')
231 if (inline) return inline
232 const next = lines.slice(at + 1).find(l => l !== '' && !l.startsWith('#'))
233 if (next) return clean(next)
234 }
235 const prose = lines.find(l => l !== '' && !l.startsWith('#') && !/^---+$/.test(l))
236 if (prose) return clean(prose)
237 const heading = lines.find(l => l.startsWith('#'))
238 return heading ? clean(heading) : ''
239}
240
241/** `just now`, `5m ago`, `3h ago`, `2d ago`. */
242export function ageText(ms: number): string {
243 const m = Math.floor(Math.max(0, ms) / 60_000)
244 if (m < 1) return 'just now'
245 if (m < 60) return `${m}m ago`
246 const h = Math.floor(m / 60)
247 if (h < 24) return `${h}h ago`
248 return `${Math.floor(h / 24)}d ago`
249}
250
251/** `14:32`, local time. */
252export const hhmm = (ms: number): string => {
253 const d = new Date(ms)
254 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
255}
256
257/** A short stable key for a path (FNV-1a, 32-bit, hex): the store key of a root's last save. */
258export function rootKey(root: string): string {
259 let h = 0x811c9dc5
260 for (let i = 0; i < root.length; i++) {
261 h ^= root.charCodeAt(i)
262 h = Math.imul(h, 0x01000193) >>> 0
263 }
264 return h.toString(16).padStart(8, '0')
265}
266
267/** `117k`, `1.2M`. */
268export const fmtTokens = (n: number): string =>
269 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
270
271/** Cuts `s` to `max` characters, the last an ellipsis. */
272export const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
273
274// Stack order of the zehui-mods band rows (plan 2026-10-20-boards-codemap-recap,
275// section 0): SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70,
276// NEXT 80. Copied unchanged from the canonical
277// ~/.claude/skills/building-claude-code-mods/references/stack-row.tsx; change it
278// there first, then in every band mod.
279
280/** The rows each zehui-mods band draws, by stack order. */
281export const STACK = { sys: 10, plan: 20, handoff: 30, cache: 40, agents: 50, goal: 60, here: 70, next: 80 } as const
282
283/** A row's key: `zm-<order>-<name>`. */
284export const stackKey = (name: keyof typeof STACK): string => `zm-${STACK[name]}-${name}`
285
286type Loose = { type?: unknown; props?: Record<string, unknown> | null; part?: unknown; children?: unknown }
287
288/** The order of a zehui-mods row (`zm-<order>-<name>`), or null for any other node. */
289export const stackOrderOf = (node: unknown): number | null => {
290 const key = node && typeof node === 'object' ? (node as Loose).props?.key : undefined
291 const m = typeof key === 'string' ? /^zm-(\d+)-/.exec(key) : null
292 return m ? Number(m[1]) : null
293}
294
295/**
296 * The column a band hook beneath built: a Box whose only prop is
297 * `flexDirection: 'column'` and whose direct children hold a zm row. A Box with
298 * other props (a border, a padding) is another mod's own drawing, kept whole.
299 */
300const isStack = (node: Loose): node is Loose & { children: unknown[] } =>
301 node.type === 'Box' &&
302 node.props?.flexDirection === 'column' &&
303 Object.keys(node.props).every(k => k === 'flexDirection') &&
304 Array.isArray(node.children) &&
305 node.children.some(c => stackOrderOf(c) !== null)
306
307/**
308 * `row` put in its slot among the zm rows of `below` (what `next(e)` drew):
309 * zm rows sorted by their number, then every other child in its original order.
310 * Nothing beneath (null, a failed next, the engine placeholder): the row alone.
311 */
312export function stackRow(row: RenderElement, below: unknown): RenderElement {
313 const tree = below && typeof below === 'object' && (below as Loose).type !== 'engine' ? (below as Loose) : null
314 if (!tree) return row
315 let items: unknown[] = [tree]
316 if (isStack(tree)) {
317 items = [...tree.children]
318 // The column's mark (whose drawing started there) moves to the one row
319 // that carries none, its own; the engine lets a mark move, never multiply.
320 const bare = items.filter(c => c && typeof c === 'object' && !(c as Loose).part)
321 if (tree.part && bare.length === 1) items = items.map(c => (c === bare[0] ? { ...(c as object), part: tree.part } : c))
322 }
323 const rows = items.filter(c => stackOrderOf(c) !== null)
324 const others = items.filter(c => stackOrderOf(c) === null)
325 // A stable sort: two rows of one number keep their order, this row after.
326 const sorted = [...rows, row].sort((a, b) => (stackOrderOf(a) ?? 0) - (stackOrderOf(b) ?? 0))
327 return { type: 'Box', props: { flexDirection: 'column' }, children: [...sorted, ...others] as RenderNode[] }
328}
329hooks/row.tsx 138 lines1// The ⇄ HANDOFF row above the prompt: the context bar stacked by /context
2// category, the fill against the compaction point in traffic-light colours,
3// the top three categories, and what to do about it (save now, clear &
4// continue, resume). Its row sits in the zehui-mods stack
5// by its order (stackRow); other mods' bands follow.
6import { atom, read } from 'claude-code'
7import type { EngineInterface, PluginOptions, RenderElement, RenderInput } from 'claude-code'
8
9import {
10 BAR_CELLS,
11 CHIP,
12 CHIP_BG,
13 CHIP_FG,
14 GREEN,
15 AMBER,
16 barRuns,
17 clip,
18 fillColor,
19 fillOf,
20 hhmm,
21 newest,
22 settingsOf,
23 topThree,
24 stackKey,
25 stackRow,
26} from './kit'
27
28const context = atom({ plugin: 'handoff', key: 'context' } as const, null)
29const save = atom({ plugin: 'handoff', key: 'save' } as const, { phase: 'idle', requestedAt: 0, baseMtime: 0, savedAt: 0, via: '' })
30const offer = atom({ plugin: 'handoff', key: 'offer' } as const, null)
31const isOver = atom({ plugin: 'handoff', key: 'isOver' } as const, false)
32const clearedAt = atom({ plugin: 'handoff', key: 'clearedAt' } as const, 0)
33const SETTINGS = { plugin: 'handoff', key: 'settings' } as const
34const TW_CONTEXT = { plugin: 'token-weather', key: 'context' } as const
35
36/** What the row's buttons do: the same as `/handoff-line now`, `/clear` then `resume`, and `resume`. */
37export type RowActions = {
38 save: () => void
39 clearAndContinue: () => void
40 resume: () => void
41}
42
43/** One run of text in the row: its words and how they are drawn. */
44type Seg = { text: string; color?: string; bg?: string; isBold?: boolean; isDim?: boolean }
45type Btn = { key: string; label: string; hotkey: string; onPress: () => void }
46
47/** The shortest the objective is cut to before anything else gives. */
48const OBJECTIVE_MIN = 12
49
50const widthOf = (segs: readonly Seg[]): number => segs.reduce((w, s) => w + [...s.text].length, 0)
51
52export async function drawRow(
53 $: EngineInterface,
54 e: RenderInput<'AbovePrompt'>,
55 pass: () => Promise<RenderElement>,
56 options: PluginOptions,
57 act: RowActions,
58) {
59 if (e.surface !== 'terminal' || e.props.hasSurvey) return pass()
60 const settings = (await $.state.get(SETTINGS)).value ?? settingsOf(options)
61 if (settings.band === 'off') return pass()
62 const tw = (await $.state.get(TW_CONTEXT)).value
63 const r = newest(tw, await read($, context), await read($, clearedAt))
64 const s = await read($, save)
65 const o = await read($, offer)
66 const over = await read($, isOver)
67 if (!r && !o && s.phase === 'idle') return pass()
68
69 // Other mods' bands beneath this row; stackRow puts this row in its slot
70 // among the zehui-mods rows and never nests the engine's own placeholder.
71 const below = await pass().catch(() => null)
72
73 const head: Seg[] = [{ text: CHIP, color: CHIP_FG, bg: CHIP_BG, isBold: true }, { text: ' ' }]
74 let tops: Seg[][] = []
75 if (r) {
76 const fill = fillOf(r)
77 const color = fillColor(fill, settings.threshold)
78 head.push(...barRuns(r, color, BAR_CELLS), { text: ` ${fill}%`, color, isBold: true })
79 tops = topThree(r.categories).map((c, i) => [{ text: i === 0 ? ' ' : ' · ', isDim: true }, { text: c.text, color: c.color }])
80 }
81
82 const tail: Seg[] = []
83 const buttons: Btn[] = []
84 let objective = ''
85 if (s.phase === 'saved') {
86 tail.push({ text: ` ✓ saved ${hhmm(s.savedAt)}`, color: GREEN, isBold: true })
87 buttons.push({ key: 'handoff-clear', label: 'c: clear & continue', hotkey: 'c', onPress: act.clearAndContinue })
88 } else if (s.phase === 'saving') {
89 tail.push({ text: ' writing HANDOFF.md…', color: AMBER })
90 } else if (o) {
91 objective = o.objective
92 buttons.push({ key: 'handoff-resume', label: 'r: resume', hotkey: 'r', onPress: act.resume })
93 }
94 if (over && s.phase === 'idle') {
95 buttons.push({ key: 'handoff-save', label: 'h: save now', hotkey: 'h', onPress: act.save })
96 }
97
98 // Fit: the top three go first (third first), then the objective is cut;
99 // past that the Text's truncate-end cuts the tail.
100 const cols = e.props.bodyColumns > 0 ? e.props.bodyColumns : 80
101 const room = cols - buttons.reduce((w, b) => w + b.label.length + 4 + 1, 0)
102 const offerSegs = (max: number): Seg[] =>
103 o && s.phase === 'idle' ? [{ text: ` from ${o.age}: `, isDim: true }, { text: clip(objective, max) }] : []
104 let objectiveMax = Math.max(objective.length, 1)
105 const build = (): Seg[] => [...head, ...tops.flat(), ...tail, ...offerSegs(objectiveMax)]
106 while (tops.length > 0 && widthOf(build()) > room) tops = tops.slice(0, -1)
107 const over2 = widthOf(build()) - room
108 if (over2 > 0 && objective) objectiveMax = Math.max(OBJECTIVE_MIN, objective.length - over2)
109 const segs = build()
110
111 const { Box, Text, Button } = $.ui.resolve(e)
112 const row = (
113 <Box key={stackKey('handoff')} flexDirection="row" gap={1}>
114 <Box key="handoff-line" flexGrow={1} flexShrink={1}>
115 <Text wrap="truncate-end">
116 {segs.map((g, i) => (
117 <Text
118 key={`seg-${i}`}
119 {...(g.color ? { color: g.color } : {})}
120 {...(g.bg ? { backgroundColor: g.bg } : {})}
121 {...(g.isBold ? { bold: true } : {})}
122 {...(g.isDim ? { dimColor: true } : {})}
123 >
124 {g.text}
125 </Text>
126 ))}
127 </Text>
128 </Box>
129 {buttons.map(b => (
130 <Box key={`${b.key}-box`} flexShrink={0}>
131 <Button key={b.key} label={b.label} hotkey={b.hotkey} onPress={() => b.onPress()} />
132 </Box>
133 ))}
134 </Box>
135 )
136 return stackRow(row, below)
137}
138types/index.d.ts 75 lines1// The handoff mod's state contract. The one value of token-weather's it reads
2// is typed in peer.d.ts beside this file, kept out of this contract so the
3// contract names only handoff's own state.
4
5/** One row of the context breakdown, in /context's order. */
6export type HandoffCategory = {
7 name: string
8 tokens: number
9 /** Hex colour. */
10 color: string
11 /** `used`, `free` or `buffer` (deferred rows are left out). */
12 kind: string
13}
14
15/** A context reading: token-weather's `context`, or this mod's own when token-weather is absent. */
16export type HandoffContext = {
17 categories: HandoffCategory[]
18 /** Tokens in the window. */
19 totalTokens: number
20 /** The context window, in tokens. */
21 maxTokens: number
22 /** Where auto-compact fires, in tokens; absent when off or not reported. */
23 autoCompactThreshold?: number
24 /** `totalTokens` over `maxTokens`, a whole percentage. */
25 percent: number
26 /** When it was read, `$.clock.now()` milliseconds. */
27 readAt: number
28}
29
30/** The row's settings: the options, overridden by what /handoff-line wrote to the store. */
31export type HandoffSettings = {
32 band: 'on' | 'off'
33 mode: 'ask' | 'auto'
34 /** Percent of the compaction point at which the row turns red. */
35 threshold: number
36}
37
38/** Where a save stands: none asked, HANDOFF.md being written, or written. */
39export type HandoffSave = {
40 phase: 'idle' | 'saving' | 'saved'
41 /** When the save was asked. */
42 requestedAt: number
43 /** HANDOFF.md's mtime when the save was asked (0: no file). */
44 baseMtime: number
45 /** HANDOFF.md's mtime once written. */
46 savedAt: number
47 /** How it was asked: the person's /handoff command, or a prompt to Claude. */
48 via: 'command' | 'prompt' | ''
49}
50
51/** The resume offer of a session that starts with a HANDOFF.md in its root. */
52export type HandoffOffer = {
53 mtimeMs: number
54 /** `2d ago`, as of when the offer was made. */
55 age: string
56 /** The objective's first line. */
57 objective: string
58}
59
60declare module 'claude-code' {
61 interface PluginState {
62 handoff: {
63 /** This mod's own reading, taken only while token-weather publishes none. */
64 context: HandoffContext | null
65 settings: HandoffSettings
66 save: HandoffSave
67 offer: HandoffOffer | null
68 /** True from a crossing of the threshold until the fill drops under it. */
69 isOver: boolean
70 /** When /clear last ran: a reading older than this is stale. */
71 clearedAt: number
72 }
73 }
74}
75