mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-25 08:22:42 +00:00
Merge remote-tracking branch 'upstream/main' into fix/968-subscription-flat-rate
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
ac96089fd5
23 changed files with 742 additions and 160 deletions
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
|
|
@ -37,6 +37,10 @@ jobs:
|
|||
# tests spawn deliberately. Quarantined (reports, never gates): the process
|
||||
# suite still races its own takeover window even serially on slow runners -
|
||||
# tracked in #904; drop continue-on-error once that race is settled.
|
||||
# Step-level timeout so a stalled lock suite fails soft here instead of
|
||||
# tripping the job's 15-minute budget, which kills the whole job as
|
||||
# "cancelled" and hides the parallel suite's green result.
|
||||
- name: Cache-lock suite (serial, quarantined)
|
||||
continue-on-error: true
|
||||
timeout-minutes: 5
|
||||
run: npm run test:locks
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@
|
|||
- **A date-ranged report classifies only the turns it keeps.** Every cached turn went through the turn classifier — category, retries, edit detection, and a full reconstruction of its API calls — before the date slice discarded most of them, so a week view paid to classify all of history to keep a few percent of it. The keep/drop decision is now taken on the raw cached turn and only the survivors are classified, still from their complete call list, with the branch and pull-request carries still walking the full ordered turn list. Output is byte-identical.
|
||||
- **One rule for every cache file.** `CODEBURN_CACHE_DIR` when set, otherwise `~/.cache/codeburn`. `XDG_CACHE_HOME` is no longer consulted; the sync ledger, the only file that ever honored it, is merged into the canonical location on first read and the legacy copy is retired, so nothing is re-uploaded after the move. (#972)
|
||||
|
||||
### Changed (Linux packaging)
|
||||
- **The snap asks for the log directories it reads, not each tool's whole home.** The first Snap Store submission declared a `personal-files` read of every AI tool's root — `$HOME/.claude`, `$HOME/.codex`, `$HOME/.cursor` and the rest — and that interface is recursive, so it granted read of every credential file those roots hold. Each entry now names the subdirectory the provider actually opens (`.claude/projects`, `.codex/sessions`, `.cline/data`, `.vibe/logs/session`, `.dsh/sessions`, `.kiro/sessions`, `.quickwork/{profiles.json,sessions,metrics}`, `.config/Claude/local-agent-mode-sessions`, `.config/Open Design/{runs,data/runs,namespaces}`), two are single files (`.forge/.forge.db`, `.zcode/cli/db/db.sqlite`), and the editor entries name only the extension folders holding transcripts instead of the editor's whole configuration. Five providers that were missing entirely and would have shown no data are declared — opencode, crush, goose, kilo, kimi-code — and four roots stay roots only because the file the provider opens sits directly in them (`.config/github-copilot`, `.local/share/{opencode,crush,kilo}`). One credential file is now requested openly rather than implicitly: `.claude/.credentials.json`, read-only, for the live plan gauge. Codex's equivalent would need write access to the Codex CLI's own `auth.json` to rotate the token, so neither it nor a Codex root is declared and the Codex live gauge is disabled under `$SNAP`; Codex usage and cost are unaffected, they come from the session rollouts. Two consequences inside the snap: `.lingtai` is dropped, because its per-agent log directory needs a wildcard the interface has no form for, and `optimize`, `context-budget` and `act` no longer see the user-scope `~/.claude/settings.json`, `agents/`, `skills/` and `commands/` — project-scope copies still work through the `home` plug. Nothing outside the snap changes.
|
||||
|
||||
### Fixed (Desktop & Menubar)
|
||||
- **The menubar's copies of your Claude and Codex credentials move out of Application Support and into the login Keychain.** Connecting a provider used to leave the copied OAuth material in `~/Library/Application Support/CodeBurn/*-credentials.v1.json`, written world-readable (0644) because macOS ignores `.completeFileProtection` outside iOS. The copy now lives in a CodeBurn-owned login-Keychain item, and the first read after upgrading migrates the old file: it is reopened with `O_NOFOLLOW`, refused if it is a symlink or not owned by you, repaired to 0600 before a single secret byte is read, written to the Keychain, read back and compared, and only then unlinked — a failed or unverified write leaves the (now 0600) file in place so a retry can still find it, and the next read retries the cleanup. Where both a Keychain item and an old file exist, the one that expires later wins before anything is removed, so an item left behind by a much older build cannot displace a fresher token. Claude's entry no longer stores a refresh token at all — the CLI owns that grant and the menubar never spends it — and any refresh token in a historical blob is dropped on read. Disconnect only reports success once the material is actually gone; if the delete fails it says so and leaves the provider connected so you can retry. Keychain reads are non-interactive and are skipped outright while the login Keychain is locked, so a background quota refresh can never raise an unlock panel. (#1037)
|
||||
- **First launch no longer asks to control System Events.** The macOS menubar registered its login item by driving System Events over AppleScript, which made macOS put up an Automation consent dialog the first time the app ran. It now registers itself through `SMAppService.mainApp`, an in-process call that needs no Automation grant; there is no AppleScript fallback, so a failure logs and leaves the login item unset rather than bringing the prompt back. The same `codeburn.loginItemRegistered` guard still limits this to the first launch, so a login item you removed by hand stays removed. (#1026)
|
||||
|
|
@ -36,6 +39,8 @@
|
|||
|
||||
### Fixed
|
||||
- **Subscription SKUs are classified from real product ids, and a false-positive built-in can be opted out.** `codex-auto-review` consumes ordinary Codex usage ([openai/codex#32224](https://github.com/openai/codex/issues/32224)) and is priced as GPT-5.5 on #1056, so treating it as $0 hid real spend — it left the flat-rate list. Warp's product id is `auto`, not the synthetic `warp`. `kimi-for-coding-highspeed` (the SKU #968 was filed around) is now honestly $0. `big-pickle` was dropped: it appears under OpenCode, not as a cited ClinePass codename. `codeburn model-flat-rate --remove` now opts out of a built-in, so a wrong classifier entry can warn again without waiting for a release. The daily-cache config hash now always includes the flat-rate section (even when empty), so the first run after upgrade re-derives every stored day once from the warm session cache. (#968, #1050)
|
||||
- **Codex calls attributed from session metadata no longer carry a stale model.** The Buffer fast path scanned `session_meta` for the first `"model"` string anywhere in the payload, so a nested `base_instructions.provenance.model` was read as if it were `payload.model` — and since the model is last-writer-wins state, that wrong value was credited to every call before the rollout's first `turn_context` and to every call after any mid-file `session_meta` (29 of 1380 rollouts on one real corpus carry a late `session_meta`, and 57 record usage before any `turn_context`). Direct payload fields are now read depth-aware, which is what the non-fast `JSON.parse` path always did. Codex sessions re-parse once (~9s on a 4 GB rollout corpus) and the daily cache re-derives once off the warm session cache, a global re-derivation of every day and every provider since it has no per-provider invalidation; it moves per-model attribution, and clears any rollup an earlier parse change had left stale. Days whose transcripts have partly aged out are held by the never-lose guard: on a real 110-day cache no day lost value and none disappeared — 100 days came back identical and 9 grok days rose by $19.80 in total. Thanks @timdp. (#1040)
|
||||
- **Codex `session_meta` cwd / session id / originator follow the same depth-1 window as `model`.** #1040 fixed nested `provenance.model`; the compact Buffer path still took the first `cwd`, `session_id`, `originator`, `name`, `forked_from_id` or `model_provider` anywhere in the payload, so a `dynamic_tools[].name` (or any same-named nested key) could steal the top-level field. Those strings now use the existing payload-depth-1 scan. Function-call `name` on other event types is unchanged. Codex sessions re-parse once. (#1045)
|
||||
- **Plan rows for sticker-price presets read as a budget instead of live provider quota.** There is no Grok quota endpoint, so a SuperGrok row was parsed API-equivalent spend divided by the plan's sticker price on a monthly reset — but the TUI labelled that math "plan" and "reset", which next to a client showing xAI's real weekly window read as CodeBurn being wrong. The bars and the arithmetic are unchanged; the words are not. Both the dashboard and the desktop app now say the number is an API-equivalent monthly budget and not a live provider window, in the same wording on both surfaces, and for every preset rather than as a SuperGrok special case. The window is anniversary-based (`plan.resetDay`, settable with `codeburn plan set --reset-day`), so it is called a budget reset rather than a calendar one. The row was also shortened to fit 80 columns: at that width the percentage and the projected month were being truncated away, including on custom plans, whose label carries the provider.
|
||||
- **MiMo sessions price from the LiteLLM Xiaomi rows, and MiMo v2 Flash no longer crashes the display path.** Hermes / Xiaomi token-plan sessions store the bare id (`mimo-v2.5-pro`, `mimo-v2.5`) while LiteLLM namespaces its row (`xiaomi/…`), so those models reported $0. They now alias to the existing snapshot rows — no invented rate, and `kimi-k3` still has none — which means a session Hermes left costless is priced from the shared tables and carries the estimated marker, exactly as `mimo-v2-flash` already did. The same change fixes a **pre-existing** crash that this alias did not introduce: the shipped `mimo-v2-flash -> xiaomi/mimo-v2-flash` alias already cycled through display-name resolution — strip the namespace, alias it back, take the leaf, repeat — so `getShortModelName` blew the stack on any real MiMo v2 Flash session and took every surface that names a model down with it, the `models` table included. Display-name resolution is now cycle-safe, and the `mimo-v2-flash` and `mimo-v2.5` rows are named rather than shown as raw slugs.
|
||||
- **A date-ranged run no longer republishes the month shards it never read.** A scoped load leaves an out-of-range month on disk, so the files it holds have no visible cache entry and the reconcile re-parses them — re-deriving the entry the shard already stores. That re-parse marked the unloaded month dirty, and the save merged and republished it under a fresh nonce name on every single run, byte-identical content and all, so a repeated `codeburn status --format json` churned old months (on a real corpus: claude/2026-03, cursor/2026-02 and warp/2026-03 renamed every run) and left the retired shards for the sweeper. A merge into an unloaded month that neither adds, changes nor removes an entry now keeps the published shard, so unchanged months keep their names and their bytes. (#1032)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,32 @@ const quota = (provider: 'claude' | 'codex'): QuotaProvider => ({
|
|||
})
|
||||
|
||||
describe('QuotaService', () => {
|
||||
// The snap declares no Codex credential path, because the live gauge would
|
||||
// need write access to the Codex CLI's own auth.json to rotate the token.
|
||||
// Under $SNAP the Codex fetch must not run at all; Claude is unaffected.
|
||||
it('skips the Codex live gauge under snap confinement', async () => {
|
||||
const previous = process.env['SNAP']
|
||||
process.env['SNAP'] = '/snap/codeburn/current'
|
||||
try {
|
||||
const claude = vi.fn(async () => ({ quota: quota('claude') }))
|
||||
const codex = vi.fn(async () => ({ quota: quota('codex') }))
|
||||
const service = new QuotaService({
|
||||
claude, codex, now: () => Date.parse('2026-08-14T00:00:00Z'),
|
||||
readFile: vi.fn(async () => null),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
statePath: '/mock/backoff.json',
|
||||
})
|
||||
const [claudeQuota, codexQuota] = await service.getQuota({ force: true })
|
||||
expect(codex).not.toHaveBeenCalled()
|
||||
expect(claude).toHaveBeenCalledTimes(1)
|
||||
expect(codexQuota?.connection).toBe('disconnected')
|
||||
expect(claudeQuota?.connection).toBe('connected')
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['SNAP']
|
||||
else process.env['SNAP'] = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('persists provider 429 blocked-until and gates the next forced fetch', async () => {
|
||||
const writes: string[] = []
|
||||
const claude = vi.fn(async () => ({ quota: quota('claude'), retryAfterSeconds: 60 }))
|
||||
|
|
|
|||
|
|
@ -37,6 +37,19 @@ function unavailable(provider: ProviderName, connection: QuotaProvider['connecti
|
|||
return { provider, connection, primary: null, details: [], planLabel: null, footerLines: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Codex live gauge needs read-write access to the Codex CLI's own
|
||||
* `~/.codex/auth.json`, because refreshing the OAuth grant rotates the token
|
||||
* and writes it back. A store-distributed snap should not hold write access to
|
||||
* another vendor's credential file, so the snap's personal-files declaration
|
||||
* requests neither that file nor a Codex root, and the gauge is disabled here
|
||||
* to match. Codex usage and cost analytics are unaffected: those come from the
|
||||
* session rollouts under `~/.codex/sessions`, which the snap does read.
|
||||
*/
|
||||
function codexQuotaSupported(): boolean {
|
||||
return !process.env['SNAP']
|
||||
}
|
||||
|
||||
export class QuotaService {
|
||||
private readonly deps: QuotaDeps
|
||||
private cache: { at: number; value: QuotaProvider[] } | null = null
|
||||
|
|
@ -115,7 +128,10 @@ export class QuotaService {
|
|||
if (this.controllers[provider] === controller) this.controllers[provider] = undefined
|
||||
return retainOnFailure(result.quota)
|
||||
}
|
||||
const value = await Promise.all([run('claude'), run('codex')])
|
||||
const value = await Promise.all([
|
||||
run('claude'),
|
||||
codexQuotaSupported() ? run('codex') : Promise.resolve(unavailable('codex', 'disconnected')),
|
||||
])
|
||||
if (startingGenerations.claude === this.generations.claude && startingGenerations.codex === this.generations.codex) {
|
||||
this.cache = { at: this.deps.now(), value }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,42 +162,70 @@
|
|||
"ai-agent-session-logs": {
|
||||
"interface": "personal-files",
|
||||
"read": [
|
||||
"$HOME/.claude",
|
||||
"$HOME/.cline",
|
||||
"$HOME/.codewhale",
|
||||
"$HOME/.codex",
|
||||
"$HOME/.copilot",
|
||||
"$HOME/.cursor",
|
||||
"$HOME/.deepseek",
|
||||
"$HOME/.claude/projects",
|
||||
"$HOME/.claude/.credentials.json",
|
||||
"$HOME/.codex/sessions",
|
||||
"$HOME/.codex/archived_sessions",
|
||||
"$HOME/.cline/data",
|
||||
"$HOME/.codewhale/sessions",
|
||||
"$HOME/.copilot/session-state",
|
||||
"$HOME/.cursor/projects",
|
||||
"$HOME/.cursor/ai-tracking",
|
||||
"$HOME/.deepseek/sessions",
|
||||
"$HOME/.dsh/sessions",
|
||||
"$HOME/.factory",
|
||||
"$HOME/.forge",
|
||||
"$HOME/.gemini",
|
||||
"$HOME/.grok",
|
||||
"$HOME/.hermes",
|
||||
"$HOME/.kimi",
|
||||
"$HOME/.kiro",
|
||||
"$HOME/.kiro-server",
|
||||
"$HOME/.lingtai",
|
||||
"$HOME/.lingtai-tui",
|
||||
"$HOME/.mux",
|
||||
"$HOME/.omp",
|
||||
"$HOME/.openclaude",
|
||||
"$HOME/.pi",
|
||||
"$HOME/.quickwork",
|
||||
"$HOME/.qwen",
|
||||
"$HOME/.vibe",
|
||||
"$HOME/.zcode",
|
||||
"$HOME/.config/Claude",
|
||||
"$HOME/.config/Code",
|
||||
"$HOME/.config/Code - Insiders",
|
||||
"$HOME/.config/Cursor",
|
||||
"$HOME/.config/Kiro",
|
||||
"$HOME/.config/Open Design",
|
||||
"$HOME/.config/VSCodium",
|
||||
"$HOME/.factory/sessions",
|
||||
"$HOME/.gemini/tmp",
|
||||
"$HOME/.gemini/antigravity",
|
||||
"$HOME/.gemini/antigravity-cli",
|
||||
"$HOME/.gemini/antigravity-ide",
|
||||
"$HOME/.grok/sessions",
|
||||
"$HOME/.hermes/profiles",
|
||||
"$HOME/.hermes/state.db",
|
||||
"$HOME/.kimi/sessions",
|
||||
"$HOME/.kimi-code/sessions",
|
||||
"$HOME/.kiro/sessions",
|
||||
"$HOME/.mux/sessions",
|
||||
"$HOME/.mux/config.json",
|
||||
"$HOME/.omp/agent",
|
||||
"$HOME/.openclaude/projects",
|
||||
"$HOME/.openclaw/agents",
|
||||
"$HOME/.pi/agent",
|
||||
"$HOME/.qwen/projects",
|
||||
"$HOME/.quickwork/profiles.json",
|
||||
"$HOME/.quickwork/sessions",
|
||||
"$HOME/.quickwork/metrics",
|
||||
"$HOME/.vibe/logs/session",
|
||||
"$HOME/.forge/.forge.db",
|
||||
"$HOME/.zcode/cli/db/db.sqlite",
|
||||
"$HOME/.config/Claude/local-agent-mode-sessions",
|
||||
"$HOME/.config/Code/User/globalStorage/saoudrizwan.claude-dev",
|
||||
"$HOME/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline",
|
||||
"$HOME/.config/Code/User/globalStorage/kilocode.kilo-code",
|
||||
"$HOME/.config/Code/User/globalStorage/GitHub.copilot-chat",
|
||||
"$HOME/.config/Code/User/globalStorage/emptyWindowChatSessions",
|
||||
"$HOME/.config/Code/User/workspaceStorage",
|
||||
"$HOME/.config/Code - Insiders/User/globalStorage/saoudrizwan.claude-dev",
|
||||
"$HOME/.config/Code - Insiders/User/globalStorage/rooveterinaryinc.roo-cline",
|
||||
"$HOME/.config/Code - Insiders/User/globalStorage/kilocode.kilo-code",
|
||||
"$HOME/.config/Code - Insiders/User/workspaceStorage",
|
||||
"$HOME/.config/VSCodium/User/globalStorage/saoudrizwan.claude-dev",
|
||||
"$HOME/.config/VSCodium/User/globalStorage/rooveterinaryinc.roo-cline",
|
||||
"$HOME/.config/VSCodium/User/globalStorage/kilocode.kilo-code",
|
||||
"$HOME/.config/VSCodium/User/workspaceStorage",
|
||||
"$HOME/.config/Cursor/User/globalStorage/state.vscdb",
|
||||
"$HOME/.config/Kiro/User/globalStorage/kiro.kiroagent",
|
||||
"$HOME/.config/Kiro/User/workspaceStorage",
|
||||
"$HOME/.config/Open Design/runs",
|
||||
"$HOME/.config/Open Design/data/runs",
|
||||
"$HOME/.config/Open Design/namespaces",
|
||||
"$HOME/.kiro-server/data/User/globalStorage/kiro.kiroagent",
|
||||
"$HOME/.config/github-copilot",
|
||||
"$HOME/.config/manicode",
|
||||
"$HOME/.local/share/zed"
|
||||
"$HOME/.config/manicode/projects",
|
||||
"$HOME/.local/share/zed/threads",
|
||||
"$HOME/.local/share/opencode",
|
||||
"$HOME/.local/share/crush",
|
||||
"$HOME/.local/share/goose/sessions",
|
||||
"$HOME/.local/share/kilo"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
app/scripts/snap-grants.test.ts
Normal file
45
app/scripts/snap-grants.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
// Snap Store review rejected the first submission because every entry was a
|
||||
// tool's whole root, and personal-files read is recursive: granting $HOME/.claude
|
||||
// granted .claude/.credentials.json with it. Each entry must name the log
|
||||
// directory (or file) the provider actually opens, never the root above it.
|
||||
// The exceptions below are roots only because the provider reads a file sitting
|
||||
// directly in them, so no narrower path exists without wildcards.
|
||||
const ROOT_GRANTS_WITH_NO_NARROWER_FORM = new Set([
|
||||
'$HOME/.config/github-copilot', // JetBrains stores nest under a variable <ide>/<kind>/<storeId>
|
||||
'$HOME/.local/share/opencode', // opencode*.db sits in the data dir itself
|
||||
'$HOME/.local/share/crush', // projects.json sits in the data dir itself
|
||||
'$HOME/.local/share/kilo', // kilo*.db sits in the data dir itself
|
||||
])
|
||||
|
||||
const XDG_PARENTS = ['.config', '.local']
|
||||
|
||||
function readGrants(): string[] {
|
||||
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'))
|
||||
const plug = pkg.build.snap.plugs.find((p: unknown) => typeof p === 'object')
|
||||
return plug['ai-agent-session-logs'].read
|
||||
}
|
||||
|
||||
describe('snap personal-files declaration', () => {
|
||||
it('names a log path under each tool root, never the root itself', () => {
|
||||
const bare: string[] = []
|
||||
for (const entry of readGrants()) {
|
||||
if (ROOT_GRANTS_WITH_NO_NARROWER_FORM.has(entry)) continue
|
||||
const segments = entry.replace('$HOME/', '').split('/')
|
||||
const depth = XDG_PARENTS.includes(segments[0] ?? '') ? 3 : 2
|
||||
if (segments.length < depth) bare.push(entry)
|
||||
}
|
||||
expect(bare).toEqual([])
|
||||
})
|
||||
|
||||
it('requests read only, and one credential file explicitly', () => {
|
||||
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'))
|
||||
const plug = pkg.build.snap.plugs.find((p: unknown) => typeof p === 'object')['ai-agent-session-logs']
|
||||
expect(Object.keys(plug).sort()).toEqual(['interface', 'read'])
|
||||
expect(readGrants().filter(e => e.includes('credential') || e.includes('auth.json')))
|
||||
.toEqual(['$HOME/.claude/.credentials.json'])
|
||||
})
|
||||
})
|
||||
|
|
@ -33,7 +33,7 @@ const WORK = process.env['UPGRADE_PATH_WORK'] || join(tmpdir(), 'codeburn upgrad
|
|||
const OLD_SESSION_CACHE = 'session-cache.v7.json'
|
||||
const OLD_DAILY_CACHE = 'daily-cache.v17.json'
|
||||
const NEW_SESSION_CACHE_DIR = 'session-cache.v9'
|
||||
const NEW_DAILY_CACHE = 'daily-cache.v19.json'
|
||||
const NEW_DAILY_CACHE = 'daily-cache.v20.json'
|
||||
|
||||
const HOME = join(WORK, 'user home')
|
||||
const PAYLOADS = join(WORK, 'payloads')
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import type { ParsedProviderCall } from './providers/types.js'
|
|||
// resumeState): they are additive and absence-safe in both directions, so a
|
||||
// bump would only throw away a warm multi-hundred-MB cache to gain nothing. An
|
||||
// entry without them simply re-parses in full once and gains them.
|
||||
const CODEX_CACHE_VERSION = 8
|
||||
// v9: parse large session_meta records structurally so nested provenance.model
|
||||
// cannot overwrite the model selected by turn_context.
|
||||
// v10: same depth-1 window for the rest of session_meta's raw string fields
|
||||
// (cwd/name/originator/session_id/forked_from_id/model_provider).
|
||||
const CODEX_CACHE_VERSION = 10
|
||||
const CACHE_FILE = 'codex-results.json'
|
||||
|
||||
export type CodexFileFingerprint = { dev: number; ino: number; mtimeMs: number; sizeBytes: number }
|
||||
|
|
|
|||
|
|
@ -114,6 +114,11 @@ async function getExchangeRate(code: string): Promise<number> {
|
|||
const cached = await loadCachedRate(code)
|
||||
if (cached) return cached
|
||||
|
||||
// Test-only escape hatch, set for the whole suite in
|
||||
// tests/setup/env-isolation.ts: skip the live Frankfurter fetch so a real FX
|
||||
// move can't shift assertions. Same fallback an unreachable network gets.
|
||||
if (process.env['CODEBURN_FX_NO_FETCH']) return 1
|
||||
|
||||
let rate: number
|
||||
try {
|
||||
rate = await fetchRate(code)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,24 @@ import { join } from 'path'
|
|||
import { getCodeburnCacheDir } from './cache-dir.js'
|
||||
import type { DateRange, ProjectSummary } from './types.js'
|
||||
|
||||
// Bumped to 20: the Codex fast-path read a nested
|
||||
// `base_instructions.provenance.model` out of `session_meta` as if it were
|
||||
// `payload.model` (#1040), so every call a rollout attributed from session
|
||||
// metadata - the ones before its first `turn_context`, and every one after a
|
||||
// mid-file `session_meta` - was credited to the wrong model. The codex parse
|
||||
// version and CODEX_CACHE_VERSION move with it, but the daily cache has no
|
||||
// per-provider invalidation, so days already finalized keep the wrong model
|
||||
// rows forever unless MIN_SUPPORTED_VERSION moves too. This pass re-derives
|
||||
// ALL days for EVERY provider off the warm session cache, so it costs seconds
|
||||
// rather than a full re-parse, and it is lossless: adoptOlderDailyCaches keeps
|
||||
// the superseded v19 file as the baseline, and the partial-survival guard from
|
||||
// #1033 holds any day whose sources have only partly aged out. On a real
|
||||
// 110-day cache the 19 -> 20 pass moved ZERO days down and lost none: 100 days
|
||||
// came back byte-identical and 9 grok days rose by $19.80 in total, clearing
|
||||
// rollups the grok parse change had left stale, while every codex model row
|
||||
// stayed identical (that corpus predates the `provenance` field, so the fix it
|
||||
// carries has nothing to correct there).
|
||||
//
|
||||
// Bumped to 19: Grok authoritative usage now keeps one session-level rollup
|
||||
// from top-level totals, clamps reasoning to reported output, and labels mixed
|
||||
// authoritative/heuristic coverage. Every day finalized under the previous
|
||||
|
|
@ -92,8 +110,8 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// that older binaries skipped. v8 added local-model savings to the daily
|
||||
// rollup; the `savingsConfigHash` field is invalidated separately when the
|
||||
// user changes their `localModelSavings` mapping.
|
||||
export const DAILY_CACHE_VERSION = 19
|
||||
const MIN_SUPPORTED_VERSION = 19
|
||||
export const DAILY_CACHE_VERSION = 20
|
||||
const MIN_SUPPORTED_VERSION = 20
|
||||
// Version-suffixed so different binaries each own a distinct file and never
|
||||
// clobber an incompatible schema. Bumping the version mints a fresh filename;
|
||||
// adoptOlderDailyCaches then unions days out of every previous file (including
|
||||
|
|
|
|||
|
|
@ -225,21 +225,30 @@ function mergeSnapshotFallbacks(pricing: Map<string, ModelCosts>): Map<string, M
|
|||
return applyBuiltinPriceOverrides(pricing)
|
||||
}
|
||||
|
||||
function setPricingCache(pricing: Map<string, ModelCosts>): void {
|
||||
pricingCache = pricing
|
||||
sortedPricingKeys = null
|
||||
lowercasePricingIndex = null
|
||||
knownNamespaces = null
|
||||
}
|
||||
|
||||
export async function loadPricing(): Promise<void> {
|
||||
const cached = await loadCachedPricing()
|
||||
if (cached) {
|
||||
pricingCache = mergeSnapshotFallbacks(cached)
|
||||
sortedPricingKeys = null
|
||||
lowercasePricingIndex = null
|
||||
knownNamespaces = null
|
||||
setPricingCache(mergeSnapshotFallbacks(cached))
|
||||
return
|
||||
}
|
||||
|
||||
// Test-only escape hatch, set for the whole suite in
|
||||
// tests/setup/env-isolation.ts: skip the live LiteLLM fetch and price purely
|
||||
// off the bundled snapshot, so an upstream reprice can't turn tests red.
|
||||
if (process.env['CODEBURN_PRICING_SNAPSHOT_ONLY']) {
|
||||
setPricingCache(mergeSnapshotFallbacks(new Map()))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
pricingCache = mergeSnapshotFallbacks(await fetchAndCachePricing())
|
||||
sortedPricingKeys = null
|
||||
lowercasePricingIndex = null
|
||||
knownNamespaces = null
|
||||
setPricingCache(mergeSnapshotFallbacks(await fetchAndCachePricing()))
|
||||
} catch {
|
||||
// snapshot already loaded at init; nothing more to do
|
||||
}
|
||||
|
|
|
|||
140
src/optimize.ts
140
src/optimize.ts
|
|
@ -8,7 +8,7 @@ import { homedir } from 'os'
|
|||
|
||||
import { isReadShapedBashCommand } from './bash-utils.js'
|
||||
import { readSessionLines, readSessionFileSync } from './fs-utils.js'
|
||||
import { discoverAllSessions } from './providers/index.js'
|
||||
import { discoverAllSessions, providerDisplayName } from './providers/index.js'
|
||||
import { parseJsonlLine, shouldSkipLine } from './parser.js'
|
||||
import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
|
||||
import { formatCost } from './currency.js'
|
||||
|
|
@ -249,6 +249,88 @@ export type PasteDestination =
|
|||
| 'shell-config' // append to ~/.zshrc / ~/.bashrc
|
||||
| 'manual' // instructions the user carries out directly
|
||||
|
||||
/// Provider-scoped remediation nouns. Cross-provider detectors and both
|
||||
/// render surfaces (CLI + TUI) share this so `--provider codex` cannot still
|
||||
/// tell the user to ask Claude or edit CLAUDE.md. Claude / unset / `all`
|
||||
/// keep the shipped CLAUDE.md copy. Only `codex` has a file CodeBurn already
|
||||
/// names (`AGENTS.md` in the Codex parser); every other provider stays on
|
||||
/// the generic "project instructions" rather than inventing a filename.
|
||||
export type OptimizeRemediationCopy = {
|
||||
agent: string
|
||||
instructionFile: string
|
||||
}
|
||||
|
||||
const REMEDIATION_INSTRUCTION_FILES: Record<string, string> = {
|
||||
claude: 'CLAUDE.md',
|
||||
codex: 'AGENTS.md',
|
||||
}
|
||||
|
||||
export function isDefaultClaudeProvider(provider?: string): boolean {
|
||||
return !provider || provider === 'all' || provider === 'claude'
|
||||
}
|
||||
|
||||
export function optimizeRemediationCopy(provider?: string): OptimizeRemediationCopy {
|
||||
const key = !provider || provider === 'all' ? 'claude' : provider.toLowerCase()
|
||||
return {
|
||||
agent: providerDisplayName(key),
|
||||
instructionFile: REMEDIATION_INSTRUCTION_FILES[key] ?? 'project instructions',
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionOpenerLabel(copy: OptimizeRemediationCopy): string {
|
||||
return `Paste at the start of your NEXT expensive thread (one-time, do not add to ${copy.instructionFile}):`
|
||||
}
|
||||
|
||||
export function askAgentLabel(copy: OptimizeRemediationCopy, rest: string): string {
|
||||
return `Ask ${copy.agent} to ${rest}:`
|
||||
}
|
||||
|
||||
export function optimizePasteHeader(destination: PasteDestination | undefined, copy: OptimizeRemediationCopy): string {
|
||||
switch (destination) {
|
||||
case 'claude-md': return `Suggested ${copy.instructionFile} addition (permanent rule)`
|
||||
case 'session-opener': return `One-time session opener (do NOT add to ${copy.instructionFile})`
|
||||
case 'prompt': return `Ask ${copy.agent} in the current session`
|
||||
case 'shell-config': return 'Add to your shell config'
|
||||
case 'manual': return 'Manual action'
|
||||
default: return 'Suggested action'
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact pre-#1049 TUI strings for unset/`all`/`claude`. Non-Claude providers
|
||||
/// reuse the CLI header table so the two surfaces cannot drift on new copy.
|
||||
export function optimizeTuiPasteHeader(destination: PasteDestination | undefined, provider?: string): string {
|
||||
if (isDefaultClaudeProvider(provider)) {
|
||||
switch (destination) {
|
||||
case 'claude-md': return '── Suggested CLAUDE.md addition (permanent rule) '.padEnd(64, '─')
|
||||
case 'session-opener': return '── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─')
|
||||
case 'prompt': return '── Ask Claude in the current session '.padEnd(64, '─')
|
||||
case 'shell-config': return '── Add to your shell config '.padEnd(64, '─')
|
||||
case 'manual': return '── Manual action '.padEnd(64, '─')
|
||||
default: return '── Suggested action '.padEnd(64, '─')
|
||||
}
|
||||
}
|
||||
return `── ${optimizePasteHeader(destination, optimizeRemediationCopy(provider))} `.padEnd(64, '─')
|
||||
}
|
||||
|
||||
export function optimizeEmptyScanLines(provider?: string): [string, string, string] {
|
||||
if (isDefaultClaudeProvider(provider)) {
|
||||
return [
|
||||
'CodeBurn optimize scans your Claude Code sessions and config for',
|
||||
'token waste: junk directory reads, duplicate file reads, unused',
|
||||
'agents/skills/MCP servers, bloated CLAUDE.md, and more.',
|
||||
]
|
||||
}
|
||||
const copy = optimizeRemediationCopy(provider)
|
||||
// scanSessions is Claude-only. Naming the provider's instruction file here
|
||||
// would claim a scan that did not run (Health A / 100 under --provider
|
||||
// codex with every listed detector claudeOnly-disabled).
|
||||
return [
|
||||
`Session-scan detectors do not cover ${copy.agent} yet.`,
|
||||
'junk directory reads, duplicate file reads, unused agents/skills/MCP,',
|
||||
'and bloated instruction files currently scan Claude Code only.',
|
||||
]
|
||||
}
|
||||
|
||||
export type WasteAction =
|
||||
| { type: 'paste'; label: string; text: string; destination?: PasteDestination }
|
||||
| { type: 'command'; label: string; text: string }
|
||||
|
|
@ -1695,6 +1777,7 @@ function collectMcpProjectProfiles(
|
|||
export function detectMcpProfileAdvisor(
|
||||
projects: ProjectSummary[],
|
||||
coverage = aggregateMcpCoverage(projects),
|
||||
provider?: string,
|
||||
): WasteFinding | null {
|
||||
const candidates = collectMcpProjectProfiles(projects, coverage)
|
||||
if (candidates.length === 0) return null
|
||||
|
|
@ -1733,7 +1816,7 @@ export function detectMcpProfileAdvisor(
|
|||
fix: {
|
||||
type: 'paste',
|
||||
destination: 'prompt',
|
||||
label: 'Ask Claude to turn this into a project-scoped MCP profile:',
|
||||
label: askAgentLabel(optimizeRemediationCopy(provider), 'turn this into a project-scoped MCP profile'),
|
||||
text: [
|
||||
`Review these MCP profile recommendations before changing config (${preview.length} of ${candidates.length} shown):`,
|
||||
...preview.map(candidate => {
|
||||
|
|
@ -1926,7 +2009,7 @@ function findCapabilityReliabilityCandidates(projects: ProjectSummary[]): Capabi
|
|||
return candidates
|
||||
}
|
||||
|
||||
export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFinding | null {
|
||||
export function detectCapabilityReliability(projects: ProjectSummary[], provider?: string): WasteFinding | null {
|
||||
projects = userStartedProjects(projects)
|
||||
const candidates = findCapabilityReliabilityCandidates(projects)
|
||||
if (candidates.length === 0) return null
|
||||
|
|
@ -1989,7 +2072,7 @@ export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFi
|
|||
fix: {
|
||||
type: 'paste',
|
||||
destination: 'prompt',
|
||||
label: 'Ask Claude to audit the retry-heavy capability before changing config:',
|
||||
label: askAgentLabel(optimizeRemediationCopy(provider), 'audit the retry-heavy capability before changing config'),
|
||||
text: `Investigate these retry-correlated capabilities: ${names}. Compare edit turns with retries against one-shot edit turns, identify whether the MCP server or skill actually caused rework, then propose a scoped MCP config or skill-instruction change with session evidence. Do not remove a capability solely because it appears in this report.`,
|
||||
},
|
||||
}
|
||||
|
|
@ -3121,7 +3204,7 @@ export function findLowWorthCandidates(projects: ProjectSummary[]): LowWorthCand
|
|||
return candidates
|
||||
}
|
||||
|
||||
export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding | null {
|
||||
export function detectLowWorthSessions(projects: ProjectSummary[], provider?: string): WasteFinding | null {
|
||||
const candidates = findLowWorthCandidates(projects)
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
|
|
@ -3156,7 +3239,7 @@ export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding
|
|||
fix: {
|
||||
type: 'paste',
|
||||
destination: 'session-opener',
|
||||
label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):',
|
||||
label: sessionOpenerLabel(optimizeRemediationCopy(provider)),
|
||||
text: LOW_WORTH_OPENER,
|
||||
},
|
||||
}
|
||||
|
|
@ -3231,7 +3314,7 @@ export function findContextBloatCandidates(projects: ProjectSummary[]): ContextB
|
|||
return candidates
|
||||
}
|
||||
|
||||
export function detectContextBloat(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet<string>): WasteFinding | null {
|
||||
export function detectContextBloat(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet<string>, provider?: string): WasteFinding | null {
|
||||
const candidates = findContextBloatCandidates(projects)
|
||||
.filter(c => !excludedSessionIds?.has(c.sessionId))
|
||||
if (candidates.length === 0) return null
|
||||
|
|
@ -3272,13 +3355,13 @@ export function detectContextBloat(projects: ProjectSummary[], excludedSessionId
|
|||
fix: {
|
||||
type: 'paste',
|
||||
destination: 'session-opener',
|
||||
label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):',
|
||||
label: sessionOpenerLabel(optimizeRemediationCopy(provider)),
|
||||
text: CONTEXT_HEAVY_OPENER,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function detectSessionOutliers(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet<string>): WasteFinding | null {
|
||||
export function detectSessionOutliers(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet<string>, provider?: string): WasteFinding | null {
|
||||
type Outlier = {
|
||||
project: string
|
||||
sessionId: string
|
||||
|
|
@ -3353,7 +3436,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio
|
|||
fix: {
|
||||
type: 'paste',
|
||||
destination: 'session-opener',
|
||||
label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):',
|
||||
label: sessionOpenerLabel(optimizeRemediationCopy(provider)),
|
||||
text: 'Before making changes, summarize the smallest viable plan. Keep context narrow, avoid broad searches, and stop after the first working patch so I can review before continuing.',
|
||||
},
|
||||
}
|
||||
|
|
@ -3551,15 +3634,15 @@ export async function scanAndDetect(
|
|||
claudeOnly(() => detectDuplicateReads(toolCalls, dateRange)),
|
||||
claudeOnly(() => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage)),
|
||||
() => detectMcpToolCoverage(projects, mcpCoverage, localMcpServerNames(projectCwds)),
|
||||
() => detectMcpProfileAdvisor(projects, mcpCoverage),
|
||||
() => detectMcpProfileAdvisor(projects, mcpCoverage, provider),
|
||||
// mcp-deferral-gaps family (#614): detection only, no apply plans yet.
|
||||
claudeOnly(() => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls)),
|
||||
claudeOnly(() => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage)),
|
||||
claudeOnly(() => detectMcpDeferThreshold(projects, projectCwds)),
|
||||
() => detectCapabilityReliability(behavioralProjects),
|
||||
() => detectLowWorthSessions(behavioralProjects),
|
||||
() => detectContextBloat(behavioralProjects, lowWorthSessionIds),
|
||||
() => detectSessionOutliers(behavioralProjects, outlierExclusions),
|
||||
() => detectCapabilityReliability(behavioralProjects, provider),
|
||||
() => detectLowWorthSessions(behavioralProjects, provider),
|
||||
() => detectContextBloat(behavioralProjects, lowWorthSessionIds, provider),
|
||||
() => detectSessionOutliers(behavioralProjects, outlierExclusions, provider),
|
||||
claudeOnly(() => detectBloatedClaudeMd(projectCwds)),
|
||||
claudeOnly(() => detectBashBloat()),
|
||||
claudeOnly(() => detectRecurringContext(openers)),
|
||||
|
|
@ -3624,7 +3707,7 @@ function wrap(text: string, width: number, indent: string): string {
|
|||
/// destination. Issue #277: users were dropping one-time session openers
|
||||
/// into CLAUDE.md as permanent rules because the prompts had no labeled
|
||||
/// home in the output.
|
||||
function renderActionHeader(action: WasteAction): string {
|
||||
function renderActionHeader(action: WasteAction, copy: OptimizeRemediationCopy): string {
|
||||
const headerWidth = PANEL_WIDTH - 4
|
||||
const fillTo = (label: string): string => {
|
||||
const inner = ` ${label} `
|
||||
|
|
@ -3637,18 +3720,11 @@ function renderActionHeader(action: WasteAction): string {
|
|||
case 'command':
|
||||
return fillTo('Run this command')
|
||||
case 'paste':
|
||||
switch (action.destination) {
|
||||
case 'claude-md': return fillTo('Suggested CLAUDE.md addition (permanent rule)')
|
||||
case 'session-opener': return fillTo('One-time session opener (do NOT add to CLAUDE.md)')
|
||||
case 'prompt': return fillTo('Ask Claude in the current session')
|
||||
case 'shell-config': return fillTo('Add to your shell config')
|
||||
case 'manual': return fillTo('Manual action')
|
||||
default: return fillTo('Suggested action')
|
||||
}
|
||||
return fillTo(optimizePasteHeader(action.destination, copy))
|
||||
}
|
||||
}
|
||||
|
||||
function renderFinding(n: number, f: WasteFinding, costRate: number): string[] {
|
||||
function renderFinding(n: number, f: WasteFinding, costRate: number, copy: OptimizeRemediationCopy): string[] {
|
||||
const lines: string[] = []
|
||||
const costSaved = f.tokensSaved * costRate
|
||||
const impactLabel = f.impact.charAt(0).toUpperCase() + f.impact.slice(1)
|
||||
|
|
@ -3674,7 +3750,7 @@ function renderFinding(n: number, f: WasteFinding, costRate: number): string[] {
|
|||
// permanent rules and one-time prompts are no longer interchangeable in
|
||||
// the output.
|
||||
const a = f.fix
|
||||
lines.push(chalk.hex(ORANGE)(` ${renderActionHeader(a)}`))
|
||||
lines.push(chalk.hex(ORANGE)(` ${renderActionHeader(a, copy)}`))
|
||||
lines.push(chalk.hex(DIM)(` ${a.label}`))
|
||||
if (a.type === 'file-content') {
|
||||
for (const line of a.content.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`))
|
||||
|
|
@ -3743,7 +3819,9 @@ export function renderOptimize(
|
|||
previouslyApplied?: Record<string, string>,
|
||||
modelRecommendations?: ModelDefaultRecommendation[],
|
||||
appliedFixes: AppliedFix[] = [],
|
||||
provider?: string,
|
||||
): string {
|
||||
const copy = optimizeRemediationCopy(provider)
|
||||
const lines: string[] = []
|
||||
lines.push('')
|
||||
lines.push(` ${chalk.bold.hex(ORANGE)('CodeBurn config health')}${chalk.dim(' ' + periodLabel)}`)
|
||||
|
|
@ -3766,9 +3844,9 @@ export function renderOptimize(
|
|||
if (findings.length === 0) {
|
||||
lines.push(chalk.hex(GREEN)(' Nothing to fix. Your setup is lean.'))
|
||||
lines.push('')
|
||||
lines.push(chalk.dim(' CodeBurn optimize scans your Claude Code sessions and config for'))
|
||||
lines.push(chalk.dim(' token waste: junk directory reads, duplicate file reads, unused'))
|
||||
lines.push(chalk.dim(' agents/skills/MCP servers, bloated CLAUDE.md, and more.'))
|
||||
for (const line of optimizeEmptyScanLines(provider)) {
|
||||
lines.push(chalk.dim(` ${line}`))
|
||||
}
|
||||
lines.push('')
|
||||
lines.push(...renderAppliedFixes(appliedFixes))
|
||||
lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes))
|
||||
|
|
@ -3799,7 +3877,7 @@ export function renderOptimize(
|
|||
for (const f of group) {
|
||||
const appliedOn = previouslyApplied?.[f.id]
|
||||
const shown = appliedOn ? { ...f, title: `${f.title} (previously applied ${appliedOn}, re-flagged)` } : f
|
||||
lines.push(...renderFinding(++n, shown, costRate))
|
||||
lines.push(...renderFinding(++n, shown, costRate, copy))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3868,7 +3946,7 @@ export async function runOptimize(
|
|||
}
|
||||
|
||||
const { topReworkedFiles, coachingNotes } = buildWorkflowReport(projects)
|
||||
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessionCount, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations, opts.appliedFixes)
|
||||
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessionCount, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations, opts.appliedFixes, opts.provider)
|
||||
console.log(output)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -431,7 +431,16 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
|
|||
? getRawDurationMs(getRawPayloadFieldWindow(line, 'duration') ?? '')
|
||||
: undefined
|
||||
const timingDuration = payloadDuration ?? getRawDurationMs(pHead) ?? getRawDurationMs(timingTail)
|
||||
const compactModel = getRawJsonStringField(pHead, 'model')
|
||||
// session_meta can embed same-name keys under base_instructions /
|
||||
// dynamic_tools (including provenance.model). A depth-agnostic scan of the
|
||||
// compact head steals the first nested hit and can overwrite turn_context.
|
||||
// Restrict every session_meta string field to payload depth 1. Other event
|
||||
// types keep the cheap first-match scan.
|
||||
const payloadString = (field: string): string | undefined =>
|
||||
type === 'session_meta'
|
||||
? getRawJsonStringField(getRawPayloadFieldWindow(line, field) ?? '', field)
|
||||
: getRawJsonStringField(pHead, field)
|
||||
const compactModel = payloadString('model')
|
||||
const compactModelName = getRawJsonStringField(pHead, 'model_name')
|
||||
const compactLastUsage = getRawTokenUsage(pHead, 'last_token_usage')
|
||||
const compactTotalUsage = getRawTokenUsage(pHead, 'total_token_usage')
|
||||
|
|
@ -446,13 +455,13 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
|
|||
payload: {
|
||||
type: payloadType,
|
||||
role,
|
||||
cwd: getRawJsonStringField(pHead, 'cwd'),
|
||||
model_provider: getRawJsonStringField(pHead, 'model_provider'),
|
||||
originator: getRawJsonStringField(pHead, 'originator'),
|
||||
session_id: getRawJsonStringField(pHead, 'session_id'),
|
||||
forked_from_id: getRawJsonStringField(pHead, 'forked_from_id'),
|
||||
model: getRawJsonStringField(pHead, 'model'),
|
||||
name: getRawJsonStringField(pHead, 'name'),
|
||||
cwd: payloadString('cwd'),
|
||||
model_provider: payloadString('model_provider'),
|
||||
originator: payloadString('originator'),
|
||||
session_id: payloadString('session_id'),
|
||||
forked_from_id: payloadString('forked_from_id'),
|
||||
model: compactModel,
|
||||
name: payloadString('name'),
|
||||
invocation,
|
||||
call_id: getRawJsonStringField(pHead, 'call_id'),
|
||||
turn_id: getRawJsonStringField(pHead, 'turn_id'),
|
||||
|
|
|
|||
|
|
@ -199,6 +199,29 @@ const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff,
|
|||
// when an optional module fails to load. Must stay in sync with getAllProviders.
|
||||
const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode', 'zed']
|
||||
|
||||
// Display names for lazy providers. Must match the `displayName` on the
|
||||
// loaded Provider object; `providerDisplayName` + getAllProviders() test
|
||||
// is the drift check.
|
||||
const lazyProviderDisplayNames: Record<string, string> = {
|
||||
antigravity: 'Antigravity',
|
||||
forge: 'Forge',
|
||||
goose: 'Goose',
|
||||
cursor: 'Cursor',
|
||||
opencode: 'OpenCode',
|
||||
'cursor-agent': 'Cursor Agent',
|
||||
crush: 'Crush',
|
||||
warp: 'Warp',
|
||||
'vercel-gateway': 'Vercel AI Gateway',
|
||||
zcode: 'ZCode',
|
||||
zed: 'Zed',
|
||||
}
|
||||
|
||||
export function providerDisplayName(name: string): string {
|
||||
const core = coreProviders.find(p => p.name === name)
|
||||
if (core) return core.displayName
|
||||
return lazyProviderDisplayNames[name] ?? name
|
||||
}
|
||||
|
||||
// Canonical set of every provider name (core + lazy), used to validate the
|
||||
// --provider CLI flag. Computed lazily so importing this module never depends on
|
||||
// every provider object being defined at load time (e.g. under test mocks).
|
||||
|
|
|
|||
|
|
@ -277,7 +277,11 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// rich-session-capture-v1: per-call LOC deltas + editFailed from
|
||||
// patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in
|
||||
// lockstep so the pre-session-cache layer re-parses too.)
|
||||
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1',
|
||||
// session-meta-model-v1: parse large session_meta records structurally so a
|
||||
// nested base_instructions provenance.model cannot overwrite turn_context.
|
||||
// session-meta-fields-v1: the same depth-1 window for cwd/name/originator/
|
||||
// session_id/forked_from_id/model_provider, not just model.
|
||||
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1',
|
||||
cursor: 'composer-anchored-crediting-v1-est-cost',
|
||||
'cursor-agent': 'workspaceless-transcript-v1',
|
||||
// source-provenance-v1 (#944): CLI sessions were misread as VS Code
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import {
|
|||
type DailyEntry,
|
||||
} from '../src/daily-cache.js'
|
||||
|
||||
// The last SHIPPED daily-cache version before the Grok accounting change, so
|
||||
// this models the real 17 -> 19 upgrade path users hit. (v18 existed only as an
|
||||
// unreleased draft.) Anything below MIN_SUPPORTED_VERSION is untrusted, which
|
||||
// is what makes the re-derivation global rather than Grok-scoped.
|
||||
const PRE_FIX_DAILY_VERSION = 17
|
||||
// The last SHIPPED daily-cache version before the Codex session_meta model fix,
|
||||
// so this models the real 19 -> 20 upgrade path users hit. Anything below
|
||||
// MIN_SUPPORTED_VERSION is untrusted, which is what makes the re-derivation
|
||||
// global rather than scoped to the provider the bump exists for.
|
||||
const PRE_FIX_DAILY_VERSION = 19
|
||||
const cacheRoot = join(tmpdir(), `codeburn-daily-rederive-${process.pid}-${Date.now()}`)
|
||||
|
||||
function day(date: string, cost: number): DailyEntry {
|
||||
|
|
@ -67,10 +67,9 @@ afterEach(async () => {
|
|||
await rm(cacheRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Raising MIN_SUPPORTED_VERSION re-derives EVERY day from EVERY provider, not
|
||||
// only Grok - the daily cache has no per-provider invalidation. A Grok day is
|
||||
// used here because Grok is the provider whose totals the bump exists to
|
||||
// correct; the mechanism under test is version-wide.
|
||||
// Raising MIN_SUPPORTED_VERSION re-derives EVERY day from EVERY provider - the
|
||||
// daily cache has no per-provider invalidation. Which provider the seeded day
|
||||
// belongs to is incidental; the mechanism under test is version-wide.
|
||||
describe('daily-cache re-derivation on a DAILY_CACHE_VERSION bump', () => {
|
||||
it('re-derives a day from a below-minimum v17 cache while preserving the old file', async () => {
|
||||
const date = toDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
|
||||
|
|
|
|||
29
tests/env-isolation-declarations.test.ts
Normal file
29
tests/env-isolation-declarations.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Static guard: every PROVIDER_ENV_VARS entry must be CLEARED or REDIRECTED
|
||||
// by tests/setup/env-isolation.ts. A data-dir override that is fingerprinted
|
||||
// for cache invalidation but not isolated in tests leaks the developer's real
|
||||
// sessions into fixture parses — green on CI (no HERMES_HOME), red on a
|
||||
// Hermes-shell laptop. The named hole was HERMES_HOME; the class is every
|
||||
// sibling override that session-cache already knows about.
|
||||
//
|
||||
// The lists are imported from the same module applyIsolation() uses. Extra
|
||||
// High #1064: scraping setup-file source treated a comment containing
|
||||
// `'HERMES_HOME'` as isolation (false-green). Runtime membership cannot.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { PROVIDER_ENV_VARS } from '../src/session-cache.js'
|
||||
import { CLEARED, REDIRECTED } from './setup/env-isolation-vars.js'
|
||||
|
||||
describe('env-isolation covers PROVIDER_ENV_VARS', () => {
|
||||
it('clears or redirects every provider data-dir override so a developer shell cannot leak real sessions into fixtures', () => {
|
||||
const isolated = new Set<string>([...CLEARED, ...REDIRECTED])
|
||||
|
||||
const missing: string[] = []
|
||||
for (const [provider, vars] of Object.entries(PROVIDER_ENV_VARS)) {
|
||||
for (const varName of vars) {
|
||||
if (!isolated.has(varName)) missing.push(`${provider}:${varName}`)
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -769,6 +769,29 @@ describe('detectMcpProfileAdvisor', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('scopes the remediation label to --provider codex', () => {
|
||||
const hotTurns = [makeTurn([
|
||||
makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 }),
|
||||
makeCall({ tools: ['mcp__github__t1'], cacheCreation: 10_000 }),
|
||||
])]
|
||||
const coldTurns = [makeTurn([makeCall({ cacheCreation: 10_000 })])]
|
||||
const projects = [
|
||||
projectNamed('api', [
|
||||
makeSession({ inventory: smallInventory, turns: hotTurns, mcpBreakdown: { github: { calls: 2 } } }),
|
||||
]),
|
||||
projectNamed('web', [
|
||||
makeSession({ inventory: smallInventory, turns: coldTurns, mcpBreakdown: { github: { calls: 0 } } }),
|
||||
]),
|
||||
projectNamed('docs', [
|
||||
makeSession({ inventory: smallInventory, turns: coldTurns, mcpBreakdown: { github: { calls: 0 } } }),
|
||||
]),
|
||||
]
|
||||
const finding = detectMcpProfileAdvisor(projects, undefined, 'codex')
|
||||
expect(finding!.fix.label).toBe('Ask Codex to turn this into a project-scoped MCP profile:')
|
||||
expect(finding!.fix.label).not.toContain('Claude')
|
||||
expect(finding!.fix.label).not.toContain('CLAUDE.md')
|
||||
})
|
||||
|
||||
it('does not flag servers used evenly across loaded projects', () => {
|
||||
const projects = ['api', 'web', 'docs'].map(name => projectNamed(name, [
|
||||
makeSession({
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ import {
|
|||
buildOptimizeJsonReport,
|
||||
renderOptimize,
|
||||
findingBasis,
|
||||
optimizeRemediationCopy,
|
||||
optimizePasteHeader,
|
||||
optimizeTuiPasteHeader,
|
||||
optimizeEmptyScanLines,
|
||||
sessionOpenerLabel,
|
||||
askAgentLabel,
|
||||
type FindingId,
|
||||
type ToolCall,
|
||||
type ApiCallMeta,
|
||||
|
|
@ -1443,3 +1449,137 @@ describe('renderOptimize applied-fixes section', () => {
|
|||
expect(render([], [])).not.toContain('Applied fixes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('provider-scoped remediation copy (#1044)', () => {
|
||||
const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, '')
|
||||
|
||||
function promptFinding(label: string): WasteFinding {
|
||||
return {
|
||||
id: 'retry-heavy-capabilities',
|
||||
title: 'retry-heavy',
|
||||
explanation: 'why',
|
||||
impact: 'medium',
|
||||
tokensSaved: 1000,
|
||||
fix: { type: 'paste', destination: 'prompt', label, text: 'audit' },
|
||||
}
|
||||
}
|
||||
|
||||
function openerFinding(label: string): WasteFinding {
|
||||
return {
|
||||
id: 'low-worth-sessions',
|
||||
title: 'low-worth',
|
||||
explanation: 'why',
|
||||
impact: 'low',
|
||||
tokensSaved: 1000,
|
||||
fix: { type: 'paste', destination: 'session-opener', label, text: 'open' },
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps the exact shipped Claude / CLAUDE.md strings', () => {
|
||||
for (const provider of [undefined, 'all', 'claude'] as const) {
|
||||
const copy = optimizeRemediationCopy(provider)
|
||||
expect(copy).toEqual({ agent: 'Claude', instructionFile: 'CLAUDE.md' })
|
||||
expect(optimizePasteHeader('prompt', copy)).toBe('Ask Claude in the current session')
|
||||
expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to CLAUDE.md)')
|
||||
expect(sessionOpenerLabel(copy)).toBe('Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):')
|
||||
expect(askAgentLabel(copy, 'audit the retry-heavy capability before changing config'))
|
||||
.toBe('Ask Claude to audit the retry-heavy capability before changing config:')
|
||||
expect(optimizeEmptyScanLines(provider)).toEqual([
|
||||
'CodeBurn optimize scans your Claude Code sessions and config for',
|
||||
'token waste: junk directory reads, duplicate file reads, unused',
|
||||
'agents/skills/MCP servers, bloated CLAUDE.md, and more.',
|
||||
])
|
||||
expect(optimizeTuiPasteHeader('session-opener', provider))
|
||||
.toBe('── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─'))
|
||||
expect(optimizeTuiPasteHeader('prompt', provider))
|
||||
.toBe('── Ask Claude in the current session '.padEnd(64, '─'))
|
||||
}
|
||||
const empty = strip(renderOptimize([], 0, 'lifetime', 0, 0, 0, 100, 'A', [], []))
|
||||
expect(empty).toContain('CodeBurn optimize scans your Claude Code sessions and config for')
|
||||
expect(empty).toContain('bloated CLAUDE.md')
|
||||
expect(empty).not.toContain('Claude sessions')
|
||||
})
|
||||
|
||||
it('uses Codex / AGENTS.md for --provider codex', () => {
|
||||
const copy = optimizeRemediationCopy('codex')
|
||||
expect(copy).toEqual({ agent: 'Codex', instructionFile: 'AGENTS.md' })
|
||||
expect(optimizePasteHeader('prompt', copy)).toBe('Ask Codex in the current session')
|
||||
expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to AGENTS.md)')
|
||||
expect(sessionOpenerLabel(copy)).toContain('AGENTS.md')
|
||||
expect(sessionOpenerLabel(copy)).not.toContain('CLAUDE.md')
|
||||
expect(optimizeEmptyScanLines('codex')[0]).toBe('Session-scan detectors do not cover Codex yet.')
|
||||
expect(optimizeEmptyScanLines('codex')[2]).toContain('currently scan Claude Code only')
|
||||
expect(optimizeEmptyScanLines('codex').join(' ')).not.toContain('scans your Codex')
|
||||
expect(optimizeEmptyScanLines('codex').join(' ')).not.toContain('bloated AGENTS.md')
|
||||
expect(optimizeTuiPasteHeader('prompt', 'codex')).toContain('Ask Codex in the current session')
|
||||
expect(optimizeTuiPasteHeader('session-opener', 'codex')).toContain('do NOT add to AGENTS.md')
|
||||
expect(optimizeTuiPasteHeader('session-opener', 'codex')).not.toContain('CLAUDE.md')
|
||||
})
|
||||
|
||||
it('uses the canonical Provider.displayName, not a title-cased id', () => {
|
||||
expect(optimizeRemediationCopy('hermes').agent).toBe('Hermes Agent')
|
||||
expect(optimizeRemediationCopy('cursor').agent).toBe('Cursor')
|
||||
expect(optimizeRemediationCopy('cursor').instructionFile).toBe('project instructions')
|
||||
expect(optimizePasteHeader('session-opener', optimizeRemediationCopy('cursor')))
|
||||
.toBe('One-time session opener (do NOT add to project instructions)')
|
||||
})
|
||||
|
||||
it('scopes every cross-provider detector label, including JSON', () => {
|
||||
const lowWorth = detectLowWorthSessions([
|
||||
projectWithLowWorthSessions([lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] })]),
|
||||
], 'codex')
|
||||
const context = detectContextBloat([
|
||||
projectWithContextSessions([contextSession(0, {
|
||||
totalInputTokens: 90_000,
|
||||
totalCacheReadTokens: 30_000,
|
||||
totalOutputTokens: 2_000,
|
||||
})]),
|
||||
], undefined, 'codex')
|
||||
const outliers = detectSessionOutliers([projectWithSessions([1, 1, 1, 10])], undefined, 'codex')
|
||||
const turns = Array.from({ length: 5 }, (_, i) => reliabilityTurn(i, {
|
||||
retries: i < 3 ? 1 : 0,
|
||||
call: { tools: ['Edit', 'Skill'], skills: ['reviewer'] },
|
||||
}))
|
||||
const retry = detectCapabilityReliability([projectWithReliabilityTurns(turns)], 'codex')
|
||||
|
||||
const findings = [lowWorth, context, outliers, retry]
|
||||
expect(findings.every(Boolean)).toBe(true)
|
||||
for (const finding of findings) {
|
||||
expect(finding!.fix.label).not.toContain('Claude')
|
||||
expect(finding!.fix.label).not.toContain('CLAUDE.md')
|
||||
}
|
||||
expect(lowWorth!.fix.label).toContain('AGENTS.md')
|
||||
expect(context!.fix.label).toContain('AGENTS.md')
|
||||
expect(outliers!.fix.label).toContain('AGENTS.md')
|
||||
expect(retry!.fix.label).toBe('Ask Codex to audit the retry-heavy capability before changing config:')
|
||||
|
||||
const json = buildOptimizeJsonReport(
|
||||
[projectWithSessions([1])],
|
||||
'lifetime',
|
||||
{ findings: findings as WasteFinding[], costRate: 0.00001, healthScore: 80, healthGrade: 'B', modelRecommendations: [] },
|
||||
)
|
||||
expect(json.findings).toHaveLength(4)
|
||||
for (const row of json.findings) {
|
||||
expect(row.fix.label).not.toContain('Claude')
|
||||
expect(row.fix.label).not.toContain('CLAUDE.md')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders destination headers from the selected provider, not the finding text', () => {
|
||||
const out = strip(renderOptimize(
|
||||
[promptFinding('Ask Claude to audit the retry-heavy capability before changing config:')],
|
||||
0.00001, 'lifetime', 10, 5, 100, 80, 'B', [], [],
|
||||
undefined, undefined, undefined, [], 'codex',
|
||||
))
|
||||
expect(out).toContain('Ask Codex in the current session')
|
||||
expect(out).not.toContain('Ask Claude in the current session')
|
||||
|
||||
const opener = strip(renderOptimize(
|
||||
[openerFinding('Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):')],
|
||||
0.00001, 'lifetime', 10, 5, 100, 80, 'B', [], [],
|
||||
undefined, undefined, undefined, [], 'codex',
|
||||
))
|
||||
expect(opener).toContain('do NOT add to AGENTS.md')
|
||||
expect(opener).not.toContain('do NOT add to CLAUDE.md')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
12
tests/provider-display-name.test.ts
Normal file
12
tests/provider-display-name.test.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getAllProviders, providerDisplayName } from '../src/providers/index.js'
|
||||
|
||||
describe('providerDisplayName', () => {
|
||||
it('matches every loaded Provider.displayName', async () => {
|
||||
const loaded = await getAllProviders()
|
||||
expect(loaded.length).toBeGreaterThan(20)
|
||||
for (const provider of loaded) {
|
||||
expect(providerDisplayName(provider.name)).toBe(provider.displayName)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -570,6 +570,82 @@ describe('codex provider - session discovery', () => {
|
|||
})
|
||||
|
||||
describe('codex provider - JSONL parsing', () => {
|
||||
it('does not treat a nested session_meta model as the active turn model', async () => {
|
||||
const largeSessionMeta = JSON.stringify({
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-04-14T10:00:00Z',
|
||||
payload: {
|
||||
cwd: '/Users/test/model-switch',
|
||||
originator: 'codex-cli',
|
||||
session_id: 'sess-model-switch',
|
||||
base_instructions: {
|
||||
provenance: { type: 'model', model: 'gpt-5.6-sol' },
|
||||
text: 'x'.repeat(40_000),
|
||||
},
|
||||
},
|
||||
})
|
||||
const turnContext = JSON.stringify({
|
||||
type: 'turn_context',
|
||||
timestamp: '2026-04-14T10:00:01Z',
|
||||
payload: { model: 'gpt-5.6-luna' },
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-model-switch.jsonl', [
|
||||
largeSessionMeta,
|
||||
turnContext,
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:02Z', last: { input: 100, output: 50 }, total: { total: 150 } }),
|
||||
largeSessionMeta,
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:03Z', last: { input: 200, output: 100 }, total: { total: 450 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls.map(call => call.model)).toEqual(['gpt-5.6-luna', 'gpt-5.6-luna'])
|
||||
})
|
||||
|
||||
it('reads session_meta cwd/session_id/originator at payload depth 1, not the first nested same-name key', async () => {
|
||||
const largeSessionMeta = JSON.stringify({
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-04-14T10:00:00Z',
|
||||
payload: {
|
||||
dynamic_tools: [{
|
||||
name: 'shadow-tool',
|
||||
cwd: '/shadow/cwd',
|
||||
originator: 'shadow-originator',
|
||||
session_id: 'shadow-session',
|
||||
forked_from_id: 'shadow-fork',
|
||||
model_provider: 'shadow-provider',
|
||||
}],
|
||||
base_instructions: { text: 'x'.repeat(40_000) },
|
||||
cwd: '/Users/test/real-project',
|
||||
originator: 'codex-cli',
|
||||
session_id: 'sess-real',
|
||||
model: 'gpt-5.6-luna',
|
||||
model_provider: 'openai',
|
||||
name: 'real-session-name',
|
||||
},
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-nested-keys.jsonl', [
|
||||
largeSessionMeta,
|
||||
functionCall('exec_command'),
|
||||
tokenCount({ timestamp: '2026-04-14T10:01:00Z', last: { input: 100, output: 50 }, total: { total: 150 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.sessionId).toBe('sess-real')
|
||||
expect(calls[0]!.workingDirectory).toBe('/Users/test/real-project')
|
||||
expect(calls[0]!.projectPath).toBe('/Users/test/real-project')
|
||||
expect(calls[0]!.model).toBe('gpt-5.6-luna')
|
||||
expect(calls[0]!.tools).toEqual(['Bash'])
|
||||
})
|
||||
|
||||
it('extracts token usage from last_token_usage', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-parse.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-parse', model: 'gpt-5.3-codex' }),
|
||||
|
|
|
|||
79
tests/setup/env-isolation-vars.ts
Normal file
79
tests/setup/env-isolation-vars.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Side-effect-free lists for tests/setup/env-isolation.ts.
|
||||
// Imported by the setup file (which applies them) and by
|
||||
// tests/env-isolation-declarations.test.ts (which asserts coverage).
|
||||
// Do not put applyIsolation() here — importing this module from a test
|
||||
// must not re-sandbox the process or register another beforeEach.
|
||||
//
|
||||
// A comment containing 'HERMES_HOME' is not isolation. The declaration
|
||||
// test imports these arrays, so a commented-out name cannot false-green.
|
||||
|
||||
export const REDIRECTED = [
|
||||
'HOME',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_STATE_HOME',
|
||||
'APPDATA',
|
||||
'LOCALAPPDATA',
|
||||
] as const
|
||||
|
||||
export const CLEARED = [
|
||||
// Provider session-discovery dirs
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
'CLAUDE_CONFIG_DIRS',
|
||||
'CLINE_DIR',
|
||||
'CLINE_DATA_DIR',
|
||||
'CLINE_SESSION_DATA_DIR',
|
||||
'CODEX_HOME',
|
||||
'CODEWHALE_HOME',
|
||||
'CRUSH_GLOBAL_DATA',
|
||||
'CODEBUFF_DATA_DIR',
|
||||
'DSH_HOME',
|
||||
'FACTORY_DIR',
|
||||
'GOOSE_PATH_ROOT',
|
||||
'GROK_HOME',
|
||||
'HERMES_HOME',
|
||||
'KIRO_HOME',
|
||||
'KIMI_CODE_HOME',
|
||||
'KIMI_SHARE_DIR',
|
||||
'LINGTAI_HOME',
|
||||
'LINGTAI_TUI_GLOBAL_DIR',
|
||||
'LINGTAI_TUI_HOME',
|
||||
'MUX_ROOT',
|
||||
'OPENCODE_DATA_DIR',
|
||||
'OPENCODE_DB_PREFIX',
|
||||
'QUICKWORK_HOME',
|
||||
'QWEN_DATA_DIR',
|
||||
'VIBE_HOME',
|
||||
'WARP_DB_PATH',
|
||||
'ZS_DATA_DIR',
|
||||
// codeburn override dirs / paths
|
||||
'CODEBURN_CACHE_DIR',
|
||||
'CODEBURN_COPILOT_GLOBAL_STORAGE_DIR',
|
||||
'CODEBURN_COPILOT_JETBRAINS_DIR',
|
||||
'CODEBURN_COPILOT_OTEL_DB',
|
||||
'CODEBURN_COPILOT_SESSION_STATE_DIR',
|
||||
'CODEBURN_COPILOT_WS_STORAGE_DIR',
|
||||
'CODEBURN_DESKTOP_SESSIONS_DIR',
|
||||
'CODEBURN_MUX_DIR',
|
||||
'CODEBURN_OPEN_DESIGN_DIR',
|
||||
'CODEBURN_OPENCLAUDE_DIR',
|
||||
'CODEBURN_ANTIGRAVITY_SETTINGS_PATH',
|
||||
// codeburn behavior toggles (set by the dev to tweak local runs)
|
||||
'CODEBURN_COPILOT_DISABLE_OTEL',
|
||||
'CODEBURN_TZ',
|
||||
'CODEBURN_VERBOSE',
|
||||
'CODEBURN_CURSOR_MAX_BUBBLES',
|
||||
'CODEBURN_FORCE_MACOS_MAJOR',
|
||||
// Provider model/credential overrides
|
||||
'KIMI_MODEL_NAME',
|
||||
'AI_GATEWAY_API_KEY',
|
||||
'VERCEL_OIDC_TOKEN',
|
||||
// Read by detectBashBloat - a dev's real shell limit must not bleed in
|
||||
'BASH_MAX_OUTPUT_LENGTH',
|
||||
] as const
|
||||
|
||||
// Snapshotted from the dev's shell and restored every test. These can't be
|
||||
// wiped (Node needs PATH for spawn / module resolution, dashboard/table layout
|
||||
// reads COLUMNS) but a test that mutates them shouldn't leak.
|
||||
export const PRESERVED = ['PATH', 'COLUMNS'] as const
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
// Vitest setup file: isolates every test from the developer's shell environment.
|
||||
//
|
||||
// codeburn discovers sessions through a long list of provider-specific env
|
||||
// vars (CLAUDE_CONFIG_DIR, CODEX_HOME, CRUSH_GLOBAL_DATA, …) and via HOME /
|
||||
// XDG_* / APPDATA / LOCALAPPDATA. Without this file, any value set in the
|
||||
// developer's shell (e.g. CLAUDE_CONFIG_DIRS=/Users/me/.claude:…) bleeds into
|
||||
// vars (CLAUDE_CONFIG_DIR, CODEX_HOME, HERMES_HOME, CRUSH_GLOBAL_DATA, …) and
|
||||
// via HOME / XDG_* / APPDATA / LOCALAPPDATA. Without this file, any value set
|
||||
// in the developer's shell (e.g. HERMES_HOME=/Users/me/.hermes) bleeds into
|
||||
// fixture-based tests: the parser reads the developer's REAL sessions instead
|
||||
// of the temp-dir fixture, producing nonsense totals and false failures that
|
||||
// pass on a clean CI runner.
|
||||
// pass on a clean CI runner. tests/env-isolation-declarations.test.ts fails
|
||||
// closed if PROVIDER_ENV_VARS grows a data-dir override that is not listed.
|
||||
//
|
||||
// What this file does:
|
||||
// 1. Mints an empty sandbox temp dir once per worker.
|
||||
|
|
@ -33,68 +34,10 @@ import { tmpdir } from 'os'
|
|||
import { join } from 'path'
|
||||
import { beforeEach } from 'vitest'
|
||||
|
||||
import { CLEARED, PRESERVED, REDIRECTED } from './env-isolation-vars.js'
|
||||
|
||||
const sandbox = mkdtempSync(join(tmpdir(), 'codeburn-test-env-'))
|
||||
|
||||
const REDIRECTED = [
|
||||
'HOME',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_STATE_HOME',
|
||||
'APPDATA',
|
||||
'LOCALAPPDATA',
|
||||
] as const
|
||||
|
||||
const CLEARED = [
|
||||
// Provider session-discovery dirs
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
'CLAUDE_CONFIG_DIRS',
|
||||
'CLINE_DIR',
|
||||
'CLINE_DATA_DIR',
|
||||
'CLINE_SESSION_DATA_DIR',
|
||||
'CODEX_HOME',
|
||||
'CODEWHALE_HOME',
|
||||
'CRUSH_GLOBAL_DATA',
|
||||
'CODEBUFF_DATA_DIR',
|
||||
'FACTORY_DIR',
|
||||
'GOOSE_PATH_ROOT',
|
||||
'GROK_HOME',
|
||||
'KIRO_HOME',
|
||||
'KIMI_SHARE_DIR',
|
||||
'MUX_ROOT',
|
||||
'OPENCODE_DATA_DIR',
|
||||
'OPENCODE_DB_PREFIX',
|
||||
'QWEN_DATA_DIR',
|
||||
'VIBE_HOME',
|
||||
'WARP_DB_PATH',
|
||||
'ZS_DATA_DIR',
|
||||
// codeburn override dirs / paths
|
||||
'CODEBURN_CACHE_DIR',
|
||||
'CODEBURN_COPILOT_JETBRAINS_DIR',
|
||||
'CODEBURN_COPILOT_OTEL_DB',
|
||||
'CODEBURN_COPILOT_SESSION_STATE_DIR',
|
||||
'CODEBURN_COPILOT_WS_STORAGE_DIR',
|
||||
'CODEBURN_DESKTOP_SESSIONS_DIR',
|
||||
'CODEBURN_MUX_DIR',
|
||||
'CODEBURN_ANTIGRAVITY_SETTINGS_PATH',
|
||||
// codeburn behavior toggles (set by the dev to tweak local runs)
|
||||
'CODEBURN_COPILOT_DISABLE_OTEL',
|
||||
'CODEBURN_TZ',
|
||||
'CODEBURN_VERBOSE',
|
||||
'CODEBURN_CURSOR_MAX_BUBBLES',
|
||||
'CODEBURN_FORCE_MACOS_MAJOR',
|
||||
// Provider model/credential overrides
|
||||
'KIMI_MODEL_NAME',
|
||||
'AI_GATEWAY_API_KEY',
|
||||
'VERCEL_OIDC_TOKEN',
|
||||
// Read by detectBashBloat - a dev's real shell limit must not bleed in
|
||||
'BASH_MAX_OUTPUT_LENGTH',
|
||||
] as const
|
||||
|
||||
// Snapshotted from the dev's shell and restored every test. These can't be
|
||||
// wiped (Node needs PATH for spawn / module resolution, dashboard/table layout
|
||||
// reads COLUMNS) but a test that mutates them shouldn't leak.
|
||||
const PRESERVED = ['PATH', 'COLUMNS'] as const
|
||||
const preservedSnapshot = new Map<string, string | undefined>()
|
||||
for (const key of PRESERVED) preservedSnapshot.set(key, process.env[key])
|
||||
|
||||
|
|
@ -106,6 +49,13 @@ function applyIsolation(): void {
|
|||
if (original === undefined) delete process.env[key]
|
||||
else process.env[key] = original
|
||||
}
|
||||
// Price off the bundled LiteLLM snapshot only. Without this, loadPricing()
|
||||
// fetches the live upstream table, so a mid-week reprice by a provider turns
|
||||
// pricing assertions red with no local change.
|
||||
process.env['CODEBURN_PRICING_SNAPSHOT_ONLY'] = '1'
|
||||
// Same for FX: skip the live Frankfurter fetch and fall back to the
|
||||
// USD-equivalent rate unless a test seeds its own exchange-rate.json cache.
|
||||
process.env['CODEBURN_FX_NO_FETCH'] = '1'
|
||||
// Pin the timezone so date grouping is deterministic regardless of the dev's
|
||||
// shell TZ. Clearing it is not enough (Node falls back to the OS zone); a
|
||||
// non-UTC TZ would otherwise shift day buckets versus a clean CI runner. A
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue