SLOPSHOPPER

context-guard

When the session context passes a size threshold, asks whether to compact, start fresh, or keep going.

bandspinnercommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-guard
› 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 › /context-guard ⎿ context-guard: context-guard is on: asks at 35% of the window (70k of 200k tokens), then every 10% (20k) after "Continue". Sh ⎿ context-guard: Usage: /context-guard <threshold> <step> | off | on | reset | ask | style <dialog|band|both> | stats | stats s context-guard is not set up: choose when it should ask [ Set up ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
context-guard is not set up: choose when it should ask [ Set up ]
README

Session Kit

Version: 13 · Created: 2026-10-16 19:08 · Updated: 2026-10-18 20:44

By Mike Daykhin | founder @ talenthunt.work

Two tools for long AI coding sessions. context-guard shows how full the context is in a one-line band above the prompt and, past a threshold you choose, asks what to do: compact, write a handoff, start a new session, or keep going. handoff writes a handoff of the session as a JSON file, so a fresh session can pick up what was actually done. The first means you do not have to watch the context yourself. The second lets you pass the work on.

Install in one paste

Copy this into your agent (Claude Code, Codex, Gemini CLI, Cursor, or another one that loads Agent Skills) and send it:

Install session-kit for me. Read https://raw.githubusercontent.com/WIPexpert/mikeday-session-kit/main/INSTALL.md and follow it. Tell me in plain words what you are about to do, and ask me before you change anything.

The agent reads INSTALL.md, works out which agent it is, and installs what fits:

AgentWhat you get
Claude Codehandoff and context-guard
Codex, Gemini CLI, Cursor, OpenCode and other agents that load Agent Skillshandoff only. context-guard runs only inside Claude Code.

The agent asks before each change. To read what it will do first, open INSTALL.md.

If you would rather type the commands (Claude Code)

/plugin marketplace add WIPexpert/mikeday-session-kit
/plugin install handoff@session-kit
/plugin install context-guard@session-kit

The two plugins are separate installs. Take one or both. The marketplace is named session-kit, which is why the plugin ids end in @session-kit. To update later, run /plugin marketplace update session-kit, then /reload-plugins.

Install the schema check

handoff checks every handoff against its schema with the Python package jsonschema. The package is optional but worth installing: without it a malformed handoff is saved with only a basic check. The install prompt above offers it. By hand:

python3 -m venv ~/.config/handoff/venv
~/.config/handoff/venv/bin/python -m pip install jsonschema

More in the skill's README.

handoff (0.2.0)

Ask your agent for a handoff (in Claude Code /handoff, in Codex $handoff), optionally with what the next session is for. It writes a handoff for the current session, checks it against the schema, and prints the file path and a one-line prompt to paste into the new session. To continue, give a new session the path to the handoff.

A handoff holds:

  • the goal
  • what is done, with a flag for what was checked and how
  • what was not checked
  • decisions and why
  • dead ends
  • open questions
  • the environment: folder, branch, uncommitted files, what is still running
  • references
  • one next step

Details:

  • Handoffs are saved where ~/.config/handoff/config.json says, or the skill asks for a folder once and offers to remember it. It never saves into the current workspace or a scratch folder.
  • Every handoff is written in English. Copy that must be reused as written goes unchanged into a verbatim_copy field.
  • In the Claude desktop app the skill offers the continuation as a launch card with a model and effort. A card cannot start a session on a higher model or effort than the one that wrote the handoff. For a stronger model the card is a gate: the new session first asks you to switch the model and waits for "go". Elsewhere the skill prints the one-line prompt.
  • handoff is a skill in the Agent Skills format, so other agents can load it. Only Claude Code was tested.

context-guard (0.6.5)

context-guard is a Claude Code mod: a plugin with code that runs inside Claude Code. After each model answer it checks the context size. Past the threshold it asks:

Context is 35% of the window (354K tokens). What now?

The choices are Compact, Handoff, then new session, New session (/clear) and Continue.

  • Handoff, then new session appears only when a handoff skill is installed. Choosing it asks the agent to write a handoff for this conversation and tell you the path. A message then says to run /clear afterwards and paste the path into the new session.
  • Continue keeps the session going. The next question comes when the context has grown by the re-ask step. Dismissing the dialog counts as Continue.
  • When the context drops below the threshold again (after a compact or a clear), the base threshold applies again.
  • Compact calls the session's own compaction. Where the session refuses that call, as the Claude desktop app does, it runs /compact for you. If an action fails anyway, a message says so and tells you which command to type.
  • It asks nothing in a session where nobody can answer, such as claude -p.

The band above the prompt

One line above the prompt input, in the terminal and in the Claude desktop app:

ctx-grd ████▉░│░░░ 26% used | next ask at 35%  [ Compact ] [ Handoff ] [ Clear ]
  • The bar is a share of the window. It is green below your threshold, amber past it and red from 90%. The last cell is a partial block, so it moves smoothly. In the desktop app the bar is a rounded graphic.
  • The white mark on the bar is the next ask line: your threshold at first, and one step further after you choose Continue. When the context is past it, the line says over 35% and the Compact button is the bright one.
  • The three buttons do what the dialog's answers do: compact, ask for a handoff, run /clear. Each one asks you to confirm first, in the band itself: the line turns into Compact now? The history is summarized. with Yes, compact and Cancel buttons, the focus starting on Cancel. Cancel, or no answer for 20 seconds, takes it back and nothing runs. Handoff is there only when a handoff skill is installed. Each press is counted in the local statistics.
  • Right after a compact or /clear there is no token count until the next answer, and the band says no reading yet.
  • While Claude works, the spinner gets a short tail, Thinking · ctx 77%….
  • It is one line at any width (the example is 80 columns). On a wide terminal it reads 26% used 264K of 1M | next ask at 35%. When the terminal is narrow, the token counts go first, then the words about the next ask get shorter (| ask 35%) and then go too, and the bar shrinks last.
  • Before the first-run questions are answered the band reads context-guard is not set up with a Set up button. When switched off it reads ctx-grd off with Turn on.
  • /context-guard style chooses what shows: both (the default), dialog only, or band only. With band it never interrupts you.
  • The band is drawn in the terminal and the Claude desktop app. The VS Code extension has no band, so there only the dialog shows.

Thresholds are a share of the model's context window, the "used" figure of the Context panel. Defaults: 35% for the threshold and 10% for the re-ask step, which is 350k and 100k tokens on a 1M window. A share stays right when the window is smaller: Claude Opus 4.6 and Sonnet 4.6 have 200k unless you use the [1m] variant, and then 35% is 70k.

On the first run it asks for the threshold (20%, 35%, 50%, or your own) and the step (5%, 10%, 20%, or your own), with the token count beside each, then remembers them. If you dismiss the dialog, the defaults stay.

The /context-guard command:

CommandWhat it does
/context-guardShows the current values, and warns when Claude Code would compact on its own before your threshold
/context-guard 35% 10%Sets the threshold and the step as shares of the window
/context-guard 350k 100kThe same in thousands of tokens, turned into shares of the current window
/context-guard offSwitches it off
/context-guard onSwitches it on
/context-guard resetAsks for the values again after the next answer
/context-guard style dialogShows only the dialog. band shows only the band, both shows both (the default). /context-guard style shows the current one
/context-guard askOpens the dialog now, below the threshold, with the current numbers. Use it to see what the dialog looks like or to compact on demand. The buttons really act, and this opening is not counted in the statistics
/context-guard statsShows your own usage counts
/context-guard stats sharePrints the counts as JSON you can paste into an issue
/context-guard stats resetClears the counts

Settings are shared by all your sessions. Version 0.2.x kept them in tokens; the first run of 0.3.0 converts them.

Local statistics

To help you see how you use it, context-guard counts a few things in its own store on your machine: how many times the dialog appeared and at what share of the window, how often you picked each answer, whether Compact went through the session or through /compact, how many handoff requests were sent, how many band buttons were pressed, and how many actions failed. /context-guard stats shows the counts. Nothing is sent anywhere: the counts never leave your machine unless you copy /context-guard stats share into a message yourself. It holds counts and your settings, no texts, paths, names or ids. /context-guard stats reset clears it.

Requirements and limits

  • Claude Code 2.1.287 or later (the Desktop app from 2.1.286) for context-guard. In Desktop's WSL sessions plugins are unavailable.
  • The Claude Code mods API can change between releases. Tested with Claude Code 2.1.295 on macOS, Python 3.14 and jsonschema 4.26. Other versions and systems are untested.
  • Run live: the context-guard dialog with the choices Continue and New session (/clear), the band above the prompt with its confirmation and Cancel focus (in the terminal and in the Claude desktop app), the spinner tail, the re-ask after Continue, /context-guard ask, and continuing a new session from a pasted handoff line, plus both launch cards (a model and effort below the current session's, and the gate for a stronger model). Covered by tests only: the percent thresholds, the first-run questions, the /context-guard command, the 0.2.x conversion, the failure messages, the /compact fallback for sessions that refuse session.compact, and the statistics.
  • Other agents: only the handoff file and the skill format are portable. Nobody has run handoff in Codex or Gemini CLI yet.

The context-guard tests run with:

claude plugin test plugins/context-guard

What a mod can reach

A mod runs inside Claude Code with your permissions and is not sandboxed. Install mods only from sources you trust, and read what a mod does first. claude plugin validate plugins/context-guard lists what context-guard does: it reads the context size and the list of commands, draws the band above the prompt and the spinner tail, refreshes the numbers every 8 seconds, asks you questions, can start a compact or /clear, can submit one prompt (the handoff request), and keeps its settings and counts in its own store. It reads no files and runs no processes, and it makes no network requests. The exact calls it makes:

$.clock.after, $.clock.every, $.clock.now, $.command.list, $.command.register, $.command.run, $.prompt.submit,
$.session.compact, $.session.surfaces, $.session.usage, $.store.delete, $.store.get, $.store.set, $.ui.ask,
$.ui.invalidate, $.ui.log, $.ui.resolve, $.ui.toast

Problems

Report them in GitHub Issues of this repository: https://github.com/WIPexpert/mikeday-session-kit/issues

License

MIT. See LICENSE.

Source 1 files
hooks/register.ts 849 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Thresholds are percentages of the model's context window, the "used" figure of the
4// Context panel. A percentage stays right when the window differs (1M, 200k, ...).
5const DEFAULT_THRESHOLD_PCT = 35
6const DEFAULT_STEP_PCT = 10
7const MIN_PCT = 1
8const MAX_PCT = 95
9
10const COMPACT = 'Compact'
11const HANDOFF = 'Handoff, then new session'
12const FRESH = 'New session (/clear)'
13const KEEP = 'Continue'
14
15const VERSION = '0.6.5'
16
17// Local counters, kept in this plugin's own store on this machine. Nothing is sent anywhere.
18const STAT_NAMES = [
19  'band.press',
20  'shown',
21  'pctSum',
22  'choice.compact',
23  'choice.handoff',
24  'choice.fresh',
25  'choice.keep',
26  'choice.other',
27  'choice.dismissed',
28  'compact.session',
29  'compact.command',
30  'compact.failed',
31  'handoff.sent',
32  'handoff.failed',
33  'fresh.failed',
34  'firstRun',
35] as const
36
37const USAGE =
38  'Usage: /context-guard <threshold> <step> | off | on | reset | ask | style <dialog|band|both> | stats | stats share | stats reset. Write each value as a percent of the window ("35%") or as thousands of tokens ("350k" or "350"), for example /context-guard 35% 10%.'
39
40// What shows: the dialog when the threshold is passed, the band above the prompt, or both.
41const STYLES = ['dialog', 'band', 'both'] as const
42type Style = (typeof STYLES)[number]
43const DEFAULT_STYLE: Style = 'both'
44
45type Settings = {
46  isEnabled: boolean
47  isConfigured: boolean
48  thresholdPct: number
49  stepPct: number
50  style: Style
51}
52
53const fmtK = (tokens: number) => `${Math.round(tokens / 1000)}k`
54const round1 = (n: number) => Math.round(n * 10) / 10
55const inRange = (pct: number) => pct >= MIN_PCT && pct <= MAX_PCT
56
57// "35%" is a percent; "350k" or "350" is thousands of tokens, turned into a percent of the window.
58const parseAmount = (text: string, windowTokens: number): number | undefined => {
59  const percent = /(\d+(?:\.\d+)?)\s*%/.exec(text)
60  const number = percent ?? /(\d+(?:\.\d+)?)/.exec(text)
61  if (!number) return undefined
62  const value = Number(number[1])
63  const pct = round1(percent ? value : (value * 1000 * 100) / windowTokens)
64  return Number.isFinite(pct) && inRange(pct) ? pct : undefined
65}
66
67const summarize = (settings: Settings, windowTokens: number, compactThreshold: number | undefined): string => {
68  const at = Math.round((windowTokens * settings.thresholdPct) / 100)
69  const step = Math.round((windowTokens * settings.stepPct) / 100)
70  let text = `context-guard is ${settings.isEnabled ? 'on' : 'off'}: asks at ${settings.thresholdPct}% of the window (${fmtK(at)} of ${fmtK(windowTokens)} tokens), then every ${settings.stepPct}% (${fmtK(step)}) after "Continue".`
71  text += ` Shows: ${settings.style === 'both' ? 'the dialog and the band above the prompt' : settings.style === 'band' ? 'the band above the prompt only' : 'the dialog only'}.`
72  if (!settings.isConfigured) text += ' These are the defaults, not confirmed yet: it will ask on the next answer.'
73  if (compactThreshold !== undefined && at >= compactThreshold) {
74    text += ` Warning: Claude Code compacts on its own at about ${fmtK(compactThreshold)}, before this threshold.`
75  }
76  return text
77}
78
79// Each setting is its own key, so two sessions writing different settings do not overwrite each other.
80// 0.2.x kept one `config` object; fold it in once, converting thousands of tokens to percents.
81const migrateOldConfig = async ($: EngineInterface, windowTokens: number): Promise<void> => {
82  const old = (await $.store.get('config')) as Record<string, unknown> | undefined
83  if (old === undefined) return
84  if ((await $.store.get('thresholdPct')) === undefined && typeof old === 'object' && old !== null) {
85    const toPct = (k: unknown, fallback: number) => {
86      const pct = typeof k === 'number' ? round1((k * 1000 * 100) / windowTokens) : NaN
87      return inRange(pct) ? pct : fallback
88    }
89    await $.store.set('thresholdPct', toPct(old.thresholdK, DEFAULT_THRESHOLD_PCT))
90    await $.store.set('stepPct', toPct(old.stepK, DEFAULT_STEP_PCT))
91    await $.store.set('enabled', old.isEnabled !== false)
92    await $.store.set('configured', old.isConfigured === true)
93  }
94  await $.store.delete('config')
95}
96
97const readSettings = async ($: EngineInterface, windowTokens: number): Promise<Settings> => {
98  await migrateOldConfig($, windowTokens)
99  const enabled = await $.store.get('enabled')
100  const configured = await $.store.get('configured')
101  const thresholdPct = await $.store.get('thresholdPct')
102  const stepPct = await $.store.get('stepPct')
103  const style = await $.store.get('style')
104  return {
105    isEnabled: enabled !== false,
106    isConfigured: configured === true,
107    thresholdPct: typeof thresholdPct === 'number' ? thresholdPct : DEFAULT_THRESHOLD_PCT,
108    stepPct: typeof stepPct === 'number' ? stepPct : DEFAULT_STEP_PCT,
109    style: STYLES.find(s => s === style) ?? DEFAULT_STYLE,
110  }
111}
112
113// No surface means nobody can answer a dialog (a `claude -p` run, a background session).
114// If the answer cannot be read, assume someone is there rather than stay silent.
115const hasSurface = async ($: EngineInterface): Promise<boolean> => {
116  try {
117    return (await $.session.surfaces()).length > 0
118  } catch {
119    return true
120  }
121}
122
123// The token count at which Claude Code compacts by itself, when it can be read.
124const compactAt = async ($: EngineInterface): Promise<number | undefined> => {
125  try {
126    const { context } = await $.session.usage({ breakdown: 'summary' })
127    return context.breakdown?.autoCompactThreshold
128  } catch {
129    return undefined
130  }
131}
132
133// Compaction goes through `$.session.compact()` where the session allows it. A headless session
134// (the Claude desktop app runs on the SDK) refuses it: there compaction runs as a /compact prompt,
135// so fall back to the command.
136const compactNow = async ($: EngineInterface): Promise<'session' | 'command'> => {
137  try {
138    await $.session.compact()
139    return 'session'
140  } catch {
141    await $.command.run({ command: 'compact' })
142    return 'command'
143  }
144}
145
146// A counter that cannot be written must never get in the user's way.
147const bump = async ($: EngineInterface, name: string, by = 1): Promise<void> => {
148  try {
149    const key = `stat.${name}`
150    const value = Number(await $.store.get(key))
151    await $.store.set(key, (Number.isFinite(value) ? value : 0) + by)
152    if ((await $.store.get('stat.since')) === undefined) {
153      await $.store.set('stat.since', new Date(await $.clock.now()).toISOString().slice(0, 10))
154    }
155  } catch {
156    // Statistics are optional.
157  }
158}
159
160const readCounts = async ($: EngineInterface): Promise<Record<string, number>> => {
161  const counts: Record<string, number> = {}
162  for (const name of STAT_NAMES) {
163    const value = Number(await $.store.get(`stat.${name}`))
164    counts[name] = Number.isFinite(value) ? value : 0
165  }
166  return counts
167}
168
169const statsText = async ($: EngineInterface): Promise<string> => {
170  const since = await $.store.get('stat.since')
171  if (typeof since !== 'string') {
172    return 'context-guard has no statistics yet. They start counting on this machine when the dialog first appears.'
173  }
174  const n = await readCounts($)
175  const c = (name: string) => n[name] ?? 0
176  const avg = c('shown') > 0 ? Math.round(c('pctSum') / c('shown')) : 0
177  return [
178    `context-guard statistics since ${since} (this machine, all your sessions; nothing is sent anywhere):`,
179    `dialogs shown: ${c('shown')}, on average at ${avg}% of the window`,
180    `answers: Compact ${c('choice.compact')} · Handoff ${c('choice.handoff')} · New session ${c('choice.fresh')} · Continue ${c('choice.keep')} · typed text ${c('choice.other')} · dismissed ${c('choice.dismissed')}`,
181    `compact: ${c('compact.session')} through the session, ${c('compact.command')} through /compact, ${c('compact.failed')} failed`,
182    `handoff requests: ${c('handoff.sent')} sent, ${c('handoff.failed')} failed · /clear failed: ${c('fresh.failed')}`,
183    `first-run questions answered: ${c('firstRun')}`,
184    `band buttons pressed: ${c('band.press')}`,
185  ].join('\n')
186}
187
188// Counts and settings only: no texts, paths, names or ids. The user decides whether to share it.
189const statsShare = async ($: EngineInterface, settings: Settings, windowTokens: number): Promise<string> => {
190  const since = await $.store.get('stat.since')
191  const n = await readCounts($)
192  const c = (name: string) => n[name] ?? 0
193  return JSON.stringify(
194    {
195      tool: 'context-guard',
196      version: VERSION,
197      since: typeof since === 'string' ? since : null,
198      windowTokens,
199      thresholdPct: settings.thresholdPct,
200      stepPct: settings.stepPct,
201      dialogsShown: c('shown'),
202      averagePctAtAsk: c('shown') > 0 ? Math.round(c('pctSum') / c('shown')) : null,
203      answers: {
204        compact: c('choice.compact'),
205        handoff: c('choice.handoff'),
206        newSession: c('choice.fresh'),
207        continue: c('choice.keep'),
208        typed: c('choice.other'),
209        dismissed: c('choice.dismissed'),
210      },
211      compact: { viaSession: c('compact.session'), viaCommand: c('compact.command'), failed: c('compact.failed') },
212      handoffRequests: { sent: c('handoff.sent'), failed: c('handoff.failed') },
213      clearFailed: c('fresh.failed'),
214      firstRunAnswered: c('firstRun'),
215      bandPresses: c('band.press'),
216      style: settings.style,
217    },
218    null,
219    2,
220  )
221}
222
223// The handoff option appears only when a handoff skill is installed. If the command list cannot be
224// read, assume the skill is there rather than hide the option.
225const dialogOptions = async ($: EngineInterface): Promise<string[]> => {
226  const commands = await $.command.list().catch(() => undefined)
227  const hasHandoff = commands === undefined || commands.some(c => c.name === 'handoff' || c.name.endsWith(':handoff'))
228  return hasHandoff ? [COMPACT, HANDOFF, FRESH, KEEP] : [COMPACT, FRESH, KEEP]
229}
230
231// What the dialog and the band buttons report to: how to count, and what to say when an action fails.
232type Report = {
233  count: (name: string, by?: number) => Promise<void>
234  failed: (action: string, err: unknown, manual?: string) => void
235}
236
237// The three actions are the same whether the dialog or a band button asks for them.
238const doCompact = async ($: EngineInterface, r: Report): Promise<void> => {
239  try {
240    const route = await compactNow($)
241    await r.count(route === 'session' ? 'compact.session' : 'compact.command')
242  } catch (err) {
243    await r.count('compact.failed')
244    r.failed('compact', err, '/compact')
245  }
246}
247
248const doHandoff = async ($: EngineInterface, r: Report): Promise<void> => {
249  $.ui.toast('After the handoff is written, run /clear and paste the path into the new session.', { timeoutMs: 12000 })
250  try {
251    // prompt.submit refuses text starting with "/", so the skill is asked for in words.
252    await $.prompt.submit({
253      text: 'Invoke the handoff skill and write a handoff document for this conversation, so a fresh session can pick up the work. Then tell me the file path.',
254    })
255    await r.count('handoff.sent')
256  } catch (err) {
257    await r.count('handoff.failed')
258    r.failed('handoff request', err)
259  }
260}
261
262const doFresh = async ($: EngineInterface, r: Report): Promise<void> => {
263  try {
264    await $.command.run({ command: 'clear' })
265  } catch (err) {
266    await r.count('fresh.failed')
267    r.failed('/clear', err, '/clear')
268  }
269}
270
271// An action that fails must not fail silently: the user chose it.
272const toastFailure = ($: EngineInterface, action: string, err: unknown, manual?: string): void => {
273  const hint = manual ? ` Run ${manual} yourself.` : ''
274  $.ui.toast(`context-guard: ${action} failed: ${String(err)}${hint}`, { timeoutMs: 12000 })
275}
276
277// The first-run questions: when to ask, and how much more context before asking again.
278const runFirstRun = async ($: EngineInterface, windowTokens: number): Promise<void> => {
279  let thresholdPct = DEFAULT_THRESHOLD_PCT
280  let stepPct = DEFAULT_STEP_PCT
281  try {
282    const first = await $.ui.ask('context-guard: at what context size should I ask what to do? (share of the window)', {
283      header: 'Threshold',
284      options: [20, 35, 50].map(p => `${p}% (${fmtK((windowTokens * p) / 100)})`),
285    })
286    thresholdPct = parseAmount(first, windowTokens) ?? thresholdPct
287    const second = await $.ui.ask('And how much more context before I ask again after "Continue"?', {
288      header: 'Step',
289      options: [5, 10, 20].map(p => `${p}% (${fmtK((windowTokens * p) / 100)})`),
290    })
291    stepPct = parseAmount(second, windowTokens) ?? stepPct
292  } catch {
293    // Dialog dismissed: keep the defaults and do not ask again.
294  }
295  await $.store.set('thresholdPct', thresholdPct)
296  await $.store.set('stepPct', stepPct)
297  await $.store.set('configured', true)
298  await bump($, 'firstRun')
299  $.ui.toast(`context-guard: asking at ${thresholdPct}% of the window, then every ${stepPct}%. Change it with /context-guard.`, {
300    timeoutMs: 8000,
301  })
302}
303
304type DialogParams = {
305  tokens: number
306  windowTokens: number
307  percent: number | undefined
308  options: string[]
309  // A preview (`/context-guard ask`) is not counted in the statistics.
310  isPreview: boolean
311  // Called when the answer means "not now": the caller waits before asking again.
312  snooze: () => void
313}
314
315// The dialog and what each answer does. The buttons really act, also in a preview.
316const runDialog = async ($: EngineInterface, p: DialogParams): Promise<void> => {
317  const report: Report = {
318    count: async (name, by = 1) => {
319      if (!p.isPreview) await bump($, name, by)
320    },
321    failed: (action, err, manual) => {
322      toastFailure($, action, err, manual)
323      p.snooze()
324    },
325  }
326  try {
327    const pct = p.percent ?? Math.round((p.tokens / p.windowTokens) * 100)
328    await report.count('shown')
329    await report.count('pctSum', pct)
330    const answer = await $.ui.ask(`Context is ${pct}% of the window (${fmtK(p.tokens).toUpperCase()} tokens). What now?`, {
331      header: 'Context',
332      options: p.options,
333    })
334    const choice = answer === COMPACT ? 'compact' : answer === HANDOFF ? 'handoff' : answer === FRESH ? 'fresh' : answer === KEEP ? 'keep' : 'other'
335    await report.count(`choice.${choice}`)
336    if (answer === COMPACT) {
337      await doCompact($, report)
338    } else if (answer === HANDOFF) {
339      await doHandoff($, report)
340    } else if (answer === FRESH) {
341      await doFresh($, report)
342    } else {
343      p.snooze()
344    }
345  } catch (err) {
346    await report.count('choice.dismissed')
347    $.ui.log(`context-guard: ${String(err)}`, { to: 'debug' })
348    // Dialog dismissed: treat as Continue.
349    p.snooze()
350  }
351}
352
353// The band above the prompt and the spinner tail read what the last turn left: the context size,
354// the settings and whether a handoff skill exists. The drawing never waits on the engine for them.
355// `isKnown` is false right after a compact or a /clear, until the next answer gives a reading.
356type Live = { tokens: number; windowTokens: number; percent: number; isKnown: boolean }
357type Snapshot = { live: Live | undefined; settings: Settings | undefined; hasHandoff: boolean }
358const EMPTY_SNAPSHOT: Snapshot = { live: undefined, settings: undefined, hasHandoff: true }
359
360// When the numbers cannot be read at all, the band keeps what it had rather than disappear.
361const takeSnapshot = async ($: EngineInterface, previous: Snapshot): Promise<Snapshot> => {
362  try {
363    const { context } = await $.session.usage()
364    const tokens = context.tokens
365    const live: Live =
366      tokens === undefined
367        ? { tokens: 0, windowTokens: context.window, percent: 0, isKnown: false }
368        : { tokens, windowTokens: context.window, percent: context.percent ?? Math.round((tokens / context.window) * 100), isKnown: true }
369    const settings = await readSettings($, context.window)
370    const options = await dialogOptions($)
371    return { live, settings, hasHandoff: options.includes(HANDOFF) }
372  } catch {
373    return previous
374  }
375}
376
377// Colors: green below the ask line, amber past it, red from 90% of the window. The track is the
378// same color, dark. The bar is drawn in cells, the last one a partial block, so it moves smoothly.
379const ZONE = { calm: '#2fb67c', warn: '#e8a33a', hot: '#e5584f' } as const
380const TRACK = { calm: '#1f3b2e', warn: '#46391d', hot: '#45231f' } as const
381const HOT_PCT = 90
382const PARTIAL = ['▏', '▎', '▍', '▌', '▋', '▊', '▉']
383type Zone = keyof typeof ZONE
384
385const zoneOf = (pct: number, thresholdPct: number): Zone => (pct < thresholdPct ? 'calm' : pct < HOT_PCT ? 'warn' : 'hot')
386
387const fmtTokens = (tokens: number): string =>
388  tokens >= 1_000_000 ? `${round1(tokens / 1_000_000)}M` : `${Math.round(tokens / 1000)}K`
389
390type Cell = { text: string; fg: string; bg: string | undefined }
391
392// One cell per step of the bar. The cell at the next ask line is a white bar, so the line is
393// visible whether it lies ahead on the track or already behind the fill.
394const barCells = (cells: number, pct: number, thresholdPct: number, nextPct: number): Cell[] => {
395  const mark = Math.min(cells - 1, Math.max(0, Math.floor((nextPct / 100) * cells)))
396  const out: Cell[] = []
397  for (let i = 0; i < cells; i++) {
398    const from = (i * 100) / cells
399    const to = ((i + 1) * 100) / cells
400    const zone = zoneOf(from + 0.01, thresholdPct)
401    const filled = Math.max(0, Math.min(1, (pct - from) / (to - from)))
402    if (i === mark) {
403      out.push({ text: '│', fg: '#ffffff', bg: filled >= 1 ? ZONE[zone] : TRACK[zone] })
404    } else if (filled >= 1) {
405      out.push({ text: '█', fg: ZONE[zone], bg: undefined })
406    } else if (filled <= 0) {
407      out.push({ text: '█', fg: TRACK[zone], bg: undefined })
408    } else {
409      const part = Math.min(PARTIAL.length - 1, Math.max(0, Math.round(filled * 8) - 1))
410      out.push({ text: PARTIAL[part] ?? '▏', fg: ZONE[zone], bg: TRACK[zone] })
411    }
412  }
413  return out
414}
415
416// Neighbouring cells with the same colors become one piece of text.
417const cellRuns = (cells: Cell[]): Cell[] => {
418  const runs: Cell[] = []
419  for (const cell of cells) {
420    const last = runs[runs.length - 1]
421    if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text
422    else runs.push({ ...cell })
423  }
424  return runs
425}
426
427const SVG_WIDTH = 140
428const SVG_HEIGHT = 10
429
430// The Desktop app draws the same bar as a rounded SVG: a faint track in three zones, the same
431// zones solid up to the fill, and a white tick at the next ask line.
432const svgBar = (pct: number, thresholdPct: number, nextPct: number): string => {
433  const at = (p: number) => round1((Math.min(100, Math.max(0, p)) / 100) * SVG_WIDTH)
434  const hotFrom = Math.max(thresholdPct, HOT_PCT)
435  const zones = (opacity: number) =>
436    [
437      `<rect x="0" width="${at(thresholdPct)}" height="${SVG_HEIGHT}" fill="${ZONE.calm}" opacity="${opacity}"/>`,
438      `<rect x="${at(thresholdPct)}" width="${round1(at(hotFrom) - at(thresholdPct))}" height="${SVG_HEIGHT}" fill="${ZONE.warn}" opacity="${opacity}"/>`,
439      `<rect x="${at(hotFrom)}" width="${round1(SVG_WIDTH - at(hotFrom))}" height="${SVG_HEIGHT}" fill="${ZONE.hot}" opacity="${opacity}"/>`,
440    ].join('')
441  const tick = Math.min(SVG_WIDTH - 1.5, Math.max(1.5, at(nextPct)))
442  return [
443    `<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_WIDTH}" height="${SVG_HEIGHT}" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}">`,
444    `<defs><clipPath id="track"><rect width="${SVG_WIDTH}" height="${SVG_HEIGHT}" rx="5"/></clipPath>`,
445    `<clipPath id="fill"><rect width="${at(pct)}" height="${SVG_HEIGHT}"/></clipPath></defs>`,
446    `<g clip-path="url(#track)">${zones(0.22)}<g clip-path="url(#fill)">${zones(1)}</g></g>`,
447    `<rect x="${round1(tick - 1)}" y="0" width="2" height="${SVG_HEIGHT}" rx="1" fill="#ffffff" stroke="#000000" stroke-opacity="0.45" stroke-width="0.5"/>`,
448    '</svg>',
449  ].join('')
450}
451
452// One way to write the band's text, from the full line down to the shortest.
453type Variant = { used: string; tokens: string; ask: string }
454
455// The band is one line. The variants run from the full text to the shortest; the first one that
456// leaves the bar at least 8 cells wins. In the Desktop app the bar has a fixed size and the buttons
457// are native, so the width only picks the variant. Widths are in columns.
458const planBand = (columns: number, surface: string, buttonsWidth: number, variants: Variant[]): { variant: Variant; barCells: number } => {
459  const last = variants[variants.length - 1] as Variant
460  if (surface !== 'terminal') {
461    const pick = columns >= 100 ? 0 : columns >= 84 ? 1 : columns >= 64 ? 2 : 3
462    return { variant: variants[Math.min(pick, variants.length - 1)] ?? last, barCells: 0 }
463  }
464  // Margin 2, the name 8, a gap of 2 before the buttons.
465  const overhead = 2 + 8 + 2 + buttonsWidth
466  for (const v of variants) {
467    const bar = columns - overhead - v.used.length - v.tokens.length - v.ask.length
468    if (bar >= 8) return { variant: v, barCells: Math.min(40, bar) }
469  }
470  return { variant: last, barCells: 8 }
471}
472
473export const register: Register = on => {
474  let nextAskAt: number | undefined
475  let isBusy = false
476  let view: Snapshot = EMPTY_SNAPSHOT
477  // The band button waiting for its confirmation. Every button asks first, in the band itself: one
478  // slip of a click must not compact or clear a session. The engine's own question dialog always
479  // adds a free-text "Other", so it is not used here.
480  let pending: 'compact' | 'handoff' | 'clear' | undefined
481
482  on('session.start', async ($, e, next) => {
483    try {
484      await $.command.register({
485        name: 'context-guard',
486        description: 'Show or set when the context-size prompt appears (percent of the window, or k tokens).',
487        argumentHint: '[<threshold> <step> | off | on | reset | ask | style <dialog|band|both> | stats | stats share | stats reset]',
488      })
489    } catch (err) {
490      // A taken name must not stop the session from starting.
491      $.ui.log(`context-guard: could not register /context-guard: ${String(err)}`, { to: 'debug' })
492    }
493    try {
494      // The band shows the numbers of the last turn, so keep them current between turns too
495      // (a compact or a /clear changes them without a turn).
496      $.clock.every(8000, async () => {
497        view = await takeSnapshot($, view)
498        $.ui.invalidate('ui.render')
499      })
500    } catch (err) {
501      $.ui.log(`context-guard: no refresh timer: ${String(err)}`, { to: 'debug' })
502    }
503    return next(e)
504  })
505
506  on('command.run', { command: 'context-guard' }, async ($, e) => {
507    const args = e.args.trim().toLowerCase().replace(/\s+/g, ' ')
508    const { context } = await $.session.usage()
509    const windowTokens = context.window
510    const settings = await readSettings($, windowTokens)
511
512    if (args === '') {
513      return { text: `${summarize(settings, windowTokens, await compactAt($))}\n${USAGE}` }
514    }
515    if (args === 'off' || args === 'on') {
516      await $.store.set('enabled', args === 'on')
517      view = await takeSnapshot($, view)
518      $.ui.invalidate('ui.render')
519      return { text: `context-guard is ${args}.` }
520    }
521    if (args === 'style' || args.startsWith('style ')) {
522      const style = STYLES.find(s => s === args.slice(6).trim())
523      if (style === undefined) return { text: `Style is ${settings.style}. Use /context-guard style ${STYLES.join(' | ')}: "dialog" asks when the threshold is passed, "band" only shows the bar above the prompt, "both" does both.` }
524      await $.store.set('style', style)
525      view = await takeSnapshot($, view)
526      $.ui.invalidate('ui.render')
527      return { text: `context-guard now shows: ${style === 'both' ? 'the dialog and the band above the prompt' : style === 'band' ? 'the band above the prompt only' : 'the dialog only'}.` }
528    }
529    if (args === 'ask') {
530      if (!(await hasSurface($))) return { text: 'Nobody can answer a dialog in this session.' }
531      if (isBusy) return { text: 'A context-guard dialog is already on its way.' }
532      const options = await dialogOptions($)
533      const tokens = context.tokens ?? 0
534      isBusy = true
535      $.clock.after(300, async () => {
536        try {
537          await runDialog($, {
538            tokens,
539            windowTokens,
540            percent: context.percent,
541            options,
542            isPreview: true,
543            snooze: () => {},
544          })
545        } finally {
546          isBusy = false
547        }
548      })
549      return { text: 'The context-guard dialog opens now with the current numbers. The buttons really act. This opening is not counted in the statistics.' }
550    }
551    if (args === 'stats') return { text: await statsText($) }
552    if (args === 'stats share') {
553      const shared = await statsShare($, settings, windowTokens)
554      return { text: `Counts only, no texts, paths or names. Paste it into a GitHub issue if you want to share it.\n${shared}` }
555    }
556    if (args === 'stats reset') {
557      for (const name of STAT_NAMES) await $.store.delete(`stat.${name}`)
558      await $.store.delete('stat.since')
559      return { text: 'context-guard statistics cleared.' }
560    }
561    if (args === 'reset') {
562      await $.store.set('configured', false)
563      view = await takeSnapshot($, view)
564      $.ui.invalidate('ui.render')
565      return { text: 'context-guard will ask for its thresholds again after the next answer.' }
566    }
567    const parts = args.split(/\s+/)
568    const thresholdPct = parts.length === 2 ? parseAmount(parts[0] ?? '', windowTokens) : undefined
569    const stepPct = parts.length === 2 ? parseAmount(parts[1] ?? '', windowTokens) : undefined
570    if (thresholdPct === undefined || stepPct === undefined) return { text: USAGE }
571
572    await $.store.set('thresholdPct', thresholdPct)
573    await $.store.set('stepPct', stepPct)
574    await $.store.set('enabled', true)
575    await $.store.set('configured', true)
576    nextAskAt = undefined
577    view = await takeSnapshot($, view)
578    $.ui.invalidate('ui.render')
579    const saved: Settings = { isEnabled: true, isConfigured: true, thresholdPct, stepPct, style: settings.style }
580    return { text: summarize(saved, windowTokens, await compactAt($)) }
581  })
582
583  on('turn.complete', async ($, e, next) => {
584    const done = await next(e)
585    // The band follows every answer of the main conversation, whatever else happens below.
586    if (e.agentId === undefined && e.reason === 'answer') {
587      view = await takeSnapshot($, view)
588      $.ui.invalidate('ui.render')
589    }
590    if (e.agentId !== undefined || e.reason !== 'answer' || isBusy) return done
591    if (!(await hasSurface($))) return done
592
593    const { context } = await $.session.usage()
594    const windowTokens = context.window
595    const settings = await readSettings($, windowTokens)
596    if (!settings.isEnabled) return done
597
598    // First run: ask for the threshold and the step once, after the turn is over.
599    if (!settings.isConfigured) {
600      isBusy = true
601      $.clock.after(300, async () => {
602        try {
603          await runFirstRun($, windowTokens)
604          view = await takeSnapshot($, view)
605          $.ui.invalidate('ui.render')
606        } finally {
607          isBusy = false
608        }
609      })
610      return done
611    }
612
613    // "band" only shows the bar: no dialog.
614    if (settings.style === 'band') return done
615
616    const tokens = context.tokens
617    if (tokens === undefined) return done
618    const thresholdTokens = (windowTokens * settings.thresholdPct) / 100
619    const stepTokens = (windowTokens * settings.stepPct) / 100
620
621    // Context shrank (compacted or cleared): re-arm at the base threshold.
622    if (tokens < thresholdTokens) nextAskAt = undefined
623    if (tokens < (nextAskAt ?? thresholdTokens)) return done
624
625    const options = await dialogOptions($)
626
627    isBusy = true
628    // Defer so the turn is fully over before the dialog and any compact run.
629    $.clock.after(300, async () => {
630      try {
631        await runDialog($, {
632          tokens,
633          windowTokens,
634          percent: context.percent,
635          options,
636          isPreview: false,
637          snooze: () => {
638            nextAskAt = tokens + stepTokens
639          },
640        })
641      } finally {
642        isBusy = false
643        // "Continue" moved the next ask line: show it.
644        $.ui.invalidate('ui.render')
645      }
646    })
647
648    return done
649  })
650
651  on('session.end', ($, e, next) => {
652    nextAskAt = undefined
653    pending = undefined
654    // After /clear the context is empty again: show it once the new session has settled.
655    $.clock.after(1500, async () => {
656      view = await takeSnapshot($, view)
657      $.ui.invalidate('ui.render')
658    })
659    return next(e)
660  })
661
662  // The band above the prompt: one line, what the context is and where the next ask lies.
663  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
664    if (e.props.hasSurvey) return next(e)
665    if (view.live === undefined) view = await takeSnapshot($, view)
666    const { live, settings, hasHandoff } = view
667    if (live === undefined || settings === undefined || settings.style === 'dialog') return next(e)
668
669    const { Box, Text, Button, Svg } = $.ui.resolve(e)
670    const isTerminal = e.surface === 'terminal'
671    const report: Report = {
672      count: async name => {
673        await bump($, name)
674      },
675      failed: (action, err, manual) => toastFailure($, action, err, manual),
676    }
677    // Whatever a button starts, the numbers change soon after: take them again, twice.
678    const refreshSoon = () => {
679      for (const ms of [1500, 6000]) {
680        $.clock.after(ms, async () => {
681          view = await takeSnapshot($, view)
682          $.ui.invalidate('ui.render')
683        })
684      }
685    }
686    const spacer = Box({ flexGrow: 1 })
687
688    // The full name only where the user has not set it up yet, and the short name otherwise.
689    if (!settings.isConfigured) {
690      return Box({
691        children: [
692          Text({ children: ['context-guard is not set up: choose when it should ask  '], dimColor: true }),
693          spacer,
694          Button({
695            key: 'setup',
696            label: 'Set up',
697            onPress: async () => {
698              if (isBusy) return
699              isBusy = true
700              try {
701                await runFirstRun($, live.windowTokens)
702              } finally {
703                isBusy = false
704                view = await takeSnapshot($, view)
705                $.ui.invalidate('ui.render')
706              }
707            },
708          }),
709        ],
710      })
711    }
712    if (!settings.isEnabled) {
713      return Box({
714        children: [
715          Text({ children: ['ctx-grd off  '], dimColor: true }),
716          spacer,
717          Button({
718            key: 'turn-on',
719            label: 'Turn on',
720            onPress: async () => {
721              await $.store.set('enabled', true)
722              view = await takeSnapshot($, view)
723              $.ui.invalidate('ui.render')
724            },
725          }),
726        ],
727      })
728    }
729
730    if (pending !== undefined) {
731      const key = pending
732      const question =
733        key === 'compact' ? 'Compact now? The history is summarized.' : key === 'handoff' ? 'Write a handoff for this conversation?' : 'Run /clear? Drops this session\'s context.'
734      const yes = key === 'compact' ? 'Yes, compact' : key === 'handoff' ? 'Yes, write it' : 'Yes, clear'
735      return Box({
736        alignItems: 'center',
737        children: [
738          Text({ children: ['ctx-grd '], dimColor: true }),
739          Text({ color: ZONE.warn, wrap: 'truncate', children: [question] }),
740          Text({ children: ['  '] }),
741          spacer,
742          Box({
743            gap: 1,
744            children: [
745              Button({
746                key: 'confirm',
747                label: yes,
748                onPress: async () => {
749                  pending = undefined
750                  $.ui.invalidate('ui.render')
751                  await bump($, 'band.press')
752                  if (key === 'compact') await doCompact($, report)
753                  else if (key === 'handoff') await doHandoff($, report)
754                  else await doFresh($, report)
755                  refreshSoon()
756                },
757              }),
758              Button({
759                key: 'cancel',
760                label: 'Cancel',
761                autoFocus: true,
762                onPress: () => {
763                  pending = undefined
764                  $.ui.invalidate('ui.render')
765                },
766              }),
767            ],
768          }),
769        ],
770      })
771    }
772
773    const thresholdTokens = (live.windowTokens * settings.thresholdPct) / 100
774    // After "Continue" the next ask lies one step further; "band" alone never moves it.
775    const nextPct =
776      nextAskAt !== undefined && live.tokens >= thresholdTokens && settings.style === 'both'
777        ? Math.min(99, round1(Math.max(settings.thresholdPct, (nextAskAt / live.windowTokens) * 100)))
778        : settings.thresholdPct
779    const isOver = live.isKnown && live.tokens >= (live.windowTokens * nextPct) / 100
780    const zone = zoneOf(live.percent, settings.thresholdPct)
781    // "264K of 1M" and the ask line in words, from the full text to the shortest.
782    const usedText = live.isKnown ? ` ${live.percent}% used` : ' no reading yet'
783    const tokensText = live.isKnown ? ` ${fmtTokens(live.tokens)} of ${fmtTokens(live.windowTokens)}` : ''
784    const askLong = isOver ? ` | over ${nextPct}%` : ` | next ask at ${nextPct}%`
785    const askShort = isOver ? ` | over ${nextPct}%` : ` | ask ${nextPct}%`
786    const variants: Variant[] = [
787      { used: usedText, tokens: tokensText, ask: askLong },
788      { used: usedText, tokens: '', ask: askLong },
789      { used: usedText, tokens: '', ask: askShort },
790      { used: live.isKnown ? ` ${live.percent}%` : ' ?', tokens: '', ask: '' },
791    ]
792    const buttonsWidth = hasHandoff ? 33 : 21
793    const plan = planBand(e.props.bodyColumns ?? 80, e.surface, buttonsWidth, variants)
794
795    const bar = isTerminal
796      ? Box({
797          children: cellRuns(barCells(plan.barCells, live.percent, settings.thresholdPct, nextPct)).map(run =>
798            run.bg === undefined ? Text({ color: run.fg, children: [run.text] }) : Text({ color: run.fg, backgroundColor: run.bg, children: [run.text] }),
799          ),
800        })
801      : Svg({ source: svgBar(live.percent, settings.thresholdPct, nextPct), alt: `Context ${live.percent}% of the window, next ask at ${nextPct}%` })
802
803    // Only Compact is bright once the line is passed: that is the one to press.
804    const press = (key: 'compact' | 'handoff' | 'clear', label: string) =>
805      Button({
806        key,
807        label,
808        dimColor: !isOver || key !== 'compact',
809        onPress: () => {
810          pending = key
811          $.ui.invalidate('ui.render')
812          // Nobody answered: go back to the normal line.
813          $.clock.after(20_000, () => {
814            if (pending === key) {
815              pending = undefined
816              $.ui.invalidate('ui.render')
817            }
818          })
819        },
820      })
821
822    return Box({
823      alignItems: 'center',
824      children: [
825        Text({ children: ['ctx-grd '], dimColor: true }),
826        bar,
827        Text({ color: ZONE[zone], children: [plan.variant.used] }),
828        ...(plan.variant.tokens === '' ? [] : [Text({ wrap: 'truncate', children: [plan.variant.tokens] })]),
829        ...(plan.variant.ask === ''
830          ? []
831          : [isOver ? Text({ color: ZONE.warn, wrap: 'truncate', children: [plan.variant.ask] }) : Text({ dimColor: true, wrap: 'truncate', children: [plan.variant.ask] })]),
832        Text({ children: ['  '] }),
833        spacer,
834        Box({
835          gap: 1,
836          children: [press('compact', 'Compact'), ...(hasHandoff ? [press('handoff', 'Handoff')] : []), press('clear', 'Clear')],
837        }),
838      ],
839    })
840  })
841
842  // Beside the spinner's word while Claude works: `Thinking · ctx 77%…`.
843  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
844    const { live, settings } = view
845    if (live === undefined || !live.isKnown || settings === undefined || !settings.isEnabled || !settings.isConfigured || settings.style === 'dialog') return next(e)
846    return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix ?? ''} · ctx ${live.percent}%` } })
847  })
848}
849