SLOPSHOPPER

agent-board

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

newbandtimeragents
v2.0.0MITupdated 2026-10-10Oualid0/claude-mods/plugins/agent-board
A shopper browsing a rack in a slop shop
README

claude-mods

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.

Quick install

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.

Manual install

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.

Update

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.

Mods

PluginWhat it does
usage-ringOne 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-boardA 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.

What the labels mean

LabelGroupMeaning
Week (Wk)limitsWeekly limit used, in percent, as a ring and a number. Red from 95%.
Session (Se)limitsSession 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)chatContext window used, in percent, as a ring and a number. Yellow from 50%, red from 80%.
Cache (Ca)chatThe 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)chatTodos 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)tokensOnly 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)agentsModel 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.
✔agentsSubagents 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)agentsA 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.

Limits

  • Needs a Claude Code version with mods (function-hook plugins); tested with Claude Code 2.1.296. The mod API is early access and may change between versions.
  • The rings (Wk, Se, Cx), the split bar and the crew are pixel images in kitty and Ghostty; other terminals show a glyph instead.
  • Percentages are rounded and held to 0..100; an entry without a figure is left out.
  • The week and session rings turn red from 95%; the context ring turns yellow from 50% and red from 80%; the cache time turns yellow and red as the 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.
  • The 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.
  • Its token counts start at 0 when the plugin loads; earlier tokens of a resumed session are not available. Tokens of loops it never saw start (the engine's compaction and memory forks) count as the main loop's. The row is emptied on /clear and when another chat is resumed.
  • A subagent that has produced no tokens for 5 minutes still counts as running in the chip; only its crew worker waits (clock and tapping foot) once all running subagents of its family are silent. One that sits in a single long tool call (over 5 minutes) looks the same.
  • The limits are display only: nothing is blocked when more subagents run than the limit.

Options

usage-ring has five options, all off by default:

OptionMeaning
limitsFileWrite 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.
showModelShow the model and its effort in grey next to the pixel Claude, e.g. Opus 5.5 (mid).
showTokensShow 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.
titlesStart each group with its title: limits, chat and (with showTokens) tokens.
compactAlways 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:

OptionMeaning
maxHaikuLimit of Haiku subagents running at once.
maxSonnetLimit of Sonnet subagents running at once.
maxOpusLimit of Opus subagents running at once.
hideAfterMinutes 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.
titlesShow the words agents (the row's title) and Split (the bar's label).
compactAlways write the families as Ha, So, Op, Ot (and the bar as Sp), not only when the terminal is narrow.
hideUnusedShow 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

Development

  • Each plugin lives in 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/.
  • Check: claude plugin validate ., then claude plugin validate plugins/<name> and claude plugin test plugins/<name>.
  • Installed from a local clone, Claude Code reads the files in place: changes apply with /reload-plugins or the next session.
  • Both mods draw above the prompt; each render hook calls next(e) and keeps what is beneath (agent-board's row on top, usage-ring's band next to the prompt).

License

MIT, see LICENSE.

Source 5 files
hooks/register.tsx 340 lines
1import { 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}
340
hooks/model.ts 218 lines
1import 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)
218
hooks/workers.ts 230 lines
1// 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}
230
hooks/ring.ts 98 lines
1// 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}
98
types/index.d.ts 39 lines
1/** 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