free-coding-models 0.5.86 → 0.5.88

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 (165) hide show
  1. package/README.md +109 -888
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/changelog/v0.5.88.md +16 -0
  5. package/package.json +13 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/security.js +170 -24
  26. package/src/core/shared-helpers.js +12 -1
  27. package/src/core/shell-env.js +3 -1
  28. package/src/core/telemetry.js +18 -5
  29. package/src/core/tool-launchers.js +9 -6
  30. package/src/core/updater.js +28 -2
  31. package/src/core/utils.js +173 -71
  32. package/src/tui/app.js +38 -14
  33. package/src/tui/command-palette.js +22 -2
  34. package/src/tui/key-handler.js +123 -179
  35. package/src/tui/overlays.js +21 -71
  36. package/src/tui/render-helpers.js +62 -4
  37. package/src/tui/render-table.js +34 -3
  38. package/src/tui/theme.js +36 -5
  39. package/src/tui/tui-filters.js +28 -8
  40. package/src/tui/tui-state.js +22 -6
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/assets/index-DFg1h0Nd.js +44 -0
  43. package/web/dist/index.html +5 -9
  44. package/web/index.html +3 -7
  45. package/web/server.js +97 -23
  46. package/web/vite.config.js +8 -0
  47. package/web/app.legacy.js +0 -900
  48. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  49. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  50. package/web/public/favicon.ico +0 -0
  51. package/web/public/favicons/apple-touch-icon.png +0 -0
  52. package/web/public/favicons/browserconfig.xml +0 -12
  53. package/web/public/favicons/favicon-16x16.png +0 -0
  54. package/web/public/favicons/favicon-192x192.png +0 -0
  55. package/web/public/favicons/favicon-32x32.png +0 -0
  56. package/web/public/favicons/favicon-48x48.png +0 -0
  57. package/web/public/favicons/favicon-512x512.png +0 -0
  58. package/web/public/favicons/favicon-96x96.png +0 -0
  59. package/web/public/favicons/favicon.ico +0 -0
  60. package/web/public/favicons/mstile-150x150.png +0 -0
  61. package/web/public/favicons/mstile-310x150.png +0 -0
  62. package/web/public/favicons/mstile-310x310.png +0 -0
  63. package/web/public/favicons/mstile-512x512.png +0 -0
  64. package/web/public/favicons/mstile-70x70.png +0 -0
  65. package/web/public/favicons/site.webmanifest +0 -25
  66. package/web/src/App.jsx +0 -521
  67. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  68. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  69. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  70. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  71. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  72. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  73. package/web/src/components/atoms/HealthCell.jsx +0 -143
  74. package/web/src/components/atoms/HealthCell.module.css +0 -60
  75. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  76. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  77. package/web/src/components/atoms/MoodCell.jsx +0 -25
  78. package/web/src/components/atoms/MoodCell.module.css +0 -6
  79. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  80. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  81. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  82. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  83. package/web/src/components/atoms/RankCell.jsx +0 -9
  84. package/web/src/components/atoms/RankCell.module.css +0 -9
  85. package/web/src/components/atoms/Sparkline.jsx +0 -63
  86. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  87. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  88. package/web/src/components/atoms/StatusDot.jsx +0 -13
  89. package/web/src/components/atoms/StatusDot.module.css +0 -29
  90. package/web/src/components/atoms/TPSCell.jsx +0 -36
  91. package/web/src/components/atoms/TPSCell.module.css +0 -44
  92. package/web/src/components/atoms/TierBadge.jsx +0 -10
  93. package/web/src/components/atoms/TierBadge.module.css +0 -29
  94. package/web/src/components/atoms/Toast.jsx +0 -25
  95. package/web/src/components/atoms/Toast.module.css +0 -41
  96. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  97. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  98. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  99. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  100. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  101. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  102. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  103. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  104. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  105. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  106. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  107. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  108. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  109. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  110. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  111. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  112. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  113. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  114. package/web/src/components/help/HelpView.jsx +0 -201
  115. package/web/src/components/help/HelpView.module.css +0 -156
  116. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  117. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  118. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  119. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  120. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  121. package/web/src/components/launch/LaunchButton.jsx +0 -30
  122. package/web/src/components/launch/LaunchButton.module.css +0 -35
  123. package/web/src/components/launch/LaunchModal.module.css +0 -125
  124. package/web/src/components/layout/Footer.jsx +0 -19
  125. package/web/src/components/layout/Footer.module.css +0 -10
  126. package/web/src/components/layout/Header.jsx +0 -265
  127. package/web/src/components/layout/Header.module.css +0 -357
  128. package/web/src/components/palette/CommandPalette.jsx +0 -301
  129. package/web/src/components/palette/CommandPalette.module.css +0 -121
  130. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  131. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  132. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  133. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  134. package/web/src/components/recommend/RecommendView.jsx +0 -121
  135. package/web/src/components/recommend/RecommendView.module.css +0 -57
  136. package/web/src/components/router/RouterView.jsx +0 -1212
  137. package/web/src/components/router/RouterView.module.css +0 -1289
  138. package/web/src/components/settings/SettingsView.jsx +0 -555
  139. package/web/src/components/settings/SettingsView.module.css +0 -550
  140. package/web/src/components/tools/ToolPicker.jsx +0 -86
  141. package/web/src/components/tools/ToolPicker.module.css +0 -97
  142. package/web/src/components/update/UpdateChip.jsx +0 -104
  143. package/web/src/components/update/UpdateChip.module.css +0 -146
  144. package/web/src/global.css +0 -298
  145. package/web/src/hooks/urlState.constants.js +0 -28
  146. package/web/src/hooks/useChangelog.js +0 -51
  147. package/web/src/hooks/useColumnSizing.js +0 -211
  148. package/web/src/hooks/useFavorites.js +0 -150
  149. package/web/src/hooks/useFilter.js +0 -310
  150. package/web/src/hooks/useInstalledModels.js +0 -42
  151. package/web/src/hooks/useRecommend.js +0 -67
  152. package/web/src/hooks/useRouterDashboard.js +0 -133
  153. package/web/src/hooks/useSSE.js +0 -73
  154. package/web/src/hooks/useSocket.js +0 -203
  155. package/web/src/hooks/useTheme.js +0 -83
  156. package/web/src/hooks/useTokenUsage.js +0 -103
  157. package/web/src/hooks/useToolMode.js +0 -77
  158. package/web/src/hooks/useUpdateChecker.js +0 -91
  159. package/web/src/hooks/useUrlState.js +0 -170
  160. package/web/src/main.jsx +0 -15
  161. package/web/src/utils/download.js +0 -15
  162. package/web/src/utils/format.js +0 -42
  163. package/web/src/utils/m3.js +0 -57
  164. package/web/src/utils/ranks.js +0 -37
  165. package/web/styles.legacy.css +0 -963
package/src/core/utils.js CHANGED
@@ -140,8 +140,13 @@ export const NEW_MODELS = new Set([
140
140
  ]);
141
141
 
142
142
  export const getVerdict = (r) => {
143
+ // 📖 401/403 rows can still carry fast measurable pings, but calling them
144
+ // 📖 "Perfect" let --fiable / findBestModel recommend a model that cannot serve
145
+ // 📖 a single authenticated request. Auth-failing rows always get the
146
+ // 📖 least-trustworthy health label instead.
147
+ if (r.status === 'auth_error' || r.status === 'noauth') return 'Not Active'
143
148
  const avg = getAvg(r)
144
- const wasUpBefore = r.pings.length > 0 && r.pings.some(p => p.code === '200')
149
+ const wasUpBefore = (r.pings || []).length > 0 && r.pings.some(p => p.code === '200')
145
150
 
146
151
  if (r.httpCode === '429') return 'Overloaded'
147
152
  if ((r.status === 'timeout' || r.status === 'down') && wasUpBefore) return 'Unstable'
@@ -200,9 +205,10 @@ export const getVerdict = (r) => {
200
205
  // 📖 Displayed as "Up%" column in the TUI — e.g., "85%" means 85% of pings got HTTP 200.
201
206
  // 📖 This metric is useful for identifying models that are technically "up" but flaky.
202
207
  export const getUptime = (r) => {
203
- if (r.pings.length === 0) return 0
204
- const successful = r.pings.filter(p => p.code === '200').length
205
- return Math.round((successful / r.pings.length) * 100)
208
+ const pings = r.pings || []
209
+ if (pings.length === 0) return 0
210
+ const successful = pings.filter(p => p.code === '200').length
211
+ return Math.round((successful / pings.length) * 100)
206
212
  }
207
213
 
208
214
  // 📖 getP95: Calculate the 95th percentile latency from measurable pings (HTTP 200/401).
@@ -303,11 +309,13 @@ export const sortResults = (results, sortColumn, sortDirection, { benchmarkResul
303
309
  cmp = TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)
304
310
  break
305
311
  case 'origin':
306
- // 📖 Sort by providerKey (or fallback to modelId prefix) for multi-provider support
307
- cmp = (a.providerKey ?? 'nvidia').localeCompare(b.providerKey ?? 'nvidia')
312
+ // 📖 Sort by providerKey (or fallback to modelId prefix) for multi-provider support.
313
+ // 📖 Explicit 'en' locale: the default is ICU-dependent, so row order would
314
+ // 📖 otherwise vary between machines.
315
+ cmp = (a.providerKey ?? 'nvidia').localeCompare(b.providerKey ?? 'nvidia', 'en')
308
316
  break
309
317
  case 'model':
310
- cmp = a.label.localeCompare(b.label)
318
+ cmp = a.label.localeCompare(b.label, 'en')
311
319
  break
312
320
  case 'ping': {
313
321
  // 📖 Sort by LAST ping only — gives a real-time "right now" snapshot
@@ -333,7 +341,7 @@ export const sortResults = (results, sortColumn, sortDirection, { benchmarkResul
333
341
  break
334
342
  }
335
343
  case 'condition':
336
- cmp = a.status.localeCompare(b.status)
344
+ cmp = a.status.localeCompare(b.status, 'en')
337
345
  break
338
346
  case 'verdict': {
339
347
  // 📖 Sort by verdict order — "Perfect" first, "Pending" last
@@ -391,6 +399,10 @@ export const sortResults = (results, sortColumn, sortDirection, { benchmarkResul
391
399
  break
392
400
  }
393
401
 
402
+ // 📖 Non-finite differences (e.g. Infinity - Infinity) collapse to 0: a NaN
403
+ // 📖 comparator result makes sort order machine-dependent and unstable.
404
+ if (!Number.isFinite(cmp)) cmp = 0
405
+
394
406
  // 📖 Flip comparison for descending order
395
407
  return sortDirection === 'asc' ? cmp : -cmp
396
408
  })
@@ -485,7 +497,7 @@ export function findBestModel(results) {
485
497
  // --daemon-status, --no-telemetry, --json, --help/-h (case-insensitive)
486
498
  // --playground / playground subcommand (open the in-TUI chat playground)
487
499
  // --fix-permissions / --yes / -y (auto-fix config permissions without prompting)
488
- // - Value flag: --tier <letter> (the next non-flag arg is the tier value)
500
+ // - Value flag: --tier <letter> (also accepts the --tier=S equals form)
489
501
  // - Probe-cache flags (t1):
490
502
  // --reprobe / --no-cache (boolean) — force-rebuild the probe cache this run
491
503
  // --probe-ttl <ms> (value) — override the 24h default TTL
@@ -500,62 +512,56 @@ export function findBestModel(results) {
500
512
  // reprobeMode, probeTtlMs, showBrokenMode }
501
513
  //
502
514
  // 📖 Note: apiKey may be null here — the main CLI falls back to env vars and saved config.
515
+
516
+ // 📖 findValueFlag: collect every occurrence of a value flag, in both "--flag value"
517
+ // 📖 and "--flag=value" forms. Equals form used to be silently ignored (the lookup
518
+ // 📖 only matched the bare flag), and repeated flags used to leak their extra values
519
+ // 📖 into the positional API-key slot because only the first index was marked used.
520
+ // 📖 Returns { indices, values }: indices point at the VALUE args (for the equals
521
+ // 📖 form, at the "--flag=value" arg itself, which is already treated as a flag).
522
+ function findValueFlag(args, flag) {
523
+ const indices = []
524
+ const values = []
525
+ for (const [i, arg] of args.entries()) {
526
+ const lower = arg.toLowerCase()
527
+ if (lower.startsWith(`${flag}=`)) {
528
+ indices.push(i)
529
+ values.push(arg.slice(flag.length + 1))
530
+ } else if (lower === flag && args[i + 1] && !args[i + 1].startsWith('--')) {
531
+ indices.push(i + 1)
532
+ values.push(args[i + 1])
533
+ }
534
+ }
535
+ return { indices, values }
536
+ }
537
+
538
+ // 📖 First usable value of a flag, or null when the flag was not passed (or was
539
+ // 📖 passed with an empty value like "--tier=").
540
+ function firstFlagValue(found) {
541
+ return found.values.length > 0 && found.values[0] !== '' ? found.values[0] : null
542
+ }
543
+
503
544
  export function parseArgs(argv) {
504
545
  const args = argv.slice(2)
505
546
  let apiKey = null
506
547
  const flags = []
507
548
 
508
- // 📖 Determine which arg indices are consumed by --tier so we skip them
509
- const tierIdx = args.findIndex(a => a.toLowerCase() === '--tier')
510
- const tierValueIdx = (tierIdx !== -1 && args[tierIdx + 1] && !args[tierIdx + 1].startsWith('--'))
511
- ? tierIdx + 1
512
- : -1
513
-
514
- // New value flags
515
- const sortIdx = args.findIndex(a => a.toLowerCase() === '--sort')
516
- const sortValueIdx = (sortIdx !== -1 && args[sortIdx + 1] && !args[sortIdx + 1].startsWith('--'))
517
- ? sortIdx + 1
518
- : -1
519
-
520
- const originIdx = args.findIndex(a => a.toLowerCase() === '--origin')
521
- const originValueIdx = (originIdx !== -1 && args[originIdx + 1] && !args[originIdx + 1].startsWith('--'))
522
- ? originIdx + 1
523
- : -1
524
-
525
- const pingIntervalIdx = args.findIndex(a => a.toLowerCase() === '--ping-interval')
526
- const pingIntervalValueIdx = (pingIntervalIdx !== -1 && args[pingIntervalIdx + 1] && !args[pingIntervalIdx + 1].startsWith('--'))
527
- ? pingIntervalIdx + 1
528
- : -1
529
-
530
- // 📖 --probe-ttl <ms> — override the 24h probe-cache TTL (power users / debugging)
531
- const probeTtlIdx = args.findIndex(a => a.toLowerCase() === '--probe-ttl')
532
- const probeTtlValueIdx = (probeTtlIdx !== -1 && args[probeTtlIdx + 1] && !args[probeTtlIdx + 1].startsWith('--'))
533
- ? probeTtlIdx + 1
534
- : -1
535
-
536
- // 📖 --sync-set [name] — auto-discover and live-probe models into a named router set
537
- const syncSetIdx = args.findIndex(a => a.toLowerCase() === '--sync-set')
538
- const syncSetValueIdx = (syncSetIdx !== -1 && args[syncSetIdx + 1] && !args[syncSetIdx + 1].startsWith('--'))
539
- ? syncSetIdx + 1
540
- : -1
541
-
542
- // 📖 --config-dir <dir> — override where FCM's config files (config.json + backups/)
543
- // 📖 live. Any directory works, e.g. --config-dir ~/.config/free-coding-models for the
544
- // 📖 XDG layout. The bin entry point persists this as FCM_CONFIG_DIR before imports.
545
- const configDirIdx = args.findIndex(a => a.toLowerCase() === '--config-dir')
546
- const configDirValueIdx = (configDirIdx !== -1 && args[configDirIdx + 1] && !args[configDirIdx + 1].startsWith('--'))
547
- ? configDirIdx + 1
548
- : -1
549
+ // 📖 Resolve all value flags up front so every occurrence (space or equals form)
550
+ // 📖 can be marked consumed before the positional API-key scan runs.
551
+ const tierFlag = findValueFlag(args, '--tier')
552
+ const sortFlag = findValueFlag(args, '--sort')
553
+ const originFlag = findValueFlag(args, '--origin')
554
+ const pingIntervalFlag = findValueFlag(args, '--ping-interval')
555
+ const probeTtlFlag = findValueFlag(args, '--probe-ttl')
556
+ const syncSetFlag = findValueFlag(args, '--sync-set')
557
+ const configDirFlag = findValueFlag(args, '--config-dir')
558
+ const driftThresholdFlag = findValueFlag(args, '--drift-threshold')
549
559
 
550
560
  // 📖 Set of arg indices that are values for flags (not API keys)
551
561
  const skipIndices = new Set()
552
- if (tierValueIdx !== -1) skipIndices.add(tierValueIdx)
553
- if (sortValueIdx !== -1) skipIndices.add(sortValueIdx)
554
- if (originValueIdx !== -1) skipIndices.add(originValueIdx)
555
- if (pingIntervalValueIdx !== -1) skipIndices.add(pingIntervalValueIdx)
556
- if (syncSetValueIdx !== -1) skipIndices.add(syncSetValueIdx)
557
- if (probeTtlValueIdx !== -1) skipIndices.add(probeTtlValueIdx)
558
- if (configDirValueIdx !== -1) skipIndices.add(configDirValueIdx)
562
+ for (const found of [tierFlag, sortFlag, originFlag, pingIntervalFlag, probeTtlFlag, syncSetFlag, configDirFlag, driftThresholdFlag]) {
563
+ for (const idx of found.indices) skipIndices.add(idx)
564
+ }
559
565
 
560
566
  for (const [i, arg] of args.entries()) {
561
567
  // 📖 -y is a boolean flag (security auto-fix), never an API key
@@ -610,7 +616,7 @@ export function parseArgs(argv) {
610
616
 
611
617
  // 📖 --sync-set [name] — auto-discover and populate a router set with best available models
612
618
  const syncSetMode = flags.includes('--sync-set')
613
- const syncSetName = syncSetValueIdx !== -1 ? args[syncSetValueIdx] : null
619
+ const syncSetName = firstFlagValue(syncSetFlag)
614
620
 
615
621
  // 📖 --web / --gui / web subcommand — launch the web dashboard instead of the TUI
616
622
  const webMode = flags.includes('--web') || flags.includes('--gui') || args[0] === 'web'
@@ -626,10 +632,11 @@ export function parseArgs(argv) {
626
632
  const hideUnconfigured = flags.includes('--hide-unconfigured')
627
633
  const showUnconfigured = flags.includes('--show-unconfigured')
628
634
 
629
- let tierFilter = tierValueIdx !== -1 ? args[tierValueIdx].toUpperCase() : null
630
- let sortColumn = sortValueIdx !== -1 ? args[sortValueIdx].toLowerCase() : null
631
- let originFilter = originValueIdx !== -1 ? args[originValueIdx] : null
632
- let pingInterval = pingIntervalValueIdx !== -1 ? parseInt(args[pingIntervalValueIdx], 10) : null
635
+ let tierFilter = firstFlagValue(tierFlag)?.toUpperCase() ?? null
636
+ let sortColumn = firstFlagValue(sortFlag)?.toLowerCase() ?? null
637
+ let originFilter = firstFlagValue(originFlag)
638
+ let pingIntervalRaw = firstFlagValue(pingIntervalFlag)
639
+ let pingInterval = pingIntervalRaw !== null ? parseInt(pingIntervalRaw, 10) : null
633
640
  let sortDirection = sortDesc ? 'desc' : (sortAscFlag ? 'asc' : null)
634
641
 
635
642
  // 📖 Profile system removed - API keys now persist permanently across all sessions
@@ -645,19 +652,14 @@ export function parseArgs(argv) {
645
652
  // 📖 --probe-ttl overrides the 24h default; --show-broken un-hides broken models for this run.
646
653
  const reprobeMode = flags.includes('--reprobe') || flags.includes('--no-cache')
647
654
  const showBrokenMode = flags.includes('--show-broken')
648
- const probeTtlRaw = probeTtlValueIdx !== -1 ? args[probeTtlValueIdx] : null
655
+ const probeTtlRaw = firstFlagValue(probeTtlFlag)
649
656
  const probeTtlMs = probeTtlRaw !== null ? parseInt(probeTtlRaw, 10) : null
650
657
 
651
658
  // 📖 Drift detection flags (t5):
652
659
  // 📖 --check-drift (boolean) — print a drift report vs models.dev, exit non-zero on mismatch.
653
660
  // 📖 --drift-threshold <N> (value) — only fail when N+ mismatches are found.
654
661
  const checkDriftMode = flags.includes('--check-drift')
655
- const driftThresholdIdx = args.findIndex(a => a.toLowerCase() === '--drift-threshold')
656
- const driftThresholdValueIdx = (driftThresholdIdx !== -1 && args[driftThresholdIdx + 1] && !args[driftThresholdIdx + 1].startsWith('--'))
657
- ? driftThresholdIdx + 1
658
- : -1
659
- if (driftThresholdValueIdx !== -1) skipIndices.add(driftThresholdValueIdx)
660
- const driftThresholdRaw = driftThresholdValueIdx !== -1 ? args[driftThresholdValueIdx] : null
662
+ const driftThresholdRaw = firstFlagValue(driftThresholdFlag)
661
663
  const driftThreshold = driftThresholdRaw !== null ? parseInt(driftThresholdRaw, 10) : null
662
664
 
663
665
  return {
@@ -716,7 +718,7 @@ export function parseArgs(argv) {
716
718
  // 📖 Runtime telemetry flag (t3) — see src/core/runtime-telemetry.js
717
719
  clearRuntimeMode,
718
720
  // 📖 Config location flag — see src/core/config.js getConfigDir()
719
- configDir: configDirValueIdx !== -1 ? args[configDirValueIdx] : null,
721
+ configDir: firstFlagValue(configDirFlag),
720
722
  // 📖 Security auto-fix flag - see src/core/security.js checkConfigSecurity()
721
723
  fixPermissionsMode,
722
724
  }
@@ -748,6 +750,103 @@ export function resolveSecurityAction({ configExists, isSecure, autoFixRequested
748
750
  return 'prompt'
749
751
  }
750
752
 
753
+ // 📖 parseIcaclsOutput: PURE parser for Windows `icacls <file>` output, used by
754
+ // 📖 the config security check to get a REAL answer about NTFS access.
755
+ // 📖 WHY: on win32 Node reports POSIX-style modes as 0666 (writable) or 0444
756
+ // 📖 (read-only) only - 0600 is unreachable - so the old `(mode & 0o777) === 0o600`
757
+ // 📖 test was always false and the warning re-fired on every launch (issue #173
758
+ // 📖 follow-up from rutexd). Only the ACL tells the truth on NTFS.
759
+ // 📖
760
+ // 📖 Sample output we must handle (ACEs, one per line, path on the first line):
761
+ // C:\Users\rutex\.free-coding-models.json NT AUTHORITY\SYSTEM:(I)(F)
762
+ // BUILTIN\Administrators:(I)(F)
763
+ // DESKTOP-XYZ\rutex:(F)
764
+ // Successfully processed 1 files; Failed processing 0 files
765
+ // 📖 `(I)` marks an inherited ACE, so "inheritance disabled" = no `(I)` anywhere.
766
+ // 📖 Locale-proof whitelists: names are machine-localized, so we whitelist by
767
+ // 📖 substring (SYSTEM, Administra* covers Administrators/Administrateurs) and
768
+ // 📖 by well-known SID (S-1-5-18 = SYSTEM, S-1-5-32-544 = Administrators), plus
769
+ // 📖 the current user (case-insensitive, domain-qualified or bare).
770
+ // 📖
771
+ // 📖 Params:
772
+ // output - raw stdout of `icacls <path>` (string)
773
+ // userName - current user name (string), e.g. "rutex"
774
+ // filePath - the exact path passed to icacls (optional but STRONGLY
775
+ // recommended: icacls echoes it on the first line, glued to the
776
+ // first ACE, and paths/account names can both contain spaces)
777
+ // 📖 Returns { inheritanceEnabled, othersHaveAccess, ownerHasAccess, grants }
778
+ // grants = [{ name, flags }] for every ACE line; empty when output unparseable.
779
+ export function parseIcaclsOutput({ output, userName, filePath = '' }) {
780
+ const text = String(output ?? '')
781
+ const user = String(userName ?? '').trim().toLowerCase()
782
+ const knownPath = String(filePath ?? '')
783
+ const lines = text.split(/\r?\n/)
784
+
785
+ // 📖 The rights/flags groups always end the line: "(I)(F)", "(F)", "(R,W)"...
786
+ const aceTail = /((?:\([^)]*\)\s*)+)$/
787
+ const grants = []
788
+ for (const rawLine of lines) {
789
+ const tail = aceTail.exec(rawLine)
790
+ if (!tail) continue
791
+
792
+ let before = rawLine.slice(0, tail.index).trim()
793
+ if (before === '') continue
794
+ // 📖 Drop the colon that separates the account name from its flags.
795
+ before = before.replace(/:$/, '').trim()
796
+ // 📖 First ACE shares its line with the echoed path: strip the path we
797
+ // 📖 passed to icacls. Without a known path, keep only the segment after
798
+ // 📖 the last whitespace (exact for simple names; callers should pass
799
+ // 📖 filePath when the path or account name contains spaces).
800
+ if (knownPath && before.startsWith(knownPath)) {
801
+ before = before.slice(knownPath.length).trim()
802
+ } else if (knownPath === '' && /\s/.test(before)) {
803
+ before = before.slice(before.lastIndexOf(' ') + 1).trim()
804
+ }
805
+ if (before === '') continue
806
+ grants.push({ name: before, flags: tail[1].replace(/\s+/g, '') })
807
+ }
808
+
809
+ const isTrusted = (name) => {
810
+ const n = name.toLowerCase()
811
+ if (user !== '' && (n === user || n.endsWith('\\' + user))) return true
812
+ if (n.includes('system') || n.includes('s-1-5-18')) return true
813
+ if (n.includes('administra') || n.includes('s-1-5-32-544')) return true
814
+ return false
815
+ }
816
+
817
+ const inheritanceEnabled = grants.some((g) => g.flags.includes('I'))
818
+ const others = grants.filter((g) => !isTrusted(g.name)).map((g) => g.name)
819
+ const ownerHasAccess = grants.some((g) => {
820
+ const n = g.name.toLowerCase()
821
+ return user !== '' && (n === user || n.endsWith('\\' + user))
822
+ })
823
+
824
+ return {
825
+ inheritanceEnabled,
826
+ othersHaveAccess: others.length > 0,
827
+ ownerHasAccess,
828
+ grants,
829
+ otherNames: others,
830
+ }
831
+ }
832
+
833
+ // 📖 shouldSkipSecurityWarn: PURE anti-nag gate for the config security warning.
834
+ // 📖 WHY: when the permission fix cannot be applied or verified (rare: icacls
835
+ // 📖 missing on Windows, chmod failing on POSIX), the insecure state persists and
836
+ // 📖 the warning used to re-fire on every single launch. Once we warned and the
837
+ // 📖 fix did not stick, stay quiet for `intervalDays` (default 30) instead of
838
+ // 📖 nagging daily. `--fix-permissions` bypasses the gate in the caller.
839
+ // 📖 Params: ackedAt - last warning timestamp (ms epoch or ISO string or null)
840
+ // now - current time in ms epoch; intervalDays - quiet window length
841
+ // 📖 Returns true only when ackedAt parses AND is less than intervalDays old.
842
+ export function shouldSkipSecurityWarn({ ackedAt, now = Date.now(), intervalDays = 30 }) {
843
+ if (ackedAt === null || ackedAt === undefined || ackedAt === '') return false
844
+ const then = typeof ackedAt === 'number' ? ackedAt : Date.parse(String(ackedAt))
845
+ if (!Number.isFinite(then)) return false
846
+ const intervalMs = Math.max(0, intervalDays) * 24 * 60 * 60 * 1000
847
+ return now - then < intervalMs && now >= then
848
+ }
849
+
751
850
  // 📖 detectTerminalCapabilities: PURE terminal capability probe for TUI overlays.
752
851
  // 📖 WHY: basic server consoles (IPMI/KVM viewers, ASPEED framebuffer, serial
753
852
  // 📖 terminals) often run 80x24 or smaller with no or limited color support, and
@@ -1031,6 +1130,9 @@ export function getVersionStatusInfo(updateState, latestVersion, startupLatestVe
1031
1130
  * @returns {string} JSON string of formatted results
1032
1131
  */
1033
1132
  export function formatResultsAsJSON(results, sortBy = 'avg', limit = 0) {
1133
+ // 📖 Only slice for a usable positive limit: a negative limit used to drop the
1134
+ // 📖 last row(s) via slice(0, -1), and 0/undefined means "all results".
1135
+ const effectiveLimit = Number.isInteger(limit) && limit > 0 ? limit : undefined
1034
1136
  const formatted = results
1035
1137
  .map((r, idx) => ({
1036
1138
  rank: r.idx || idx + 1,
@@ -1050,7 +1152,7 @@ export function formatResultsAsJSON(results, sortBy = 'avg', limit = 0) {
1050
1152
  status: r.status || null,
1051
1153
  httpCode: r.httpCode || null
1052
1154
  }))
1053
- .slice(0, limit || undefined)
1155
+ .slice(0, effectiveLimit)
1054
1156
 
1055
1157
  return JSON.stringify(formatted, null, 2)
1056
1158
  }
package/src/tui/app.js CHANGED
@@ -93,7 +93,7 @@ import chalk from 'chalk'
93
93
  import { createRequire } from 'module'
94
94
  import { randomUUID } from 'crypto'
95
95
  import { MODELS, sources } from '../../sources.js'
96
- import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, findBestModel, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatCtxWindow, labelFromId, formatResultsAsJSON } from '../core/utils.js'
96
+ import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatResultsAsJSON } from '../core/utils.js'
97
97
  import { loadConfig, saveConfig, getApiKey, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, persistApiKeysForProvider } from '../core/config.js'
98
98
  import { buildMergedModels } from '../core/model-merger.js'
99
99
  import { loadOpenCodeConfig, saveOpenCodeConfig } from '../core/opencode-config.js'
@@ -101,7 +101,7 @@ import { usageForRow as _usageForRow } from '../core/usage-reader.js'
101
101
  import { buildProviderModelTokenKey, loadTokenUsageByProviderModel } from '../core/token-usage-reader.js'
102
102
  import { parseOpenRouterResponse, fetchProviderQuota as _fetchProviderQuotaFromModule, getAllQuotas as _getAllPassiveQuotasFromModule } from '../core/provider-quota-fetchers.js'
103
103
  import { isKnownQuotaTelemetry } from '../core/quota-capabilities.js'
104
- import { ALT_ENTER, ALT_LEAVE, ALT_HOME, PING_TIMEOUT, PING_INTERVAL, FPS, COL_MODEL, COL_MS, CELL_W, FRAMES, TIER_CYCLE, VERDICT_CYCLE, HEALTH_CYCLE, SETTINGS_OVERLAY_BG, HELP_OVERLAY_BG, RECOMMEND_OVERLAY_BG, OVERLAY_PANEL_WIDTH, TABLE_HEADER_LINES, TABLE_FOOTER_LINES, TABLE_FIXED_LINES, WIDTH_WARNING_MIN_COLS, msCell, spinCell } from '../core/constants.js'
104
+ import { ALT_ENTER, ALT_LEAVE, ALT_HOME, PING_TIMEOUT, PING_INTERVAL, FPS, FRAMES, TIER_CYCLE, VERDICT_CYCLE, HEALTH_CYCLE, SETTINGS_OVERLAY_BG, HELP_OVERLAY_BG, RECOMMEND_OVERLAY_BG, OVERLAY_PANEL_WIDTH, TABLE_HEADER_LINES, TABLE_FOOTER_LINES, TABLE_FIXED_LINES, WIDTH_WARNING_MIN_COLS } from '../core/constants.js'
105
105
  import { TIER_COLOR } from './tier-colors.js'
106
106
  import { resolveCloudflareUrl, buildPingRequest, ping, extractQuotaPercent, getProviderQuotaPercentCached, usagePlaceholderForProvider } from '../core/ping.js'
107
107
  import { runFiableMode, filterByTierOrExit, fetchOpenRouterFreeModels } from '../core/analysis.js'
@@ -109,12 +109,12 @@ import { PROVIDER_METADATA, ENV_VAR_NAMES, isWindows, isMac } from '../core/prov
109
109
  import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig, getTelemetryDistinctId, getTelemetrySystem, getTelemetryTerminal, isTelemetryEnabled, sendUsageTelemetry } from '../core/telemetry.js'
110
110
  import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel, reorderFavorite, pruneOrphanedFavorites } from '../core/favorites.js'
111
111
  import { checkForUpdateDetailed, checkForUpdate, runUpdate, fetchLastReleaseDate } from '../core/updater.js'
112
- import { createTuiState, PING_MODE_INTERVALS, PING_MODE_CYCLE, SPEED_MODE_DURATION_MS, IDLE_SLOW_AFTER_MS, intervalToPingMode } from './tui-state.js'
112
+ import { createTuiState, PING_MODE_INTERVALS, PING_MODE_CYCLE, SPEED_MODE_DURATION_MS, IDLE_SLOW_AFTER_MS, PING_HISTORY_CAP, intervalToPingMode } from './tui-state.js'
113
113
  import { createPingLoop } from '../core/ping-loop.js'
114
114
  import { createTuiFilters } from './tui-filters.js'
115
115
  import { promptApiKey } from '../core/setup.js'
116
116
  import { syncShellEnv, ensureShellRcSource, removeShellEnv } from '../core/shell-env.js'
117
- import { stripAnsi, maskApiKey, displayWidth, padEndDisplay, tintOverlayLines, keepOverlayTargetVisible, sliceOverlayLines, calculateViewport, sortResultsWithPinnedFavorites, adjustScrollOffset } from './render-helpers.js'
117
+ import { displayWidth, padEndDisplay, tintOverlayLines, keepOverlayTargetVisible, sliceOverlayLines, sortResultsWithPinnedFavorites, adjustScrollOffset, runWithConcurrency } from './render-helpers.js'
118
118
  import { renderTable, PROVIDER_COLOR } from './render-table.js'
119
119
  import { setOpenCodeModelData, startOpenCode, startOpenCodeDesktop, startOpenCodeWeb } from '../core/opencode.js'
120
120
  import { startKilo } from '../core/kilo.js'
@@ -478,6 +478,12 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
478
478
  let ticker = null
479
479
  let onKeyPress = null
480
480
  let onMouseData = null // 📖 Mouse data listener — set after createMouseEventHandler
481
+ // 📖 The variables above hold raw handler functions, but what actually gets
482
+ // 📖 registered on process.stdin are the WRAPPERS below. stopUi used to remove
483
+ // 📖 the raw functions (a silent no-op) - it now removes these tracked
484
+ // 📖 wrappers so launch/update paths really detach the listeners.
485
+ let keypressWrapper = null
486
+ let mouseDataWrapper = null
481
487
  let pingModel = null
482
488
 
483
489
  // 📖 scheduleNextPing: wrapper that defers to the factory version, passing the current runPingCycle.
@@ -505,7 +511,9 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
505
511
  r.verdict = cachedModel.verdict
506
512
  r.status = cachedModel.status
507
513
  r.httpCode = cachedModel.httpCode
508
- r.pings = cachedModel.pings || []
514
+ // 📖 Trim restored history to PING_HISTORY_CAP so an oversized cache
515
+ // 📖 file cannot instantly reintroduce the unbounded-growth problem.
516
+ r.pings = (cachedModel.pings || []).slice(-PING_HISTORY_CAP)
509
517
  }
510
518
  }
511
519
  }
@@ -621,6 +629,13 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
621
629
  }
622
630
 
623
631
  r.pings.push({ ms, code })
632
+ // 📖 Cap ping history (PING_HISTORY_CAP = 300): r.pings used to grow
633
+ // 📖 unbounded and getAvg/getP95/getVerdict/getStabilityScore rescan it
634
+ // 📖 per row at ~12 FPS. Stats (uptime, avg) now roll over the last
635
+ // 📖 300 pings instead of the entire session; the oldest entries shift out.
636
+ if (r.pings.length > PING_HISTORY_CAP) {
637
+ r.pings.splice(0, r.pings.length - PING_HISTORY_CAP)
638
+ }
624
639
 
625
640
  // 📖 Probe-cache (t1): record the result so future runs can skip healthy models
626
641
  // 📖 and auto-hide broken ones. Uses the module-level in-memory cache; flushed
@@ -767,8 +782,9 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
767
782
  clearTimeout(state.pingIntervalObj)
768
783
  clearInterval(state.versionRecheckTimer)
769
784
  stopRouterDashboardClient(state)
770
- if (onKeyPress) process.stdin.removeListener('keypress', onKeyPress)
771
- if (onMouseData) process.stdin.removeListener('data', onMouseData)
785
+ // 📖 Remove the actual registered wrappers (see keypressWrapper/mouseDataWrapper).
786
+ if (keypressWrapper) process.stdin.removeListener('keypress', keypressWrapper)
787
+ if (mouseDataWrapper) process.stdin.removeListener('data', mouseDataWrapper)
772
788
  if (process.stdin.isTTY && resetRawMode) process.stdin.setRawMode(false)
773
789
  process.stdin.pause()
774
790
  process.stdout.write(ALT_LEAVE)
@@ -837,6 +853,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
837
853
  sortResultsWithPinnedFavorites,
838
854
  adjustScrollOffset,
839
855
  applyTierFilter,
856
+ getPingModel: () => pingModel,
840
857
  PING_INTERVAL,
841
858
  TIER_CYCLE,
842
859
  ORIGIN_CYCLE,
@@ -948,7 +965,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
948
965
  }
949
966
  })
950
967
 
951
- process.stdin.on('keypress', async (str, key) => {
968
+ keypressWrapper = async (str, key) => {
952
969
  try {
953
970
  // 📖 Skip keypress events that originate from mouse escape sequences.
954
971
  // 📖 readline may partially parse SGR mouse sequences as garbage keypresses.
@@ -963,18 +980,20 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
963
980
  console.error(chalk.yellow('\nPlease file an issue at https://github.com/vava-nessa/free-coding-models/issues or join the Discord to report this to the author.'));
964
981
  process.exit(1);
965
982
  }
966
- })
983
+ }
984
+ process.stdin.on('keypress', keypressWrapper)
967
985
 
968
986
  // 📖 Mouse data listener: parses SGR mouse escape sequences from raw stdin
969
987
  // 📖 and dispatches structured events (click, scroll, double-click) to the mouse handler.
970
- process.stdin.on('data', (data) => {
988
+ mouseDataWrapper = (data) => {
971
989
  try {
972
990
  if (onMouseData) onMouseData(data)
973
991
  } catch (err) {
974
992
  // 📖 Mouse errors are non-fatal — log and continue so the TUI doesn't crash.
975
993
  // 📖 This could happen on terminals that send unexpected mouse sequences.
976
994
  }
977
- })
995
+ }
996
+ process.stdin.on('data', mouseDataWrapper)
978
997
 
979
998
  process.on('SIGCONT', noteUserActivity)
980
999
 
@@ -1103,8 +1122,6 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1103
1122
  ? overlays.renderPlayground()
1104
1123
  : state.tokenUsageOpen
1105
1124
  ? overlays.renderTokenUsage()
1106
- : state.routerOnboardingOpen
1107
- ? overlays.renderRouterOnboarding()
1108
1125
  : state.incompatibleFallbackOpen
1109
1126
  ? overlays.renderIncompatibleFallback()
1110
1127
  : state.commandPaletteOpen
@@ -1207,7 +1224,14 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1207
1224
  const initialDueModels = state.results.filter(r => !isCacheFresh(r.providerKey, r.modelId, {
1208
1225
  ttlMs: state.probeCacheTtlMs,
1209
1226
  }))
1210
- const initialPing = Promise.all(initialDueModels.map(r => pingModel(r)))
1227
+ // 📖 Startup burst runs through runWithConcurrency(…, 5) - the same bounded
1228
+ // 📖 helper the 404 probe uses. The old Promise.all fired up to ~226
1229
+ // 📖 simultaneous pings at once, a guaranteed 429 storm on rate-limited
1230
+ // 📖 providers (and per-task errors are captured instead of rejecting the batch).
1231
+ const initialPing = runWithConcurrency(
1232
+ initialDueModels.map(r => () => pingModel(r)),
1233
+ 5
1234
+ )
1211
1235
 
1212
1236
  // 📖 Continuous ping loop with mode-driven cadence.
1213
1237
  const runPingCycle = async () => {
@@ -9,7 +9,7 @@
9
9
  * → `fuzzyMatchCommand` — scores a query against one string and returns match positions
10
10
  * → `filterCommandPaletteEntries` — returns sorted command matches for a query
11
11
  *
12
- * @exports { buildCommandPaletteTree, flattenCommandTree, fuzzyMatchCommand, filterCommandPaletteEntries }
12
+ * @exports { buildCommandPaletteTree, flattenCommandTree, fuzzyMatchCommand, filterCommandPaletteEntries, COMMAND_PALETTE_MAX_RESULTS, clampPaletteCursor }
13
13
  *
14
14
  * @see src/key-handler.js
15
15
  * @see src/overlays.js
@@ -17,6 +17,25 @@
17
17
 
18
18
  import { TOOL_METADATA, TOOL_MODE_ORDER } from '../core/tool-metadata.js'
19
19
  import { sources } from '../../sources.js'
20
+ import { sanitizePingInterval } from './tui-state.js'
21
+
22
+ // 📖 Max result rows the palette renders (overlays.js slices to this). The
23
+ // 📖 cursor must never point past the rendered slice, so key-handler clamps
24
+ // 📖 with clampPaletteCursor() against the same constant.
25
+ export const COMMAND_PALETTE_MAX_RESULTS = 80
26
+
27
+ /**
28
+ * 📖 clampPaletteCursor: Keep the palette cursor inside the RENDERED slice.
29
+ * 📖 The overlay only paints the first COMMAND_PALETTE_MAX_RESULTS rows, so a
30
+ * 📖 cursor clamped against the full result list could sit on an invisible row.
31
+ * @param {number} cursor
32
+ * @param {number} totalResults
33
+ * @returns {number}
34
+ */
35
+ export function clampPaletteCursor(cursor, totalResults) {
36
+ const max = Math.max(0, Math.min(totalResults, COMMAND_PALETTE_MAX_RESULTS) - 1)
37
+ return Math.min(Math.max(0, cursor), max)
38
+ }
20
39
 
21
40
  const PROVIDER_FILTER_COMMANDS = Object.entries(sources).map(([providerKey, source]) => {
22
41
  const label = source?.name || providerKey
@@ -229,7 +248,8 @@ export function buildCommandPaletteTree(visibleModels = [], config = null) {
229
248
 
230
249
  // 📖 Update Normal mode label if config is provided
231
250
  if (config) {
232
- const normalInterval = config.settings?.pingInterval ?? 10000
251
+ // 📖 Guard against 0/negative/garbage pingInterval (same rule as tui-state).
252
+ const normalInterval = sanitizePingInterval(config.settings?.pingInterval, 10000)
233
253
  const normalSec = Math.round(normalInterval / 1000)
234
254
  const actionsNode = tree.find(n => n.id === 'actions')
235
255
  const pingNode = actionsNode?.children.find(n => n.id === 'action-ping-mode')