Prompt-cache countdown, cost to re-warm, usage limits, cold-cache warnings and one-click handoff → clear & continue.

A mod (function-hook plugin) for Claude Code, named session-band. It puts a one-line band right above the prompt that tracks your session (prompt cache, context, usage limits, cost) and manages it (keep warm, compact, handoff, model switch, save notes, reply style) in one click.

Why: notice before the prompt cache goes cold, avoid paying to re-write it, and compact or hand off in time when the context grows.
claude plugin marketplace add einaruk/claude-session-band
claude plugin install session-band@session-band
Then restart Claude Code. The band appears above the prompt after the first reply.
Note: tested in the terminal on Windows PowerShell and WSL; not tested in the Claude Code desktop app.
This section is for a Claude Code agent installing the mod on the user's behalf. Follow the steps in order:
claude --version. Function-hook mod support is required; tested with 2.1.291. If older, suggest claude update to the user and stop.claude plugin marketplace add einaruk/claude-session-band.claude plugin install session-band@session-band.session-band appears in the output of claude plugin list./session-band opens the details pane.settings.json; the marketplace install is enough. If a step fails, show its output to the user verbatim and do not try another route.Optional dependencies: the 🪨 button toggles the caveman plugin. Without it the button only writes a flag file and has no effect. The ASD button appears only while the tr-sade-teknik-dil skill is installed (npx skills add einaruk/tr-sade-teknik-dil).
git clone https://github.com/einaruk/claude-session-band.git ~/.claude/mods/session-band
Set env.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json to ~/.claude/mods/session-band (several folders: separate with ; on Windows, : on macOS/Linux/WSL). For a one-session trial: claude --plugin-dir ~/.claude/mods/session-band.
Figures on the left, buttons on the right. The colored dot in front of a figure shows its state: green fine, yellow watch, red act now.
Table 1 (v1.0) — Band figures (left to right)
| # | Figure | Example | Meaning |
|---|---|---|---|
| 1 | Model | opus-5-5 | The model running the session |
| 2 | Cache | 47' · $0.85 | Time until the prompt cache goes cold · estimated cost to re-warm it if it does. cold · $0.85 = it went cold; the next prompt re-writes the whole context |
| 3 | Context | ctx 159k/300k | Tokens in context / auto-compact point (when off: ctx 159k 16% = share of the window) |
| 4 | Limits | 5h 72% · wk 40% | What is left of the 5-hour and weekly usage limits (shown on a subscription) |
| 5 | Session cost | session $3.20 | The session's cost at API rates |
Table 2 (v1.1) — Band buttons
| # | Icon | What it does |
|---|---|---|
| 1 | O / F | Switch model: on Fable, O → Opus 5.5; on Opus, F → Fable 5.1 (runs /model) |
| 2 | 🔥 | Keep warm: sends a short "ok" turn that resets the cache timer (hidden once the cache is cold) |
| 3 | 📦 | Compact now: runs /compact with the configured instructions |
| 4 | 📝 | Save notes: runs the skill set in saveCommand; when none is set, sends a prompt asking Claude to update the project's working notes |
| 5 | 🧭 | Status: sends the prompt set in statusPrompt; when none is set, asks where the work stands, where it was left off and what is next |
| 6 | 🤝 | Handoff: has Claude write a handoff note for a fresh session; when it is ready the band shows Clear & continue → /clear + the note sent as the first message |
| 7 | 📊 | Details: opens the details pane (TTL, when limits reset, auto-compact −/+ buttons) |
| 8 | 🪨 on/off | Toggles the caveman plugin (terse replies). Without the plugin it only writes a flag file and has no effect |
| 9 | ASD on/off | Toggles the writing rules set in styleRules. The default is a summary of the tr-sade-teknik-dil skill: plain technical language for Turkish and English. While on, the rules go with every prompt. Shown only while the skill named in styleSkill is installed. Exclusive with 🪨: switching one on switches the other off |
Buttons hide while a turn runs and before the first reply (except the model switch and 📊). A toast warns warnMinutes (default 5) before the cache goes cold. Hovering a figure or a button shows a one-line description of it in the band.
The same actions are available as a command: /session-band [open|warm|compact|handoff|save|continue|caveman|style|autocompact <250k|off|auto>]
.claude-plugin/plugin.json → userConfig (also editable from Claude Code's plugin settings):
Table 3 (v1.1) — Settings
| # | Setting | Default | Note |
|---|---|---|---|
| 1 | cacheTtl | auto | 1h on a subscription, 5m otherwise; corrected by what the API actually served after a gap |
| 2 | warnMinutes | 5 | How many minutes before the cache goes cold to warn |
| 3 | inputPricePerMTok | 0 | 0 = calibrate from the session's own cost ledger |
| 4 | autoCompact | off | auto = 300k on 1M-context models, 70% of smaller windows; or a fixed point such as 250k |
| 5 | handoffCommand | anthropic-skills:context-handoff | Falls back to a built-in handoff prompt when missing |
| 6 | saveCommand | empty | Skill the 📝 button runs; empty sends a built-in prompt |
| 7 | statusPrompt | empty | Prompt the 🧭 button sends; empty sends a built-in English prompt |
| 8 | compactInstructions | "what to keep / what to drop" summary instructions | Passed as the argument to 📦 and to auto-compact |
| 9 | styleRules | summary of the tr-sade-teknik-dil rules | Text the ASD toggle attaches to every prompt while on; empty hides the toggle |
| 10 | styleSkill | tr-sade-teknik-dil | The ASD toggle shows only while this skill is installed; empty shows it whenever rules are set |
Built on etding/cache-keeper (MIT, commit 6a2ba1b) and adapted for personal use: model chip and Fable ↔ Opus switch, session cost chip, borderless rendering in the terminal, 📝 save notes button, 🪨 caveman toggle, 🧭 status button, ASD writing-rules toggle, hover descriptions, auto-compact off by default, custom compact instructions. License: MIT; the original copyright line is kept in LICENSE.
hooks/register.tsx 883 lines1// session-band: watches the prompt cache's TTL, prices a re-warm, warns before it goes cold,
2// and turns "handoff → /clear → paste → send" into two button presses.
3// Every function that takes $ lives at the top of this file: the engine follows $ only there.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
6
7import {
8 COMPACT_FLOOR_TOKENS,
9 COMPACT_REGROW_TOKENS,
10 COMPACT_STEP_TOKENS,
11 defaultCompactAt,
12 describeCompactPoint,
13 parseCompactSetting,
14 resolveCompactPoint,
15 shouldAutoCompact,
16} from './auto-compact-policy'
17import type { CompactSetting } from './auto-compact-policy'
18import {
19 MINUTE_MS,
20 TTL_1H_MS,
21 TTL_5M_MS,
22 cacheWriteMultiple,
23 formatCountdown,
24 formatTokens,
25 formatUntil,
26 formatUsd,
27 resolveInputPrice,
28 weightedUnits,
29} from './cache-math'
30import { LIMIT_LABEL, PHASE_COLOR, TONE_DOT_COLOR, autoCompactSummary, bandChips, headline, modelFamily, percentLeft } from './cache-view-model'
31import type { CachePhase, KeeperConfig, KeeperView, ModelFamily, TtlSource } from './cache-view-model'
32
33// Session state, held by the host in $.state so it survives hot reloads.
34const touchAtom = atom({ plugin: 'session-band', key: 'touch' } as const, null)
35const isRunningAtom = atom({ plugin: 'session-band', key: 'isRunning' } as const, false)
36const turnStartedAtAtom = atom({ plugin: 'session-band', key: 'turnStartedAt' } as const, null)
37// Written by the ticker so drawings that read it redraw as the countdown moves.
38const nowAtom = atom({ plugin: 'session-band', key: 'now' } as const, 0)
39const observedTtlAtom = atom({ plugin: 'session-band', key: 'observedTtlMs' } as const, null)
40// The touch timestamp already warned about, so each cache cycle warns once.
41const warnedForAtom = atom({ plugin: 'session-band', key: 'warnedFor' } as const, null)
42const autoOpenedAtom = atom({ plugin: 'session-band', key: 'autoOpened' } as const, false)
43const handoffAtom = atom({ plugin: 'session-band', key: 'handoff' } as const, {
44 status: 'idle',
45 text: '',
46})
47const calibrationAtom = atom({ plugin: 'session-band', key: 'calibration' } as const, null)
48const compactAtAtom = atom({ plugin: 'session-band', key: 'compactAt' } as const, null)
49const compactRefusedAtAtom = atom({ plugin: 'session-band', key: 'compactRefusedAt' } as const, null)
50// The caveman plugin's flag file as last read: its level, '' while off, null before the first read.
51const cavemanAtom = atom({ plugin: 'session-band', key: 'caveman' } as const, null)
52const cavemanResumeAtom = atom({ plugin: 'session-band', key: 'cavemanResume' } as const, 'full')
53const cavemanToldAtom = atom({ plugin: 'session-band', key: 'cavemanTold' } as const, null)
54// The writing-rules toggle's flag file as last read, and whether the model was last told the rules are on.
55const styleAtom = atom({ plugin: 'session-band', key: 'style' } as const, false)
56const styleToldAtom = atom({ plugin: 'session-band', key: 'styleTold' } as const, null)
57const styleAvailableAtom = atom({ plugin: 'session-band', key: 'styleAvailable' } as const, false)
58const modelSeenAtom = atom({ plugin: 'session-band', key: 'modelSeen' } as const, {})
59
60const PANE_ID = 'session-band'
61const PANE_TITLE = 'Session band'
62const TICK_MS = 15_000
63// Lets the finished turn settle before an auto-compaction starts.
64const AUTO_COMPACT_DELAY_MS = 1_000
65
66const KEEP_ALIVE_PROMPT =
67 'Prompt-cache keep-alive from the session-band mod. No action needed: reply with just "ok".'
68
69const HANDOFF_ARGS =
70 'Put the complete handoff in your final reply, ready to paste as the first message of a fresh session.'
71
72// Used when the configured save command is not installed.
73const SAVE_PROMPT =
74 "Update the project's working notes (status, decisions, open items) for the work done in this session. " +
75 'Re-read each file right before writing it.'
76
77// Used when no status prompt is configured.
78const STATUS_PROMPT = 'Where do we stand? What is done, where did we leave off, and what is next?'
79
80// Used when the configured handoff command is not installed.
81const HANDOFF_PROMPT = [
82 'Write a session handoff so a fresh session can continue this work without the transcript.',
83 'Cover: the objective, decisions made, current state (files, commands, IDs exactly), what is left, and what not to redo.',
84 'Reply with the handoff only, written as the first message of the new session.',
85].join(' ')
86
87// Levels the caveman plugin keeps on between turns, and the one-shot modes its own commands set.
88const CAVEMAN_LEVELS = ['lite', 'full', 'ultra', 'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra']
89const CAVEMAN_ONE_SHOT_MODES = ['commit', 'review', 'compress']
90
91// The band's model switch: from each family, the family it goes to, the letter on the button,
92// and the model id used until that family has been seen in this session.
93const MODEL_SWITCH: Record<ModelFamily, { to: ModelFamily; label: string; model: string }> = {
94 fable: { to: 'opus', label: 'O', model: 'claude-opus-5-5' },
95 opus: { to: 'fable', label: 'F', model: 'claude-fable-5-1' },
96}
97
98// Attached to the next prompt after a flip, so the toggle costs no turn of its own.
99const CAVEMAN_OFF_NOTE =
100 'The user switched caveman mode off with the toggle on the session-band band, the same as typing "stop caveman". ' +
101 'Write in normal prose from this reply on, until caveman is switched on again.'
102const CAVEMAN_ON_NOTE =
103 'The user switched caveman mode on with the toggle on the session-band band, the same as typing "/caveman". ' +
104 'Follow the caveman rules from this reply on.'
105
106// Sent with every prompt while the writing rules are on: a rule set has to outlive a compaction.
107const STYLE_ON_NOTE =
108 'The writing rules of the session-band toggle are on. They replace caveman mode: ignore any caveman reminder ' +
109 'while they are on. Follow them in every reply until the user switches them off:\n'
110const STYLE_OFF_NOTE =
111 'The user switched the writing rules off with the toggle on the session-band band. ' +
112 'Write in your normal style from this reply on.'
113
114// ---------------------------------------------------------------- view
115
116/** The TTL in force: the configured one, else what a gap proved, else 1h on a subscription and 5m off one. */
117async function resolveTtl(
118 $: EngineInterface,
119 config: KeeperConfig,
120 rateLimits: SessionRateLimit[],
121): Promise<{ ms: number; source: TtlSource }> {
122 if (config.cacheTtl === '1h') return { ms: TTL_1H_MS, source: 'config' }
123 if (config.cacheTtl === '5m') return { ms: TTL_5M_MS, source: 'config' }
124 const observed = await read($, observedTtlAtom)
125 if (observed !== null) return { ms: observed, source: 'observed' }
126 return { ms: rateLimits.length > 0 ? TTL_1H_MS : TTL_5M_MS, source: 'assumed' }
127}
128
129/** Reads state and the session's usage figures into one view; reading from a render subscribes it. */
130async function computeView($: EngineInterface, config: KeeperConfig): Promise<KeeperView> {
131 const ticked = await read($, nowAtom)
132 const now = ticked > 0 ? ticked : await $.clock.now()
133 const usage = await $.session.usage()
134 const touch = await read($, touchAtom)
135 const isRunning = await read($, isRunningAtom)
136 const ttl = await resolveTtl($, config, usage.rateLimits)
137
138 const contextTokens = usage.context.tokens ?? touch?.contextTokens ?? 0
139 const sessionModel = await $.session.model()
140 // The price follows the model the cache was last written under; the band shows the one in force.
141 const model = touch?.model ?? sessionModel
142 const price = resolveInputPrice(
143 config.inputPricePerMTok,
144 await read($, calibrationAtom),
145 usage.cost?.usd,
146 model,
147 )
148 const remainingMs = touch ? touch.at + ttl.ms - now : 0
149
150 let phase: CachePhase = 'empty'
151 if (isRunning) phase = 'running'
152 else if (touch && remainingMs <= 0) phase = 'cold'
153 else if (touch && remainingMs <= config.warnMs) phase = 'cooling'
154 else if (touch) phase = 'warm'
155
156 return {
157 phase,
158 model: sessionModel,
159 now,
160 remainingMs,
161 ttlMs: ttl.ms,
162 ttlSource: ttl.source,
163 touchAt: touch?.at ?? null,
164 contextTokens,
165 contextWindow: usage.context.window,
166 contextPercent: usage.context.percent,
167 rewarmUsd: contextTokens * price.perToken * cacheWriteMultiple(ttl.ms),
168 priceSource: price.source,
169 rateLimits: usage.rateLimits,
170 costUsd: usage.cost?.usd,
171 compact: resolveCompactPoint(await read($, compactAtAtom), config.autoCompact, usage.context.window),
172 }
173}
174
175/** A flag file under the Claude Code configuration directory, shared by every session. */
176async function flagPath($: EngineInterface, name: string): Promise<string | null> {
177 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
178 if (configDir) return `${configDir}/${name}`
179 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
180 return home ? `${home}/.claude/${name}` : null
181}
182
183/** Where the caveman plugin keeps its mode flag. */
184async function cavemanFlagPath($: EngineInterface): Promise<string | null> {
185 return flagPath($, '.caveman-active')
186}
187
188/** Where the writing-rules toggle keeps its flag: 'on', or empty while off. */
189async function styleFlagPath($: EngineInterface): Promise<string | null> {
190 return flagPath($, '.session-band-style')
191}
192
193/**
194 * Reads the flag into state and returns the level, '' while caveman is off. A missing, empty or
195 * unknown flag is off, as the caveman plugin's own readers treat it.
196 */
197async function refreshCaveman($: EngineInterface): Promise<string> {
198 const path = await cavemanFlagPath($)
199 let level = ''
200 if (path !== null && (await $.fs.exists(path).catch(() => false))) {
201 const text = (await $.fs.read(path).catch(() => '')).trim().toLowerCase()
202 if (CAVEMAN_LEVELS.includes(text) || CAVEMAN_ONE_SHOT_MODES.includes(text)) level = text
203 }
204 if ((await read($, cavemanAtom)) !== level) await update($, cavemanAtom, () => level)
205 if (CAVEMAN_LEVELS.includes(level) && (await read($, cavemanResumeAtom)) !== level) {
206 await update($, cavemanResumeAtom, () => level)
207 }
208 return level
209}
210
211/**
212 * Whether the writing-rules toggle is offered: rules are set and the skill they belong to,
213 * when one is named, is installed.
214 */
215async function refreshStyleAvailable($: EngineInterface, config: KeeperConfig): Promise<boolean> {
216 let isAvailable = config.styleRules !== ''
217 if (isAvailable && config.styleSkill !== '') {
218 const names = (await $.command.list().catch(() => [])).map(command => command.name)
219 isAvailable = names.some(name => name === config.styleSkill || name.endsWith(`:${config.styleSkill}`))
220 }
221 if ((await read($, styleAvailableAtom)) !== isAvailable) await update($, styleAvailableAtom, () => isAvailable)
222 return isAvailable
223}
224
225/** Reads the writing-rules flag into state. A missing or unreadable flag is off. */
226async function refreshStyle($: EngineInterface): Promise<boolean> {
227 const path = await styleFlagPath($)
228 let isOn = false
229 if (path !== null && (await $.fs.exists(path).catch(() => false))) {
230 isOn = (await $.fs.read(path).catch(() => '')).trim().toLowerCase() === 'on'
231 }
232 if ((await read($, styleAtom)) !== isOn) await update($, styleAtom, () => isOn)
233 return isOn
234}
235
236/** Moves the countdown, refreshes the status line, and warns once per cache cycle. */
237async function tick($: EngineInterface, config: KeeperConfig): Promise<void> {
238 const now = await $.clock.now()
239 await update($, nowAtom, () => now)
240 // Caveman is also switched by typed commands and by other sessions; the flag file is the truth.
241 await refreshCaveman($)
242 if (await refreshStyleAvailable($, config)) await refreshStyle($)
243 const view = await computeView($, config)
244
245 if (view.phase !== 'cooling' || view.touchAt === null) return
246 if ((await read($, warnedForAtom)) === view.touchAt) return
247 await update($, warnedForAtom, () => view.touchAt)
248 $.ui.toast(
249 `Cache goes cold in ${formatCountdown(view.remainingMs)}; re-warming ${formatTokens(view.contextTokens)} tokens ` +
250 `would cost ≈${formatUsd(view.rewarmUsd)}. Keep warm, compact or hand off from the band above the prompt.`,
251 { timeoutMs: 15_000 },
252 )
253}
254
255// ---------------------------------------------------------------- actions
256
257/** Runs an action without letting a failure escape the press or command that started it. */
258function runAction($: EngineInterface, label: string, action: () => Promise<unknown>): void {
259 void action().catch((error: unknown) => {
260 $.ui.toast(`session-band: ${label} failed: ${error instanceof Error ? error.message : String(error)}`)
261 })
262}
263
264async function keepWarm($: EngineInterface): Promise<void> {
265 await $.prompt.submit({ text: KEEP_ALIVE_PROMPT })
266}
267
268async function compactNow($: EngineInterface, config: KeeperConfig): Promise<void> {
269 const args = config.compactInstructions.trim()
270 await $.command.run(args ? { command: 'compact', args } : { command: 'compact' })
271}
272
273/** Runs the configured notes skill; a plain prompt when none is set or it is not installed. */
274async function saveNotes($: EngineInterface, config: KeeperConfig): Promise<void> {
275 const names = (await $.command.list()).map(command => command.name)
276 const wanted = config.saveCommand.replace(/^\//, '')
277 const command = wanted
278 ? (names.find(name => name === wanted) ?? names.find(name => name.endsWith(`:${wanted}`)))
279 : undefined
280 if (command) await $.command.run({ command })
281 else await $.prompt.submit({ text: SAVE_PROMPT })
282}
283
284/** Asks where the work stands: the configured status prompt, else the built-in one. */
285async function askStatus($: EngineInterface, config: KeeperConfig): Promise<void> {
286 await $.prompt.submit({ text: config.statusPrompt.trim() || STATUS_PROMPT, asUser: true })
287}
288
289/** Starts the handoff turn; turn.complete captures its answer as the handoff text. */
290async function startHandoff($: EngineInterface, config: KeeperConfig): Promise<void> {
291 const names = (await $.command.list()).map(command => command.name)
292 const wanted = config.handoffCommand.replace(/^\//, '')
293 const command =
294 names.find(name => name === wanted) ??
295 names.find(name => name === 'context-handoff' || name.endsWith(':context-handoff'))
296
297 await update($, handoffAtom, () => ({ status: 'pending', text: '' }))
298 try {
299 if (command) await $.command.run({ command, args: HANDOFF_ARGS })
300 else await $.prompt.submit({ text: HANDOFF_PROMPT })
301 } catch (error) {
302 await update($, handoffAtom, () => ({ status: 'idle', text: '' }))
303 throw error
304 }
305}
306
307/** /clear, then sends the captured handoff as the first prompt of the fresh conversation. */
308async function clearAndContinue($: EngineInterface): Promise<void> {
309 const handoff = await read($, handoffAtom)
310 const text = handoff.text.trim()
311 if (handoff.status !== 'ready' || text === '') {
312 $.ui.toast('session-band: no handoff ready yet. Press Handoff first.')
313 return
314 }
315 await update($, handoffAtom, () => ({ status: 'idle', text: '' }))
316 await $.command.run({ command: 'clear' })
317 await $.prompt.submit({ text, asUser: true })
318}
319
320/**
321 * Compacts between turns once the context reaches the point in force. A refusal or failure
322 * waits for more context before trying again, so it never repeats every turn.
323 */
324async function autoCompact($: EngineInterface, config: KeeperConfig): Promise<void> {
325 if (await read($, isRunningAtom)) return
326 if ((await read($, handoffAtom)).status !== 'idle') return
327 const view = await computeView($, config)
328 if (!shouldAutoCompact(view.contextTokens, view.compact.at, await read($, compactRefusedAtAtom))) return
329
330 $.ui.toast(`Auto-compacting at ${formatTokens(view.contextTokens)} · point ${describeCompactPoint(view.compact)}`)
331 let reason: string
332 try {
333 const instructions = config.compactInstructions.trim()
334 const result = await $.session.compact(instructions ? { instructions } : undefined)
335 if (result.skip === undefined) return
336 reason = result.skip
337 } catch (error) {
338 if (await read($, isRunningAtom)) return // a turn started first: try again when it ends
339 reason = error instanceof Error ? error.message : String(error)
340 // Desktop and SDK sessions refuse $.session.compact (compaction there runs inside a turn), so
341 // queue /compact the way the 📦 button does. A compaction that lands clears the refusal mark
342 // in the session.compact hook; one that never runs waits for more context before the next try.
343 if (/not available/i.test(reason)) {
344 await update($, compactRefusedAtAtom, () => view.contextTokens)
345 await compactNow($, config)
346 return
347 }
348 }
349 await update($, compactRefusedAtAtom, () => view.contextTokens)
350 $.ui.toast(
351 `session-band: auto-compact did not run (${reason}). Next try after ${formatTokens(COMPACT_REGROW_TOKENS)} more context.`,
352 )
353}
354
355/** Sets this session's auto-compact point; null hands it back to the mod setting / research default. */
356async function setSessionCompact($: EngineInterface, value: CompactSetting | null): Promise<void> {
357 await update($, compactAtAtom, () => value)
358 await update($, compactRefusedAtAtom, () => null)
359}
360
361async function discardHandoff($: EngineInterface): Promise<void> {
362 await update($, handoffAtom, () => ({ status: 'idle', text: '' }))
363}
364
365/**
366 * Flips the caveman plugin's flag file. Off is an empty flag, not 'off': the plugin's per-turn
367 * reminder treats 'off' as a level and would keep announcing the mode. The model hears of the
368 * flip with the next prompt, from the prompt.submit hook.
369 */
370async function toggleCaveman($: EngineInterface): Promise<void> {
371 const path = await cavemanFlagPath($)
372 if (path === null) {
373 $.ui.toast('session-band: cannot find the Claude Code configuration directory for the caveman flag.')
374 return
375 }
376 const wasOn = (await refreshCaveman($)) !== ''
377 // Before the first prompt nothing is recorded yet: record the state the model started under.
378 if ((await read($, cavemanToldAtom)) === null) await update($, cavemanToldAtom, () => wasOn)
379 const level = wasOn ? '' : await read($, cavemanResumeAtom)
380 await $.fs.write(path, level)
381 await update($, cavemanAtom, () => level)
382 $.ui.toast(wasOn ? 'Caveman off from the next prompt.' : `Caveman on (${level}) from the next prompt.`)
383 // Both set the reply style: switching one on switches the other off.
384 if (!wasOn && (await refreshStyle($))) await toggleStyle($)
385}
386
387/**
388 * Flips the writing-rules flag file. While it is on, the prompt.submit hook attaches the
389 * configured rules to every prompt; the model hears of a flip to off with the next prompt.
390 */
391async function toggleStyle($: EngineInterface): Promise<void> {
392 const path = await styleFlagPath($)
393 if (path === null) {
394 $.ui.toast('session-band: cannot find the Claude Code configuration directory for the writing-rules flag.')
395 return
396 }
397 const wasOn = await refreshStyle($)
398 // Before the first prompt nothing is recorded yet: record the state the model started under.
399 if ((await read($, styleToldAtom)) === null) await update($, styleToldAtom, () => wasOn)
400 await $.fs.write(path, wasOn ? '' : 'on')
401 await update($, styleAtom, () => !wasOn)
402 $.ui.toast(wasOn ? 'Writing rules off from the next prompt.' : 'Writing rules on from the next prompt.')
403 if (!wasOn && (await refreshCaveman($)) !== '') await toggleCaveman($)
404}
405
406/**
407 * Runs /model for the other side of the Fable ↔ Opus pair. The id being left is remembered, so
408 * switching back restores it exactly (a [1m] variant included) rather than the default id.
409 */
410async function switchModel($: EngineInterface, config: KeeperConfig): Promise<void> {
411 const current = await $.session.model()
412 const family = modelFamily(current)
413 if (family === null) return
414 const target = MODEL_SWITCH[family]
415 await update($, modelSeenAtom, seen => ({ ...seen, [family]: current }))
416 const model = (await read($, modelSeenAtom))[target.to] ?? target.model
417 await $.command.run({ command: 'model', args: model })
418 // Redraw now: the model chip and the button's letter follow the new model.
419 await tick($, config)
420}
421
422/** Opens the details pane; pressed or typed, so the surface places it at any width. */
423async function openPane($: EngineInterface): Promise<void> {
424 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
425 if (!opened.isPlaced) $.ui.toast(`session-band: the details pane could not open (${opened.reason}).`)
426}
427
428// ---------------------------------------------------------------- hooks
429
430export const register: Register = (on, options) => {
431 const config: KeeperConfig = {
432 cacheTtl: String(options.cacheTtl ?? 'auto'),
433 warnMs: Math.max(1, Number(options.warnMinutes ?? 5)) * MINUTE_MS,
434 inputPricePerMTok: Number(options.inputPricePerMTok ?? 0),
435 handoffCommand: String(options.handoffCommand ?? 'anthropic-skills:context-handoff'),
436 compactInstructions: String(options.compactInstructions ?? ''),
437 saveCommand: String(options.saveCommand ?? ''),
438 statusPrompt: String(options.statusPrompt ?? ''),
439 styleRules: String(options.styleRules ?? '').trim(),
440 styleSkill: String(options.styleSkill ?? '').replace(/^\//, '').trim(),
441 autoCompact: parseCompactSetting(String(options.autoCompact ?? 'auto')) ?? 'auto',
442 }
443
444 on('session.start', async ($, e, next) => {
445 await $.command.register({
446 name: 'session-band',
447 description: 'Prompt-cache countdown and handoff: open, warm, compact, handoff, save notes, continue, caveman and writing-rules toggles',
448 argumentHint: '[open|warm|compact|handoff|save|continue|caveman|style|autocompact <250k|off|auto>]',
449 })
450 if ((await read($, calibrationAtom)) === null) {
451 const cost = (await $.session.usage()).cost?.usd
452 if (cost !== undefined) await update($, calibrationAtom, () => ({ costStart: cost, units: 0 }))
453 }
454 // The bar above the prompt shows everything; clear a status entry left by an earlier version.
455 $.ui.status(undefined)
456 $.clock.every(TICK_MS, () => void tick($, config))
457 await tick($, config)
458
459 return next(e)
460 })
461
462 on('command.run', { command: 'session-band' }, async ($, e) => {
463 const [first = '', ...rest] = e.args.trim().toLowerCase().split(/\s+/)
464 const verb = first || 'open'
465 if (verb === 'autocompact') {
466 const setting = parseCompactSetting(rest.join(''))
467 if (setting === undefined) return { text: 'Usage: /session-band autocompact <250k|off|auto>' }
468 const sessionValue = setting === 'auto' ? null : setting
469 await setSessionCompact($, sessionValue)
470 const window = (await $.session.usage()).context.window
471 return { text: `session-band: auto-compact for this session at ${describeCompactPoint(resolveCompactPoint(sessionValue, config.autoCompact, window))}` }
472 }
473 // Fire-and-forget: these queue a command or prompt that runs once this one finishes.
474 if (verb === 'warm') runAction($, 'keep warm', () => keepWarm($))
475 else if (verb === 'compact') runAction($, 'compact', () => compactNow($, config))
476 else if (verb === 'handoff') runAction($, 'handoff', () => startHandoff($, config))
477 else if (verb === 'save') runAction($, 'save', () => saveNotes($, config))
478 else if (verb === 'continue') runAction($, 'clear & continue', () => clearAndContinue($))
479 else if (verb === 'open') await openPane($)
480 else if (verb === 'caveman') await toggleCaveman($)
481 else if (verb === 'style') {
482 if (!(await refreshStyleAvailable($, config))) {
483 return { text: 'session-band: the writing-rules toggle is not available (no rules are set, or the skill named in styleSkill is not installed).' }
484 }
485 await toggleStyle($)
486 } else return { text: 'Usage: /session-band [open|warm|compact|handoff|save|continue|caveman|style|autocompact <250k|off|auto>]' }
487
488 return { text: `session-band: ${verb}` }
489 })
490
491 // Attaches the writing rules to every prompt while their toggle is on, and tells the model once
492 // when they or caveman flipped since it was last told, whoever flipped it: this band, another
493 // session's, or a typed command.
494 on('prompt.submit', async ($, e, next) => {
495 const notes: string[] = []
496 try {
497 const isStyleOn = (await refreshStyleAvailable($, config)) && (await refreshStyle($))
498 // The caveman plugin switches itself back on at every session start; the writing rules win while on.
499 if (isStyleOn && (await refreshCaveman($)) !== '') await toggleCaveman($)
500 const styleTold = await read($, styleToldAtom)
501 if (styleTold !== isStyleOn) await update($, styleToldAtom, () => isStyleOn)
502 if (isStyleOn) notes.push(STYLE_ON_NOTE + config.styleRules)
503 else if (styleTold === true) notes.push(STYLE_OFF_NOTE)
504 } catch {
505 // A flag that cannot be read must not hold up the prompt.
506 }
507 try {
508 const isOn = (await refreshCaveman($)) !== ''
509 const told = await read($, cavemanToldAtom)
510 if (told !== isOn) await update($, cavemanToldAtom, () => isOn)
511 if (told !== null && told !== isOn) notes.push(isOn ? CAVEMAN_ON_NOTE : CAVEMAN_OFF_NOTE)
512 } catch {
513 // A flag that cannot be read must not hold up the prompt.
514 }
515
516 return next(notes.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...notes] })
517 })
518
519 on('turn.start', async ($, e, next) => {
520 const now = await $.clock.now()
521 await update($, isRunningAtom, () => true)
522 await update($, turnStartedAtAtom, () => now)
523 void tick($, config)
524
525 return next(e)
526 })
527
528 on('turn.complete', async ($, e, next) => {
529 const result = await next(e)
530 const usage = await $.session.usage()
531 const ttl = await resolveTtl($, config, usage.rateLimits)
532 const costUsd = usage.cost?.usd
533
534 // Every loop's requests feed the price calibration against the cost ledger.
535 if (e.usage && costUsd !== undefined) {
536 const units = weightedUnits(e.usage, ttl.ms)
537 await update($, calibrationAtom, prev => ({
538 costStart: prev?.costStart ?? costUsd,
539 units: (prev?.units ?? 0) + units,
540 }))
541 }
542 if (e.agentId !== undefined) return result // a subagent's cache is not the main thread's
543
544 const now = await $.clock.now()
545 const prev = await read($, touchAtom)
546 const startedAt = await read($, turnStartedAtAtom)
547 await update($, isRunningAtom, () => false)
548
549 // A gap between 5m and 1h tells the TTL apart: a 5m cache had to re-write the whole prefix.
550 if (e.usage && prev && startedAt !== null && prev.contextTokens > 10_000) {
551 const gap = startedAt - prev.at
552 if (gap > TTL_5M_MS + 30_000 && gap < TTL_1H_MS - MINUTE_MS) {
553 const rewrote = e.usage.cache_creation_input_tokens >= 0.5 * prev.contextTokens
554 await update($, observedTtlAtom, () => (rewrote ? TTL_5M_MS : TTL_1H_MS))
555 }
556 }
557 // Only a turn that reached the API refreshes the cache; an early interrupt leaves the old clock.
558 if (e.usage) {
559 const model = e.usage.model
560 const contextTokens = usage.context.tokens ?? prev?.contextTokens ?? 0
561 await update($, touchAtom, () => ({ at: now, contextTokens, model }))
562 }
563
564 const handoff = await read($, handoffAtom)
565 if (handoff.status === 'pending') {
566 const isAnswered = e.reason === 'answer' && e.answer.trim() !== ''
567 await update($, handoffAtom, () =>
568 isAnswered ? { status: 'ready', text: e.answer } : { status: 'idle', text: '' },
569 )
570 $.ui.toast(isAnswered ? 'Handoff ready: press Clear & continue.' : 'Handoff did not finish.')
571 }
572
573 if (!(await read($, autoOpenedAtom))) {
574 await update($, autoOpenedAtom, () => true)
575 void $.ui.open({ id: PANE_ID, title: PANE_TITLE })
576 }
577 await tick($, config)
578 // Only after a finished answer: an interrupted turn means the person is steering, so leave the context alone.
579 if (e.reason === 'answer') $.clock.after(AUTO_COMPACT_DELAY_MS, () => void autoCompact($, config))
580
581 return result
582 })
583
584 on('session.compact', async ($, e, next) => {
585 const result = await next(e)
586 if (result.skip !== undefined) return result
587 if (result.usage) {
588 const ttl = await resolveTtl($, config, (await $.session.usage()).rateLimits)
589 const units = weightedUnits(result.usage, ttl.ms)
590 await update($, calibrationAtom, prev => (prev ? { ...prev, units: prev.units + units } : prev))
591 }
592 // The summarized conversation is a new prefix: nothing of it is cached until the next request.
593 await update($, touchAtom, () => null)
594 await update($, compactRefusedAtAtom, () => null)
595 void tick($, config)
596
597 return result
598 })
599
600 on('session.end', async ($, e, next) => {
601 if (e.reason === 'clear') {
602 await update($, touchAtom, () => null)
603 await update($, isRunningAtom, () => false)
604 await update($, turnStartedAtAtom, () => null)
605 await update($, warnedForAtom, () => null)
606 void tick($, config)
607 }
608
609 return next(e)
610 })
611
612 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
613 const { Box, Button, Text } = $.ui.resolve(e)
614 const view = await computeView($, config)
615 const handoff = await read($, handoffAtom)
616 const ttlLabel = view.ttlMs >= TTL_1H_MS ? '1h' : '5m'
617 const hasActions = view.phase !== 'running' && view.phase !== 'empty' && handoff.status === 'idle'
618 // The − / + buttons and "Turn on" start from the point in force, else this model's default.
619 const compactBase = view.compact.at ?? defaultCompactAt(view.contextWindow) ?? COMPACT_FLOOR_TOKENS
620 const compactCeiling = view.contextWindow > 0 ? view.contextWindow : Number.POSITIVE_INFINITY
621
622 return (
623 <Box flexDirection="column">
624 <Text bold color={PHASE_COLOR[view.phase]}>
625 {headline(view)}
626 </Text>
627 <Text dimColor>
628 TTL {ttlLabel} ({view.ttlSource}) · re-warm if cold ≈{formatUsd(view.rewarmUsd)} ({view.priceSource} price)
629 </Text>
630 <Text> </Text>
631 <Text>
632 Context {formatTokens(view.contextTokens)} / {formatTokens(view.contextWindow)}
633 {view.contextPercent !== undefined ? ` (${view.contextPercent}%)` : ''}
634 </Text>
635 {view.rateLimits.map(limit => (
636 <Text>
637 {LIMIT_LABEL[limit.kind] ?? limit.kind} {percentLeft(limit)}% left
638 {formatUntil(limit.resetsAt, view.now) ? ` · resets in ${formatUntil(limit.resetsAt, view.now)}` : ''}
639 </Text>
640 ))}
641 {view.costUsd !== undefined && <Text>Session at API rates {formatUsd(view.costUsd)}</Text>}
642
643 {/* Auto-compact gets its own titled section so its buttons don't read as part of the actions below. */}
644 <Box flexDirection="column" marginTop={1}>
645 <Text bold>Auto-compact</Text>
646 <Text dimColor>{autoCompactSummary(view)}</Text>
647 </Box>
648 <Box flexDirection="row" gap={2}>
649 <Button
650 key="compact-less"
651 label={`Sooner −${formatTokens(COMPACT_STEP_TOKENS)}`}
652 onPress={() =>
653 runAction($, 'auto-compact', () =>
654 setSessionCompact($, Math.max(COMPACT_STEP_TOKENS, compactBase - COMPACT_STEP_TOKENS)),
655 )
656 }
657 />
658 <Button
659 key="compact-more"
660 label={`Later +${formatTokens(COMPACT_STEP_TOKENS)}`}
661 onPress={() =>
662 runAction($, 'auto-compact', () =>
663 setSessionCompact($, Math.min(compactCeiling, compactBase + COMPACT_STEP_TOKENS)),
664 )
665 }
666 />
667 <Button
668 key="compact-toggle"
669 label={view.compact.at === null ? 'Turn on' : 'Turn off'}
670 onPress={() =>
671 runAction($, 'auto-compact', () => setSessionCompact($, view.compact.at === null ? compactBase : 'off'))
672 }
673 />
674 {view.compact.source === 'session' && (
675 <Button
676 key="compact-reset"
677 label="Reset to default"
678 onPress={() => runAction($, 'auto-compact', () => setSessionCompact($, null))}
679 />
680 )}
681 </Box>
682
683 {hasActions && (
684 <Box flexDirection="column" marginTop={1}>
685 <Text bold>Actions</Text>
686 <Box flexDirection="row" gap={2}>
687 <Button key="warm" label="🔥 Keep warm" onPress={() => runAction($, 'keep warm', () => keepWarm($))} />
688 <Button key="compact" label="📦 Compact now" onPress={() => runAction($, 'compact', () => compactNow($, config))} />
689 <Button
690 key="handoff"
691 label="🤝 Handoff"
692 onPress={() => runAction($, 'handoff', () => startHandoff($, config))}
693 />
694 <Button key="save" label="📝 Save notes" onPress={() => runAction($, 'save', () => saveNotes($, config))} />
695 <Button key="status" label="🧭 Status" onPress={() => runAction($, 'status', () => askStatus($, config))} />
696 </Box>
697 </Box>
698 )}
699 {handoff.status === 'pending' && <Text color="yellow">Handoff running…</Text>}
700 {handoff.status === 'ready' && (
701 <Box flexDirection="row">
702 <Text color="green">Handoff ready ({formatTokens(handoff.text.length)} chars) </Text>
703 <Button
704 key="continue"
705 label="Clear & continue"
706 variant="primary"
707 onPress={() => runAction($, 'clear & continue', () => clearAndContinue($))}
708 />
709 <Text> </Text>
710 <Button key="discard" label="Discard" onPress={() => runAction($, 'discard', () => discardHandoff($))} />
711 </Box>
712 )}
713 </Box>
714 )
715 })
716
717 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
718 if (e.props.hasSurvey) return next(e)
719 const view = await computeView($, config)
720 const handoff = await read($, handoffAtom)
721
722 const { Box, Button, Text } = $.ui.resolve(e)
723 if (handoff.status === 'ready') {
724 return (
725 <Box flexDirection="row" justifyContent="space-between">
726 <Text color="green">Handoff ready.</Text>
727 <Box flexDirection="row" flexShrink={0} gap={1}>
728 <Button
729 key="band-continue"
730 label="Clear & continue"
731 variant="primary"
732 onPress={() => runAction($, 'clear & continue', () => clearAndContinue($))}
733 />
734 <Button key="band-discard" label="Discard" onPress={() => runAction($, 'discard', () => discardHandoff($))} />
735 </Box>
736 </Box>
737 )
738 }
739
740 // Always-on bar: the figures at a glance, then the actions (hidden before the first reply and while a turn runs).
741 const hasActions = view.phase !== 'empty' && view.phase !== 'running' && handoff.status === 'idle'
742 const isTerminal = e.surface === 'terminal'
743 const isCavemanOn = Boolean(await read($, cavemanAtom))
744 const hasStyle = await read($, styleAvailableAtom)
745 const isStyleOn = hasStyle && (await read($, styleAtom))
746 // Offered before the first reply too (the cheapest moment to switch), never while a turn runs.
747 const family = view.phase !== 'running' && handoff.status === 'idle' ? modelFamily(view.model) : null
748 const modelTarget = family === null ? null : MODEL_SWITCH[family]
749
750 const chips = [
751 ...bandChips(view),
752 ...(handoff.status === 'pending'
753 ? [{ text: 'handoff running…', tone: 'warn' as const, tip: 'Claude is writing the handoff note' }]
754 : []),
755 ]
756 // Each pill and button joins its own hover group; the matching tip is drawn over the pills while it is hovered.
757 const tips: { scope: string; text: string }[] = [
758 ...chips.map((chip, index) => ({ scope: `sb-chip-${index}`, text: chip.tip })),
759 ...(modelTarget ? [{ scope: 'sb-model', text: `Switch the model to ${modelTarget.to === 'opus' ? 'Opus 5.5' : 'Fable 5.1'}` }] : []),
760 { scope: 'sb-warm', text: '🔥 Keep warm: send a short turn that resets the cache timer' },
761 { scope: 'sb-compact', text: '📦 Compact now: run /compact with your instructions' },
762 { scope: 'sb-save', text: "📝 Save notes: update the project's working notes" },
763 { scope: 'sb-status', text: '🧭 Status: ask where we stand, where we left off and what is next' },
764 { scope: 'sb-handoff', text: '🤝 Handoff: write a note for a fresh session, then Clear & continue' },
765 { scope: 'sb-details', text: '📊 Details: cache TTL, limit resets, auto-compact settings' },
766 { scope: 'sb-caveman', text: `🪨 Caveman (terse replies) is ${isCavemanOn ? 'on: press to turn it off' : 'off: press to turn it on'}` },
767 ...(hasStyle
768 ? [{ scope: 'sb-style', text: `ASD writing rules (${config.styleSkill || 'from the mod settings'}) are ${isStyleOn ? 'on: press to turn them off' : 'off: press to turn them on'}` }]
769 : []),
770 ]
771
772 // Figures on the left (growing to fill the row), buttons pinned to the right edge.
773 // When both do not fit, the buttons wrap to a second row: overlapped by the pills they lose their clicks.
774 return (
775 <Box flexDirection="row" flexWrap="wrap" justifyContent="space-between">
776 {/*
777 One pill per group: gray text with a small colored dot for status.
778 Desktop: a faint rounded border; height={1} holds it to one text row, else the desktop pads the border.
779 Terminal: a bordered box needs three rows and height={1} clips the text away, so the pills are
780 plain text separated by a dim bar instead.
781 */}
782 <Box flexDirection="row" flexGrow={1} flexShrink={1} gap={isTerminal ? 0 : 1} position="relative">
783 {chips.map((chip, index) =>
784 isTerminal ? (
785 <Box flexDirection="row" flexShrink={0} hover={{ scope: `sb-chip-${index}` }}>
786 {index > 0 && <Text dimColor> │ </Text>}
787 {TONE_DOT_COLOR[chip.tone] && <Text color={TONE_DOT_COLOR[chip.tone]}>● </Text>}
788 <Text dimColor>{chip.text}</Text>
789 </Box>
790 ) : (
791 <Box
792 flexDirection="row"
793 flexShrink={0}
794 borderStyle="round"
795 borderDimColor
796 paddingX={1}
797 height={1}
798 hover={{ scope: `sb-chip-${index}` }}
799 >
800 {TONE_DOT_COLOR[chip.tone] && <Text color={TONE_DOT_COLOR[chip.tone]}>● </Text>}
801 <Text dimColor>{chip.text}</Text>
802 </Box>
803 ),
804 )}
805 {/* Hidden until its group is hovered; absolute, so revealing it moves nothing and covers the pills. */}
806 {tips.map(tip => (
807 <Box
808 position="absolute"
809 top={0}
810 left={0}
811 width="100%"
812 display="none"
813 backgroundColor="text"
814 hover={{ scope: tip.scope, display: 'flex' }}
815 >
816 <Text color="inverseText"> {tip.text} </Text>
817 </Box>
818 ))}
819 </Box>
820 {/* One-glyph buttons to save width; the hover tip and the Details pane spell out what each does. */}
821 <Box flexDirection="row" flexShrink={0} gap={1}>
822 {modelTarget && (
823 <Button
824 key="band-model"
825 label={modelTarget.label}
826 hover={{ scope: 'sb-model' }}
827 onPress={() => runAction($, 'model switch', () => switchModel($, config))}
828 />
829 )}
830 {hasActions && view.phase !== 'cold' && (
831 <Button key="band-warm" label="🔥" hover={{ scope: 'sb-warm' }} onPress={() => runAction($, 'keep warm', () => keepWarm($))} />
832 )}
833 {hasActions && (
834 <Button
835 key="band-compact"
836 label="📦"
837 hover={{ scope: 'sb-compact' }}
838 onPress={() => runAction($, 'compact', () => compactNow($, config))}
839 />
840 )}
841 {hasActions && (
842 <Button key="band-save" label="📝" hover={{ scope: 'sb-save' }} onPress={() => runAction($, 'save', () => saveNotes($, config))} />
843 )}
844 {hasActions && (
845 <Button
846 key="band-status"
847 label="🧭"
848 hover={{ scope: 'sb-status' }}
849 onPress={() => runAction($, 'status', () => askStatus($, config))}
850 />
851 )}
852 {hasActions && (
853 <Button
854 key="band-handoff"
855 label="🤝"
856 hover={{ scope: 'sb-handoff' }}
857 onPress={() => runAction($, 'handoff', () => startHandoff($, config))}
858 />
859 )}
860 <Button key="band-details" label="📊" hover={{ scope: 'sb-details' }} onPress={() => runAction($, 'details', () => openPane($))} />
861 {/* Shown in every phase: the toggle writes a flag file and submits nothing. */}
862 <Button
863 key="band-caveman"
864 label={isCavemanOn ? '🪨 on' : '🪨 off'}
865 dimColor={!isCavemanOn}
866 hover={{ scope: 'sb-caveman' }}
867 onPress={() => runAction($, 'caveman toggle', () => toggleCaveman($))}
868 />
869 {hasStyle && (
870 <Button
871 key="band-style"
872 label={isStyleOn ? 'ASD on' : 'ASD off'}
873 dimColor={!isStyleOn}
874 hover={{ scope: 'sb-style' }}
875 onPress={() => runAction($, 'writing-rules toggle', () => toggleStyle($))}
876 />
877 )}
878 </Box>
879 </Box>
880 )
881 })
882}
883hooks/auto-compact-policy.ts 74 lines1// When session-band compacts by itself: the research-backed default point per context window,
2// the per-session and global overrides, and the guard that keeps a refused compaction from retrying every turn.
3import { formatTokens } from './cache-math'
4
5/** A compact point in context tokens, or auto-compact turned off. */
6export type CompactSetting = number | 'off'
7
8export type CompactSource = 'session' | 'setting' | 'default'
9
10export type CompactPoint = { at: number | null; source: CompactSource }
11
12// Long-context recall on Claude models falls off well before a 1M window fills (MRCR v2: ~93% at
13// 256k, ~76% at 1M), so 1M-window models compact at a fixed 300k rather than a share of the window.
14const LARGE_WINDOW_TOKENS = 500_000
15const LARGE_WINDOW_COMPACT_AT = 300_000
16// Smaller windows (200k) compact at 70%, early enough to leave room for the summary turn itself.
17const SMALL_WINDOW_SHARE = 0.7
18// Below this, a compaction saves less than the fresh cache write it causes; the default never fires lower.
19export const COMPACT_FLOOR_TOKENS = 100_000
20// After a compaction is refused, wait for this much more context before asking again.
21export const COMPACT_REGROW_TOKENS = 50_000
22// A typed point below this is a typo (a "50" meant as 50k), not a request to compact every turn.
23const MIN_TYPED_TOKENS = 10_000
24// Step of the pane's − / + buttons.
25export const COMPACT_STEP_TOKENS = 50_000
26
27/** The research default for a model with this context window; null when the window is unknown. */
28export function defaultCompactAt(contextWindow: number): number | null {
29 if (!(contextWindow > 0)) return null
30 if (contextWindow >= LARGE_WINDOW_TOKENS) return LARGE_WINDOW_COMPACT_AT
31 return Math.max(COMPACT_FLOOR_TOKENS, Math.round(contextWindow * SMALL_WINDOW_SHARE))
32}
33
34/**
35 * Reads "off", "auto", "250k", "1.2m" or "250000"; undefined when it is none of these.
36 * "auto" means: no override at this level.
37 */
38export function parseCompactSetting(raw: string): CompactSetting | 'auto' | undefined {
39 const text = raw.trim().toLowerCase().replace(/[\s,_]/g, '')
40 if (text === 'off' || text === 'auto') return text
41 const match = /^(\d+(?:\.\d+)?)([km]?)$/.exec(text)
42 if (!match) return undefined
43 const tokens = Math.round(Number(match[1]) * (match[2] === 'm' ? 1e6 : match[2] === 'k' ? 1e3 : 1))
44 return tokens >= MIN_TYPED_TOKENS ? tokens : undefined
45}
46
47/** The point in force: this session's override, else the mod setting, else the research default. */
48export function resolveCompactPoint(
49 session: CompactSetting | null,
50 setting: CompactSetting | 'auto',
51 contextWindow: number,
52): CompactPoint {
53 if (session !== null) return { at: session === 'off' ? null : session, source: 'session' }
54 if (setting !== 'auto') return { at: setting === 'off' ? null : setting, source: 'setting' }
55 return { at: defaultCompactAt(contextWindow), source: 'default' }
56}
57
58/** True when the context has reached the point and has grown enough since a refused attempt. */
59export function shouldAutoCompact(contextTokens: number, at: number | null, refusedAt: number | null): boolean {
60 if (at === null || contextTokens < at) return false
61 return refusedAt === null || contextTokens >= refusedAt + COMPACT_REGROW_TOKENS
62}
63
64const SOURCE_LABEL: Record<CompactSource, string> = {
65 session: 'this session',
66 setting: 'mod setting',
67 default: 'default for this model',
68}
69
70/** "300k (default for this model)" or "off (this session)". */
71export function describeCompactPoint(point: CompactPoint): string {
72 return `${point.at === null ? 'off' : formatTokens(point.at)} (${SOURCE_LABEL[point.source]})`
73}
74hooks/cache-math.ts 83 lines1// Pure helpers for session-band: TTLs, Anthropic prompt-cache price multiples, and formatting.
2import type { ModelUsage } from 'claude-code'
3
4import type { CacheCalibration } from '../types'
5
6export const MINUTE_MS = 60_000
7export const TTL_1H_MS = 60 * MINUTE_MS
8export const TTL_5M_MS = 5 * MINUTE_MS
9
10// Prompt-cache prices are fixed multiples of a model's base input price:
11// a cache read costs 0.1x, a cache write 1.25x (5m TTL) or 2x (1h TTL), output 5x.
12const CACHE_READ_MULTIPLE = 0.1
13const OUTPUT_MULTIPLE = 5
14
15export const cacheWriteMultiple = (ttlMs: number): number => (ttlMs >= TTL_1H_MS ? 2 : 1.25)
16
17/** A response's tokens expressed in base-input-price units, so cost / units = base price per token. */
18export function weightedUnits(usage: ModelUsage, ttlMs: number): number {
19 return (
20 usage.input_tokens +
21 CACHE_READ_MULTIPLE * usage.cache_read_input_tokens +
22 cacheWriteMultiple(ttlMs) * usage.cache_creation_input_tokens +
23 OUTPUT_MULTIPLE * usage.output_tokens
24 )
25}
26
27/** List base input price (USD per token) by model family; the fallback before the ledger has calibrated. */
28function listInputPricePerToken(model: string): number {
29 const id = model.toLowerCase()
30 if (id.includes('haiku')) return 1e-6
31 if (id.includes('sonnet')) return 3e-6
32 return 5e-6
33}
34
35// Enough weighted tokens that the ledger ratio is not dominated by one tiny side request.
36const MIN_CALIBRATION_UNITS = 50_000
37
38export type InputPrice = { perToken: number; source: 'config' | 'ledger' | 'list' }
39
40/** Picks the base input price: configured value, else calibrated from the cost ledger, else list price. */
41export function resolveInputPrice(
42 configuredPerMTok: number,
43 calibration: CacheCalibration | null,
44 costNowUsd: number | undefined,
45 model: string,
46): InputPrice {
47 if (configuredPerMTok > 0) return { perToken: configuredPerMTok / 1e6, source: 'config' }
48 if (calibration && costNowUsd !== undefined && calibration.units >= MIN_CALIBRATION_UNITS) {
49 const spent = costNowUsd - calibration.costStart
50 if (spent > 0) return { perToken: spent / calibration.units, source: 'ledger' }
51 }
52 return { perToken: listInputPricePerToken(model), source: 'list' }
53}
54
55export const formatUsd = (usd: number): string =>
56 usd >= 100 ? `$${usd.toFixed(0)}` : usd >= 10 ? `$${usd.toFixed(1)}` : `$${usd.toFixed(2)}`
57
58export const formatTokens = (tokens: number): string =>
59 tokens >= 1e6 ? `${(tokens / 1e6).toFixed(2)}M` : tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : `${tokens}`
60
61/** Countdown text with prime marks to save width: 59', then 1'30" and 45" in the last two minutes. */
62export function formatCountdown(ms: number): string {
63 if (ms <= 0) return '0"'
64 const minutes = Math.floor(ms / MINUTE_MS)
65 if (minutes >= 2) return `${minutes}'`
66 const seconds = Math.ceil(ms / 1000)
67 return minutes > 0 ? `${minutes}'${String(seconds % 60).padStart(2, '0')}"` : `${seconds}"`
68}
69
70/** Time until an ISO timestamp, as "2h 10'" / "3d 4h"; empty when unknown or past. */
71export function formatUntil(iso: string | undefined, now: number): string {
72 if (!iso) return ''
73 const ms = Date.parse(iso) - now
74 if (!(ms > 0)) return ''
75 const totalMinutes = Math.round(ms / MINUTE_MS)
76 const days = Math.floor(totalMinutes / 1440)
77 const hours = Math.floor((totalMinutes % 1440) / 60)
78 const minutes = totalMinutes % 60
79 if (days > 0) return `${days}d ${hours}h`
80 if (hours > 0) return `${hours}h ${minutes}'`
81 return `${minutes}'`
82}
83hooks/cache-view-model.ts 194 lines1// The shape session-band's pane, band and status line share, and the pure text built from it.
2import type { SessionRateLimit } from 'claude-code'
3
4import { describeCompactPoint } from './auto-compact-policy'
5import type { CompactPoint, CompactSetting } from './auto-compact-policy'
6import { formatCountdown, formatTokens, formatUsd } from './cache-math'
7import type { InputPrice } from './cache-math'
8
9export type KeeperConfig = {
10 cacheTtl: string
11 warnMs: number
12 inputPricePerMTok: number
13 handoffCommand: string
14 autoCompact: CompactSetting | 'auto'
15 /** /compact's argument for the 📦 button and auto-compact; empty for Claude Code's default summary. */
16 compactInstructions: string
17 /** Skill the 📝 button runs to update the project's working notes; empty sends a built-in prompt. */
18 saveCommand: string
19 /** Prompt the 🧭 button sends to ask where the work stands; empty sends a built-in prompt. */
20 statusPrompt: string
21 /** Writing rules the ASD toggle attaches to every prompt while on; empty hides the toggle. */
22 styleRules: string
23 /** Skill the rules belong to: the toggle shows only while it is installed; empty shows it always. */
24 styleSkill: string
25}
26
27/** empty: nothing cached yet · running: a turn keeps it warm · cooling: inside the warning window. */
28export type CachePhase = 'empty' | 'running' | 'warm' | 'cooling' | 'cold'
29
30export type TtlSource = 'config' | 'observed' | 'assumed'
31
32export type KeeperView = {
33 phase: CachePhase
34 /** The main loop's model, as /model shows it. */
35 model: string
36 now: number
37 remainingMs: number
38 ttlMs: number
39 ttlSource: TtlSource
40 touchAt: number | null
41 contextTokens: number
42 contextWindow: number
43 contextPercent?: number
44 rewarmUsd: number
45 priceSource: InputPrice['source']
46 rateLimits: SessionRateLimit[]
47 costUsd?: number
48 compact: CompactPoint
49}
50
51export const PHASE_COLOR: Record<CachePhase, string> = {
52 empty: 'gray',
53 running: 'green',
54 warm: 'green',
55 cooling: 'yellow',
56 cold: 'red',
57}
58
59export const LIMIT_LABEL: Record<string, string> = { five_hour: '5-hour limit', seven_day: 'Weekly limit' }
60
61/** The pane's first line. */
62export function headline(view: KeeperView): string {
63 switch (view.phase) {
64 case 'empty':
65 return 'No cache yet: it starts with the first reply'
66 case 'running':
67 return 'Cache warm: turn running'
68 case 'cold':
69 return `Cache cold: next prompt re-writes ${formatTokens(view.contextTokens)} tokens`
70 default:
71 return `Cache warm: ${formatCountdown(view.remainingMs)} left`
72 }
73}
74
75/** Share of a rate-limit window still available, in whole percent. */
76export const percentLeft = (limit: SessionRateLimit): number => Math.max(0, Math.round(100 - limit.percentUsed))
77
78/** How a bar chip reads: fine, worth watching, act now, or plain information. */
79export type ChipTone = 'good' | 'warn' | 'bad' | 'neutral'
80
81/**
82 * The status dot's color as a theme key, so it follows the light and dark themes. The pill
83 * text stays gray; this small dot is the only color on the bar. Neutral pills get no dot.
84 */
85export const TONE_DOT_COLOR: Record<ChipTone, string | undefined> = {
86 good: 'success',
87 warn: 'warning',
88 bad: 'error',
89 neutral: undefined,
90}
91
92/** One pill on the bar; `tip` is the line shown while the pointer is over it. */
93export type BandChip = { text: string; tone: ChipTone; tip: string }
94
95const PHASE_TONE: Record<CachePhase, ChipTone> = {
96 empty: 'neutral',
97 running: 'good',
98 warm: 'good',
99 cooling: 'warn',
100 cold: 'bad',
101}
102
103const SHORT_LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: 'wk' }
104const LIMIT_TIP: Record<string, string> = {
105 five_hour: 'What is left of the 5-hour usage limit',
106 seven_day: 'What is left of the weekly usage limit',
107}
108
109const CACHE_TIP: Record<CachePhase, string> = {
110 empty: 'Prompt cache: it starts with the first reply',
111 running: 'Prompt cache: a running turn keeps it warm',
112 warm: 'Prompt cache: time until it goes cold · cost to re-warm it if it does',
113 cooling: 'Prompt cache: about to go cold · cost to re-warm it if it does',
114 cold: 'Prompt cache is cold: the next prompt re-writes the whole context at about this cost',
115}
116
117/** Plenty left above half, getting low down to a fifth, nearly out below that. */
118const limitTone = (left: number): ChipTone => (left > 50 ? 'good' : left >= 20 ? 'warn' : 'bad')
119
120/** The cache chip: countdown and re-warm cost. Kept terse; the pane has the long form. */
121function cacheChipText(view: KeeperView): string {
122 const rewarm = formatUsd(view.rewarmUsd)
123 switch (view.phase) {
124 case 'empty':
125 return 'cache after reply'
126 case 'running':
127 return 'cache warm'
128 case 'cold':
129 return `cold · ${rewarm}`
130 default:
131 return `${formatCountdown(view.remainingMs)} · ${rewarm}`
132 }
133}
134
135// The context chip turns yellow once the context passes this share of the auto-compact point.
136const COMPACT_NEAR_SHARE = 0.8
137
138/** "ctx 159k/300k" against the auto-compact point while it is on; "ctx 159k 16%" of the window while off. */
139function contextChip(view: KeeperView): BandChip {
140 const tokens = `ctx ${formatTokens(view.contextTokens)}`
141 const at = view.compact.at
142 if (at === null) {
143 return {
144 text: `${tokens}${view.contextPercent !== undefined ? ` ${view.contextPercent}%` : ''}`,
145 tone: 'neutral',
146 tip: 'Context tokens · share of the context window (auto-compact is off)',
147 }
148 }
149 return {
150 text: `${tokens}/${formatTokens(at)}`,
151 tone: view.contextTokens >= at * COMPACT_NEAR_SHARE ? 'warn' : 'neutral',
152 tip: 'Context tokens / the point where auto-compact runs',
153 }
154}
155
156/** The pane's auto-compact line: "Compacts at 300k (default for this model) · 84k to go", or why it won't. */
157export function autoCompactSummary(view: KeeperView): string {
158 if (view.compact.at === null) return `Off (${view.compact.source === 'session' ? 'this session' : 'mod setting'})`
159 const toGo = view.compact.at - view.contextTokens
160 return `Compacts at ${describeCompactPoint(view.compact)} · ${toGo > 0 ? `${formatTokens(toGo)} to go` : 'after the next reply'}`
161}
162
163/** "claude-fable-5-1" reads "fable-5-1" on the bar: the vendor prefix and a release date say nothing there. */
164export const shortModel = (model: string): string => model.replace(/^claude-/i, '').replace(/-\d{8}(?=$|\[)/, '')
165
166/** The two model families the band's switch button moves between. */
167export type ModelFamily = 'fable' | 'opus'
168
169/** Which side of the Fable ↔ Opus switch a model id is on; null for any other model. */
170export const modelFamily = (model: string): ModelFamily | null =>
171 /fable/i.test(model) ? 'fable' : /opus/i.test(model) ? 'opus' : null
172
173/** The bar's chips, one per group: model, cache, context fill, what is left of each rate-limit window, then the session's cost at API rates. */
174export function bandChips(view: KeeperView): BandChip[] {
175 const limits = view.rateLimits.map((limit): BandChip => {
176 const left = percentLeft(limit)
177 return {
178 text: `${SHORT_LIMIT_LABEL[limit.kind] ?? limit.kind} ${left}%`,
179 tone: limitTone(left),
180 tip: LIMIT_TIP[limit.kind] ?? `What is left of the ${limit.kind} usage limit`,
181 }
182 })
183
184 return [
185 ...(view.model ? [{ text: shortModel(view.model), tone: 'neutral' as const, tip: 'Model running this session' }] : []),
186 { text: cacheChipText(view), tone: PHASE_TONE[view.phase], tip: CACHE_TIP[view.phase] },
187 contextChip(view),
188 ...limits,
189 ...(view.costUsd !== undefined
190 ? [{ text: `session ${formatUsd(view.costUsd)}`, tone: 'neutral' as const, tip: "This session's cost at API rates" }]
191 : []),
192 ]
193}
194types/index.d.ts 43 lines1/** The last main-thread API activity: when the prompt cache was last refreshed and how big it is. */
2export type CacheTouch = { at: number; contextTokens: number; model: string }
3
4/** Handoff flow: idle → pending (handoff turn running) → ready (text captured, waiting for Clear & continue). */
5export type CacheHandoff = { status: 'idle' | 'pending' | 'ready'; text: string }
6
7/** Price calibration from the cost ledger: cost at the first observation and weighted token units since. */
8export type CacheCalibration = { costStart: number; units: number }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'session-band': {
13 touch: CacheTouch | null
14 isRunning: boolean
15 turnStartedAt: number | null
16 now: number
17 observedTtlMs: number | null
18 warnedFor: number | null
19 autoOpened: boolean
20 handoff: CacheHandoff
21 calibration: CacheCalibration | null
22 /** This session's auto-compact point in tokens, 'off', or null for the mod setting / research default. */
23 compactAt: number | 'off' | null
24 /** Context tokens when an auto-compaction was last refused; null once one goes through. */
25 compactRefusedAt: number | null
26 /** The caveman plugin's flag as last read: its level, '' while off, null before the first read. */
27 caveman: string | null
28 /** The level the band's toggle switches caveman back on at: the last one seen on. */
29 cavemanResume: string
30 /** Whether caveman was on when the model was last told; null until a prompt or a toggle records it. */
31 cavemanTold: boolean | null
32 /** The writing-rules toggle's flag as last read. */
33 style: boolean
34 /** Whether the writing rules were on when the model was last told; null until a prompt or a toggle records it. */
35 styleTold: boolean | null
36 /** Whether the writing-rules toggle is offered: rules are set and their skill is installed. */
37 styleAvailable: boolean
38 /** The model id last seen on each side of the Fable ↔ Opus switch, so switching back restores it exactly. */
39 modelSeen: { fable?: string; opus?: string }
40 }
41 }
42}
43