Skip to content

feat(multi-account): track multiple Claude.ai accounts in parallel#17

Open
jeanfw wants to merge 3 commits into
eddmann:mainfrom
jeanfw:feat/multi-account-upstream
Open

feat(multi-account): track multiple Claude.ai accounts in parallel#17
jeanfw wants to merge 3 commits into
eddmann:mainfrom
jeanfw:feat/multi-account-upstream

Conversation

@jeanfw
Copy link
Copy Markdown

@jeanfw jeanfw commented May 10, 2026

Summary

Replaces the single-account model with a list of ClaudeAccount entries, each with its own keychain slot, disk cache file, and NSStatusItem. Useful when you have more than one Claude plan to keep an eye on — for example a personal account and a separate client account.

What changes

  • New ClaudeAccount model (id: UUID, label, organizationId). The label is user-facing; the id is the stable key for keychain and cache.
  • AppSettings drops cachedOrganizationId in favour of accounts: [ClaudeAccount]. A custom Codable initialiser performs the migration: a saved blob with cached_organization_id and no accounts is decoded into a single account labelled Default.
  • AppModel stores per-account fetch state in accountStates: [UUID: AccountUsageState] and exposes refreshUsage(accountId:), refreshAllUsage(), addAccount(label:sessionKey:), updateSessionKey(accountId:_:), renameAccount(_:label:), removeAccount(_:).
  • CacheRepository and UsageService are now keyed by account UUID. The public ~/.claudemeter/usage.json export is written for the primary (first) account only, preserving compatibility with existing statusline scripts.
  • MenuBarManager reconciles one NSStatusItem per account (plus a setup item when no account is configured). Each status item gets its own popover scoped to that account; when more than one account exists each icon is prefixed by the account's first letter so they're distinguishable at a glance.
  • SetupWizardView now collects a label in addition to the session key. SettingsView gains an Accounts section with add / rename / replace-session-key / remove actions.
  • Keychain migration: on first bootstrap after upgrade, if the legacy default keychain slot exists and the first account's UUID slot is empty, the key is copied across and the legacy slot is deleted. Idempotent on subsequent launches.
  • Tests in AppModelTests, UsageServiceTests, SettingsRepositoryTests and the TestDoubles are updated to match the new API; a new test exercises the legacy-cached_organization_id migration path.

What stays the same

  • Refresh interval, notification thresholds, icon styles, Sonnet toggle, launch-at-login — all untouched.
  • The 5-hour / weekly / Sonnet API contract.
  • The public ~/.claudemeter/usage.json schema (only the primary account is written).

Test plan

  • Fresh install: setup wizard asks for a label + session key. App appears with one menu bar item for that account.
  • Upgrade from 1.3.0 (with cached_organization_id set in UserDefaults and default session key in Keychain): one account named Default appears, usage loads, keychain default slot is gone, the new UUID slot is populated.
  • Settings → Accounts → Add Account: enter a label + session key. A second menu bar item appears with the first letter of the new label.
  • Both menu bar items refresh independently and show correct percentages; opening one popover only shows that account's data.
  • Settings → Edit Account: rename — the menu bar accessibility label and popover header update.
  • Settings → Edit Account: paste a new session key — it validates and replaces the keychain entry without losing the account id.
  • Settings → Remove Account: the menu bar item disappears, the keychain entry is deleted, and the cache file under ~/Library/Application Support/com.claudemeter/usage_<uuid>.json is purged.
  • All accounts removed: the setup wizard menu bar item reappears.
  • Existing tests still pass after the test-double API update.

🤖 Generated with Claude Code

jeanfw and others added 2 commits May 13, 2026 10:57
Replaces the single-account model with a list of ClaudeAccount entries
keyed in settings, the keychain, and the disk cache by per-account UUID.
Each configured account gets its own NSStatusItem with a dedicated
popover so the menu bar shows freelance and client usage side by side.
Setup wizard now collects a label, and Settings exposes add/edit/rename/
remove for accounts. Existing installs migrate the legacy
'cached_organization_id' setting and 'default' keychain slot to the new
schema on first bootstrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-account users were getting "Usage Warning" / "Session Reset"
notifications with no indication of which account fired them. Now the
title is prefixed with the account label, e.g. "Client X — Session
Reset". The body is left untouched.

`accountLabel` is required on `evaluateThresholds` and optional on
`sendThresholdNotification` / `sendResetNotification` (test
notifications fired from Settings pass nil to keep the bare title).

Known limitation (not addressed here): `NotificationState`'s warning /
critical / reset trackers remain account-global, so account A hitting
a threshold can briefly suppress account B's notification until A
drops below. That's a separate refactor and the existing behaviour
is unchanged vs. the rest of this PR.
@jeanfw jeanfw force-pushed the feat/multi-account-upstream branch from 163e08f to 419d08b Compare May 13, 2026 08:58
`NotificationState` kept `hasWarningBeenNotified`, `hasCriticalBeenNotified`
and `lastPercentage` as account-global flags. In multi-account mode every
refresh would overwrite the global with whichever account was checked
last — so when account A sat at 50% and account B at 0%, B repeatedly
satisfied the "reset detected" check (`lastPercentage > 0 && current == 0`)
because A had just bumped lastPercentage back to 50. Result: a "Session
Reset" notification fired on every refresh interval for any account
sitting at 0% while another account had non-zero usage.

Fix: store all three trackers per-account UUID:
  - `hasWarningBeenNotified: [UUID: Bool]`
  - `hasCriticalBeenNotified: [UUID: Bool]`
  - `lastSessionPercentageByAccount: [UUID: Double]`

`evaluateThresholds` now takes an `accountId` parameter and indexes into
the dictionaries. Re-arming the warning/critical "already notified" flags
when utilization drops below the threshold is also scoped per-account.

Legacy single-account keys (`warning_notified`, `critical_notified`,
`last_percentage`) cannot be safely migrated — they have no account
attribution — so the per-account dicts start empty on upgrade. Worst
case: one extra reset notification after the upgrade if a user was sitting
at non-zero utilization at the moment of upgrade (the very first refresh
will write the percentage; the second refresh will see the value drop or
hold). Worth the cleanup.

Also clarifies the reset notification's title and body to specify the
5-hour session window (vs the weekly window, which doesn't fire reset
notifications today). New test cases cover:
  - reset fires exactly once per real reset (subsequent refreshes at 0%
    don't re-fire)
  - a second account sitting at 0% never receives a spurious reset notif
    even while another account is bouncing around non-zero values
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant