free-coding-models 0.5.87 → 0.5.89

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.
@@ -17,21 +17,34 @@
17
17
  * Now checkConfigSecurity() is async, awaited by the bin entry BEFORE the TUI
18
18
  * starts, and it never prompts on non-TTY stdin or daemon/web/JSON surfaces.
19
19
  *
20
- * 📖 Secure permissions:
21
- * - 0o600 (octal 600) = user:rw, group:---, world:---
22
- * - Only the file owner can read or write
23
- * - This is the standard for files containing secrets (SSH keys, API keys, etc.)
20
+ * 📖 Issue #173 follow-up (rutexd): on Windows the "fix" never persisted and the
21
+ * warning re-fired on every launch. Root cause: Node maps win32 modes to only
22
+ * 0666 (writable) or 0444 (read-only), so 0600 is unreachable and
23
+ * `(mode & 0o777) === 0o600` was always false; chmod 600 on win32 just clears
24
+ * the read-only bit. The real fix:
25
+ * - the Windows verdict now comes from the NTFS ACL via `icacls <file>`
26
+ * - fixing on Windows runs `icacls /inheritance:r /grant:r <user>:F` and is
27
+ * verified by re-reading the ACL before claiming success
28
+ * - when the fix cannot be applied or verified, an ack marker file
29
+ * (<config>.securityack) keeps the warning quiet for 30 days instead of
30
+ * nagging on every launch (--fix-permissions bypasses the marker)
31
+ * POSIX behaviour (chmod 600) is unchanged on macOS/Linux.
24
32
  *
25
- * 📖 Windows note: Node's chmod on win32 is best-effort (it can only toggle the
26
- * read-only attribute, NTFS ACLs still govern real access). We still try,
27
- * and the manual hint points at icacls for a real fix.
33
+ * 📖 Secure permissions:
34
+ * - POSIX: 0o600 (octal 600) = user:rw, group:---, world:---
35
+ * - Windows: NTFS inheritance disabled, grants only for the current user
36
+ * (SYSTEM/Administrators whitelisted) - see parseIcaclsOutput in utils.js
28
37
  *
29
38
  * @functions
30
39
  * → checkConfigSecurity() - Async main security check; awaited before the TUI starts
31
40
  * → resolveSecurityAction() - Pure gate deciding auto-fix / prompt / warn-only (in utils.js)
41
+ * → parseIcaclsOutput() - Pure parser for icacls output (in utils.js)
42
+ * → shouldSkipSecurityWarn() - Pure anti-nag gate for repeat warnings (in utils.js)
32
43
  * → getConfigPermissions() - Returns file mode object for config
33
- * → isConfigSecure() - Boolean check if permissions are correct
34
- * → fixConfigPermissions() - Applies chmod 600 to config file (best-effort on Windows)
44
+ * → isConfigSecure() - Boolean check if permissions are correct (POSIX modes)
45
+ * → fixConfigPermissions() - Applies chmod 600 to config file (POSIX)
46
+ * → getWindowsAclStatus() - Async icacls read + parse → is the ACL secure?
47
+ * → fixWindowsAcl() - Async icacls fix + ACL re-verify
35
48
  * → promptSecurityFix() - Interactive prompt asking user to fix permissions
36
49
  *
37
50
  * @exports checkConfigSecurity, isConfigSecure, fixConfigPermissions, formatMode, formatModeRwx
@@ -41,8 +54,12 @@ import fs from 'node:fs'
41
54
  import path from 'node:path'
42
55
  import os from 'node:os'
43
56
  import readline from 'node:readline'
57
+ import { execFile } from 'node:child_process'
58
+ import { promisify } from 'node:util'
44
59
  import { CONFIG_PATH } from './config.js'
45
- import { resolveSecurityAction } from './utils.js'
60
+ import { resolveSecurityAction, parseIcaclsOutput, shouldSkipSecurityWarn } from './utils.js'
61
+
62
+ const execFileAsync = promisify(execFile)
46
63
 
47
64
  // 📖 Config file path - matches the path used in config.js (honours the
48
65
  // 📖 --config-dir / FCM_CONFIG_DIR override when set).
@@ -91,7 +108,8 @@ export function isConfigSecure() {
91
108
  }
92
109
 
93
110
  // 📖 Fix config file permissions to secure mode (chmod 600)
94
- // 📖 Best-effort on Windows (read-only bit); returns true if successful, false otherwise
111
+ // 📖 POSIX path; on Windows this is NOT the real fix (see fixWindowsAcl) and is
112
+ // 📖 kept only as a harmless last-ditch fallback. Returns true if successful.
95
113
  export function fixConfigPermissions() {
96
114
  const configPath = getConfigPath()
97
115
 
@@ -107,6 +125,88 @@ export function fixConfigPermissions() {
107
125
  }
108
126
  }
109
127
 
128
+ // 📖 Current Windows user name, used both for icacls grants and for matching
129
+ // 📖 ACE lines when parsing the ACL (os.userInfo works on win32 too).
130
+ function getWindowsUserName() {
131
+ try {
132
+ return os.userInfo().username
133
+ } catch {
134
+ return process.env.USERNAME || ''
135
+ }
136
+ }
137
+
138
+ // 📖 Read the real NTFS ACL via icacls and decide if it is secure:
139
+ // 📖 inheritance disabled + grants only for the current user (and trusted
140
+ // 📖 SYSTEM/Administrators entries). Returns { checked, secure, ...parsed };
141
+ // 📖 checked=false when icacls is missing or failed (caller falls back to the
142
+ // 📖 ack-gated warning path instead of trusting useless mode bits).
143
+ async function getWindowsAclStatus(configPath) {
144
+ try {
145
+ const { stdout } = await execFileAsync('icacls', [configPath], {
146
+ timeout: 5000,
147
+ windowsHide: true,
148
+ })
149
+ const parsed = parseIcaclsOutput({ output: stdout, userName: getWindowsUserName(), filePath: configPath })
150
+ const secure = !parsed.inheritanceEnabled && !parsed.othersHaveAccess && parsed.ownerHasAccess
151
+ return { checked: true, secure, ...parsed }
152
+ } catch (err) {
153
+ return { checked: false, secure: false, grants: [], otherNames: [] }
154
+ }
155
+ }
156
+
157
+ // 📖 Fix the NTFS ACL for real: drop inherited ACEs, grant only the current
158
+ // 📖 user full control, then VERIFY by re-reading the ACL (never claim success
159
+ // 📖 without proof - that lie was the core of the issue #173 follow-up).
160
+ // 📖 execFile (no shell) keeps the path injection-safe even with spaces.
161
+ async function fixWindowsAcl(configPath) {
162
+ const userName = getWindowsUserName()
163
+ if (!userName) return false
164
+
165
+ try {
166
+ await execFileAsync(
167
+ 'icacls',
168
+ [configPath, '/inheritance:r', '/grant:r', `${userName}:F`],
169
+ { timeout: 5000, windowsHide: true }
170
+ )
171
+ const status = await getWindowsAclStatus(configPath)
172
+ return status.checked && status.secure
173
+ } catch (err) {
174
+ return false
175
+ }
176
+ }
177
+
178
+ // 📖 Anti-nag marker: written when we warned but could not fix/verify, so the
179
+ // 📖 same warning does not re-fire on every launch (see shouldSkipSecurityWarn).
180
+ // 📖 Lives next to the config so --config-dir / FCM_CONFIG_DIR stays coherent.
181
+ function getAckPath() {
182
+ return `${getConfigPath()}.securityack`
183
+ }
184
+
185
+ function readSecurityAck() {
186
+ try {
187
+ return fs.readFileSync(getAckPath(), 'utf8').trim()
188
+ } catch {
189
+ return null
190
+ }
191
+ }
192
+
193
+ function writeSecurityAck() {
194
+ try {
195
+ fs.writeFileSync(getAckPath(), new Date().toISOString(), { mode: 0o600 })
196
+ return true
197
+ } catch {
198
+ return false
199
+ }
200
+ }
201
+
202
+ function clearSecurityAck() {
203
+ try {
204
+ fs.unlinkSync(getAckPath())
205
+ } catch {
206
+ // 📖 Nothing to clean up is fine.
207
+ }
208
+ }
209
+
110
210
  // 📖 Format permission mode in octal (e.g., 0o644 → "644")
111
211
  // 📖 Exported for unit tests
112
212
  export function formatMode(mode) {
@@ -133,7 +233,9 @@ export function formatModeRwx(mode) {
133
233
  }
134
234
 
135
235
  // 📖 Print the insecure-permissions warning (stderr, so --json stdout stays clean)
136
- function printSecurityWarning(perms) {
236
+ // 📖 On Windows, mode bits are meaningless (always 0666/0444), so when the ACL
237
+ // 📖 was read we show WHAT actually grants access instead of a fake octal story.
238
+ function printSecurityWarning(perms, aclStatus = { checked: false }) {
137
239
  const currentMode = formatMode(perms.mode)
138
240
  const currentRwx = formatModeRwx(perms.mode)
139
241
 
@@ -145,16 +247,29 @@ function printSecurityWarning(perms) {
145
247
  console.error('')
146
248
  console.error('This means other users on this system may be able to read your API keys.')
147
249
  console.error('')
148
- console.error('Recommended: Fix permissions to 600 (rw-------) - owner read/write only')
250
+
251
+ if (aclStatus.checked) {
252
+ if (aclStatus.inheritanceEnabled) {
253
+ console.error('NTFS: this file inherits access permissions from your user folder.')
254
+ }
255
+ if (aclStatus.othersHaveAccess) {
256
+ const names = (aclStatus.otherNames || []).slice(0, 3).join(', ')
257
+ console.error(`NTFS: access is also granted to: ${names}`)
258
+ }
259
+ console.error('')
260
+ }
261
+
262
+ console.error('Recommended: restrict access to your user account only')
149
263
  }
150
264
 
151
- // 📖 Print the manual fix hint. On Windows, point at icacls since Node's chmod
152
- // 📖 only toggles the read-only attribute there.
265
+ // 📖 Print the manual fix hint. Platform-matched command: icacls on Windows
266
+ // 📖 (NTFS ACLs), chmod elsewhere.
153
267
  function printManualFixHint() {
154
268
  console.error('')
155
269
  if (IS_WINDOWS) {
270
+ const user = getWindowsUserName() || '$env:USERNAME'
156
271
  console.error('To fix manually (PowerShell), run:')
157
- console.error(` icacls "${getConfigPath()}" /inheritance:r /grant:r "$env:USERNAME:R,W"`)
272
+ console.error(` icacls "${getConfigPath()}" /inheritance:r /grant:r "${user}:F"`)
158
273
  } else {
159
274
  console.error('To fix manually, run:')
160
275
  console.error(` chmod 600 ${getConfigPath()}`)
@@ -164,24 +279,37 @@ function printManualFixHint() {
164
279
 
165
280
  // 📖 Apply the fix and report the outcome. Shared by the prompt path (user said
166
281
  // 📖 yes) and the auto-fix path (--fix-permissions / --yes / -y).
167
- function applyFixAndReport() {
168
- const success = fixConfigPermissions()
282
+ // 📖 Windows: icacls ACL fix, verified by re-reading the ACL.
283
+ // 📖 POSIX: chmod 600, verified by re-statting the file.
284
+ // 📖 If the fix cannot be verified, write the anti-nag ack so the warning does
285
+ // 📖 not re-fire on every launch, and point at the manual command.
286
+ async function applyFixAndReport() {
287
+ const configPath = getConfigPath()
288
+ let success = false
289
+
290
+ if (IS_WINDOWS) {
291
+ success = await fixWindowsAcl(configPath)
292
+ } else {
293
+ success = fixConfigPermissions() && getConfigPermissions()?.isSecure === true
294
+ }
169
295
 
170
296
  if (success) {
297
+ clearSecurityAck()
171
298
  console.error('')
172
299
  console.error('✅ Permissions fixed! Your API keys are now secure.')
173
300
  console.error('')
174
301
  if (IS_WINDOWS) {
175
- console.error('Note: on Windows this is best-effort (read-only bit). See docs for NTFS ACLs.')
302
+ console.error(`NTFS access is now restricted to "${getWindowsUserName()}".`)
176
303
  console.error('')
177
304
  }
178
305
  return { wasSecure: false, wasFixed: true }
179
306
  }
180
307
 
308
+ writeSecurityAck()
181
309
  console.error('')
182
310
  console.error('❌ Failed to fix permissions automatically.')
183
311
  printManualFixHint()
184
- return { wasSecure: false, wasFixed: false, error: 'chmod_failed' }
312
+ return { wasSecure: false, wasFixed: false, error: 'fix_failed' }
185
313
  }
186
314
 
187
315
  // 📖 Check security and handle the fix flow if needed
@@ -189,9 +317,14 @@ function applyFixAndReport() {
189
317
  // 📖 the confirmation prompt are visible and fully resolved before raw mode /
190
318
  // 📖 the alternate screen take over.
191
319
  //
320
+ // 📖 Windows verdict (issue #173 follow-up): mode bits on win32 are only ever
321
+ // 📖 0666 or 0444, so the POSIX 0600 check would nag forever. The real verdict
322
+ // 📖 comes from the NTFS ACL (icacls). If icacls is unavailable, fall through to
323
+ // 📖 the warning path but let the anti-nag ack keep it to once per 30 days.
324
+ //
192
325
  // 📖 Options:
193
326
  // autoFix - true when --fix-permissions / --yes / -y was passed: apply the
194
- // fix without asking
327
+ // fix without asking (also bypasses the anti-nag ack)
195
328
  // promptAllowed - false on daemon/web/JSON surfaces: never prompt there, at most
196
329
  // warn on stderr
197
330
  // stdinIsTTY - override the stdin TTY detection (tests); defaults to real detection
@@ -205,11 +338,23 @@ export async function checkConfigSecurity(options = {}) {
205
338
  return { wasSecure: true, wasFixed: false }
206
339
  }
207
340
 
208
- // 📖 Permissions are already secure
209
- if (perms.isSecure) {
341
+ // 📖 Security verdict, platform-aware.
342
+ let aclStatus = { checked: false, secure: false, grants: [], otherNames: [] }
343
+ if (IS_WINDOWS) {
344
+ aclStatus = await getWindowsAclStatus(perms.path)
345
+ if (aclStatus.checked && aclStatus.secure) {
346
+ return { wasSecure: true, wasFixed: false }
347
+ }
348
+ } else if (perms.isSecure) {
210
349
  return { wasSecure: true, wasFixed: false }
211
350
  }
212
351
 
352
+ // 📖 Anti-nag gate: if we already warned recently and the fix did not stick,
353
+ // 📖 stay quiet instead of nagging on every launch. --fix-permissions bypasses.
354
+ if (options.autoFix !== true && shouldSkipSecurityWarn({ ackedAt: readSecurityAck() })) {
355
+ return { wasSecure: false, wasFixed: false, error: 'warned_recently' }
356
+ }
357
+
213
358
  // 📖 Pure gate (see utils.js): decides auto-fix vs prompt vs warn-only.
214
359
  const action = resolveSecurityAction({
215
360
  configExists: true,
@@ -225,7 +370,7 @@ export async function checkConfigSecurity(options = {}) {
225
370
 
226
371
  // 📖 Security issue detected! Print the warning first so it is on screen
227
372
  // 📖 no matter which path follows.
228
- printSecurityWarning(perms)
373
+ printSecurityWarning(perms, aclStatus)
229
374
 
230
375
  if (action === 'auto-fix') {
231
376
  return applyFixAndReport()
@@ -234,6 +379,7 @@ export async function checkConfigSecurity(options = {}) {
234
379
  if (action === 'warn-only') {
235
380
  console.error('Running non-interactively (piped stdin or daemon/web mode), so skipping the prompt.')
236
381
  printManualFixHint()
382
+ writeSecurityAck()
237
383
  return { wasSecure: false, wasFixed: false, error: 'non_interactive' }
238
384
  }
239
385
 
package/src/core/utils.js CHANGED
@@ -608,6 +608,13 @@ export function parseArgs(argv) {
608
608
  const daemonBackgroundMode = flags.includes('--daemon-bg')
609
609
  const daemonStopMode = flags.includes('--daemon-stop')
610
610
  const daemonStatusMode = flags.includes('--daemon-status')
611
+ // 📖 Router v2 (beta) lifecycle flags - run v2 alongside v1 on its own
612
+ // port (19380) with persisted breakers + decision traces. See
613
+ // src/core/router-v2/daemon.js and docs/router-v2.md.
614
+ const routerV2Mode = flags.includes('--router-v2')
615
+ const routerV2BackgroundMode = flags.includes('--router-v2-bg')
616
+ const routerV2StopMode = flags.includes('--router-v2-stop')
617
+ const routerV2StatusMode = flags.includes('--router-v2-status')
611
618
 
612
619
  // 📖 --fix-permissions / --yes / -y - auto-answer "yes" to the config-permission
613
620
  // 📖 security prompt (chmod 600, best-effort on Windows) so scripts, CI and
@@ -706,6 +713,11 @@ export function parseArgs(argv) {
706
713
  checkDriftMode,
707
714
  driftThreshold,
708
715
  daemonStatusMode,
716
+ // 📖 Router v2 (beta) lifecycle flags - see src/core/router-v2/daemon.js
717
+ routerV2Mode,
718
+ routerV2BackgroundMode,
719
+ routerV2StopMode,
720
+ routerV2StatusMode,
709
721
  // 📖 Profile system removed - API keys now persist permanently across all sessions
710
722
  recommendMode,
711
723
  devMode,
@@ -750,6 +762,103 @@ export function resolveSecurityAction({ configExists, isSecure, autoFixRequested
750
762
  return 'prompt'
751
763
  }
752
764
 
765
+ // 📖 parseIcaclsOutput: PURE parser for Windows `icacls <file>` output, used by
766
+ // 📖 the config security check to get a REAL answer about NTFS access.
767
+ // 📖 WHY: on win32 Node reports POSIX-style modes as 0666 (writable) or 0444
768
+ // 📖 (read-only) only - 0600 is unreachable - so the old `(mode & 0o777) === 0o600`
769
+ // 📖 test was always false and the warning re-fired on every launch (issue #173
770
+ // 📖 follow-up from rutexd). Only the ACL tells the truth on NTFS.
771
+ // 📖
772
+ // 📖 Sample output we must handle (ACEs, one per line, path on the first line):
773
+ // C:\Users\rutex\.free-coding-models.json NT AUTHORITY\SYSTEM:(I)(F)
774
+ // BUILTIN\Administrators:(I)(F)
775
+ // DESKTOP-XYZ\rutex:(F)
776
+ // Successfully processed 1 files; Failed processing 0 files
777
+ // 📖 `(I)` marks an inherited ACE, so "inheritance disabled" = no `(I)` anywhere.
778
+ // 📖 Locale-proof whitelists: names are machine-localized, so we whitelist by
779
+ // 📖 substring (SYSTEM, Administra* covers Administrators/Administrateurs) and
780
+ // 📖 by well-known SID (S-1-5-18 = SYSTEM, S-1-5-32-544 = Administrators), plus
781
+ // 📖 the current user (case-insensitive, domain-qualified or bare).
782
+ // 📖
783
+ // 📖 Params:
784
+ // output - raw stdout of `icacls <path>` (string)
785
+ // userName - current user name (string), e.g. "rutex"
786
+ // filePath - the exact path passed to icacls (optional but STRONGLY
787
+ // recommended: icacls echoes it on the first line, glued to the
788
+ // first ACE, and paths/account names can both contain spaces)
789
+ // 📖 Returns { inheritanceEnabled, othersHaveAccess, ownerHasAccess, grants }
790
+ // grants = [{ name, flags }] for every ACE line; empty when output unparseable.
791
+ export function parseIcaclsOutput({ output, userName, filePath = '' }) {
792
+ const text = String(output ?? '')
793
+ const user = String(userName ?? '').trim().toLowerCase()
794
+ const knownPath = String(filePath ?? '')
795
+ const lines = text.split(/\r?\n/)
796
+
797
+ // 📖 The rights/flags groups always end the line: "(I)(F)", "(F)", "(R,W)"...
798
+ const aceTail = /((?:\([^)]*\)\s*)+)$/
799
+ const grants = []
800
+ for (const rawLine of lines) {
801
+ const tail = aceTail.exec(rawLine)
802
+ if (!tail) continue
803
+
804
+ let before = rawLine.slice(0, tail.index).trim()
805
+ if (before === '') continue
806
+ // 📖 Drop the colon that separates the account name from its flags.
807
+ before = before.replace(/:$/, '').trim()
808
+ // 📖 First ACE shares its line with the echoed path: strip the path we
809
+ // 📖 passed to icacls. Without a known path, keep only the segment after
810
+ // 📖 the last whitespace (exact for simple names; callers should pass
811
+ // 📖 filePath when the path or account name contains spaces).
812
+ if (knownPath && before.startsWith(knownPath)) {
813
+ before = before.slice(knownPath.length).trim()
814
+ } else if (knownPath === '' && /\s/.test(before)) {
815
+ before = before.slice(before.lastIndexOf(' ') + 1).trim()
816
+ }
817
+ if (before === '') continue
818
+ grants.push({ name: before, flags: tail[1].replace(/\s+/g, '') })
819
+ }
820
+
821
+ const isTrusted = (name) => {
822
+ const n = name.toLowerCase()
823
+ if (user !== '' && (n === user || n.endsWith('\\' + user))) return true
824
+ if (n.includes('system') || n.includes('s-1-5-18')) return true
825
+ if (n.includes('administra') || n.includes('s-1-5-32-544')) return true
826
+ return false
827
+ }
828
+
829
+ const inheritanceEnabled = grants.some((g) => g.flags.includes('I'))
830
+ const others = grants.filter((g) => !isTrusted(g.name)).map((g) => g.name)
831
+ const ownerHasAccess = grants.some((g) => {
832
+ const n = g.name.toLowerCase()
833
+ return user !== '' && (n === user || n.endsWith('\\' + user))
834
+ })
835
+
836
+ return {
837
+ inheritanceEnabled,
838
+ othersHaveAccess: others.length > 0,
839
+ ownerHasAccess,
840
+ grants,
841
+ otherNames: others,
842
+ }
843
+ }
844
+
845
+ // 📖 shouldSkipSecurityWarn: PURE anti-nag gate for the config security warning.
846
+ // 📖 WHY: when the permission fix cannot be applied or verified (rare: icacls
847
+ // 📖 missing on Windows, chmod failing on POSIX), the insecure state persists and
848
+ // 📖 the warning used to re-fire on every single launch. Once we warned and the
849
+ // 📖 fix did not stick, stay quiet for `intervalDays` (default 30) instead of
850
+ // 📖 nagging daily. `--fix-permissions` bypasses the gate in the caller.
851
+ // 📖 Params: ackedAt - last warning timestamp (ms epoch or ISO string or null)
852
+ // now - current time in ms epoch; intervalDays - quiet window length
853
+ // 📖 Returns true only when ackedAt parses AND is less than intervalDays old.
854
+ export function shouldSkipSecurityWarn({ ackedAt, now = Date.now(), intervalDays = 30 }) {
855
+ if (ackedAt === null || ackedAt === undefined || ackedAt === '') return false
856
+ const then = typeof ackedAt === 'number' ? ackedAt : Date.parse(String(ackedAt))
857
+ if (!Number.isFinite(then)) return false
858
+ const intervalMs = Math.max(0, intervalDays) * 24 * 60 * 60 * 1000
859
+ return now - then < intervalMs && now >= then
860
+ }
861
+
753
862
  // 📖 detectTerminalCapabilities: PURE terminal capability probe for TUI overlays.
754
863
  // 📖 WHY: basic server consoles (IPMI/KVM viewers, ASPEED framebuffer, serial
755
864
  // 📖 terminals) often run 80x24 or smaller with no or limited color support, and
package/src/tui/app.js CHANGED
@@ -123,6 +123,7 @@ import { createOverlayRenderers } from './overlays.js'
123
123
  import { createKeyHandler, createMouseEventHandler } from './key-handler.js'
124
124
  import { createMouseHandler, containsMouseSequence } from './mouse.js'
125
125
  import { stopRouterDashboardClient } from '../core/router-dashboard.js'
126
+ import { stopRouterV2DashboardClient } from '../core/router-v2/tui-dashboard.js'
126
127
  import { getToolModeOrder, getToolMeta } from '../core/tool-metadata.js'
127
128
  import { startExternalTool } from '../core/tool-launchers.js'
128
129
  import { getToolInstallPlan, installToolWithPlan, isToolInstalled } from '../core/tool-bootstrap.js'
@@ -758,6 +759,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
758
759
  clearTimeout(state.pingIntervalObj)
759
760
  clearInterval(state.versionRecheckTimer)
760
761
  stopRouterDashboardClient(state)
762
+ stopRouterV2DashboardClient(state)
761
763
  process.stdout.write(ALT_LEAVE)
762
764
  if (process.stdout.isTTY) {
763
765
  process.stdout.flush && process.stdout.flush()
@@ -782,6 +784,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
782
784
  clearTimeout(state.pingIntervalObj)
783
785
  clearInterval(state.versionRecheckTimer)
784
786
  stopRouterDashboardClient(state)
787
+ stopRouterV2DashboardClient(state)
785
788
  // 📖 Remove the actual registered wrappers (see keypressWrapper/mouseDataWrapper).
786
789
  if (keypressWrapper) process.stdin.removeListener('keypress', keypressWrapper)
787
790
  if (mouseDataWrapper) process.stdin.removeListener('data', mouseDataWrapper)
@@ -1118,6 +1121,8 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1118
1121
  ? overlays.renderInstalledModels()
1119
1122
  : state.routerDashboardOpen
1120
1123
  ? overlays.renderRouterDashboard()
1124
+ : state.routerV2DashboardOpen
1125
+ ? overlays.renderRouterV2Dashboard()
1121
1126
  : state.playgroundOpen
1122
1127
  ? overlays.renderPlayground()
1123
1128
  : state.tokenUsageOpen
@@ -1261,9 +1266,9 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1261
1266
  }
1262
1267
 
1263
1268
  state.results.forEach(r => {
1264
- // 📖 When router dashboard is open, ONLY ping favorites every second
1269
+ // 📖 When a router dashboard is open, ONLY ping favorites every second
1265
1270
  // 📖 to prevent massive rate limiting across the entire 90+ model catalog.
1266
- if (state.routerDashboardOpen) {
1271
+ if (state.routerDashboardOpen || state.routerV2DashboardOpen) {
1267
1272
  const favKey = `${r.providerKey}/${r.modelId}`
1268
1273
  if (!state.config.favorites.includes(favKey)) return
1269
1274
  }
@@ -47,6 +47,10 @@ const CONFIG_FLAGS = [
47
47
  { flag: '--daemon-bg', description: 'Start the FCM Router daemon in the background' },
48
48
  { flag: '--daemon-status', description: 'Print FCM Router daemon status JSON' },
49
49
  { flag: '--daemon-stop', description: 'Gracefully stop the FCM Router daemon' },
50
+ { flag: '--router-v2', description: 'Start the Router v2 daemon (BETA) on port 19380: content-validated failover + Anthropic /v1/messages' },
51
+ { flag: '--router-v2-bg', description: 'Start Router v2 (BETA) in the background' },
52
+ { flag: '--router-v2-status', description: 'Print Router v2 (BETA) daemon status JSON' },
53
+ { flag: '--router-v2-stop', description: 'Gracefully stop the Router v2 (BETA) daemon' },
50
54
  { flag: '--sync-set [name]', description: 'Auto-discover and live-probe models into a router set' },
51
55
  { flag: '--config-dir <dir>', description: 'Store config.json + backups/ in <dir> (e.g. --config-dir ~/.config/free-coding-models for the XDG layout)' },
52
56
  { flag: '--fix-permissions, --yes, -y', description: 'Auto-fix insecure config file permissions (chmod 600) without prompting; safe for scripts and CI' },
@@ -46,6 +46,15 @@ import {
46
46
  setDashboardNotice,
47
47
  toggleRouterDashboardProbePause,
48
48
  } from '../core/router-dashboard.js'
49
+ import {
50
+ closeRouterV2DashboardOverlay,
51
+ cycleRouterV2ProbeMode,
52
+ openRouterV2DashboardOverlay,
53
+ renderRouterV2Dashboard,
54
+ setRouterV2Notice,
55
+ testAllVisibleViaRouterV2,
56
+ testModelViaRouterV2,
57
+ } from '../core/router-v2/tui-dashboard.js'
49
58
  import {
50
59
  closePlaygroundOverlay,
51
60
  openPlaygroundOverlay,
@@ -82,8 +91,8 @@ function spawnDaemonCommand(state, args) {
82
91
  })
83
92
  child.unref()
84
93
 
85
- // 📖 Only capture stdout for start commands stop doesn't need error surfacing.
86
- if (!args.includes('--daemon-bg')) return
94
+ // 📖 Only capture stdout for start commands - stop doesn't need error surfacing.
95
+ if (!args.includes('--daemon-bg') && !args.includes('--router-v2-bg')) return
87
96
 
88
97
  let stdout = ''
89
98
  let stderr = ''
@@ -1502,6 +1511,7 @@ export function createKeyHandler(ctx) {
1502
1511
 
1503
1512
  case 'open-recommend': return openRecommendOverlay()
1504
1513
  case 'open-router-dashboard': return openRouterDashboardOverlay(state)
1514
+ case 'open-router-v2-dashboard': return openRouterV2DashboardOverlay(state)
1505
1515
  case 'open-playground': return openPlaygroundOverlay(state)
1506
1516
  case 'open-token-usage': return openTokenUsageOverlay()
1507
1517
  case 'open-runtime-report': return openRuntimeReportOverlay()
@@ -1774,6 +1784,82 @@ export function createKeyHandler(ctx) {
1774
1784
  return
1775
1785
  }
1776
1786
 
1787
+ // 📖 Router v2 (BETA) dashboard: ↑↓ navigate the fallback chain,
1788
+ // T tests the model under the cursor through the router (pinned request
1789
+ // through the FULL chain), S toggles the daemon, I cycles probe speed,
1790
+ // C clears the persisted history, Esc goes back.
1791
+ if (state.routerV2DashboardOpen) {
1792
+ const routingOrder = Array.isArray(state.routerV2Stats?.routingOrder) ? state.routerV2Stats.routingOrder : []
1793
+ const maxCursor = Math.max(0, routingOrder.length)
1794
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
1795
+
1796
+ if (key.name === 'escape') {
1797
+ closeRouterV2DashboardOverlay(state)
1798
+ return
1799
+ }
1800
+
1801
+ if (key.name === 's') {
1802
+ const isRunning = state.routerV2Status === 'ready' || state.routerV2Status === 'partial'
1803
+ state.routerV2Status = 'loading'
1804
+ spawnDaemonCommand(state, [isRunning ? '--daemon-stop' : '--daemon-bg'])
1805
+ return
1806
+ }
1807
+
1808
+ if (key.name === 'return' || key.name === 'enter') {
1809
+ if ((state.routerV2CursorIndex ?? 0) === maxCursor) {
1810
+ const isRunning = state.routerV2Status === 'ready' || state.routerV2Status === 'partial'
1811
+ state.routerV2Status = 'loading'
1812
+ spawnDaemonCommand(state, [isRunning ? '--daemon-stop' : '--daemon-bg'])
1813
+ }
1814
+ return
1815
+ }
1816
+
1817
+ if (key.name === 't') {
1818
+ const entry = routingOrder[state.routerV2CursorIndex ?? 0]
1819
+ if (entry?.key) void testModelViaRouterV2(state, entry.key)
1820
+ return
1821
+ }
1822
+
1823
+ if (key.name === 'up' || key.name === 'k') {
1824
+ state.routerV2CursorIndex = Math.max(0, (state.routerV2CursorIndex ?? 0) - 1)
1825
+ return
1826
+ }
1827
+ if (key.name === 'down' || key.name === 'j') {
1828
+ state.routerV2CursorIndex = Math.min(maxCursor, (state.routerV2CursorIndex ?? 0) + 1)
1829
+ return
1830
+ }
1831
+ if (key.name === 'pageup') {
1832
+ state.routerV2ScrollOffset = Math.max(0, (state.routerV2ScrollOffset || 0) - pageStep)
1833
+ return
1834
+ }
1835
+ if (key.name === 'pagedown') {
1836
+ state.routerV2ScrollOffset = (state.routerV2ScrollOffset || 0) + pageStep
1837
+ return
1838
+ }
1839
+ if (key.name === 'home') {
1840
+ state.routerV2CursorIndex = 0
1841
+ state.routerV2ScrollOffset = 0
1842
+ return
1843
+ }
1844
+ if (key.name === 'i') {
1845
+ try { await cycleRouterV2ProbeMode(state) } catch {}
1846
+ return
1847
+ }
1848
+ if (key.name === 'c') {
1849
+ void (async () => {
1850
+ try {
1851
+ if (state.routerV2BaseUrl) {
1852
+ await fetch(`${state.routerV2BaseUrl}/api/router-v2/history`, { method: 'DELETE' })
1853
+ state.routerV2History = null
1854
+ setRouterV2Notice(state, 'success', 'Request history cleared.')
1855
+ }
1856
+ } catch {}
1857
+ })()
1858
+ return
1859
+ }
1860
+ return
1861
+ }
1862
+
1777
1863
  // 📖 Playground overlay: handles Enter, Esc, Backspace, Ctrl+S, Ctrl+L,
1778
1864
  // 📖 PageUp/PageDown, and printable input itself. The dedicated handler
1779
1865
  // 📖 in core/playground.js owns the draft and submission state.
@@ -2926,6 +3012,16 @@ export function createKeyHandler(ctx) {
2926
3012
  return
2927
3013
  }
2928
3014
 
3015
+ // 📖 Shift+V: Open / close the Router v2 (BETA) dashboard.
3016
+ if (key.name === 'v' && key.shift && !key.ctrl && !key.meta) {
3017
+ if (state.routerV2DashboardOpen) {
3018
+ closeRouterV2DashboardOverlay(state)
3019
+ return
3020
+ }
3021
+ openRouterV2DashboardOverlay(state)
3022
+ return
3023
+ }
3024
+
2929
3025
  // 📖 Shift+T: open the Token Usage screen.
2930
3026
  if (key.name === 't' && key.shift && !key.ctrl && !key.meta) {
2931
3027
  openTokenUsageOverlay()
@@ -3070,6 +3166,25 @@ export function createKeyHandler(ctx) {
3070
3166
  return
3071
3167
  }
3072
3168
 
3169
+ // 📖 Ctrl+T: test the selected model THROUGH the Router v2 daemon (beta).
3170
+ // 📖 Unlike Ctrl+A (direct provider call), this exercises the full routing
3171
+ // chain: schema normalization, pre-prompt, content gate and the breaker
3172
+ // update. The pinned-model request runs with failover disabled.
3173
+ if (key.ctrl && key.name === 't' && !key.shift) {
3174
+ const selected = state.visibleSorted?.[state.cursor]
3175
+ if (selected?.providerKey && selected?.modelId) {
3176
+ void testModelViaRouterV2(state, `${selected.providerKey}/${selected.modelId}`)
3177
+ }
3178
+ return
3179
+ }
3180
+
3181
+ // 📖 Ctrl+Shift+T: test every visible configured model through Router v2,
3182
+ // results streaming into the Shift+V overlay as they land.
3183
+ if (key.ctrl && key.shift && key.name === 't') {
3184
+ void testAllVisibleViaRouterV2(state)
3185
+ return
3186
+ }
3187
+
3073
3188
  if (key.shift && key.name === 'up') {
3074
3189
  const selected = state.visibleSorted?.[state.cursor]
3075
3190
  if (selected?.isFavorite) {