SLOPSHOPPER

auto-handoff

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…

newbandguardcommandtoastprompt
v0.4.0MITupdated 2026-10-09Amel-DZRV/claude-code-auto-handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-handoff
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ auto-handoff │ ⏺ Read(src/auth.ts) │ auto-handoff is on: at 50% context it │ ⎿ Read 6 lines │ writes a handoff and clears the session. │ ⏺ Update(src/auth.ts) │ /auto-handoff clear off keeps the session; │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ auto-handoff │ │ requested with /handoff-now. Writing a │ ● Done. refresh now rejects expired claims and logs an audit event. │ handoff summary, then starting a fresh │ │ session that picks up where this one left │ ✻ Worked for 42s · done 4:20 PM ╰────────────────────────────────────────────╯ › /auto-handoff ⎿ auto-handoff: auto-handoff is on · threshold 50% · clear automatically: yes · resume automatically: no · bar: shown · cache Context ████████████████████┃░░░░░░░░░░░░░░░░░░░ 49% handoff at 50% Cache: no request yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context ████████████████████┃░░░░░░░░░░░░░░░░░░░ 49% handoff at 50% Cache: no request yet
README

auto-handoff

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
  • Context bar, always above the prompt, scaled 0-100% with a red line at the handoff threshold (default 50%). Green, then yellow, then red as it nears and passes the line. The desktop app draws a vector bar; the terminal draws block characters.
  • Prompt-cache countdown at the right of the bar: how long the cache stays warm after the last request.
  • Last request: the full input size, how much of it came from the cache, how much was new, and the output tokens.
  • Auto-handoff: when context passes the threshold, the mod asks the model for a handoff summary (from the cached transcript when it is still warm), saves it (see below), runs /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.

Status

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.

Install

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.

Commands

CommandWhat it does
/auto-handoff or statusSettings and the last handoff's outcome
/auto-handoff <percent>Set the threshold (5 to 95)
/auto-handoff on / offTurn auto-handoff on or off
/auto-handoff clear on / offRun /clear automatically after saving
/auto-handoff resume on / offSend a "continue" prompt in the fresh session
/auto-handoff bar on / offShow 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 tokensThe last 10 requests, plus main-thread and subagent totals
/handoff-nowWrite the handoff now

Develop

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.

Notes

  • Percent comes from $.session.usage().context.percent; request tokens come from the API's usage on each turn.step.
  • The cache countdown assumes a cache lifetime (the ttl setting); the mod cannot read the real value.
  • Where the handoff is saved: in a project, <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.
  • The automatic handoff only runs when a turn ends, right after the last request, so the prompt cache is warm and the summary reuses it. The threshold is the only knob; there is no separate "early" setting. /handoff-now can run any time and warns when the cache is cold.
  • Claude Code also compacts the context on its own when it gets full. Keep the threshold below that point, or the built-in compaction runs first and the handoff never fires. Check /config for the compaction setting on your version.
  • Managed settings can restrict which models a plugin may call. If the fallback summary model is refused, the mod says so and leaves the session untouched.
  • Not done, on purpose: cache keep-alive pings (they cost real money) and a session cost estimate (the mod cannot read prices).

License

MIT, see LICENSE.

Source 2 files
hooks/register.tsx 626 lines
1import { 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}
626
types/index.d.ts 38 lines
1export 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