Commit graph

46 commits

Author SHA1 Message Date
Resham Joshi
2eb4718122
sessions: correlate cross-provider work with pull requests (#792) 2026-07-21 11:50:11 +02:00
Resham Joshi
8e1caae33a
sessions: fold subagent runs into PR attribution (#791)
Some checks are pending
CI / semgrep (push) Waiting to run
* sessions: fold subagent runs into PR attribution

Sidechain (subagent) session cost never reached the by-PR view, so a
session orchestrated on one model with subagent lanes on another showed
only the parent model on every PR row. Fold each sidechain's cost, calls,
models, and categories into the parent turn that spawned it, so it
inherits that turn's PR set under the existing turn-level state machine.

Linkage, in priority order: the spawn result's toolUseResult.agentId
pairs the child's agent id with the Agent/Task tool_use id that launched
it (recorded per turn), which is the true launch point and wins even when
the child's first activity landed during a later turn; else the child's
first-activity timestamp is bucketed into the containing turn span; else
the child folds into the parent's unattributed spend. Children of parents
that referenced no PR, and orphans whose parent is absent from the scan,
contribute nothing, unchanged.

Cache v6 to v7 (neither shipped, so one combined bump from v5): per-turn
spawnToolUseIds, per-file parentSessionId and agentSpawnLinks; the
validator and the append/compact paths thread them like prRefs. By-PR
footers now count parents plus folded subagent runs and the payload gains
an additive subagentSessions field. distinctCost now includes folded
subagent spend, documented in the payload comment.

* sessions: address adversarial review of subagent PR attribution

Rework child attribution to resolve each subagent to the PR its launching
turn was working on, using the parent's UNFILTERED turn data, and enforce
that every dollar is counted exactly once.

- Mutual exclusion: a child that referenced its own PR attributes
  standalone and is never folded; a child with no links is folded only.
  Fixes a double-charge where a self-linking child was both folded and
  self-attributed.
- Recursion: a fold aggregates a child plus its non-self-linking
  descendants (depth-first, cycle-guarded), so grandchildren spawned by
  subagents reach the PR report.
- Global linkage: the subagent index keys by parentSessionId alone
  (UUIDs are globally unique), so a child whose worktree resolves to a
  different project still links.
- Date-range correctness: spawn-to-PR sets are built at assembly from the
  full turn list, so a spawn in a pre-range turn attributes to the right
  PR; a PR-linked parent whose own turns fall out of range is kept as a
  0-cost fold anchor so its in-range child is not lost.
- Timestamp fallback compares epoch ms (mixed UTC offsets order right) and
  is end-bounded: a child active after the parent's last turn is unlinked
  (contributes nothing), matching orphan semantics.
- Cache adoption tries the newest prior versioned file (v6 then v5) so the
  preceding build's expired-PR history survives the v7 bump; an invariant
  note requires the list to cover every version that can exist on disk.
- Spawn-result pairing matches the tool_result block that carries the
  agentId, not the first block, when a record batches several results.
- resolveSubagentAttribution is computed once and shared by aggregateByPr
  and prLinkedTotals.

subagentSessions now counts folded subtrees (children plus descendants).
Verified on real data: attributed + unattributed reconciles to cost, and
parent-only cost plus folded-children cost equals the folded total to the
cent (no double-count).

* sessions: round-2 hardening of subagent PR attribution

Address a second adversarial review of the new machinery.

- ID collision: parents and a child's parent reference are keyed by
  provider + sessionId, not bare sessionId. When two distinct parents
  still share a key (true duplicate/imported data), the child folds into
  NEITHER (deterministic skip, stays standalone): correctness over
  coverage.
- Recursion dedup is global: one claimed-set spans all of a parent's
  direct children, so a descendant reachable through two paths (a diamond
  or duplicate id) folds exactly once and a parent-link cycle terminates.
- Cache adoption migrates every prior version oldest-to-newest and MERGES
  per source path (newer wins per entry), so a sparse or partial newer
  file no longer masks older-only expired-PR orphans.
- Fold anchors (0-cost PR-linked parents kept only for attribution) live
  in a new ProjectSummary.subagentAnchors, never in `sessions`, so they no
  longer contaminate session counts, averages, or any per-session report.
  Folded PR rows take their date span from the contributing child activity
  rather than the anchor's empty timestamps.
- One-pass buildPrAttribution computes rows and totals together; the
  payload builder and CLI call it once. Drops the identity-keyed
  memoization, which could return stale folds if the array was mutated.
- Ambiguous multi-block spawn-result pairing leaves the spawn link unset
  on purpose; the child then folds via the timestamp fallback rather than
  pairing with the wrong id or disappearing.

Every fix is mutation-verified. A fresh real-data drive re-proves the
no-double-count identity to the cent (parent-only cost plus folded cost
equals the folded total) and that the PR rows sum to attributedCost.

* sessions: round-3 hardening of subagent PR attribution

Third adversarial review pass.

- Ambiguity counts ALL candidate parents (and anchors) sharing a
  provider+sessionId key, not just PR-bearing ones, and uses a
  per-record fingerprint: a key carried by more than one DISTINCT record
  folds its child/subtree into NEITHER (identical duplicates still fold
  once). This unifies the parent-collision and duplicate-descendant rules
  and is deterministic across input order.
- Project-rebuilding filters (by day, by date range, by config source)
  now carry subagentAnchors through, and a date filter CONVERTS a spawn
  parent whose in-range turns are all filtered out into an anchor so a
  surviving in-range child still folds. Rebuilt sessions also keep their
  PR + subagent-linkage metadata (prLinks, parentSessionId, spawnPrSets,
  ...), which buildSessionSummary otherwise drops, so by-PR and folding
  work on a filtered slice (menubar/dashboard flow).
- Fold anchors leave ProjectSummary.sessions entirely and folded PR rows
  take their span from the child, so 0-cost anchors never touch session
  counts or averages.
- Ambiguous spawn pairing (parent named the agent but its exact launching
  tool_use could not be paired) is recorded per parent; a late child of
  such a pairing folds to the parent's last turn within a 30 minute grace
  window, else stays unlinked. A truly-absent pairing gets no grace.
- Row session key is NUL-delimited and provider-prefixed, so a project
  name or session id containing a space no longer collides and
  undercounts distinct sessions.

Every fix mutation-verified. A fresh real-data drive re-proves the
no-double-count identity to the cent, and a day-filtered drive proves the
filter fix end to end (anchors created, subagents fold, identity holds).

* sessions: round-4 hardening of subagent PR attribution

Fourth adversarial review pass.

- sessionFingerprint now covers the COMPLETE linkage-relevant payload,
  not just headline stats: a canonical (sorted-key) serialization of
  agentSpawnLinks, spawnPrSets, prRefsAtRangeStart, ambiguousSpawnAgentIds,
  parent/agent identity, and the per-turn prRefs timeline. Two records
  that share an id and headline stats but map the child to different
  spawns/PRs now fingerprint DISTINCT, so the ambiguity rule fires and
  they fold into neither, deterministically rather than order-dependent
  first-wins.
- A date/day filter recomputes prRefsAtRangeStart at the new slice
  boundary by replaying the original full turn sequence, instead of
  copying the wide range's value. A PR switch between the wide start and
  the slice start (July 1 A, July 10 B, slice July 20) now carries B, not
  a stale A; a turn exactly on the boundary stays in-slice and applies its
  own refs. The recompute selects by timestamp, so it is order-independent.
  Non-contiguous day selections are documented as treated contiguous from
  the earliest selected day (a single session-level seed cannot represent
  multiple segments; the menubar selection is a single day or a run).
- The anchor-carry path drops an anchor that duplicates a surviving
  session id, so malformed merged input cannot double-count.

Also: rebuilt filtered sessions were losing their PR/subagent-linkage
metadata (a child its parentSessionId, a parent its prLinks), which
carryLinkageFields now restores, so by-PR and folding work on any filtered
slice.

Every fix mutation-verified. A fresh real-data drive re-proves the
no-double-count identity and reconciliation to the cent for both a
lifetime scan and a day-filtered slice (anchors created, subagents fold
through the filter).

* sessions: round-5 hardening of subagent PR attribution

Fifth adversarial review pass; closed-form fixes.

- sessionFingerprint serializes the COMPLETE fold-determining state via a
  real recursive canonical encoder: session-level linkage AND, per turn in
  sequence, timestamp, prRefs, cost, calls, savings, and per-model cost.
  Object keys are sorted recursively and set-semantic arrays (PR-ref lists,
  ambiguous ids, spawnPrSets values) are sorted, while the turn list keeps
  order; the structure is emitted through JSON.stringify (no delimiter
  concatenation). Two same-id parents that differ only in a turn timestamp
  now fingerprint DISTINCT (fold neither), and records differing only in
  set-array order fingerprint EQUAL (no false ambiguity).
- recomputeRangeStartPrRefs breaks an exact-same-millisecond tie
  deterministically by the lexicographically-last sorted-ref key, so the
  recomputed seed is stable regardless of turn order.
- A day filter seeds EACH selected day's first ref-less turn by replaying
  the original full turn sequence up to that day's start (per-day seeding),
  so a PR switch on an UNSELECTED day between two selected days carries to
  the later day. Contiguous and non-contiguous selections are both correct.
- The anchor dedupe drops an anchor only when a surviving session shares
  the full provider-aware, fingerprint-qualified identity (a proven
  duplicate): a different-provider or different-record same-id session no
  longer wrongly drops the anchor.

Also fixed a double-count the fingerprint test exposed: two duplicate
parent sessions share a key and the SAME resolved children, so folding is
now done once per parent key.

Every fix mutation-verified. Fresh real-data drives (lifetime, single-day,
and a NON-CONTIGUOUS day selection) re-prove no-double-count and
reconciliation to the cent.

---------

Co-authored-by: reviewer <review@local>
2026-07-20 20:47:23 -07:00
Resham Joshi
45e93129f2
sessions: attribute PR spend per turn instead of per session (#790)
* sessions: attribute PR spend per turn instead of per session

The by-PR surfaces attributed a session's full cost to every PR it
referenced, so one orchestration session that touched many PRs rendered
identical full-session rows. Capture per-turn PR references during
transcript parsing and attribute each turn's cost to the PR set active at
that turn (split evenly across a multi-PR merge-sweep turn), carrying the
most recent PR set forward across turns that reference nothing. Turns
before the first reference form an unattributed bucket.

A session whose transcript expired before per-turn capture keeps its
session-level prLinks but has no per-turn refs; it falls back to an even
whole-session split, and any row carrying such a portion is flagged
approximate.

Rows are now summable: the CLI and app footers report the attributed sum
plus the unattributed remainder instead of a distinct-session total.
Bumps the session cache version so surviving transcripts re-parse and
populate the new per-turn field. Daily-cache versioning is untouched.

* sessions: harden PR attribution and add models + category breakdown

Addresses the review findings on the per-turn PR attribution and adds the
model and task-category surfaces.

Correctness:
- Cache migration: the 5 -> 6 session-cache bump now adopts the prior v5
  file's expired-source PR entries instead of abandoning them, and the claude
  scan preserves and surfaces PR-bearing orphans, so a session whose transcript
  was deleted still appears as a legacy even-split instead of vanishing. The
  daily cache is untouched.
- Date-range carry: the parser captures the PR set active at the start of the
  in-range turn slice and seeds the state machine with it, so a PR referenced
  before the window still owns its later, in-range, ref-less turns instead of
  the session falling back to a whole-session approx split.
- Calls are split across a multi-PR turn by largest-remainder, keeping per-PR
  counts whole (a 1-call, 2-PR turn no longer renders as 2 calls).
- The CLI and app footers reconcile to the rounded row values actually shown.
- Distinct sessions are keyed by project + sessionId, not sessionId alone.
- The app tolerates an older by-reference payload (no attributedCost): it keeps
  the old non-summable footer and never renders NaN.

Features:
- Each PR contribution records the models of its calls and the turn's task
  category (split by the same share on a multi-PR turn); legacy even-split rows
  carry the model union but no category breakdown.
- Payload rows gain models (short names, cost-desc) and categories (label + cost,
  cost-desc, omitted when empty); the payload gains otherPrCount/otherPrCost for
  the PRs beyond the sent top 20.
- CLI --by-pr gains a Models column. The desktop table is now full-width with a
  Models column and click-to-expand rows showing a per-category cost breakdown
  with proportional bars, keyboard accessible, with an "Other (N more PRs)" row
  when capped.

Tests: state-machine seed/models/categories/largest-remainder/dedup, the prRefs
round-trip through the real incremental-append path (continuation + straddle),
v5 adoption of an expired PR session, payload round-trip, and the desktop
expansion/models/old-payload cases.

* sessions: reconcile mixed PR rows, harden v5 adoption, bound models

Round-2 review follow-ups.

- Mixed legacy/live category breakdown: a PR row that combines an expired
  legacy contribution (even-split, no turn data) with a live per-turn
  contribution now emits an explicit "Legacy estimate (no per-turn detail)"
  category carrying the legacy share, so the expansion reconciles to the row
  cost instead of silently dropping it. A legacy-only row still shows no
  breakdown.
- v5 adoption is now per cached file: one malformed entry is skipped instead
  of rejecting the whole v5 cache and dropping every valid expired PR session.
- The desktop "Other (N more PRs)" line moved to the table footer as a muted,
  separated summary rather than a sorted row.
- Row expansion resets when the PR set changes (period/provider/data), so a
  stale expansion cannot linger on a row that is gone.
- Payload models per row are capped to the top 4 by attributed cost, with a
  name-ascending tie-break; categories get the same stable tie-break.

Tests: mixed live+legacy reconciliation, legacy-only omission, model cap and
tie-break, corrupt-plus-valid v5 adoption, and the desktop expansion-reset.

* session-cache: validate optional agentType and failed during v5 adoption; app: reset PR expansion on period switch

A v5 entry that was valid except for a malformed optional field (agentType,
failed) passed per-file validation and flowed downstream as a non-string.
The expansion reset keyed only on the row URL set, so a period switch that
returned the same PRs kept a stale expansion open over changed numbers.

---------

Co-authored-by: reviewer <review@local>
2026-07-20 20:44:29 -07:00
Resham Joshi
a9d4471d17
payload: spend by pull request and by branch; desktop Pull requests tab (#788)
Some checks are pending
CI / semgrep (push) Waiting to run
Part 1 (src): expose two add-only, optional aggregations on the menubar
payload's `current`, computed on the unscoped all-provider path from the
surviving-session parse (carried history cannot contribute, as expected).

- current.pullRequests: aggregateByPr rows (top 20 by cost) plus the
  multi-link-safe distinctCost/distinctSessions. Rows are by-reference, so
  they are never summed.
- current.byBranch: new aggregateByBranch, per-branch spend (top 15 by cost)
  that carries each session's last-seen git branch forward across its turns.
  The cache stores a turn's branch only when it changes, so the parser now
  resolves the branch at reconstruction (before the date slice) and records
  SessionSummary.everHadBranch from the full transcript. That lets the report
  keep a branch-bearing session's pre-branch spend in an explicit null row
  even when the range clipped the anchor turn, while a provider that never
  captures a branch still contributes nothing.

Part 2 (app): new "Pull requests" sidebar entry between Sessions and Spend
(shortcuts reflow to number 8 for Plans). It renders the pullRequests rows as
a refined table with the PR label linking out via openExternal, cost,
sessions, calls and an active-date span, plus a footnote stating the distinct
total and the by-reference attribution. A quiet explanatory line shows when no
PR links exist, never a fake table.

Tests: aggregateByBranch unit tests (carry-forward, null bucket, clipped
anchor, by-reference session counts) and the Pull requests component tests
(table, external link, footnote, both empty states). Nav-reflow assertions in
Sidebar and App tests updated.

Co-authored-by: reviewer <review@local>
2026-07-20 14:36:53 -07:00
Resham Joshi
4d2bcbac7f
app: Workflow card on Overview and session titles in the Sessions view (#784)
Two Phase-1 desktop additions over the renderer's existing card grammar.

Overview workflow card: a panel below the daily chart with two stat tiles
(correction rate with count, median time to first edit) plus a top-rework
line and one coaching caption. The caption is derived locally with the CLI's
buildCoachingNotes thresholds (corrections, then file churn, then time to
first edit; first that fires). The card hides entirely when no real signal is
present, never renders zeros, and shows a "N% priced" chip only when pricing
coverage is a number below 1. Payload types gain optional workflow,
topReworkedFiles, and pricingCoverage fields so older payloads render as before.

Sessions view: the captured session title becomes the row headline with the id
demoted to the mono secondary line; untitled rows keep the project as the
headline (unchanged). Title is also searchable and added as an optional
SessionRow field so older CLIs render unchanged.

Co-authored-by: reviewer <review@local>
2026-07-20 13:55:33 -07:00
Resham Joshi
ae1d1c026b
app: Download button direct-downloads the update asset (#777)
The update Download button opened the GitHub release page; it now
downloads the right artifact for the running platform directly: arm64
or x64 dmg on macOS (preload newly exposes process.arch), the Setup exe
on Windows. Linux keeps the release page since it ships three formats
and the user picks. Unknown platforms and preloads without arch fall
back to the page. URL mapping is unit-tested per platform.

Co-authored-by: reviewer <review@local>
2026-07-20 10:02:43 -07:00
Resham Joshi
dbd93fee51
app,dash: render pre-history days as no data, not a currency zero (#765)
* fix(app,dash): render pre-history days as no data, not a currency zero

Days before the first recorded day in history.daily were painted as a real
0.00/0 calls in the daily activity heatmap and the daily spend charts. That
zero is unknown, not measured, so those cells and bars now read "No data
recorded" instead of a currency zero. Genuinely idle days within recorded
history stay as real zeros.

- app: heatmap cells, the daily spend bars, and the daily-by-model columns
  get a distinct no-data style plus a "No data recorded" hover for days before
  the first recorded day.
- dash: the granular line chart drops buckets before the first recorded day so
  no flat zero line is drawn before any history exists.
- data-start is derived in the UI layer from history.daily; payload shape is
  unchanged.

Verified: app vitest 418 pass (7 new for the no-data behavior); app, dash, and
root typecheck clean; dash and app renderer vite builds succeed.

* no-data: cap guard, timezone-free dash trim, StackedBars aria

Three hardening fixes from adversarial review of the no-data rendering:

dataStartKey returns null at the payload's 365-entry history cap, where
the oldest retained entry is no longer the true data start; classifying
past it would label real aged-out history as no data on long custom
ranges. Documented that the install-to-first-use gap reading as no data
is literally accurate: nothing was recorded then either.

The dash granular chart now trims leading zero-only buckets by value
instead of comparing bucket timestamps against date keys, so producer
and viewer timezone skew can never drop a real first-day bucket, and an
all-zero series lands in the established empty state.

StackedBars no-data columns expose their state via aria-label, not only
a title on a non-focusable div.
2026-07-20 08:03:24 -07:00
iamtoruk
6eb8797efa fix(clients): display CLI-computed values verbatim, align period windows across app/dash/menubar 2026-07-20 06:26:15 -07:00
iamtoruk
f26bf69f88 perf(app): cap CLI spawn concurrency, background-priority prefetch, default to today 2026-07-18 16:28:30 -07:00
iamtoruk
8fc9e303b9 feat(app): update-available notifications
Checks the GitHub releases for newer desktop-v tags once per launch
and every 24h (15s timeout, no identifying headers, offline = silent
retry). A dismissible banner and a Settings About row surface the
newer version and link the release page; dismissal persists per
version so each release nags at most once. No auto-install: unsigned
builds cannot, by platform constraint; the checker only notifies.

App 364/364 (+24), typechecks + build green.
2026-07-17 04:58:13 -07:00
iamtoruk
e4bbcaaba7 fix(app): zero background work when hidden; currency applies instantly
Energy (field report: ~3x Chrome drain):
- polls skip entirely while the window is hidden/minimized, with one
  catch-up refresh on return when data is stale; visible-but-unfocused
  keeps polling (second-monitor case)
- all looping animations pause under html.page-hidden (the sidebar
  flame flicker was a perpetual compositor drain)
- default cadence 60s; an explicit stored choice is always honored

Currency (field report: set USD, still saw EUR):
- memo-served payloads re-applied their embedded stale currency; config
  mutations now purge the renderer memo and force-refresh, and a
  switching payload can never overwrite the applied currency
- USD default verified end to end

Measured: hidden window = 0 new CLI spawns over 5 cadences (was: full
polling forever). App 340/340, build + package green.
2026-07-16 18:41:28 -07:00
iamtoruk
ae18b1fd91 fix: audit batch — prefetch one-shot, turn-anchored day bucketing, warm-path collapse
- prefetch fires exactly once per provider per session; memo sized to
  detected providers so base keys never evict (was: 12 full-history
  parses every 30s forever from LRU thrash re-triggering the effect)
- day-aggregator buckets whole turns by user-message timestamp,
  matching the report/headline path: trend bars and provider breakdown
  now reconcile exactly (midnight-straddling turns were split);
  DAILY_CACHE_VERSION 13 with an IANA tz guard that rehydrates when
  the cache crosses timezones
- overview's redundant second all-provider scan collapsed (~11% faster
  warm); numbers parity-guarded
- lock takeover cleans stale hydrating.lock; SIGINT/SIGTERM release it
- scanner skips EPERM/EACCES provider dirs (progress shows skipped)
  instead of aborting; onboarding hints Full Disk Access
- rateLimited quota copy render tests; export -f json emits valid
  empty JSON when no data

Root 2023 (+4), app 327 (+3), typechecks clean.
2026-07-16 15:37:03 -07:00
iamtoruk
e9655fa03c fix: hydration completeness markers, cold-only splash, honest switching, prefetch
P0: an interrupted cold hydration left partial caches that the
emptiness check read as warm — the daily backfill froze (missing older
days) while session parsing healed gradually (drifting totals),
poisoning every surface sharing the cache. Both caches now carry an
explicit complete marker: partial resumes cold under the hydration
lock; unmarked caches self-heal with one re-hydration. Regression test
seeds the exact frozen artifact and asserts bit-for-bit convergence
with a never-interrupted run.

- splash indexing reveals only on genuinely cold scans (explicit cold
  flag in the progress protocol), never on warm incremental re-parses
- uncached filter switches clear to skeleton instead of showing the
  previous filter's numbers; cached switches paint same-commit;
  background prefetch warms every detected provider after idle
- build stamp (git sha + date) in splash and About ends build ambiguity
- payload parity test: identical payloads on identical cache, and
  payload totals equal the CLI report path
- Sessions empty state keeps the provider filter row; zero savings
  line hidden; TCC usage-description strings + Full Disk Access docs
- warm profile documented: optimize scan ~1.47s dominant (safe
  refactor deferred), parse ~1s, aggregation ~30ms

Root 1848, app 320, typechecks clean.
2026-07-16 14:05:11 -07:00
iamtoruk
300e949951 feat(app): refresh cadence, instant switching, cache coherence, telemetry v1
Performance and coherence:
- Settings > General 'Refresh every' (Manual/30s/1m/3m/5m/10m), live via
  RefreshCadenceContext; Manual polls only on demand
- usePolled memoKey LRU: provider/period switches paint the last-good
  result instantly with a switching hairline while refreshing quietly
- quota TTL 5min + honest rate-limited copy on 429 backoff
- version-suffixed cache files (session-cache.v5.json, daily-cache.v12,
  auto-minted on future bumps); legacy files never written or deleted,
  adopted once when versions match: old and new binaries coexist
  without clobbering (field-observed menubar-vs-desktop ping-pong)
- advisory hydration lock: concurrent cold starts share one scan
  (wait-then-read-warm), stale/dead locks self-heal, never a
  correctness gate

Telemetry v1 + onboarding (desktop only, per owner decisions):
- first-launch onboarding (3 feature screens + consent); region-split
  default (EU/EEA/UK/CH off, elsewhere on, unknown off); nothing sends
  before consent completion or while off; dev builds never send
- anonymous install UUID, rotated on opt-out; day-granularity events,
  cost buckets only; whitelisted names; 200-event queue, 5min flush
- Settings > Privacy live toggle replaces the static claim

Zero computed-number changes. App 316/316, root 1805.

Wire contract targets api.codeburn.app/v1/telemetry (Worker follows).
2026-07-16 11:47:05 -07:00
iamtoruk
55bc53e6be fix(app): cold start survives, first-run shows per-provider progress
Root cause of the field-reported 45s timeout + perpetual slowness: on
a cold cache all six sections spawned different CLI subcommands at
once, each running its own full-history parse (~11-31s, ~3GB RSS),
contending past the 45s kill so the rebuilt cache never persisted and
every poll restarted from zero.

- the first overview fetch runs as a warmup: 10-minute timeout,
  re-arms until it succeeds, reverts to 45s after; section polls gate
  on that first resolution (usePolled gains enabled), so cold
  hydration happens exactly once
- warmup streams the CLI's progress protocol; the splash shows 'First
  run: indexing your usage history' with a per-provider ingest list
  (logo, live counts, check on done), static under reduced motion,
  generic fallback without events; warm launches unchanged
- overview spawns pass --no-timeline (desktop never renders it)

Measured on real data: cold 31s once (was: killed at 45s forever),
warm 2.6s. 295/295 app, 1797 root.
2026-07-16 10:51:18 -07:00
iamtoruk
8d1a10369f fix(app): quota connects via macOS keychain + Connect affordance
Root causes (verified live): the Claude keychain branch was never
enabled (allowClaudeKeychain never passed), security -w returns
hex-encoded JSON the parser rejected, and Codex had no keychain
discovery at all.

- shared readKeychainPassword: Apple-signed /usr/bin/security, 90s
  window so the user can answer the macOS dialog, hex decode,
  notFound vs accessDenied classification; secrets never logged
- Claude: keychain fallback active (gated to explicit Refresh so
  launch never ambushes with a dialog); connection retained across
  keychain-less background polls
- Codex sources in order: the menubar's own cached OAuth (read-only,
  401 = one re-read, never rotate), legacy ~/.codex/auth.json
  (writable), com.openai.codex plaintext if usable; Safe Storage
  never decrypted
- new accessDenied state (amber, 'locked') + ConnectAffordance on
  Plans cards and Settings rows: login command, keychain-Allow note,
  force Refresh

Live verification: Claude connected with its real tier and windows;
Codex correctly accessDenied until the keychain dialog is allowed.

273/273 (26 files), typecheck + build green.
2026-07-16 06:20:44 -07:00
iamtoruk
4b1426eab2 polish(app): consistency review nits
Loading quota label matches sibling skeleton labels (Sentence case);
matchMedia test stub deduped into lib/testMatchMedia.

253/253, typecheck + build green.
2026-07-16 05:11:10 -07:00
iamtoruk
daf944e714 feat(app): motion layer (gsap) — count-ups, chart grow-in, section fade, skeletons, toasts
- one gate (motionEnabled): off under reduced-motion, missing matchMedia,
  and vitest; every path checks it first
- mount/filter-change only: count-up and bar grow-in key off the
  period|provider|range key, so 30s poll refreshes snap values silently
  instead of re-animating
- first-load skeleton shimmer replaces bare scanning text (kept sr-only
  for screen readers); slide-in toast host for Settings/export feedback
  (validation errors stay inline); CSS hover-lift + press micro-
  interactions, all with a reduced-motion escape hatch
- gsap 3.15.0 + @gsap/react 2.1.2 (+74KB raw JS; G4 flame work shares it)

244/244, typecheck + build green.
2026-07-16 04:39:09 -07:00
iamtoruk
edaa284c0f feat(app): Audit lens on Models + Pricing pane in Settings
- Models gains an Audit lens: raw provider token fields vs displayed
  totals and cost derivation per model, est badge where pricing is
  missing or recomputed cost diverges from attributed
- Settings Pricing pane: list/add/remove price overrides (USD per 1M),
  inline confirm on remove, only provided rates sent; rates validated
  finite and strictly positive on both sides of the bridge
- four new IPC channels with the established validation pattern

Root 1617/1617, app 235/235, build green.
2026-07-16 03:45:36 -07:00
iamtoruk
93349e7eba feat(app): daily budget alert (USD or token cap)
- Settings/General: Daily budget row (Off / USD amount / Tokens),
  positive-finite validation, warns at 80% and alerts at 100%
- app-wide one-line banner above every section: --warn at 80%+, --bad
  at 100%+, dismissible for the rest of the day
- USD caps compare raw USD against per-provider daily cost (works under
  any filter); token caps evaluate only on the all-providers view since
  provider-filtered history zeroes token fields (comparing against a
  false zero would always pass)

212/212, build green.
2026-07-16 03:16:07 -07:00
iamtoruk
55fdbfd761 feat(app): Claude config switcher (multi-account overview scope)
- TopBar picker (shown only when >1 Claude config dir exists) threads
  --claude-config-source through getOverview; synthetic 'All Claude
  configs' entry returns to the aggregate default
- config id validation accepts the real <kind>:<16hex> id shape while
  still rejecting flag-shaped input (leading char anchored)
- both incompatibility directions handled: selecting a config resets a
  non-Claude provider filter, and picking a non-Claude provider clears
  the config scope, so the CLI rejection is unreachable
- choice persisted (localStorage); active config shown in the scope
  line and read-only in Settings/General

Verified live against 2 real config dirs. 205/205, build green.
2026-07-16 03:06:05 -07:00
iamtoruk
015a1a069a polish(app): shared empty states, type ladder, AA contrast, honest affordances, a11y semantics
D1 one shared EmptyNote (7 copies removed); CliErrorPanel on canonical
   tokens; dead code deleted (CapsuleChart, stale SERIES_HEX palette,
   Stat tone API, unused rail/doc-chrome CSS)
D2 type ladder tokens (--fs-*/--fw-*); panel titles unified 13/600/ink;
   high-visibility sizes/weights snapped to rungs
D3 contrast: --mut2 5.00:1 light / 5.74:1 dark (was 2.37/3.11); new
   --accent-text 5.07:1 + persistent underline on text links; last
   prose em-dash removed
D4 'See all' and 'Most expensive sessions' navigate to Sessions; 'add
   alias' jumps to Settings/Aliases; chevrons only on clickable rows;
   inline confirm replaces window.confirm; default-period setting
   honored at boot; Sankey caption no longer promises a click; Optimize
   'improving' trend badges; cursor + Space-key fixes
D5 aria-current on nav, SegTabs as real tablist, aria-pressed toggles,
   ProviderPop rebuilt on Dropdown (one tab stop, arrow keys, focus ring)

191/191, typecheck + build green. Contrast ratios independently
recomputed. Net -93 lines.
2026-07-16 02:27:41 -07:00
iamtoruk
916aa6430f harden(app): Electron security + spawn efficiency batch
- will-navigate/window-open denied; openExternal restricted to http(s);
  production CSP drops 'unsafe-inline' scripts (dev-only Vite plugin
  re-adds it for the Fast Refresh preamble)
- single-instance lock; before-quit reaps in-flight CLI children; 16MB
  output cap rejects with CliError 'too-large'
- read-only CLI spawns coalesce: concurrent identical calls share one
  child, a 5s result cache absorbs same-cadence pollers (six sections
  polled getOverview independently); actions bypass and flush the cache
  on completion so post-action refetches are fresh
- every renderer-supplied argv string validated (period/provider/range/
  currency allowlists, no flag-shaped tokens, absolute export path) with
  new CliError kind 'bad-args'; envelope + action stderr routed through
  sanitizeError
- quota force-refresh: manual refresh (refreshToken) passes force which
  invalidates QuotaService's cache; steady polls do not
- POSIX credential mode check gated off on win32

App suite 190/190, build green (production index.html verified strict).
2026-07-16 01:57:44 -07:00
iamtoruk
8a3fa69fe9 fix(app): real currency conversion, provider ids in picker, yield provider scoping
- formatUsd applies the payload currency {code,symbol,rate} once at
  display; formatConverted (symbol only) for CLI-preconverted plan
  values so nothing converts twice
- provider picker built from providerDetails: label shown, internal id
  sent as --provider (fixes filters for providers whose display name
  differs, e.g. Grok Build); falls back to map keys on older CLIs
- getYield threads the active provider through preload/main/sections

App suite 170/170, root 1615/1615.
2026-07-16 01:36:59 -07:00
iamtoruk
cbddd3a9d7 fix(app): accuracy batch A — provider scoping, honest chart axes, range threading, stale-error banner
- Provider filter: Models-this-period sources current.topModels (history
  days carry empty topModels per provider); StackedBars falls back to a
  cost-proportional single segment like the Swift menubar
- Chart windows align to the CLI (week -7, 30days -30, all = 6 months)
  and zero-fill a contiguous calendar window; history.daily is sparse and
  both sides key local YYYY-MM-DD, so lookups are safe (verified on real
  CLI output)
- Custom range threads into Overview panels (chart, models table) and
  suppresses MTD/projected and vs-last-week comparisons; Compare states
  that custom dates fall back to the period
- usePolled clears errors per attempt and sections show a StaleBanner
  instead of silently rendering last-good data after a failed refresh
- Cache-hit denominator matches the CLI; hero savings relabeled 'Saved by
  applied fixes' + new 'Saved via local models' line; formatCompact
  everywhere; Optimize 'Fixes' count matches the rendered list

App suite 163/163 (was 147), root 1613/1613.
2026-07-16 01:14:41 -07:00
iamtoruk
a8351bfd1a fix(app): daily charts empty/flat on real data — use real last-N days
contiguousDailyWindow rebuilt a calendar window from the client clock and looked
up days by date key; on real CLI data every lookup missed and zero-filled, so the
Spend chart went empty and the Overview chart went flat. Use the real last-N
backfilled entries of history.daily directly (Spend last-15, Overview
last-max(30, periodDaily)) and remove the dead helper.

typecheck clean; 147/147 tests pass.
2026-07-12 17:06:46 -07:00
iamtoruk
880f84d593 feat(app): live quota fetch module + getQuota bridge (Phase 4 backend)
Port the menu bar's OAuth quota fetch to the Electron main:
- app/electron/quota/{claude,codex,index,security}.ts reads the local Claude/
  Codex credentials, hits the usage APIs, normalizes to QuotaProvider (5h/weekly
  windows, auto-detected tier, resets-at) with 429 backoff + single-flight
- getQuota IPC bridge (main + preload + renderer types); needs a full relaunch

Security: tokens are never returned or logged (redact Bearer/sk-ant/sk-/JWT,
strip NUL, truncate 240); credential reads refuse symlinks (O_NOFOLLOW/lstat) and
insecure modes; Anthropic refresh is never POSTed (single-use, shared with CLI).

typecheck clean; 147/147 tests pass; Electron build passes.
2026-07-12 16:43:53 -07:00
iamtoruk
905d94253a feat(app): actionable Optimize — wire the CLI's rich findings + copy-paste fixes
- new getOptimizeReport bridge (main + preload + renderer types) running
  `codeburn optimize --format json` (needs a full relaunch)
- restore the Waste / Reverts / Abandoned / Fixes tabs
- Waste findings expand inline to show the explanation (why you're losing
  tokens) + a copy-paste fix block with a Copy button, like the TUI
- summary header: findings count / potential savings / health score

typecheck clean; 133/133 tests pass.
2026-07-12 16:28:47 -07:00
iamtoruk
82f0402abd feat(app): Spend chart — fixed 15-day window + date axis, no gridlines
- extract contiguousDailyWindow + formatChartDate to lib/period (shared)
- Spend "Daily spend by model": fixed contiguous 15-day window (zero-filled)
  with an .ov-xax date axis, matching the Overview chart
- remove the horizontal gridlines from both charts (they read like data lines)

typecheck clean; 129/129 tests pass.
2026-07-12 16:21:41 -07:00
iamtoruk
a02ae15b6b feat(app): rebuild Sessions — fixed columns, clean paths, inline expand
- share a shortenProjectPath helper (Sankey + Sessions) so rows show
  "projects/eywa" instead of the dash-mangled full path
- fixed-column table layout so values align and stop shifting row-to-row
- TUI-style inline row expansion: click a row to unfold its 8-stat detail in
  place (one at a time, chevron + aria-expanded); replaces the navigate-away view

typecheck clean; 128/128 tests pass.
2026-07-12 13:38:45 -07:00
iamtoruk
6f4b2bef38 feat(app): About panel + working social links (+ LinkedIn)
- add a shell.openExternal bridge (main ipc handler + preload + renderer type)
  so links open in the default browser (requires a full relaunch)
- wire the sidebar social icons to real URLs and add LinkedIn
- AboutModal: flame + wordmark, version, tagline, the five social links, and a
  "Check for updates" link to the GitHub releases page

typecheck clean; 128/128 tests pass.
2026-07-12 13:07:38 -07:00
iamtoruk
4186ad393a feat(app): color Fable as its own series (aqua)
Fable was bucketing into grey "Other" despite being a large chunk of spend.

- add `fable` SeriesKey across modelSeries.ts (label, css-var, class, matcher)
- validated CVD-safe --s-fable token (aqua #1baf7a light / #199e70 dark) in all theme blocks
- StackedBars now uses a fixed CVD-safe series order [opus, fable, haiku, gpt, sonnet, other]
  for both legend and stack, so aqua never sits adjacent to the magenta (Sonnet)

typecheck clean; 128/128 tests pass.
2026-07-12 12:52:12 -07:00
iamtoruk
b0299c57ac feat(app): design reskin — token unification, a11y, and chart color + shading
Design-consistency pass (from the audit):
- unify the indigo→plain grey/border token families (alias --t1/2/3, --hair/2 onto --ink/mut/mut2, --line/2); fix a leftover hardcoded chevron color
- accent :focus-visible rings on SegTabs, chart bars, calendar, buttons, Settings toggles; ov-link hover
- theme-aware Spend legend swatches + Sankey (fixes a light-mode contrast failure)
- wire Models "Compare…" → Compare and Plans "Add plan…" → Settings pane; drop the fake-affordance Plans label; Settings loading captions
- Compare card radius/title, set-input, alias CTA color, outer-border tokens, session mono numbers, frame spacing to match the mockup
- scope a date-flaky Overview test assertion to its card

Reskin batch 1 — color + shade (current tone):
- validated CVD-safe model-series palette via dedicated --s-* tokens (light+dark), applied to the daily bars, Sankey, and series dots
- subtle soft card elevation via --card-shadow on the main card containers

typecheck clean; 128/128 tests pass.
2026-07-12 12:36:50 -07:00
iamtoruk
1de907ac8b feat(app): Settings part 2 — Plans, Export, Devices actions, theme on boot
- Plans tab: add/remove budget presets via `plan set`/`plan reset`, lists
  configured plans, links to the full Plans screen
- Export tab: format + provider + native folder picker (electron dialog)
  → runs `codeburn export`, reports the result
- Devices: real Remove (`devices rm`) + Refresh; visibility is read-only
  from `share status`; pairing shows an honest "pair from terminal" note
  instead of a dead Approve button; combine row is read-only status
- Apply the persisted theme at app startup (not only when General mounts)
- Settings gets onNavigate for the "Open Plans" link

Only one-shot, non-interactive CLI commands are wired as buttons; the
interactive share daemon and pairing flow stay honestly read-only.
2026-07-11 17:31:32 -07:00
iamtoruk
55e9fa51f6 feat(app): functional Settings — rail nav + General/Providers/Aliases/Privacy
Settings was a dead shell (rail didn't switch, all buttons disabled).
Now the rail navigates and four tabs are wired to the real CLI:
- action bridge: spawnCliAction + runAction (text output, not JSON) with
  getAliases/getProxyPaths reads and setCurrency/resetCurrency/addAlias/
  removeAlias actions
- General: theme switcher (System/Light/Dark, persisted) + live currency
  (reads status, changes via `currency`) + default-period preference
- Providers: real detected-tools list with spend
- Model aliases: full add/remove against real config
- Privacy: honest local-only info

Devices content preserved as-is; Plans/Export are placeholders for Part 2.
2026-07-11 17:24:04 -07:00
iamtoruk
d9c56e3c6b feat(app): production-ready Sessions screen
Search (project/model/id), sort (cost/recent/turns/tokens), group-by-
provider toggle, and a summary bar (count · cost · tokens) over the
filtered set. Dependency-free "show more" paging keeps large lists fast;
provider headers are sticky and the list flows in the page scroll.

Richer detail: 8 real stat cards (cost/calls/turns/saved/input/output/
cache read+hit%/cache write), full date range + duration, Esc-to-back,
selection tracked by id so background refreshes keep the detail live.

Centralizes formatCompact/date/duration helpers in lib/format ("184K"
not "0.2M"); Compare now uses the shared helper. Search-aware empty state.
2026-07-11 13:50:58 -07:00
iamtoruk
6023067bdc feat(app): bridge for sessions/compare + dev-mode CLI fallback
Adds getSessions / getCompareModels / getCompare IPC channels (argv for
the new CLI JSON emitters), mirrors SessionRow + compare-stats types into
the renderer, and — in Vite dev — resolves the repo's own dist/cli.js so
newly-added commands work without setting CODEBURN_BIN.
2026-07-11 12:37:04 -07:00
iamtoruk
d0ce20e49c feat(app): working periods, detected-provider dropdown, drag-select date range
- Period seg now uses the 5 real CLI periods (Today/7D/30D/Month/6M);
  6M maps to 'all' (matches the menubar).
- Provider control is a real dropdown listing only DETECTED providers
  (accumulated from current.providers so it never shrinks) + All.
- New RangeCalendar: month grid with click-and-drag range selection
  (+ two-click fallback, future-date disabling) behind a calendar button;
  commits a custom {from,to} threaded to Overview/Spend/Models/Optimize
  via new --from/--to CLI args (Plans/Settings stay period-only).
- typecheck clean, 87 vitest pass.
2026-07-11 09:14:54 -07:00
iamtoruk
6775592716 feat(app): native integrated title bar + draggable chrome
macOS uses titleBarStyle hiddenInset so the traffic lights float over
the sidebar (Linear/Hermes-style), with the sidebar top inset to clear
them and the top chrome marked -webkit-app-region: drag (interactive
controls stay no-drag). Windows/Linux keep their native frame + controls.
Exposes process.platform through the bridge and tags <html data-platform>
so CSS adapts per OS. typecheck + 82 tests green.
2026-07-11 08:51:24 -07:00
iamtoruk
95cf876999 feat(app): plain theme + Overview reskin wired to real data
New plain.css token layer (light+dark) repaints the whole app shell;
Overview.tsx rebuilt to the locked mockup: hero burn with count-up +
streak, budget fuel ring (getPlans), coach insight, Saved-to-date
(act report), area sparklines, gridline + hover-tooltip daily chart,
sessions. Adds getActReport bridge plumbing. All data real/derived;
per-day tooltip shows calls (no per-day sessions field). typecheck
clean, 82/82 vitest pass.

Note: Saved-to-date needs a codeburn build with 'act report --json'
(source-only today; installed 0.9.15 lacks it) — falls back to $0.
2026-07-11 08:32:11 -07:00
iamtoruk
f362195946 fix(app): final-review nits — app menu (⌘R refresh), honest model labels, yield retain, hint cleanup
Install an app menu without the CmdOrCtrl+R reload accelerator so ⌘R reaches the renderer for
in-place refresh (keeps Edit/Window roles + dev DevTools). Model labels are now family-level
("Opus"/"Sonnet"/"Haiku"/"GPT / Codex") and the Sankey shows the real model id instead of the
wireframe's sample version names. Optimize retains last-good yield across 30s revalidation (no
flicker to "—"). Removed hints for unimplemented ⌘K/⌘E/esc; fixed stale TopBar comment.

Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
2026-07-10 19:54:09 -07:00
iamtoruk
9e6ea306fe feat(app): T8 integration + cleanup — single refresh, ⌘-nav, shared error/format/period/series, README
- One 30s refresh + manual ⌘R; footer shows real last-fetch time; getOverview fetched once (no double-poll).
- Keyboard nav: ⌘1–⌘5/⌘, switch sections, ⌘R refresh.
- Verified period/provider re-polls all sections.
- Shared CliErrorPanel: amber permission ("Full Disk Access") vs red error, used by every section.
- DRY: shared lib/format.ts (USD), lib/period.ts (Overview migrated to it), lib/modelSeries.ts (unified model→series).
- Settings review-minor tests (!paired filter, empty/not-found states) + App.test.tsx.
- app/README.md incl. the approved M2 delivery model (self-contained, bundles engine, Electron auto-updater).
- 76 tests, typecheck clean. Live GUI smoke (npm --prefix app run dev) still pending a display.

Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
2026-07-10 19:35:41 -07:00
iamtoruk
dc52b3226b feat(app): Settings/Devices section + getDevicesScan bridge
Settings rail (Devices active) with This device (getIdentity), Discovered nearby
(new getDevicesScan bridge → `codeburn devices scan --format json`), and Paired
(getDevices perDevice). Pairing/approve/pull/visibility/combine are M1 visual
affordances (mutations = M2); share status is process-local so not shown as
authoritative. Settings uses a title-only bar (no period/provider).

Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
2026-07-10 19:17:14 -07:00
iamtoruk
5cd5c783a2 test(app): Spend coverage (lenses, states, colors, Sankey, period windows) + proportional/labeled Sankey
Addresses T3 review: full test coverage for all lenses + empty/error states, segment
color mapping, Sankey ribbon properties, and lib/period.ts across all windows; Sankey
ribbon widths now truly proportional (removed the 28px cap) and labels are pretty/basename
+ truncated so real ids don't clip; legend reflects present models; honest "top N" project count.

Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
2026-07-10 18:30:17 -07:00
iamtoruk
d7f3ccd1f0 feat(app): Spend section — daily-by-model stacked chart, by-project, model→project Sankey
Period-sliced StackedBars (history.daily[].topModels within the selected window via
a new shared lib/period.ts), the By-project list, and a Sankey rendered dynamically
from getSpendFlow (per-model hue ribbons). Lens tabs; series palette shared with the
legend. Adds a neutral .s-other class for the rollup segment.

Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
2026-07-10 16:43:27 -07:00
iamtoruk
e22e5511ba feat(app): electron shell, 30s timer, and typed IPC bridge
- main.ts: 1200x820 BrowserWindow (contextIsolation:true,
  nodeIntegration:false, sandbox:true), loads the Vite dev URL or the
  built index.html, registers one ipcMain.handle per CodeburnBridge
  channel, and starts a 30s tick that emits `codeburn:refresh`.
  createBridgeHandlers() is a pure, injectable channel→argv map so the
  spawn→IPC wiring is unit-testable without a GUI (main.test.ts).
- preload.ts: exposes window.codeburn (CodeburnBridge, exactly) and
  window.codeburnEvents.onRefresh via contextBridge. Handlers return an
  {ok,value|error} envelope so the structured CliError kind survives the
  world boundary.
- types.ts: mirrors MenubarPayload/DailyHistoryEntry/DailyModelBreakdown
  and the other CLI payload types verbatim, plus the exact CodeburnBridge
  interface and Period union.
- ipc.ts: typed 1:1 wrapper + normalizeCliError.
2026-07-10 15:17:03 -07:00