SLOPSHOPPER

session-band

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

newpanebandcommandtoaststatus
v0.2.1MITupdated 2026-10-06einaruk/claude-session-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-band
│ ┃ Session band ✕ › fix the failing auth test and add an audit log call │ ┃ Cache warm: 59' left │ ┃ TTL 1h (assumed) · re-warm if cold ≈$0.97 ⏺ Read(src/auth.ts) │ ┃ (list price) ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Context 97k / 200k (49%) ⎿ Added 2 lines, removed 1 line │ ┃ 5-hour limit 69% left ⏺ Bash(bun test) │ ┃ Session at API rates $0.42 ⎿ 3 pass, 1 fail │ ┃ │ ┃ Auto-compact ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Off (mod setting) │ ┃ [ Sooner −50k ] [ Later +50k ] [ Turn on ] ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Actions › /session-band │ ┃ [ 🔥 Keep warm ] [ 📦 Compact now ] [ 🤝 H ⎿ session-band: session-band: open │ │ opus-5-5 │ ● 59' · $0.97 │ ctx 97k 49% │ ● 5h 69% │ session $0.42[ F ] [ 🔥 ] [ 📦 ] [ 📝 ] [ 🧭 ] [ 🤝 ] [ 📊 ] [ 🪨 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
opus-5-5 │ ● 59' · $0.97 │ ctx 97k 49% │ ● 5h 69% │ session $0.42[ F ] [ 🔥 ] [ 📦 ] [ 📝 ] [ 🧭 ] [
Pane · Session band
Cache warm: 59' left TTL 1h (assumed) · re-warm if cold ≈$0.97 (list price) Context 97k / 200k (49%) 5-hour limit 69% left Session at API rates $0.42 Auto-compact Off (mod setting) [ Sooner −50k ] [ Later +50k ] [ Turn on ] Actions [ 🔥 Keep warm ] [ 📦 Compact now ] [ 🤝 Handoff ] [ 📝 S
README

claude-session-band

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.

session-band: the band above the prompt (terminal)

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.

Install

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.

Install instructions for an agent

This section is for a Claude Code agent installing the mod on the user's behalf. Follow the steps in order:

  1. Run claude --version. Function-hook mod support is required; tested with 2.1.291. If older, suggest claude update to the user and stop.
  2. Run claude plugin marketplace add einaruk/claude-session-band.
  3. Run claude plugin install session-band@session-band.
  4. Confirm that session-band appears in the output of claude plugin list.
  5. Tell the user to restart Claude Code; the band shows up after the first reply. In a new session, /session-band opens the details pane.
  6. Do not edit 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).

Alternative: from a local folder

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.

What is on the 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)

#FigureExampleMeaning
1Modelopus-5-5The model running the session
2Cache47' · $0.85Time 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
3Contextctx 159k/300kTokens in context / auto-compact point (when off: ctx 159k 16% = share of the window)
4Limits5h 72% · wk 40%What is left of the 5-hour and weekly usage limits (shown on a subscription)
5Session costsession $3.20The session's cost at API rates

Table 2 (v1.1) — Band buttons

#IconWhat it does
1O / FSwitch 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/offToggles the caveman plugin (terse replies). Without the plugin it only writes a flag file and has no effect
9ASD on/offToggles 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>]

Settings

.claude-plugin/plugin.json → userConfig (also editable from Claude Code's plugin settings):

Table 3 (v1.1) — Settings

#SettingDefaultNote
1cacheTtlauto1h on a subscription, 5m otherwise; corrected by what the API actually served after a gap
2warnMinutes5How many minutes before the cache goes cold to warn
3inputPricePerMTok00 = calibrate from the session's own cost ledger
4autoCompactoffauto = 300k on 1M-context models, 70% of smaller windows; or a fixed point such as 250k
5handoffCommandanthropic-skills:context-handoffFalls back to a built-in handoff prompt when missing
6saveCommandemptySkill the 📝 button runs; empty sends a built-in prompt
7statusPromptemptyPrompt the 🧭 button sends; empty sends a built-in English prompt
8compactInstructions"what to keep / what to drop" summary instructionsPassed as the argument to 📦 and to auto-compact
9styleRulessummary of the tr-sade-teknik-dil rulesText the ASD toggle attaches to every prompt while on; empty hides the toggle
10styleSkilltr-sade-teknik-dilThe ASD toggle shows only while this skill is installed; empty shows it whenever rules are set

Credits and license

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.

Source 5 files
hooks/register.tsx 883 lines
1// 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}
883
hooks/auto-compact-policy.ts 74 lines
1// 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}
74
hooks/cache-math.ts 83 lines
1// 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}
83
hooks/cache-view-model.ts 194 lines
1// 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}
194
types/index.d.ts 43 lines
1/** 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