Band above the prompt: context fill, rate limits, session cost and a Handoff button that writes a handoff doc, clears the context and reads it back

Claude Code mods.
| Mod | What it does |
|---|---|
| agent-crabs | Side pane with a card per subagent and background process: animated crab, model, context fill, idle warning, API-equivalent cost, and expandable operations, files and tokens |
| context-band | Band above the prompt: context fill, rate limits, session cost and a Handoff button that writes a handoff doc, clears the context and reads it back |
| next-steps | Только в desktop-приложении: после каждого ответа Haiku предлагает над полем ввода три разных варианта следующей команды столбиком с полным текстом; клик или цифра 1–3 вставляет её в поле ввода, Enter отправляет как твою команду. |
| done-sound | Проигрывает звук Glass, когда Claude завершает задачу ответом (только основной цикл, без сабагентов). |
| session-branches | Terminal-only pane with the tree of this project's sessions and their forks; press a row to switch to it |
At the prompt of a Claude Code terminal session (replace <mod> with a name from the table):
/plugin install <mod> --marketplace Pavelkv94/mods
Answer y to add the marketplace, then pick a scope (user is the default).
From a shell:
claude plugin install <mod> --marketplace Pavelkv94/mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Pavelkv94/mods, then Add plugin.
claude plugin update <mod>@pavelkv94-mods
smart-compact was renamed to context-band: uninstall the old one and install context-band.
hooks/register.tsx 450 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Cache, CacheConfig, Flow, Usage } from '../types'
5import {
6 adviceColor,
7 advise,
8 BIG_CONTEXT,
9 barSvg,
10 cacheColor,
11 cacheConfig,
12 DEFAULT_CACHE_CONFIG,
13 cachePercent,
14 pieSvg,
15 ringSvg,
16 cacheLeftMs,
17 cacheText,
18 cacheTtlMs,
19 costText,
20 cacheWarnMs,
21 degradation,
22 HANDOFF_LABEL,
23 idleLeftMs,
24 handoffPath,
25 levelColor,
26 limitName,
27 meter,
28 MIN_HANDOFF_BYTES,
29 MTIME_SLACK_MS,
30 NO_ACTIVITY,
31 parseModel,
32 recordTool,
33 PALETTE,
34 pickFit,
35 readPrompt,
36 resetIn,
37 tokensText,
38 toneRank,
39 WARM_LABEL,
40 WARM_PROMPT,
41 writePrompt,
42} from './lib'
43
44const usageAtom = atom({ plugin: 'context-band', key: 'usage' } as const, null)
45const IDLE: Flow = { phase: 'idle', path: null, startedAt: null }
46const flowAtom = atom({ plugin: 'context-band', key: 'flow' } as const, IDLE)
47const NO_CACHE: Cache = { lastAt: null, warned: false }
48const cacheAtom = atom({ plugin: 'context-band', key: 'cache' } as const, NO_CACHE)
49// written on a timer so the cache countdown redraws between turns
50const tickAtom = atom({ plugin: 'context-band', key: 'tick' } as const, 0)
51const handoffAtom = atom({ plugin: 'context-band', key: 'handoff' } as const, null)
52const activityAtom = atom({ plugin: 'context-band', key: 'activity' } as const, NO_ACTIVITY)
53const configAtom = atom({ plugin: 'context-band', key: 'config' } as const, DEFAULT_CACHE_CONFIG)
54
55// idle compaction runs only in an interactive terminal; the timer has no surface of its own, so it reads the last drawn
56let lastSurface: string | null = null
57
58async function readJson($: EngineInterface, path: string): Promise<Record<string, unknown>> {
59 try {
60 const value: unknown = JSON.parse(String(await $.fs.read(path)))
61 return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
62 } catch {
63 return {}
64 }
65}
66
67/** Reads the env and the user, project and local settings, later ones winning, as Claude Code layers them. */
68async function loadConfig($: EngineInterface): Promise<void> {
69 // each name literal: the engine lists the variables a module reads
70 const env: Record<string, string | undefined> = {
71 FORCE_PROMPT_CACHING_5M: await $.env.get('FORCE_PROMPT_CACHING_5M'),
72 CLAUDE_CODE_PROMPT_CACHE_TTL: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
73 ENABLE_PROMPT_CACHING_1H: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
74 DISABLE_AUTO_COMPACT: await $.env.get('DISABLE_AUTO_COMPACT'),
75 CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: await $.env.get('CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS'),
76 }
77 const home = (await $.env.get('HOME')) ?? '~'
78 const root = await $.session.root()
79 const settings = {
80 ...(await readJson($, `${home}/.claude/settings.json`)),
81 ...(await readJson($, `${root}/.claude/settings.json`)),
82 ...(await readJson($, `${root}/.claude/settings.local.json`)),
83 }
84 await update($, configAtom, (): CacheConfig => cacheConfig(env, settings))
85}
86
87const PHASE_TEXT: Record<Flow['phase'], string> = {
88 idle: '',
89 writing: '✍ Writing handoff…',
90 clearing: '◌ Clearing context…',
91 reading: '❖ Reading handoff…',
92}
93
94async function refresh($: EngineInterface): Promise<void> {
95 const u = await $.session.usage()
96 // /model may show a bare alias; the id the last response reported names the version
97 const named = await $.session.model().catch(() => null)
98 const known = (await read($, usageAtom))?.model ?? null
99 const next: Usage = {
100 percent: u.context.percent ?? null,
101 tokens: u.context.tokens ?? null,
102 window: u.context.window,
103 limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
104 cost: u.cost?.usd ?? null,
105 model: parseModel(named) ? named : (known ?? named),
106 }
107 await update($, usageAtom, () => next)
108}
109
110/** Redraws the countdown and warns once when the cache is about to cool. */
111async function tickCache($: EngineInterface): Promise<void> {
112 const now = await $.clock.now()
113 await update($, tickAtom, () => now)
114 const cache = await read($, cacheAtom)
115 if (cache.lastAt === null || cache.warned) return
116 const usage = await read($, usageAtom)
117 const config = await read($, configAtom)
118 const ttl = cacheTtlMs(usage?.limits ?? [], config.ttl)
119 const left = cacheLeftMs(cache.lastAt, ttl, now)
120 const idle = idleLeftMs({ lastAt: cache.lastAt, ttl, tokens: usage?.tokens ?? null, config, terminal: lastSurface === 'terminal', now })
121 if (idle !== null && idle > 0 && idle <= cacheWarnMs(ttl)) {
122 await update($, cacheAtom, (c): Cache => ({ ...c, warned: true }))
123 $.ui.toast(`Claude Code compacts this idle conversation in ${Math.ceil(idle / 60_000)} min: send a message to keep it, or Handoff now`)
124 } else if (left !== null && left > 0 && left <= cacheWarnMs(ttl)) {
125 await update($, cacheAtom, (c): Cache => ({ ...c, warned: true }))
126 const min = Math.ceil(left / 60_000)
127 $.ui.toast(
128 (usage?.tokens ?? 0) >= BIG_CONTEXT
129 ? `Prompt cache cools in ${min} min: send a message to keep it warm, or Handoff before a long break`
130 : `Prompt cache cools in ${min} min: send a message to keep it warm`,
131 )
132 }
133}
134
135async function start($: EngineInterface): Promise<string> {
136 const flow = await read($, flowAtom)
137 if (flow.phase !== 'idle') return 'Handoff is already running.'
138 const home = (await $.env.get('HOME')) ?? '~'
139 const root = await $.session.root()
140 const now = await $.clock.now()
141 const path = handoffPath(home, root, now)
142 await update($, flowAtom, (): Flow => ({ phase: 'writing', path, startedAt: now }))
143 const sent = await $.prompt.submit({ text: writePrompt(path, root, await read($, handoffAtom)) })
144 if ('drop' in sent && sent.drop !== undefined) {
145 await update($, flowAtom, () => IDLE)
146 $.ui.toast(`Handoff cancelled: ${sent.drop}`)
147 }
148 return `Handoff → ${path}, then /clear`
149}
150
151/**
152 * Whether the model really wrote a handoff worth clearing the context for: written during this run,
153 * so an earlier file at the same path never stands in for one the model did not write.
154 */
155async function handoffWritten($: EngineInterface, path: string, since: number): Promise<boolean> {
156 const stat = await $.fs.stat(path).catch(() => undefined)
157 return stat !== undefined && stat.kind === 'file' && stat.size >= MIN_HANDOFF_BYTES && stat.mtimeMs >= since - MTIME_SLACK_MS
158}
159
160/** Clears the conversation, then has the fresh one read the handoff. */
161async function clearAndRead($: EngineInterface, path: string): Promise<void> {
162 await update($, flowAtom, (): Flow => ({ phase: 'clearing', path, startedAt: null }))
163 try {
164 // $.command.run is queued and runs once the session is idle, as if the person typed /clear
165 await $.command.run({ command: 'clear', args: '' })
166 } catch (err) {
167 await update($, flowAtom, () => IDLE)
168 $.ui.toast(`/clear failed: ${String(err)}. The handoff is saved: ${path}`)
169 return
170 }
171 // the new conversation has written no cache yet and done no work
172 await update($, cacheAtom, () => NO_CACHE)
173 await update($, activityAtom, () => NO_ACTIVITY)
174 await update($, handoffAtom, () => path)
175 await update($, flowAtom, (): Flow => ({ phase: 'reading', path, startedAt: null }))
176 void refresh($)
177 const sent = await $.prompt.submit({ text: readPrompt(path) })
178 if ('drop' in sent && sent.drop !== undefined) {
179 await update($, flowAtom, () => IDLE)
180 $.ui.toast(`Context cleared, but the read prompt was dropped (${sent.drop}). Ask to read ${path}`)
181 }
182}
183
184/** Toasts once per level as the context strains, so the advice reaches the person mid-work too. */
185async function alertStrain($: EngineInterface): Promise<void> {
186 const usage = await read($, usageAtom)
187 const activity = await read($, activityAtom)
188 const tip = degradation(usage?.tokens ?? 0, activity, usage?.model ?? null)
189 if (!tip || tip.tone === 'info' || toneRank(tip.tone) <= toneRank(activity.alerted)) return
190 await update($, activityAtom, (a): Activity => ({ ...a, alerted: tip.tone }))
191 $.ui.toast(tip.text)
192}
193
194/** Sends a one-word turn so the cache is read again and lives another TTL. */
195async function warm($: EngineInterface): Promise<void> {
196 const sent = await $.prompt.submit({ text: WARM_PROMPT })
197 if ('drop' in sent && sent.drop !== undefined) $.ui.toast(`Warm cancelled: ${sent.drop}`)
198}
199
200export const register: Register = on => {
201 on('session.start', async ($, e, next) => {
202 $.clock.every(30_000, () => void tickCache($))
203 await update($, flowAtom, () => IDLE)
204 // unread settings leave Claude Code's defaults, which is what most sessions run on
205 void loadConfig($).catch(() => undefined)
206 await $.command.register({
207 name: 'handoff',
208 description: 'Write a handoff doc, /clear the session, then read the handoff in the fresh context',
209 })
210 await $.command.register({
211 name: 'smart-compact',
212 description: 'Old name of /handoff: handoff doc, /clear, read it back',
213 })
214 void refresh($)
215 return next(e)
216 })
217
218 on('command.run', { command: 'handoff' }, async $ => ({ text: await start($) }))
219 on('command.run', { command: 'smart-compact' }, async $ => ({ text: await start($) }))
220
221 // a /clear the person ran themself: the earlier handoff is not in the new context
222 on('session.end', async ($, e, next) => {
223 if (e.reason === 'clear' && (await read($, flowAtom)).phase !== 'clearing') {
224 await update($, handoffAtom, () => null)
225 await update($, cacheAtom, () => NO_CACHE)
226 await update($, activityAtom, () => NO_ACTIVITY)
227 }
228 return next(e)
229 })
230
231 // a compaction the person or the engine ran (/compact, the auto threshold): the work the counts
232 // describe is summarized away, and the new transcript has no prompt cache of its own yet
233 on('session.compact', async ($, e, next) => {
234 const result = await next(e)
235 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
236 await update($, activityAtom, () => NO_ACTIVITY)
237 await update($, cacheAtom, () => NO_CACHE)
238 void refresh($)
239 }
240 return result
241 })
242
243 // subagents' calls fill their own context, not this one
244 on('tool.call', async ($, e, next) => {
245 const result = await next(e)
246 if (e.agentId === undefined && result.deny === undefined) {
247 const input = {
248 file_path: 'file_path' in e && typeof e.file_path === 'string' ? e.file_path : undefined,
249 command: 'command' in e && typeof e.command === 'string' ? e.command : undefined,
250 }
251 await update($, activityAtom, a => recordTool(a, e.tool, input, result.isError === true))
252 }
253 return result
254 })
255
256 on('session.measure', async ($, e, next) => {
257 await refresh($)
258 return next(e)
259 })
260
261 on('turn.complete', async ($, e, next) => {
262 const result = await next(e)
263 if (e.agentId !== undefined) return result
264 // the id the response reported, written before the refresh that reads it back
265 const model = e.usage?.model
266 if (model !== undefined && parseModel(model)) await update($, usageAtom, u => (u ? { ...u, model } : u))
267 void refresh($).then(() => alertStrain($))
268 // the turn's last API response just refreshed the prompt cache
269 if (e.usage !== undefined) {
270 const now = await $.clock.now()
271 await update($, cacheAtom, (): Cache => ({ lastAt: now, warned: false }))
272 }
273
274 const flow = await read($, flowAtom)
275 if (flow.phase === 'writing' && flow.path) {
276 const path = flow.path
277 if (e.isAborted || e.reason !== 'answer') {
278 await update($, flowAtom, () => IDLE)
279 $.ui.toast('Handoff interrupted: nothing was cleared')
280 return result
281 }
282 // /clear loses everything not in the file, so clear only over a real handoff
283 if (!(await handoffWritten($, path, flow.startedAt ?? 0))) {
284 await update($, flowAtom, () => IDLE)
285 $.ui.toast(`Handoff not found or too short at ${path}: the context was NOT cleared`)
286 return result
287 }
288 void clearAndRead($, path)
289 } else if (flow.phase === 'reading') {
290 await update($, flowAtom, () => IDLE)
291 void refresh($)
292 }
293 return result
294 })
295
296 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
297 if (e.props.hasSurvey) return next(e)
298 // Полоса одна на все моды: то, что рисуют моды ниже (например, next-steps), ставим над индикаторами.
299 const below = await next(e).catch(() => null)
300 const usage = await read($, usageAtom)
301 const flow = await read($, flowAtom)
302 const now = Math.max(await $.clock.now(), await read($, tickAtom))
303 const cache = await read($, cacheAtom)
304 const working = e.props.isWorking
305 lastSurface = e.surface
306 const config = await read($, configAtom)
307 const ttl = cacheTtlMs(usage?.limits ?? [], config.ttl)
308 const cacheLeft = cacheLeftMs(cache.lastAt, ttl, now)
309 const cacheTone = cacheColor(cacheLeft, ttl, working)
310 const cacheValue = cacheText(cacheLeft, working)
311 const pct = usage?.percent ?? null
312 const ctxTone = pct === null ? PALETTE.muted : levelColor(pct)
313 const idle = flow.phase === 'idle'
314 // warming a cold cache is no ping but a full re-write, so the button goes inert then
315 const warmable = working || (cacheLeft !== null && cacheLeft > 0)
316 const activity = await read($, activityAtom)
317 const idleLeft = idleLeftMs({ lastAt: cache.lastAt, ttl, tokens: usage?.tokens ?? null, config, terminal: e.surface === 'terminal', now })
318 const tip = usage && idle && !working ? advise({ ...usage, cacheLeft, ttl, now, activity, idleLeft }) : null
319 const limits = usage?.limits ?? []
320 const cost = usage?.cost ?? null
321
322 if (e.surface !== 'terminal') {
323 const { Box, Button, Svg, Text } = $.ui.resolve(e)
324 // one row that never wraps: the bars and values keep their size, the grey details shrink first
325 const segment = (key: string, label: string, percent: number, tone: string, value: string, detail: string, alt: string, chart = 'bar') => (
326 <Box key={key} flexDirection="row" alignItems="center" gap="xs" flexShrink={detail === '' ? 0 : 1} minWidth={0}>
327 {label !== '' && <Text size="caption" color={PALETTE.muted} wrap="truncate-end">{label}</Text>}
328 {chart === 'pie' ? (
329 <Svg source={pieSvg(percent, tone)} alt={alt} width={14} height={14} />
330 ) : chart === 'ring' ? (
331 <Svg source={ringSvg(percent, tone)} alt={alt} width={14} height={14} />
332 ) : chart === 'bar' ? (
333 <Svg source={barSvg(percent, tone, 40, 5)} alt={alt} width={40} height={5} />
334 ) : null}
335 <Text size="footnote" weight="semibold" tabular color={tone} wrap="truncate-end">{value}</Text>
336 {detail !== '' && (
337 <Box flexShrink={1} minWidth={0} overflow="hidden">
338 <Text size="caption" color={PALETTE.muted} wrap="truncate-end">{detail}</Text>
339 </Box>
340 )}
341 </Box>
342 )
343 return (
344 <Box flexDirection="column" gap="xs">
345 {below}
346 {tip && <Text size="caption" color={adviceColor(tip.tone)}>{tip.text}</Text>}
347 {/* context and limits hold the left edge, the rest the right one, so a changing value never shifts the other side */}
348 <Box flexDirection="row" alignItems="center" justifyContent="space-between" flexWrap="nowrap" columnGap="md" overflow="hidden">
349 <Box key="meters" flexDirection="row" alignItems="center" flexWrap="nowrap" columnGap="md" flexShrink={1} minWidth={0} overflow="hidden">
350 {segment(
351 'context', 'Context', pct ?? 0, ctxTone, pct === null ? '—' : `${pct}%`,
352 usage ? tokensText(usage, true) : '',
353 `Context ${pct ?? '—'}% full`,
354 )}
355 {limits.map(l => {
356 const reset = resetIn(l.resetsAt, now)
357 return segment(
358 l.kind, limitName(l.kind), l.percentUsed, levelColor(l.percentUsed),
359 `${Math.round(l.percentUsed)}%`, reset,
360 `${limitName(l.kind)} limit ${Math.round(l.percentUsed)}% used`,
361 'ring',
362 )
363 })}
364 </Box>
365 {/* the button sits right after the cache and never shrinks away */}
366 <Box key="tail" flexDirection="row" alignItems="center" gap="md" flexWrap="nowrap" flexShrink={0}>
367 {cost !== null && segment('cost', '', 0, PALETTE.accent, costText(cost), '', `Session cost ${costText(cost)}`, 'none')}
368 {segment(
369 'cache', 'Cache', cachePercent(cacheLeft, ttl, working), cacheTone, cacheValue,
370 '',
371 `Prompt cache ${cacheValue}`,
372 'pie',
373 )}
374 {!idle ? (
375 <Text size="footnote" weight="semibold" color={PALETTE.accent}>{PHASE_TEXT[flow.phase]}</Text>
376 ) : (
377 <Box flexDirection="row" gap="sm">
378 {warmable ? (
379 <Button key="keep-warm" label={WARM_LABEL} variant="secondary" onPress={() => void warm($)} />
380 ) : (
381 <Box key="keep-warm-off" borderStyle="round" borderColor={PALETTE.track} paddingX="sm">
382 <Text size="footnote" color={PALETTE.track}>{WARM_LABEL}</Text>
383 </Box>
384 )}
385 <Button key="smart-compact" label={HANDOFF_LABEL} variant="primary" onPress={() => void start($)} />
386 </Box>
387 )}
388 </Box>
389 </Box>
390 </Box>
391 )
392 }
393
394 const { Box, Button, Text } = $.ui.resolve(e)
395 const fit = pickFit(e.props.bodyColumns, usage, now, idle)
396 const ctx = meter(pct ?? 0, fit.ctxWidth)
397 return (
398 <Box flexDirection="column">
399 {below}
400 {tip && <Text color={adviceColor(tip.tone)}>▸ {tip.text}</Text>}
401 <Box flexDirection="row" alignItems="center" flexWrap="wrap" columnGap={3}>
402 <Text>
403 {fit.ctxLabel && <Text color={PALETTE.muted}>Context </Text>}
404 <Text color={ctxTone}>{ctx.filled}</Text>
405 <Text color={PALETTE.track}>{ctx.empty}</Text>
406 <Text bold color={ctxTone}> {pct === null ? '—' : `${pct}%`}</Text>
407 {usage && <Text color={PALETTE.muted}> {tokensText(usage, fit.ctxLabel)}</Text>}
408 </Text>
409 {limits.map(l => {
410 const m = meter(l.percentUsed, fit.limitMeter)
411 const reset = fit.reset ? resetIn(l.resetsAt, now) : ''
412 return (
413 <Text key={l.kind}>
414 <Text color={PALETTE.muted}>{limitName(l.kind)} </Text>
415 <Text color={levelColor(l.percentUsed)}>{m.filled}</Text>
416 <Text color={PALETTE.track}>{m.empty}</Text>
417 <Text bold color={levelColor(l.percentUsed)}> {Math.round(l.percentUsed)}%</Text>
418 {reset !== '' && <Text color={PALETTE.muted}> · {reset}</Text>}
419 </Text>
420 )
421 })}
422 {cost !== null && (
423 <Text key="cost">
424 <Text bold color={PALETTE.accent}>{costText(cost)}</Text>
425 </Text>
426 )}
427 <Box key="tail" flexDirection="row" alignItems="center" columnGap={2} flexWrap="nowrap">
428 <Text>
429 {fit.ctxLabel && <Text color={PALETTE.muted}>Cache </Text>}
430 <Text bold color={cacheTone}>{cacheValue}</Text>
431 </Text>
432 {!idle ? (
433 <Text bold color={PALETTE.accent}>{PHASE_TEXT[flow.phase]}</Text>
434 ) : (
435 <Box flexDirection="row" columnGap={2}>
436 {warmable ? (
437 <Button key="keep-warm" label={fit.warm} variant="secondary" onPress={() => void warm($)} />
438 ) : (
439 <Text key="keep-warm-off" color={PALETTE.track}>[ {fit.warm} ]</Text>
440 )}
441 <Button key="smart-compact" label={fit.button} variant="primary" onPress={() => void start($)} />
442 </Box>
443 )}
444 </Box>
445 </Box>
446 </Box>
447 )
448 })
449}
450hooks/lib.ts 602 lines1import type { Activity, CacheConfig } from '../types'
2
3const LIMIT_NAMES: Record<string, string> = {
4 five_hour: '5h',
5 seven_day: '7d',
6 seven_day_opus: '7d opus',
7 spend_limit: 'spend',
8}
9
10/** Theme keys, so the band follows the person's light or dark theme. */
11export const PALETTE = {
12 ok: 'success',
13 warn: 'warning',
14 hot: 'error',
15 track: 'inactive',
16 muted: 'subtle',
17 accent: 'suggestion',
18} as const
19
20/** The same tones as raw colors for SVG, which cannot name a theme key; mid tones read on light and dark. */
21const SVG_HEX: Record<string, string> = {
22 success: '#3fb950',
23 warning: '#d29922',
24 error: '#f85149',
25 inactive: '#8b949e',
26 subtle: '#8b949e',
27 suggestion: '#58a6ff',
28}
29
30export function levelColor(percent: number): string {
31 if (percent >= 85) return PALETTE.hot
32 if (percent >= 60) return PALETTE.warn
33 return PALETTE.ok
34}
35
36/** A terminal meter in whole cells: full-height blocks on a shaded track, so the bar fills the row's height. */
37export function meter(percent: number, width = 12): { filled: string; empty: string } {
38 const p = Math.max(0, Math.min(100, percent))
39 const full = Math.min(width, Math.round((p / 100) * width))
40 return { filled: '█'.repeat(full), empty: '░'.repeat(width - full) }
41}
42
43/** A rounded progress bar for surfaces that draw SVG. */
44export function barSvg(percent: number, color: string, width = 56, height = 6): string {
45 const p = Math.max(0, Math.min(100, percent))
46 const fill = p === 0 ? 0 : Math.max(height, Math.round((p / 100) * width))
47 const r = height / 2
48 return (
49 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
50 `<rect width="${width}" height="${height}" rx="${r}" fill="${SVG_HEX.inactive}" fill-opacity="0.25"/>` +
51 (fill > 0 ? `<rect width="${fill}" height="${height}" rx="${r}" fill="${SVG_HEX[color] ?? color}"/>` : '') +
52 '</svg>'
53 )
54}
55
56export function shortTokens(n: number): string {
57 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
58 if (n >= 1000) return `${Math.round(n / 1000)}k`
59 return String(n)
60}
61
62export function resetIn(resetsAt: string | undefined, now: number): string {
63 if (!resetsAt) return ''
64 const ms = Date.parse(resetsAt) - now
65 if (!Number.isFinite(ms) || ms <= 0) return ''
66 const min = Math.round(ms / 60000)
67 if (min < 60) return `${min}m`
68 const h = Math.floor(min / 60)
69 if (h < 48) return `${h}h${min % 60 ? ` ${min % 60}m` : ''}`
70 return `${Math.round(h / 24)}d`
71}
72
73export function limitName(kind: string): string {
74 return LIMIT_NAMES[kind] ?? kind
75}
76
77export function slug(s: string): string {
78 return s.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^-+|-+$/g, '') || 'project'
79}
80
81export function stamp(ms: number): string {
82 const d = new Date(ms)
83 const p = (n: number) => String(n).padStart(2, '0')
84 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
85}
86
87export function handoffPath(home: string, root: string, now: number): string {
88 const project = slug(root.split('/').filter(Boolean).at(-1) ?? 'project')
89 return `${home}/.claude/handoffs/${project}/${stamp(now)}.md`
90}
91
92/** A handoff shorter than this is a stub or an error, never one worth clearing the context for. */
93export const MIN_HANDOFF_BYTES = 400
94
95export function writePrompt(path: string, root: string, prev: string | null = null): string {
96 return [
97 `Handoff: write a handoff document to \`${path}\` (outside the project; create the folder if it does not exist).`,
98 `Project: \`${root}\`.`,
99 'Right after you finish, the conversation will be CLEARED with /clear: no summary is kept, and a fresh session will continue from this file alone. Anything not in the file is lost.',
100 ...(prev
101 ? [
102 `An earlier handoff of this work is at \`${prev}\`: read it first. Carry over everything in it that is still true, update what changed, drop what is obsolete; fold finished phases into one line each under History. Rewrite the document, do not append to it.`,
103 ]
104 : []),
105 '',
106 '## Before writing',
107 '- Gather facts, do not recall them: if the project is a git repository, run `git status --short`, `git branch --show-current` and `git log --oneline -5`; note background processes, dev servers and ports you started. Read-only commands only.',
108 '- Change nothing else: no edits, commits or cleanup. Never write secrets, tokens or passwords into the file; name where they live instead.',
109 '',
110 '## Format',
111 'Markdown, in the language the user speaks in this conversation. Start with this header:',
112 '```',
113 '# Handoff: <task in a few words>',
114 '<date and time> · project <path> · branch <branch> @ <short sha>, <clean | N uncommitted files> · status <in progress | blocked on the user | waiting on a process | done>',
115 '```',
116 'Then the sections below, in this order. Skip a section that has nothing in it rather than writing "none", except 1, 2 and 7, which are always there.',
117 '1. **Resume here**: 2–4 lines: where things stand and the single next action, concrete enough to start without reading further (a command, a file and function, or a question to ask the user).',
118 '2. **Goal**: what the user ultimately wants and what "done" looks like.',
119 '3. **User\'s words**: the latest request and every instruction, constraint or preference that still applies, quoted verbatim with a few words of context. Paraphrase loses nuance; quote.',
120 '4. **State**: done (and how it was verified) / done but not verified / in progress, half-made (which files are half-edited, what is uncommitted, what is still running) / not started.',
121 '5. **Decisions and dead ends**: each decision with its reason; each approach tried and rejected with why, so it is not retried.',
122 '6. **Map**: the files, functions, configs, URLs, tickets, PRs and documents that matter, each with one line on its role. Point to them; do not copy their contents.',
123 '7. **Next steps**: an ordered checklist, each step small enough to do and check on its own; mark steps that need the user\'s answer or approval.',
124 '8. **How to check**: exact commands to build, test or run, with the expected result and the last actual one; the exact text of any error still open.',
125 '9. **Environment**: what the next session must know about the machine: processes or servers left running (and their ports), env vars, worktrees, temp files, services that must be up.',
126 '10. **Open questions and risks**: unknowns, assumptions not verified (say so), things that could go wrong.',
127 '11. **History** (only with an earlier handoff): one line per earlier phase.',
128 '',
129 'Fit the sections to the work: for code, the map and the checks carry most weight; for research or analysis, list sources and findings with their confidence in State; for writing, where each draft lives and what feedback it got; for a discussion, the conclusions reached and what is still open.',
130 '',
131 '## Quality',
132 '- Specific over general: paths, names, numbers, commands, exact error text. Mark facts you did not verify.',
133 '- Self-contained: no "as discussed above" or "the fix we talked about": the reader has never seen this conversation.',
134 '- As short as it can be while complete: usually 60–200 lines. Small critical snippets (a signature, an error, a config line) are fine; large pastes are not.',
135 '- When done, reread the file once and ask: could a new session continue with only this file and the repository? Fix what is missing.',
136 '',
137 'Reply with one line: the path to the file.',
138 ].join('\n')
139}
140
141export function readPrompt(path: string): string {
142 return [
143 `This session continues work from before a /clear. The handoff document is \`${path}\`: read it in full first.`,
144 'Then check cheaply that it still matches reality (for example `git status` and `git branch --show-current` when the header names a branch) and say if anything differs.',
145 'Then confirm briefly, 3–6 bullets: the goal, the current state, the next step, and any question waiting on me. Do not start the work until I say so.',
146 ].join('\n')
147}
148
149/** The cheapest turn that still reads the whole context, which refreshes the prompt cache's TTL. */
150export const WARM_PROMPT = 'Cache keep-alive: reply with just "ok". Do nothing else.'
151export const WARM_LABEL = '♨ Warm'
152export const HANDOFF_LABEL = '✦ Handoff'
153
154const MIN = 60_000
155
156// the plan's own windows; a gateway's `spend_limit` is API-key billing, not a subscription
157const PLAN_WINDOW = /^(five_hour|seven_day)/
158
159/**
160 * Prompt-cache TTL of the main conversation, as Claude Code picks it (code.claude.com/docs/en/prompt-caching):
161 * the person's choice first (env, then `promptCacheTtl`); else 1 hour on a subscription within its plan,
162 * 5 minutes once a plan window is used up (overage) and with an API key, a gateway or a cloud provider.
163 */
164export function cacheTtlMs(limits: readonly { kind: string; percentUsed: number }[], pref: CacheConfig['ttl'] = null): number {
165 if (pref) return pref === '1h' ? 60 * MIN : 5 * MIN
166 const plan = limits.filter(l => PLAN_WINDOW.test(l.kind))
167 return plan.length > 0 && plan.every(l => l.percentUsed < 100) ? 60 * MIN : 5 * MIN
168}
169
170export const DEFAULT_CACHE_CONFIG: CacheConfig = { ttl: null, idle: true, idleMin: 200_000 }
171
172const truthy = (v: string | undefined) => v !== undefined && /^(1|true|yes|on)$/i.test(v.trim())
173
174/**
175 * The cache and idle-compaction settings that change what the band should say, in Claude Code's order:
176 * `FORCE_PROMPT_CACHING_5M`, then `CLAUDE_CODE_PROMPT_CACHE_TTL`, then `promptCacheTtl`, then
177 * `ENABLE_PROMPT_CACHING_1H` (settings reference, promptCacheTtl); idle compaction is on unless
178 * `idleCompaction` or `autoCompactEnabled` is false or `DISABLE_AUTO_COMPACT` is set, from 200,000
179 * tokens or `CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS`, never below 100,000 (settings reference, idleCompaction).
180 */
181export function cacheConfig(env: Record<string, string | undefined>, settings: Record<string, unknown>): CacheConfig {
182 const asTtl = (v: unknown) => (v === '5m' || v === '1h' ? v : null)
183 const ttl = truthy(env.FORCE_PROMPT_CACHING_5M)
184 ? '5m'
185 : asTtl(env.CLAUDE_CODE_PROMPT_CACHE_TTL?.trim()) ?? asTtl(settings.promptCacheTtl) ?? (truthy(env.ENABLE_PROMPT_CACHING_1H) ? '1h' : null)
186 const idle = settings.idleCompaction !== false && settings.autoCompactEnabled !== false && !truthy(env.DISABLE_AUTO_COMPACT)
187 const min = Number.parseInt(env.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS ?? '', 10)
188 return { ttl, idle, idleMin: Number.isFinite(min) && min > 0 ? Math.max(100_000, min) : 200_000 }
189}
190
191/** Idle compaction runs after about 54 minutes without a request (settings reference, idleCompaction). */
192export const IDLE_COMPACT_MS = 54 * MIN
193
194/**
195 * Milliseconds until Claude Code compacts this conversation while it sits idle, or null where it will not:
196 * an interactive terminal session on the 1-hour cache whose context has reached the threshold.
197 */
198export function idleLeftMs(o: { lastAt: number | null; ttl: number; tokens: number | null; config: CacheConfig; terminal: boolean; now: number }): number | null {
199 if (!o.terminal || !o.config.idle || o.lastAt === null || o.ttl < 60 * MIN || (o.tokens ?? 0) < o.config.idleMin) return null
200 return Math.max(0, o.lastAt + IDLE_COMPACT_MS - o.now)
201}
202
203/** Leeway for comparing a file's mtime with the clock: the two are read at different moments. */
204export const MTIME_SLACK_MS = 2000
205
206/** Milliseconds until the cache entry written by the last response expires; null before any response. */
207export function cacheLeftMs(lastAt: number | null, ttlMs: number, now: number): number | null {
208 return lastAt === null ? null : Math.max(0, lastAt + ttlMs - now)
209}
210
211/** When to warn that the cache is about to cool: 5 minutes before on a 1h TTL, 1 minute on 5m. */
212export function cacheWarnMs(ttlMs: number): number {
213 return ttlMs > 10 * MIN ? 5 * MIN : MIN
214}
215
216export function cacheText(leftMs: number | null, isWorking: boolean): string {
217 if (isWorking) return 'active'
218 if (leftMs === null) return '—'
219 if (leftMs <= 0) return 'cold'
220 const min = Math.floor(leftMs / MIN)
221 return min < 1 ? '<1m' : `${min}m`
222}
223
224export function cacheColor(leftMs: number | null, ttlMs: number, isWorking: boolean): string {
225 if (isWorking) return PALETTE.ok
226 if (leftMs === null || leftMs <= 0) return PALETTE.muted
227 if (leftMs <= cacheWarnMs(ttlMs)) return PALETTE.hot
228 if (leftMs <= ttlMs / 3) return PALETTE.warn
229 return PALETTE.ok
230}
231
232/** A pie for surfaces that draw SVG: the coloured slice is the share left, clockwise from the top. */
233// an unfilled ring like Claude's own usage gauge: a faint track and an arc of the used share, clockwise from 12 o'clock
234export function ringSvg(percent: number, color: string, size = 14, stroke = 2): string {
235 const p = Math.max(0, Math.min(100, percent))
236 const c = size / 2
237 const r = c - stroke / 2
238 const fill = SVG_HEX[color] ?? color
239 const len = 2 * Math.PI * r
240 const used = ((p / 100) * len).toFixed(2)
241 const track = `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${SVG_HEX.inactive}" stroke-opacity="0.35" stroke-width="${stroke}"/>`
242 const arc = p > 0
243 ? `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${fill}" stroke-width="${stroke}" stroke-linecap="round" stroke-dasharray="${used} ${len.toFixed(2)}" transform="rotate(-90 ${c} ${c})"/>`
244 : ''
245 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${track}${arc}</svg>`
246}
247
248export function pieSvg(percent: number, color: string, size = 14): string {
249 const p = Math.max(0, Math.min(100, percent))
250 const c = size / 2
251 const r = c - 0.5
252 const fill = SVG_HEX[color] ?? color
253 const track = `<circle cx="${c}" cy="${c}" r="${r}" fill="${SVG_HEX.inactive}" fill-opacity="0.25"/>`
254 let slice = ''
255 if (p >= 99.5) slice = `<circle cx="${c}" cy="${c}" r="${r}" fill="${fill}"/>`
256 else if (p > 0) {
257 const a = (p / 100) * 2 * Math.PI
258 const x = (c + r * Math.sin(a)).toFixed(2)
259 const y = (c - r * Math.cos(a)).toFixed(2)
260 slice = `<path d="M${c} ${c}L${c} ${c - r}A${r} ${r} 0 ${p > 50 ? 1 : 0} 1 ${x} ${y}Z" fill="${fill}"/>`
261 }
262 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${track}${slice}</svg>`
263}
264
265/** Share of the TTL still left, for the cache pie: 100 while working, 0 cold or unknown. */
266export function cachePercent(leftMs: number | null, ttlMs: number, isWorking: boolean): number {
267 if (isWorking) return 100
268 return leftMs === null ? 0 : Math.round((Math.max(0, leftMs) / ttlMs) * 100)
269}
270
271/** Columns the cache segment takes: the gap, label (wide fits only), the longest text. */
272export function cacheWidth(label: boolean): number {
273 return SEGMENT_GAP + (label ? 'Cache '.length : 0) + 'active'.length
274}
275
276/** The session's cost in dollars: `$0.51`, `$12.40`, `$153`; under a cent reads `<$0.01`. */
277export function costText(usd: number): string {
278 if (usd > 0 && usd < 0.005) return '<$0.01'
279 return usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`
280}
281
282/** Used / total context tokens: `120k / 200k`, or `120k/200k` when tight; `—` while unknown. */
283export function tokensText(u: { tokens: number | null; window: number }, spaced: boolean): string {
284 return `${u.tokens === null ? '—' : shortTokens(u.tokens)}${spaced ? ' / ' : '/'}${shortTokens(u.window)}`
285}
286
287/** How much of the band to draw: the richest that fits the columns wins. */
288export type Fit = {
289 border: boolean
290 ctxLabel: boolean
291 ctxWidth: number
292 limitMeter: number
293 reset: boolean
294 button: string
295 warm: string
296}
297
298export const FITS: readonly Fit[] = [
299 { border: false, ctxLabel: true, ctxWidth: 16, limitMeter: 8, reset: true, button: HANDOFF_LABEL, warm: WARM_LABEL },
300 { border: false, ctxLabel: true, ctxWidth: 10, limitMeter: 5, reset: true, button: HANDOFF_LABEL, warm: WARM_LABEL },
301 { border: false, ctxLabel: false, ctxWidth: 8, limitMeter: 4, reset: true, button: HANDOFF_LABEL, warm: WARM_LABEL },
302 { border: false, ctxLabel: false, ctxWidth: 6, limitMeter: 3, reset: false, button: HANDOFF_LABEL, warm: WARM_LABEL },
303 { border: false, ctxLabel: false, ctxWidth: 4, limitMeter: 0, reset: false, button: HANDOFF_LABEL, warm: '♨' },
304]
305
306const SEGMENT_GAP = 3
307
308export type BandData = {
309 percent: number | null
310 tokens: number | null
311 window: number
312 limits: { kind: string; percentUsed: number; resetsAt?: string }[]
313 cost?: number | null
314}
315
316const BUTTON_CHROME = 4
317
318/** The columns the band takes with this fit; an estimate on the generous side. */
319export function bandWidth(fit: Fit, u: BandData | null, now: number, showButton: boolean): number {
320 const pct = u?.percent ?? null
321 let w = (fit.ctxLabel ? 'Context '.length : 0) + fit.ctxWidth + 1 + (pct === null ? 1 : `${pct}%`.length)
322 if (u) w += 2 + tokensText(u, fit.ctxLabel).length
323 for (const l of u?.limits ?? []) {
324 const reset = fit.reset ? resetIn(l.resetsAt, now) : ''
325 w += SEGMENT_GAP + limitName(l.kind).length + 1 + fit.limitMeter + 1 + `${Math.round(l.percentUsed)}%`.length
326 if (reset !== '') w += 3 + reset.length
327 }
328 if (u?.cost != null) w += SEGMENT_GAP + costText(u.cost).length
329 w += cacheWidth(fit.ctxLabel)
330 w += 2 + (showButton ? fit.button.length + BUTTON_CHROME + 2 + fit.warm.length + BUTTON_CHROME : 22)
331 return w + (fit.border ? 4 : 0)
332}
333
334export function pickFit(columns: number, u: BandData | null, now: number, showButton: boolean): Fit {
335 return FITS.find(f => bandWidth(f, u, now, showButton) <= columns) ?? FITS[FITS.length - 1]!
336}
337
338/** Above this many tokens a cold cache costs enough to plan around it. */
339export const BIG_CONTEXT = 50_000
340
341export const NO_ACTIVITY: Activity = {
342 edits: 0, reads: 0, web: 0, runs: 0, rereads: 0, repeatFails: 0, hotFile: null, readCounts: {}, failCounts: {}, alerted: null,
343}
344
345const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
346const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS'])
347const WEB_TOOLS = new Set(['WebFetch', 'WebSearch'])
348// past this many distinct keys the counts stop growing; the totals still do
349const MAX_KEYS = 400
350
351/** Folds one finished main-thread tool call into the activity. */
352export function recordTool(a: Activity, tool: string, input: { file_path?: string; command?: string }, isError: boolean): Activity {
353 const next = { ...a }
354 if (EDIT_TOOLS.has(tool)) next.edits++
355 else if (WEB_TOOLS.has(tool)) next.web++
356 else if (READ_TOOLS.has(tool)) {
357 next.reads++
358 const path = tool === 'Read' ? input.file_path : undefined
359 if (path && (path in a.readCounts || Object.keys(a.readCounts).length < MAX_KEYS)) {
360 const count = (a.readCounts[path] ?? 0) + 1
361 next.readCounts = { ...a.readCounts, [path]: count }
362 if (count > 2) next.rereads++
363 if (count > 2 && count > (a.hotFile?.count ?? 0)) next.hotFile = { path, count }
364 }
365 } else if (tool === 'Bash') {
366 next.runs++
367 const cmd = input.command?.trim()
368 if (isError && cmd && (cmd in a.failCounts || Object.keys(a.failCounts).length < MAX_KEYS)) {
369 const count = (a.failCounts[cmd] ?? 0) + 1
370 next.failCounts = { ...a.failCounts, [cmd]: count }
371 if (count > 1) next.repeatFails++
372 }
373 }
374 return next
375}
376
377export type Work = 'code' | 'research' | 'chat'
378
379/** The kind of work, from what the tools did: each kind wears a long context differently. */
380export function workKind(a: Activity): Work {
381 const doing = a.edits + a.runs
382 const looking = a.reads + a.web
383 if (doing + looking < 5) return 'chat'
384 return a.edits >= 3 || doing >= looking ? 'code' : 'research'
385}
386
387/**
388 * Measured long-context curves. Anthropic gives no token count where quality starts to drop, only "a
389 * performance gradient rather than a hard cliff" (anthropic.com/engineering/effective-context-engineering-for-ai-agents),
390 * so the band reads the published per-model measurements instead of fixed thresholds.
391 *
392 * MRCR v2, 8 needles (contextarena.ai, "GDM-MRCRv2 Full", read 2026-10-21): pick the right one of many
393 * near-identical items, the closest public measure of keeping exact details apart, as code work does with
394 * files edited many times. Average score, %, per bin of (b/2, b] tokens, at the highest reasoning effort
395 * measured; the last bin stands in beyond 512k, where no Claude model has a score.
396 */
397export const MRCR_BINS = [8_192, 16_384, 32_768, 65_536, 131_072, 262_144, 524_288]
398export const MRCR: Record<string, readonly number[]> = {
399 'opus-5': [98.7, 99.9, 99.3, 99.9, 91.3, 65.6, 42.5],
400 'sonnet-5': [96.4, 94.9, 87.9, 68.0, 52.9, 52.2, 32.0],
401 'opus-4.8': [97.4, 96.1, 93.0, 90.7, 75.2, 61.8, 39.8],
402 'opus-4.6': [98.2, 84.4, 86.2, 74.3, 68.1, 69.1, 54.3],
403 'sonnet-4.6': [96.7, 82.0, 85.2, 79.4, 72.4, 69.3, 45.9],
404 'haiku-4.5': [82.5, 47.5, 31.5, 17.0, 13.8],
405}
406
407/**
408 * GraphWalks Parents, F1 (Claude Opus 4.8 system card, table 8.9.A): following links through a long
409 * input, the measure of reading comprehension; the 256k and 1M subsets.
410 */
411export const GRAPHWALKS: Record<string, { k256: number; m1: number }> = {
412 'opus-4.8': { k256: 99.3, m1: 83.3 },
413 'opus-4.7': { k256: 93.6, m1: 56.6 },
414 'opus-4.6': { k256: 95.4, m1: 48.6 },
415}
416
417/** Drops below the model's own short-context score, in points, that make a tip: info, warn, hot. */
418export const DROP = { info: 10, warn: 20, hot: 30 } as const
419
420/** Where comprehension was measured to hold: GraphWalks' 256k subset. */
421export const COMPREHENSION_TOKENS = 262_144
422
423export type ModelId = { family: string; major: number; minor: number }
424
425/** `claude-opus-4-6`, `Opus 5.5 (1M context)`, `claude-sonnet-5-20260301` → family and version; null otherwise. */
426export function parseModel(name: string | null | undefined): ModelId | null {
427 const m = /(opus|sonnet|haiku|fable|mythos)[\s_-]*(\d+)(?:[.-](\d)(?!\d))?/i.exec(name ?? '')
428 return m ? { family: m[1]!.toLowerCase(), major: Number(m[2]), minor: Number(m[3] ?? 0) } : null
429}
430
431export type Curves = {
432 mrcr: { key: string; scores: readonly number[] } | null
433 graph: { key: string; k256: number; m1: number } | null
434}
435
436/**
437 * The curves for a model: its own where published, else its nearest measured predecessor in the same line,
438 * a conservative stand-in (newer models have scored higher). Opus 4.7's MRCR run near zero from 64k looks
439 * like a harness failure next to its own GraphWalks scores, so it reads Opus 4.6's. Haiku 5.x has no
440 * predecessor close enough (Haiku 4.5 is two generations back), so it reads none.
441 */
442export function curvesFor(model: string | null | undefined): Curves {
443 const id = parseModel(model)
444 if (!id) return { mrcr: null, graph: null }
445 const v = id.major + id.minor / 10
446 const own = `${id.family}-${id.minor ? `${id.major}.${id.minor}` : id.major}`
447 let mrcr: string | null = own in MRCR ? own : null
448 let graph: string | null = own in GRAPHWALKS ? own : null
449 if (id.family === 'opus' || id.family === 'fable' || id.family === 'mythos') {
450 mrcr ??= v >= 5 || id.family !== 'opus' ? 'opus-5' : v >= 4.7 ? (v >= 4.8 ? 'opus-4.8' : 'opus-4.6') : null
451 graph ??= v >= 4.8 || id.family !== 'opus' ? 'opus-4.8' : null
452 } else if (id.family === 'sonnet') {
453 mrcr ??= v >= 5 ? 'sonnet-5' : null
454 }
455 return {
456 mrcr: mrcr ? { key: mrcr, scores: MRCR[mrcr]! } : null,
457 graph: graph ? { key: graph, ...GRAPHWALKS[graph]! } : null,
458 }
459}
460
461const binName = (b: number) => `${b / 1024}k`
462
463/** The MRCR score at this many tokens against the model's own score at 8k. */
464export function mrcrAt(scores: readonly number[], tokens: number): { score: number; base: number; drop: number; bin: string } {
465 let i = MRCR_BINS.findIndex(b => tokens <= b)
466 if (i < 0 || i >= scores.length) i = scores.length - 1
467 const base = scores[0]!
468 const score = scores[i]!
469 const bin = i === 0 ? `≤${binName(MRCR_BINS[0]!)}` : `${binName(MRCR_BINS[i - 1]!)}–${binName(MRCR_BINS[i]!)}`
470 return { score, base, drop: Math.round((base - score) * 10) / 10, bin }
471}
472
473function dropTone(drop: number): Advice['tone'] | null {
474 return drop >= DROP.hot ? 'hot' : drop >= DROP.warn ? 'warn' : drop >= DROP.info ? 'info' : null
475}
476
477/**
478 * The tip names the work, how strongly the context degrades, what the measurement for that work tested and
479 * what to do; the scores stay in this file. MRCR tests telling near-identical items apart, GraphWalks
480 * following links across a long input, so each effect says only that.
481 */
482const WORK_NAME: Record<Work, string> = { code: 'Сессия с кодом', research: 'Исследование', chat: 'Обсуждение' }
483const MRCR_EFFECT: Record<Work, string> = {
484 code: 'модель чаще путает похожие фрагменты и версии правок',
485 research: 'модель может путать похожие источники',
486 chat: 'модель может путать похожие фрагменты',
487}
488const GRAPH_EFFECT = 'модель хуже связывает факты из разных частей контекста'
489const DEGREE: Record<Advice['tone'], string> = { info: 'начинает деградировать', warn: 'заметно деградирует', hot: 'сильно деградирует' }
490const STEP: Record<Advice['tone'], string> = {
491 info: 'Сделайте Handoff на границе задачи',
492 warn: 'Сделайте Handoff после текущего шага',
493 hot: 'Сделайте Handoff сейчас',
494}
495
496function symptoms(a: Activity): string[] {
497 const out: string[] = []
498 if (a.hotFile && a.rereads >= 3) out.push(`${a.hotFile.path.split('/').at(-1)} прочитан ${a.hotFile.count} раз`)
499 // Anthropic: "After two failed corrections, /clear and write a better initial prompt" (code.claude.com/docs/en/best-practices)
500 if (a.repeatFails >= 2) out.push('команды падают повторно')
501 return out
502}
503
504const higher = (x: Advice['tone'] | null, y: Advice['tone'] | null) => (toneRank(x) >= toneRank(y) ? x : y)
505
506/**
507 * The degradation tip for this context, work and model, or null while the measurements show no drop.
508 *
509 * Code reads MRCR: precise edits among many similar versions break first (LongCodeBench: repair fell from
510 * 32k while code QA held). Research and discussion read GraphWalks: comprehension held to 256k on every
511 * measured Opus and fell past it; below that they warn only when MRCR already shows a 20-point drop,
512 * which matters when comparing many similar sources. Symptoms count at any size, raised by the curve.
513 */
514export function degradation(tokens: number, a: Activity, model: string | null = null): Advice | null {
515 const kind = workKind(a)
516 const c = curvesFor(model)
517 const at = c.mrcr ? mrcrAt(c.mrcr.scores, tokens) : null
518
519 let tone: Advice['tone'] | null = null
520 let effect = MRCR_EFFECT[kind]
521 if (kind === 'code') tone = at ? dropTone(at.drop) : null
522 else if (c.graph && tokens > COMPREHENSION_TOKENS) {
523 tone = 'warn'
524 effect = GRAPH_EFFECT
525 } else if (at && at.drop >= DROP.warn) tone = 'info'
526
527 const signs = symptoms(a)
528 if (signs.length > 0) {
529 const raised = higher(at && at.drop >= DROP.warn ? 'hot' : at && at.drop >= DROP.info ? 'warn' : 'info', tone)!
530 const about = `${WORK_NAME[kind]}, признаки деградации: ${signs.join('; ')}`
531 return { tone: raised, kind: 'degrade', about, text: `${about}. ${STEP[raised]}.` }
532 }
533 if (!tone) return null
534 const about = `${WORK_NAME[kind]}, контекст ${DEGREE[tone]} (~${shortTokens(tokens)}): ${effect}`
535 return { tone, kind: 'degrade', about, text: `${about}. ${STEP[tone]}.` }
536}
537
538export function toneRank(t: Advice['tone'] | null): number {
539 return t === 'hot' ? 3 : t === 'warn' ? 2 : t === 'info' ? 1 : 0
540}
541
542/** `about`: the degradation without the step, for a tip that joins it with another reason */
543export type Advice = { tone: 'info' | 'warn' | 'hot'; kind?: 'degrade'; about?: string; text: string }
544
545export type AdviceInput = BandData & {
546 cacheLeft: number | null
547 ttl: number
548 now: number
549 activity?: Activity
550 model?: string | null
551 /** ms until idle compaction, null where it will not run (idleLeftMs) */
552 idleLeft?: number | null
553}
554
555/** The single most useful tip for the current context, cache and limits; null when nothing needs doing. */
556export function advise(a: AdviceInput): Advice | null {
557 const pct = a.percent ?? 0
558 const tokens = a.tokens ?? 0
559 const big = tokens >= BIG_CONTEXT
560 const ctx = a.tokens === null ? 'весь контекст' : `~${shortTokens(a.tokens)} контекста`
561 const warm = a.cacheLeft !== null && a.cacheLeft > 0
562 const idle = a.idleLeft ?? null
563
564 // idle compaction comes 6 minutes before the 1-hour cache cools, so it is the deadline that matters
565 if (idle !== null && idle > 0 && idle <= cacheWarnMs(a.ttl)) {
566 const min = Math.ceil(idle / MIN)
567 return { tone: 'hot', text: `Через ${min} мин простоя Claude Code сам сожмёт ${ctx} (idle compaction). Нужны детали — Handoff сейчас; вернётесь скоро — отправьте любое сообщение.` }
568 }
569 if (warm && a.cacheLeft! <= cacheWarnMs(a.ttl)) {
570 const min = Math.ceil(a.cacheLeft! / MIN)
571 return big
572 ? { tone: 'hot', text: `Кэш остынет через ${min} мин. Уходите надолго — Handoff сейчас, пока чтение дешёвое; вернётесь скоро — отправьте любое сообщение.` }
573 : { tone: 'warn', text: `Кэш остынет через ${min} мин: отправьте сообщение, чтобы сохранить его.` }
574 }
575 if (pct >= 85) {
576 return { tone: 'hot', text: `Контекст ${pct}%: сделайте Handoff на ближайшей паузе, иначе автокомпакт сожмёт без handoff.` }
577 }
578 const strain = degradation(tokens, a.activity ?? NO_ACTIVITY, a.model ?? null)
579 const cold = a.cacheLeft !== null && a.cacheLeft <= 0 && big
580 // both call for a Handoff, and a cold cache makes the next message re-write the whole degraded context
581 if (strain?.about && strain.tone !== 'info' && cold) {
582 return { tone: strain.tone, kind: 'degrade', text: `Кэш остыл. ${strain.about}. Сделайте Handoff до следующего сообщения — оно заново запишет ${ctx}.` }
583 }
584 if (strain && strain.tone !== 'info') return strain
585 if (cold) {
586 return { tone: 'warn', text: `Кэш остыл: следующее сообщение заново запишет ${ctx}. Продолжаете ту же задачу — сначала Handoff; новая задача — новая сессия.` }
587 }
588 const lim = a.limits.filter(l => l.percentUsed >= 85).sort((x, y) => y.percentUsed - x.percentUsed)[0]
589 if (lim) {
590 const reset = resetIn(lim.resetsAt, a.now)
591 return {
592 tone: 'warn',
593 text: `Лимит ${limitName(lim.kind)} ${Math.round(lim.percentUsed)}%${reset ? `, сброс через ${reset}` : ''}: поиски отдавайте субагентам, уточнения шлите одним сообщением.`,
594 }
595 }
596 return strain
597}
598
599export function adviceColor(tone: Advice['tone']): string {
600 return tone === 'hot' ? PALETTE.hot : tone === 'warn' ? PALETTE.warn : PALETTE.accent
601}
602types/index.d.ts 48 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = {
4 percent: number | null
5 tokens: number | null
6 window: number
7 limits: Limit[]
8 /** US dollars this session has cost, as /cost totals it; null where the host keeps no ledger */
9 cost: number | null
10 /** the main loop's model as /model shows it; null until known */
11 model: string | null
12}
13
14/** What Claude Code was told about the prompt cache and idle compaction (lib cacheConfig). */
15export type CacheConfig = { ttl: '5m' | '1h' | null; idle: boolean; idleMin: number }
16
17/** When the main thread's last API response landed (ms), and whether the cooling warning was shown for it. */
18export type Cache = { lastAt: number | null; warned: boolean }
19
20export type Phase = 'idle' | 'writing' | 'clearing' | 'reading'
21/** startedAt: when the handoff was asked for (ms); only a file written after it counts as this run's */
22export type Flow = { phase: Phase; path: string | null; startedAt: number | null }
23
24/** What the main thread did with its tools this conversation, the evidence for the kind of work and its strain. */
25export type Activity = {
26 edits: number
27 reads: number
28 web: number
29 runs: number
30 /** reads of a file already read twice */
31 rereads: number
32 /** failures of a command that had failed before */
33 repeatFails: number
34 /** the most re-read file and its read count */
35 hotFile: { path: string; count: number } | null
36 readCounts: Record<string, number>
37 failCounts: Record<string, number>
38 /** the highest degradation tone already toasted, so each level is announced once */
39 alerted: 'info' | 'warn' | 'hot' | null
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 // handoff: the last handoff this work continued from, which the next one builds on
45 'context-band': { usage: Usage | null; flow: Flow; cache: Cache; tick: number; handoff: string | null; activity: Activity; config: CacheConfig }
46 }
47}
48