free-coding-models 0.5.4 → 0.5.5

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.
@@ -11,11 +11,12 @@ if (process.argv.includes('--dev')) {
11
11
 
12
12
  import chalk from 'chalk';
13
13
  import { parseArgs, TIER_LETTER_MAP } from '../src/core/utils.js';
14
- import { loadConfig } from '../src/core/config.js';
14
+ import { loadConfig, saveConfig } from '../src/core/config.js';
15
15
  import { ensureTelemetryConfig } from '../src/core/telemetry.js';
16
16
  import { ensureFavoritesConfig } from '../src/core/favorites.js';
17
17
  import { buildCliHelpText } from '../src/tui/cli-help.js';
18
18
  import { ALT_LEAVE } from '../src/core/constants.js';
19
+ import { enforceMandatoryStartupUpdate, isPackageDevMode } from '../src/core/updater.js';
19
20
  import { runApp } from '../src/tui/app.js';
20
21
 
21
22
  // Global error handlers to ensure terminal is restored if something crashes catastrophically
@@ -53,13 +54,32 @@ async function main() {
53
54
  process.exit(0);
54
55
  }
55
56
 
57
+ // Load JSON config before operational modes so the mandatory update policy can
58
+ // 📖 persist failure counters for TUI, Web Dashboard, Docker daemon, and Desktop sidecar launches.
59
+ const config = loadConfig();
60
+ ensureTelemetryConfig(config);
61
+ ensureFavoritesConfig(config);
62
+
63
+ const isDevMode = isPackageDevMode();
64
+ const shouldEnforceUpdate = !cliArgs.daemonStopMode && !cliArgs.daemonStatusMode;
65
+ const startupUpdate = shouldEnforceUpdate
66
+ ? await enforceMandatoryStartupUpdate(config, {
67
+ saveConfig,
68
+ isDevMode,
69
+ surface: cliArgs.webMode ? 'web dashboard' : cliArgs.daemonMode ? 'router daemon' : 'TUI',
70
+ })
71
+ : { latestVersion: null, allowedOutdated: false, warningMessage: null, failures: 0, checked: false, updated: false, blocked: false };
72
+
73
+ if (startupUpdate.updated) return;
74
+ if (startupUpdate.blocked) process.exit(1);
75
+
56
76
  // 📖 Standalone web dashboard: same full-catalog ping UI as the TUI, served
57
77
  // 📖 locally with Socket.IO/SSE/REST realtime updates.
58
78
  if (cliArgs.webMode) {
59
79
  const { startWebServer } = await import('../web/server.js');
60
80
  const parsedPort = Number.parseInt(process.env.FCM_WEB_PORT || process.env.FCM_PORT || '3333', 10);
61
81
  const port = Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : 3333;
62
- await startWebServer(port, { open: true, startPingLoop: true });
82
+ await startWebServer(port, { open: true, startPingLoop: true, updateStatus: startupUpdate });
63
83
  return;
64
84
  }
65
85
 
@@ -102,12 +122,7 @@ async function main() {
102
122
  process.exit(1);
103
123
  }
104
124
 
105
- // Load JSON config
106
- const config = loadConfig();
107
- ensureTelemetryConfig(config);
108
- ensureFavoritesConfig(config);
109
-
110
- await runApp(cliArgs, config);
125
+ await runApp(cliArgs, config, { startupUpdate, isDevMode });
111
126
  }
112
127
 
113
128
  main().catch((err) => {
@@ -0,0 +1,15 @@
1
+ # Changelog v0.5.5 - 2026-06-01
2
+
3
+ ### Changed
4
+ - ⬆️ **deps-dev**: bump `vite` from `8.0.14` → `8.0.16` (#105)
5
+ - ⬆️ **deps-dev**: bump `vite-plus` from `0.1.20` → `0.1.23` (#106)
6
+ - ⬆️ **deps-dev**: bump `@vitejs/plugin-react` from `6.0.1` → `6.0.2` (#107)
7
+
8
+ ### CI
9
+ - ⬆️ bump `docker/setup-buildx-action` from `3` → `4` (#104)
10
+ - ⬆️ bump `docker/login-action` from `3` → `4` (#103)
11
+ - ⬆️ bump `pnpm/action-setup` from `4` → `6` (#102)
12
+
13
+ ### Notes
14
+ - All 6 dependabot PRs merged in a single batch with conflict resolution on `pnpm-lock.yaml`.
15
+ - 440/440 tests passing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
@@ -32,22 +32,29 @@
32
32
  * → getManualInstallCmd(pm, version) — Human-readable install command string for error messages
33
33
  * → checkForUpdateDetailed() — Fetch npm latest with explicit error info
34
34
  * → checkForUpdate() — Startup wrapper, returns version string or null
35
+ * → isPackageDevMode() — Detect git/dev checkouts that must not self-update
36
+ * → enforceMandatoryStartupUpdate() — Mandatory startup self-update with two-failure fallback
35
37
  * → runUpdate(latestVersion) — Install new version via detected PM + relaunch
36
38
  * @exports
37
39
  * detectPackageManager, getInstallArgs, getManualInstallCmd,
38
- * checkForUpdateDetailed, checkForUpdate, runUpdate, fetchLastReleaseDate
40
+ * checkForUpdateDetailed, checkForUpdate, isPackageDevMode,
41
+ * enforceMandatoryStartupUpdate, runUpdate, fetchLastReleaseDate
39
42
  *
40
43
  * @see bin/free-coding-models.js — calls checkForUpdate() at startup and runUpdate() on confirm
41
44
  */
42
45
 
43
46
  import chalk from 'chalk'
44
47
  import { createRequire } from 'module'
45
- import { accessSync, constants } from 'fs'
48
+ import { fileURLToPath } from 'url'
49
+ import { dirname, join } from 'path'
50
+ import { accessSync, constants, existsSync } from 'fs'
46
51
 
47
52
  const require = createRequire(import.meta.url)
48
53
  const readline = require('readline')
49
54
  const pkg = require('../../package.json')
50
55
  const LOCAL_VERSION = pkg.version
56
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
57
+ export const UPDATE_FAILURE_THRESHOLD = 2
51
58
 
52
59
  /**
53
60
  * 📖 detectPackageManager: figure out which package manager owns the current installation.
@@ -65,6 +72,70 @@ export function detectPackageManager() {
65
72
  return 'npm'
66
73
  }
67
74
 
75
+ /**
76
+ * 📖 isPackageDevMode: true for repo checkouts and explicit --dev runs.
77
+ * 📖 Self-updating a git checkout creates noisy loops during local development,
78
+ * 📖 while published npm installs do not ship a .git directory and can update safely.
79
+ * @returns {boolean}
80
+ */
81
+ export function isPackageDevMode() {
82
+ return process.env.FCM_DEV === '1' || existsSync(join(PACKAGE_ROOT, '.git'))
83
+ }
84
+
85
+ /**
86
+ * 📖 getUpdateInstallFailureCount: sanitized persistent failure counter.
87
+ * @param {object} config
88
+ * @returns {number}
89
+ */
90
+ export function getUpdateInstallFailureCount(config) {
91
+ const raw = Number(config?.settings?.updateInstallFailures || 0)
92
+ if (!Number.isFinite(raw) || raw < 0) return 0
93
+ return Math.floor(raw)
94
+ }
95
+
96
+ function ensureUpdateSettings(config) {
97
+ if (!config.settings || typeof config.settings !== 'object') config.settings = {}
98
+ return config.settings
99
+ }
100
+
101
+ function persistUpdateSettings(config, saveConfig) {
102
+ if (typeof saveConfig !== 'function') return
103
+ try { saveConfig(config) } catch {}
104
+ }
105
+
106
+ function resetUpdateInstallFailures(config, saveConfig) {
107
+ const settings = ensureUpdateSettings(config)
108
+ if (!settings.updateInstallFailures && !settings.updateLastFailureAt && !settings.updateLastFailureMessage) return
109
+ settings.updateInstallFailures = 0
110
+ delete settings.updateLastFailureAt
111
+ delete settings.updateLastFailureMessage
112
+ delete settings.updateInstallFailureVersion
113
+ persistUpdateSettings(config, saveConfig)
114
+ }
115
+
116
+ function recordUpdateInstallFailure(config, latestVersion, error, saveConfig) {
117
+ const settings = ensureUpdateSettings(config)
118
+ const nextFailures = Math.min(getUpdateInstallFailureCount(config) + 1, UPDATE_FAILURE_THRESHOLD)
119
+ settings.updateInstallFailures = nextFailures
120
+ settings.updateInstallFailureVersion = latestVersion
121
+ settings.updateLastFailureAt = new Date().toISOString()
122
+ settings.updateLastFailureMessage = error instanceof Error ? error.message : String(error || 'Unknown update error')
123
+ persistUpdateSettings(config, saveConfig)
124
+ return nextFailures
125
+ }
126
+
127
+ /**
128
+ * 📖 buildOutdatedWarningMessage: one-line message shown when mandatory updates
129
+ * 📖 failed twice and FCM must let the UI start instead of trapping the user.
130
+ * @param {string|null} latestVersion
131
+ * @param {number} failures
132
+ * @returns {string}
133
+ */
134
+ export function buildOutdatedWarningMessage(latestVersion, failures = UPDATE_FAILURE_THRESHOLD) {
135
+ const target = latestVersion ? `v${LOCAL_VERSION} → v${latestVersion}` : `v${LOCAL_VERSION}`
136
+ return `⚠️ OUTDATED VERSION (${target}) — automatic update failed ${failures} times. Models and free quotas change often; update as soon as possible for the freshest catalog.`
137
+ }
138
+
68
139
  /**
69
140
  * 📖 getInstallArgs: return the correct binary and argument list for a given PM.
70
141
  * 📖 Each PM has different syntax for global install — this normalises them.
@@ -120,6 +191,89 @@ export async function checkForUpdate() {
120
191
  return latestVersion
121
192
  }
122
193
 
194
+ /**
195
+ * 📖 enforceMandatoryStartupUpdate: startup policy for every user-facing surface.
196
+ * 📖 If npm has a newer release, FCM installs it immediately without asking.
197
+ * 📖 The first failed install blocks startup so the next launch retries. After two
198
+ * 📖 consecutive install failures, startup is allowed with a loud outdated warning
199
+ * 📖 so offline/proxy/permission users are not permanently locked out.
200
+ *
201
+ * @param {object} config
202
+ * @param {{ saveConfig?: Function, isDevMode?: boolean, surface?: string }} [options]
203
+ * @returns {Promise<{ latestVersion: string|null, allowedOutdated: boolean, warningMessage: string|null, failures: number, checked: boolean, updated: boolean, blocked: boolean }>}
204
+ */
205
+ export async function enforceMandatoryStartupUpdate(config, options = {}) {
206
+ const { saveConfig, surface = 'app' } = options
207
+ const devMode = typeof options.isDevMode === 'boolean' ? options.isDevMode : isPackageDevMode()
208
+ const base = {
209
+ latestVersion: null,
210
+ allowedOutdated: false,
211
+ warningMessage: null,
212
+ failures: getUpdateInstallFailureCount(config),
213
+ checked: false,
214
+ updated: false,
215
+ blocked: false,
216
+ }
217
+
218
+ if (devMode) return base
219
+
220
+ const { latestVersion, error } = await checkForUpdateDetailed()
221
+ base.checked = true
222
+
223
+ if (error) {
224
+ const settings = ensureUpdateSettings(config)
225
+ settings.updateCheckFailures = Math.min(Number(settings.updateCheckFailures || 0) + 1, UPDATE_FAILURE_THRESHOLD)
226
+ persistUpdateSettings(config, saveConfig)
227
+ return base
228
+ }
229
+
230
+ const settings = ensureUpdateSettings(config)
231
+ if (settings.updateCheckFailures) {
232
+ settings.updateCheckFailures = 0
233
+ persistUpdateSettings(config, saveConfig)
234
+ }
235
+
236
+ if (!latestVersion) {
237
+ resetUpdateInstallFailures(config, saveConfig)
238
+ return base
239
+ }
240
+
241
+ base.latestVersion = latestVersion
242
+ const failuresBeforeInstall = getUpdateInstallFailureCount(config)
243
+ if (failuresBeforeInstall >= UPDATE_FAILURE_THRESHOLD) {
244
+ base.allowedOutdated = true
245
+ base.failures = failuresBeforeInstall
246
+ base.warningMessage = buildOutdatedWarningMessage(latestVersion, failuresBeforeInstall)
247
+ return base
248
+ }
249
+
250
+ console.log(chalk.dim(` ⬆ New version v${latestVersion} detected for ${surface}; updating automatically...`))
251
+ const updateResult = runUpdate(latestVersion, { exitOnFailure: false })
252
+ if (updateResult?.ok) {
253
+ resetUpdateInstallFailures(config, saveConfig)
254
+ base.updated = true
255
+ return base
256
+ }
257
+
258
+ const failures = recordUpdateInstallFailure(config, latestVersion, updateResult?.error, saveConfig)
259
+ base.failures = failures
260
+
261
+ if (failures >= UPDATE_FAILURE_THRESHOLD) {
262
+ base.allowedOutdated = true
263
+ base.warningMessage = buildOutdatedWarningMessage(latestVersion, failures)
264
+ console.log(chalk.red(` ${base.warningMessage}`))
265
+ console.log(chalk.dim(` Manual update: ${getManualInstallCmd(detectPackageManager(), latestVersion)}`))
266
+ console.log()
267
+ return base
268
+ }
269
+
270
+ base.blocked = true
271
+ console.log(chalk.red(' ✖ Mandatory update failed. FCM will retry on the next launch.'))
272
+ console.log(chalk.dim(` Attempt ${failures}/${UPDATE_FAILURE_THRESHOLD}. Manual update: ${getManualInstallCmd(detectPackageManager(), latestVersion)}`))
273
+ console.log()
274
+ return base
275
+ }
276
+
123
277
  /**
124
278
  * 📖 fetchLastReleaseDate: Get the human-readable publish date of the latest npm release.
125
279
  * 📖 Used in the TUI footer to show users how fresh the package is.
@@ -265,10 +419,15 @@ function installUpdateCommand(latestVersion, useSudo) {
265
419
  /**
266
420
  * 📖 runUpdate: Run npm global install to update to latestVersion.
267
421
  * 📖 Retries with sudo on permission errors.
268
- * 📖 Relaunches the process on success, exits with code 1 on failure.
422
+ * 📖 Relaunches the process on success. Manual update actions keep the historic
423
+ * 📖 behavior and exit on failure; mandatory startup checks pass exitOnFailure=false
424
+ * 📖 so they can persist failure counters and decide whether to let the UI start.
269
425
  * @param {string} latestVersion
426
+ * @param {{ exitOnFailure?: boolean, relaunchOnSuccess?: boolean }} [options]
427
+ * @returns {{ ok: boolean, error?: unknown }}
270
428
  */
271
- export function runUpdate(latestVersion) {
429
+ export function runUpdate(latestVersion, options = {}) {
430
+ const { exitOnFailure = true, relaunchOnSuccess = true } = options
272
431
  console.log()
273
432
  console.log(chalk.bold.cyan(' ⬆ Updating free-coding-models to v' + latestVersion + '...'))
274
433
  console.log()
@@ -276,6 +435,7 @@ export function runUpdate(latestVersion) {
276
435
  const pm = detectPackageManager()
277
436
  const { needsSudo, checkedPath } = detectGlobalInstallPermission(pm)
278
437
  const sudoAvailable = process.platform !== 'win32' && hasSudoCommand()
438
+ let lastError = null
279
439
 
280
440
  if (needsSudo && checkedPath && sudoAvailable) {
281
441
  console.log(chalk.yellow(` ⚠ Global ${pm} path is not writable: ${checkedPath}`))
@@ -288,9 +448,10 @@ export function runUpdate(latestVersion) {
288
448
  console.log()
289
449
  console.log(chalk.green(` ✅ Update complete! Version ${latestVersion} installed.`))
290
450
  console.log()
291
- relaunchCurrentProcess()
292
- return
451
+ if (relaunchOnSuccess) relaunchCurrentProcess()
452
+ return { ok: true }
293
453
  } catch (err) {
454
+ lastError = err
294
455
  const manualCmd = getManualInstallCmd(pm, latestVersion)
295
456
  console.log()
296
457
  if (isPermissionError(err) && !needsSudo && sudoAvailable) {
@@ -301,9 +462,10 @@ export function runUpdate(latestVersion) {
301
462
  console.log()
302
463
  console.log(chalk.green(` ✅ Update complete with sudo! Version ${latestVersion} installed.`))
303
464
  console.log()
304
- relaunchCurrentProcess()
305
- return
306
- } catch {
465
+ if (relaunchOnSuccess) relaunchCurrentProcess()
466
+ return { ok: true }
467
+ } catch (sudoErr) {
468
+ lastError = sudoErr
307
469
  console.log()
308
470
  console.log(chalk.red(' ✖ Update failed even with sudo. Try manually:'))
309
471
  console.log(chalk.dim(` sudo ${manualCmd}`))
@@ -318,7 +480,9 @@ export function runUpdate(latestVersion) {
318
480
  console.log()
319
481
  }
320
482
  }
321
- process.exit(1)
483
+
484
+ if (exitOnFailure) process.exit(1)
485
+ return { ok: false, error: lastError }
322
486
  }
323
487
 
324
488
 
package/src/tui/app.js CHANGED
@@ -91,11 +91,7 @@
91
91
 
92
92
  import chalk from 'chalk'
93
93
  import { createRequire } from 'module'
94
- import { fileURLToPath } from 'url'
95
- import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs'
96
94
  import { randomUUID } from 'crypto'
97
- import { homedir } from 'os'
98
- import { join, dirname } from 'path'
99
95
  import { MODELS, sources } from '../../sources.js'
100
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'
101
97
  import { loadConfig, saveConfig, getApiKey, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, persistApiKeysForProvider } from '../core/config.js'
@@ -181,7 +177,7 @@ const LOCAL_VERSION = pkg.version
181
177
  // ─── OpenCode integration ──────────────────────────────────────────────────────
182
178
  // 📖 OpenCode helpers are imported from ../src/opencode.js
183
179
 
184
- export async function runApp(cliArgs, config) {
180
+ export async function runApp(cliArgs, config, startupOptions = {}) {
185
181
 
186
182
  // 📖 Detect user active terminal theme
187
183
  detectActiveTheme(config.settings?.theme || 'auto')
@@ -278,32 +274,12 @@ export async function runApp(cliArgs, config) {
278
274
  },
279
275
  })
280
276
 
281
- // 📖 Auto-update detection: check npm registry for new versions at startup.
282
- // 📖 If a new version is available, show an interactive prompt (Update / Changelogs / Skip).
283
- // 📖 Dev mode (git checkout) skips auto-update to avoid infinite relaunch loops.
284
- let latestVersion = null
285
- const isDevMode = existsSync(join(dirname(fileURLToPath(import.meta.url)), '..', '.git'))
286
- try {
287
- latestVersion = await checkForUpdate()
288
- // 📖 Reset failure counter on successful check
289
- if (config.settings?.updateCheckFailures) {
290
- config.settings.updateCheckFailures = 0
291
- saveConfig(config)
292
- }
293
- } catch (err) {
294
- const failures = (config.settings?.updateCheckFailures || 0) + 1
295
- if (!config.settings) config.settings = {}
296
- config.settings.updateCheckFailures = Math.min(failures, 3)
297
- saveConfig(config)
298
- }
299
-
300
- // 📖 Auto-update: if a new version is available, install it immediately (skip in dev mode)
301
- // 📖 runUpdate() will relaunch the process with the new version after install completes
302
- if (latestVersion && !isDevMode) {
303
- console.log(chalk.dim(` ⬆ New version v${latestVersion} detected, updating...`))
304
- runUpdate(latestVersion)
305
- return // 📖 runUpdate relaunches the process — this line is a safety guard
306
- }
277
+ // 📖 Mandatory startup update is enforced in the bin entry before any user-facing
278
+ // 📖 surface starts. runApp receives the result so the TUI can display the
279
+ // 📖 two-failure fallback warning without repeating npm registry/install work.
280
+ const startupUpdate = startupOptions.startupUpdate || {}
281
+ const latestVersion = startupUpdate.latestVersion || null
282
+ const isDevMode = startupOptions.isDevMode === true
307
283
 
308
284
  // 📖 Dynamic OpenRouter free model discovery — fetch live free models from API
309
285
  // 📖 Replaces static openrouter entries in MODELS with fresh data.
@@ -363,6 +339,8 @@ export async function runApp(cliArgs, config) {
363
339
  sessionId,
364
340
  latestVersion,
365
341
  isDevMode,
342
+ updateWarningMessage: startupUpdate.warningMessage || null,
343
+ updateInstallFailures: startupUpdate.failures || 0,
366
344
  })
367
345
 
368
346
  // 📖 Apply the pre-fetched last release date now that state is initialized
@@ -823,6 +801,7 @@ export async function runApp(cliArgs, config) {
823
801
  settingsUpdateLatestVersion: state.settingsUpdateLatestVersion,
824
802
  startupLatestVersion: state.startupLatestVersion,
825
803
  versionAlertsEnabled: state.versionAlertsEnabled,
804
+ updateWarningMessage: state.updateWarningMessage,
826
805
  favoritesPinnedAndSticky: state.favoritesPinnedAndSticky,
827
806
  customTextFilter: state.customTextFilter,
828
807
  lastReleaseDate: state.lastReleaseDate,
@@ -914,6 +893,7 @@ export async function runApp(cliArgs, config) {
914
893
  settingsUpdateLatestVersion: state.settingsUpdateLatestVersion,
915
894
  startupLatestVersion: state.startupLatestVersion,
916
895
  versionAlertsEnabled: state.versionAlertsEnabled,
896
+ updateWarningMessage: state.updateWarningMessage,
917
897
  favoritesPinnedAndSticky: state.favoritesPinnedAndSticky,
918
898
  customTextFilter: state.customTextFilter,
919
899
  lastReleaseDate: state.lastReleaseDate,
@@ -136,6 +136,7 @@ export const PROVIDER_COLOR = new Proxy({}, {
136
136
  * settingsUpdateLatestVersion: string|null,
137
137
  * startupLatestVersion: string|null,
138
138
  * versionAlertsEnabled: boolean,
139
+ * updateWarningMessage?: string|null,
139
140
  * favoritesPinnedAndSticky: boolean,
140
141
  * customTextFilter: string|null,
141
142
  * lastReleaseDate: string|null,
@@ -174,6 +175,7 @@ export function renderTable({
174
175
  settingsUpdateLatestVersion = null,
175
176
  startupLatestVersion = null,
176
177
  versionAlertsEnabled = true,
178
+ updateWarningMessage = null,
177
179
  favoritesPinnedAndSticky = false,
178
180
  customTextFilter = null,
179
181
  lastReleaseDate = null,
@@ -1073,15 +1075,19 @@ export function renderTable({
1073
1075
  )
1074
1076
 
1075
1077
  if (versionStatus.isOutdated) {
1076
- const updateMsg = ` 🚀⬆️ UPDATE AVAILABLE — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
1078
+ const updateMsg = updateWarningMessage
1079
+ ? ` ${updateWarningMessage} • Press Shift+U to retry update `
1080
+ : ` 🚀⬆️ UPDATE AVAILABLE — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
1077
1081
  const paddedBanner = terminalCols > 0
1078
1082
  ? updateMsg + ' '.repeat(Math.max(0, terminalCols - displayWidth(updateMsg)))
1079
1083
  : updateMsg
1080
- const fluoGreenBanner = chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
1084
+ const updateBanner = updateWarningMessage
1085
+ ? chalk.bgRed.white.bold(paddedBanner)
1086
+ : chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
1081
1087
  const updateBannerRow = lines.length + 1
1082
1088
  _lastLayout.updateBannerRow = updateBannerRow
1083
1089
  footerHotkeys.push({ key: 'update-click', row: updateBannerRow, xStart: 1, xEnd: Math.max(terminalCols, displayWidth(updateMsg)) })
1084
- lines.push(fluoGreenBanner)
1090
+ lines.push(updateBanner)
1085
1091
  } else {
1086
1092
  _lastLayout.updateBannerRow = 0
1087
1093
  }
@@ -70,6 +70,8 @@ export function intervalToPingMode(intervalMs) {
70
70
  * sessionId: string,
71
71
  * latestVersion: string|null,
72
72
  * isDevMode: boolean,
73
+ * updateWarningMessage?: string|null,
74
+ * updateInstallFailures?: number,
73
75
  * }} opts
74
76
  * @returns {object} The complete TUI state (mutable)
75
77
  */
@@ -80,6 +82,8 @@ export function createTuiState({
80
82
  sessionId,
81
83
  latestVersion,
82
84
  isDevMode,
85
+ updateWarningMessage = null,
86
+ updateInstallFailures = 0,
83
87
  }) {
84
88
  const now = Date.now()
85
89
 
@@ -110,6 +114,8 @@ export function createTuiState({
110
114
 
111
115
  // 📖 Version tracking — startup auto-check + periodic re-check
112
116
  startupLatestVersion: latestVersion,
117
+ updateWarningMessage,
118
+ updateInstallFailures,
113
119
  lastReleaseDate: null,
114
120
  versionAlertsEnabled: !isDevMode,
115
121