Repository navigation
Description
Problem
Two Claude Code accounts on one Mac are common (personal Max plan + employer Team plan, switched via CLAUDE_CONFIG_DIR). Today this breaks in two independent, silent ways:
- Counting: only the default roots are scanned, so the second account's config dir (e.g.
~/.claude-tradeengage) is invisible — its tokens never reach the egg. Extend additional scan folders to every provider, not just Claude #177 / feat: add per-provider additional scan folders (#177) #187 / Add user-configurable additional scan folders with wildcard support #162 address finding the folders; this issue is about what happens after they're found. - Attribution: even once a second root is scanned, everything merges into one undifferentiated total. The transcripts themselves carry no account/org field on any line, so a jsonl parser cannot attribute usage — the root is the only attribution boundary that exists on disk.
Real-world numbers from my machine: default root 1,334 session files / 701MB (personal Max 5x), second root invisible — including sessions from the same evening missing from todayTotalTokens.
Proposal
Layered on top of per-provider scan folders (#177/#187), not competing with it:
- Root → account mapping: each Claude config root contains
.claude.jsonwith anoauthAccountblock (email, org name, org type). Read it once per root (cached by mtime) and tag the root with that identity. - Entry tagging: add an optional
accountKey(the root it came from, resolved to the account identity when known) toLocalUsageReader.Entry/ the provider snapshot. Dedup stays global by entry ID. - Display: default UI unchanged (one merged total — the companion aggregates everything, per the no-provider-branch rule). Add a per-account breakdown in the popover's Claude section when more than one account is present, mirroring how
scopedLimitEntriesextends the limits list.
This composes with #187's per-provider folders: those PRs contribute the roots; this contributes what the roots mean. It also complements #199 (account labeling on the official limits), which covers the OAuth side of the same two-account confusion.
Open questions
- Is an account dimension on
Entryacceptable, or would you rather keepEntryflat and aggregate per-root at the provider-snapshot level? (Snapshot-level is smaller; entry-level survives cache merging.) .claude.jsonis large (can be several MB) — proposal reads only theoauthAccountobject viaJSONSerializationon a mtime-gated cache. Any concerns?
Happy to implement after alignment — flagging the design first since this touches Entry and the cache format.
Activity
shawnla90 commentedon Aug 20, 2026
Design caveat I hit on my own machine after filing this — worth baking into the proposal:
A root's account can change over time. I assumed one config root ↔ one account, but the same default
~/.claudealso serves users who switch accounts in place via/logout→/login(in my case: a personal Max login for months, then a Team login in the same root). Two consequences:oauthAccountmapping is only trustworthy for usage written while that mapping was observed. The historical pile under a switched root is genuinely unattributable — transcripts carry no account field, so no parser can recover it.oauthAccountblock in.claude.jsoncan itself be stale (mine showed the pre-switch account with aprofileFetchedAtseveral days old while the live session was already on the other account), so a one-shot read at scan time can mislabel current usage too.Amended proposal: attribute forward, not backward. At each scan, record
(root, oauthAccount, firstObservedAt)in the app's own cache; attribute an entry to the identity that was current when its file was growing; render everything older than the first observation as "unattributed" rather than guessing. That keeps labels honest at the cost of not retro-labeling history — which I don't think is solvable anyway.shawnla90 commentedon Sep 3, 2026
Before building this I want to get the shape agreed (I learned that lesson on #224). Here's an amendment to my earlier addendum, refined after reading how the pipeline actually works.
Since I wrote the addendum,
3214f83(#228) picks up an in-place account switch on the auto-poll ande81e620(#232) resolves the account across multiple Keychain entries. The ledger below hangs off that same poll result, so it adds no second fetch; it only records what the poll already learned.Attribute by time, not by root. Entry provenance is already discarded and cross-root dedup is root-blind (
dedupKeepMax), so per-root attribution would fight the pipeline. ButEntry.dateround-trips throughusage-cache.json, which means entries can be partitioned at any instant. The same turn seen in two roots has the same timestamp, so it lands in exactly one epoch.Account-epoch ledger. The app already has exactly one live account at a time (credential ->
api/oauth/profile). On every successful limits fetch (~120 s cadence): same account -> extend the current epoch'slastSeen; different account -> close the old epoch at itslastSeen(not at now) and open a new one at now. The gap between epochs is deliberately unattributed because the switch instant is unknowable. Persisted at Application Supportaccount-epochs.jsonfollowing theLocalUsageCachepattern (version int, reset on mismatch, atomic write,.corruptrename on decode failure,PTB_STATE_DIRoverride).Attribution is a pure function
account(for entryDate)= the epoch containing it; gaps and pre-ledger history -> "unattributed". Forward-only, as in the addendum. Mid-day splits fall out for free: daily totals stay whole, attribution is a separate reduce.UI: Claude-only, inside the already gated
limitsSection. One line under the account row, e.g.This month: you@a.com $X · you@b.com $Y · unattributed Z tok. No newProviderSnapshotrows (they would double-count the generic reduces); the accessor sits onUsageStorenext tolimits, the same waylabelsCostAsAPIEquivalentwas parked onLimitStatus. Six localizations plus interpolation-guard entries.Tests: ledger extend/close/gap semantics; decode failure -> fresh ledger +
.corrupt; mid-window split via syntheticEntrydates;UsageStoreTestswithSequenceClaudeLimitsreturning different emails across fetches to observe an epoch rollover; extendingtestMultipleRootsSumButShareGlobalDedupwith "a deduped turn attributes to exactly one epoch". New branches verified withllvm-cov --show-regions.One addition from using this for real: the ledger file should be plain, documented JSON that a user can hand-edit to seed epochs the app never observed (I switched accounts on Aug 14 and the app was not running; a two-line edit backfilled 20k turns correctly). The app only ever appends or extends; manual entries are honored as-is.
If that shape works for you I'll open the PR on
feat/account-epochs. Happy to drop or change any piece.DyRize commentedon Sep 17, 2026
Cross-referencing #327, which shows the official limits and the token usage of several Claude accounts that are live at the same time. It attributes usage through each config folder's
history.jsonl(last prompt of the session before each exchange) rather than an epoch ledger, since two accounts can be active in parallel. Happy to align the two approaches.