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

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.
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:
| Agent | What you get |
|---|---|
| Claude Code | handoff and context-guard |
| Codex, Gemini CLI, Cursor, OpenCode and other agents that load Agent Skills | handoff only. context-guard runs only inside Claude Code. |
The agent asks before each change. To read what it will do first, open INSTALL.md.
/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.
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.
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:
Details:
~/.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.verbatim_copy field.handoff is a skill in the Agent Skills format, so other agents can load it. Only Claude Code was tested.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.
/clear afterwards and paste the path into the new session./compact for you. If an action fails anyway, a message says so and tells you which command to type.claude -p.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 ]
Continue. When the context is past it, the line says over 35% and the Compact button is the bright one./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./clear there is no token count until the next answer, and the band says no reading yet.Thinking · ctx 77%….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.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.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:
| Command | What it does |
|---|---|
/context-guard | Shows 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 100k | The same in thousands of tokens, turned into shares of the current window |
/context-guard off | Switches it off |
/context-guard on | Switches it on |
/context-guard reset | Asks for the values again after the next answer |
/context-guard style dialog | Shows only the dialog. band shows only the band, both shows both (the default). /context-guard style shows the current one |
/context-guard ask | Opens 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 stats | Shows your own usage counts |
/context-guard stats share | Prints the counts as JSON you can paste into an issue |
/context-guard stats reset | Clears 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.
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.
context-guard. In Desktop's WSL sessions plugins are unavailable.jsonschema 4.26. Other versions and systems are untested.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.handoff in Codex or Gemini CLI yet.The context-guard tests run with:
claude plugin test plugins/context-guard
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
Report them in GitHub Issues of this repository: https://github.com/WIPexpert/mikeday-session-kit/issues
MIT. See LICENSE.
hooks/register.ts 849 lines1import 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