A live crew panel: every agent, its model, effort, tokens and estimated cost, with a friendly pixel crab.

Claude Code mods from Barebone Agents. Each mod is a plain Claude Code plugin you can read in a few minutes.
| Mod | What it does |
|---|---|
activity | A live crew panel: every agent in your session, its model, effort, tokens, estimated cost and time, with a friendly pixel crab. Terminal and desktop app. |
token-weather | A one-line forecast of your context window above the prompt. |
/plugin marketplace add gveshk/barebone-mods
/plugin install activity@barebone-mods
/plugin install token-weather@barebone-mods
/reload-plugins
Mods run with the same access Claude Code has, so read the code first. Neither mod here makes network calls, touches the filesystem or starts a process; the call manifest at the bottom lists every engine API the code uses. To pin what you reviewed, clone the repo at a commit and load it by hand with claude --plugin-dir ./activity instead of installing from the marketplace (a marketplace install tracks main).
Mods use Claude Code's function-hooks API, which is early access and may change. They were tested on Claude Code 2.1.291. The unit tests and a headless load pass, but the interactive panes have not yet been checked by eye in a live terminal or the desktop app; see docs/manual-checklist.md.
Open or close the pane with /activity. In a fullscreen terminal at 110 columns or wider it docks beside the transcript; otherwise it sits above the prompt. It also tries to open by itself once per flow (or per session when no flow is declared), when a flow with tasks is declared or a subagent starts. The engine seats a pane nobody asked for only at 144 columns or wider (110 once you have opened it yourself); below that it waits undrawn until you run /activity. In a terminal that is not fullscreen, an opened pane sits above the prompt.
What the pane lists, top to bottom:
+N older line for the rest. Failures are counted in the heading (N failed) even while the list is collapsed.flow tool that have not started yet.How a row is drawn:
Raster of half-block pixels. It needs a truecolor-capable terminal for exact colours and falls back to the nearest palette otherwise. It only appears when the pane body is at least 60 columns wide; a narrower pane shows the text lines only.Numbers:
activity/hooks/prices.ts, not a bill. Documented prices exist for Fable and Mythos 5 and 5.1, Opus 5.5, Opus 5 and 4.6 to 4.8, Sonnet 5.5, 5 and 4.6, and Haiku 4.5. Cache prices are derived from the input price (reads 0.1x, writes 1.25x) except where the table cites them: cache reads are $1/MTok for Fable 5 and Mythos 5, $0.25 for Fable 5.1 and Mythos 5.1, and $0.20 for Opus 5.5 and Sonnet 5.5. A model that matches none of these uses an Opus-tier fallback price. The session total in the header comes from Claude Code itself.The band above the prompt:
agents, phase and progress, running count, tokens and estimated cost. ×N toggles the pane.token-weather, next-steps) instead of replacing them.finished: true); the pane keeps showing the finished flow and its tasks. A later batch of agents shows the band again.✕ hides it for the current flow. The choice is remembered per flow, and it does not delete the plan: the pane still lists the planned tasks.Optional tools for skills:
mcp__activity__flow lets a skill report a plan (title, phases, tasks, current phase, done count) and mcp__activity__step lets a subagent report its own progress bar. Both exist only when the reportTools option is not false; set it to false to leave the model's tool list untouched.What it never does:
MIT. The activity plugin is derived from savvy-progress in JohnnyVizz/claude-kit (commit 272a877), MIT, Copyright (c) 2026 johnnyvizz; see NOTICE. The crab mascot and all artwork in this repository are original and are not taken from that project.
Output of claude plugin validate, listing each hook and engine call a plugin makes:
Validating hooks: activity/hooks/hooks.json
❯ ./register.tsx hooks: session.start, command.run{command=activity}, tool.call{tool=mcp__activity__flow}, tool.call{tool=mcp__activity__step}, agent.spawn, turn.step, turn.complete, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=activity}
❯ ./register.tsx gating hook with .catch: tool.call{tool=mcp__activity__flow}
❯ ./register.tsx gating hook with .catch: tool.call{tool=mcp__activity__step}
❯ ./register.tsx gating hook without .catch: agent.spawn
❯ ./register.tsx calls: $.clock.every, $.clock.now, $.command.register, $.session.model, $.session.usage, $.state.get, $.state.set, $.tool.register, $.ui.close (via togglePane), $.ui.open (via autoOpen, togglePane), $.ui.panes (via togglePane), $.ui.resolve
❯ ./register.tsx state writes: activity.crew, activity.flow, activity.now, activity.panel
❯ ./register.tsx state reads: activity.crew, activity.flow, activity.now, activity.panel
✔ Validation passed
Validating hooks: token-weather/hooks/hooks.json
❯ ./register.tsx hooks: session.start, turn.complete, ui.render{component=AbovePrompt}
❯ ./register.tsx calls: $.session.usage (via takeReading), $.state.get, $.state.set, $.ui.resolve
❯ ./register.tsx state writes: token-weather.readings
❯ ./register.tsx state reads: token-weather.readings
✔ Validation passedhooks/register.tsx 301 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { clean } from './format'
5import { laneOf } from './lanes'
6import { renderBand, renderPane } from './render'
7import {
8 applyComplete,
9 applySpawn,
10 applyStep,
11 applyStepReport,
12 bandKey,
13 blankFlow,
14 ensureMain,
15 flowLabel,
16 isBandShown,
17 isNewFlow,
18 mergeFlow,
19 norm,
20} from './reducers'
21import type { FlowInput } from './reducers'
22
23const PANE = 'activity'
24const FLOW_TOOL = 'mcp__activity__flow'
25const STEP_TOOL = 'mcp__activity__step'
26
27const crew = atom({ plugin: 'activity', key: 'crew' } as const, [])
28const flow = atom({ plugin: 'activity', key: 'flow' } as const, null)
29const panel = atom({ plugin: 'activity', key: 'panel' } as const, { isDoneCollapsed: false, autoOpenedFor: '', dismissedFor: '' })
30const now = atom({ plugin: 'activity', key: 'now' } as const, 0)
31
32async function togglePane($: EngineInterface): Promise<boolean> {
33 // A pane that exists but is not seated (an unasked auto-open below the width floor waits undrawn) is not open.
34 const isOpen = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced && p.isShown)
35
36 if (isOpen) {
37 await $.ui.close({ id: PANE })
38
39 return false
40 }
41
42 const at = await $.clock.now()
43 await update($, now, () => at)
44 await $.ui.open({ id: PANE, title: 'Crew' })
45
46 return true
47}
48
49async function autoOpen($: EngineInterface, key: string): Promise<void> {
50 let shouldOpen = false
51
52 await update($, panel, prev => {
53 shouldOpen = prev.autoOpenedFor !== key
54
55 return shouldOpen ? { ...prev, autoOpenedFor: key } : prev
56 })
57
58 if (shouldOpen) {
59 void $.ui.open({ id: PANE, title: 'Crew' }).catch(() => {})
60 }
61}
62
63// Ticks the clocks of running rows once a second; quiet when nothing runs.
64async function tick($: EngineInterface): Promise<void> {
65 const rows = await read($, crew)
66
67 if (!rows.some(r => r.status === 'running')) {
68 return
69 }
70
71 const at = await $.clock.now()
72 await update($, now, () => at)
73}
74
75export const register: Register = (on, options) => {
76 const wantsTools = options.reportTools !== false
77
78 on('session.start', async ($, e, next) => {
79 const started = await next(e)
80 const at = await $.clock.now()
81 const model = await $.session.model()
82
83 await update($, crew, rows => ensureMain(rows, model, at))
84 await update($, now, () => at)
85
86 if (wantsTools) {
87 await $.tool.register({
88 name: 'flow',
89 description:
90 'Report a plan to the activity panel and the band above the prompt. Call it after presenting a plan (title, tasks, phase), ' +
91 'when a task is accepted (done), when the phase changes, and once at the end with finished: true. Fields left out keep their previous value.',
92 inputSchema: {
93 type: 'object',
94 properties: {
95 title: { type: 'string', description: 'Short name of the overall task, a few words.' },
96 phases: { type: 'array', items: { type: 'string' }, description: 'Optional ordered phase names, free-form.' },
97 phase: { type: 'string', description: 'The current phase name.' },
98 total: { type: 'integer', minimum: 0, description: 'Number of planned tasks.' },
99 done: { type: 'integer', minimum: 0, description: 'Number of tasks finished.' },
100 finished: { type: 'boolean', description: 'True once the whole flow is closed.' },
101 tasks: {
102 type: 'array',
103 description: 'The planned tasks in order, numbered from 1. A title should equal the Agent tool description the task is delegated with, so the panel can match runs to tasks.',
104 items: {
105 type: 'object',
106 properties: {
107 title: { type: 'string', description: 'A few words.' },
108 lane: { type: 'string', description: 'A lane name for color grouping, optional.' },
109 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
110 },
111 required: ['title'],
112 },
113 },
114 },
115 },
116 })
117
118 await $.tool.register({
119 name: 'step',
120 description:
121 'For subagents: report progress on your own task to the activity panel. Call it right after reading your brief with `total` ' +
122 '(your plan in 3-8 steps) and `done: 0`, then again as each step finishes. Cheap and silent: it only draws a bar.',
123 inputSchema: {
124 type: 'object',
125 properties: {
126 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
127 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change.' },
128 note: { type: 'string', description: 'The step in progress, a few words.' },
129 },
130 required: ['done'],
131 },
132 })
133 }
134
135 await $.command.register({ name: 'activity', description: 'Show or hide the activity panel: every agent, its model, tokens and cost' })
136 $.clock.every(1000, () => {
137 void tick($).catch(() => {})
138 })
139
140 return started
141 })
142
143 on('command.run', { command: 'activity' }, async $ => {
144 const isOpen = await togglePane($)
145
146 return { text: isOpen ? 'Activity panel opened' : 'Activity panel closed' }
147 })
148
149 if (wantsTools) {
150 on('tool.call', { tool: FLOW_TOOL }, async ($, e) => {
151 if (e.agentId) {
152 return { result: 'ignored: only the main session reports the flow' }
153 }
154
155 const input = e as unknown as FlowInput
156 let isNew = false
157 let merged = blankFlow()
158
159 await update($, flow, prev => {
160 isNew = isNewFlow(prev, input)
161 merged = mergeFlow(prev, input)
162
163 return merged
164 })
165
166 if (isNew) {
167 await update($, crew, rows => rows.filter(r => r.id === 'main' || r.status === 'running'))
168 await update($, panel, prev => ({ ...prev, dismissedFor: '', autoOpenedFor: '' }))
169 }
170
171 // A finishing call must not auto-open the pane under the session key.
172 if (merged.tasks.length > 0 && !merged.isFinished) {
173 await autoOpen($, bandKey(merged))
174 }
175
176 return { result: `ok: ${flowLabel(merged) || 'flow updated'}` }
177 }).catch(() => ({ result: 'ignored: the activity panel could not record this' }))
178
179 // A worker's own progress: the call runs in the worker's loop, so agentId names it.
180 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
181 const input = e as unknown as { done?: number; total?: number; note?: string }
182 const agentId = e.agentId
183
184 if (!agentId) {
185 return { result: 'ignored: only subagents report steps' }
186 }
187
188 await update($, crew, rows => applyStepReport(rows, agentId, input))
189
190 return { result: 'ok' }
191 }).catch(() => ({ result: 'ignored: the activity panel could not record this' }))
192 }
193
194 // Observers: they call next first. If our bookkeeping throws afterwards the engine keeps next's result, so the call's own result is never lost and nothing runs twice; only our bookkeeping for that one event is (hence no .catch).
195 // This covers agent.spawn, turn.step and turn.complete.
196 on('agent.spawn', async ($, e, next) => {
197 const started = await next(e)
198
199 if (started.deny !== undefined || started.agentId === undefined) {
200 return started
201 }
202
203 const at = await $.clock.now()
204 const f = await read($, flow)
205 const description = clean(e.description, 80)
206 const reported = f?.tasks.find(t => norm(t.title) === norm(description))?.lane
207
208 const hadRunning = (await read($, crew)).some(r => r.id !== 'main' && r.status === 'running')
209
210 // A subagent's first turn.step could in theory land before this row exists; model requests take seconds, this takes milliseconds.
211 await update($, crew, rows =>
212 applySpawn(rows, {
213 id: started.agentId,
214 agentId: started.agentId,
215 type: e.subagentType,
216 lane: laneOf(e.subagentType, reported),
217 description,
218 model: started.model,
219 at,
220 }),
221 )
222 await update($, now, () => at)
223
224 if (!hadRunning) {
225 await update($, panel, prev => (prev.dismissedFor === 'session' ? { ...prev, dismissedFor: '' } : prev))
226 }
227
228 await autoOpen($, bandKey(f))
229
230 return started
231 })
232
233 // Each model request: live context, tokens and cost, for the main loop and every subagent.
234 on('turn.step', async function* ($, e, next) {
235 const result = yield* next(e)
236
237 if (result.usage) {
238 const usage = result.usage
239
240 await update($, crew, rows =>
241 applyStep(rows, { agentId: e.agentId, model: e.model, effort: typeof e.effort === 'string' ? e.effort : undefined, usage }),
242 )
243 }
244
245 return result
246 })
247
248 on('turn.complete', async ($, e, next) => {
249 const result = await next(e)
250 const at = await $.clock.now()
251
252 await update($, crew, rows => applyComplete(rows, { agentId: e.agentId, reason: e.reason, at, usage: e.usage ?? null }))
253 await update($, now, () => at)
254
255 return result
256 })
257
258 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259 const below = await next(e)
260 const rows = await read($, crew)
261 const f = await read($, flow)
262 const p = await read($, panel)
263
264 if (e.props.hasSurvey || !isBandShown(rows, f, p.dismissedFor === bandKey(f))) {
265 return below
266 }
267
268 return renderBand($.ui.resolve(e), {
269 rows,
270 flow: f,
271 below,
272 onOpen: () => void togglePane($).catch(() => {}),
273 onDismiss: () => {
274 void (async () => {
275 const current = await read($, flow)
276 await update($, panel, prev => ({ ...prev, dismissedFor: bandKey(current) }))
277 })().catch(() => {})
278 },
279 })
280 })
281
282 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
283 const rows = await read($, crew)
284 const f = await read($, flow)
285 const p = await read($, panel)
286 const at = await read($, now)
287 const usage = await $.session.usage().catch(() => null)
288
289 return renderPane($.ui.resolve(e), {
290 rows,
291 flow: f,
292 panel: p,
293 at,
294 sessionUsd: usage?.cost?.usd,
295 columns: e.props.bodyColumns,
296 surface: e.surface,
297 onToggleDone: () => void update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed })).catch(() => {}),
298 })
299 })
300}
301hooks/format.ts 86 lines1export const short = (n: number): string => {
2 if (!Number.isFinite(n) || n <= 0) {
3 return '0'
4 }
5
6 // 999_950 rounds to 1.0M, so promote before toFixed can print "1000k".
7 if (n >= 999_950) {
8 return `${+(n / 1_000_000).toFixed(1)}M`
9 }
10
11 if (n >= 1_000) {
12 return `${+(n / 1_000).toFixed(1)}k`
13 }
14
15 return String(Math.round(n))
16}
17
18export const duration = (ms: number): string => {
19 const seconds = Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 1000)) : 0
20 const minutes = Math.floor(seconds / 60)
21 const hours = Math.floor(minutes / 60)
22 const pad = (n: number): string => String(n).padStart(2, '0')
23
24 return hours > 0 ? `${hours}:${pad(minutes % 60)}:${pad(seconds % 60)}` : `${pad(minutes)}:${pad(seconds % 60)}`
25}
26
27export const usd = (n: number): string => {
28 if (!Number.isFinite(n) || n <= 0) {
29 return '$0.00'
30 }
31
32 return n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`
33}
34
35// Majors and minors are 1-2 digits, so a date suffix (20250514) never reads as a version.
36const MODEL = /(fable|mythos|opus|sonnet|haiku)-(\d{1,2})(?!\d)(?:-(\d{1,2})(?!\d))?/i
37
38// An id that is not a known family is returned as is: clean() it before drawing.
39export const modelName = (id: string): string => {
40 const match = MODEL.exec(id)
41
42 if (!match) {
43 return /^[a-z]+$/i.test(id) ? id.charAt(0).toUpperCase() + id.slice(1).toLowerCase() : id
44 }
45
46 const family = match[1]!.charAt(0).toUpperCase() + match[1]!.slice(1).toLowerCase()
47
48 return match[3] ? `${family} ${match[2]}.${match[3]}` : `${family} ${match[2]}`
49}
50
51export const percent = (n: number, of: number): number =>
52 Number.isFinite(n) && of > 0 ? Math.min(100, Math.max(0, Math.round((100 * n) / of))) : 0
53
54export const bar = (ratio: number, width: number): string => {
55 const cells = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0
56 const r = Math.min(1, Math.max(0, Number.isFinite(ratio) ? ratio : 0))
57 const filled = Math.round(r * cells)
58
59 return '█'.repeat(filled) + '░'.repeat(cells - filled)
60}
61
62// Agent descriptions and notes are model output: drop terminal escapes and
63// every control/format/unassigned character, fold whitespace, cap by code point.
64// This makes text safe for a TERMINAL. Text drawn into SVG must ALSO be XML-escaped
65// (`esc` in svg-rows.ts) after clean(): clean() leaves `& < > " '` untouched.
66const ESCAPES = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
67const UNSEEN = /[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Cs}]/gu
68
69export const clean = (text: string, max: number): string => {
70 const limit = Math.floor(max)
71
72 if (!(limit > 0)) {
73 return ''
74 }
75
76 const safe = text
77 .replace(ESCAPES, '')
78 .replace(/\s+/g, ' ')
79 .replace(UNSEEN, '')
80 .replace(/ {2,}/g, ' ')
81 .trim()
82 const points = [...safe]
83
84 return points.length > limit ? `${points.slice(0, limit - 1).join('').trimEnd()}…` : safe
85}
86hooks/lanes.ts 28 lines1import { clean } from './format'
2
3// Accent colors for lanes. None is the mascot's coral, so a lane never looks like the crab.
4export const LANE_COLORS = ['#7aa2f7', '#e0af68', '#9ece6a', '#bb9af7', '#7dcfff', '#ff9bd2'] as const
5
6export const hash = (text: string): number => {
7 let h = 5381
8
9 for (const char of text) {
10 h = ((h * 33) ^ char.codePointAt(0)!) >>> 0
11 }
12
13 return h
14}
15
16export const FAILED = '#e5484d'
17
18export const laneColor = (lane: string): string => LANE_COLORS[hash(lane) % LANE_COLORS.length]!
19
20// A lane is the name a skill reported, else the agent type without its plugin prefix.
21// Both come from model or plugin text, so they are cleaned (terminal-safe) here.
22export const laneOf = (type: string, reported?: string): string => {
23 const raw = clean(reported ?? '', 40) || clean(type ?? '', 40)
24 const bare = raw.includes(':') ? raw.slice(raw.lastIndexOf(':') + 1).trim() : raw
25
26 return bare === '' ? 'agent' : bare
27}
28hooks/render.tsx 158 lines1import type { ElementTable } from 'claude-code'
2
3import type { Flow, Panel, Row } from '../types'
4import { bar, short, usd } from './format'
5import { FAILED, laneColor } from './lanes'
6import { flowLabel, plannedOf, totals } from './reducers'
7import { rowText } from './row-text'
8import { spriteRaster } from './sprite'
9import { ROW_H, rowSvg } from './svg-rows'
10
11const DONE = '#5fbf8f'
12const ACCENT = '#8f8cf4'
13
14export type BandData = {
15 rows: Row[]
16 flow: Flow | null
17 below: unknown
18 onOpen: () => void
19 onDismiss: () => void
20}
21
22export function renderBand(ui: ElementTable, d: BandData) {
23 const { Box, Text, Button } = ui
24 const t = totals(d.rows)
25 const running = d.rows.filter(r => r.id !== 'main' && r.status === 'running').length
26 const label = d.flow ? flowLabel(d.flow) : ''
27
28 return (
29 <Box flexDirection="column">
30 {d.below}
31 <Box flexDirection="row" gap={2}>
32 <Text color={running > 0 ? ACCENT : DONE}>●</Text>
33 <Text bold wrap="truncate-end">
34 {d.flow?.title || 'agents'}
35 </Text>
36 {label !== '' && <Text>{label}</Text>}
37 <Text dimColor>{`${running} running · ${short(t.tokens)} tok · est. ${usd(t.costUsd)}`}</Text>
38 <Button key="crew" label={`×${d.rows.filter(r => r.id !== 'main').length}`} plain onPress={d.onOpen} />
39 <Button key="dismiss" label="✕" plain role="dismiss" onPress={d.onDismiss} />
40 </Box>
41 </Box>
42 )
43}
44
45export type PaneData = {
46 rows: Row[]
47 flow: Flow | null
48 panel: Panel
49 at: number
50 sessionUsd?: number
51 columns: number
52 surface: string
53 onToggleDone: () => void
54}
55
56// The stats line needs about 42 cells next to the 17-wide sprite.
57const SPRITE_MIN_COLUMNS = 60
58const MAX_FINISHED = 20
59
60function terminalRow(ui: ElementTable, r: Row, d: PaneData) {
61 const { Box, Text } = ui
62 const Raster = (ui as any).Raster
63 const text = rowText(r, d.at)
64 const accent = laneColor(r.lane)
65 const frame = (r.status === 'running' ? Math.floor(d.at / 1000) % 2 : 0) as 0 | 1
66 const sprite = Raster !== undefined && d.columns >= SPRITE_MIN_COLUMNS ? spriteRaster(r.status, frame) : null
67 const total = Number.isFinite(r.stepTotal) && (r.stepTotal ?? 0) > 0 ? Math.min(9999, Math.floor(r.stepTotal!)) : 0
68 const rawDone = Number.isFinite(r.stepDone) ? Math.floor(r.stepDone!) : 0
69 const done = total > 0 ? Math.min(total, Math.max(0, rawDone)) : 0
70 const ratio = total > 0 ? done / total : 0
71
72 return (
73 <Box key={r.id} flexDirection="row" gap={1} marginBottom={1} alignItems="center">
74 {sprite && (
75 <Box flexShrink={0} width={sprite.columns}>
76 <Raster key={`sprite-${r.id}`} columns={sprite.columns} rows={sprite.rows} cells={sprite.cells} />
77 </Box>
78 )}
79 <Box flexDirection="column" flexShrink={1}>
80 <Text wrap="truncate-end">
81 <Text color={r.status === 'failed' ? FAILED : accent}>● </Text>
82 <Text bold>{text.title}</Text>
83 {text.mark}
84 </Text>
85 <Text dimColor wrap="truncate-end">
86 {text.model + text.note}
87 </Text>
88 <Text wrap="truncate-end">{text.stats}</Text>
89 {total > 0 ? <Text color={accent}>{`${bar(ratio, 16)} ${done}/${total}`}</Text> : null}
90 </Box>
91 </Box>
92 )
93}
94
95function desktopRow(ui: ElementTable, r: Row, d: PaneData) {
96 const Svg = (ui as any).Svg
97 const text = rowText(r, d.at)
98 const width = Math.max(240, Math.min(900, d.columns * 8 - 24))
99
100 return (
101 <Svg
102 key={r.id}
103 source={rowSvg(r, width, d.at)}
104 alt={`${text.title}: ${text.model}, ${r.status}, ${text.stats}`}
105 width={width}
106 height={ROW_H}
107 />
108 )
109}
110
111export function renderPane(ui: ElementTable, d: PaneData) {
112 const { Box, Text, Button } = ui
113 const hasSvg = d.surface !== 'terminal' && (ui as any).Svg !== undefined
114 const row = (r: Row) => (hasSvg ? desktopRow(ui, r, d) : terminalRow(ui, r, d))
115
116 const main = d.rows.find(r => r.id === 'main')
117 const agents = d.rows.filter(r => r.id !== 'main')
118 const running = agents.filter(r => r.status === 'running')
119 const finished = agents.filter(r => r.status !== 'running')
120 const failedCount = finished.filter(r => r.status === 'failed').length
121 const shownFinished = finished.slice(-MAX_FINISHED)
122 const planned = d.flow ? plannedOf(d.flow, d.rows) : []
123 const t = totals(d.rows)
124 const session = d.sessionUsd !== undefined ? ` · session ${usd(d.sessionUsd)}` : ''
125
126 return (
127 <Box flexDirection="column">
128 <Text bold>{`Crew · ${t.running} running · ${short(t.tokens)} tok · est. ${usd(t.costUsd)}${session}`}</Text>
129 {d.flow ? <Text dimColor>{`${d.flow.title}${flowLabel(d.flow) ? ' · ' + flowLabel(d.flow) : ''}`}</Text> : null}
130 <Box marginTop={1} flexDirection="column">
131 {main ? row(main) : null}
132 {agents.length === 0 ? <Text dimColor>No subagents yet</Text> : null}
133 {running.length > 0 ? <Text dimColor>{`Running · ${running.length}`}</Text> : null}
134 {running.map(row)}
135 {finished.length > 0 ? (
136 <Button
137 key="toggle-done"
138 plain
139 label={`${d.panel.isDoneCollapsed ? '▸' : '▾'} Finished · ${finished.length}${failedCount > 0 ? ` · ${failedCount} failed` : ''}`}
140 onPress={d.onToggleDone}
141 />
142 ) : null}
143 {!d.panel.isDoneCollapsed && finished.length > shownFinished.length ? (
144 <Text dimColor>{`+${finished.length - shownFinished.length} older`}</Text>
145 ) : null}
146 {!d.panel.isDoneCollapsed && shownFinished.map(row)}
147 {planned.length > 0 ? <Text dimColor>{`Planned · ${planned.length}`}</Text> : null}
148 {planned.map(p => (
149 <Box key={`plan-${p.n}`} flexDirection="column" marginBottom={1}>
150 <Text dimColor wrap="truncate-end">{`▢ ${p.n}. ${p.title} ◷`}</Text>
151 <Text dimColor wrap="truncate-end">{` ${p.lane}${p.after.length ? ' · after ' + p.after.join(', ') : ''}`}</Text>
152 </Box>
153 ))}
154 </Box>
155 </Box>
156 )
157}
158hooks/reducers.ts 279 lines1import type { Flow, PlannedTask, Row } from '../types'
2import { clean } from './format'
3import { laneOf } from './lanes'
4import { costOf, windowOf } from './prices'
5import type { Usage } from './prices'
6
7export const MAX_ROWS = 200
8
9export const norm = (text: string): string => text.trim().toLowerCase()
10
11export const whole = (n: unknown, fallback: number): number =>
12 typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback
13
14// New work processed: cache reads are excluded (they are priced separately, in costOf).
15const fresh = (u: Usage): number => (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0)
16// Context fill: the whole prompt plus this reply.
17const sum = (u: Usage): number => fresh(u) + (u.cache_read_input_tokens || 0)
18
19// Over the cap, drop the oldest FINISHED rows first; main and running rows are never evicted,
20// so the cap is soft while many agents run at once.
21function trim(rows: Row[]): Row[] {
22 let extra = rows.length - MAX_ROWS
23
24 if (extra <= 0) {
25 return rows
26 }
27
28 return rows.filter(r => {
29 if (extra > 0 && r.id !== 'main' && r.status !== 'running') {
30 extra--
31
32 return false
33 }
34
35 return true
36 })
37}
38
39const isTarget = (r: Row, agentId: string | undefined): boolean => (agentId === undefined ? r.id === 'main' : r.agentId === agentId)
40
41// --- rows -------------------------------------------------------------------
42
43export type SpawnInfo = {
44 id: string
45 agentId?: string
46 type: string
47 lane: string
48 description: string
49 model: string
50 at: number
51}
52
53export function applySpawn(rows: Row[], s: SpawnInfo): Row[] {
54 if (rows.some(r => r.id === s.id)) {
55 return rows.map(r =>
56 r.id === s.id ? { ...r, status: 'running', endedAt: undefined, model: s.model, contextMax: windowOf(s.model) ?? r.contextMax } : r,
57 )
58 }
59
60 const round = s.description === '' ? 1 : 1 + rows.filter(r => norm(r.description) === norm(s.description)).length
61
62 const row: Row = {
63 id: s.id,
64 agentId: s.agentId,
65 lane: s.lane,
66 type: s.type,
67 description: s.description,
68 model: s.model,
69 status: 'running',
70 startedAt: s.at,
71 contextTokens: 0,
72 contextMax: windowOf(s.model),
73 tokens: 0,
74 costUsd: 0,
75 steps: 0,
76 round,
77 }
78
79 return trim([...rows, row])
80}
81
82export function ensureMain(rows: Row[], model: string, at: number): Row[] {
83 if (rows.some(r => r.id === 'main')) {
84 return rows
85 }
86
87 const main: Row = {
88 id: 'main',
89 lane: 'main',
90 type: 'main',
91 description: 'main session',
92 model,
93 status: 'done',
94 startedAt: at,
95 contextTokens: 0,
96 contextMax: windowOf(model),
97 tokens: 0,
98 costUsd: 0,
99 steps: 0,
100 round: 1,
101 }
102
103 return [main, ...rows]
104}
105
106export type StepInfo = {
107 agentId?: string
108 model?: string
109 effort?: string
110 usage: Usage & { model?: string }
111}
112
113export function applyStep(rows: Row[], s: StepInfo): Row[] {
114 return rows.map(r => {
115 if (!isTarget(r, s.agentId)) {
116 return r
117 }
118
119 const model = s.usage.model || s.model || r.model
120
121 return {
122 ...r,
123 model,
124 effort: s.effort ?? r.effort,
125 status: 'running',
126 endedAt: undefined,
127 contextTokens: sum(s.usage),
128 contextMax: windowOf(model) ?? r.contextMax,
129 tokens: r.tokens + fresh(s.usage),
130 costUsd: r.costUsd + costOf(model, s.usage),
131 steps: r.steps + 1,
132 }
133 })
134}
135
136export type CompleteInfo = {
137 agentId?: string
138 reason: string
139 at: number
140 usage?: (Usage & { model: string }) | null
141}
142
143export function applyComplete(rows: Row[], c: CompleteInfo): Row[] {
144 return rows.map(r => {
145 if (!isTarget(r, c.agentId)) {
146 return r
147 }
148
149 const status = c.agentId === undefined || c.reason === 'answer' ? 'done' : 'failed'
150 const useTurn = r.steps === 0 && c.usage
151
152 return {
153 ...r,
154 status,
155 endedAt: c.at,
156 ...(useTurn && c.usage
157 ? { model: c.usage.model || r.model, tokens: fresh(c.usage), costUsd: costOf(c.usage.model || r.model, c.usage) }
158 : {}),
159 }
160 })
161}
162
163export function applyStepReport(rows: Row[], agentId: string, input: { done?: number; total?: number; note?: string }): Row[] {
164 return rows.map(r => {
165 if (r.agentId !== agentId) {
166 return r
167 }
168
169 const total = whole(input.total, r.stepTotal ?? 0)
170 const done = whole(input.done, r.stepDone ?? 0)
171
172 return { ...r, stepTotal: total, stepDone: total > 0 ? Math.min(total, done) : done, stepNote: typeof input.note === 'string' ? clean(input.note, 60) || undefined : r.stepNote }
173 })
174}
175
176// main is the session itself, so it does not count as a running or failed agent.
177export const totals = (rows: Row[]): { tokens: number; costUsd: number; running: number; failed: number } =>
178 rows.reduce(
179 (t, r) => ({
180 tokens: t.tokens + r.tokens,
181 costUsd: t.costUsd + r.costUsd,
182 running: t.running + (r.id !== 'main' && r.status === 'running' ? 1 : 0),
183 failed: t.failed + (r.id !== 'main' && r.status === 'failed' ? 1 : 0),
184 }),
185 { tokens: 0, costUsd: 0, running: 0, failed: 0 },
186 )
187
188// --- flow -------------------------------------------------------------------
189
190export type FlowInput = {
191 title?: string
192 phases?: string[]
193 phase?: string
194 total?: number
195 done?: number
196 finished?: boolean
197 tasks?: Array<{ title?: string; lane?: string; after?: number[] }>
198}
199
200export const blankFlow = (): Flow => ({ title: '', phases: [], phase: '', total: 0, done: 0, isFinished: false, tasks: [] })
201
202const MAX_TASKS = 50
203const MAX_PHASES = 12
204const text = (v: unknown, max: number): string => (typeof v === 'string' ? clean(v, max) : '')
205
206// A call starts a NEW flow when there is none, or the previous one finished and this call carries a
207// title or tasks, or it names a different title (compared case-insensitively). A call without a title
208// or tasks updates the current flow, and a title arriving for an untitled flow keeps its tasks.
209export function isNewFlow(prev: Flow | null, input: FlowInput): boolean {
210 const title = text(input.title, 60)
211
212 if (prev === null) {
213 return true
214 }
215
216 if (prev.isFinished) {
217 return title !== '' || (Array.isArray(input.tasks) && input.tasks.length > 0)
218 }
219
220 return title !== '' && prev.title !== '' && norm(title) !== norm(prev.title)
221}
222
223export function mergeFlow(prev: Flow | null, input: FlowInput): Flow {
224 const isNew = isNewFlow(prev, input)
225 const base = prev !== null && !isNew ? prev : blankFlow()
226 const items: unknown[] | null = Array.isArray(input.tasks) ? input.tasks.slice(0, MAX_TASKS) : null
227
228 const tasks: PlannedTask[] = items
229 ? items.map((raw, i) => {
230 const item = (typeof raw === 'string' ? { title: raw } : raw && typeof raw === 'object' ? raw : {}) as {
231 title?: unknown
232 lane?: unknown
233 after?: unknown
234 }
235 const after = Array.isArray(item.after)
236 ? [...new Set(item.after.filter((n): n is number => Number.isInteger(n) && n >= 1 && n <= items.length && n !== i + 1))]
237 : []
238
239 return { n: i + 1, title: text(item.title, 80), lane: laneOf('', typeof item.lane === 'string' ? item.lane : undefined), after }
240 })
241 : base.tasks
242
243 const total = items ? Math.max(tasks.length, whole(input.total, tasks.length)) : whole(input.total, base.total)
244 const isFinished = typeof input.finished === 'boolean' ? input.finished : base.isFinished
245
246 return {
247 title: text(input.title, 60) || base.title,
248 phases: Array.isArray(input.phases) ? input.phases.map(p => text(p, 24)).filter(p => p !== '').slice(0, MAX_PHASES) : base.phases,
249 phase: isFinished ? 'done' : typeof input.phase === 'string' ? clean(input.phase, 24) : base.phase,
250 total,
251 done: Math.min(whole(input.done, base.done), total),
252 isFinished,
253 tasks,
254 }
255}
256
257export const flowLabel = (f: Flow): string => {
258 const progress = f.total > 0 ? `${f.done}/${f.total}` : ''
259
260 return [f.phase, progress].filter(Boolean).join(' · ')
261}
262
263export const plannedOf = (f: Flow, rows: Row[]): PlannedTask[] =>
264 f.tasks.filter(t => !rows.some(r => norm(r.description) === norm(t.title)))
265
266// A finished flow is history: the pane still shows it, but it neither keeps the band alive nor owns a dismissal.
267const live = (f: Flow | null): Flow | null => (f && !f.isFinished ? f : null)
268
269// Which band a dismissal or an auto-open belongs to: the live named flow, or the session when there is none.
270export const bandKey = (f: Flow | null): string => {
271 const l = live(f)
272
273 return l ? `flow:${l.title}` : 'session'
274}
275
276// The band shows for a live flow or while any subagent runs, unless the person dismissed it.
277export const isBandShown = (rows: Row[], f: Flow | null, isDismissed = false): boolean =>
278 !isDismissed && (live(f) !== null || rows.some(r => r.id !== 'main' && r.status === 'running'))
279hooks/row-text.ts 16 lines1import type { Row } from '../types'
2import { clean, duration, modelName, percent, short, usd } from './format'
3
4// The text of one crew row, shared by the terminal and desktop renderers.
5export function rowText(r: Row, at: number): { title: string; model: string; stats: string; mark: string; note: string } {
6 const context = r.contextMax ? ` · ctx ${percent(r.contextTokens, r.contextMax)}%` : ''
7
8 return {
9 title: clean(r.description || r.type, 60),
10 model: clean(`${modelName(r.model)}${r.effort ? ` · ${r.effort}` : ''}`, 40),
11 stats: `est. ${usd(r.costUsd)} · ${duration((r.endedAt ?? at) - r.startedAt)} · ${short(r.tokens)} tok${context}`,
12 mark: r.status === 'done' ? ' ✓' : r.status === 'failed' ? ' ✕' : '',
13 note: r.stepNote ? ` · ${clean(r.stepNote, 60)}` : '',
14 }
15}
16hooks/sprite.ts 219 lines1// The Barebone crab: a friendly coral crab with a skull on its head. An original
2// character on a 17x14 pixel grid. One grid feeds both the desktop SVG and the
3// terminal Raster, so the two always match.
4export const WIDTH = 17
5export const HEIGHT = 14
6export const LEG_ROW = 12
7
8export const PALETTE = {
9 o: '#F0876A', // coral body
10 s: '#D9694A', // coral shade
11 k: '#2B2320', // ink
12 w: '#FFFFFF', // glint
13 p: '#FFB0A0', // blush
14 b: '#F2EDE4', // bone
15} as const
16
17export type Key = keyof typeof PALETTE
18export type Grid = Array<Array<Key | '.'>>
19export type SpriteState = 'running' | 'done' | 'failed'
20
21export function buildFrame(frame: 0 | 1, state: SpriteState = 'running'): Grid {
22 const g: Grid = Array.from({ length: HEIGHT }, () => Array<Key | '.'>(WIDTH).fill('.'))
23 const put = (x: number, y: number, key: Key): void => {
24 g[y]![x] = key
25 }
26
27 // body, rounded corners, shaded bottom row
28 for (let y = 5; y <= 11; y++) {
29 for (let x = 3; x <= 13; x++) {
30 if ((x === 3 || x === 13) && (y === 5 || y === 11)) {
31 continue
32 }
33
34 put(x, y, y === 11 ? 's' : 'o')
35 }
36 }
37
38 // raised pincers
39 for (const [x, y] of [[1, 6], [1, 7], [2, 7], [1, 8], [2, 8], [15, 6], [14, 7], [15, 7], [14, 8], [15, 8]] as const) {
40 put(x, y, 'o')
41 }
42
43 // eyes: tall 2x3, a glint on the top-left pixel unless the agent failed
44 for (const ex of [5, 10]) {
45 for (let dx = 0; dx < 2; dx++) {
46 for (let dy = 0; dy < 3; dy++) {
47 put(ex + dx, 6 + dy, 'k')
48 }
49 }
50
51 put(ex, 6, state === 'failed' ? 'k' : 'w')
52 }
53
54 // blush
55 for (const [x, y] of [[4, 9], [5, 9], [11, 9], [12, 9]] as const) {
56 put(x, y, 'p')
57 }
58
59 // smile, or a frown when failed
60 const mouth = state === 'failed' ? ([[7, 10], [9, 10], [8, 9]] as const) : ([[7, 9], [9, 9], [8, 10]] as const)
61
62 for (const [x, y] of mouth) {
63 put(x, y, 'k')
64 }
65
66 // legs, two walk frames
67 for (const x of frame === 0 ? [4, 6, 10, 12] : [5, 7, 9, 11]) {
68 put(x, 12, 's')
69 put(x, 13, 's')
70 }
71
72 // the skull on its head
73 ;['.bbb.', 'bkbkb', '.bbb.', '.b.b.'].forEach((row, dy) => {
74 ;[...row].forEach((char, dx) => {
75 if (char !== '.') {
76 put(9 + dx, 1 + dy, char as Key)
77 }
78 })
79 })
80
81 return g
82}
83
84// --- terminal: Raster cells -------------------------------------------------
85
86const DEFAULT = 0x01000000
87const rgb = (hex: string): number => parseInt(hex.slice(1), 16)
88
89export function rasterCells(grid: Grid): { columns: number; rows: number; cells: number[] } {
90 const rows = Math.ceil(grid.length / 2)
91 const cells: number[] = []
92
93 for (let r = 0; r < rows; r++) {
94 for (let x = 0; x < WIDTH; x++) {
95 const topKey = grid[2 * r]?.[x] ?? '.'
96 const bottomKey = grid[2 * r + 1]?.[x] ?? '.'
97 const top = topKey === '.' ? null : rgb(PALETTE[topKey])
98 const bottom = bottomKey === '.' ? null : rgb(PALETTE[bottomKey])
99
100 if (top === null && bottom === null) {
101 cells.push(0x20, DEFAULT, DEFAULT)
102 } else if (bottom === null) {
103 cells.push(0x2580, top!, DEFAULT)
104 } else if (top === null) {
105 cells.push(0x2584, bottom, DEFAULT)
106 } else {
107 cells.push(0x2580, top, bottom)
108 }
109 }
110 }
111
112 return { columns: WIDTH, rows, cells }
113}
114
115// Hand-rolled: the plugin sandbox has no Buffer or btoa.
116const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
117
118export function base64(bytes: number[]): string {
119 let out = ''
120
121 for (let i = 0; i < bytes.length; i += 3) {
122 const a = bytes[i]!
123 const b = bytes[i + 1]
124 const c = bytes[i + 2]
125 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
126
127 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
128 out += b === undefined ? '=' : B64[(n >> 6) & 63]!
129 out += c === undefined ? '=' : B64[n & 63]!
130 }
131
132 return out
133}
134
135// Little-endian u32 per word, as the Raster element's `cells` expects.
136export function encodeCells(words: number[]): string {
137 const bytes: number[] = []
138
139 for (const w of words) {
140 bytes.push(w & 255, (w >>> 8) & 255, (w >>> 16) & 255, (w >>> 24) & 255)
141 }
142
143 return base64(bytes)
144}
145
146// Pure and tiny (6 combinations): cached because the pane redraws every second.
147const rasterCache = new Map<string, { columns: number; rows: number; cells: string }>()
148
149export function spriteRaster(state: SpriteState, frame: 0 | 1): { columns: number; rows: number; cells: string } {
150 const key = `${state}:${frame}`
151 const hit = rasterCache.get(key)
152
153 if (hit) {
154 return hit
155 }
156
157 const { columns, rows, cells } = rasterCells(buildFrame(frame, state))
158 const made = { columns, rows, cells: encodeCells(cells) }
159 rasterCache.set(key, made)
160
161 return made
162}
163
164// --- desktop: SVG layer -----------------------------------------------------
165
166export const SPRITE_CSS = `.lg0{animation:l0 1s steps(1) infinite}.lg1{animation:l1 1s steps(1) infinite}
167@keyframes l0{0%{opacity:1}50%{opacity:0}}@keyframes l1{0%{opacity:0}50%{opacity:1}}
168@media (prefers-reduced-motion: reduce){.lg0,.lg1{animation:none}.lg1{opacity:0}}`
169
170const rect = (x: number, y: number, size: number, key: Key): string =>
171 `<rect${key === 'b' ? ' class="bone"' : ''} x="${x * size}" y="${y * size}" width="${size}" height="${size}" fill="${PALETTE[key]}"/>`
172
173function layer(grid: Grid, size: number, from: number, to: number): string {
174 let out = ''
175
176 for (let y = from; y < to; y++) {
177 for (let x = 0; x < WIDTH; x++) {
178 const key = grid[y]![x]!
179
180 if (key !== '.') {
181 out += rect(x, y, size, key)
182 }
183 }
184 }
185
186 return out
187}
188
189// A soft ink halo behind the bone pixels: one group opacity, so adjacent rects merge into one silhouette.
190function haloLayer(grid: Grid, size: number): string {
191 const pad = size / 4
192 let rects = ''
193
194 for (let y = 0; y < HEIGHT; y++) {
195 for (let x = 0; x < WIDTH; x++) {
196 if (grid[y]![x] === 'b') {
197 rects += `<rect x="${x * size - pad}" y="${y * size - pad}" width="${size + 2 * pad}" height="${size + 2 * pad}"/>`
198 }
199 }
200 }
201
202 return `<g opacity="0.45" fill="${PALETTE.k}">${rects}</g>`
203}
204
205export function spriteGroup(size: number, state: SpriteState, ox: number, oy: number): string {
206 const f0 = buildFrame(0, state)
207 const open = `<g transform="translate(${ox},${oy})" shape-rendering="crispEdges">${haloLayer(f0, size)}`
208 const body = layer(f0, size, 0, LEG_ROW)
209 const legs0 = layer(f0, size, LEG_ROW, HEIGHT)
210
211 if (state !== 'running') {
212 return `${open}${body}${legs0}</g>`
213 }
214
215 const legs1 = layer(buildFrame(1, state), size, LEG_ROW, HEIGHT)
216
217 return `${open}${body}<g class="lg0">${legs0}</g><g class="lg1" opacity="0">${legs1}</g></g>`
218}
219hooks/svg-rows.ts 78 lines1import type { Row } from '../types'
2import { clean } from './format'
3import { FAILED, laneColor } from './lanes'
4import { rowText } from './row-text'
5import { SPRITE_CSS, spriteGroup } from './sprite'
6
7export const ROW_H = 84
8const TEXT_X = 108
9const MIN_WIDTH = 200
10
11export const esc = (text: string): string =>
12 text.replace(/&/g, '&').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
13
14// The row is a self-contained dark card with fixed colours: as an image it cannot see the app's theme
15// (prefers-color-scheme follows the OS), so it must be legible on any panel by itself.
16const CARD = '#1f1e1d'
17const BORDER = '#333331'
18const TITLE = '#ececec'
19const SUB = '#a8a8a4'
20const TRACK = '#333331'
21const FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
22
23const ROW_CSS = `<style>
24.live{animation:pulse 1s ease-in-out infinite}@keyframes pulse{50%{opacity:.3}}
25@media (prefers-reduced-motion: reduce){.live{animation:none}}
26${SPRITE_CSS}
27</style>`
28
29const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
30
31// Cut at the last ' · ' boundary that fits, so a narrow row never shows half a value.
32const fit = (text: string, max: number): string => {
33 const safe = clean(text, 1000)
34
35 if ([...safe].length <= max) {
36 return safe
37 }
38
39 const head = [...safe].slice(0, max).join('')
40 const cut = head.lastIndexOf(' · ')
41
42 return cut > 0 ? `${head.slice(0, cut)}…` : clean(safe, max)
43}
44
45// One crew row as a single SVG: the desktop wraps sibling elements, so the whole row is one drawing.
46export function rowSvg(r: Row, width: number, at: number): string {
47 const w = finite(width) ? Math.max(MIN_WIDTH, Math.floor(width)) : MIN_WIDTH
48 const text = rowText(r, at)
49 const accent = laneColor(r.lane)
50 const dot = r.status === 'failed' ? FAILED : accent
51 const room = w - TEXT_X - 10
52 const titleMax = Math.max(8, Math.floor(room / 7.5)) // 14px semi-bold, ~7.5px per character
53 const lineMax = Math.max(8, Math.floor(room / 5.8)) // 12px, ~5.8px per character
54 const total = finite(r.stepTotal) && r.stepTotal > 0 ? Math.min(9999, Math.floor(r.stepTotal)) : 0
55 const done = total > 0 ? Math.min(total, Math.max(0, Math.floor(finite(r.stepDone) ? r.stepDone : 0))) : 0
56 const trackW = Math.max(20, w - TEXT_X - 56)
57
58 const progress =
59 total > 0
60 ? `<rect x="${TEXT_X}" y="68" width="${trackW}" height="6" rx="3" fill="${TRACK}"/>` +
61 `<rect x="${TEXT_X}" y="68" width="${Math.round(trackW * (done / total))}" height="6" rx="3" fill="${accent}"/>` +
62 `<text x="${TEXT_X + trackW + 8}" y="75" font-size="11" fill="${SUB}">${done}/${total}</text>`
63 : ''
64
65 return (
66 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${ROW_H}" viewBox="0 0 ${w} ${ROW_H}" font-family="${FONT}">` +
67 ROW_CSS +
68 `<rect x="0.5" y="0.5" width="${w - 1}" height="${ROW_H - 1}" rx="10" fill="${CARD}" stroke="${BORDER}"/>` +
69 spriteGroup(5, r.status, 8, 4) +
70 `<circle${r.status === 'running' ? ' class="live"' : ''} cx="100" cy="18" r="4" fill="${dot}"/>` +
71 `<text x="${TEXT_X}" y="22" font-size="14" font-weight="600" fill="${TITLE}">${esc(clean(text.title + text.mark, titleMax))}</text>` +
72 `<text x="${TEXT_X}" y="42" font-size="12" fill="${SUB}">${esc(fit(text.model + text.note, lineMax))}</text>` +
73 `<text x="${TEXT_X}" y="60" font-size="12" fill="${SUB}">${esc(fit(text.stats, lineMax))}</text>` +
74 progress +
75 `</svg>`
76 )
77}
78hooks/prices.ts 55 lines1export type Usage = {
2 input_tokens: number
3 output_tokens: number
4 cache_read_input_tokens: number
5 cache_creation_input_tokens: number
6}
7
8export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
9
10// USD per million tokens. ESTIMATES for display, not a bill. Source: the claude-api
11// reference's model table cached 2026-09-25 for input and output of: Fable 5/5.1 and
12// Mythos 5/5.1 (10/50), Opus 5.5 (4/20), Opus 5, 4.8, 4.7, 4.6 (5/25), Sonnet 5.5, 5 (2/10),
13// Sonnet 4.6 (3/15), Haiku 4.5 (1/5); its stated cache reads for Fable 5.1 (0.25), Fable 5 and
14// Mythos 5 (1.0), Opus 5.5 (0.20) and Sonnet 5.5 (0.20). Everything else is DERIVED: cache read
15// = 0.1x input, cache write = 1.25x input (5-minute TTL; 1-hour writes cost more and are not
16// modelled). The 5.1 row comes first so it wins over the broader Fable/Mythos row.
17// A model that matches no row falls back to FALLBACK (an Opus-tier guess), never to a
18// neighbouring family's price.
19const TABLE: Array<[RegExp, Price]> = [
20 [/fable-5-1|mythos-5-1/i, { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 }],
21 [/fable|mythos/i, { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }],
22 [/opus-5-5/i, { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }],
23 [/opus-(5|4-[678])(?!\d)/i, { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }],
24 [/sonnet-4-6/i, { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }],
25 [/sonnet-5/i, { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }],
26 [/haiku-4-5/i, { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }],
27]
28
29export const FALLBACK: Price = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }
30
31export const isKnownModel = (model: string): boolean => TABLE.some(([pattern]) => pattern.test(model))
32
33export const priceOf = (model: string): Price => TABLE.find(([pattern]) => pattern.test(model))?.[1] ?? FALLBACK
34
35export const costOf = (model: string, usage: Usage): number => {
36 const price = priceOf(model)
37
38 return (
39 ((usage.input_tokens || 0) * price.input +
40 (usage.output_tokens || 0) * price.output +
41 (usage.cache_read_input_tokens || 0) * price.cacheRead +
42 (usage.cache_creation_input_tokens || 0) * price.cacheWrite) /
43 1e6
44 )
45}
46
47// Context windows from the same reference table: 1M for the Fable/Mythos, Opus 5.x/4.6-4.8,
48// Sonnet 5.x/4.6 rows, 200K for Haiku 4.5. Any other id has no known window.
49const WINDOWS: Array<[RegExp, number]> = [
50 [/haiku-4-5/i, 200_000],
51 [/fable|mythos|opus-5|opus-4-[678](?!\d)|sonnet-5|sonnet-4-6/i, 1_000_000],
52]
53
54export const windowOf = (model: string): number | undefined => WINDOWS.find(([pattern]) => pattern.test(model))?.[1]
55types/index.d.ts 44 lines1export type RowStatus = 'running' | 'done' | 'failed'
2
3export type Row = {
4 id: string
5 agentId?: string
6 lane: string
7 type: string
8 description: string
9 model: string
10 effort?: string
11 status: RowStatus
12 startedAt: number
13 endedAt?: number
14 contextTokens: number
15 contextMax?: number
16 tokens: number
17 costUsd: number
18 steps: number
19 round: number
20 stepDone?: number
21 stepTotal?: number
22 stepNote?: string
23}
24
25export type PlannedTask = { n: number; title: string; lane: string; after: number[] }
26
27export type Flow = {
28 title: string
29 phases: string[]
30 phase: string
31 total: number
32 done: number
33 isFinished: boolean
34 tasks: PlannedTask[]
35}
36
37export type Panel = { isDoneCollapsed: boolean; autoOpenedFor: string; dismissedFor: string }
38
39declare module 'claude-code' {
40 interface PluginState {
41 'activity': { crew: Row[]; flow: Flow | null; panel: Panel; now: number }
42 }
43}
44