SLOPSHOPPER

agent-panel

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

bandguardtimeragents
★ 2v0.1.0no licenseupdated 2026-10-18mshadmanrahman/claude-agent-panel/agent-panel
A shopper browsing a rack in a slop shop
README

<img src="assets/hero.png" alt="A pixel crew of Claude agents working above the Claude Code prompt box" width="100%">

Claude Agent Panel

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.

Works with Claude Code Free No coding needed Install time

Watch it · What it does · Install · Did it work? · Help! · Remove it


Watch it work

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%">


What you get

Two small add-ons (Claude calls them mods) for Claude Code. You install both with one line.

ModWhat it showsWhen you see it
Agent panelYour crew of agents, what each one is doing, and your weekly usage barAlways, right above the box where you type
Context bandHow full Claude's memory is, with a one-click tidy-up buttonOnly 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.


Meet the crew

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.

CharacterShows up when the job says...
<img src="assets/crew/main-running.svg" width="56" alt="Captain">The CaptainAlways 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 HelperAnything 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."


How to read the panel

<img src="assets/band-anatomy.png" alt="The agent panel with six numbered parts explained" width="100%">

Read the faces

<img src="assets/moods.png" alt="Four faces: working, done, failed, waiting" width="100%">

The weekly bar

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.

The context band

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


Install it (pick one way)

You need the Claude desktop app with a paid plan (Pro or Max). That's it.

🛋️ The lazy way: let Claude do it

  1. Open the Claude app.
  2. Click the Code tab at the top.
  3. Copy the box below. (Hover over it, then click the copy icon on the right.)
  4. Paste it into Claude and press Enter.
  5. If Claude asks for permission to run a command, click Allow.
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?

⌨️ The Terminal way (Mac)

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.


Did it work?

  1. Quit the Claude app fully (⌘ Command + Q), then open it again.
  2. Go to the Code tab and start a new chat.
  3. Look just above the box where you type. You should see Agents with the Captain, and a Usage bar on the right.

No crew? Check the list below.


Something went wrong

What you seeWhat to do
zsh: command not found: claudeYour 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 insteadCheck your internet, then paste the line again. Running it twice is safe.
Ticks worked, but no panelQuit 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 missingThe bar fills in after your first message. Send Claude anything, like "hi".
The context band never showsThat's normal. It only appears when Claude's memory is 60% full.
The panel takes too much roomClick Hide on the right. Click Show agents to bring it back.

Still stuck? Open an issue and paste a screenshot. Or ask your teacher.


Get the newest version

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

Remove it

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:

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.

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