KOZMOS Ballast: a context coach. A toast when the context window passes warnAt, then a band with an anchor and a water-level gauge at actAt: "⚓ ctx 89% —…

A context coach. Ballast watches how full the context window is (the engine's session.measure pushes, plus a reading after every turn):
/compact before it fills;⚓ ctx 89% — compact before the next big task with Compact now and Not now. Compact now runs between turns; pressed while a turn runs it is queued and runs when the turn ends. Not now rests the band until the fill grows four more points.autoCompact (default off) compacts by itself at actAt, between turns./compact, or the engine's own) a toast shows before → after: ⚓ compacted: 890k → 120k tokens (−87%).Terminal: the band's first row is the message and its buttons; the second a waterline gauge ▕~≈~≈~≈…┊··┊·▏ with marks at warnAt and actAt. Desktop: one SVG: a swaying anchor and a tank whose water level is the context fill, with animated waves and dashed warn / act lines.
/ballast hides or shows the band (kept across sessions), /ballast now compacts, /ballast status reports the fill and the last compaction.
Bir bağlam koçu. Ballast bağlam penceresinin doluluğunu izler:
⚓ ctx 89% — compact before the next big task, Compact now (şimdi sıkıştır) ve Not now (şimdi değil) düğmeleriyle. Bir tur sürerken basılırsa sıkıştırma kuyruğa alınır ve tur bitince çalışır.autoCompact (varsayılan kapalı) actAt eşiğinde turlar arasında kendiliğinden sıkıştırır.Terminal: mesaj, düğmeler ve uyarı/eylem işaretli bir su çizgisi göstergesi. Masaüstü: sallanan bir çapa ve su seviyesi bağlam doluluğu olan, dalgaları canlandırılmış bir tank (tek SVG).
/ballast bandı gizler/gösterir, /ballast now hemen sıkıştırır, /ballast status durumu bildirir.
hooks/register.tsx 346 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BallastSnap } from '../types'
5import { KZ, clamp01, fitText, fmtPct, fmtTokens, heat, mix, pxOf, svg, svgText } from './lib/kz.ts'
6import { compactedText, errorText } from './text.ts'
7
8const SNOOZE_POINTS = 4
9const AUTO_GAP_MS = 60_000
10
11const blank = (warnAt: number, actAt: number, autoCompact: boolean): BallastSnap => ({
12 percent: null,
13 tokens: null,
14 window: 1_000_000,
15 warnAt,
16 actAt,
17 autoCompact,
18 isWorking: false,
19 isQueued: false,
20 isCompacting: false,
21 snoozedAt: null,
22 last: null,
23})
24
25const snapAtom = atom({ plugin: 'ballast', key: 'snap' } as const, blank(75, 88, false))
26const hiddenAtom = atom({ plugin: 'ballast', key: 'isHidden' } as const, false)
27
28// ---------------------------------------------------------------------------
29// The live coach. Module state starts over on a reload; session.start fills it.
30
31let live: BallastSnap = blank(75, 88, false)
32let hasWarned = false
33let isAutoArmed = true
34let lastAutoAt = 0
35let publishedKey = ''
36
37async function publish($: EngineInterface): Promise<void> {
38 const key = JSON.stringify(live)
39 if (key === publishedKey) return
40 publishedKey = key
41 const copy: BallastSnap = { ...live, last: live.last ? { ...live.last } : null }
42 await update($, snapAtom, () => copy)
43}
44
45async function observe($: EngineInterface, percent: number | undefined, tokens: number | undefined, window: number): Promise<void> {
46 if (percent === undefined || !Number.isFinite(percent)) return
47 live.percent = Math.round(percent * 10) / 10
48 live.tokens = tokens ?? null
49 live.window = window || live.window
50 if (live.percent < live.warnAt) hasWarned = false
51 if (live.percent < live.actAt) isAutoArmed = true
52 if (live.snoozedAt !== null && live.percent < live.snoozedAt - SNOOZE_POINTS) live.snoozedAt = null
53 if (live.percent >= live.warnAt && live.percent < live.actAt && !hasWarned) {
54 hasWarned = true
55 $.ui.toast(`⚓ ballast: context at ${fmtPct(live.percent)} — plan a /compact before it fills`, { timeoutMs: 6000 })
56 }
57 if (live.percent >= live.actAt) hasWarned = true
58 if (live.autoCompact && live.percent >= live.actAt && isAutoArmed && !live.isCompacting) {
59 const now = await $.clock.now()
60 if (now - lastAutoAt > AUTO_GAP_MS) {
61 isAutoArmed = false
62 lastAutoAt = now
63 if (live.isWorking) live.isQueued = true
64 else $.clock.after(250, () => void compactNow($).catch(() => undefined))
65 }
66 }
67 await publish($)
68}
69
70async function sample($: EngineInterface): Promise<void> {
71 try {
72 const u = await $.session.usage()
73 await observe($, u.context.percent, u.context.tokens, u.context.window)
74 } catch {
75 // Keep the last reading.
76 }
77}
78
79/** Compacts now between turns, or queues it for the end of the running turn. */
80async function compactNow($: EngineInterface): Promise<string> {
81 if (live.isCompacting) return 'Already compacting.'
82 if (live.isWorking) {
83 live.isQueued = true
84 await publish($)
85 $.ui.toast('⚓ ballast: compaction queued for the end of this turn')
86 return 'Queued: ballast compacts when this turn ends.'
87 }
88 live.isQueued = false
89 live.isCompacting = true
90 const before = live.tokens
91 try {
92 // Inside the try, so a refused state write cannot leave isCompacting stuck.
93 await publish($)
94 const r = await $.session.compact()
95 live.isCompacting = false
96 if (r.skip !== undefined) {
97 $.ui.toast(`⚓ ballast: compaction skipped — ${r.skip}`)
98 await publish($)
99 return `Compaction skipped: ${r.skip}`
100 }
101 await sample($)
102 const b = r.tokensBefore ?? before
103 const a = r.tokensAfter ?? live.tokens
104 live.last = { before: b, after: a, at: await $.clock.now(), by: 'ballast' }
105 live.snoozedAt = null
106 await publish($)
107 const text = compactedText(b, a)
108 $.ui.toast(text, { timeoutMs: 8000 })
109 return text
110 } catch (err) {
111 // A turn started under us: try again when it ends.
112 live.isCompacting = false
113 live.isQueued = true
114 await publish($)
115 return `Could not compact now (${errorText(err)}); queued for the end of the next turn.`
116 }
117}
118
119async function snooze($: EngineInterface): Promise<void> {
120 live.snoozedAt = live.percent
121 await publish($)
122}
123
124async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
125 await update($, hiddenAtom, () => isHidden)
126 try {
127 await $.store.set('isHidden', isHidden)
128 } catch {
129 // Hidden for this session at least.
130 }
131}
132
133async function loadHidden($: EngineInterface): Promise<void> {
134 try {
135 const v = await $.store.get('isHidden')
136 if (typeof v === 'boolean') await update($, hiddenAtom, () => v)
137 } catch {
138 // Nothing stored yet.
139 }
140}
141
142async function setWorking($: EngineInterface, isWorking: boolean): Promise<void> {
143 live.isWorking = isWorking
144 await publish($)
145}
146
147async function afterTurn($: EngineInterface): Promise<void> {
148 await setWorking($, false)
149 await sample($)
150 if (live.isQueued && !live.isCompacting) await compactNow($)
151}
152
153async function noteCompaction($: EngineInterface, before: number | null, after: number | null, by: string): Promise<void> {
154 live.last = { before, after, at: await $.clock.now(), by }
155 live.snoozedAt = null
156 live.isQueued = false
157 await sample($)
158 if (live.last.after === null && live.tokens !== null) live.last.after = live.tokens
159 await publish($)
160 $.ui.toast(compactedText(before, live.last.after), { timeoutMs: 8000 })
161}
162
163const isBandDue = (s: BallastSnap): boolean =>
164 s.percent !== null && s.percent >= s.actAt && (s.snoozedAt === null || s.percent >= s.snoozedAt + SNOOZE_POINTS)
165
166export const register: Register = (on, options) => {
167 // The manifest fills in the defaults; a value that is not a number falls back to them.
168 const warnAt = Math.max(1, Math.min(99, Number(options.warnAt) || 75))
169 const actAt = Math.max(warnAt, Math.min(100, Number(options.actAt) || 88))
170 const autoCompact = options.autoCompact === true
171
172 on('session.start', async ($, e, next) => {
173 const started = await next(e)
174 live = blank(warnAt, actAt, autoCompact)
175 hasWarned = false
176 isAutoArmed = true
177 lastAutoAt = 0
178 publishedKey = ''
179 await $.command.register({ name: 'ballast', description: 'KOZMOS: the context coach band — toggle it; /ballast now compacts, /ballast status reports', argumentHint: '[now|status]', immediate: true })
180 await loadHidden($)
181 await sample($)
182 return started
183 })
184
185 on('command.run', { command: 'ballast' }, async ($, e) => {
186 const arg = e.args.trim().toLowerCase()
187 if (arg === 'now' || arg === 'compact') {
188 if (live.isCompacting || live.isWorking) return { text: await compactNow($) }
189 // The host refuses a compaction under the command.run hook that asks for
190 // it: start it just after this hook has answered.
191 $.clock.after(250, () => void compactNow($).catch(() => undefined))
192 return { text: '⚓ ballast: compacting now; a toast shows before → after.' }
193 }
194 if (arg === 'status') {
195 await sample($)
196 const last = live.last ? ` Last compaction: ${compactedText(live.last.before, live.last.after).replace('⚓ ', '')} (${live.last.by}).` : ''
197 return { text: `⚓ ballast: context ${fmtPct(live.percent ?? undefined)} (${fmtTokens(live.tokens ?? 0)} of ${fmtTokens(live.window)}); warn at ${warnAt}%, act at ${actAt}%, auto-compact ${autoCompact ? 'on' : 'off'}.${last}` }
198 }
199 const isHidden = !(await read($, hiddenAtom))
200 await setHidden($, isHidden)
201 return { text: isHidden ? 'ballast band hidden. /ballast shows it again.' : 'ballast band shown: it appears when the context passes ' + actAt + '%.' }
202 })
203
204 on('session.measure', async ($, e, next) => {
205 const r = await next(e)
206 if (e.changed.includes('context')) await observe($, e.context.percent, e.context.tokens, e.context.window)
207 return r
208 }).catch(($, e, next) => next(e))
209
210 on('turn.start', async ($, e, next) => {
211 await setWorking($, true)
212 return next(e)
213 })
214
215 on('turn.complete', async ($, e, next) => {
216 const r = await next(e)
217 // Between turns only: the queued compaction runs once this turn has ended.
218 if (e.agentId === undefined) $.clock.after(300, () => void afterTurn($).catch(() => undefined))
219 return r
220 })
221
222 // Every other compaction (/compact, the engine's own): toast before → after.
223 on('session.compact', async ($, e, next) => {
224 const r = await next(e)
225 if (e.trigger !== 'precompute' && e.agentId === undefined && r.skip === undefined) {
226 void noteCompaction($, r.tokensBefore ?? live.tokens, r.tokensAfter ?? null, e.trigger === 'manual' ? 'you' : e.trigger).catch(() => undefined)
227 }
228 return r
229 }).catch(($, e, next) => next(e))
230
231 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
232 const drawn = await next(e)
233 if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
234 const s = await read($, snapAtom)
235 if (!isBandDue(s) && !s.isQueued && !s.isCompacting) return drawn
236 const ui = $.ui.resolve(e)
237 const cols = Math.max(30, e.props.bodyColumns || 80)
238 const pct = s.percent ?? 0
239 const status = s.isCompacting ? 'compacting…' : s.isQueued ? 'compaction queued for the end of this turn' : 'compact before the next big task'
240 const head = `⚓ ctx ${fmtPct(pct)} — ${status}`
241 const onCompact = () => void compactNow($).catch(() => undefined)
242 const onSnooze = () => void snooze($).catch(() => undefined)
243 const { Box, Button } = ui
244 const buttons = [
245 !s.isCompacting && !s.isQueued ? <Button key="ballast-compact" label="Compact now" onPress={onCompact} /> : null,
246 !s.isCompacting ? <Button key="ballast-snooze" label="Not now" plain onPress={onSnooze} /> : null,
247 <Button key="ballast-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />,
248 ]
249
250 if ('Svg' in ui && e.surface !== 'terminal') {
251 const { Svg } = ui
252 const W = Math.max(260, pxOf(cols) - 250)
253 return (
254 <Box flexDirection="column">
255 {drawn}
256 <Box key="ballast" flexDirection="row" alignItems="center" gap={1}>
257 <Svg source={bandSvg(W, s, status)} alt={`${head}; ${fmtTokens(s.tokens ?? 0)} of ${fmtTokens(s.window)} tokens`} width={W} height={56} />
258 {buttons}
259 </Box>
260 </Box>
261 )
262 }
263
264 const { Text } = ui
265 const gaugeW = Math.max(10, Math.min(60, cols - 24))
266 return (
267 <Box flexDirection="column">
268 {drawn}
269 <Box key="ballast" flexDirection="column">
270 <Box flexDirection="row" justifyContent="space-between">
271 <Text wrap="truncate-end">
272 <Text bold color={heat(pct / 100)}>{head}</Text>
273 </Text>
274 <Box flexDirection="row" gap={1}>{buttons}</Box>
275 </Box>
276 <Text wrap="truncate-end">
277 {waterLine(pct, s.warnAt, s.actAt, gaugeW).map(r => <Text key={r.key} color={r.color}>{r.ch}</Text>)}
278 <Text dimColor> {fmtTokens(s.tokens ?? 0)}/{fmtTokens(s.window)}</Text>
279 </Text>
280 </Box>
281 </Box>
282 )
283 })
284}
285
286/** The terminal gauge: a waterline with marks at warnAt and actAt. */
287function waterLine(pct: number, warnAt: number, actAt: number, width: number): Run[] {
288 const fill = Math.round(clamp01(pct / 100) * width)
289 const warnCol = Math.round((warnAt / 100) * width)
290 const actCol = Math.round((actAt / 100) * width)
291 const out: Run[] = [textRun('▕', KZ.mist, 'l')]
292 for (let i = 0; i < width; i++) {
293 const isMark = i === warnCol || i === actCol
294 if (i < fill) out.push(textRun(i % 2 ? '≈' : '~', mix(KZ.cyan, heat(pct / 100), i / Math.max(1, width)), `w${i}`))
295 else out.push(textRun(isMark ? '┊' : '·', isMark ? (i === actCol ? KZ.red : KZ.yellow) : KZ.mist, `w${i}`))
296 }
297 out.push(textRun('▏', KZ.mist, 'r'))
298 return out
299}
300
301type Run = { ch: string; color: string; key: string }
302
303function textRun(ch: string, color: string, key: string): Run {
304 return { ch, color, key }
305}
306
307/** The desktop band: an anchor, the words, and a tank whose water rises with the context. */
308function bandSvg(W: number, s: BallastSnap, status: string): string {
309 const pct = s.percent ?? 0
310 const level = clamp01(pct / 100)
311 const c = heat(level)
312 const tankW = Math.min(220, Math.max(110, W * 0.32))
313 const tankX = W - tankW - 10
314 const tankY = 6
315 const tankH = 44
316 const waterY = tankY + tankH * (1 - level)
317 const markY = (p: number) => tankY + tankH * (1 - p / 100)
318 const wave = (amp: number, len: number, y: number) => {
319 let d = `M${tankX - len} ${y}`
320 for (let x = tankX - len; x < tankX + tankW + len; x += len) d += ` q${len / 4} ${-amp} ${len / 2} 0 t${len / 2} 0`
321 return `${d} V${tankY + tankH + 4} H${tankX - len} Z`
322 }
323 const textW = tankX - 64
324 const css = `.wv1{animation:bwave 3.2s linear infinite}.wv2{animation:bwave 5s linear infinite reverse;opacity:.55}
325@keyframes bwave{to{transform:translateX(36px)}}
326.sway{transform-box:fill-box;transform-origin:50% 10%;animation:bsway 4s ease-in-out infinite}@keyframes bsway{50%{transform:rotate(-7deg)}}`
327 const body = `<defs><clipPath id="btank"><rect x="${tankX}" y="${tankY}" width="${tankW}" height="${tankH}" rx="9"/></clipPath>
328<linearGradient id="bwater" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(KZ.cyan, c, 0.55)}"/><stop offset="1" stop-color="${KZ.blue}" stop-opacity=".85"/></linearGradient></defs>
329<rect class="p" x="0" y="0" width="${W}" height="56" rx="12"/>
330<g class="sway" transform="translate(14 8)" fill="none" stroke="${c}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
331<circle cx="15" cy="5" r="3.6"/><path d="M15 9v28M8 15h14M3 26c1 7 6 11 12 11s11-4 12-11M3 26l-2 3M3 26l3.5 1.2M27 26l2 3M27 26l-3.5 1.2"/></g>
332${svgText(56, 24, `ctx ${fmtPct(pct)}`, { size: 16, weight: 750, fill: c })}
333${svgText(56 + 82, 24, fitText(`— ${status}`, 12.5, Math.max(40, textW - 82)), { cls: 't', size: 12.5, weight: 600 })}
334${svgText(56, 43, fitText(`${fmtTokens(s.tokens ?? 0)} of ${fmtTokens(s.window)} tokens · warn ${s.warnAt}% · act ${s.actAt}%${s.autoCompact ? ' · auto-compact' : ''}`, 11, textW), { cls: 'm', size: 11 })}
335<rect class="k" x="${tankX}" y="${tankY}" width="${tankW}" height="${tankH}" rx="9"/>
336<g clip-path="url(#btank)">
337<path class="wv2" d="${wave(2.6, 36, waterY + 1.5)}" fill="${KZ.blue}"/>
338<path class="wv1" d="${wave(3.2, 36, waterY)}" fill="url(#bwater)"/>
339</g>
340<line x1="${tankX + 4}" x2="${tankX + tankW - 4}" y1="${markY(s.warnAt)}" y2="${markY(s.warnAt)}" stroke="${KZ.yellow}" stroke-dasharray="3 3" stroke-width="1.2"/>
341<line x1="${tankX + 4}" x2="${tankX + tankW - 4}" y1="${markY(s.actAt)}" y2="${markY(s.actAt)}" stroke="${KZ.red}" stroke-dasharray="3 3" stroke-width="1.2"/>
342<rect x="${tankX}" y="${tankY}" width="${tankW}" height="${tankH}" rx="9" fill="none" stroke="${c}" stroke-opacity=".6" stroke-width="1.4"/>
343${svgText(tankX + tankW - 8, tankY + 15, fmtPct(pct), { size: 12, weight: 700, anchor: 'end' })}`
344 return svg(W, 56, body, css)
345}
346hooks/lib/kz.ts 510 lines1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11 violet: '#a78bfa',
12 magenta: '#f472b6',
13 cyan: '#22d3ee',
14 teal: '#2dd4bf',
15 green: '#4ade80',
16 lime: '#a3e635',
17 yellow: '#facc15',
18 amber: '#fb923c',
19 red: '#f87171',
20 blue: '#60a5fa',
21 clay: '#d97757',
22 ink: '#1f1e1d',
23 mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28 const t = String(tool)
29 if (t === 'Bash' || t === 'PowerShell') return KZ.green
30 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31 if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32 if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33 if (t.startsWith('Web')) return KZ.cyan
34 if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35 if (t.startsWith('mcp__')) return KZ.magenta
36 return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41 const t = String(tool)
42 if (t === 'Bash' || t === 'PowerShell') return '$'
43 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44 if (t === 'Read') return '◉'
45 if (t === 'Glob' || t === 'Grep') return '⌕'
46 if (t === 'Agent') return '◈'
47 if (t.startsWith('Web')) return '◍'
48 if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49 if (t.startsWith('mcp__')) return '⬡'
50 return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57 const h = hex.replace('#', '')
58 const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63 const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64 return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68 const [r1, g1, b1] = hexToRgb(a)
69 const [r2, g2, b2] = hexToRgb(b)
70 const k = clamp01(t)
71 return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76 const k = clamp01(t)
77 return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82 const k = ((h % 1) + 1) % 1
83 const a = s * Math.min(l, 1 - l)
84 const f = (n: number) => {
85 const x = (n + k * 12) % 12
86 return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87 }
88 return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92 const [r, g, b] = hexToRgb(hex)
93 return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102 if (!Number.isFinite(n)) return '—'
103 // Each unit starts where the one below would round up to 1000 of itself.
104 if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105 if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106 if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107 return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111 if (!Number.isFinite(usd)) return '$—'
112 if (usd < 0.01 && usd > 0) return '<$0.01'
113 return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118 const s = Math.max(0, Math.round(ms / 1000))
119 const h = Math.floor(s / 3600)
120 const m = Math.floor((s % 3600) / 60)
121 const ss = String(s % 60).padStart(2, '0')
122 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127 const s = Math.max(0, Math.round(ms / 1000))
128 if (s < 60) return `${s}s`
129 const m = Math.floor(s / 60)
130 if (m < 60) return `${m}m`
131 const h = Math.floor(m / 60)
132 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133 return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137 return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141 const one = s.replace(/\s+/g, ' ').trim()
142 return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146 return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150 return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155 if (!id) return '—'
156 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157 if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158 const fam = m[1]!
159 return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164 const parts = p.split(/[\\/]/).filter(Boolean)
165 return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170 let h = 2166136261
171 for (let i = 0; i < s.length; i++) {
172 h ^= s.charCodeAt(i)
173 h = Math.imul(h, 16777619)
174 }
175 return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181 return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186 let a = seed >>> 0
187 return () => {
188 a = (a + 0x6d2b79f5) >>> 0
189 let t = a
190 t = Math.imul(t ^ (t >>> 15), t | 1)
191 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193 }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍ `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203 const w = Math.max(1, Math.floor(width))
204 const exact = clamp01(ratio) * w
205 const full = Math.floor(exact)
206 const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207 const used = full + (part ? 1 : 0)
208 return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213 const w = Math.max(1, Math.floor(width))
214 const n = Math.round(clamp01(ratio) * w)
215 return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222 const tail = values.slice(-Math.max(1, width))
223 const top = max ?? Math.max(1e-9, ...tail)
224 const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225 return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230 const samples = values.slice(-(width * 2))
231 const top = max ?? Math.max(1e-9, ...samples)
232 const dotsTall = rows * 4
233 const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234 const offset = width * 2 - samples.length
235 // Dot bits per column, from the bottom row of a cell up.
236 const LEFT = [0x40, 0x04, 0x02, 0x01]
237 const RIGHT = [0x80, 0x20, 0x10, 0x08]
238 samples.forEach((v, i) => {
239 const x = offset + i
240 const col = Math.floor(x / 2)
241 const isRight = x % 2 === 1
242 const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243 for (let d = 0; d <= h; d++) {
244 const row = rows - 1 - Math.floor(d / 4)
245 const bits = isRight ? RIGHT : LEFT
246 const cellRow = grid[row]
247 if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248 }
249 })
250 return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259 let out = ''
260 let i = 0
261 for (; i + 2 < bytes.length; i += 3) {
262 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264 }
265 const rest = bytes.length - i
266 if (rest === 1) {
267 const n = bytes[i]! << 16
268 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269 } else if (rest === 2) {
270 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272 }
273 return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280 readonly cols: number
281 readonly rows: number
282 private readonly words: Uint32Array
283
284 constructor(cols: number, rows: number) {
285 this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286 this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287 this.words = new Uint32Array(this.cols * this.rows * 3)
288 this.clear()
289 }
290
291 clear(bg: number = DEFAULT_COLOR): void {
292 for (let i = 0; i < this.cols * this.rows; i++) {
293 this.words[i * 3] = 0x20
294 this.words[i * 3 + 1] = DEFAULT_COLOR
295 this.words[i * 3 + 2] = bg
296 }
297 }
298
299 /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300 set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301 const cx = Math.floor(x)
302 const cy = Math.floor(y)
303 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304 const i = (cy * this.cols + cx) * 3
305 const code = ch.codePointAt(0) ?? 0x20
306 this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307 if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308 if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309 }
310
311 /** Writes a string left to right, clipped to the canvas. */
312 text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313 let cx = x
314 for (const ch of s) {
315 this.set(cx, y, ch, fg, bg)
316 cx++
317 }
318 }
319
320 /** Paints a cell's background only, keeping its character. */
321 paint(x: number, y: number, bg: string | number): void {
322 const cx = Math.floor(x)
323 const cy = Math.floor(y)
324 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325 this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326 }
327
328 /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329 pixel(x: number, py: number, color: string): void {
330 const cx = Math.floor(x)
331 const cy = Math.floor(py / 2)
332 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333 const i = (cy * this.cols + cx) * 3
334 const c = hexToInt(color)
335 const isTop = Math.floor(py) % 2 === 0
336 if (this.words[i] !== 0x2580) {
337 this.words[i] = 0x2580
338 this.words[i + 1] = DEFAULT_COLOR
339 this.words[i + 2] = DEFAULT_COLOR
340 }
341 if (isTop) this.words[i + 1] = c
342 else this.words[i + 2] = c
343 }
344
345 /** The `cells` prop of a Raster. */
346 encode(): string {
347 return toBase64(new Uint8Array(this.words.buffer))
348 }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
358
359export function xml(s: string): string {
360 return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378 let w = 0
379 for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380 return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384 if (textWidth(s, size) <= maxW) return s
385 let out = ''
386 for (const ch of s) {
387 if (textWidth(out + ch + '…', size) > maxW) break
388 out += ch
389 }
390 return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395 const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396 return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397 (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402 const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403 return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408 return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418 [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419 [/opus-5-5/i, [4, 20, 0.2, 5]],
420 [/opus/i, [5, 25, 0.5, 6.25]],
421 [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422 [/sonnet/i, [3, 15, 0.3, 3.75]],
423 [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424 [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428 input_tokens?: number
429 output_tokens?: number
430 cache_read_input_tokens?: number
431 cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435 return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439 if (!u) return 0
440 const [i, o, r, w] = priceOf(model)
441 return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445 if (!u) return 0
446 return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451 return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455 return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464 if (kind === 'five_hour') return '5h'
465 if (kind === 'seven_day') return '7d'
466 if (kind === 'spend_limit') return '$'
467 return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472 if (!l.resetsAt) return undefined
473 const t = Date.parse(l.resetsAt)
474 return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479 if (kind === 'five_hour') return 5 * 3600_000
480 if (kind === 'seven_day') return 7 * 24 * 3600_000
481 return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489 const e = (input ?? {}) as Record<string, unknown>
490 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491 const t = String(tool)
492 if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493 if (s('file_path')) return baseName(s('file_path'))
494 if (s('notebook_path')) return baseName(s('notebook_path'))
495 if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496 if (s('pattern')) return clip(s('pattern'), 60)
497 if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498 if (s('query')) return clip(s('query'), 60)
499 if (s('subject')) return clip(s('subject'), 60)
500 if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501 if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502 return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507 const t = String(tool)
508 return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510hooks/text.ts 18 lines1// Ballast's words: pure, no `$`.
2import { fmtTokens } from './lib/kz.ts'
3
4/** The toast after a compaction: before → after when both are known. */
5export function compactedText(before: number | null, after: number | null): string {
6 if (before && after !== null) {
7 const cut = Math.round((1 - after / before) * 100)
8 return `⚓ compacted: ${fmtTokens(before)} → ${fmtTokens(after)} tokens (−${Math.max(0, cut)}%)`
9 }
10 if (after !== null) return `⚓ compacted: now ${fmtTokens(after)} tokens`
11 return '⚓ compacted.'
12}
13
14/** A failure as one line of text. */
15export function errorText(err: unknown): string {
16 return err instanceof Error ? err.message : String(err)
17}
18types/index.d.ts 32 lines1export type BallastCompaction = {
2 /** Tokens in the window before and after; null when the engine did not say. */
3 before: number | null
4 after: number | null
5 at: number
6 /** Who compacted: `you` (/compact), `auto` (the engine), `ballast`. */
7 by: string
8}
9
10export type BallastSnap = {
11 /** The context fill, 0-100, or null before the first reading. */
12 percent: number | null
13 tokens: number | null
14 window: number
15 warnAt: number
16 actAt: number
17 autoCompact: boolean
18 isWorking: boolean
19 /** A compaction asked for while a turn ran, waiting for turn.complete. */
20 isQueued: boolean
21 isCompacting: boolean
22 /** "Not now" at this percent: the band rests until the fill grows 4 points. */
23 snoozedAt: number | null
24 last: BallastCompaction | null
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 ballast: { snap: BallastSnap; isHidden: boolean }
30 }
31}
32