SLOPSHOPPER

switchboard

KOZMOS Switchboard: an MCP observatory sidebar. Every MCP server in the session with its tools, calls, errors, average and p95 latency, last call and a status…

newpaneguardcommandtoasttimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/switchboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · switchboard
│ ┃ KOZMOS · Switchboard ✕ › fix the failing auth test and add an audit log call │ ┃ ⬡ SWITCHBOARD │ ┃ No MCP servers in this session yet. ⏺ Read(src/auth.ts) │ ┃ Connect one with /mcp; it shows here as soon ⎿ Read 6 lines │ ┃ as its tools are listed. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /switchboard │ ⎿ switchboard: Switchboard open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Switchboard
⬡ SWITCHBOARD No MCP servers in this session yet. Connect one with /mcp; it shows here as soon as its tools are listed.
README

KOZMOS Switchboard

An MCP observatory sidebar. Switchboard reads the session's tool list ($.tool.list(), every 30 s) and times every mcp__<server>__<tool> call made by Claude or a subagent. For each server:

  • tools available now, calls, errors, average and p95 latency;
  • the last call (how long ago, which tool) and the last error;
  • a status lamp: healthy (called in the last 5 min, last call fine), erroring (the last call failed, or two of the last five), busy (a call is running), idle (no recent calls);
  • servers in the tool list with zero calls show too; expand a server for its per-tool rows.

An erroring server has a reconnect button. It calls $.mcp.connect, which the engine only allows for servers a plugin's own manifest lists; for your own servers it says so and points to /mcp.

  • Terminal: a header, then two rows per server (lamp, name, status; the figures) with ▸/▾ to open its tools.
  • Desktop: a patch bay: a CLAUDE jack with glowing cables to every server that has been called, sockets for the idle ones, a dashed current running along the cables of servers in use; then one card per server.
  • /switchboard opens or closes the sidebar; /switchboard list prints the table. autoOpen (default off) opens it at start.

Türkçe

Bir MCP gözlem kenar çubuğu. Switchboard oturumun araç listesini okur ve Claude ya da bir alt ajanın yaptığı her mcp__<sunucu>__<araç> çağrısını zamanlar. Her sunucu için: kullanılabilir araçlar, çağrılar, hatalar, ortalama ve p95 gecikme, son çağrı ve son hata, ve bir durum lambası (healthy, erroring, busy, idle). Hiç çağrılmamış sunucular da görünür; genişletince araç bazında satırlar açılır.

Hata veren sunucuda bir reconnect düğmesi vardır; motor yalnızca bir eklentinin kendi manifestinde listelenen sunuculara yeniden bağlanmaya izin verdiği için, kendi sunucularınızda /mcp komutunu önerir.

  • Terminal: başlık ve sunucu başına iki satır, araçlar için ▸/▾.
  • Masaüstü: CLAUDE jakından kullanılan sunuculara parlayan kablolarla bir bağlantı paneli (patch bay) ve sunucu kartları.
  • /switchboard kenar çubuğunu açar/kapatır; /switchboard list tabloyu yazdırır.
Source 4 files
hooks/register.tsx 296 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SwitchServer, SwitchSnap, SwitchStatus, SwitchTool } from '../types'
5import { Board, fmtMs } from './board.ts'
6import { KZ, clip, fitText, fmtSpan, padEnd, pxOf, svg, svgText, xml } from './lib/kz.ts'
7
8const PANE = 'kz-switchboard'
9const TITLE = 'KOZMOS · Switchboard'
10const EMPTY: SwitchSnap = { servers: [], calls: 0, errors: 0, now: 0 }
11const snapAtom = atom({ plugin: 'switchboard', key: 'snap' } as const, EMPTY)
12const expandedAtom = atom({ plugin: 'switchboard', key: 'expanded' } as const, [] as string[])
13
14const LAMP: Record<SwitchStatus, string> = { healthy: KZ.green, erroring: KZ.red, idle: KZ.mist, busy: KZ.cyan }
15
16// ---------------------------------------------------------------------------
17// The ledger lives here; the pane draws only the published snapshot.
18
19let board = new Board()
20let publishedKey = ''
21
22async function publish($: EngineInterface): Promise<void> {
23  const now = await $.clock.now()
24  const snap = board.snapshot(now)
25  // Ages move every tick; round the clock so an idle pane redraws twice a minute.
26  const key = JSON.stringify({ ...snap, now: Math.floor(now / 30_000) })
27  if (key === publishedKey) return
28  await update($, snapAtom, () => snap)
29  // Noted once written, so a refused write is tried again on the next tick.
30  publishedKey = key
31}
32
33async function readTools($: EngineInterface): Promise<void> {
34  try {
35    const tools = await $.tool.list()
36    board.list(tools.filter(t => t.mcp || t.name.startsWith('mcp__')).map(t => t.name))
37  } catch {
38    // Keep what the board knew.
39  }
40  await publish($)
41}
42
43async function toggle($: EngineInterface): Promise<boolean> {
44  if ((await $.ui.panes()).some(p => p.id === PANE)) {
45    await $.ui.close({ id: PANE })
46    return false
47  }
48  await readTools($)
49  await $.ui.open({ id: PANE, title: TITLE })
50  return true
51}
52
53async function flip($: EngineInterface, server: string): Promise<void> {
54  await update($, expandedAtom, prev => (prev.includes(server) ? prev.filter(s => s !== server) : [...prev, server]))
55}
56
57/** $.mcp.connect dials only the servers this plugin's own manifest lists; say so when it refuses. */
58async function reconnect($: EngineInterface, server: string): Promise<void> {
59  try {
60    const r = await $.mcp.connect(server)
61    if (r.isConnected) $.ui.toast(`⬡ ${server}: connected`)
62    else if (r.reason === 'unlisted') $.ui.toast(`⬡ ${server}: a plugin cannot redial this server — reconnect it from /mcp`, { timeoutMs: 7000 })
63    else $.ui.toast(`⬡ ${server}: ${r.message}`, { timeoutMs: 7000 })
64  } catch (err) {
65    // A refusal from beneath always arrives as an Error (a hook that throws is skipped).
66    $.ui.toast(`⬡ ${server}: ${(err as Error).message} — try /mcp`, { timeoutMs: 7000 })
67  }
68  await readTools($)
69}
70
71function report(s: SwitchSnap): string {
72  if (!s.servers.length) return 'Switchboard: no MCP servers in this session.'
73  const lines = [`⬡ Switchboard — ${s.servers.length} servers · ${s.calls} calls · ${s.errors} errors`]
74  for (const v of s.servers) {
75    lines.push(`  ${lampGlyph(v.status)} ${padEnd(v.name, 18)} ${v.status.padEnd(8)} ${v.toolCount} tools · ${v.calls} calls · ${v.errors} err · avg ${fmtMs(v.avgMs)} · p95 ${fmtMs(v.p95Ms)}`)
76  }
77  return lines.join('\n')
78}
79
80const lampGlyph = (s: SwitchStatus): string => (s === 'erroring' ? '✖' : s === 'busy' ? '◉' : s === 'healthy' ? '●' : '○')
81
82export const register: Register = (on, options) => {
83  on('session.start', async ($, e, next) => {
84    const started = await next(e)
85    board = new Board()
86    publishedKey = ''
87    await $.command.register({ name: 'switchboard', description: 'KOZMOS: toggle the Switchboard MCP observatory sidebar (/switchboard list prints it)', argumentHint: '[list]', immediate: true })
88    await readTools($)
89    // MCP servers come and go: read the list again every 30 s, ages every 5 s.
90    let tick = 0
91    $.clock.every(5000, () => void (++tick % 6 === 0 ? readTools($) : publish($)).catch(() => undefined))
92    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
93    return started
94  })
95
96  on('command.run', { command: 'switchboard' }, async ($, e) => {
97    if (e.args.trim().toLowerCase() === 'list') {
98      await readTools($)
99      return { text: report(await read($, snapAtom)) }
100    }
101    return { text: (await toggle($)) ? 'Switchboard open.' : 'Switchboard closed.' }
102  })
103
104  // Observer on MCP calls only: time them, note the outcome, never refuse.
105  on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
106    const name = String(e.tool)
107    const at = await $.clock.now()
108    board.start(name)
109    void publish($).catch(() => undefined)
110    try {
111      const ran = await next(e)
112      const isError = ran.isError === true || ran.deny !== undefined
113      const end = await $.clock.now()
114      board.finish(name, end, end - at, isError, ran.deny ?? (ran.isError ? ran.text : undefined))
115      void publish($).catch(() => undefined)
116      return ran
117    } catch (err) {
118      const end = await $.clock.now()
119      board.finish(name, end, end - at, true, (err as Error).message)
120      void publish($).catch(() => undefined)
121      throw err
122    }
123  }).catch(($, e, next) => next(e))
124
125  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
126    const s = await read($, snapAtom)
127    const expanded = await read($, expandedAtom)
128    const ui = $.ui.resolve(e)
129    const { Box, Button, Text } = ui
130
131    if ('Svg' in ui && e.surface !== 'terminal') {
132      const { Svg } = ui
133      const W = pxOf(e.props.bodyColumns, 48)
134      const bay = patchBay(W, s)
135      const cardW = Math.max(180, W - 150)
136      return (
137        <Box flexDirection="column" gap={1}>
138          <Svg source={bay.source} alt={`Switchboard: ${s.servers.length} MCP servers, ${s.calls} calls, ${s.errors} errors`} width={W} height={bay.height} />
139          {s.servers.map(v => {
140            const isOpen = expanded.includes(v.name)
141            return (
142              <Box key={`srv-${v.name}`} flexDirection="column">
143                <Box flexDirection="row" alignItems="center" gap={1}>
144                  <Svg source={serverCard(cardW, v, s.now)} alt={`${v.name}: ${v.status}, ${v.calls} calls, ${v.errors} errors`} width={cardW} height={52} />
145                  <Button key={`exp-${v.name}`} label={isOpen ? '▾ tools' : '▸ tools'} plain onPress={() => void flip($, v.name)} />
146                  {v.status === 'erroring' ? <Button key={`re-${v.name}`} label="reconnect" onPress={() => void reconnect($, v.name)} /> : null}
147                </Box>
148                {isOpen && v.tools.length ? <Svg key={`tools-${v.name}`} source={toolTable(cardW, v.tools, s.now)} alt={`${v.name} tools`} width={cardW} height={20 + v.tools.length * 20} /> : null}
149              </Box>
150            )
151          })}
152        </Box>
153      )
154    }
155
156    const cols = Math.max(30, e.props.bodyColumns || 44)
157    if (!s.servers.length) {
158      return (
159        <Box flexDirection="column">
160          <Text bold color={KZ.magenta}>⬡ SWITCHBOARD</Text>
161          <Text dimColor>No MCP servers in this session yet.</Text>
162          <Text dimColor>Connect one with /mcp; it shows here as soon as its tools are listed.</Text>
163        </Box>
164      )
165    }
166    return (
167      <Box flexDirection="column">
168        <Box flexDirection="row" justifyContent="space-between">
169          <Text bold color={KZ.magenta}>⬡ SWITCHBOARD</Text>
170          <Text dimColor>{s.servers.length} srv · {s.calls} calls{s.errors ? <Text color={KZ.red}> · {s.errors} err</Text> : ''}</Text>
171        </Box>
172        <Text dimColor>{'─'.repeat(Math.min(cols, 80))}</Text>
173        {s.servers.map(v => {
174          const isOpen = expanded.includes(v.name)
175          const ago = v.lastAt !== null ? `${fmtSpan(s.now - v.lastAt)} ago` : 'never called'
176          return (
177            <Box key={`srv-${v.name}`} flexDirection="column">
178              <Box flexDirection="row" justifyContent="space-between">
179                <Box flexDirection="row">
180                  <Button key={`exp-${v.name}`} label={isOpen ? '▾' : '▸'} plain onPress={() => void flip($, v.name)} />
181                  <Text wrap="truncate-end">
182                    <Text color={LAMP[v.status]}> {lampGlyph(v.status)} </Text>
183                    <Text bold>{clip(v.name, Math.max(8, cols - 26))}</Text>
184                    <Text color={LAMP[v.status]}> {v.status}</Text>
185                  </Text>
186                </Box>
187                {v.status === 'erroring'
188                  ? <Button key={`re-${v.name}`} label="reconnect" plain onPress={() => void reconnect($, v.name)} />
189                  : <Text dimColor>{v.calls} calls</Text>}
190              </Box>
191              <Text dimColor wrap="truncate-end">
192                {'    '}{v.toolCount} tools · {v.calls} calls · <Text color={v.errors ? KZ.red : undefined} dimColor={!v.errors}>{v.errors} err</Text> · avg {fmtMs(v.avgMs)} · p95 {fmtMs(v.p95Ms)} · {ago}{v.lastTool ? ` · ${v.lastTool}` : ''}
193              </Text>
194              {v.status === 'erroring' && v.lastError ? <Text color={KZ.red} wrap="truncate-end">    ✖ {clip(v.lastError, cols - 6)}</Text> : null}
195              {isOpen
196                ? v.tools.map(t => (
197                    <Text key={`t-${v.name}-${t.name}`} wrap="truncate-end">
198                      <Text dimColor>      {t.isListed ? '◦' : '·'} </Text>
199                      <Text color={t.errors ? KZ.amber : undefined}>{padEnd(clip(t.name, 22), Math.min(22, Math.max(8, cols - 30)))}</Text>
200                      <Text dimColor> {String(t.calls).padStart(3)}× {fmtMs(t.avgMs).padStart(6)} p95 {fmtMs(t.p95Ms).padStart(6)}</Text>
201                      {t.errors ? <Text color={KZ.red}> {t.errors}✖</Text> : ''}
202                    </Text>
203                  ))
204                : null}
205            </Box>
206          )
207        })}
208      </Box>
209    )
210  })
211}
212
213// ---------------------------------------------------------------------------
214// Desktop drawings.
215
216/** The patch bay: a Claude jack on the left, a socket per server, cables to the live ones. */
217function patchBay(W: number, s: SwitchSnap): { source: string; height: number } {
218  const shown = s.servers.slice(0, 12)
219  const rowH = 26
220  const H = Math.max(96, 44 + shown.length * rowH)
221  const jackX = 46
222  const jackY = H / 2 + 6
223  const sockX = Math.max(jackX + 120, W - Math.min(190, W * 0.45))
224  const parts: string[] = []
225  parts.push(`<defs><linearGradient id="sbpanel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1b1830"/><stop offset="1" stop-color="#0f1622"/></linearGradient>
226<filter id="sbglow" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="2.4"/></filter></defs>`)
227  parts.push(`<rect x="0" y="0" width="${W}" height="${H}" rx="14" fill="url(#sbpanel)"/>`)
228  // Rack screws and the title strip.
229  for (const [x, y] of [[10, 10], [W - 10, 10], [10, H - 10], [W - 10, H - 10]] as const) parts.push(`<circle cx="${x}" cy="${y}" r="2.6" fill="#3b3760"/><path d="M${x - 1.6} ${y}h3.2" stroke="#1b1830" stroke-width="1"/>`)
230  parts.push(`<text x="18" y="24" font-family="'Segoe UI',Inter,sans-serif" font-size="12" font-weight="800" letter-spacing="3" fill="${KZ.magenta}">SWITCHBOARD</text>`)
231  parts.push(`<text x="${W - 18}" y="24" text-anchor="end" font-family="'Segoe UI',Inter,sans-serif" font-size="10.5" fill="#a8a3c7">${xml(`${s.servers.length} servers · ${s.calls} calls · ${s.errors} err`)}</text>`)
232  // The Claude jack.
233  parts.push(`<circle cx="${jackX}" cy="${jackY}" r="15" fill="#26223f" stroke="${KZ.violet}" stroke-width="2"/><circle cx="${jackX}" cy="${jackY}" r="6" fill="${KZ.violet}" class="pulse"/>`)
234  parts.push(`<text x="${jackX}" y="${jackY + 30}" text-anchor="middle" font-family="'Segoe UI',Inter,sans-serif" font-size="9" font-weight="700" letter-spacing="1.5" fill="#a8a3c7">CLAUDE</text>`)
235  if (!shown.length) {
236    parts.push(`<text x="${sockX}" y="${jackY + 4}" font-family="'Segoe UI',Inter,sans-serif" font-size="11.5" fill="#a8a3c7">no MCP servers yet</text>`)
237    parts.push(`<path d="M${jackX + 15} ${jackY}C${jackX + 70} ${jackY + 26} ${sockX - 70} ${jackY + 26} ${sockX - 14} ${jackY}" stroke="#4a4570" stroke-width="2" fill="none" stroke-dasharray="3 5"/>`)
238  }
239  shown.forEach((v, i) => {
240    const y = 44 + i * rowH + rowH / 2 - 6
241    const c = LAMP[v.status]
242    const isLive = v.calls > 0
243    if (isLive) {
244      const d = `M${jackX + 15} ${jackY}C${jackX + 90} ${jackY} ${sockX - 90} ${y} ${sockX - 12} ${y}`
245      const isFlowing = v.status === 'busy' || (v.lastAt !== null && s.now - v.lastAt < 60_000)
246      parts.push(`<path d="${d}" stroke="${c}" stroke-width="5" fill="none" opacity=".35" filter="url(#sbglow)"${isFlowing ? ' class="pulse"' : ''}/>`)
247      parts.push(`<path d="${d}" stroke="${c}" stroke-width="2.2" fill="none" stroke-linecap="round"/>`)
248      if (isFlowing) parts.push(`<path d="${d}" stroke="#ffffff" stroke-width="1.6" fill="none" stroke-dasharray="2 14" class="flow" opacity=".85"/>`)
249      parts.push(`<circle cx="${sockX - 12}" cy="${y}" r="3.6" fill="${c}"/>`)
250    }
251    parts.push(`<circle cx="${sockX}" cy="${y}" r="8.5" fill="#0b0f18" stroke="${isLive ? c : '#4a4570'}" stroke-width="2"/>`)
252    parts.push(`<circle cx="${sockX}" cy="${y}" r="3" fill="${isLive ? c : '#2c2948'}"${v.status === 'erroring' ? ' class="pulse"' : ''}/>`)
253    parts.push(`<text x="${sockX + 16}" y="${y + 4}" font-family="'Segoe UI',Inter,sans-serif" font-size="11.5" font-weight="650" fill="#ecebf7">${xml(fitText(v.name, 11.5, W - sockX - 70))}</text>`)
254    parts.push(`<text x="${W - 16}" y="${y + 4}" text-anchor="end" font-family="ui-monospace,Consolas,monospace" font-size="10" fill="${v.status === 'idle' ? '#8d88ad' : c}">${v.calls ? `${v.calls}×` : 'idle'}</text>`)
255  })
256  if (s.servers.length > shown.length) parts.push(`<text x="${sockX + 16}" y="${H - 8}" font-family="'Segoe UI',Inter,sans-serif" font-size="10" fill="#8d88ad">+${s.servers.length - shown.length} more</text>`)
257  const css = `.flow{animation:sbflow 1.1s linear infinite}@keyframes sbflow{to{stroke-dashoffset:-32}}`
258  return { source: svg(W, H, parts.join(''), css), height: H }
259}
260
261function serverCard(W: number, v: SwitchServer, now: number): string {
262  const c = LAMP[v.status]
263  const ago = v.lastAt !== null ? `${fmtSpan(now - v.lastAt)} ago${v.lastTool ? ` · ${v.lastTool}` : ''}` : 'never called'
264  const chipW = v.status.length * 6.4 + 16
265  const body = `<rect class="p" x="0" y="0" width="${W}" height="52" rx="11"/>
266<rect x="0" y="0" width="4" height="52" rx="2" fill="${c}"/>
267<circle cx="20" cy="18" r="9" fill="${c}" opacity=".18"${v.status === 'erroring' || v.status === 'busy' ? ' class="pulse"' : ''}/>
268<circle cx="20" cy="18" r="4.6" fill="${c}"/>
269${svgText(36, 22, fitText(v.name, 13.5, W - chipW - 110), { size: 13.5, weight: 700 })}
270<rect x="${W - chipW - 70}" y="9" width="${chipW}" height="17" rx="8.5" fill="${c}" opacity=".18"/>
271${svgText(W - chipW / 2 - 70, 21, v.status, { size: 10, weight: 700, anchor: 'middle', fill: c })}
272${svgText(W - 10, 21, `${v.calls} calls`, { size: 11, weight: 650, anchor: 'end' })}
273${svgText(14, 42, fitText(`${v.toolCount} tools · ${v.errors} err · avg ${fmtMs(v.avgMs)} · p95 ${fmtMs(v.p95Ms)} · ${ago}`, 10.5, W - 24), { cls: v.errors ? 's' : 'm', size: 10.5 })}`
274  return svg(W, 52, body)
275}
276
277function toolTable(W: number, tools: readonly SwitchTool[], now: number): string {
278  const H = 20 + tools.length * 20
279  const parts = [`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="9"/>`]
280  parts.push(svgText(14, 14, 'TOOL', { cls: 'm', size: 9, weight: 700 }))
281  parts.push(svgText(W - 150, 14, 'CALLS', { cls: 'm', size: 9, weight: 700, anchor: 'end' }))
282  parts.push(svgText(W - 92, 14, 'AVG', { cls: 'm', size: 9, weight: 700, anchor: 'end' }))
283  parts.push(svgText(W - 40, 14, 'P95', { cls: 'm', size: 9, weight: 700, anchor: 'end' }))
284  tools.forEach((t, i) => {
285    const y = 32 + i * 20
286    const c = t.errors ? KZ.red : t.inFlight ? KZ.cyan : t.calls ? KZ.green : KZ.mist
287    parts.push(`<circle cx="18" cy="${y - 4}" r="3" fill="${c}"/>`)
288    parts.push(svgText(28, y, fitText(t.name, 11, W - 210), { size: 11, mono: true, ...(t.isListed ? {} : { cls: 'm' }) }))
289    parts.push(svgText(W - 150, y, `${t.calls}${t.errors ? ` (${t.errors}✖)` : ''}`, { size: 10.5, anchor: 'end', ...(t.errors ? { fill: KZ.red } : { cls: 's' }) }))
290    parts.push(svgText(W - 92, y, fmtMs(t.avgMs), { cls: 's', size: 10.5, anchor: 'end' }))
291    parts.push(svgText(W - 40, y, fmtMs(t.p95Ms), { cls: 's', size: 10.5, anchor: 'end' }))
292    parts.push(svgText(W - 8, y, t.lastAt !== null ? fmtSpan(now - t.lastAt) : '', { cls: 'm', size: 9.5, anchor: 'end' }))
293  })
294  return svg(W, H, parts.join(''))
295}
296
hooks/board.ts 152 lines
1// Switchboard's ledger: pure bookkeeping of MCP servers, tools and calls.
2// No `$` here; register.tsx feeds it names, times and outcomes.
3
4import type { SwitchServer, SwitchSnap, SwitchStatus, SwitchTool } from '../types'
5
6/** A call within this long keeps a server lit as healthy; older is idle. */
7export const RECENT_MS = 5 * 60_000
8const SAMPLES = 200
9
10/** `mcp__github__search_code` → `{ server: 'github', tool: 'search_code' }`. */
11export function splitMcp(name: string): { server: string; tool: string } | null {
12  if (!name.startsWith('mcp__')) return null
13  const rest = name.slice(5)
14  const at = rest.indexOf('__')
15  if (at <= 0) return { server: rest || '?', tool: '' }
16  return { server: rest.slice(0, at), tool: rest.slice(at + 2) }
17}
18
19type ToolLedger = {
20  name: string
21  isListed: boolean
22  calls: number
23  errors: number
24  ms: number[]
25  lastAt: number | null
26  lastOk: boolean[]
27  inFlight: number
28}
29
30type ServerLedger = {
31  name: string
32  tools: Map<string, ToolLedger>
33  lastTool: string | null
34  lastAt: number | null
35  lastError: string | null
36  recent: boolean[]
37}
38
39export class Board {
40  private servers = new Map<string, ServerLedger>()
41
42  private server(name: string): ServerLedger {
43    let s = this.servers.get(name)
44    if (!s) {
45      s = { name, tools: new Map(), lastTool: null, lastAt: null, lastError: null, recent: [] }
46      this.servers.set(name, s)
47    }
48    return s
49  }
50
51  private tool(s: ServerLedger, name: string): ToolLedger {
52    let t = s.tools.get(name)
53    if (!t) {
54      t = { name, isListed: false, calls: 0, errors: 0, ms: [], lastAt: null, lastOk: [], inFlight: 0 }
55      s.tools.set(name, t)
56    }
57    return t
58  }
59
60  /** The tool list as it stands: names the model can call now. */
61  list(names: readonly string[]): void {
62    for (const s of this.servers.values()) for (const t of s.tools.values()) t.isListed = false
63    for (const n of names) {
64      const p = splitMcp(n)
65      if (!p) continue
66      this.tool(this.server(p.server), p.tool).isListed = true
67    }
68  }
69
70  start(name: string): void {
71    const p = splitMcp(name)
72    if (!p) return
73    this.tool(this.server(p.server), p.tool).inFlight++
74  }
75
76  finish(name: string, at: number, ms: number, isError: boolean, error?: string): void {
77    const p = splitMcp(name)
78    if (!p) return
79    const s = this.server(p.server)
80    const t = this.tool(s, p.tool)
81    t.inFlight = Math.max(0, t.inFlight - 1)
82    t.calls++
83    if (isError) t.errors++
84    t.ms = [...t.ms, Math.max(0, ms)].slice(-SAMPLES)
85    t.lastAt = at
86    s.lastAt = at
87    s.lastTool = p.tool
88    s.recent = [...s.recent, !isError].slice(-5)
89    if (isError) s.lastError = error ? error.slice(0, 160) : 'error'
90  }
91
92  snapshot(now: number): SwitchSnap {
93    const servers = [...this.servers.values()].map(s => serverRow(s, now))
94    servers.sort((a, b) => rank(b.status) - rank(a.status) || (b.lastAt ?? 0) - (a.lastAt ?? 0) || a.name.localeCompare(b.name))
95    return {
96      servers,
97      calls: servers.reduce((n, s) => n + s.calls, 0),
98      errors: servers.reduce((n, s) => n + s.errors, 0),
99      now,
100    }
101  }
102}
103
104const rank = (s: SwitchStatus): number => (s === 'erroring' ? 3 : s === 'busy' ? 2 : s === 'healthy' ? 1 : 0)
105
106export function avgOf(ms: readonly number[]): number | null {
107  return ms.length ? Math.round(ms.reduce((a, b) => a + b, 0) / ms.length) : null
108}
109
110/** The 95th percentile, nearest rank. */
111export function p95Of(ms: readonly number[]): number | null {
112  if (!ms.length) return null
113  const sorted = [...ms].sort((a, b) => a - b)
114  return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)]!
115}
116
117/** Erroring when the last call failed or two of the last five did. */
118export function statusOf(recent: readonly boolean[], lastAt: number | null, inFlight: number, now: number): SwitchStatus {
119  const fails = recent.filter(ok => !ok).length
120  if (recent.length && (recent[recent.length - 1] === false || fails >= 2)) return 'erroring'
121  if (inFlight > 0) return 'busy'
122  if (lastAt !== null && now - lastAt < RECENT_MS) return 'healthy'
123  return 'idle'
124}
125
126function serverRow(s: ServerLedger, now: number): SwitchServer {
127  const tools: SwitchTool[] = [...s.tools.values()]
128    .map(t => ({ name: t.name, isListed: t.isListed, calls: t.calls, errors: t.errors, avgMs: avgOf(t.ms), p95Ms: p95Of(t.ms), lastAt: t.lastAt, inFlight: t.inFlight }))
129    .sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name))
130  const all = [...s.tools.values()].flatMap(t => t.ms)
131  const inFlight = tools.reduce((n, t) => n + t.inFlight, 0)
132  return {
133    name: s.name,
134    toolCount: tools.filter(t => t.isListed).length,
135    calls: tools.reduce((n, t) => n + t.calls, 0),
136    errors: tools.reduce((n, t) => n + t.errors, 0),
137    avgMs: avgOf(all),
138    p95Ms: p95Of(all),
139    lastAt: s.lastAt,
140    lastTool: s.lastTool,
141    lastError: s.lastError,
142    inFlight,
143    status: statusOf(s.recent, s.lastAt, inFlight, now),
144    tools,
145  }
146}
147
148export function fmtMs(ms: number | null): string {
149  if (ms === null) return '—'
150  return ms >= 10_000 ? `${(ms / 1000).toFixed(0)}s` : ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`
151}
152
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
types/index.d.ts 43 lines
1export type SwitchStatus = 'healthy' | 'erroring' | 'idle' | 'busy'
2
3export type SwitchTool = {
4  name: string
5  /** Listed by $.tool.list() now. */
6  isListed: boolean
7  calls: number
8  errors: number
9  avgMs: number | null
10  p95Ms: number | null
11  lastAt: number | null
12  inFlight: number
13}
14
15export type SwitchServer = {
16  name: string
17  /** Tools of this server the model can call now. */
18  toolCount: number
19  calls: number
20  errors: number
21  avgMs: number | null
22  p95Ms: number | null
23  lastAt: number | null
24  lastTool: string | null
25  lastError: string | null
26  inFlight: number
27  status: SwitchStatus
28  tools: SwitchTool[]
29}
30
31export type SwitchSnap = {
32  servers: SwitchServer[]
33  calls: number
34  errors: number
35  now: number
36}
37
38declare module 'claude-code' {
39  interface PluginState {
40    switchboard: { snap: SwitchSnap; expanded: string[] }
41  }
42}
43