free-coding-models 0.3.54 → 0.3.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +47 -49
  2. package/README.md +236 -160
  3. package/bin/free-coding-models.js +46 -0
  4. package/package.json +2 -2
  5. package/sources.js +133 -309
  6. package/src/analysis.js +23 -10
  7. package/src/app.js +113 -7
  8. package/src/cache.js +1 -1
  9. package/src/cli-help.js +9 -0
  10. package/src/command-palette.js +16 -12
  11. package/src/config.js +199 -32
  12. package/src/endpoint-installer.js +45 -1
  13. package/src/favorites.js +22 -0
  14. package/src/graphify-out/cache/089db1c1def873cf6d112f1590da4490e61e691aff0db41e006aa2fb15ba0656.json +1 -0
  15. package/src/graphify-out/cache/0b510b53cf1a1393fb52b1fc3bbbf88b63938e961ec5b82119a2e9715fee8bd7.json +1 -0
  16. package/src/graphify-out/cache/0ec9a95a326bde58e0316889018b278062d06d494d0f31ba177c9de71e5fed2d.json +1 -0
  17. package/src/graphify-out/cache/1548663a24a68dce740ebab1bd1d3091048c9604e9d067a1650a42a6d82541d4.json +1 -0
  18. package/src/graphify-out/cache/1783af63cb6d0dfb4d469009f71ac83a74ba0b33d48186ff2c6e63f9429e900a.json +1 -0
  19. package/src/graphify-out/cache/1e109f5eb5dc4fd285871c3613e32b6b14a8c225f4080ee34b51c7e1a1764571.json +1 -0
  20. package/src/graphify-out/cache/1eb24dbeb69b46c8bc1caf925df2f2a964af0f33aea143adf8ddf88e017db6ca.json +1 -0
  21. package/src/graphify-out/cache/21e1bcfed11685e8347243f9d8516072dda183266a4bfe22c52fb31753a446c8.json +1 -0
  22. package/src/graphify-out/cache/2327473478b9c4b1940bf7ef66c9ee960b3cba8d5302e56b625df8274246e0b4.json +1 -0
  23. package/src/graphify-out/cache/25955b81fd25454c8fa90fb71a47db8d1215cf621beb8ff3cbd580aaf011b4f3.json +1 -0
  24. package/src/graphify-out/cache/2739677f19c702f88f3de0a0bac475066adbda98709907ad3de967aef689f86d.json +1 -0
  25. package/src/graphify-out/cache/2bba03422f6b3ee7f5b5d29cc90314a064d259e5822a176657bda3e04505cf00.json +1 -0
  26. package/src/graphify-out/cache/2ddf1d2c6d10147b0402446bc71a7988187b79b6210dd7e7250be8c555b9ff35.json +1 -0
  27. package/src/graphify-out/cache/2ee07457a5767c95a57f8e9eb95b28f800044f35666e0715e9d88ad1103a092e.json +1 -0
  28. package/src/graphify-out/cache/2fe9f75dc2951c417f2c8dd22749092cf550dc67599f1c8d1866900dc6e9154e.json +1 -0
  29. package/src/graphify-out/cache/41c4b7c27e7fc3e2948d3a4bf95a72de2ed9a6f0463994babdce8ed2cc84598c.json +1 -0
  30. package/src/graphify-out/cache/5028defd54b7fbd3c7e444973e493de036e097e9b1d2a7cae7f19b88d68aacde.json +1 -0
  31. package/src/graphify-out/cache/5b133aba3fb16410c5b1fdbd1730039fc7fa1ac93abd99d7be08f60da70fc8d4.json +1 -0
  32. package/src/graphify-out/cache/74252e5b0978d85ab3421a3de1a9384aa282ffd2be2cfe7db2530139089f4275.json +1 -0
  33. package/src/graphify-out/cache/7695ebeea056095edd14332963cc43354ef3a097caf46f1e28d0f01369642901.json +1 -0
  34. package/src/graphify-out/cache/777aa7085c395a935c6556bbde182cd871edb61f3a685ed8068ec0c8f6fb0075.json +1 -0
  35. package/src/graphify-out/cache/82a723881980e82273c113def8315533d7da28827e300413d9ad30f27b7407df.json +1 -0
  36. package/src/graphify-out/cache/86b87c9603e6cd188f42c7eed3b86c291d48a781c223a707e74f3e7ed0c02a21.json +1 -0
  37. package/src/graphify-out/cache/890fead9a78cadaed560a2d2453916121fa605c3e43a334910ac4bc951a9ef6d.json +1 -0
  38. package/src/graphify-out/cache/89d3ea66f52783caa775ef9a30923d7d6225e1d8ae9e962f4741b8c7785dab1e.json +1 -0
  39. package/src/graphify-out/cache/8cc82cd9edce41f0e1c092f14a94fd52bf847addf3237b616dc5a9e505bd05bd.json +1 -0
  40. package/src/graphify-out/cache/93ba2e25e3ff7ad525f397902345fbd375df7315de7b402e20cc803c14eccde8.json +1 -0
  41. package/src/graphify-out/cache/99beed29580b9c7bfecfee794cb3d8e535fcf0eb3b92113108f88bdd0a8e79b3.json +1 -0
  42. package/src/graphify-out/cache/aeeb931fa477c65ce2e51d8149957350fa54225c613222bbbe8448998d1afd3d.json +1 -0
  43. package/src/graphify-out/cache/baf91bef5b5ecb2a476433b6cc0c48c563c54ee2d07fc3c192e543685e3e7222.json +1 -0
  44. package/src/graphify-out/cache/bd98b94ac4e9b92b6336d47b26e0366b51a4eaf0711d722f05f98dfae23ab42b.json +1 -0
  45. package/src/graphify-out/cache/bfcb51e9328e9cbfbee4f6fee0f56635d7b03488addc9f6c4e4b190b70a73362.json +1 -0
  46. package/src/graphify-out/cache/c0d3dabeb093aa758c49eadf41b87ecc96a16c1449c2670aaf48cbfc891d8da6.json +1 -0
  47. package/src/graphify-out/cache/c20d6630236f473c1406068c3ae205853e649b216495c93dfec055dd222c55cf.json +1 -0
  48. package/src/graphify-out/cache/c22b9122816bebce0a2f79af41a986559d01e00163dbcd579c5755621b4cb483.json +1 -0
  49. package/src/graphify-out/cache/ca556ec14453ddb8f9e0c5a832dac90d77111b9bad5f8c2d80d272e2e7a06371.json +1 -0
  50. package/src/graphify-out/cache/d6dbc9135dfa35a756b3b09b06700e4bc229fdccba11bb963f2ba44028e0bbae.json +1 -0
  51. package/src/graphify-out/cache/e1cf71276f1779d0fa075f79bd7c8a9fd0b8eef6932ac043137451b7c7fa7cbe.json +1 -0
  52. package/src/graphify-out/cache/e4b3be14494467df2d2ed389bc4f18f099021cb5bc355b901fa88387b2d8b8a2.json +1 -0
  53. package/src/graphify-out/cache/eaea0dded097f6f9553b654220046c6ec0c9be592a5973d906564ee60af34e0d.json +1 -0
  54. package/src/graphify-out/cache/ef07d0cd2675d1f79d2a2fdbf3bc3319687638751e9ce89b0d0d97ed1cd9f7e1.json +1 -0
  55. package/src/graphify-out/cache/f81272d6eb8aaff9e96d5a1d9f06777db70ac3652a646b951ded51f79871d733.json +1 -0
  56. package/src/graphify-out/cache/f9619dd92186f75a6dbda937e0c606647153918524cdb5763f956e6ec2a9e386.json +1 -0
  57. package/src/graphify-out/cache/fd88b1b2ff4bfcae08559d9c2aaeeb9a3f1e2f5cd8928762c311196956c170a5.json +1 -0
  58. package/src/key-handler.js +316 -13
  59. package/src/kilo.js +20 -1
  60. package/src/opencode.js +24 -3
  61. package/src/overlays.js +206 -5
  62. package/src/provider-metadata.js +26 -17
  63. package/src/quota-capabilities.js +6 -10
  64. package/src/render-table.js +37 -4
  65. package/src/router-daemon.js +1986 -0
  66. package/src/router-dashboard.js +893 -0
  67. package/src/sync-set.js +479 -0
  68. package/src/theme.js +4 -0
  69. package/src/tool-launchers.js +1 -0
  70. package/src/tool-metadata.js +6 -2
  71. package/src/utils.js +30 -6
  72. package/web/dist/assets/{index-D2ban2S-.js → index-DNRCaWPi.js} +2 -2
  73. package/web/dist/index.html +1 -1
@@ -0,0 +1,893 @@
1
+ /**
2
+ * @file router-dashboard.js
3
+ * @description TUI client, SSE reader, and renderer for the Smart Model Router dashboard.
4
+ *
5
+ * @details
6
+ * 📖 This module is intentionally defensive: the dashboard talks to a local
7
+ * daemon that may be stopped, stale, mid-restart, or returning malformed JSON.
8
+ * Every public helper treats unexpected payloads as partial data instead of
9
+ * throwing into the render loop.
10
+ *
11
+ * The dashboard uses polling for baseline reliability and an SSE stream for
12
+ * low-latency request/probe/circuit updates. Polling keeps the screen useful
13
+ * when `/stream/events` is unavailable, while SSE keeps the request log live.
14
+ *
15
+ * @functions
16
+ * → `openRouterDashboardOverlay` — Start polling/SSE and mark the overlay open
17
+ * → `closeRouterDashboardOverlay` — Close the overlay and stop dashboard I/O
18
+ * → `refreshRouterDashboardSnapshot` — Fetch `/health` + `/stats` safely
19
+ * → `startRouterDashboardEventStream` — Connect to `/stream/events`
20
+ * → `cycleRouterDashboardActiveSet` — Activate the next daemon set
21
+ * → `cycleRouterDashboardProbeMode` — Rotate eco/balanced/aggressive mode
22
+ * → `renderRouterDashboard` — Render the full-screen dashboard overlay
23
+ * → `normalizeRouterDashboardSnapshot` — Sanitize daemon payloads for rendering
24
+ * → `parseRouterDashboardSseFrame` — Parse one SSE frame
25
+ *
26
+ * @exports openRouterDashboardOverlay, closeRouterDashboardOverlay
27
+ * @exports refreshRouterDashboardSnapshot, startRouterDashboardEventStream
28
+ * @exports cycleRouterDashboardActiveSet, cycleRouterDashboardProbeMode
29
+ * @exports clearRouterDashboardRequestLog, restartRouterDashboardDaemon
30
+ * @exports toggleRouterDashboardProbePause, stopRouterDashboardClient
31
+ * @exports renderRouterDashboard, normalizeRouterDashboardSnapshot
32
+ * @exports parseRouterDashboardSseFrame, formatRouterDuration
33
+ * @exports fetchRouterSets, createRouterSet, renameRouterSet, duplicateRouterSet
34
+ * @exports deleteRouterSet, activateRouterSet, updateRouterSetModels
35
+ * @exports addModelToRouterSet, removeModelFromRouterSet, reorderRouterSetModel
36
+ *
37
+ * @see ./router-daemon.js — daemon endpoints consumed by this screen
38
+ * @see ./overlays.js — overlay factory that mounts this renderer
39
+ * @see ./key-handler.js — dashboard key bindings
40
+ */
41
+
42
+ import { existsSync, readFileSync } from 'node:fs'
43
+ import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from './render-helpers.js'
44
+ import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, ROUTER_PID_PATH, ROUTER_PORT_PATH, getRouterPortRange } from './router-daemon.js'
45
+ import { themeColors } from './theme.js'
46
+ import { formatTokenTotalCompact } from './token-usage-reader.js'
47
+ import { sendUsageTelemetry } from './telemetry.js'
48
+
49
+ export const ROUTER_DASHBOARD_POLL_INTERVAL_MS = 2000
50
+ export const ROUTER_DASHBOARD_FETCH_TIMEOUT_MS = 1200
51
+ export const ROUTER_PROBE_MODE_CYCLE = ['eco', 'balanced', 'aggressive']
52
+
53
+ const ROUTER_DASHBOARD_EVENT_LIMIT = 80
54
+ const ROUTER_DASHBOARD_REQUEST_LIMIT = 30
55
+
56
+ function isRecord(value) {
57
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
58
+ }
59
+
60
+ function toFiniteNumber(value, fallback = null) {
61
+ const numeric = Number(value)
62
+ return Number.isFinite(numeric) ? numeric : fallback
63
+ }
64
+
65
+ function safeString(value, fallback = '—') {
66
+ if (typeof value === 'string' && value.trim()) return value.trim()
67
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
68
+ return fallback
69
+ }
70
+
71
+ function compactText(value, width) {
72
+ const text = safeString(value, '')
73
+ if (displayWidth(text) <= width) return padEndDisplay(text, width)
74
+ const plain = text.replace(/\s+/g, ' ')
75
+ let out = ''
76
+ for (const char of plain) {
77
+ if (displayWidth(`${out}${char}…`) > width) break
78
+ out += char
79
+ }
80
+ return padEndDisplay(`${out}…`, width)
81
+ }
82
+
83
+ function readNumberFile(path) {
84
+ try {
85
+ const value = Number.parseInt(readFileSync(path, 'utf8').trim(), 10)
86
+ return Number.isFinite(value) ? value : null
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ function isProcessAlive(pid) {
93
+ if (!Number.isInteger(pid) || pid <= 0) return false
94
+ try {
95
+ process.kill(pid, 0)
96
+ return true
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
102
+ function makeTimeoutController(ms) {
103
+ const controller = new AbortController()
104
+ const timer = setTimeout(() => controller.abort(), ms)
105
+ return {
106
+ signal: controller.signal,
107
+ clear: () => clearTimeout(timer),
108
+ }
109
+ }
110
+
111
+ async function fetchJson(url, options = {}) {
112
+ const {
113
+ method = 'GET',
114
+ body = null,
115
+ fetchFn = globalThis.fetch,
116
+ timeoutMs = ROUTER_DASHBOARD_FETCH_TIMEOUT_MS,
117
+ } = options
118
+ if (typeof fetchFn !== 'function') {
119
+ return { ok: false, status: 0, data: null, error: 'fetch is not available in this Node runtime' }
120
+ }
121
+
122
+ const timeout = makeTimeoutController(timeoutMs)
123
+ try {
124
+ const response = await fetchFn(url, {
125
+ method,
126
+ body,
127
+ headers: body ? { 'content-type': 'application/json' } : undefined,
128
+ signal: timeout.signal,
129
+ })
130
+ const raw = await response.text()
131
+ let data = {}
132
+ try {
133
+ data = raw.trim() ? JSON.parse(raw) : {}
134
+ } catch {
135
+ return { ok: false, status: response.status, data: null, error: `Malformed JSON from ${url}` }
136
+ }
137
+ if (!response.ok) {
138
+ const message = data?.error?.message || data?.error?.code || `HTTP ${response.status}`
139
+ return { ok: false, status: response.status, data, error: message }
140
+ }
141
+ return { ok: true, status: response.status, data, error: null }
142
+ } catch (error) {
143
+ const message = error?.name === 'AbortError' ? 'request timed out' : (error?.message || String(error))
144
+ return { ok: false, status: 0, data: null, error: message }
145
+ } finally {
146
+ timeout.clear()
147
+ }
148
+ }
149
+
150
+ function readDaemonFiles() {
151
+ const recordedPort = readNumberFile(ROUTER_PORT_PATH)
152
+ const recordedPid = readNumberFile(ROUTER_PID_PATH)
153
+ return {
154
+ port: recordedPort,
155
+ pid: recordedPid,
156
+ pidAlive: recordedPid ? isProcessAlive(recordedPid) : false,
157
+ hasPidFile: existsSync(ROUTER_PID_PATH),
158
+ hasPortFile: existsSync(ROUTER_PORT_PATH),
159
+ }
160
+ }
161
+
162
+ function buildPortCandidates(state) {
163
+ const ports = []
164
+ const currentPort = Number.parseInt(String(state.routerDashboardPort || ''), 10)
165
+ const baseUrlMatch = typeof state.routerDashboardBaseUrl === 'string'
166
+ ? state.routerDashboardBaseUrl.match(/:(\d+)$/)
167
+ : null
168
+ const baseUrlPort = baseUrlMatch ? Number.parseInt(baseUrlMatch[1], 10) : null
169
+ const filePort = readNumberFile(ROUTER_PORT_PATH)
170
+ const { defaultPort, maxPort } = getRouterPortRange()
171
+ for (const port of [baseUrlPort, currentPort, filePort, defaultPort]) {
172
+ if (Number.isInteger(port) && port > 0 && !ports.includes(port)) ports.push(port)
173
+ }
174
+ for (let port = defaultPort; port <= maxPort; port += 1) {
175
+ if (!ports.includes(port)) ports.push(port)
176
+ }
177
+ return ports
178
+ }
179
+
180
+ async function discoverRouterDashboard(state, fetchFn = globalThis.fetch) {
181
+ let lastError = null
182
+ for (const port of buildPortCandidates(state)) {
183
+ const baseUrl = `http://127.0.0.1:${port}`
184
+ const health = await fetchJson(`${baseUrl}/health`, { fetchFn })
185
+ if (health.ok) return { baseUrl, port, health: health.data, error: null }
186
+ lastError = health.error
187
+ }
188
+ const files = readDaemonFiles()
189
+ return {
190
+ baseUrl: null,
191
+ port: files.port,
192
+ health: {
193
+ ok: false,
194
+ running: false,
195
+ pid: files.pid,
196
+ port: files.port,
197
+ stalePid: files.pid && !files.pidAlive ? files.pid : null,
198
+ },
199
+ error: lastError || 'Router daemon is not reachable',
200
+ }
201
+ }
202
+
203
+ function normalizeTokens(tokens) {
204
+ const today = isRecord(tokens?.today) ? tokens.today : {}
205
+ const allTime = isRecord(tokens?.all_time) ? tokens.all_time : {}
206
+ return {
207
+ today: {
208
+ total_tokens: toFiniteNumber(today.total_tokens, 0),
209
+ prompt_tokens: toFiniteNumber(today.prompt_tokens, 0),
210
+ completion_tokens: toFiniteNumber(today.completion_tokens, 0),
211
+ requests: toFiniteNumber(today.requests, 0),
212
+ by_model: isRecord(today.by_model) ? today.by_model : {},
213
+ },
214
+ all_time: {
215
+ total_tokens: toFiniteNumber(allTime.total_tokens, 0),
216
+ prompt_tokens: toFiniteNumber(allTime.prompt_tokens, 0),
217
+ completion_tokens: toFiniteNumber(allTime.completion_tokens, 0),
218
+ requests: toFiniteNumber(allTime.requests, 0),
219
+ first_tracked: safeString(allTime.first_tracked, null),
220
+ },
221
+ }
222
+ }
223
+
224
+ function normalizeModelHealth(entry, index) {
225
+ const model = isRecord(entry) ? entry : {}
226
+ return {
227
+ priority: toFiniteNumber(model.priority, index + 1),
228
+ provider: safeString(model.provider, 'unknown'),
229
+ model: safeString(model.model, 'unknown'),
230
+ key: safeString(model.key, `${safeString(model.provider, 'unknown')}/${safeString(model.model, 'unknown')}`),
231
+ state: safeString(model.state, 'UNKNOWN').toUpperCase(),
232
+ score: toFiniteNumber(model.score, null),
233
+ last_latency_ms: toFiniteNumber(model.last_latency_ms, null),
234
+ uptime: toFiniteNumber(model.uptime, null),
235
+ last_error: safeString(model.last_error, null),
236
+ }
237
+ }
238
+
239
+ function normalizeRequestEntry(entry) {
240
+ const item = isRecord(entry) ? entry : {}
241
+ return {
242
+ at: safeString(item.at, new Date().toISOString()),
243
+ request_id: safeString(item.request_id, ''),
244
+ model: safeString(item.model, '—'),
245
+ status: item.status ?? '—',
246
+ latency_ms: toFiniteNumber(item.latency_ms, null),
247
+ tokens: toFiniteNumber(item.tokens, 0),
248
+ failover: item.failover === true,
249
+ stream: item.stream === true,
250
+ error: safeString(item.error, null),
251
+ }
252
+ }
253
+
254
+ export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
255
+ const health = isRecord(healthPayload) ? healthPayload : {}
256
+ const stats = isRecord(statsPayload) ? statsPayload : {}
257
+ const merged = { ...health, ...stats }
258
+ const models = Array.isArray(stats.models) ? stats.models.map(normalizeModelHealth) : []
259
+ const requestLog = Array.isArray(stats.requestLog) ? stats.requestLog.map(normalizeRequestEntry) : []
260
+
261
+ return {
262
+ ok: merged.ok === true,
263
+ pid: toFiniteNumber(merged.pid, null),
264
+ port: toFiniteNumber(merged.port, null),
265
+ enabled: merged.enabled === true,
266
+ activeSet: safeString(merged.activeSet, '—'),
267
+ activeModelCount: toFiniteNumber(merged.activeModelCount, models.length),
268
+ setCount: toFiniteNumber(merged.setCount, 0),
269
+ uptimeSeconds: toFiniteNumber(merged.uptimeSeconds, 0),
270
+ requestsRouted: toFiniteNumber(merged.requestsRouted, 0),
271
+ inFlight: toFiniteNumber(merged.inFlight, 0),
272
+ shuttingDown: merged.shuttingDown === true,
273
+ probeMode: safeString(merged.probeMode, 'unknown'),
274
+ lastProbeAt: safeString(merged.lastProbeAt, null),
275
+ crashRecovered: toFiniteNumber(merged.crashRecovered, 0),
276
+ configPath: safeString(merged.configPath, ''),
277
+ tokenStatsPath: safeString(merged.tokenStatsPath, ''),
278
+ logPath: safeString(merged.logPath, ''),
279
+ stalePid: toFiniteNumber(merged.stalePid, null),
280
+ tokens: normalizeTokens(stats.tokens),
281
+ models,
282
+ requestLog,
283
+ }
284
+ }
285
+
286
+ export function formatRouterDuration(seconds) {
287
+ const total = Math.max(0, Math.floor(Number(seconds) || 0))
288
+ const days = Math.floor(total / 86400)
289
+ const hours = Math.floor((total % 86400) / 3600)
290
+ const minutes = Math.floor((total % 3600) / 60)
291
+ const secs = total % 60
292
+ if (days > 0) return `${days}d ${hours}h`
293
+ if (hours > 0) return `${hours}h ${minutes}m`
294
+ if (minutes > 0) return `${minutes}m ${secs}s`
295
+ return `${secs}s`
296
+ }
297
+
298
+ function formatAge(iso) {
299
+ if (!iso) return 'never'
300
+ const then = Date.parse(iso)
301
+ if (!Number.isFinite(then)) return 'unknown'
302
+ const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000))
303
+ return `${formatRouterDuration(seconds)} ago`
304
+ }
305
+
306
+ function formatPercent(value) {
307
+ if (!Number.isFinite(value)) return '—'
308
+ return `${Math.round(value * 100)}%`
309
+ }
310
+
311
+ function modelStateBadge(state) {
312
+ const label = safeString(state, 'UNKNOWN').toUpperCase()
313
+ if (label === 'CLOSED') return themeColors.success('● CLOSED')
314
+ if (label === 'HALF_OPEN') return themeColors.warning('◐ HALF')
315
+ if (label === 'OPEN') return themeColors.error('○ OPEN')
316
+ if (label === 'AUTH_ERROR') return themeColors.error('⚠ AUTH')
317
+ if (label === 'STALE') return themeColors.dim('💀 STALE')
318
+ if (label === 'UNSUPPORTED') return themeColors.dim('× UNSUP')
319
+ return themeColors.dim(`? ${label}`)
320
+ }
321
+
322
+ function statusBadge(status, snapshot) {
323
+ if (status === 'ready') return themeColors.successBold('● RUNNING')
324
+ if (status === 'partial') return themeColors.warningBold('◐ PARTIAL')
325
+ if (status === 'loading') return themeColors.warning('◌ LOADING')
326
+ if (status === 'stale' || snapshot.stalePid) return themeColors.errorBold('○ STALE PID')
327
+ if (status === 'malformed') return themeColors.errorBold('○ BAD JSON')
328
+ if (status === 'stopped') return themeColors.dim('○ STOPPED')
329
+ return themeColors.error('○ UNREACHABLE')
330
+ }
331
+
332
+ function setDashboardNotice(state, type, message, ttlMs = 3500) {
333
+ state.routerDashboardNotice = { type, message, at: Date.now() }
334
+ if (state.routerDashboardNoticeTimer) clearTimeout(state.routerDashboardNoticeTimer)
335
+ state.routerDashboardNoticeTimer = setTimeout(() => {
336
+ if (state.routerDashboardNotice?.message === message) state.routerDashboardNotice = null
337
+ }, ttlMs)
338
+ state.routerDashboardNoticeTimer.unref?.()
339
+ }
340
+
341
+ export function parseRouterDashboardSseFrame(frame) {
342
+ const lines = String(frame || '').split(/\r?\n/)
343
+ let event = 'message'
344
+ const dataLines = []
345
+ for (const line of lines) {
346
+ if (line.startsWith('event:')) event = line.slice(6).trim() || 'message'
347
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
348
+ }
349
+ const rawData = dataLines.join('\n')
350
+ let data = null
351
+ if (rawData) {
352
+ try {
353
+ data = JSON.parse(rawData)
354
+ } catch {
355
+ data = rawData
356
+ }
357
+ }
358
+ return { event, data }
359
+ }
360
+
361
+ export function appendRouterDashboardEvent(state, event, data) {
362
+ if (!Array.isArray(state.routerDashboardEvents)) state.routerDashboardEvents = []
363
+ const entry = { event, data, at: new Date().toISOString() }
364
+ state.routerDashboardEvents.unshift(entry)
365
+ while (state.routerDashboardEvents.length > ROUTER_DASHBOARD_EVENT_LIMIT) state.routerDashboardEvents.pop()
366
+
367
+ if (event === 'request') {
368
+ if (!Array.isArray(state.routerDashboardLiveRequests)) state.routerDashboardLiveRequests = []
369
+ state.routerDashboardLiveRequests.unshift(normalizeRequestEntry({ ...(isRecord(data) ? data : {}), at: entry.at }))
370
+ while (state.routerDashboardLiveRequests.length > ROUTER_DASHBOARD_REQUEST_LIMIT) state.routerDashboardLiveRequests.pop()
371
+ }
372
+ }
373
+
374
+ export async function refreshRouterDashboardSnapshot(state, options = {}) {
375
+ const fetchFn = options.fetchFn || globalThis.fetch
376
+ if (!state.routerDashboardOpen && !options.force) return null
377
+ state.routerDashboardLastRefreshStartedAt = Date.now()
378
+ if (!state.routerDashboardStatus || state.routerDashboardStatus === 'idle') {
379
+ state.routerDashboardStatus = 'loading'
380
+ }
381
+
382
+ let discovery
383
+ try {
384
+ discovery = await discoverRouterDashboard(state, fetchFn)
385
+ } catch (err) {
386
+ // 📖 Guard: if discovery itself throws (e.g. fetchFn not callable), degrade gracefully
387
+ state.routerDashboardStatus = 'unreachable'
388
+ state.routerDashboardError = err?.message || 'Discovery failed unexpectedly'
389
+ state.routerDashboardLastUpdatedAt = Date.now()
390
+ return normalizeRouterDashboardSnapshot(null, null)
391
+ }
392
+ if (!discovery.baseUrl) {
393
+ state.routerDashboardBaseUrl = null
394
+ state.routerDashboardPort = discovery.port
395
+ state.routerDashboardHealth = discovery.health
396
+ state.routerDashboardStats = null
397
+ const files = readDaemonFiles()
398
+ state.routerDashboardStatus = discovery.health?.stalePid
399
+ ? 'stale'
400
+ : files.hasPidFile || files.hasPortFile
401
+ ? 'unreachable'
402
+ : 'stopped'
403
+ state.routerDashboardError = discovery.error
404
+ state.routerDashboardLastUpdatedAt = Date.now()
405
+ stopRouterDashboardEventStream(state)
406
+ return normalizeRouterDashboardSnapshot(state.routerDashboardHealth, null)
407
+ }
408
+
409
+ state.routerDashboardBaseUrl = discovery.baseUrl
410
+ state.routerDashboardPort = discovery.port
411
+ state.routerDashboardHealth = discovery.health
412
+ const stats = await fetchJson(`${discovery.baseUrl}/stats`, { fetchFn })
413
+ if (!stats.ok) {
414
+ state.routerDashboardStats = null
415
+ state.routerDashboardStatus = stats.error?.includes('Malformed JSON') ? 'malformed' : 'partial'
416
+ state.routerDashboardError = stats.error
417
+ state.routerDashboardLastUpdatedAt = Date.now()
418
+ startRouterDashboardEventStream(state, { fetchFn })
419
+ return normalizeRouterDashboardSnapshot(state.routerDashboardHealth, null)
420
+ }
421
+
422
+ state.routerDashboardStats = stats.data
423
+ state.routerDashboardStatus = 'ready'
424
+ state.routerDashboardError = null
425
+ state.routerDashboardLastUpdatedAt = Date.now()
426
+ startRouterDashboardEventStream(state, { fetchFn })
427
+ return normalizeRouterDashboardSnapshot(state.routerDashboardHealth, state.routerDashboardStats)
428
+ }
429
+
430
+ export function startRouterDashboardPolling(state, options = {}) {
431
+ if (state.routerDashboardPollTimer) return
432
+ const fetchFn = options.fetchFn || globalThis.fetch
433
+ void refreshRouterDashboardSnapshot(state, { fetchFn, force: true })
434
+ state.routerDashboardPollTimer = setInterval(() => {
435
+ void refreshRouterDashboardSnapshot(state, { fetchFn, force: true })
436
+ }, ROUTER_DASHBOARD_POLL_INTERVAL_MS)
437
+ state.routerDashboardPollTimer.unref?.()
438
+ }
439
+
440
+ export function stopRouterDashboardEventStream(state) {
441
+ if (state.routerDashboardEventAbort) {
442
+ try { state.routerDashboardEventAbort.abort() } catch {}
443
+ }
444
+ state.routerDashboardEventAbort = null
445
+ if (state.routerDashboardEventStatus === 'connected' || state.routerDashboardEventStatus === 'connecting') {
446
+ state.routerDashboardEventStatus = 'idle'
447
+ }
448
+ }
449
+
450
+ export function stopRouterDashboardClient(state) {
451
+ if (state.routerDashboardPollTimer) clearInterval(state.routerDashboardPollTimer)
452
+ state.routerDashboardPollTimer = null
453
+ stopRouterDashboardEventStream(state)
454
+ if (state.routerDashboardNoticeTimer) clearTimeout(state.routerDashboardNoticeTimer)
455
+ state.routerDashboardNoticeTimer = null
456
+ }
457
+
458
+ export function startRouterDashboardEventStream(state, options = {}) {
459
+ const fetchFn = options.fetchFn || globalThis.fetch
460
+ if (!state.routerDashboardOpen) return
461
+ if (!state.routerDashboardBaseUrl || typeof fetchFn !== 'function') return
462
+ if (state.routerDashboardEventAbort) return
463
+
464
+ const controller = new AbortController()
465
+ state.routerDashboardEventAbort = controller
466
+ state.routerDashboardEventStatus = 'connecting'
467
+ state.routerDashboardEventError = null
468
+
469
+ void (async () => {
470
+ try {
471
+ const response = await fetchFn(`${state.routerDashboardBaseUrl}/stream/events`, {
472
+ headers: { accept: 'text/event-stream' },
473
+ signal: controller.signal,
474
+ })
475
+ if (!response.ok) throw new Error(`SSE HTTP ${response.status}`)
476
+ if (!response.body || typeof response.body.getReader !== 'function') throw new Error('SSE body is not readable')
477
+ state.routerDashboardEventStatus = 'connected'
478
+ const reader = response.body.getReader()
479
+ const decoder = new TextDecoder()
480
+ let buffer = ''
481
+ while (!controller.signal.aborted) {
482
+ const chunk = await reader.read()
483
+ if (chunk.done) break
484
+ buffer += decoder.decode(chunk.value, { stream: true })
485
+ let frameEnd = buffer.indexOf('\n\n')
486
+ while (frameEnd >= 0) {
487
+ const frame = buffer.slice(0, frameEnd)
488
+ buffer = buffer.slice(frameEnd + 2)
489
+ const parsed = parseRouterDashboardSseFrame(frame)
490
+ appendRouterDashboardEvent(state, parsed.event, parsed.data)
491
+ frameEnd = buffer.indexOf('\n\n')
492
+ }
493
+ }
494
+ } catch (error) {
495
+ if (!controller.signal.aborted) {
496
+ state.routerDashboardEventStatus = 'offline'
497
+ state.routerDashboardEventError = error?.message || String(error)
498
+ }
499
+ } finally {
500
+ if (state.routerDashboardEventAbort === controller) state.routerDashboardEventAbort = null
501
+ }
502
+ })()
503
+ }
504
+
505
+ export function openRouterDashboardOverlay(state) {
506
+ state.routerDashboardOpen = true
507
+ state.routerDashboardScrollOffset = 0
508
+ state.routerDashboardStatus = state.routerDashboardStatus || 'loading'
509
+ startRouterDashboardPolling(state)
510
+ // 📖 Fire app_router_install on first Shift+R dashboard open for upgrade-path users
511
+ if (!state.routerDashboardEverOpened && state.config?.router) {
512
+ state.routerDashboardEverOpened = true
513
+ void sendUsageTelemetry(state.config, {}, {
514
+ event: 'app_router_install',
515
+ mode: 'dashboard',
516
+ properties: { router_version: '0.4.0', trigger: 'upgrade_path' },
517
+ })
518
+ }
519
+ }
520
+
521
+ export function closeRouterDashboardOverlay(state) {
522
+ state.routerDashboardOpen = false
523
+ state.routerDashboardScrollOffset = 0
524
+ stopRouterDashboardClient(state)
525
+ }
526
+
527
+ export async function cycleRouterDashboardActiveSet(state, options = {}) {
528
+ const fetchFn = options.fetchFn || globalThis.fetch
529
+ const baseUrl = state.routerDashboardBaseUrl
530
+ if (!baseUrl) {
531
+ setDashboardNotice(state, 'error', 'Router daemon is not reachable; cannot switch sets.')
532
+ return
533
+ }
534
+ const response = await fetchJson(`${baseUrl}/sets`, { fetchFn })
535
+ if (!response.ok || !isRecord(response.data?.sets)) {
536
+ setDashboardNotice(state, 'error', `Could not load router sets: ${response.error || 'unexpected payload'}`)
537
+ return
538
+ }
539
+ const setNames = Object.keys(response.data.sets)
540
+ if (setNames.length <= 1) {
541
+ setDashboardNotice(state, 'info', 'Only one router set exists right now.')
542
+ return
543
+ }
544
+ const active = safeString(response.data.activeSet, setNames[0])
545
+ const activeIdx = Math.max(0, setNames.indexOf(active))
546
+ const nextName = setNames[(activeIdx + 1) % setNames.length]
547
+ const activate = await fetchJson(`${baseUrl}/sets/${encodeURIComponent(nextName)}/activate`, {
548
+ method: 'POST',
549
+ fetchFn,
550
+ })
551
+ if (!activate.ok) {
552
+ setDashboardNotice(state, 'error', `Could not activate ${nextName}: ${activate.error}`)
553
+ return
554
+ }
555
+ setDashboardNotice(state, 'success', `Active router set: ${nextName}`)
556
+ await refreshRouterDashboardSnapshot(state, { fetchFn, force: true })
557
+ }
558
+
559
+ export async function cycleRouterDashboardProbeMode(state, options = {}) {
560
+ const fetchFn = options.fetchFn || globalThis.fetch
561
+ const baseUrl = state.routerDashboardBaseUrl
562
+ if (!baseUrl) {
563
+ setDashboardNotice(state, 'error', 'Router daemon is not reachable; cannot change probe mode.')
564
+ return
565
+ }
566
+ const snapshot = normalizeRouterDashboardSnapshot(state.routerDashboardHealth, state.routerDashboardStats)
567
+ const currentIndex = ROUTER_PROBE_MODE_CYCLE.indexOf(snapshot.probeMode)
568
+ const nextMode = ROUTER_PROBE_MODE_CYCLE[(currentIndex >= 0 ? currentIndex + 1 : 0) % ROUTER_PROBE_MODE_CYCLE.length]
569
+ const response = await fetchJson(`${baseUrl}/daemon/probe-mode`, {
570
+ method: 'POST',
571
+ body: JSON.stringify({ probeMode: nextMode }),
572
+ fetchFn,
573
+ })
574
+ if (!response.ok) {
575
+ setDashboardNotice(state, 'error', `Probe mode change failed: ${response.error}`)
576
+ return
577
+ }
578
+ setDashboardNotice(state, 'success', `Probe mode: ${nextMode}`)
579
+ await refreshRouterDashboardSnapshot(state, { fetchFn, force: true })
580
+ }
581
+
582
+ export function clearRouterDashboardRequestLog(state) {
583
+ state.routerDashboardClearedAt = Date.now()
584
+ state.routerDashboardLiveRequests = []
585
+ state.routerDashboardEvents = []
586
+ setDashboardNotice(state, 'success', 'Local dashboard request log cleared.')
587
+ }
588
+
589
+ export function restartRouterDashboardDaemon(state) {
590
+ setDashboardNotice(state, 'info', 'Restart is reserved for Phase 7 service-manager support.')
591
+ }
592
+
593
+ export function toggleRouterDashboardProbePause(state) {
594
+ setDashboardNotice(state, 'info', 'Probe pause/resume needs backend support and remains disabled for now.')
595
+ }
596
+
597
+ // ── Set Manager helpers (Phase 4) ──────────────────────────────────────────────
598
+
599
+ const SETS_FETCH_INTERVAL_MS = 5000
600
+
601
+ function isSetsDataStale(lastFetchAt) {
602
+ return Date.now() - lastFetchAt > SETS_FETCH_INTERVAL_MS
603
+ }
604
+
605
+ export async function fetchRouterSets(state, options = {}) {
606
+ const fetchFn = options.fetchFn || globalThis.fetch
607
+ const baseUrl = state.routerDashboardBaseUrl
608
+ if (!baseUrl) {
609
+ return { ok: false, error: 'Daemon not reachable', sets: null }
610
+ }
611
+ const result = await fetchJson(`${baseUrl}/sets`, { fetchFn })
612
+ if (!result.ok) return { ok: false, error: result.error, sets: null }
613
+ const payload = result.data
614
+ if (!isRecord(payload) || !isRecord(payload.sets)) {
615
+ return { ok: false, error: 'Unexpected /sets payload', sets: null }
616
+ }
617
+ state.setsData = payload
618
+ state.setsLastFetchAt = Date.now()
619
+ return { ok: true, error: null, sets: payload }
620
+ }
621
+
622
+ export async function createRouterSet(state, name, options = {}) {
623
+ const fetchFn = options.fetchFn || globalThis.fetch
624
+ const baseUrl = state.routerDashboardBaseUrl
625
+ if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
626
+ const result = await fetchJson(`${baseUrl}/sets`, {
627
+ method: 'POST',
628
+ body: JSON.stringify({ name, models: [] }),
629
+ fetchFn,
630
+ })
631
+ if (!result.ok) return { ok: false, error: result.error }
632
+ await fetchRouterSets(state, { fetchFn })
633
+ return { ok: true }
634
+ }
635
+
636
+ export async function renameRouterSet(state, oldName, newName, options = {}) {
637
+ const fetchFn = options.fetchFn || globalThis.fetch
638
+ const baseUrl = state.routerDashboardBaseUrl
639
+ if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
640
+ const result = await fetchJson(`${baseUrl}/sets/${encodeURIComponent(oldName)}`, {
641
+ method: 'PUT',
642
+ body: JSON.stringify({ name: newName }),
643
+ fetchFn,
644
+ })
645
+ if (!result.ok) return { ok: false, error: result.error }
646
+ await fetchRouterSets(state, { fetchFn })
647
+ return { ok: true }
648
+ }
649
+
650
+ export async function duplicateRouterSet(state, sourceName, newName, options = {}) {
651
+ const fetchFn = options.fetchFn || globalThis.fetch
652
+ const baseUrl = state.routerDashboardBaseUrl
653
+ if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
654
+ const sets = state.setsData?.sets
655
+ if (!sets || !sets[sourceName]) return { ok: false, error: 'Source set not found' }
656
+ const models = sets[sourceName].models || []
657
+ const result = await fetchJson(`${baseUrl}/sets`, {
658
+ method: 'POST',
659
+ body: JSON.stringify({ name: newName, models }),
660
+ fetchFn,
661
+ })
662
+ if (!result.ok) return { ok: false, error: result.error }
663
+ await fetchRouterSets(state, { fetchFn })
664
+ return { ok: true }
665
+ }
666
+
667
+ export async function deleteRouterSet(state, name, options = {}) {
668
+ const fetchFn = options.fetchFn || globalThis.fetch
669
+ const baseUrl = state.routerDashboardBaseUrl
670
+ if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
671
+ const result = await fetchJson(`${baseUrl}/sets/${encodeURIComponent(name)}`, {
672
+ method: 'DELETE',
673
+ fetchFn,
674
+ })
675
+ if (!result.ok) return { ok: false, error: result.error }
676
+ await fetchRouterSets(state, { fetchFn })
677
+ return { ok: true }
678
+ }
679
+
680
+ export async function activateRouterSet(state, name, options = {}) {
681
+ const fetchFn = options.fetchFn || globalThis.fetch
682
+ const baseUrl = state.routerDashboardBaseUrl
683
+ if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
684
+ const result = await fetchJson(`${baseUrl}/sets/${encodeURIComponent(name)}/activate`, {
685
+ method: 'POST',
686
+ fetchFn,
687
+ })
688
+ if (!result.ok) return { ok: false, error: result.error }
689
+ await fetchRouterSets(state, { fetchFn })
690
+ return { ok: true }
691
+ }
692
+
693
+ export async function updateRouterSetModels(state, setName, models, options = {}) {
694
+ const fetchFn = options.fetchFn || globalThis.fetch
695
+ const baseUrl = state.routerDashboardBaseUrl
696
+ if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
697
+ const result = await fetchJson(`${baseUrl}/sets/${encodeURIComponent(setName)}`, {
698
+ method: 'PUT',
699
+ body: JSON.stringify({ models }),
700
+ fetchFn,
701
+ })
702
+ if (!result.ok) return { ok: false, error: result.error }
703
+ await fetchRouterSets(state, { fetchFn })
704
+ return { ok: true }
705
+ }
706
+
707
+ export async function addModelToRouterSet(state, setName, provider, model, priority, options = {}) {
708
+ const fetchFn = options.fetchFn || globalThis.fetch
709
+ const baseUrl = state.routerDashboardBaseUrl
710
+ if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
711
+ const sets = state.setsData?.sets
712
+ if (!sets || !sets[setName]) return { ok: false, error: 'Set not found' }
713
+ const currentModels = sets[setName].models || []
714
+ const modelEntry = { provider, model, priority: Number(priority) || currentModels.length + 1 }
715
+ const result = await updateRouterSetModels(state, setName, [...currentModels, modelEntry], { fetchFn })
716
+ return result
717
+ }
718
+
719
+ export async function removeModelFromRouterSet(state, setName, provider, model, options = {}) {
720
+ const fetchFn = options.fetchFn || globalThis.fetch
721
+ const baseUrl = state.routerDashboardBaseUrl
722
+ if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
723
+ const sets = state.setsData?.sets
724
+ if (!sets || !sets[setName]) return { ok: false, error: 'Set not found' }
725
+ const currentModels = (sets[setName].models || []).filter(
726
+ (m) => !(m.provider === provider && m.model === model)
727
+ )
728
+ return updateRouterSetModels(state, setName, currentModels, { fetchFn })
729
+ }
730
+
731
+ export async function reorderRouterSetModel(state, setName, provider, model, direction, options = {}) {
732
+ const fetchFn = options.fetchFn || globalThis.fetch
733
+ const baseUrl = state.routerDashboardBaseUrl
734
+ if (!baseUrl) return { ok: false, error: 'Daemon not reachable' }
735
+ const sets = state.setsData?.sets
736
+ if (!sets || !sets[setName]) return { ok: false, error: 'Set not found' }
737
+ const currentModels = [...(sets[setName].models || [])]
738
+ const idx = currentModels.findIndex((m) => m.provider === provider && m.model === model)
739
+ if (idx < 0) return { ok: false, error: 'Model not in set' }
740
+ const newIdx = direction === 'up' ? idx - 1 : idx + 1
741
+ if (newIdx < 0 || newIdx >= currentModels.length) return { ok: false, error: 'Already at edge' }
742
+ const [moved] = currentModels.splice(idx, 1)
743
+ currentModels.splice(newIdx, 0, moved)
744
+ for (let i = 0; i < currentModels.length; i++) {
745
+ currentModels[i] = { ...currentModels[i], priority: i + 1 }
746
+ }
747
+ return updateRouterSetModels(state, setName, currentModels, { fetchFn })
748
+ }
749
+
750
+ function requestLogRows(state, snapshot) {
751
+ const clearedAt = Number(state.routerDashboardClearedAt || 0)
752
+ const candidates = [
753
+ ...(Array.isArray(state.routerDashboardLiveRequests) ? state.routerDashboardLiveRequests : []),
754
+ ...snapshot.requestLog,
755
+ ]
756
+ const seen = new Set()
757
+ return candidates
758
+ .filter((entry) => {
759
+ const at = Date.parse(entry.at)
760
+ return !Number.isFinite(at) || at >= clearedAt
761
+ })
762
+ .filter((entry) => {
763
+ const key = entry.request_id || `${entry.at}:${entry.model}:${entry.status}:${entry.latency_ms}`
764
+ if (seen.has(key)) return false
765
+ seen.add(key)
766
+ return true
767
+ })
768
+ .slice(0, 10)
769
+ }
770
+
771
+ function topTokenModel(tokens) {
772
+ const byModel = tokens.today.by_model
773
+ let bestKey = null
774
+ let bestTotal = 0
775
+ for (const [key, value] of Object.entries(byModel || {})) {
776
+ const total = toFiniteNumber(isRecord(value) ? value.total : value, 0)
777
+ if (total > bestTotal) {
778
+ bestKey = key
779
+ bestTotal = total
780
+ }
781
+ }
782
+ return bestKey ? `${bestKey} (${formatTokenTotalCompact(bestTotal)})` : '—'
783
+ }
784
+
785
+ function renderNotice(notice) {
786
+ if (!notice?.message) return null
787
+ const color = notice.type === 'error'
788
+ ? themeColors.errorBold
789
+ : notice.type === 'success'
790
+ ? themeColors.successBold
791
+ : themeColors.warningBold
792
+ return ` ${color(notice.message)}`
793
+ }
794
+
795
+ export function renderRouterDashboard(state, deps = {}) {
796
+ const LOCAL_VERSION = deps.LOCAL_VERSION || ''
797
+ const EL = '\x1b[K'
798
+ const lines = []
799
+ const snapshot = normalizeRouterDashboardSnapshot(state.routerDashboardHealth, state.routerDashboardStats)
800
+ const status = state.routerDashboardStatus || 'idle'
801
+ const width = Math.max(80, state.terminalCols || 80)
802
+ const separator = themeColors.dim('─'.repeat(Math.max(20, width - 6)))
803
+ const requestRows = requestLogRows(state, snapshot)
804
+ const eventStatus = state.routerDashboardEventStatus || 'idle'
805
+ const updatedAt = state.routerDashboardLastUpdatedAt
806
+ ? new Date(state.routerDashboardLastUpdatedAt).toLocaleTimeString()
807
+ : 'never'
808
+
809
+ lines.push(` ${themeColors.accent('🚀')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(LOCAL_VERSION ? `v${LOCAL_VERSION}` : '')}`)
810
+ lines.push(` ${themeColors.textBold('🔀 FCM Router Dashboard')} ${themeColors.dim('Shift+R from main table')}`)
811
+ lines.push('')
812
+ lines.push(` Daemon: ${statusBadge(status, snapshot)} ${themeColors.dim('Port:')} ${themeColors.info(String(snapshot.port || state.routerDashboardPort || '—'))} ${themeColors.dim('PID:')} ${snapshot.pid || '—'} ${themeColors.dim('SSE:')} ${eventStatus}`)
813
+ lines.push(` Set: ${themeColors.textBold(snapshot.activeSet)} ${themeColors.dim('Models:')} ${snapshot.activeModelCount} ${themeColors.dim('Uptime:')} ${formatRouterDuration(snapshot.uptimeSeconds)} ${themeColors.dim('Requests:')} ${snapshot.requestsRouted} ${themeColors.dim('In flight:')} ${snapshot.inFlight}`)
814
+ lines.push(` Probes: ${themeColors.info(snapshot.probeMode)} ${themeColors.dim('Last probe:')} ${formatAge(snapshot.lastProbeAt)} ${themeColors.dim('Last refresh:')} ${updatedAt}`)
815
+ if (state.routerDashboardError) lines.push(` ${themeColors.error(`Dashboard note: ${state.routerDashboardError}`)}`)
816
+ if (state.routerDashboardEventError) lines.push(` ${themeColors.warning(`Event stream: ${state.routerDashboardEventError}`)}`)
817
+ const notice = renderNotice(state.routerDashboardNotice)
818
+ if (notice) lines.push(notice)
819
+ if (snapshot.setCount === 0) {
820
+ lines.push(` ${themeColors.warningBold('⚠ No router sets found. Press Y to install providers into FCM Router, then restart the daemon.')}`)
821
+ }
822
+ lines.push(` ${separator}`)
823
+ lines.push('')
824
+
825
+ lines.push(` ${themeColors.textBold('Model Health / Circuit Breakers')}`)
826
+ if (snapshot.models.length === 0) {
827
+ lines.push(` ${themeColors.dim('No model health rows available yet. Start the daemon or wait for /stats to answer.')}`)
828
+ } else {
829
+ const header = ` ${padEndDisplay('#', 4)} ${padEndDisplay('Provider', 12)} ${padEndDisplay('Model', 30)} ${padEndDisplay('State', 12)} ${padEndDisplay('P95', 8)} ${padEndDisplay('Up', 5)} Score`
830
+ lines.push(themeColors.dim(header))
831
+ for (const model of snapshot.models.slice(0, 12)) {
832
+ const latency = Number.isFinite(model.last_latency_ms) ? `${Math.round(model.last_latency_ms)}ms` : '—'
833
+ const score = Number.isFinite(model.score) ? model.score.toFixed(2) : '—'
834
+ const errorSuffix = model.last_error ? themeColors.dim(` ${compactText(model.last_error, 22).trimEnd()}`) : ''
835
+ lines.push(
836
+ ` ${padEndDisplay(String(model.priority), 4)} ` +
837
+ `${compactText(model.provider, 12)} ` +
838
+ `${compactText(model.model, 30)} ` +
839
+ `${padEndDisplay(modelStateBadge(model.state), 12)} ` +
840
+ `${padEndDisplay(latency, 8)} ` +
841
+ `${padEndDisplay(formatPercent(model.uptime), 5)} ` +
842
+ `${score}${errorSuffix}`
843
+ )
844
+ }
845
+ if (snapshot.models.length > 12) lines.push(themeColors.dim(` … ${snapshot.models.length - 12} more models in active set`))
846
+ }
847
+
848
+ lines.push('')
849
+ lines.push(` ${themeColors.textBold('Token Summary')}`)
850
+ lines.push(` Today: ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} tok ${themeColors.dim('Requests:')} ${snapshot.tokens.today.requests} ${themeColors.dim('Prompt/Completion:')} ${formatTokenTotalCompact(snapshot.tokens.today.prompt_tokens)} / ${formatTokenTotalCompact(snapshot.tokens.today.completion_tokens)}`)
851
+ lines.push(` All-time: ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} tok ${themeColors.dim('Requests:')} ${snapshot.tokens.all_time.requests} ${themeColors.dim('Top today:')} ${compactText(topTokenModel(snapshot.tokens), Math.max(18, width - 58)).trimEnd()}`)
852
+
853
+ lines.push('')
854
+ lines.push(` ${themeColors.textBold('Live Request Log')}`)
855
+ if (requestRows.length === 0) {
856
+ lines.push(` ${themeColors.dim('No routed requests in the local dashboard log yet.')}`)
857
+ } else {
858
+ const header = ` ${padEndDisplay('Time', 10)} ${padEndDisplay('Model', 34)} ${padEndDisplay('Status', 8)} ${padEndDisplay('Latency', 9)} ${padEndDisplay('Tokens', 9)} Detail`
859
+ lines.push(themeColors.dim(header))
860
+ for (const row of requestRows) {
861
+ const atMs = Date.parse(row.at)
862
+ const time = Number.isFinite(atMs) ? new Date(atMs).toLocaleTimeString() : '—'
863
+ const statusText = String(row.status)
864
+ const statusColor = statusText.startsWith('2') ? themeColors.success : statusText === 'ERR' ? themeColors.error : themeColors.warning
865
+ const latency = Number.isFinite(row.latency_ms) ? `${Math.round(row.latency_ms)}ms` : '—'
866
+ const detail = [
867
+ row.failover ? 'failover' : '',
868
+ row.stream ? 'stream' : '',
869
+ row.error || '',
870
+ ].filter(Boolean).join(', ') || '—'
871
+ lines.push(
872
+ ` ${padEndDisplay(time, 10)} ` +
873
+ `${compactText(row.model, 34)} ` +
874
+ `${padEndDisplay(statusColor(statusText), 8)} ` +
875
+ `${padEndDisplay(latency, 9)} ` +
876
+ `${padEndDisplay(formatTokenTotalCompact(row.tokens), 9)} ` +
877
+ `${compactText(detail, Math.max(10, width - 78)).trimEnd()}`
878
+ )
879
+ }
880
+ }
881
+
882
+ lines.push('')
883
+ const lastEvent = Array.isArray(state.routerDashboardEvents) && state.routerDashboardEvents[0]
884
+ ? `${state.routerDashboardEvents[0].event} @ ${new Date(state.routerDashboardEvents[0].at).toLocaleTimeString()}`
885
+ : 'none'
886
+ lines.push(` ${themeColors.dim(`Endpoint: ${state.routerDashboardBaseUrl || 'not connected'} • Last SSE event: ${lastEvent}`)}`)
887
+ lines.push(` ${themeColors.hotkey('S')} ${themeColors.dim('Switch set')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim('Probe mode')} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim('Restart (Phase 7)')} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('P')} ${themeColors.dim('Pause probes (disabled)')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
888
+
889
+ const { visible, offset } = sliceOverlayLines(lines, state.routerDashboardScrollOffset || 0, state.terminalRows || 24)
890
+ state.routerDashboardScrollOffset = offset
891
+ const tinted = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols || 80)
892
+ return tinted.map((line) => line + EL).join('\n')
893
+ }