From df30011a39711437662628dd7a88f7f4ba0541e0 Mon Sep 17 00:00:00 2001
From: pekth <5535435+pekth@users.noreply.github.com>
Date: Thu, 15 Oct 2026 02:26:28 -0400
Subject: [PATCH 1/8] feat: prepare Electron public beta with compact usage
panels
---
CHANGELOG.md | 114 +++++
CONTRIBUTING.md | 54 ++-
README.md | 280 ++++--------
docs/DESKTOP-CONNECTIONS.md | 129 +++++-
docs/KB.md | 122 ++++-
docs/PRIVACY.md | 47 +-
docs/SIDE-NOTCH.md | 69 ++-
docs/VERSIONS.md | 92 ++++
docs/adr/0015-codex-test-public-status.md | 28 ++
docs/adr/0016-claude-live-test-profile.md | 37 ++
docs/adr/0017-connected-codex-menu-reset.md | 41 ++
docs/adr/0018-hover-only-notch-details.md | 35 ++
docs/adr/0019-single-design-notch.md | 30 ++
docs/adr/0021-provider-group-order.md | 25 +
docs/adr/0022-codex-account-token-history.md | 37 ++
docs/adr/0023-onboarding-source-choice.md | 31 ++
.../0024-electron-primary-swift-retirement.md | 72 +++
docs/adr/README.md | 17 +-
docs/electron/README.md | 192 ++++++++
docs/mockups/README.md | 9 +-
docs/plans/macos-electron-migration-plan.md | 27 +-
docs/swift/README.md | 211 +++++++++
src/domain/models.ts | 3 +-
src/domain/notch.ts | 2 +-
src/domain/overview.ts | 2 +-
src/domain/provider-order.ts | 16 +
src/main/composition.ts | 31 +-
src/main/connections.ts | 125 +++--
src/main/coordinator.ts | 40 +-
src/main/electron.ts | 58 ++-
src/main/ipc.ts | 5 +-
src/main/launch.ts | 19 +-
src/main/preferences.ts | 26 +-
src/main/providers/claude-desktop.ts | 25 +-
src/main/providers/codex-account-usage.ts | 24 +
src/main/providers/transport.ts | 4 +-
src/renderer/app.tsx | 139 ++++--
src/renderer/preview.ts | 16 +-
src/renderer/provider-order.tsx | 36 ++
src/renderer/style.css | 139 +++++-
src/shared/ipc.ts | 8 +-
src/shared/state.ts | 3 +-
tests/codex-account-usage.test.ts | 25 +
tests/codex-menu-reset.test.ts | 148 ++++++
tests/composition.test.ts | 100 ++--
tests/connections.test.ts | 430 +++++++++++++++++-
tests/coordinator.test.ts | 69 +++
tests/domain.test.ts | 10 +-
tests/electron-lifecycle.test.ts | 179 +++++++-
tests/ipc.test.ts | 13 +-
tests/notch-shell.test.ts | 20 +-
tests/preferences.test.ts | 16 +-
tests/provider-order-ui.test.ts | 62 +++
tests/provider-order.test.ts | 86 ++++
tests/providers.test.ts | 7 +
tests/renderer.test.ts | 375 ++++++++++++++-
56 files changed, 3492 insertions(+), 468 deletions(-)
create mode 100644 docs/VERSIONS.md
create mode 100644 docs/adr/0015-codex-test-public-status.md
create mode 100644 docs/adr/0016-claude-live-test-profile.md
create mode 100644 docs/adr/0017-connected-codex-menu-reset.md
create mode 100644 docs/adr/0018-hover-only-notch-details.md
create mode 100644 docs/adr/0019-single-design-notch.md
create mode 100644 docs/adr/0021-provider-group-order.md
create mode 100644 docs/adr/0022-codex-account-token-history.md
create mode 100644 docs/adr/0023-onboarding-source-choice.md
create mode 100644 docs/adr/0024-electron-primary-swift-retirement.md
create mode 100644 docs/electron/README.md
create mode 100644 docs/swift/README.md
create mode 100644 src/domain/provider-order.ts
create mode 100644 src/main/providers/codex-account-usage.ts
create mode 100644 src/renderer/provider-order.tsx
create mode 100644 tests/codex-account-usage.test.ts
create mode 100644 tests/codex-menu-reset.test.ts
create mode 100644 tests/provider-order-ui.test.ts
create mode 100644 tests/provider-order.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 432eaa6..bfd56cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,71 @@ follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+### Fixed
+
+- Remove the bright focus outline around the Electron usage and Settings
+ panels. Keep keyboard scrolling and focus indicators on individual controls.
+
+### Changed
+
+- Make Electron the sole active feature-development target in contributor and
+ version guidance. Freeze the legacy Swift app for new features and record
+ staged retirement in ADR 0024. Keep the published Swift download, rollback
+ source and Electron-required Swift helpers until the applicable checks pass.
+
+- Split edition setup into Swift and Electron guides with root download routing
+ and compatibility links. Document source/build/release/installation boundaries,
+ candidate parity gaps and isolated QA. Record a proposed same-repository
+ strategy without changing identity, versions, layout, CI or release state.
+ Explain the updater changes required before beta or edition-specific assets.
+
+- Shorten heatmap readouts with readable dates and compact totals such as
+ `12.5K`, `1.2M` and `2.4B` tokens. Keep exact counts in tooltips and accessible
+ labels across providers.
+
+- Unify provider heatmaps with compact headings, a shared intensity legend and
+ compact hover or keyboard readouts with exact tooltips and accessible labels.
+ Unreported account days use quiet solid
+ outlines, distinct from reported zeroes. Keep each source's time range and
+ token or session units.
+
+- Report fixed Claude Desktop Connect errors for Keychain access, account
+ configuration, active-organization cookie, cache decryption, missing or ambiguous
+ current token, allowance request, identity recheck and preference save in the
+ existing error banner through the renderer error filter. Messages contain no
+ account identifiers or token data. Source and synthetic tests only; native and
+ live acceptance remain open.
+
+- Narrow the Electron side notch strip from 44 to 36 px and its hover card from
+ 250 to 220 px. Keep the share capture at 250 px and preserve whole-pixel
+ typography and the anchored strip edge.
+
### Added
+- Choose a usage source during onboarding or in Settings: Codex browser sign-in
+ or CLI, Claude Desktop or Claude Code CLI, and Grok CLI. Apply CLI selection
+ explicitly after disconnecting only the MeterUsage account connection.
+
+- Show provider-reported account token activity in connected Codex main, notch
+ and shared cards through the existing managed ChatGPT helper. Daily dates and
+ exact counts remain separate from local usage. Missing history and unreported
+ days stay unavailable; reported zero remains zero. Native and live coverage
+ acceptance remain open.
+
+- Reorder provider groups in Electron Settings with drag handles, arrow keys or
+ move buttons. The saved order applies to usage cards, menu bar and side notch;
+ additional accounts stay with their provider. Failed saves keep the saved order.
+
+- Add a separate Claude cloud test profile for the existing candidate UI.
+ It starts without consent, reads only connected account allowance, and keeps
+ preferences and Electron state separate from installed-app and Codex profiles.
+ Native Keychain, Desktop compatibility and live allowance remain unverified.
+- Show **Use reset** beside eligible Codex menu credits for the connected
+ account, including the isolated Codex test profile. Native confirmation
+ defaults to Cancel. Redemption rechecks availability and the same connection
+ profile before using the official helper, then refreshes quota. Synthetic
+ tests consume no real credits; native and live redemption proof remain open.
+
- Add Small, Medium and Large panel sizes in candidate Settings. The choice
persists and applies to main-panel and Settings text and controls. Main-panel
width scales with the choice; its height fits content up to the display edge.
@@ -34,6 +97,33 @@ follows [Semantic Versioning](https://semver.org/).
### Changed
+- Compact candidate Settings onboarding and repeated rows. Explain demo account
+ restrictions before disabled connections, align provider controls, and stack
+ labelled controls on narrow windows. Keep action failures visible in Settings
+ and wrap long account, quota and status text.
+- Compact Service status and the three-metric local activity summary. Separate
+ recorded activity from account/local totals, retain dated quota warnings, and
+ keep controls out of static shared cards. Live heatmaps retain their exact
+ hover/focus readout; exports retain data without the unused readout space.
+- Use the existing brighter dark secondary tone and 100 ms pointer color
+ feedback across candidate Usage and Settings. Press and keyboard focus stay
+ immediate. Existing sizes, notch geometry and data/action behavior remain.
+ Renderer fixtures do not establish browser or native visual acceptance.
+
+- Brighten secondary labels in the candidate notch card, separate telemetry
+ with a border and size-specific padding, and add 100 ms color feedback on
+ enabled control hover with a fine pointer. Press and keyboard focus stay
+ immediate; reduced motion disables transitions. Card opening, provider
+ switching, the 450 ms close delay and integer text sizes are unchanged.
+
+- Require an explicit consent callback for Claude Desktop connection. Omitting
+ it denies access before the Keychain helper or allowance request runs.
+- Show Codex public service status in the isolated live Codex test profile.
+ The Service status panel, notch card, tray tooltip and copied diagnostics mark
+ retained readings as stale or last known with their age and refresh failure. Public-feed errors use
+ service-status wording while retaining their retry behavior. Requests use no
+ account credentials and add no other provider source.
+
- Move candidate onboarding into Settings. First-time setup reuses account
connections and allows Done without signing in. Done saves completion before
returning to usage; a save failure keeps setup open. The main panel shows usage
@@ -66,6 +156,30 @@ follows [Semantic Versioning](https://semver.org/).
### Fixed
+- Keep Electron side-notch details open on hover and close them after the
+ existing 450 ms exit delay. Remove Pin detail card and Keep open controls;
+ legacy pin data no longer retains a card. The strip always shows usage rings
+ and percentages, with no design selector or dots option. Disable/hide clears
+ expanded state so re-enabling starts with the 44 px strip.
+- Replace side-notch CSS zoom with integer pixel text, controls and spacing.
+ Small uses an 11 px body and 10 px secondary text. Small, Medium and Large
+ keep the strip and card widths fixed. Flyout, Settings and shared cards keep
+ their existing rendering scales. Browser and native acceptance remain separate.
+
+- Match Codex Use reset buttons to compact footer controls in the Electron
+ candidate. Keep eligibility, account labels and Cancel-default confirmation.
+- Show exact heatmap counts and dates immediately on hover or keyboard focus.
+ Weekly and cumulative views name their scope. Count-only data does not claim
+ measured token totals. The readout reserves space inside the scrolling card.
+- Recover missed side-notch pointer exits through a cursor check limited to an
+ open interaction, then use the existing 450 ms fold delay. Keep the provider
+ strip anchor, drag and native share/reset guards.
+
+- Abort confirmed Codex resets when their source is replaced or the coordinator
+ stops. Replacement during the fresh eligibility check now prevents consume
+ dispatch, including after Clear local scan cache. Cancellation cannot undo a
+ request already sent to the provider.
+
- Handle opening an already-running candidate without an uncaught startup
popup. Validate Electron's singleton metadata before requesting its lock,
retain all other symlink checks, and reveal the existing panel after startup.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e59f25b..88bf8ab 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,17 +1,33 @@
# Contributing to meterusage
-> ### ⚡ TL;DR
+> ### Quick start
> 1. Fork & clone the repo.
> 2. Enable pre-commit hook: `git config core.hooksPath .githooks`
-> 3. Verify: `swift test` & `./Scripts/make-app.sh --build-from-source`
+> 3. For Electron, use the declared Node/pnpm versions, then run
+> `pnpm install --frozen-lockfile`, `pnpm typecheck`, `pnpm test` and `pnpm build`.
> 4. Keep diffs focused, zero secrets or credentials, synthetic fixtures only.
Fork the repository, make one focused change, run the checks below, and open a
pull request.
+## Edition routing
+
+New features target the [Electron implementation](docs/electron/README.md).
+The [legacy Swift app](docs/swift/README.md) is frozen for new features and kept
+for critical maintenance, transition compatibility and rollback. Both source
+trees remain until the [retirement conditions](docs/adr/0024-electron-primary-swift-retirement.md#retirement-conditions)
+pass. Validate both affected paths when a shared contract changes.
+`Scripts/make-app.sh` prepares the published Swift app by default and never
+builds Electron. Isolated demo/test profiles are required for candidate QA;
+shared normal profiles do not support safe side-by-side use.
+
## Before you start
+Electron development uses the Node and pnpm versions declared in `package.json`.
+Follow [the Electron commands](#typescript-electron-candidate). Electron's macOS
+packaging still needs Swift for its Keychain helper and asset exporter.
+
The published app is a macOS 13+ Swift menu-bar app. A TypeScript/Electron
candidate is under development; see the candidate commands below. The package uses Swift tools
version 5.9 and has no third-party package dependencies. Swift source builds need Xcode Command Line Tools:
@@ -76,13 +92,14 @@ Add OpenRouter balance display
Do not mix unrelated fixes in the same commit.
-## Build and test
+
+## Legacy Swift build and test
GitHub Actions runs `swift build` and `swift test` on macOS 15 for pushes to
`main` and pull requests. Repository owners must enable Actions under
Settings > Actions > General for these checks to run.
-Run these checks before a pull request:
+For Swift source changes, run these checks before a pull request:
```sh
swift build
@@ -119,6 +136,11 @@ See [`docs/DEMO.md`](docs/DEMO.md) for the data and privacy rules.
## TypeScript Electron candidate
+The source version is 0.2.41. This candidate is unpublished; private build and
+installation receipts exist, while complete native acceptance remains open.
+See the [candidate guide](docs/electron/README.md) for source choices and parity gaps.
+
+
Use the Node and pnpm versions declared in `package.json`. Provider accounts
are optional; synthetic tests and demo mode require no login. Shared checks
can run on Linux. Packaging, system symbol export and native acceptance require
@@ -186,17 +208,26 @@ See [ADR 0010](docs/adr/0010-macos-typescript-electron.md) and
## Where to make changes
+New providers and data sources belong in Electron. Use the `src/` and `tests/`
+map in [Electron development](#typescript-electron-candidate).
+
+### Legacy Swift maintenance
+
+Use these paths only for critical fixes, transition compatibility and rollback:
+
- `Sources/MeterUsage/Services/` contains provider readers, pricing, and
service-status sources.
- `Sources/MeterUsage/Core/` contains coordination and saved preferences.
- `Sources/MeterUsage/Views/` contains the SwiftUI menu-bar interface.
- `Tests/MeterUsageTests/` contains unit tests and synthetic fixtures.
-When adding a provider or data source, use the existing `QuotaSource`,
+When maintaining a legacy provider or data source, use the existing `QuotaSource`,
`UsageSource`, `LocalActivitySource`, or `StatusSource` contract in
`Sources/MeterUsage/Services/DataSource.swift`. Keep provider failures isolated
so one unavailable provider does not hide the others. Add or update the model,
-coordinator, settings, and tests only when the feature needs them.
+coordinator, settings, and tests only when the maintenance change needs them.
+
+### Shared contribution rules
New providers must have synthetic demo data and tests. Normal tests and
screenshots must not require a live login.
@@ -223,10 +254,11 @@ positive.
## Generated assets
-Run `./Scripts/make-icon.sh` only when the icon design changes. The app bundle
-is assembled from source by `./Scripts/make-app.sh --build-from-source`.
-Output under `dist/` is ignored
-and should not be force-added.
+Run `./Scripts/make-icon.sh` only when the icon design changes. Build the legacy
+Swift app bundle with `./Scripts/make-app.sh --build-from-source`. For Electron
+packaging, follow [Electron development](#typescript-electron-candidate).
+Generated output under `dist/` and `release/` is ignored and should not be
+force-added.
## Pull requests
@@ -234,7 +266,7 @@ A pull request should include:
- the user-visible change and why it is needed;
- the files or provider paths affected;
-- the exact checks you ran, including `swift test`;
+- the exact checks you ran, including the affected Swift or TypeScript checks;
- any privacy, network, or credential-handling impact; and
- demo-mode screenshots when a UI change needs visual review, plus captured
side notch frames when the change touches panel layout or motion.
diff --git a/README.md b/README.md
index 6d7fcdf..64b06fc 100644
--- a/README.md
+++ b/README.md
@@ -1,223 +1,127 @@
-
+
-
meterusage
+# MeterUsage
-
- Native macOS menu-bar and floating side-notch HUD for monitoring your AI coding quotas.
-
-
-
-
-
-
-
-
----
-
-### ⚡ TL;DR
-
-**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.
+MeterUsage monitors AI coding allowance and usage in a macOS menu bar and
+floating side notch. Electron is the active development target. The legacy
+Swift app remains the stable download and rollback option during migration.
-* **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.
-* **Existing provider access**: No accounts to connect or passwords to enter. Uses existing provider CLIs, local SQLite/JSON stores and aggregate usage endpoints.
-* **Agent Budget API**: Machine-readable JSON CLI (`meterusage json`) exporting burn rates, pacing, and time-to-empty for autonomous AI agents.
+## Choose an edition
-[Download MeterUsage 0.2.41 for Apple silicon](https://github.com/pekth/meterusage/releases/download/v0.2.41/MeterUsage-0.2.41.zip),
-unzip it, and drag `MeterUsage.app` to
-**Applications**. No Swift, Xcode, Command Line Tools, or Git is required.
-The current prebuilt release supports Apple silicon Macs with macOS 13 or later.
-See [installation](#download-and-installation) for first-launch steps.
+| Edition | Current status | Setup and behavior |
+| --- | --- | --- |
+| Electron 0.2.41 source candidate | Active feature development; unpublished; native acceptance is incomplete | [Electron guide](docs/electron/README.md) |
+| Legacy Swift 0.2.41 | Frozen for new features; published stable download and migration rollback | [Swift guide](docs/swift/README.md) |
----
+Both implementations are already in this repository. The shared version number
+does not make the candidate the published download, and it is not a released
+v2.1. New contributions target Electron. Swift receives critical maintenance
+only until verified cutover. See the [staged retirement decision](docs/adr/0024-electron-primary-swift-retirement.md)
+and [version strategy](docs/VERSIONS.md).
-## macOS Electron migration candidate
-
-The macOS app and schema-1 JSON CLI are being ported to TypeScript, Electron
-and React. The candidate uses pnpm, Vite+, Tailwind, Vitest and electron-builder.
-The downloadable 0.2.41 app above remains the Swift release. The Electron
-candidate has not passed native macOS acceptance and is not a published release.
-
-The candidate also has [desktop account connection controls](docs/DESKTOP-CONNECTIONS.md)
-for Codex browser sign-in and opt-in Claude Desktop allowance. Setup uses buttons
-and approval dialogs. Grok consumer connection remains unavailable. Native
-sign-in, provider responses and Keychain acceptance still require verification.
-
-Provider readers and credentials stay in the main process. The sandboxed
-renderer receives validated snapshots through a small IPC bridge. Existing
-history/archive JSON, preference keys, managed account IDs and provider marks
-remain compatibility targets. Windows implementation, installers, testing and
-release are paused until a test machine is available.
-
-See [the migration decision](docs/adr/0010-macos-typescript-electron.md),
-[contributor commands](CONTRIBUTING.md#typescript-electron-candidate), and
-[isolated demo testing](docs/DEMO.md#electron-candidate). Native tray, settings,
-notifications, login items, sharing, updater and two different-height notch
-captures are required before cutover.
-
----
-
-## 📸 Showcase
+## Download and installation
-### Ambient Time-To-Empty & Side Notch HUD
-A dockable, collapsible HUD pinned to the edge of your screen. Hovering any provider ring expands a dedicated detail card with rate limits, ambient time-to-empty, pacing diagnostics, and token telemetry. The panel is anchored by the strip's top-right corner, so switching providers never moves the strip ([`docs/SIDE-NOTCH.md`](docs/SIDE-NOTCH.md)):
+The default installation is the published Swift app for Apple silicon Macs
+with macOS 13 or later. No Swift, Xcode, Command Line Tools or Git is required.
-
-
-
-
-
+1. [Download MeterUsage 0.2.41](https://github.com/pekth/meterusage/releases/download/v0.2.41/MeterUsage-0.2.41.zip).
+2. Unzip and drag `MeterUsage.app` to Applications.
+3. Open the app. The release is ad-hoc signed and not notarized. If macOS blocks
+ first launch, right-click the app in Finder and choose **Open**, or use
+ **Open Anyway** in System Settings > Privacy & Security.
-
-
-
-
-
+From a Git checkout or source archive, run:
-### Menu Bar & Popover Dashboard
-Click the menu bar mark anytime to inspect full rate-limit details, multi-window countdowns, and 26-week activity heatmaps:
+```sh
+./Scripts/make-app.sh
+```
-
-
-
-
-
-
-
+This downloads the pinned Swift ZIP, verifies its checksum and signature, and
+prepares `dist/MeterUsage.app`. Drag that app to Applications to install it.
+The script does not build Electron. A failed download or verification does not
+fall back to compilation. See the [Swift installation guide](docs/swift/README.md#download-and-installation).
-Screenshots show the app in demo mode (`METERUSAGE_DEMO=1`) — all numbers and names are synthetic.
+## Setup and data coverage
-### Settings: Accounts & Providers
-Toggle providers on or off, choose refresh cadence, and switch themes and the accent colour:
+All nine provider adapters remain in the retained Swift and candidate source:
+Codex, Claude Code, Antigravity, OpenRouter, Grok, OpenCode Go, Cursor, Copilot
+CLI and Gemini CLI. Their inputs and available measurements differ. The
+[Swift provider matrix](docs/swift/README.md#provider-matrix) describes existing
+CLI/local collection. The [Electron source choices](docs/electron/README.md#source-choices)
+also describe optional Codex browser sign-in and Claude Desktop allowance.
-
-
-
-
-
+Local activity covers this Mac. Provider-reported account data has its own
+coverage limits. Missing tokens remain unknown; costs are estimates and provider
+dashboards remain the billing source of record. Read the [privacy contract](docs/PRIVACY.md)
+before changing a data source or sharing screenshots.
----
-
-## 🔍 Provider Matrix
-
-| Provider | Live Cloud Quota | Local Activity & Tokens | Reset Countdowns | 26-Week Heatmap | Source Mechanism |
-|---|:---:|:---:|:---:|:---:|---|
-| **Codex** | ✅ | ✅ | ✅ | ✅ | Local JSON-RPC via `codex app-server --stdio` |
-| **Antigravity** | ✅ | ✅ | ✅ | — | CLI `/usage` & local conversation SQLite |
-| **Claude Code** | Optional* | ✅ | ✅* | ✅ | Local session JSONL streams (`limits[]` file optional) |
-| **OpenRouter** | ✅ | ✅ | ✅ | — | Public account API & `/api/v1/activity` telemetry |
-| **Grok** | ✅ | ✅ | ✅ | — | CLI auth bearer token & session summaries |
-| **OpenCode Go** | ✅ | ✅ | ✅ | — | Local `opencode db` read-only queries |
-| **Cursor** | Local snapshot | — | Snapshot-dependent | — | Existing local quota/usage JSON or presence check |
-| **Copilot CLI** | Local snapshot | — | Snapshot-dependent | — | Existing local quota/usage JSON or presence check |
-| **Gemini CLI** | Local snapshot | — | Snapshot-dependent | — | Existing local quota/usage JSON or presence check |
-
-*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)).
-
-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)).
-
----
-
-## 🚀 Key Features
-
-* **Ambient Time-to-Empty**: Dynamic ETA calculations (`~47m left at current pace` / `Paced to last until reset`) directly in the menu bar and side notch. The bar, banner, and menu-bar chip report the window's own pace, so a deficit keeps its projected run-out after the burst has cooled; only the present-tense "burning fast" nudge and pace alerts require a current burn. After a successful Codex "Use reset", pacing waits for fresh quota readings with usage growth. The first reading establishes a baseline, and later growth determines the rate and ETA. Each account keeps its own baseline across relaunches.
-* **Window Burn Attribution** — Explains *"Where did my tokens go?"* by decomposing the last 7 days of token consumption by project, model, and message turns across all token-bearing providers.
-* **Context Waste & Cache Hints** — Diagnostic metadata highlighting cache hit rate %, average tokens per turn, and long-chat flags ($\ge 10$ turns or $\ge 100\text{k}$ tokens) to curb silent context waste.
-* **Durable Daily History Store** — Preserves daily token tallies, estimated spend, and peak window utilization in a durable local database (`~/Library/Application Support/MeterUsage/durable-daily-history.json`) that survives CLI transcript pruning.
-* **Unified AI Coding Strip** — High-level summary card in the popover showing all AI coding today (tokens, weekly volume, and estimated USD spend) across all active providers.
-* **Floating Side Notch HUD** — Fixed-black, hardware-like collapsible strip. Hovering a ring expands a docked detail card with smooth spring animations. Features auto-flip positioning (switches left/right depending on screen position). The strip stays anchored to its parked corner while cards change, so provider switches never shift it.
-* **Cross-Provider Headroom Failover** — Instant suggestions when a provider is burning fast or near exhaustion, identifying which alternative model has headroom available. "Burning fast" requires a current burn; a window that is merely near its limit says so in its own words.
-* **Agent Budget API** — Run `meterusage json` to export machine-readable quota telemetry, burn velocity, pacing status, and seconds-to-empty for autonomous AI agents. Pacing in the report is gated on burn recency, matching the failover nudge and pace alerts.
-* **26-Week Activity Heatmaps** — GitHub-style activity matrix inside Codex and Claude cards with Day, Week, or Cumulative views, accompanied by 7-day sparklines.
-* **Accent Colour Themes** — Pick an accent palette (Blue, Violet, Teal, Amber, Rose, Graphite) in Settings → Appearance. It recolours the app accent and the primary provider's mark across the popover, menu bar, and side notch; quota headroom and the other provider identities stay fixed.
-* **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 provider account identifier is 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
-
-meterusage is built from the ground up to respect developer privacy:
-* **No Network Man-in-the-Middle**: Reuses the authenticated CLI sessions already on your Mac.
-* **Selective local reads**: Selects numeric session metadata, token tallies, model names and timestamps. Project directories become basenames. Message content, tool payloads and file diffs are skipped.
-* **No Telemetry / Analytics**: Zero outgoing telemetry calls.
-* **Inspectable data boundary**: Full privacy architecture documented in [docs/PRIVACY.md](docs/PRIVACY.md).
-
----
+## Can both editions coexist?
-## Download and installation
+Both source trees remain during migration; the intended end state is one
+Electron app. Normal-profile side-by-side installation is unsafe:
+the app name, bundle ID and default preferences/data locations are shared.
+Use only the documented [isolated candidate QA profiles](docs/electron/README.md#isolated-qa)
+for testing. A separate app identity and data root require a later decision;
+there is no automatic cutover. See [ADR 0010](docs/adr/0010-macos-typescript-electron.md).
-### Install the prebuilt app
+## Documentation
-Installing and running MeterUsage does not require Swift, Xcode, Command Line
-Tools, or Git. The Swift runtime it uses ships with macOS.
+- [Swift guide and older demo screenshots](docs/swift/README.md)
+- [Electron candidate guide and known gaps](docs/electron/README.md)
+- [Versions and staged Swift retirement](docs/VERSIONS.md)
+- [Contributing](CONTRIBUTING.md)
+- [Project knowledge](docs/KB.md) and [decision index](docs/adr/README.md)
+- [Desktop connections](docs/DESKTOP-CONNECTIONS.md), [demo mode](docs/DEMO.md)
+ and [side notch](docs/SIDE-NOTCH.md)
+- [Concept mockups](docs/mockups/README.md) and [changelog](CHANGELOG.md)
-1. [Download MeterUsage 0.2.41 for Apple silicon](https://github.com/pekth/meterusage/releases/download/v0.2.41/MeterUsage-0.2.41.zip). This app ZIP is for Apple silicon Macs (M1 or later) with macOS 13 or later. Other versions are on [Releases](https://github.com/pekth/meterusage/releases). Choose the app ZIP, not GitHub's **Source code** archives.
-2. Unzip and drag `MeterUsage.app` to your `/Applications/` folder.
-3. Open `MeterUsage.app`. Releases are ad-hoc signed and are not notarized. If macOS blocks the first launch, right-click the app in Finder and choose **Open**, or use **Open Anyway** in System Settings → Privacy & Security.
+## Compatibility links
-If a release has no app ZIP, use an earlier release that includes one or report
-the missing asset.
+Older root README links continue below. Detailed Swift descriptions and images
+now live in the Swift guide.
-### From a Git checkout (no Swift)
+
+See the [Electron candidate guide](docs/electron/README.md).
-If you already cloned this repository or downloaded its source archive, run:
+
+See [Swift additional local accounts](docs/swift/README.md#second-accounts) and
+[Electron advanced local accounts](docs/electron/README.md#advanced-local-accounts).
-```sh
-./Scripts/make-app.sh
-```
+
+See [Swift requirements](docs/swift/README.md#requirements).
-This downloads the verified prebuilt release to `dist/MeterUsage.app`. Drag
-that app to Applications and use the same first-launch steps above. It does
-not compile your checkout, install developer tools, or fall back to a source
-build when the download fails.
+
+See [contributor setup](CONTRIBUTING.md).
----
+
+See the [Swift Agent Budget CLI](docs/swift/README.md#agent-budget-cli) and
+[Electron commands](docs/electron/README.md#build-and-test).
-## Requirements
+
+
+
+
+See [older Swift demo screenshots](docs/swift/README.md#showcase).
-* Apple silicon Mac with macOS 13 Ventura or later for the current prebuilt release.
-* A supported provider CLI or data source for the meters you want to use. Provider accounts are not required to install the app or run demo mode.
+
+See the [Swift provider matrix](docs/swift/README.md#provider-matrix).
-### Contributing
+
+See [Swift behavior](docs/swift/README.md#key-features).
-Swift and Xcode Command Line Tools are needed only to build from source.
-See [CONTRIBUTING.md](CONTRIBUTING.md) for developer setup, build commands,
-and tests. Source builds require the explicit `--build-from-source` option.
+
+See [privacy and data handling](docs/PRIVACY.md).
-### Agent Budget CLI
-To consume quota telemetry programmatically in scripts or agents:
-```sh
-meterusage json
-```
-Outputs structured JSON including `remaining_percent`, `resets_at`, `pacing`, `burn_rate`, and `eta_seconds`.
+
+See [installation details](docs/swift/README.md#download-and-installation).
----
+
+See [edition choice](#choose-an-edition).
-## 📄 License & Disclaimer
+
+## License and disclaimer
-* **License**: [MIT](LICENSE)
-* **Disclaimer**: Independent open-source project. Not affiliated with or endorsed by OpenAI, Anthropic, Google, xAI, Microsoft, or OpenRouter.
+[MIT](LICENSE). Independent project, not affiliated with or endorsed by OpenAI,
+Anthropic, Google, xAI, Microsoft or other providers.
diff --git a/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md
index 571be49..47282e4 100644
--- a/docs/DESKTOP-CONNECTIONS.md
+++ b/docs/DESKTOP-CONNECTIONS.md
@@ -7,33 +7,69 @@ provider acceptance remain unverified.
## Setup
-Before setup is complete, launch or app activation opens Settings. Connect an
-account there, or choose **Done** to continue without signing in. Done saves the
+Before setup is complete, launch or app activation opens Settings. Choose a
+connection method for each provider, or choose **Done** below the choices to
+continue without signing in. Done saves the
existing setup-completion preference before opening usage. If saving fails,
Settings keeps the setup guidance and reports the error. Account connections
remain in Settings after setup; the main usage panel has no welcome card.
See [ADR 0014](adr/0014-settings-onboarding.md).
+The **Connect using** selector offers the methods supported by each provider.
+Selecting a method does not start sign-in or change collection. Use the button
+below it to apply the choice. Existing profiles keep their current source.
+
- Codex: choose **Sign in to Codex** and complete browser sign-in. The candidate
finds the helper in an existing Codex app in Applications, with existing
command installations as a compatibility path. Users do not open a terminal
or choose a configuration folder. A missing helper produces an unavailable
message. The Codex app's authentication is not changed.
+ Alternatively, choose **Codex CLI** and **Use CLI** to use an existing CLI
+ sign-in for allowance and read local token activity.
- Claude: sign in to Claude Desktop on the Mac, choose **Connect Claude
Desktop**, and approve MeterUsage's access explanation and any macOS
Keychain prompt. The candidate requests account allowance using Desktop's
current access token. This interface can change and is not a documented
third-party consumer API contract.
+ Alternatively, choose **Claude Code CLI** and **Use CLI** to read local Claude
+ Code activity. CLI allowance requires a companion usage file; this option
+ does not provide Claude website token history.
- Grok: automatic consumer connection is unavailable. Existing command-based
collection remains available for existing users. It does not establish
browser/mobile consumer support. The setup does not ask for manual numbers.
+**Use CLI** first disconnects only the MeterUsage-owned account connection and
+waits for any Codex logout cleanup to succeed. A failed cleanup keeps local
+collection off and reports an error. The source choice survives relaunch. The
+ordinary **Disconnect** action still keeps account collection off without
+switching to local data. Demo and isolated live test profiles cannot select
+local collection. [ADR 0023](adr/0023-onboarding-source-choice.md) records this
+explicit transition.
+
The normal refresh interval applies after connection. An expired login,
unsupported cache format, missing permission or provider failure keeps usage
unavailable. Open the relevant desktop app, disconnect the MeterUsage
connection, then connect again. Disconnect removes MeterUsage's own connection;
it does not sign out or erase data in another app.
+Claude Desktop **Connect** failures use fixed messages in the existing error
+banner. They distinguish foreground Keychain access, account configuration,
+the active organization cookie, cache decryption, missing or ambiguous current
+tokens, allowance request or response validation, post-request identity verification,
+or saving MeterUsage's connection preferences. An identity recheck failure
+can include a failed background Keychain or cache read. Messages contain no
+raw exceptions, responses, tokens, account identifiers or paths. Cancellation
+keeps its existing message. Background collection keeps its existing
+unavailable and not-signed-in results.
+The renderer error filter permits only these fixed diagnostics. Unknown
+exceptions still produce the generic action error. Missing or ambiguous current
+tokens keep collection disabled.
+
+These diagnostics have source and synthetic-fixture proof only. They do not
+identify the cause of a live connection failure or prove that Claude Desktop
+connection works. Packaged banner display, Keychain access, Desktop compatibility
+and live allowance still require native acceptance.
+
## Data coverage
Account allowance comes from the provider account service. It can reflect use
@@ -45,7 +81,7 @@ Local activity charts are labelled as activity on this Mac. OpenRouter's
remote usage is labelled as account activity and excluded from local totals. Connected
Codex/Claude account cards omit the separate local CLI activity source because
MeterUsage cannot establish that it belongs to the connected account. No local
-and account total is added together. Detailed account token history, ordinary
+and account total is added together. Claude account token history, ordinary
Claude chat tokens and Grok consumer history are outside this implementation.
## Authentication and isolation
@@ -67,10 +103,17 @@ retry. Retry completes cleanup before starting another login. Normal quit waits
up to five seconds for authentication cleanup. Headless reports only read existing
connections; they never start sign-in, show a permission prompt or perform
cleanup logout. Clear local scan cache keeps the established connection reader.
-Connected account cards show reset-credit details without redemption controls;
-redemption stays limited to existing sources that support it.
-
-Claude access requires explicit consent. A bundled native helper performs one
+Connected Codex cards show **Use reset** beside available, unexpired credits in
+the menu. The existing native confirmation names the account and credit and
+defaults to Cancel. After confirmation, the candidate rereads allowance and
+rejects a missing, spent or expired credit or a changed/disconnected profile.
+The official helper consumes once under the same keyring-only profile, then
+quota refreshes. Cancelling makes no consume call. A disconnect aborts pending
+work; a request already sent to the provider cannot be undone. See
+[ADR 0017](adr/0017-connected-codex-menu-reset.md).
+
+Claude access requires explicit consent; a connection call without a consent
+callback denies access. A bundled native helper performs one
fixed Keychain read. Background calls forbid interactive authentication. The
main process decrypts the Desktop cache, reads the active organization cookie
from a read-only database, and selects one valid account-scoped access token.
@@ -95,6 +138,32 @@ Synthetic tests cover protocol, consent, token selection, account switching,
disconnect, numeric validation, stale source results and IPC boundaries.
See [privacy](PRIVACY.md) and [ADR 0011](adr/0011-desktop-account-connections.md).
+## Codex account daily tokens
+
+A connected Codex account also reads `account/usage/read` through the same
+managed ChatGPT helper. The request omits `threadId`. The helper must support
+this method and pass the existing effective keyring-storage guard. A failed or
+unsupported read shows "Account history unavailable" and leaves quota separate.
+
+The main, notch and shared card label this grid "Account token activity" with
+"Provider-reported account scope". It shows 26 weeks ending at the latest
+reported date. Hover or focus a day for its exact token count. Dashed days are
+unavailable, including gaps before or between reported dates. A reported zero
+is a measured value. The existing Daily heatmaps and Codex heatmap settings
+control this grid. Provider dates remain calendar labels; no timezone is assumed.
+
+Null or absent daily buckets cannot be reconstructed from summary statistics.
+Invalid, duplicate or unsafe buckets fail closed. No token-category breakdown,
+session count or cost is invented. History stays in memory and is cleared on
+failed reads, source replacement or disconnect. It never merges with local
+profile history or local totals. Collection uses the existing deadline and
+bounded backoff, with no new retry loop.
+
+The public contract does not establish complete Codex-only, cross-device or
+cloud coverage, day timezone, retention or service polling limits. Live account
+availability and coverage require separate proof. Additional local profiles
+retain their existing readers. See [ADR 0022](adr/0022-codex-account-token-history.md).
+
## Isolated live Codex validation
The candidate accepts `--codex-test-profile` with an absolute test directory.
@@ -117,12 +186,54 @@ The app starts disconnected, so an unavailable reading before sign-in is
expected. Successful connections remain available across normal quit and
relaunch; **Disconnect** signs out only that MeterUsage connection.
-This mode reads only Codex account allowance. It has no local activity, status
-feed, reset redemption or other-provider collection. Update checks, update
+This mode reads Codex account allowance, provider-reported daily tokens and the public OpenAI service-status
+feed, and permits explicitly confirmed Codex reset redemption. Status requests
+use no account credentials. It has no local activity or other-provider collection.
+See [ADR 0015](adr/0015-codex-test-public-status.md) and
+[ADR 0017](adr/0017-connected-codex-menu-reset.md). Update checks, update
installation, login-item changes and notifications are disabled. It does not
change the primary setup for users or replace an installed app.
+After a failed refresh, the Service status panel, notch card and tray tooltip
+keep the last successful reading marked as last known with its age and the
+refresh failure. Public-feed errors do not use account or quota error text.
+Copied diagnostics also marks retained status as stale, with the time since its
+last successful check and a fixed service-status error. A successful retry clears
+that stale marker.
+
Synthetic isolation tests do not prove native sign-in or visible cloud usage.
Live acceptance requires the packaged app, human browser sign-in, fresh
allowance in its actual UI and comparison with the same provider account.
See [ADR 0012](adr/0012-codex-live-test-profile.md).
+
+## Isolated live Claude validation
+
+Use `--claude-test-profile` with a separate absolute test directory. It reuses
+the existing Settings consent dialog, fixed Keychain helper, read-only Desktop
+reader and allowance request. The directory must be empty on first use. Its
+marker differs from Codex and demo markers; those profiles cannot be reused.
+The same protected-path, symlink and Electron singleton checks apply.
+Do not combine the flag with another test profile, `--demo` or
+`--candidate-profile`.
+
+The app starts disconnected with Claude collection visible. Cancel the access
+dialog to leave consent off. After approval, only Claude account allowance is
+collected. JSON preferences, archives and Electron state stay in the test
+directory; OS home remains available only to the existing Desktop reader.
+Codex sign-in, polling and cleanup are blocked. Other providers, local activity,
+status feeds and reset redemption are excluded. Updates, login-item changes
+and notifications remain blocked by candidate mode.
+
+Normal quit retains an approved account fingerprint for relaunch. Background
+reads cannot prompt. Disconnect saves consent off, clears allowance and leaves
+Claude Desktop signed in. An unavailable reading must not fall back to local
+CLI or companion files.
+
+Synthetic tests prove isolation and consent/connection/relaunch/disconnect
+against invented Desktop data. Native acceptance still requires a separately
+packaged candidate: cancel consent, approve access, compare fresh allowance
+with the same Desktop account, relaunch without a prompt, then disconnect and
+confirm allowance is removed. Test denied access and account/organization
+switches only with the account owner's approval. Never run the Keychain helper
+directly or capture its output. See
+[ADR 0016](adr/0016-claude-live-test-profile.md).
diff --git a/docs/KB.md b/docs/KB.md
index f546b43..af0802f 100644
--- a/docs/KB.md
+++ b/docs/KB.md
@@ -2,6 +2,33 @@
Last source review: 2026-10-10
+## Edition documentation
+
+- [ADR 0024](adr/0024-electron-primary-swift-retirement.md) makes Electron the
+ sole active feature-development target. Freeze the legacy Swift app for new
+ features; retain critical maintenance, compatibility and rollback until
+ native acceptance, deliberate cutover and retrievable archive checks pass.
+ Source retirement is pending. Electron-required Swift helpers and assets stay.
+- [Swift 0.2.41](swift/README.md) is the published default install route.
+ [Electron source 0.2.41](electron/README.md) is an unpublished candidate with
+ private build/installation receipts and incomplete native acceptance.
+- Both implementations remain in their current source trees. Shared app name,
+ bundle ID and default data/preferences prevent safe normal-profile coexistence.
+ Use only documented isolated QA profiles.
+- [Versions](VERSIONS.md) records the accepted one-app development direction
+ and staged retirement. It makes no layout, version, CI, identity or cutover
+ change. ADR 0010's acceptance and compatibility requirements remain in force.
+- Both updaters use GitHub's latest-release endpoint and the same versioned ZIP
+ name. Electron rejects prerelease tags. Separate beta/edition update channels
+ require code and asset-routing changes; see the
+ [update-channel prerequisites](VERSIONS.md#update-channel-prerequisites).
+- Candidate gaps include Settings account overview, seven-day sparkline, reset
+ timing display, update feedback and tray refresh. Smaller display omissions,
+ OpenCode Bun fallback discovery, pending AGY/OpenCode onboarding and unresolved
+ notch-dimension/grid-geometry feedback remain distinct. See the candidate guide.
+- Edition documentation was reconciled against revision
+ `605f1c4328eed6d4f793fcee0937a8d2e1659f6d` and its accompanying candidate changes.
+
## Repository state
- Default branch: `main`.
@@ -10,6 +37,76 @@ Last source review: 2026-10-10
## Product and source facts
+- Onboarding and Settings offer provider-specific source choices: Codex browser
+ sign-in or CLI, Claude Desktop or Claude Code CLI, and Grok CLI only. Choosing
+ an option does not change collection until its action is used. Use CLI waits
+ for MeterUsage-owned connection cleanup before selecting existing local
+ collection. Ordinary Disconnect still prevents automatic local fallback.
+ CLI data covers this Mac; Claude CLI allowance needs a companion usage file.
+ Done follows the source choices and remains optional. Demo and isolated live
+ test profiles cannot switch to local collection. See
+ [ADR 0023](adr/0023-onboarding-source-choice.md).
+
+- Candidate Settings uses compact onboarding and grouped rows. Demo account
+ restrictions appear before disabled connection actions. Provider column
+ headings replace repeated visual labels on wide windows; at 450 pixels or
+ narrower, each provider has two labelled control columns below its name.
+ Input accessible names and full label hit areas remain. Settings action
+ errors retain their alert role, message and dismissal in a sticky banner.
+ Advanced account rows wrap without changing their save/remove behavior.
+- Candidate Usage compacts Service status and shows local summary metrics in
+ three equal columns, with two columns at 360 pixels or narrower. Recorded
+ activity labels distinguish telemetry counters from account/local totals
+ without changing values or adding a time scope. Archived quota retains its
+ timestamp and failure reason in a warning well. Long labels and metadata
+ wrap; percentages and reset actions stay together. This is resilience
+ polish, not a reproduced overflow fix.
+- Static shared cards omit footer actions, reset buttons and the heatmap
+ selector. Credit details and the 26 weeks label remain. Export heatmaps omit
+ the unused hover readout space; live cards retain it. Full-height capture,
+ frozen account selection and the 250-pixel export width remain unchanged.
+ Dark secondary text uses `#b0b0b8`; light text and semantic colors retain their
+ existing rules. Usage and Settings reuse the notch's 100 ms background/border
+ feedback only for enabled fine-pointer hover with reduced motion off. Press
+ and keyboard focus remain immediate. Renderer fixture and build proof does
+ not establish rendered layout, sticky alerts, motion or native export proof.
+
+- Electron heatmaps show compact counts and readable dates for daily, weekly
+ or cumulative scope on hover and keyboard focus. Exact counts remain in
+ tooltips and accessible labels. Account, local-token and session-only
+ providers share the same heading, caption, cells and intensity legend.
+ Hover and keyboard focus replace the legend with the compact dated readout
+ without changing the card height. Unreported account days have subtle solid
+ outlines; reported zeroes have filled neutral cells. Each source retains its
+ own time range and units. Count-only providers report token totals unavailable.
+ Reset buttons use the compact control size. An open notch
+ interaction checks the native cursor to recover a missed pointer exit, then
+ closes details through the existing delay and temporary action guards.
+ The strip always shows usage rings and percentages, with no design selector
+ or dots option. Disable/hide clears expanded state before re-enabling.
+ Legacy pin data has no effect and pin controls are removed.
+ Notch sizes use integer pixel text and spacing instead of CSS zoom.
+ [ADR 0019](adr/0019-single-design-notch.md) records this candidate correction.
+ Source fixtures do not prove native pointer events, keyboard access in the
+ non-activating panel or rendered anchors. [Side notch](SIDE-NOTCH.md) records
+ the behavior and native checks.
+
+- Connected Codex menu cards expose **Use reset** for available, unexpired
+ credits. The existing validated IPC and native confirmation use the captured
+ connection profile. Confirmation rereads allowance before one helper consume;
+ disconnect, stopped connections and profile changes reject the action.
+ Coordinator source replacement, including Clear local scan cache, and stop
+ abort active resets through the existing consumer signal. Cancellation during
+ fresh eligibility prevents consume dispatch; a request already sent cannot be
+ undone. The late-result guard still prevents an obsolete reset from changing
+ pacing or refreshing its replacement. These cancellation checks use synthetic
+ fixtures, with native and live account proof pending.
+ Success collects fresh quota under the reset-pacing rules. This also applies
+ in `--codex-test-profile`; other collection and system-effect restrictions
+ remain. [ADR 0017](adr/0017-connected-codex-menu-reset.md) narrowly supersedes
+ ADR 0012's reset prohibition. Synthetic proof is separate from native display,
+ native confirmation and user-owned live redemption.
+
- Candidate onboarding lives in Settings and reuses its account connections.
Incomplete setup opens Settings on launch or app activation. Done persists
`onboardingCompleted` before returning to usage; a failed write leaves setup
@@ -44,12 +141,12 @@ Last source review: 2026-10-10
- Windows implementation, installers, CI, testing and releases are paused until a test machine is available. The former RN/Tauri Windows-first plan is superseded; shared portable source does not establish Windows support.
- Candidate checks use synthetic fixtures. Demo/candidate selection isolates preferences, history, caches and Electron state before composition. Live provider/key discovery, updater installation and login-item changes are disabled in demo. `pnpm package:mac` creates a separate candidate with publication disabled, using existing macOS tools.
- Candidate account visibility rechecks directory presence. Sharing freezes the displayed account key. Pacing-off hides forecasts; notch details reuse window-scoped burn metrics and have a bounded scroller. These contracts have synthetic/static-markup proof; native UI acceptance remains open.
-- The browser-demo notch uses viewport height when no native work-area height is supplied. The card and strip scroll independently; the strip retains all 44 pixels for its rings. Synthetic browser checks at 294x500 and 294x900 cover scrolling, two card heights and both CSS card sides. These checks do not verify native window placement or display changes.
+- The browser-demo notch uses viewport height when no native work-area height is supplied. The card and strip scroll independently; the 36-pixel strip keeps the existing rings. The 220-pixel detail card plus strip use 256 pixels total. Synthetic browser checks cover scrolling, two card heights and both CSS card sides, but do not verify native window placement or display changes.
- Candidate state publication returns before touching a destroyed tray. A synthetic Electron entrypoint regression emits display and theme events after tray destruction; native shutdown/display behavior remains an acceptance check.
- Candidate window cleanup uses the document ID captured while the window is alive. Closing a destroyed window cannot read its web contents or remove a replacement window for the same surface. The synthetic lifecycle regression exercises this path; native shutdown remains an acceptance check.
- Candidate Quit removes the display subscription before stopping collection and waiting for authentication cleanup. A late collection publication cannot recreate the notch or restart update checks. Lifecycle regressions cover flyout and tray Quit, repeated Quit during cleanup, and notch enabled or disabled. These tests do not establish native process termination.
- Candidate macOS GUI startup validates Electron's three direct singleton links as one coherent metadata set before requesting the native lock. Other profile symlinks remain rejected, and JSON entrypoints retain strict validation. A second instance exits before collection starts and asks the primary instance to reveal its panel. Startup failures emit a fixed error; activation during Quit cannot reopen windows. [ADR 0013](adr/0013-candidate-single-instance-startup.md) records the bounded exception and native acceptance requirements.
-- Candidate Appearance includes a saved Small, Medium or Large panel size, defaulting to Medium. Main-panel widths are 336, 420 and 483 points before display clamping. Main-panel and Settings content scale together; the Settings frame, side-notch, tray and shared-card geometry retain their existing sizes. Ctrl/Cmd plus or minus selects the next size; Ctrl/Cmd 0 restores Medium. These shortcuts use the same saved preference and main-panel resizing as Settings. Window loads normalize retained Chromium zoom to avoid applying a second scale. The main panel measures natural content height, shrinks when content gets shorter, and scrolls within available display space without visible indicators. Synthetic tests cover persistence, invalid values, write failure, rapid keys and display clamping; actual native sizing acceptance is separate.
+- Candidate Appearance includes a saved Small, Medium or Large panel size, defaulting to Medium. Main-panel widths are 336, 420 and 483 points before display clamping. Main-panel and Settings content use the same scale. Side-notch detail content uses explicit integer pixel sizes without CSS zoom. The notch card is 220 pixels wide beside the fixed 36-pixel strip; its height follows the size-specific content within the work area. The Settings frame, tray and 250-pixel shared-card capture remain unchanged. Ctrl/Cmd plus or minus selects the next size; Ctrl/Cmd 0 restores Medium. These shortcuts use the same saved preference and main-panel resizing as Settings. Window loads normalize retained Chromium zoom to avoid applying a second scale. The main panel measures natural content height, shrinks when content gets shorter, and scrolls within available display space without visible indicators. Synthetic tests cover persistence, invalid values, write failure, rapid keys and display clamping; actual native sizing acceptance is separate.
- Candidate Settings places Appearance directly below account connections, followed by General, Providers, Side notch, Usage details and Diagnostics. Related options use grouped rows. Panel size uses visible Small, Medium and Large choices; accent color uses six labelled radio swatches. Aligned provider controls distinguish collection from menu-bar and side-notch display; stopping primary collection preserves the saved display preference. The display control stays available when an additional account has an active source slot. Advanced local accounts remain available. Scrollbar indicators are hidden throughout candidate windows, with scrolling and keyboard access retained. These renderer contracts do not establish native UI parity.
- Codex login, allowance and own-profile logout select `app-server --listen stdio://`. Connection subprocesses keep interpreter search paths while excluding inherited credential variables. Connection tests restrict app-helper discovery to generated fixture roots. These are source contracts, not proof of a live helper or cloud account.
- The desktop-connection candidate has matching passing source proof for 165 synthetic TypeScript tests, TypeScript checking and production bundling on Linux. Its connection cleanup, account verification, cache/JSON readers, reset capability, alerts and locality labels have regression coverage. Disconnect blocks Claude's post-request credential reread; account-bound collection timeout removes unverified allowance; explicit Codex sign-in retry recovers retained cleanup before a new login. The unchanged migration baseline's headless demo comparison preserves stable schema-1 fields against the Swift demo oracle; that older native receipt does not verify the new connections. Source checks do not establish native parity or a published Electron release.
@@ -65,12 +162,29 @@ Last source review: 2026-10-10
- Archive reads validate optional plan, group, credit and reset metadata, retain Swift date/null compatibility and project only known fields. Reset preparation, confirmation and UI actions share the same reported-available and unexpired rule; absent status is unavailable.
- Primary Claude quota discovery prioritizes the app-support `claude-usage.json`, then `.claude/claudewatch-usage.json`, then `.claude/meterusage-usage.json`. Additional accounts read only their configured directory. Without `updated_at`, capture time comes from the selected file's modification time. Optional OpenRouter credits have a five-second deadline starting with the required quota request. Malformed optional Codex additional limits never discard valid base windows.
- Usage cards show current-day session/message counts and capture age without inventing token totals. Tray tooltips include non-operational status, reset countdown, stale capture age and last refresh. Claude, Antigravity, OpenCode Go, OpenRouter and Grok keep the Swift light/dark identity pairs, with semantic status overrides. These display contracts have synthetic/static-markup proof; native acceptance remains open.
-- Flyout service health uses reported statuses independently of usage visibility. Its status buttons ask main to open only the existing fixed public provider pages. Notch ambient ETA uses raw window pace, including dated archive readings, without requiring recent burn; disabling pacing hides it. Native rendering and page opening remain unverified.
+- Flyout service health uses reported statuses independently of usage visibility. Failed refreshes retain the last successful status as last known, with its age and refresh failure shown in the status panel, notch card and tray tooltip. Copied diagnostics marks the reading stale with its successful-check age and a fixed service-status error; successful retry clears the marker. Public-feed failures use service-status wording while keeping their failure code for retry behavior. Status buttons ask main to open only the existing fixed public provider pages. Notch ambient ETA uses raw window pace, including dated archive readings, without requiring recent burn; disabling pacing hides it. Native rendering and page opening remain unverified.
- Replacing an account source invalidates its live quota, activity, usage, plan and pending reset confirmation. Late reads and reset completion from that source cannot update its replacement. Dated slot archives and durable history remain available; reset requires a current source reading.
- Shared-card sizing uses the full measured height, separate from visible-window bounds. The candidate rejects capture heights above 8,192 pixels, or 4,096 CSS pixels at 2x, with `Card too tall to share`. Native image completeness remains unverified.
-- The `--codex-test-profile` candidate mode runs the existing Codex connection UI with isolated preferences, app data and Electron state. It excludes synthetic readings, other providers, local activity and status feeds; updates, login-item changes and notifications are blocked. [ADR 0012](adr/0012-codex-live-test-profile.md) amends the demo-only candidate restriction. Native and provider acceptance remain separate.
+- The `--codex-test-profile` candidate mode runs the existing Codex connection UI with isolated preferences, app data and Electron state. It excludes synthetic readings, other provider sources, local activity and Claude status. It reads the public OpenAI status feed without credentials; updates, login-item changes and notifications are blocked. [ADR 0012](adr/0012-codex-live-test-profile.md) amends the demo-only candidate restriction; [ADR 0015](adr/0015-codex-test-public-status.md) permits this public status request. Native and provider acceptance remain separate.
+- The separate `--claude-test-profile` candidate mode starts without consent and runs only the existing Claude Desktop allowance flow. It uses isolated JSON preferences and Electron state, blocks Codex access and cleanup, and retains the candidate system-effect guards. A Claude connection call without a consent callback denies access. Synthetic fixtures cover consent, relaunch, disconnect and profile isolation; native Keychain, Desktop format and live-provider acceptance remain open. See [ADR 0016](adr/0016-claude-live-test-profile.md).
+
+- Electron notch secondary labels use a scoped `#b0b0b8`; semantic provider and quota colors retain their existing rules. Telemetry adds a border and size-specific top padding. Enabled detail buttons and strip Settings use 100 ms color feedback for fine-pointer hover with reduced motion off. Press and keyboard focus stay immediate; reduced motion disables transitions. Card/window/ring geometry and the 450 ms close delay retain their source behavior. Rendered feedback, scrolling and native anchors still need target proof. See [SIDE-NOTCH.md](SIDE-NOTCH.md).
+
+- Electron Settings persists provider group order through validated IPC. Drag handles, arrow keys and move buttons use the same saved order for usage cards, service health, menu bar and side notch. Stored lists retain known providers once and append missing providers in default order. Additional accounts remain grouped, with independent Collect and Show settings. Failed saves and canceled drags retain the saved order. Synthetic proof does not establish rendered or native drag, focus, VoiceOver or notch-anchor behavior. See [ADR 0021](adr/0021-provider-group-order.md).
+
+- Connected Codex account history uses `account/usage/read` without `threadId`
+ through the existing managed ChatGPT helper and effective keyring guard. It
+ publishes only valid unique calendar labels, safe nonnegative token counts
+ and capture time in separate in-memory state. Main, notch and shared cards
+ show "Account token activity" under existing heatmap preferences. Null or
+ absent history and unreported days remain unavailable; reported zero stays
+ zero. Profile replacement, disconnect and failed reads clear the reading;
+ late results cannot update a replacement source. Account history is excluded
+ from local rollups, durable history and schema-1 quota JSON. Source and
+ synthetic proof do not establish live availability or universal cloud,
+ device or product coverage. See [ADR 0022](adr/0022-codex-account-token-history.md).
## Verification gaps
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
index 6a4a5e2..61af276 100644
--- a/docs/PRIVACY.md
+++ b/docs/PRIVACY.md
@@ -54,6 +54,14 @@ The candidate offers connection buttons in setup and Settings. No terminal,
folder picker or credential copying is part of the primary connection flow.
Existing local account directories remain an advanced option.
+Setup also offers the existing CLI sources through an explicit **Use CLI**
+action. Selecting an option alone performs no account access. Applying CLI
+selection disconnects only MeterUsage-owned account state and waits for its
+logout cleanup before enabling local collection. Cleanup failure prevents the
+switch. Ordinary Disconnect never silently falls back to local data. The
+selector adds no credential store, provider endpoint or authentication flow;
+CLI sources retain the input and coverage limits above.
+
Codex browser sign-in uses the official app-server helper discovered in an
existing Codex app, with existing command installations as a compatibility path.
MeterUsage creates a separate generated profile under its own support directory
@@ -61,17 +69,32 @@ and checks effective `cli_auth_credentials_store=keyring` before login or
polling. Any other mode is refused. It never opens the user's Codex `auth.json`.
Login opens only an approved HTTPS OpenAI host and uses the helper's local
callback. Requests are initialization, effective config read, account login,
-rate-limit read and own-profile logout. No model session or prompt is started.
+rate-limit read, account daily-token read, explicitly confirmed reset redemption and own-profile logout. No model session or prompt is started.
The provider helper handles tokens in OS storage; no plaintext fallback is
permitted. Cancelling login attempts own-profile logout and reports cleanup
failure. Disconnect does not sign out the user's Codex desktop session.
Unfinished sign-in profiles are retained only for cleanup and cannot enable
collection. Cancellation and disconnect disable collection before logout.
GUI startup and explicit sign-in retry recover owned-profile cleanup before
-starting another login. Normal quit waits up to five seconds. Headless reports
+starting another login. Normal quit gives authentication helpers up to five
+seconds, then cancels them. Pending CLI-selection preference saves and rollback
+must settle before the app exits. Headless reports
do not sign in or perform cleanup logout.
-Claude requires an explicit explanation and consent before access. The bundled
+Connected Codex daily history uses `account/usage/read` without `threadId`
+through that same managed ChatGPT profile and keyring guard. The main process
+projects only valid unique date labels, safe nonnegative integer token counts
+and a capture timestamp before publication. Summary fields, unknown fields and
+provider error text are discarded. No raw response, account identifier or
+credential reaches the renderer or a new durable cache. Account token history
+stays in memory, clears after failed reads, replacement or disconnect, and never
+enters local rollups or durable daily history. Captured-profile and cancellation
+checks reject late results. Missing history and unreported dates remain
+unavailable. The provider contract does not establish complete product, device
+or cloud coverage. See [ADR 0022](adr/0022-codex-account-token-history.md).
+
+Claude requires an explicit explanation and consent before access. A connection
+call without a consent callback denies access. The bundled
native helper reads only the `Claude Safe Storage` / `Claude Key` item. Background
reads disable interaction. Its stdout goes only to the main-process bounded
transport; do not run the helper interactively or record its output. The main
@@ -191,9 +214,21 @@ untouched.
The separate `--codex-test-profile` mode uses the real UI and opt-in Codex
connection with isolated JSON preferences, app data and Electron state. It
retains the OS home for Keychain and helper discovery. It excludes all other
-provider sources, local activity, status feeds and reset redemption, and blocks
-updates, login-item changes and notifications. Its Codex credential boundary
-is the same keyring-only boundary described above.
+provider sources, local activity and Claude status. Its only status request is
+an unauthenticated GET to the public OpenAI status feed above. It blocks updates,
+login-item changes and notifications. Explicitly confirmed Codex reset redemption
+uses the same captured keyring-only connection profile and a fresh eligibility
+check. [ADR 0017](adr/0017-connected-codex-menu-reset.md) supersedes only ADR 0012's
+reset prohibition. Its Codex credential boundary is the same keyring-only boundary
+described above.
+
+`--claude-test-profile` uses a separate profile marker and the existing Claude
+consent and allowance flow. It starts without consent and collects only Claude
+account allowance. It cannot use a Codex or demo profile. JSON preferences and
+Electron state remain isolated; OS home is retained for the read-only Desktop
+reader. Codex access and cleanup, other providers, local activity, status feeds
+and reset redemption are blocked. Candidate update, login-item and notification
+guards apply. It adds no credential source, refresh flow or provider request.
## Installation and enforcement
diff --git a/docs/SIDE-NOTCH.md b/docs/SIDE-NOTCH.md
index 2664e3d..70884a8 100644
--- a/docs/SIDE-NOTCH.md
+++ b/docs/SIDE-NOTCH.md
@@ -1,10 +1,10 @@
# Side notch panel
The side notch is the floating strip of provider rings on the screen edge. This
-document describes what it does and the geometry it must hold. The decision
+document describes the retained Swift behavior and the Electron candidate. The decision
record is [`docs/adr/0004`](adr/0004-side-notch-anchor-invariant.md).
-## States
+## Retained Swift states
| State | Trigger | What shows |
| --- | --- | --- |
@@ -85,14 +85,75 @@ floats at the status-bar level, and accepts mouse events only on itself. Because
the window never takes key focus, each ring is an accessibility element with an
explicit "Show details" action rather than a focusable control.
+## Electron candidate interaction
+
+The candidate panel is non-focusable and uses `showInactive`, so it cannot rely
+on a window blur event to dismiss a card. Pointer exit hides the detail card
+through the existing 450 ms fold delay and leaves the usage strip.
+During an open hover or selection interaction, main checks the native cursor
+against the panel bounds every 100 ms. A missed renderer exit therefore enters
+the same fold path on the next cursor check. The check stops on exit, hide, close, drag or
+quit. It does not install a global input hook.
+
+The Electron strip always shows usage rings and used percentages while details
+are closed or open. There is one design, with no dots markup or design selector.
+Small, Medium and Large change size only. Hover opens details, ring hover
+switches providers, and pointer exit closes details after 450 ms.
+Disable/hide clears expanded state, so re-enabling with the pointer outside
+starts with the 36 px strip and the next hover opens details normally.
+
+The candidate has no Pin detail card or Keep open control. Legacy saved
+`sideNotchPanelPinned=true` remains stored but cannot open or retain details.
+The share menu, reset confirmation and drag retain their temporary guards.
+Drag completion reads the current cursor rather than restoring stale hover
+state. Dismissal does not change a preference. [ADR 0019](adr/0019-single-design-notch.md)
+records this candidate correction; the retained Swift implementation is unchanged.
+
+Heatmap cells show a dated readout immediately on hover or keyboard focus.
+Daily cells name their date, weekly cells name the week starting Sunday, and
+cumulative cells name the date through which the displayed history is summed.
+Counts use the existing history scope and full numeric formatting. OpenRouter
+ledger dates retain their UTC calendar day. Count-only
+providers show session counts and report token totals unavailable. The readout
+has reserved space inside the card to avoid overlay clipping or hover resizing.
+Keyboard focus has a visible outline. The non-activating native notch's keyboard
+access still needs native proof; source fixtures cover focus labels and styles.
+Codex Use reset uses the same base 11 px font and padding as compact footer
+buttons. Settings panel size uses explicit integer pixel sizes for notch
+text, buttons and spacing, without CSS zoom. Small uses an 11 px body and
+10 px secondary text; Medium uses 13 px and 10 px; Large uses 15 px and
+12 px. The visible card stays 220 pixels wide and the strip stays 36 pixels
+wide. Changing size preserves their horizontal anchor. Content scrolls within
+the display work area. Flyout and Settings retain their existing scaling.
+Shared cards keep their original scale. Eligibility, account labels and Cancel-default confirmation remain.
+
+Secondary labels in the Electron notch card use `#b0b0b8`, without changing
+provider status or quota colors. Telemetry starts with a border and 6/8/10 px
+of top padding for Small/Medium/Large. Existing metric margins remain.
+Enabled detail buttons transition only background and border colors for
+100 ms on fine-pointer hover when reduced motion is off. The strip Settings
+button transitions only its text color. Press and keyboard focus stay
+immediate, and reduced motion disables transitions. Card, window, ring and
+provider-switch states do not animate. Browser and native checks must verify
+these rendered states and scrolling after the added telemetry spacing.
+
+Live cards keep the reserved heatmap readout and show Daily, Weekly and
+Cumulative choices with their existing values. Recorded activity headings and
+Recorded sessions/messages labels distinguish telemetry from account/local
+summary counts. Static shared cards omit footer actions, reset buttons and the
+heatmap selector while retaining credit details and the 26 weeks label. They
+also omit the unused hover readout space. Export width, full-height sizing and
+frozen selected-account behavior retain their existing implementation. Native
+export completeness remains a separate check.
+
## Verifying a change
The Electron candidate bounds the card and strip independently. The native
window supplies the display work-area height; the browser demo uses the viewport
height when that value is absent. Tall content scrolls inside each column. The
candidate hides scrollbar indicators throughout its windows while retaining
-scrolling. The strip retains the full 44-pixel ring width. The shared-card
-capture keeps its full height and does not use the notch's scrolling bounds.
+scrolling. The strip is 36 pixels wide around the existing ring. The shared-card
+capture keeps its 250-pixel width, full height and does not use the notch's scrolling bounds.
Automated tests cover geometry and in-process drag events, but cannot see the
rendered result. For any change to side notch layout or motion, capture the
diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md
new file mode 100644
index 0000000..21e77f0
--- /dev/null
+++ b/docs/VERSIONS.md
@@ -0,0 +1,92 @@
+# MeterUsage versions and repository strategy
+
+Electron is the sole active target for new features. The published Swift app is
+frozen as legacy support and rollback during migration. Both currently say
+`0.2.41`. That shared number does not establish
+build identity, publication, installation or native acceptance. The candidate
+is not a released v2.1. This comparison describes source at revision
+`605f1c4328eed6d4f793fcee0937a8d2e1659f6d` with the accompanying candidate changes.
+
+## Current layout and status
+
+| Area | Retained Swift | Electron candidate |
+| --- | --- | --- |
+| Development | Critical fixes and transition compatibility only | All new features |
+| Source and manifest | `Sources/`, `Tests/`, [Package.swift](../Package.swift) | `src/`, `tests/`, [package.json](../package.json) |
+| Version | 0.2.41 in [Info.plist](../Resources/Info.plist) | 0.2.41 in package metadata |
+| Build | `swift build`; explicit `./Scripts/make-app.sh --build-from-source` | `pnpm build`; macOS `pnpm package:mac` |
+| Default user download | Pinned published Swift ZIP via `./Scripts/make-app.sh` | No published candidate download route |
+| Build/install evidence | Separate from source and release metadata | Private build and installation receipts exist; complete native acceptance remains open |
+| Guide | [Swift](swift/README.md) | [Electron](electron/README.md) |
+
+A source check does not prove a signed bundle. A built bundle does not prove
+installation or launch. A tag does not create a GitHub Release, and an uploaded
+asset does not prove native runtime acceptance. Current public availability and
+installed state need their own observation; this documentation does not refresh
+them. The pinned Swift download contract remains unchanged.
+
+## Repository coexistence and app isolation
+
+Both implementations remain in one repository during migration. The intended
+end state is one Electron app. Two normally installed apps cannot safely share
+the present identity and data locations.
+[ADR 0010](adr/0010-macos-typescript-electron.md) retains `MeterUsage.app`,
+executable `meterusage`, bundle ID `dev.meterusage.app`, preference keys and
+history/archive formats. There is no automatic cutover or new durable-data
+migration. Swift remains the behavior reference and rollback path.
+
+Only documented isolated demo/test profiles are supported for candidate QA.
+They isolate preferences, history, caches and Electron state; live test modes
+retain only their explicitly permitted provider access. See
+[Electron isolated QA](electron/README.md#isolated-qa) and [privacy](PRIVACY.md).
+A distinct app ID and data root would require a later decision before true
+side-by-side installation. A single-app replacement can retain the existing
+identity after its compatibility and update/rollback checks pass. This decision
+does not rename apps or perform the replacement.
+
+
+## Accepted development and retirement strategy
+
+The owner selected one active implementation in
+[ADR 0024](adr/0024-electron-primary-swift-retirement.md). This supersedes the
+earlier proposal to maintain two editions as a long-term direction. ADR 0010's
+data compatibility, native acceptance and no-automatic-cutover rules still apply.
+
+1. Freeze new Swift features now. Route feature work to Electron; keep Swift for
+ critical maintenance, transition compatibility and rollback.
+2. Close agreed setup/parity gaps and complete source/native acceptance on the
+ exact Electron candidate, including preserved saved data and measured native
+ performance. Keep intentional UI removals intact.
+3. Verify deliberate update and rollback before public cutover. Preserve the
+ current Swift download and installer contract until the replacement is ready.
+ Release publication remains subject to the existing release holds.
+4. Preserve the retrievable legacy source revision and matching release artifact,
+ then remove old-app-only source, tests, build steps and obsolete CI references
+ from the active branch in a reviewed change. No second repository is needed.
+
+Keep `Scripts/electron/desktop-keychain.swift`,
+`Scripts/electron/export-symbols.swift`, required `Resources/` assets and shared
+fixtures/contracts used by Electron. Trace dependencies before removing legacy
+files. Retiring the Swift application does not mean deleting all Swift files.
+
+Only the development guidance is applied here. Source removal, workflow changes,
+version assignment and cutover remain pending.
+
+### Update-channel prerequisites
+
+Both the [Swift updater](../Sources/MeterUsage/Services/UpdateChecker.swift) and
+[Electron updater](../src/main/updater.ts) request GitHub's `/releases/latest`
+endpoint and expect an asset named `MeterUsage-.zip`. Electron's release
+parser accepts only numeric stable versions such as `0.2.41`; it rejects
+prerelease tags. Neither updater currently selects an edition-specific channel.
+
+An optional Electron beta channel needs explicit routing, prerelease parsing
+and matching asset validation. It does not require a permanent second edition.
+Preserve the existing Swift asset contract for installed clients. Renaming ZIPs
+or publishing a prerelease alone does not implement this routing. Verify that
+stable clients receive only the intended tested replacement before changing
+release assets. The current updater is unchanged.
+
+No competitor feature or parity fix is implemented by this documentation change.
+See the [candidate gaps](electron/README.md#known-parity-gaps),
+[contribution routing](../CONTRIBUTING.md) and [accepted decision index](adr/README.md).
diff --git a/docs/adr/0015-codex-test-public-status.md b/docs/adr/0015-codex-test-public-status.md
new file mode 100644
index 0000000..ddb9efb
--- /dev/null
+++ b/docs/adr/0015-codex-test-public-status.md
@@ -0,0 +1,28 @@
+# ADR 0015: Read public Codex status in the isolated test profile
+
+- Status: Accepted implementation direction; native acceptance pending
+- Date: 2026-10-10
+- Amends: ADR 0012, public status collection only
+
+## Decision
+
+The isolated Codex test profile reads the existing public OpenAI component
+status feed without account credentials. Attach that reader to its existing
+Codex source. Keep normal collection limited to the existing Codex and Claude
+status readers.
+
+The Service status panel distinguishes an initial check, an unavailable feed
+and a failed refresh with dated last-known status. A failed request does not
+become an operational result. Public service status does not prove account
+allowance, sign-in health or provider-dashboard accuracy.
+
+Preserve ADR 0012 as the original decision. This amendment does not enable
+other provider sources, Claude status in the Codex test profile, local scans,
+reset redemption, model requests, updates, login-item changes or notifications.
+The installed app and saved provider credentials remain outside this change.
+
+## Acceptance
+
+Synthetic tests cover source selection, unauthenticated requests, refresh and
+failure display. Public HTTP responses establish feed availability only.
+Packaged macOS display and refresh behavior require separate native proof.
diff --git a/docs/adr/0016-claude-live-test-profile.md b/docs/adr/0016-claude-live-test-profile.md
new file mode 100644
index 0000000..94bbc43
--- /dev/null
+++ b/docs/adr/0016-claude-live-test-profile.md
@@ -0,0 +1,37 @@
+# ADR 0016: Isolate Claude allowance validation in the existing UI
+
+- Status: Candidate implementation; independent review and native acceptance pending
+- Date: 2026-10-10
+
+## Decision
+
+Add `--claude-test-profile` for a separate Claude candidate. This extends
+ADR 0012's isolated validation pattern to the Claude flow accepted in ADR 0011.
+It does not enable Claude in a Codex profile or change its saved consent.
+
+Reuse the absolute-directory, protected-path, symlink and Electron singleton
+checks. Use a distinct marker. Reject another provider test flag, demo flags
+and candidate-profile selection. Store preferences, archives and Electron
+state in the test directory. Retain OS home for the existing Desktop reader.
+
+Start without consent. Reuse Settings, its explicit consent dialog, the fixed
+Keychain helper and read-only Desktop cache/cookie reader. A connection call
+without a consent callback must deny Claude access. Compose only account-bound
+Claude allowance, with no local or companion fallback. Block Codex access and
+cleanup, other provider collection, status feeds and reset redemption.
+Candidate update, login-item and notification guards remain in force.
+
+Keep the approved account/organization fingerprint across normal quit so
+relaunch can test noninteractive collection. Disconnect disables access and
+clears allowance without modifying Claude Desktop. No new credential source,
+refresh tokens, client impersonation or model request is permitted.
+
+## Acceptance
+
+Synthetic fixtures cover isolation, consent, connection, relaunch and
+disconnect. Packaged native acceptance must verify the helper, user-owned
+permission flow, current Desktop format, fresh allowance, relaunch without
+prompts and removal of allowance on disconnect. Account switches and denied
+access need their own approved checks. Synthetic proof cannot establish live
+provider compatibility or cross-device coverage. Installed-app replacement,
+release and Windows holds remain unchanged.
diff --git a/docs/adr/0017-connected-codex-menu-reset.md b/docs/adr/0017-connected-codex-menu-reset.md
new file mode 100644
index 0000000..63358ad
--- /dev/null
+++ b/docs/adr/0017-connected-codex-menu-reset.md
@@ -0,0 +1,41 @@
+# ADR 0017: Allow confirmed resets for the connected Codex menu
+
+- Status: Accepted implementation direction; native and live redemption proof pending
+- Date: 2026-10-10
+
+## Decision
+
+Show **Use reset** beside available, unexpired Codex reset credits in the menu.
+Reuse the existing renderer action, validated IPC, coordinator intent and native
+confirmation. Confirmation defaults to Cancel and identifies the credit and
+account. No automatic redemption or model request is added.
+
+Bind the source and consume operation to the generated connection profile that
+supplied allowance. After confirmation, reread allowance and require the same
+available, unexpired credit. Recheck the enabled profile before helper dispatch
+and after completion. Reject overlap; abort pending reset work on disconnect or
+quit. Profile replacement invalidates the coordinator's intent and readings.
+A request already sent to the provider may consume a credit despite cancellation;
+do not retry automatically or claim it was undone.
+
+Use the existing official helper reset RPC with keyring-only configuration and
+real OS home. The helper continues to own authentication and secrets. Require an
+explicit reset outcome before resetting pacing and collecting fresh quota.
+
+This narrowly supersedes ADR 0012's prohibition on reset redemption in
+`--codex-test-profile`. That mode retains isolated preferences, app data and
+Electron state. Public Codex status remains permitted under
+[ADR 0015](0015-codex-test-public-status.md). Other providers, local activity,
+other status feeds, updates, login-item changes, notifications and model requests
+remain excluded.
+
+## Acceptance
+
+Synthetic fixtures cover connected eligibility, the menu request, cancellation,
+one confirmed consume, duplicate actions, stale/spent/expired credits,
+disconnect/profile changes, failure outcomes and refreshed quota. Static markup
+checks cover the accessible control at Small, Medium and Large panel scales.
+These checks do not prove native layout, native confirmation or provider RPC
+support in an installed helper. Native integration validation is separate. Live
+redemption requires the user's own click and confirmation; implementation tests
+must not consume real credits.
diff --git a/docs/adr/0018-hover-only-notch-details.md b/docs/adr/0018-hover-only-notch-details.md
new file mode 100644
index 0000000..22a1e38
--- /dev/null
+++ b/docs/adr/0018-hover-only-notch-details.md
@@ -0,0 +1,35 @@
+# ADR 0018: Hover-only Electron notch details
+
+- Status: Superseded by [ADR 0019](0019-single-design-notch.md). The text below preserves the prior candidate decision.
+- Date: 2026-10-11
+
+## Decision
+
+The Electron candidate opens provider details on hover and closes them after
+450 ms outside the panel. Moving between rings selects that provider. The
+native cursor check remains the fallback for a missed renderer exit.
+
+Settings offers Usage and Compact dots for the resting strip. Usage is the
+default for new and existing profiles without a saved choice. It keeps rings
+and percentages visible while details are closed. Dots reuses the folded pill.
+Invalid saved choices fall back to Usage.
+
+Remove Pin detail card and Keep open controls. Retain legacy stored pin data
+without using it to open or retain details. Temporary share, reset confirmation
+and drag guards remain. No account or sign-in state changes.
+
+Use explicit integer pixel sizes for Small, Medium and Large notch content.
+Small has an 11 px body and 10 px secondary text. Do not scale notch content
+with CSS zoom. Retain flyout and Settings scaling and original share rendering.
+
+## Scope and evidence
+
+This decision supersedes the Electron candidate pin behavior and fractional
+notch sizing described in SIDE-NOTCH.md. It leaves the retained Swift behavior
+and ADR 0004's anchor invariant unchanged: a 44 px strip, a 250 px card, and a
+fixed top and strip edge when details change.
+
+Synthetic checks must cover old pin=true data, preference validation, both
+resting modes, two-provider selection, delayed closure and temporary guards.
+Browser captures and native two-provider anchors, cursor events, reset/share
+menus and drag recovery remain separate acceptance gates.
diff --git a/docs/adr/0019-single-design-notch.md b/docs/adr/0019-single-design-notch.md
new file mode 100644
index 0000000..d419d58
--- /dev/null
+++ b/docs/adr/0019-single-design-notch.md
@@ -0,0 +1,30 @@
+# ADR 0019: One Electron side-notch design
+
+- Status: Accepted implementation direction; independent review and native acceptance pending
+- Date: 2026-10-11
+- Supersedes: [ADR 0018](0018-hover-only-notch-details.md)
+
+## Decision
+
+The Electron side notch has one usage-ring and percentage strip. Keep it
+visible whether details are open or closed. Remove the candidate dots markup,
+CSS, Resting strip selector, preference schema, validation and IPC projection.
+Do not migrate or reset real preferences.
+
+Keep hover-only details and the 450 ms leave-close delay. Legacy pin data
+cannot retain details. Small, Medium and Large remain size choices with
+explicit integer pixel text and controls. Preserve logos, the 44 px strip,
+the 250 px detail card and ADR 0004's fixed top and strip edges.
+
+Clear expanded state in the shared disable/hide path. Re-enabling with the
+pointer outside must show only the strip; subsequent hover must still open
+and close details. Share, reset confirmation and drag guards remain.
+
+## Evidence and limits
+
+Synthetic checks cover the single strip for two providers, removed preference
+and IPC rejection, unchanged stored fixture bytes, disable/hide/re-enable and
+subsequent hover recovery. TypeScript, renderer build and main/preload packing
+verify source inputs. Independent review, browser captures and native cursor,
+anchor, package and install proof remain separate Controller-owned gates.
+The retained Swift implementation is unchanged.
diff --git a/docs/adr/0021-provider-group-order.md b/docs/adr/0021-provider-group-order.md
new file mode 100644
index 0000000..51c8c63
--- /dev/null
+++ b/docs/adr/0021-provider-group-order.md
@@ -0,0 +1,25 @@
+# ADR 0021: Save provider group order
+
+- Status: Accepted implementation direction; rendered and native acceptance pending
+- Date: 2026-10-11
+
+Electron Settings saves a `providerOrder` list of known provider names. Installed
+profiles use JSON encoded as defaults data, as managed accounts do. Isolated
+candidate profiles use their existing preferences JSON file.
+
+Stored input is normalized: keep the first occurrence of each known provider,
+ignore invalid entries and append missing providers in the default sequence.
+IPC writes require a complete, unique list of known providers and come only
+from Settings. External drag data is never read.
+
+Usage cards, service health, menu bar and side notch follow the saved order.
+Each primary account precedes its additional accounts, which keep their existing
+relative order. Collect and Show settings remain independent. OpenRouter stays
+excluded from the menu bar. Ordering does not change polling or account identity.
+
+Settings saves once on drop or a keyboard move. A canceled drag or failed save
+keeps the last saved order and restores handle focus. Arrow keys and named move
+buttons provide keyboard access. A line marks the proposed drop position.
+
+The Swift app retains its existing fixed order. Synthetic tests do not prove
+native drag behavior, VoiceOver, focus, or the fixed notch anchors.
diff --git a/docs/adr/0022-codex-account-token-history.md b/docs/adr/0022-codex-account-token-history.md
new file mode 100644
index 0000000..11d25c1
--- /dev/null
+++ b/docs/adr/0022-codex-account-token-history.md
@@ -0,0 +1,37 @@
+# ADR 0022: Read provider account token history separately
+
+- Status: Accepted implementation direction; independent review, native and live acceptance pending
+- Date: 2026-10-12
+
+The Electron candidate reads daily token history through the existing managed
+ChatGPT helper with `account/usage/read`. It omits `threadId`, which selects
+account-wide activity instead of a thread estimate. Initialization, effective
+keyring storage checks and the generated connection profile remain required.
+This extends ADR 0011's read boundary without changing login, quota, reset or
+own-profile logout behavior.
+
+Use a separate in-memory account activity type containing only provider date
+labels, safe nonnegative integer token counts and a capture timestamp. Discard
+summary and unknown fields before publication. Reject invalid calendar dates,
+duplicate dates, unsafe counts and malformed buckets. Preserve reported zero;
+null, absent history and unreported dates remain unavailable.
+
+The card says "Account token activity" and "Provider-reported account scope".
+It shows a compact daily grid for 26 weeks ending at the latest reported date.
+Hover and keyboard focus expose exact counts. Existing global and Codex heatmap
+preferences control main, notch and shared cards. Keep provider dates unchanged
+as calendar labels without assigning a timezone. Do not infer token categories,
+sessions, cost or daily counts from summary statistics or allowance.
+
+Account history never enters local Activity, Usage, burn attribution, durable
+daily history or the schema-1 quota report. Replacement, disconnect and failed
+reads clear the account reading; captured profile checks reject late results.
+Reuse the existing collector deadline and bounded backoff. Add no durable
+account cache, credential source, raw-response log or additional browser login.
+Advanced local profile collection remains unchanged.
+
+The public app-server contract describes ChatGPT account token activity. It
+does not establish universal client, cloud, device or product coverage, token
+category definitions, timezone, retention or polling limits. Live availability
+and coverage need separate provider proof. Native notch anchor and shared-image
+acceptance also remain open. This decision does not authorize a release.
diff --git a/docs/adr/0023-onboarding-source-choice.md b/docs/adr/0023-onboarding-source-choice.md
new file mode 100644
index 0000000..44eae2c
--- /dev/null
+++ b/docs/adr/0023-onboarding-source-choice.md
@@ -0,0 +1,31 @@
+# ADR 0023: Choose usage sources explicitly in onboarding
+
+- Status: Accepted implementation direction; native interaction acceptance pending
+- Date: 2026-10-13
+
+Users need to choose between an existing CLI setup and the account connections
+supported by each provider. Keep this choice in Settings alongside onboarding,
+as required by ADR 0014.
+
+Offer Codex browser sign-in or CLI, Claude Desktop or Claude Code CLI, and Grok
+CLI only. Explain their data coverage. Do not offer unsupported consumer sign-in
+or imply that local activity covers the website or another device. Changing the
+selector alone has no account or collection effect. An explicit action applies
+the choice, and Done follows the source choices.
+
+Add one validated `connectionLocal` IPC action. This supersedes only the
+no-new-IPC restriction in ADR 0014. Reuse existing connection preferences and
+readers. Account-to-CLI switching ends only MeterUsage-owned connection state
+and waits for successful cleanup before selecting local collection. Failure
+stays closed. Ordinary Disconnect retains the `off` state and never silently
+switches to local data. Existing accounts keep their source on upgrade.
+
+Publish only the source name and whether CLI selection is available, without
+account identifiers or profile paths. Reject local selection in demo and isolated
+live test profiles. Preserve consent, cancellation, safe error handling, stale
+result rejection and the save-before-close onboarding contract. No new credential
+source, authentication protocol, provider request or dependency is introduced.
+
+Verify supported options, explicit application, cleanup and preference-save
+failures, concurrent actions, relaunch persistence and Done. Source and synthetic
+browser checks do not prove live provider authentication or native interaction.
diff --git a/docs/adr/0024-electron-primary-swift-retirement.md b/docs/adr/0024-electron-primary-swift-retirement.md
new file mode 100644
index 0000000..aa47c54
--- /dev/null
+++ b/docs/adr/0024-electron-primary-swift-retirement.md
@@ -0,0 +1,72 @@
+# ADR 0024: Develop Electron and retire the legacy Swift app in stages
+
+- Status: Accepted development direction; source retirement and public cutover pending
+- Date: 2026-10-14
+- Amends: [ADR 0010](0010-macos-typescript-electron.md), for ongoing development and eventual Swift retirement only
+
+## Decision
+
+Electron is the sole active target for new MeterUsage features. Freeze the
+legacy Swift app as a behavior reference and rollback option. Limit Swift app
+changes to critical fixes and compatibility work needed for the transition.
+Do not maintain two feature roadmaps or require two permanent app editions.
+
+Keep both source trees during migration. Remove the legacy app implementation
+from the active branch only after the acceptance and cutover conditions below
+pass. Preserve a retrievable source revision and its matching release artifact
+before removal. Git history and the retained release provide the archive;
+an additional repository is unnecessary.
+
+The retained Swift baseline is
+`4b913db3a3a8eb2602296489872af2ecaa5d0530`. The published Swift 0.2.41 ZIP
+remains the default download until a verified Electron release replaces it.
+The Electron candidate still has source version 0.2.41 and incomplete native
+acceptance. This decision does not publish or install a release.
+
+## Retirement conditions
+
+1. Finish the agreed provider setup and parity work. Verify the affected paths
+ against the [candidate gaps](../electron/README.md#known-parity-gaps).
+ Intentional changes, including removal of pinned details and dot designs,
+ remain intentional.
+2. Complete ADR 0010's source and native acceptance on the exact candidate.
+ Preserve saved accounts, preferences, history and schema-1 output. Exercise
+ launch, relaunch, offline behavior, native controls, sharing and updates;
+ capture different-height notch cards with fixed anchors. Record package
+ size, idle memory and startup/scan performance without inventing pass limits.
+3. Verify a deliberate update from the published Swift app to the intended
+ Electron artifact, and verify rollback with preserved data. Keep the stable
+ download and existing client asset contract until the replacement is ready.
+ Existing publication and cutover holds remain in force.
+4. Confirm that the exact legacy source and release remain retrievable. Then
+ remove old-app-only `Sources/`, `Tests/`, `Package.swift`, build steps and
+ obsolete CI references in an independently reviewed change. Trace consumers
+ before removal and retain any shared fixture, asset or contract still used.
+ Verify Electron build, packaging and the affected user paths afterward.
+
+## Swift that stays
+
+Retiring the Swift app does not remove every Swift file. Electron's current
+build still uses [desktop-keychain.swift](../../Scripts/electron/desktop-keychain.swift)
+and [export-symbols.swift](../../Scripts/electron/export-symbols.swift).
+Keep those helpers and their required assets in `Resources/` unless a separate,
+verified replacement removes the dependency. Do not delete by file extension.
+
+## Identity and distribution
+
+The intended end state is one app. ADR 0010's app identity, data compatibility
+and no-automatic-cutover requirements remain intact. Normal-profile parallel
+installs remain unsupported. A separate identity and data root are only needed
+if simultaneous installs become a separately chosen requirement.
+
+Beta distribution is optional and still needs explicit updater routing. Both
+current updaters request `/releases/latest` and expect
+`MeterUsage-.zip`; Electron rejects prerelease tags. Renaming assets
+alone does not create a channel. See [version strategy](../VERSIONS.md).
+
+## Consequences
+
+New work has one implementation target. Swift remains available while migration
+proof is incomplete, but gains no parallel feature roadmap. Documentation
+labels development status separately from the published download. This decision
+does not itself change source files, workflows, versions, data or installed apps.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index a868647..358447c 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -2,10 +2,17 @@
This directory records decisions that affect repository work. Read an ADR before changing a documented decision. Add a new numbered ADR for a new decision; do not rewrite an accepted decision to fit a later change.
+The [version comparison](../VERSIONS.md) separates active Electron development
+from frozen legacy Swift support. [ADR 0024](0024-electron-primary-swift-retirement.md)
+amends the long-term development direction. ADR 0010's shared identity,
+compatibility and acceptance requirements remain in force. The
+[Swift](../swift/README.md) and [Electron](../electron/README.md) guides route setup.
+
## Index
| ADR | Decision | Status |
| --- | --- | --- |
+| [0024](0024-electron-primary-swift-retirement.md) | Develop new features in Electron; freeze Swift as legacy rollback and retire its app source after verified acceptance and cutover. Keep required Swift helpers. | Accepted development direction; retirement and public cutover pending |
| [0001](0001-repository-owned-knowledge.md) | Keep public-safe project knowledge and decisions beside the project and exclude private operational content. | Accepted |
| [0002](0002-provider-marks-stay.md) | Keep existing provider logos/marks (`ProviderMark` + `Resources/*-logo.png`); do not redesign them in feature or cross-platform work. | Accepted |
| [0003](0003-burn-attribution-scope.md) | Scope burn attribution to the last 7 days across all token-bearing providers, with per-provider aggregates that never count as long chats. | Accepted |
@@ -16,6 +23,14 @@ This directory records decisions that affect repository work. Read an ADR before
| [0009](0009-prebuilt-bundle-default.md) | Download and verify the prebuilt app by default; compile only with `--build-from-source`. | Accepted |
| [0010](0010-macos-typescript-electron.md) | Port macOS and the schema-1 JSON CLI to TypeScript/Electron/React; preserve data contracts and pause Windows until a test machine is available. | Accepted architecture; native acceptance pending |
| [0011](0011-desktop-account-connections.md) | Add opt-in Codex browser sign-in and Claude Desktop account allowance; separate account allowance from local activity. | Accepted implementation direction; native and provider acceptance pending |
-| [0012](0012-codex-live-test-profile.md) | Run isolated live Codex validation in the existing UI while blocking other providers and candidate system effects. | Accepted implementation direction; native UI acceptance pending |
+| [0012](0012-codex-live-test-profile.md) | Run isolated live Codex validation in the existing UI while blocking other providers and candidate system effects. | Accepted implementation direction; reset prohibition superseded by [0017](0017-connected-codex-menu-reset.md); native UI acceptance pending |
| [0013](0013-candidate-single-instance-startup.md) | Validate candidate Electron singleton metadata before locking; preserve other symlink rejection and safe activation during startup and Quit. | Accepted implementation direction; native acceptance pending |
| [0014](0014-settings-onboarding.md) | Keep first-run setup and account connections in Settings, with optional sign-in and saved completion before opening usage. | Accepted implementation direction; native acceptance pending |
+| [0016](0016-claude-live-test-profile.md) | Run isolated Claude allowance validation in the existing UI with explicit consent and no Codex access or cleanup. | Candidate implementation; independent review and native acceptance pending |
+| [0015](0015-codex-test-public-status.md) | Read public Codex service status in the isolated test profile and show unavailable or dated status explicitly. | Accepted implementation direction; native acceptance pending |
+| [0017](0017-connected-codex-menu-reset.md) | Allow explicitly confirmed Codex menu reset redemption for the same connected keyring-only profile, including isolated Codex mode. | Accepted implementation direction; native and live redemption proof pending |
+| [0018](0018-hover-only-notch-details.md) | Use hover-only Electron notch details, Usage or Compact dots at rest, and integer pixel notch sizing. | Superseded by ADR 0019 |
+| [0019](0019-single-design-notch.md) | Keep one usage-ring and percentage strip, remove the dots option, and clear expanded state on disable/hide. | Accepted implementation direction; independent review and native acceptance pending |
+| [0021](0021-provider-group-order.md) | Persist Electron provider group order across usage, menu bar and side notch, with keyboard moves and saved-order recovery. | Accepted implementation direction; rendered and native acceptance pending |
+| [0022](0022-codex-account-token-history.md) | Read provider account daily tokens separately from local activity through the managed ChatGPT helper. | Accepted implementation direction; independent review, native and live acceptance pending |
+| [0023](0023-onboarding-source-choice.md) | Offer supported CLI and account methods in onboarding, with explicit cleanup before switching to local collection. | Accepted implementation direction; native interaction acceptance pending |
diff --git a/docs/electron/README.md b/docs/electron/README.md
new file mode 100644
index 0000000..4b56f0d
--- /dev/null
+++ b/docs/electron/README.md
@@ -0,0 +1,192 @@
+# MeterUsage Electron candidate
+
+Electron is the sole active target for new MeterUsage features. The legacy
+Swift app is frozen as the current stable download and rollback option until
+[staged retirement](../adr/0024-electron-primary-swift-retirement.md) passes.
+
+This is the unpublished macOS TypeScript/Electron/React candidate. Its source
+version is `0.2.41` in [package.json](../../package.json), matching the retained
+Swift version number. Private build and installation receipts exist. Complete
+native acceptance and public cutover remain open; this is not a released v2.1.
+Use the [published Swift app](../swift/README.md) for the default installation.
+
+## Source choices
+
+Settings contains optional onboarding and the supported source choices. Changing
+a selector alone does not access an account or switch collection. Apply the
+selected method with its action. **Done** saves setup completion before opening
+Usage. See [ADR 0023](../adr/0023-onboarding-source-choice.md).
+
+| Provider and method | Available source behavior | Coverage limit |
+| --- | --- | --- |
+| Codex browser sign-in | Existing official app helper, with command-install compatibility, signs in through the provider page using a separate keyring-only profile. Reads allowance and provider-reported daily account tokens. | Account history is separate from CLI tokens. No local sessions, category split, cost or burn attribution comes from this method. Universal device/product coverage is unverified. |
+| Codex CLI | Existing authenticated CLI supplies quota/reset metadata; local rollouts supply tokens, sessions and estimated cost. | Activity on this Mac only. |
+| Claude Desktop | Explicit consent permits a fixed Keychain helper and read-only Desktop cache/cookie access for current account allowance. | Allowance only. No Desktop chat/token history, local cost, tier or extra-usage credit projection. Endpoint compatibility and live acceptance remain open. |
+| Claude Code CLI | Local transcript counters, tier metadata and optional companion quota files. | Local activity; allowance requires the [companion](../COMPANION.md). |
+| Grok CLI | Existing CLI authentication and local session summaries. | Consumer website/mobile sign-in is unsupported. Session/message counts do not establish token totals. |
+
+**Use CLI** ends only MeterUsage-owned account state and waits for successful
+cleanup before enabling local collection. Failed cleanup blocks the switch.
+Ordinary **Disconnect** leaves collection off rather than silently falling back.
+Other apps remain signed in. Demo and isolated live test profiles cannot switch
+to local collection. See [desktop connections](../DESKTOP-CONNECTIONS.md) and
+[privacy](../PRIVACY.md) for the exact access and failure contracts.
+
+### Existing provider inputs
+
+All nine adapters remain wired in [composition](../../src/main/composition.ts).
+Antigravity and OpenCode onboarding guidance/actions remain requested work;
+their collection exists without a connection row.
+
+- Antigravity quota uses existing authenticated `agy` container state, Docker or
+ Podman, image `antigravity-cli:local` and volumes `antigravity-config` and
+ `antigravity-bin`. Native database/history usage is independent of that quota
+ runtime. No separate MeterUsage browser login or API-key form exists.
+- OpenCode Go quota reads the existing `opencode-go` key saved by OpenCode
+ `/connect` in `~/.local/share/opencode/auth.json`. Independently, token history
+ reads `~/.local/share/opencode/opencode.db`, with read-only CLI SQL fallback.
+ Only `opencode-go` sessions count, not every provider used through OpenCode.
+ A quota key alone cannot supply local history. The candidate fallback omits
+ the legacy explicit `~/.bun/bin/opencode` probe; when direct DB reads fail and
+ Bun is absent from the GUI PATH, CLI fallback discovery can fail.
+- OpenRouter uses existing keys for account quota/credit and aggregate activity.
+ That activity is provider account scope, not a local chat count.
+- Cursor, Copilot CLI and Gemini CLI use optional existing local quota snapshots.
+ Presence alone does not provide cloud quota or a token ledger.
+
+Do not paste credentials into MeterUsage or documentation. The full input and
+request list is in [privacy](../PRIVACY.md#provider-inputs-and-requests).
+
+### Advanced local accounts
+
+Additional Codex and Claude accounts are user-selected local configuration
+directories, not arbitrary cloud subaccount logins. Settings supports adding,
+renaming, enabling, changing directory and removing rows. Stable generated slot
+IDs keep history separate through rename. Removing a row does not delete provider
+files. Source reconstruction takes effect without the legacy relaunch step;
+native picker and persistence acceptance remain open. See
+[ADR 0006](../adr/0006-managed-account-list.md).
+
+## Current interface
+
+The candidate has a Usage panel and a separate Settings window. Small, Medium
+and Large sizing applies to panel/Settings text and controls; main-panel width
+scales and height fits within the display. Ctrl/Cmd plus or minus changes size;
+Ctrl/Cmd 0 restores Medium. Refresh interval, theme/accent, collection/display,
+heatmap, pacing, telemetry, chart, alerts, login and update preferences remain.
+
+Provider groups can be reordered with drag handles, arrow keys or move buttons.
+Saved order applies to Usage, service status, menu bar and side notch. Additional
+accounts stay with their provider. Failed saves retain saved order. See
+[ADR 0021](../adr/0021-provider-group-order.md).
+
+The notch has one usage-ring and percentage strip. Details open on hover and
+close through the existing delay and temporary action guards. Pin, dots and
+multiple-design controls are removed. Current source uses a 220-pixel card and
+36-pixel strip; retained Swift uses 250/44 points. Shared-image capture remains
+250 pixels wide. ADR 0019 still names the older dimensions; that documented
+mismatch remains unresolved. Native fixed-edge captures for two different-height
+cards are still required. See [side notch](../SIDE-NOTCH.md) and
+[ADR 0019](../adr/0019-single-design-notch.md).
+
+### Heatmap scope
+
+Local Codex/Claude activity supports 26-week daily, weekly and cumulative views.
+Usage telemetry without extended daily activity uses a 30-day grid and preserves
+its token or session units. The optional 30-day line chart is separate. Connected
+Codex has a daily 26-week account-token grid ending on the latest provider date.
+Unreported dates stay unavailable; reported zero is zero. Account history stays
+in memory and is excluded from local rollups, durable history and schema-1 JSON.
+Live availability and coverage remain unverified. See
+[ADR 0022](../adr/0022-codex-account-token-history.md).
+
+Hover and keyboard focus expose dated compact readouts; tooltips and accessible
+labels retain exact counts. Feedback about 30-day versus 26-week grid geometry
+remains pending. These source contracts do not establish native keyboard access,
+layout or anchor acceptance.
+
+## Known parity gaps
+
+The source comparison identifies five primary UI omissions/reductions:
+
+- Settings lacks the legacy read-only slot/plan/source overview. Plan badges
+ remain on Usage cards.
+- The independent per-provider seven-day sparkline is absent; the optional
+ 30-day chart and aggregate seven-day total remain.
+- Reset countdown/date is not independent of projected exhaustion. ETA can
+ replace the reset date field; notch header countdown is absent.
+- Update feedback lacks release notes and visible download/relaunch progress.
+ Install/Retry exists; native update completion remains unverified.
+- The tray context menu lacks **Refresh Now**. Usage and notch refresh remain.
+
+Smaller display gaps include today's session count in token-unavailable
+telemetry, average tokens/turn and aggregate long-chat count on the main burn
+card, provider-specific recovery hints, diagnostics copy feedback and a timestamp
+on fresh quota-only notch cards. OpenCode Bun fallback discovery is a separate
+provider regression. These findings do not establish how many users are affected.
+AGY/OpenCode onboarding is pending requested scope, and pin/dots removals are
+intentional. This documentation implements none of the proposed fixes.
+
+## Build and test
+
+Run commands from the repository root. Node `>=24.13.1` and `pnpm@12.9.1` are
+declared in [package.json](../../package.json).
+
+```sh
+pnpm install --frozen-lockfile
+pnpm typecheck
+pnpm test
+pnpm build
+```
+
+These checks can run on Linux. They prove their source/fixture layer only.
+On macOS, `pnpm package:mac` exports marks, compiles the fixed Claude Desktop
+helper, builds and packages an Apple silicon ZIP with `--publish never`.
+It does not install or publish the candidate. It requires the existing Swift
+and signing tools. Do not invoke the Keychain helper during synthetic QA.
+See [contributor commands](../../CONTRIBUTING.md#typescript-electron-candidate).
+
+## Isolated QA
+
+The default app name, bundle ID and data/preferences overlap Swift. Do not run
+both editions against normal profiles. Use an empty isolated directory on first
+use, or reuse its matching candidate marker for relaunch checks. Candidate paths
+must be canonical and cannot follow symlinks.
+
+After `pnpm build`, synthetic headless QA uses:
+
+```sh
+meterusage_demo_root="$(node -p 'require("node:fs").realpathSync(require("node:os").tmpdir())')"
+node dist/main/cli.cjs json --force --demo --candidate-profile "$meterusage_demo_root/meterusage-cli-demo"
+```
+
+After packaging on macOS, synthetic GUI QA uses:
+
+```sh
+./release/mac-arm64/MeterUsage.app/Contents/MacOS/meterusage --demo --candidate-profile /private/tmp/meterusage-native-demo
+```
+
+Demo isolates preferences, history, caches and Electron state and blocks live
+provider discovery, updates and login-item changes. It does write isolated files.
+The separate opt-in `--codex-test-profile` and `--claude-test-profile` flows are
+for isolated live QA, with different provider access limits. They do not grant
+normal-profile coexistence or complete native acceptance. Follow
+[demo and candidate isolation](../DEMO.md#electron-candidate),
+[Codex test boundaries](../adr/0012-codex-live-test-profile.md),
+[reset confirmation amendment](../adr/0017-connected-codex-menu-reset.md) and
+[Claude test boundaries](../adr/0016-claude-live-test-profile.md).
+
+## Acceptance and release boundary
+
+Private installation receipts do not close native acceptance. Remaining proof
+includes native setup/account/offline journeys, tray/focus/accessibility,
+notifications, login items, sharing, updater replacement/relaunch, drag/display
+persistence and two different-height notch captures. Reuse matching source,
+packaging and installation receipts only for their recorded layer. Windows
+implementation, packaging, CI, testing and release remain paused.
+
+[ADR 0010](../adr/0010-macos-typescript-electron.md) keeps Swift as the behavior
+reference and rollback path. No automatic cutover, data migration, release or
+installed-app replacement follows from these docs. The [repository strategy](../VERSIONS.md#proposed-same-repository-strategy)
+records the accepted development direction and the checks still required for
+source retirement and release. Electron's required Swift helpers remain.
diff --git a/docs/mockups/README.md b/docs/mockups/README.md
index d97e528..c1dd101 100644
--- a/docs/mockups/README.md
+++ b/docs/mockups/README.md
@@ -1,6 +1,10 @@
# Concept mockups
-Synthetic UI concepts for the next meterusage feature waves. Numbers are invented. Visuals use the shipping hardware-black notch palette.
+Historical synthetic UI concepts for MeterUsage feature waves. Numbers are
+invented. These concepts use the Swift hardware-black notch palette; they are
+not current Electron screenshots or proof that every concept shipped. Read the
+[Swift guide](../swift/README.md) for older demo captures and the
+[Electron guide](../electron/README.md) for current candidate source behavior.
## Hard constraint: provider marks stay
@@ -15,7 +19,8 @@ Do **not** redesign or replace provider icons/logos.
| App icon | `Resources/AppIcon.png`, `Resources/AppIcon.icns` |
| Render path | `ProviderMark` in `Sources/MeterUsage/Views/MenuBarLabel.swift` |
-Menu bar, popover, settings, and side-notch continue to use `ProviderMark`. New UI (including any cross-platform shell) must reuse these assets and the same mark semantics (service-status tint on the mark, headroom tint on the %).
+The retained Swift menu bar, popover, Settings and side notch use `ProviderMark`.
+The Electron renderer reuses the provider assets through its own mark renderer. New UI (including any cross-platform shell) must reuse these assets and the same mark semantics (service-status tint on the mark, headroom tint on the %).
## Waves
diff --git a/docs/plans/macos-electron-migration-plan.md b/docs/plans/macos-electron-migration-plan.md
index 50c0761..dd7ecf7 100644
--- a/docs/plans/macos-electron-migration-plan.md
+++ b/docs/plans/macos-electron-migration-plan.md
@@ -2,12 +2,37 @@
Port the macOS app and headless JSON CLI to TypeScript without changing provider or persisted-data contracts. Completion requires fixture parity and a native candidate, with each unproven native path recorded explicitly.
+Current direction, 2026-10-14: [ADR 0024](../adr/0024-electron-primary-swift-retirement.md)
+makes Electron the sole active feature target. The legacy Swift app is frozen
+for new features and remains the published download and rollback until the
+retirement conditions pass. Earlier execution receipts below describe their
+recorded artifacts; the [candidate guide](../electron/README.md) lists current
+source capabilities and remaining acceptance gaps.
+
## Human review
### Goal
A macOS Electron app and headless CLI provide the current quota, usage, pacing, accounts, history, alerts, settings and update behavior. Windows development is paused.
+### Staged Swift retirement
+
+- Freeze legacy feature development now; route new work to Electron. Verify
+ contributor and edition guidance against ADR 0024.
+- Finish agreed setup/parity work and the existing source/native acceptance
+ gates. Reuse exact matching receipts; verify changed inputs and open paths.
+- Verify the deliberate Swift-to-Electron update and rollback with preserved
+ accounts, preferences and history before public cutover. Existing release
+ holds remain; a documentation decision does not publish the app.
+- After a retrievable legacy source revision and matching release are preserved,
+ remove old-app-only source, tests, build steps and CI references in a reviewed
+ change. Verify Electron build, packaging and affected native behavior after
+ removal. Keep the Swift Keychain helper, asset exporter and required assets.
+
+These retirement steps are pending beyond the documentation freeze. The
+intended end state is one app; no permanent dual-edition feature roadmap is
+required.
+
### Diagram
```mermaid
@@ -127,7 +152,7 @@ Windows source, installers, CI, tests and releases; studio guidance and future a
Independent plan review round 1 requested five revisions: typed UserDefaults compatibility, enforced candidate isolation, explicit native parity inventory, structural selective parsing, and separate source/native/cutover gates. The plan now specifies each mechanism. Round 2 passed all review axes against the revised mechanisms. This is plan approval only; implementation and native proof remain open. Review completeness, strategic scope, privacy, native UI invariants, operational safety and security in one bounded pass. Existing owner authorization permits execution after concerns are resolved.
-### Current execution state
+### Historical execution receipts
S1 through S3 have synthetic fixture and TypeScript-check proof. All 128
current fixtures have matching passing execution receipts, including refreshed
diff --git a/docs/swift/README.md b/docs/swift/README.md
new file mode 100644
index 0000000..2b115e8
--- /dev/null
+++ b/docs/swift/README.md
@@ -0,0 +1,211 @@
+
+
+
+
+
MeterUsage Swift 0.2.41
+
+
+ Native macOS menu-bar and floating side-notch HUD for monitoring your AI coding quotas.
+
+
+
+
+
+
+
+
+---
+
+## Released legacy Swift edition
+
+[Choose an edition](../../README.md) or read the [version comparison](../VERSIONS.md).
+
+This guide describes the published Swift 0.2.41 app and retained Swift sources.
+The Swift app is frozen for new features. New feature work targets Electron;
+Swift receives only critical fixes and transition compatibility work. Keep this
+release as the stable download and rollback until the
+[retirement conditions](../adr/0024-electron-primary-swift-retirement.md#retirement-conditions)
+pass. Removing the old app source later will preserve its retrievable history.
+The screenshots below are older synthetic Swift demo captures. They are not
+Electron screenshots or evidence of the current candidate UI.
+
+MeterUsage shows supported AI coding allowances, measured token usage, pacing
+and reset times in the menu bar, popover and floating side notch.
+
+* **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 local configuration directory gets its own row with quota windows, plan badge, history and alerts. 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.
+* **Existing provider access**: Uses existing provider setup; there is no account connection UI in Swift. Uses existing provider CLIs, local SQLite/JSON stores and aggregate usage endpoints.
+* **Agent Budget API**: Machine-readable JSON CLI (`meterusage json`) exporting burn rates, pacing, and time-to-empty for autonomous AI agents.
+
+[Download MeterUsage 0.2.41 for Apple silicon](https://github.com/pekth/meterusage/releases/download/v0.2.41/MeterUsage-0.2.41.zip),
+unzip it, and drag `MeterUsage.app` to
+**Applications**. No Swift, Xcode, Command Line Tools, or Git is required.
+The current prebuilt release supports Apple silicon Macs with macOS 13 or later.
+See [installation](#download-and-installation) for first-launch steps.
+
+---
+
+## Showcase
+
+### Ambient Time-To-Empty & Side Notch HUD
+A dockable, collapsible HUD pinned to the edge of your screen. Hovering any provider ring expands a dedicated detail card with rate limits, ambient time-to-empty, pacing diagnostics, and token telemetry. The panel is anchored by the strip's top-right corner, so switching providers never moves the strip ([`docs/SIDE-NOTCH.md`](../../docs/SIDE-NOTCH.md)):
+
+
+
+
+
+
+
+
+
+
+
+
+
+### Menu Bar & Popover Dashboard
+Click the menu bar mark anytime to inspect full rate-limit details, multi-window countdowns, and 26-week activity heatmaps:
+
+
+
+
+
+
+
+
+
+Screenshots show the app in demo mode (`METERUSAGE_DEMO=1`). All numbers and names are synthetic.
+
+### Settings: Accounts & Providers
+Toggle providers on or off, choose refresh cadence, and switch themes and the accent colour:
+
+
+
+
+
+
+
+---
+
+## Provider matrix
+
+| Provider | Live Cloud Quota | Local Activity & Tokens | Reset Countdowns | 26-Week Heatmap | Source Mechanism |
+|---|:---:|:---:|:---:|:---:|---|
+| **Codex** | ✅ | ✅ | ✅ | ✅ | Local JSON-RPC via `codex app-server --listen stdio://` |
+| **Antigravity** | ✅ | ✅ | ✅ |. | CLI `/usage` & local conversation SQLite |
+| **Claude Code** | Optional* | ✅ | ✅* | ✅ | Local session JSONL streams (`limits[]` file optional) |
+| **OpenRouter** | ✅ | ✅ | ✅ |. | Public account API & `/api/v1/activity` telemetry |
+| **Grok** | ✅ | ✅ | ✅ |. | CLI auth bearer token & session summaries |
+| **OpenCode Go** | ✅ | ✅ | ✅ |. | Existing Go key for quota; independent Go-only read-only session SQL |
+| **Cursor** | Local snapshot |. | Snapshot-dependent |. | Existing local quota/usage JSON or presence check |
+| **Copilot CLI** | Local snapshot |. | Snapshot-dependent |. | Existing local quota/usage JSON or presence check |
+| **Gemini CLI** | Local snapshot |. | Snapshot-dependent |. | Existing local quota/usage JSON or presence check |
+
+*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)).
+
+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)).
+
+---
+
+## Key features
+
+* **Ambient Time-to-Empty**: Dynamic ETA calculations (`~47m left at current pace` / `Paced to last until reset`) directly in the menu bar and side notch. The bar, banner, and menu-bar chip report the window's own pace, so a deficit keeps its projected run-out after the burst has cooled; only the present-tense "burning fast" nudge and pace alerts require a current burn. After a successful Codex "Use reset", pacing waits for fresh quota readings with usage growth. The first reading establishes a baseline, and later growth determines the rate and ETA. Each account keeps its own baseline across relaunches.
+* **Window Burn Attribution**: Explains *"Where did my tokens go?"* by decomposing the last 7 days of token consumption by project, model, and message turns across all token-bearing providers.
+* **Context Waste & Cache Hints**: Diagnostic metadata highlighting cache hit rate %, average tokens per turn, and long-chat flags ($\ge 10$ turns or $\ge 100\text{k}$ tokens) to curb silent context waste.
+* **Durable Daily History Store**: Preserves daily token tallies, estimated spend, and peak window utilization in a durable local database (`~/Library/Application Support/MeterUsage/durable-daily-history.json`) that survives CLI transcript pruning.
+* **Unified AI Coding Strip**: High-level summary card in the popover showing all AI coding today (tokens, weekly volume, and estimated USD spend) across all active providers.
+* **Floating Side Notch HUD**: Fixed-black, hardware-like collapsible strip. Hovering a ring expands a docked detail card with smooth spring animations. Features auto-flip positioning (switches left/right depending on screen position). The strip stays anchored to its parked corner while cards change, so provider switches never shift it.
+* **Cross-Provider Headroom Failover**: Instant suggestions when a provider is burning fast or near exhaustion, identifying which alternative model has headroom available. "Burning fast" requires a current burn; a window that is merely near its limit says so in its own words.
+* **Agent Budget API**: Run `meterusage json` to export machine-readable quota telemetry, burn velocity, pacing status, and seconds-to-empty for autonomous AI agents. Pacing in the report is gated on burn recency, matching the failover nudge and pace alerts.
+* **26-Week Activity Heatmaps**: GitHub-style activity matrix inside Codex and Claude cards with Day, Week, or Cumulative views, accompanied by 7-day sparklines.
+* **Accent Colour Themes**: Pick an accent palette (Blue, Violet, Teal, Amber, Rose, Graphite) in Settings → Appearance. It recolours the app accent and the primary provider's mark across the popover, menu bar, and side notch; quota headroom and the other provider identities stay fixed.
+* **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 row with 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 provider account identifier is 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 and architecture
+
+MeterUsage reads selected numeric usage fields and existing provider access:
+* **No Network Man-in-the-Middle**: Reuses the authenticated CLI sessions already on your Mac.
+* **Selective local reads**: Selects numeric session metadata, token tallies, model names and timestamps. Project directories become basenames. Message content, tool payloads and file diffs are skipped.
+* **No Telemetry / Analytics**: Zero outgoing telemetry calls.
+* **Inspectable data boundary**: Full privacy architecture documented in [docs/PRIVACY.md](../../docs/PRIVACY.md).
+
+---
+
+## Download and installation
+
+### Install the prebuilt app
+
+Installing and running MeterUsage does not require Swift, Xcode, Command Line
+Tools, or Git. The Swift runtime it uses ships with macOS.
+
+1. [Download MeterUsage 0.2.41 for Apple silicon](https://github.com/pekth/meterusage/releases/download/v0.2.41/MeterUsage-0.2.41.zip). This app ZIP is for Apple silicon Macs (M1 or later) with macOS 13 or later. Other versions are on [Releases](https://github.com/pekth/meterusage/releases). Choose the app ZIP, not GitHub's **Source code** archives.
+2. Unzip and drag `MeterUsage.app` to your `/Applications/` folder.
+3. Open `MeterUsage.app`. Releases are ad-hoc signed and are not notarized. If macOS blocks the first launch, right-click the app in Finder and choose **Open**, or use **Open Anyway** in System Settings → Privacy & Security.
+
+If a release has no app ZIP, use an earlier release that includes one or report
+the missing asset.
+
+### From a Git checkout (no Swift)
+
+If you already cloned this repository or downloaded its source archive, run:
+
+```sh
+./Scripts/make-app.sh
+```
+
+This downloads the verified prebuilt release to `dist/MeterUsage.app`. Drag
+that app to Applications and use the same first-launch steps above. It does
+not compile your checkout, install developer tools, or fall back to a source
+build when the download fails.
+
+---
+
+## Requirements
+
+* Apple silicon Mac with macOS 13 Ventura or later for the current prebuilt release.
+* A supported provider CLI or data source for the meters you want to use. Provider accounts are not required to install the app or run demo mode.
+
+### Contributing
+
+Swift and Xcode Command Line Tools are needed only to build from source.
+See [CONTRIBUTING.md](../../CONTRIBUTING.md) for developer setup, build commands,
+and tests. Source builds require the explicit `--build-from-source` option.
+
+### Agent Budget CLI
+To consume quota telemetry programmatically in scripts or agents:
+```sh
+meterusage json
+```
+The schema-1 quota report does not export token totals, sessions or heatmaps. It includes `remaining_percent`, `resets_at`, `pacing`, `burn_rate`, and `eta_seconds`.
+
+---
+
+## License and disclaimer
+
+* **License**: [MIT](../../LICENSE)
+* **Disclaimer**: Independent open-source project. Not affiliated with or endorsed by OpenAI, Anthropic, Google, xAI, Microsoft, or OpenRouter.
+
+Cost figures are estimates. Provider dashboards remain the billing source of record.
diff --git a/src/domain/models.ts b/src/domain/models.ts
index 4b573d0..82a8fe0 100644
--- a/src/domain/models.ts
+++ b/src/domain/models.ts
@@ -48,6 +48,7 @@ export interface Session {
export const activeUntil = (s: Session) => Math.max(s.lastActivityAt ?? s.startedAt, s.startedAt);
export const lastBurn = (sessions: Session[]) => sessions.length ? Math.max(...sessions.map(activeUntil)) : undefined;
export interface Daily { day: number; tokens: Tokens; estimatedCostUSD: number; sessionCount: number }
+export interface AccountActivity { dailyUsageBuckets: { startDate: string; tokens: number }[] | null; capturedAt: number }
export interface Activity { provider: Provider; sessions: Session[]; daily: Daily[]; scannedAt: number; telemetry?: Telemetry }
export interface UsageWindow { label: string; sessionCount: number; messageCount: number; tokens: Tokens; estimatedCostUSD: number }
export interface Usage {
@@ -64,7 +65,7 @@ export interface Telemetry {
}
export type Severity = "operational" | "unknown" | "degraded" | "partialOutage" | "majorOutage";
export const severityNames: Record = { operational: "Operational", unknown: "Unknown", degraded: "Degraded", partialOutage: "Partial outage", majorOutage: "Major outage" };
-export interface ServiceStatus { provider: Provider; severity: Severity; description: string; checkedAt: number }
+export interface ServiceStatus { provider: Provider; severity: Severity; description: string; checkedAt: number; stale?: boolean; refreshError?: string }
export type MissingCode = "cliNotFound" | "notSignedIn" | "offline" | "failed" | "noData" | "dataNotFound";
export class Unavailable extends Error {
constructor(public code: MissingCode, public subject?: string) {
diff --git a/src/domain/notch.ts b/src/domain/notch.ts
index 9b6ddc0..bd56162 100644
--- a/src/domain/notch.ts
+++ b/src/domain/notch.ts
@@ -1,7 +1,7 @@
import { type Quota, type QuotaWindow, window, slotKey, headlineWindow } from "./models";
import type { Snapshot } from "../shared/state";
export interface Rect { x: number; y: number; width: number; height: number }
-export const stripWidth = 44, cardWidth = 250;
+export const stripWidth = 36, cardWidth = 220;
// Coordinates here use Electron's top-left origin. Persistence converts the
// saved AppKit top-right corner at the native boundary.
export function notchFrame(anchor: { x: number; y: number }, size: { width: number; height: number }, cardOnRight: boolean, screen: Rect): Rect {
diff --git a/src/domain/overview.ts b/src/domain/overview.ts
index 61d8dcf..1996eef 100644
--- a/src/domain/overview.ts
+++ b/src/domain/overview.ts
@@ -12,7 +12,7 @@ export function trayTooltip(s: Snapshot): string {
if (!fresh) parts.push(`last reading ${formatETA((s.clock - reading!.capturedAt) / 1000, true)} ago`);
}
const status = s.statuses[slot.provider];
- if (status?.status === "value" && status.value.severity !== "operational") parts.push(severityNames[status.value.severity]);
+ if (status?.status === "value" && (status.value.severity !== "operational" || status.value.stale)) parts.push(`${status.value.stale ? "Last known " : ""}${severityNames[status.value.severity]}${status.value.stale ? ` · checked ${formatETA((s.clock - status.value.checkedAt) / 1000, true)} ago · ${status.value.refreshError}` : ""}`);
return parts.length ? [`${slotName(slot)}: ${parts.join(" · ")}`] : [];
});
if (!entries.length) entries.push("No usage data yet");
diff --git a/src/domain/provider-order.ts b/src/domain/provider-order.ts
new file mode 100644
index 0000000..e2dd91f
--- /dev/null
+++ b/src/domain/provider-order.ts
@@ -0,0 +1,16 @@
+import { providers, type Provider, type Slot } from "./models";
+export function normalizeProviderOrder(raw: unknown): Provider[] {
+ const known = Array.isArray(raw) ? raw.filter((p): p is Provider => providers.includes(p)) : [];
+ return [...new Set([...known, ...providers])];
+}
+export function validProviderOrder(raw: unknown): raw is Provider[] {
+ return Array.isArray(raw) && raw.length === providers.length && providers.every(p => raw.includes(p));
+}
+export function orderedSlots(slots: readonly Slot[], order: unknown): Slot[] {
+ return normalizeProviderOrder(order).flatMap(p => slots.filter(s => s.provider === p));
+}
+export function moveProvider(order: readonly Provider[], provider: Provider, offset: -1 | 1): Provider[] {
+ const next = [...order], from = next.indexOf(provider), to = from + offset;
+ if (from >= 0 && to >= 0 && to < next.length) [next[from], next[to]] = [next[to], next[from]];
+ return next;
+}
diff --git a/src/main/composition.ts b/src/main/composition.ts
index 428078a..6618fdf 100644
--- a/src/main/composition.ts
+++ b/src/main/composition.ts
@@ -1,6 +1,6 @@
import { readFileSync, existsSync, statSync } from "node:fs";
import { join, isAbsolute, resolve } from "node:path";
-import { type Quota, type Activity, type Usage, type ServiceStatus, type Slot, type ManagedAccount, primary, accountSlot, Unavailable, failed } from "../domain/models";
+import { type Quota, type AccountActivity, type Activity, type Usage, type ServiceStatus, type Slot, type ManagedAccount, primary, accountSlot, Unavailable, failed } from "../domain/models";
import { selectJSON } from "./select-json";
import { type Launch } from "./launch";
import { Preferences } from "./preferences";
@@ -13,6 +13,7 @@ import { demoSources } from "./demo";
import type { DesktopConnections } from "./connections";
export interface Source {
accountBound?: boolean;
+ accountActivity?: (signal?: AbortSignal) => Promise;
slot: Slot;
quota?: (signal?: AbortSignal) => Promise;
activity?: (signal?: AbortSignal) => Promise;
@@ -44,9 +45,19 @@ function openCodeKey(launch: Launch): string | undefined {
}
export function compose(launch: Launch, prefs: Preferences, options: { command?: Command; http?: HTTP; now?: () => number; connections?: DesktopConnections } = {}): Source[] {
const now = options.now ?? Date.now;
- if (launch.codexTest) return [{ slot: primary("codex"), accountBound: true, quota: signal => options.connections ? options.connections.quota("codex", signal) : Promise.reject(new Unavailable("notSignedIn", "codex")) }];
+ if (launch.claudeTest) return [{ slot: primary("claude"), accountBound: true, quota: signal => options.connections ? options.connections.quota("claude", signal) : Promise.reject(new Unavailable("notSignedIn", "claude")) }];
+ const http = options.http ?? httpTransport();
+ const connectedCodex = (): Source => {
+ const profile = String(prefs.values.desktopCodexConnection), connections = options.connections;
+ return { slot: primary("codex"), accountBound: true,
+ accountActivity: signal => connections ? connections.accountActivity(signal, profile) : Promise.reject(new Unavailable("notSignedIn", "codex")),
+ quota: signal => connections ? connections.quota("codex", signal, profile) : Promise.reject(new Unavailable("notSignedIn", "codex")),
+ ...(connections?.enabled("codex") ? { consumeReset: (id: string, signal?: AbortSignal) => connections.consumeReset(profile, id, signal) } : {}),
+ };
+ };
+ if (launch.codexTest) return [{ ...connectedCodex(), status: async signal => parseStatus("codex", await http(endpoints.codexStatus, "codex", undefined, signal), now()) }];
if (launch.demo) return demoSources(now, prefs.accounts);
- const command = options.command ?? runCommand, http = options.http ?? httpTransport(), env = cliEnvironment(launch.home, launch.env);
+ const command = options.command ?? runCommand, env = cliEnvironment(launch.home, launch.env);
const runtime = new AntigravityRuntime(runtimeCandidates(env), command, env);
const makeCodex = (slot: Slot, home?: string): Source => {
const rpc = (signal?: AbortSignal, id?: string) => codexRPC(cliPath("codex", launch.home, env), { env: { ...env, ...(home ? { CODEX_HOME: home } : {}) }, signal }, id);
@@ -66,14 +77,12 @@ export function compose(launch: Launch, prefs: Preferences, options: { command?:
try { return parseClaudeQuota(raw, capturedAt); } catch { throw new Unavailable("noData"); }
}, activity: async signal => activity.scan(now(), signal), plan: async () => { const raw = read(alternate ? join(home, ".claude.json") : join(launch.home, ".claude.json")); if (!raw) throw new Unavailable("noData"); try { return parseClaudePlan(raw); } catch { throw new Unavailable("noData"); } } };
};
- const codex = makeCodex(primary("codex")), claude = makeClaude(primary("claude"), join(launch.home, ".claude"));
- for (const [provider, source, preference] of [["codex", codex, "desktopCodexConnection"], ["claude", claude, "desktopClaudeIdentity"]] as const) {
- if (prefs.values[preference] !== "") {
- source.accountBound = true;
- source.quota = signal => options.connections ? options.connections.quota(provider, signal) : Promise.reject(new Unavailable("notSignedIn", provider));
- // Local CLI activity cannot be attributed to a separately connected account.
- delete source.activity; delete source.plan; delete source.consumeReset;
- }
+ const codex = prefs.values.desktopCodexConnection !== "" ? connectedCodex() : makeCodex(primary("codex")), claude = makeClaude(primary("claude"), join(launch.home, ".claude"));
+ if (prefs.values.desktopClaudeIdentity !== "") {
+ claude.accountBound = true;
+ claude.quota = signal => options.connections ? options.connections.quota("claude", signal) : Promise.reject(new Unavailable("notSignedIn", "claude"));
+ // Local CLI activity cannot be attributed to a separately connected account.
+ delete claude.activity; delete claude.plan;
}
codex.status = async signal => parseStatus("codex", await http(endpoints.codexStatus, "codex", undefined, signal), now());
claude.status = async signal => parseStatus("claude", await http(endpoints.claudeStatus, "claude", undefined, signal), now());
diff --git a/src/main/connections.ts b/src/main/connections.ts
index bbaffe2..4e5fa6c 100644
--- a/src/main/connections.ts
+++ b/src/main/connections.ts
@@ -5,10 +5,11 @@ import type { Launch } from "./launch";
import { Preferences } from "./preferences";
import { boundedChild, cliEnvironment, cliPath, codexRPC, endpoints, httpTransport, runCommand, type Command, type HTTP } from "./providers/transport";
import { object, parseCodex } from "./providers/parsers";
-import { desktopQuota, readDesktopCredential } from "./providers/claude-desktop";
+import { parseCodexAccountUsage } from "./providers/codex-account-usage";
+import { desktopQuota, readDesktopCredential, desktopReadErrors } from "./providers/claude-desktop";
import { selectJSON } from "./select-json";
-import { Unavailable } from "../domain/models";
-import type { ConnectionState, ConnectionProvider } from "../shared/ipc";
+import { Unavailable, failed, resetCreditAvailable } from "../domain/models";
+import { providerKey, type ConnectionState, type ConnectionProvider } from "../shared/ipc";
const configuration = ["-c", 'cli_auth_credentials_store="keyring"', "-c", 'forced_login_method="chatgpt"', "-c", 'chatgpt_base_url="https://chatgpt.com"'];
export function codexLoginURL(raw: unknown): string {
@@ -58,16 +59,17 @@ export function connectCodex(binary: string, env: NodeJS.ProcessEnv, open: (url:
}
export class DesktopConnections {
- private pending = new Map }>();
+ private pending = new Map; local: boolean }>();
private errors = new Set();
private stopped = false;
+ private resetController?: AbortController;
private stopping?: Promise;
private cleanupController = new AbortController();
constructor(readonly launch: Launch, readonly prefs: Preferences, readonly helper: string, readonly changed: () => void, readonly command: Command = runCommand, readonly http: HTTP = httpTransport(), readonly now = Date.now) {}
get state(): ConnectionState[] {
- return (["codex", "claude", "grok"] as const).map(provider => ({ provider, status: provider === "grok" || this.launch.codexTest && provider !== "codex" ? "unsupported" : this.pending.has(provider) ? "connecting" : this.enabled(provider) ? "connected" : this.errors.has(provider) ? "failed" : "disconnected" }));
+ return (["codex", "claude", "grok"] as const).map(provider => ({ provider, status: provider === "grok" || this.launch.codexTest && provider !== "codex" || this.launch.claudeTest && provider !== "claude" ? "unsupported" : this.pending.has(provider) ? "connecting" : this.enabled(provider) ? "connected" : this.errors.has(provider) ? "failed" : "disconnected", source: provider !== "grok" && this.configured(provider) ? "account" : "cli", localAvailable: !this.launch.demo && !this.launch.codexTest && !this.launch.claudeTest }));
}
- enabled(provider: "codex" | "claude") { const value = this.prefs.values[provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"]; return (!this.launch.codexTest || provider === "codex") && !this.stopped && !this.pending.has(provider) && !(provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") && typeof value === "string" && value !== "" && value !== "off"; }
+ enabled(provider: "codex" | "claude") { const value = this.prefs.values[provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"]; return (!this.launch.codexTest || provider === "codex") && (!this.launch.claudeTest || provider === "claude") && !this.stopped && !this.pending.has(provider) && !(provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") && typeof value === "string" && value !== "" && value !== "off"; }
configured(provider: "codex" | "claude") { return this.prefs.values[provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"] !== ""; }
private binary() {
for (const root of ["/Applications", join(this.launch.home, "Applications")]) {
@@ -81,15 +83,23 @@ export class DesktopConnections {
mkdirSync(home, { recursive: true, mode: 0o700 });
return { HOME: this.launch.home, PATH: cliEnvironment(this.launch.home, { PATH: this.launch.env.PATH }).PATH, CODEX_HOME: home, TMPDIR: this.launch.env.TMPDIR };
}
- private async claude(allowUI: boolean, signal?: AbortSignal) {
+ private async claude(allowUI: boolean, signal?: AbortSignal, readingCache?: (stage: keyof typeof desktopReadErrors) => void) {
try {
const password = await this.command(this.helper, allowUI ? ["--allow-ui"] : [], { signal, timeoutMs: allowUI ? 60000 : 5000, maxBytes: 4096 });
- return await readDesktopCredential(this.launch.home, password, this.now());
+ return await readDesktopCredential(this.launch.home, password, this.now(), readingCache);
} catch { throw new Unavailable("notSignedIn", "Claude Desktop"); }
}
- async quota(provider: ConnectionProvider, signal?: AbortSignal) {
+ private checkCodexProfile(profile: string) {
+ if (this.launch.demo || !this.enabled("codex") || this.prefs.values.desktopCodexConnection !== profile) throw new Unavailable("notSignedIn", "codex");
+ }
+ async quota(provider: ConnectionProvider, signal?: AbortSignal, profile = String(this.prefs.values.desktopCodexConnection)) {
if (this.launch.demo || provider === "grok" || !this.enabled(provider)) throw new Unavailable("notSignedIn", provider);
- if (provider === "codex") return parseCodex(await codexRPC(this.binary(), { env: this.env(String(this.prefs.values.desktopCodexConnection)), signal }, undefined, configuration), this.now());
+ if (provider === "codex") {
+ this.checkCodexProfile(profile);
+ const raw = await codexRPC(this.binary(), { env: this.env(profile), signal }, undefined, configuration);
+ this.checkCodexProfile(profile); signal?.throwIfAborted();
+ return parseCodex(raw, this.now());
+ }
const credential = await this.claude(false, signal);
if (credential.identity !== this.prefs.values.desktopClaudeIdentity) throw new Unavailable("notSignedIn", "Claude Desktop");
let raw: string;
@@ -101,10 +111,33 @@ export class DesktopConnections {
}
return desktopQuota(raw, this.now());
}
- async connect(provider: ConnectionProvider, open: (url: string) => Promise, consent: () => Promise = async () => true) {
+ async accountActivity(signal?: AbortSignal, profile = String(this.prefs.values.desktopCodexConnection)) {
+ this.checkCodexProfile(profile);
+ const raw = await codexRPC(this.binary(), { env: this.env(profile), signal }, undefined, configuration, "usage");
+ this.checkCodexProfile(profile); signal?.throwIfAborted();
+ return parseCodexAccountUsage(raw, this.now());
+ }
+ async consumeReset(profile: string, id: string, signal?: AbortSignal) {
+ this.checkCodexProfile(profile);
+ if (!id || id.length > 200 || this.resetController) throw failed("codex");
+ const controller = new AbortController(); this.resetController = controller;
+ const resetSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
+ try {
+ const q = await this.quota("codex", resetSignal, profile);
+ if (!q.resetCredits.some(c => c.id === id && resetCreditAvailable(c, this.now()))) throw failed("codex");
+ this.checkCodexProfile(profile); resetSignal.throwIfAborted();
+ const raw = await codexRPC(this.binary(), { env: this.env(profile), signal: resetSignal }, id, configuration);
+ this.checkCodexProfile(profile); resetSignal.throwIfAborted();
+ const root = object(selectJSON(raw, { result: { outcome: true }, error: { code: true, message: true } }));
+ if (root.error) { parseCodex(raw, this.now()); throw failed("codex"); }
+ return object(root.result).outcome === "reset";
+ } finally { this.resetController = undefined; }
+ }
+ async connect(provider: ConnectionProvider, open: (url: string) => Promise, consent: () => Promise = async () => provider !== "claude") {
if (this.stopped) throw new Error("Connection unavailable");
if (this.launch.demo) throw new Error("Connections are disabled in demo mode");
if (this.launch.codexTest && provider !== "codex") throw new Error("Connection unavailable in Codex test mode");
+ if (this.launch.claudeTest && provider !== "claude") throw new Error("Connection unavailable in Claude test mode");
if (provider === "grok") throw new Error("Automatic Grok connection is unavailable");
if (this.pending.has(provider) || this.enabled(provider)) throw new Error("Connection already active");
if (provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") {
@@ -115,6 +148,7 @@ export class DesktopConnections {
const { controller, finish } = this.begin(provider);
let candidateID: string | undefined;
let savedIdentity: string | undefined;
+ let claudeError = "Open Claude Desktop and sign in, then try connecting again";
try {
if (!await consent()) return false;
controller.signal.throwIfAborted();
@@ -128,9 +162,16 @@ export class DesktopConnections {
if (!q.windows.length) throw new Unavailable("noData");
controller.signal.throwIfAborted();
} else {
- const credential = await this.claude(true, controller.signal);
+ claudeError = "Could not access Claude Desktop through macOS Keychain. Check the access prompt, then try connecting again.";
+ const credential = await this.claude(true, controller.signal, stage => {
+ claudeError = desktopReadErrors[stage];
+ });
+ claudeError = "Could not read Claude account allowance. Try connecting again later.";
const q = desktopQuota(await this.http(endpoints.claudeAllowance, "claude", credential.token, controller.signal), this.now());
- if (!q.windows.length || (await this.claude(false, controller.signal)).identity !== credential.identity) throw new Unavailable("noData");
+ if (!q.windows.length) throw new Unavailable("noData");
+ claudeError = "Could not verify the same Claude Desktop account after the allowance request. Try connecting again.";
+ if ((await this.claude(false, controller.signal)).identity !== credential.identity) throw new Unavailable("noData");
+ claudeError = "Could not save the Claude Desktop connection preferences. Try connecting again.";
controller.signal.throwIfAborted(); savedIdentity = credential.identity; await this.prefs.set("desktopClaudeIdentity", credential.identity);
}
await this.prefs.set(provider === "codex" ? "showProviderCodex" : "showProviderClaude", true);
@@ -148,16 +189,20 @@ export class DesktopConnections {
try { await this.cleanup(candidateID); }
catch { this.errors.add(provider); throw new Error("Could not clear the cancelled Codex connection"); }
}
- if (savedIdentity && this.prefs.values.desktopClaudeIdentity === savedIdentity) await this.disable("claude");
- this.errors.add(provider); throw new Error(provider === "codex" ? "Open Codex on this Mac, then try connecting again" : "Open Claude Desktop and sign in, then try connecting again");
+ if (savedIdentity && this.prefs.values.desktopClaudeIdentity === savedIdentity) {
+ // Disabling clears consent in memory first; keep a failed rollback write private.
+ try { await this.disable("claude"); } catch { /* Report the fixed preference-save message. */ }
+ }
+ this.errors.add(provider); throw new Error(provider === "codex" ? "Open Codex on this Mac, then try connecting again" : controller.signal.aborted ? "Open Claude Desktop and sign in, then try connecting again" : claudeError);
}
finally { finish(); }
}
- private begin(provider: ConnectionProvider) {
+ private begin(provider: ConnectionProvider, local = false) {
+ if (provider === "codex") this.resetController?.abort();
const controller = new AbortController();
let settle!: () => void;
const done = new Promise(resolve => { settle = resolve; });
- this.pending.set(provider, { controller, done }); this.errors.delete(provider); this.changed();
+ this.pending.set(provider, { controller, done, local }); this.errors.delete(provider); this.changed();
return { controller, finish: () => { this.pending.delete(provider); if (!this.stopped) this.changed(); settle(); } };
}
cancel(provider: ConnectionProvider) { this.pending.get(provider)?.controller.abort(); }
@@ -176,23 +221,47 @@ export class DesktopConnections {
const raw = await codexRPC(this.binary(), { env: this.env(id), signal: this.cleanupController.signal }, undefined, configuration, "logout");
if (object(selectJSON(raw, { error: { code: true } })).error) throw new Error("Could not disconnect Codex");
}
- async disconnect(provider: ConnectionProvider) {
+ disconnect(provider: ConnectionProvider) { return this.clearConnection(provider, false); }
+ async useLocal(provider: ConnectionProvider) {
+ if (this.launch.codexTest || this.launch.claudeTest) throw new Error("Connection unavailable in test mode");
+ await this.clearConnection(provider, true);
+ }
+ private async clearConnection(provider: ConnectionProvider, local: boolean) {
if (this.stopped) throw new Error("Connection unavailable");
if (this.launch.demo) throw new Error("Connections are disabled in demo mode");
if (this.launch.codexTest && provider !== "codex") throw new Error("Connection unavailable in Codex test mode");
- if (provider === "grok" || this.pending.has(provider)) throw new Error("Connection unavailable");
+ if (this.launch.claudeTest && provider !== "claude") throw new Error("Connection unavailable in Claude test mode");
+ if (provider === "grok" && !local || this.pending.has(provider)) throw new Error("Connection unavailable");
+ const wasVisible = this.prefs.values[providerKey(provider)] === true;
const id = String(this.prefs.values.desktopCodexCleanup || this.prefs.values.desktopCodexConnection);
- const { finish } = this.begin(provider);
+ const { controller, finish } = this.begin(provider, local);
try {
- await this.disable(provider, provider === "codex" && id !== "" && id !== "off" ? id : undefined);
+ if (provider !== "grok") await this.disable(provider, provider === "codex" && id !== "" && id !== "off" ? id : undefined);
if (provider === "codex" && id !== "" && id !== "off") await this.cleanup(id);
+ if (local) {
+ controller.signal.throwIfAborted();
+ await this.prefs.set(providerKey(provider), true);
+ controller.signal.throwIfAborted();
+ if (provider !== "grok") {
+ await this.prefs.set(provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity", "");
+ controller.signal.throwIfAborted();
+ }
+ }
this.errors.delete(provider);
- } catch { this.errors.add(provider); throw new Error(provider === "codex" ? "Could not disconnect Codex" : "Could not disconnect Claude Desktop");
+ } catch {
+ if (local && provider === "grok" && controller.signal.aborted) {
+ try { await this.prefs.set(providerKey(provider), wasVisible); }
+ catch { this.prefs.values[providerKey(provider)] = wasVisible; }
+ }
+ if (local && provider !== "grok" && !this.configured(provider)) {
+ try { await this.disable(provider); } catch { /* Disabling in memory keeps a failed rollback private. */ }
+ }
+ this.errors.add(provider); throw new Error(provider === "codex" ? "Could not disconnect Codex" : provider === "claude" ? "Could not disconnect Claude Desktop" : "Could not select Grok CLI");
} finally { finish(); }
}
async recover() {
const id = String(this.prefs.values.desktopCodexCleanup);
- if (this.launch.demo || !id) return;
+ if (this.launch.demo || this.launch.claudeTest || !id) return;
if (this.stopped || this.pending.has("codex")) throw new Error("Connection unavailable");
const { finish } = this.begin("codex");
try { await this.disable("codex", id); await this.cleanup(id); }
@@ -201,11 +270,15 @@ export class DesktopConnections {
}
stop(timeoutMs = 5000): Promise {
if (this.stopping) return this.stopping;
- this.stopped = true;
- for (const pending of this.pending.values()) pending.controller.abort();
+ this.stopped = true; this.resetController?.abort();
+ const pending = [...this.pending.values()];
+ for (const operation of pending) operation.controller.abort();
let timer: ReturnType;
const expired = new Promise(resolve => { timer = setTimeout(() => { this.cleanupController.abort(); resolve(); }, timeoutMs); });
- this.stopping = Promise.race([Promise.all([...this.pending.values()].map(p => p.done)).then(() => {}), expired]).finally(() => clearTimeout(timer));
+ // The helper deadline must not cut off a queued source-selection rollback.
+ this.stopping = Promise.race([Promise.all(pending.map(p => p.done)).then(() => {}), expired])
+ .then(() => Promise.all(pending.filter(p => p.local).map(p => p.done))).then(() => {})
+ .finally(() => clearTimeout(timer));
return this.stopping;
}
}
diff --git a/src/main/coordinator.ts b/src/main/coordinator.ts
index 6da7748..cb64276 100644
--- a/src/main/coordinator.ts
+++ b/src/main/coordinator.ts
@@ -1,7 +1,8 @@
import { join } from "node:path";
import { readdir, unlink } from "node:fs/promises";
import { randomUUID } from "node:crypto";
-import { type Loaded, type Quota, type Activity, type Usage, type ServiceStatus, type Slot, type Provider, providers, primary, slotKey, lastBurn, totalTokens, utcDay, value, missing, Unavailable, resetCreditAvailable } from "../domain/models";
+import { type Loaded, type Quota, type AccountActivity, type Activity, type Usage, type ServiceStatus, type Slot, type Provider, providers, primary, slotKey, lastBurn, totalTokens, utcDay, value, missing, Unavailable, resetCreditAvailable } from "../domain/models";
+import { orderedSlots } from "../domain/provider-order";
import { AlertEvaluator, type Alert } from "../domain/alerts";
import { telemetry } from "../domain/telemetry";
import { resetPacing } from "../domain/pacing";
@@ -11,9 +12,10 @@ import { type Launch } from "./launch";
import { type Source, compose, accountHome } from "./composition";
import { HistoryStore, readArchive, saveArchive } from "./history";
import { limitsReport, canonicalJSON } from "../domain/report";
-type Kind = "quota" | "activity" | "usage" | "plan" | "status";
+type Kind = "accountActivity" | "quota" | "activity" | "usage" | "plan" | "status";
export const backoffDelay = (failures: number) => Math.min(2 ** (Math.min(Math.max(failures, 1), 6) - 1), 30) * 60000;
export class Coordinator {
+ accountActivities: Record> = Object.create(null);
quotas: Record> = Object.create(null);
activities: Record> = Object.create(null);
usages: Record> = Object.create(null);
@@ -33,7 +35,7 @@ export class Coordinator {
private stopped = false;
private controllers = new Set();
private confirmations = new Map();
- private resets = new Set();
+ private resets = new Map();
private currentSources: readonly Source[];
constructor(readonly launch: Launch, readonly preferences: Preferences, sources: Source[], readonly now: () => number = Date.now, readonly notify: (alert: Alert) => void = () => {}, readonly sourceFactory: () => Source[] = () => compose(launch, preferences)) {
this.currentSources = sources;
@@ -43,8 +45,9 @@ export class Coordinator {
set sources(next: readonly Source[]) {
for (const source of this.currentSources) if (!next.includes(source)) {
const key = slotKey(source.slot);
- for (const readings of [this.quotas, this.activities, this.usages, this.plans]) delete readings[key];
- for (const kind of ["quota", "activity", "usage", "plan"]) this.backoffs.delete(`${kind}-${key}`);
+ this.resets.get(key)?.abort();
+ for (const readings of [this.accountActivities, this.quotas, this.activities, this.usages, this.plans]) delete readings[key];
+ for (const kind of ["accountActivity", "quota", "activity", "usage", "plan"]) this.backoffs.delete(`${kind}-${key}`);
for (const [token, intent] of this.confirmations) if (intent.source === source) this.confirmations.delete(token);
}
// Durable slot history and dated archives survive directory/label edits.
@@ -60,20 +63,22 @@ export class Coordinator {
}
get slots(): Slot[] {
const byKey = new Map(this.sources.filter(s => this.visible(s)).map(s => [slotKey(s.slot), s.slot]));
- return [...providers.map(primary), ...this.sources.filter(s => s.slot.slotID).map(s => s.slot)].filter(s => byKey.has(slotKey(s))).map(s => byKey.get(slotKey(s))!);
+ return orderedSlots([...providers.map(primary), ...this.sources.filter(s => s.slot.slotID).map(s => s.slot)].filter(s => byKey.has(slotKey(s))).map(s => byKey.get(slotKey(s))!), this.preferences.providerOrder);
}
get burns() { return Object.fromEntries(Object.entries(this.activities).flatMap(([key, state]) => { const at = state.status === "value" ? lastBurn(state.value.sessions) : undefined; return at === undefined ? [] : [[key, at]]; })); }
snapshot(): Snapshot {
const p = this.preferences.values, slots = this.slots, tray = slots.filter(s => p[trayPreference(s.provider)] === true);
const resettable = new Set(this.sources.filter(s => s.consumeReset).map(s => slotKey(s.slot)));
const quotas = Object.fromEntries(Object.entries(this.quotas).map(([key, state]) => [key, state.status === "value" ? value({ ...state.value, canConsumeReset: resettable.has(key) }) : state]));
- return structuredClone({ demo: this.launch.demo, refreshing: !!this.inflight, clock: this.now(), lastRefreshedAt: this.lastRefreshedAt, slots, traySlots: tray.filter(s => s.provider !== "openRouter"), notchSlots: tray, quotas, archived: this.archived, activities: this.activities, usages: this.usages, plans: this.plans, statuses: this.statuses, appearance: { theme: p.appearanceTheme as string, accent: p.accentTheme as string, heatmap: p.showHeatmap === true, claudeHeatmap: p.showClaudeHeatmap === true, codexHeatmap: p.showCodexHeatmap === true, pacing: p.showPacingBurnRate === true, telemetry: p.showActivityTelemetry === true, chart: p.showDailyActivityChart === true, resetButton: p.showSideNotchResetButton === true, compactTray: p.menuBarCompactEnabled === true, notch: p.sideNotchPanelEnabled === true, pinned: p.sideNotchPanelPinned === true, onboarding: p.onboardingCompleted === true }, historyError: this.history.error, archiveWriteFailed: this.archiveWriteFailed, clearingCache: this.clearingCache });
+ const statuses = { ...this.statuses };
+ for (const source of this.sources) if (source.status && !statuses[source.slot.provider]) statuses[source.slot.provider] = { status: "idle" };
+ return structuredClone({ demo: this.launch.demo, refreshing: !!this.inflight, clock: this.now(), lastRefreshedAt: this.lastRefreshedAt, slots, traySlots: tray.filter(s => s.provider !== "openRouter"), notchSlots: tray, quotas, archived: this.archived, accountActivities: Object.fromEntries(this.sources.filter(s => s.accountActivity).map(s => [slotKey(s.slot), this.accountActivities[slotKey(s.slot)] ?? { status: "idle" }])), activities: this.activities, usages: this.usages, plans: this.plans, statuses, appearance: { theme: p.appearanceTheme as string, accent: p.accentTheme as string, heatmap: p.showHeatmap === true, claudeHeatmap: p.showClaudeHeatmap === true, codexHeatmap: p.showCodexHeatmap === true, pacing: p.showPacingBurnRate === true, telemetry: p.showActivityTelemetry === true, chart: p.showDailyActivityChart === true, resetButton: p.showSideNotchResetButton === true, compactTray: p.menuBarCompactEnabled === true, notch: p.sideNotchPanelEnabled === true, pinned: false, onboarding: p.onboardingCompleted === true }, historyError: this.history.error, archiveWriteFailed: this.archiveWriteFailed, clearingCache: this.clearingCache });
}
subscribe(observer: (state: Snapshot) => void) { this.observers.add(observer); return () => this.observers.delete(observer); }
publish() { const state = this.snapshot(); for (const observe of this.observers) observe(state); }
start() { this.stopped = false; this.restartTimer(); return this.refresh(); }
restartTimer() { if (this.timer) clearInterval(this.timer); this.timer = setInterval(() => { void this.refresh(true); }, this.preferences.refreshInterval * 1000); }
- stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); this.timer = undefined; this.pendingForced = false; for (const c of this.controllers) c.abort(); this.confirmations.clear(); }
+ stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); this.timer = undefined; this.pendingForced = false; for (const c of this.controllers) c.abort(); for (const c of this.resets.values()) c.abort(); this.confirmations.clear(); }
refreshIfStale() { return this.lastRefreshedAt === undefined || this.now() - this.lastRefreshedAt > 20000 ? this.refresh() : Promise.resolve(); }
refresh(scheduled = false, onlyKey?: string): Promise {
if (this.stopped) return Promise.resolve();
@@ -93,6 +98,7 @@ export class Coordinator {
for (const source of this.sources) {
const key = slotKey(source.slot); if (onlyKey && key !== onlyKey) continue;
if (this.visible(source)) {
+ if (source.accountActivity) tasks.push(this.load("accountActivity", source, force, source.accountActivity, this.accountActivities));
if (source.quota) tasks.push(this.load("quota", source, force, source.quota, this.quotas));
if (source.activity) tasks.push(this.load("activity", source, force, source.activity, this.activities));
if (source.usage) tasks.push(this.load("usage", source, force, source.usage, this.usages));
@@ -129,7 +135,7 @@ export class Coordinator {
} catch { this.archiveWriteFailed = true; }
}
forgetQuota(key: string) {
- delete this.quotas[key]; delete this.archived[key]; delete this.plans[key];
+ delete this.accountActivities[key]; delete this.quotas[key]; delete this.archived[key]; delete this.plans[key];
this.evaluator.forgetSlot(key);
this.backoffs.delete(`quota-${key}`); this.persistArchive();
}
@@ -154,7 +160,13 @@ export class Coordinator {
result = value(resetPacing(q, previous, previous.resetPacingSince, q.capturedAt) as T);
}
}
- target[key] = result;
+ const priorStatus = kind === "status" ? target[key] as Loaded | undefined : undefined;
+ if (kind === "status" && result.status === "missing") {
+ const failure = "Service status unavailable";
+ target[key] = priorStatus?.status === "value"
+ ? value({ ...priorStatus.value, stale: true, refreshError: failure }) as Loaded
+ : { ...result, reason: failure };
+ } else target[key] = result;
if (kind === "quota" && source.accountBound && result.status === "missing" && (controller.signal.aborted || ["notSignedIn", "noData"].includes(result.code))) delete this.archived[key];
if (result.status === "missing" && ["failed", "offline"].includes(result.code)) { const failures = (prior?.failures ?? 0) + 1; this.backoffs.set(backoffKey, { failures, next: this.now() + backoffDelay(failures) }); }
else this.backoffs.delete(backoffKey);
@@ -172,9 +184,9 @@ export class Coordinator {
if (!intent || intent.expires <= this.now() || this.resets.has(intent.key)) throw new Error("Reset confirmation expired");
const source = this.sources.find(s => slotKey(s.slot) === intent.key && this.visible(s)); const q = this.quotas[intent.key];
if (source !== intent.source || !source?.consumeReset || q?.status !== "value" || !q.value.resetCredits.some(c => c.id === intent.creditID && resetCreditAvailable(c, this.now()))) throw new Error("Reset credit unavailable");
- this.resets.add(intent.key);
+ const controller = new AbortController(); this.resets.set(intent.key, controller);
try {
- if (!await source.consumeReset(intent.creditID)) throw new Unavailable("failed", "Codex");
+ if (!await source.consumeReset(intent.creditID, controller.signal)) throw new Unavailable("failed", "Codex");
if (this.stopped || !this.sources.includes(source) || !this.visible(source)) throw new Error("Reset source replaced");
const acceptedAt = this.now();
const latest = this.quotas[intent.key];
@@ -197,10 +209,12 @@ export class Coordinator {
}
diagnostics(): string {
const describe = (s: Loaded | undefined) => !s || s.status === "idle" ? "not checked yet" : s.status === "value" ? "ok" : `unavailable (${s.code})`;
+ const describeStatus = (s: Loaded | undefined) => s?.status === "value" && s.value.stale
+ ? `stale (last successful check: ${Math.max(0, Math.trunc((this.now() - s.value.checkedAt) / 1000))}s ago; Service status unavailable)` : describe(s);
const slots = this.slots, lines = ["MeterUsage 0.2.41", `mode: ${this.launch.demo ? "demo" : "live"}`, `refresh interval: ${this.preferences.refreshInterval}s`, `last refresh: ${this.lastRefreshedAt === undefined ? "never" : Math.max(0, Math.trunc((this.now() - this.lastRefreshedAt) / 1000)) + "s ago"}`, `enabled: ${slots.map(s => s.provider + (s.slotID ? "#additional" : "")).join(", ")}`];
if (this.history.error) lines.push(`history: ${this.history.error}`);
if (this.archiveWriteFailed) lines.push("archive: writeFailed");
- for (const [index, s] of slots.entries()) { const key = slotKey(s); lines.push(`\n[${s.provider}${s.slotID ? ` account ${index + 1}` : ""}]`, ` quota: ${describe(this.quotas[key])}`, ` activity: ${describe(this.activities[key])}`, ` usage: ${describe(this.usages[key])}`, ` status: ${describe(this.statuses[s.provider])}`, ` plan: ${describe(this.plans[key])}`); }
+ for (const [index, s] of slots.entries()) { const key = slotKey(s); lines.push(`\n[${s.provider}${s.slotID ? ` account ${index + 1}` : ""}]`, ` quota: ${describe(this.quotas[key])}`, ` activity: ${describe(this.activities[key])}`, ` usage: ${describe(this.usages[key])}`, ` status: ${describeStatus(this.statuses[s.provider])}`, ` plan: ${describe(this.plans[key])}`); }
return lines.join("\n");
}
json() { return canonicalJSON(limitsReport(this.slots.filter(s => this.sources.some(src => src.quota && slotKey(src.slot) === slotKey(s))), this.quotas, this.burns, this.now())); }
diff --git a/src/main/electron.ts b/src/main/electron.ts
index 1639a2d..9dd745d 100644
--- a/src/main/electron.ts
+++ b/src/main/electron.ts
@@ -7,6 +7,7 @@ import { launchConfiguration, type Launch } from "./launch";
import { Preferences } from "./preferences";
import { compose } from "./composition";
import { DesktopConnections } from "./connections";
+import { desktopReadErrors } from "./providers/claude-desktop";
import { Coordinator } from "./coordinator";
import { NotchFold } from "./notch-fold";
import { Updater } from "./updater";
@@ -17,6 +18,8 @@ import { providers, slotKey } from "../domain/models";
import { notchFrame, cardOnRight, stripWidth, cardWidth, minimumShareSize } from "../domain/notch";
import { trayTooltip } from "../domain/overview";
+const shareCardWidth = 250;
+
const args = process.argv.slice(1);
const desktopHelper = app.isPackaged ? join(process.resourcesPath, "desktop-keychain") : join(__dirname, "../../build/desktop-keychain");
void main().catch(() => { process.stderr.write("MeterUsage could not start.\n"); app.exit(1); });
@@ -60,7 +63,7 @@ async function start(launch: Launch) {
}, sources);
// Do not restore a legacy archive under a newly connected account.
for (const p of ["codex", "claude"] as const) if (connections.configured(p)) coordinator.forgetQuota(p);
- const notch = { expanded: prefs.values.sideNotchPanelPinned === true, cardOnRight: false, selected: "codex", dragging: false };
+ const notch = { expanded: false, cardOnRight: false, selected: "codex", dragging: false };
let notchHeight = 200, anchor: { x: number; y: number } | undefined;
let flyoutHeight = 700;
const panelScale = () => panelScales[prefs.values.panelSize as PanelSize] ?? 1;
@@ -78,7 +81,21 @@ async function start(launch: Launch) {
const updater = new Updater(prefs, app.getVersion(), publish, undefined, release => {
if (!launch.demo && !launch.candidate && Notification.isSupported()) new Notification({ title: "MeterUsage update available", body: `MeterUsage ${release.version} is ready to install.`, silent: true }).show();
});
- const folding = new NotchFold(() => prefs.values.sideNotchPanelPinned === true || sharing || confirmingReset || notch.dragging, () => { notch.expanded = false; placeNotch(); publish(); });
+ let hoverCheck: ReturnType | undefined;
+ const folding = new NotchFold(() => sharing || confirmingReset || notch.dragging, () => { notch.expanded = false; placeNotch(); publish(); });
+ function stopHoverCheck() { if (hoverCheck) clearInterval(hoverCheck); hoverCheck = undefined; }
+ function pointerInNotch() {
+ const w = windows.get("notch"); if (!w || w.isDestroyed()) return false;
+ const b = w.getBounds(), p = screen.getCursorScreenPoint();
+ return p.x >= b.x && p.x < b.x + b.width && p.y >= b.y && p.y < b.y + b.height;
+ }
+ function watchNotchHover() {
+ if (hoverCheck) return;
+ // Non-activating panels cannot blur; recover a missed Chromium pointerleave.
+ hoverCheck = setInterval(() => {
+ if (!pointerInNotch()) { stopHoverCheck(); folding.hovered = false; folding.changed(); }
+ }, 100);
+ }
function publish() {
if (tray.isDestroyed()) return;
const s = state();
@@ -92,13 +109,13 @@ async function start(launch: Launch) {
function create(surface: Surface) {
const existing = windows.get(surface); if (existing && !existing.isDestroyed()) return existing;
const panel = surface === "notch", trayImage = surface === "tray", shareImage = surface === "share";
- const w = new BrowserWindow({ width: shareImage ? cardWidth * 2 : trayImage ? 1000 : panel ? stripWidth : surface === "settings" ? 620 : Math.round(420 * panelScale()), height: trayImage ? 22 : panel ? notchHeight : surface === "settings" ? 680 : Math.ceil(flyoutHeight * panelScale()), show: false, frame: surface === "settings", transparent: panel || trayImage || shareImage, resizable: surface === "settings", minimizable: false, maximizable: false, fullscreenable: false, skipTaskbar: true, ...(panel ? { type: "panel", focusable: false, hasShadow: false } : {}), title: surface === "settings" ? "MeterUsage Settings" : "MeterUsage", backgroundColor: panel || trayImage || shareImage ? "#00000000" : "#242428", webPreferences: { preload: join(__dirname, "../preload/preload.cjs"), offscreen: trayImage || shareImage, zoomFactor: shareImage ? 2 : 1, sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, spellcheck: false } });
+ const w = new BrowserWindow({ width: shareImage ? shareCardWidth * 2 : trayImage ? 1000 : panel ? stripWidth : surface === "settings" ? 620 : Math.round(420 * panelScale()), height: trayImage ? 22 : panel ? notchHeight : surface === "settings" ? 680 : Math.ceil(flyoutHeight * panelScale()), show: false, frame: surface === "settings", transparent: panel || trayImage || shareImage, resizable: surface === "settings", minimizable: false, maximizable: false, fullscreenable: false, skipTaskbar: true, ...(panel ? { type: "panel", focusable: false, hasShadow: false } : {}), title: surface === "settings" ? "MeterUsage Settings" : "MeterUsage", backgroundColor: panel || trayImage || shareImage ? "#00000000" : "#242428", webPreferences: { preload: join(__dirname, "../preload/preload.cjs"), offscreen: trayImage || shareImage, zoomFactor: shareImage ? 2 : 1, sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, spellcheck: false } });
if (trayImage) { w.webContents.setFrameRate(10); w.webContents.on("paint", (_event, _rect, image) => { if (!tray.isDestroyed() && trayWidth > 0) tray.setImage(image); }); }
if (shareImage) {
w.webContents.setFrameRate(10);
w.webContents.on("paint", (_event, _rect, image) => {
const size = image.getSize();
- if (shareReady && shareHeight > 0 && size.width >= cardWidth * 2 && size.height >= shareHeight) { const ready = shareReady; shareReady = undefined; ready(image); }
+ if (shareReady && shareHeight > 0 && size.width >= shareCardWidth * 2 && size.height >= shareHeight) { const ready = shareReady; shareReady = undefined; ready(image); }
});
}
const document = pathToFileURL(join(__dirname, "../renderer/index.html")); document.searchParams.set("surface", surface);
@@ -120,7 +137,7 @@ async function start(launch: Launch) {
}).catch(() => { dialog.showErrorBox("Could not change panel size", "Your panel size was not saved. Try again."); });
});
}
- w.on("closed", () => { documents.delete(webContentsId); if (windows.get(surface) === w) windows.delete(surface); if (panel) finishDrag(); });
+ w.on("closed", () => { documents.delete(webContentsId); if (windows.get(surface) === w) windows.delete(surface); if (panel) { stopHoverCheck(); finishDrag(); } });
if (surface === "flyout") w.on("blur", () => { if (!sharing) w.hide(); });
if (panel) { w.setAlwaysOnTop(true, "status"); w.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); }
void w.loadURL(document.href).then(() => { publish(); if (panel) { placeNotch(); w.showInactive(); } });
@@ -160,12 +177,14 @@ async function start(launch: Launch) {
}
function syncNotch() {
if (prefs.values.sideNotchPanelEnabled) { create("notch"); placeNotch(); windows.get("notch")?.showInactive(); }
- else { finishDrag(); windows.get("notch")?.hide(); }
+ else { finishDrag(); stopHoverCheck(); folding.hovered = false; folding.cancel(); notch.expanded = false; windows.get("notch")?.hide(); }
}
function finishDrag() {
if (!drag) return;
clearInterval(drag.timer); clearTimeout(drag.timeout); drag = undefined; notch.dragging = false;
- notch.expanded = prefs.values.sideNotchPanelPinned === true || folding.hovered; folding.changed();
+ folding.hovered = pointerInNotch();
+ notch.expanded = folding.hovered; folding.changed();
+ if (folding.hovered) watchNotchHover();
if (anchor) {
const primary = screen.getPrimaryDisplay().bounds;
void prefs.set("sideNotchPanelCorner", `${anchor.x},${primary.y + primary.height - anchor.y}`).catch(() => {});
@@ -175,7 +194,7 @@ async function start(launch: Launch) {
function startDrag() {
if (drag) return;
const w = windows.get("notch"); if (!w) return;
- folding.cancel(); notch.expanded = false; placeNotch();
+ stopHoverCheck(); folding.hovered = false; folding.cancel(); notch.expanded = false; placeNotch();
const bounds = w.getBounds(); anchor = { x: bounds.x + stripWidth, y: bounds.y };
notch.dragging = true;
const origin = { ...anchor }, cursor = screen.getCursorScreenPoint();
@@ -210,12 +229,17 @@ async function start(launch: Launch) {
case "connectionCancel":
if (surface !== "settings" && surface !== "flyout") throw new Error("Invalid request");
connections.cancel(r.provider); break;
- case "disconnect":
+ case "disconnect": case "connectionLocal":
if (surface !== "settings" && surface !== "flyout") throw new Error("Invalid request");
- await connections.disconnect(r.provider);
+ if (r.action === "connectionLocal") await connections.useLocal(r.provider);
+ else await connections.disconnect(r.provider);
await coordinator.refresh(); break;
case "copyDiagnostics": clipboard.writeText(coordinator.diagnostics()); break;
case "copyJSON": clipboard.writeText(coordinator.json()); break;
+ case "providerOrder":
+ if (surface !== "settings") throw new Error("Invalid request");
+ await prefs.set("providerOrder", r.order);
+ coordinator.publish(); break;
case "setPreference":
if (r.key === "launchAtLogin") {
if (launch.demo || launch.candidate || !app.isPackaged) throw new Error("Login items require an installed app");
@@ -226,7 +250,6 @@ async function start(launch: Launch) {
if (r.key === "onboardingCompleted" && r.value === true && surface === "settings" && !quitting && !source.isDestroyed()) { source.hide(); showFlyout(); }
if (r.key === "panelSize") sizeFlyout();
if (r.key === "refreshIntervalSeconds") coordinator.restartTimer();
- if (r.key === "sideNotchPanelPinned") { notch.expanded = r.value === true || folding.hovered; folding.changed(); }
nativeTheme.themeSource = prefs.values.appearanceTheme as "system" | "dark" | "light";
if (r.key === "updateCheckEnabled") { if (r.value) void updater.checkIfDue(); else updater.reset(); }
syncNotch(); coordinator.publish(); if (r.key.startsWith("showProvider")) await coordinator.refresh(); break;
@@ -263,16 +286,17 @@ async function start(launch: Launch) {
case "notchSelect":
if (notch.dragging) break;
if (!coordinator.snapshot().notchSlots.some(s => slotKey(s) === r.key)) throw new Error("Unknown account");
- notch.selected = r.key; notch.expanded = true; placeNotch(); break;
+ notch.selected = r.key; notch.expanded = true; placeNotch(); watchNotchHover(); break;
case "notchHover":
if (surface !== "notch" || notch.dragging) break;
folding.hovered = r.hovering;
- if (r.hovering) { notch.expanded = true; placeNotch(); }
+ if (r.hovering) { notch.expanded = true; placeNotch(); watchNotchHover(); }
+ else stopHoverCheck();
folding.changed();
break;
case "notchContext":
if (surface !== "notch") throw new Error("Invalid request");
- Menu.buildFromTemplate([{ label: "Keep open", type: "checkbox", checked: prefs.values.sideNotchPanelPinned === true, click: () => { void handle({ action: "setPreference", key: "sideNotchPanelPinned", value: prefs.values.sideNotchPanelPinned !== true }, source, surface); } }, { label: "Refresh now", click: () => { void coordinator.refresh(); } }, { type: "separator" }, { label: "Hide panel", click: () => { void handle({ action: "setPreference", key: "sideNotchPanelEnabled", value: false }, source, surface); } }]).popup({ window: source });
+ Menu.buildFromTemplate([{ label: "Refresh now", click: () => { void coordinator.refresh(); } }, { type: "separator" }, { label: "Hide panel", click: () => { void handle({ action: "setPreference", key: "sideNotchPanelEnabled", value: false }, source, surface); } }]).popup({ window: source });
break;
case "resize":
if (surface === "notch") { notchHeight = Math.min(2000, Math.ceil(r.height)); placeNotch(); }
@@ -281,7 +305,7 @@ async function start(launch: Launch) {
break;
case "shareResize":
if (surface !== "share" || !shareReady) throw new Error("Invalid request");
- try { const size = minimumShareSize(cardWidth, r.height, 2); shareHeight = size.height; source.setSize(size.width, size.height, false); }
+ try { const size = minimumShareSize(shareCardWidth, r.height, 2); shareHeight = size.height; source.setSize(size.width, size.height, false); }
catch (e) { shareFailed?.(new Error(e instanceof Error && e.message === "Card too tall to share" ? e.message : "Could not share snapshot")); }
break;
case "dragStart": if (surface === "notch") startDrag(); break;
@@ -324,7 +348,7 @@ async function start(launch: Launch) {
const source = BrowserWindow.fromWebContents(event.sender), surface = [...windows].find(([, w]) => w === source)?.[0];
if (!source || !surface) return { ok: false, error: "Unknown window" };
try { return await handle(parseRequest(raw), source, surface); }
- catch (e) { return { ok: false, error: e instanceof Error && ["Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share", "Open Codex on this Mac, then try connecting again", "Open Claude Desktop and sign in, then try connecting again", "Could not clear the cancelled Codex connection", "Could not disconnect Codex", "Connections are disabled in demo mode", "Automatic Grok connection is unavailable"].includes(e.message) ? e.message : "Could not complete action" }; }
+ catch (e) { return { ok: false, error: e instanceof Error && ["Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share", "Open Codex on this Mac, then try connecting again", "Open Claude Desktop and sign in, then try connecting again", "Could not access Claude Desktop through macOS Keychain. Check the access prompt, then try connecting again.", ...Object.values(desktopReadErrors), "Could not read Claude account allowance. Try connecting again later.", "Could not verify the same Claude Desktop account after the allowance request. Try connecting again.", "Could not save the Claude Desktop connection preferences. Try connecting again.", "Could not clear the cancelled Codex connection", "Could not disconnect Codex", "Could not disconnect Claude Desktop", "Could not select Grok CLI", "Connections are disabled in demo mode", "Automatic Grok connection is unavailable"].includes(e.message) ? e.message : "Could not complete action" }; }
});
const unsubscribe = coordinator.subscribe(() => { syncNotch(); publish(); if (!coordinator.snapshot().refreshing) void updater.checkIfDue(); });
screen.on("display-metrics-changed", () => { placeNotch(); sizeFlyout(); publish(); }); screen.on("display-removed", () => { placeNotch(); sizeFlyout(); publish(); });
@@ -339,7 +363,7 @@ async function start(launch: Launch) {
if (quitting) return;
quitting = true;
unsubscribe();
- coordinator.stop(); updater.reset(); clearInterval(clockTimer); if (drag) { clearInterval(drag.timer); clearTimeout(drag.timeout); } folding.cancel();
+ coordinator.stop(); updater.reset(); stopHoverCheck(); clearInterval(clockTimer); if (drag) { clearInterval(drag.timer); clearTimeout(drag.timeout); } folding.cancel();
void connections.stop().finally(() => {
if (shareDirectory) rmSync(shareDirectory, { recursive: true, force: true }); for (const directory of sharedDirectories) rmSync(directory, { recursive: true, force: true }); tray.destroy();
quitReady = true; app.quit();
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 3fc256a..037fb20 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -3,6 +3,7 @@ import type { ManagedAccount } from "../domain/models";
import type { PreferenceValue } from "./preferences";
import type { Surface, SettingsState, ViewState } from "../shared/ipc";
import { notchEntries } from "../domain/notch";
+import { normalizeProviderOrder, validProviderOrder } from "../domain/provider-order";
import { providers } from "../domain/models";
import { editableBooleans, providerKey, trayKey, statusPages, panelScales, type Request } from "../shared/ipc";
const empty = new Set(["state", "refresh", "settings", "close", "quit", "clearCache", "copyDiagnostics", "copyJSON", "dragStart", "dragEnd", "notchContext", "updateInstall", "updateDismiss"]);
@@ -20,8 +21,9 @@ export function parseRequest(raw: unknown): Request {
fields = ["key", "value"];
valid = typeof r.key === "string" && (boolKeys.has(r.key) ? typeof r.value === "boolean" : r.key === "refreshIntervalSeconds" ? typeof r.value === "number" && [30, 60, 120, 300, 900].includes(r.value) : r.key === "appearanceTheme" ? typeof r.value === "string" && ["system", "light", "dark"].includes(r.value) : r.key === "panelSize" ? typeof r.value === "string" && Object.hasOwn(panelScales, r.value) : r.key === "accentTheme" && typeof r.value === "string" && ["blue", "violet", "teal", "amber", "rose", "graphite"].includes(r.value));
break;
+ case "providerOrder": fields = ["order"]; valid = validProviderOrder(r.order); break;
case "accountAdd": fields = ["provider"]; valid = r.provider === "codex" || r.provider === "claude"; break;
- case "connect": case "disconnect": case "connectionCancel": fields = ["provider"]; valid = typeof r.provider === "string" && ["codex", "claude", "grok"].includes(r.provider); break;
+ case "connect": case "disconnect": case "connectionCancel": case "connectionLocal": fields = ["provider"]; valid = typeof r.provider === "string" && ["codex", "claude", "grok"].includes(r.provider); break;
case "statusPage": fields = ["provider"]; valid = typeof r.provider === "string" && Object.hasOwn(statusPages, r.provider); break;
case "accountUpdate": fields = ["id", ...("label" in r ? ["label"] : []), ...("enabled" in r ? ["enabled"] : [])]; valid = id(r.id) && fields.length > 1 && (!("label" in r) || string(r.label, 100)) && (!("enabled" in r) || typeof r.enabled === "boolean"); break;
case "accountRemove": case "accountPath": fields = ["id"]; valid = id(r.id); break;
@@ -48,6 +50,7 @@ export function shareSnapshot(view: ViewState, displayedKey: string): ViewState
export function projectSettings(values: Record, accounts: ManagedAccount[], home: string, surface?: Surface): SettingsState {
return {
+ providerOrder: normalizeProviderOrder(values.providerOrder),
values: Object.fromEntries(["refreshIntervalSeconds", "appearanceTheme", "accentTheme", "panelSize", ...editableBooleans, ...providers.flatMap(p => [providerKey(p), trayKey(p)])].map(k => [k, values[k] as boolean | number | string])),
accounts: accounts.map(a => {
const sub = relative(home, a.path), pathLabel = a.path.startsWith("~") ? a.path : !sub.startsWith("..") ? `~/${sub}` : basename(a.path);
diff --git a/src/main/launch.ts b/src/main/launch.ts
index aefc659..e5ed44e 100644
--- a/src/main/launch.ts
+++ b/src/main/launch.ts
@@ -1,12 +1,13 @@
import { userInfo, tmpdir, hostname } from "node:os";
import { mkdtempSync, mkdirSync, realpathSync, existsSync, readdirSync, writeFileSync, lstatSync, readlinkSync } from "node:fs";
import { isAbsolute, join, resolve, relative, dirname } from "node:path";
-export interface Launch { demo: boolean; candidate: boolean; home: string; data: string; env: NodeJS.ProcessEnv; profile?: string; codexTest?: boolean }
+export interface Launch { demo: boolean; candidate: boolean; home: string; data: string; env: NodeJS.ProcessEnv; profile?: string; codexTest?: boolean; claudeTest?: boolean }
export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = process.env, realHome = userInfo().homedir, electronSingletons = false): Launch {
- const demo = args.includes("--demo") || env.METERUSAGE_DEMO === "1", candidateIndex = args.indexOf("--candidate-profile"), codexIndex = args.indexOf("--codex-test-profile"), codexTest = codexIndex >= 0;
- if (codexTest && (demo || candidateIndex >= 0)) throw new Error("Codex test profile flags conflict with demo/candidate mode");
- const index = codexTest ? codexIndex : candidateIndex, profile = index < 0 ? undefined : args[index + 1];
- if (index >= 0 && ((!codexTest && !demo) || !profile || !isAbsolute(profile))) throw new Error("Candidate profile requires an absolute test directory and --demo unless using --codex-test-profile");
+ const demo = args.includes("--demo") || env.METERUSAGE_DEMO === "1", candidateIndex = args.indexOf("--candidate-profile"), codexIndex = args.indexOf("--codex-test-profile"), claudeIndex = args.indexOf("--claude-test-profile"), codexTest = codexIndex >= 0, claudeTest = claudeIndex >= 0;
+ const liveTest = codexTest || claudeTest;
+ if (liveTest && (demo || candidateIndex >= 0) || codexTest && claudeTest) throw new Error("Provider test profile flags conflict with demo/candidate or another provider test mode");
+ const index = codexTest ? codexIndex : claudeTest ? claudeIndex : candidateIndex, profile = index < 0 ? undefined : args[index + 1];
+ if (index >= 0 && ((!liveTest && !demo) || !profile || !isAbsolute(profile))) throw new Error("Candidate profile requires an absolute test directory and --demo unless using --codex-test-profile or --claude-test-profile");
if (profile) {
const normalized = resolve(profile), liveData = join(realHome, "Library/Application Support/MeterUsage");
const inside = relative(liveData, normalized);
@@ -15,7 +16,7 @@ export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = pro
while (!existsSync(ancestor)) ancestor = dirname(ancestor);
if (realpathSync(ancestor) !== ancestor) throw new Error("Candidate profile cannot follow a symlink");
mkdirSync(normalized, { recursive: true, mode: 0o700 });
- const marker = join(normalized, codexTest ? ".meterusage-codex-test" : ".meterusage-candidate");
+ const marker = join(normalized, codexTest ? ".meterusage-codex-test" : claudeTest ? ".meterusage-claude-test" : ".meterusage-candidate");
if (existsSync(marker) && !lstatSync(marker).isFile()) throw new Error("Candidate profile marker must be a regular file, not a symlink");
if (!existsSync(marker) && readdirSync(normalized).length) throw new Error("Candidate profile must be an empty test directory");
// Validate metadata before Electron or the helper can write into this profile.
@@ -34,12 +35,12 @@ export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = pro
else if (!stat.isFile()) throw new Error("Candidate profile must contain only regular files and directories");
}
if (singletonLinks) validateElectronSingletons(singletonPaths);
- if (!existsSync(marker)) writeFileSync(marker, codexTest ? "MeterUsage Codex test profile\n" : "Synthetic MeterUsage candidate profile\n", { mode: 0o600 });
+ if (!existsSync(marker)) writeFileSync(marker, liveTest ? `MeterUsage ${codexTest ? "Codex" : "Claude"} test profile\n` : "Synthetic MeterUsage candidate profile\n", { mode: 0o600 });
}
// Demo always isolates preferences, provider sources, caches and Electron's
// userData before any provider or coordinator is constructed.
- const isolated = codexTest ? resolve(profile!) : demo ? profile ?? mkdtempSync(join(tmpdir(), "meterusage-demo-")) : undefined;
- return { demo, candidate: index >= 0, ...(codexTest ? { codexTest: true } : {}), home: demo ? isolated! : realHome, data: isolated ?? join(realHome, "Library/Application Support/MeterUsage"), profile: isolated, env: codexTest ? { PATH: env.PATH, TMPDIR: env.TMPDIR } : demo ? {} : { ...env } };
+ const isolated = liveTest ? resolve(profile!) : demo ? profile ?? mkdtempSync(join(tmpdir(), "meterusage-demo-")) : undefined;
+ return { demo, candidate: index >= 0, ...(codexTest ? { codexTest: true } : {}), ...(claudeTest ? { claudeTest: true } : {}), home: demo ? isolated! : realHome, data: isolated ?? join(realHome, "Library/Application Support/MeterUsage"), profile: isolated, env: liveTest ? { PATH: env.PATH, TMPDIR: env.TMPDIR } : demo ? {} : { ...env } };
}
function validateElectronSingletons([socketLink, cookieLink, lockLink]: string[]) {
diff --git a/src/main/preferences.ts b/src/main/preferences.ts
index 3ba957c..8059c98 100644
--- a/src/main/preferences.ts
+++ b/src/main/preferences.ts
@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { providers, type Provider, type ManagedAccount } from "../domain/models";
+import { normalizeProviderOrder, validProviderOrder } from "../domain/provider-order";
import { atomicJSON } from "./history";
import { runCommand, type Command } from "./providers/transport";
import { type Launch } from "./launch";
@@ -9,16 +10,16 @@ export const defaultsDomain = "dev.meterusage.app";
const suffix = (p: Provider) => p[0].toUpperCase() + p.slice(1);
export const providerPreference = (p: Provider) => `showProvider${suffix(p)}`;
export const trayPreference = (p: Provider) => `menuBarProvider${suffix(p)}`;
-export type PreferenceValue = boolean | number | string | ManagedAccount[];
+export type PreferenceValue = boolean | number | string | ManagedAccount[] | Provider[];
type Kind = "boolean" | "number" | "string" | "data" | "date";
const kinds: Record = {
- refreshIntervalSeconds: "number", appearanceTheme: "string", accentTheme: "string", panelSize: "string", managedAccounts: "data",
+ refreshIntervalSeconds: "number", appearanceTheme: "string", accentTheme: "string", panelSize: "string", managedAccounts: "data", providerOrder: "data",
sideNotchPanelCorner: "string", updateLastCheckDate: "date", updateDismissedVersion: "string", updateAnnouncedVersion: "string",
desktopCodexConnection: "string", desktopCodexCleanup: "string", desktopClaudeIdentity: "string",
...Object.fromEntries(["launchAtLogin", "showHeatmap", "showClaudeHeatmap", "showCodexHeatmap", "quotaAlertsEnabled", "sideNotchPanelEnabled", "sideNotchPanelPinned", "menuBarCompactEnabled", "onboardingCompleted", "updateCheckEnabled", "showPacingBurnRate", "showActivityTelemetry", "showDailyActivityChart", "showSideNotchResetButton", ...providers.flatMap(p => [providerPreference(p), trayPreference(p)])].map(k => [k, "boolean" as const])),
};
export const initialPreferences: Record = {
- refreshIntervalSeconds: 60, appearanceTheme: "system", accentTheme: "blue", panelSize: PanelSize.Medium, managedAccounts: [], sideNotchPanelCorner: "",
+ refreshIntervalSeconds: 60, appearanceTheme: "system", accentTheme: "blue", panelSize: PanelSize.Medium, managedAccounts: [], providerOrder: [...providers], sideNotchPanelCorner: "",
launchAtLogin: false, showHeatmap: true, showClaudeHeatmap: true, showCodexHeatmap: true, quotaAlertsEnabled: false,
sideNotchPanelEnabled: false, sideNotchPanelPinned: false, menuBarCompactEnabled: true, onboardingCompleted: false,
updateCheckEnabled: true, showPacingBurnRate: true, showActivityTelemetry: true, showDailyActivityChart: true, showSideNotchResetButton: true,
@@ -30,11 +31,16 @@ const codexTestPreferences: Record = {
launchAtLogin: false, updateCheckEnabled: false, desktopClaudeIdentity: "off",
...Object.fromEntries(providers.filter(p => p !== "codex").map(p => [providerPreference(p), false])),
};
+const claudeTestPreferences: Record = {
+ launchAtLogin: false, updateCheckEnabled: false, desktopCodexConnection: "off", desktopCodexCleanup: "",
+ ...Object.fromEntries(providers.filter(p => p !== "claude").map(p => [providerPreference(p), false])),
+};
export function validAccounts(raw: unknown): raw is ManagedAccount[] {
return Array.isArray(raw) && raw.every(a => a && typeof a === "object" && typeof a.id === "string" && /^[A-Za-z0-9-]{1,80}$/.test(a.id) && ["codex", "claude"].includes(a.provider) && typeof a.label === "string" && a.label.length <= 100 && !/[\r\n]/.test(a.label) && typeof a.path === "string" && a.path.length <= 4096 && !/[\r\n\0]/.test(a.path) && typeof a.enabled === "boolean") && new Set(raw.map(a => a.id)).size === raw.length;
}
function validate(key: string, v: unknown): v is PreferenceValue {
const kind = kinds[key]; if (!kind) return false;
+ if (key === "providerOrder") return validProviderOrder(v);
if (kind === "data") return validAccounts(v);
if (kind === "date" || kind === "number") return typeof v === "number" && Number.isFinite(v);
if (kind === "boolean") return typeof v === "boolean";
@@ -54,9 +60,10 @@ export class Preferences {
static async load(launch: Launch, command: Command = runCommand, domain = defaultsDomain): Promise {
const prefs = new Preferences(launch, command, domain);
if (launch.codexTest) prefs.values.desktopCodexConnection = "off";
- if (launch.demo || launch.codexTest) {
+ if (launch.claudeTest) { prefs.values.desktopClaudeIdentity = "off"; prefs.values.showProviderClaude = true; }
+ if (launch.demo || launch.codexTest || launch.claudeTest) {
const path = join(launch.data, "preferences.json");
- if (existsSync(path)) { try { const raw = JSON.parse(readFileSync(path, "utf8")); for (const [key, v] of Object.entries(raw)) if (validate(key, v)) prefs.values[key] = v; } catch { /* Keep known defaults for a damaged candidate file. */ } }
+ if (existsSync(path)) { try { const raw = JSON.parse(readFileSync(path, "utf8")); for (const [key, v] of Object.entries(raw)) if (key === "providerOrder") prefs.values[key] = normalizeProviderOrder(v); else if (validate(key, v)) prefs.values[key] = v; } catch { /* Keep known defaults for a damaged candidate file. */ } }
else if (launch.demo) for (const p of providers) prefs.values[providerPreference(p)] = true;
} else {
await Promise.all(Object.keys(kinds).map(async key => {
@@ -69,16 +76,18 @@ export class Preferences {
else if (kind === "number" && /integer|float|double/i.test(type)) value = Number(raw);
else if (kind === "date" && /date/i.test(type)) value = Date.parse(raw);
else if (kind === "string" && /string/i.test(type)) value = raw;
- if (validate(key, value)) prefs.values[key] = value;
+ if (key === "providerOrder") prefs.values[key] = normalizeProviderOrder(value); else if (validate(key, value)) prefs.values[key] = value;
} catch { /* An absent or mismatched key retains its registered default. */ }
}));
}
if (launch.codexTest) Object.assign(prefs.values, codexTestPreferences);
+ if (launch.claudeTest) Object.assign(prefs.values, claudeTestPreferences);
prefs.values.refreshIntervalSeconds = prefs.refreshInterval; return prefs;
}
get refreshInterval() { const stored = this.values.refreshIntervalSeconds as number; return Math.max(30, stored > 0 ? stored : 60); }
+ get providerOrder() { return normalizeProviderOrder(this.values.providerOrder); }
get accounts() { return this.values.managedAccounts as ManagedAccount[]; }
- enabled(p: Provider) { return (!this.launch.codexTest || p === "codex") && this.values[providerPreference(p)] === true; }
+ enabled(p: Provider) { return (!this.launch.codexTest || p === "codex") && (!this.launch.claudeTest || p === "claude") && this.values[providerPreference(p)] === true; }
private enqueue(write: () => Promise) {
const result = this.writes.then(write); this.writes = result.catch(() => {}); return result;
}
@@ -89,8 +98,9 @@ export class Preferences {
private async write(key: string, value: unknown) {
if (!validate(key, value)) throw new Error("Invalid preference");
if (this.launch.codexTest && key in codexTestPreferences && value !== codexTestPreferences[key]) throw new Error("Preference unavailable in Codex test mode");
+ if (this.launch.claudeTest && key in claudeTestPreferences && value !== claudeTestPreferences[key]) throw new Error("Preference unavailable in Claude test mode");
if (key === "refreshIntervalSeconds") value = Math.max(30, (value as number) > 0 ? value as number : 60);
- if (this.launch.demo || this.launch.codexTest) {
+ if (this.launch.demo || this.launch.codexTest || this.launch.claudeTest) {
const next = { ...this.values, [key]: value as PreferenceValue }; atomicJSON(join(this.launch.data, "preferences.json"), next);
} else {
const kind = kinds[key]; const typed = kind === "data" ? ["-data", Buffer.from(JSON.stringify(value)).toString("hex")] : kind === "date" ? ["-date", new Date(value as number).toISOString()] : kind === "boolean" ? ["-bool", value ? "true" : "false"] : kind === "number" ? ["-float", String(value)] : ["-string", value as string];
diff --git a/src/main/providers/claude-desktop.ts b/src/main/providers/claude-desktop.ts
index b3ff828..0ec4e6a 100644
--- a/src/main/providers/claude-desktop.ts
+++ b/src/main/providers/claude-desktop.ts
@@ -7,6 +7,14 @@ import { Unavailable, quota, window } from "../../domain/models";
const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
const unavailable = () => new Unavailable("notSignedIn", "Claude Desktop");
+export const desktopReadErrors = {
+ configuration: "Could not read Claude Desktop's account configuration.",
+ organization: "Could not read Claude Desktop's active organization cookie.",
+ decryption: "Could not decrypt Claude Desktop's sign-in cache with its Keychain entry.",
+ selection: "Claude Desktop's cache has no current allowance token for the active account and organization.",
+ ambiguous: "Claude Desktop's cache has multiple matching allowance tokens. MeterUsage cannot safely choose one.",
+} as const;
+type ReadStage = (stage: keyof typeof desktopReadErrors) => void;
export function decryptDesktop(bytes: Uint8Array, password: string): Buffer {
const input = Buffer.from(bytes);
if (input.length > 1048576 || input.subarray(0, 3).toString() !== "v10") throw unavailable();
@@ -17,23 +25,29 @@ export function decryptDesktop(bytes: Uint8Array, password: string): Buffer {
} catch { throw unavailable(); }
finally { key.fill(0); }
}
-export function desktopCredential(config: string, organization: string, password: string, now: number) {
+export function desktopCredential(config: string, organization: string, password: string, now: number, reading?: ReadStage) {
try {
+ reading?.("configuration");
const root = object(selectJSON(config, { lastKnownAccountUuid: true, "oauth:tokenCacheV2": true }));
const account = text(root.lastKnownAccountUuid)?.toLowerCase();
if (!account || !uuid.test(account) || !uuid.test(organization)) throw unavailable();
const cache = text(root["oauth:tokenCacheV2"]); if (!cache || cache.length > 1400000) throw unavailable();
+ reading?.("decryption");
const plaintext = decryptDesktop(Buffer.from(cache, "base64"), password);
try {
// Select only scoped access tokens and expiry. Refresh tokens stay skipped.
const entries = object(selectJSON(plaintext.toString("utf8"), { "*": { token: true, expiresAt: true } }));
+ reading?.("selection");
const candidates = Object.entries(entries).filter(([key, value]) => {
const match = /^acct:([^|]+)\|([^:]+):([^:]+):https:\/\/api\.anthropic\.com:(.*)$/.exec(key);
const entry = object(value), scopes = match?.[4].split(/\s+/);
return match && match[1].toLowerCase() === account && uuid.test(match[2]) && match[3].toLowerCase() === organization.toLowerCase() && scopes?.includes("user:profile") && scopes.includes("user:inference") && typeof entry.expiresAt === "number" && Number.isFinite(entry.expiresAt) && entry.expiresAt > now + 120000 && !!text(entry.token);
});
// Fail closed for ambiguous caches instead of guessing which login to use.
- if (candidates.length !== 1) throw unavailable();
+ if (candidates.length !== 1) {
+ if (candidates.length > 1) reading?.("ambiguous");
+ throw unavailable();
+ }
return { token: text(object(candidates[0][1]).token)!, identity: createHash("sha256").update(account + ":" + organization.toLowerCase()).digest("hex") };
} finally { plaintext.fill(0); }
} catch { throw unavailable(); }
@@ -43,9 +57,11 @@ function configFile(home: string) {
if (statSync(path).size > 2 * 1048576) throw unavailable();
return readFileSync(path, "utf8");
}
-export async function readDesktopCredential(home: string, password: string, now: number) {
+export async function readDesktopCredential(home: string, password: string, now: number, reading?: ReadStage) {
try {
+ reading?.("configuration");
const config = configFile(home);
+ reading?.("organization");
const { DatabaseSync } = await import("node:sqlite");
for (const relative of ["Cookies", "Network/Cookies"]) {
const path = join(home, "Library/Application Support/Claude", relative); if (!existsSync(path)) continue;
@@ -59,7 +75,8 @@ export async function readDesktopCredential(home: string, password: string, now:
try { if (decrypted.subarray(0, 32).equals(hash)) organization = decrypted.subarray(32).toString("utf8"); }
finally { decrypted.fill(0); }
}
- return desktopCredential(config, organization, password, now);
+ if (!uuid.test(organization)) throw unavailable();
+ return desktopCredential(config, organization, password, now, reading);
}
} finally { db.close(); }
}
diff --git a/src/main/providers/codex-account-usage.ts b/src/main/providers/codex-account-usage.ts
new file mode 100644
index 0000000..6cfe205
--- /dev/null
+++ b/src/main/providers/codex-account-usage.ts
@@ -0,0 +1,24 @@
+import { type AccountActivity, Unavailable, failed } from "../../domain/models";
+
+export function parseCodexAccountUsage(raw: string, capturedAt: number): AccountActivity {
+ try {
+ // Keep integer text until validation so JSON number rounding cannot hide fractions.
+ const tokenText = new WeakMap