Band above the prompt for every subagent: pixel avatars by role, cost, tokens read, context, steps and time.

<img src="assets/hero.png" alt="A pixel crew of Claude agents working above the Claude Code prompt box" width="100%">
A tiny pixel crew that lives above your prompt box in Claude Code. Watch your helper agents work, see what they cost, and know if your weekly limit will last.
Watch it · What it does · Install · Did it work? · Help! · Remove it
You ask Claude for help, the crew shows up above the box where you type, and each helper cheers when its job is done.
<img src="assets/demo.svg" alt="Animated demo of the Claude app: you ask for three agents, they appear above the prompt box, finish one by one and fold away" width="100%">
Two small add-ons (Claude calls them mods) for Claude Code. You install both with one line.
| Mod | What it shows | When you see it |
|---|---|---|
| Agent panel | Your crew of agents, what each one is doing, and your weekly usage bar | Always, right above the box where you type |
| Context band | How full Claude's memory is, with a one-click tidy-up button | Only when the memory is 60% full or more |
Both work in the Code tab of the Claude desktop app and in Claude Code in the Terminal.
When Claude hands a job to a helper agent, that helper gets a character. Claude picks the character from words in the job's name.
| Character | Shows up when the job says... | |
|---|---|---|
| <img src="assets/crew/main-running.svg" width="56" alt="Captain"> | The Captain | Always there. This is your main chat. |
| <img src="assets/crew/architect-running.svg" width="56" alt="Architect"> | The Architect | "plan" or "architect" |
| <img src="assets/crew/developer-running.svg" width="56" alt="Developer"> | The Developer | "build" or "develop" |
| <img src="assets/crew/reviewer-running.svg" width="56" alt="Reviewer"> | The Reviewer | "review", "verify" or "check" |
| <img src="assets/crew/shipper-running.svg" width="56" alt="Shipper"> | The Shipper | "ship" or "deploy" |
| <img src="assets/crew/scout-running.svg" width="56" alt="Scout"> | The Scout | "scout" |
| <img src="assets/crew/explore-running.svg" width="56" alt="Explorer"> | The Explorer | "explore" |
| <img src="assets/crew/agent-running.svg" width="56" alt="Helper"> | The Helper | Anything else |
They move. The Developer bobs to music with headphones on, the Reviewer sweeps a magnifying glass, and the Shipper hops out the door with a box.
Try it: after you install, type this into Claude and watch the crew appear: "Use three agents at once: one to plan a birthday party, one to build a shopping list, and one to review both."
<img src="assets/band-anatomy.png" alt="The agent panel with six numbered parts explained" width="100%">
<img src="assets/moods.png" alt="Four faces: working, done, failed, waiting" width="100%">
Claude Code gives you a weekly allowance. This bar tells you, at a glance, if you are using it too fast.
<img src="assets/pace.png" alt="Four pace levels: under pace, on pace, ahead of pace, over pace" width="100%">
In plain words: if the first number is smaller than the second, you are fine. If it is bigger, slow down a little.
<img src="assets/context-band.png" alt="The context band with Compact now and Later buttons" width="100%">
Think of Claude's memory as a notebook. The longer you chat, the fuller it gets, and a full notebook makes Claude slower and pricier. Compact now asks Claude to write a short summary on a fresh page and carry on from there.
You need the Claude desktop app with a paid plan (Pro or Max). That's it.
Install the Claude Code mods from github.com/mshadmanrahman/claude-agent-panel for me. Run: claude plugin marketplace add mshadmanrahman/claude-agent-panel, then claude plugin install agent-panel@claude-agent-panel and claude plugin install context-band@claude-agent-panel. Tell me when all three say Successfully.
<img src="assets/step-0-ask-claude.png" alt="The Claude app Code tab with the install message pasted in" width="100%">
When Claude says all three worked, jump to Did it work?
Only three steps, and you never have to understand what the words mean.
Step 1. Open Terminal.
<img src="assets/step-1-terminal.png" alt="Spotlight search with Terminal typed in" width="100%">
Step 2. Copy this line (click the copy icon on the right of the box), paste it into Terminal with ⌘ Command + V, then press Enter.
claude plugin marketplace add mshadmanrahman/claude-agent-panel && claude plugin install agent-panel@claude-agent-panel && claude plugin install context-band@claude-agent-panel
<img src="assets/step-2-paste.png" alt="Terminal with the install line pasted in" width="100%">
Step 3. Wait about ten seconds. Look for three green ticks.
<img src="assets/step-3-success.png" alt="Terminal showing three green Successfully lines" width="100%">
Three ✔ lines means you are done. You can close Terminal.
No crew? Check the list below.
| What you see | What to do | |
|---|---|---|
zsh: command not found: claude | Your Mac does not have the Claude Code command yet. Paste this into Terminal, press Enter, wait for it to finish, then close and reopen Terminal and try Step 2 again: `curl -fsSL https://claude.ai/install.sh \ | bash` |
| No ticks, a red error instead | Check your internet, then paste the line again. Running it twice is safe. | |
| Ticks worked, but no panel | Quit the Claude app with ⌘ Command + Q (closing the window is not enough), open it, start a new chat. | |
| The panel is there but the Usage bar is missing | The bar fills in after your first message. Send Claude anything, like "hi". | |
| The context band never shows | That's normal. It only appears when Claude's memory is 60% full. | |
| The panel takes too much room | Click Hide on the right. Click Show agents to bring it back. |
Still stuck? Open an issue and paste a screenshot. Or ask your teacher.
Paste this into Terminal (or ask Claude in the Code tab to run it for you), then restart the app:
claude plugin marketplace update claude-agent-panel
Changed your mind? This takes both mods out. Restart the app afterwards.
claude plugin uninstall agent-panel@claude-agent-panel && claude plugin uninstall context-band@claude-agent-panel
Claude Code lets you add small programs, called mods, that draw things on screen and listen for events like "an agent started". Each mod here is one TypeScript file:
agent-panel/hooks/register.tsx watches agents start and finish, adds up tokens and time, guesses the cost from public prices, and draws the panel.context-band/hooks/register.tsx checks how full the memory is after every turn and shows the band past 60%. Change const WARN = 60 to move that line.The costs are estimates with a ≈ sign. Your real bill comes from your Claude plan, not from this panel.
The characters are tiny SVG drawings animated with built-in SVG animation, so they move without slowing anything down.
Made by Shadman Rahman for his students. Share it with yours.
hooks/register.tsx 593 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, AgentUsage, MainChat, Run, Week } from '../types'
5
6const runAtom = atom({ plugin: 'agent-panel', key: 'run' } as const, null as Run | null)
7const agentsAtom = atom({ plugin: 'agent-panel', key: 'agents' } as const, [] as AgentRow[])
8const usageAtom = atom({ plugin: 'agent-panel', key: 'usage' } as const, {} as Record<string, AgentUsage>)
9const nowAtom = atom({ plugin: 'agent-panel', key: 'now' } as const, 0)
10const doneOpenAtom = atom({ plugin: 'agent-panel', key: 'doneOpen' } as const, false)
11const minAtom = atom({ plugin: 'agent-panel', key: 'min' } as const, false)
12const weekAtom = atom({ plugin: 'agent-panel', key: 'week' } as const, null as Week | null)
13const mainAtom = atom({ plugin: 'agent-panel', key: 'main' } as const, null as MainChat | null)
14
15// Each agent row's context bar is scaled to a 1M-token window.
16const WINDOW = 1_000_000
17
18// Dollars per million tokens: [input, output]. Cache read 0.1x input, write 1.25x.
19const PRICES: [RegExp, number, number][] = [
20 [/fable/i, 10, 50],
21 [/opus/i, 4, 20],
22 [/sonnet/i, 2, 10],
23 [/haiku/i, 1, 5],
24]
25
26const EFFORT: Record<string, [string, string]> = {
27 max: ['heavy', '#D9553B'],
28 xhigh: ['heavy', '#D9553B'],
29 high: ['careful', '#D99A3B'],
30 medium: ['medium', '#4A9FD9'],
31 low: ['light', '#3BB8A0'],
32}
33
34// Clawd as pixel art, 11 x 10 pixels, drawn two pixels per cell with half blocks.
35// B body, E eye, H hat, F flag, . empty. Rows 0-1 are the hat, 7-8 the legs.
36const BODY_ROWS = [
37 '.BBBBBBBBB.',
38 '.BEBBBBBEB.',
39 'BBBBBBBBBBB',
40 'BBBBBBBBBBB',
41 '.BBBBBBBBB.',
42]
43const LEGS = [
44 ['.B.B...B.B.', '.B.B...B.B.'],
45 ['..B.B.B.B..', '.B.B...B.B.'],
46]
47const HATS: Record<string, { rows: [string, string]; color: number; accent?: number }> = {
48 architect: { rows: ['...HHHHH...', '..HHHHHHH..'], color: 0x4a7fd9 },
49 developer: { rows: ['...HHHHH...', '.HHHHHHHHH.'], color: 0xe8b931 },
50 reviewer: { rows: ['..HHHHH....', '..HHHHHHHH.'], color: 0x9aa0a6 },
51 'tech lead': { rows: ['..HHHHH....', '..HHHHHHHH.'], color: 0x9aa0a6 },
52 shipper: { rows: ['..HHHH..FF.', '.HHHHHH.F..'], color: 0x3ba55c, accent: 0xf2f2f2 },
53 scout: { rows: ['...........', '.HHHHHHHHHF'], color: 0x3bb8a0, accent: 0x3bb8a0 },
54 explore: { rows: ['.....H.....', '...HHHHH...'], color: 0x9b6bd9 },
55}
56const BODY = { running: 0xd97757, done: 0xd97757, failed: 0xc0392b, scheduled: 0x8a5a44 }
57const EYE = 0x2b1d17
58const DEFAULT = 0x01000000
59
60const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
61function base64(bytes: Uint8Array): string {
62 let out = ''
63 for (let i = 0; i < bytes.length; i += 3) {
64 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
65 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
66 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
67 out += i + 2 < bytes.length ? B64[n & 63]! : '='
68 }
69 return out
70}
71
72// 11x10 pixel grid of colors (-1 = transparent), shared by the terminal Raster and the desktop Svg.
73function pixels(role: string, body: number, frame: number): number[][] {
74 const hat = HATS[role]
75 const legs = LEGS[frame % 2]!
76 const px = [...(hat?.rows ?? ['...........', '...........']), ...BODY_ROWS, legs[0], legs[1], '...........']
77 const color = (c: string | undefined): number =>
78 c === 'B' ? body : c === 'E' ? EYE : c === 'H' ? (hat?.color ?? DEFAULT) : c === 'F' ? (hat?.accent ?? DEFAULT) : -1
79 return px.map(row => Array.from({ length: 11 }, (_, x) => color(row?.[x])))
80}
81
82const spriteCache = new Map<string, string>()
83function sprite(role: string, body: number, frame: number): string {
84 const id = `${role}|${body}|${frame}`
85 const hit = spriteCache.get(id)
86 if (hit) return hit
87 const g = pixels(role, body, frame)
88 const words: number[] = []
89 for (let r = 0; r < 10; r += 2) {
90 for (let x = 0; x < 11; x++) {
91 const top = g[r]?.[x] ?? -1
92 const bot = g[r + 1]?.[x] ?? -1
93 if (top < 0 && bot < 0) words.push(0x20, DEFAULT, DEFAULT)
94 else if (top < 0) words.push(0x2584, bot, DEFAULT)
95 else if (bot < 0) words.push(0x2580, top, DEFAULT)
96 else if (top === bot) words.push(0x2588, top, DEFAULT)
97 else words.push(0x2580, top, bot)
98 }
99 }
100 const cells = base64(new Uint8Array(Uint32Array.from(words).buffer))
101 spriteCache.set(id, cells)
102 return cells
103}
104
105// Desktop avatars: one SVG per role and state, animated with SMIL so nothing re-renders per frame.
106type Mood = 'running' | 'done' | 'failed' | 'scheduled'
107const R = (x: number, y: number, w: number, h: number, fill: string, inner = '') =>
108 `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}">${inner}</rect>`
109const loop = (attr: string, values: string, dur: string, extra = '') =>
110 `<animate attributeName="${attr}" values="${values}" dur="${dur}" repeatCount="indefinite" ${extra}/>`
111const move = (type: string, values: string, dur: string, extra = '') =>
112 `<animateTransform attributeName="transform" type="${type}" values="${values}" dur="${dur}" repeatCount="indefinite" ${extra}/>`
113
114type Persona = {
115 motion?: string // on the whole body while running
116 shadow?: string // shadow rx while running
117 flip?: string // discrete left/right turn while running
118 arms?: (live: boolean, B: string) => string
119 back?: (live: boolean) => string // drawn behind the body
120 front?: (live: boolean) => string // drawn over the body
121 hidesEyes?: boolean
122 over?: (live: boolean) => string // above the head, never flipped
123}
124
125const PERSONAS: Record<string, Persona> = {
126 // Blue hard hat and round glasses, holding up a blueprint whose system diagram draws itself while it works.
127 architect: {
128 arms: (live, B) => R(4, 12, 2, 2, B) + R(20, 11, 2, 2, B, live ? loop('y', '11;10;11', '0.7s') : ''),
129 back: () => R(9, 4, 8, 2, '#4A7FD9') + R(7, 6, 12, 3, '#4A7FD9') + R(12.5, 3.6, 1, 5.4, '#2F5FA8') + R(5, 8.4, 16, 1, '#2F5FA8'),
130 front: live =>
131 `<circle cx="9.5" cy="12" r="1.8" fill="none" stroke="#2B1D17" stroke-width="0.5"/>` +
132 `<circle cx="16.5" cy="12" r="1.8" fill="none" stroke="#2B1D17" stroke-width="0.5"/>` +
133 R(11.3, 11.7, 3.4, 0.5, '#2B1D17') +
134
135 R(22, 8, 7, 9, '#2F5FA8') + R(21.6, 7.4, 7.8, 1, '#4A7FD9') + R(21.6, 16.6, 7.8, 1, '#4A7FD9') +
136 `<path d="M24.3 9.2 h2.4 v1.6 h-2.4 Z M25.5 10.8 V12.2 M23.9 12.2 H27.2 M23.9 12.2 V13.6 M27.2 12.2 V13.6 M22.8 13.6 h2.2 v1.6 h-2.2 Z M26.1 13.6 h2.2 v1.6 h-2.2 Z" fill="none" stroke="#EAF3FF" stroke-width="0.5" pathLength="1" stroke-dasharray="1" stroke-dashoffset="0"></path>`,
137 },
138 // Boy Scout: campaign hat and neckerchief, hops, raises the binoculars to scan, turns, spots something.
139 scout: {
140 arms: (live, B) =>
141 `<g transform="translate(0 ${live ? -3.5 : 0})">${live ? move('translate', '0 -3.5;0 -4.5;0 -3.5', '0.7s') : ''}${R(5, 14, 2, 2, B)}${R(19, 14, 2, 2, B)}</g>`,
142 back: () =>
143 R(10, 2, 6, 1, '#B08A55') + R(12.5, 2, 1, 1, '#8C6A3E') + R(9, 3, 8, 1, '#B08A55') + R(8, 4, 10, 2.4, '#B08A55') +
144 R(8, 5.4, 10, 0.8, '#6B4F3A') + R(3, 6.4, 20, 1.4, '#B08A55') + R(3, 7.8, 20, 0.4, '#8C6A3E'),
145 front: live =>
146 R(8, 14, 10, 1, '#D9553B') + R(9, 15, 8, 1, '#D9553B') + R(11, 16, 4, 1, '#D9553B') + R(12, 14, 2, 1.2, '#FFD54A') +
147 `<g transform="translate(0 ${live ? 0 : 4.5})">${live ? move('translate', '0 0;0 -1;0 0', '0.7s') : ''}` +
148 R(7, 10, 4, 3, '#33363B') + R(15, 10, 4, 3, '#33363B') + R(11, 11, 4, 1, '#33363B') +
149 R(8, 11, 1, 1, '#8FD3FF') + R(16, 11, 1, 1, '#8FD3FF') + `</g>`,
150 },
151 // In the zone: headphones on, bobs to the beat, types fast, code floats up off the screen.
152 developer: {
153 arms: (live, B) =>
154 R(7, 18, 2, 1.4, B, live ? loop('y', '18;17;18', '0.24s') : '') +
155 R(17, 18, 2, 1.4, B, live ? loop('y', '17;18;17', '0.24s') : ''),
156 back: () => R(6, 6, 14, 1, '#33363B') + R(5, 7, 1, 4, '#33363B') + R(20, 7, 1, 4, '#33363B'),
157 front: live =>
158 R(4, 10, 2, 3, '#E8B931') + R(20, 10, 2, 3, '#E8B931') +
159 (live ? R(6, 12, 14, 2, '#9FE8FF', loop('opacity', '0.15;0.35;0.15', '0.5s')) : '') +
160 R(8, 14, 10, 5, '#5F6670') + R(12.5, 16, 1, 1, '#9AA0A6') + R(6, 19, 14, 1, '#9AA0A6'),
161 },
162 // Inspects: sweeps a magnifying glass across the work.
163 reviewer: {
164 back: () => R(7, 6, 12, 3, '#9AA0A6') + R(13, 8, 8, 1, '#6B7076'),
165 front: live =>
166 `<g>${live ? move('translate', '0 0;1 0;0 0', '0.7s') : ''}<circle cx="9.5" cy="12" r="2.6" fill="#CFE9FF" fill-opacity="0.45" stroke="#33363B" stroke-width="0.6"/>${R(11.2, 14, 1, 1, '#6B4F3A')}${R(12, 15, 1, 1, '#6B4F3A')}${R(12.8, 16, 1, 1, '#6B4F3A')}</g>`,
167 },
168 // Carries the box out the door with big hops.
169 shipper: {
170 arms: (_l, B) => R(5, 6, 2, 4, B) + R(19, 6, 2, 4, B),
171 back: live => `<g>${live ? move('translate', '0 0;0 -1;0 0', '0.6s') : ''}${R(7, 0, 12, 6, '#C68B4E')}${R(12.5, 0, 1, 6, '#E9D3A6')}</g>`,
172 },
173 explore: {
174 flip: move('scale', '1 1;-1 1', '4s', 'calcMode="discrete" keyTimes="0;0.5"'),
175 back: () => R(11, 2, 4, 2, '#9B6BD9') + R(9, 4, 8, 2, '#9B6BD9') + R(7, 6, 12, 3, '#9B6BD9') + R(12, 4, 1, 1, '#FFD54A'),
176 },
177 agent: {},
178 // The main chat: a captain's cap, white top, navy band, gold badge, brim over the brow.
179 main: {
180 back: () =>
181 R(7, 2, 12, 1, '#C9CED6') + R(5, 3, 16, 1, '#C9CED6') + R(6, 3, 14, 1, '#FFFFFF') + R(5, 4, 16, 2, '#FFFFFF') +
182 R(5, 6, 16, 1, '#C9CED6') + R(7, 7, 12, 2, '#1F2A44') + R(12, 6, 2, 2, '#E8B931'),
183 front: () => R(7, 9, 12, 1, '#111827') + R(8, 10, 10, 1, '#111827'),
184 },
185}
186PERSONAS['tech lead'] = PERSONAS.reviewer!
187
188const avatarCache = new Map<string, string>()
189function avatarSvg(role: string, mood: Mood): string {
190 const id = `${role}|${mood}`
191 const hit = avatarCache.get(id)
192 if (hit) return hit
193 const p = PERSONAS[role] ?? PERSONAS.agent!
194 const live = mood === 'running'
195 const B = mood === 'failed' ? '#C0392B' : '#D97757'
196 const E = '#2B1D17'
197 const blink = (x: number) =>
198 R(x, 11, 1, 2, E, live ? loop('height', '2;2;0.3;2', '0.9s', 'keyTimes="0;0.8;0.9;1"') + loop('y', '11;11;12.2;11', '0.9s', 'keyTimes="0;0.8;0.9;1"') : '')
199 const eyes =
200 mood === 'done' && (role === 'architect' || role === 'main')
201 ? blink(9) + blink(16)
202 : mood === 'done'
203 ? R(8, 12, 1, 1, E) + R(9, 11, 1, 1, E) + R(10, 12, 1, 1, E) + R(15, 12, 1, 1, E) + R(16, 11, 1, 1, E) + R(17, 12, 1, 1, E)
204 : mood === 'failed'
205 ? R(8, 10, 1, 1, E) + R(10, 10, 1, 1, E) + R(9, 11, 1, 1, E) + R(8, 12, 1, 1, E) + R(10, 12, 1, 1, E) +
206 R(15, 10, 1, 1, E) + R(17, 10, 1, 1, E) + R(16, 11, 1, 1, E) + R(15, 12, 1, 1, E) + R(17, 12, 1, 1, E)
207 : mood === 'scheduled'
208 ? R(8, 12, 3, 0.6, E) + R(15, 12, 3, 0.6, E)
209 : p.hidesEyes ? '' : blink(9) + blink(16)
210 const legs = [7, 10, 15, 18].map(x => R(x, 17, 1, 3, B)).join('')
211 const figure =
212 (p.back?.(live) ?? '') +
213 legs +
214 R(6, 9, 14, 8, B) +
215 (p.arms ? p.arms(live, B) : R(4, 12, 2, 2, B) + R(20, 12, 2, 2, B)) +
216 eyes +
217 (p.front?.(live) ?? '')
218 const turned = live && p.flip ? `<g transform="translate(13 0)"><g>${p.flip}<g transform="translate(-13 0)">${figure}</g></g></g>` : figure
219 const zzz =
220 mood === 'scheduled'
221 ? `<text x="20" y="5" font-size="4" font-family="monospace" font-weight="bold" fill="#9AA0A6">z</text><text x="23" y="2" font-size="3" font-family="monospace" font-weight="bold" fill="#9AA0A6">z</text>`
222 : ''
223 const svg =
224 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 33 27" shape-rendering="crispEdges">` +
225 `<ellipse cx="13" cy="21.6" rx="7" ry="0.9" fill="#000" fill-opacity="0.18">${live && p.shadow ? p.shadow : ''}</ellipse>` +
226 `<g${mood === 'scheduled' ? ' opacity="0.55"' : ''}>${live && p.motion ? p.motion : ''}${turned}</g>` +
227 (p.over?.(live) ?? '') +
228 zzz +
229 `</svg>`
230 avatarCache.set(id, svg)
231 return svg
232}
233
234// Working indicator: three dots dimming in turn. The rest state is full brightness, since every pane redraw restarts the animation.
235const dotsCache = new Map<string, string>()
236function dotsSvg(color: string): string {
237 const hit = dotsCache.get(color)
238 if (hit) return hit
239 const dot = (cx: number, begin: string) =>
240 `<circle cx="${cx}" cy="5" r="1.6" fill="${color}">` +
241 `<animate attributeName="opacity" values="1;0.45;1;1" keyTimes="0;0.25;0.5;1" dur="0.8s" begin="${begin}" repeatCount="indefinite"/>` +
242 `</circle>`
243 const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 8">${dot(3, '0s')}${dot(8, '0.13s')}${dot(13, '0.26s')}</svg>`
244 dotsCache.set(color, svg)
245 return svg
246}
247
248function roleOf(type: string, description: string): string {
249 const s = `${type} ${description}`.toLowerCase()
250 if (/architect|plan/.test(s)) return 'architect'
251 if (/develop|build/.test(s)) return 'developer'
252 if (/tech.?lead/.test(s)) return 'tech lead'
253 if (/review|verify|gate/.test(s)) return 'reviewer'
254 if (/ship|deploy/.test(s)) return 'shipper'
255 if (/scout/.test(s)) return 'scout'
256 if (/explore/.test(s)) return 'explore'
257 return 'agent'
258}
259
260function modelName(id: string): string {
261 const m = id.match(/(opus|sonnet|haiku|fable)-(\d+)(?:-(\d))?/i)
262 if (!m) return id
263 const f = m[1] ?? ''
264 const fam = f.charAt(0).toUpperCase() + f.slice(1)
265 return m[3] ? `${fam} ${m[2]}.${m[3]}` : `${fam} ${m[2]}`
266}
267
268function price(model: string, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }): number {
269 const p = PRICES.find(([re]) => re.test(model)) ?? PRICES[1]!
270 const inn = p[1] / 1e6
271 return (
272 u.input_tokens * inn +
273 (u.cache_read_input_tokens ?? 0) * inn * 0.1 +
274 (u.cache_creation_input_tokens ?? 0) * inn * 1.25 +
275 u.output_tokens * (p[2] / 1e6)
276 )
277}
278
279const k = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`)
280const clock = (ms: number) => {
281 const s = Math.max(0, Math.floor(ms / 1000))
282 const h = Math.floor(s / 3600)
283 const mm = String(Math.floor((s % 3600) / 60)).padStart(h ? 2 : 1, '0')
284 const ss = String(s % 60).padStart(2, '0')
285 return h ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
286}
287
288const WEEK = 7 * 24 * 3600 * 1000
289
290// Weekly usage against the share of the week elapsed, shown on the band's totals line.
291async function refreshWeek($: EngineInterface) {
292 try {
293 const { rateLimits } = await $.session.usage()
294 const week = rateLimits.find(r => r.kind.includes('seven_day'))
295 if (!week?.resetsAt) return update($, weekAtom, () => null)
296 const left = Math.max(0, new Date(week.resetsAt).getTime() - (await $.clock.now()))
297 const elapsed = Math.min(1, Math.max(0.01, 1 - left / WEEK))
298 const pace = week.percentUsed / (elapsed * 100)
299 const verdict = pace > 1.15 ? 'over pace' : pace > 1 ? 'ahead of pace' : pace < 0.85 ? 'under pace' : 'on pace'
300 await update($, weekAtom, () => ({ used: Math.round(week.percentUsed), elapsed: Math.round(elapsed * 100), verdict }))
301 } catch {
302 // No usage this tick; the next turn retries.
303 }
304}
305
306export const register: Register = on => {
307 on('session.start', async ($, e, next) => {
308 await refreshWeek($)
309 // Tick the clock and poll agent status while anything runs.
310 // A reload drops the previous environment's timers, so this never stacks.
311 // Every 5 seconds: each pane redraw restarts the avatar and dot animations, so fewer redraws means fewer snaps.
312 $.clock.every(5000, async () => {
313 try {
314 const agents = await read($, agentsAtom)
315 const now = await $.clock.now()
316 const main = await read($, mainAtom)
317 if (main?.turnStartedAt) await update($, nowAtom, () => now)
318 if (!agents.some(a => a.status === 'running' || (a.endedAt && now - a.endedAt < 600_000))) return
319 await update($, nowAtom, () => now)
320 const list = await $.agent.list()
321 const byId = new Map(list.map(a => [a.id, a.status]))
322 await update($, agentsAtom, rows =>
323 rows.map((r): AgentRow => {
324 if (r.status !== 'running') return r
325 const st = r.agentId ? byId.get(r.agentId) : undefined
326 // Unknown to the engine: no id after 15 minutes, or an id the list has dropped. Close the row.
327 if (!st) {
328 const lost = r.agentId ? now - r.startedAt > 10_000 : now - r.startedAt > 900_000
329 return lost ? { ...r, status: r.agentId ? 'done' : 'failed', endedAt: now } : r
330 }
331 if (st === 'running' || st === 'pending')
332 return r.status === 'running' ? r : { ...r, status: 'running', endedAt: undefined }
333 return { ...r, status: st === 'completed' ? 'done' : 'failed', endedAt: now }
334 }),
335 )
336 } catch {
337 // A failed poll skips one tick; the next second retries.
338 }
339 })
340 return next(e)
341 })
342
343 on('agent.spawn', async ($, e, next) => {
344 const now = await $.clock.now()
345 // The first subagent after a quiet spell starts a fresh run.
346 const run = await read($, runAtom)
347 const prior = await read($, agentsAtom)
348 const idle = !prior.some(a => a.status === 'running' || (a.endedAt ?? 0) > now - 120_000)
349 if (!run || (idle && now - run.startedAt > 120_000)) {
350 await update($, runAtom, () => ({ workflow: 'Subagents', startedAt: now, scheduled: [] }))
351 await update($, agentsAtom, () => [])
352 await update($, usageAtom, () => ({}))
353 }
354 const role = roleOf(e.subagentType, e.description)
355 const row: AgentRow = {
356 key: e.tool_use_id,
357 description: e.description,
358 type: e.subagentType,
359 role,
360 model: e.model ?? e.parentModel,
361 status: 'running',
362 startedAt: now,
363 }
364 await update($, agentsAtom, rows => [...rows, row].slice(-40))
365 await update($, nowAtom, () => now)
366 await update($, runAtom, run =>
367 run ? { ...run, scheduled: run.scheduled.filter((r, i) => i !== run.scheduled.indexOf(role)) } : run,
368 )
369 const res = await next(e)
370 if (res.deny !== undefined) {
371 await update($, agentsAtom, rows => rows.map((r): AgentRow => (r.key === row.key ? { ...r, status: 'failed', endedAt: now } : r)))
372 } else {
373 await update($, agentsAtom, rows =>
374 rows.map(r => (r.key === row.key ? { ...r, agentId: res.agentId, model: res.model } : r)),
375 )
376 }
377 return res
378 })
379
380 // A foreground Agent call resolves when its subagent is finished.
381 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
382 const res = await next(e)
383 const out = res.result as { totalDurationMs?: number; totalTokens?: number } | undefined
384 if (res.deny !== undefined || res.isError || out?.totalDurationMs !== undefined) {
385 const now = await $.clock.now()
386 const failed = res.deny !== undefined || !!res.isError
387 await update($, agentsAtom, rows =>
388 rows.map((r): AgentRow =>
389 r.key === e.tool_use_id && r.status === 'running'
390 ? { ...r, status: failed ? 'failed' : 'done', endedAt: now, engineTokens: out?.totalTokens }
391 : r,
392 ),
393 )
394 await update($, nowAtom, () => now)
395 }
396 return res
397 })
398
399 // The main chat's own row: working from turn.start to its turn.complete. Subagent runs raise no turn.start.
400 on('turn.start', async ($, e, next) => {
401 const now = await $.clock.now()
402 await update($, mainAtom, m => ({ ...(m ?? { model: '', tokens: 0, ctx: 0, steps: 0, cost: 0, activeMs: 0 }), turnStartedAt: now }))
403 await update($, nowAtom, () => now)
404 return next(e)
405 })
406
407 on('turn.complete', async ($, e, next) => {
408 if (!e.agentId) await refreshWeek($)
409 if (!e.agentId)
410 await update($, mainAtom, m => (m ? { ...m, turnStartedAt: undefined, activeMs: m.activeMs + e.durationMs } : m))
411 return next(e)
412 })
413
414 on('turn.step', async function* ($, e, next) {
415 const res = yield* next(e)
416 if (!res.usage) return res
417 const u = res.usage
418 const ctx = u.input_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
419 const effort = typeof e.effort === 'string' ? e.effort : undefined
420 if (!e.agentId) {
421 await update($, mainAtom, m => {
422 const prev = m ?? { model: u.model, tokens: 0, ctx: 0, steps: 0, cost: 0, activeMs: 0 }
423 return {
424 ...prev,
425 model: u.model,
426 effort: effort ?? prev.effort,
427 tokens: prev.tokens + ctx + u.output_tokens,
428 ctx: ctx + u.output_tokens,
429 steps: prev.steps + 1,
430 cost: prev.cost + price(u.model, u),
431 }
432 })
433 return res
434 }
435 const id = e.agentId
436 await update($, usageAtom, all => {
437 const prev = all[id] ?? { model: u.model, tokens: 0, ctx: 0, cost: 0, steps: 0 }
438 return {
439 ...all,
440 [id]: {
441 model: u.model,
442 effort: effort ?? prev.effort,
443 tokens: prev.tokens + ctx + u.output_tokens,
444 ctx: ctx + u.output_tokens,
445 steps: (prev.steps ?? 0) + 1,
446 cost: prev.cost + price(u.model, u),
447 },
448 }
449 })
450 return res
451 })
452
453 // The band above the prompt: totals, one row per agent, finished agents folded into one row.
454 // Whatever the plugins beneath draw (the context band) stays on top.
455 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
456 const below = await next(e)
457 if (e.props.hasSurvey) return below
458 const ui = $.ui.resolve(e)
459 const { Box, Text, Button } = ui
460 const Svg = e.surface === 'terminal' ? undefined : (ui as Partial<Elements['desktop']>).Svg
461 const run = await read($, runAtom)
462 const agents = await read($, agentsAtom)
463 const usage = await read($, usageAtom)
464 const now = (await read($, nowAtom)) || (await $.clock.now())
465 const doneOpen = await read($, doneOpenAtom)
466 const main = await read($, mainAtom)
467 // Older sessions stored this as text; ignore that until the next refresh.
468 const weekRaw = await read($, weekAtom)
469 const week = weekRaw && typeof weekRaw === 'object' ? weekRaw : null
470 const min = await read($, minAtom)
471 // Narrow bands drop the model and context columns first.
472 const wide = e.props.bodyColumns >= 80
473
474 const rows = agents.map(a => ({ a, u: a.agentId ? usage[a.agentId] : undefined }))
475 // Totals include the main chat: its whole session plus this run's subagents.
476 const cost = (main?.cost ?? 0) + rows.reduce((s, r) => s + (r.u?.cost ?? 0), 0)
477 const tokens = (main?.tokens ?? 0) + rows.reduce((s, r) => s + (r.u?.tokens ?? 0), 0)
478 // Time is the longer of the main chat's working time and this run's wall clock, since subagents can outlast a turn.
479 const mainMs = main ? main.activeMs + (main.turnStartedAt ? now - main.turnStartedAt : 0) : 0
480 const started = run?.startedAt ?? agents[0]?.startedAt ?? now
481 const running = rows.filter(r => r.a.status === 'running')
482 const finished = rows.filter(r => r.a.status !== 'running').reverse()
483 if (!main?.steps && !agents.length && !week) return below
484
485 const Avatar = (p: { id: string; role: string; mood: Mood }) =>
486 Svg ? (
487 <Box key={`av-${p.id}`} flexShrink={0}>
488 <Svg key={`svg-${p.id}-${p.mood}`} source={avatarSvg(p.role, p.mood)} alt={`${p.role} avatar`} width={50} height={41} />
489 </Box>
490 ) : null
491
492 const Bar = (p: { id: string; pct: number; color: string }) => {
493 const w = 6
494 const fill = Math.min(w, Math.round((p.pct / 100) * w))
495 return (
496 <Box key={`bar-${p.id}`} flexDirection="row" flexShrink={0}>
497 <Text color={p.color}>{'━'.repeat(fill)}</Text>
498 <Text dimColor>{'━'.repeat(w - fill)}</Text>
499 </Box>
500 )
501 }
502
503 const Row = ({ a, u, time, markOverride }: { a: AgentRow; u?: AgentUsage; time?: string; markOverride?: [string, string] }) => {
504 const eff = u?.effort ? EFFORT[u.effort] : undefined
505 const ctx = a.engineTokens ?? u?.ctx ?? 0
506 const mark = a.status === 'running' ? '…' : a.status === 'done' ? '✓' : '✗'
507 const markColor = a.status === 'running' ? '#D97757' : a.status === 'done' ? '#3BA55C' : '#C0392B'
508 const [shownMark, shownColor] = markOverride ?? [mark, markColor]
509 return (
510 <Box key={`row-${a.key}`} flexDirection="row" gap={1} alignItems="center">
511 {Avatar({ id: a.key, role: a.role, mood: a.status })}
512 {a.status === 'running' && !markOverride && Svg ? (
513 <Box key={`dots-${a.key}`} flexShrink={0}>
514 <Svg source={dotsSvg(markColor)} alt="working" width={20} height={10} />
515 </Box>
516 ) : (
517 <Text color={shownColor} bold>{shownMark}</Text>
518 )}
519 <Box flexShrink={1} minWidth={0}><Text bold wrap="truncate-end">{a.description}</Text></Box>
520 {wide && eff && <Text color={eff[1]}>{eff[0]}</Text>}
521 {wide && <Text dimColor wrap="truncate-end">{modelName(u?.model ?? a.model)}</Text>}
522 <Box flexGrow={1} />
523 {wide && u && Bar({ id: a.key, pct: Math.round((u.ctx / WINDOW) * 100), color: eff?.[1] ?? '#D97757' })}
524 {wide && u && <Text dimColor>{k(ctx)}</Text>}
525 <Text dimColor>{time ?? clock((a.endedAt ?? now) - a.startedAt)}</Text>
526 <Text dimColor>≈${(u?.cost ?? 0).toFixed(2)}</Text>
527 </Box>
528 )
529 }
530
531 const doneCost = finished.reduce((s, r) => s + (r.u?.cost ?? 0), 0)
532 const failed = finished.filter(r => r.a.status === 'failed').length
533
534 return (
535 // The app's empty notice slot sits in "below"; keep it out of the row gap so it adds no space above Agents.
536 <Box flexDirection="column">
537 {below}
538 <Box flexDirection="column" gap={1}>
539 <Box key="totals" flexDirection="row" gap={1} alignItems="center">
540 <Text bold>
541 {`Agents${min && running.length ? ` · ${running.length} running` : ''}`}
542 </Text>
543 <Text dimColor>
544 ≈${cost.toFixed(2)} · {k(tokens)} · {clock(Math.max(mainMs, agents.length ? now - started : 0))}
545 </Text>
546 <Box flexGrow={1} />
547 {week && <Text dimColor>Usage</Text>}
548 {week && (() => {
549 const w = 10
550 const fill = Math.min(w, Math.round((week.used / 100) * w))
551 const color = ({ 'over pace': '#C0392B', 'ahead of pace': '#D99A3B', 'under pace': '#3BA55C' } as Record<string, string>)[week.verdict] ?? '#9AA0A6'
552 return (
553 <Box key="week-bar" flexDirection="row" flexShrink={0}>
554 <Text color={color}>{'█'.repeat(fill)}</Text>
555 <Text dimColor>{'░'.repeat(w - fill)}</Text>
556 </Box>
557 )
558 })()}
559 {week && <Text dimColor wrap="truncate-end">{`${week.used}% / ${week.elapsed}% Week, ${week.verdict.replace(/\b\w/g, c => c.toUpperCase())}`}</Text>}
560 <Button key="min-toggle" label={min ? 'Show agents' : 'Hide'} onPress={() => update($, minAtom, v => !v)} />
561 </Box>
562 {!min && main && main.steps > 0 &&
563 Row({
564 a: {
565 key: 'main-chat',
566 description: 'Main agent',
567 type: 'main',
568 role: 'main',
569 model: main.model,
570 status: main.turnStartedAt ? 'running' : 'done',
571 startedAt: 0,
572 },
573 u: main,
574 time: clock(mainMs),
575 markOverride: main.turnStartedAt ? undefined : ['◦', '#9AA0A6'],
576 })}
577 {!min && running.map(r => Row({ a: r.a, u: r.u }))}
578 {!min && run && run.scheduled.length > 0 && (
579 <Text key="scheduled" dimColor wrap="truncate-end">◷ Next: {run.scheduled.join(', ')}</Text>
580 )}
581 {!min && finished.length > 0 && (
582 <Box key="done-row" flexDirection="row" gap={1} alignItems="center">
583 <Text dimColor>{`${finished.length - failed} done${failed ? `, ${failed} failed` : ''} · ≈$${doneCost.toFixed(2)}`}</Text>
584 <Button key="done-toggle" label={doneOpen ? 'Hide' : 'Show'} onPress={() => update($, doneOpenAtom, v => !v)} />
585 </Box>
586 )}
587 {!min && doneOpen && finished.map(r => Row({ a: r.a, u: r.u }))}
588 </Box>
589 </Box>
590 )
591 })
592}
593types/index.d.ts 58 lines1export type AgentStatus = 'running' | 'done' | 'failed'
2
3export type AgentRow = {
4 key: string
5 agentId?: string
6 description: string
7 type: string
8 role: string
9 model: string
10 status: AgentStatus
11 startedAt: number
12 endedAt?: number
13 engineTokens?: number
14}
15
16export type AgentUsage = {
17 model: string
18 effort?: string
19 tokens: number
20 ctx: number
21 steps?: number
22 cost: number
23}
24
25export type MainChat = AgentUsage & {
26 steps: number
27 activeMs: number
28 turnStartedAt?: number
29}
30
31export type Week = {
32 used: number
33 elapsed: number
34 verdict: string
35}
36
37export type Run = {
38 workflow: string
39 ticket?: string
40 startedAt: number
41 scheduled: string[]
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 'agent-panel': {
47 run: Run | null
48 agents: AgentRow[]
49 usage: Record<string, AgentUsage>
50 now: number
51 doneOpen: boolean
52 min: boolean
53 week: Week | null
54 main: MainChat | null
55 }
56 }
57}
58