os.freemem() reports free pages on macOS, not available memory: on an idle 128 GB machine it reads a few hundred MB, so the 2 GB gate switched the worker pool on and off between runs on the platform the desktop app ships to. The gate and the budget now use process.availableMemory() (cgroup/rlimit-aware in a container), falling back to os.totalmem(): serial under 4 GB available, budget min(0.25 * available, 2 GB). An 8 GB box earns 8 threads, a 4 GB box none. The verbose line now carries every decision input — cores, available GB, pending files and bytes — on both the gate and the go path, so one support log explains itself.
12 KiB
CodeBurn Architecture
A map of the codebase. Read this once before opening a non-trivial PR.
Three Surfaces
CodeBurn is one Node.js CLI plus two GUI clients that shell out to it.
+----------------------+ +-----------------+
| mac/ (Swift) | ---> | |
+----------------------+ | src/cli.ts |
| gnome/ (JavaScript) | ---> | (the CLI) |
+----------------------+ | |
| status |
| --format |
| menubar-json |
+-----------------+
|
v
+----------------------------+
| session files on disk |
| (JSONL, SQLite, protobuf) |
+----------------------------+
The macOS menubar (mac/) and the GNOME extension (gnome/) both invoke codeburn status --format menubar-json --period <p> and parse the JSON. They do not share code with the CLI; they only depend on its output contract.
CLI (src/)
src/cli.ts is the Commander.js entry point. The bin field in package.json points at dist/cli.js. Twelve commands are registered:
| Command | Line | Purpose |
|---|---|---|
report |
274 | Default. Interactive Ink TUI dashboard. |
status |
358 | Compact text status, plus --format menubar-json for clients. |
today |
524 | Today-only view of report. |
month |
542 | Month-only view of report. |
export |
560 | CSV or JSON dump of usage data. |
menubar |
621 | Downloads and launches the macOS menubar bundle. |
currency |
636 | Sets display currency. |
model-alias |
687 | Maps an unknown model name to a known one for pricing. |
plan |
737 | Configures a subscription plan for overage tracking. |
optimize |
857 | Runs all 14 waste detectors. |
compare |
870 | Compares two models side by side. |
yield |
882 | Tracks which sessions shipped to main vs. were reverted (experimental). |
Pipeline
provider.discoverSessions()
|
v
provider.createSessionParser(source, seenKeys)
|
v yields ParsedProviderCall (see src/providers/types.ts)
|
v
src/parser.ts: parseAllSessions()
|
v aggregates into ProjectSummary[]
|
v
src/daily-cache.ts: aggregate per day, persist
|
v
output formatter (Ink TUI, JSON, or menubar-json)
src/parser.ts is the central aggregator. Public exports: parseAllSessions, filterProjectsByName, extractMcpInventory. It owns the dedup Set (seenKeys) that is passed into every provider parser so a turn that surfaces in two providers (Claude logs vs. Cursor mirror, for instance) is counted once.
Parallel Cold Parse
A cold Claude parse spends most of its time on work that is per-file and pure:
reading a session JSONL, decoding it, and turning each line into a journal entry.
src/parse-workers.ts moves that onto worker_threads when the pending workload
is big enough to pay for them. Each worker runs parseClaudeFileFull against an
empty dedup set and ships the result back as a JSON string; the parent installs
results in the same order the serial loop would, and everything with cross-file
state (the seenMsgIds dedup, canonical project paths, spawn links, PR
correlation) stays on the main thread. A file whose message ids were already
claimed by an earlier file, or whose worker failed, is re-parsed in-process — so
the output is identical to the serial path either way. Only whole-file re-parses
go off-thread; the append/incremental path is untouched. Workers are created at
the start of a qualifying parse and terminated when it ends, so the resident
serve child never accumulates threads.
The pool is off by default for anything that is not a large cold parse:
| Gate | Serial when |
|---|---|
| Pending files | fewer than 200 whole-file re-parses |
| Pending bytes | under 200 MB behind those files |
| Cores | availableParallelism() <= 2 |
| Memory | under 4 GB available |
Otherwise the worker count is
min(cores - 1, min(0.25 * available, 2 GB) / 256 MB, pendingFiles / 50).
"Available" is process.availableMemory(), falling back to os.totalmem(). It is
deliberately not os.freemem(): on macOS that counts free pages rather than
available memory and reads as a few hundred MB on an idle 128 GB machine, so a
gate built on it switches the feature on and off between runs.
CODEBURN_PARSE_WORKERS overrides the decision and skips every gate above:
0 forces the serial parse, N forces N workers (capped at the core count).
CODEBURN_VERBOSE=1 prints the resolved worker count and the reason for it.
Cache Layers
Three caches under ~/.cache/codeburn/ (override with CODEBURN_CACHE_DIR):
| File | Owner | Invalidation |
|---|---|---|
codex-results.json |
src/codex-cache.ts |
mtimeMs + sizeBytes per Codex .jsonl. |
cursor-results.json |
src/cursor-cache.ts |
mtimeMs + sizeBytes of the Cursor SQLite db. |
daily-cache.json |
src/daily-cache.ts |
Tracks lastComputedDate; new days are backfilled, old days are reused. |
All three use atomic write (temp file + rename) and write with mode 0o600. All three carry a numeric version field; bumping it forces a recompute next run.
Optimize Detectors
src/optimize.ts exports 14 detectors. Each returns a WasteFinding | null. They are composed by runOptimize() which collects findings, ranks them by impact, and returns them with WasteAction objects (paste-to-CLAUDE.md, paste-to-session-opener, prompt-now, edit shell config).
| Detector | Line | What it catches |
|---|---|---|
detectJunkReads |
428 | Reads into node_modules, .git, dist, etc. |
detectDuplicateReads |
477 | Re-reads of the same file in a session. |
detectMcpToolCoverage |
795 | MCP servers with many tools but low usage. |
detectUnusedMcp |
855 | MCP servers configured but never invoked. |
detectBloatedClaudeMd |
944 | CLAUDE.md files past a healthy size. |
detectLowReadEditRatio |
987 | Edit-heavy sessions with too few prior reads. |
detectCacheBloat |
1048 | High cache_creation_input_tokens. |
detectGhostAgents |
1124 | Defined but never-invoked Claude agents. |
detectGhostSkills |
1154 | Defined but never-invoked skills. |
detectGhostCommands |
1184 | Defined but never-invoked slash commands. |
detectBashBloat |
1228 | Shell output limit set above the recommended 15K chars. |
detectLowWorthSessions |
1405 | Sessions with cost but no edits or git delivery. |
detectContextBloat |
1512 | Input:output token ratio above 25:1. |
detectSessionOutliers |
1558 | Sessions costing more than 2x the project average. |
Output Formats
| Command | --format choices |
Default |
|---|---|---|
report, today, month |
tui, json |
tui |
status |
terminal, menubar-json, json |
terminal |
export |
csv, json |
csv |
plan |
text, json |
text |
The macOS menubar and GNOME extension consume menubar-json. src/menubar-json.ts defines the contract; tests/menubar-json.test.ts pins it.
Providers (src/providers/)
Every provider implements the Provider interface in src/providers/types.ts:
type Provider = {
name: string
displayName: string
modelDisplayName(model: string): string
toolDisplayName(rawTool: string): string
discoverSessions(): Promise<SessionSource[]>
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser
}
src/providers/index.ts registers providers across two tiers:
- Eager:
claude,cline,codewhale,codebuff,codex,copilot,devin,droid,gemini,hermes,ibm-bob,kilo-code,kiro,kimi,lingtai-tui,mistral-vibe,mux,openclaw,open-design,pi,omp,qwen,roo-code,zerostack,grok. Imported at module load. - Lazy:
antigravity,forge,goose,cursor,opencode,cursor-agent,crush,warp,vercel-gateway,zcode,zed. Imported via dynamicimport()so the heavy dependencies (SQLite, protobuf, network clients) do not touch users who do not have those tools installed.
Both lists hit the same getAllProviders() aggregator. A failed lazy import is silent and excludes that provider from the run.
src/providers/vscode-cline-parser.ts is a shared helper consumed by cline, ibm-bob, kilo-code, and roo-code. It is not registered as a provider on its own.
For the per-provider data location, storage format, parser quirks, and test coverage, see docs/providers/.
macOS Menubar (mac/)
Swift package (mac/Package.swift), targets macOS 14, strict concurrency on. Layout under mac/Sources/CodeBurnMenubar/:
CodeBurnApp.swiftboots the SwiftUIAppand theNSStatusItem.AppStore.swiftis the single source of truth for UI state.Data/holds models, the CLI client, credential stores, and subscription services.DataClient.swiftspawns the CLI and decodesMenubarPayload. See file-level comment for why we never route through/bin/zsh -c.MenubarPayload.swiftmirrors the JSON the CLI emits; keep it in sync withsrc/menubar-json.ts.
Security/CodeburnCLI.swiftresolves the CLI binary (env overrideCODEBURN_BIN, fallbackcodeburn), validates each argv entry against an allowlist regex, and augments PATH for Homebrew and npm-global installs. The Process is launched via/usr/bin/env, never via a shell.Theme/holds color and typography constants and the dark/light state.Views/are the SwiftUI components rendered insideNSPopover.
Tests live in mac/Tests/CodeBurnMenubarTests/ (currently CapacityEstimatorTests.swift).
The build artifact is a zipped .app bundle produced by mac/Scripts/package-app.sh. See RELEASING.md for how the GitHub Actions workflow uses it.
GNOME Extension (gnome/)
Plain JavaScript, no bundler. Targets GNOME Shell 45-50 (metadata.json).
extension.jsis the entry point. Onenable()it constructs aCodeBurnIndicatorand adds it to the panel.indicator.jsis the popover. It owns the period selector, the insight tabs, and the provider filter.dataClient.jswrapsGio.Subprocessto call the CLI. It validates argv against the same allowlist pattern as the macOS client and augments PATH with~/.local/bin,~/.npm-global/bin,~/.volta/bin,~/.bun/bin,~/.cargo/bin,~/.asdf/shims, and a few others. Results are cached for 300 seconds.prefs.jsis the settings dialog backed byschemas/org.gnome.shell.extensions.codeburn.gschema.xml.install.shcopies the extension into~/.local/share/gnome-shell/extensions/.
Build (scripts/, tsup.config.ts)
npm run build is two steps:
node scripts/bundle-litellm.mjsfetches the latest litellm pricing JSON and writessrc/data/litellm-snapshot.json. The bundle script keeps a manual override for MiniMax variants. Direct (un-prefixed) entries win over prefixed ones. The result is checked in so the build is reproducible.tsupreadstsup.config.tsand emits a single ESM bundle atdist/cli.jswith a Node shebang banner. No source maps in publish builds; sourcemaps on for development.
The prepublishOnly hook in package.json runs npm run build so npm publish always ships fresh code.
Tests
npm test runs vitest, scoped to tests/. 192 test files live there:
tests/root (141 files) covers CLI, parser, optimize, cache, format, models, plans.tests/security/(1 file) covers prototype-pollution guards.tests/providers/(44 files) covers per-provider parsing.tests/sharing/(6 files) covers the share/export surface.tests/setup/holds the env-isolation setup file, not specs.tests/fixtures/holds redacted real-world session data.
The scope is deliberate: the Electron app under app/ has its own vitest config and its
own jsdom dependency, so vitest's default glob must not reach it from a root install.
The three cache-refresh-lock suites are excluded from npm test and run serially via
npm run test:locks, because they exercise a cross-process file lock and fail under full
worker pressure.
Three providers ship without dedicated test files today: claude, goose, qwen. Closing this gap is a standing good-first-issue.
CI runs Semgrep against .semgrep/rules/no-bracket-assign-hot-paths.yml over src/providers/ and src/parser.ts (.github/workflows/ci.yml). The vitest suite runs in CI too, via .github/workflows/tests.yml, on every pull request and every push to main.