SLOPSHOPPER

agent-badge

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…

newbandpromptmodeltimer
v1.2.1MITupdated 2026-10-08GlacierHubAB/agent-badge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-badge
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ▣ Spicy Hedgehog Spicy Hedgehog NEW AGENT Context ████▉ 49% 103k le… ▸ waiting for a first task 5-hour ███▏ 31% ↳ earlier: nothing yet Spent $0.42 this session Opus 5.5 · /work/app ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ Spicy Hedgehog Spicy Hedgehog NEW AGENT Context ████▉ 49% 10 ▸ waiting for a first task 5-hour ███▏ 31% ↳ earlier: nothing yet Spent $0.42 this session Opus 5.5 · /work/app
README

Agent Badge

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

Features

  • Never lose track of a session's history. Every time you switch context mid-session, Agent Badge spots it and keeps a trail of what the session worked on before (the last three tasks), right under the current one. Follow-ups, refinements and "thanks" don't count as switches, so the trail stays clean. Labels are saved per session, so claude --resume brings the trail back too.
  • A critter per session. Each session gets its own procedurally generated 16×16 pixel-art creature and name (e.g. "Fuzzy Koala"), seeded from the session id, so a resumed session keeps the same one. It breathes and blinks when idle, and bounces and looks around while Claude is working.
  • Role and current task. After each prompt you type, a small Haiku call labels the session's role (coding agent, copywriter, media editor, researcher…) and what it's working on now.
  • Model and folder of the session.
  • Gauges. Ring charts for the context window and, on subscription plans, the 5-hour and weekly usage limits, each with plain-language detail ("124k left", "resets in 2h 10m"), plus the session's spend. They switch to your theme's warning colour at 75% and alert colour at 90%.
  • Responsive. Rings on wide terminals, slim bars on narrower ones, gauges hidden when space is tight.

Requirements

  • Claude Code 2.1.289 or newer. Agent Badge is built on Claude Code's function-hooks plugin API, which is in early access and may change between releases.
  • A terminal with the kitty graphics protocol (Ghostty, kitty) for the image avatar and ring gauges. Other terminals show text in their place. The desktop app gets bars and a text critter.

Install

/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.

Theming

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.

Hooks

Agent Badge hooks these Claude Code events. None of them changes what passes through; each passes its input on unchanged.

EventWhat it does
prompt.submitReads 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.startRestores the session's saved labels, reads the model, folder, theme and usage, and starts the refresh and animation timers.
session.measurePicks up new context, usage-limit and cost figures after each turn.
turn.start / turn.completeSwitches 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.

Sharing with other tools

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.

Privacy and cost

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.

Development

claude plugin validate .
claude plugin test .

License

MIT

Source 2 files
hooks/register.tsx 866 lines
1import { 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}
866
types/index.d.ts 41 lines
1export 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