A row above the prompt for the subagents Claude starts: running and done per model, a token bar, and a working pixel crew

Mods for the Claude Code terminal: a band above the prompt that shows your limits, this chat's usage and the prompt cache, plus a row for the subagents Claude starts.
Copy this prompt into Claude Code:
Install the Claude Code plugins from https://github.com/Oualid0/claude-mods:
add the repo as a plugin marketplace and install usage-ring. Then ask me whether
I also want agent-board (a row above the prompt that shows the subagents Claude
starts) and install it only if I say yes. If I do, ask whether I want to set
subagent limits (maxHaiku, maxSonnet, maxOpus); recommend leaving them unset if
I don't know my limits, and set only the numbers I give you. Finally tell me to
run /reload-plugins.
claude plugin marketplace add Oualid0/claude-mods
claude plugin install usage-ring@claude-mods
claude plugin install agent-board@claude-mods
Then run /reload-plugins in Claude Code, or start a new session. From a local clone, ./install.sh (it needs python3) registers the clone as the marketplace claude-mods (unless a marketplace of that name is already added, from GitHub or elsewhere) and installs the plugins that are not installed yet. It is safe to run again but does not update installed plugins.
Auto-update is off by default. Update by hand with /plugin marketplace update claude-mods in a session, or claude plugin update usage-ring@claude-mods and claude plugin update agent-board@claude-mods in the shell. You can also turn on Enable auto-update for the marketplace under Marketplaces in /plugin.
| Plugin | What it does |
|---|---|
usage-ring | One frame right above the prompt with up to three groups, separated by a dimmed │ : limits (Week, Session), chat (Context, Cache, Todos) and, only with showTokens, tokens (the chat's token count and rate). A pixel Claude stands beside the frame, hammering while a turn runs and sleeping otherwise (fast animation while it hammers, one slow step a second while it sleeps). Options add the tokens group (showTokens), the group titles (titles), the model in grey next to the pixel Claude, e.g. Opus 5.5 (mid) (showModel; effort low, mid, high, xhigh or max, known from the first request on), and always-short labels (compact). |
agent-board | A row right above the usage-ring band, shown once a subagent has started in this chat: one chip with an entry per model family (Haiku, Sonnet, Opus; Other only once used), a bar of how the tokens split, and a pixel crew beside it, one hard-hatted worker each for Haiku, Sonnet and Opus (Other has none) that works while the family has a subagent running and sleeps otherwise. A worker whose running subagents have all produced no tokens for 5 minutes looks at a clock and taps its foot (waiting). Five minutes after the last subagent ended the row hides, and it comes back with the next one, counts kept. For the single subagents, use Claude Code's own agent list below the prompt. |
session-board was removed in 2.0 and is no longer part of this marketplace. If you installed it earlier, remove it with claude plugin uninstall session-board@claude-mods.
See CHANGELOG.md for what changed in each release.
| Label | Group | Meaning |
|---|---|---|
Week (Wk) | limits | Weekly limit used, in percent, as a ring and a number. Red from 95%. |
Session (Se) | limits | Session limit (the 5-hour window) used, as a ring and a number, and the time until it resets: 4:50 (hours:minutes), or 33m under an hour. Red from 95%. Once the window is over it shows 0% 5:00 until the next reading. |
Context (Cx) | chat | Context window used, in percent, as a ring and a number. Yellow from 50%, red from 80%. |
Cache (Ca) | chat | The prompt cache as text, no ring: the time left (33m, in the last minute 45s). With a 1-hour cache it turns yellow under 10 minutes and red under 3; with a 5-minute cache yellow under 2 minutes and red under 1. A red cold once the cache is gone: its lifetime ran out, the model was switched, or the chat was compacted (/compact or automatic). A change of effort does not count. The next request warms it again. – before the first request. |
Todos (Td) | chat | Todos done out of all, as plain text, e.g. 3/5. Only while the chat has a todo list. In a muted terracotta (#9a5a44, not bold) while none is done (0/5). |
Tokens (Tk) | tokens | Only with the showTokens option; without it there is no tokens group and no such entry, and no tokens title even with titles. One entry with two figures, e.g. Tokens 39.5M 6k tok/s. The first is all tokens this chat used since the session started (input, output, cache reads and writes, subagents included); it grows after every model request. The second, after a gap of two spaces, is all tokens of the last minute per second, rounded, with the unit tok/s (800 tok/s, 6k tok/s; 0 tok/s in a pause). |
Haiku / Sonnet / Opus / Other (Ha / So / Op / Ot) | agents | Model family of the entry: Haiku, Sonnet, Opus, or Other only if a subagent's model fits none of them. The family is guessed from the model name. The label word is dimmed grey, like usage-ring's labels; the family colors are on the Split bar and the hats. The entry shows the running subagents, with a limit set as running/limit (e.g. 1/15). The count is a muted terracotta (#9a5a44) with 0 running, in the chip color from 1 running, and red above the limit. Haiku, Sonnet and Opus always show once any subagent has started; with hideUnused, a family shows only once it had a subagent. |
✔ | agents | Subagents of that family not running in this chat (done, failed, stopped, or idle teammates), e.g. ✔3, dimmed. Hidden while it would be ✔0. |
Split (Sp) | agents | A bar of how this chat's tokens split by who used them: the main loop in terracotta, Haiku neon mint (#39ff88), Sonnet neon cyan (#22d3ff), Opus neon violet (#b26bff), Other grey (#6a6a6a). The same colors tint the family's hard hat in the crew (Haiku, Sonnet and Opus; Other has no crew member), not the label words. Every part with tokens gets at least one cell. It shows the split only; the total is in the tokens group of usage-ring (option showTokens). Empty until the first request. The word Split itself shows only with the titles option. |
A value of 0 is shown in a fixed muted terracotta (#9a5a44, not bold) instead of the bright one: 0% of Week, Session or Context, a Todos count with none done, a subagent count with 0 running, and, with showTokens, a total of 0 or a rate of 0 tok/s. The labels stay grey and the ✔n done counts stay dimmed. From above 0 a value has its normal color, and the yellow and red warning colors win. The Cache text has no zero case.
Labels are full words; the short form in brackets is used once the terminal is too narrow for them, or always with the compact option. The group titles limits, chat and tokens show only with the titles option, the tokens group only with showTokens, and the model only with showModel.
When the terminal is narrower still, usage-ring drops the least important first: the model label, the group titles, then (only with showTokens) the rate part of Tokens and the whole Tokens entry (and with it the tokens group), Ca, Td, Wk, the pixel Claude, then Cx. Se stays longest (without a limit reading, whichever entry is left); if not even one entry fits, the band is hidden. Nothing is squeezed or wrapped.
The agents row first shortens its labels, then drops, in this order: the titles agents and Split, the split bar, the ✔ counts, then the crew. If not even the counts fit, the row is hidden. It is also hidden when the band has fewer than 6 rows to draw in, to leave room for the prompt.
Wk, Se, Cx), the split bar and the crew are pixel images in kitty and Ghostty; other terminals show a glyph instead.Cache row above says.Ca is an estimate. Claude Code does not tell plugins how long the cache lives (5 minutes or 1 hour) except when the model is switched, where it names the lifetime (5m or 1h) and the band takes it. Otherwise the band assumes 1 hour and learns from what each request read from the cache after a pause of more than 5 minutes: a hit means 1 hour at once, but only two misses in a row mean 5 minutes. A single miss can come from a changed system prompt or tool list (for example after /reload-plugins), which the band cannot see, so it does not count.Tokens figures and Ca start over when a session starts or resumes and on /clear; earlier tokens of a resumed session are not available. Td starts over on /clear (a new chat); on resume (in a session or with claude --resume) it shows the resumed chat's own task list. Cx is also reset on /clear and resume until the next measurement.Td counts TaskCreate/TaskUpdate/TaskList and TodoWrite. Newer models only have these tools with CLAUDE_CODE_ENABLE_TODO_TOOLS=1 (docs).agent-board syncs each subagent's status from the engine's agent list every 2 s, so a failed or stopped subagent may show a moment late. A subagent that drops out of that list without ending counts as stopped after two syncs. A subagent that is never in it (a workflow's) keeps its status until its turn ends, or counts as done once it has sent no tokens for 30 minutes; a later request revives it./clear and when another chat is resumed.usage-ring has five options, all off by default:
| Option | Meaning |
|---|---|
limitsFile | Write the session and weekly limits to $CLAUDE_CONFIG_DIR/usage-limits.json (default ~/.claude) for other tools to read. It also seeds the rings from that file when a session starts and the engine has no reading yet; a window whose reset time has passed is skipped. |
showModel | Show the model and its effort in grey next to the pixel Claude, e.g. Opus 5.5 (mid). |
showTokens | Show the tokens group: all tokens this chat used and the rate of the last minute (Tokens 39.5M 6k tok/s). Without it the group, its entry and its title are not shown. |
titles | Start each group with its title: limits, chat and (with showTokens) tokens. |
compact | Always use the two-letter labels (Wk, Se, Cx, Ca, Td, Tk), not only when the terminal is narrow. |
Set them with /plugin configure usage-ring@claude-mods in Claude Code, or:
echo '{"limitsFile":"true","showModel":"true","showTokens":"true","titles":"false","compact":"false"}' | claude plugin configure usage-ring@claude-mods --values-stdin
agent-board has seven options. The limits are unset by default (only the count), the others are off by default:
| Option | Meaning |
|---|---|
maxHaiku | Limit of Haiku subagents running at once. |
maxSonnet | Limit of Sonnet subagents running at once. |
maxOpus | Limit of Opus subagents running at once. |
hideAfter | Minutes after the last subagent ended until the row hides (default 5); 0 keeps it shown. A value that is not a number of minutes (negative, blank, text) means 5. |
titles | Show the words agents (the row's title) and Split (the bar's label). |
compact | Always write the families as Ha, So, Op, Ot (and the bar as Sp), not only when the terminal is narrow. |
hideUnused | Show a family only once it had a subagent in this chat (Other always needs a subagent of its own); by default Haiku, Sonnet and Opus always show. |
A limit counts only as a whole number from 1 up; 0, negatives, fractions and blanks mean no limit.
The limits are unset by default. Leave them unset if you don't know how many subagents you want to allow: the row then shows plain counts and never turns red.
Set them with /plugin configure agent-board@claude-mods, or (example values):
echo '{"maxHaiku":"4","maxSonnet":"2","maxOpus":"1","hideAfter":"5","titles":"false","compact":"false","hideUnused":"false"}' | claude plugin configure agent-board@claude-mods --values-stdin
plugins/<name>/: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.tsx with the hooks, pure logic in files beside it, types/index.d.ts (the state contract) and tests/.claude plugin validate ., then claude plugin validate plugins/<name> and claude plugin test plugins/<name>./reload-plugins or the next session.next(e) and keeps what is beneath (agent-board's row on top, usage-ring's band next to the prompt).MIT, see LICENSE.
hooks/register.tsx 340 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Agent, AgentState } from '../types'
5import {
6 ALERT,
7 BAR_CELLS,
8 COLOR,
9 GAP,
10 bar,
11 barLabel,
12 crewStates,
13 displayFrom,
14 LABEL_TEXT,
15 limitsFrom,
16 CREW_COLUMNS,
17 TERRACOTTA,
18 ZERO,
19 fit,
20 hideAfterFrom,
21 isAbandoned,
22 isLive,
23 segmentText,
24 segments,
25 tierOf,
26} from './model'
27import { CREW_H, CREW_ROWS, CREW_W, SCALE, crewKey, crewPixels, frameStep, nextFrame } from './workers'
28import { barPixels, barSize, hexToRgb, hot, toBase64 } from './ring'
29import type { BarPart } from './model'
30
31const agents = atom({ plugin: 'agent-board', key: 'agents' } as const, {} as Record<string, Agent>)
32const tokens = atom({ plugin: 'agent-board', key: 'tokens' } as const, {})
33const frame = atom({ plugin: 'agent-board', key: 'frame' } as const, 0)
34const idleSince = atom({ plugin: 'agent-board', key: 'idleSince' } as const, null as number | null)
35const hidden = atom({ plugin: 'agent-board', key: 'hidden' } as const, false)
36
37const TICK_MS = 2000
38/** A work frame every FRAME_MS; the z's move every second frame. */
39const FRAME_MS = 333
40/** Polls a listed live agent may be missing from the engine's list before it counts as stopped. */
41const MISSES = 2
42/** Rows of the row (the chip's border and the crew), and the rows it leaves the band beneath it. */
43const ROW_ROWS = 3
44const ROOM_BELOW = 3
45
46/** The bar changes with the shares, so only the one drawn last is kept. */
47let barCache: { key: string; base64: string } | undefined
48
49function barPicture(parts: BarPart[]): string {
50 const key = parts.map(p => `${p.key}${p.cells}`).join(',')
51 if (barCache?.key !== key) {
52 const runs = parts.map(p => ({ cells: p.cells, rgb: hexToRgb(COLOR[p.key]) }))
53 barCache = { key, base64: toBase64(barPixels(runs, BAR_CELLS, hexToRgb(TERRACOTTA))) }
54 }
55 return barCache.base64
56}
57
58/** The crew's pictures are large and many, so only the one drawn last is kept. */
59let crew: { key: string; base64: string } | undefined
60
61function crewPicture(states: Parameters<typeof crewPixels>[0], f: number): string {
62 const key = crewKey(states, f)
63 if (crew?.key !== key) crew = { key, base64: toBase64(crewPixels(states, f)) }
64 return crew.base64
65}
66
67/**
68 * Keeps `idleSince` in step with the agents after a change: cleared (and the row
69 * shown) while one runs, set to now when the last one ended.
70 */
71async function markIdle($: EngineInterface): Promise<void> {
72 const live = Object.values(await read($, agents)).some(a => isLive(a.state))
73 if (live) {
74 if ((await read($, idleSince)) !== null) await update($, idleSince, () => null)
75 if (await read($, hidden)) await update($, hidden, () => false)
76 } else if ((await read($, idleSince)) === null) {
77 const t = await $.clock.now()
78 await update($, idleSince, () => t)
79 }
80}
81
82/** The engine's status in the board's words; `pending` counts as running. */
83function stateOf(status: string): AgentState {
84 return status === 'pending' ? 'running' : (status as AgentState)
85}
86
87/**
88 * Takes each agent's status from the engine's list. The list may end an agent or
89 * change how it ended, never revive one (a request does that, in `turn.step`), so a
90 * list fetched before a `turn.complete` cannot undo it. A live agent that was listed
91 * and then is missing for MISSES polls ended without a word and counts as stopped;
92 * one never listed (a workflow's) keeps its state until it has used no tokens for
93 * ABANDON_AFTER, then counts as done (a later request revives it).
94 */
95async function syncStates($: EngineInterface): Promise<void> {
96 if (Object.keys(await read($, agents)).length === 0) return
97 // Without a list (the call failed) nothing counts as missing, only silence is judged.
98 let listed: Map<string, AgentState> | undefined
99 try {
100 listed = new Map((await $.agent.list()).map(one => [one.id, stateOf(one.status)]))
101 } catch {
102 listed = undefined
103 }
104 const now = await $.clock.now()
105 await update($, agents, all => {
106 let changed = false
107 const next = { ...all }
108 for (const a of Object.values(all)) {
109 const state = listed?.get(a.id)
110 let b = a
111 if (state !== undefined) {
112 const keep = !isLive(a.state) && isLive(state)
113 b = { ...a, state: keep ? a.state : state, isListed: true, misses: 0 }
114 } else if (listed && a.isListed && isLive(a.state)) {
115 const misses = (a.misses ?? 0) + 1
116 b = { ...a, misses, state: misses >= MISSES ? 'killed' : a.state }
117 } else if (isAbandoned(a, now)) {
118 b = { ...a, state: 'completed' }
119 }
120 if (b.state !== a.state || b.isListed !== a.isListed || b.misses !== a.misses) {
121 next[a.id] = b
122 changed = true
123 }
124 }
125 return changed ? next : all
126 })
127 await markIdle($)
128}
129
130/** Hides the row once it has been idle for `hideAfter` ms; 0 never hides. */
131async function hideIfIdle($: EngineInterface, hideAfter: number): Promise<void> {
132 const since = await read($, idleSince)
133 if (hideAfter === 0 || since === null || (await read($, hidden))) return
134 if ((await $.clock.now()) - since >= hideAfter) await update($, hidden, () => true)
135}
136
137/** The sync and frame timers of the running session. */
138let timers: Timer[] = []
139
140function stopTimers(): void {
141 for (const t of timers) t.cancel()
142 timers = []
143}
144
145/** A timer's run: an error in it ends that run only, the timer goes on. */
146const guarded = (run: () => Promise<void>) => () => {
147 run().catch(() => {})
148}
149
150/** Starts the sync and frame timers, after cancelling any started before. */
151function startTimers($: EngineInterface, hideAfter: number): void {
152 stopTimers()
153 let tick = 0
154 timers = [
155 $.clock.every(
156 TICK_MS,
157 guarded(async () => {
158 await syncStates($)
159 await hideIfIdle($, hideAfter)
160 }),
161 ),
162 $.clock.every(
163 FRAME_MS,
164 guarded(async () => {
165 tick++
166 const all = await read($, agents)
167 // No row, no crew to move.
168 if (Object.keys(all).length === 0 || (await read($, hidden))) return
169 const isAsleep = Object.values(crewStates(all, await $.clock.now())).every(s => s === 'sleep')
170 const step = frameStep(isAsleep, tick)
171 if (step > 0) await update($, frame, f => nextFrame(f, step))
172 }),
173 ),
174 ]
175}
176
177export const register: Register = (on, options) => {
178 const limits = limitsFrom(options)
179 const hideAfter = hideAfterFrom(options)
180 const display = displayFrom(options)
181
182 on('session.start', async ($, e, next) => {
183 const result = await next(e)
184 startTimers($, hideAfter)
185 return result
186 })
187
188 // A new chat (after /clear, or another one resumed in its place) starts an empty row.
189 // The timers stop with a session; a chat that goes on in its place gets new ones.
190 on('session.end', async ($, e, next) => {
191 stopTimers()
192 if (e.reason === 'clear' || e.reason === 'resume') {
193 try {
194 await update($, agents, () => ({}))
195 await update($, tokens, () => ({}))
196 await update($, idleSince, () => null)
197 await update($, hidden, () => false)
198 } finally {
199 // The chat goes on: its timers start even if a reset failed.
200 startTimers($, hideAfter)
201 }
202 }
203 return next(e)
204 })
205
206 // Only watches the spawn: if the bookkeeping fails, the spawn goes on as the engine answered it.
207 on('agent.spawn', async ($, e, next) => {
208 const r = await next(e)
209 if (r.deny !== undefined || r.agentId === undefined) return r
210 const id = r.agentId
211 const now = await $.clock.now()
212 await update($, agents, all => ({
213 ...all,
214 [id]: { id, model: r.model, tier: tierOf(r.model), state: 'running' as AgentState, lastTokenAt: now },
215 }))
216 await markIdle($)
217 return r
218 }).catch(($, e, next) => next(e))
219
220 on('turn.step', async function* ($, e, next) {
221 const r = yield* next(e)
222 const u = r.usage
223 if (u) {
224 const used = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
225 const id = e.agentId
226 const all = await read($, agents)
227 // Loops the row never saw spawn (the engine's compaction and memory
228 // forks, forked skills) count as the main loop's work.
229 const known = id === undefined ? undefined : all[id]
230 const key = known?.tier ?? 'main'
231 await update($, tokens, t => ({ ...t, [key]: (t[key] ?? 0) + used }))
232 if (id !== undefined && known) {
233 // New tokens end a stall. A request also means it runs: a message may have woken an agent that had ended.
234 const now = await $.clock.now()
235 const wake = !isLive(known.state)
236 await update($, agents, a =>
237 a[id] ? { ...a, [id]: { ...a[id], lastTokenAt: now, ...(wake ? { state: 'running' as AgentState, misses: 0 } : {}) } } : a,
238 )
239 if (wake) await markIdle($)
240 }
241 }
242 return r
243 })
244
245 on('turn.complete', async ($, e, next) => {
246 const id = e.agentId
247 if (id !== undefined) {
248 const state: AgentState =
249 e.reason === 'error' || e.reason === 'refusal' ? 'failed' : e.isAborted || e.reason === 'aborted' ? 'killed' : 'completed'
250 await update($, agents, all => (all[id] ? { ...all, [id]: { ...all[id], state } } : all))
251 await markIdle($)
252 }
253 return next(e)
254 })
255
256 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
257 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
258 // A short band keeps its rows for what lies beneath, next to the prompt.
259 if (e.props.maxRows < ROW_ROWS + ROOM_BELOW) return next(e)
260 if (await read($, hidden)) return next(e)
261 const used = await read($, tokens)
262 const list = segments(await read($, agents), limits, display.hideUnused)
263 if (list.length === 0) return next(e)
264 const shown = fit(list, e.props.bodyColumns, display)
265 if (!shown) return next(e)
266
267 const parts = bar(used)
268 const now = await $.clock.now()
269 const below = await next(e)
270 const { Box, Text, Image } = $.ui.resolve(e)
271 // Image keys carry the plugin's name: two equal keys in one band refuse the whole band.
272 const chip = (
273 <Box borderStyle="round" borderColor={TERRACOTTA} paddingX={1}>
274 <Box flexDirection="row" gap={1} alignItems="center">
275 {shown.hasTitles ? <Text color={TERRACOTTA}>{LABEL_TEXT}</Text> : null}
276 <Box flexDirection="row" gap={GAP} alignItems="center">
277 {list.map(s => {
278 const t = segmentText(s, shown)
279 const color = s.isOver ? ALERT : TERRACOTTA
280 return (
281 <Box key={s.tier} flexDirection="row" gap={1} alignItems="center">
282 <Text dimColor>{t.label}</Text>
283 {s.running === 0 ? (
284 <Text color={ZERO}>{t.count}</Text>
285 ) : (
286 <Text color={hot(color)} bold>
287 {t.count}
288 </Text>
289 )}
290 {t.done ? (
291 <Text dimColor>{t.done}</Text>
292 ) : null}
293 </Box>
294 )
295 })}
296 </Box>
297 {shown.hasBar ? (
298 <Box flexDirection="row" gap={1}>
299 {shown.hasTitles ? <Text dimColor>{barLabel(shown)}</Text> : null}
300 <Image
301 key="agent-board-bar"
302 source={{ rgba: barPicture(parts), ...barSize(BAR_CELLS) }}
303 columns={BAR_CELLS}
304 rows={1}
305 alt={parts.map(p => '█'.repeat(p.cells)).join('') || '░'.repeat(BAR_CELLS)}
306 />
307 </Box>
308 ) : null}
309 </Box>
310 </Box>
311 )
312 let workers = null
313 if (shown.hasCrew) {
314 const f = await read($, frame)
315 const states = crewStates(await read($, agents), now)
316 const awake = Object.values(states)
317 workers = (
318 <Image
319 key="agent-board-crew"
320 source={{ rgba: crewPicture(states, f), width: CREW_W * SCALE, height: CREW_H * SCALE }}
321 columns={CREW_COLUMNS}
322 rows={CREW_ROWS}
323 alt={awake.includes('work') ? '⚒' : awake.includes('wait') ? '⌛' : 'z'}
324 />
325 )
326 }
327 // Above whatever lies beneath, so the row sits right over the usage rings;
328 // the crew stands right of the chip, as the pixel Claude does below.
329 return (
330 <Box flexDirection="column">
331 <Box flexDirection="row" alignItems="center">
332 {chip}
333 {workers ? <Box marginLeft={1}>{workers}</Box> : null}
334 </Box>
335 {below}
336 </Box>
337 )
338 })
339}
340hooks/model.ts 218 lines1import type { Agent, AgentState, Tier, Tokens } from '../types'
2import type { CrewState, CrewTier } from './workers'
3
4export const TERRACOTTA = '#d97757'
5export const ALERT = '#e5484d'
6/** A count of zero running: terracotta muted, not bold, so it stands apart from the grey labels. */
7export const ZERO = '#9a5a44'
8
9/** Columns between two items inside the chip. */
10export const GAP = 2
11/** Border and padding of a chip: `╭ ` and ` ╮`. */
12const CHIP_FRAME = 4
13const LABEL = 'agents'
14
15export const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus', 'other']
16/** Each family's label: the full name, the two letters when the row is narrow or `compact` is set. */
17export const NAME: Record<Tier, string> = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus', other: 'Other' }
18export const SHORT: Record<Tier, string> = { haiku: 'Ha', sonnet: 'So', opus: 'Op', other: 'Ot' }
19/**
20 * Each family's neon color, for its label, its run of the token bar and its crew
21 * member's hat; the main loop in Claude's own. `other` is a grey darker than the cold grey of usage-ring's cache.
22 */
23export const COLOR: Record<Tier | 'main', string> = {
24 main: TERRACOTTA,
25 haiku: '#39ff88',
26 sonnet: '#22d3ff',
27 opus: '#b26bff',
28 other: '#6a6a6a',
29}
30/** Cells of the token bar. */
31export const BAR_CELLS = 12
32const BAR_NAME = 'Split'
33const BAR_SHORT = 'Sp'
34
35/** How many agents of a family may run at once; a family left out has no limit (only its count). */
36export type Limits = Partial<Record<Tier, number>>
37
38/** The limits from the plugin's options; only a whole number from 1 up counts, anything else means none. */
39export function limitsFrom(options: Readonly<Record<string, unknown>>): Limits {
40 const whole = (n: number) => (Number.isInteger(n) && n >= 1 ? n : undefined)
41 const one = (v: unknown) => (typeof v === 'number' ? whole(v) : typeof v === 'string' ? whole(Number(v)) : undefined)
42 return { haiku: one(options.maxHaiku), sonnet: one(options.maxSonnet), opus: one(options.maxOpus) }
43}
44
45/** Minutes the row stays after the last subagent ended. */
46export const HIDE_AFTER = 5
47
48/** The idle time in ms after which the row hides; 0 never hides, anything not a number of minutes is HIDE_AFTER. */
49export function hideAfterFrom(options: Readonly<Record<string, unknown>>): number {
50 const v = options.hideAfter
51 const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
52 return (Number.isFinite(n) && n >= 0 ? n : HIDE_AFTER) * 60_000
53}
54
55/** Whether an option is on: `true`, or the text `true` (the CLI hands options over as strings). */
56export const flagFrom = (v: unknown): boolean => v === true || (typeof v === 'string' && v.trim().toLowerCase() === 'true')
57
58/** What the row shows besides the counts; each is an option, off by default. */
59export type Display = {
60 /** The words `agents` and `Split` (the chip's title and the bar's label). */
61 titles: boolean
62 /** Always the two-letter labels, not only when the row is narrow. */
63 compact: boolean
64 /** A family appears only once it had a subagent in this chat. */
65 hideUnused: boolean
66}
67
68export function displayFrom(options: Readonly<Record<string, unknown>>): Display {
69 return { titles: flagFrom(options.titles), compact: flagFrom(options.compact), hideUnused: flagFrom(options.hideUnused) }
70}
71
72export function tierOf(model: string): Tier {
73 const m = model.toLowerCase()
74 if (m.includes('haiku')) return 'haiku'
75 if (m.includes('sonnet')) return 'sonnet'
76 if (m.includes('opus')) return 'opus'
77 return 'other'
78}
79
80export const isLive = (s: AgentState): boolean => s === 'running' || s === 'waiting'
81
82/** Ms without new tokens after which a live subagent counts as stalled. */
83export const STALL_AFTER = 5 * 60_000
84
85/** Whether `a` is live and has used no tokens for STALL_AFTER; one with no token time yet is not. */
86export const isStalled = (a: Agent, now: number): boolean =>
87 isLive(a.state) && a.lastTokenAt !== undefined && now - a.lastTokenAt >= STALL_AFTER
88
89/** Ms without tokens after which a live agent the engine's list never showed (a workflow's) counts as done. */
90export const ABANDON_AFTER = 30 * 60_000
91
92/**
93 * Whether `a` is live, was never in the engine's list (which would end it) and has
94 * used no tokens for ABANDON_AFTER; a later request revives it.
95 */
96export const isAbandoned = (a: Agent, now: number): boolean =>
97 isLive(a.state) && !a.isListed && a.lastTokenAt !== undefined && now - a.lastTokenAt >= ABANDON_AFTER
98
99/**
100 * What each crew member does: works while its family has a live agent still using
101 * tokens, waits when all its live agents are stalled, sleeps with none live.
102 */
103export function crewStates(agents: Record<string, Agent>, now: number): Record<CrewTier, CrewState> {
104 const list = Object.values(agents)
105 const one = (tier: CrewTier): CrewState => {
106 const live = list.filter(a => a.tier === tier && isLive(a.state))
107 if (live.length === 0) return 'sleep'
108 return live.every(a => isStalled(a, now)) ? 'wait' : 'work'
109 }
110 return { haiku: one('haiku'), sonnet: one('sonnet'), opus: one('opus') }
111}
112
113export type Segment = {
114 tier: Tier
115 name: string
116 short: string
117 running: number
118 done: number
119 limit?: number
120 isOver: boolean
121}
122
123/**
124 * One segment per family once any agent exists: Haiku, Sonnet and Opus always, Other
125 * only when used. With `isUnusedHidden` a family also needs an agent of its own.
126 */
127export function segments(agents: Record<string, Agent>, limits: Limits, isUnusedHidden = false): Segment[] {
128 const list = Object.values(agents)
129 if (list.length === 0) return []
130 return TIERS.flatMap(tier => {
131 const mine = list.filter(a => a.tier === tier)
132 if ((tier === 'other' || isUnusedHidden) && mine.length === 0) return []
133 const running = mine.filter(a => isLive(a.state)).length
134 const done = mine.length - running
135 const limit = limits[tier]
136 return [{ tier, name: NAME[tier], short: SHORT[tier], running, done, limit, isOver: limit !== undefined && running > limit }]
137 })
138}
139
140/** One run of the token bar: whose tokens, and how many cells they take. */
141export type BarPart = { key: Tier | 'main'; cells: number }
142
143/**
144 * The token bar: the main loop and each family by its share of all this chat's
145 * tokens, in BAR_CELLS cells (largest remainders, every share above 0 keeps at
146 * least one cell, taken from the largest); empty before any tokens.
147 */
148export function bar(tokens: Tokens): BarPart[] {
149 const keys = ['main', ...TIERS] as const
150 const total = keys.reduce((sum, k) => sum + (tokens[k] ?? 0), 0)
151 if (total === 0) return []
152 const exact = keys.map(k => ((tokens[k] ?? 0) / total) * BAR_CELLS)
153 const cells = exact.map(Math.floor)
154 let left = BAR_CELLS - cells.reduce((a, b) => a + b, 0)
155 const order = exact.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
156 for (const [, i] of order) {
157 if (left-- <= 0) break
158 cells[i]!++
159 }
160 // Five keys at most in twelve cells: a missing one always finds a run of three or more to take from.
161 keys.forEach((k, i) => {
162 if ((tokens[k] ?? 0) <= 0 || cells[i]! > 0) return
163 cells[cells.indexOf(Math.max(...cells))]!--
164 cells[i] = 1
165 })
166 return keys.map((key, i) => ({ key, cells: cells[i]! })).filter(p => p.cells > 0)
167}
168
169/** What the row draws; `fit` turns parts off, starting from what the options ask for. */
170export type Shown = { hasLongNames: boolean; hasTitles: boolean; hasBar: boolean; hasDone: boolean; hasCrew: boolean }
171
172/** Columns of the crew right of the chip (60 x 24 sprite pixels at the size of usage-ring's Claude), and the gap before it. */
173export const CREW_COLUMNS = 15
174const CREW_GAP = 1
175
176const countText = (s: Segment): string => (s.limit !== undefined ? `${s.running}/${s.limit}` : `${s.running}`)
177
178/** The texts of one entry; the done count (`✔3`) only from one up, and only while `hasDone`. */
179export function segmentText(s: Segment, shown: Shown): { label: string; count: string; done?: string } {
180 return {
181 label: shown.hasLongNames ? s.name : s.short,
182 count: countText(s),
183 done: shown.hasDone && s.done >= 1 ? `✔${s.done}` : undefined,
184 }
185}
186
187function width(list: Segment[], shown: Shown): number {
188 const items = list.map(s => {
189 const t = segmentText(s, shown)
190 return t.label.length + 1 + t.count.length + (t.done ? 1 + t.done.length : 0)
191 })
192 const body = items.reduce((w, n) => w + n, 0) + GAP * Math.max(0, items.length - 1)
193 // The gap, the title `Split ` if shown, and the cells.
194 const tk = shown.hasBar ? 1 + (shown.hasTitles ? barLabel(shown).length + 1 : 0) + BAR_CELLS : 0
195 const chip = CHIP_FRAME + (shown.hasTitles ? LABEL.length + 1 : 0) + body + tk
196 return chip + (shown.hasCrew ? CREW_GAP + CREW_COLUMNS : 0)
197}
198
199/**
200 * What fits in `columns`, starting from what the options ask for (`titles` on, `compact` off
201 * by default). Drops in this order, the least useful first: the long names (two letters
202 * instead), the titles `agents` and `Split`, the token bar, the done counts, the crew. What the
203 * options already left out is skipped. Null when not even the counts fit.
204 */
205export function fit(list: Segment[], columns: number, display: Pick<Display, 'titles' | 'compact'> = { titles: false, compact: false }): Shown | null {
206 let shown: Shown = { hasLongNames: !display.compact, hasTitles: display.titles, hasBar: true, hasDone: true, hasCrew: true }
207 const drops: (keyof Shown)[] = ['hasLongNames', 'hasTitles', 'hasBar', 'hasDone', 'hasCrew']
208 for (const drop of drops) {
209 if (width(list, shown) <= columns) return shown
210 shown = { ...shown, [drop]: false }
211 }
212 return width(list, shown) <= columns ? shown : null
213}
214
215export const LABEL_TEXT = LABEL
216/** The token bar's title. */
217export const barLabel = (shown: Shown): string => (shown.hasLongNames ? BAR_NAME : BAR_SHORT)
218hooks/workers.ts 230 lines1// Pure pixel logic of the crew right of the agents chip: one small pixel Claude
2// in a hard hat per model family, at work while the family has a subagent
3// running, waiting when all its subagents stalled, asleep otherwise. No `$` in here, so tests can drive it directly.
4import { COLOR } from './model'
5import { hexToRgb, hot } from './ring'
6import type { Rgb } from './ring'
7
8export type CrewTier = 'haiku' | 'sonnet' | 'opus'
9export const CREW: readonly CrewTier[] = ['haiku', 'sonnet', 'opus']
10/** What a worker does: works, waits (its subagents stalled), or sleeps (none running). */
11export type CrewState = 'work' | 'wait' | 'sleep'
12
13/** Each worker gets a slot SLOT pixels wide; the crew is as tall as the pixel Claude below. */
14const SLOT = 20
15export const CREW_W = SLOT * CREW.length
16export const CREW_H = 24
17/** Each sprite pixel becomes SCALE x SCALE image pixels, so the terminal scales crisp blocks. */
18export const SCALE = 4
19/** The crew is 60 x 24 pixels on 3 rows (its columns are CREW_COLUMNS in `model`). */
20export const CREW_ROWS = 3
21/** Frames of a working loop, and of the waiting one. */
22export const WORK_FRAMES = 4
23/** Phases of the sleeping z's. */
24export const Z_PHASES = 12
25/** The frame counter wraps here: a multiple of the z cycle (2 frames a phase) and of the work loop, so no animation jumps. */
26export const FRAME_WRAP = Z_PHASES * 2 * 400
27/** While the whole crew sleeps, a frame step comes every SLEEP_EVERY ticks and moves that many frames, so the z's keep their pace. */
28export const SLEEP_EVERY = 3
29
30/** How far the frame counter moves on tick number `tick`; 0 skips the tick (no redraw while all sleep). */
31export function frameStep(isAsleep: boolean, tick: number): number {
32 if (!isAsleep) return 1
33 return tick % SLEEP_EVERY === 0 ? SLEEP_EVERY : 0
34}
35
36/** The frame `step` frames on, wrapping at FRAME_WRAP. */
37export const nextFrame = (frame: number, step = 1): number => (frame + step) % FRAME_WRAP
38
39const BODY = hexToRgb('#d97757')
40const EYE: Rgb = [38, 24, 20]
41const WOOD: Rgb = [139, 90, 60]
42const IRON: Rgb = [150, 150, 158]
43const PAPER: Rgb = [245, 245, 240]
44const BOX: Rgb = [176, 124, 72]
45const BOX_EDGE: Rgb = [120, 80, 45]
46const DIRT: Rgb = [120, 84, 52]
47const TICK: Rgb = [90, 170, 90]
48const Z_RGB = hexToRgb(hot('#d97757'))
49
50/** The hard hat per family, in its color from the split bar. */
51const HELMET: Record<CrewTier, Rgb> = {
52 haiku: hexToRgb(COLOR.haiku),
53 sonnet: hexToRgb(COLOR.sonnet),
54 opus: hexToRgb(COLOR.opus),
55}
56
57const Z = ['###', '.#.', '###']
58
59type Grid = (Rgb | null)[][]
60type Paint = { put: (x: number, y: number, rgb: Rgb) => void; rect: (x: number, y: number, w: number, h: number, rgb: Rgb) => void }
61
62function canvas(): Paint & { grid: Grid } {
63 const grid: Grid = Array.from({ length: CREW_H }, () => Array<Rgb | null>(CREW_W).fill(null))
64 const put = (x: number, y: number, rgb: Rgb) => {
65 if (x < 0 || y < 0 || x >= CREW_W || y >= CREW_H) return
66 grid[y]![x] = rgb
67 }
68 const rect = (x0: number, y0: number, w: number, h: number, rgb: Rgb) => {
69 for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) put(x, y, rgb)
70 }
71 return { grid, put, rect }
72}
73
74function toPixels(grid: Grid): Uint8Array {
75 const w = CREW_W * SCALE
76 const h = CREW_H * SCALE
77 const px = new Uint8Array(w * h * 4)
78 for (let y = 0; y < h; y++)
79 for (let x = 0; x < w; x++) {
80 const rgb = grid[Math.floor(y / SCALE)]![Math.floor(x / SCALE)]
81 if (!rgb) continue
82 const i = (y * w + x) * 4
83 px[i] = rgb[0]
84 px[i + 1] = rgb[1]
85 px[i + 2] = rgb[2]
86 px[i + 3] = 255
87 }
88 return px
89}
90
91/** Body top row: the hat sits on rows 7..10, the legs end on row 21. */
92const TOP = 11
93
94type Legs = 'still' | 'left' | 'right' | 'tap'
95
96/** One worker, its body 16 pixels wide from `x + 2`; `dy` bobs it, `isArmsUp` lifts both arms, `gaze` moves the eyes. */
97function worker(p: Paint, slot: number, tier: CrewTier, isAwake: boolean, dy = 0, legs: Legs = 'still', isArmsUp = false, gaze = 0) {
98 const { rect } = p
99 const x = slot + 2
100 const top = TOP + dy
101 const hat = HELMET[tier]
102 rect(x + 5, top - 4, 6, 1, hat)
103 rect(x + 4, top - 3, 8, 2, hat)
104 rect(x + 2, top - 1, 12, 1, hat)
105 rect(x + 3, top, 10, 8, BODY)
106 if (isArmsUp) {
107 rect(x + 1, top - 2, 2, 4, BODY)
108 rect(x + 13, top - 2, 2, 4, BODY)
109 } else {
110 rect(x + 1, top + 3, 2, 2, BODY)
111 rect(x + 13, top + 3, 2, 2, BODY)
112 }
113 if (isAwake) {
114 rect(x + 5 + gaze, top + 2, 1, 2, EYE)
115 rect(x + 10 + gaze, top + 2, 1, 2, EYE)
116 } else {
117 rect(x + 4, top + 3, 3, 1, EYE)
118 rect(x + 9, top + 3, 3, 1, EYE)
119 }
120 // Walking: every other leg one pixel shorter, alternating; tapping: only the right foot lifts.
121 const short = legs === 'left' ? [6, 11] : legs === 'right' ? [4, 9] : legs === 'tap' ? [11] : []
122 for (const dx of [4, 6, 9, 11]) rect(x + dx, top + 8, 1, short.includes(dx) ? 2 : 3, BODY)
123}
124
125/** Haiku: walks in place with a crate held over the head. */
126function crate(p: Paint, slot: number, frame: number) {
127 const { rect } = p
128 const dy = frame % 2
129 worker(p, slot, 'haiku', true, dy, dy ? 'left' : 'right', true)
130 const x = slot + 5
131 const y = TOP - 8 + dy
132 rect(x, y, 10, 5, BOX)
133 rect(x, y, 10, 1, BOX_EDGE)
134 rect(x + 4, y, 2, 5, BOX_EDGE)
135}
136
137/** Sonnet: the shovel goes into the ground, then throws the dirt up. */
138function shovel(p: Paint, slot: number, frame: number) {
139 const { rect } = p
140 worker(p, slot, 'sonnet', true)
141 rect(slot + 15, TOP + 10, 5, 1, DIRT)
142 if (frame % 2 === 0) {
143 rect(slot + 16, TOP - 1, 1, 9, WOOD)
144 rect(slot + 15, TOP + 8, 3, 2, IRON)
145 } else {
146 rect(slot + 15, TOP + 2, 1, 4, WOOD)
147 rect(slot + 16, TOP + 1, 1, 1, WOOD)
148 rect(slot + 17, TOP - 1, 3, 2, IRON)
149 rect(slot + 18, TOP - 3, 1, 1, DIRT)
150 rect(slot + 19, TOP - 5, 1, 1, DIRT)
151 if (frame === 3) rect(slot + 17, TOP - 6, 1, 1, DIRT)
152 }
153}
154
155/** Opus: a big clipboard, one line ticked green per frame, the pencil beside it. */
156function clipboard(p: Paint, slot: number, frame: number) {
157 const { rect } = p
158 worker(p, slot, 'opus', true)
159 const ticks = frame % WORK_FRAMES
160 rect(slot + 13, TOP - 1, 7, 9, WOOD)
161 rect(slot + 14, TOP, 5, 7, PAPER)
162 for (let i = 0; i < ticks; i++) rect(slot + 15, TOP + 1 + i * 2, 3, 1, TICK)
163 rect(slot + 19, TOP - 1 + ticks * 2, 1, 3, EYE)
164}
165
166/** Waiting: stands, looks at a small clock on its wrist, whose hand ticks round, and taps its foot. */
167function waiting(p: Paint, slot: number, tier: CrewTier, frame: number) {
168 const { rect, put } = p
169 worker(p, slot, tier, true, 0, frame % 2 ? 'tap' : 'still', false, 1)
170 const x = slot + 14
171 // The right arm reaches out below the clock, which sits in front of the body.
172 rect(slot + 15, TOP + 5, 2, 2, BODY)
173 rect(x, TOP, 5, 5, IRON)
174 rect(x + 1, TOP + 1, 3, 3, PAPER)
175 put(x + 2, TOP + 2, EYE)
176 // The hand points up, right, down, left.
177 const [hx, hy] = [
178 [2, 1],
179 [3, 2],
180 [2, 3],
181 [1, 2],
182 ][frame % WORK_FRAMES]!
183 put(x + hx!, TOP + hy!, EYE)
184}
185
186function zs(p: Paint, x: number, zPhase: number) {
187 // Two z's one by one, then a pause: the slot is too small for a third.
188 const shown = zPhase < 8 ? Math.floor(zPhase / 4) + 1 : 0
189 const spots = [
190 [x + 12, 3],
191 [x + 8, 0],
192 ] as const
193 spots.slice(0, shown).forEach(([x0, y0]) =>
194 Z.forEach((line, dy) => {
195 for (let dx = 0; dx < line.length; dx++) if (line[dx] === '#') p.put(x0 + dx, y0 + dy, Z_RGB)
196 }),
197 )
198}
199
200/**
201 * The crew: Haiku, Sonnet, Opus left to right, each at work (`frame`) or waiting
202 * with its clock as `states` says for its family, asleep with rising z's otherwise.
203 */
204export function crewPixels(states: Record<CrewTier, CrewState>, frame: number): Uint8Array {
205 const c = canvas()
206 const f = frame % WORK_FRAMES
207 const zPhase = Math.floor(frame / 2) % Z_PHASES
208 CREW.forEach((tier, i) => {
209 const slot = i * SLOT
210 if (states[tier] === 'sleep') {
211 worker(c, slot, tier, false)
212 zs(c, slot + 2, zPhase)
213 } else if (states[tier] === 'wait') waiting(c, slot, tier, f)
214 else if (tier === 'haiku') crate(c, slot, f)
215 else if (tier === 'sonnet') shovel(c, slot, f)
216 else clipboard(c, slot, f)
217 })
218 return toPixels(c.grid)
219}
220
221/** The image key for a crew state: one picture per family state, work frame and z phase. */
222export function crewKey(crew: Record<CrewTier, CrewState>, frame: number): string {
223 const letter = { work: 'w', wait: 'c', sleep: 's' } as const
224 const states = CREW.map(t => letter[crew[t]]).join('')
225 // Only what changes the picture: the frame while one works or waits, the z phase while one sleeps.
226 const work = /[wc]/.test(states) ? frame % WORK_FRAMES : '-'
227 const zs = states.includes('s') ? Math.floor(frame / 2) % Z_PHASES : '-'
228 return `c${states}${work}.${zs}`
229}
230hooks/ring.ts 98 lines1// Pixel helpers of the row: colors, the whitened "hot" tone, base64 for images and
2// the token bar as RGBA pixels.
3
4const SAMPLES = 4
5const TRACK_ALPHA = 0.22
6/** How far the hot tone moves toward white. */
7const CORE = 0.35
8
9export type Rgb = readonly [number, number, number]
10
11export function hexToRgb(hex: string): Rgb {
12 const n = Number.parseInt(hex.replace('#', ''), 16)
13 return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
14}
15
16function toWhite(rgb: Rgb, t: number): Rgb {
17 return [0, 1, 2].map(i => Math.round(rgb[i]! + (255 - rgb[i]!) * t)) as unknown as Rgb
18}
19
20/** The hot tone of `hex`: the color moved toward white, for counts and the crew's z's. */
21export function hot(hex: string): string {
22 return `#${toWhite(hexToRgb(hex), CORE)
23 .map(c => c.toString(16).padStart(2, '0'))
24 .join('')}`
25}
26
27const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
28
29/** Standard padded base64, as `Image` sources take it. */
30export function toBase64(bytes: Uint8Array): string {
31 let out = ''
32 for (let i = 0; i < bytes.length; i += 3) {
33 const a = bytes[i] ?? 0
34 const b = bytes[i + 1] ?? 0
35 const c = bytes[i + 2] ?? 0
36 const n = (a << 16) | (b << 8) | c
37 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
38 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
39 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
40 }
41 return out
42}
43
44/** Pixels per terminal column and row, as the rings use them. */
45const CELL_W = 16
46const CELL_H = 32
47/** The bar's height and the gap between two of its runs, in pixels. */
48const BAR_H = 12
49const BAR_GAP = 2
50
51/** The bar's picture size in pixels for `cells` columns and one row. */
52export function barSize(cells: number): { width: number; height: number } {
53 return { width: cells * CELL_W, height: CELL_H }
54}
55
56/**
57 * A rounded bar `cells` columns wide, one run per part in its color, a small gap
58 * between runs; with no parts, an empty track in `track`, dimmed.
59 */
60export function barPixels(parts: readonly { cells: number; rgb: Rgb }[], cells: number, track: Rgb): Uint8Array {
61 const { width: w, height: h } = barSize(cells)
62 const top = (h - BAR_H) / 2
63 const r = BAR_H / 2
64 const total = parts.reduce((n, p) => n + p.cells, 0)
65 // Where each run ends, in pixels.
66 let at = 0
67 const ends = parts.map(p => (at += (p.cells / (total || 1)) * w))
68 const pixels = new Uint8Array(w * h * 4)
69 const step = 1 / SAMPLES
70 for (let py = 0; py < h; py++) {
71 for (let px = 0; px < w; px++) {
72 let hit = 0
73 let rgb: Rgb | undefined
74 for (let sy = 0; sy < SAMPLES; sy++) {
75 for (let sx = 0; sx < SAMPLES; sx++) {
76 const x = px + (sx + 0.5) * step
77 const y = py + (sy + 0.5) * step
78 // Inside the pill: the middle band, or one of the two round ends.
79 const cx = Math.min(Math.max(x, r), w - r)
80 if (Math.hypot(x - cx, y - (top + r)) > r) continue
81 const i = ends.findIndex(e => x < e)
82 if (i > 0 && x < ends[i - 1]! + BAR_GAP) continue
83 hit++
84 rgb = parts[i === -1 ? parts.length - 1 : i]?.rgb
85 }
86 }
87 const o = (py * w + px) * 4
88 const alpha = hit / (SAMPLES * SAMPLES)
89 const c = rgb ?? track
90 pixels[o] = c[0]
91 pixels[o + 1] = c[1]
92 pixels[o + 2] = c[2]
93 pixels[o + 3] = Math.round(alpha * (rgb ? 255 : TRACK_ALPHA * 255))
94 }
95 }
96 return pixels
97}
98types/index.d.ts 39 lines1/** A model family; `other` for a model whose name says none of the three. */
2export type Tier = 'haiku' | 'sonnet' | 'opus' | 'other'
3
4/** Where a subagent stands, as the board tracks it. */
5export type AgentState = 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
6
7export type Agent = {
8 id: string
9 /** The model it runs on, as the spawn resolved it. */
10 model: string
11 tier: Tier
12 state: AgentState
13 /** Whether the engine's list has shown it (a workflow's agents never are). */
14 isListed?: boolean
15 /** Polls in a row it was missing from the engine's list while live. */
16 misses?: number
17 /** When it last used tokens (its spawn counts); a live agent silent for too long is stalled. */
18 lastTokenAt?: number
19}
20
21/** Tokens per loop owner: the main loop, or the subagents by model family. */
22export type Tokens = Partial<Record<'main' | Tier, number>>
23
24declare module 'claude-code' {
25 interface PluginState {
26 'agent-board': {
27 /** This chat's subagents by id. */
28 agents: Record<string, Agent>
29 tokens: Tokens
30 /** When the last live subagent ended; null while one runs or before the first. */
31 idleSince: number | null
32 /** Whether the row is hidden after being idle for the set time. */
33 hidden: boolean
34 /** Animation frame counter for the workers. */
35 frame: number
36 }
37 }
38}
39