SLOPSHOPPER

session-band

A status row above the prompt (context, cache, usage limits, generated tokens) with a handoff-file action and guided compact.

newbandguardcommandtoasttimer
v0.1.0MITupdated 2026-10-05leonqi-io/claude-code-mods/session-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-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 ⎿ session-band: Handoff failed: the template could not be read. ▎ ctx 49% (97k/200k) | cache ~59m | 5h 31% | out 1k | opus-5-5 h: handoff c: compact ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▎ ctx 49% (97k/200k) | cache ~59m | 5h 31% | out 1k | opus-5-5 h: handoff c: compact
README

session-band

A status row above the Claude Code prompt, and two actions for when the context fills up.

▎ ctx 54% (108k/200k) | cache ~41m | 5h 9% (resets 2h10m) | week 59% | out 41k | opus-5-5 medium    h: handoff  c: compact

What the row shows

SegmentMeaning
ctx 54% (108k/200k)Context window fill. Turns to the warning color at 60% and the alert color at 75% (both configurable). Left out after a compaction until the next response, which is when Claude Code measures the context again.
cache ~41mEstimated time before the prompt cache goes cold, counted from the last response. Reads cache cold after that, and from a model switch or a compaction until the next response, since neither leaves anything in the cache to read.
5h 9% (resets 2h10m)The five-hour usage window: percent used, and when it resets.
week 59%The seven-day usage window across all models, percent used. Dim below 50%.
out 41kOutput tokens of the main conversation since the mod loaded or since the last /clear; subagents are not counted. One figure that does not depend on how the session is billed.
opus-5-5 mediumModel and effort of the last request. The model shows from session start and the effort appears with the first request; after a model switch it shows the new model at once, without effort until the next request reports it. Terminal only.

The row sits one blank line below the output and starts with a dim ▎, so it does not read as the last line of a reply. A segment with no reading is left out. On a narrow terminal the row drops model, then generated tokens, then shortens the actions to h and c, then drops the remaining segments. The marker always stays, and ctx is the last segment to go. Claude Code draws its own [-] at the right end of the blank line above, which collapses the row (ctrl+x ctrl+a does the same).

The two actions

In my experience long sessions get worse as the context fills, often called context rot, which makes 90% the worst moment to ask for a summary. So the row offers two ways out, early.

Handoff — for a task boundary, or a session cluttered with failed attempts. Claude writes a short handoff file (goal, decisions, constraints, state, open questions, dead ends, next step) to .handoff/handoff-<date>-<time>.md, the time in UTC to the second (handoff-20261005-184107.md). Start a new session and open with Read <that file> and continue from "Next step".

Compact — for the middle of a task you do not want to leave. Runs Claude Code's own compaction with instructions to keep decisions, constraints, changed files and the current state, and to drop tool output that has already been used.

How to trigger them:

From the rowctrl+x then tab to focus the row, then h or c; esc returns to the prompt
As a command/handoff [note], /compact-keep [note]

A note after the command is passed along, for example /handoff fix the auth test first.

Handoff runs at once: it only writes a file. Compact from the row asks for a second press within five seconds, because compaction cannot be undone. /compact-keep typed as a command runs directly. Compaction only runs between turns.

What it touches

The mod reads Claude Code's own readings (context, usage limits, model, each response's token usage), notes the paths of the files Claude edits so the two prompts can list them, and reads its two template files. It makes no network requests, has no dependencies and writes no files itself: a handoff file is written by Claude with the Write tool, in a turn you can see. Its only two actions are submitting the handoff prompt and starting a compaction. It adds two commands, /handoff and /compact-keep, and its keys act only while the row is focused.

To stop loading it, drop the flag or the CLAUDE_CODE_PLUGIN_DIRS entry. To keep the entry and switch the mod off, set "enabledPlugins": { "session-band@inline": false } in a settings file.

Settings

Set them in /config. Claude Code keeps them under pluginConfigs in ~/.claude/settings.json; to edit the file by hand, change the entry /config wrote there. Its key depends on how the mod was loaded (session-band@inline with --plugin-dir).

OptionDefault
warnPercent60Context fill where the row turns to the warning color.
alertPercent75Context fill where it turns to the alert color.
cacheTtlMinutes60The prompt-cache lifetime to start from. The row corrects it by itself (see Limits), so this rarely needs changing.
handoffDir.handoffWhere handoff files go, relative to the working directory. Add it to .gitignore.
templateDiremptyA folder with your own handoff.md and compact.md.

Your own templates

Copy templates/handoff.md and templates/compact.md to a folder, edit them, and point templateDir at it. Three placeholders are filled in: {{path}} (the handoff file), {{files}} (files edited this session) and {{note}} (what you typed after the command).

This is the place for anything specific to how you work. If your tasks are driven by a spec or a ticket file, add a line such as "After compaction, re-read the task file before continuing."

Limits

  • The cache countdown is an estimate. The cache lives on the API side and nothing reports its remaining lifetime, so the row counts from the last response. It checks itself against what the next request actually read from the cache: cold after an idle gap shorter than assumed switches the countdown to five minutes, warm after a longer one switches it to an hour, and a toast says so. A model switch also sets it from the lifetime Claude Code reports.
  • The thresholds are a default, not a rule. There is no official number; community guidance puts the drop in quality around 70–80% of the window. They are percentages, so on a 1M window the default 60% is 600k tokens: lower them if you want the warning earlier in absolute terms.
  • The usage windows appear only where Claude Code has them, which means a subscription.
  • The usage figures come with the last response, while /usage fetches its own. The row can read a point or so lower until the next request.
  • Not yet tested against a local model.
  • Written against and tested on Claude Code 2.1.289 in the terminal. The mods API is early access and may change.

Desktop app

The mod was tested in the terminal. In the Code tab of the Claude desktop app, which shipped Claude Code 2.1.288 when this was written, it loads through CLAUDE_CODE_PLUGIN_DIRS, and the row and the handoff button work. Known issues there:

  • Compact does not run, from the button or as /compact-keep. Claude Code refuses it: $.session.compact: not available in a headless (-p / SDK) session yet. The row shows that message in a toast and changes nothing.
  • out is not shown.
  • Model and effort are not shown; the app has them beside its prompt box.
  • An edit to the mod's files takes effect after the app restarts.

Develop

From the repo root:

claude plugin validate session-band
claude plugin test session-band
Source 3 files
hooks/register.tsx 456 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionCompactResult, SessionRateLimit } from 'claude-code'
3
4import type { BandLimit, BandStats, BandTtl, BandUsage } from '../types'
5import { MARKER, buildSegments, fillTemplate, fitRow, observeTtl, stamp, tierOf } from './format'
6
7const ARM_MS = 5000
8// Work a command starts that must wait until the command has answered.
9const AFTER_COMMAND_MS = 200
10const TICK_MS = 30_000
11const MAX_FILES = 40
12
13// A request made right after a compaction, a /clear or a model change misses
14// the cache for reasons that say nothing about its lifetime.
15let isUnsettled = false
16
17const NO_USAGE: BandUsage = {
18  tokens: null,
19  window: null,
20  percent: null,
21  fiveHour: null,
22  sevenDay: null,
23}
24
25const NO_STATS: BandStats = { outputTokens: 0, model: null, effort: null, lastResponseAt: null, isCacheCold: false }
26
27const usage = atom({ plugin: 'session-band', key: 'usage' } as const, NO_USAGE)
28const stats = atom({ plugin: 'session-band', key: 'stats' } as const, NO_STATS)
29const files = atom({ plugin: 'session-band', key: 'files' } as const, [])
30const tick = atom({ plugin: 'session-band', key: 'tick' } as const, 0)
31const armedUntil = atom({ plugin: 'session-band', key: 'armedUntil' } as const, 0)
32const ttl = atom({ plugin: 'session-band', key: 'ttl' } as const, null)
33
34type Measured = {
35  context: { tokens?: number; window: number; percent?: number }
36  rateLimits: SessionRateLimit[]
37}
38
39const limitOf = (limits: SessionRateLimit[], kind: string): BandLimit | null => {
40  const found = limits.find(one => one.kind === kind)
41
42  if (found === undefined) {
43    return null
44  }
45
46  const resetsAt = found.resetsAt === undefined ? Number.NaN : Date.parse(found.resetsAt)
47
48  return { percentUsed: found.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
49}
50
51const measured = (from: Measured): BandUsage => ({
52  tokens: from.context.tokens ?? null,
53  window: from.context.window,
54  percent: from.context.percent ?? null,
55  fiveHour: limitOf(from.rateLimits, 'five_hour'),
56  sevenDay: limitOf(from.rateLimits, 'seven_day'),
57})
58
59type Settings = {
60  warnPercent: number
61  alertPercent: number
62  ttlMinutes: number
63  handoffDir: string
64  templateDir: string
65}
66
67async function template($: EngineInterface, set: Settings, name: string): Promise<string> {
68  if (set.templateDir !== '' && (await $.fs.exists(`${set.templateDir}/${name}`))) {
69    return $.fs.read(`${set.templateDir}/${name}`)
70  }
71
72  return $.fs.read(`${$.plugin.root}/templates/${name}`)
73}
74
75async function refresh($: EngineInterface): Promise<void> {
76  const now = await $.session.usage()
77
78  await update($, usage, () => measured(now))
79}
80
81// Before the first request only the session knows its model; effort stays null
82// until a request reports it. A guess would be worse than an empty segment.
83async function showModel($: EngineInterface): Promise<void> {
84  if ((await read($, stats)).model !== null) {
85    return
86  }
87
88  try {
89    const model = await $.session.model()
90
91    if (typeof model === 'string' && model !== '') {
92      await update($, stats, one => (one.model === null ? { ...one, model } : one))
93    }
94  } catch {
95    // Leave the segment empty.
96  }
97}
98
99type Handoff = { path: string; text: string }
100
101async function prepareHandoff($: EngineInterface, set: Settings, note: string): Promise<Handoff> {
102  const now = await $.clock.now()
103  const path = `${set.handoffDir}/handoff-${stamp(now)}.md`
104  const text = fillTemplate(await template($, set, 'handoff.md'), {
105    path,
106    files: await read($, files),
107    note,
108  })
109
110  return { path, text }
111}
112
113// The call resolves only as the handoff turn starts, so nothing waits on it; a
114// refusal or a drop is told in a toast instead of vanishing.
115function submitHandoff($: EngineInterface, one: Handoff): void {
116  $.prompt.submit({ text: one.text }).then(
117    done => {
118      if (done.drop !== undefined) {
119        $.ui.toast(`Handoff not sent: ${done.drop}`)
120      }
121    },
122    () => $.ui.toast('Handoff failed: the prompt could not be submitted.'),
123  )
124}
125
126async function compact($: EngineInterface, set: Settings, note: string): Promise<void> {
127  const instructions = fillTemplate(await template($, set, 'compact.md'), {
128    files: await read($, files),
129    note,
130  })
131
132  let done: SessionCompactResult
133
134  try {
135    // This plugin's own session.compact hook does not see its own call.
136    done = await $.session.compact({ instructions })
137  } catch (error) {
138    // The engine says why; a turn running is one reason of several, and a
139    // guess here would hide the others.
140    $.ui.toast(`Compact failed: ${firstLine(error)}`)
141
142    return
143  }
144
145  if (done.skip !== undefined) {
146    $.ui.toast(`Compact skipped: ${done.skip}`)
147
148    return
149  }
150
151  isUnsettled = true
152  await compacted($)
153}
154
155function firstLine(error: unknown): string {
156  const text = error instanceof Error ? error.message : String(error)
157
158  return text.split('\n')[0]?.trim() || 'no reason given'
159}
160
161// The engine measures the context again only with the next response, and the
162// compaction's own `tokensAfter` counts the conversation alone, without the
163// system prompt and tools, so the row shows no context until that response
164// rather than a stale or a wrong figure. The old prefix is gone, so the cache
165// starts cold.
166async function compacted($: EngineInterface): Promise<void> {
167  await update($, usage, one => ({ ...one, tokens: null, percent: null }))
168  await update($, stats, one => ({ ...one, isCacheCold: true }))
169}
170
171async function lifetime($: EngineInterface, set: Settings): Promise<BandTtl> {
172  return (await read($, ttl)) ?? { minutes: set.ttlMinutes, source: 'config' }
173}
174
175async function remember($: EngineInterface, path: unknown): Promise<void> {
176  if (typeof path !== 'string' || path === '') {
177    return
178  }
179
180  await update($, files, list => [...list.filter(one => one !== path), path].slice(-MAX_FILES))
181}
182
183export const register: Register = (on, options) => {
184  const set: Settings = {
185    warnPercent: Number(options.warnPercent ?? 60),
186    alertPercent: Number(options.alertPercent ?? 75),
187    ttlMinutes: Number(options.cacheTtlMinutes ?? 60),
188    handoffDir: String(options.handoffDir ?? '.handoff').replace(/\/+$/, '') || '.handoff',
189    templateDir: String(options.templateDir ?? '').replace(/\/+$/, ''),
190  }
191
192  on('session.start', async ($, e, next) => {
193    await $.command.register({
194      name: 'handoff',
195      description: 'Write a handoff file for a fresh session (optional note after the name)',
196    })
197    await $.command.register({
198      name: 'compact-keep',
199      description: 'Compact with instructions that keep decisions, constraints and state (optional note)',
200    })
201    await refresh($)
202    await showModel($)
203    $.clock.every(TICK_MS, () => {
204      void update($, tick, count => count + 1)
205    })
206
207    return next(e)
208  })
209
210  on('session.measure', async ($, e, next) => {
211    await update($, usage, () => measured(e))
212
213    return next(e)
214  })
215
216  // A /clear moves the process to a new session id and fires no session.start.
217  // The state is kept per session id, so the new session reads every atom at its
218  // initial: out, cache and the edited files start from zero without a write
219  // here, and a write from session.end would land in the old session. What the
220  // new session does not have is the model and the usage readings, so ask again.
221  // The timer registered in session.start belongs to the process and keeps going.
222  // The event's own model is absent on a clear; the session knows it.
223  on('classic.SessionStart', async ($, e, next) => {
224    if (e.source === 'clear') {
225      isUnsettled = true
226      await refresh($)
227      await showModel($)
228    }
229
230    return next(e)
231  })
232
233  // A compaction from /compact, the threshold or another plugin. A subagent
234  // compacts its own transcript, a precompute only prepares one, and a skip
235  // keeps it.
236  on('session.compact', async ($, e, next) => {
237    const done = await next(e)
238
239    if (e.agentId === undefined && e.trigger !== 'precompute' && done.skip === undefined) {
240      isUnsettled = true
241      await compacted($)
242    }
243
244    return done
245  })
246
247  on('classic.PostModelSwitch', async ($, e, next) => {
248    isUnsettled = true
249    await update($, ttl, () => ({ minutes: e.cache_ttl === '5m' ? 5 : 60, source: 'engine' as const }))
250
251    if (e.from_model !== e.to_model) {
252      // The event carries no effort, and the new model may run at another one.
253      await update($, stats, one => ({ ...one, model: e.to_model, effort: null, isCacheCold: true }))
254    }
255
256    return next(e)
257  })
258
259  on('turn.step', async function* ($, e, next) {
260    if (e.agentId !== undefined) {
261      return yield* next(e)
262    }
263
264    const startedAt = await $.clock.now()
265    const before = await read($, stats)
266    const priorTokens = (await read($, usage)).tokens ?? 0
267    const effort = e.effort === undefined ? null : String(e.effort)
268
269    await update($, stats, one => ({ ...one, model: e.model, effort }))
270
271    const result = yield* next(e)
272
273    if (result.usage === null) {
274      return result
275    }
276
277    const assumed = await lifetime($, set)
278    const corrected =
279      e.index === 0 &&
280      !isUnsettled &&
281      !before.isCacheCold &&
282      before.lastResponseAt !== null &&
283      before.model === e.model
284        ? observeTtl({
285            minutes: assumed.minutes,
286            idleMs: startedAt - before.lastResponseAt,
287            priorTokens,
288            cacheRead: result.usage.cache_read_input_tokens,
289          })
290        : null
291
292    isUnsettled = false
293
294    if (corrected !== null) {
295      await update($, ttl, () => ({ minutes: corrected, source: 'observed' as const }))
296      $.ui.toast(`Prompt cache lifetime looks like ${corrected} minutes here; the countdown now uses that.`)
297    }
298
299    const answeredAt = await $.clock.now()
300
301    await update($, stats, one => ({ ...one, lastResponseAt: answeredAt, isCacheCold: false }))
302
303    return result
304  })
305
306  on('turn.complete', async ($, e, next) => {
307    if (e.agentId === undefined) {
308      const now = await $.clock.now()
309      const out = e.usage?.output_tokens ?? 0
310
311      await update($, stats, one => ({
312        ...one,
313        outputTokens: one.outputTokens + out,
314        model: e.usage?.model ?? one.model,
315        lastResponseAt: e.usage === undefined ? one.lastResponseAt : now,
316        isCacheCold: e.usage === undefined ? one.isCacheCold : false,
317      }))
318    }
319
320    return next(e)
321  })
322
323  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
324    const ran = await next(e)
325
326    if (ran.deny === undefined && ran.isError !== true) {
327      await remember($, e.file_path)
328    }
329
330    return ran
331  })
332
333  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
334    const ran = await next(e)
335
336    if (ran.deny === undefined && ran.isError !== true) {
337      await remember($, e.file_path)
338    }
339
340    return ran
341  })
342
343  on('command.run', { command: 'handoff' }, async ($, e) => {
344    let one: Handoff
345
346    try {
347      one = await prepareHandoff($, set, e.args)
348    } catch {
349      return { text: 'Handoff failed: the template could not be read.' }
350    }
351
352    // A prompt submitted while this hook runs would wait on the turn the command
353    // holds, and the host refuses it, so it goes once the command has answered.
354    $.clock.after(AFTER_COMMAND_MS, () => submitHandoff($, one))
355
356    return { text: `Handoff requested. Claude will write ${one.path}.` }
357  })
358
359  on('command.run', { command: 'compact-keep' }, async ($, e) => {
360    // Compaction runs between turns, so it starts once this command has answered.
361    $.clock.after(AFTER_COMMAND_MS, () => {
362      compact($, set, e.args).catch(() => $.ui.toast('Compact failed: the template could not be read.'))
363    })
364
365    return { text: 'Compacting with the keep-instructions.' }
366  })
367
368  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
369    if (e.props.hasSurvey) {
370      return next(e)
371    }
372
373    await read($, tick)
374
375    const now = await $.clock.now()
376    const seen = await read($, usage)
377    const tier = tierOf(seen.percent, set)
378    const isArmed = (await read($, armedUntil)) > now
379    // The desktop app shows the model and effort beside its own prompt box.
380    const row = fitRow(
381      buildSegments(seen, await read($, stats), now, set, (await lifetime($, set)).minutes, e.surface === 'terminal'),
382      e.props.bodyColumns,
383    )
384    const { Box, Button, Text } = $.ui.resolve(e)
385
386    const pressHandoff = (): void => {
387      void prepareHandoff($, set, '').then(
388        one => {
389          submitHandoff($, one)
390          $.ui.toast(`Handoff requested: ${one.path}`)
391        },
392        () => $.ui.toast('Handoff failed: the template could not be read.'),
393      )
394    }
395
396    const pressCompact = (): void => {
397      void (async () => {
398        const pressedAt = await $.clock.now()
399
400        if ((await read($, armedUntil)) > pressedAt) {
401          await update($, armedUntil, () => 0)
402          await compact($, set, '')
403
404          return
405        }
406
407        await update($, armedUntil, () => pressedAt + ARM_MS)
408        $.clock.after(ARM_MS, () => {
409          void update($, tick, count => count + 1)
410        })
411      })().catch(() => $.ui.toast('Compact failed: the template could not be read.'))
412    }
413
414    // The blank line above also holds the engine's collapse mark, `[-]`, drawn
415    // at the band's top right; without it the mark covers the end of the row.
416    return (
417      <Box flexDirection="row" marginTop={1}>
418        <Text dimColor>{MARKER}</Text>
419        {row.segments.map((one, index) => (
420          <Text color={one.color} dimColor={one.isDim} wrap="truncate">
421            {index === 0 ? '' : ' | '}
422            {one.text}
423          </Text>
424        ))}
425        <Box flexGrow={1} />
426        <Text> </Text>
427        {row.isCompact ? (
428          <Button key="handoff" label="h" plain dimColor={tier === 'quiet'} onPress={pressHandoff} />
429        ) : (
430          <Button
431            key="handoff"
432            label="handoff"
433            hotkey="h"
434            plain
435            dimColor={tier === 'quiet'}
436            onPress={pressHandoff}
437          />
438        )}
439        <Text> </Text>
440        {row.isCompact && !isArmed ? (
441          <Button key="compact" label="c" plain dimColor={tier !== 'alert'} onPress={pressCompact} />
442        ) : (
443          <Button
444            key="compact"
445            label={isArmed ? 'again to compact' : 'compact'}
446            hotkey="c"
447            plain
448            dimColor={tier !== 'alert' && !isArmed}
449            onPress={pressCompact}
450          />
451        )}
452      </Box>
453    )
454  })
455}
456
hooks/format.ts 315 lines
1import type { BandLimit, BandStats, BandUsage } from '../types'
2
3export type Tier = 'quiet' | 'warn' | 'alert'
4
5export type Segment = {
6  id: 'ctx' | 'cache' | 'fiveHour' | 'week' | 'spend' | 'model'
7  text: string
8  /** A theme color key, or undefined for the default text color. */
9  color?: string
10  isDim: boolean
11  /** Lower stays longer when the row is too narrow. */
12  priority: number
13}
14
15export type Thresholds = { warnPercent: number; alertPercent: number }
16
17export type Row = { segments: Segment[]; isCompact: boolean }
18
19const SEPARATOR = ' | '
20/** Leads the row so it reads as a status row, not the last line of output. Never dropped. */
21export const MARKER = '▎ '
22const BUTTONS_WIDE = 'h: handoff c: compact'.length
23const BUTTONS_COMPACT = 'h c'.length
24
25export const tierOf = (percent: number | null, at: Thresholds): Tier => {
26  if (percent === null) {
27    return 'quiet'
28  }
29
30  if (percent >= at.alertPercent) {
31    return 'alert'
32  }
33
34  return percent >= at.warnPercent ? 'warn' : 'quiet'
35}
36
37export const colorOf = (tier: Tier): string | undefined => {
38  if (tier === 'alert') {
39    return 'error'
40  }
41
42  return tier === 'warn' ? 'warning' : undefined
43}
44
45/** 950 -> "950", 108000 -> "108k", 1250000 -> "1.3M". */
46export const formatTokens = (count: number): string => {
47  if (count < 1000) {
48    return String(Math.round(count))
49  }
50
51  if (count < 1_000_000) {
52    return `${Math.round(count / 1000)}k`
53  }
54
55  return `${(count / 1_000_000).toFixed(1)}M`
56}
57
58/** 45_000 -> "<1m", 2_460_000 -> "41m", 7_800_000 -> "2h10m". */
59export const formatSpan = (ms: number): string => {
60  const minutes = Math.floor(ms / 60_000)
61
62  if (minutes < 1) {
63    return '<1m'
64  }
65
66  if (minutes < 60) {
67    return `${minutes}m`
68  }
69
70  const hours = Math.floor(minutes / 60)
71  const rest = minutes % 60
72
73  if (hours >= 48) {
74    return `${Math.floor(hours / 24)}d`
75  }
76
77  return rest === 0 ? `${hours}h` : `${hours}h${rest}m`
78}
79
80/** "claude-opus-5-5-20260915" -> "opus-5-5". */
81export const shortModel = (model: string): string =>
82  model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
83
84const limitText = (label: string, limit: BandLimit, now: number): string => {
85  const used = `${label} ${Math.round(limit.percentUsed)}%`
86
87  if (limit.resetsAt === null || limit.resetsAt <= now) {
88    return used
89  }
90
91  return `${used} (resets ${formatSpan(limit.resetsAt - now)})`
92}
93
94/**
95 * How long the prompt cache is assumed to stay warm: an estimate counted from
96 * the last response, since the engine reports no remaining lifetime.
97 */
98export const cacheText = (
99  lastResponseAt: number | null,
100  now: number,
101  ttlMinutes: number,
102  isReset = false,
103): { text: string; isCold: boolean } | null => {
104  if (isReset) {
105    return { text: 'cache cold', isCold: true }
106  }
107
108  if (lastResponseAt === null) {
109    return null
110  }
111
112  const left = ttlMinutes * 60_000 - (now - lastResponseAt)
113
114  return left <= 0
115    ? { text: 'cache cold', isCold: true }
116    : { text: `cache ~${formatSpan(left)}`, isCold: false }
117}
118
119export const buildSegments = (
120  usage: BandUsage,
121  stats: BandStats,
122  now: number,
123  at: Thresholds,
124  ttlMinutes: number,
125  hasModel: boolean,
126): Segment[] => {
127  const segments: Segment[] = []
128  const tier = tierOf(usage.percent, at)
129
130  if (usage.percent !== null && usage.window !== null) {
131    const used = usage.tokens === null ? '' : ` (${formatTokens(usage.tokens)}/${formatTokens(usage.window)})`
132
133    segments.push({
134      id: 'ctx',
135      text: `ctx ${Math.round(usage.percent)}%${used}`,
136      color: colorOf(tier),
137      isDim: false,
138      priority: 0,
139    })
140  }
141
142  const cache = cacheText(stats.lastResponseAt, now, ttlMinutes, stats.isCacheCold === true)
143
144  if (cache !== null) {
145    segments.push({
146      id: 'cache',
147      text: cache.text,
148      color: cache.isCold ? 'warning' : undefined,
149      isDim: false,
150      priority: 2,
151    })
152  }
153
154  if (usage.fiveHour !== null) {
155    segments.push({
156      id: 'fiveHour',
157      text: limitText('5h', usage.fiveHour, now),
158      color: usage.fiveHour.percentUsed >= 90 ? 'error' : undefined,
159      isDim: false,
160      priority: 1,
161    })
162  }
163
164  if (usage.sevenDay !== null) {
165    segments.push({
166      id: 'week',
167      text: `week ${Math.round(usage.sevenDay.percentUsed)}%`,
168      color: usage.sevenDay.percentUsed >= 90 ? 'error' : undefined,
169      isDim: usage.sevenDay.percentUsed < 50,
170      priority: 3,
171    })
172  }
173
174  // Generated tokens, not dollars: one figure that reads the same on a
175  // subscription, an API key and a local model, with no price behind it.
176  if (stats.outputTokens > 0) {
177    segments.push({
178      id: 'spend',
179      text: `out ${formatTokens(stats.outputTokens)}`,
180      isDim: true,
181      priority: 4,
182    })
183  }
184
185  if (hasModel && stats.model !== null) {
186    const effort = stats.effort === null ? '' : ` ${stats.effort}`
187
188    segments.push({
189      id: 'model',
190      text: `${shortModel(stats.model)}${effort}`,
191      isDim: true,
192      priority: 5,
193    })
194  }
195
196  return segments
197}
198
199const widthOf = (segments: Segment[], buttons: number): number =>
200  MARKER.length +
201  segments.reduce((sum, one) => sum + one.text.length, 0) +
202  Math.max(0, segments.length - 1) * SEPARATOR.length +
203  2 +
204  buttons
205
206/**
207 * Fits the row to `columns`: the record group goes first (model, then spend),
208 * then the buttons shrink to one letter each, then the remaining segments go
209 * by priority. The marker and `ctx` are never dropped.
210 */
211export const fitRow = (all: Segment[], columns: number): Row => {
212  let segments = [...all]
213  let isCompact = false
214
215  const dropLast = (): boolean => {
216    const worst = segments.reduce<Segment | null>(
217      (found, one) => (one.priority > (found?.priority ?? 0) ? one : found),
218      null,
219    )
220
221    if (worst === null) {
222      return false
223    }
224
225    segments = segments.filter(one => one !== worst)
226
227    return true
228  }
229
230  while (widthOf(segments, isCompact ? BUTTONS_COMPACT : BUTTONS_WIDE) > columns) {
231    const worst = Math.max(0, ...segments.map(one => one.priority))
232
233    if (worst >= 4) {
234      dropLast()
235    } else if (!isCompact) {
236      isCompact = true
237    } else if (!dropLast()) {
238      break
239    }
240  }
241
242  return { segments, isCompact }
243}
244
245/**
246 * "20261003-155407", in UTC: a file name stamp, not a time shown to anyone.
247 * To the second, so two handoffs in the same minute do not share a file.
248 */
249export const stamp = (now: number): string => {
250  const iso = new Date(now).toISOString()
251
252  return `${iso.slice(0, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}-${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}`
253}
254
255export type Fill = { path?: string; files: string[]; note: string }
256
257/** Fills a template's `{{path}}`, `{{files}}` and `{{note}}`. */
258export const fillTemplate = (template: string, fill: Fill): string => {
259  const files = fill.files.length === 0 ? '- none recorded' : fill.files.map(one => `- ${one}`).join('\n')
260  const note = fill.note.trim() === '' ? '' : `The user adds: ${fill.note.trim()}`
261
262  return template
263    .replaceAll('{{path}}', fill.path ?? '')
264    .replaceAll('{{files}}', files)
265    .replaceAll('{{note}}', note)
266    .replace(/\n{3,}/g, '\n\n')
267    .trim()
268}
269
270const SHORT_TTL = 5
271const LONG_TTL = 60
272const MIN_PRIOR_TOKENS = 8000
273const MIN_IDLE_MS = 60_000
274
275export type Observation = {
276  /** The lifetime assumed so far, in minutes. */
277  minutes: number
278  /** How long the session sat idle before this request, in milliseconds. */
279  idleMs: number
280  /** Context tokens the previous response was answered over. */
281  priorTokens: number
282  /** Tokens this request read from the prompt cache. */
283  cacheRead: number
284}
285
286/**
287 * Corrects the assumed cache lifetime from what a request actually read.
288 *
289 * After an idle gap, the first request either reads most of the previous
290 * context from the cache (it was still warm) or almost none of it (it had gone
291 * cold). Cold inside the assumed lifetime means the lifetime is shorter than
292 * assumed; warm past it means it is longer. The cache has two lifetimes, five
293 * minutes and one hour, so those are the only corrections made.
294 *
295 * @returns the corrected lifetime in minutes, or null when nothing changes
296 */
297export const observeTtl = (seen: Observation): number | null => {
298  if (seen.priorTokens < MIN_PRIOR_TOKENS || seen.idleMs < MIN_IDLE_MS) {
299    return null
300  }
301
302  const idleMinutes = seen.idleMs / 60_000
303  const share = seen.cacheRead / seen.priorTokens
304
305  if (share < 0.1 && idleMinutes < seen.minutes && idleMinutes >= SHORT_TTL && seen.minutes > SHORT_TTL) {
306    return SHORT_TTL
307  }
308
309  if (share >= 0.5 && idleMinutes > seen.minutes && seen.minutes < LONG_TTL) {
310    return LONG_TTL
311  }
312
313  return null
314}
315
types/index.d.ts 35 lines
1export type BandLimit = { percentUsed: number; resetsAt: number | null }
2
3export type BandUsage = {
4  tokens: number | null
5  window: number | null
6  percent: number | null
7  fiveHour: BandLimit | null
8  sevenDay: BandLimit | null
9}
10
11export type BandStats = {
12  outputTokens: number
13  model: string | null
14  effort: string | null
15  lastResponseAt: number | null
16  /** True from a model switch or a compaction until the next response: neither leaves anything in the cache to read. */
17  isCacheCold: boolean
18}
19
20/** The assumed prompt-cache lifetime and where the figure came from. */
21export type BandTtl = { minutes: number; source: 'config' | 'engine' | 'observed' }
22
23declare module 'claude-code' {
24  interface PluginState {
25    'session-band': {
26      usage: BandUsage
27      stats: BandStats
28      files: string[]
29      tick: number
30      armedUntil: number
31      ttl: BandTtl | null
32    }
33  }
34}
35