flatSlice returned strings within the bound unchanged, but provider adapters
pre-truncate with .slice(0, 500) before the cache site, so those views still
pinned their parent buffers. Always flatten; the round-trip is ~150ns per turn.
Use utf16le so lone surrogates survive the copy.
Cache the canonical-path Promise instead of the resolved value so calls in one
Promise.all batch share a single walk.
Document the one-time kiro re-parse and worktree regrouping.
The kiro provider was reading the full working directory from session
metadata (meta.cwd for CLI sessions, workspacePaths[0] for v2 IDE
sessions, workspaceDirectory for workspace sessions) but discarding it
via basename(), keeping only the leaf name for display. This meant
computeAttributionRecords could never resolve kiro sessions to a git
repo, so `codeburn sync push --attribution` produced 0 facts for all
kiro-originated sessions.
Now passes the full path as projectPath on emitted ParsedProviderCalls,
which buildRepoGroups uses to resolve git identity and correlate
commits with sessions via timestamp windows. The path stays local:
only the normalized origin remote egresses in attribution spans.
Behavior changes beyond attribution:
- kiro calls now flow through canonicalizeProviderCallProject, so kiro
sessions in LINKED GIT WORKTREES canonicalize to the main repository:
their report project name changes from the worktree dir name to the
main repo name (consistent with claude/codex behavior).
- workingDirectory is now populated on kiro calls.
- PROVIDER_PARSE_VERSIONS.kiro bumped (project-path-v1): cached entries
predate projectPath and are served without re-invoking the parser, so
without the bump this fix silently no-ops for every warm cache. The
bump forces a one-time cold kiro re-parse on upgrade.
ORDERING: this commit must land WITH (or after) the preceding
SlicedString OOM fix. The forced cold re-parse it triggers is exactly
the workload that OOM'd before that fix on multi-GB kiro stores.
Perf: per-call canonicalization added a measured +5% to cold parse
(.git-marker lstat walk per call). resolveCanonicalProjectPath is now
memoized on cwd (cleared with the session cache), removing the
redundant walks for all providers.
Tests: projectPath emission fixtures for all three session formats
(CLI, v2 IDE, workspace-session), fingerprint-change assertion, and a
regression test seeding a pre-bump cache entry and proving the re-parse
recovers projectPath.
AI-Origin: human
String.prototype.slice returns a V8 SlicedString: a view that retains a
reference to its ENTIRE parent string. The parsers store short previews
of message text (userMessage.slice(0, 500/2000)) in the long-lived
session cache. Session files routinely carry 100KB+ strings (agent-
injected system prompts, tool results), so every cached preview pinned
its full parent buffer for the life of the process.
Measured on 3.2GB of kiro CLI session files (6,659 files, largest 40MB):
cold parse, default heap, before: 4.33GB peak -> OOM crash
cold parse, 8GB heap, before: 5.67GB peak (kiro provider alone)
after kiro flatSlice: 0.91GB peak
after parser.ts cache sites too: 0.64GB peak
original failing command (cold,
default heap, all providers): 0.89GB peak -> completes
Warm runs were always fine (~0.29GB) because the cache's JSON round-trip
flattens the strings on load — which made this bug appear intermittent:
it only fired on a cold or invalidated cache.
Fix: flatSlice() in content-utils.ts forces a flat copy via Buffer
round-trip. Applied at the six kiro userMessage capture sites and the
three shared cache-building sites in parser.ts (protects all providers).
Regression test asserts the no-retention property via bounded heap
growth over 1000 large-parent slices.
AI-Origin: human
cli-json-daily, spend-flow and cli-emitters spawn the real CLI per test
and blow the 5s default under full parallel suite load while passing in
isolation - the flake set #948 documented on unmodified main, observed
again locally (cli-json-daily) and in CI (cli-emitters on a green PR).
Same file-level remedy the CLI menubar suite already uses; the default
stays 5s for everything else.
The cwd evidence rule attributed ANY session sharing a checkout with a
PR-linked session, with no time bound - so a repo whose only captured PR
link was pasted once became a black hole: 129 of 131 sessions and a
month of unrelated work (~$7.4K direct, $11.2K displayed) attributed to
one PR, observed live on the desktop Pull requests tab.
Cwd anchors now carry the evidence sessions' own activity window (union
across evidence for the same PR set), and only sessions overlapping that
window plus a 6h pad inherit the PR. The rule's charter is 'a tool
session launched around PR work in this checkout', which is inherently a
same-working-stretch claim; the design's own philosophy (timestamps
narrow, never create) now applies to this rule too.
On the real corpus the row corrected to $450.87 / 21 sessions across the
PR's actual two-day working stretch. Regression pins both directions:
nearby same-cwd session inherits, weeks-later one never does; multiple
evidence sessions widen the window.
The remaining warm-serve cost was the per-request discovery sweep
(stat-ing thousands of session files) plus re-aggregation, even when
nothing on disk had changed. Serve now watches every provider's
probeRoots() via fs.watch (FSEvents-backed recursive watches on macOS)
and injects a quiet-since validator into the parser: while the watched
roots are quiet, a previous parse stays reusable past the burst window,
and an output-level memo returns identical panel queries verbatim - so a
fetch with no data changes skips the sweep AND the aggregation.
Safety rails, in order: a parse is validated-reusable only if the
watchers were armed before it ran; any filesystem event ends reuse
instantly; a 5-minute hard cap self-heals a missed event; a root that
fails to watch just goes uncovered (shorter reuse, never staleness);
outside serve the validator is never installed and behavior is
byte-identical. During an active AI session the session roots fire
constantly, so reuse correctly stays inside the 10s burst window - the
extended reuse serves the idle-browsing case it was built for.
The one watched path inside the cache dir is antigravity's statusline
file specifically, so serve's own cache writes never self-invalidate.
Adversarial review of the serve design surfaced three weaknesses a
one-shot CLI never had, because it never lived long enough:
- Pricing-affecting config (model aliases, price overrides, local-model
savings) now participates in the parse memo key. Config reloads fresh
per request (the preAction hook), but a memoized or burst-reused parse
embedded costs priced under the OLD config; the widened key makes any
such change an automatic memo miss. New alias-hash helper + tests.
- Memory guard: past 3GB RSS the serve loop drops its in-memory memos
(session cache + parse entries) and the next request re-parses once.
The child never exits for this, so the client's death budget is
untouched.
- codeburn serve typed in an interactive terminal now explains itself on
stderr instead of hanging silently on stdin.
Every CLI spawn on a large corpus pays seconds of fixed cost before any
query work: node boot, a 100MB+ session-cache JSON.parse, the discovery +
fingerprint sweep, and serve-time classification. The desktop app spawns
one CLI per panel fetch, so it pays that cost per panel.
codeburn serve --stdio is the same CLI kept warm: the app holds one child,
sends {id, args} per line, and gets the command's stdout back. Three layers
make it fast, each disabled outside serve so one-shot runs stay byte-exact:
- loadCache memo (session-cache.ts): the parsed cache object is reused
while a stat() shows the file unchanged; saveCache updates it
write-through. A rewrite by another process still forces a fresh read.
- burst reuse (parser.ts, CODEBURN_PARSE_BURST_MS, serve sets 10s): panel
bursts anchor their range ends at their own new Date(), so the exact-key
memo never hits in real traffic; within the window a re-anchored range is
served by trimming the previous parse instead of re-running discovery.
- fresh commander program per request (main.ts buildProgram factory),
because commander option state is sticky across parses.
The server allows only the app's read queries (status/overview/models/
sessions/compare/yield/spend/optimize/audit), refuses everything else
(client falls back to a spawn), serializes requests, and converts
process.exit into a caught signal. The app starts the child once at
startup; requests route through it only when warm, cold-start keeps the
spawn path with its progress events, any serve failure falls back to a
spawn, and three child deaths disable serve for the app run.
Measured on a real 17B-token corpus: panel fetches drop from ~7.4s per
spawn to 5-900ms warm (sessions/spend 5ms, status 898ms). One-shot CLI
output verified byte-identical against the pre-branch baseline.
Improve dashboard refresh coordination, scrolling, responsive layout, and data-density behavior, including the Windows-safe resize correction validated on the final head.
Add doctor probeRoots coverage for the remaining fixed-location providers while keeping discovery and diagnostics on the same shared root-resolution logic.
Round 2 of the independent review proved five things by mutation: it broke the
behavior and the tests stayed green. Every one is now pinned.
The most important invariant in this change was the least guarded. Copilot must
have NO entry in PROVIDER_ENV_VARS - declaring any of its nine reads moves its
fingerprint and re-opens the durable history-loss path - but only one of the
nine was covered, so declaring any of the other eight passed the whole suite.
Now the absence of the entry is asserted directly, and all nine vars are
table-tested for fingerprint stability.
Doctor stops blaming parse-only overrides for a failed discovery.
CODEBURN_CURSOR_MAX_BUBBLES caps how many bubbles Cursor parses and
KIMI_MODEL_NAME renames an attributed model; neither relocates anything, so
"NOTHING FOUND (override CODEBURN_CURSOR_MAX_BUBBLES set...)" pointed the user
at the wrong thing. Both join NON_DISCOVERY_ENV_VARS, which exists for exactly
this, and both still appear in Details - only the verdict's blame line changes.
The secret-redaction and ambient-suppression tests are table-driven over both
names each covers, since removing either second name (VERCEL_OIDC_TOKEN,
LOCALAPPDATA) previously leaked or surfaced it with every test still passing.
The changelog no longer claims a one-time re-parse for the Vercel gateway: it
is a network provider re-fetched on every writable run, so its declaration is a
read-only-path correction, not a migration. Fourteen file-backed providers
migrate once.
Five findings from a cross-model review of the previous two commits, each
verified on the code before acting:
Copilot is no longer declared. Declaring anything for it changes its
fingerprint, and getOrCreateProviderSection keeps only cached entries whose
source path is gone - but OTel discovery returns one source per DB file
(copilot.ts:1935) and that DB keeps existing, so the entry would be dropped and
re-parsed, destroying conversations Copilot has since pruned from the DB that
only the cache still holds. Trading a staleness bug for a data-loss bug is a
bad trade; copilot waits for the durable carry-forward to merge instead of
drop, and its reads are allowlisted with that reason.
The Vercel gateway credentials ARE declared, reversing the previous commit's
reasoning, which was wrong: servedSources is seeded with every discovered
source (parser.ts:2875) before the network branch, and the network re-fetch
(parser.ts:2888) only runs when !readOnly, so a read-only refresh serves the
cached report and an undeclared credential keeps reporting the previous
account's usage after a swap. Doctor redacts credential values so a key can
never reach terminal output or the JSON report.
AMBIENT_ENV_VARS narrows to APPDATA and LOCALAPPDATA. Windows sets those for
every process so they carry no intent, but the XDG vars are opt-in and do:
suppressing them made doctor answer a deliberately relocated XDG_DATA_HOME with
"tool likely not installed", which is worse than the noise it avoided.
The guard's allowlist is keyed by file and var, not var alone - a var
allowlisted for one file silenced every other file's undeclared read of it.
Cursor drops its stale XDG_DATA_HOME declaration, which it never reads; its
fingerprint already changes here, so this costs no extra migration.
cursor-agent keeps its equally stale one, since removing it would force a
re-parse to fix nothing.
The nine undeclared overrides in #920 all slipped through the same way: the
declaration lives in one file and the read in another, and nothing tied them
together. Add the static guard the issue asked for - every process.env read in
src/providers is either declared in PROVIDER_ENV_VARS for the provider(s) that
file serves, or allowlisted with a reason. It resolves bracket literals, dot
access and `process.env[CONST]` indirection (open-design's ENV_DIR), and fails
loudly on any read it cannot resolve to a name rather than skipping it, since a
silently skipped read is how this class of defect survives. A read-bearing
provider file missing from the file-to-provider map fails too, so a new
provider cannot join without being mapped. A second assertion catches a
PROVIDER_ENV_VARS key that is not a registered provider name, which declares
nothing and fails just as silently.
Plus the direct regression: each of the nine reported (provider, var) pairs
must move the fingerprint, with codex/CODEX_HOME as the control the issue used,
and the round trip asserted so the hash stays a pure function of the
environment.
Reconcile with #914 (which independently fixed the same date-sensitive
fixtures): keep this branch's equivalent date fixes for parser.test.ts and
cli-durable-totals.test.ts, and drop the global vitest retry #914 added now
that this branch fixes the flakes at the root (fs.rm retries, longer timeouts,
serial cache-lock CI step). The targeted cache-lock local retries stay.
Hermes, Cursor, OpenCode and copilot OTel sources all live in SQLite
databases that their agents keep open in WAL mode for the life of the
process. Committed writes park in <db>-wal until a checkpoint, so the
main file's stat can sit hours or days behind the newest committed data.
fingerprintFile only statted the main file, which broke two ways:
- The date-range mtime pre-filter in parseProviderSources read the stale
mtime as "nothing in range" and skipped the source entirely. Every
Hermes session committed after the last checkpoint vanished from
reports: the today-parse skipped the db (mtime < local midnight) while
the backfill only keeps days through yesterday. Exactly the "17
sessions in the DB, 14 reported, the 3 from today missing" report in
issue #913.
- reconcileFile saw an unchanged fingerprint between checkpoints and kept
serving stale cached turns for sessions that had since grown.
Fold the -wal sibling into the fingerprint: newest mtime wins and sizes
add, so both WAL growth and a checkpoint (db grows, wal truncates) move
the fingerprint. -shm is deliberately ignored (it mutates on reads).
Bare SQLite paths get the fold only when the extension says database, so
JSONL transcript fingerprints (offset-based append detection) are
untouched.
Refs #913
parser.test.ts (a)/(f): createJsonlSession stamped events at a fixed 2026-05-01
that aged past the 90-day retention window, pruning to zero; date them relative
to now. cli-durable-totals: seedLiveTodaySession stamped noon, which is in the
future on a pre-noon run so the provider-scoped today slice (ends at now)
dropped it while the all path (ends at range end) kept it; seed a past-today
time. cache-refresh-lock and other integration tests starve under a saturated
parallel run and fail closed; add a small global retry and raise the two most
load-sensitive lock tests. Test-only; no production code changed.
When the project directory IS the home directory, countSkills pushed both
~/.claude/skills and <project>/.claude/skills - the same path - and counted
every skill twice, and scanMemoryFiles read ~/.claude/CLAUDE.md twice,
inflating the context-budget estimate. Dedupe both by resolved path.
Mutation-checked: a single home skill counts 2 before the fix, 1 after.
- vscode-cline-parser: entry.ts was truthy-checked but not validity-checked,
so a garbage timestamp made new Date(ts).toISOString() throw RangeError and
abort the whole session parse. Validate the date, fall back to empty.
- models: parseLiteLLMEntry read fields off its argument with no null/type
guard, so a null value in the remote LiteLLM pricing JSON threw and aborted
the entire live pricing load. Return null for a null/non-object entry.
Both mutation-checked: the tests raise RangeError / TypeError before the fix.
cacheKey fingerprinted only project count + api-call sum, so two datasets
agreeing on those two numbers collided onto one cached OptimizeResult, and
a cost/token change that left call count unchanged (e.g. a re-price) served
stale findings within the 60s TTL - reachable in the long-lived menubar.
Fold total cost, savings and proxied cost (scaled to micro-dollars) into
the key. Exported cacheKey and mutation-checked: the old key collides two
same-shape datasets and a re-price; the new one separates both, while an
identical dataset still keys identically.
parseChatFile estimated input tokens from pendingUserMessage - the last
human turn sliced to 500 chars - while output summed every bot char, so a
multi-turn session or any prompt over 500 chars undercounted input tokens
and therefore costUSD severalfold. Accumulate every human turn's full
length (inputChars), matching the modern-execution path; keep the 500
slice for the display userMessage only. Mutation-checked: a 2400-char
prompt reports 125 tokens before, 600 after.
handle() is dispatched via `void`, so a throw before its try/catch is an
unhandled rejection on a LAN-facing server. A request target the HTTP
parser accepts but the WHATWG URL parser rejects (unterminated IPv6 host
like //[::1) threw at new URL() and hung/killed the process. Parse inside
a guard and answer 400. Mutation-checked: the test times out with an
unhandled error before the fix, passes after.
On a tz-change full re-derive, mergeDayEntries carried a baseline slice
whenever the fresh day had no data slice for that (date, provider), so a
turn that re-bucketed across local midnight left its old day sliceless,
got carried there, AND counted again on its new day. This subtracts from
each carried baseline slice exactly what the fresh parse still attributes
to that (date, provider) under the OLD bucketing (dateKeyInTz): the
re-bucketed turns, nothing else. A sources-gone slice has no such content
and survives untouched; a fully-explained slice is dropped; residual
slices ADD their sessions instead of max-dedup, since the subtraction
already removed the placeholder's share.
The shared base computation guarded hours >= 2 but its h < 2 branch
still subtracted five minutes past midnight, escaping into yesterday
during the first five minutes of UTC hours 0 and 1 and zeroing every
'today' assertion - which is exactly when runs #6 landed. Midnight
clamp replaces the guard at all four sites.
OpenClaude is a Claude Code fork routing to any LLM; transcripts are
Claude-Code-schema JSONL under ~/.openclaude/projects/<slug>/<uuid>.jsonl
with replay.json siblings skipped. Only usage-bearing assistant lines
become calls; sidechain lines are counted as real spend; costs are always
computed (the transcript reports none) through the shared tables.
Real local testing: sessions generated with the actual CLI against
DeepSeek (deepseek-chat), parsed end to end.
server.close() only stops new connections; an in-flight fire-and-forget
cache save can land a file mid-recursive-rm, surfacing as ENOTEMPTY on
slower runners (run #5). fs.rm's built-in retries absorb the window.
Every case spawns the real CLI and does genuine multi-provider parse
work; run #3 showed a second sibling crossing the 5s default on the
shared runner. File-level cap replaces the earlier single-test one.
- cli-durable-totals: the live fixture session was stamped at noon today,
so every before-noon run saw it in the future; the provider-filtered
path drops future instants while the all-provider path keeps the whole
day, failing the parity assertion. Relative-and-clamped timestamps,
the same fix project-filter-durable-totals got in 1596220.
- parser (copilot, 2 cases): the fixture's fixed 2026-05-01 dates crossed
copilot's durable 90-day age-out on 2026-07-30, so the first parse
pruned the freshly-cached session. Relative timestamps.
- parser-incremental-append: unlink-then-create let ext4 hand the freed
inode straight back, breaking the new-inode premise. The replacement
is now created beside the original and renamed over it.
- parser-proxy-pricing: normalizeProxyPath folds case only on darwin and
win32, deliberately; the test now asserts the platform-correct
behavior on both kinds of filesystem instead of hardcoding macOS.
- cli-status-menubar: the config-source filter case does real multi-parse
work and needs more than the 5s default on shared runners; 30s cap.
Each implementation calls the exact resolution helpers its provider's
discovery uses (same env fallbacks, same OS branches, same closure
overrides), so doctor reports precisely where discovery looks and a
stale override or moved data dir stops being a silent $0.00.
lingtai-tui deliberately reports its candidates pre-existence-filter:
getLingTaiHomes drops non-existent dirs (right for discovery, wrong for
doctor, whose job is to show where it looked).
tests/provider-probe-roots.test.ts locks the mirroring: where a factory
takes an override, the same override must come back through probeRoots.
The ConPTY guard swallowed a chunk only when it exactly equaled BSU or
ESU, so any write concatenating them with other output reached Windows
raw and hung ConPTY, which buffers the unimplemented 2026 sequence
indefinitely (#195; the class recurred in #863's resize path). Strip
every occurrence from string chunks instead: standalone escapes are
swallowed, concatenated ones lose only the escapes, and a swallowed
write now also honors its callback so callback-style writers cannot
wedge. Non-string chunks pass through untouched.
The rollup fallback was gated on the post-dedup emitted counter, so a session
whose per-message calls were all suppressed by the shared dedup (a duplicated
session directory reusing a session_id) fell through to the metadata.usage
rollup and double-counted its cost. Gate on a hadMetrics flag set before the
dedup check instead.