Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- **Multi-account meters for Codex and Claude.** People who hold two or more
accounts with the same tool get one independent meter row per account
instead of a merge — two accounts' windows are two different budgets. Add
accounts in Settings → Second accounts (name + config directory, any
number, add or remove freely); readings appear after relaunch once the
directory exists. The Codex slot spawns its own `codex` subprocess with
that home (the CLI authenticates as that account; meterusage still never
touches any auth file); the Claude slot scans that account's own transcript
tree, plan file, and companion quota snapshot. Account rows carry your
label ("Codex · Work") and a digit beside the mark in the tray and side
notch, and the `meterusage json` report lists each account with its own
windows, plan, and reset credits plus an additive `account` label field.
No account identifier is ever read or displayed — labels are user-typed,
and each account's history, archive, and alerts are kept separate.

## [0.2.37] - 2026-09-25

### Fixed
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
**meterusage** brings all your AI coding allowances, token burn rates, and rate-limit reset timers together into a clean macOS menu bar item and an interactive floating side-notch HUD.

* **Broad Provider Coverage**: OpenAI Codex, Google Antigravity, Claude Code, OpenRouter, Grok, OpenCode Go, Cursor, GitHub Copilot CLI, and Google Gemini CLI.
* **Second-Account Meters**: Hold two Codex or Claude Code accounts? Each one gets its own independent meter row — own quota windows, plan badge, history, and alerts — instead of a merged guess. See [Second accounts](#second-accounts).
* **Ambient Time-to-Empty**: Live depletion velocity against reset deadlines (`~47m left at current pace` / `Paced to last until reset`) directly in the menu bar and side notch.
* **Burn Attribution & Context Waste**: Token breakdown by project, model, and turns over the last 7 days across every token-bearing provider, plus cache-hit efficiency % and long-chat flags.
* **Durable Daily History**: Local summary store surviving CLI transcript purges and session cleanup.
Expand Down Expand Up @@ -92,6 +93,8 @@ Toggle providers on or off, choose refresh cadence, and switch themes:

<small>*Claude publishes no public quota API; meterusage reads an on-disk JSON snapshot if a local companion writes one (see [docs/COMPANION.md](docs/COMPANION.md) and [`Scripts/claude-companion.sh`](Scripts/claude-companion.sh)).</small>

<small>Second accounts for Codex and Claude use the same mechanisms pointed at the alternate account's config directory (see [Second accounts](#second-accounts) and [docs/adr/0005](docs/adr/0005-multi-account-slots.md)).</small>

---

## 🚀 Key Features
Expand All @@ -108,6 +111,24 @@ Toggle providers on or off, choose refresh cadence, and switch themes:
* **Opt-In Pacing Alerts** — Native macOS notifications when an active window crosses critical burn velocity or drops below 30 minutes to empty. Pace alerts fire only on a current burn; threshold alerts (80%/95%) remain state-based.
* **Share screenshot**: The share button on each provider's usage card (side notch panel detail card) shares a sharp 2x image of the panel through macOS share services, or saves it for X and other apps.

### Second accounts

Two or more accounts with the same tool are separate budgets, so meterusage
gives each one its own meter row — own quota windows, plan badge, session
history, alerts, and (for Codex) reset credits. Add as many as you need:
**Settings → Second accounts → "Add Codex/Claude account"**, then name the
account and point it at that account's own config directory (e.g.
`~/.codex-work`, `~/.claude-personal`). Sign the CLI in under that directory
(`CODEX_HOME=~/.codex-work codex login`, or run Claude Code with
`CLAUDE_CONFIG_DIR=~/.claude-personal`), and relaunch meterusage — readings
appear once the directory exists. Removing a row stops metering that account;
nothing in the directory is deleted.

Slots are named by your own label ("Codex · Work", a digit beside the mark in
the tray and notch); no account identifier is ever read or displayed. Each
account stays under its own keys in the Agent Budget API, which reports the
label in an additive `account` field.

---

## 🔒 Privacy & Architecture
Expand Down
139 changes: 127 additions & 12 deletions Sources/MeterUsage/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
preferences: preferences,
isDemoMode: Composition.isDemoMode,
quotaSources: quotaSources,
resetConsumer: quotaSources.compactMap { $0 as? QuotaResetConsumer }.first,
activitySources: Composition.activitySources(),
usageSources: Composition.usageSources(),
statusSources: Composition.statusSources(),
Expand Down Expand Up @@ -122,10 +121,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
static func tooltip(for coordinator: AppCoordinator) -> String {
var lines: [String] = []

for provider in coordinator.menuBarProviders {
for slot in coordinator.menuBarSlots {
var parts: [String] = []
if let display = coordinator.displayQuota(for: provider),
let window = provider.headlineWindow(from: display.quota.windows) {
if let display = coordinator.displayQuota(for: slot),
let window = slot.provider.headlineWindow(from: display.quota.windows) {
parts.append("\(Fmt.percent(window.usedPercent)) used")
if let resets = window.resetsAt, let until = Fmt.timeUntil(resets) {
parts.append("resets in \(until)")
Expand All @@ -134,14 +133,14 @@ static func tooltip(for coordinator: AppCoordinator) -> String {
parts.append("last reading \(Fmt.timeSince(display.quota.capturedAt))")
}
}
if let service = coordinator.statuses[provider]?.value,
if let service = coordinator.status(for: slot.provider)?.value,
service.severity != .operational {
// Unknown reads as its own word, never silence: a check that
// cannot report is information, not health.
parts.append(service.severity.displayName)
}
if !parts.isEmpty {
lines.append("\(provider.displayName): \(parts.joined(separator: " · "))")
lines.append("\(slot.displayName): \(parts.joined(separator: " · "))")
}
}

Expand Down Expand Up @@ -265,9 +264,17 @@ enum Composition {

static func quotaSources() -> [QuotaSource] {
if isDemoMode {
return [DemoClaudeQuotaSource(), DemoCodexQuotaSource(), DemoOpenRouterQuotaSource(), DemoOpenCodeGoQuotaSource(), DemoGrokQuotaSource(), DemoAntigravityQuotaSource()]
// Demo mounts a second account per tool, so multi-account rows
// get real screenshot coverage. All numbers stay synthetic.
return [
DemoClaudeQuotaSource(),
DemoClaudeQuotaSource(slot: Self.demoSlot(.claude)),
DemoCodexQuotaSource(),
DemoCodexQuotaSource(slot: Self.demoSlot(.codex)),
DemoOpenRouterQuotaSource(), DemoOpenCodeGoQuotaSource(), DemoGrokQuotaSource(), DemoAntigravityQuotaSource()
]
}
return [
var sources: [QuotaSource] = [
// Live Codex limits, read over the CLI's local RPC.
CodexQuotaSource(),
// OpenRouter key spend and optional limit, read from its
Expand All @@ -293,12 +300,84 @@ enum Composition {
// Google Gemini CLI quota and accounts
GeminiQuotaSource()
]
// Additional accounts, one source set per configured directory. A
// Codex account's source spawns the CLI with that home, so the
// subprocess authenticates as that account; meterusage still never
// touches any auth file. A Claude account reads its companion-
// written quota snapshot inside its own directory; absence is the
// normal `.noData` state, exactly like the primary slot.
for account in managedAccounts() {
guard let home = ManagedAccountPaths.home(for: account) else { continue }
switch account.provider {
case .codex:
sources.append(
CodexQuotaSource(
slot: account.slot,
client: SubprocessJSONRPCClient(codexHome: home)
)
)
case .claude:
sources.append(
OptionalQuotaFileSource(
slot: account.slot,
candidatePaths: [
home.appendingPathComponent("meterusage-usage.json"),
home.appendingPathComponent("claudewatch-usage.json"),
]
)
)
default:
// Only tools with per-account config directories mount extra
// slots; other providers stay single-account.
continue
}
}
return sources
}

static func activitySources() -> [LocalActivitySource] {
isDemoMode
? [DemoLocalActivitySource(), DemoCodexActivitySource()]
: [ClaudeLocalSource(), CodexLocalSource()]
if isDemoMode {
return [
DemoLocalActivitySource(),
DemoLocalActivitySource(slot: Self.demoSlot(.claude)),
DemoCodexActivitySource(),
DemoCodexActivitySource(slot: Self.demoSlot(.codex))
]
}
var sources: [LocalActivitySource] = [ClaudeLocalSource(), CodexLocalSource()]
for account in managedAccounts() {
guard let home = ManagedAccountPaths.home(for: account) else { continue }
switch account.provider {
case .codex:
// The account's rollout store lives inside its own home; the
// rollout format is the primary account's, relocated.
sources.append(
CodexLocalSource(slot: account.slot, root: home.appendingPathComponent("sessions", isDirectory: true))
)
case .claude:
// A distinct scan cache per slot: two concurrent scans sharing
// one cache file would let the later writer drop the earlier
// scan's freshly-parsed entries.
sources.append(
ClaudeLocalSource(
slot: account.slot,
root: home.appendingPathComponent("projects", isDirectory: true),
cacheFileURL: Self.cacheDirectory
.appendingPathComponent("claude-local-scan-cache-\(account.id.prefix(8)).json")
)
)
default:
continue
}
}
return sources
}

/// The directory this app is allowed to write its own caches into.
static var cacheDirectory: URL {
HomeDirectory.real
.appendingPathComponent("Library/Application Support", isDirectory: true)
.appendingPathComponent("MeterUsage", isDirectory: true)
}

static func usageSources() -> [UsageSource] {
Expand All @@ -319,6 +398,10 @@ enum Composition {
}

static func statusSources() -> [StatusSource] {
// One check per service: alternate-account slots resolve their
// health through the base provider (`AppCoordinator.status(for:)`),
// so an outage tints both accounts without polling the same public
// feed twice.
if isDemoMode {
return [.codex, .claude].map { DemoStatusSource(provider: $0) }
}
Expand All @@ -330,7 +413,39 @@ enum Composition {

/// Codex reports its plan inline with its quota, so it needs no source
/// here; Claude's tier lives in local account metadata and needs its own.
/// An additional Claude account reads its tier from its own account file,
/// never falling back to the primary account's — a wrong-but-plausible
/// tier is worse than none.
static func planSources() -> [PlanSource] {
isDemoMode ? [DemoPlanSource()] : [ClaudePlanSource()]
if isDemoMode {
return [
DemoPlanSource(),
DemoPlanSource(slot: Self.demoSlot(.claude), tier: .max20x)
]
}
var sources: [PlanSource] = [ClaudePlanSource()]
for account in managedAccounts() where account.provider == .claude {
guard let home = ManagedAccountPaths.home(for: account) else { continue }
sources.append(
ClaudePlanSource(slot: account.slot, fileURL: home.appendingPathComponent(".claude.json"))
)
}
return sources
}

/// The configured additional accounts, read straight from defaults:
/// composition runs before a `Preferences` instance exists, and the
/// headless CLI has none at all.
static func managedAccounts() -> [ManagedAccount] {
guard let data = UserDefaults.standard.data(forKey: PrefKey.managedAccounts),
let accounts = try? JSONDecoder().decode([ManagedAccount].self, from: data)
else { return [] }
return accounts
}

/// The demo's synthetic second account per tool. Fixed id so demo state
/// stays stable across launches of the same run.
static func demoSlot(_ provider: Provider) -> ProviderSlot {
ProviderSlot(provider: provider, slotID: "demo-second", label: "Second account")
}
}
Loading
Loading