diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md index 6de247d0..52984350 100644 --- a/docs/TRANSCRIPTS.md +++ b/docs/TRANSCRIPTS.md @@ -202,8 +202,7 @@ The file is parsed with `withTurns: true` by the provider's parser (`usage-index.mjs:1518-1545`) with the same fields the Sessions view rows carry — `prompts`, `responses`, `exceptions`, `sidechain`, `threadSource`, `models`, `tools`, `skill`/`plugin`, worktree — plus a `cost` priced from the -same per-model usage rows `aggregate()` uses (the header used to render a -hardcoded `$0.00`; the comment at the site records why). +same per-model usage rows `aggregate()` uses. ### 4.3 Mask, then truncate — both marked, differently @@ -363,6 +362,11 @@ was wrong before, for the curious. - **Session expander fields shipped but unrendered.** The per-session fields §6.1's expander now renders (classification `basis` + confidence, the token split, flags) once travelled on the wire and rendered nowhere. +- **Transcript header once showed a hardcoded `$0.00`.** `readSession`'s + assembled `meta` left `cost` undefined, and `fmtUsd(undefined)` renders the + truthy string `"$0.00"` — a fixed-looking zero on a panel whose whole + subject is cost. `meta.cost` is now priced via `sessionCost()` from the + same per-model usage rows `aggregate()` uses (`usage-index.mjs:1538-1542`). - **Aggregate-side incidents** (the v4/v5 cache bumps, the Codex parsing defects) are recorded in `USAGE-SCORECARD-METRICS.md` Appendix A. diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index d29f9ba2..982f7f4a 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -687,7 +687,13 @@ export function startDashboard({ const usageApi = usage || lazyUsage(); // Injectable like `usage`: tests must never spawn a real codex or read the // real ~/.config through this route. Lazy for the same reason lazyUsage is. - const provideLimits = limits || (async () => (await import('./quota.mjs')).readLimits()); + // enabledHosts drives quota.mjs's F-10 labeling (any OTHER enabled host with + // no sanctioned quota channel) from the same kit.json read used elsewhere in + // this file (see loadKitConfig() below) — never a second, ad hoc source. + const provideLimits = limits || (async () => { + const { readLimits } = await import('./quota.mjs'); + return readLimits({ enabledHosts: loadKitConfig().integrations.hosts }); + }); const provideLive = typeof live === 'function' ? live : live ? async () => live : lazyLive(liveOptions); // Same injection contract as `live`: a function is called to produce the // collector, a value is reused verbatim (tests hand over a fake so the route diff --git a/src/lib/quota.mjs b/src/lib/quota.mjs index 0ef294a9..54e4e6e5 100644 --- a/src/lib/quota.mjs +++ b/src/lib/quota.mjs @@ -18,6 +18,12 @@ // server-enforced), no Keychain/credentials reads, no chatgpt.com backend // endpoints (private; requires bearer-token handling ak must not do). // +// Labeling policy (F-10): any OTHER registry-managed host (adapters/ +// registries.mjs) that the caller reports enabled gets an explicit +// `{ supported: false }` entry instead of being silently absent — this adds +// no channel and performs no probe, it only says "no quota surface exists +// for this host" so a user can tell that apart from "broken". +// // Field-name trap, load-bearing: Codex's `primary`/`secondary` window fields do // NOT reliably mean "5-hour"/"weekly" — a live prolite account answered with // `primary.windowDurationMins = 10080` (the weekly) and `secondary = null`. @@ -26,6 +32,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { configDir } from './paths.mjs'; +import { managedHostIds } from './adapters/registries.mjs'; export const claudeLimitsFile = () => path.join(configDir(), 'claude-rate-limits.json'); export const codexLimitsFile = () => path.join(configDir(), 'codex-rate-limits.json'); @@ -239,20 +246,45 @@ export async function collectCodexLimits({ return fresh; } +// ── Unsupported hosts (F-10 labeling policy) ──────────────────────────────── + +/** + * Every OTHER registry-managed host (session-driving, per adapters/ + * registries.mjs `managedHostIds()`) beyond the two ADR-0010-sanctioned + * channels, labeled `{ supported: false }` instead of left absent — IF the + * caller reports it enabled. A host the caller does not report as enabled is + * omitted entirely: a user who never turned it on should see nothing, not a + * label. Adds no channel; performs no probe. Pure. + * + * @param {{ enabledHosts?: Record }} [o] + */ +export function unsupportedQuotaHosts({ enabledHosts = {} } = {}) { + return managedHostIds() + .filter((id) => id !== 'claude' && id !== 'codex' && enabledHosts[id] === true) + .map((provider) => ({ provider, supported: false, reason: 'no quota surface for this host' })); +} + // ── Combined read (the /api/limits payload) ───────────────────────────────── /** * Both providers, plus the freshness contract the UI renders: `fetchedAt` on * each side and `generatedAt` overall. Claude is a pure file read (push - * model); Codex may spawn one vendor subprocess, TTL-bounded. + * model); Codex may spawn one vendor subprocess, TTL-bounded. `others` lists + * any additional enabled host with no sanctioned quota channel (F-10); + * omitting `enabledHosts` (the default) leaves it empty, so claude/codex + * output is unchanged unless a caller opts in. * * @param {{ now?: number, claudeFile?: string, codexCacheFile?: string, ttlMs?: number, - * timeoutMs?: number, spawnImpl?: any, bin?: string }} [o] + * timeoutMs?: number, spawnImpl?: any, bin?: string, + * enabledHosts?: Record }} [o] */ -export async function readLimits({ now = Date.now(), claudeFile, codexCacheFile, ttlMs, timeoutMs, spawnImpl, bin } = {}) { +export async function readLimits({ + now = Date.now(), claudeFile, codexCacheFile, ttlMs, timeoutMs, spawnImpl, bin, enabledHosts, +} = {}) { const claude = readClaudeLimits({ file: claudeFile ?? claudeLimitsFile() }); const codex = await collectCodexLimits({ ttlMs, cacheFile: codexCacheFile ?? codexLimitsFile(), now, timeoutMs, spawnImpl, bin, }); - return { generatedAt: new Date(now).toISOString(), claude, codex }; + const others = unsupportedQuotaHosts({ enabledHosts }); + return { generatedAt: new Date(now).toISOString(), claude, codex, others }; } diff --git a/tests/kit/quota.test.mjs b/tests/kit/quota.test.mjs index 9cea65cb..fdc4eba5 100644 --- a/tests/kit/quota.test.mjs +++ b/tests/kit/quota.test.mjs @@ -9,7 +9,7 @@ import path from 'node:path'; import { EventEmitter } from 'node:events'; import { windowLabel, normalizeClaudeLimits, normalizeCodexLimits, readClaudeLimits, - collectCodexLimits, CODEX_TTL_MS, + collectCodexLimits, CODEX_TTL_MS, unsupportedQuotaHosts, readLimits, } from '../../src/lib/quota.mjs'; const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ak-quota-')); @@ -192,3 +192,59 @@ test('collectCodexLimits returns null when there has never been an answer', asyn const out = await collectCodexLimits({ cacheFile: path.join(tmp(), 'none.json'), spawnImpl: failSpawn }); assert.equal(out, null); }); + +// ── unsupportedQuotaHosts — F-10: label absence instead of leaving it silent ─ +// +// ADR-0010 sanctions exactly two channels (claude's statusline tee, codex's +// app-server). This does not add a third — it never probes anything — it only +// says so, for any OTHER registry-managed host the caller reports enabled. + +test('unsupportedQuotaHosts labels an enabled managed host with no channel', () => { + const out = unsupportedQuotaHosts({ enabledHosts: { opencode: true } }); + assert.deepEqual(out, [ + { provider: 'opencode', supported: false, reason: 'no quota surface for this host' }, + ]); +}); + +test('unsupportedQuotaHosts omits a host the caller has not enabled', () => { + assert.deepEqual(unsupportedQuotaHosts(), []); + assert.deepEqual(unsupportedQuotaHosts({ enabledHosts: {} }), []); + assert.deepEqual(unsupportedQuotaHosts({ enabledHosts: { opencode: false } }), []); +}); + +test('unsupportedQuotaHosts never labels the two sanctioned channels', () => { + const out = unsupportedQuotaHosts({ enabledHosts: { claude: true, codex: true, opencode: true } }); + assert.deepEqual(out.map((h) => h.provider), ['opencode']); +}); + +// ── readLimits — the combined /api/limits payload ─────────────────────────── + +test('readLimits: claude/codex outputs are byte-identical whether or not others are reported', async () => { + const dir = tmp(); + const claudeFile = path.join(dir, 'claude-rate-limits.json'); + fs.writeFileSync(claudeFile, JSON.stringify(CLAUDE_TEE)); + const codexCacheFile = path.join(dir, 'codex-rate-limits.json'); + + const withoutOthers = await readLimits({ + now: 1000, claudeFile, codexCacheFile, spawnImpl: fakeSpawn(CODEX_RESP), + }); + const withOthers = await readLimits({ + now: 1000, claudeFile, codexCacheFile: path.join(dir, 'codex-rate-limits-2.json'), + spawnImpl: fakeSpawn(CODEX_RESP), enabledHosts: { claude: true, codex: true, opencode: true }, + }); + + assert.deepEqual(withOthers.claude, withoutOthers.claude); + assert.deepEqual(withOthers.codex, withoutOthers.codex); + assert.deepEqual(withoutOthers.others, []); + assert.deepEqual(withOthers.others, [ + { provider: 'opencode', supported: false, reason: 'no quota surface for this host' }, + ]); +}); + +test('readLimits defaults to no unsupported-host labels when enabledHosts is not passed', async () => { + const out = await readLimits({ + now: 1000, claudeFile: path.join(tmp(), 'absent.json'), + codexCacheFile: path.join(tmp(), 'codex.json'), spawnImpl: failSpawn, + }); + assert.deepEqual(out.others, []); +});