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

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
| Segment | Meaning |
|---|---|
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 ~41m | Estimated 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 41k | Output 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 medium | Model 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).
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 row | ctrl+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.
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.
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).
| Option | Default | |
|---|---|---|
warnPercent | 60 | Context fill where the row turns to the warning color. |
alertPercent | 75 | Context fill where it turns to the alert color. |
cacheTtlMinutes | 60 | The prompt-cache lifetime to start from. The row corrects it by itself (see Limits), so this rarely needs changing. |
handoffDir | .handoff | Where handoff files go, relative to the working directory. Add it to .gitignore. |
templateDir | empty | A folder with your own handoff.md and compact.md. |
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."
/usage fetches its own. The row can read a point or so lower until the next request.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-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.From the repo root:
claude plugin validate session-band
claude plugin test session-bandhooks/register.tsx 456 lines1import { 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}
456hooks/format.ts 315 lines1import 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}
315types/index.d.ts 35 lines1export 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