Skip to content

Multi-account attribution: tag usage with the account each Claude config root belongs to #201

Open

Description

@shawnla90

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:

  1. 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.
  2. 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:

  1. Root → account mapping: each Claude config root contains .claude.json with an oauthAccount block (email, org name, org type). Read it once per root (cached by mtime) and tag the root with that identity.
  2. Entry tagging: add an optional accountKey (the root it came from, resolved to the account identity when known) to LocalUsageReader.Entry / the provider snapshot. Dedup stays global by entry ID.
  3. 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 scopedLimitEntries extends 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 Entry acceptable, or would you rather keep Entry flat and aggregate per-root at the provider-snapshot level? (Snapshot-level is smaller; entry-level survives cache merging.)
  • .claude.json is large (can be several MB) — proposal reads only the oauthAccount object via JSONSerialization on a mtime-gated cache. Any concerns?

Happy to implement after alignment — flagging the design first since this touches Entry and the cache format.

Activity

  1. shawnla90 commented on Aug 20, 2026

    @shawnla90
    ContributorAuthor

    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 ~/.claude also 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:

    1. Root → oauthAccount mapping 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.
    2. The cached oauthAccount block in .claude.json can itself be stale (mine showed the pre-switch account with a profileFetchedAt several 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.

  2. shawnla90 commented on Sep 3, 2026

    @shawnla90
    ContributorAuthor

    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 and e81e620 (#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. But Entry.date round-trips through usage-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's lastSeen; different account -> close the old epoch at its lastSeen (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 Support account-epochs.json following the LocalUsageCache pattern (version int, reset on mismatch, atomic write, .corrupt rename on decode failure, PTB_STATE_DIR override).

    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 new ProviderSnapshot rows (they would double-count the generic reduces); the accessor sits on UsageStore next to limits, the same way labelsCostAsAPIEquivalent was parked on LimitStatus. Six localizations plus interpolation-guard entries.

    Tests: ledger extend/close/gap semantics; decode failure -> fresh ledger + .corrupt; mid-window split via synthetic Entry dates; UsageStoreTests with SequenceClaudeLimits returning different emails across fetches to observe an epoch rollover; extending testMultipleRootsSumButShareGlobalDedup with "a deduped turn attributes to exactly one epoch". New branches verified with llvm-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.

  3. DyRize commented on Sep 17, 2026

    @DyRize
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions