SLOPSHOPPER

activity

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

newpanebandguardcommandtool
v0.1.0MITupdated 2026-10-06gveshk/barebone-mods/activity
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · activity
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ Crew · 0 running · 7.9k tok · est. $0.08 · │ ┃ session $0.42 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ● main session ✓ ⏺ Update(src/auth.ts) │ ┃ Opus 5.5 ⎿ Added 2 lines, removed 1 line │ ┃ est. $0.08 · 00:00 · 7.9k tok · ctx 0% ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ No subagents yet │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /activity │ ⎿ activity: Activity panel opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
Crew · 0 running · 7.9k tok · est. $0.08 · session $0.42 ● main session ✓ Opus 5.5 est. $0.08 · 00:00 · 7.9k tok · ctx 0% No subagents yet
README

barebone-mods

Claude Code mods from Barebone Agents. Each mod is a plain Claude Code plugin you can read in a few minutes.

ModWhat it does
activityA 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-weatherA one-line forecast of your context window above the prompt.

Install

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

activity

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:

  • main: your own session, pinned first.
  • Running: every subagent that is working now, each with its model, effort, estimated cost, elapsed time, tokens and context fill.
  • Finished: a collapsible list (press its heading). It shows the newest 20 and a +N older line for the rest. Failures are counted in the heading (N failed) even while the list is collapsed.
  • Planned: tasks a skill declared with the flow tool that have not started yet.

How a row is drawn:

  • Terminal: the Barebone crab is a 17x7 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.
  • Desktop app: every row is an SVG card with its own dark background, so it is legible on any theme. The legs and the status dot animate with CSS, and stay still when the system asks for reduced motion.

Numbers:

  • "tokens" means fresh tokens processed: input plus output plus cache writes. Cache reads are left out of the count but are priced in the cost.
  • Cost is an estimate from the price table in 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:

  • One line with the flow title or agents, phase and progress, running count, tokens and estimated cost. ×N toggles the pane.
  • It shows while a flow is live (declared and not yet finished) or a subagent runs, and it stacks with other mods that draw there (token-weather, next-steps) instead of replacing them.
  • The band disappears when a flow finishes (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:

  • Hooks only observe. They never change or block a tool call, a spawn or a turn, and the two registered tools answer only their own calls.
  • No network, no filesystem access and no process spawning anywhere in either mod.

Credits and licenses

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.

Call manifest

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 passed
Source 10 files
hooks/register.tsx 301 lines
1import { 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}
301
hooks/format.ts 86 lines
1export 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}
86
hooks/lanes.ts 28 lines
1import { 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}
28
hooks/render.tsx 158 lines
1import 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}
158
hooks/reducers.ts 279 lines
1import 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'))
279
hooks/row-text.ts 16 lines
1import 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}
16
hooks/sprite.ts 219 lines
1// 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}
219
hooks/svg-rows.ts 78 lines
1import 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, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
78
hooks/prices.ts 55 lines
1export 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]
55
types/index.d.ts 44 lines
1export 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