Live SIA dashboard pane for Claude Code

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
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:
| Tab | Shows |
|---|---|
| Overview | Agents running and seen this session, tokens handled by subagents, the estimated saving, session cost, skills, rules, and plan progress |
| Agents | The main conversation and every subagent, with its status, the model it ran on, and its tokens |
| Skills | The skills SIA wrote from your requirements, with uses this session and in total, plus other skills used |
| Calls | Every 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.
/cost..sia/ and refreshed every 1.5 seconds. Skill use counts every Skill tool call, including calls inside subagents, and keeps a per-project total.claude plugin validate plugins/sia-dashboard
cd plugins/sia-dashboard && claude plugin test
claude --plugin-dir plugins/sia-dashboard # hot-reloads on savehooks/register.ts 420 lines1// 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}
420hooks/metrics.ts 261 lines1// 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