A pinned band above the prompt that shows each Claude Code session's role, current task and a trail of what it worked on before, so context switches are never…

A pinned band above the Claude Code prompt that tells you at a glance who this agent is, what it's doing, what it did before, and how much room it has left.
Switch tasks halfway through a session and Agent Badge notices: the new task takes the top line and the old one moves to an "earlier" trail, so you always know what this session has already worked on, even after hours away or a dozen tabs later.
▄██▄▄██▄ Fuzzy Koala CODING AGENT
████████ ▸ Fix login redirect bug ◜◝ Context ◜◝ 5-hour limit ◜◝ Weekly limit Spent
████████ ↳ earlier: Set up CI ◟◞ 38% used ◟◞ 12% used ◟◞ 92% used $2.14
▀▀ ▀▀ Opus 5.5 · ~/site 124k left resets in 2h 10m resets in 3d 4h this session
claude --resume brings the trail back too./plugin marketplace add GlacierHubAB/agent-badge
/plugin install agent-badge@agent-badge
Or load it from a local clone:
claude --plugin-dir /path/to/agent-badge
Collapse the band any time with [-] or Ctrl+X Ctrl+A.
Colours are read from an Omarchy theme at ~/.local/state/omarchy/current/theme/colors.toml when present, and refresh when you switch themes. Only the theme's real hues are used for critters (greys are skipped), topped up from a built-in set of twelve soft colours so that at least ten are available and sessions stay easy to tell apart, even on a monochrome theme.
The critter's colour is picked by the session id. When Den is running it hands out colours so that no two live sessions share one, and records them in ~/.local/state/den/colors.json; the badge follows that file when it has an entry for the session.
Agent Badge hooks these Claude Code events. None of them changes what passes through; each passes its input on unchanged.
| Event | What it does |
|---|---|
prompt.submit | Reads the text of a prompt you typed (not slash commands, not prompts from plugins or other agents) and queues it for role and task labelling. The prompt itself is passed on untouched, and labelling runs afterwards, so your prompt is never delayed. |
session.start | Restores the session's saved labels, reads the model, folder, theme and usage, and starts the refresh and animation timers. |
session.measure | Picks up new context, usage-limit and cost figures after each turn. |
turn.start / turn.complete | Switches the critter between its idle and working animations, and refreshes the model shown. |
ui.render (AbovePrompt) | Draws the band. It yields to Claude Code's own surveys and to subagent views. |
Agent Badge keeps two records per session in Claude Code's local plugin store: badge:<session id> (role, task, earlier tasks) and usage:<session id> (context, rate limits and spend, the same figures the gauges show). Other tools on the machine can read them to show the same identity; Den does.
To label the role and task, the text of each prompt you type (up to 4,000 characters), the current role and task, and the earlier tasks are sent to Claude Haiku through your own Claude Code session. That's one small request per prompt, billed like any other Claude Code usage. Slash commands are skipped. Nothing is sent anywhere else. The labels are stored in Claude Code's local plugin store so resumed sessions keep them. The full policy is in PRIVACY.md.
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 866 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Badge, Meta, Palette, Usage } from '../types'
5
6const badge = atom({ plugin: 'agent-badge', key: 'badge' } as const, {
7 role: 'new agent',
8 task: 'waiting for a first task',
9 history: [],
10} as Badge)
11const meta = atom({ plugin: 'agent-badge', key: 'meta' } as const, {
12 model: '',
13 folder: '',
14 seed: 0,
15 color: '',
16} as Meta)
17// Body colours the theme does not supply: soft, distinct, readable on a dark
18// background. Den (the fleet TUI) uses the same list in the same order, so a
19// session's colour matches there.
20const HUES = [
21 '#ea9e9e', // rose
22 '#f0b07a', // apricot
23 '#e8d27a', // straw
24 '#a7d98c', // leaf
25 '#66cb8e', // mint
26 '#7fd3c4', // teal
27 '#86c6e8', // sky
28 '#9bb0f0', // periwinkle
29 '#b9a3f0', // lilac
30 '#e39ad8', // orchid
31 '#f0a8c0', // pink
32 '#c9b89a', // sand
33]
34const MIN_HUES = 10
35// Den records the colour it gave each session here so no two live agents
36// share one; the badge follows it when present.
37const DEN_COLORS = 'den/colors.json'
38
39const palette = atom({ plugin: 'agent-badge', key: 'palette' } as const, {
40 colors: fillHues([], '#000000'),
41 dark: '#000000',
42 warn: '#e8b84a',
43 alert: '#ea9e9e',
44 track: '#2e2e2e',
45 blush: '#ea9e9e',
46} as Palette)
47const usage = atom({ plugin: 'agent-badge', key: 'usage' } as const, {} as Usage)
48
49const THEME_COLORS = '.local/state/omarchy/current/theme/colors.toml'
50const PALETTE_KEYS = [
51 'red', 'yellow', 'orange', 'green', 'cyan', 'blue', 'magenta',
52 'bright_red', 'bright_yellow', 'bright_green', 'bright_cyan', 'bright_blue', 'bright_magenta',
53 'accent',
54]
55const HISTORY_LIMIT = 3
56
57// Layout, in terminal cells. Cells are about twice as tall as wide, so a
58// square picture is twice as many columns as rows.
59const AVATAR_COLUMNS = 8
60const AVATAR_ROWS = 4
61const RING_COLUMNS = 6
62const RING_ROWS = 3
63const GAUGE_TEXT = 17
64const GAUGE_WIDTH = RING_COLUMNS + 1 + GAUGE_TEXT + 2
65const SPENT_WIDTH = 12
66const BAR_CELLS = 10
67const COMPACT_WIDTH = 34
68const INFO_MIN = 40
69
70// Animation.
71const TICK_MS = 160
72const SPRITE = 16
73const SPRITE_SCALE = 6
74const RING_PX = 60
75
76const ADJECTIVES = [
77 'Mossy', 'Fizzy', 'Sleepy', 'Brave', 'Tiny', 'Cosmic', 'Fuzzy', 'Zippy', 'Wobbly', 'Sunny',
78 'Misty', 'Plucky', 'Snug', 'Bouncy', 'Quiet', 'Spicy', 'Velvet', 'Pebble', 'Comet', 'Maple',
79]
80const CRITTERS = [
81 'Otter', 'Moth', 'Newt', 'Fox', 'Owl', 'Axolotl', 'Panda', 'Gecko', 'Quokka', 'Hedgehog',
82 'Puffin', 'Wombat', 'Ferret', 'Koala', 'Lemur', 'Capybara', 'Badger', 'Narwhal', 'Sprite', 'Tanuki',
83]
84
85// ── small helpers ─────────────────────────────────────────────────────────
86
87function hash(text: string): number {
88 let acc = 0x811c9dc5
89 for (let i = 0; i < text.length; i++) {
90 acc ^= text.charCodeAt(i)
91 acc = Math.imul(acc, 0x01000193)
92 }
93 return acc >>> 0
94}
95
96function rng(seed: number): () => number {
97 let a = seed || 1
98 return () => {
99 a = (a + 0x6d2b79f5) | 0
100 let t = Math.imul(a ^ (a >>> 15), 1 | a)
101 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
102 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
103 }
104}
105
106function pick<T>(items: readonly T[], index: number): T {
107 return items[index % items.length] as T
108}
109
110function critterName(seed: number): string {
111 const rand = rng(seed ^ 0x9e3779b9)
112 return `${pick(ADJECTIVES, Math.floor(rand() * ADJECTIVES.length))} ${pick(CRITTERS, Math.floor(rand() * CRITTERS.length))}`
113}
114
115type RGB = [number, number, number]
116
117function hexToRgb(hex: string): RGB {
118 const n = parseInt(hex.slice(1, 7), 16)
119 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
120}
121
122function luminance(hex: string): number {
123 const [r, g, b] = hexToRgb(hex)
124 return 0.2126 * r + 0.7152 * g + 0.0722 * b
125}
126
127function mix(a: RGB, b: RGB, t: number): RGB {
128 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
129}
130
131function bodyColor(seed: number, colors: string[], assigned = ''): string {
132 return assigned || pick(colors, seed)
133}
134
135// saturation is the spread between the strongest and weakest channel; greys
136// score 0.
137function saturation(hex: string): number {
138 const c = hexToRgb(hex)
139 return Math.max(...c) - Math.min(...c)
140}
141
142// fillHues appends curated hues until the list has MIN_HUES entries, skipping
143// ones already present or that vanish against the background.
144function fillHues(colors: string[], dark: string): string[] {
145 const out = [...colors]
146 for (const hue of HUES) {
147 if (out.length >= MIN_HUES) break
148 if (out.includes(hue) || Math.abs(luminance(hue) - luminance(dark)) <= 60) continue
149 out.push(hue)
150 }
151 return out
152}
153
154const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
155
156function toBase64(bytes: Uint8Array): string {
157 const out: string[] = []
158 for (let i = 0; i < bytes.length; i += 3) {
159 const a = bytes[i] ?? 0
160 const b = bytes[i + 1] ?? 0
161 const c = bytes[i + 2] ?? 0
162 const n = (a << 16) | (b << 8) | c
163 out.push(
164 BASE64.charAt((n >> 18) & 63) + BASE64.charAt((n >> 12) & 63) +
165 (i + 1 < bytes.length ? BASE64.charAt((n >> 6) & 63) : '=') +
166 (i + 2 < bytes.length ? BASE64.charAt(n & 63) : '='),
167 )
168 }
169 return out.join('')
170}
171
172function shortCount(n: number): string {
173 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
174 if (n >= 1000) return `${Math.round(n / 1000)}k`
175 return String(n)
176}
177
178function untilText(iso: string | undefined, now: number | undefined): string {
179 if (!iso || now === undefined) return ''
180 const at = Date.parse(iso)
181 if (Number.isNaN(at)) return ''
182 const minutes = Math.max(0, Math.round((at - now) / 60000))
183 if (minutes < 1) return 'resets now'
184 if (minutes < 60) return `resets in ${minutes}m`
185 const hours = Math.floor(minutes / 60)
186 if (hours < 24) return `resets in ${hours}h ${minutes % 60}m`
187 return `resets in ${Math.floor(hours / 24)}d ${hours % 24}h`
188}
189
190function prettyModel(model: string): string {
191 const m = model.match(/claude-([a-z]+)-(\d+)(?:-(\d+))?/i)
192 if (!m || !m[1] || !m[2]) return model
193 const name = m[1].charAt(0).toUpperCase() + m[1].slice(1)
194 const version = m[3] && m[3].length <= 2 ? `${m[2]}.${m[3]}` : m[2]
195 return `${name} ${version}${model.includes('[1m]') ? ' 1M' : ''}`
196}
197
198function parseJson(text: string): Record<string, unknown> | null {
199 const found = text.match(/\{[\s\S]*\}/)
200 if (!found) return null
201 try {
202 return JSON.parse(found[0])
203 } catch {
204 return null
205 }
206}
207
208// ── the critter: 16x16 pixel art, upscaled so the terminal keeps it crisp ──
209
210type Pose = { breathe: boolean; blink: boolean; look: -1 | 0 | 1; hop: boolean }
211
212type Species = {
213 ears: 'cat' | 'bunny' | 'antenna' | 'horns' | 'round' | 'tuft'
214 halfWidth: number
215 top: number
216 belly: boolean
217 spots: boolean
218 mouth: boolean
219}
220
221function species(seed: number): Species {
222 const rand = rng(seed ^ 0x51ed270b)
223 return {
224 ears: pick(['cat', 'bunny', 'antenna', 'horns', 'round', 'tuft'] as const, Math.floor(rand() * 6)),
225 halfWidth: rand() < 0.5 ? 5.5 : 6.2,
226 top: rand() < 0.5 ? 5 : 6,
227 belly: rand() < 0.7,
228 spots: rand() < 0.35,
229 mouth: rand() < 0.6,
230 }
231}
232
233function spritePixels(seed: number, p: Palette, pose: Pose, assigned = ''): Uint8Array {
234 const s = species(seed)
235 const body = hexToRgb(bodyColor(seed, p.colors, assigned))
236 const white: RGB = [255, 255, 255]
237 const ink = mix(hexToRgb(p.dark), body, 0.15)
238 const outline = mix(body, ink, 0.6)
239 const light = mix(body, white, 0.35)
240 const shade = mix(body, ink, 0.25)
241 const bellyColor = mix(body, white, 0.5)
242 const blush = hexToRgb(p.blush)
243 const spotsRand = rng(seed ^ 0x2545f491)
244
245 const N = SPRITE
246 const grid: (RGB | null)[] = new Array(N * N).fill(null)
247 const at = (x: number, y: number) => (x >= 0 && x < N && y >= 0 && y < N ? grid[y * N + x] : null)
248 const put = (x: number, y: number, c: RGB) => {
249 if (x >= 0 && x < N && y >= 0 && y < N) grid[y * N + x] = c
250 }
251 const mirror = (x: number, y: number, c: RGB) => {
252 put(x, y, c)
253 put(N - 1 - x, y, c)
254 }
255
256 const lift = pose.hop ? 1 : 0
257 const top = s.top + (pose.breathe ? 1 : 0) - lift
258 const bottom = 13 - lift
259 const cy = (top + bottom) / 2 + 0.5
260 const ry = (bottom - top + 1) / 2
261 const rx = s.halfWidth + (pose.breathe ? 0.3 : 0)
262
263 // Body with soft light from the top left and shade underneath.
264 for (let y = 0; y < N; y++) {
265 for (let x = 0; x < N; x++) {
266 const dx = (x + 0.5 - N / 2) / rx
267 const dy = (y + 0.5 - cy) / ry
268 if (dx * dx + dy * dy > 1) continue
269 let c = body
270 if (dy > 0.45) c = shade
271 else if (dx < -0.25 && dy < -0.2) c = light
272 put(x, y, c)
273 }
274 }
275
276 // Ears and other head decorations (left side, mirrored).
277 const ex = Math.round(N / 2 - s.halfWidth + 1.5)
278 switch (s.ears) {
279 case 'cat':
280 for (let k = 0; k < 3; k++) for (let i = 0; i <= 2 - k; i++) mirror(ex + i, top - k, body)
281 mirror(ex + 1, top, light)
282 break
283 case 'bunny':
284 for (let k = 1; k <= 4; k++) {
285 mirror(ex + 1, top - k, body)
286 mirror(ex + 2, top - k, k > 1 && k < 4 ? bellyColor : body)
287 }
288 break
289 case 'antenna':
290 for (let k = 1; k <= 2; k++) mirror(ex + 2, top - k, outline)
291 mirror(ex + 1, top - 3, light)
292 mirror(ex + 2, top - 3, light)
293 break
294 case 'horns':
295 mirror(ex + 1, top - 1, bellyColor)
296 mirror(ex, top - 2, bellyColor)
297 break
298 case 'round':
299 for (const [x, y] of [[0, 0], [1, 0], [0, -1], [1, -1], [-1, 0]] as const) mirror(ex + x, top + y, body)
300 mirror(ex, top - 1, light)
301 break
302 case 'tuft':
303 put(7, top - 1, body)
304 put(8, top - 2, body)
305 put(8, top - 1, light)
306 break
307 }
308
309 // Belly patch.
310 if (s.belly) {
311 for (let y = 0; y < N; y++) {
312 for (let x = 0; x < N; x++) {
313 const dx = (x + 0.5 - N / 2) / (rx - 2.6)
314 const dy = (y + 0.5 - (cy + ry * 0.45)) / (ry * 0.45)
315 if (dx * dx + dy * dy <= 1 && at(x, y)) put(x, y, bellyColor)
316 }
317 }
318 }
319
320 // Spots.
321 if (s.spots) {
322 for (let i = 0; i < 3; i++) {
323 const x = 3 + Math.floor(spotsRand() * 3)
324 const y = Math.round(cy - 1 + spotsRand() * 3)
325 if (at(x, y)) mirror(x, y, shade)
326 }
327 }
328
329 // Feet.
330 mirror(5, bottom + 1, outline)
331 mirror(4, bottom + 1, outline)
332
333 // Outline everything drawn so far.
334 const filled = grid.map(c => c !== null)
335 for (let y = 0; y < N; y++) {
336 for (let x = 0; x < N; x++) {
337 if (filled[y * N + x]) continue
338 const near = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([ox, oy]) => {
339 const nx = x + (ox ?? 0)
340 const ny = y + (oy ?? 0)
341 return nx >= 0 && nx < N && ny >= 0 && ny < N && filled[ny * N + nx]
342 })
343 if (near) put(x, y, outline)
344 }
345 }
346
347 // Face.
348 const eyeY = Math.round(cy - ry * 0.2)
349 const look = pose.look
350 for (const side of [-1, 1]) {
351 const ex0 = side < 0 ? 5 + look : 9 + look
352 if (pose.blink) {
353 put(ex0, eyeY, ink)
354 put(ex0 + 1, eyeY, ink)
355 } else {
356 put(ex0, eyeY - 1, ink)
357 put(ex0 + 1, eyeY - 1, ink)
358 put(ex0, eyeY, ink)
359 put(ex0 + 1, eyeY, ink)
360 put(ex0 + (look > 0 ? 1 : 0), eyeY - 1, white) // sparkle
361 }
362 }
363 put(4, eyeY + 1, mix(at(4, eyeY + 1) ?? body, blush, 0.7))
364 put(11, eyeY + 1, mix(at(11, eyeY + 1) ?? body, blush, 0.7))
365 if (s.mouth) {
366 // A soft little smile: faint corners, darker middle.
367 put(7, eyeY + 1, mix(at(7, eyeY + 1) ?? body, ink, 0.45))
368 put(8, eyeY + 1, mix(at(8, eyeY + 1) ?? body, ink, 0.45))
369 }
370
371 // Upscale with nearest neighbour into RGBA.
372 const S = SPRITE_SCALE
373 const W = N * S
374 const out = new Uint8Array(W * W * 4)
375 for (let y = 0; y < N; y++) {
376 for (let x = 0; x < N; x++) {
377 const c = grid[y * N + x]
378 if (!c) continue
379 for (let yy = 0; yy < S; yy++) {
380 for (let xx = 0; xx < S; xx++) {
381 const i = ((y * S + yy) * W + (x * S + xx)) * 4
382 out[i] = c[0]
383 out[i + 1] = c[1]
384 out[i + 2] = c[2]
385 out[i + 3] = 255
386 }
387 }
388 }
389 }
390 return out
391}
392
393const spriteCache = new Map<string, string>()
394
395function poseId(pose: Pose): string {
396 return `${pose.breathe ? 'b' : '-'}${pose.blink ? 'k' : '-'}${pose.look}${pose.hop ? 'h' : '-'}`
397}
398
399function spriteSource(seed: number, p: Palette, pose: Pose, assigned = ''): { rgba: string; width: number; height: number } {
400 const cacheId = `${seed}|${assigned}|${p.colors.join()}|${p.dark}|${poseId(pose)}`
401 let rgba = spriteCache.get(cacheId)
402 if (rgba === undefined) {
403 if (spriteCache.size > 64) spriteCache.clear()
404 rgba = toBase64(spritePixels(seed, p, pose, assigned))
405 spriteCache.set(cacheId, rgba)
406 }
407 return { rgba, width: SPRITE * SPRITE_SCALE, height: SPRITE * SPRITE_SCALE }
408}
409
410// ── ring gauges ───────────────────────────────────────────────────────────
411
412const ringCache = new Map<string, string>()
413
414function ringSource(percent: number, fill: string, track: string): { rgba: string; width: number; height: number } {
415 const pct = Math.max(0, Math.min(100, Math.round(percent)))
416 const cacheId = `${pct}|${fill}|${track}`
417 let rgba = ringCache.get(cacheId)
418 if (rgba === undefined) {
419 if (ringCache.size > 256) ringCache.clear()
420 const N = RING_PX
421 const out = new Uint8Array(N * N * 4)
422 const c = N / 2
423 const outer = N / 2 - 1
424 const inner = outer - N * 0.15
425 const mid = (outer + inner) / 2
426 const half = (outer - inner) / 2
427 const sweep = (pct / 100) * Math.PI * 2
428 const f = hexToRgb(fill)
429 const t = hexToRgb(track)
430 // Rounded caps at both ends of the filled arc.
431 const cap = (angle: number) => [c + Math.sin(angle) * mid, c - Math.cos(angle) * mid] as const
432 const [sx, sy] = cap(0)
433 const [ex, ey] = cap(sweep)
434 for (let y = 0; y < N; y++) {
435 for (let x = 0; x < N; x++) {
436 const dx = x + 0.5 - c
437 const dy = y + 0.5 - c
438 const d = Math.hypot(dx, dy)
439 const ring = Math.min(1, Math.max(0, half + 0.5 - Math.abs(d - mid)))
440 let angle = Math.atan2(dx, -dy)
441 if (angle < 0) angle += Math.PI * 2
442 const inArc = pct > 0 && angle <= sweep
443 const capCover = pct > 0
444 ? Math.max(
445 Math.min(1, Math.max(0, half + 0.5 - Math.hypot(x + 0.5 - sx, y + 0.5 - sy))),
446 Math.min(1, Math.max(0, half + 0.5 - Math.hypot(x + 0.5 - ex, y + 0.5 - ey))),
447 )
448 : 0
449 const fillCover = Math.max(inArc ? ring : 0, capCover)
450 const trackCover = ring
451 const alpha = Math.max(fillCover, trackCover)
452 if (alpha <= 0) continue
453 const color = fillCover > 0 ? mix(t, f, fillCover / alpha) : t
454 const i = (y * N + x) * 4
455 out[i] = color[0]
456 out[i + 1] = color[1]
457 out[i + 2] = color[2]
458 out[i + 3] = Math.round(alpha * 255)
459 }
460 }
461 rgba = toBase64(out)
462 ringCache.set(cacheId, rgba)
463 }
464 return { rgba, width: RING_PX, height: RING_PX }
465}
466
467function levelColor(percent: number, normal: string, p: Palette): string {
468 return percent >= 90 ? p.alert : percent >= 75 ? p.warn : normal
469}
470
471// ── animation ─────────────────────────────────────────────────────────────
472
473let bandRequestId: string | undefined
474let isWorking = false
475let tick = 0
476let lastPose = ''
477let blinkUntil = -1
478
479function currentPose(seed: number): Pose {
480 const breathe = Math.floor(tick / (isWorking ? 3 : 6)) % 2 === 1
481 if (tick >= blinkUntil + 12 && (seed + tick * 7919) % 37 === 0) blinkUntil = tick + 1
482 const blink = tick <= blinkUntil
483 if (!isWorking) return { breathe, blink, look: 0, hop: false }
484 const phase = Math.floor(tick / 8) % 4
485 const look = phase === 1 ? -1 : phase === 3 ? 1 : 0
486 return { breathe: false, blink, look, hop: tick % 6 < 2 }
487}
488
489async function animate($: EngineInterface): Promise<void> {
490 tick++
491 if (bandRequestId === undefined) return
492 try {
493 await drawFrame($)
494 } catch {
495 // The band may be collapsed or not mounted yet; try again next tick.
496 }
497}
498
499async function drawFrame($: EngineInterface): Promise<void> {
500 if (bandRequestId === undefined) return
501 const m = await read($, meta)
502 const p = await read($, palette)
503 const pose = currentPose(m.seed)
504 const id = poseId(pose)
505 if (id === lastPose) return
506 lastPose = id
507 await $.ui.blit({ requestId: bandRequestId, key: 'avatar', source: spriteSource(m.seed, p, pose, m.color) })
508}
509
510// ── session data ──────────────────────────────────────────────────────────
511
512const SYSTEM = `You label a Claude Code session for a status band. Reply with JSON only:
513{"role": string, "task": string, "isNewTask": boolean}
514- role: what kind of agent this session is, 1-3 lowercase words, e.g. "coding agent", "copywriter", "media editor", "researcher", "sysadmin", "designer", "data analyst", "writer", "assistant". Keep the previous role unless the work clearly changed kind.
515- task: the current task as a short imperative phrase, at most 8 words, no trailing period.
516- isNewTask: true only when the new message starts a different task from the current one, not when it continues, refines, answers a question, or says thanks/ok.`
517
518async function save($: EngineInterface): Promise<void> {
519 const id = await $.session.id()
520 await $.store.set(`badge:${id}`, await read($, badge))
521}
522
523async function refreshMeta($: EngineInterface): Promise<void> {
524 const model = await $.session.model()
525 const cwd = await $.session.cwd()
526 const home = await $.env.get('HOME')
527 const id = await $.session.id()
528 const folder = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd
529 const next: Meta = { model: prettyModel(model), folder, seed: hash(id), color: await denColor($, home, id) }
530 const current = await read($, meta)
531 if (current.model !== next.model || current.folder !== next.folder || current.seed !== next.seed || current.color !== next.color) {
532 await update($, meta, () => next)
533 }
534}
535
536// denColor returns the colour Den assigned this session, or '' when Den has
537// not recorded one.
538async function denColor($: EngineInterface, home: string | undefined, id: string): Promise<string> {
539 const state = (await $.env.get('XDG_STATE_HOME')) || (home ? `${home}/.local/state` : '')
540 if (!state) return ''
541 try {
542 const parsed: unknown = JSON.parse(await $.fs.read(`${state}/${DEN_COLORS}`))
543 if (parsed && typeof parsed === 'object') {
544 const c = (parsed as Record<string, unknown>)[id]
545 if (typeof c === 'string' && /^#[0-9a-fA-F]{6}$/.test(c)) return c.toLowerCase()
546 }
547 } catch {
548 // No Den, or nothing recorded yet: the seed picks.
549 }
550 return ''
551}
552
553async function refreshPalette($: EngineInterface): Promise<void> {
554 const home = await $.env.get('HOME')
555 let toml: string
556 try {
557 toml = await $.fs.read(`${home}/${THEME_COLORS}`)
558 } catch {
559 return
560 }
561 const values: Record<string, string> = {}
562 for (const line of toml.split('\n')) {
563 const m = line.match(/^\s*([a-z_]+)\s*=\s*"(#[0-9a-fA-F]{6})/)
564 if (m?.[1] && m[2]) values[m[1]] = m[2].toLowerCase()
565 }
566 const dark = values.background ?? '#000000'
567 // Real hues only: a monochrome theme would otherwise give every agent grey.
568 const themed = [...new Set(PALETTE_KEYS.map(k => values[k]).filter((c): c is string => Boolean(c)))]
569 .filter(c => Math.abs(luminance(c) - luminance(dark)) > 60 && saturation(c) >= 40)
570 const next: Palette = {
571 colors: fillHues(themed, dark),
572 dark,
573 warn: values.yellow ?? values.orange ?? '#e8b84a',
574 alert: values.red ?? '#ea9e9e',
575 track: values.selection_background ?? values.lighter_background ?? values.muted ?? '#2e2e2e',
576 blush: values.bright_red ?? values.red ?? '#f4c4c4',
577 }
578 const current = await read($, palette)
579 if (JSON.stringify(current) !== JSON.stringify(next)) await update($, palette, () => next)
580}
581
582async function storeUsage($: EngineInterface, figures: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Promise<void> {
583 const limit = (kind: string) => figures.rateLimits.find(r => r.kind === kind)
584 const next: Usage = {
585 context: figures.context.percent,
586 filled: figures.context.tokens,
587 window: figures.context.window,
588 fiveHour: limit('five_hour')?.percentUsed,
589 fiveHourResets: limit('five_hour')?.resetsAt,
590 sevenDay: limit('seven_day')?.percentUsed,
591 sevenDayResets: limit('seven_day')?.resetsAt,
592 usd: figures.cost?.usd,
593 // Rounded to the minute so the "resets in" text moves without redrawing every tick.
594 now: Math.floor((await $.clock.now()) / 60000) * 60000,
595 }
596 const current = await read($, usage)
597 if (JSON.stringify(current) !== JSON.stringify(next)) {
598 await update($, usage, () => next)
599 // Shared with other tools on this machine (Den reads it to show the same
600 // gauges per agent). Same figures as the band, nothing more.
601 await $.store.set(`usage:${await $.session.id()}`, next)
602 }
603}
604
605async function refreshUsage($: EngineInterface): Promise<void> {
606 await storeUsage($, await $.session.usage())
607}
608
609async function classify($: EngineInterface, text: string): Promise<void> {
610 const current = await read($, badge)
611 const prompt = [
612 `Current role: ${current.role}`,
613 `Current task: ${current.task}`,
614 `Earlier tasks: ${current.history.join('; ') || 'none'}`,
615 `New message from the user:\n${text.slice(0, 4000)}`,
616 ].join('\n')
617 const result = await $.model.complete({
618 model: 'haiku',
619 system: SYSTEM,
620 prompt,
621 maxTokens: 150,
622 effort: 'low',
623 timeoutMs: 20000,
624 })
625 if (!result.isAnswered) return
626 const parsed = parseJson(result.text)
627 if (!parsed) return
628 const role = typeof parsed.role === 'string' && parsed.role.trim() ? parsed.role.trim().toLowerCase() : current.role
629 const task = typeof parsed.task === 'string' && parsed.task.trim() ? parsed.task.trim() : current.task
630 const isFirst = current.role === 'new agent'
631 const isNewTask = parsed.isNewTask === true && !isFirst && task !== current.task
632
633 await update($, badge, b => ({
634 role,
635 task,
636 history: isNewTask ? [b.task, ...b.history].slice(0, HISTORY_LIMIT) : b.history,
637 }))
638 await save($)
639}
640
641async function refreshAll($: EngineInterface): Promise<void> {
642 try {
643 await refreshMeta($)
644 await refreshPalette($)
645 await refreshUsage($)
646 } catch {
647 // Figures refresh again on the next tick.
648 }
649}
650
651// Prompts waiting to be labelled, handled one at a time in order.
652const pendingLabels: string[] = []
653let isLabelling = false
654
655async function drainLabels($: EngineInterface): Promise<void> {
656 if (isLabelling) return
657 isLabelling = true
658 try {
659 while (pendingLabels.length > 0) {
660 const text = pendingLabels.shift() as string
661 try {
662 await classify($, text)
663 } catch {
664 // A failed label leaves the band as it was.
665 }
666 }
667 } finally {
668 isLabelling = false
669 }
670}
671
672// ── drawing ───────────────────────────────────────────────────────────────
673
674type Gauge = { label: string; percent: number; detail: string }
675
676function gauges(u: Usage): Gauge[] {
677 const list: Gauge[] = []
678 if (u.window !== undefined) {
679 const left = u.filled === undefined ? u.window : Math.max(0, u.window - u.filled)
680 list.push({ label: 'Context', percent: u.context ?? 0, detail: `${shortCount(left)} left` })
681 }
682 if (u.fiveHour !== undefined) {
683 list.push({ label: '5-hour limit', percent: u.fiveHour, detail: untilText(u.fiveHourResets, u.now) })
684 }
685 if (u.sevenDay !== undefined) {
686 list.push({ label: 'Weekly limit', percent: u.sevenDay, detail: untilText(u.sevenDayResets, u.now) })
687 }
688 return list
689}
690
691// A pill-shaped bar with eighth-block precision, for narrower windows.
692function smoothBar(percent: number): { full: string; partial: string; empty: string } {
693 const eighths = Math.round((Math.max(0, Math.min(100, percent)) / 100) * BAR_CELLS * 8)
694 const fullCells = Math.floor(eighths / 8)
695 const rest = eighths % 8
696 const partial = rest > 0 ? ' ▏▎▍▌▋▊▉'.charAt(rest) : ''
697 const empty = ' '.repeat(BAR_CELLS - fullCells - (partial ? 1 : 0))
698 return { full: '█'.repeat(fullCells), partial, empty }
699}
700
701export const register: Register = on => {
702 on('session.start', async ($, e, next) => {
703 const id = await $.session.id()
704 const saved = (await $.store.get(`badge:${id}`)) as Badge | undefined
705 const current = await read($, badge)
706 if (saved && current.role === 'new agent') await update($, badge, () => saved)
707 await refreshAll($)
708 // Catch /model switches, theme changes and rate-limit drift between turns.
709 $.clock.every(5000, () => refreshAll($))
710 $.clock.every(TICK_MS, () => animate($))
711 return next(e)
712 })
713
714 on('prompt.submit', ($, e, next) => {
715 const text = e.text.trim()
716 if (e.origin.kind === 'composer' && text && !text.startsWith('/')) {
717 // Label in the background so the prompt is never held up.
718 pendingLabels.push(text)
719 $.clock.after(0, () => drainLabels($))
720 }
721 return next(e)
722 })
723
724 on('session.measure', async ($, e, next) => {
725 await storeUsage($, e)
726 return next(e)
727 })
728
729 on('turn.start', async ($, e, next) => {
730 isWorking = true
731 await refreshMeta($)
732 return next(e)
733 })
734
735 on('turn.complete', ($, e, next) => {
736 if (e.agentId === undefined) isWorking = false
737 return next(e)
738 })
739
740 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
741 if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
742
743 const b = await read($, badge)
744 const m = await read($, meta)
745 const p = await read($, palette)
746 const u = await read($, usage)
747 const body = bodyColor(m.seed, p.colors, m.color)
748 const name = critterName(m.seed)
749 const width = e.props.bodyColumns
750 const list = gauges(u)
751 const spent = u.usd === undefined ? undefined : `$${u.usd.toFixed(2)}`
752
753 const ringsWidth = list.length * GAUGE_WIDTH + (spent ? SPENT_WIDTH : 0)
754 const avatarWidth = AVATAR_COLUMNS + 1
755 const mode = e.surface !== 'terminal'
756 ? (width >= INFO_MIN + COMPACT_WIDTH ? 'bars' : 'none')
757 : width >= avatarWidth + INFO_MIN + ringsWidth
758 ? 'rings'
759 : width >= avatarWidth + INFO_MIN + COMPACT_WIDTH ? 'bars' : 'none'
760 const sideWidth = mode === 'rings' ? ringsWidth : mode === 'bars' ? COMPACT_WIDTH : 0
761 const infoWidth = Math.max(10, width - avatarWidth - sideWidth - 1)
762
763 const info = (Box: any, Text: any) => (
764 <Box flexDirection="column" marginLeft={1} width={infoWidth}>
765 <Text wrap="truncate">
766 <Text bold color={body}>{name}</Text>
767 <Text> </Text>
768 <Text color={p.dark} backgroundColor={body} bold>{` ${b.role.toUpperCase()} `}</Text>
769 </Text>
770 <Text wrap="truncate">
771 <Text color={body}>▸ </Text>
772 <Text bold>{b.task}</Text>
773 </Text>
774 <Text dimColor wrap="truncate">
775 {b.history.length > 0 ? `↳ earlier: ${b.history.join(' · ')}` : '↳ earlier: nothing yet'}
776 </Text>
777 <Text dimColor wrap="truncate">{`${m.model} · ${m.folder}`}</Text>
778 </Box>
779 )
780
781 const bars = (Box: any, Text: any) => (
782 <Box flexDirection="column" width={COMPACT_WIDTH} marginLeft={1}>
783 {list.map(g => {
784 const color = levelColor(g.percent, body, p)
785 const bar = smoothBar(g.percent)
786 return (
787 <Text wrap="truncate">
788 <Text dimColor>{g.label.split(' ')[0]!.padEnd(8)}</Text>
789 <Text color={color} backgroundColor={p.track}>{bar.full + bar.partial}</Text>
790 <Text backgroundColor={p.track}>{bar.empty}</Text>
791 <Text color={color} bold>{` ${String(Math.round(g.percent)).padStart(3)}%`}</Text>
792 <Text dimColor>{g.detail ? ` ${g.detail.replace('resets ', '↻ ')}` : ''}</Text>
793 </Text>
794 )
795 })}
796 {spent ? <Text dimColor wrap="truncate">{`Spent ${spent} this session`}</Text> : null}
797 </Box>
798 )
799
800 if (e.surface === 'terminal') {
801 const { Box, Text, Image } = $.ui.resolve(e)
802 bandRequestId = e.requestId
803 isWorking = isWorking || e.props.isWorking
804 const pose = currentPose(m.seed)
805 lastPose = poseId(pose)
806
807 const rings = (
808 <Box flexDirection="row" width={ringsWidth}>
809 {list.map((g, i) => {
810 const color = levelColor(g.percent, body, p)
811 return (
812 <Box flexDirection="row" width={GAUGE_WIDTH}>
813 <Image
814 key={`ring-${i}`}
815 source={ringSource(g.percent, color, p.track)}
816 columns={RING_COLUMNS}
817 rows={RING_ROWS}
818 alt={`${Math.round(g.percent)}%`}
819 />
820 <Box flexDirection="column" marginLeft={1} width={GAUGE_TEXT}>
821 <Text dimColor wrap="truncate">{g.label}</Text>
822 <Text wrap="truncate">
823 <Text color={color} bold>{`${Math.round(g.percent)}%`}</Text>
824 <Text dimColor> used</Text>
825 </Text>
826 <Text dimColor wrap="truncate">{g.detail}</Text>
827 </Box>
828 </Box>
829 )
830 })}
831 {spent ? (
832 <Box flexDirection="column" width={SPENT_WIDTH}>
833 <Text dimColor>Spent</Text>
834 <Text bold color={body}>{spent}</Text>
835 <Text dimColor>this session</Text>
836 </Box>
837 ) : null}
838 </Box>
839 )
840
841 return (
842 <Box flexDirection="row" paddingTop={1}>
843 <Image
844 key="avatar"
845 source={spriteSource(m.seed, p, pose, m.color)}
846 columns={AVATAR_COLUMNS}
847 rows={AVATAR_ROWS}
848 alt={name}
849 />
850 {info(Box, Text)}
851 {mode === 'rings' ? rings : mode === 'bars' ? bars(Box, Text) : null}
852 </Box>
853 )
854 }
855
856 const { Box, Text } = $.ui.resolve(e)
857 return (
858 <Box flexDirection="row" paddingTop={1}>
859 <Text color={body}>ʕ•ᴥ•ʔ</Text>
860 {info(Box, Text)}
861 {mode === 'bars' ? bars(Box, Text) : null}
862 </Box>
863 )
864 })
865}
866types/index.d.ts 41 lines1export type Badge = {
2 role: string
3 task: string
4 history: string[]
5}
6
7export type Meta = {
8 model: string
9 folder: string
10 seed: number
11 /** Colour assigned by Den, '' when the seed picks. */
12 color: string
13}
14
15export type Palette = {
16 colors: string[]
17 dark: string
18 warn: string
19 alert: string
20 track: string
21 blush: string
22}
23
24export type Usage = {
25 context?: number
26 filled?: number
27 window?: number
28 fiveHour?: number
29 fiveHourResets?: string
30 sevenDay?: number
31 sevenDayResets?: string
32 usd?: number
33 now?: number
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 'agent-badge': { badge: Badge; meta: Meta; palette: Palette; usage: Usage }
39 }
40}
41