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

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 ]
#aaff00), amber from 70%, coral from 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].The band shrinks gracefully on narrow terminals, down to 58% · 31% · 64% ●.
/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
Change these in /config:
| Setting | Default | Meaning |
|---|---|---|
heavyPct | 45 | Context % that counts as "getting heavy" |
compactPct | 60 | Context % that says "compact now" |
heavyTurns | 8 | Turns left before auto-compact that count as "getting heavy" |
compactTurns | 4 | Turns left before auto-compact that say "compact now" |
freshCompactions | 2 | Compactions after which a heavy session should start fresh |
freshTurns | 150 | Turns after which a heavy session should start fresh |
budgetWarnPct | 90 | 5-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.
claude plugin validate session-health
claude plugin test session-health
MIT
hooks/register.tsx 358 lines1import { 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}
358hooks/band.tsx 46 lines1import 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}
46hooks/layout.ts 182 lines1import 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}
182hooks/verdict.ts 91 lines1export 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}
91hooks/toasts.ts 37 lines1import 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}
37hooks/budgets.ts 41 lines1import 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}
41hooks/handoff.ts 47 lines1export 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}`
47hooks/meters.ts 40 lines1export 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}
40types/index.d.ts 31 lines1export 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