SLOPSHOPPER

sia-dashboard

Live SIA dashboard pane for Claude Code

newpanebandguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sia-dashboard
│ ┃ SIA ✕ › fix the failing auth test and add an audit log call │ ┃ SIA not set up here. Say "set up SIA on this │ ┃ project". ⏺ Read(src/auth.ts) │ ┃ 1: Overview 2: Agents 3: Skills 4: Cal ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Agents 0 running 0 this session ⎿ Added 2 lines, removed 1 line │ ┃ Subagents 0 tokens kept out of the main co ⏺ Bash(bun test) │ ┃ Saved - no subagent has run on a cheap ⎿ 3 pass, 1 fail │ ┃ Session $0.420 spent so far │ ┃ Skills 0 written by SIA 0 uses this ses ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Rules 0 active loaded into every sessi │ ✻ Worked for 42s · done 4:20 PM │ │ › /sia-dashboard │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · SIA
SIA not set up here. Say "set up SIA on this project". 1: Overview 2: Agents 3: Skills 4: Calls Agents 0 running 0 this session Subagents 0 tokens kept out of the main conversation Saved - no subagent has run on a cheaper model yet Session $0.420 spent so far Skills 0 written by SIA 0 uses this session Rules 0 active loaded into every session and subagent
README

SIA dashboard

A live view of what SIA is doing in your project, inside Claude Code. It's a Claude Code mod and needs Claude Code 2.1.287 or later. Install it next to the sia plugin:

/plugin install sia-dashboard@sia

What it shows

A line above the prompt, in SIA projects only:

SIA plan · 1 agent running · ~$0.176 saved · 3 skills · 1 rule   /sia-dashboard

/sia-dashboard opens a pane with four tabs:

TabShows
OverviewAgents running and seen this session, tokens handled by subagents, the estimated saving, session cost, skills, rules, and plan progress
AgentsThe main conversation and every subagent, with its status, the model it ran on, and its tokens
SkillsThe skills SIA wrote from your requirements, with uses this session and in total, plus other skills used
CallsEvery SIA tool with what it does. Press Enter on one to start a prompt that uses it

Turn the line above the prompt off or on with /sia-dashboard band off and /sia-dashboard band on.

Where the numbers come from

  • Tokens are measured. Every model request reports its token usage to the mod, tagged with the agent that made it.
  • Saved is an estimate: each subagent request priced at your main model's rates, minus its price on the cheaper model it actually ran on. It assumes the main model would have used the same number of tokens. Prices come from the tier prices in SIA's orchestration config when set, otherwise from Anthropic's list prices. A model with no known price is named and left out.
  • Session cost is Claude Code's own total, the same figure as /cost.
  • Skills and rules are read from .sia/ and refreshed every 1.5 seconds. Skill use counts every Skill tool call, including calls inside subagents, and keeps a per-project total.

Develop

claude plugin validate plugins/sia-dashboard
cd plugins/sia-dashboard && claude plugin test
claude --plugin-dir plugins/sia-dashboard   # hot-reloads on save
Source 2 files
hooks/register.ts 420 lines
1// SIA dashboard: a live pane (/sia-dashboard) and a one-line band above the
2// prompt showing what SIA is doing in this project: agents at work, tokens
3// handled by subagents and the estimated saving from cheaper models, the
4// skills SIA wrote and how often they are used, and the calls SIA offers.
5//
6// Token figures are measured, not guessed: every model request reports its
7// usage at `turn.step`, tagged with the agent loop that made it.
8
9import {
10  clip,
11  fmtTokens,
12  fmtUsd,
13  isSiaTool,
14  loopModels,
15  loopTokens,
16  record,
17  savings,
18  shortModel,
19  shortTool,
20  siaSnapshot,
21  skillMatches,
22  type Ledger,
23  type SiaSnapshot,
24} from './metrics.ts'
25
26const PANE = 'sia-dashboard'
27const TABS = [
28  { id: 'overview', label: 'Overview', hotkey: '1' },
29  { id: 'agents', label: 'Agents', hotkey: '2' },
30  { id: 'skills', label: 'Skills', hotkey: '3' },
31  { id: 'calls', label: 'Calls', hotkey: '4' },
32] as const
33
34type AgentRow = { id: string; type: string; description: string; status: string }
35
36// Session state. A reload of this module (a save during development) starts
37// these over; skill use counts are also kept per project in $.store.
38let tab: (typeof TABS)[number]['id'] = 'overview'
39let ledger: Ledger = {}
40let agentModels: Record<string, string> = {}
41// Every subagent seen this session. $.agent.list() drops an agent once it
42// finishes, so the dashboard keeps its own record.
43let agents: Record<string, AgentRow> = {}
44let sia: SiaSnapshot | undefined
45let mainModel = ''
46let sessionUsd: number | undefined
47let skillUses: Record<string, number> = {}
48let projectUses: Record<string, number> = {}
49let siaTools: { name: string; description: string }[] = []
50let showBand = true
51
52// --- helpers that take $ (top-level, so `claude plugin validate` can follow them)
53
54function parentOf(dir: string): string | undefined {
55  const trimmed = dir.replace(/[\\/]+$/, '')
56  const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
57  if (at <= 0) return undefined
58  const parent = trimmed.slice(0, at)
59  return /^[A-Za-z]:$/.test(parent) ? parent + '\\' : parent
60}
61
62function join(dir: string, ...parts: string[]): string {
63  const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/'
64  return [dir.replace(/[\\/]+$/, ''), ...parts].join(sep)
65}
66
67async function readOr($, path: string): Promise<string | undefined> {
68  try {
69    return await $.fs.read(path)
70  } catch {
71    return undefined
72  }
73}
74
75/** The nearest directory at or above the working directory that holds .sia/state.json. */
76async function findSiaRoot($): Promise<string | undefined> {
77  let dir: string | undefined = await $.session.cwd()
78  for (let depth = 0; dir && depth < 12; depth++) {
79    if (await $.fs.exists(join(dir, '.sia', 'state.json'))) return dir
80    dir = parentOf(dir)
81  }
82  return undefined
83}
84
85async function refreshSia($): Promise<void> {
86  const root = await findSiaRoot($)
87  if (!root) {
88    sia = undefined
89    return
90  }
91  sia = siaSnapshot(root, {
92    config: await readOr($, join(root, '.sia', 'config.json')),
93    state: await readOr($, join(root, '.sia', 'state.json')),
94    rules: await readOr($, join(root, '.sia', 'rules.json')),
95    skills: await readOr($, join(root, '.sia', 'skills.json')),
96  })
97  const stored = await $.store.get('uses:' + root)
98  projectUses = stored && typeof stored === 'object' ? (stored as Record<string, number>) : {}
99}
100
101async function refreshAll($): Promise<void> {
102  try {
103    const list = await $.agent.list()
104    const live = new Set(list.map((a) => a.id))
105    for (const a of list) agents[a.id] = { id: a.id, type: a.type, description: a.description, status: a.status }
106    // One that left the list while running has finished.
107    for (const a of Object.values(agents)) if (!live.has(a.id) && a.status === 'running') a.status = 'completed'
108  } catch {
109    // Keep what was seen.
110  }
111  try {
112    mainModel = await $.session.model()
113    const usage = await $.session.usage()
114    sessionUsd = usage.cost?.usd
115  } catch {
116    // Usage is unavailable on some hosts; the dashboard omits the line.
117  }
118  await refreshSia($)
119}
120
121async function countSkillUse($, called: string): Promise<void> {
122  skillUses[called] = (skillUses[called] ?? 0) + 1
123  const name = sia?.skills.find((s) => skillMatches(called, s.name))?.name
124  if (!name || !sia) return
125  // Read again right before writing: other sessions share the store.
126  const key = 'uses:' + sia.root
127  const stored = await $.store.get(key)
128  const uses = stored && typeof stored === 'object' ? { ...(stored as Record<string, number>) } : {}
129  uses[name] = (uses[name] ?? 0) + 1
130  await $.store.set(key, uses)
131  projectUses = uses
132}
133
134// --- figures shown on every tab and in the band ------------------------------
135
136function usesOf(name: string): number {
137  return Object.entries(skillUses)
138    .filter(([called]) => skillMatches(called, name))
139    .reduce((sum, [, count]) => sum + count, 0)
140}
141
142function agentRows(): AgentRow[] {
143  // A subagent can report usage before it shows up in the list.
144  for (const loop of Object.keys(ledger)) {
145    if (loop !== 'main' && !agents[loop]) agents[loop] = { id: loop, type: 'subagent', description: loop, status: 'completed' }
146  }
147  return Object.values(agents)
148}
149
150function figures() {
151  const rows = agentRows()
152  const running = rows.filter((a) => a.status === 'running').length
153  const saved = savings(ledger, mainModel, sia?.overrides ?? {})
154  const siaSkills = (sia?.skills ?? []).filter((s) => s.status === 'active')
155  const usesThisSession = siaSkills.reduce((sum, s) => sum + usesOf(s.name), 0)
156  return { rows, running, saved, siaSkills, usesThisSession }
157}
158
159export function register(on) {
160  on('session.start', async ($, e, next) => {
161    const band = await $.store.get('band')
162    showBand = band !== 'off'
163    await refreshAll($)
164    try {
165      const tools = await $.tool.list()
166      siaTools = tools.filter((t) => isSiaTool(t.name)).map((t) => ({ name: t.name, description: t.description }))
167    } catch {
168      siaTools = []
169    }
170    // Agents and SIA's files change outside any event this mod sees, so poll.
171    $.clock.every(1500, async () => {
172      await refreshAll($)
173      $.ui.invalidate('ui.render')
174    })
175    await $.command.register({
176      name: 'sia-dashboard',
177      description: 'Open the SIA dashboard: agents, token savings, skills, and SIA calls',
178      argumentHint: '[band on|off]',
179      immediate: true,
180    })
181    return next(e)
182  })
183
184  // Every model request, main loop or subagent, reports its usage here.
185  on('turn.step', async function* ($, e, next) {
186    const result = yield* next(e)
187    try {
188      if (result?.usage) {
189        record(ledger, e.agentId ?? 'main', result.usage.model || e.model, result.usage)
190        $.ui.invalidate('ui.render')
191      }
192    } catch {
193      // Never let bookkeeping break a turn.
194    }
195    return result
196  })
197
198  // The model each subagent actually started on.
199  on('agent.spawn', async ($, e, next) => {
200    const result = await next(e)
201    if (result && result.agentId && result.model) {
202      agentModels[result.agentId] = result.model
203      agents[result.agentId] ??= { id: result.agentId, type: e.subagentType, description: e.description, status: 'running' }
204      $.ui.invalidate('ui.render')
205    }
206    return result
207  })
208
209  // Skill use, in the main loop and inside subagents.
210  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
211    const called = typeof e.skill === 'string' ? e.skill : undefined
212    if (called) {
213      try {
214        await countSkillUse($, called)
215        $.ui.invalidate('ui.render')
216      } catch {
217        // Counting is best effort.
218      }
219    }
220    return next(e)
221  })
222
223  on('command.run', { command: 'sia-dashboard' }, async ($, e) => {
224    const arg = e.args.trim().toLowerCase()
225    if (arg === 'band off' || arg === 'band on') {
226      showBand = arg === 'band on'
227      await $.store.set('band', showBand ? 'on' : 'off')
228      $.ui.invalidate('ui.render')
229      return { text: 'SIA band above the prompt is ' + (showBand ? 'on' : 'off') + '.' }
230    }
231    await refreshAll($)
232    await $.ui.open({ id: PANE, title: 'SIA', focus: true, closeOnEscape: true })
233    return {}
234  })
235
236  // The one-line band above the prompt, in SIA projects only.
237  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
238    if (!showBand || !sia) return next(e)
239    const { Box, Text } = $.ui.resolve(e)
240    const f = figures()
241    const parts = [
242      sia.stage ? sia.stage : 'done',
243      f.running + ' agent' + (f.running === 1 ? '' : 's') + ' running',
244      f.saved.routedTokens > 0 ? '~' + fmtUsd(f.saved.saved) + ' saved' : fmtTokens(f.saved.subagentTokens) + ' subagent tokens',
245      f.siaSkills.length + ' skill' + (f.siaSkills.length === 1 ? '' : 's'),
246      sia.rules + ' rule' + (sia.rules === 1 ? '' : 's'),
247    ]
248    const line = Box({
249      flexDirection: 'row',
250      columnGap: 1,
251      children: [
252        Text({ bold: true, color: 'cyan', children: ['SIA'] }),
253        Text({ dimColor: true, wrap: 'truncate-end', children: [parts.join(' · ') + '   /sia-dashboard'] }),
254      ],
255    })
256    // Keep whatever other mods draw in the band.
257    const theirs = await next(e)
258    return theirs ? Box({ flexDirection: 'column', children: [theirs, line] }) : line
259  })
260
261  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
262    if (e.requestId !== PANE) return next(e)
263    const { Box, Text, Button } = $.ui.resolve(e)
264    const width = Math.max(30, (e.props.bodyColumns ?? 60) - 2)
265    const nameWidth = Math.max(12, Math.min(34, width - 24))
266    const redraw = () => $.ui.invalidate('ui.render')
267    const f = figures()
268
269    const row = (label: string, value: string, note?: string) =>
270      Box({
271        key: 'row-' + label,
272        flexDirection: 'row',
273        children: [
274          Text({ dimColor: true, children: [label.padEnd(11)] }),
275          Text({ children: [value] }),
276          ...(note ? [Text({ dimColor: true, wrap: 'truncate-end', children: ['  ' + note] })] : []),
277        ],
278      })
279
280    const tabs = Box({
281      flexDirection: 'row',
282      columnGap: 3,
283      children: TABS.map((t) =>
284        Button({
285          key: 'tab-' + t.id,
286          label: t.label,
287          hotkey: t.hotkey,
288          plain: true,
289          dimColor: tab !== t.id,
290          onPress: () => {
291            tab = t.id
292            redraw()
293          },
294        }),
295      ),
296    })
297
298    const header = Box({
299      flexDirection: 'row',
300      columnGap: 1,
301      children: sia
302        ? [
303            Text({ bold: true, color: 'cyan', children: ['SIA'] }),
304            Text({ children: [(sia.stage ?? 'workflow complete') + (sia.mode ? '  (' + sia.mode + ' mode)' : '')] }),
305          ]
306        : [
307            Text({ bold: true, color: 'cyan', children: ['SIA'] }),
308            Text({ dimColor: true, children: ['not set up here. Say "set up SIA on this project".'] }),
309          ],
310    })
311
312    let body: any[] = []
313
314    if (tab === 'overview') {
315      const s = f.saved
316      body = [
317        row('Agents', f.running + ' running', f.rows.length + ' this session'),
318        row('Subagents', fmtTokens(s.subagentTokens) + ' tokens', 'kept out of the main conversation'),
319        s.routedTokens > 0
320          ? row('Saved', '~' + fmtUsd(s.saved), 'estimate vs ' + shortModel(mainModel) + ': ' + fmtTokens(s.routedTokens) + ' tokens on cheaper models')
321          : row('Saved', '-', 'no subagent has run on a cheaper model yet'),
322        ...(sessionUsd !== undefined ? [row('Session', fmtUsd(sessionUsd), 'spent so far')] : []),
323        row('Skills', f.siaSkills.length + ' written by SIA', f.usesThisSession + (f.usesThisSession === 1 ? ' use' : ' uses') + ' this session'),
324        row('Rules', (sia?.rules ?? 0) + ' active', 'loaded into every session and subagent'),
325        ...(sia?.operations
326          ? [row('Plan', sia.operations.complete + '/' + sia.operations.total + ' operations done', sia.operations.status ?? '')]
327          : []),
328        ...(s.unpriced.length
329          ? [Text({ key: 'unpriced', dimColor: true, children: ['No price known for ' + s.unpriced.map(shortModel).join(', ') + '; left out of the saving.'] })]
330          : []),
331      ]
332    } else if (tab === 'agents') {
333      const agentRow = (key: string, mark: string, color: string | undefined, name: string, model: string, tokens: number) =>
334        Box({
335          key,
336          flexDirection: 'row',
337          children: [
338            Text({ ...(color ? { color } : { dimColor: true }), children: [mark + ' '] }),
339            Text({ children: [clip(name, nameWidth).padEnd(nameWidth)] }),
340            Text({ dimColor: true, children: ['  ' + (model ? shortModel(model) : '?').padEnd(11) + fmtTokens(tokens)] }),
341          ],
342        })
343      body = [
344        agentRow('agent-main', '●', 'cyan', 'main conversation', mainModel, loopTokens(ledger, 'main')),
345        ...(f.rows.length === 0 ? [Text({ key: 'no-agents', dimColor: true, children: ['No subagents yet this session.'] })] : []),
346        ...f.rows.map((a) =>
347          agentRow(
348            'agent-' + a.id,
349            a.status === 'running' ? '●' : a.status === 'completed' ? '✓' : '✗',
350            a.status === 'running' ? 'green' : a.status === 'completed' ? undefined : 'red',
351            a.type + ': ' + a.description,
352            agentModels[a.id] ?? loopModels(ledger, a.id)[0] ?? '',
353            loopTokens(ledger, a.id),
354          ),
355        ),
356      ]
357    } else if (tab === 'skills') {
358      const others = Object.entries(skillUses).filter(([called]) => !f.siaSkills.some((s) => skillMatches(called, s.name)))
359      body = [
360        Text({ key: 'h-sia', bold: true, children: ["Written by SIA from this project's requirements"] }),
361        ...(f.siaSkills.length === 0
362          ? [Text({ key: 'no-skills', dimColor: true, children: ['None yet. SIA writes them at the skills stage.'] })]
363          : f.siaSkills.map((s) =>
364              Box({
365                key: 'skill-' + s.name,
366                flexDirection: 'column',
367                children: [
368                  Box({
369                    flexDirection: 'row',
370                    columnGap: 2,
371                    children: [
372                      Text({ color: 'cyan', children: [s.name] }),
373                      Text({ dimColor: true, children: [usesOf(s.name) + ' this session · ' + (projectUses[s.name] ?? 0) + ' total'] }),
374                    ],
375                  }),
376                  Text({ dimColor: true, children: ['  ' + clip(s.description, width - 2)] }),
377                ],
378              }),
379            )),
380        Text({ key: 'gap', children: [' '] }),
381        Text({ key: 'h-other', bold: true, children: ['Other skills used this session'] }),
382        ...(others.length === 0
383          ? [Text({ key: 'no-other', dimColor: true, children: ['None yet.'] })]
384          : others.map(([called, count]) => Text({ key: 'other-' + called, children: [called + '  ×' + count] }))),
385      ]
386    } else {
387      body = [
388        Text({ key: 'calls-hint', dimColor: true, children: ['Press Enter on a call to start a prompt that uses it.'] }),
389        ...(siaTools.length === 0
390          ? [Text({ key: 'no-tools', dimColor: true, children: ["SIA's tools are not connected in this session. Install the sia plugin."] })]
391          : siaTools.map((t) =>
392              Box({
393                key: 'call-row-' + t.name,
394                flexDirection: 'column',
395                children: [
396                  Button({
397                    key: 'call-' + t.name,
398                    label: shortTool(t.name),
399                    plain: true,
400                    onPress: async () => {
401                      await $.prompt.fill({ text: "Use SIA's " + shortTool(t.name) + ' tool to ' })
402                      await $.ui.close({ id: PANE })
403                    },
404                  }),
405                  Text({ dimColor: true, children: ['  ' + clip(t.description.split(/(?<=\.)\s/)[0] ?? '', width - 2)] }),
406                ],
407              }),
408            )),
409        Text({ key: 'gap', children: [' '] }),
410        Text({ key: 'cmds', dimColor: true, children: ['Dashboard: /sia-dashboard · /sia-dashboard band on|off'] }),
411      ]
412    }
413
414    return Box({
415      flexDirection: 'column',
416      children: [header, tabs, Text({ children: [' '] }), ...body],
417    })
418  })
419}
420
hooks/metrics.ts 261 lines
1// Pure logic for the SIA dashboard: prices, token ledger, savings, and reading
2// SIA's state files. Nothing here touches the mods API, so tests import it
3// directly.
4
5export type Usage = {
6  input_tokens: number
7  output_tokens: number
8  cache_read_input_tokens?: number
9  cache_creation_input_tokens?: number
10}
11
12/** USD per million tokens. */
13export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
14
15const price = (input: number, output: number, cacheRead: number): Price => ({
16  input,
17  output,
18  cacheRead,
19  // A 5-minute cache write costs 1.25x the input price on every current model.
20  cacheWrite: input * 1.25,
21})
22
23// Anthropic first-party list prices, per million tokens (checked 2026-10-03).
24// Most specific prefix first: model ids can carry a date suffix, such as
25// claude-haiku-4-5-20251001, and short aliases (haiku, sonnet, opus) appear too.
26const LIST_PRICES: [string, Price][] = [
27  ['claude-fable-5-1', price(10, 50, 0.25)],
28  ['claude-mythos-5-1', price(10, 50, 0.25)],
29  ['claude-fable-5', price(10, 50, 1)],
30  ['claude-opus-5-5', price(4, 20, 0.2)],
31  ['claude-opus-5', price(5, 25, 0.5)],
32  ['claude-opus-4', price(5, 25, 0.5)],
33  ['claude-sonnet-5-5', price(2, 10, 0.2)],
34  ['claude-sonnet-5', price(2, 10, 0.2)],
35  ['claude-sonnet-4', price(3, 15, 0.3)],
36  ['claude-haiku-4-5', price(1, 5, 0.1)],
37  ['fable', price(10, 50, 0.25)],
38  ['opus', price(4, 20, 0.2)],
39  ['sonnet', price(2, 10, 0.2)],
40  ['haiku', price(1, 5, 0.1)],
41]
42
43/** Prices a project configured in SIA's orchestration tiers, keyed by model id. */
44export type PriceOverrides = Record<string, { input: number; output: number }>
45
46export function priceFor(model: string, overrides: PriceOverrides = {}): Price | undefined {
47  const id = model.toLowerCase().replace(/\[.*\]$/, '')
48  // SIA configs often name a tier by alias (haiku), while usage reports the
49  // full id (claude-haiku-4-5-20251001), so an alias also matches its family.
50  const alias = Object.keys(overrides).find((key) => /^[a-z]+$/.test(key) && id.includes('-' + key + '-'))
51  const own = overrides[id] ?? overrides[model] ?? (alias ? overrides[alias] : undefined)
52  if (own && (own.input > 0 || own.output > 0)) {
53    // SIA's config holds input and output prices only; cache rates follow the
54    // usual ratios.
55    return { input: own.input, output: own.output, cacheRead: own.input * 0.1, cacheWrite: own.input * 1.25 }
56  }
57  const found = LIST_PRICES.find(([prefix]) => id === prefix || id.startsWith(prefix + '-') || id.startsWith(prefix + '['))
58  return found?.[1]
59}
60
61export function costOf(usage: Usage, p: Price): number {
62  return (
63    (usage.input_tokens * p.input +
64      usage.output_tokens * p.output +
65      (usage.cache_read_input_tokens ?? 0) * p.cacheRead +
66      (usage.cache_creation_input_tokens ?? 0) * p.cacheWrite) /
67    1_000_000
68  )
69}
70
71export function totalTokens(usage: Usage): number {
72  return (
73    usage.input_tokens +
74    usage.output_tokens +
75    (usage.cache_read_input_tokens ?? 0) +
76    (usage.cache_creation_input_tokens ?? 0)
77  )
78}
79
80/** Token use per agent loop and model. 'main' is the main conversation. */
81export type Ledger = Record<string, Record<string, Usage & { requests: number }>>
82
83export function record(ledger: Ledger, loop: string, model: string, usage: Usage): void {
84  const byModel = (ledger[loop] ??= {})
85  const row = (byModel[model] ??= {
86    input_tokens: 0,
87    output_tokens: 0,
88    cache_read_input_tokens: 0,
89    cache_creation_input_tokens: 0,
90    requests: 0,
91  })
92  row.input_tokens += usage.input_tokens
93  row.output_tokens += usage.output_tokens
94  row.cache_read_input_tokens = (row.cache_read_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0)
95  row.cache_creation_input_tokens = (row.cache_creation_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
96  row.requests += 1
97}
98
99export type Savings = {
100  /** Tokens processed inside subagents, which never entered the main conversation. */
101  subagentTokens: number
102  /** Of those, tokens processed on a model other than the main one. */
103  routedTokens: number
104  /** What the routed tokens cost on the models they ran on. */
105  routedCost: number
106  /** What the same tokens would have cost on the main model. */
107  baselineCost: number
108  /** baselineCost - routedCost. Negative when subagents ran on a pricier model. */
109  saved: number
110  /** Models seen without a known price; their tokens are left out of the cost figures. */
111  unpriced: string[]
112}
113
114/**
115 * Estimated saving from running subagents on cheaper models: each subagent
116 * request priced at the main model's rates minus its price on the model it
117 * actually ran on. It assumes the main model would have used the same tokens,
118 * which is an estimate, so the dashboard labels it as one.
119 */
120export function savings(ledger: Ledger, mainModel: string, overrides: PriceOverrides = {}): Savings {
121  const base = priceFor(mainModel, overrides)
122  const result: Savings = { subagentTokens: 0, routedTokens: 0, routedCost: 0, baselineCost: 0, saved: 0, unpriced: [] }
123  for (const [loop, byModel] of Object.entries(ledger)) {
124    if (loop === 'main') continue
125    for (const [model, usage] of Object.entries(byModel)) {
126      const tokens = totalTokens(usage)
127      result.subagentTokens += tokens
128      if (sameModel(model, mainModel)) continue
129      const actual = priceFor(model, overrides)
130      if (!actual || !base) {
131        if (!result.unpriced.includes(model)) result.unpriced.push(model)
132        continue
133      }
134      result.routedTokens += tokens
135      result.routedCost += costOf(usage, actual)
136      result.baselineCost += costOf(usage, base)
137    }
138  }
139  result.saved = result.baselineCost - result.routedCost
140  return result
141}
142
143export function sameModel(a: string, b: string): boolean {
144  const norm = (m: string) => m.toLowerCase().replace(/\[.*\]$/, '').replace(/-\d{8}$/, '')
145  return norm(a) === norm(b)
146}
147
148export function loopTokens(ledger: Ledger, loop: string): number {
149  return Object.values(ledger[loop] ?? {}).reduce((sum, usage) => sum + totalTokens(usage), 0)
150}
151
152export function loopModels(ledger: Ledger, loop: string): string[] {
153  return Object.keys(ledger[loop] ?? {})
154}
155
156// --- SIA state ---------------------------------------------------------------
157
158export type SiaSkill = { name: string; description: string; status: string; version?: number }
159
160export type SiaSnapshot = {
161  root: string
162  mode?: string
163  stage?: string | null
164  completed: string[]
165  rules: number
166  skills: SiaSkill[]
167  operations?: { total: number; complete: number; status?: string }
168  overrides: PriceOverrides
169}
170
171function parse(text: string | undefined): any {
172  if (!text) return undefined
173  try {
174    return JSON.parse(text)
175  } catch {
176    return undefined
177  }
178}
179
180/** Builds a snapshot from the raw text of SIA's files; a missing or broken file counts as empty. */
181export function siaSnapshot(
182  root: string,
183  files: { config?: string; state?: string; rules?: string; skills?: string },
184): SiaSnapshot {
185  const config = parse(files.config) ?? {}
186  const state = parse(files.state) ?? {}
187  const rules = parse(files.rules)
188  const skills = parse(files.skills)
189  const overrides: PriceOverrides = {}
190  for (const tier of Object.values(config.orchestration?.models ?? {}) as any[]) {
191    if (tier && typeof tier.id === 'string') {
192      overrides[tier.id.toLowerCase()] = {
193        input: Number(tier.input_usd_per_million) || 0,
194        output: Number(tier.output_usd_per_million) || 0,
195      }
196    }
197  }
198  const orchestration = state.orchestration
199  const operations = orchestration?.operations
200  return {
201    root,
202    mode: config.mode,
203    stage: state.current_stage,
204    completed: Array.isArray(state.completed_stages) ? state.completed_stages : [],
205    rules: Array.isArray(rules) ? rules.filter((r: any) => r?.status === 'active').length : 0,
206    skills: Array.isArray(skills)
207      ? skills
208          .filter((s: any) => s && typeof s.name === 'string')
209          .map((s: any) => ({ name: s.name, description: String(s.description ?? ''), status: String(s.status ?? ''), version: s.version }))
210      : [],
211    operations:
212      operations && typeof operations === 'object'
213        ? {
214            total: Object.keys(operations).length,
215            complete: Object.values(operations).filter((o: any) => o?.status === 'complete').length,
216            status: orchestration.status,
217          }
218        : undefined,
219    overrides,
220  }
221}
222
223/** True when a Skill tool call's `skill` names this project skill, plain or plugin-qualified. */
224export function skillMatches(called: string, name: string): boolean {
225  return called === name || called.endsWith(':' + name)
226}
227
228// --- formatting --------------------------------------------------------------
229
230export function fmtTokens(n: number): string {
231  if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + 'M'
232  if (n >= 1_000) return (n / 1_000).toFixed(n >= 10_000 ? 0 : 1) + 'k'
233  return String(n)
234}
235
236export function fmtUsd(n: number): string {
237  const sign = n < 0 ? '-' : ''
238  const abs = Math.abs(n)
239  return sign + '$' + (abs >= 100 ? abs.toFixed(0) : abs >= 1 ? abs.toFixed(2) : abs.toFixed(3))
240}
241
242/** claude-haiku-4-5-20251001 -> haiku-4-5 */
243export function shortModel(model: string): string {
244  return model.replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/\[.*\]$/, '')
245}
246
247export function clip(text: string, width: number): string {
248  const flat = text.replace(/\s+/g, ' ').trim()
249  return flat.length <= width ? flat : flat.slice(0, Math.max(0, width - 1)) + '…'
250}
251
252/** sia_orchestrate_plan from mcp__plugin_sia_sia__sia_orchestrate_plan */
253export function shortTool(name: string): string {
254  const at = name.lastIndexOf('__')
255  return at >= 0 ? name.slice(at + 2) : name
256}
257
258export function isSiaTool(name: string): boolean {
259  return name.startsWith('mcp__') && /__sia_[a-z_]+$/.test(name)
260}
261