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…

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:
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.
/switchboard opens or closes the sidebar; /switchboard list prints the table. autoOpen (default off) opens it at start.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.
/switchboard kenar çubuğunu açar/kapatır; /switchboard list tabloyu yazdırır.hooks/register.tsx 296 lines1import { 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}
296hooks/board.ts 152 lines1// 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}
152hooks/lib/kz.ts 510 lines1// 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
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}
510types/index.d.ts 43 lines1export 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