free-coding-models 0.5.28 → 0.5.30

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 (64) hide show
  1. package/README.md +1 -1
  2. package/bin/free-coding-models.js +10 -2
  3. package/changelog/v0.5.29.md +19 -0
  4. package/changelog/v0.5.30.md +15 -0
  5. package/package.json +1 -1
  6. package/sources.js +3 -2
  7. package/src/core/config.js +1 -14
  8. package/src/core/endpoint-installer.js +5 -9
  9. package/src/core/legacy-proxy-cleanup.js +2 -0
  10. package/src/core/model-merger.js +4 -13
  11. package/src/core/playground.js +6 -4
  12. package/src/core/router-daemon.js +101 -43
  13. package/src/core/router-dashboard.js +63 -25
  14. package/src/core/shared-helpers.js +117 -0
  15. package/src/core/sync-set.js +11 -24
  16. package/src/core/tool-launchers.js +24 -92
  17. package/src/core/utils.js +26 -100
  18. package/src/tui/app.js +2 -2
  19. package/src/tui/command-palette.js +1 -0
  20. package/src/tui/key-handler.js +77 -20
  21. package/src/tui/render-table.js +3 -3
  22. package/web/dist/assets/index-CsFt3qt5.css +1 -0
  23. package/web/dist/assets/index-I6N91rH7.js +40 -0
  24. package/web/dist/favicon.ico +0 -0
  25. package/web/dist/favicons/apple-touch-icon.png +0 -0
  26. package/web/dist/favicons/favicon-16x16.png +0 -0
  27. package/web/dist/favicons/favicon-192x192.png +0 -0
  28. package/web/dist/favicons/favicon-32x32.png +0 -0
  29. package/web/dist/favicons/favicon-48x48.png +0 -0
  30. package/web/dist/favicons/favicon-512x512.png +0 -0
  31. package/web/dist/favicons/favicon-96x96.png +0 -0
  32. package/web/dist/favicons/favicon.ico +0 -0
  33. package/web/dist/favicons/mstile-150x150.png +0 -0
  34. package/web/dist/favicons/mstile-310x150.png +0 -0
  35. package/web/dist/favicons/mstile-310x310.png +0 -0
  36. package/web/dist/favicons/mstile-512x512.png +0 -0
  37. package/web/dist/favicons/mstile-70x70.png +0 -0
  38. package/web/dist/index.html +2 -2
  39. package/web/public/favicon.ico +0 -0
  40. package/web/public/favicons/apple-touch-icon.png +0 -0
  41. package/web/public/favicons/favicon-16x16.png +0 -0
  42. package/web/public/favicons/favicon-192x192.png +0 -0
  43. package/web/public/favicons/favicon-32x32.png +0 -0
  44. package/web/public/favicons/favicon-48x48.png +0 -0
  45. package/web/public/favicons/favicon-512x512.png +0 -0
  46. package/web/public/favicons/favicon-96x96.png +0 -0
  47. package/web/public/favicons/favicon.ico +0 -0
  48. package/web/public/favicons/mstile-150x150.png +0 -0
  49. package/web/public/favicons/mstile-310x150.png +0 -0
  50. package/web/public/favicons/mstile-310x310.png +0 -0
  51. package/web/public/favicons/mstile-512x512.png +0 -0
  52. package/web/public/favicons/mstile-70x70.png +0 -0
  53. package/web/server.js +34 -4
  54. package/web/src/components/dashboard/ExpandedDetailRow.jsx +10 -113
  55. package/web/src/components/dashboard/ExpandedDetailRow.module.css +9 -0
  56. package/web/src/components/playground/PlaygroundChat.jsx +527 -0
  57. package/web/src/components/playground/PlaygroundChat.module.css +382 -0
  58. package/web/src/components/playground/PlaygroundView.jsx +104 -442
  59. package/web/src/components/playground/PlaygroundView.module.css +10 -0
  60. package/web/src/components/router/RouterView.jsx +268 -56
  61. package/web/src/components/router/RouterView.module.css +416 -1
  62. package/src/core/product-flags.js +0 -9
  63. package/web/dist/assets/index-BRFowJv5.css +0 -1
  64. package/web/dist/assets/index-Pr3waI0-.js +0 -41
@@ -33,6 +33,7 @@
33
33
  * @exports fetchRouterSets, createRouterSet, renameRouterSet, duplicateRouterSet
34
34
  * @exports deleteRouterSet, activateRouterSet, updateRouterSetModels
35
35
  * @exports addModelToRouterSet, removeModelFromRouterSet, reorderRouterSetModel
36
+ * @exports setDashboardNotice
36
37
  *
37
38
  * @see ./router-daemon.js — daemon endpoints consumed by this screen
38
39
  * @see ./overlays.js — overlay factory that mounts this renderer
@@ -42,7 +43,7 @@
42
43
  import chalk from 'chalk'
43
44
  import { existsSync, readFileSync } from 'node:fs'
44
45
  import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
45
- import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, ROUTER_PID_PATH, ROUTER_PORT_PATH, getRouterPortRange } from './router-daemon.js'
46
+ import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, getRouterPidPath, getRouterPortPath, getRouterPortRange } from './router-daemon.js'
46
47
  import { themeColors, getTierRgb } from '../tui/theme.js'
47
48
  import { formatTokenTotalCompact } from './token-usage-reader.js'
48
49
  import { sendUsageTelemetry } from './telemetry.js'
@@ -150,14 +151,16 @@ async function fetchJson(url, options = {}) {
150
151
  }
151
152
 
152
153
  function readDaemonFiles() {
153
- const recordedPort = readNumberFile(ROUTER_PORT_PATH)
154
- const recordedPid = readNumberFile(ROUTER_PID_PATH)
154
+ const portPath = getRouterPortPath()
155
+ const pidPath = getRouterPidPath()
156
+ const recordedPort = readNumberFile(portPath)
157
+ const recordedPid = readNumberFile(pidPath)
155
158
  return {
156
159
  port: recordedPort,
157
160
  pid: recordedPid,
158
161
  pidAlive: recordedPid ? isProcessAlive(recordedPid) : false,
159
- hasPidFile: existsSync(ROUTER_PID_PATH),
160
- hasPortFile: existsSync(ROUTER_PORT_PATH),
162
+ hasPidFile: existsSync(pidPath),
163
+ hasPortFile: existsSync(portPath),
161
164
  }
162
165
  }
163
166
 
@@ -168,7 +171,7 @@ function buildPortCandidates(state) {
168
171
  ? state.routerDashboardBaseUrl.match(/:(\d+)$/)
169
172
  : null
170
173
  const baseUrlPort = baseUrlMatch ? Number.parseInt(baseUrlMatch[1], 10) : null
171
- const filePort = readNumberFile(ROUTER_PORT_PATH)
174
+ const filePort = readNumberFile(getRouterPortPath())
172
175
  const { defaultPort, maxPort } = getRouterPortRange()
173
176
  for (const port of [baseUrlPort, currentPort, filePort, defaultPort]) {
174
177
  if (Number.isInteger(port) && port > 0 && !ports.includes(port)) ports.push(port)
@@ -259,6 +262,21 @@ export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
259
262
  const merged = { ...health, ...stats }
260
263
  const models = Array.isArray(stats.models) ? stats.models.map(normalizeModelHealth) : []
261
264
  const requestLog = Array.isArray(stats.requestLog) ? stats.requestLog.map(normalizeRequestEntry) : []
265
+ // 📖 routingOrder — priority-first attempt order from /stats (issue #120).
266
+ // 📖 routingOrder[0].key is the model that will serve the next request, so the
267
+ // 📖 TUI can mark it with a ▶ NEXT glyph. Guarded so older daemons without
268
+ // 📖 the field still render fine.
269
+ const routingOrder = Array.isArray(stats.routingOrder)
270
+ ? stats.routingOrder
271
+ .filter((entry) => isRecord(entry) && typeof entry.key === 'string')
272
+ .map((entry) => ({
273
+ key: entry.key,
274
+ provider: safeString(entry.provider, ''),
275
+ model: safeString(entry.model, ''),
276
+ priority: toFiniteNumber(entry.priority, 0),
277
+ state: safeString(entry.state, 'UNKNOWN'),
278
+ }))
279
+ : []
262
280
 
263
281
  return {
264
282
  ok: merged.ok === true,
@@ -281,6 +299,7 @@ export function normalizeRouterDashboardSnapshot(healthPayload, statsPayload) {
281
299
  stalePid: toFiniteNumber(merged.stalePid, null),
282
300
  tokens: normalizeTokens(stats.tokens),
283
301
  models,
302
+ routingOrder,
284
303
  requestLog,
285
304
  }
286
305
  }
@@ -331,7 +350,7 @@ function statusBadge(status, snapshot) {
331
350
  return themeColors.error('○ UNREACHABLE')
332
351
  }
333
352
 
334
- function setDashboardNotice(state, type, message, ttlMs = 3500) {
353
+ export function setDashboardNotice(state, type, message, ttlMs = 3500) {
335
354
  state.routerDashboardNotice = { type, message, at: Date.now() }
336
355
  if (state.routerDashboardNoticeTimer) clearTimeout(state.routerDashboardNoticeTimer)
337
356
  state.routerDashboardNoticeTimer = setTimeout(() => {
@@ -843,15 +862,19 @@ export function renderRouterDashboard(state, deps = {}) {
843
862
  lines.push(` ${paintBanner(bannerLine)}`)
844
863
  lines.push('')
845
864
 
846
- // ── Quick Setup (connection info) ───────────────────────────────────────────
847
- const port = snapshot.port || state.routerDashboardPort || '—'
848
- const baseUrl = isRunning ? `http://localhost:${port}/v1` : `http://localhost:${port}/v1`
849
- lines.push(` ${themeColors.textBold('Quick Setup')} ${themeColors.dim('— paste into your coding tool')}`)
850
- lines.push(` ${themeColors.dim('URL')} ${themeColors.info(baseUrl)}`)
851
- lines.push(` ${themeColors.dim('Model')} ${themeColors.info('fcm')}`)
852
- lines.push(` ${themeColors.dim('API Key')} ${themeColors.info('fcm-local')}`)
865
+ // ── Quick Setup (connection info) — HERO section ──────────────────────────
866
+ // 📖 Always visible with default port 19280 so users can copy even when stopped.
867
+ const { defaultPort: currentDefaultPort } = getRouterPortRange()
868
+ const port = snapshot.port || state.routerDashboardPort || currentDefaultPort
869
+ const baseUrl = `http://localhost:${port}/v1`
870
+ lines.push(` ${themeColors.textBold('Quick Setup')} ${themeColors.dim('— paste into your coding tool config')}`)
871
+ lines.push(` ${themeColors.dim('URL')} ${themeColors.infoBold(baseUrl)}`)
872
+ lines.push(` ${themeColors.dim('Model')} ${themeColors.infoBold('fcm')}`)
873
+ lines.push(` ${themeColors.dim('API Key')} ${themeColors.infoBold('fcm-local')}`)
853
874
  if (isRunning) {
854
875
  lines.push(` ${themeColors.dim('Uptime')} ${themeColors.success(formatRouterDuration(snapshot.uptimeSeconds))} ${themeColors.dim('Requests routed:')} ${themeColors.info(String(snapshot.requestsRouted))}`)
876
+ } else {
877
+ lines.push(` ${themeColors.dim('Hint')} ${themeColors.dim('Start the daemon to enable routing')}`)
855
878
  }
856
879
  lines.push(` ${separator}`)
857
880
  lines.push('')
@@ -868,7 +891,8 @@ export function renderRouterDashboard(state, deps = {}) {
868
891
  const cursor = state.routerDashboardCursorIndex ?? 0
869
892
 
870
893
  if (favorites.length === 0) {
871
- lines.push(` ${themeColors.warning('No favorites yet.')} ${themeColors.dim('Press Esc, then F on any model to add it.')}`)
894
+ lines.push(` ${themeColors.warning('No favorites yet. Press Esc, then F on any model to add it.')}`)
895
+ lines.push(` ${themeColors.dim('Favorites become your router fallback chain — #1 is tried first.')}`)
872
896
  } else {
873
897
  // 📖 Priority keycap glyphs for the fallback order
874
898
  const KEYCAPS = ['1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟']
@@ -880,8 +904,13 @@ export function renderRouterDashboard(state, deps = {}) {
880
904
  healthByKey.set(`${m.provider}/${m.model}`, m)
881
905
  }
882
906
 
883
- // 📖 Column headers
884
- lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay('MODEL', 42))} ${themeColors.dim(padEndDisplay('DAEMON STATUS', 16))} ${themeColors.dim(padEndDisplay('AVG PING', 8))} ${themeColors.dim('VERDICT')}`)
907
+ // 📖 The model the daemon will serve on the next request (priority-first,
908
+ // 📖 see issue #120). Marked with NEXT so the user understands the top
909
+ // 📖 of the chain is what actually handles traffic — not whichever is fastest.
910
+ const nextToServeKey = snapshot.routingOrder?.[0]?.key || null
911
+
912
+ // 📖 Column headers — leading space lines up with the ▶ NEXT marker column.
913
+ lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay('MODEL', 42))} ${themeColors.dim(padEndDisplay('STATUS', 16))} ${themeColors.dim(padEndDisplay('AVG PING', 8))} ${themeColors.dim('VERDICT')}`)
885
914
 
886
915
  for (let i = 0; i < favorites.length; i++) {
887
916
  const favKey = favorites[i]
@@ -926,14 +955,14 @@ export function renderRouterDashboard(state, deps = {}) {
926
955
  }
927
956
 
928
957
  // 📖 Get global metrics from main table state
929
- let avgPingDisplay = themeColors.dim('———')
958
+ let avgPingDisplay = themeColors.dim('')
930
959
  let verdictDisplay = themeColors.dim('Pending ⏳')
931
960
 
932
961
  if (mainResult) {
933
962
  // Avg Ping
934
963
  const avg = getAvg(mainResult)
935
964
  if (avg !== Infinity) {
936
- const str = String(avg).padEnd(4)
965
+ const str = `${avg}ms`
937
966
  avgPingDisplay = avg < 500 ? themeColors.metricGood(str) : avg < 1500 ? themeColors.metricWarn(str) : themeColors.metricBad(str)
938
967
  }
939
968
 
@@ -957,7 +986,13 @@ export function renderRouterDashboard(state, deps = {}) {
957
986
  verdictDisplay = padEndDisplay(verdictDisplay, 14)
958
987
  }
959
988
 
960
- const rowText = ` ${padEndDisplay(priorityGlyph(i), 4)} ${padEndDisplay(favKey, 42)} ${padEndDisplay(healthLabel, 16)} ${padEndDisplay(avgPingDisplay, 8)} ${verdictDisplay}`
989
+ // 📖 Prefix the next-to-serve model with a NEXT marker so the active
990
+ // 📖 routing target is obvious. Only shown when the daemon is actually
991
+ // 📖 running and has reported a routing order (stopped → no marker).
992
+ const nextMarker = (nextToServeKey && nextToServeKey === favKey)
993
+ ? themeColors.successBold('▶')
994
+ : themeColors.dim(' ')
995
+ const rowText = ` ${nextMarker} ${padEndDisplay(priorityGlyph(i), 4)} ${padEndDisplay(favKey, 42)} ${padEndDisplay(healthLabel, 16)} ${padEndDisplay(avgPingDisplay, 8)} ${verdictDisplay}`
961
996
 
962
997
  if (isCursorRow) {
963
998
  lines.push(themeColors.bgCursor(rowText + ' '.repeat(Math.max(0, width - displayWidth(rowText) - 3))))
@@ -996,14 +1031,14 @@ export function renderRouterDashboard(state, deps = {}) {
996
1031
  lines.push(` ${separator}`)
997
1032
  lines.push('')
998
1033
 
999
- // ── Token Summary (compact) ─────────────────────────────────────────────────
1000
- lines.push(` ${themeColors.textBold('Tokens')} ${themeColors.dim('Today:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim('All-time:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim('Requests:')} ${snapshot.tokens.today.requests}/${snapshot.tokens.all_time.requests}`)
1034
+ // ── Token Summary (compact, visual) ─────────────────────────────────────────
1035
+ lines.push(` ${themeColors.textBold('📊 Tokens')} ${themeColors.dim('Today:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.today.requests} req)`)} ${themeColors.dim('Lifetime:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.all_time.requests} req)`)}`)
1001
1036
 
1002
1037
  // ── Live Request Log (compact) ──────────────────────────────────────────────
1003
1038
  const requestRows = requestLogRows(state, snapshot)
1039
+ lines.push('')
1040
+ lines.push(` ${themeColors.textBold('Recent Requests')}`)
1004
1041
  if (requestRows.length > 0) {
1005
- lines.push('')
1006
- lines.push(` ${themeColors.textBold('Recent Requests')}`)
1007
1042
  const header = ` ${padEndDisplay('Time', 10)} ${padEndDisplay('Model', 34)} ${padEndDisplay('Status', 8)} ${padEndDisplay('Latency', 9)} Detail`
1008
1043
  lines.push(themeColors.dim(header))
1009
1044
  for (const row of requestRows.slice(0, 6)) {
@@ -1025,6 +1060,8 @@ export function renderRouterDashboard(state, deps = {}) {
1025
1060
  `${compactText(detail, Math.max(10, width - 68)).trimEnd()}`
1026
1061
  )
1027
1062
  }
1063
+ } else {
1064
+ lines.push(` ${themeColors.dim('No requests routed yet')}`)
1028
1065
  }
1029
1066
 
1030
1067
  // ── Health check speed ──────────────────────────────────────────────────────
@@ -1048,7 +1085,8 @@ export function renderRouterDashboard(state, deps = {}) {
1048
1085
 
1049
1086
  // ── Footer ──────────────────────────────────────────────────────────────────
1050
1087
  lines.push('')
1051
- lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim('Reorder')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start daemon' : 'Stop daemon')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Health check: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
1088
+ lines.push(` ${separator}`)
1089
+ lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim('Reorder')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start daemon' : 'Stop daemon')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Health check: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim('Sync best')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
1052
1090
 
1053
1091
  const { visible, offset } = sliceOverlayLines(lines, state.routerDashboardScrollOffset || 0, state.terminalRows || 24)
1054
1092
  state.routerDashboardScrollOffset = offset
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @file shared-helpers.js
3
+ * @description Shared utility functions used across multiple modules.
4
+ *
5
+ * @details
6
+ * 📖 DRY helpers extracted from router-daemon.js, tool-launchers.js,
7
+ * 📖 endpoint-installer.js, and legacy-proxy-cleanup.js to eliminate
8
+ * duplicate implementations of the same patterns.
9
+ *
10
+ * @functions
11
+ * → sleep — Promise-based setTimeout
12
+ * → ensureDir — Create parent directory if missing
13
+ * → readJson — Read and parse JSON file with fallback
14
+ * → writeJson — Write JSON file with directory creation
15
+ * → atomicWriteJson — Atomic write via temp file + rename
16
+ * → safeJsonParse — JSON.parse with fallback
17
+ * → maskApiKey — Mask API key for display (show last 4 chars)
18
+ *
19
+ * @exports sleep, ensureDir, readJson, writeJson, atomicWriteJson, safeJsonParse, maskApiKey
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
23
+ import { dirname } from 'node:path'
24
+
25
+ /**
26
+ * 📖 Promise-based sleep. Used by daemon probe staggering, TUI animations, etc.
27
+ * @param {number} ms
28
+ * @returns {Promise<void>}
29
+ */
30
+ export function sleep(ms) {
31
+ return new Promise((resolve) => setTimeout(resolve, ms))
32
+ }
33
+
34
+ /**
35
+ * 📖 Create parent directory of `filePath` if it doesn't exist.
36
+ * @param {string} filePath
37
+ */
38
+ export function ensureDir(filePath) {
39
+ const dir = dirname(filePath)
40
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
41
+ }
42
+
43
+ /**
44
+ * 📖 Read and parse a JSON file. Returns `fallback` on any error.
45
+ * @param {string} filePath
46
+ * @param {*} [fallback=null]
47
+ * @returns {*}
48
+ */
49
+ export function readJson(filePath, fallback = null) {
50
+ if (!existsSync(filePath)) return fallback
51
+ try {
52
+ return JSON.parse(readFileSync(filePath, 'utf8'))
53
+ } catch {
54
+ return fallback
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 📖 Write JSON to file, creating parent directories as needed.
60
+ * @param {string} filePath
61
+ * @param {*} value
62
+ * @param {object} [options]
63
+ * @param {boolean} [options.backup=false] — Not implemented here; callers handle it
64
+ */
65
+ export function writeJson(filePath, value) {
66
+ ensureDir(filePath)
67
+ writeFileSync(filePath, JSON.stringify(value, null, 2))
68
+ }
69
+
70
+ /**
71
+ * 📖 Atomic JSON write: writes to a temp file, then renames over the target.
72
+ * Prevents partial writes from corrupting the file on crash.
73
+ * @param {string} path
74
+ * @param {*} data
75
+ * @param {number} [mode=0o600]
76
+ */
77
+ export function atomicWriteJson(path, data, mode = 0o600) {
78
+ const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`
79
+ writeFileSync(tempPath, JSON.stringify(data, null, 2), { mode })
80
+ renameSync(tempPath, path)
81
+ }
82
+
83
+ /**
84
+ * 📖 JSON.parse with fallback. Returns `fallback` on parse failure.
85
+ * @param {string} raw
86
+ * @param {*} [fallback=null]
87
+ * @returns {*}
88
+ */
89
+ export function safeJsonParse(raw, fallback = null) {
90
+ try {
91
+ return JSON.parse(raw)
92
+ } catch {
93
+ return fallback
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 📖 Mask an API key for display. Shows last 4 chars, rest as bullets.
99
+ * @param {string} key
100
+ * @returns {string}
101
+ */
102
+ /**
103
+ * 📖 Check if a provider supports routing (has chat/completions URL, not CLI-only).
104
+ * @param {string} providerKey
105
+ * @param {Record<string, {url?: string, cliOnly?: boolean}>} sources — provider catalog
106
+ * @returns {boolean}
107
+ */
108
+ export function isRouteableProvider(providerKey, sources) {
109
+ const source = sources[providerKey]
110
+ return Boolean(source?.url && !source.cliOnly && source.url.includes('/chat/completions'))
111
+ }
112
+
113
+ export function maskApiKey(key) {
114
+ if (!key || typeof key !== 'string') return ''
115
+ if (key.length <= 8) return '••••••••'
116
+ return '••••••••' + key.slice(-4)
117
+ }
@@ -37,11 +37,10 @@ import {
37
37
  saveConfig,
38
38
  } from './config.js'
39
39
  import { resolveCloudflareUrl } from './ping.js'
40
- import { ROUTER_PID_PATH } from './router-daemon.js'
40
+ import { ROUTER_PID_PATH, getRouterPidPath } from './router-daemon.js'
41
41
  import { existsSync, readFileSync } from 'node:fs'
42
-
43
- // 📖 Tier ordering best tiers first, used for scoring candidates.
44
- const TIER_ORDER = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
42
+ import { TIER_ORDER, parseSweToNum } from './utils.js'
43
+ import { isRouteableProvider } from './shared-helpers.js'
45
44
 
46
45
  // 📖 Numeric value per tier for composite scoring.
47
46
  const TIER_SCORES = {
@@ -69,14 +68,7 @@ const OPENROUTER_FREE_MODEL_IDS = new Set([
69
68
  'openrouter/owl-alpha',
70
69
  ])
71
70
 
72
- /**
73
- * Check whether a provider's catalog entry supports routing (has a
74
- * chat/completions URL and is not CLI-only).
75
- */
76
- function isRouteableProvider(providerKey) {
77
- const source = sources[providerKey]
78
- return Boolean(source?.url && !source.cliOnly && source.url.includes('/chat/completions'))
79
- }
71
+ // 📖 isRouteableProvider imported from shared-helpers.js (needs `sources` param)
80
72
 
81
73
  /**
82
74
  * Resolve the upstream URL for a provider, handling Cloudflare template substitution.
@@ -98,14 +90,7 @@ function isOpenRouterFreeModelId(modelId) {
98
90
  return String(modelId).endsWith(':free') || OPENROUTER_FREE_MODEL_IDS.has(String(modelId))
99
91
  }
100
92
 
101
- /**
102
- * Parse a SWE-bench percentage string like "49.2%" to a number.
103
- */
104
- function parseSwePercent(value) {
105
- if (typeof value !== 'string') return 0
106
- const numeric = parseFloat(value.replace('%', '').trim())
107
- return Number.isFinite(numeric) ? numeric : 0
108
- }
93
+ // 📖 parseSwePercent replaced by shared parseSweToNum (same logic)
109
94
 
110
95
  /**
111
96
  * Score a candidate model for ranking. Higher is better.
@@ -156,12 +141,12 @@ export function buildSyncCandidates(apiKeys, options = {}) {
156
141
 
157
142
  for (const [providerKey, sourceData] of Object.entries(sources)) {
158
143
  if (!apiKeys[providerKey]) continue
159
- if (!isRouteableProvider(providerKey)) continue
144
+ if (!isRouteableProvider(providerKey, sources)) continue
160
145
 
161
146
  for (const tuple of sourceData.models || []) {
162
147
  const [modelId, label = '', tier = '', swe = '0%'] = tuple
163
148
  if (typeof modelId !== 'string' || !modelId.trim()) continue
164
- const swePercent = parseSwePercent(swe)
149
+ const swePercent = parseSweToNum(swe)
165
150
  if (shouldSkipModel(providerKey, modelId, tier, swePercent, options)) continue
166
151
  const score = scoreCandidate(providerKey, modelId, label, tier, swePercent)
167
152
  candidates.push({
@@ -323,8 +308,10 @@ export async function probeModel(candidate, apiKey) {
323
308
  */
324
309
  function signalDaemonReload() {
325
310
  try {
326
- if (!existsSync(ROUTER_PID_PATH)) return false
327
- const pid = Number(readFileSync(ROUTER_PID_PATH, 'utf8').trim())
311
+ // 📖 Dynamic resolver so dev checkouts signal the dev daemon (FCM_DEV=1).
312
+ const pidPath = getRouterPidPath()
313
+ if (!existsSync(pidPath)) return false
314
+ const pid = Number(readFileSync(pidPath, 'utf8').trim())
328
315
  if (!Number.isFinite(pid) || pid <= 0) return false
329
316
  process.kill(pid, 'SIGHUP')
330
317
  return true
@@ -39,7 +39,7 @@
39
39
  import chalk from 'chalk'
40
40
  import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
41
41
  import { homedir } from 'os'
42
- import { dirname, join } from 'path'
42
+ import { join } from 'path'
43
43
  import { spawn, spawnSync } from 'child_process'
44
44
  import { sources } from '../../sources.js'
45
45
  import { PROVIDER_COLOR } from '../tui/render-table.js'
@@ -48,6 +48,7 @@ import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
48
48
  import { getToolMeta, TOOL_METADATA } from './tool-metadata.js'
49
49
  import { PROVIDER_METADATA } from './provider-metadata.js'
50
50
  import { resolveToolBinaryPath } from './tool-bootstrap.js'
51
+ import { ensureDir, readJson, writeJson } from './shared-helpers.js'
51
52
 
52
53
  const OPENAI_COMPAT_ENV_KEYS = [
53
54
  'OPENAI_API_KEY',
@@ -60,11 +61,6 @@ const OPENAI_COMPAT_ENV_KEYS = [
60
61
  ]
61
62
  const SANITIZED_TOOL_ENV_KEYS = [...OPENAI_COMPAT_ENV_KEYS]
62
63
 
63
- function ensureDir(filePath) {
64
- const dir = dirname(filePath)
65
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
66
- }
67
-
68
64
  // 📖 Parse a context window string (e.g. "128k", "1M", "32k") to token count number.
69
65
  function parseCtxToTokens(ctx) {
70
66
  if (!ctx || typeof ctx !== 'string') return null
@@ -104,19 +100,7 @@ function backupIfExists(filePath) {
104
100
  return backupPath
105
101
  }
106
102
 
107
- function readJson(filePath, fallback) {
108
- if (!existsSync(filePath)) return fallback
109
- try {
110
- return JSON.parse(readFileSync(filePath, 'utf8'))
111
- } catch {
112
- return fallback
113
- }
114
- }
115
-
116
- function writeJson(filePath, value) {
117
- ensureDir(filePath)
118
- writeFileSync(filePath, JSON.stringify(value, null, 2))
119
- }
103
+ // 📖 readJson/writeJson imported from shared-helpers.js
120
104
 
121
105
  function getProviderBaseUrl(providerKey) {
122
106
  const url = sources[providerKey]?.url
@@ -966,55 +950,8 @@ export async function startExternalTool(mode, model, config) {
966
950
  console.log(chalk.cyan(` ▶ Launching ${meta.label} with ${chalk.bold(model.label)}...`))
967
951
  printConfigArtifacts(meta.label, launchPlan.configArtifacts)
968
952
 
969
- if (mode === 'aider') {
970
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
971
- }
972
-
973
- if (mode === 'crush') {
974
- console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
975
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
976
- }
977
-
978
- if (mode === 'goose') {
979
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
980
- }
981
-
982
- if (mode === 'qwen') {
983
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
984
- }
985
-
986
- if (mode === 'openhands') {
987
- console.log(chalk.dim(` 📖 OpenHands launched with model: ${model.modelId}`))
988
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
989
- }
990
-
991
- if (mode === 'amp') {
992
- console.log(chalk.dim(` 📖 Amp config updated with model: ${model.modelId}`))
993
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
994
- }
995
-
996
- if (mode === 'pi') {
997
- // 📖 Pi supports --provider and --model flags for guaranteed auto-selection
998
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
999
- }
1000
-
1001
- if (mode === 'hermes') {
1002
- // 📖 Restart the Hermes gateway so the new model config takes effect immediately
1003
- restartHermesGateway()
1004
- console.log(chalk.dim(` 📖 Hermes Agent configured with model: ${model.modelId}`))
1005
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1006
- }
1007
-
1008
- if (mode === 'continue') {
1009
- console.log(chalk.dim(` 📖 Continue CLI configured with model: ${model.modelId}`))
1010
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1011
- }
1012
-
1013
- if (mode === 'cline') {
1014
- console.log(chalk.dim(` 📖 Cline configured with model: ${model.modelId}`))
1015
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1016
- }
1017
-
953
+ // 📖 Pre-launch hooks for tools that need special treatment
954
+ if (mode === 'hermes') restartHermesGateway()
1018
955
  if (mode === 'xcode') {
1019
956
  const xcodeUrl = launchPlan.baseUrl ? launchPlan.baseUrl.replace(/\/v1$/, '').replace(/\/v1\/chat\/completions$/, '') : ''
1020
957
  console.log(chalk.bold.cyan('\n 🛠️ Xcode Intelligence Setup Instructions:'))
@@ -1027,29 +964,24 @@ export async function startExternalTool(mode, model, config) {
1027
964
  console.log(chalk.dim(' Description: ') + chalk.green(`FCM - ${sources[model.providerKey]?.name || model.providerKey}`))
1028
965
  console.log(chalk.white(` 4. Click Add, then select `) + chalk.bold(model.modelId) + chalk.white(` from the list.\n`))
1029
966
  console.log(chalk.dim(` 📖 Attempting to launch Xcode...`))
1030
- return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
1031
967
  }
1032
-
1033
- if (mode === 'caveman') {
1034
- console.log(chalk.dim(` 📖 Launching Caveman Code...`))
1035
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1036
- }
1037
-
1038
- if (mode === 'jcode') {
1039
- console.log(chalk.dim(` 📖 Launching jcode...`))
1040
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1041
- }
1042
-
1043
- if (mode === 'copilot') {
1044
- console.log(chalk.dim(` 📖 Copilot CLI configured with model: ${model.modelId}`))
1045
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1046
- }
1047
-
1048
- if (mode === 'forgecode') {
1049
- console.log(chalk.dim(` 📖 ForgeCode configured with model: ${model.modelId}`))
1050
- return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
1051
- }
1052
-
1053
- console.log(chalk.red(` X Unsupported external tool mode: ${mode}`))
1054
- return 1
968
+ if (mode === 'crush') console.log(chalk.dim(' 📖 Crush will use the provider directly for this launch.'))
969
+
970
+ // 📖 Tool-specific info messages (only for modes that have no prepare-step message)
971
+ const infoMessages = {
972
+ openhands: ` 📖 OpenHands launched with model: ${model.modelId}`,
973
+ amp: ` 📖 Amp config updated with model: ${model.modelId}`,
974
+ hermes: ` 📖 Hermes Agent configured with model: ${model.modelId}`,
975
+ continue: ` 📖 Continue CLI configured with model: ${model.modelId}`,
976
+ cline: ` 📖 Cline configured with model: ${model.modelId}`,
977
+ caveman: ' 📖 Launching Caveman Code...',
978
+ jcode: ' 📖 Launching jcode...',
979
+ copilot: ` 📖 Copilot CLI configured with model: ${model.modelId}`,
980
+ forgecode: ` 📖 ForgeCode configured with model: ${model.modelId}`,
981
+ }
982
+ if (infoMessages[mode]) console.log(chalk.dim(infoMessages[mode]))
983
+
984
+ // 📖 xcode uses raw command ("open"), everything else resolves via tool-bootstrap
985
+ const command = mode === 'xcode' ? launchPlan.command : resolveLaunchCommand(mode, launchPlan.command)
986
+ return spawnCommand(command, launchPlan.args, launchPlan.env)
1055
987
  }