Animated pixel agents above the prompt: this session's subagents and teammates plus your other Claude sessions, with what each is doing.

A Claude Code mod that draws small animated pixel sprites above the prompt: one for each subagent or teammate of the current session, and one for each of your other Claude sessions that is busy or waiting. You see at a glance who is working and who needs you.
Each sprite has a label and a state line underneath:
| State | Meaning | Look |
|---|---|---|
| working | busy right now | sprite bobs up and down, blue label |
| waiting for you | idle, your turn | yellow label and a yellow corner mark |
| done | finished (stays for one minute) | dimmed sprite, green mark |
| failed | failed or killed (stays for one minute) | red mark |
Sprite types:
Sessions that have been idle for more than 30 minutes are not drawn, only counted ("N other sessions idle"). If the row does not fit your terminal width, the rest is counted as "+N more on the board".
In a Claude Code terminal session:
/plugin install agent-board --marketplace CH4RL3I/agent-board
Answer y to add the marketplace, then pick a scope.
/agent-board toggles the board on and off.extraSessionDirs (in /config): comma-separated extra directories that hold Claude session files. Useful if you keep sessions somewhere unusual. ~/.claude/sessions and every ~/.claude-*/sessions (for example a second profile with its own config dir) are scanned automatically.~/.claude/sessions (and the other directories above), then checking with ps that the process is still alive.ps.claude plugin commands).ps on the PATH (macOS or Linux).entrypoint: cli) are listed.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT, see LICENSE.
hooks/register.tsx 282 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Board, Entry, Mood, Sprite } from '../types'
5
6// Agent board: pixel agents above the prompt, one per subagent or teammate of
7// this session and one per other Claude session that is working or waiting.
8// Sprites are 16x10 pixels drawn as 16x5 cells with half blocks; working
9// agents bob, which is the only animation.
10
11const board = atom({ plugin: 'agent-board', key: 'board' } as const, { entries: [], resting: 0 } as Board)
12const isHidden = atom({ plugin: 'agent-board', key: 'isHidden' } as const, false)
13
14const COLS = 16
15const ROWS = 5
16const SLOT = 18
17const MAX_SHOWN = 8
18const WAITING_FOR_MS = 30 * 60 * 1000
19const DEFAULT = 0x01000000
20
21// One char per pixel; '.' is transparent.
22const MAPS: Record<Sprite, { pal: Record<string, number>; px: string[] }> = {
23 conductor: {
24 pal: { o: 0xe07a52, k: 0x15161d },
25 px: ['...oooooooo.....', '...oooooooo.....', '...oooookoo.....', 'oooooooookoooo..', 'oooooooooooooo..', '..oooooooooo....', '..oo......oo....', '................'],
26 },
27 builder: {
28 pal: { b: 0x6aa6ff, k: 0x15161d },
29 px: ['...bbbbbbbbbb...', '...bkbbbbbbkb...', '.bbbbbbbbbbbbbb.', '.bbbbbbbbbbbbbb.', '...bbbbbbbbbb...', '...b.b....b.b...', '...b.b....b.b...', '................'],
30 },
31 scout: {
32 pal: { c: 0x5fe0e0, k: 0x15161d, w: 0xf1ede4 },
33 px: ['....cccccccc....', '..cccwwwwwwccc..', '..ccwwkwwkwwcc..', '..cccwwwwwwccc..', '....cccccccc....', '...cc......cc...', '..cc........cc..', '................'],
34 },
35 mate: {
36 pal: { y: 0xffd23f, r: 0xef3f78, s: 0xf4dcc0, k: 0x15161d },
37 px: ['......yyyy......', '...rrryyyyrrr...', '..rrrrrrrrrrrr..', '..rssssssssssr..', '..rsskssssksr...', '..rssssssssssr..', '...rrrrrrrrrr...', '................'],
38 },
39 session: {
40 pal: { p: 0xa58cff, k: 0x15161d },
41 px: ['................', '....pppppppp....', '...pppppppppp...', '...ppkppppkpp...', '...pppppppppp...', '....pppppppp....', '....p..pp..p....', '................'],
42 },
43}
44
45const DIM = 0x55566a
46
47function pixelGrid(sprite: Sprite, lift: number, mood: Mood): number[][] {
48 const { pal, px } = MAPS[sprite]
49 const grid: number[][] = Array.from({ length: ROWS * 2 }, () => Array(COLS).fill(-1))
50 const top = 2 - lift
51 px.forEach((row, y) => {
52 for (let x = 0; x < COLS; x += 1) {
53 const c = pal[row[x]]
54 if (c !== undefined) grid[top + y][x] = mood === 'done' ? DIM : c
55 }
56 })
57 // a status pixel pair in the top-right corner: yellow = waiting for you, green = done, red = failed
58 const mark = mood === 'waiting' ? 0xffd23f : mood === 'done' ? 0x61d68a : mood === 'failed' ? 0xef3f78 : -1
59 if (mark !== -1) {
60 grid[0][14] = mark
61 grid[0][15] = mark
62 grid[1][14] = mark
63 grid[1][15] = mark
64 }
65 return grid
66}
67
68const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
69function toBase64(bytes: Uint8Array): string {
70 let out = ''
71 for (let i = 0; i < bytes.length; i += 3) {
72 const a = bytes[i]
73 const b = i + 1 < bytes.length ? bytes[i + 1] : 0
74 const c = i + 2 < bytes.length ? bytes[i + 2] : 0
75 const n = (a << 16) | (b << 8) | c
76 out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63]
77 out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '='
78 out += i + 2 < bytes.length ? B64[n & 63] : '='
79 }
80 return out
81}
82
83export function cells(sprite: Sprite, lift: number, mood: Mood): string {
84 const grid = pixelGrid(sprite, lift, mood)
85 const words = new Uint32Array(COLS * ROWS * 3)
86 for (let r = 0; r < ROWS; r += 1) {
87 for (let x = 0; x < COLS; x += 1) {
88 const upper = grid[r * 2][x]
89 const lower = grid[r * 2 + 1][x]
90 const i = (r * COLS + x) * 3
91 if (upper === -1 && lower === -1) {
92 words.set([0x20, DEFAULT, DEFAULT], i)
93 } else if (upper === -1) {
94 words.set([0x2584, lower, DEFAULT], i)
95 } else {
96 words.set([0x2580, upper, lower === -1 ? DEFAULT : lower], i)
97 }
98 }
99 }
100 return toBase64(new Uint8Array(words.buffer))
101}
102
103function short(text: string, n = SLOT - 2): string {
104 const t = text.replace(/\s+/g, ' ').trim()
105 return t.length > n ? `${t.slice(0, n - 1)}…` : t
106}
107
108function moodOf(status: string): Mood {
109 if (status === 'running' || status === 'pending') return 'working'
110 if (status === 'waiting' || status === 'idle') return 'waiting'
111 if (status === 'failed' || status === 'killed') return 'failed'
112 return 'done'
113}
114
115function spriteOf(type: string, isTeammate: boolean): Sprite {
116 if (isTeammate) return 'mate'
117 if (/explore|plan|guide|review|validator/i.test(type)) return 'scout'
118 return 'builder'
119}
120
121type SessionFile = { pid?: number; sessionId?: string; status?: string; entrypoint?: string; statusUpdatedAt?: number; tmux?: string; name?: string; cwd?: string }
122
123const ctx = { home: '', ownId: '', bandId: '', frame: 0, isMainWorking: false, extraDirs: [] as string[], doneAt: new Map<string, number>() }
124
125// Session directories to read: ~/.claude/sessions, every ~/.claude-*/sessions
126// (a second profile kept in its own config dir), and any directories listed in
127// the extraSessionDirs option.
128export async function sessionDirs($: any): Promise<string[]> {
129 const dirs = [`${ctx.home}/.claude/sessions`]
130 try {
131 for (const f of await $.fs.list(ctx.home)) {
132 if (f.kind === 'dir' && /^\.claude-.+/.test(f.name)) dirs.push(`${ctx.home}/${f.name}/sessions`)
133 }
134 } catch {
135 // home not listable; the default directory still works
136 }
137 for (const d of ctx.extraDirs) dirs.push(d.startsWith('~/') ? `${ctx.home}${d.slice(1)}` : d)
138 return [...new Set(dirs)]
139}
140
141async function sessions($: any): Promise<{ shown: Entry[]; resting: number }> {
142 const found: SessionFile[] = []
143 for (const dir of await sessionDirs($)) {
144 if (!(await $.fs.exists(dir))) continue
145 for (const f of await $.fs.list(dir)) {
146 if (f.kind !== 'file' || !f.name.endsWith('.json')) continue
147 try {
148 found.push(JSON.parse((await $.fs.read(`${dir}/${f.name}`)) as string))
149 } catch {
150 // a file being rewritten right now; next poll reads it
151 }
152 }
153 }
154 const mine = found.filter(s => s.entrypoint === 'cli' && s.sessionId !== ctx.ownId && typeof s.pid === 'number')
155 if (mine.length === 0) return { shown: [], resting: 0 }
156 const ps = await $.process.run(['ps', '-o', 'pid=', '-p', mine.map(s => s.pid).join(',')], { timeoutMs: 3000 })
157 const alive = new Set(ps.stdout.split('\n').map((l: string) => Number(l.trim())).filter(Boolean))
158 const now = await $.clock.now()
159 const seen = new Set<string>()
160 const shown: Entry[] = []
161 let resting = 0
162 for (const s of mine) {
163 if (!alive.has(s.pid as number) || !s.sessionId || seen.has(s.sessionId)) continue
164 seen.add(s.sessionId)
165 // the session's own name if it has one, else its tmux session, else the folder it runs in
166 const tmuxName = (s.tmux ?? '').split(':')[0]
167 const label = short(s.name || tmuxName || (s.cwd ?? '').split('/').pop() || 'session')
168 if (s.status === 'busy') {
169 shown.push({ id: s.sessionId, label, mood: 'working', sprite: 'session' })
170 } else if (now - (s.statusUpdatedAt ?? 0) < WAITING_FOR_MS) {
171 shown.push({ id: s.sessionId, label, mood: 'waiting', sprite: 'session' })
172 } else {
173 resting += 1
174 }
175 }
176 return { shown, resting }
177}
178
179async function refresh($: any) {
180 const now = await $.clock.now()
181 const agents = await $.agent.list()
182 const own: Entry[] = []
183 for (const a of agents) {
184 const mood = moodOf(a.status)
185 if (mood === 'done' || mood === 'failed') {
186 // keep a finished agent on the board for a minute, then let it go
187 const at = ctx.doneAt.get(a.id) ?? now
188 ctx.doneAt.set(a.id, at)
189 if (now - at > 60_000) continue
190 }
191 own.push({ id: a.id, label: short(a.name ?? a.description ?? a.type), mood, sprite: spriteOf(a.type, Boolean(a.teammateId)) })
192 }
193 const { shown, resting } = await sessions($)
194 const entries: Entry[] = []
195 if (own.length > 0) {
196 entries.push({ id: 'main', label: '♪ this session', mood: ctx.isMainWorking ? 'working' : 'waiting', sprite: 'conductor' })
197 }
198 entries.push(...own, ...shown)
199 const before = await read($, board)
200 const same = JSON.stringify(before) === JSON.stringify({ entries, resting })
201 if (!same) await update($, board, () => ({ entries, resting }))
202}
203
204async function animate($: any) {
205 if (!ctx.bandId || (await read($, isHidden))) return
206 const { entries } = await read($, board)
207 if (!entries.some(x => x.mood === 'working')) return
208 ctx.frame = 1 - ctx.frame
209 for (const x of entries.slice(0, MAX_SHOWN)) {
210 if (x.mood !== 'working') continue
211 void $.ui.blit({ requestId: ctx.bandId, key: `s-${x.id}`, cells: cells(x.sprite, ctx.frame, x.mood) })
212 }
213}
214
215export const register: Register = (on, options) => {
216 const raw = typeof options?.extraSessionDirs === 'string' ? options.extraSessionDirs : ''
217 ctx.extraDirs = raw.split(',').map(x => x.trim()).filter(Boolean)
218
219 on('session.start', async ($, e, next) => {
220 const r = await $.process.run(['/bin/sh', '-c', 'printf %s "$HOME"'], { timeoutMs: 3000 })
221 ctx.home = r.stdout.trim()
222 ctx.ownId = await $.session.id()
223 await $.command.register({ name: 'agent-board', description: 'Show or hide the pixel agent board above the prompt' })
224 $.clock.every(3000, () => {
225 void refresh($)
226 })
227 // the bob: only working agents move, two frames
228 $.clock.every(350, () => {
229 void animate($)
230 })
231 void refresh($)
232 return next(e)
233 })
234
235 on('turn.start', async ($, e, next) => {
236 if (!e.agentId) ctx.isMainWorking = true
237 return next(e)
238 })
239
240 on('turn.complete', async ($, e, next) => {
241 if (!e.agentId) ctx.isMainWorking = false
242 void refresh($)
243 return next(e)
244 })
245
246 on('command.run', { command: 'agent-board' }, async $ => {
247 const hidden = await update($, isHidden, v => !v)
248 return { text: hidden ? 'Agent board hidden. /agent-board shows it again.' : 'Agent board shown.' }
249 })
250
251 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
252 if (e.surface !== 'terminal' || e.props.hasSurvey || (await read($, isHidden))) return next(e)
253 const { entries, resting } = await read($, board)
254 if (entries.length === 0) return next(e)
255 ctx.bandId = e.requestId
256 const { Box, Text, Raster } = $.ui.resolve(e)
257 const fit = Math.max(1, Math.min(MAX_SHOWN, Math.floor(e.props.bodyColumns / SLOT)))
258 const shown = entries.slice(0, fit)
259 const more = entries.length - shown.length
260 const colour = (m: Mood) => (m === 'working' ? '#6aa6ff' : m === 'waiting' ? '#ffd23f' : m === 'failed' ? '#ef3f78' : '#61d68a')
261 const word = (m: Mood) => (m === 'working' ? 'working' : m === 'waiting' ? 'waiting for you' : m === 'failed' ? 'failed' : 'done')
262 return (
263 <Box flexDirection="column">
264 <Box flexDirection="row" gap={2}>
265 {shown.map(x => (
266 <Box key={x.id} flexDirection="column" width={SLOT}>
267 <Raster key={`s-${x.id}`} columns={COLS} rows={ROWS} cells={cells(x.sprite, 0, x.mood)} />
268 <Text bold color={colour(x.mood)} wrap="truncate-end">{x.label}</Text>
269 <Text dimColor wrap="truncate-end">{word(x.mood)}</Text>
270 </Box>
271 ))}
272 </Box>
273 {(more > 0 || resting > 0) && (
274 <Text dimColor>
275 {more > 0 ? `+${more} more on the board` : ''}{more > 0 && resting > 0 ? ' · ' : ''}{resting > 0 ? `${resting} other sessions idle` : ''}
276 </Text>
277 )}
278 </Box>
279 )
280 })
281}
282types/index.d.ts 11 lines1export type Mood = 'working' | 'waiting' | 'done' | 'failed'
2export type Sprite = 'conductor' | 'builder' | 'scout' | 'mate' | 'session'
3export type Entry = { id: string; label: string; mood: Mood; sprite: Sprite }
4export type Board = { entries: Entry[]; resting: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'agent-board': { board: Board; isHidden: boolean }
9 }
10}
11