SLOPSHOPPER

session-health

Context, 5-hour and weekly usage meters above the prompt, with compact / fresh-start advice and auto-loading hand-offs.

newbandcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-health
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ session-health │ ⏺ Read(src/auth.ts) │ ◐ Getting heavy — consider /compact at a │ ⎿ Read 6 lines │ natural break │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ╭─────────────────────────────╮ ⏺ Bash(bun test) │ session-health │ ⎿ 3 pass, 1 fail │ Hand-off ready — run /clear │ ╰─────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /handoff ⎿ session-health: Hand-off written to .claude/handoff.md — run /clear to start fresh. CONTEXT ████▉░░░░░ 49% 97k/200k ~— turns SESSION ███▏░░░░░░ 31% ◐ getting heavy — /compact at a natural break [ compact ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
CONTEXT ████▉░░░░░ 49% 97k/200k ~— turns SESSION ███▏░░░░░░ 31% ◐ getting heavy — /compact at a natural break [ compact ]
README

session-health

A Claude Code mod that keeps an eye on your session from a band above the prompt:

 CONTEXT ███████▌░░ 75% 150k/200k ~3 turns ⟲2   SESSION ███▎░░░░░░ 31% · 2h14m   WEEK ██████▍░░░ 64% · 3d
 ◑ compact now — auto-compact in ~3 turns                                        [ compact ] [ fresh start ]
  • Meters for the context window, the 5-hour session budget and the weekly budget: lime (#aaff00), amber from 70%, coral from 90%.
  • A verdict — healthy → getting heavy → compact now → start fresh — from context %, turns left until auto-compact, and how often you've compacted. A ⚠ budget warning shows when either budget passes 90%.
  • [compact] / /smart-compact runs a smart compact that keeps your goal, open tasks, decisions and files in play. The command works any time, even when the button isn't showing. In a terminal, click the button (fullscreen layout) or press ctrl+x tab to focus the band, then c.
  • [fresh start] / /handoff writes .claude/handoff.md in the background. Run /clear and your next prompt arrives with the brief attached, so the new conversation starts already briefed. Each brief loads once, within 24 hours. With the band focused (ctrl+x tab), f presses [fresh start].
  • Toasts fire once when the verdict escalates or a budget crosses 75% / 90%.

The band shrinks gracefully on narrow terminals, down to 58% · 31% · 64% ●.

Install

/plugin install session-health --marketplace useloadflow-dev/session-health-claude-code-mod

Answer y to add the marketplace, then pick a scope.

Hand-off briefs are written to .claude/handoff.md in each project. You'll probably want that file out of git:

echo .claude/handoff.md >> .gitignore

Settings

Change these in /config:

SettingDefaultMeaning
heavyPct45Context % that counts as "getting heavy"
compactPct60Context % that says "compact now"
heavyTurns8Turns left before auto-compact that count as "getting heavy"
compactTurns4Turns left before auto-compact that say "compact now"
freshCompactions2Compactions after which a heavy session should start fresh
freshTurns150Turns after which a heavy session should start fresh
budgetWarnPct905-hour / weekly % that shows the budget warning

The SESSION and WEEK meters appear only on a Claude subscription. API-key sessions show the context meter alone.

The SESSION and WEEK meters sync across your open sessions every 15s, so an idle session shows the latest reading any session has seen.

Development

claude plugin validate session-health
claude plugin test session-health

License

MIT

Source 9 files
hooks/register.tsx 358 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3import type { HandoffPhase, Phase, Snapshot, ToastMemory } from '../types'
4import { Band } from './band.tsx'
5import { fitLine1, line2, type Action, type BandModel } from './layout.ts'
6import { DEFAULTS, autoCompactPoint, countsCompaction, turnsLeft, verdict, type Thresholds } from './verdict.ts'
7import { toastsFor } from './toasts.ts'
8import { BUDGETS_KEY, SYNC_MS, current, isBudgets, newer, sameWindow, type Budgets } from './budgets.ts'
9import {
10  COMPACT_INSTRUCTIONS,
11  briefPrompt,
12  handoffPath,
13  injection,
14  shouldLoad,
15  storeKey,
16  transcriptPrompt,
17  type HandoffRecord,
18} from './handoff.ts'
19
20type $ = EngineInterface
21
22const snapshot = atom({ plugin: 'session-health', key: 'snapshot' } as const, null as Snapshot | null)
23const history = atom({ plugin: 'session-health', key: 'history' } as const, [] as number[])
24const compactions = atom({ plugin: 'session-health', key: 'compactions' } as const, 0)
25const turns = atom({ plugin: 'session-health', key: 'turns' } as const, 0)
26const handoff = atom({ plugin: 'session-health', key: 'handoff' } as const, { phase: 'idle' } as HandoffPhase)
27const toasted = atom({ plugin: 'session-health', key: 'toasted' } as const, { level: 'healthy', budget: {} } as ToastMemory)
28
29const handoffRef = { plugin: 'session-health', key: 'handoff' } as const
30
31const STALE_MS = 5 * 60 * 1000
32const READY_TURNS = 3
33
34const setPhase = ($: $, phase: Phase, at?: number) =>
35  update($, handoff, (): HandoffPhase => (at === undefined ? { phase } : { phase, at }))
36
37// Settles a running phase without disturbing one another action has moved on to.
38const endPhase = ($: $, running: Phase) =>
39  update($, handoff, (h): HandoffPhase => (h.phase === running ? { phase: 'idle' } : h))
40
41// The only way into 'writing': one claim wins even when two presses race.
42let writing = false
43async function claimWriting($: $, now: number): Promise<boolean> {
44  if (writing) return false
45  writing = true
46  const held = await $.state.get(handoffRef)
47  const h = held.value
48  const busy = h?.phase === 'writing' && now - (h.at ?? 0) < STALE_MS
49  if (busy) {
50    writing = false
51    return false
52  }
53  const set = await $.state.set(handoffRef, { phase: 'writing', at: now }, { ifVersion: held.version })
54  if (!set.isSet) writing = false
55  return set.isSet
56}
57
58type Measured = {
59  context: { tokens?: number; window: number; percent?: number }
60  rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
61}
62
63async function autoCompactAt($: $, window: number): Promise<number | undefined> {
64  try {
65    const usage = await $.session.usage({ breakdown: 'summary' })
66    return autoCompactPoint(usage.context.breakdown, window)
67  } catch {
68    return window
69  }
70}
71
72// Set once a response arrives with no rate limits: off a subscription, so other sessions' budgets are not ours.
73let offPlan = false
74
75async function sharedBudgets($: $): Promise<Budgets | undefined> {
76  try {
77    const v = await $.store.get(BUDGETS_KEY)
78    return isBudgets(v) ? v : undefined
79  } catch {
80    return undefined
81  }
82}
83
84// `heard`: the figures come from a response that just arrived, so they are the account's latest.
85async function record($: $, m: Measured, heard: boolean) {
86  const win = (kind: string) => {
87    const r = m.rateLimits.find(x => x.kind === kind)
88    if (!r) return undefined
89    return r.resetsAt === undefined ? { percent: r.percentUsed } : { percent: r.percentUsed, resetsAt: r.resetsAt }
90  }
91  const now = await $.clock.now()
92  const snap: Snapshot = { window: m.context.window }
93  const at = await autoCompactAt($, m.context.window)
94  if (at !== undefined) snap.autoCompactAt = at
95  if (m.context.percent !== undefined) snap.percent = m.context.percent
96  if (m.context.tokens !== undefined) snap.tokens = m.context.tokens
97
98  let own: Budgets | undefined
99  const five = win('five_hour')
100  const week = win('seven_day')
101  if (five || week) {
102    own = { at: heard ? now : 0 }
103    if (five) own.fiveHour = five
104    if (week) own.week = week
105    offPlan = false
106  } else if (heard && m.context.tokens !== undefined) {
107    offPlan = true
108  }
109  const shared = offPlan ? undefined : await sharedBudgets($)
110  const shown = newer(own, shared)
111  if (shown) {
112    const b = current(shown, now)
113    if (b.fiveHour) snap.fiveHour = b.fiveHour
114    if (b.week) snap.week = b.week
115    snap.budgetsAt = b.at
116    if (own && heard && shown === own) {
117      try {
118        await $.store.set(BUDGETS_KEY, own)
119      } catch {}
120    }
121  }
122  await update($, snapshot, () => snap)
123  const n = await $.session.turns()
124  await update($, turns, () => n)
125}
126
127// Picks up a reading another session heard more recently, and empties windows that have reset.
128async function sync($: $, t: Thresholds) {
129  if (offPlan) return
130  const now = await $.clock.now()
131  const snap = await read($, snapshot)
132  const mine: Budgets = { at: snap?.budgetsAt ?? 0 }
133  if (snap?.fiveHour) mine.fiveHour = snap.fiveHour
134  if (snap?.week) mine.week = snap.week
135  const b = current(newer(mine, await sharedBudgets($)) ?? mine, now)
136  if (b.at === mine.at && sameWindow(b.fiveHour, mine.fiveHour) && sameWindow(b.week, mine.week)) return
137  await update($, snapshot, s => {
138    const next: Snapshot = { ...(s ?? { window: 0 }), budgetsAt: b.at }
139    delete next.fiveHour
140    delete next.week
141    if (b.fiveHour) next.fiveHour = b.fiveHour
142    if (b.week) next.week = b.week
143    return next
144  })
145  await notify($, t)
146}
147
148let ticker: Timer | undefined
149
150async function notify($: $, t: Thresholds) {
151  const m = await modelOf($, t)
152  const prev = await read($, toasted)
153  const { messages, memory } = toastsFor(prev, { level: m.level, fiveHour: m.fiveHour, week: m.week })
154  await update($, toasted, () => memory)
155  for (const msg of messages) $.ui.toast(msg)
156}
157
158async function modelOf($: $, t: Thresholds): Promise<BandModel> {
159  const snap = await read($, snapshot)
160  const hist = await read($, history)
161  const comp = await read($, compactions)
162  const nTurns = await read($, turns)
163  const ho = await read($, handoff)
164  const now = await $.clock.now()
165  const left = snap?.autoCompactAt !== undefined ? turnsLeft(hist, snap.autoCompactAt) : undefined
166  const v = verdict(
167    { percent: snap?.percent, turnsLeft: left, compactions: comp, turns: nTurns, fiveHour: snap?.fiveHour, week: snap?.week },
168    t,
169  )
170  return {
171    percent: snap?.percent,
172    tokens: snap?.tokens,
173    window: snap?.window ?? 0,
174    turnsLeft: left,
175    compactions: comp,
176    fiveHour: snap?.fiveHour,
177    week: snap?.week,
178    level: v.level,
179    budget: v.budget,
180    phase: ho.phase,
181    phaseAt: ho.at,
182    now,
183  }
184}
185
186export const register: Register = (on, options) => {
187  const t: Thresholds = { ...DEFAULTS, ...(options as Partial<Thresholds>) }
188
189  on('session.start', async ($, e, next) => {
190    await $.command.register({ name: 'handoff', description: 'Write a hand-off brief for a fresh session' })
191    await $.command.register({ name: 'smart-compact', description: 'Compact, keeping the goal, open tasks, decisions and files in play' })
192    // A reload drops whatever compaction or fork was in flight; its phase must not outlive it.
193    writing = false
194    await update($, handoff, (h): HandoffPhase =>
195      h.phase === 'writing' || h.phase === 'compacting' ? { phase: 'idle' } : h,
196    )
197    try {
198      await record($, await $.session.usage(), false)
199    } catch {}
200    ticker?.cancel()
201    ticker = $.clock.every(SYNC_MS, () => {
202      void sync($, t).catch(() => {})
203    })
204    return next(e)
205  })
206
207  on('command.run', { command: 'handoff' }, async $ => {
208    const written = await startHandoff($)
209    if (written === 'busy') return { text: 'A hand-off is already being written.' }
210    return {
211      text: written
212        ? 'Hand-off written to .claude/handoff.md — run /clear to start fresh.'
213        : 'Hand-off not written (see toast).',
214    }
215  })
216
217  on('command.run', { command: 'smart-compact' }, async $ => {
218    if ((await read($, handoff)).phase === 'compacting') return { text: 'A compaction is already running.' }
219    // Not awaited: /compact is queued behind this command and runs once it returns.
220    void compactNow($)
221    return { text: 'Compacting with smart instructions…' }
222  })
223
224  on('prompt.submit', async ($, e, next) => {
225    let brief: string | undefined
226    try {
227      brief = await loadHandoff($)
228    } catch (err) {
229      $.ui.status(`session-health: hand-off not loaded (${err instanceof Error ? err.message : String(err)})`)
230    }
231    return brief === undefined ? next(e) : next({ ...e, context: [...(e.context ?? []), injection(brief)] })
232  }).catch(($, e, next) => next(e))
233
234  on('session.measure', async ($, e, next) => {
235    await record($, e, e.rateLimits.length > 0)
236    const tokens = e.context.tokens
237    if (tokens !== undefined) {
238      await update($, history, h => (h[h.length - 1] === tokens ? h : [...h, tokens].slice(-8)))
239    }
240    const ho = await read($, handoff)
241    const nTurns = await $.session.turns()
242    if (ho.phase === 'resumed' && nTurns >= 1) await endPhase($, 'resumed')
243    if (ho.phase === 'ready' && nTurns >= (ho.turn ?? 0) + READY_TURNS) await endPhase($, 'ready')
244    await notify($, t)
245    return next(e)
246  })
247
248  on('session.compact', async ($, e, next) => {
249    const r = await next(e)
250    if (countsCompaction(e, Boolean(r.skip))) {
251      await update($, compactions, n => n + 1)
252      await update($, history, () => [])
253    }
254    return r
255  }).catch(($, e, next) => next(e))
256
257  on('session.end', async ($, e, next) => {
258    if (e.reason === 'clear' || e.reason === 'resume') {
259      // The next conversation's figures arrive with its first measure; until then keep only the budgets.
260      await update($, snapshot, s => {
261        if (!s) return s
262        const kept: Snapshot = { window: s.window }
263        if (s.fiveHour) kept.fiveHour = s.fiveHour
264        if (s.week) kept.week = s.week
265        if (s.budgetsAt !== undefined) kept.budgetsAt = s.budgetsAt
266        return kept
267      })
268      await update($, turns, () => 0)
269      await update($, compactions, () => 0)
270      await update($, history, () => [])
271      await update($, toasted, () => ({ level: 'healthy', budget: {} }))
272      await endPhase($, 'ready')
273    }
274    return next(e)
275  })
276
277  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
278    if (e.props.hasSurvey) return next(e)
279    const m = await modelOf($, t)
280    const cols = e.props.bodyColumns
281    return (
282      <Band
283        el={$.ui.resolve(e)}
284        line1={fitLine1(m, cols)}
285        line2={line2(m, cols)}
286        onAction={(a: Action) => onAction($, a)}
287      />
288    )
289  })
290}
291
292async function onAction($: $, a: Action) {
293  if (a === 'compact') return compactNow($)
294  await startHandoff($)
295}
296
297async function compactNow($: $) {
298  await setPhase($, 'compacting')
299  try {
300    // Run /compact as if typed: it works on every surface (a direct
301    // $.session.compact is refused headless), and the engine's own
302    // session.compact (trigger 'manual') reaches our hook, so ⟲ counts it.
303    await $.command.run({ command: 'compact', args: COMPACT_INSTRUCTIONS })
304  } catch (err) {
305    $.ui.toast(`Compact failed: ${err instanceof Error ? err.message : String(err)}`)
306  } finally {
307    await endPhase($, 'compacting')
308  }
309}
310
311async function startHandoff($: $): Promise<boolean | 'busy'> {
312  if (!(await claimWriting($, await $.clock.now()))) {
313    $.ui.toast('A hand-off is already being written')
314    return 'busy'
315  }
316  try {
317    const root = await $.session.root()
318    const project = root.split('/').filter(Boolean).pop() ?? root
319    const stamp = new Date(await $.clock.now()).toISOString().slice(0, 16).replace('T', ' ')
320    const prompt = briefPrompt(project, stamp)
321
322    let r = await $.model.fork({ prompt })
323    if (!r.isAnswered && r.reason === 'nothing-to-fork') {
324      const messages = await $.session.messages()
325      if (messages.length === 0) throw new Error('nothing to hand off yet')
326      r = await $.model.complete({ model: await $.session.model(), prompt: transcriptPrompt(messages) + prompt })
327    }
328    if (!r.isAnswered) throw new Error(r.reason)
329
330    const path = handoffPath(root)
331    await $.fs.write(path, r.text)
332    const rec: HandoffRecord = { path, writtenAt: await $.clock.now(), sessionId: await $.session.id(), consumed: false }
333    await $.store.set(storeKey(root), rec)
334    const turn = await $.session.turns()
335    await update($, handoff, (): HandoffPhase => ({ phase: 'ready', at: rec.writtenAt, turn }))
336    $.ui.toast('Hand-off ready — run /clear')
337    return true
338  } catch (err) {
339    await endPhase($, 'writing')
340    $.ui.toast(`Hand-off failed: ${err instanceof Error ? err.message : String(err)}`)
341    return false
342  } finally {
343    writing = false
344  }
345}
346
347// The brief to hand the model with this prompt, when a fresh conversation has one waiting.
348async function loadHandoff($: $): Promise<string | undefined> {
349  const root = await $.session.root()
350  const rec = (await $.store.get(storeKey(root))) as HandoffRecord | undefined
351  if (!shouldLoad(rec, await $.clock.now(), await $.session.turns())) return undefined
352  if (rec!.sessionId === (await $.session.id())) return undefined
353  const brief = await $.fs.read(rec!.path)
354  await $.store.set(storeKey(root), { ...rec!, consumed: true })
355  await setPhase($, 'resumed', rec!.writtenAt)
356  return brief
357}
358
hooks/band.tsx 46 lines
1import type { EngineInterface } from 'claude-code'
2import { BUTTON_HOTKEY, BUTTON_LABEL, type Action, type Line2, type Segment } from './layout.ts'
3
4type Elements = ReturnType<EngineInterface['ui']['resolve']>
5
6function segments(Text: Elements['Text'], list: readonly Segment[]) {
7  return list.map(s => (
8    <Text color={s.color} dimColor={s.dim} bold={s.bold}>
9      {s.text}
10    </Text>
11  ))
12}
13
14export function Band(props: {
15  el: Elements
16  line1: Segment[]
17  line2?: Line2
18  onAction: (a: Action) => void
19}) {
20  const { Box, Text, Button } = props.el
21  const l2 = props.line2
22  return (
23    <Box flexDirection="column">
24      <Text wrap="truncate-end">{segments(Text, props.line1)}</Text>
25      {l2 && (
26        <Box flexDirection="row">
27          <Box flexGrow={1}>
28            <Text wrap="truncate-end">{segments(Text, l2.segments)}</Text>
29          </Box>
30          {l2.actions.map(a => (
31            <Box flexShrink={0} marginLeft={1}>
32              <Button
33                key={a}
34                label={BUTTON_LABEL[a]}
35                hotkey={BUTTON_HOTKEY[a]}
36                variant={a === 'fresh' ? 'primary' : undefined}
37                onPress={() => props.onAction(a)}
38              />
39            </Box>
40          ))}
41        </Box>
42      )}
43    </Box>
44  )
45}
46
hooks/layout.ts 182 lines
1import type { Budget, Level, Window } from './verdict.ts'
2import { AMBER, CORAL, LIME, bar, barColor, kTokens, relTime } from './meters.ts'
3
4export type Phase = 'idle' | 'compacting' | 'writing' | 'ready' | 'resumed'
5
6export type BandModel = {
7  percent?: number
8  tokens?: number
9  window: number
10  turnsLeft?: number
11  compactions: number
12  fiveHour?: Window
13  week?: Window
14  level: Level
15  budget?: Budget
16  phase: Phase
17  phaseAt?: number
18  now: number
19}
20
21export type Segment = { text: string; color?: string; dim?: boolean; bold?: boolean }
22export type Action = 'compact' | 'fresh'
23export type Line2 = { segments: Segment[]; actions: Action[] }
24
25export const LEVEL_ICON: Record<Level, string> = { healthy: '●', heavy: '◐', compact: '◑', fresh: '✦' }
26export const LEVEL_COLOR: Record<Level, string> = { healthy: LIME, heavy: AMBER, compact: CORAL, fresh: CORAL }
27
28const GAP = '   '
29const MIN_TIER = 5
30const MIN_TEXT = 16
31
32export const BUTTON_LABEL: Record<Action, string> = { compact: 'compact', fresh: 'fresh start' }
33// One key presses each button once the band has the focus (ctrl+x tab).
34export const BUTTON_HOTKEY: Record<Action, string> = { compact: 'c', fresh: 'f' }
35
36// Cells the buttons take on the terminal: `[ label ]` and a gap before each.
37export function buttonsWidth(actions: readonly Action[]): number {
38  return actions.reduce((n, a) => n + BUTTON_LABEL[a].length + 5, 0)
39}
40
41function truncate(segments: Segment[], room: number): Segment[] {
42  if (width(segments) <= room) return segments
43  const out: Segment[] = []
44  let left = Math.max(0, room - 1)
45  for (const s of segments) {
46    const chars = [...s.text]
47    if (chars.length <= left) {
48      out.push(s)
49      left -= chars.length
50      continue
51    }
52    out.push({ ...s, text: chars.slice(0, left).join('') + '…' })
53    break
54  }
55  return out
56}
57
58export function width(segments: readonly Segment[]): number {
59  return segments.reduce((n, s) => n + [...s.text].length, 0)
60}
61
62type Opts = { tokens: boolean; short: boolean; cells: number }
63const TIERS: Opts[] = [
64  { tokens: true, short: false, cells: 10 },
65  { tokens: false, short: false, cells: 10 },
66  { tokens: false, short: true, cells: 10 },
67  { tokens: false, short: true, cells: 5 },
68  { tokens: false, short: true, cells: 0 },
69]
70
71function meter(label: string, percent: number, cells: number): Segment[] {
72  const out: Segment[] = [{ text: `${label} `, dim: true }]
73  if (cells > 0) {
74    const b = bar(percent, cells)
75    out.push({ text: b.filled, color: barColor(percent) }, { text: b.empty, dim: true }, { text: ' ' })
76  }
77  out.push({ text: `${Math.round(percent)}%`, bold: true })
78  return out
79}
80
81function windowMeter(label: string, w: Window, cells: number, now: number): Segment[] {
82  const out = meter(label, w.percent, cells)
83  const rel = cells > 0 ? relTime(w.resetsAt, now) : undefined
84  if (rel !== undefined) out.push({ text: ` · ${rel}`, dim: true })
85  return out
86}
87
88export function line1(m: BandModel, tier: number): Segment[] {
89  if (tier >= MIN_TIER) return minimal(m)
90  const o = TIERS[tier]!
91  const out: Segment[] = [{ text: ' ' }]
92
93  const ctxLabel = o.short ? 'ctx' : 'CONTEXT'
94  if (m.percent === undefined) {
95    out.push({ text: `${ctxLabel} `, dim: true }, { text: '—', dim: true })
96  } else {
97    out.push(...meter(ctxLabel, m.percent, o.cells))
98    if (o.tokens && m.tokens !== undefined) {
99      out.push({ text: ` ${kTokens(m.tokens)}/${kTokens(m.window)}`, dim: true })
100    }
101    if (o.cells > 0) out.push({ text: ` ~${m.turnsLeft ?? '—'} turns`, dim: true })
102    if (o.cells > 0 && m.compactions > 0) out.push({ text: ` ⟲${m.compactions}`, dim: true })
103  }
104
105  const sep = o.cells > 0 ? GAP : ' · '
106  if (m.fiveHour) out.push({ text: sep, dim: true }, ...windowMeter(o.short ? '5h' : 'SESSION', m.fiveHour, o.cells, m.now))
107  if (m.week) out.push({ text: sep, dim: true }, ...windowMeter(o.short ? 'wk' : 'WEEK', m.week, o.cells, m.now))
108  return out
109}
110
111function minimal(m: BandModel): Segment[] {
112  const parts = [m.percent, m.fiveHour?.percent, m.week?.percent]
113    .filter((p): p is number => p !== undefined)
114    .map(p => `${Math.round(p)}%`)
115  const head = parts.length > 0 ? parts.join(' · ') : '—'
116  return [{ text: ` ${head} ` }, { text: LEVEL_ICON[m.level], color: LEVEL_COLOR[m.level] }]
117}
118
119export function fitLine1(m: BandModel, columns: number): Segment[] {
120  for (let tier = 0; tier < MIN_TIER; tier++) {
121    const segs = line1(m, tier)
122    if (width(segs) <= columns) return segs
123  }
124  return line1(m, MIN_TIER)
125}
126
127function ago(m: BandModel): string {
128  const minutes = Math.max(0, Math.floor((m.now - (m.phaseAt ?? m.now)) / 60_000))
129  return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`
130}
131
132function verdictText(m: BandModel): string | undefined {
133  const turns = m.turnsLeft === undefined ? '' : ` (~${m.turnsLeft} turns left)`
134  switch (m.level) {
135    case 'heavy':
136      return `◐ getting heavy — /compact at a natural break${turns}`
137    case 'compact':
138      return m.turnsLeft === undefined
139        ? '◑ compact now — context is filling up'
140        : `◑ compact now — auto-compact in ~${m.turnsLeft} turns`
141    case 'fresh':
142      return `✦ start fresh — compacted ${m.compactions}× already, quality drops`
143    case 'healthy':
144      return undefined
145  }
146}
147
148export function line2(m: BandModel, columns: number): Line2 | undefined {
149  const segments: Segment[] = [{ text: ' ' }]
150  let actions: Action[] = []
151
152  if (m.phase === 'compacting') {
153    segments.push({ text: '⟲ compacting…', color: LIME })
154  } else if (m.phase === 'writing') {
155    segments.push({ text: '✎ writing hand-off…', color: LIME })
156  } else if (m.phase === 'ready') {
157    segments.push({ text: '✓ hand-off ready — run /clear', color: LIME, bold: true })
158  } else if (m.phase === 'resumed') {
159    segments.push({ text: `↪ resumed from hand-off (${ago(m)} ago)`, color: LIME })
160  } else {
161    const v = verdictText(m)
162    if (v !== undefined) segments.push({ text: v, color: LEVEL_COLOR[m.level], bold: m.level !== 'heavy' })
163    if (m.level === 'heavy') actions = ['compact']
164    if (m.level === 'compact') actions = ['compact', 'fresh']
165    if (m.level === 'fresh') actions = ['fresh']
166  }
167
168  if (m.budget) {
169    const rel = relTime(m.budget.resetsAt, m.now)
170    const tail = rel === undefined ? '' : ` — resets in ${rel}`
171    if (segments.length > 1) segments.push({ text: ' · ', dim: true })
172    segments.push({
173      text: `⚠ ${m.budget.kind} budget ${Math.round(m.budget.percent)}%${tail}, pace yourself`,
174      color: barColor(m.budget.percent),
175    })
176  }
177
178  if (segments.length === 1) return undefined
179  if (columns < 40 || columns - buttonsWidth(actions) < MIN_TEXT) actions = []
180  return { segments: truncate(segments, columns - buttonsWidth(actions)), actions }
181}
182
hooks/verdict.ts 91 lines
1export type Level = 'healthy' | 'heavy' | 'compact' | 'fresh'
2
3export type Thresholds = {
4  heavyPct: number
5  compactPct: number
6  heavyTurns: number
7  compactTurns: number
8  freshCompactions: number
9  freshTurns: number
10  budgetWarnPct: number
11}
12
13export const DEFAULTS: Thresholds = {
14  heavyPct: 45,
15  compactPct: 60,
16  heavyTurns: 8,
17  compactTurns: 4,
18  freshCompactions: 2,
19  freshTurns: 150,
20  budgetWarnPct: 90,
21}
22
23export const LEVEL_RANK: Record<Level, number> = { healthy: 0, heavy: 1, compact: 2, fresh: 3 }
24
25export type Window = { percent: number; resetsAt?: string }
26export type Budget = { kind: '5h' | 'wk'; percent: number; resetsAt?: string }
27
28export type VerdictInput = {
29  percent?: number
30  turnsLeft?: number
31  compactions: number
32  turns: number
33  fiveHour?: Window
34  week?: Window
35}
36
37export type Verdict = { level: Level; budget?: Budget }
38
39export function turnsLeft(history: readonly number[], autoCompactAt: number): number | undefined {
40  if (history.length < 2) return undefined
41  const current = history[history.length - 1]!
42  if (current >= autoCompactAt) return 0
43  const growths: number[] = []
44  for (let i = 1; i < history.length; i++) {
45    const growth = history[i]! - history[i - 1]!
46    if (growth > 0) growths.push(growth)
47  }
48  const recent = growths.slice(-5)
49  if (recent.length === 0) return undefined
50  const average = recent.reduce((sum, g) => sum + g, 0) / recent.length
51  return Math.floor((autoCompactAt - current) / average)
52}
53
54export function verdict(input: VerdictInput, t: Thresholds = DEFAULTS): Verdict {
55  const pct = input.percent ?? 0
56  const left = input.turnsLeft
57  const compactWorthy = pct >= t.compactPct || (left !== undefined && left <= t.compactTurns)
58  const heavy = pct >= t.heavyPct || (left !== undefined && left <= t.heavyTurns)
59  const worn = input.compactions >= t.freshCompactions || input.turns > t.freshTurns
60
61  const level: Level = compactWorthy ? (worn ? 'fresh' : 'compact') : heavy ? 'heavy' : 'healthy'
62  const budget = budgetOf(input, t)
63  return budget ? { level, budget } : { level }
64}
65
66function budgetOf(input: VerdictInput, t: Thresholds): Budget | undefined {
67  const five = input.fiveHour
68  const week = input.week
69  const fiveHot = five !== undefined && five.percent >= t.budgetWarnPct
70  const weekHot = week !== undefined && week.percent >= t.budgetWarnPct
71  if (!fiveHot && !weekHot) return undefined
72  const w = fiveHot && (!weekHot || five!.percent >= week!.percent) ? five! : week!
73  const kind = w === five ? '5h' : 'wk'
74  return w.resetsAt === undefined ? { kind, percent: w.percent } : { kind, percent: w.percent, resetsAt: w.resetsAt }
75}
76
77// Where auto-compaction will run: the engine's own threshold, or undefined when it is off.
78export function autoCompactPoint(
79  b: { autoCompactThreshold?: number; isAutoCompactEnabled: boolean } | undefined,
80  window: number,
81): number | undefined {
82  if (!b) return window
83  if (b.autoCompactThreshold !== undefined) return b.autoCompactThreshold
84  return b.isAutoCompactEnabled ? window : undefined
85}
86
87// Only the main conversation's real compactions count toward ⟲.
88export function countsCompaction(e: { trigger: string; agentId?: string }, skipped: boolean): boolean {
89  return e.agentId === undefined && e.trigger !== 'precompute' && !skipped
90}
91
hooks/toasts.ts 37 lines
1import type { Level, Window } from './verdict.ts'
2import { LEVEL_RANK } from './verdict.ts'
3
4export type ToastMemory = { level: Level; budget: Record<string, number> }
5
6const LEVEL_MESSAGE: Record<Level, string> = {
7  healthy: '',
8  heavy: '◐ Getting heavy — consider /compact at a natural break',
9  compact: '◑ Compact now — context is filling up',
10  fresh: '✦ Start fresh — use [fresh start] to write a hand-off',
11}
12
13const STEPS = [90, 75] as const
14
15export function toastsFor(
16  prev: ToastMemory,
17  v: { level: Level; fiveHour?: Window; week?: Window },
18): { messages: string[]; memory: ToastMemory } {
19  const messages: string[] = []
20  if (LEVEL_RANK[v.level] > LEVEL_RANK[prev.level]) messages.push(LEVEL_MESSAGE[v.level])
21
22  const budget = { ...prev.budget }
23  const check = (name: string, w: Window | undefined) => {
24    if (!w) return
25    const key = `${name}|${w.resetsAt ?? ''}`
26    const step = STEPS.find(s => w.percent >= s)
27    if (step === undefined || (budget[key] ?? 0) >= step) return
28    budget[key] = step
29    const pct = Math.round(w.percent)
30    messages.push(step >= 90 ? `⚠ ${name} budget at ${pct}% — pace yourself` : `${name} budget at ${pct}%`)
31  }
32  check('5-hour', v.fiveHour)
33  check('Weekly', v.week)
34
35  return { messages, memory: { level: v.level, budget } }
36}
37
hooks/budgets.ts 41 lines
1import type { Window } from '../types'
2
3// The 5-hour and weekly budgets are the account's, not the session's, but each
4// session only hears them from its own API responses. Sessions share the newest
5// reading through the plugin's store, stamped with when it was heard.
6export type Budgets = { at: number; fiveHour?: Window; week?: Window }
7
8export const BUDGETS_KEY = 'budgets'
9export const SYNC_MS = 15_000
10
11export function isBudgets(v: unknown): v is Budgets {
12  return typeof v === 'object' && v !== null && typeof (v as Budgets).at === 'number'
13}
14
15// The reading to show: whichever was heard last.
16export function newer(a: Budgets | undefined, b: Budgets | undefined): Budgets | undefined {
17  if (!a) return b
18  if (!b) return a
19  return b.at > a.at ? b : a
20}
21
22// A window whose reset has passed is empty until a response says otherwise.
23export function lapse(w: Window | undefined, now: number): Window | undefined {
24  if (!w || w.resetsAt === undefined) return w
25  const at = Date.parse(w.resetsAt)
26  return !Number.isNaN(at) && at <= now ? { percent: 0 } : w
27}
28
29export function current(b: Budgets, now: number): Budgets {
30  const out: Budgets = { at: b.at }
31  const five = lapse(b.fiveHour, now)
32  const week = lapse(b.week, now)
33  if (five) out.fiveHour = five
34  if (week) out.week = week
35  return out
36}
37
38export function sameWindow(a: Window | undefined, b: Window | undefined): boolean {
39  return a?.percent === b?.percent && a?.resetsAt === b?.resetsAt
40}
41
hooks/handoff.ts 47 lines
1export const COMPACT_INSTRUCTIONS =
2  'Preserve: the current goal and acceptance criteria; open tasks and their status; key decisions and the reasons for them; ' +
3  'files created or modified and their purpose; unresolved errors or blockers; exact names of functions, commands and paths in play. ' +
4  'Drop: exploratory dead ends, verbose tool output, superseded plans.'
5
6export type HandoffRecord = { path: string; writtenAt: number; sessionId: string; consumed: boolean }
7
8export const MAX_AGE_MS = 24 * 60 * 60 * 1000
9const TRANSCRIPT_BUDGET = 120_000
10
11export const handoffPath = (root: string) => `${root}/.claude/handoff.md`
12export const storeKey = (root: string) => `handoff:${root}`
13
14export function briefPrompt(project: string, stamp: string): string {
15  return [
16    'Write a hand-off brief so a brand-new Claude Code session can continue this work without re-reading the codebase.',
17    'Use only what this conversation established. Be concrete: exact file paths, function names, commands, error messages.',
18    'Output Markdown only, under 600 words, with exactly these headings in this order:',
19    '',
20    `# Hand-off — ${project} — ${stamp}`,
21    '## Goal',
22    '## Current state',
23    '## Decisions (and why)',
24    '## Files touched',
25    '## Next steps',
26    '## Gotchas',
27  ].join('\n')
28}
29
30export function transcriptPrompt(messages: readonly { role: string; text: string }[]): string {
31  const lines: string[] = []
32  let size = 0
33  for (let i = messages.length - 1; i >= 0; i--) {
34    const line = `${messages[i]!.role.toUpperCase()}: ${messages[i]!.text}`
35    if (size + line.length > TRANSCRIPT_BUDGET) break
36    lines.unshift(line)
37    size += line.length
38  }
39  return `<transcript>\n${lines.join('\n\n')}\n</transcript>\n\n`
40}
41
42export function shouldLoad(rec: HandoffRecord | undefined, now: number, turnsSoFar: number): boolean {
43  return rec !== undefined && !rec.consumed && turnsSoFar === 0 && now - rec.writtenAt <= MAX_AGE_MS
44}
45
46export const injection = (brief: string) => `Hand-off from the previous session:\n\n${brief}`
47
hooks/meters.ts 40 lines
1export const LIME = '#aaff00'
2export const AMBER = '#ffb000'
3export const CORAL = '#ff5f57'
4
5const PARTIALS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'] as const
6
7export function barColor(percent: number): string {
8  if (percent >= 90) return CORAL
9  if (percent >= 70) return AMBER
10  return LIME
11}
12
13export function bar(percent: number, cells: number): { filled: string; empty: string } {
14  const clamped = Math.min(100, Math.max(0, percent))
15  const eighths = Math.round((clamped / 100) * cells * 8)
16  const full = Math.floor(eighths / 8)
17  const rem = eighths % 8
18  const filled = '█'.repeat(full) + PARTIALS[rem]
19  const empty = '░'.repeat(cells - full - (rem > 0 ? 1 : 0))
20  return { filled, empty }
21}
22
23export function relTime(resetsAt: string | undefined, now: number): string | undefined {
24  if (resetsAt === undefined) return undefined
25  const at = Date.parse(resetsAt)
26  if (Number.isNaN(at)) return undefined
27  const minutes = Math.floor((at - now) / 60_000)
28  if (minutes <= 0) return 'now'
29  if (minutes < 60) return `${minutes}m`
30  const hours = Math.floor(minutes / 60)
31  if (hours < 48) return `${hours}h${minutes % 60}m`
32  return `${Math.floor(hours / 24)}d`
33}
34
35export function kTokens(n: number): string {
36  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
37  if (n >= 1000) return `${Math.round(n / 1000)}k`
38  return `${n}`
39}
40
types/index.d.ts 31 lines
1export type Level = 'healthy' | 'heavy' | 'compact' | 'fresh'
2export type Phase = 'idle' | 'compacting' | 'writing' | 'ready' | 'resumed'
3export type Window = { percent: number; resetsAt?: string }
4
5export type Snapshot = {
6  percent?: number
7  tokens?: number
8  window: number
9  autoCompactAt?: number
10  fiveHour?: Window
11  week?: Window
12  // When the budgets shown were heard, in ms; 0 when their age is unknown.
13  budgetsAt?: number
14}
15
16export type HandoffPhase = { phase: Phase; at?: number; turn?: number }
17export type ToastMemory = { level: Level; budget: Record<string, number> }
18
19declare module 'claude-code' {
20  interface PluginState {
21    'session-health': {
22      snapshot: Snapshot | null
23      history: number[]
24      compactions: number
25      turns: number
26      handoff: HandoffPhase
27      toasted: ToastMemory
28    }
29  }
30}
31