diff --git a/CHANGELOG.md b/CHANGELOG.md index 52ee4e4..68f7ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,41 @@ +- [2026-09-01] scope the installStatus fallback to top-level keys + +The parse-failure fallback matched our selection line anywhere in the file, +but a model_provider under a table belongs to that table: a legacy selection +swallowed into [notice], or a [profiles.x] naming our provider while another +is active, would read as routed — and healInstalledConfigs would re-enable +routing from that. Scan only the region before the first table header. +Also pin that the installed fixture genuinely breaks Bun.TOML, so the +regression test keeps exercising the fallback if bun's parser improves. +- [2026-09-01] [installStatus reads routed state when Bun.TOML cannot parse the config + +Bun.TOML.parse rejects bare table keys that start with a digit — a real-world +example is [mcp_servers.1password], which codex itself writes and accepts. +On any config containing such a table, installStatus() threw into its catch +and reported codexRouted = false no matter what the file actually said. + +The visible damage: the dashboard's Settings page showed codex routing off +while model_provider = "tokenmaxx" was actively sending traffic through the +proxy, and pressing the routing toggle computed enable from that wrong false +and re-installed the managed block instead of removing it — turning routing +off through the dashboard was impossible on such a config. + +On parse failure, fall back to detecting our own active selection line +(commented-out lines never match). The semantic TOML check stays the primary +path for configs that parse.](https://github.com/dallascrilley/tokenmaxx/commit/008b7297c177f2aa122f4ec78517e931f46b47fb) +- [2026-09-01] [keep settings routing state live + +The dashboard received routing once at launch (options.routing) and never +re-read it, so Settings kept showing the launch-time snapshot for the whole +session. Routing actually lives in the harness config files and can change +while the dashboard is open — tokenmaxx install/uninstall from another +shell, first-login auto-enable, or the daemon's post-update heal — leaving +Settings contradicting the on-disk config (e.g. showing codex routing off +while model_provider still points at the proxy). + +reload() now re-reads installStatus() alongside piStatus(), so the 2s tick +and manual refresh both bring the display back to the files' truth, and the +routing toggle flips from the live value instead of the stale snapshot.](https://github.com/dallascrilley/tokenmaxx/commit/db617e8573c9e5390f43a9f9d732275b2a205c5e) - [2026-08-18] meter clients that hang up early - [2026-08-18] reclaim bare provider tables - [2026-08-18] settings shows pi diff --git a/package.json b/package.json index 04a0258..aab76fc 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.65" + "version": "0.0.68" } diff --git a/src/config-install.test.ts b/src/config-install.test.ts index a7da7c3..4fca51e 100644 --- a/src/config-install.test.ts +++ b/src/config-install.test.ts @@ -89,6 +89,47 @@ describe('installCodexConfig', () => { expect(status.codexStale).toBe(false) }) + test('installStatus stays truthful when Bun.TOML cannot parse the config', async () => { + // Bun.TOML rejects bare table keys that start with a digit, like + // [mcp_servers.1password]; codex accepts them. A parse failure must not + // read as "not routed" while our selection line is active. + await writeCodexConfig( + `${legacyBrokenConfig}\n[mcp_servers.1password]\ncommand = "1password-mcp"\nenabled = false\n` + ) + await installCodexConfig(paths()) + const written = await readCodexConfig() + expect(() => Bun.TOML.parse(written)).toThrow() + const status = await installStatus() + expect(status.codexRouted).toBe(true) + }) + + test('an unparseable config without our selection still reads as not routed', async () => { + await writeCodexConfig( + 'model = "gpt-5.6-sol"\n\n[mcp_servers.1password]\ncommand = "1password-mcp"\n' + ) + const status = await installStatus() + expect(status.codexRouted).toBe(false) + }) + + test('the fallback ignores a swallowed legacy selection under a table', async () => { + // legacyBrokenConfig's model_provider = "tokmax" sits under [notice], so + // codex never routes through it; the digit table only breaks parsing. + await writeCodexConfig( + `${legacyBrokenConfig}\n[mcp_servers.1password]\ncommand = "1password-mcp"\n` + ) + const status = await installStatus() + expect(status.codexRouted).toBe(false) + expect(status.codexStale).toBe(true) + }) + + test('the fallback ignores our provider named inside a codex profile', async () => { + await writeCodexConfig( + 'model_provider = "ollama"\n\n[profiles.work]\nmodel_provider = "tokenmaxx"\n\n[mcp_servers.1password]\ncommand = "1password-mcp"\n' + ) + const status = await installStatus() + expect(status.codexRouted).toBe(false) + }) + test('reinstall is idempotent', async () => { await writeCodexConfig(legacyBrokenConfig) await installCodexConfig(paths()) diff --git a/src/config-install.ts b/src/config-install.ts index fb10149..033a999 100644 --- a/src/config-install.ts +++ b/src/config-install.ts @@ -196,7 +196,15 @@ export async function installStatus(): Promise { const baseUrl = selected === null ? undefined : parsed.model_providers?.[selected]?.base_url codexRouted = typeof baseUrl === 'string' && baseUrl.includes('127.0.0.1') } catch { - codexRouted = false + // Bun.TOML rejects configs codex accepts — bare table keys starting with a + // digit, like [mcp_servers.1password]. Reading that as "not routed" makes + // the dashboard show routing off while traffic flows through the proxy, + // and turns the routing toggle into a re-install. Fall back to our own + // active selection line, scanning only the top-level region: a + // model_provider line under a table belongs to that table, not to codex. + const firstTable = codexRaw.search(/^\[/m) + const topLevel = firstTable === -1 ? codexRaw : codexRaw.slice(0, firstTable) + codexRouted = topLevel.split('\n').some(line => ownProviderSelection.test(line)) } const codexStale = !codexRouted && diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index 61209ad..4cebbfb 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -1,5 +1,11 @@ import { Box, createCliRenderer, parseColor, type RGBA, Text } from '@opentui/core' -import { installPiConfig, type PiStatus, piStatus, uninstallPiConfig } from '../config-install.ts' +import { + installPiConfig, + installStatus, + type PiStatus, + piStatus, + uninstallPiConfig +} from '../config-install.ts' import type { Account, AnalyticsSnapshot, @@ -1347,6 +1353,11 @@ export async function runTuiDashboard( : buildScenario(fixture.name, simulatedNow) let rows = orderedRows(analytics.snapshot) let pi: PiStatus = live ? await piStatus() : { present: true, routed: true } + // Routing is derived from the harness config files, which can change while + // this dashboard is open (tokenmaxx install/uninstall from another shell, + // first-login auto-enable, daemon heal after an update). options.routing is + // only the launch-time snapshot; reload() keeps this current. + let routing = options.routing const state: ViewState = { addConfirm: null, alert: options.alert ?? '', @@ -1387,7 +1398,7 @@ export async function runTuiDashboard( columns, now: live ? Date.now() : simulatedNow, pi, - routing: options.routing, + routing, rows: process.stdout.rows ?? 24, switchFlagMs: fixture !== undefined && fixture.timewarp > 0 ? 24 * 60_000 : 120_000, theme: currentTheme(), @@ -1436,6 +1447,8 @@ export async function runTuiDashboard( analytics = await readAnalytics(socketPath) rows = orderedRows(analytics.snapshot) pi = await piStatus() + const status = await installStatus() + routing = { anthropic: status.claudeRouted, openai: status.codexRouted } clampSelection() }) @@ -1573,7 +1586,7 @@ export async function runTuiDashboard( } const toggleRouting = (provider: ProviderId) => { - finish({ enable: !options.routing[provider], kind: 'routing', provider }) + finish({ enable: !routing[provider], kind: 'routing', provider }) } const adjustSetting = (delta: number) => {