SLOPSHOPPER

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

newbandguardcommandtoasttimer
v0.10.5no licenseupdated 2026-10-26Pavelkv94/mods/context-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /handoff ⎿ context-band: Handoff → /Users/dev/.claude/handoffs/app/2025-10-09_085320.md, then /clear ⟨Claude Code's own drawing⟩ Context █████░░░░░ 49% 97k / 200k 5h ██░░░ 31% $0.42 Cache 59m ✍ Writing handoff… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Context █████░░░░░ 49% 97k / 200k 5h ██░░░ 31% $0.42 Cache 59m ✍ Writing handoff…
README

mods

Claude Code mods.

ModWhat it does
agent-crabsSide 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-bandBand 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-branchesTerminal-only pane with the tree of this project's sessions and their forks; press a row to switch to it

Install

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.

Update

claude plugin update <mod>@pavelkv94-mods

smart-compact was renamed to context-band: uninstall the old one and install context-band.

Source 3 files
hooks/register.tsx 450 lines
1import { 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}
450
hooks/lib.ts 602 lines
1import 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}
602
types/index.d.ts 48 lines
1export 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