SLOPSHOPPER

handoff

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…

newbandcommandtoasttimer
v0.1.1MITupdated 2026-10-20ZH-L1N/agent-window-mods/handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handoff
› 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-line ⎿ handoff: HANDOFF line: on · mode ask · threshold 80% of the compaction point ⎿ handoff: Context: no reading yet ⎿ handoff: HANDOFF.md: none in /work/app ⎿ handoff: Save now: asks Claude to write HANDOFF.md ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

handoff

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 ]
  • Bar: 20 cells stacked by the categories /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.
  • Percent: the fill as a share of the compaction point (where auto-compact fires; the window when auto-compact is off). Green under 60%, amber from 60% to the threshold, red from the threshold. The PLAN row of token-weather shows the share of the whole window, so the two numbers can differ.
  • Top three used categories, name n% of the window, in their colours; dropped first (third first) when the row is short of columns.
  • At the threshold (default 80% of the compaction point): one toast per crossing, "Chat is 80% full, save a handoff (/handoff-line now)", and a framed [ h: save now ] button. In auto mode the save starts by itself.
  • Save now runs your own /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.
  • Next session (or after a /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.
  • Before a compaction with no handoff saved in the last 10 minutes: a toast "Compacting without a fresh handoff". It never stops the compaction.

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:

ButtonCommand
h: save now/handoff-line now
c: clear & continue/clear, then /handoff-line resume
r: resume/handoff-line resume

Command

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

Options

OptionValuesDefault
bandon, offon
modeask, autoask
threshold50 to 95 (% of the compaction point)80

Install

From the local marketplace folder (~/.claude/mods):

claude plugin install handoff@zehui-mods --scope user

Notes

  • The reading is token-weather's (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.
  • The row composes with the other bands: keyed 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.
Source 4 files
hooks/register.tsx 401 lines
1// 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}
401
hooks/kit.ts 329 lines
1// 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}
329
hooks/row.tsx 138 lines
1// 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}
138
types/index.d.ts 75 lines
1// 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