Shows a context bar with a red handoff mark, the prompt-cache countdown and per-request tokens. When context passes a threshold it writes a handoff summary…

A Claude Code mod (a plugin of JS/TS event hooks) that keeps an eye on your context window.
Context ███████░░░░░░░┃░░░░░░░░░░░░░░░░ 23% handoff at 50% Cache warm 59m left
Last request · Input: 233k (99% cached, 2k new) · Output: 351
/clear, and loads the summary into the fresh session once. The summary uses the same sections and frontmatter as the writing-handoffs skill, so writing-handoffs can resume from it.Early. The mod uses Claude Code's early-access plugin API, which may change without notice. Requires Claude Code 2.1.285 or later.
Seen working in Claude Code Desktop: the bar, the cache countdown, the per-request line, the slash commands, and the handoff itself (the summary is saved, the session is cleared, the summary is loaded into the fresh session, and the mod keeps running after the clear). Not yet verified live: the threshold trigger (the handoff has been run from /handoff-now), and the home-folder slots used by scratch sessions (covered by tests only). If /clear is refused, the summary is still saved and the mod says so in the transcript.
From the marketplace in this repo (any machine, terminal or the Desktop app's Code tab):
claude plugin marketplace add Amel-DZRV/claude-code-auto-handoff
claude plugin install auto-handoff@amel-mods
Add .claude/handoffs/ to your global gitignore (git config --global core.excludesfile) so handoffs are never committed.
Run /reload-plugins in an open session. Update later with claude plugin marketplace update amel-mods; bump version in .claude-plugin/plugin.json when you push a change.
Other options:
Hot reloading, for trying it out: copy this folder to ~/.claude/dev-mods/<session-id>/auto-handoff/ and answer "Enable for this session" when Claude Code asks.
For every session: clone the repo and add its absolute path to the env block of ~/.claude/settings.json (several folders are separated by ; on Windows, : elsewhere):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:\\dev\\claude-code-auto-handoff"
}
}
New sessions load the mod from that folder and reload it when a file in it is saved. In a terminal you can use claude --plugin-dir <folder> instead.
Do not keep two copies of the mod loaded at once. They share a name, and an older copy can answer the commands before the newer one.
| Command | What it does |
|---|---|
/auto-handoff or status | Settings and the last handoff's outcome |
/auto-handoff <percent> | Set the threshold (5 to 95) |
/auto-handoff on / off | Turn auto-handoff on or off |
/auto-handoff clear on / off | Run /clear automatically after saving |
/auto-handoff resume on / off | Send a "continue" prompt in the fresh session |
/auto-handoff bar on / off | Show or hide the bar |
/auto-handoff ttl <minutes> | How long the prompt cache lives after its last use (default 60; the API gives 5 or 60) |
/auto-handoff tokens | The last 10 requests, plus main-thread and subagent totals |
/handoff-now | Write the handoff now |
claude plugin validate .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .
The test command refuses to run hooks modules without that variable while the plugin API is early access.
claude plugin test runs the files in tests/ against the engine with stubbed host calls.
$.session.usage().context.percent; request tokens come from the API's usage on each turn.step.ttl setting); the mod cannot read the real value.<project>/.claude/handoffs/YYYY-MM-DD-HHMMSS-auto-handoff-<session>.md, the same folder the writing-handoffs skill uses. Add .claude/handoffs/ to the repo's .gitignore (or your global one): handoffs summarise your conversation and should not be committed. A session with no project folder works in a scratch workspace that is deleted with the session, so it saves to ~/.claude/handoffs/handoff-1.md to handoff-5.md instead, reusing the oldest slot when all five exist./handoff-now can run any time and warns when the cache is cold./config for the compaction setting on your version.MIT, see LICENSE.
hooks/register.tsx 626 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ContextBar, RequestRow, Requests } from '../types'
5
6type Settings = {
7 enabled: boolean
8 threshold: number
9 autoClear: boolean
10 autoResume: boolean
11 showBar: boolean
12 cacheTtlMinutes: number
13}
14
15type Pending = { path: string; sessionId: string; root: string; savedAt: number }
16
17const DEFAULTS: Settings = { enabled: true, threshold: 50, autoClear: true, autoResume: false, showBar: true, cacheTtlMinutes: 60 }
18
19const bar = atom({ plugin: 'auto-handoff', key: 'bar' } as const, {
20 percent: null,
21 threshold: DEFAULTS.threshold,
22 isEnabled: DEFAULTS.enabled,
23 isShown: DEFAULTS.showBar,
24} satisfies ContextBar)
25
26const requestsRef = { plugin: 'auto-handoff', key: 'requests' } as const
27const INITIAL_REQUESTS: Requests = { rows: [], ttlMinutes: DEFAULTS.cacheTtlMinutes, tick: 0 }
28const requests = atom(requestsRef, INITIAL_REQUESTS)
29
30const MAX_ROWS = 200
31const TICK_MS = 30_000
32const BAR_MAX_CELLS = 40
33const BAR_MIN_CELLS = 10
34const SVG_WIDTH = 300
35const SVG_HEIGHT = 18
36const SVG_COLORS = { green: '#2da44e', yellow: '#d4a72c', red: '#e5484d' } as const
37const MAX_PENDING_AGE_MS = 6 * 60 * 60 * 1000
38const HANDOFFS_DIR = '.claude/handoffs'
39const KEPT_DIR = '.claude/handoffs'
40const KEPT_SLOTS = 5
41
42// The store is shared by every session, so each project keeps its own pending handoff.
43const pendingKey = (root: string): string => `pending:${root}`
44
45// Every session that has handed off, newest last; one id in one key let two sessions re-arm each other.
46const MAX_HANDED_OFF = 20
47const handedOffIds = async ($: any): Promise<string[]> => {
48 const ids = await $.store.get('handedOff')
49 return Array.isArray(ids) ? ids : []
50}
51const markHandedOff = async ($: any, sessionId: string): Promise<void> => {
52 const ids = await handedOffIds($)
53 await $.store.set('handedOff', [...ids.filter(id => id !== sessionId), sessionId].slice(-MAX_HANDED_OFF))
54}
55
56const pad = (n: number) => String(n).padStart(2, '0')
57// Local time, then the session's first 8 characters, so two sessions in the same second never share a file.
58const handoffFileName = (now: number, sessionId: string): string => {
59 const d = new Date(now)
60 const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
61 return `${stamp}-auto-handoff-${sessionId.slice(0, 8)}.md`
62}
63
64const SUMMARY_PROMPT = `Write a handoff for a fresh Claude Code session that will continue this work with no memory of this conversation.
65Use exactly these sections, in markdown, starting with the first heading (no frontmatter, no title):
66
67## Summary
682-3 sentences: what this work is about, its current status, who it is for.
69
70## Current State
71Checkboxes: done, half-done, not started. Say what is broken right now.
72
73## Key Decisions
74Choices made and the reason for each, so they are not re-litigated.
75
76## Open Issues
77Blockers, known bugs, dead ends already tried.
78
79## Artifacts
80Files the next session needs, as file:line, one line each on why.
81
82## Next Steps
83Ordered, actionable. Item 1 is the very next thing to do.
84
85Prefer file:line references over pasted code. Be specific and terse. Output only the handoff.`
86
87// Module variables start over on a hot reload; the host's store does not.
88let isBusy = false
89// Counts prompts this process has seen; a handoff compares before and after its summary.
90let promptCount = 0
91
92const loadSettings = async ($: any): Promise<Settings> => ({
93 ...DEFAULTS,
94 ...((await $.store.get('settings')) as Partial<Settings> | undefined),
95})
96
97// Keeps the bar's atom in step with the stored settings; the write redraws the band.
98const syncBar = ($: any, settings: Settings) =>
99 update($, bar, (b: ContextBar) =>
100 b.threshold === settings.threshold && b.isEnabled === settings.enabled && b.isShown === settings.showBar
101 ? b
102 : { ...b, threshold: settings.threshold, isEnabled: settings.enabled, isShown: settings.showBar },
103 )
104
105const saveSettings = async ($: any, settings: Settings) => {
106 await $.store.set('settings', settings)
107 await syncBar($, settings)
108 await update($, requests, (r: Requests) =>
109 r.ttlMinutes === settings.cacheTtlMinutes ? r : { ...r, ttlMinutes: settings.cacheTtlMinutes },
110 )
111}
112
113// Reads the live context fill into the bar; a write only when it has moved.
114const refreshBar = async ($: any): Promise<void> => {
115 const { context } = await $.session.usage()
116 const percent: number | null = context.percent === undefined ? null : Math.round(context.percent)
117 await update($, bar, (b: ContextBar) => (b.percent === percent ? b : { ...b, percent }))
118}
119
120const saveSettingsView = async ($: any, settings: Settings) => {
121 await syncBar($, settings)
122 await update($, requests, (r: Requests) =>
123 r.ttlMinutes === settings.cacheTtlMinutes ? r : { ...r, ttlMinutes: settings.cacheTtlMinutes },
124 )
125}
126
127const tickCountdown = async ($: any): Promise<void> => {
128 const now: number = await $.clock.now()
129 const tick = Math.floor(now / TICK_MS)
130 await update($, requests, (r: Requests) => (r.tick === tick ? r : { ...r, tick }))
131}
132
133// Says where a handoff stands: a toast, a transcript notice the model never reads, and a stored record.
134const say = async ($: any, text: string): Promise<void> => {
135 $.ui.toast(text)
136 await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: `auto-handoff: ${text}` }] } }).catch(() => undefined)
137 const at: number = await $.clock.now()
138 await $.store.set('lastHandoff', { at, text }).catch(() => undefined)
139}
140
141const quietly = ($: any, work: Promise<unknown>): void => {
142 void work.catch((error: unknown) => $.ui.toast(`Auto-handoff bar: ${String(error)}`))
143}
144
145const kilo = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : String(n))
146const promptTokens = (r: RequestRow): number => r.input + r.cacheRead + r.cacheWrite
147const hitPercent = (r: RequestRow): number => {
148 const total = promptTokens(r)
149 return total === 0 ? 0 : Math.round((r.cacheRead / total) * 100)
150}
151const clock = (ms: number): string => {
152 const d = new Date(ms)
153 return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
154}
155const span = (ms: number): string => {
156 const minutes = Math.floor(ms / 60000)
157 if (minutes < 1) return '<1m'
158 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
159}
160
161const lastMain = (rows: readonly RequestRow[]): RequestRow | undefined =>
162 [...rows].reverse().find(r => r.agentId === undefined)
163
164// Milliseconds the prompt cache has left after the main thread's last request; undefined with none yet.
165const cacheLeft = (rows: readonly RequestRow[], ttlMinutes: number, now: number): number | undefined => {
166 const last = lastMain(rows)
167 return last === undefined ? undefined : last.at + ttlMinutes * 60000 - now
168}
169
170const getRequests = async ($: any): Promise<Requests> => {
171 const { value } = await $.state.get(requestsRef)
172 return value ?? INITIAL_REQUESTS
173}
174
175const recordRequest = async ($: any, agentId: string | undefined, usage: any): Promise<void> => {
176 const at: number = await $.clock.now()
177 const row: RequestRow = {
178 at,
179 model: String(usage.model),
180 ...(agentId === undefined ? {} : { agentId }),
181 input: usage.input_tokens,
182 output: usage.output_tokens,
183 cacheRead: usage.cache_read_input_tokens,
184 cacheWrite: usage.cache_creation_input_tokens,
185 }
186 await update($, requests, (r: Requests) => ({ ...r, rows: [...r.rows, row].slice(-MAX_ROWS) }))
187}
188
189const tokenReport = (r: Requests, now: number): string => {
190 if (r.rows.length === 0) return 'No requests recorded yet in this session.'
191 const sum = (rows: RequestRow[]) =>
192 rows.reduce(
193 (t, x) => ({ prompt: t.prompt + promptTokens(x), read: t.read + x.cacheRead, out: t.out + x.output }),
194 { prompt: 0, read: 0, out: 0 },
195 )
196 const main = r.rows.filter(x => x.agentId === undefined)
197 const sub = r.rows.filter(x => x.agentId !== undefined)
198 const m = sum(main)
199 const sb = sum(sub)
200 const left = cacheLeft(r.rows, r.ttlMinutes, now)
201 const lines = r.rows.slice(-10).map(
202 x =>
203 `${clock(x.at)} Input: ${kilo(promptTokens(x))} (${hitPercent(x)}% cached, ${kilo(x.input + x.cacheWrite)} new) · Output: ${kilo(x.output)}` +
204 ` · ${x.model}${x.agentId === undefined ? '' : ' · subagent'}`,
205 )
206 return [
207 `Last ${lines.length} of ${r.rows.length} requests:`,
208 ...lines,
209 `Main thread: ${main.length} requests, ${kilo(m.prompt)} prompt tokens sent (${kilo(m.read)} from cache), ${kilo(m.out)} out`,
210 ...(sub.length === 0 ? [] : [`Subagents: ${sub.length} requests, ${kilo(sb.prompt)} prompt tokens, ${kilo(sb.out)} out`]),
211 left === undefined
212 ? 'Cache: no main-thread request yet'
213 : left > 0
214 ? `Cache: warm, ${span(left)} left of ${r.ttlMinutes}m`
215 : `Cache: cold for ${span(-left)}`,
216 ].join('\n')
217}
218
219const summarize = async ($: any): Promise<string | undefined> => {
220 const forked = await $.model.fork({ prompt: SUMMARY_PROMPT })
221 if (forked.isAnswered && forked.text.trim() !== '') return forked.text
222
223 const messages = await $.session.messages()
224 const transcript = messages
225 .slice(-60)
226 .map((m: any) => `${m.role.toUpperCase()}: ${m.text}`)
227 .join('\n\n')
228 .slice(-60000)
229 // Managed settings can pin the model, so the fallback may be refused outright.
230 let completed: { isAnswered: boolean; text: string }
231 try {
232 completed = await $.model.complete({
233 model: 'sonnet',
234 prompt: `${SUMMARY_PROMPT}\n\n<transcript>\n${transcript}\n</transcript>`,
235 maxTokens: 4000,
236 })
237 } catch (error) {
238 throw new Error(`the fallback summary model was refused (${String(error)})`)
239 }
240 return completed.isAnswered && completed.text.trim() !== '' ? completed.text : undefined
241}
242
243const slash = (path: string): string => path.replace(/\\/g, '/').replace(/\/$/, '')
244
245// A session started with no project folder works in a scratch workspace that is deleted with it.
246const isScratch = (root: string): boolean => root.includes('/scratch-workspaces/')
247
248// The newest KEPT_SLOTS handoffs live in the home folder; there is no delete, so a full set reuses the oldest slot.
249const keepCopy = async ($: any, content: string): Promise<string | undefined> => {
250 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
251 if (typeof home !== 'string' || home === '') return undefined
252 const dir = `${slash(home)}/${KEPT_DIR}`
253 const entries: { name: string; mtimeMs: number }[] = await $.fs.list(dir).catch(() => [])
254 const slots = Array.from({ length: KEPT_SLOTS }, (_, i) => `handoff-${i + 1}.md`)
255 const taken = new Map(entries.map(entry => [entry.name, entry.mtimeMs]))
256 const free = slots.find(name => !taken.has(name))
257 const oldest = [...slots].sort((a, b) => (taken.get(a) ?? 0) - (taken.get(b) ?? 0))[0]
258 const path = `${dir}/${free ?? oldest}`
259 await $.fs.write(path, content)
260 return path
261}
262
263const handoff = async ($: any, why: string): Promise<string> => {
264 if (isBusy) return 'A handoff is already running.'
265 isBusy = true
266 const promptsBefore = promptCount
267 try {
268 const settings = await loadSettings($)
269 await say(
270 $,
271 `${why}. Writing a handoff summary` +
272 (settings.autoClear ? ', then starting a fresh session that picks up where this one left off.' : '. Run /clear afterwards to load it into a fresh session.'),
273 )
274 const seen = await getRequests($)
275 const left = cacheLeft(seen.rows, seen.ttlMinutes, await $.clock.now())
276 const last = lastMain(seen.rows)
277 if (left !== undefined && left <= 0 && last !== undefined) {
278 $.ui.toast(`Auto-handoff: the prompt cache is cold, so the summary re-bills about ${kilo(promptTokens(last))} tokens.`)
279 }
280 const sessionId: string = await $.session.id()
281 const root = slash(String(await $.session.root()))
282 // A failure pauses the automatic handoff for this session, so it does not re-fork on every turn.
283 const PAUSED = 'Automatic handoff is paused for this session; run /handoff-now to try again.'
284 let text: string | undefined
285 try {
286 text = await summarize($)
287 } catch (error) {
288 await markHandedOff($, sessionId)
289 await say($, `could not write a summary: ${String(error)}; nothing was cleared. ${PAUSED}`)
290 return 'Could not write a summary; the session is untouched.'
291 }
292 if (text === undefined) {
293 await markHandedOff($, sessionId)
294 await say($, `could not write a summary, so nothing was cleared. ${PAUSED}`)
295 return 'Could not write a summary; the session is untouched.'
296 }
297
298 const now: number = await $.clock.now()
299 // The writing-handoffs skill's frontmatter, so its resume mode can read the file. A JSON
300 // string is a valid YAML scalar, so a path with ' #' or ': ' still parses.
301 const header =
302 `---\n` +
303 `date: ${new Date(now).toISOString()}\n` +
304 `author: auto-handoff (session ${sessionId})\n` +
305 `type: session\n` +
306 `status: in-progress\n` +
307 `project: ${JSON.stringify(root)}\n` +
308 `reason: ${JSON.stringify(why)}\n` +
309 `---\n\n`
310 const content = header + text.trim() + '\n'
311 // A project gets its own timestamped file, as the writing-handoffs skill does; a scratch
312 // workspace is deleted with the session, so it uses the home-folder slots instead.
313 const projectPath = isScratch(root) ? undefined : `${root}/${HANDOFFS_DIR}/${handoffFileName(now, sessionId)}`
314 // A repo that cannot be written (read-only, permissions) falls back to the home-folder slots.
315 const savedInProject =
316 projectPath !== undefined && (await $.fs.write(projectPath, content).then(() => true, () => false))
317 const path = savedInProject ? projectPath : await keepCopy($, content).catch(() => undefined)
318 if (path === undefined) {
319 await markHandedOff($, sessionId)
320 await say($, `could not save the handoff anywhere, so nothing was cleared. ${PAUSED}`)
321 return 'Could not save the handoff; the session is untouched.'
322 }
323 await $.store.set(pendingKey(root), { path, sessionId, root, savedAt: now } satisfies Pending)
324 // In the store, so a hot reload or a /auto-handoff bar change does not trigger a second handoff.
325 await markHandedOff($, sessionId)
326
327 if (!settings.autoClear) {
328 await say($, `handoff saved to ${path}. Run /clear and it loads into the new session.`)
329 return `Handoff saved to ${path}. Run /clear to start fresh with it.`
330 }
331
332 await say($, `handoff saved to ${path}. Starting a fresh session…`)
333 // Checked last, right before the /clear, which would wipe a prompt the user sent during the handoff.
334 if (promptCount !== promptsBefore) {
335 await say($, `handoff saved to ${path}, but you sent a new prompt meanwhile, so the session was not cleared. Run /clear yourself when ready.`)
336 return `Handoff saved to ${path}; the session was not cleared. Run /clear yourself.`
337 }
338 try {
339 await $.command.run({ command: 'clear' })
340 } catch (error) {
341 await say($, `handoff saved, but /clear failed (${String(error)}). Run /clear yourself.`)
342 return `Handoff saved to ${path}, but /clear failed. Run /clear yourself.`
343 }
344 if (settings.autoResume) {
345 void $.prompt.submit({ text: 'Continue from the handoff notes in your context.' })
346 }
347 return `Handoff saved to ${path}; session cleared.`
348 } finally {
349 isBusy = false
350 }
351}
352
353const checkContext = async ($: any): Promise<void> => {
354 const settings = await loadSettings($)
355 if (!settings.enabled) return
356 const sessionId: string = await $.session.id()
357 if ((await handedOffIds($)).includes(sessionId)) return
358 const { context } = await $.session.usage()
359 if ((context.percent ?? 0) < settings.threshold) return
360 await handoff($, `context at ${Math.round(context.percent)}%`)
361}
362
363const describe = (s: Settings) =>
364 `auto-handoff is ${s.enabled ? 'on' : 'off'} · threshold ${s.threshold}% · ` +
365 `clear automatically: ${s.autoClear ? 'yes' : 'no'} · resume automatically: ${s.autoResume ? 'yes' : 'no'} · ` +
366 `bar: ${s.showBar ? 'shown' : 'hidden'} · cache ttl: ${s.cacheTtlMinutes}m`
367
368const USAGE =
369 'Usage: /auto-handoff [on|off|status|<percent>|clear on|off|resume on|off|bar on|off|ttl <minutes>|tokens]'
370
371// Green while well under the mark, yellow as it nears, red once past it.
372const fillColor = (percent: number, threshold: number): 'red' | 'yellow' | 'green' =>
373 percent >= threshold ? 'red' : percent >= threshold * 0.8 ? 'yellow' : 'green'
374
375// One toast, the first time the mod runs for this user, so the auto-clear is not a surprise.
376const introduce = async ($: any): Promise<void> => {
377 if ((await $.store.get('introduced')) === true) return
378 const settings = await loadSettings($)
379 $.ui.toast(
380 `auto-handoff is on: at ${settings.threshold}% context it writes a handoff and clears the session. ` +
381 `/auto-handoff clear off keeps the session; /auto-handoff off disables it.`,
382 )
383 await $.store.set('introduced', true)
384}
385
386export const register: Register = on => {
387 on('session.start', async ($, e, next) => {
388 await $.command.register({
389 name: 'auto-handoff',
390 description: 'Configure auto-handoff: on, off, a threshold percent, clear on|off, resume on|off',
391 })
392 await $.command.register({
393 name: 'handoff-now',
394 description: 'Write the handoff file now, and clear the session when auto-clear is on',
395 })
396 quietly($, loadSettings($).then(settings => saveSettingsView($, settings)).then(() => refreshBar($)))
397 quietly($, introduce($))
398 // Redraws the cache countdown; timers die with a reload and start again here.
399 $.clock.every(TICK_MS, () => {
400 quietly($, tickCountdown($))
401 })
402 return next(e)
403 })
404
405 // One row per model request, from the usage the API reported; passes the stream through.
406 on('turn.step', async function* ($, e, next) {
407 const result = yield* next(e)
408 if (result.usage !== null) await recordRequest($, e.agentId, result.usage).catch((error: unknown) => $.ui.toast(`Auto-handoff tokens: ${String(error)}`))
409 return result
410 })
411
412 // Each tool call follows a model response, so the bar fills while a long turn runs.
413 on('tool.call', ($, e, next) => {
414 if (e.agentId === undefined) quietly($, refreshBar($))
415 return next(e)
416 })
417
418 // The turn has ended: the session is idle enough for a fork and a queued /clear.
419 on('turn.complete', async ($, e, next) => {
420 const result = await next(e)
421 if (e.agentId !== undefined || e.reason !== 'answer') return result
422 quietly($, refreshBar($))
423 void checkContext($).catch((error: unknown) => {
424 void say($, `failed: ${String(error)}`)
425 })
426 return result
427 })
428
429 // The first prompt of a conversation carries the handoff the previous session left.
430 on('prompt.context', async ($, e, next) => {
431 promptCount += 1
432 const result = await next(e)
433 const key = pendingKey(slash(String(await $.session.root())))
434 const pending = (await $.store.get(key)) as Pending | undefined
435 if (pending === undefined) return result
436 const now: number = await $.clock.now()
437 const isStale = now - pending.savedAt > MAX_PENDING_AGE_MS
438 if (isStale) {
439 await $.store.delete(key)
440 return result
441 }
442 if (pending.sessionId === (await $.session.id())) return result
443
444 const text = await $.fs.read(pending.path).catch(() => undefined)
445 await $.store.delete(key)
446 if (typeof text !== 'string') return result
447 $.ui.toast('Loaded the handoff from your previous session.')
448 return {
449 ...result,
450 blocks: [
451 ...result.blocks,
452 {
453 name: 'handoff',
454 text:
455 'Handoff from the previous session, which ended at its context threshold. ' +
456 'Treat it as the starting state and continue the work it describes.\n\n' +
457 text,
458 },
459 ],
460 }
461 })
462
463 // Detached: a command hook may not wait on a /clear queued behind itself.
464 on('command.run', { command: 'handoff-now' }, async $ => {
465 void handoff($, 'requested with /handoff-now').then(
466 (outcome: string) => say($, outcome),
467 (error: unknown) => say($, `failed: ${String(error)}`),
468 )
469 return { text: 'Writing the handoff…' }
470 })
471
472 on('command.run', { command: 'auto-handoff' }, async ($, e) => {
473 const settings = await loadSettings($)
474 const before = { ...settings }
475 const words = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
476 const [first, second] = words
477
478 if (first === 'tokens') return { text: tokenReport(await getRequests($), await $.clock.now()) }
479 if (first === 'status' || first === undefined) {
480 const last = (await $.store.get('lastHandoff')) as { at: number; text: string } | undefined
481 const when = last === undefined ? '' : ` · last handoff ${clock(last.at)}: ${last.text}`
482 return { text: describe(settings) + when }
483 }
484 if (first === 'on' || first === 'off') settings.enabled = first === 'on'
485 else if (first === 'bar' && (second === 'on' || second === 'off')) settings.showBar = second === 'on'
486 else if (first === 'ttl') {
487 const minutes = Number(second)
488 if (!Number.isInteger(minutes) || minutes < 1 || minutes > 120) {
489 return { text: 'Cache ttl must be 1 to 120 minutes (the API gives 5 or 60).' }
490 }
491 settings.cacheTtlMinutes = minutes
492 }
493 else if ((first === 'clear' || first === 'resume') && (second === 'on' || second === 'off')) {
494 if (first === 'clear') settings.autoClear = second === 'on'
495 else settings.autoResume = second === 'on'
496 } else if (/^\d+%?$/.test(first)) {
497 const percent = parseInt(first, 10)
498 if (percent < 5 || percent > 95) return { text: 'Threshold must be between 5 and 95.' }
499 settings.threshold = percent
500 } else return { text: USAGE }
501
502 await saveSettings($, settings)
503 // A new threshold or turning it back on is a fresh decision; the bar, clear, resume and ttl are not.
504 if (before.enabled !== settings.enabled || before.threshold !== settings.threshold) {
505 await $.store.delete('handedOff')
506 }
507 return { text: describe(settings) }
508 })
509
510 // The bar: cells filled for the context used, a mark at the handoff threshold.
511 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
512 const state = await read($, bar)
513 if (e.props.hasSurvey || !state.isShown) return next(e)
514
515 const table = $.ui.resolve(e)
516 const { Box, Text } = table
517 const usage = await read($, requests)
518 const now: number = await $.clock.now()
519 const left = cacheLeft(usage.rows, usage.ttlMinutes, now)
520 const last = lastMain(usage.rows)
521 const cacheColor = left === undefined ? undefined : left <= 0 ? 'red' : left < 5 * 60000 ? 'yellow' : 'green'
522 const cacheText =
523 left === undefined ? 'Cache: no request yet' : left > 0 ? `Cache warm ${span(left)} left` : `Cache cold ${span(-left)}`
524 const fresh = last === undefined ? 0 : last.input + last.cacheWrite
525 // The cache state sits at the right of the bar's row; the last request's tokens take the row below.
526 const cacheLine = (
527 <Text color={cacheColor} dimColor={cacheColor === undefined}>
528 {cacheText}
529 </Text>
530 )
531 const requestLine =
532 last === undefined ? null : (
533 <Text dimColor>
534 {`Last request · Input: ${kilo(promptTokens(last))} (${hitPercent(last)}% cached, ${kilo(fresh)} new) · Output: ${kilo(last.output)}`}
535 </Text>
536 )
537 const layout = (barRow: any) => (
538 <Box flexDirection="column">
539 <Box flexDirection="row" justifyContent="space-between">
540 {barRow}
541 {cacheLine}
542 </Box>
543 {requestLine}
544 </Box>
545 )
546 const percent = state.percent ?? 0
547 const label = state.percent === null ? ' --%' : ` ${String(percent).padStart(2)}%`
548 const tail = state.isEnabled ? ` handoff at ${state.threshold}%` : ' handoff off'
549
550 // The desktop draws proportional text, so a row of block glyphs would not
551 // line up: it gets a real vector bar, 0-100 across, with the red line.
552 if (e.surface === 'desktop' && 'Svg' in table) {
553 const { Svg } = table
554 const color = SVG_COLORS[fillColor(percent, state.threshold)]
555 const isPast = state.isEnabled && percent >= state.threshold
556 const fillWidth = Math.max(0, Math.min(100, percent)) * (SVG_WIDTH / 100)
557 const markX = Math.min(SVG_WIDTH - 1, Math.max(1, state.threshold * (SVG_WIDTH / 100)))
558 const source =
559 `<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_WIDTH}" height="${SVG_HEIGHT}" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}">` +
560 `<rect x="0" y="5" width="${SVG_WIDTH}" height="8" rx="4" fill="#8a8a8a" fill-opacity="0.3"/>` +
561 (fillWidth > 0 ? `<rect x="0" y="5" width="${fillWidth}" height="8" rx="4" fill="${color}"/>` : '') +
562 (state.isEnabled
563 ? `<rect x="${markX - 2}" y="0" width="4" height="${SVG_HEIGHT}" fill="#ffffff" fill-opacity="0.85"/>` +
564 `<rect x="${markX - 1}" y="0" width="2" height="${SVG_HEIGHT}" fill="${SVG_COLORS.red}"/>`
565 : '') +
566 `</svg>`
567
568 return layout(
569 <Box flexDirection="row" alignItems="center">
570 <Text dimColor>Context </Text>
571 <Svg
572 source={source}
573 alt={`Context ${state.percent === null ? 'unknown' : `${percent}%`}, handoff at ${state.threshold}%`}
574 width={SVG_WIDTH}
575 height={SVG_HEIGHT}
576 />
577 <Text color={isPast ? 'red' : undefined} bold={isPast}>
578 {label}
579 </Text>
580 <Text dimColor>{tail}</Text>
581 </Box>,
582 )
583 }
584 const cells = Math.max(
585 BAR_MIN_CELLS,
586 Math.min(BAR_MAX_CELLS, e.props.bodyColumns - 'Context '.length - label.length - tail.length - 2),
587 )
588
589 const filled = Math.round((percent / 100) * cells)
590 const mark = Math.min(cells - 1, Math.round((state.threshold / 100) * cells))
591 const color = fillColor(percent, state.threshold)
592 const isPast = state.isEnabled && percent >= state.threshold
593
594 // Cells [from, to): filled ones in the fill color, the rest dim.
595 const run = (from: number, to: number) => {
596 const fillCount = Math.max(0, Math.min(to, filled) - from)
597 const emptyCount = Math.max(0, to - from - fillCount)
598 return (
599 <Box flexDirection="row">
600 <Text color={color}>{'█'.repeat(fillCount)}</Text>
601 <Text dimColor>{'░'.repeat(emptyCount)}</Text>
602 </Box>
603 )
604 }
605
606 return layout(
607 <Box flexDirection="row">
608 <Text dimColor>Context </Text>
609 {run(0, mark)}
610 {state.isEnabled ? (
611 <Text color={isPast ? 'white' : 'red'} bold>
612 ┃
613 </Text>
614 ) : (
615 run(mark, mark + 1)
616 )}
617 {run(mark + 1, cells)}
618 <Text color={color} bold={isPast}>
619 {label}
620 </Text>
621 <Text dimColor>{tail}</Text>
622 </Box>,
623 )
624 })
625}
626types/index.d.ts 38 lines1export type ContextBar = {
2 /** Context window used, 0 to 100; null until the session's first response. */
3 percent: number | null
4 /** The handoff mark, 5 to 95. */
5 threshold: number
6 isEnabled: boolean
7 isShown: boolean
8}
9
10/** One model request, as the API reported its usage. */
11export type RequestRow = {
12 /** When the response arrived, ms since the epoch. */
13 at: number
14 model: string
15 /** Set for a subagent's request; absent on the main thread. */
16 agentId?: string
17 /** Uncached input tokens. */
18 input: number
19 output: number
20 cacheRead: number
21 cacheWrite: number
22}
23
24export type Requests = {
25 /** The latest requests, oldest first, capped. */
26 rows: RequestRow[]
27 /** Minutes the prompt cache lives after its last use. */
28 ttlMinutes: number
29 /** Bumped by a timer so the cache countdown redraws. */
30 tick: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'auto-handoff': { bar: ContextBar; requests: Requests }
36 }
37}
38