Add orcarouter/ as a routing wrapper like cmd/ and antigravity/, alias the
smart and fusion routes to their current upstream rows, and give each route
a branded display name. Unknown vendors nested inside the route still fail
closed.
Extends the existing borrowed-credential quota module (claude/codex) with
three providers, per-provider settings toggles, and a provider display map:
- gemini: Code Assist loadCodeAssist/retrieveUserQuota via the Gemini CLI's
on-disk OAuth creds; optional env-gated token refresh; retired-tier
responses degrade to terminalFailure with migration guidance
- copilot: copilot_internal/user with editor-plugin headers; token read-only
from hosts.json/apps.json with one re-read on 401; marked as an internal
API that may drift, failures degrade honestly
- antigravity: loopback-only Connect-RPC probe of the local language server
(ps/lsof discovery via execFile, csrf token for the app variant, agy CLI
accepted tokenless); TLS relaxation scoped to 127.0.0.1; no OAuth fallback
- settings: per-provider quota switches (default on) persisted renderer-side,
honored in the main-process service so disabled fetchers never run; IPC
disabled-list filtered against the provider allowlist
- sanitizeError extended for Google (ya29.) and GitHub (gh*_) token shapes
Kimi audited, not duplicated: the macOS menubar already fetches Kimi quota
live (KimiSubscriptionService.swift); the electron app not surfacing it is a
pre-existing gap, unchanged here.
The unset default opened on 7 days everywhere. It now opens on today and
only falls back to 7 days when today holds no sessions yet — the decision
is made from the today-scoped slice of the parse the first paint already
runs, so the probe costs a filter, not a second pass.
Explicit selections are untouched: -p/--period, --day, --from/--to, the
TUI period keys, the app's persisted default period, and every one-shot
(--format json, report/sessions/status, the piped non-TTY render) keep
the 7-day default they had.
Fixes#1111
A cold start parsed the whole corpus before painting anything (~36s on a 21k
corpus) even though the default view only shows the last 7 days. A session log
is append-only, so its last event timestamp is <= its mtime: a file stamped
before the start of the displayed range provably holds nothing that range can
show. On a cold cache the interactive dashboard now parses only the files above
that floor, paints, and indexes the rest from the mounted UI.
- parser: `withColdFirstPaintFloor` scopes a parse to files that can hold
in-range data (mtime >= rangeStart - 48h clock-skew margin), deferring only
files with NO cache entry — nothing that has something to serve is held back,
and network sources (synthetic now-stamped fingerprint) always load.
- A run that deferred anything is a partial hydration: it does not stamp the
session cache complete and reports hydrationComplete false, so the daily
backfill cannot finalize history off it and the next launch resumes cold.
A floored run that deferred NOTHING keeps the normal stamps.
- The floor participates in the parse memo key, so a first-paint result can
never be served to (or burst-reused by) an unfloored request.
- dashboard: cold + TTY + standard dated view only. The background fill is an
ordinary unscoped background reload, so its cache writes and its refreshed
panels are exactly what a full cold parse produces. While it runs, an
"indexing history N/M files" banner states that the totals cover only what is
indexed so far.
One-shot outputs (json/csv/markdown, report, sessions, models, serve/menubar/app
payloads) are non-TTY and never enter this path.
Fixes#1107
renderDashboard issued three parseAllSessions calls (scan range, plan window,
durable headline). They often share a START and differ only in where they end —
end-of-day vs each caller's own `new Date()` — which the exact-key memo cannot
match, so a warm render paid for the discovery sweep, the cache read and the
parse two or three times over.
withSinglePassParse lets a caller declare the widest range it will ask for. A
later request that is a PURE NARROWING of it is served by slicing that parse
instead of running the pipeline again. Pure narrowing is deliberately strict:
same start, an end that is inside, and the same month shard scope. Both extra
conditions are load-bearing, because a parse's file set is a function of its
range:
- a changed file with `mtimeMs < range.start` is skipped without being parsed,
so an earlier start reads files a later start never sees;
- loadCache reads only the shards monthScopeForRange selects;
- either way the extra files seed seenKeys/seenMsgIds BEFORE the range slice,
and a seeded key suppresses the matching in-range turn in a provider parsed
later — usage the narrower parse would have counted.
Holding start and month scope equal makes both parses see an identical file set,
leaving the range slice as the only difference — the same trade burstReuse
already makes for the mirror-image case (same start, later end).
Independently, correlateCrossProviderPrSessions ran two O(n*m) scans: a
`sessions.filter` per subagent child (now a one-shot agentId index) and a full
`launches.filter` per candidate (now a sorted array plus a windowed scan). Both
preserve the exact match set; launch order was never observable because the
matches collapse into a Map keyed by the sorted ref list.
Warm, 21k-file corpus, isolated cache, median of 5 interleaved:
today 5.12s -> 3.81s (1.34x) 3 parses -> 2
month 5.46s -> 3.41s (1.60x) 2 parses -> 1
today --format json 4.11s -> 3.49s (1.18x, correlation hoist only)
vitest.config gains an explicit exclude so a .claude worktree's stale tests/
copy stops being swept into the run.
Refs #1106
Every dated command re-walks and re-stats every provider tree before it can
decide what the cache already covers. That sweep was strictly serial at every
level -- one readdir, one stat, one state.json read at a time, and one provider
after another -- so on a 21k-file / 9-provider corpus it owned most of a warm
run's wall clock with the machine idle waiting on the kernel.
Measured per warm `today` pass on that corpus before this change: 859 ms in
discoverAllSessions (kimicode 467, codex 143, claude 135, grok 77) and 727 ms in
the Claude project walk + fingerprint pass.
- fs-utils: mapWithConcurrency + FS_SCAN_CONCURRENCY, one bounded, order-
preserving helper for the whole sweep.
- providers/index: run provider discovery concurrently, concatenated in
registry order.
- claude/codex/grok/kimicode: walk each level with the level fanned out,
re-concatenated in readdir order before anything reconciles.
- parser: the Claude dir walk and both fingerprint passes (scanProjectDirs and
parseProviderSources) fan out, then reconcile serially in discovery order,
which is what changedFiles ordering and the seenMsgIds pre-seed depend on.
- parser: collectJsonlFiles reads entries with their types, so a plain file no
longer costs a wasted subagents/ probe.
- codex-cache: share one in-flight load between concurrent readers. The memo is
only populated after the read + parse resolves, so concurrent discovery had
every caller re-reading and re-parsing the same (here 59 MB) file.
No reconciliation logic changed: the same fingerprints reach the same cache
comparison in the same order. Warm `today` 5.30s -> 2.99s, cold 34.7s -> 31.1s
(medians of 5 / 2, isolated HOME + cache). today/report -p month/models/sessions
JSON, warm and cold, per-provider and combined, are byte-identical apart from
the run's own `generated` timestamp.
Closes#1104
isSessionHydrationComplete() (parser.ts) reads a module-level global that is
only safe immediately after the parse it describes, with no intervening
awaits. The stale-marker wiring landed in the prior commit read it at the
bottom of buildMenubarPayloadForRange, after several awaits -- including,
on the claude-config-scoped branch, a second parseAllSessions call for the
365-day history block that runs after the parse producing the headline
data. That let the history backfill's hydration outcome silently overwrite
the headline's.
Capture the flag into a local right after each branch's primary parse
resolves (the claude-config-scoped parse, or buildDurablePeriod) and thread
that captured value to buildMenubarPayload instead of re-reading the global
at the end. This also closes a cross-request race: once captured
synchronously, no other in-flight request's parse can flip it under us.
Regression test simulates a complete primary parse followed by an
incomplete bystander parse and asserts the payload reflects the primary
outcome, not the bystander's.
Drafted with minimax/MiniMax-M3 via local gateway.
The Copilot session-store.db writes the model as claude-haiku-4.5
(tier-first, dot), but no pricing alias existed for that raw id, so
calculateCost returned $0 while GitHub billed the real Anthropic rate
(kelchm's #946 validation: $0.0063536 for a sampled request). Aliases
it to the existing, correctly-priced claude-haiku-4-5 snapshot row --
no new rate invented. Unlike #1090's gpt-5.6-codex case, this id has
no prefix-fallback self-heal, so the daily cache bumps v26 -> v27 to
force re-derivation of already-finalized days.
Drafted with cline-pass/deepseek-v4-pro via local gateway.
isSessionHydrationComplete() (parser.ts, PR #937) already reports when a
read-only serve skipped or staled real on-disk changes; thread it through
buildMenubarPayload as an optional stale field, present and true only on a
stale serve, always absent otherwise, so older/newer CLI-app pairs stay
compatible. Mirrors the field into the desktop renderer types and the macOS
menubar's Codable payload model (data layer only, no view change).
Drafted with minimax/MiniMax-M3 via local gateway.
Today/week/month often have no session-linked PRs, so the tab
looked broken. Name the selected period and, when All has rows,
point at that count. Do not invent gh links. Do not change
attribution or the default period.
G1a - the stdin-EOF drain is now bounded (Promise.race against 45s, overridable
via CODEBURN_SERVE_DRAIN_MS for the test). Generous enough that no legitimate
request answered at EOF comes near it, and an async-wedged one releases the
child instead of turning it into the orphan the drain was added to prevent. The
comment names the ceiling: a SYNCHRONOUSLY wedged parse never yields to this
timer or to any other JS path, and only a signal can end that process.
G1b - Windows is the majority of the desktop fleet and had no orphan escape at
all: no ps, and no stdin-close recourse once the app has crashed. The reap now
works there. tasklist cannot report argv (image name and window title only) and
wmic is gone from current Windows, so the command line comes from
Get-CimInstance Win32_Process. Match discipline is unchanged - exact argv - with
quoting normalized on both sides, because we record a plain space-joined argv
while Windows reports the real quoted command line; a path with spaces still
round-trips. serveCommandMatches is extracted and tested directly.
G2 - the grace test raced node's cold boot (a 300ms window). The child now emits
one byte the moment it is ready, so the watchdog window is measured from
READINESS, not from spawn: boot cost cannot eat it at any machine speed. Timeout
raised on top of that. 6/6 green under 8 busy loops.
G3 - a served request heartbeats for its whole duration now, not just its parse:
the wrap moved to the one seam (runCaptured) that also covers aggregation and
payload serialization, whose ~8s tail lands back-to-back with the parse's own
quiet stretches. The one-shot path keeps the parse-only wrap, where an 8s tail
is comfortably inside the window on its own, and the CHANGELOG now says exactly
that instead of claiming every phase.
G4 - stillCold() anchors to coldStartBegan ?? bootedAt.
Text: the cold-timeout bound no longer claims to restore the "locate the CLI"
recovery (that is a not-found state, never cold-flagged), and the CHANGELOG
states the >15min case honestly - the ceiling still ends it, but partial cache
saves mean successive polls converge rather than each rescanning from nothing.
F1 - `codeburn serve` lost an in-flight request's response when stdin closed
mid-flight: runStdioServe returned before its queue drained, and the explicit
process.exit(0) then hit runCaptured's monkeypatched exit, throwing ExitSignal
and exiting 1 with the frame never written. The finally now awaits the queue.
F2 - the SIGTERM rationale was false. armSignalCleanup unlinks the refresh lock
and re-raises; it publishes no partial cache, and a lock left by SIGKILL already
self-heals through the stale-pid takeover. SIGTERM-first is kept for the real
(smaller) benefit - a clean lock release instead of a takeover - and every
comment plus the CHANGELOG now says only that.
F3 - the cold gate had no exit. overviewWarmed only flips on success, so an
install that can never hydrate sat behind an indexing splash forever with no
error and no route to the CLI recovery. The cold claim now expires with the cold
window itself.
F4 - the real CLI does not heartbeat the way the demo did: a cold parse's
inter-provider cache save measured 31.6s of total silence, which a 45s window
survives only until the machine is 1.5x slower. Under CODEBURN_PROGRESS a
running parse now emits a keepalive every 10s regardless of phase, so silence
genuinely means stopped. Consumers that do not know the event ignore it.
F5 - the orphan-reap identity check matched any `cli.js` running `serve`. The
pidfile now records the exact argv and `ps -ww` must match it exactly.
F6 - bump() re-armed the watchdog after settle, leaving a timer finish() never
clears when a killed child's buffered output landed.
F7 - kill paths dropped the child from activeChildren before SIGTERM and the
SIGKILL backstop was unref'd, so a quit inside the 5s grace orphaned a child
that ignores SIGTERM. It now stays registered until it actually dies.
N8 - the silence test wrote its only byte at t~0, so it passed without the
re-arm. The byte now lands mid-window and the kill is asserted from it.
N9 - documented why mutations keep a plain total cap.
scanProjectDirs (Claude) has long taken a throttled saveProgress
callback so a killed cold parse resumes from a warm cache;
parseProviderSources (every other provider) only persisted at the
whole-provider boundary. A run killed mid-way through a large single-
provider phase (a multi-GB codex corpus is the common case) discarded
everything parsed since the last provider finished, restarting that
whole phase from zero.
parseProviderSources now accepts the same onFileParsed callback,
invoked once per source right after that source's cache entry lands,
outside the per-file try/catch, mirroring scanProjectDirs' placement
and throttle. A file's fingerprint is only ever set after it fully
parses, so a mid-file kill can never leave a half-parsed file looking
complete on resume.
Verified against a real ~2GB Claude + ~4GB codex corpus (frozen
snapshot): kill -9 mid-codex-phase leaves a genuine partial cache
(no codex-results flush, complete: false), a resume only reparses the
unfinished remainder, and the final report is byte-identical to an
uninterrupted cold parse.
Resolves the conflicts #946 accumulated while it was in validation. Eight
files conflicted; the session-store accounting is unchanged.
src/daily-cache.ts — version collision. This PR minted 25 when main was at
24; #1056 (`codex-auto-review` pricing) then spent 25 on main. The bump moves
to 26/MIN 26 and daily-cache.v26.json, with main's full comment ladder kept as
the foundation and this PR's paragraph rewritten to name 26 and record the
collision. PENDING_REDERIVE_PROVIDERS and the B1 migration semantics from
b6481c19 carry over intact, retargeted at 26.
src/models.ts, src/parser.ts, src/audit-report.ts, src/models-report.ts —
#1075/#1078 replaced the per-site "reasoning is already inside output" tests
with billableOutputTokens() and REASONING_INCLUDED_IN_OUTPUT. This PR had
added copilot to that case at three sites independently. Union: all three
sites take main's helper call verbatim, and copilot joins claude and codex in
the set — same accounting this PR shipped, now through main's single source of
truth. It also reaches parser.ts activeGeneratedTokens (a fourth site, from
#1079), which is the same correction: a copilot supplementary call carries
reasoning with output 0, so counting it as generated repeats the per-turn
output. The audit legend already said so on this side.
src/providers/copilot.ts — comment-only. #1054's lastEventTimestamp-first
shutdown fallback was derived from this branch, so the code was already
identical on both sides: the `shutdownTimestamp` expression and the
`copilot:<sid>:shutdown:<model>:<n>` key are byte-for-byte main's. Both
rationales are kept (leg-collapse on date, and residual anchoring).
src/session-cache.ts — PROVIDER_PARSE_VERSIONS.copilot takes this PR's
`-session-store-v3` suffix; main's #1051 note about why a fingerprint change
is expensive is kept above it. Codex keeps main's #1092 suffix chain untouched.
src/main.ts — #1067 deleted the unreachable live dailyMap fallback that this
PR had taught behavioral weight. Main's deletion wins; the now-unused
isBehavioralTurn import goes with it.
tests/parser.test.ts — import union.
Also: scripts/upgrade-path/run.mjs NEW_DAILY_CACHE -> daily-cache.v26.json,
CODEBURN_COPILOT_SESSION_STORE_DB added to the #1064 env-isolation CLEARED
list, and the CHANGELOG entry's stale "v21" corrected to v26.
Verified: tsc clean; 3132 tests pass across 223 files; test:locks 26/26;
verify:upgrade PASSED, re-deriving daily-cache.v26.json and holding durable
copilot history across the bump. #1054's regression ("keeps three stampless
shutdown legs as :n keys with lastEventTimestamp") passes on the merged tree.
Real-corpus A/B against origin/main over 2026-07-01..2026-08-22: codex, grok,
kimicode and opencode byte-identical in export, audit and models; claude drifts
only monotonically with run order (a live session writing transcripts, confirmed
by interleaving four runs). This machine has no copilot data, so the copilot
recovery semantics rest on the suites and the upgrade-path corpus.
A settings mutation replaces the resident serve child, which may be mid-write:
the same lock hazard a timed-out read has, so it gets the same treatment.
restartAfterMutation and destroy now go through killGracefully instead of
SIGKILL. reapAll's one-shot loop stays a hard kill and now says why - quit has a
1.5s flush budget, shorter than the grace, so waiting one out would wedge the
quit, and a one-shot read holds no lock worth releasing.
The desktop app capped every read at 45s of TOTAL runtime, so a slow machine's
warm `optimize` (52.5s on 0.9.20, 39.6s on main) was SIGKILLed mid-parse and the
panel painted a red "timed out" that a 60s poll then reproduced forever.
- cli.ts: the read timeout is now a no-output watchdog. The window restarts on
every byte the child writes (serve: every frame carrying that request's id),
so only a genuinely silent child times out. A 15-minute absolute ceiling still
reaps a livelocked child. Every read spawn sets CODEBURN_PROGRESS=1 so a long
parse heartbeats through it; progress lines are stripped from any surfaced
stderr so they can never become the error message.
- cli.ts: a timed-out child gets SIGTERM first and SIGKILL only after a 5s
grace, on both the spawn and the serve branch. That lets the CLI's signal
cleanup publish its partial parse and release the cross-process refresh lock
instead of leaving both stale.
- main.ts: the 10-minute cold floor now covers EVERY read while the first
hydration is still running, not just the overview. Sections start polling the
moment `ready` flips (which an overview error also does), and a 45s section
spawn queued behind the cold parse was killed on arrival.
- main.ts/renderer: a timeout raised while the hydration is still running is
flagged `cold`, and the renderer keeps its indexing splash (and keeps the
sections gated) instead of latching ready and painting CliErrorPanel. Only
timeouts are flagged; a permission or nonzero failure still surfaces.
- cli.ts/main.ts: the resident serve child's pid is recorded under userData and
a serve orphaned by a previous crash is reaped at launch, after `ps` confirms
the pid still belongs to a codeburn serve.
- src/main.ts: `codeburn serve` exits explicitly once its stdin closes, so a
handle outliving the transport cannot keep the child alive as an orphan.
The merge with main pulled in #1056's codex-auto-review -> gpt-5.5
alias, which findUnpricedModels' pre-existing hasBillableRate check
now resolves for the raw id, so a synthetic $0 row for it is no
longer reported as unpriced (it correctly has a billable rate).
Removed it from the expected findUnpricedModels() output in the
"skips subscription / flat-rate product SKUs" test; the display-name
variant ('Codex Auto Review') is unaffected since getModelCosts does
not resolve display names, so it stays in the expected list. The
codex-auto-review / #1056 pricing interaction itself is already
covered by the "Codex activity ids (#1047)" describe block.
Resolves conflicts against main after #1078/#1084/#1088/#1090/#1092/
#1053/#1056 landed since this branch's last upstream merge.
- CHANGELOG.md: kept both entries (this PR's #968/#1050 note plus
main's #1079/#1088, #1082, #1075 notes that had moved into the same
"### Fixed" slot).
- Everything else (src/models.ts, main.ts, usage-aggregator.ts,
daily-cache.ts, tests/*) merged cleanly with no conflict markers;
git's recursive merge combined #1050's flat-rate classifier changes
with #1056's codex-auto-review -> gpt-5.5 alias without overlap.
Verified the codex-auto-review / #1056 interaction post-merge:
isBuiltInFlatRateModel no longer matches codex-auto-review (dropped
per #1050), while MODEL_ALIASES still aliases it to gpt-5.5 (#1056),
so it prices at GPT-5.5 rates rather than $0. Covered by the existing
tests/models.test.ts "Codex activity ids (#1047)" describe block and
the "does not treat a priced sibling as expected-free" case.
getFlatRateModelsConfigHash's output is folded into
getDailyCacheConfigHash's template literal unconditionally
(flatRateModels=<hash>), so the flat-rate section always participates
in the daily-cache invalidation hash even when empty -- no
DAILY_CACHE_VERSION bump needed for this change.
Resolves conflicts from main's #1078 (billableOutputTokens for the
output bucket) and #1084 (models-report test exemplar swap) against
this branch's canonical-id row merging.
- src/audit-report.ts: import-line collision only. Union both sides'
imports (billableOutputTokens + fallbackRawModelDisplayName/
getShortModelName); both are used elsewhere in the file and neither
side's logic needed further changes.
- src/models-report.ts: import-line collision resolved the same way.
The row-construction conflict was structural, not a data conflict:
main's side pushed one row per raw bucket (a simpler variant this
branch's canonical-id folding already made obsolete downstream --
rowsByKey/foldedCategoryCost/foldKey are used unconditionally past
this point). Kept this branch's rowsByKey merge-by-canonical-id
structure, which already computes credits from bucket.outputTokens
(already billable-output-summed at the accumulation stage, main's
change, untouched by this conflict) using the identical formula
main used; folded main's explanatory comment about billable output
into the kept credits block.
@vidoluco's round-6 re-validation: a cache generated by 0.9.20, copied and then
migrated by this branch, dropped exactly ONE call a virgin cache serves -
mai-code-1-flash-picker, 324 input / 91,136 cache read / $0.01, two minutes
after the labelled compaction row in its session. Every other day matched.
Root cause is the durable union merge, not the reconciliation. `compactedAt` is
a capture-only field this branch adds to the shutdown-ROLLUP call, and 0.9.20
already cached that call under the same (deliberately stable) dedup key. The
union appended only UNSEEN keys, so the bump could never reach it. The migrated
cache then ran the compaction-anchored residual math with no anchor - the
pre-anchor behaviour exactly: the interval opens at -Infinity, the
PRE-compaction rows are subtracted from a rollup that never counted them, and
the residual clamps to zero and disappears.
The union now REPLACES a cached call with the freshly-derived one wherever the
key matches. Nothing is deleted: keys the re-read did not produce are exactly
the rows the source has pruned, and those still carry forward untouched with
the old parser's fields. Keys it did produce were just derived from the live
source in this very pass, so the fresh call is by construction the better one.
This is also what makes the round-5 compaction-output fix reach an existing
cache - that row's key is stable too, so append-only would have pinned it at
output 0 forever. Both directions are pinned by (c6).
Two defects from @kelchm's two-machine validation of #946.
1. The daily-cache migration carried the copilot slice of every existing day
entry verbatim instead of re-deriving it, so `overview` on a migrated cache
kept serving pre-store numbers (2,980,804 tokens for a day whose fresh
derivation is 74,811,412; 21,608 calls vs 16,326) while export/models/audit
served the corrected ones off the same cache.
Root cause is the partial-survival guard (e9d922ca), not the migration: it
keeps a settled baseline slice whenever the fresh derivation reports FEWER
calls, reading that as aged-out sources. This change's whole point is that
copilot's supplementary accounting calls stop counting as api calls, so
every store-era day shrinks in calls and got pinned to its pre-store value.
The guard's own TRADE-OFF note predicted exactly this case.
A migration from an older cache version now grants copilot ONE
guarded-shrink-exempt re-derivation (`pendingRederive`), spent by the first
COMPLETE parse. It fires only where that parse actually produced a slice for
the (day, provider), so a day whose sources are gone still carries forward
whole - never-lose is unchanged in both directions - and no other provider
is exempt.
The daily cache moves 21 -> 25: 21-24 landed on main during validation, and
only a number the validators' own daily-cache.v21.json cannot claim gets
their stale carried slices re-derived.
2. The `initiator='compaction'` store row was ingested with outputTokens 0. Its
output has no assistant.message anywhere in events.jsonl, so that row is the
only place those tokens exist - dropping them was the sole token discrepancy
(-3,085) across a 30-session store-matched comparison. It is now read (on the
same optional-select rung as `initiator`, which is what identifies the row),
counted and priced; every other row keeps output 0 because a per-turn call
owns it. The copilot parse version moves to session-store-v3 so cached v2
rows re-parse; the dedup key is deliberately unchanged.
Settle window stays at 24 h - the three-machine evidence for tightening it is
recorded next to the constant, but shortening it is a product call.
Main already spent Codex results v14 (#1092). Daily stays 25
(#1090 still the last daily bump). Keep all four parse-version
tokens (codex-pricing-v1 + codex-tps-v1 + codex-mcp-skills-v1 +
activity-price-v1) so a take-ours merge cannot drop the pricing,
throughput, or MCP/skills invalidation. Do not Extra High again.
The `mcp-cli call <server> <tool>` matcher from #656 only ran on
`function_call` arguments. Codex records the same exec two other ways:
the custom-tool transport carries the shell tool as a `custom_tool_call`
whose payload is an `input` program (no `arguments` at all), and the item
model repeats a finished command as `event_msg`/`item_completed` with a
`CommandExecution` item holding an argv `command`. Both reach the Bash
counter and neither reached the matcher, so a CLI-wrapped MCP call stayed
missing from the MCP breakdown (#478).
All three shapes now feed one classification pipeline. The same pipeline
learns skills: Codex has no skill tool, so a skill load is a shell read of
the skill's SKILL.md, which landed entirely under Bash. A read counts only
when the command segment starts with a file-reading binary and the path
ends in `<name>/SKILL.md`; the skill is `<name>`, matching pi's key (#588)
and the Claude parser's vocabulary. A grep near a SKILL.md stays Bash.
Attribution only: the item-model path adds no tool of its own, and a
command carried by both shapes is attributed once (per-name claim ledger).
Cost, tokens, calls and daily rollups cannot move.
CODEX_CACHE_VERSION 13 -> 14 and the codex parse version both move: both
layers store tools/toolSequence/skills verbatim, and on a warm cache the
fix is otherwise invisible (verified by reverting the bumps: skills stay
empty). DAILY_CACHE_VERSION is deliberately unchanged - the daily cache
holds no tool/mcp/skill breakdown, and turn category cannot change because
every added attribution rides on a call that already has Bash.
Main already spent Codex results v13 (#1079) and daily v24 (#1090).
Keep all three parse-version tokens (codex-pricing-v1 +
codex-tps-v1 + activity-price-v1) so a take-ours merge cannot
drop the pricing or throughput invalidation. Do not Extra High again.
Re-instrumenting this exact head over the full corpus traced the
original 12.5%/46% figures to a replication gap: the earlier
prevCumulativeTotal guard (codex.ts) already discards any token_count
event whose running total exactly repeats the previous kept one, so a
drop at the seenKeys dedup site is always a byte-identical replay of
already-counted tokens -- never a real loss. The condition BUG-2's fix
guarded against does not occur in real Codex output.
Removes taskDedupedTokens, its increment at the dedup site, the
active-time scaling at task_complete, and the now-unexercisable unit
test (its fixture forces a dedup collision that is not also a
cumulative-total repeat, a state the real writer never produces). The
dedup site keeps a short comment recording why no rescaling is needed,
so the next investigator doesn't retrace this.
Keeps: the reasoning double-count fix, the harness-startup exclusion
(BUG-1, confirmed to the decimal), the shared mergeToolIntervals
helper and permissive duration parsing (BUG-8), and the legend rename.
The real-corpus table is unchanged -- BUG-2 measured zero effect on it
before this revert too.
Extends #1079 (reasoning double-count) with three more findings from an
exactness pass over the same throughput path:
- BUG-1: task_started fires before Codex assembles the request, so the
gap to the first request-context event (turn_context, world_state,
event_msg/user_message, or response_item/message) was counted as
active model time. The active window now starts at that event instead.
- BUG-2: a token_count event dropped by fork-replay dedup lost its
tokens from the numerator while the task's real duration still spanned
it in the denominator, understating Tok/s for a partially (not fully)
deduped task. The active window is now scaled down by the dropped
tokens' proportional share.
- BUG-8: the tool-interval clip/merge/cap logic was duplicated between
providers/codex.ts and codex-throughput.ts and had already drifted
(task_complete only read a plain-number duration, unlike
mcp_tool_call_end). Now one shared mergeToolIntervals, and
task_complete's duration parses the same permissive forms.
Cost and every token count remain byte-identical. activeGeneratedTokens/
activeDurationMs/toolWaitMs are stored verbatim in both Codex caches, so
none of this self-heals -- but the CODEX_CACHE_VERSION 13 bump already
shipped for #1079 covers the same fields, so no further bump is needed.
Dashboard's per-model column keeps the "Tok/s" header (zero width slack
at the standard layout, verified against a real test); the legend now
spells out "Effective Tok/s" with a decode-speed disclaimer.
Neither id is in LiteLLM yet, so litellm-snapshot.json had no row for
them (285 sessions / 5,446 calls priced at $0 on one real corpus,
flagged during #1075 verification). Add explicit snapshot rows sourced
from the pattern every prior Codex-suffixed id LiteLLM does carry
follows: it bills identically to its bare-model sibling of the same
generation (gpt-5-codex == gpt-5, gpt-5.1-codex == gpt-5.1-codex-max ==
gpt-5.1, gpt-5.2-codex == gpt-5.2, gpt-5.3-codex == gpt-5.3, verified
against the live model_prices_and_context_window.json) - both new ids
get the exact gpt-5.6 tuple rather than an invented rate.
getModelCosts already resolved both ids to the correct rate through
the `gpt-5.6` prefix fallback before this, so a fresh parse or a warm
session-cache read (codeburn models included) was already pricing
these calls correctly. The daily cache is the one place that does not
self-heal: it has no per-provider invalidation, so a day finalized
before a `gpt-5.6` snapshot row existed at all keeps that $0 forever.
Raising MIN_SUPPORTED_VERSION (v23 -> v24) forces the one-time
re-derivation, a lossless no-op for days already correct - the
mechanism itself is generic and already covered by
daily-cache-version-rederivation.test.ts. #1056 also claims 24 on its
own branch; whichever lands second takes the next number.
Closes#1077