diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..183669e
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+
+[*.{json,yml,yaml}]
+indent_size = 2
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7c78902..443765f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,29 +7,25 @@ on:
branches: [main, dev]
jobs:
- shellcheck:
+ verify:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - name: Install shellcheck
- run: sudo apt-get install -y shellcheck
- - name: Lint shell scripts
- run: shellcheck scripts/freeby.sh scripts/copilot-setup.sh
-
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Install dependencies
- run: sudo apt-get install -y meson ninja-build libglib2.0-dev-bin python3
- - name: Build
- run: meson setup build --prefix=$HOME/.local && meson install -C build
-
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
- name: Install dependencies
- run: sudo apt-get install -y bats python3 curl
- - name: Run tests
- run: bats tests/
+ run: sudo apt-get update && sudo apt-get install -y gjs gir1.2-soup-3.0 libglib2.0-dev-bin meson ninja-build python3
+ - name: Verify JavaScript, schemas, fixtures, and package
+ run: npm run verify
+ - name: Stress history, notifications, and subprocess bounds
+ run: npm run test:stress
+ - name: Verify isolated install
+ run: |
+ meson setup build --prefix="$RUNNER_TEMP/usagebeam-install"
+ meson install -C build
+ test -f "$RUNNER_TEMP/usagebeam-install/share/gnome-shell/extensions/usagebeam@oo7kc.github.io/schemas/gschemas.compiled"
+ - uses: actions/upload-artifact@v7
+ with:
+ name: usagebeam-extension
+ path: dist/*.zip
diff --git a/.gitignore b/.gitignore
index 40d87c3..007f19f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,17 @@
-build/
+/build/
+/dist/
+/.cache/
+/.ruff_cache/
+meson-private/
+
+# Local agent workspace and visual research are not product source.
+/.AGENTS/
+/AGENTS.md
+/reference-images/
+
+__pycache__/
+*.py[cod]
*.swp
*.swo
*~
-.cache/
-meson-private/
-plan.md
+.DS_Store
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c3f947..1863de9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,89 +1,117 @@
# Changelog
-## v1.0.2
+All notable UsageBeam changes are documented here.
-### Fixed
-- Added GNOME 49 and 50 to shell-version compatibility
-- Removed deprecated `version` field from metadata.json
-- Dropdown now stays open when clicking refresh (deferred reopen)
-- Removed invalid `accessible_name` from PopupMenuItem (caused extension to fail loading)
-
-## v1.0.1
+## Unreleased
### Added
-- Accessible names for screen readers on panel indicator, dots, and summaries
-- System theme support in CSS (uses `currentColor` and opacity for better theme integration)
-- CONTRIBUTING.md with development setup and code style guidelines
-- GitHub Actions CI: shellcheck, meson build, bats tests
-- Bats test suite for shell script (JSON structure, required fields, cleanup)
+
+- UsageBeam product identity with the permanent extension UUID
+ `usagebeam@oo7kc.github.io` and settings schema
+ `org.gnome.shell.extensions.usagebeam`.
+- Active-provider panel readout with provider icon, shortest current quota, and
+ reset countdown.
+- Left area, right area, left-of-calendar, and right-of-calendar placement.
+- Expandable seven-day activity chart and compact per-model token totals.
+- Native preferences for placement, refresh interval, and notifications.
+- Explicit quota severity states: caution at 80%, warning at 90%, and exhausted
+ at 100%.
+- Product screenshots for the panel indicator, account limits, and local
+ activity views.
### Changed
-- CSS colors use `currentColor` where possible for better theme compatibility
-- Status dot and summary text use opacity for dimmed states instead of hardcoded colors
-## v1.0.0
+- Rebuilt the popup as a compact, accent-aware GNOME surface with aligned quota
+ metrics and stable panel geometry.
+- Account limits, local activity, and saved results now remain available
+ independently when one source cannot refresh.
+- Codex discovery supports common user-local and Node version-manager layouts.
+- Recognized Freeby alpha settings and derived data migrate without copying
+ credentials or overwriting existing UsageBeam data.
### Fixed
-- Crash when subprocess returns empty/null output
-- Race condition from concurrent refresh calls (clicking during auto-refresh)
-- Stale async callback after extension disable
-- False "limit reached" notification on first refresh
-- Schema not compiled during install (gsettings now works out of the box)
-- Codex percentage parser using string instead of boolean for `has_remaining`
-- Copilot percentage exceeding 100% on overages
-- Cursor showing "resets now" when billing cycle end is unknown
-- `copilot-setup.sh` infinite loop (now times out after 5 minutes)
-- Shell script temp directory shadowing system `$TMPDIR`
-- Shell script hanging forever when curl or npx times out
-### Changed
-- Consistent percentage display across all providers
-- Metadata: added `version` and `settings-schema` fields
-- Metadata: removed unreleased GNOME shell versions from compatibility list
-- GSettings: enforced 30–3600s range on refresh interval
-- Build: schema compilation now runs automatically during `meson install`
-- README: added prerequisites, uninstall instructions, corrected copilot-setup path
+- Expanded menus adapt to smaller work areas with keyboard-accessible pagination,
+ preserving access to all quotas and model totals without nested scrolling.
+- Calendar-gap centering now accounts for neighboring panel extensions and their
+ size changes.
+- Local activity totals recover when provider history files are rewritten or
+ contain an unreadable record.
+- Large or malformed provider responses no longer interrupt future refreshes.
+- Calendar-side readouts anchor toward the clock on both sides, keeping reserved
+ width outside the visible gap when provider names and countdowns change.
+- Each quota now shows its reset countdown below the bar as `Resets in …`, with
+ its percentage right-aligned above the bar.
+- Daily chart columns allocate visible bottom-aligned bars for non-zero activity.
+- Model fills remain compact and stable across repeated layout passes.
+- Calendar placement remains fixed while providers and quota values change.
+- Reset values, percentages, and separator dots retain consistent alignment.
+- The active-provider readout uses compact internal and calendar-side spacing,
+ while retaining a fixed width across provider changes.
+- Panel usage metrics share the provider label's visual baseline.
+- The panel always prefers the shortest available quota period, even at 0% or
+ 100%, instead of switching to a longer window with higher usage.
+- Seven-day activity emphasizes its local/account scope without repeating a
+ date range already represented by the chart.
+- The activity disclosure now shows only its title and chevron; period, totals,
+ and source remain in the expanded content where they are needed.
+- Usage alerts now progress through configured, warning, and exhausted milestones
+ once per quota period and combine simultaneous crossings into one notification.
+- Quota labels use compact `5H Session` and `Weekly Reserve` names, while expanded
+ activity keeps scope implicit and gives the chart more breathing room.
+- Expired quota windows are not presented as current cached data.
+- Empty, malformed, or unavailable provider responses are never shown as zero
+ usage.
+
+### Removed
+
+- Unverified Cursor and Copilot preview adapters. Providers now ship only after
+ their data sources and failure states have completed validation.
+- Superseded runtime scripts and historical UI artifacts that were not part of
+ the product.
+
+## 2.0.0-alpha.2 - 2026-09-06
-### Security
-- Fixed Python code injection risk when `$HOME` contains single quotes
-- Removed unused `json_escape` function
+### Added
-## v0.5.0
+- Claude Code installation and saved OAuth sign-in detection.
+- Supported 5-hour, weekly, and model-scoped Claude quota windows.
+- Incremental Claude Code activity with per-model input, output, cache-read, and
+ cache-write totals.
+- Shared provider switching between Codex and Claude Code.
### Fixed
-- Dropdown now stays open when clicking the refresh icon
-- Consistent percentage display for Copilot (was showing token counts)
-## v0.4.0
+- Expired cached quota windows are discarded after their reset time.
+- Missing, absent, and expired Claude authentication remain distinct states.
-### Added
-- Desktop notifications on provider limit hit
-- Auto-refresh on wake from sleep (systemd PrepareForSleep)
-- Configurable refresh interval via gsettings
-- Settings UI in Extension Manager (prefs.js)
-- Parallel provider fetches (4.6s → 1.9s)
+### Compatibility
-### Changed
-- Split extension.js into extension.js + indicator.js + prefs.js
+- Tested with Claude Code 2.1.218.
+- Supports standard and model-scoped limits, expired sign-ins, duplicate local
+ activity records, and separate cache-token categories.
+- Live account-limit validation was not available for this prerelease.
-## v0.3.0
+## 2.0.0-alpha.1 - 2026-09-06
### Added
-- Click panel to refresh
-- Clickable refresh icon in dropdown
-- `has_remaining` field for accurate provider count
+
+- Codex account limits with distinct quota periods and reset times.
+- Incremental local Codex history with seven-day and model aggregates.
+- Private local storage and temporary saved-value recovery during refresh
+ failures.
### Fixed
-- Consistent comma separator and countdown format
-- Codex parsed via `--json` flag for reliable parsing
-## v0.2.0
+- Refresh occurs after resume instead of before suspend.
+- Repeated refreshes no longer overlap.
+- Notifications appear once for each reached milestone and quota period.
-### Added
-- Real provider integrations: Codex, Cursor, Copilot
-- `copilot-setup.sh` device-flow auth script
-- Panel indicator with colored status
+## 1.0.2 - 2026-04-27
-## v0.1.0
+- Added GNOME 49 and 50 metadata compatibility and removed deprecated extension
+ version metadata.
+- Kept the dropdown open after manual refresh and fixed an invalid popup
+ accessibility property.
-- Initial release with placeholder data
+Earlier history remains available in the repository's Git tags and releases.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index 27606ca..0000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,83 +0,0 @@
-# Contributing
-
-Thanks for your interest in Freeby.
-
-## Development
-
-```bash
-git clone https://github.com/kcnewman/freeby.git && cd freeby
-git checkout dev
-meson setup build --prefix=$HOME/.local
-meson install -C build
-```
-
-Restart GNOME Shell (Alt+F2, type `r`, Enter) and enable:
-
-```bash
-gnome-extensions enable freeby@kelvin.local
-```
-
-## Project structure
-
-```
-freeby/
-├── extension.js # Entry point (enable/disable)
-├── indicator.js # Panel UI, dropdown, refresh
-├── prefs.js # Settings UI in Extension Manager
-├── scripts/
-│ ├── freeby.sh # Data aggregator (parallel provider fetches)
-│ └── copilot-setup.sh # GitHub device-flow auth
-├── schemas/
-│ └── *.gschema.xml # GSettings schema
-├── build-aux/
-│ └── compile-schemas.sh
-├── tests/
-│ └── freeby.bats # Shell script tests
-├── .github/workflows/
-│ └── ci.yml # shellcheck, meson build, bats tests
-└── docs/
- ├── s1.png
- └── s2.png
-```
-
-## Code style
-
-- Keep it simple. No unnecessary abstraction.
-- Lowercase sentence case for user-facing text.
-- Follow existing patterns in the codebase.
-
-## Commits
-
-Use [conventional commits](https://www.conventionalcommits.org/):
-
-- `feat:` new feature
-- `fix:` bug fix
-- `docs:` documentation only
-- `chore:` maintenance, tests, CI
-
-## Testing
-
-```bash
-# test the data script
-bash scripts/freeby.sh | python3 -m json.tool
-
-# run shellcheck
-shellcheck scripts/freeby.sh scripts/copilot-setup.sh
-
-# run bats tests
-bats tests/
-```
-
-CI runs automatically on push to `main` or `dev`, and on all pull requests.
-
-## Branches
-
-- `main` — stable releases
-- `dev` — active development
-
-## Pull requests
-
-1. Fork and create a branch from `dev`
-2. Make your changes
-3. Test manually (the extension runs in your live desktop)
-4. Open a PR against `dev`
diff --git a/README.md b/README.md
index 8ecdf50..a830a12 100644
--- a/README.md
+++ b/README.md
@@ -1,120 +1,133 @@
-
-
Freeby
- Track your free-tier AI coding tool usage at a glance.
-
-
-
-
-
-
-
-
- Features ·
- Providers ·
- Install ·
- Settings ·
- Contributing ·
- Releases
-
-
-### Features
-
-- **Panel indicator** — colored `ai·N` shows available providers at a glance
-- **Dropdown** — per-provider usage, limits, and reset countdown
-- **Notifications** — desktop alert when a provider hits its limit
-- **Auto-refresh on wake** — refreshes immediately after sleep
-- **Parallel fetches** — all providers queried in parallel (~2s)
-- **Configurable** — adjust refresh interval and notifications in settings
-- **Accessible** — screen reader support for all UI elements
-- **Theme-aware** — works with light and dark GNOME themes
-
-### Supported providers
-
-| Provider | Auth source | Data source |
-|---|---|---|
-| 🟡 **Codex** | `~/.codex/auth.json` | `codex-check` CLI |
-| 🟢 **Cursor** | `~/.config/cursor/auth.json` | Cursor API |
-| 🟠 **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API |
-
-### Install
-
-**Prerequisites**
-
-- GNOME Shell 45+ (Wayland or X11)
-- `python3`, `curl`, `meson`, `ninja-build`
-- `npx` (for Codex)
-- `gh` CLI (for Copilot, optional)
-
-
-Fedora
+# UsageBeam
-```bash
-sudo dnf install meson ninja-build python3 curl glib2-devel
-```
-
+UsageBeam puts Codex and Claude Code usage where it is easiest to see: in the
+GNOME top panel. The compact indicator shows the active provider's shortest quota
+and reset countdown; its popup reveals every reported limit plus seven days of
+local token and model activity.
-
-Ubuntu / Debian
+The product concept was adapted for GNOME Shell from the
+[Agents plugin in Omarchy](https://github.com/omacom/omarchy/blob/quattro/shell/plugins/agents/README.md).
+UsageBeam is an independent implementation designed around GNOME's native panel,
+preferences, accessibility, and lifecycle conventions.
-```bash
-sudo apt install meson ninja-build python3 curl libglib2.0-dev-bin
-```
-
+## At a glance
-**Build and install**
+- Live account limits and reset windows for Codex and Claude Code.
+- A stable, single-provider panel indicator.
+- Four panel placements: left area, right area, left of calendar, or right of
+ calendar.
+- Seven-day local activity chart and per-model token totals.
+- Independent live, local, cached, syncing, and setup states.
+- Light and dark surfaces derived from the active GNOME accent color.
+- Private local storage with no prompt, response, transcript, or credential
+ retention.
-```bash
-git clone https://github.com/kcnewman/freeby.git && cd freeby
-meson setup build --prefix=$HOME/.local
-meson install -C build
-```
+UsageBeam displays only data reported by a provider or found in local usage
+records. Local activity covers this device; it is not billing data and is never
+converted into an account quota.
-Then restart your session and enable:
+## Screenshots
-```bash
-gnome-extensions enable freeby@kelvin.local
-```
+### Panel indicator
+
+
+
+### Account limits
+
+
+
+### Local activity
+
+
+
+### Preferences
+
+_Screenshot placeholder — provider, placement, refresh, and notification settings._
+
+## Supported providers
-> **Copilot users:** If `gh` isn't installed, run `copilot-setup.sh` first.
+### Codex
-### Settings
+UsageBeam reads account quota windows through the installed Codex CLI app-server
+and scans local Codex session records for activity. The CLI must be installed
+and signed in to show live limits. Common user-local, fnm, nvm, mise, asdf, and
+Volta installations are discovered even when GNOME Shell has a restricted
+`PATH`.
-| Setting | Default | Range | Description |
-|---|---|---|---|
-| Refresh interval | `120s` | 30–3600s | How often to check usage |
-| Notifications | `on` | — | Alert when a provider hits its limit |
+### Claude Code
-Configure via Extension Manager or CLI:
+UsageBeam reads supported account limits from Claude Code's saved OAuth sign-in
+and scans local Claude Code project records for activity. Account limits require
+an active sign-in; local activity can remain available independently.
+
+See the detailed [Codex](docs/providers/codex.md) and
+[Claude Code](docs/providers/claude.md) provider notes for source and
+compatibility details.
+
+## Requirements
+
+- GNOME Shell 50.
+- GJS with Gio, GLib, and Soup 3 introspection data.
+- The Codex CLI and/or Claude Code, installed and signed in for account limits.
+
+## Install
+
+Download the UsageBeam ZIP from [GitHub Releases](https://github.com/oo7kc/usagebeam/releases),
+then run:
```bash
-gsettings --schemadir ~/.local/share/glib-2.0/schemas \
- set org.gnome.shell.extensions.freeby refresh-interval 60
+gnome-extensions install ./usagebeam@oo7kc.github.io-VERSION.zip
+gnome-extensions enable usagebeam@oo7kc.github.io
```
-### Uninstall
+Log out and back in when installing UsageBeam for the first time so GNOME Shell
+can discover the new extension identity. When replacing an existing UsageBeam
+installation, add `--force`; it tells `gnome-extensions` that overwriting the
+installed copy is intentional.
+
+## Settings
+
+Open the preferences window from the popup or with:
```bash
-gnome-extensions disable freeby@kelvin.local
-rm -rf ~/.local/share/gnome-shell/extensions/freeby@kelvin.local
-rm -f ~/.local/bin/freeby.sh ~/.local/bin/copilot-setup.sh
-rm -f ~/.local/share/glib-2.0/schemas/org.gnome.shell.extensions.freeby.gschema.xml
-rm -f ~/.local/share/glib-2.0/schemas/gschemas.compiled
+gnome-extensions prefs usagebeam@oo7kc.github.io
```
-### Debug
+Preferences control panel placement, refresh frequency, and quota notifications.
-```bash
-# test the data script
-bash ~/.local/bin/freeby.sh | python3 -m json.tool
+## Privacy and storage
-# watch extension logs
-journalctl -f -o cat /usr/bin/gnome-shell
-```
+UsageBeam processes provider data locally and stores only derived usage metadata:
+
+- State: `$XDG_STATE_HOME/usagebeam`
+- Incremental history cache: `$XDG_CACHE_HOME/usagebeam`
+
+Files are created with private permissions. Credentials are read only when a
+provider requires them and are never copied, cached, or logged. Account and
+session identifiers used for change detection or deduplication are hashed before
+persistence. Prompt and response content is ignored.
+
+Recognized settings and derived data from older Freeby alpha builds are migrated
+once without overwriting existing UsageBeam data. Credentials are excluded from
+that migration.
-### Contributing
+## Troubleshooting
-See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
+- **Limits show Setup:** open the provider's CLI and confirm it is signed in.
+- **Activity is local only:** this is expected; UsageBeam does not merge records
+ from other devices.
+- **Values show Cached:** the last valid values remain visible during a temporary
+ provider or network failure.
+- **The indicator is absent after installation:** log out and back in, then enable
+ the extension again.
+- **Another panel extension changes placement:** UsageBeam follows GNOME's panel
+ boxes, so layout extensions may override the final arrangement.
-### License
+## Remove
+
+```bash
+gnome-extensions disable usagebeam@oo7kc.github.io
+gnome-extensions uninstall usagebeam@oo7kc.github.io
+```
-[MIT](LICENSE)
+UsageBeam is licensed under the [MIT License](LICENSE).
diff --git a/docs/images/account-limits.png b/docs/images/account-limits.png
new file mode 100644
index 0000000..4bb6724
Binary files /dev/null and b/docs/images/account-limits.png differ
diff --git a/docs/images/local-activity.png b/docs/images/local-activity.png
new file mode 100644
index 0000000..69bea8c
Binary files /dev/null and b/docs/images/local-activity.png differ
diff --git a/docs/images/panel-indicator.png b/docs/images/panel-indicator.png
new file mode 100644
index 0000000..070fdea
Binary files /dev/null and b/docs/images/panel-indicator.png differ
diff --git a/docs/providers/claude.md b/docs/providers/claude.md
new file mode 100644
index 0000000..b4dbe6f
--- /dev/null
+++ b/docs/providers/claude.md
@@ -0,0 +1,37 @@
+# Claude Code support
+
+UsageBeam can show supported Claude Code account limits alongside seven days of
+activity recorded on the current device.
+
+## Set up Claude Code
+
+1. Install Claude Code.
+2. Open Claude Code and complete its normal sign-in flow.
+3. Enable Claude in UsageBeam preferences.
+4. Select **Refresh** from the UsageBeam menu.
+
+## What UsageBeam shows
+
+- The shortest available quota window in the top panel, including when it is
+ exhausted.
+- The current 5-hour, weekly, and model-scoped limits reported for the signed-in
+ account in the menu.
+- A seven-day token chart and per-model totals from local Claude Code activity.
+
+Account limits and local activity are independent. Local activity is not an
+account-wide total, subscription quota, or billable cost.
+
+## Privacy
+
+UsageBeam reads only the sign-in and usage fields needed for these views. It
+does not store access tokens, prompts, responses, credentials, or raw session
+identifiers.
+
+## If Claude shows Setup
+
+- Open Claude Code and confirm the same user is signed in.
+- Refresh UsageBeam after completing sign-in.
+- If limits remain unavailable, local activity may still continue to work.
+
+Claude Code's account-usage interface can change independently of UsageBeam. An
+unsupported response is shown as unavailable rather than as zero usage.
diff --git a/docs/providers/codex.md b/docs/providers/codex.md
new file mode 100644
index 0000000..dbb7cd7
--- /dev/null
+++ b/docs/providers/codex.md
@@ -0,0 +1,38 @@
+# Codex support
+
+UsageBeam can show live Codex account limits alongside seven days of activity
+recorded on the current device.
+
+## Set up Codex
+
+1. Install the Codex CLI.
+2. Sign in with `codex login`.
+3. Enable Codex in UsageBeam preferences.
+4. Select **Refresh** from the UsageBeam menu.
+
+UsageBeam recognizes standard command locations as well as common fnm, nvm,
+mise, asdf, and Volta installations.
+
+## What UsageBeam shows
+
+- The shortest available quota window in the top panel, including when it is
+ exhausted.
+- Every session, weekly, reserve, or scoped limit reported for the signed-in
+ account in the menu.
+- A seven-day token chart and per-model totals from local Codex activity.
+
+Account limits and local activity are independent. Local activity is not an
+account-wide total, subscription quota, or billable cost.
+
+## Privacy
+
+UsageBeam reads only the account and usage fields needed for these views. It
+does not store account email addresses, prompts, responses, credentials, or raw
+session identifiers.
+
+## If Codex shows Setup
+
+- Confirm `codex` runs from a terminal for the same user.
+- Confirm the CLI is signed in.
+- Refresh UsageBeam after signing in.
+- If the CLI was just installed, log out of GNOME and back in once.
diff --git a/docs/s1.png b/docs/s1.png
deleted file mode 100644
index 02694cc..0000000
Binary files a/docs/s1.png and /dev/null differ
diff --git a/docs/s2.png b/docs/s2.png
deleted file mode 100644
index 1858500..0000000
Binary files a/docs/s2.png and /dev/null differ
diff --git a/extension.js b/extension.js
index 00aae09..a71a532 100644
--- a/extension.js
+++ b/extension.js
@@ -1,16 +1,48 @@
-import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
+import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
-import { FreebyIndicator } from './indicator.js';
+import {notificationBody} from './src/core/notifications.js';
+import {UsageBeamIndicator} from './src/ui/indicator.js';
+import {clearIndicatorPlacement, placeIndicator} from './src/ui/panelPlacement.js';
+import {migrateLegacyInstall} from './src/services/migration.js';
+import {UsageService} from './src/services/usageService.js';
-export default class FreebyExtension extends Extension {
+export default class UsageBeamExtension extends Extension {
enable() {
- this._settings = this.getSettings('org.gnome.shell.extensions.freeby');
- this._settingsId = this._settings.connect('changed::refresh-interval', () => this._indicator?._setupTimer());
- this._indicator = new FreebyIndicator(this._settings);
- Main.panel.addToStatusArea(this.uuid, this._indicator);
+ this._settings = this.getSettings('org.gnome.shell.extensions.usagebeam');
+ migrateLegacyInstall(this._settings);
+ this._indicator = new UsageBeamIndicator(this._settings, this.path, () => this.openPreferences());
+ Main.panel.addToStatusArea(this.uuid, this._indicator, 0, 'center');
+ this._placeIndicator();
+ this._service = new UsageService(this._settings, this.path, () => this._indicator?.render(), alerts => {
+ const message = notificationBody(alerts);
+ if (message)
+ Main.notify('UsageBeam usage alert', message);
+ });
+ this._indicator.attach(this._service);
+ this._settingsIds = [];
+ this._settingsIds.push(this._settings.connect('changed::enabled-providers', () => {
+ this._service.configure();
+ this._indicator.render();
+ this._service.refreshAll();
+ }));
+ this._settingsIds.push(this._settings.connect('changed::default-provider', () => this._indicator.render()));
+ this._settingsIds.push(this._settings.connect('changed::panel-position', () => this._placeIndicator()));
+ this._settingsIds.push(this._settings.connect('changed::history-retention-days', () =>
+ this._service.refreshAll(true)));
+ this._service.refreshAll();
}
+
+ _placeIndicator() {
+ placeIndicator(this._indicator, this._settings.get_string('panel-position'));
+ }
+
disable() {
- if (this._settingsId) { this._settings.disconnect(this._settingsId); this._settingsId = null; }
+ for (const id of this._settingsIds ?? [])
+ this._settings.disconnect(id);
+ this._settingsIds = null;
+ this._service?.destroy();
+ this._service = null;
+ clearIndicatorPlacement(this._indicator);
this._indicator?.destroy();
this._indicator = null;
this._settings = null;
diff --git a/icons/claude.svg b/icons/claude.svg
new file mode 100644
index 0000000..0489ce8
--- /dev/null
+++ b/icons/claude.svg
@@ -0,0 +1,8 @@
+
+ Claude
+
+
diff --git a/icons/codex-symbolic.svg b/icons/codex-symbolic.svg
new file mode 100644
index 0000000..0f55071
--- /dev/null
+++ b/icons/codex-symbolic.svg
@@ -0,0 +1,6 @@
+
+ Codex
+
+
diff --git a/indicator.js b/indicator.js
deleted file mode 100644
index b2aaa8a..0000000
--- a/indicator.js
+++ /dev/null
@@ -1,140 +0,0 @@
-import Clutter from 'gi://Clutter';
-import GLib from 'gi://GLib';
-import Gio from 'gi://Gio';
-import St from 'gi://St';
-import GObject from 'gi://GObject';
-
-import * as Main from 'resource:///org/gnome/shell/ui/main.js';
-import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
-import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
-
-const PROVIDERS = ['codex', 'cursor', 'copilot'];
-const LABELS = { codex: 'Codex', cursor: 'Cursor', copilot: 'Copilot' };
-const SCRIPT = GLib.build_filenamev([GLib.get_home_dir(), '.local', 'bin', 'freeby.sh']);
-
-export const FreebyIndicator = GObject.registerClass(
- class FreebyIndicator extends PanelMenu.Button {
- _init(settings) {
- super._init(0.0, 'Freeby', false);
- this._settings = settings;
- this._prev = {};
- for (const k of PROVIDERS) this._prev[k] = null;
- this._refreshSeq = 0;
-
- this._panelBox = new St.BoxLayout({ style_class: 'panel-status-menu-box freeby-panel', accessible_name: 'Freeby usage indicator' });
- this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label', accessible_name: 'Usage count' });
- this._panelBox.add_child(this._label);
- this.add_child(this._panelBox);
- this.connect('button-press-event', () => { this._refresh(); return false; });
-
- this._items = {};
- for (const k of PROVIDERS) {
- const box = new St.BoxLayout({ style_class: 'freeby-item-box', accessible_name: `${LABELS[k]} usage` });
- const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} status` });
- const name = new St.Label({ text: LABELS[k], style_class: 'freeby-item-name', y_align: Clutter.ActorAlign.CENTER });
- const summary = new St.Label({ text: '\u2014', style_class: 'freeby-item-summary', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} summary` });
- box.add_child(dot);
- box.add_child(name);
- box.add_child(summary);
- const item = new PopupMenu.PopupMenuItem('', { reactive: false, can_focus: false });
- item.add_child(box);
- this.menu.addMenuItem(item);
- this._items[k] = { dot, summary };
- }
-
- this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
- this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false });
- this._statusItem.label.add_style_class_name('freeby-status');
- this._statusItem.connect('activate', () => { this._refresh(); GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { this.menu.open(); return GLib.SOURCE_REMOVE; }); });
- this.menu.addMenuItem(this._statusItem);
-
- this._timerId = null;
- this._setupTimer();
- this._refresh();
- this._monitorWake();
- }
-
- _setupTimer() {
- if (this._timerId) GLib.source_remove(this._timerId);
- const sec = Math.max(30, this._settings.get_int('refresh-interval'));
- this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, sec, () => { this._refresh(); return GLib.SOURCE_CONTINUE; });
- }
-
- _monitorWake() {
- try {
- this._sleepId = Gio.DBus.system.signal_subscribe(
- 'org.freedesktop.login1', 'org.freedesktop.login1.Manager',
- 'PrepareForSleep', '/org/freedesktop/login1', null, Gio.DBusSignalFlags.NONE,
- (_, __, ___, ____, _____, params) => {
- if (params.get_child_value(0).get_boolean()) {
- log('freeby: woke from sleep, refreshing');
- this._refresh();
- }
- });
- } catch (e) { logError(e, 'freeby: could not monitor sleep/wake'); }
- }
-
- _notify(title, body) {
- if (!this._settings.get_boolean('notifications-enabled')) return;
- try {
- const src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic');
- Main.messageTray.add(src);
- src.addNotification(new Main.messageTray.Notification({ source: src, title, body }));
- } catch (e) { logError(e, 'freeby: notification failed'); }
- }
-
- _setError() {
- this._label.text = 'ai';
- this._label.style_class = 'freeby-panel-label';
- }
-
- _refresh() {
- this._refreshSeq++;
- const seq = this._refreshSeq;
- let proc;
- try { proc = Gio.Subprocess.new(['/bin/bash', SCRIPT], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); }
- catch (e) { this._setError(); logError(e, 'freeby: failed to spawn script'); return; }
-
- proc.communicate_utf8_async(null, null, (p, res) => {
- if (seq !== this._refreshSeq) return;
- let stdout, stderr;
- try { [, stdout, stderr] = p.communicate_utf8_finish(res); }
- catch (e) { this._setError(); logError(e, 'freeby: subprocess failed'); return; }
- if (!p.get_successful()) { this._setError(); log(`freeby: script error: ${stderr}`); return; }
- this._apply(stdout);
- });
- }
-
- _apply(stdout) {
- let data;
- try { data = JSON.parse(stdout); }
- catch (e) { this._setError(); logError(e, 'freeby: bad JSON'); return; }
- if (!data || typeof data !== 'object') { this._setError(); log('freeby: empty or invalid data'); return; }
-
- const active = PROVIDERS.filter(k => data[k]?.has_remaining).length;
- const total = PROVIDERS.filter(k => data[k]?.available).length;
- this._label.text = active > 0 ? `ai\u00B7${active}` : 'ai';
- this._label.style_class = 'freeby-panel-label' + (active === total && total > 0 ? ' freeby-panel-green' : active > 0 ? ' freeby-panel-yellow' : total > 0 ? ' freeby-panel-red' : '');
-
- const hit = PROVIDERS.filter(k => data[k]?.available && !data[k].has_remaining && this._prev[k]?.has_remaining !== false).map(k => LABELS[k]);
- this._prev = {};
- for (const k of PROVIDERS) this._prev[k] = data[k] ? { has_remaining: data[k].has_remaining } : null;
- if (hit.length) this._notify('Usage limit reached', `${hit.join(', ')} ${hit.length === 1 ? 'has' : 'have'} hit their limit`);
-
- for (const k of PROVIDERS) {
- const d = data[k], { dot, summary } = this._items[k];
- if (!d) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = 'no data'; summary.style_class = 'freeby-item-summary'; }
- else if (!d.available) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = d.summary || 'not available'; summary.style_class = 'freeby-item-summary freeby-dim'; }
- else if (d.summary?.includes('limit reached')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-red'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-red'; }
- else if (d.summary?.includes('expired') || d.summary?.includes('parse error')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-yellow'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-yellow'; }
- else { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-green'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-green'; }
- }
- this._statusItem.label.text = `\u21BB Last checked: ${GLib.DateTime.new_now_local().format('%H:%M:%S')}`;
- }
-
- destroy() {
- if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; }
- if (this._sleepId) { Gio.DBus.system.signal_unsubscribe(this._sleepId); this._sleepId = null; }
- super.destroy();
- }
- });
diff --git a/meson.build b/meson.build
index 6af6f8f..5116c41 100644
--- a/meson.build
+++ b/meson.build
@@ -1,21 +1,19 @@
-project('freeby', version: '1.0.2', license: 'MIT',
+project('usagebeam', version: '2.0.0-alpha.3-dev.0', license: 'MIT',
meson_version: '>= 0.56.0',
default_options: ['warning_level=0'])
-uuid = 'freeby@kelvin.local'
+uuid = 'usagebeam@oo7kc.github.io'
prefix = get_option('prefix')
extdir = join_paths(prefix, 'share', 'gnome-shell', 'extensions', uuid)
-bindir = join_paths(prefix, 'bin')
-schemadir = join_paths(prefix, 'share', 'glib-2.0', 'schemas')
+schemadir = join_paths(extdir, 'schemas')
-install_data('extension.js', 'indicator.js', 'prefs.js', 'metadata.json', 'stylesheet.css',
+install_data('extension.js', 'prefs.js', 'metadata.json', 'stylesheet.css', 'LICENSE',
install_dir: extdir)
-install_data('scripts/freeby.sh', 'scripts/copilot-setup.sh',
- install_dir: bindir,
- install_mode: 'rwxr-xr-x')
+install_subdir('src', install_dir: extdir)
+install_subdir('icons', install_dir: extdir)
-install_data('schemas/org.gnome.shell.extensions.freeby.gschema.xml',
+install_data('schemas/org.gnome.shell.extensions.usagebeam.gschema.xml',
install_dir: schemadir)
meson.add_install_script('build-aux/compile-schemas.sh', schemadir)
diff --git a/metadata.json b/metadata.json
index f131767..19b94a8 100644
--- a/metadata.json
+++ b/metadata.json
@@ -1,8 +1,8 @@
{
- "uuid": "freeby@kelvin.local",
- "name": "Freeby",
- "description": "Minimal panel indicator for local AI coding tool usage (Codex, Cursor, Copilot)",
- "shell-version": ["45", "46", "47", "48", "49", "50"],
- "settings-schema": "org.gnome.shell.extensions.freeby",
- "url": "https://github.com/kcnewman/freeby"
+ "uuid": "usagebeam@oo7kc.github.io",
+ "name": "UsageBeam",
+ "description": "Private, at-a-glance usage monitoring for Codex and Claude Code, with account limits, reset windows, local token activity and model breakdowns.",
+ "shell-version": ["50"],
+ "settings-schema": "org.gnome.shell.extensions.usagebeam",
+ "url": "https://github.com/oo7kc/usagebeam"
}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..992f777
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "usagebeam",
+ "version": "2.0.0-alpha.3-dev.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node --test tests/unit/*.test.js",
+ "lint": "node tools/check.js",
+ "test:integration": "gjs -m tests/integration/collector.js",
+ "test:stress": "gjs -m tests/integration/stress.js",
+ "check": "npm run lint && npm test && npm run test:integration",
+ "pack": "python3 tools/package.py",
+ "verify": "npm run check && npm run pack",
+ "smoke:shell": "python3 tools/smoke-shell.py"
+ },
+ "engines": { "node": ">=20" }
+}
diff --git a/prefs.js b/prefs.js
index b67f37f..c892c61 100644
--- a/prefs.js
+++ b/prefs.js
@@ -1,29 +1,46 @@
import Adw from 'gi://Adw';
import Gtk from 'gi://Gtk';
-import { ExtensionPreferences, gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
+import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
-export default class FreebyPreferences extends ExtensionPreferences {
+export default class UsageBeamPreferences extends ExtensionPreferences {
fillPreferencesWindow(window) {
- const settings = this.getSettings('org.gnome.shell.extensions.freeby');
+ const settings = this.getSettings('org.gnome.shell.extensions.usagebeam');
const page = new Adw.PreferencesPage();
window.add(page);
- const group = new Adw.PreferencesGroup({ title: 'General' });
+ const group = new Adw.PreferencesGroup({title: 'General'});
page.add(group);
+ const positions = ['left', 'right', 'left-of-calendar', 'right-of-calendar'];
+ const positionRow = new Adw.ComboRow({
+ title: 'Panel position',
+ subtitle: 'Choose a side area or place usage beside the calendar',
+ model: Gtk.StringList.new(['Left panel', 'Right panel',
+ 'Left of calendar', 'Right of calendar']),
+ });
+ const syncPosition = () => {
+ const selected = positions.indexOf(settings.get_string('panel-position'));
+ positionRow.selected = selected >= 0 ? selected : 3;
+ };
+ syncPosition();
+ positionRow.connect('notify::selected', () =>
+ settings.set_string('panel-position', positions[positionRow.selected]));
+ settings.connect('changed::panel-position', syncPosition);
+ group.add(positionRow);
+
const intervalRow = new Adw.SpinRow({
title: 'Refresh interval',
subtitle: 'How often to check usage (seconds, min 30)',
- adjustment: new Gtk.Adjustment({ lower: 30, upper: 3600, step_increment: 10, page_increment: 60 }),
+ adjustment: new Gtk.Adjustment({lower: 30, upper: 3600, step_increment: 10, page_increment: 60}),
});
settings.bind('refresh-interval', intervalRow, 'value', 0);
group.add(intervalRow);
const notifyRow = new Adw.SwitchRow({
title: 'Notifications',
- subtitle: 'Alert when a provider hits its limit',
+ subtitle: 'Alert once when usage crosses each warning milestone',
});
settings.bind('notifications-enabled', notifyRow, 'active', 0);
group.add(notifyRow);
diff --git a/schemas/org.gnome.shell.extensions.freeby.gschema.xml b/schemas/org.gnome.shell.extensions.freeby.gschema.xml
deleted file mode 100644
index 4af8f75..0000000
--- a/schemas/org.gnome.shell.extensions.freeby.gschema.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
- 120
-
- Refresh interval
- How often to refresh usage data, in seconds (30–3600)
-
-
- true
- Enable notifications
- Show desktop notifications when a provider hits its limit
-
-
-
diff --git a/schemas/org.gnome.shell.extensions.usagebeam.gschema.xml b/schemas/org.gnome.shell.extensions.usagebeam.gschema.xml
new file mode 100644
index 0000000..131bb67
--- /dev/null
+++ b/schemas/org.gnome.shell.extensions.usagebeam.gschema.xml
@@ -0,0 +1,47 @@
+
+
+
+
+ 120
+
+ Refresh interval
+ How often to refresh usage data, in seconds (30–3600)
+
+
+ true
+ Enable notifications
+ Show one desktop notification when usage crosses each configured warning milestone
+
+
+ ['codex', 'claude']
+ Enabled providers in display order
+
+
+ 'codex'
+ Selected usage provider
+
+
+
+
+
+
+
+
+ 'right-of-calendar'
+ Panel position
+ Place UsageBeam in the left or right panel area, or immediately beside the calendar
+
+
+ 30
+
+ Days of usage metadata to retain
+
+
+ 90
+
+ First usage percentage that triggers a warning
+
+
+
+
diff --git a/scripts/copilot-setup.sh b/scripts/copilot-setup.sh
deleted file mode 100644
index c872d5f..0000000
--- a/scripts/copilot-setup.sh
+++ /dev/null
@@ -1,79 +0,0 @@
-#!/usr/bin/env bash
-# One-time GitHub Copilot device-flow authentication.
-# Saves token to ~/.config/freeby/copilot-token.
-set -euo pipefail
-
-# --- prereq checks -----------------------------------------------------------
-for cmd in curl python3; do
- if ! command -v "$cmd" >/dev/null 2>&1; then
- echo "error: $cmd is required but not installed" >&2
- exit 1
- fi
-done
-
-CONFIG_DIR="$HOME/.config/freeby"
-TOKEN_FILE="$CONFIG_DIR/copilot-token"
-CLIENT_ID="Iv1.b507a08c87ecfe98"
-SCOPE="read:user"
-MAX_ATTEMPTS=60
-
-mkdir -p "$CONFIG_DIR"
-
-if [ -f "$TOKEN_FILE" ]; then
- echo "Token already exists at $TOKEN_FILE"
- echo "Delete it and re-run to re-authenticate."
- exit 0
-fi
-
-echo "Requesting device code..."
-DEVICE_RESP="$(curl -s -X POST 'https://github.com/login/device/code' \
- -H 'Accept: application/json' \
- -d "client_id=$CLIENT_ID&scope=$SCOPE")"
-
-DEVICE_CODE="$(printf '%s' "$DEVICE_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('device_code',''))")"
-USER_CODE="$(printf '%s' "$DEVICE_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('user_code',''))")"
-VERIFICATION_URI="$(printf '%s' "$DEVICE_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('verification_uri',''))")"
-INTERVAL="$(printf '%s' "$DEVICE_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('interval',5))")"
-
-if [ -z "$DEVICE_CODE" ] || [ -z "$USER_CODE" ]; then
- echo "Failed to get device code. Response:"
- printf '%s\n' "$DEVICE_RESP"
- exit 1
-fi
-
-echo ""
-echo "1. Go to: $VERIFICATION_URI"
-echo "2. Enter code: $USER_CODE"
-echo ""
-echo "Waiting for authentication... (timeout after $MAX_ATTEMPTS attempts)"
-
-attempt=0
-while [ "$attempt" -lt "$MAX_ATTEMPTS" ]; do
- attempt=$((attempt + 1))
- sleep "$INTERVAL"
- TOKEN_RESP="$(curl -s -X POST 'https://github.com/login/oauth/access_token' \
- -H 'Accept: application/json' \
- -d "client_id=$CLIENT_ID&device_code=$DEVICE_CODE&grant_type=urn:ietf:params:oauth:grant-type:device_code")"
-
- TOKEN="$(printf '%s' "$TOKEN_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('access_token',''))")"
- ERROR="$(printf '%s' "$TOKEN_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('error',''))")"
-
- if [ -n "$TOKEN" ]; then
- printf '%s' "$TOKEN" > "$TOKEN_FILE"
- chmod 600 "$TOKEN_FILE"
- echo "Token saved to $TOKEN_FILE"
- exit 0
- fi
-
- if [ "$ERROR" != "authorization_pending" ]; then
- echo "Error: $ERROR"
- printf '%s\n' "$TOKEN_RESP"
- exit 1
- fi
-
- printf '.'
-done
-
-echo ""
-echo "Timed out after $MAX_ATTEMPTS attempts. Try again when ready."
-exit 1
diff --git a/scripts/freeby.sh b/scripts/freeby.sh
deleted file mode 100755
index 4da906b..0000000
--- a/scripts/freeby.sh
+++ /dev/null
@@ -1,187 +0,0 @@
-#!/usr/bin/env bash
-# Aggregates local AI coding tool usage into one JSON blob.
-# Run standalone: bash ~/.local/bin/freeby.sh | python3 -m json.tool
-
-set -uo pipefail
-
-FREEBY_TMP="$(mktemp -d)" || { echo '{"error":"failed to create temp dir"}'; exit 1; }
-trap 'rm -rf "$FREEBY_TMP"' EXIT
-
-# --- codex -------------------------------------------------------------------
-fetch_codex() {
- local out="$FREEBY_TMP/codex"
- if [ ! -f "$HOME/.codex/auth.json" ]; then
- echo '{"available":false,"has_remaining":false,"summary":"not detected"}' > "$out"
- return
- fi
- local json
- json="$(npx --yes codex-check --auth "$HOME/.codex/auth.json" --json 2>/dev/null)"
- if [ -z "$json" ]; then
- echo '{"available":true,"has_remaining":false,"summary":"auth.json found, codex-check produced no output"}' > "$out"
- return
- fi
- printf '%s' "$json" | python3 -c "
-import json, sys
-from datetime import datetime, timezone
-try:
- data = json.load(sys.stdin)
- if isinstance(data, list):
- if not data:
- raise ValueError('empty response from codex-check')
- data = data[0]
- w = data.get('windows', {}).get('primary', {})
- pct = float(w.get('percentUsed', 0))
- resets_at = w.get('resetsAt', '')
- if resets_at:
- dt = datetime.fromisoformat(resets_at.replace('Z', '+00:00'))
- now = datetime.now(timezone.utc)
- diff = dt - now
- if diff.total_seconds() <= 0: cd = 'now'
- elif diff.days > 0: cd = f'in {diff.days}d {diff.seconds//3600}h'
- else: cd = f'in {diff.seconds//3600}h {(diff.seconds%3600)//60}m'
- else: cd = 'unknown'
- has = pct < 100
- summary = f'limit reached, resets {cd}' if pct >= 100 else f'{min(100, pct):.0f}% used, resets {cd}'
- print(json.dumps({'available': True, 'has_remaining': has, 'summary': summary}))
-except Exception as e:
- print(json.dumps({'available': True, 'has_remaining': False, 'summary': f'parse error: {e}'}))
-" > "$out" 2>/dev/null
-}
-
-# --- cursor ------------------------------------------------------------------
-fetch_cursor() {
- local out="$FREEBY_TMP/cursor"
- local auth_file="$HOME/.config/cursor/auth.json"
- if [ ! -f "$auth_file" ]; then
- echo '{"available":false,"has_remaining":false,"summary":"not detected"}' > "$out"
- return
- fi
- local token
- token="$(python3 -c "
-import json, sys
-print(json.load(open(sys.argv[1])).get('accessToken',''))
-" "$auth_file" 2>/dev/null)"
- if [ -z "$token" ]; then
- echo '{"available":true,"has_remaining":false,"summary":"auth.json found but no accessToken"}' > "$out"
- return
- fi
- local resp http body
- resp="$(curl -s -w '\n%{http_code}' --connect-timeout 5 --max-time 10 \
- -X POST 'https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage' \
- -H "Authorization: Bearer $token" \
- -H 'Content-Type: application/json' \
- -H 'Connect-Protocol-Version: 1' \
- -d '{}' 2>/dev/null)"
- http="$(printf '%s' "$resp" | tail -1)"
- body="$(printf '%s' "$resp" | sed '$d')"
-
- if [ "$http" = "401" ]; then
- echo '{"available":true,"has_remaining":false,"summary":"token expired, reopen cursor to refresh"}' > "$out"
- elif [ "$http" = "200" ] && [ -n "$body" ]; then
- printf '%s' "$body" | python3 -c "
-import json, sys
-from datetime import datetime, timezone
-try:
- d = json.load(sys.stdin)
- pct = float(d.get('planUsage', {}).get('totalPercentUsed', 0))
- end_ms = int(d.get('billingCycleEnd', '0'))
- if end_ms == 0:
- print(json.dumps({'available': True, 'has_remaining': pct < 100, 'summary': f'{min(100, pct):.0f}% used, resets unknown'}))
- else:
- dt = datetime.fromtimestamp(end_ms/1000, tz=timezone.utc)
- now = datetime.now(timezone.utc)
- diff = dt - now
- if diff.total_seconds() <= 0: cd = 'now'
- elif diff.days > 0: cd = f'in {diff.days}d {diff.seconds//3600}h'
- else: cd = f'in {diff.seconds//3600}h {(diff.seconds%3600)//60}m'
- has = pct < 100
- summary = f'limit reached, resets {cd}' if pct >= 100 else f'{min(100, pct):.0f}% used, resets {cd}'
- print(json.dumps({'available': True, 'has_remaining': has, 'summary': summary}))
-except Exception as e:
- print(json.dumps({'available': True, 'has_remaining': False, 'summary': f'parse error: {e}'}))
-" > "$out" 2>/dev/null
- else
- printf '{"available":true,"has_remaining":false,"summary":"API returned HTTP %s"}' "$http" > "$out"
- fi
-}
-
-# --- copilot -----------------------------------------------------------------
-fetch_copilot() {
- local out="$FREEBY_TMP/copilot"
- local token=""
- if command -v gh >/dev/null 2>&1; then
- token="$(gh auth token 2>/dev/null | tr -d '[:space:]')"
- fi
- if [ -z "$token" ] && [ -f "$HOME/.config/freeby/copilot-token" ]; then
- token="$(tr -d '[:space:]' < "$HOME/.config/freeby/copilot-token" 2>/dev/null)"
- fi
- if [ -z "$token" ]; then
- if [ ! -f "$HOME/.config/freeby/copilot-token" ] && ! command -v gh >/dev/null 2>&1; then
- echo '{"available":false,"has_remaining":false,"summary":"run copilot-setup to authenticate"}' > "$out"
- else
- echo '{"available":false,"has_remaining":false,"summary":"not detected"}' > "$out"
- fi
- return
- fi
- local resp http body
- resp="$(curl -s -w '\n%{http_code}' --connect-timeout 5 --max-time 10 \
- 'https://api.github.com/copilot_internal/user' \
- -H "Authorization: Bearer $token" \
- -H 'Content-Type: application/json' 2>/dev/null)"
- http="$(printf '%s' "$resp" | tail -1)"
- body="$(printf '%s' "$resp" | sed '$d')"
-
- if [ "$http" = "401" ] || [ "$http" = "403" ]; then
- echo '{"available":true,"has_remaining":false,"summary":"auth expired, re-run copilot-setup"}' > "$out"
- elif [ "$http" = "200" ] && [ -n "$body" ]; then
- printf '%s' "$body" | python3 -c "
-import json, sys
-from datetime import datetime, timezone
-try:
- d = json.load(sys.stdin)
- reset_str = d.get('quota_reset_date_utc', '')
- dt = datetime.fromisoformat(reset_str.replace('Z', '+00:00')) if reset_str else None
- q = d.get('quota_snapshots', {}).get('chat', {})
- if q.get('entitlement', 0) == 0:
- q = d.get('quota_snapshots', {}).get('completions', {})
- remaining = q.get('remaining', 0)
- entitlement = q.get('entitlement', 0)
- used = q.get('credits_used', 0)
- if dt:
- now = datetime.now(timezone.utc)
- diff = dt - now
- if diff.total_seconds() <= 0: cd = 'now'
- elif diff.days > 0: cd = f'in {diff.days}d {diff.seconds//3600}h'
- else: cd = f'in {diff.seconds//3600}h {(diff.seconds%3600)//60}m'
- else: cd = 'unknown'
- has = remaining > 0
- if entitlement == 0: summary = f'no quota, resets {cd}'
- elif remaining <= 0: summary = f'limit reached, resets {cd}'
- else: summary = f'{min(100, round(used/entitlement*100))}% used, resets {cd}'
- print(json.dumps({'available': True, 'has_remaining': has, 'summary': summary}))
-except Exception as e:
- print(json.dumps({'available': True, 'has_remaining': False, 'summary': f'parse error: {e}'}))
-" > "$out" 2>/dev/null
- else
- printf '{"available":true,"has_remaining":false,"summary":"API returned HTTP %s"}' "$http" > "$out"
- fi
-}
-
-# --- run all in parallel -----------------------------------------------------
-fetch_codex &
-fetch_cursor &
-fetch_copilot &
-wait
-
-# --- output ------------------------------------------------------------------
-python3 -c "
-import json, sys
-result = {}
-for name in ('codex', 'cursor', 'copilot'):
- try:
- with open('$FREEBY_TMP/' + name) as f:
- result[name] = json.load(f)
- except Exception:
- result[name] = {'available': False, 'has_remaining': False, 'summary': 'no data'}
-print(json.dumps(result))
-"
diff --git a/src/collector/main.js b/src/collector/main.js
new file mode 100644
index 0000000..3bfbd0b
--- /dev/null
+++ b/src/collector/main.js
@@ -0,0 +1,72 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import GLibUnix from 'gi://GLibUnix';
+import System from 'system';
+import {record, section, validateRecord} from '../core/usage.js';
+import {collectClaude} from '../providers/claude.js';
+import {collectCodex} from '../providers/codex.js';
+import {commandSpec, findCommand} from '../services/commands.js';
+import {fingerprint, join, readJson} from '../services/files.js';
+import {scanHistory} from '../services/history.js';
+import {requestJson} from '../services/http.js';
+import {RpcClient} from '../services/process.js';
+
+const COLLECTORS = Object.freeze({codex: collectCodex, claude: collectClaude});
+const HISTORY_ROOTS = {
+ codex: GLib.getenv('CODEX_HOME') || join(GLib.get_home_dir(), '.codex'),
+ claude: GLib.getenv('CLAUDE_CONFIG_DIR') || join(GLib.get_home_dir(), '.claude'),
+};
+const id = ARGV[0];
+if (!Object.prototype.hasOwnProperty.call(COLLECTORS, id)) {
+ printerr('Usage: gjs -m src/collector/main.js [retention-days]');
+ System.exit(2);
+}
+const retentionText = ARGV[1] ?? '';
+const requestedRetention = /^\d+$/.test(retentionText) ? Number(retentionText) : 30;
+const retention = Math.max(7, Math.min(90, Number.isSafeInteger(requestedRetention) ? requestedRetention : 30));
+const cancellable = new Gio.Cancellable();
+const loop = new GLib.MainLoop(null, false);
+let signal = GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, 15, () => {
+ signal = 0;
+ cancellable.cancel();
+ return GLib.SOURCE_REMOVE;
+});
+const io = {
+ fingerprint,
+ now: () => Date.now(),
+ hasCommand(name) {
+ try { findCommand(name); return true; } catch { return false; }
+ },
+ scan(provider, suffixes, parser) {
+ const root = HISTORY_ROOTS[provider];
+ if (!root)
+ throw new Error(`Unsupported local history provider: ${provider}`);
+ return scanHistory(provider, suffixes.map(suffix => join(root, suffix)), parser, {retention});
+ },
+ credentials(provider) {
+ if (provider === 'claude')
+ return readJson(join(HISTORY_ROOTS.claude, '.credentials.json'), null, 1024 * 1024);
+ return null;
+ },
+ codexClient: () => new RpcClient(commandSpec('codex', ['app-server'], {runtimes: ['node']}), cancellable),
+ http: (url, options) => requestJson(url, {...options, cancellable}),
+};
+
+(async () => {
+ try {
+ const result = validateRecord(await COLLECTORS[id](io), id);
+ if (!cancellable.is_cancelled())
+ print(JSON.stringify(result));
+ } catch {
+ const result = record(id);
+ result.limits = {...result.limits, ...section('unavailable', 'Could not collect usage. Retry or check provider sign-in.')};
+ result.history = {...result.history, ...section('unavailable', 'Could not collect local history.')};
+ if (!cancellable.is_cancelled())
+ print(JSON.stringify(result));
+ } finally {
+ if (signal)
+ GLib.source_remove(signal);
+ loop.quit();
+ }
+})();
+loop.run();
diff --git a/src/core/format.js b/src/core/format.js
new file mode 100644
index 0000000..5b91b1b
--- /dev/null
+++ b/src/core/format.js
@@ -0,0 +1,53 @@
+export function tokens(value) {
+ if (!Number.isFinite(value))
+ return '—';
+ for (const [unit, divisor] of [['B', 1e9], ['M', 1e6], ['K', 1e3]]) {
+ if (value >= divisor)
+ return `${(value / divisor).toFixed(1)}${unit}`;
+ }
+ return String(Math.round(value));
+}
+
+export function compactTokens(value) {
+ return tokens(value).replace(/\.0(?=[BMK]$)/, '');
+}
+
+export function resetTime(time, now = Date.now()) {
+ if (!Number.isFinite(time) || time <= 0)
+ return 'Reset time unavailable';
+ const minutes = Math.ceil((time - now) / 60000);
+ if (minutes <= 0)
+ return 'Reset due · awaiting update';
+ if (minutes >= 1440)
+ return `Resets in ${Math.floor(minutes / 1440)}d ${Math.floor(minutes % 1440 / 60)}h`;
+ if (minutes >= 60)
+ return `Resets in ${Math.floor(minutes / 60)}h ${minutes % 60}m`;
+ return `Resets in ${minutes}m`;
+}
+
+export function resetCountdown(time, now = Date.now()) {
+ const value = resetTime(time, now);
+ if (value.startsWith('Resets in '))
+ return value.slice('Resets in '.length);
+ if (value.startsWith('Reset due'))
+ return 'due';
+ return null;
+}
+
+export function age(time, now = Date.now()) {
+ if (!time)
+ return 'Never updated';
+ const minutes = Math.max(0, Math.floor((now - time) / 60000));
+ if (!minutes)
+ return 'Updated just now';
+ if (minutes < 60)
+ return `Updated ${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ return hours < 24 ? `Updated ${hours}h ago` : `Updated ${Math.floor(hours / 24)}d ago`;
+}
+
+export function modelName(value) {
+ const acronyms = new Map([['gpt', 'GPT'], ['api', 'API']]);
+ return String(value || 'Unknown model').replaceAll(/[-_/]+/g, ' ').split(/\s+/).filter(Boolean)
+ .map(word => acronyms.get(word.toLowerCase()) ?? word[0].toUpperCase() + word.slice(1)).join(' ');
+}
diff --git a/src/core/notifications.js b/src/core/notifications.js
new file mode 100644
index 0000000..056509b
--- /dev/null
+++ b/src/core/notifications.js
@@ -0,0 +1,36 @@
+import {notificationMilestones} from './thresholds.js';
+
+export function notificationBody(alerts) {
+ return [...new Set(alerts.map(alert => {
+ const state = alert.threshold >= 100 ? 'limit reached' : `reached ${alert.threshold}%`;
+ return `${alert.provider}: ${alert.label} ${state}.`;
+ }))].join('\n');
+}
+
+export class ThresholdTracker {
+ constructor() {
+ this.previous = new Map();
+ }
+
+ update(record, threshold = 90) {
+ if (!['ready', 'partial'].includes(record.limits.status))
+ return [];
+ const alerts = [];
+ const milestones = notificationMilestones(threshold);
+ for (const window of record.limits.windows) {
+ if (window.unlimited || !Number.isFinite(window.usedPercent))
+ continue;
+ const key = `${record.id}:${record.accountKey ?? 'default'}:${window.id}:${window.resetsAt ?? 'unknown'}`;
+ const previous = this.previous.get(key);
+ const reached = milestones.filter(value => window.usedPercent >= value).at(-1) ?? 0;
+ if (previous !== undefined && reached > previous)
+ alerts.push({provider: record.name, label: window.label, threshold: reached});
+ // Refresh insertion order so active windows survive eviction of old periods.
+ this.previous.delete(key);
+ this.previous.set(key, Math.max(previous ?? 0, reached));
+ }
+ while (this.previous.size > 200)
+ this.previous.delete(this.previous.keys().next().value);
+ return alerts;
+ }
+}
diff --git a/src/core/thresholds.js b/src/core/thresholds.js
new file mode 100644
index 0000000..57c988a
--- /dev/null
+++ b/src/core/thresholds.js
@@ -0,0 +1,26 @@
+export const QUOTA_THRESHOLDS = Object.freeze({
+ caution: 80,
+ warning: 90,
+ danger: 100,
+});
+
+export function quotaSeverity(percent) {
+ if (!Number.isFinite(percent))
+ return null;
+ if (percent >= QUOTA_THRESHOLDS.danger)
+ return 'danger';
+ if (percent >= QUOTA_THRESHOLDS.warning)
+ return 'warning';
+ if (percent >= QUOTA_THRESHOLDS.caution)
+ return 'caution';
+ return null;
+}
+
+export function notificationMilestones(minimum = QUOTA_THRESHOLDS.warning) {
+ const floor = Number.isFinite(minimum)
+ ? Math.max(0, Math.min(100, Math.round(minimum)))
+ : QUOTA_THRESHOLDS.warning;
+ return [...new Set([floor, ...Object.values(QUOTA_THRESHOLDS)])]
+ .filter(value => value >= floor)
+ .sort((left, right) => left - right);
+}
diff --git a/src/core/usage.js b/src/core/usage.js
new file mode 100644
index 0000000..0ec49b5
--- /dev/null
+++ b/src/core/usage.js
@@ -0,0 +1,273 @@
+export const SCHEMA_VERSION = 2;
+export const NAMES = Object.freeze({codex: 'Codex', claude: 'Claude Code'});
+
+const STATES = new Set(['loading', 'ready', 'partial', 'stale', 'missing-auth', 'unsupported', 'unavailable']);
+const WINDOW_STATES = new Set(['active', 'exhausted', 'unlimited']);
+
+const MAX_WINDOWS = 32;
+const MAX_DAYS = 90;
+const MAX_MODELS = 128;
+const READY_STATES = new Set(['ready', 'partial', 'stale']);
+
+function fail(message) {
+ throw new Error(`Invalid usage record: ${message}`);
+}
+
+function shape(value, field, allowed) {
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
+ Object.keys(value).some(key => !allowed.includes(key)))
+ fail(field);
+}
+
+function text(value, field, {nullable = false, max = 240} = {}) {
+ if (nullable && value === null)
+ return;
+ if (typeof value !== 'string' || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value))
+ fail(field);
+}
+
+function numeric(value, field, {nullable = false, integer = false} = {}) {
+ if (nullable && value === null)
+ return;
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || (integer && !Number.isSafeInteger(value)))
+ fail(field);
+}
+
+function isoDate(value, field) {
+ const parsed = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
+ ? Date.parse(`${value}T00:00:00Z`)
+ : NaN;
+ if (!Number.isFinite(parsed) || new Date(parsed).toISOString().slice(0, 10) !== value)
+ fail(field);
+}
+
+export function number(value) {
+ if (value === null || value === undefined || typeof value === 'boolean' ||
+ !['number', 'string'].includes(typeof value) || (typeof value === 'string' && !value.trim()))
+ return null;
+ const result = Number(value);
+ return Number.isFinite(result) && result >= 0 ? result : null;
+}
+
+export function section(status = 'loading', message = '') {
+ return {status, message, updatedAt: null};
+}
+
+export function isProvider(id) {
+ return typeof id === 'string' && Object.prototype.hasOwnProperty.call(NAMES, id);
+}
+
+export function record(id) {
+ if (!isProvider(id))
+ throw new Error(`Unsupported provider: ${id}`);
+ return {
+ schemaVersion: SCHEMA_VERSION, id, name: NAMES[id], plan: null, accountKey: null,
+ capabilities: {limits: false, history: false, models: false},
+ limits: {...section(), scope: 'account', source: null, windows: []},
+ history: {...section('unsupported', 'This provider does not expose local token history.'),
+ scope: 'local', period: null, days: [], models: [], source: null},
+ };
+}
+
+export function windowUsage({id, label, usedPercent, used, limit, unit = 'percent', durationMinutes = null,
+ resetsAt = null, unlimited = false}) {
+ let percent = number(usedPercent);
+ used = number(used);
+ limit = number(limit);
+ if (percent === null && used !== null && limit > 0)
+ percent = used / limit * 100;
+ if (percent === null && !unlimited)
+ return null;
+ const normalizedPercent = unlimited ? null : percent;
+ return {id, label, usedPercent: normalizedPercent, used, limit, unit, unlimited,
+ state: unlimited ? 'unlimited' : normalizedPercent >= 100 ? 'exhausted' : 'active',
+ durationMinutes: number(durationMinutes), resetsAt: validTime(resetsAt)};
+}
+
+export function validTime(value) {
+ if (value === null || value === undefined || value === '')
+ return null;
+ const numeric = typeof value === 'number' || /^\d+(\.\d+)?$/.test(String(value).trim()) ? Number(value) : null;
+ const ms = numeric !== null ? (numeric < 1e12 ? numeric * 1000 : numeric) : Date.parse(value);
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
+}
+
+export function validateRecord(value, expectedId) {
+ shape(value, 'record shape', [
+ 'schemaVersion', 'id', 'name', 'plan', 'accountKey', 'capabilities', 'limits', 'history',
+ ]);
+ if (!value || value.schemaVersion !== SCHEMA_VERSION || value.id !== expectedId || !isProvider(value.id))
+ fail('provider identity');
+ if (value.name !== NAMES[value.id])
+ fail('provider name');
+ text(value.plan, 'plan', {nullable: true, max: 80});
+ if (value.accountKey !== null && (typeof value.accountKey !== 'string' || !/^[a-f0-9]{64}$/.test(value.accountKey)))
+ fail('account key');
+ shape(value.capabilities, 'provider capabilities', ['limits', 'history', 'models']);
+ if (['limits', 'history', 'models'].some(name => typeof value.capabilities[name] !== 'boolean'))
+ fail('provider capabilities');
+ for (const name of ['limits', 'history']) {
+ shape(value[name], `${name} shape`, name === 'limits'
+ ? ['status', 'message', 'updatedAt', 'scope', 'source', 'windows']
+ : ['status', 'message', 'updatedAt', 'scope', 'source', 'period', 'days', 'models', 'scannedFiles']);
+ if (!value[name] || !STATES.has(value[name].status))
+ fail(`${name} state`);
+ if (typeof value[name].message !== 'string' || value[name].message.length > 500 ||
+ /[\u0000-\u001f\u007f]/.test(value[name].message))
+ fail(`${name} message`);
+ numeric(value[name].updatedAt, `${name} timestamp`, {nullable: true});
+ if (READY_STATES.has(value[name].status) && (!value[name].updatedAt || value[name].updatedAt <= 0))
+ fail(`${name} freshness`);
+ if (!READY_STATES.has(value[name].status) && value[name].updatedAt !== null)
+ fail(`${name} freshness`);
+ if (value[name].source !== null)
+ text(value[name].source, `${name} source`, {max: 160});
+ if (READY_STATES.has(value[name].status) && value[name].source === null)
+ fail(`${name} source`);
+ }
+ if (value.history.scannedFiles !== undefined)
+ numeric(value.history.scannedFiles, 'history scanned files', {integer: true});
+ if (value.limits.scope !== 'account' || !['local', 'account'].includes(value.history.scope))
+ fail('source scope');
+ if (!Array.isArray(value.limits.windows) || !Array.isArray(value.history.days) || !Array.isArray(value.history.models))
+ fail('usage arrays');
+ if (value.limits.windows.length > MAX_WINDOWS || value.history.days.length > MAX_DAYS ||
+ value.history.models.length > MAX_MODELS)
+ fail('collection bounds');
+ if (value.limits.windows.length && !value.capabilities.limits)
+ fail('limits capability');
+ if (READY_STATES.has(value.limits.status) && !value.limits.windows.length)
+ fail('limits readiness');
+ if (value.history.days.length && !value.capabilities.history)
+ fail('history capability');
+ if (value.history.models.length && !value.capabilities.models)
+ fail('models capability');
+ const windows = new Set();
+ for (const item of value.limits.windows) {
+ shape(item, 'quota window', [
+ 'id', 'label', 'usedPercent', 'used', 'limit', 'unit', 'unlimited', 'state', 'durationMinutes', 'resetsAt',
+ ]);
+ text(item.id, 'quota id', {max: 160});
+ text(item.label, 'quota label', {max: 160});
+ text(item.unit, 'quota unit', {max: 40});
+ if (typeof item.unlimited !== 'boolean' || !WINDOW_STATES.has(item.state))
+ fail('quota state');
+ numeric(item.usedPercent, 'quota percentage', {nullable: true});
+ numeric(item.used, 'quota usage', {nullable: true});
+ numeric(item.limit, 'quota limit', {nullable: true});
+ numeric(item.durationMinutes, 'quota duration', {nullable: true, integer: true});
+ numeric(item.resetsAt, 'quota reset', {nullable: true, integer: true});
+ if (item.unlimited ? item.state !== 'unlimited' || item.usedPercent !== null : item.usedPercent === null ||
+ item.state !== (item.usedPercent >= 100 ? 'exhausted' : 'active'))
+ fail('quota consistency');
+ if (windows.has(item.id))
+ fail('duplicate quota');
+ windows.add(item.id);
+ }
+ if (value.history.period === null) {
+ if (value.history.days.length || value.history.models.length)
+ fail('history period');
+ } else {
+ shape(value.history.period, 'history period', ['start', 'end']);
+ isoDate(value.history.period?.start, 'history start date');
+ isoDate(value.history.period?.end, 'history end date');
+ if (value.history.period.start > value.history.period.end)
+ fail('history date order');
+ }
+ const dates = new Set();
+ let previousDate = null;
+ for (const item of value.history.days) {
+ shape(item, 'daily entry', ['date', 'total', 'sessions', 'events']);
+ isoDate(item.date, 'daily date');
+ numeric(item.total, 'daily total', {integer: true});
+ numeric(item.sessions, 'daily sessions', {integer: true});
+ numeric(item.events, 'daily events', {integer: true});
+ if (dates.has(item.date) || (previousDate && item.date <= previousDate) ||
+ item.date < value.history.period.start || item.date > value.history.period.end)
+ fail('daily ordering');
+ dates.add(item.date);
+ previousDate = item.date;
+ }
+ const models = new Set();
+ for (const item of value.history.models) {
+ shape(item, 'model entry', ['model', 'total', 'input', 'output', 'cacheRead', 'cacheWrite']);
+ text(item.model, 'model name', {max: 160});
+ for (const field of ['total', 'input', 'output', 'cacheRead', 'cacheWrite'])
+ numeric(item[field], `model ${field}`, {integer: true});
+ if (models.has(item.model) || item.total !== item.input + item.output + item.cacheRead + item.cacheWrite)
+ fail('model totals');
+ models.add(item.model);
+ }
+ return value;
+}
+
+export function mergeRecord(previous, next, now = Date.now()) {
+ if (!previous || (previous.accountKey && next.accountKey && previous.accountKey !== next.accountKey))
+ return next;
+ const result = {...next};
+ for (const key of ['limits', 'history']) {
+ const old = previous[key];
+ const current = next[key];
+ if (current.status === 'unavailable' && old?.updatedAt &&
+ ['ready', 'partial', 'stale'].includes(old.status)) {
+ const preserved = key === 'limits'
+ ? {...old, windows: old.windows.filter(window => !window.resetsAt || window.resetsAt > now)}
+ : old;
+ if (key !== 'limits' || preserved.windows.length)
+ result[key] = {...preserved, status: 'stale', message: current.message};
+ }
+ }
+ if (result.limits.status === 'stale') {
+ result.plan ??= previous.plan;
+ result.accountKey ??= previous.accountKey;
+ }
+ return result;
+}
+
+export function localDate(time) {
+ if (time === null || time === undefined || time === '')
+ return null;
+ const d = new Date(time);
+ if (!Number.isFinite(d.getTime()))
+ return null;
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+}
+
+export function recentDates(now, count = 7) {
+ const today = new Date(now);
+ today.setHours(12, 0, 0, 0);
+ return Array.from({length: count}, (_, i) => {
+ const day = new Date(today);
+ day.setDate(day.getDate() - (count - 1 - i));
+ return localDate(day);
+ });
+}
+
+export function aggregateEvents(events, now = Date.now(), count = 7) {
+ const dates = recentDates(now, count);
+ const days = new Map(dates.map(date => [date, {date, total: 0, sessions: 0, events: 0}]));
+ const models = new Map();
+ const seen = new Set();
+ const sessions = new Map(dates.map(date => [date, new Set()]));
+ for (const event of events) {
+ const day = days.get(event.date);
+ if (!day || seen.has(event.id))
+ continue;
+ seen.add(event.id);
+ const tokens = ['input', 'output', 'cacheRead', 'cacheWrite'].map(k => number(event[k]) ?? 0);
+ const total = tokens.reduce((a, b) => a + b, 0);
+ if (!total)
+ continue;
+ const model = String(event.model || 'Unknown model');
+ const bucket = models.get(model) ?? {model, total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0};
+ ['input', 'output', 'cacheRead', 'cacheWrite'].forEach((k, i) => { bucket[k] += tokens[i]; });
+ bucket.total += total;
+ models.set(model, bucket);
+ day.total += total;
+ day.events++;
+ sessions.get(event.date).add(event.session);
+ day.sessions = sessions.get(event.date).size;
+ }
+ return {period: {start: dates[0], end: dates.at(-1)}, days: [...days.values()],
+ models: [...models.values()].sort((a, b) => b.total - a.total)};
+}
diff --git a/src/providers/claude.js b/src/providers/claude.js
new file mode 100644
index 0000000..b66fbc0
--- /dev/null
+++ b/src/providers/claude.js
@@ -0,0 +1,168 @@
+import {localDate, number, record, section, validTime, windowUsage} from '../core/usage.js';
+
+const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
+const MAX_WINDOWS = 32;
+
+function shortText(value, fallback = null, max = 160) {
+ if (typeof value !== 'string')
+ return fallback;
+ const text = value.trim();
+ return text && !/[\u0000-\u001f\u007f]/.test(text) ? text.slice(0, max) : fallback;
+}
+
+function planLabel(tier, subscription) {
+ const match = shortText(tier, '')?.match(/max_(\d+x)/i);
+ if (match)
+ return `Max ${match[1]}`;
+ const value = shortText(subscription, '', 79);
+ return value ? value[0].toUpperCase() + value.slice(1) : null;
+}
+
+export function claudeLogin(credentials) {
+ const login = credentials?.claudeAiOauth;
+ if (!login || typeof login !== 'object')
+ return {token: null, expiresAt: null, plan: null};
+ return {token: shortText(login.accessToken, null, 8192),
+ expiresAt: number(login.expiresAt),
+ plan: planLabel(login.rateLimitTier, login.subscriptionType)};
+}
+
+function rawUtilization(value) {
+ if (value === null || value === undefined || value === '')
+ return null;
+ const result = Number(String(value).trim().replace('%', ''));
+ return Number.isFinite(result) && result >= 0 ? result : null;
+}
+
+function utilization(value, percentScale) {
+ const result = rawUtilization(value);
+ if (result === null)
+ return null;
+ return Math.min(100, percentScale || result > 1 ? result : result * 100);
+}
+
+function scopedDuration(kind) {
+ const value = String(kind || '').toLowerCase();
+ if (value.includes('month'))
+ return {label: 'Monthly', minutes: 43200};
+ if (value.includes('week') || value.includes('day'))
+ return {label: 'Weekly', minutes: 10080};
+ if (value.includes('hour') || value.includes('session'))
+ return {label: 'Session', minutes: 300};
+ return {label: '', minutes: null};
+}
+
+export function claudeLimits(payload, now = Date.now()) {
+ if (!payload || typeof payload !== 'object')
+ return {...section('unavailable', 'Claude did not report any quota windows.'),
+ scope: 'account', source: 'Anthropic OAuth usage', windows: []};
+ const session = payload.five_hour;
+ const weekly = payload.seven_day_oauth_apps ?? payload.seven_day;
+ const scoped = Array.isArray(payload.limits) ? payload.limits : [];
+ const raw = [session?.utilization, weekly?.utilization,
+ ...scoped.map(item => item?.percent)].map(rawUtilization).filter(value => value !== null);
+ const percentScale = raw.some(value => value >= 1);
+ const windows = [];
+ let truncated = false;
+ const add = (id, label, bucket, durationMinutes) => {
+ if (!bucket || typeof bucket !== 'object')
+ return;
+ const item = windowUsage({id, label, usedPercent: utilization(bucket.utilization, percentScale),
+ durationMinutes, resetsAt: validTime(bucket.resets_at)});
+ if (item)
+ windows.push(item);
+ };
+ add('five-hour', 'Session · 5 hours', session, 300);
+ add('weekly', 'Weekly', weekly, 10080);
+ const seen = new Set();
+ for (const item of scoped) {
+ if (windows.length >= MAX_WINDOWS) {
+ truncated = true;
+ break;
+ }
+ const model = item?.scope?.model;
+ const name = shortText(model?.display_name ?? model?.id, '', 100);
+ const kind = shortText(item?.kind, '', 40);
+ const key = `${name}:${kind}`;
+ if (!name || seen.has(key))
+ continue;
+ const duration = scopedDuration(kind);
+ const value = windowUsage({id: `scoped:${key}`, label: `${name}${duration.label ? ` · ${duration.label}` : ''}`,
+ usedPercent: utilization(item.percent, percentScale), durationMinutes: duration.minutes,
+ resetsAt: validTime(item.resets_at)});
+ if (value) {
+ seen.add(key);
+ windows.push(value);
+ }
+ }
+ return windows.length
+ ? {...section(truncated ? 'partial' : 'ready',
+ truncated ? 'Some Claude quota windows were omitted to keep the response bounded.' : ''),
+ updatedAt: now, scope: 'account', source: 'Anthropic OAuth usage', windows}
+ : {...section('unavailable', 'Claude did not report any supported quota windows.'),
+ scope: 'account', source: 'Anthropic OAuth usage', windows: []};
+}
+
+export function parseClaudeEvent(entry, state) {
+ const message = entry?.message && typeof entry.message === 'object' ? entry.message : {};
+ if (entry?.type !== 'assistant' && message.role !== 'assistant')
+ return null;
+ const usage = message.usage ?? entry.usage;
+ if (!usage || typeof usage !== 'object')
+ return null;
+ if (typeof entry.sessionId === 'string' && entry.sessionId)
+ state.session = entry.sessionId;
+ const timestamp = entry.timestamp ?? message.timestamp;
+ const date = localDate(timestamp);
+ if (!date)
+ return null;
+ const input = number(usage.input_tokens ?? usage.inputTokens) ?? 0;
+ const output = number(usage.output_tokens ?? usage.outputTokens) ?? 0;
+ const cacheRead = number(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens) ?? 0;
+ const cacheWrite = number(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens) ?? 0;
+ if (input + output + cacheRead + cacheWrite === 0)
+ return null;
+ const model = shortText(message.model ?? entry.model ?? state.model, 'Unknown model');
+ state.model = model;
+ const identity = message.id ?? entry.messageId ?? entry.uuid ?? entry.requestId ?? `${timestamp}:${JSON.stringify(usage)}`;
+ return {id: `${state.session}:${identity}`, session: state.session, date, model,
+ input, output, cacheRead, cacheWrite};
+}
+
+export async function collectClaude(io) {
+ const now = io.now?.() ?? Date.now();
+ const result = record('claude');
+ result.capabilities = {limits: true, history: true, models: true};
+ result.history = io.scan('claude', ['projects'], parseClaudeEvent);
+ const login = claudeLogin(io.credentials('claude'));
+ result.plan = login.plan;
+ if (!login.token) {
+ const installed = io.hasCommand?.('claude') !== false;
+ result.limits = {...result.limits, ...section(installed ? 'missing-auth' : 'unsupported',
+ installed ? 'Run claude auth login to read account limits.' : 'Install Claude Code, then sign in to read account limits.')};
+ return result;
+ }
+ result.accountKey = io.fingerprint(login.token);
+ if (login.expiresAt && login.expiresAt <= now) {
+ result.limits = {...result.limits, ...section('missing-auth', 'Claude Code sign-in expired. Start Claude Code or run claude auth login.')};
+ return result;
+ }
+ try {
+ const response = await io.http(USAGE_URL, {headers: {
+ Authorization: `Bearer ${login.token}`,
+ 'anthropic-beta': 'oauth-2025-04-20',
+ Accept: 'application/json',
+ }});
+ if (response.status === 200) {
+ result.limits = claudeLimits(response.data, now);
+ } else {
+ result.limits = {...result.limits, ...section([401, 403].includes(response.status) ? 'missing-auth' : 'unavailable',
+ [401, 403].includes(response.status) ? 'Reconnect Claude Code to read account limits.' :
+ response.status === 429 ? 'Anthropic is rate limiting usage checks. Local history is still available.' :
+ `Claude usage endpoint unavailable (HTTP ${response.status}).`)};
+ }
+ } catch {
+ result.limits = {...result.limits, ...section('unavailable', 'Could not reach Claude usage. Local history is still available.')};
+ }
+ return result;
+}
diff --git a/src/providers/codex.js b/src/providers/codex.js
new file mode 100644
index 0000000..226b0fa
--- /dev/null
+++ b/src/providers/codex.js
@@ -0,0 +1,136 @@
+import {localDate, number, record, section, windowUsage} from '../core/usage.js';
+
+const MAX_WINDOWS = 32;
+
+function shortText(value, fallback = null, max = 80) {
+ if (typeof value !== 'string')
+ return fallback;
+ const text = value.trim();
+ return text && !/[\u0000-\u001f\u007f]/.test(text) ? text.slice(0, max) : fallback;
+}
+
+function compareBuckets([left], [right]) {
+ if (left === 'codex')
+ return -1;
+ if (right === 'codex')
+ return 1;
+ return left.localeCompare(right);
+}
+
+export function codexLimits(response, now = Date.now()) {
+ const scoped = response?.rateLimitsByLimitId && typeof response.rateLimitsByLimitId === 'object' &&
+ !Array.isArray(response.rateLimitsByLimitId)
+ ? Object.entries(response.rateLimitsByLimitId).sort(compareBuckets)
+ : [];
+ const buckets = scoped.length ? scoped : [['codex', response?.rateLimits]];
+ const windows = [];
+ let truncated = false;
+ for (const [bucketId, bucket] of buckets) {
+ if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket))
+ continue;
+ for (const key of ['primary', 'secondary']) {
+ if (windows.length >= MAX_WINDOWS) {
+ truncated = true;
+ break;
+ }
+ const w = bucket?.[key];
+ if (!w || typeof w !== 'object' || Array.isArray(w))
+ continue;
+ const reportedMinutes = number(w.windowDurationMins);
+ const mins = Number.isSafeInteger(reportedMinutes) ? reportedMinutes : null;
+ const duration = mins === 10080 ? 'Weekly' : mins === 300 ? 'Session · 5 hours'
+ : mins ? `${mins >= 60 ? `${mins / 60} hours` : `${mins} minutes`}` : key === 'primary' ? 'Primary window' : 'Secondary window';
+ const safeBucketId = shortText(bucketId, 'quota', 120);
+ const bucketName = shortText(bucket.limitName,
+ safeBucketId === 'codex' ? null : safeBucketId, 120);
+ const item = windowUsage({id: `${safeBucketId}:${key}`,
+ label: buckets.length > 1 && bucketName ? `${bucketName} · ${duration}` : duration,
+ usedPercent: w.usedPercent, durationMinutes: mins,
+ resetsAt: w.resetsAt});
+ if (item)
+ windows.push(item);
+ }
+ if (truncated)
+ break;
+ }
+ return windows.length
+ ? {...section(truncated ? 'partial' : 'ready',
+ truncated ? 'Some Codex quota windows were omitted to keep the response bounded.' : ''),
+ updatedAt: now, scope: 'account', source: 'Codex app-server', windows}
+ : {...section('unavailable', 'Codex did not report quota windows for this account.'),
+ scope: 'account', source: 'Codex app-server', windows: []};
+}
+
+export function parseCodexEvent(entry, state) {
+ if (entry?.type === 'session_meta')
+ state.session = entry.payload?.id || state.session;
+ if (entry?.type === 'turn_context')
+ state.model = entry.payload?.model || entry.payload?.model_slug || state.model;
+ const payload = entry?.payload;
+ if (payload?.type !== 'token_count' || !payload.info)
+ return null;
+ const cumulative = payload.info.total_token_usage;
+ let usage = payload.info.last_token_usage;
+ let identity;
+ if (cumulative && number(cumulative.total_tokens) !== null) {
+ const fields = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'cache_write_input_tokens'];
+ const current = Object.fromEntries(fields.map(field => [field, number(cumulative[field]) ?? 0]));
+ current.total_tokens = Number(cumulative.total_tokens);
+ if (state.cumulative && current.total_tokens === state.cumulative.total_tokens)
+ return null;
+ if (state.cumulative && current.total_tokens < state.cumulative.total_tokens) {
+ state.cumulativeEpoch = (number(state.cumulativeEpoch) ?? 0) + 1;
+ usage = current;
+ } else {
+ const previous = state.cumulative ?? {};
+ usage = Object.fromEntries(fields.map(field =>
+ [field, Math.max(0, current[field] - (number(previous[field]) ?? 0))]));
+ }
+ state.cumulative = current;
+ identity = `total:${state.cumulativeEpoch ?? 0}:${current.total_tokens}`;
+ } else {
+ identity = `${entry.timestamp}:${JSON.stringify(usage)}`;
+ }
+ if (!usage || !localDate(entry.timestamp))
+ return null;
+ const input = number(usage.input_tokens) ?? 0;
+ const cacheRead = Math.min(input, number(usage.cached_input_tokens) ?? 0);
+ const cacheWrite = Math.min(input - cacheRead, number(usage.cache_write_input_tokens) ?? 0);
+ return {id: `${state.session}:${identity}`, session: state.session, date: localDate(entry.timestamp),
+ model: state.model || 'Unknown model', input: input - cacheRead - cacheWrite,
+ output: number(usage.output_tokens) ?? 0, cacheRead, cacheWrite};
+}
+
+export async function collectCodex(io) {
+ const now = io.now?.() ?? Date.now();
+ const result = record('codex');
+ result.capabilities = {limits: true, history: true, models: true};
+ result.history = io.scan('codex', ['sessions', 'archived_sessions'], parseCodexEvent);
+ let rpc;
+ try {
+ rpc = io.codexClient();
+ await rpc.request('initialize', {clientInfo: {name: 'usagebeam', title: 'UsageBeam', version: '2.0.0'},
+ capabilities: {experimentalApi: false}});
+ rpc.notify('initialized', {});
+ const account = await rpc.request('account/read', {refreshToken: false});
+ if (!account?.account) {
+ result.limits = {...result.limits, ...section('missing-auth', 'Sign in with codex login to read account limits.')};
+ return result;
+ }
+ result.plan = shortText(account.account.planType ?? account.account.type);
+ const accountIdentity = shortText(account.account.email ?? account.account.chatgptAccountId, null, 320);
+ result.accountKey = accountIdentity ? io.fingerprint(accountIdentity) : null;
+ const limits = await rpc.request('account/rateLimits/read', {});
+ result.limits = codexLimits(limits, now);
+ const scopedLimits = limits?.rateLimitsByLimitId && typeof limits.rateLimitsByLimitId === 'object' &&
+ !Array.isArray(limits.rateLimitsByLimitId) ? Object.values(limits.rateLimitsByLimitId) : [];
+ result.plan = shortText(limits?.rateLimits?.planType ??
+ scopedLimits.find(bucket => bucket?.planType)?.planType, result.plan);
+ } catch (error) {
+ result.limits = {...result.limits, ...section(error.code === 'NOT_FOUND' ? 'unsupported' : 'unavailable',
+ error.code === 'NOT_FOUND' ? 'Install the Codex CLI to read account limits.' : 'Could not read Codex limits. Check CLI sign-in and compatibility.')};
+ } finally {
+ rpc?.close();
+ }
+ return result;
+}
diff --git a/src/services/commands.js b/src/services/commands.js
new file mode 100644
index 0000000..1034193
--- /dev/null
+++ b/src/services/commands.js
@@ -0,0 +1,138 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import {join} from './files.js';
+
+const STATIC_DIRECTORIES = [
+ '.local/bin',
+ '.cargo/bin',
+ '.npm-global/bin',
+ '.volta/bin',
+ '.asdf/shims',
+ '.local/share/mise/shims',
+ '.bun/bin',
+];
+
+const VERSIONED_DIRECTORIES = [
+ ['.local/share/fnm/node-versions', ['installation', 'bin']],
+ ['.nvm/versions/node', ['bin']],
+ ['.local/share/mise/installs/node', ['bin']],
+ ['.asdf/installs/nodejs', ['bin']],
+];
+
+function validCommandName(name) {
+ return typeof name === 'string' && /^[A-Za-z0-9._+-]+$/.test(name) && name !== '.' && name !== '..';
+}
+
+function executable(path) {
+ return GLib.file_test(path, GLib.FileTest.IS_REGULAR) &&
+ GLib.file_test(path, GLib.FileTest.IS_EXECUTABLE) ? path : null;
+}
+
+function versionParts(value) {
+ return (String(value).match(/\d+/g) ?? []).map(Number);
+}
+
+function compareVersions(a, b) {
+ const left = versionParts(a);
+ const right = versionParts(b);
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
+ if (difference)
+ return difference;
+ }
+ return String(a).localeCompare(String(b));
+}
+
+function commandDirectories(command) {
+ const directories = [];
+ let current = command;
+ for (let depth = 0; depth < 8; depth++) {
+ const directory = GLib.path_get_dirname(current);
+ if (!directories.includes(directory))
+ directories.push(directory);
+ if (!GLib.file_test(current, GLib.FileTest.IS_SYMLINK))
+ break;
+ try {
+ const target = GLib.file_read_link(current);
+ current = GLib.canonicalize_filename(target, GLib.path_get_dirname(current));
+ } catch {
+ break;
+ }
+ }
+ return directories;
+}
+
+function versionedCommand(root, suffix, name) {
+ const directory = Gio.File.new_for_path(root);
+ if (!directory.query_exists(null))
+ return null;
+
+ const candidates = [];
+ let iterator = null;
+ try {
+ iterator = directory.enumerate_children('standard::name,standard::type,standard::is-symlink',
+ Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ let info;
+ while ((info = iterator.next_file(null))) {
+ if (info.get_is_symlink() || info.get_file_type() !== Gio.FileType.DIRECTORY)
+ continue;
+ const path = executable(join(root, info.get_name(), ...suffix, name));
+ if (path)
+ candidates.push({version: info.get_name(), path});
+ }
+ } catch {
+ return null;
+ } finally {
+ iterator?.close(null);
+ }
+
+ candidates.sort((a, b) => compareVersions(b.version, a.version));
+ return candidates[0]?.path ?? null;
+}
+
+export function findCommand(name, {home = GLib.get_home_dir(), usePath = true} = {}) {
+ if (!validCommandName(name))
+ throw new Error('Invalid command name');
+
+ const found = usePath ? GLib.find_program_in_path(name) : null;
+ if (found)
+ return found;
+
+ for (const directory of STATIC_DIRECTORIES) {
+ const path = executable(join(home, directory, name));
+ if (path)
+ return path;
+ }
+
+ for (const [directory, suffix] of VERSIONED_DIRECTORIES) {
+ const path = versionedCommand(join(home, directory), suffix, name);
+ if (path)
+ return path;
+ }
+
+ const error = new Error(`${name} is not installed`);
+ error.code = 'NOT_FOUND';
+ throw error;
+}
+
+export function commandSpec(name, args = [], {runtimes = [], ...options} = {}) {
+ if (!Array.isArray(args) || args.some(value => typeof value !== 'string' &&
+ (typeof value !== 'number' || !Number.isFinite(value))))
+ throw new Error('Command arguments must be strings or numbers');
+ if (!Array.isArray(runtimes) || runtimes.some(runtime => !validCommandName(runtime)))
+ throw new Error('Command runtimes must be command names');
+ const command = findCommand(name, options);
+ const directories = commandDirectories(command);
+ for (const runtime of runtimes) {
+ try {
+ const directory = GLib.path_get_dirname(findCommand(runtime, options));
+ if (!directories.includes(directory))
+ directories.push(directory);
+ } catch {
+ // The command may be native or resolve its own runtime.
+ }
+ }
+ const inheritedPath = GLib.getenv('PATH') || '/usr/local/bin:/usr/bin:/bin';
+ const path = [...directories, ...inheritedPath.split(':').filter(entry => entry && !directories.includes(entry))].join(':');
+ return {argv: [command, ...args.map(String)], environment: {PATH: path}};
+}
diff --git a/src/services/files.js b/src/services/files.js
new file mode 100644
index 0000000..fc12096
--- /dev/null
+++ b/src/services/files.js
@@ -0,0 +1,88 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+
+export const join = (...parts) => GLib.build_filenamev(parts);
+export const fingerprint = value => GLib.compute_checksum_for_string(GLib.ChecksumType.SHA256, String(value), -1);
+
+const PRODUCT_DIRECTORY = 'usagebeam';
+const LEGACY_DIRECTORY = 'freeby';
+const PROVIDERS = Object.freeze(['codex', 'claude']);
+
+function readText(path, fallback = null, maxBytes = 16 * 1024 * 1024) {
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
+ throw new Error('File size limit must be a positive integer');
+ try {
+ const file = Gio.File.new_for_path(path);
+ if (file.query_info('standard::size', Gio.FileQueryInfoFlags.NONE, null).get_size() > maxBytes)
+ return fallback;
+ const [, bytes] = file.load_contents(null);
+ if (bytes.length > maxBytes)
+ return fallback;
+ return new TextDecoder().decode(bytes);
+ } catch {
+ return fallback;
+ }
+}
+
+export function readJson(path, fallback = null, maxBytes = 16 * 1024 * 1024) {
+ const text = readText(path, null, maxBytes);
+ if (text === null)
+ return fallback;
+ try {
+ return JSON.parse(text);
+ } catch {
+ return fallback;
+ }
+}
+
+export function writeJson(path, value) {
+ const file = Gio.File.new_for_path(path);
+ const parent = file.get_parent();
+ const parentPath = parent?.get_path();
+ if (!parentPath || GLib.mkdir_with_parents(parentPath, 0o700) < 0 || GLib.chmod(parentPath, 0o700) < 0)
+ throw new Error('Could not secure the private data directory');
+ file.replace_contents(new TextEncoder().encode(JSON.stringify(value)), null, false,
+ Gio.FileCreateFlags.PRIVATE | Gio.FileCreateFlags.REPLACE_DESTINATION, null);
+ if (GLib.chmod(path, 0o600) < 0)
+ throw new Error('Could not secure the private data file');
+}
+
+export function stateDirectory() {
+ return join(GLib.get_user_state_dir(), PRODUCT_DIRECTORY);
+}
+
+export function cacheDirectory() {
+ return join(GLib.get_user_cache_dir(), PRODUCT_DIRECTORY);
+}
+
+export function migrateLegacyData({
+ legacyState = join(GLib.get_user_state_dir(), LEGACY_DIRECTORY),
+ legacyCache = join(GLib.get_user_cache_dir(), LEGACY_DIRECTORY),
+ state = stateDirectory(),
+ cache = cacheDirectory(),
+} = {}) {
+ const groups = [
+ {source: legacyState, destination: state, names: PROVIDERS.map(id => `${id}.json`),
+ maxBytes: 16 * 1024 * 1024},
+ {source: legacyCache, destination: cache, names: PROVIDERS.map(id => `history-${id}.json`),
+ maxBytes: 64 * 1024 * 1024},
+ ];
+ let migrated = 0;
+ for (const group of groups) {
+ for (const name of group.names) {
+ const destination = join(group.destination, name);
+ if (Gio.File.new_for_path(destination).query_exists(null))
+ continue;
+ const value = readJson(join(group.source, name), null, group.maxBytes);
+ if (value === null)
+ continue;
+ try {
+ writeJson(destination, value);
+ migrated++;
+ } catch {
+ // Migration is best-effort; collection can rebuild derived data.
+ }
+ }
+ }
+ return migrated;
+}
diff --git a/src/services/history.js b/src/services/history.js
new file mode 100644
index 0000000..9283ffc
--- /dev/null
+++ b/src/services/history.js
@@ -0,0 +1,315 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import {aggregateEvents, recentDates, section} from '../core/usage.js';
+import {cacheDirectory, fingerprint, join, readJson, writeJson} from './files.js';
+
+const CACHE_VERSION = 5;
+const MAX_DEPTH = 12;
+const MAX_FILES = 20000;
+const MAX_LINE_BYTES = 4 * 1024 * 1024;
+const MAX_MODELS = 128;
+const SCAN_SECONDS = 20;
+
+function fileStamp(info) {
+ const modified = info.get_attribute_uint64('time::modified');
+ const modifiedUsec = info.get_attribute_uint32('time::modified-usec');
+ const changed = info.get_attribute_uint64('time::changed');
+ const changedUsec = info.get_attribute_uint32('time::changed-usec');
+ return `${modified}:${modifiedUsec}:${changed}:${changedUsec}`;
+}
+
+function listFiles(root, output, deadline, depth = 0) {
+ if (depth > MAX_DEPTH || output.length >= MAX_FILES || GLib.get_monotonic_time() > deadline)
+ throw new Error('History directory exceeds scan bounds');
+ const file = Gio.File.new_for_path(root);
+ if (!file.query_exists(null))
+ return false;
+ const iterator = file.enumerate_children('standard::name,standard::type,standard::is-symlink,standard::size,' +
+ 'time::modified,time::modified-usec,time::changed,time::changed-usec,id::file',
+ Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ try {
+ let info;
+ while ((info = iterator.next_file(null))) {
+ if (GLib.get_monotonic_time() > deadline)
+ throw new Error('History directory scan timed out');
+ if (info.get_is_symlink())
+ continue;
+ const path = join(root, info.get_name());
+ if (info.get_file_type() === Gio.FileType.DIRECTORY)
+ listFiles(path, output, deadline, depth + 1);
+ else if (info.get_file_type() === Gio.FileType.REGULAR && info.get_name().endsWith('.jsonl')) {
+ if (output.length >= MAX_FILES)
+ throw new Error('History directory exceeds scan bounds');
+ output.push({path, size: info.get_size(), stamp: fileStamp(info),
+ fileId: info.get_attribute_string('id::file') ?? ''});
+ }
+ }
+ } finally {
+ iterator.close(null);
+ }
+ return true;
+}
+
+function privateIdentity(provider, value) {
+ const text = String(value ?? '');
+ return /^h:[a-f0-9]{64}$/.test(text) ? text : `h:${fingerprint(`${provider}:${text}`)}`;
+}
+
+function parserState(value, fallbackSession) {
+ const session = typeof value?.session === 'string' && /^h:[a-f0-9]{64}$/.test(value.session)
+ ? value.session
+ : fallbackSession;
+ const state = {session};
+ if (typeof value?.model === 'string' && value.model.trim() && value.model.length <= 160 &&
+ !/[\u0000-\u001f\u007f]/.test(value.model))
+ state.model = value.model;
+ if (numberValue(value?.cumulativeEpoch) !== null)
+ state.cumulativeEpoch = numberValue(value.cumulativeEpoch);
+ if (value?.cumulative && typeof value.cumulative === 'object') {
+ const fields = ['total_tokens', 'input_tokens', 'output_tokens', 'cached_input_tokens', 'cache_write_input_tokens'];
+ if (fields.every(field => numberValue(value.cumulative[field]) !== null))
+ state.cumulative = Object.fromEntries(fields.map(field => [field, numberValue(value.cumulative[field])]));
+ }
+ return state;
+}
+
+function numberValue(value) {
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
+}
+
+function cachedFile(value, fallbackSession) {
+ if (!value || typeof value !== 'object' || !value.events || typeof value.events !== 'object' ||
+ Array.isArray(value.events))
+ return null;
+ const offset = numberValue(value.offset);
+ const size = value.size === -1 ? -1 : numberValue(value.size);
+ if (offset === null || size === null || typeof value.stamp !== 'string' || typeof value.fileId !== 'string')
+ return null;
+ return {offset, size, stamp: value.stamp, fileId: value.fileId,
+ digest: typeof value.digest === 'string' && /^[a-f0-9]{64}$/.test(value.digest) ? value.digest : null,
+ skipping: value.skipping === true,
+ state: parserState(value.state, fallbackSession), events: value.events, partial: value.partial === true};
+}
+
+function newFileState(fileId, session) {
+ return {offset: 0, size: 0, stamp: '', fileId, digest: null, skipping: false,
+ state: {session}, events: {}, partial: false};
+}
+
+function hashPrefix(input, length, deadline) {
+ const checksum = new GLib.Checksum(GLib.ChecksumType.SHA256);
+ input.seek(0, GLib.SeekType.SET, null);
+ let remaining = length;
+ while (remaining > 0) {
+ if (GLib.get_monotonic_time() > deadline)
+ throw new Error('History validation timed out');
+ const bytes = input.read_bytes(Math.min(65536, remaining), null).toArray();
+ if (!bytes.length)
+ throw new Error('History changed while validating');
+ checksum.update(bytes);
+ remaining -= bytes.length;
+ }
+ return checksum;
+}
+
+function validDate(value) {
+ const parsed = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
+ ? Date.parse(`${value}T00:00:00Z`)
+ : NaN;
+ return Number.isFinite(parsed) && new Date(parsed).toISOString().slice(0, 10) === value;
+}
+
+function sanitizedEvent(event, provider, stored = false) {
+ if (!event || typeof event !== 'object' || typeof event.id !== 'string' || !event.id ||
+ typeof event.session !== 'string' || !event.session || typeof event.model !== 'string' ||
+ !event.model.trim() || event.model.length > 160 || event.id.length > 2048 || event.session.length > 2048 ||
+ /[\u0000-\u001f\u007f]/.test(event.model) || !validDate(event.date) ||
+ (stored && (!/^h:[a-f0-9]{64}$/.test(event.id) || !/^h:[a-f0-9]{64}$/.test(event.session))))
+ return null;
+ const values = Object.fromEntries(['input', 'output', 'cacheRead', 'cacheWrite']
+ .map(field => [field, numberValue(event[field])]));
+ if (Object.values(values).some(value => value === null))
+ return null;
+ return {...values, date: event.date, model: event.model,
+ id: stored ? event.id : privateIdentity(provider, event.id),
+ session: stored ? event.session : privateIdentity(provider, event.session)};
+}
+
+export function scanHistory(id, roots, parse, {retention = 30, now = Date.now(), cachePath = null} = {}) {
+ if (!Number.isSafeInteger(retention) || retention < 7 || retention > 90 ||
+ !Number.isSafeInteger(now) || now <= 0 || !Array.isArray(roots) || !roots.length ||
+ roots.some(root => typeof root !== 'string' || !root) || typeof parse !== 'function' ||
+ (cachePath !== null && (typeof cachePath !== 'string' || !cachePath)))
+ throw new Error('Invalid history scan options');
+ const cutoff = recentDates(now, retention)[0];
+ const destination = cachePath ?? join(cacheDirectory(), `history-${id}.json`);
+ let cached = readJson(destination, {}, 64 * 1024 * 1024);
+ const identity = fingerprint(JSON.stringify(roots));
+ if (cached?.version !== CACHE_VERSION || cached.identity !== identity || !cached.files || typeof cached.files !== 'object' ||
+ Array.isArray(cached.files))
+ cached = {version: CACHE_VERSION, identity, files: {}, updatedAt: null};
+ const files = [];
+ let detected = false;
+ let partial = false;
+ let parsed = 0;
+ const started = GLib.get_monotonic_time();
+ const deadline = started + SCAN_SECONDS * 1000000;
+ for (const root of roots) {
+ try { detected = listFiles(root, files, deadline) || detected; } catch { partial = true; }
+ }
+ files.sort((a, b) => a.path.localeCompare(b.path));
+ const currentKeys = new Set(files.map(file => fingerprint(file.path)));
+ for (const file of files) {
+ if ((GLib.get_monotonic_time() - started) / 1000000 > SCAN_SECONDS) {
+ partial = true;
+ break;
+ }
+ const key = fingerprint(file.path);
+ const fallbackSession = privateIdentity(id, key);
+ let previous = cachedFile(cached.files[key], fallbackSession);
+ if (previous?.size === file.size && previous.stamp === file.stamp && previous.fileId === file.fileId) {
+ partial ||= previous.partial;
+ continue;
+ }
+ // Changed or truncated files are rebuilt; append-only files resume at the last complete line.
+ if (!previous || file.fileId !== previous.fileId || file.size < previous.size || previous.offset > file.size ||
+ (file.size === previous.size && file.stamp !== previous.stamp))
+ previous = newFileState(file.fileId, fallbackSession);
+ let input;
+ try {
+ input = Gio.File.new_for_path(file.path).read(null);
+ // Verify the entire committed prefix before resuming. In-place rewrites
+ // can grow as well as shrink, so size/mtime or sampled bytes are not proof
+ // that a file is append-only. Only a digest reaches the private cache.
+ let checksum = hashPrefix(input, previous.offset, deadline);
+ if (previous.offset && (!previous.digest || checksum.copy().get_string() !== previous.digest)) {
+ previous = newFileState(file.fileId, fallbackSession);
+ checksum = hashPrefix(input, 0, deadline);
+ }
+ input.seek(previous.offset, GLib.SeekType.SET, null);
+ let offset = previous.offset;
+ let tail = new Uint8Array();
+ let stop = false;
+ while (!stop) {
+ const bytes = input.read_bytes(65536, null).toArray();
+ if (!bytes.length)
+ break;
+ const buffer = new Uint8Array(tail.length + bytes.length);
+ buffer.set(tail);
+ buffer.set(bytes, tail.length);
+ let start = 0;
+ for (let i = 0; i < buffer.length; i++) {
+ if (buffer[i] !== 10)
+ continue;
+ const line = buffer.subarray(start, i);
+ offset += i - start + 1;
+ start = i + 1;
+ if (previous.skipping || line.length > MAX_LINE_BYTES) {
+ previous.skipping = false;
+ previous.partial = true;
+ continue;
+ }
+ try {
+ const text = new TextDecoder().decode(line);
+ if (!text.trim())
+ continue;
+ const oldSession = previous.state.session;
+ const event = parse(JSON.parse(text), previous.state);
+ // Provider session identifiers are useful only for
+ // deduplication. Hash them before either parser state or
+ // derived events reach UsageBeam's private cache.
+ if (previous.state.session !== oldSession)
+ previous.state.session = privateIdentity(id, previous.state.session);
+ if (event) {
+ const sanitized = sanitizedEvent(event, id);
+ if (!sanitized)
+ previous.partial = true;
+ else if (sanitized.date >= cutoff)
+ previous.events[sanitized.id] = sanitized;
+ }
+ } catch {
+ previous.partial = true;
+ }
+ }
+ checksum.update(buffer.subarray(0, start));
+ tail = buffer.slice(start);
+ if (tail.length > MAX_LINE_BYTES || previous.skipping) {
+ // Consume, rather than retry, oversized lines. Persist the skip
+ // state across deadlines and appends, never treating their suffix
+ // as a new JSON record. Keep processing later complete records.
+ previous.partial = true;
+ previous.skipping = true;
+ checksum.update(tail);
+ offset += tail.length;
+ tail = new Uint8Array();
+ }
+ if ((GLib.get_monotonic_time() - started) / 1000000 > SCAN_SECONDS) {
+ partial = true;
+ stop = true;
+ }
+ }
+ previous.offset = offset;
+ previous.digest = checksum.get_string();
+ // A time-limited scan must resume even if the source file has not changed.
+ previous.size = stop ? -1 : Math.max(file.size, offset);
+ previous.stamp = file.stamp;
+ previous.fileId = file.fileId;
+ previous.state = parserState(previous.state, fallbackSession);
+ cached.files[key] = previous;
+ partial ||= previous.partial;
+ parsed++;
+ } catch { partial = true; } finally { input?.close(null); }
+ }
+ const events = [];
+ for (const [key, file] of Object.entries(cached.files)) {
+ const normalized = /^[a-f0-9]{64}$/.test(key) ? cachedFile(file, privateIdentity(id, key)) : null;
+ if (!normalized) {
+ delete cached.files[key];
+ partial = true;
+ continue;
+ }
+ cached.files[key] = normalized;
+ partial ||= normalized.partial;
+ for (const [eventKey, event] of Object.entries(normalized.events)) {
+ const valid = sanitizedEvent(event, id, true);
+ if (!valid || event.date < cutoff) {
+ delete normalized.events[eventKey];
+ partial ||= !valid;
+ } else {
+ if (eventKey !== valid.id) {
+ delete normalized.events[eventKey];
+ partial = true;
+ }
+ normalized.events[valid.id] = valid;
+ events.push(valid);
+ }
+ }
+ if (!currentKeys.has(key) && !Object.keys(normalized.events).length)
+ delete cached.files[key];
+ }
+ if (files.length)
+ cached.updatedAt = now;
+ try { writeJson(destination, cached); } catch { partial = true; }
+ const sourceAvailable = files.length > 0;
+ const hasCachedEvents = !sourceAvailable && events.length > 0;
+ const status = sourceAvailable ? partial ? 'partial' : 'ready' : hasCachedEvents ? 'stale' : 'unsupported';
+ const result = {...section(status,
+ partial ? 'Some local records could not be read. Totals may be incomplete.' :
+ sourceAvailable ? 'Local records only; activity on other devices is not included.' :
+ hasCachedEvents ? 'Showing saved local activity; source records are currently unavailable.' :
+ detected ? 'No local usage records found yet.' : 'No local usage records found.'),
+ updatedAt: sourceAvailable ? now : hasCachedEvents ? numberValue(cached.updatedAt) : null,
+ scope: 'local', source: `${id} local usage records`,
+ ...aggregateEvents(events, now), scannedFiles: parsed};
+ if (result.models.length > MAX_MODELS) {
+ result.models = result.models.slice(0, MAX_MODELS);
+ result.status = 'partial';
+ result.message = 'Model totals were truncated to keep local history bounded.';
+ }
+ if ((!sourceAvailable || partial) && !events.length) {
+ result.days = [];
+ result.models = [];
+ result.period = null;
+ }
+ return result;
+}
diff --git a/src/services/http.js b/src/services/http.js
new file mode 100644
index 0000000..034b712
--- /dev/null
+++ b/src/services/http.js
@@ -0,0 +1,42 @@
+import GLib from 'gi://GLib';
+import Soup from 'gi://Soup?version=3.0';
+
+const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
+const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
+
+export async function requestJson(url, {method = 'GET', headers = {}, body = null, cancellable = null,
+ maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES} = {}) {
+ if (typeof url !== 'string' || !url.startsWith('https://'))
+ throw new Error('Only HTTPS provider endpoints are allowed');
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0 || maxResponseBytes > MAX_RESPONSE_BYTES)
+ throw new Error('HTTP response limit is outside the supported range');
+ if (typeof method !== 'string' || !/^[A-Z]+$/.test(method) || !headers ||
+ typeof headers !== 'object' || Array.isArray(headers))
+ throw new Error('Invalid provider request options');
+ for (const [key, value] of Object.entries(headers)) {
+ if (!/^[A-Za-z0-9-]+$/.test(key) || typeof value !== 'string' || /[\r\n]/.test(value))
+ throw new Error('Invalid provider request header');
+ }
+
+ const message = Soup.Message.new(method, url);
+ if (!message)
+ throw new Error('Invalid provider endpoint');
+ const session = new Soup.Session({timeout: 12});
+ try {
+ for (const [key, value] of Object.entries(headers))
+ message.request_headers.append(key, value);
+ if (body !== null)
+ message.set_request_body_from_bytes('application/json',
+ new GLib.Bytes(new TextEncoder().encode(JSON.stringify(body))));
+ const bytes = await new Promise((resolve, reject) => session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, cancellable, (s, res) => {
+ try { resolve(s.send_and_read_finish(res)); } catch (error) { reject(error); }
+ }));
+ if (bytes.get_size() > maxResponseBytes)
+ throw new Error('Provider response exceeded the configured limit');
+ let data = null;
+ try { data = JSON.parse(new TextDecoder().decode(bytes.toArray())); } catch { /* Status remains available for error handling. */ }
+ return {status: message.status_code, data};
+ } finally {
+ session.abort();
+ }
+}
diff --git a/src/services/migration.js b/src/services/migration.js
new file mode 100644
index 0000000..70c1278
--- /dev/null
+++ b/src/services/migration.js
@@ -0,0 +1,60 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import {join, migrateLegacyData} from './files.js';
+
+const LEGACY_UUID = 'freeby@kelvin.local';
+const LEGACY_SCHEMA = 'org.gnome.shell.extensions.freeby';
+const SETTINGS = Object.freeze([
+ 'refresh-interval',
+ 'notifications-enabled',
+ 'enabled-providers',
+ 'default-provider',
+ 'history-retention-days',
+ 'notification-threshold',
+]);
+
+function legacySchemaDirectories() {
+ return [GLib.get_user_data_dir(), ...GLib.get_system_data_dirs()]
+ .map(directory => join(directory, 'gnome-shell', 'extensions', LEGACY_UUID, 'schemas'));
+}
+
+function legacySettings(directories = legacySchemaDirectories()) {
+ for (const directory of directories) {
+ if (!Gio.File.new_for_path(directory).query_exists(null))
+ continue;
+ try {
+ const source = Gio.SettingsSchemaSource.new_from_directory(
+ directory, Gio.SettingsSchemaSource.get_default(), false);
+ const schema = source.lookup(LEGACY_SCHEMA, false);
+ if (schema)
+ return new Gio.Settings({settings_schema: schema});
+ } catch {
+ // A missing or invalid former installation must not block startup.
+ }
+ }
+ return null;
+}
+
+export function migrateLegacySettings(settings, directories = undefined) {
+ const legacy = legacySettings(directories);
+ if (!legacy)
+ return 0;
+ return copyLegacySettings(settings, legacy);
+}
+
+export function copyLegacySettings(settings, legacy) {
+ let migrated = 0;
+ for (const key of SETTINGS) {
+ if (settings.get_user_value(key) !== null)
+ continue;
+ const value = legacy.get_user_value(key);
+ if (value !== null && settings.set_value(key, value))
+ migrated++;
+ }
+ return migrated;
+}
+
+export function migrateLegacyInstall(settings) {
+ migrateLegacyData();
+ migrateLegacySettings(settings);
+}
diff --git a/src/services/process.js b/src/services/process.js
new file mode 100644
index 0000000..c2b0203
--- /dev/null
+++ b/src/services/process.js
@@ -0,0 +1,229 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import {readLines, readText, waitProcess, writeText} from './streams.js';
+
+const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
+const MAX_TIMEOUT_MS = 10 * 60 * 1000;
+const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
+
+export class ProcessError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.name = 'ProcessError';
+ this.code = code;
+ }
+}
+
+function normalizeSpec(spec) {
+ const value = Array.isArray(spec) ? {argv: spec, environment: {}} : spec;
+ if (!value || !Array.isArray(value.argv) || !value.argv.length ||
+ value.argv.some(argument => typeof argument !== 'string' || !argument))
+ throw new ProcessError('INVALID_COMMAND', 'Command arguments must be non-empty strings');
+ if (value.environment && (typeof value.environment !== 'object' || Array.isArray(value.environment)))
+ throw new ProcessError('INVALID_COMMAND', 'Command environment must be an object');
+ for (const [name, entry] of Object.entries(value.environment ?? {})) {
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof entry !== 'string')
+ throw new ProcessError('INVALID_COMMAND', 'Command environment entries must be named strings');
+ }
+ return {argv: value.argv, environment: value.environment ?? {}};
+}
+
+function spawn(spec, flags) {
+ const {argv, environment} = normalizeSpec(spec);
+ const launcher = new Gio.SubprocessLauncher({flags});
+ for (const [name, value] of Object.entries(environment))
+ launcher.setenv(name, value, true);
+ return launcher.spawnv(argv);
+}
+
+export async function runCommand(spec, {input = null, timeout = 15000, cancellable = null,
+ maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES} = {}) {
+ if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS ||
+ !Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0 || maxOutputBytes > MAX_OUTPUT_BYTES)
+ throw new ProcessError('INVALID_OPTIONS', 'Process limits are outside the supported range');
+ if (input !== null && typeof input !== 'string')
+ throw new ProcessError('INVALID_OPTIONS', 'Process input must be text or null');
+
+ const proc = spawn(spec, Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE |
+ (input === null ? 0 : Gio.SubprocessFlags.STDIN_PIPE));
+ const local = new Gio.Cancellable();
+ let completed = false;
+ let cancellation = null;
+ const externalId = cancellable?.connect(() => {
+ cancellation = 'CANCELLED';
+ local.cancel();
+ });
+ if (cancellable?.is_cancelled()) {
+ cancellation = 'CANCELLED';
+ local.cancel();
+ }
+ let timer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, () => {
+ timer = 0;
+ cancellation = 'TIMED_OUT';
+ local.cancel();
+ return GLib.SOURCE_REMOVE;
+ });
+ try {
+ const writeInput = async () => {
+ if (input === null)
+ return;
+ const stdin = proc.get_stdin_pipe();
+ await writeText(stdin, input, local);
+ stdin.close(null);
+ };
+ const [stdout] = await Promise.all([
+ readText(proc.get_stdout_pipe(), maxOutputBytes, local,
+ () => new ProcessError('OUTPUT_LIMIT', 'Command output exceeded the configured limit')),
+ writeInput(),
+ waitProcess(proc, local).then(() => { completed = true; }),
+ ]);
+ if (!proc.get_successful())
+ throw new ProcessError('FAILED', 'Command exited unsuccessfully');
+ return stdout;
+ } catch (error) {
+ if (cancellation === 'TIMED_OUT')
+ throw new ProcessError('TIMED_OUT', 'Command timed out');
+ if (cancellation === 'CANCELLED')
+ throw new ProcessError('CANCELLED', 'Command was cancelled');
+ throw error;
+ } finally {
+ if (timer)
+ GLib.source_remove(timer);
+ if (externalId)
+ cancellable.disconnect(externalId);
+ // Abort remaining stream operations on every failure, including output overflow.
+ local.cancel();
+ // Give managed collectors time to cancel and reap their own CLI child.
+ if (!completed) {
+ try { proc.send_signal(15); } catch { /* The child exited between callbacks. */ }
+ let killTimer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => {
+ killTimer = 0;
+ proc.force_exit();
+ return GLib.SOURCE_REMOVE;
+ });
+ let reaped = false;
+ try { await waitProcess(proc); reaped = true; } finally {
+ if (killTimer)
+ GLib.source_remove(killTimer);
+ if (!reaped)
+ proc.force_exit();
+ }
+ }
+ }
+}
+
+export class RpcClient {
+ constructor(spec, cancellable, timeout = 8000) {
+ if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS)
+ throw new ProcessError('INVALID_OPTIONS', 'RPC timeout is outside the supported range');
+ this.proc = spawn(spec, Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE |
+ Gio.SubprocessFlags.STDERR_SILENCE);
+ this.input = this.proc.get_stdout_pipe();
+ this.cancellable = new Gio.Cancellable();
+ this.parent = cancellable;
+ this.timeout = timeout;
+ this.nextId = 0;
+ this.pending = new Map();
+ this.closed = false;
+ this._writes = Promise.resolve();
+ this._queuedBytes = 0;
+ this._queuedMessages = 0;
+ this.parentId = cancellable?.connect(() => this.close()) ?? 0;
+ if (!this.closed)
+ this.read();
+ }
+
+ send(value) {
+ if (this.closed)
+ throw new Error('RPC closed');
+ const text = `${JSON.stringify(value)}\n`;
+ const size = new TextEncoder().encode(text).length;
+ if (this._queuedMessages >= 64 || this._queuedBytes + size > DEFAULT_MAX_OUTPUT_BYTES)
+ throw new ProcessError('OUTPUT_LIMIT', 'RPC request exceeded the configured limit');
+ this._queuedBytes += size;
+ this._queuedMessages++;
+ this._writes = this._writes.then(() => {
+ if (!this.closed)
+ return writeText(this.proc.get_stdin_pipe(), text, this.cancellable);
+ }).catch(() => this.close()).finally(() => {
+ this._queuedBytes -= size;
+ this._queuedMessages--;
+ });
+ }
+
+ notify(method, params) {
+ this.send({method, params});
+ }
+
+ request(method, params = {}) {
+ if (this.closed || this.pending.size >= 64)
+ return Promise.reject(new ProcessError('RPC_CLOSED', 'RPC unavailable or too many pending requests'));
+ return new Promise((resolve, reject) => {
+ const id = ++this.nextId;
+ const timer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, this.timeout, () => {
+ this.pending.delete(id);
+ reject(new ProcessError('RPC_TIMEOUT', `RPC request timed out: ${method}`));
+ return GLib.SOURCE_REMOVE;
+ });
+ this.pending.set(id, {resolve, reject, timer});
+ try {
+ this.send({id, method, params});
+ } catch (error) {
+ this.pending.delete(id);
+ GLib.source_remove(timer);
+ reject(error);
+ }
+ });
+ }
+
+ async read() {
+ try {
+ for await (const line of readLines(this.input, DEFAULT_MAX_OUTPUT_BYTES, this.cancellable,
+ () => new ProcessError('OUTPUT_LIMIT', 'RPC response exceeded the configured limit'))) {
+ if (this.closed)
+ return;
+ const value = JSON.parse(line);
+ const pending = this.pending.get(value.id);
+ if (pending) {
+ this.pending.delete(value.id);
+ GLib.source_remove(pending.timer);
+ if (value.error) {
+ const message = String(value.error.message || 'RPC request unavailable').slice(0, 240);
+ const error = new ProcessError('RPC_ERROR', message);
+ error.rpcCode = value.error.code ?? null;
+ pending.reject(error);
+ } else {
+ pending.resolve(value.result);
+ }
+ }
+ }
+ } catch {
+ // Malformed, oversized and interrupted streams all close pending work.
+ } finally {
+ this.close();
+ }
+ }
+
+ close() {
+ if (this.closed)
+ return;
+ this.closed = true;
+ for (const pending of this.pending.values()) {
+ GLib.source_remove(pending.timer);
+ pending.reject(new ProcessError('RPC_CLOSED', 'RPC connection closed'));
+ }
+ this.pending.clear();
+ this.cancellable.cancel();
+ this.proc.force_exit();
+ this.proc.wait_async(null, (p, result) => { try { p.wait_finish(result); } catch { /* Reap best effort. */ } });
+ // Disconnecting inside a Gio.Cancellable callback deadlocks; defer it.
+ if (this.parentId) {
+ const id = this.parentId;
+ this.parentId = 0;
+ GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this.parent.disconnect(id);
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+ }
+}
diff --git a/src/services/streams.js b/src/services/streams.js
new file mode 100644
index 0000000..6f90496
--- /dev/null
+++ b/src/services/streams.js
@@ -0,0 +1,91 @@
+import GLib from 'gi://GLib';
+
+const CHUNK_BYTES = 65536;
+
+export function readBytes(stream, cancellable) {
+ return new Promise((resolve, reject) => {
+ stream.read_bytes_async(CHUNK_BYTES, GLib.PRIORITY_DEFAULT, cancellable, (input, result) => {
+ try { resolve(input.read_bytes_finish(result).toArray()); } catch (error) { reject(error); }
+ });
+ });
+}
+
+export async function readText(stream, maxBytes, cancellable, limitError) {
+ const parts = [];
+ let size = 0;
+ while (true) {
+ const bytes = await readBytes(stream, cancellable);
+ if (!bytes.length)
+ break;
+ size += bytes.length;
+ if (size > maxBytes)
+ throw limitError();
+ parts.push(bytes);
+ }
+ // GJS does not support TextDecoder's streaming option. Decode only after
+ // joining the already-bounded bytes so split multibyte characters survive.
+ const output = new Uint8Array(size);
+ let offset = 0;
+ for (const part of parts) {
+ output.set(part, offset);
+ offset += part.length;
+ }
+ return new TextDecoder('utf-8', {fatal: true}).decode(output);
+}
+
+// RPC lines are bounded before decoding or parsing, including unterminated lines.
+export async function* readLines(stream, maxBytes, cancellable, limitError) {
+ let parts = [];
+ let size = 0;
+ while (true) {
+ const bytes = await readBytes(stream, cancellable);
+ if (!bytes.length) {
+ if (size)
+ throw new Error('Incomplete RPC message');
+ return;
+ }
+ let start = 0;
+ for (let i = 0; i < bytes.length; i++) {
+ if (bytes[i] === 10) {
+ const part = bytes.subarray(start, i);
+ size += part.length;
+ if (size > maxBytes)
+ throw limitError();
+ const line = new Uint8Array(size);
+ let offset = 0;
+ for (const entry of [...parts, part]) {
+ line.set(entry, offset);
+ offset += entry.length;
+ }
+ yield new TextDecoder('utf-8', {fatal: true}).decode(line);
+ parts = [];
+ size = 0;
+ start = i + 1;
+ }
+ }
+ if (start < bytes.length) {
+ const part = bytes.slice(start);
+ parts.push(part);
+ size += part.length;
+ }
+ if (size > maxBytes)
+ throw limitError();
+ }
+}
+
+export function writeText(stream, text, cancellable) {
+ return new Promise((resolve, reject) => {
+ stream.write_all_async(new TextEncoder().encode(text), GLib.PRIORITY_DEFAULT, cancellable,
+ (output, result) => {
+ try { output.write_all_finish(result); resolve(); } catch (error) { reject(error); }
+ });
+ });
+}
+
+export function waitProcess(proc, cancellable = null) {
+ return new Promise((resolve, reject) => {
+ proc.wait_async(cancellable, (child, result) => {
+ try { child.wait_finish(result); resolve(); } catch (error) { reject(error); }
+ });
+ });
+}
diff --git a/src/services/usageService.js b/src/services/usageService.js
new file mode 100644
index 0000000..84ab248
--- /dev/null
+++ b/src/services/usageService.js
@@ -0,0 +1,172 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import {isProvider, mergeRecord, record, section, validateRecord} from '../core/usage.js';
+import {ThresholdTracker} from '../core/notifications.js';
+import {commandSpec} from './commands.js';
+import {join, readJson, stateDirectory, writeJson} from './files.js';
+import {runCommand} from './process.js';
+
+const SCHEDULER_TICK_SECONDS = 15;
+const MANUAL_REFRESH_COOLDOWN_MS = 2000;
+const MAX_BACKOFF_SECONDS = 3600;
+
+export class UsageService {
+ constructor(settings, directory, changed, alerts) {
+ this._settings = settings;
+ this._directory = directory;
+ this._changed = changed;
+ this._alerts = alerts;
+ this._records = {};
+ this._jobs = new Map();
+ this._attempts = new Map();
+ this._failures = new Map();
+ this._thresholds = new ThresholdTracker();
+ this._closed = false;
+ this._enabled = [];
+ this.configure();
+ this._timer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, SCHEDULER_TICK_SECONDS, () => {
+ for (const id of this._enabled)
+ this.refresh(id);
+ return GLib.SOURCE_CONTINUE;
+ });
+ this._sleepId = Gio.DBus.system.signal_subscribe('org.freedesktop.login1', 'org.freedesktop.login1.Manager',
+ 'PrepareForSleep', '/org/freedesktop/login1', null, Gio.DBusSignalFlags.NONE,
+ (_connection, _sender, _path, _interface, _signal, params) => {
+ if (!params.get_child_value(0).get_boolean())
+ this.refreshAll(true);
+ });
+ }
+
+ get enabledProviders() {
+ return [...this._enabled];
+ }
+
+ recordFor(id) {
+ return this._records[id] ?? null;
+ }
+
+ isRefreshing(id) {
+ return this._jobs.has(id);
+ }
+
+ _emitChanged() {
+ try {
+ this._changed();
+ } catch (error) {
+ console.error('UsageBeam could not update its panel', error);
+ }
+ }
+
+ _emitAlerts(alerts) {
+ try {
+ this._alerts(alerts);
+ } catch (error) {
+ console.error('UsageBeam could not display a usage alert', error);
+ }
+ }
+
+ configure() {
+ if (this._closed)
+ return;
+ this._enabled = [...new Set(this._settings.get_strv('enabled-providers'))].filter(isProvider);
+ for (const [id, job] of this._jobs) {
+ if (!this._enabled.includes(id)) {
+ job.cancel();
+ this._jobs.delete(id);
+ }
+ }
+ for (const id of Object.keys(this._records)) {
+ if (!this._enabled.includes(id)) {
+ delete this._records[id];
+ this._attempts.delete(id);
+ this._failures.delete(id);
+ }
+ }
+ for (const id of this._enabled) {
+ if (this._records[id])
+ continue;
+ try {
+ const cached = validateRecord(readJson(join(stateDirectory(), `${id}.json`)), id);
+ for (const key of ['limits', 'history']) {
+ if (['ready', 'partial', 'stale'].includes(cached[key].status))
+ cached[key] = {...cached[key], ...section('stale', 'Showing saved usage while refreshing.'), updatedAt: cached[key].updatedAt};
+ }
+ this._records[id] = cached;
+ } catch {
+ this._records[id] = record(id);
+ }
+ }
+ }
+
+ refreshAll(force = false) {
+ for (const id of this._enabled)
+ this.refresh(id, force);
+ }
+
+ async refresh(id, force = false) {
+ if (this._closed || !this._enabled.includes(id) || this._jobs.has(id))
+ return;
+ const now = Date.now();
+ const elapsed = now - (this._attempts.get(id) ?? 0);
+ const wait = Math.min(MAX_BACKOFF_SECONDS,
+ this._settings.get_int('refresh-interval') * 2 ** (this._failures.get(id) ?? 0));
+ if (elapsed >= 0 && elapsed < (force ? MANUAL_REFRESH_COOLDOWN_MS : wait * 1000))
+ return;
+ const job = new Gio.Cancellable();
+ this._jobs.set(id, job);
+ this._attempts.set(id, now);
+ this._emitChanged();
+ try {
+ const collector = commandSpec('gjs', ['-m', join(this._directory, 'src', 'collector', 'main.js'),
+ id, String(this._settings.get_int('history-retention-days'))]);
+ const stdout = await runCommand(collector, {cancellable: job, timeout: 45000});
+ if (this._closed || job.is_cancelled() || this._jobs.get(id) !== job)
+ return;
+ const result = validateRecord(JSON.parse(stdout), id);
+ const failed = ['unavailable', 'missing-auth'].includes(result.limits.status);
+ this._failures.set(id, failed ? Math.min(4, (this._failures.get(id) ?? 0) + 1) : 0);
+ const alerts = this._thresholds.update(result, this._settings.get_int('notification-threshold'));
+ this._records[id] = mergeRecord(this._records[id], result);
+ try {
+ writeJson(join(stateDirectory(), `${id}.json`), this._records[id]);
+ } catch {
+ // Cache failure must not hide current usage.
+ }
+ if (this._settings.get_boolean('notifications-enabled') && alerts.length)
+ this._emitAlerts(alerts);
+ } catch {
+ if (!this._closed && !job.is_cancelled()) {
+ const failure = record(id);
+ failure.limits = {...failure.limits,
+ ...section('unavailable', 'Collection failed or timed out. Retry from the panel.')};
+ failure.history = {...failure.history, ...section('unavailable', 'History could not be refreshed.')};
+ this._records[id] = mergeRecord(this._records[id], failure);
+ this._failures.set(id, Math.min(4, (this._failures.get(id) ?? 0) + 1));
+ }
+ } finally {
+ if (this._jobs.get(id) === job)
+ this._jobs.delete(id);
+ if (!this._closed)
+ this._emitChanged();
+ }
+ }
+
+ destroy() {
+ if (this._closed)
+ return;
+ this._closed = true;
+ if (this._timer) {
+ GLib.source_remove(this._timer);
+ this._timer = 0;
+ }
+ if (this._sleepId) {
+ Gio.DBus.system.signal_unsubscribe(this._sleepId);
+ this._sleepId = 0;
+ }
+ for (const job of this._jobs.values())
+ job.cancel();
+ this._jobs.clear();
+ this._changed = () => {};
+ this._alerts = () => {};
+ }
+}
diff --git a/src/ui/bar.js b/src/ui/bar.js
new file mode 100644
index 0000000..b336299
--- /dev/null
+++ b/src/ui/bar.js
@@ -0,0 +1,51 @@
+import Atk from 'gi://Atk';
+import Clutter from 'gi://Clutter';
+import GObject from 'gi://GObject';
+import St from 'gi://St';
+import {chartBarGeometry} from './presentation.js';
+
+// Fills are allocated, never sized from allocation notifications. Their geometry
+// must not contribute to preferred size or trigger a layout feedback loop.
+export const UsageBeamBar = GObject.registerClass(class UsageBeamBar extends St.Widget {
+ _init({fraction, style, fillStyle, name, content = null, vertical = false}) {
+ super._init({style_class: style, x_expand: true,
+ accessible_name: name, accessible_role: Atk.Role.PROGRESS_BAR});
+ this._fraction = Math.max(0, Math.min(1, Number(fraction) || 0));
+ this._vertical = vertical;
+ this._fill = new St.Widget({style_class: fillStyle, visible: this._fraction > 0});
+ this._overlay = content;
+ this.add_child(this._fill);
+ if (content)
+ this.add_child(content);
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ const node = this.get_theme_node();
+ const sizes = this._overlay?.get_preferred_width(node.adjust_for_height(forHeight)) ?? [0, 0];
+ return node.adjust_preferred_width(...sizes);
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ const node = this.get_theme_node();
+ const sizes = this._overlay?.get_preferred_height(node.adjust_for_width(forWidth)) ?? [0, 0];
+ return node.adjust_preferred_height(...sizes);
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+ const contentBox = this.get_theme_node().get_content_box(box);
+ const [width, height] = contentBox.get_size();
+ const scale = St.ThemeContext.get_for_stage(this.get_stage()).scale_factor;
+ const geometry = this._vertical
+ ? Object.fromEntries(Object.entries(chartBarGeometry(this._fraction, 1, width / scale, height / scale))
+ .map(([key, value]) => [key, value * scale]))
+ : {x: 0, y: 0, width: Math.round(width * this._fraction), height};
+ const fillBox = new Clutter.ActorBox();
+ fillBox.x1 = contentBox.x1 + geometry.x;
+ fillBox.y1 = contentBox.y1 + geometry.y;
+ fillBox.x2 = fillBox.x1 + geometry.width;
+ fillBox.y2 = fillBox.y1 + geometry.height;
+ this._fill.allocate(fillBox);
+ this._overlay?.allocate(contentBox);
+ }
+});
diff --git a/src/ui/indicator.js b/src/ui/indicator.js
new file mode 100644
index 0000000..385a182
--- /dev/null
+++ b/src/ui/indicator.js
@@ -0,0 +1,351 @@
+import Clutter from 'gi://Clutter';
+import GObject from 'gi://GObject';
+import St from 'gi://St';
+import Pango from 'gi://Pango';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
+import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
+import {NAMES, recentDates} from '../core/usage.js';
+import {age, modelName, tokens} from '../core/format.js';
+import {quotaSeverity} from '../core/thresholds.js';
+import {initialLayout, nextLayout, pageSlice} from './layoutPolicy.js';
+import {historyOverview, latestUpdate, panelQuota, periodDays, providerStatus,
+ quotaPresentation} from './presentation.js';
+import {actionButton, button, dayChart, disclosureButton, label, meter, modelMeter,
+ limitRow, metricLabel, pageControls, providerIcon, separatorDot} from './widgets.js';
+
+const TAB_NAMES = {claude: 'Claude'};
+const PROVIDER_MARKS = {codex: '>_', claude: '✦'};
+
+export const UsageBeamIndicator = GObject.registerClass(class UsageBeamIndicator extends PanelMenu.Button {
+ _init(settings, extensionPath, openPreferences) {
+ super._init(0.5, 'UsageBeam usage monitor');
+ this.add_style_class_name('usagebeam-panel-button');
+ this._settings = settings;
+ this._extensionPath = extensionPath;
+ this._openPreferences = openPreferences;
+ this._service = null;
+ this._detailsExpanded = false;
+ this._limitPage = 0;
+ this._activityPage = 0;
+ this._modelPage = 0;
+ this._sectionPage = 0;
+ this._panelStatus = new St.BoxLayout({style_class: 'usagebeam-panel-status',
+ x_expand: true, x_align: Clutter.ActorAlign.START});
+ this._panelIcon = new St.Bin({style_class: 'usagebeam-panel-icon-slot', y_align: Clutter.ActorAlign.CENTER});
+ this._panelProvider = label('UsageBeam', 'usagebeam-panel-provider');
+ this._panelProvider.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+ this._panelValue = metricLabel('—', 'usagebeam-panel-value');
+ this._panelReset = metricLabel('—', 'usagebeam-panel-reset');
+ this._panelSeparator = separatorDot('usagebeam-panel-separator');
+ this._panelMetrics = new St.BoxLayout({style_class: 'usagebeam-panel-metrics',
+ y_align: Clutter.ActorAlign.CENTER});
+ for (const actor of [this._panelValue, this._panelSeparator, this._panelReset])
+ this._panelMetrics.add_child(actor);
+ for (const actor of [this._panelIcon, this._panelProvider, this._panelMetrics])
+ this._panelStatus.add_child(actor);
+ this._panelProviderId = null;
+ // Reserve stable panel space separately from the naturally sized readout.
+ // The unused space belongs on the side away from the calendar.
+ this.add_child(new St.Bin({style_class: 'usagebeam-panel-slot', child: this._panelStatus}));
+ this.menu.actor.add_style_class_name('usagebeam-menu');
+ this._shellSettings = St.Settings.get();
+ this._shellSettings.connectObject(
+ 'notify::color-scheme', () => this._syncColorScheme(),
+ 'notify::shell-color-scheme', () => this._syncColorScheme(), this);
+ this._syncColorScheme();
+ const section = new PopupMenu.PopupMenuSection();
+ this.menu.addMenuItem(section);
+ // `content` is an inherited Clutter.Actor property whose value must be
+ // ClutterContent, so keep the menu actor under an unambiguous name.
+ this._contentBox = new St.BoxLayout({vertical: true, style_class: 'usagebeam-content', x_expand: true});
+ section.box.add_child(this._contentBox);
+ const restyle = () => { this._layout = null; this.render(); };
+ Main.layoutManager.connectObject('monitors-changed', restyle, this);
+ St.ThemeContext.get_for_stage(global.stage).connectObject('changed', restyle, this);
+ this.menu.connect('open-state-changed', (_menu, open) => {
+ if (open) {
+ this._layout = null;
+ this.resize();
+ this._service?.refreshAll();
+ this.render();
+ } else {
+ this._detailsExpanded = false;
+ this._layout = null;
+ }
+ });
+ this.render();
+ }
+
+ _syncColorScheme() {
+ const {colorScheme, shellColorScheme} = this._shellSettings;
+ const light = shellColorScheme === 'prefer-light' ||
+ (shellColorScheme !== 'prefer-dark' && colorScheme === St.SystemColorScheme.PREFER_LIGHT);
+ this.menu.actor.remove_style_class_name(light ? 'usagebeam-dark' : 'usagebeam-light');
+ this.menu.actor.add_style_class_name(light ? 'usagebeam-light' : 'usagebeam-dark');
+ }
+
+ resize() {
+ const monitor = Main.layoutManager.findMonitorForActor(this) ?? Main.layoutManager.primaryMonitor;
+ const work = Main.layoutManager.getWorkAreaForMonitor(monitor.index);
+ const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ const availableWidth = Math.max(160, Math.floor(work.width / scale) - 64);
+ this._contentBox.style = `width: ${Math.min(392, availableWidth)}px;`;
+ // Allow for the native popup frame, panel gap, and our content padding.
+ this._contentHeightBudget = Math.max(80, work.height - 48 * scale);
+ }
+
+ attach(service) {
+ this._service = service;
+ this.render();
+ }
+
+ alignPanelContent(position) {
+ this._panelStatus.x_align = position === 'left-of-calendar'
+ ? Clutter.ActorAlign.END : Clutter.ActorAlign.START;
+ }
+
+ render() {
+ if (!this._contentBox)
+ return;
+ const focus = global.stage.get_key_focus();
+ const restoreName = focus && this._contentBox.contains(focus) ? focus.accessible_name : null;
+ const enabled = this._service?.enabledProviders ?? [];
+ const requested = this._settings.get_string('default-provider');
+ const id = enabled.includes(requested) ? requested : enabled[0];
+ const record = this._service?.recordFor(id);
+ const busy = this._service?.isRefreshing(id) ?? false;
+ if (this._renderedProvider !== id) {
+ this._limitPage = this._activityPage = this._modelPage = this._sectionPage = 0;
+ this._layout = null;
+ this._renderedProvider = id;
+ }
+ const layoutKey = JSON.stringify([record?.plan, record?.limits.status, record?.limits.message,
+ record?.limits.windows.map(window => [window.label, window.unlimited]),
+ record?.history.status, record?.history.message,
+ record?.history.days.length, record?.history.models.map(model => model.model)]);
+ if (this._layoutKey !== layoutKey) {
+ this._layoutKey = layoutKey;
+ this._layout = null;
+ this._limitPage = this._modelPage = this._sectionPage = 0;
+ }
+ this._renderPanelStatus(id, record);
+ this.resize();
+ // Keep page sizes stable while navigating, even on a shorter last page.
+ // A new opening, provider, theme or monitor starts a fresh measurement.
+ this._layout ??= initialLayout();
+ // Fit before the next paint. Never rebuild from allocation callbacks,
+ // which caused the old bar flicker and recursive growth.
+ while (this._layout) {
+ this._contentBox.destroy_all_children();
+ this._contentBox.style_class = `usagebeam-content${this._layout.compact ? ' usagebeam-compact' : ''}`;
+ this._buildContent(enabled, id, record, busy);
+ const width = this._contentBox.get_preferred_width(-1)[1];
+ if (this._contentBox.get_preferred_height(width)[1] <= this._contentHeightBudget)
+ break;
+ const next = nextLayout(this._layout, this._detailsExpanded);
+ if (!next)
+ break;
+ this._layout = next;
+ }
+ this._restoreFocus(restoreName);
+ }
+
+ _buildContent(enabled, id, record, busy) {
+ this._renderHeader(id, record, busy);
+ if (enabled.length > 1)
+ this._renderSelector(enabled, id);
+ if (record) {
+ if (this._layout.splitSections)
+ this._contentBox.add_child(pageControls('View', this._sectionPage, 2, value => {
+ this._sectionPage = value;
+ this.render();
+ }));
+ if (!this._layout.splitSections || this._sectionPage === 0)
+ this._renderLimits(record.limits);
+ if (!this._layout.splitSections || this._sectionPage === 1)
+ this._renderHistory(record.history);
+ } else {
+ this._message('Choose your providers in preferences to get started.');
+ }
+ this._renderFooter(record, busy);
+ }
+
+ _renderPanelStatus(id, record) {
+ const name = TAB_NAMES[id] ?? NAMES[id] ?? 'UsageBeam';
+ if (this._panelProviderId !== id) {
+ this._panelIcon.get_child()?.destroy();
+ this._panelIcon.set_child(providerIcon(id, this._extensionPath, 'usagebeam-panel-icon') ??
+ label(PROVIDER_MARKS[id] ?? 'AI', 'usagebeam-panel-mark'));
+ this._panelProviderId = id;
+ }
+ this._panelProvider.text = name;
+ const quota = panelQuota(record);
+ this._panelValue.text = quota ? `${quota.percent}%` : '—';
+ const severity = quotaSeverity(quota?.percent);
+ this._panelValue.style_class = `usagebeam-panel-value${severity ? ` usagebeam-${severity}` : ''}`;
+ this._panelReset.text = quota?.reset ?? '—';
+ const resetDescription = quota?.reset === 'due' ? ', reset due' :
+ quota?.reset ? `, resets in ${quota.reset}` : '';
+ this.accessible_name = `UsageBeam, ${name}${quota ? `, ${quota.percent} percent used${resetDescription}` : ''}`;
+ }
+
+ _renderHeader(id, record, busy) {
+ const header = new St.BoxLayout({style_class: 'usagebeam-header', x_expand: true});
+ const icon = providerIcon(id, this._extensionPath, 'usagebeam-header-icon');
+ if (icon)
+ header.add_child(icon);
+ else
+ header.add_child(label(PROVIDER_MARKS[id] ?? 'AI', 'usagebeam-provider-mark'));
+ const identity = new St.BoxLayout({vertical: true, style_class: 'usagebeam-identity', x_expand: true});
+ identity.add_child(label(TAB_NAMES[id] ?? record?.name ?? 'UsageBeam', 'usagebeam-title'));
+ const plan = label(record?.plan ? String(record.plan).toUpperCase() : 'USAGE MONITOR', 'usagebeam-caption');
+ plan.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+ identity.add_child(plan);
+ header.add_child(identity);
+ const statusText = providerStatus(record, busy);
+ header.add_child(label(`● ${statusText}`, `usagebeam-status usagebeam-status-${statusText.toLowerCase()}`));
+ this._contentBox.add_child(header);
+ }
+
+ _renderSelector(enabled, selected) {
+ const selector = new St.Widget({style_class: 'usagebeam-selector', x_expand: true,
+ layout_manager: new Clutter.BoxLayout({orientation: Clutter.Orientation.HORIZONTAL,
+ homogeneous: true, spacing: 8})});
+ for (const provider of enabled) {
+ selector.add_child(button(TAB_NAMES[provider] ?? NAMES[provider],
+ () => this._settings.set_string('default-provider', provider),
+ {active: provider === selected, name: `Show ${NAMES[provider]} usage`}));
+ }
+ this._contentBox.add_child(selector);
+ }
+
+ _renderLimits(limits) {
+ this._heading('LIMITS');
+ const page = pageSlice(limits.windows.filter(window => quotaPresentation(window)), this._limitPage, this._layout.limits);
+ for (const window of page.items) {
+ const box = new St.BoxLayout({vertical: true, style_class: 'usagebeam-window'});
+ const view = quotaPresentation(window);
+ if (!view)
+ continue;
+ const severity = quotaSeverity(window.usedPercent);
+ box.add_child(limitRow(view.name, view.value, severity));
+ if (!window.unlimited) {
+ box.add_child(meter(window.usedPercent / 100,
+ `${view.name}: ${view.value} used, ${view.reset.toLowerCase()}`,
+ severity ? `usagebeam-${severity}` : ''));
+ box.add_child(label(view.reset, 'usagebeam-limit-reset'));
+ }
+ this._contentBox.add_child(box);
+ }
+ if (page.pages > 1)
+ this._contentBox.add_child(pageControls('Limits', page.page, page.pages, value => {
+ this._limitPage = value;
+ this.render();
+ }));
+ if (limits.status !== 'ready') {
+ this._message(limits.message ||
+ (limits.status === 'loading' ? 'Checking account limits…' : 'Limits unavailable'));
+ }
+ if (limits.status === 'stale')
+ this._message(`Saved limits · ${age(limits.updatedAt).toLowerCase()}`);
+ }
+
+ _renderHistory(history) {
+ const overview = historyOverview(history);
+ if (overview) {
+ this._contentBox.add_child(disclosureButton(this._detailsExpanded, () => {
+ this._detailsExpanded = !this._detailsExpanded;
+ this._layout = null;
+ this._limitPage = this._activityPage = this._modelPage = this._sectionPage = 0;
+ this.render();
+ }));
+
+ if (this._detailsExpanded)
+ this._renderActivity(history);
+ }
+ if (history.message && history.status !== 'ready')
+ this._message(history.message);
+ }
+
+ _renderActivity(history) {
+ const details = new St.BoxLayout({vertical: true, style_class: 'usagebeam-details', x_expand: true});
+ const split = this._layout.splitActivity && history.days.length && history.models.length;
+ if (split)
+ details.add_child(pageControls('Activity', this._activityPage, 2, value => {
+ this._activityPage = value;
+ this.render();
+ }));
+ if (history.days.length && (!split || this._activityPage === 0))
+ this._renderDays(history, details);
+ if (history.models.length && (!split || this._activityPage === 1))
+ this._renderModels(history, details);
+ this._contentBox.add_child(details);
+ }
+
+ _renderDays(history, parent = this._contentBox) {
+ const overview = historyOverview(history);
+ this._heading(`LAST ${overview?.days ?? history.days.length} DAYS · ${tokens(overview?.total ?? 0)} TOKENS`, parent);
+ const today = recentDates(Date.now(), 1)[0];
+ parent.add_child(dayChart(history.days, today));
+ }
+
+ _renderModels(history, parent = this._contentBox) {
+ const modelScope = history.scope === 'account' ? 'ACCOUNT' : 'LOCAL';
+ const days = periodDays(history.period);
+ this._heading(`TOKENS BY MODEL${days ? ` · ${days}D` : ''} ${modelScope}`, parent);
+ const max = Math.max(1, ...history.models.map(model => model.total));
+ const page = pageSlice(history.models, this._modelPage, this._layout.models);
+ for (const model of page.items) {
+ const displayName = modelName(model.model);
+ const cache = model.cacheRead + model.cacheWrite;
+ const accessible = `${displayName}, ${model.total} tokens. Input ${model.input}, output ${model.output}, cache ${cache}.`;
+ parent.add_child(modelMeter(displayName, tokens(model.total), model.total / max, accessible));
+ }
+ if (page.pages > 1)
+ parent.add_child(pageControls('Models', page.page, page.pages, value => {
+ this._modelPage = value;
+ this.render();
+ }));
+ }
+
+ _renderFooter(record, busy) {
+ const actions = new St.BoxLayout({style_class: 'usagebeam-actions', x_expand: true});
+ actions.add_child(label(busy ? 'Refreshing…' : age(latestUpdate(record)), 'usagebeam-caption', true));
+ actions.add_child(actionButton('Refresh', () => this._service?.refreshAll(true), 'Refresh usage'));
+ actions.add_child(actionButton('Settings', () => { this.menu.close(); this._openPreferences(); },
+ 'Open extension settings'));
+ this._contentBox.add_child(actions);
+ }
+
+ _restoreFocus(name) {
+ if (!name)
+ return;
+ const actors = [];
+ const visit = actor => { actors.push(actor); actor.get_children().forEach(visit); };
+ visit(this._contentBox);
+ const opposite = name.startsWith('Next ') ? name.replace('Next ', 'Previous ')
+ : name.startsWith('Previous ') ? name.replace('Previous ', 'Next ') : null;
+ const target = [name, opposite].filter(Boolean).map(candidate =>
+ actors.find(actor => actor.can_focus && actor.accessible_name === candidate)).find(Boolean);
+ target?.grab_key_focus();
+ }
+
+ _heading(text, parent = this._contentBox) {
+ parent.add_child(label(text, 'usagebeam-section-title'));
+ }
+
+ _message(text) {
+ const message = label(text, 'usagebeam-message');
+ message.clutter_text.line_wrap = true;
+ this._contentBox.add_child(message);
+ }
+
+ destroy() {
+ Main.layoutManager.disconnectObject(this);
+ St.ThemeContext.get_for_stage(global.stage).disconnectObject(this);
+ this._shellSettings?.disconnectObject(this);
+ this._shellSettings = null;
+ super.destroy();
+ }
+});
diff --git a/src/ui/layoutPolicy.js b/src/ui/layoutPolicy.js
new file mode 100644
index 0000000..b18ece8
--- /dev/null
+++ b/src/ui/layoutPolicy.js
@@ -0,0 +1,24 @@
+// Progressively reduce simultaneous content, never the user's font size.
+export function initialLayout() {
+ return {limits: 3, models: 4, splitActivity: false, compact: false, splitSections: false};
+}
+
+export function nextLayout(layout, expanded) {
+ if (expanded && !layout.splitActivity)
+ return {...layout, splitActivity: true};
+ if (layout.limits > 1)
+ return {...layout, limits: layout.limits - 1};
+ if (!layout.compact)
+ return {...layout, compact: true};
+ if (expanded && layout.models > 1)
+ return {...layout, models: layout.models - 1};
+ if (expanded && !layout.splitSections)
+ return {...layout, splitSections: true};
+ return null;
+}
+
+export function pageSlice(items, requested, size) {
+ const pages = Math.max(1, Math.ceil(items.length / size));
+ const page = Math.max(0, Math.min(pages - 1, requested));
+ return {items: items.slice(page * size, (page + 1) * size), page, pages};
+}
diff --git a/src/ui/panelPlacement.js b/src/ui/panelPlacement.js
new file mode 100644
index 0000000..95ddf7a
--- /dev/null
+++ b/src/ui/panelPlacement.js
@@ -0,0 +1,86 @@
+import St from 'gi://St';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+
+const CALENDAR_POSITIONS = new Set(['left-of-calendar', 'right-of-calendar']);
+
+export function clearIndicatorPlacement(indicator) {
+ const state = indicator?._usageBeamPlacement;
+ if (!state)
+ return;
+ for (const [actor, signal] of state.signals)
+ actor.disconnect(signal);
+ for (const spacer of state.spacers)
+ spacer.destroy();
+ delete indicator._usageBeamPlacement;
+}
+
+function centerCalendarGap(indicator, calendar, target, position) {
+ // GNOME centers the whole box, including actors owned by other extensions.
+ // Balance every visible sibling around the calendar/readout boundary.
+ const leading = new St.Widget({style_class: 'usagebeam-center-balance'});
+ const trailing = new St.Widget({style_class: 'usagebeam-center-balance'});
+ const leftIsIndicator = position === 'left-of-calendar';
+ const state = {signals: [], spacers: [leading, trailing]};
+ const watched = new Map();
+ const sync = () => {
+ const children = target.get_children().filter(actor => actor.visible);
+ const widths = children.map(actor => state.spacers.includes(actor) ? 0 : actor.get_preferred_width(-1)[1]);
+ const index = children.indexOf(leftIsIndicator ? indicator.container : calendar);
+ if (index < 0)
+ return;
+ const spacing = target.get_theme_node().get_length('spacing');
+ const boundary = widths.slice(0, index + 1).reduce((sum, width) => sum + width, 0) + spacing * (index + 0.5);
+ const total = widths.reduce((sum, width) => sum + width, 0) + spacing * Math.max(0, children.length - 1);
+ const difference = 2 * boundary - total;
+ leading.set_width(Math.max(0, -difference));
+ trailing.set_width(Math.max(0, difference));
+ };
+ const calendarIndex = target.get_children().indexOf(calendar);
+ target.insert_child_at_index(leading, calendarIndex);
+ target.insert_child_at_index(indicator.container, calendarIndex + (leftIsIndicator ? 1 : 2));
+ target.insert_child_at_index(trailing, calendarIndex + 3);
+ const observe = () => {
+ const children = target.get_children().filter(actor => !state.spacers.includes(actor));
+ for (const [actor, signals] of watched) {
+ if (children.includes(actor))
+ continue;
+ for (const signal of signals) {
+ actor.disconnect(signal);
+ state.signals = state.signals.filter(pair => pair[0] !== actor || pair[1] !== signal);
+ }
+ watched.delete(actor);
+ }
+ for (const actor of children) {
+ if (watched.has(actor))
+ continue;
+ const signals = ['notify::width', 'notify::visible'].map(name => actor.connect(name, sync));
+ watched.set(actor, signals);
+ state.signals.push(...signals.map(signal => [actor, signal]));
+ }
+ sync();
+ };
+ for (const signal of ['child-added', 'child-removed'])
+ state.signals.push([target, target.connect(signal, observe)]);
+ indicator._usageBeamPlacement = state;
+ observe();
+}
+
+export function placeIndicator(indicator, position) {
+ clearIndicatorPlacement(indicator);
+ const container = indicator.container;
+ const calendar = Main.panel.statusArea.dateMenu?.container;
+ const boxes = {left: Main.panel._leftBox, right: Main.panel._rightBox};
+ const resolved = CALENDAR_POSITIONS.has(position) || position in boxes
+ ? position : 'right-of-calendar';
+ indicator.alignPanelContent(resolved);
+ const besideCalendar = CALENDAR_POSITIONS.has(resolved);
+ const target = besideCalendar ? calendar?.get_parent() ?? Main.panel._centerBox : boxes[resolved];
+ container.get_parent()?.remove_child(container);
+ if (besideCalendar && calendar) {
+ centerCalendarGap(indicator, calendar, target, resolved);
+ return;
+ }
+ const siblings = target.get_children();
+ const index = resolved === 'right' ? 0 : siblings.length;
+ target.insert_child_at_index(container, index);
+}
diff --git a/src/ui/presentation.js b/src/ui/presentation.js
new file mode 100644
index 0000000..262c583
--- /dev/null
+++ b/src/ui/presentation.js
@@ -0,0 +1,105 @@
+import {resetCountdown, resetTime} from '../core/format.js';
+
+const CURRENT_STATES = new Set(['ready', 'partial']);
+
+export function providerStatus(record, refreshing = false) {
+ if (CURRENT_STATES.has(record?.limits?.status))
+ return 'LIVE';
+ if (CURRENT_STATES.has(record?.history?.status))
+ return 'LOCAL';
+ if (record?.limits?.status === 'stale' || record?.history?.status === 'stale')
+ return 'CACHED';
+ return refreshing ? 'SYNC' : 'SETUP';
+}
+
+export function latestUpdate(record) {
+ const timestamps = [record?.limits?.updatedAt, record?.history?.updatedAt]
+ .filter(value => Number.isFinite(value) && value > 0);
+ return timestamps.length ? Math.max(...timestamps) : null;
+}
+
+export function periodDays(period) {
+ const start = Date.parse(`${period?.start ?? ''}T00:00:00Z`);
+ const end = Date.parse(`${period?.end ?? ''}T00:00:00Z`);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start)
+ return null;
+ return Math.round((end - start) / 86400000) + 1;
+}
+
+export function historyOverview(history) {
+ const days = Array.isArray(history?.days) ? history.days : [];
+ const models = Array.isArray(history?.models) ? history.models : [];
+ const values = days.length ? days : models;
+ if (!values.length)
+ return null;
+
+ const total = values.reduce((sum, item) => sum +
+ (Number.isFinite(item?.total) ? item.total : 0), 0);
+ return {
+ days: periodDays(history.period),
+ scope: history.scope === 'account' ? 'account' : 'local',
+ total,
+ };
+}
+
+export function quotaName(window) {
+ const source = `${window?.id ?? ''} ${window?.label ?? ''}`.toLowerCase();
+ if (source.includes('five-hour') || source.includes('5 hours'))
+ return '5H Session';
+ if (source.includes('reserve') &&
+ (source.includes('weekly') || window?.durationMinutes === 10080))
+ return 'Weekly Reserve';
+ return String(window?.label ?? 'Usage limit');
+}
+
+export function quotaPresentation(window, now = Date.now()) {
+ if (!window)
+ return null;
+ if (window.unlimited)
+ return {name: quotaName(window), value: 'Unlimited', reset: null};
+ if (!Number.isFinite(window.usedPercent))
+ return null;
+ return {
+ name: quotaName(window),
+ value: `${Math.round(window.usedPercent)}%`,
+ reset: resetTime(window.resetsAt, now),
+ };
+}
+
+export function panelQuota(record, now = Date.now()) {
+ if (!CURRENT_STATES.has(record?.limits?.status))
+ return null;
+ let window = null;
+ let shortest = Infinity;
+ for (const candidate of record.limits.windows) {
+ if (candidate.unlimited || !Number.isFinite(candidate.usedPercent))
+ continue;
+ const duration = Number.isFinite(candidate.durationMinutes) &&
+ candidate.durationMinutes > 0 ? candidate.durationMinutes : Infinity;
+ if (!window || duration < shortest) {
+ window = candidate;
+ shortest = duration;
+ }
+ }
+ if (!window)
+ return null;
+ return {
+ percent: Math.round(window.usedPercent),
+ reset: resetCountdown(window.resetsAt, now),
+ };
+}
+
+export function chartBarGeometry(value, maximum, width, height, inset = 4) {
+ const availableWidth = Math.max(0, width - inset * 2);
+ const availableHeight = Math.max(0, height);
+ const ratio = maximum > 0 && value > 0 ? Math.min(1, value / maximum) : 0;
+ const barHeight = ratio && availableHeight
+ ? Math.min(availableHeight, Math.max(3, Math.round(availableHeight * ratio)))
+ : 0;
+ return {
+ x: inset,
+ y: availableHeight - barHeight,
+ width: availableWidth,
+ height: barHeight,
+ };
+}
diff --git a/src/ui/widgets.js b/src/ui/widgets.js
new file mode 100644
index 0000000..0e28d35
--- /dev/null
+++ b/src/ui/widgets.js
@@ -0,0 +1,161 @@
+import Atk from 'gi://Atk';
+import Clutter from 'gi://Clutter';
+import Gio from 'gi://Gio';
+import Pango from 'gi://Pango';
+import St from 'gi://St';
+import {compactTokens} from '../core/format.js';
+import {UsageBeamBar} from './bar.js';
+
+const PROVIDER_ICONS = new Set(['claude', 'codex']);
+const PROVIDER_ICON_FILES = {claude: 'claude.svg', codex: 'codex-symbolic.svg'};
+
+export function label(text, style = '', expand = false) {
+ return new St.Label({text: String(text ?? ''), style_class: style,
+ y_align: Clutter.ActorAlign.CENTER, x_expand: expand});
+}
+
+export function metricLabel(text, style = '', expand = false) {
+ const actor = label(text, style, expand);
+ actor.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ return actor;
+}
+
+export function separatorDot(style = '') {
+ const dot = new St.Widget({style_class: 'usagebeam-separator-dot-core'});
+ return new St.Bin({
+ child: dot,
+ style_class: `usagebeam-separator-dot ${style}`.trim(),
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+}
+
+export function providerIcon(provider, extensionPath, style = '') {
+ if (!PROVIDER_ICONS.has(provider))
+ return null;
+ return new St.Icon({
+ gicon: new Gio.FileIcon({
+ file: Gio.File.new_for_path(`${extensionPath}/icons/${PROVIDER_ICON_FILES[provider]}`),
+ }),
+ style_class: `usagebeam-provider-icon usagebeam-${provider}-icon ${style}`.trim(),
+ });
+}
+
+export function limitRow(name, value, severity = null) {
+ const box = new St.BoxLayout({style_class: 'usagebeam-limit-row', x_expand: true});
+ const title = label(name, 'usagebeam-limit-name', true);
+ title.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+ box.add_child(title);
+ box.add_child(metricLabel(value,
+ `usagebeam-limit-percent${severity ? ` usagebeam-${severity}` : ''}`));
+ return box;
+}
+
+export function meter(fraction, name, style = '') {
+ return new UsageBeamBar({fraction, name, style: `usagebeam-track ${style}`, fillStyle: 'usagebeam-fill'});
+}
+
+export function modelMeter(left, right, fraction, name) {
+ const content = new St.BoxLayout({style_class: 'usagebeam-model-content'});
+ const title = label(left, 'usagebeam-model-name', true);
+ title.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+ content.add_child(title);
+ content.add_child(metricLabel(right, 'usagebeam-number'));
+ return new UsageBeamBar({fraction, name, content,
+ style: 'usagebeam-model-meter', fillStyle: 'usagebeam-model-fill'});
+}
+
+export function dayChart(days, today) {
+ const values = Array.isArray(days) ? days : [];
+ const max = Math.max(1, ...values.map(day => day.total));
+ const chart = new St.Widget({
+ style_class: 'usagebeam-day-chart',
+ x_expand: true,
+ layout_manager: new Clutter.BoxLayout({
+ homogeneous: true,
+ orientation: Clutter.Orientation.HORIZONTAL,
+ spacing: 6,
+ }),
+ accessible_name: 'Daily token activity chart',
+ accessible_role: Atk.Role.PANEL,
+ });
+ for (const day of values) {
+ const isToday = day.date === today;
+ const weekday = new Date(`${day.date}T12:00:00`).toLocaleDateString(undefined, {weekday: 'short'});
+ const column = new St.BoxLayout({
+ vertical: true,
+ style_class: `usagebeam-chart-column${isToday ? ' usagebeam-today' : ''}`,
+ x_expand: true,
+ accessible_name: `${isToday ? 'Today, ' : ''}${weekday}, ${day.total} tokens, ${day.sessions ?? 0} sessions`,
+ });
+ column.add_child(label(compactTokens(day.total), 'usagebeam-chart-value'));
+ const plot = new UsageBeamBar({
+ fraction: day.total / max,
+ name: `${day.date}: ${day.total} tokens`,
+ style: 'usagebeam-chart-plot',
+ fillStyle: 'usagebeam-chart-bar',
+ vertical: true,
+ });
+ column.add_child(plot);
+ column.add_child(label(weekday, 'usagebeam-chart-day'));
+ chart.add_child(column);
+ }
+ return chart;
+}
+
+export function button(text, callback, {active = false, name = text} = {}) {
+ const actor = new St.Button({label: text, can_focus: true, reactive: true, track_hover: true,
+ accessible_name: name, style_class: 'usagebeam-button', x_expand: true});
+ if (active)
+ actor.add_style_pseudo_class('checked');
+ actor.connect('clicked', callback);
+ return actor;
+}
+
+export function disclosureButton(expanded, callback) {
+ const actor = new St.Button({
+ accessible_name: 'Activity details',
+ accessible_role: Atk.Role.TOGGLE_BUTTON,
+ can_focus: true,
+ checked: expanded,
+ reactive: true,
+ style_class: 'usagebeam-disclosure',
+ toggle_mode: true,
+ track_hover: true,
+ x_expand: true,
+ });
+ const content = new St.BoxLayout({style_class: 'usagebeam-disclosure-content', x_expand: true});
+ content.add_child(label('Activity', 'usagebeam-disclosure-title', true));
+ content.add_child(new St.Icon({
+ icon_name: expanded ? 'pan-up-symbolic' : 'pan-down-symbolic',
+ style_class: 'usagebeam-disclosure-icon',
+ }));
+ actor.set_child(content);
+ actor.connect('clicked', callback);
+ return actor;
+}
+
+export function actionButton(text, callback, name = text) {
+ const actor = button(text, callback, {name});
+ actor.x_expand = false;
+ actor.add_style_class_name('usagebeam-action-button');
+ return actor;
+}
+
+export function pageControls(name, page, pages, changed) {
+ const row = new St.BoxLayout({style_class: 'usagebeam-pagination', x_expand: true});
+ for (const direction of [-1, 0, 1]) {
+ if (!direction) {
+ row.add_child(label(`${name} · ${page + 1} / ${pages}`, 'usagebeam-page-label', true));
+ continue;
+ }
+ const control = actionButton(direction < 0 ? '‹' : '›', () => changed(page + direction),
+ `${direction < 0 ? 'Previous' : 'Next'} ${name.toLowerCase()} page`);
+ control.reactive = page + direction >= 0 && page + direction < pages;
+ control.can_focus = control.reactive;
+ if (!control.reactive)
+ control.add_style_pseudo_class('insensitive');
+ row.add_child(control);
+ }
+ return row;
+}
diff --git a/stylesheet.css b/stylesheet.css
index ac2912b..f4bfb90 100644
--- a/stylesheet.css
+++ b/stylesheet.css
@@ -1,83 +1,470 @@
-.freeby-panel {
- spacing: 4px;
+.usagebeam-menu {
+ font-family: "SF Pro Text";
+ font-size: 10.5pt;
+ font-feature-settings: "tnum";
+ -arrow-background-color: transparent;
+ -arrow-border-color: transparent;
+ -arrow-border-width: 0px;
+ -arrow-rise: 6px;
+ -arrow-base: 0px;
+ background-color: transparent;
+ box-shadow: none;
}
-.freeby-panel-label {
- font-weight: bold;
- font-size: 10pt;
- min-width: 16px;
- color: currentColor;
+.usagebeam-menu .popup-menu-content {
+ font-family: "SF Pro Text";
+ font-size: 10.5pt;
+ font-feature-settings: "tnum";
+ border: 1px solid st-transparentize(st-lighten(-st-accent-color, 20%), 0.34);
+ border-radius: 12px;
+ box-shadow: none;
}
-.freeby-panel-green {
- color: #4ade80;
+.usagebeam-menu.usagebeam-dark .popup-menu-content {
+ color: #edf4f6;
+ background-color: st-mix(-st-accent-color, #182b35, 6%);
}
-.freeby-panel-yellow {
- color: #facc15;
+.usagebeam-menu.usagebeam-light .popup-menu-content {
+ color: #17252c;
+ background-color: st-mix(-st-accent-color, #f4f8f9, 4%);
}
-.freeby-panel-red {
- color: #f87171;
+.usagebeam-panel-button {
+ -natural-hpadding: 4px;
+ -minimum-hpadding: 3px;
}
-.freeby-item-box {
- spacing: 8px;
+.usagebeam-panel-slot {
+ width: 12.5em;
+ font-family: "SF Pro Text";
+ font-size: 9.5pt;
+ font-feature-settings: "tnum";
}
-.freeby-dot {
- font-size: 8pt;
- min-width: 10px;
+.usagebeam-panel-status {
+ spacing: 4px;
}
-.freeby-dot-off {
- color: currentColor;
- opacity: 0.4;
+.usagebeam-panel-metrics {
+ spacing: 4px;
+ padding-top: 2px;
}
-.freeby-dot-green {
- color: #4ade80;
+.usagebeam-panel-icon-slot { width: 16px; }
+
+.usagebeam-provider-icon {
+ icon-size: 20px;
+}
+
+.usagebeam-panel-icon {
+ icon-size: 15px;
+}
+
+.usagebeam-panel-provider,
+.usagebeam-panel-mark {
+ font-size: 9.5pt;
+}
+
+.usagebeam-panel-provider {
+ font-weight: 650;
+}
+
+.usagebeam-panel-value,
+.usagebeam-panel-reset {
+ color: st-lighten(-st-accent-color, 25%);
+ font-size: 8.75pt;
+}
+
+.usagebeam-panel-value {
+ font-weight: 620;
+}
+
+.usagebeam-panel-reset {
+ font-weight: 500;
+ opacity: 0.86;
+ text-align: left;
+}
+
+.usagebeam-separator-dot {
+ min-width: 4px;
+ min-height: 4px;
+}
+
+.usagebeam-separator-dot-core {
+ width: 4px;
+ height: 4px;
+ border-radius: 2px;
+ background-color: rgba(220, 232, 237, 0.62);
+}
+
+.usagebeam-panel-separator .usagebeam-separator-dot-core {
+ background-color: rgba(255, 255, 255, 0.58);
+}
+
+.usagebeam-content {
+ padding: 12px 14px 12px;
+ spacing: 0;
+}
+
+.usagebeam-header {
+ spacing: 10px;
+ padding: 1px 0 10px;
+}
+
+.usagebeam-header-icon {
+ icon-size: 29px;
+ margin: 1px 2px 1px 0;
+}
+
+.usagebeam-identity {
+ spacing: 0;
+}
+
+.usagebeam-provider-mark {
+ min-width: 29px;
+ min-height: 29px;
+ color: st-lighten(-st-accent-color, 22%);
+ font-family: "SF Pro Text";
+ font-size: 10pt;
+ font-weight: 650;
+ text-align: center;
+}
+
+.usagebeam-title {
+ font-family: "SF Pro Display";
+ font-size: 14pt;
+ font-weight: 700;
+}
+
+.usagebeam-caption {
+ font-feature-settings: "tnum";
+ font-size: 9.5pt;
+ opacity: 0.76;
+}
+
+.usagebeam-status {
+ padding: 2px 1px;
+ font-size: 8.5pt;
+ font-weight: 650;
+ opacity: 0.94;
+}
+
+.usagebeam-status-live {
+ color: #62e6a0;
+}
+
+.usagebeam-status-sync {
+ color: st-lighten(-st-accent-color, 25%);
+}
+
+.usagebeam-status-local {
+ color: #f8d66d;
+}
+
+.usagebeam-status-cached {
+ opacity: 0.72;
+}
+
+.usagebeam-selector {
+ spacing: 8px;
+ padding-bottom: 10px;
+}
+
+.usagebeam-button {
+ min-height: 23px;
+ padding: 4px 10px;
+ border: 1px solid rgba(128, 150, 160, 0.30);
+ border-radius: 6px;
+ background-color: rgba(128, 150, 160, 0.055);
+}
+
+.usagebeam-button:hover,
+.usagebeam-button:focus {
+ border-color: st-transparentize(st-lighten(-st-accent-color, 22%), 0.34);
+ background-color: st-transparentize(-st-accent-color, 0.88);
+}
+
+.usagebeam-button:checked {
+ border-color: st-transparentize(st-lighten(-st-accent-color, 24%), 0.24);
+ background-color: st-transparentize(-st-accent-color, 0.80);
+ font-weight: 650;
+}
+
+.usagebeam-button:focus,
+.usagebeam-disclosure:focus {
+ box-shadow: inset 0 0 0 2px st-transparentize(-st-accent-color, 0.24);
+}
+
+.usagebeam-section-title {
+ padding: 8px 0 6px;
+ border-top: 1px solid rgba(128, 150, 160, 0.22);
+ font-size: 9.5pt;
+ font-weight: 700;
+ opacity: 0.92;
+}
+
+.usagebeam-limit-row {
+ min-height: 21px;
+ spacing: 10px;
+}
+
+.usagebeam-limit-name {
+ font-size: 10.5pt;
+ font-weight: 520;
+}
+
+.usagebeam-limit-percent {
+ color: st-mix(-st-accent-color, #d7e4eb, 28%);
+ font-size: 9.5pt;
+ font-weight: 620;
+ text-align: right;
+}
+
+.usagebeam-limit-reset {
+ color: st-mix(-st-accent-color, #9baab4, 12%);
+ font-size: 9.5pt;
+ font-weight: 500;
+ text-align: left;
+}
+
+.usagebeam-number {
+ font-feature-settings: "tnum";
+ font-weight: 550;
+}
+
+.usagebeam-window {
+ spacing: 4px;
+ padding-bottom: 8px;
+}
+
+.usagebeam-track {
+ height: 5px;
+ min-width: 20px;
+ border-radius: 3px;
+ background-color: rgba(143, 166, 176, 0.22);
+}
+
+.usagebeam-fill {
+ border-radius: 3px;
+ background-color: st-mix(-st-accent-color, #b7cbd5, 42%);
+}
+
+.usagebeam-disclosure {
+ min-height: 25px;
+ margin: 3px 0 4px;
+ padding: 6px 9px;
+ border: 1px solid rgba(128, 150, 160, 0.30);
+ border-radius: 7px;
+ background-color: rgba(128, 150, 160, 0.065);
+}
+
+.usagebeam-disclosure:hover,
+.usagebeam-disclosure:focus,
+.usagebeam-disclosure:checked {
+ border-color: st-transparentize(st-lighten(-st-accent-color, 22%), 0.35);
+ background-color: st-transparentize(-st-accent-color, 0.89);
+}
+
+.usagebeam-disclosure-content {
+ spacing: 8px;
+}
+
+.usagebeam-disclosure-title {
+ font-size: 10pt;
+ font-weight: 650;
+}
+
+.usagebeam-disclosure-icon {
+ icon-size: 10px;
+ opacity: 0.76;
+}
+
+.usagebeam-details {
+ spacing: 0;
+ padding-top: 1px;
+}
+
+.usagebeam-day-chart {
+ padding: 7px 0 8px;
+}
+
+.usagebeam-chart-column {
+ spacing: 4px;
+}
+
+.usagebeam-chart-value,
+.usagebeam-chart-day {
+ font-feature-settings: "tnum";
+ text-align: center;
+}
+
+.usagebeam-chart-value {
+ font-size: 9pt;
+ opacity: 0.79;
+}
+
+.usagebeam-chart-day {
+ font-size: 9.5pt;
+ opacity: 0.72;
+}
+
+.usagebeam-chart-plot {
+ height: 52px;
+}
+
+.usagebeam-chart-bar {
+ border-radius: 4px 4px 2px 2px;
+ background-color: st-mix(-st-accent-color, #adbec7, 24%);
+}
+
+.usagebeam-today .usagebeam-chart-value,
+.usagebeam-today .usagebeam-chart-day {
+ color: st-mix(-st-accent-color, #e4f1f6, 40%);
+ font-weight: 700;
+ opacity: 1;
+}
+
+.usagebeam-today .usagebeam-chart-bar {
+ background-color: st-mix(-st-accent-color, #c7dce6, 52%);
+}
+
+.usagebeam-model-meter {
+ min-height: 27px;
+ margin-bottom: 5px;
+ border: 1px solid rgba(128, 150, 160, 0.12);
+ border-radius: 4px;
+ background-color: rgba(128, 150, 160, 0.11);
+}
+
+.usagebeam-model-fill {
+ border-radius: 3px;
+ background-color: st-transparentize(st-mix(-st-accent-color, #aebfc8, 36%), 0.34);
+}
+
+.usagebeam-model-content {
+ spacing: 10px;
+ padding: 4px 8px;
+ font-size: 9.25pt;
+}
+
+.usagebeam-model-name {
+ font-weight: 520;
+}
+
+.usagebeam-model-content .usagebeam-number {
+ font-size: 8.75pt;
+ font-weight: 650;
+}
+
+.usagebeam-actions {
+ spacing: 7px;
+ padding-top: 9px;
+ margin-top: 4px;
+ border-top: 1px solid rgba(128, 150, 160, 0.22);
+}
+
+.usagebeam-pagination {
+ spacing: 8px;
+ padding: 3px 0 7px;
+}
+
+.usagebeam-page-label {
+ text-align: center;
+ font-size: 9pt;
+ opacity: 0.8;
+}
+
+.usagebeam-pagination .usagebeam-button:insensitive {
+ opacity: 0.35;
+}
+
+.usagebeam-compact .usagebeam-header { padding-bottom: 5px; }
+.usagebeam-compact .usagebeam-selector { padding-bottom: 5px; }
+.usagebeam-compact .usagebeam-window { padding-bottom: 4px; }
+.usagebeam-compact .usagebeam-message { padding: 4px 8px; margin-bottom: 4px; }
+
+.usagebeam-action-button {
+ min-height: 21px;
+ padding: 3px 9px;
+ font-size: 9.5pt;
+}
+
+.usagebeam-message {
+ margin: 0 0 8px;
+ padding: 8px 10px;
+ border-left: 2px solid #f6c85f;
+ border-radius: 4px;
+ background-color: rgba(246, 200, 95, 0.08);
+ font-size: 9pt;
+ opacity: 0.92;
+}
+
+.usagebeam-menu.usagebeam-light .usagebeam-limit-percent {
+ color: st-mix(-st-accent-color, #263b46, 38%);
+}
+
+.usagebeam-menu.usagebeam-light .usagebeam-limit-reset {
+ color: st-mix(-st-accent-color, #586b76, 12%);
+}
+
+.usagebeam-menu.usagebeam-light .usagebeam-separator-dot-core {
+ background-color: rgba(38, 59, 70, 0.54);
+}
+
+.usagebeam-menu.usagebeam-light .usagebeam-track {
+ background-color: rgba(55, 76, 86, 0.18);
+}
+
+.usagebeam-menu.usagebeam-light .usagebeam-chart-bar {
+ background-color: st-mix(-st-accent-color, #6f8793, 28%);
+}
+
+.usagebeam-menu.usagebeam-light .usagebeam-today .usagebeam-chart-value,
+.usagebeam-menu.usagebeam-light .usagebeam-today .usagebeam-chart-day {
+ color: st-mix(-st-accent-color, #263b46, 38%);
+}
+
+.usagebeam-menu.usagebeam-light .usagebeam-status-live {
+ color: #167345;
}
-.freeby-dot-yellow {
- color: #facc15;
+/* Quota severity is deliberately encoded in both color and numeric text. */
+.usagebeam-panel-value.usagebeam-caution,
+.usagebeam-menu .usagebeam-limit-percent.usagebeam-caution {
+ color: #e8c65d;
}
-.freeby-dot-red {
- color: #f87171;
+.usagebeam-panel-value.usagebeam-warning,
+.usagebeam-menu .usagebeam-limit-percent.usagebeam-warning {
+ color: #ee9d50;
}
-.freeby-item-name {
- font-weight: bold;
- font-size: 9pt;
- min-width: 52px;
- color: currentColor;
+.usagebeam-panel-value.usagebeam-danger,
+.usagebeam-menu .usagebeam-limit-percent.usagebeam-danger {
+ color: #f05f78;
}
-.freeby-item-summary {
- font-size: 9pt;
- color: currentColor;
- opacity: 0.8;
+.usagebeam-track.usagebeam-caution .usagebeam-fill {
+ background-color: #e8c65d;
}
-.freeby-dim {
- opacity: 0.4;
+.usagebeam-track.usagebeam-warning .usagebeam-fill {
+ background-color: #ee9d50;
}
-.freeby-green {
- color: #4ade80;
+.usagebeam-track.usagebeam-danger .usagebeam-fill {
+ background-color: #f05f78;
}
-.freeby-yellow {
- color: #facc15;
+.usagebeam-menu.usagebeam-light .usagebeam-limit-percent.usagebeam-caution {
+ color: #7a5b00;
}
-.freeby-red {
- color: #f87171;
+.usagebeam-menu.usagebeam-light .usagebeam-limit-percent.usagebeam-warning {
+ color: #9a4f00;
}
-.freeby-status {
- font-size: 9pt;
- color: currentColor;
- opacity: 0.7;
+.usagebeam-menu.usagebeam-light .usagebeam-limit-percent.usagebeam-danger {
+ color: #b12643;
}
diff --git a/tests/freeby.bats b/tests/freeby.bats
deleted file mode 100644
index b092960..0000000
--- a/tests/freeby.bats
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/usr/bin/env bats
-
-SCRIPT="$BATS_TEST_DIRNAME/../scripts/freeby.sh"
-
-@test "outputs valid JSON" {
- run bash "$SCRIPT"
- [ "$status" -eq 0 ]
- echo "$output" | python3 -c "import json,sys; json.load(sys.stdin)"
-}
-
-@test "JSON contains codex, cursor, copilot keys" {
- run bash "$SCRIPT"
- echo "$output" | python3 -c "
-import json,sys
-d = json.load(sys.stdin)
-assert 'codex' in d, 'missing codex'
-assert 'cursor' in d, 'missing cursor'
-assert 'copilot' in d, 'missing copilot'
-"
-}
-
-@test "each provider has required fields" {
- run bash "$SCRIPT"
- echo "$output" | python3 -c "
-import json,sys
-d = json.load(sys.stdin)
-for k in ('codex','cursor','copilot'):
- p = d[k]
- assert 'available' in p, f'{k} missing available'
- assert 'has_remaining' in p, f'{k} missing has_remaining'
- assert 'summary' in p, f'{k} missing summary'
- assert isinstance(p['available'], bool), f'{k} available not bool'
- assert isinstance(p['has_remaining'], bool), f'{k} has_remaining not bool'
-"
-}
-
-@test "script cleans up temp directory" {
- before=$(ls /tmp | grep -c 'tmp\.' || true)
- run bash "$SCRIPT"
- after=$(ls /tmp | grep -c 'tmp\.' || true)
- [ "$after" -le "$before" ]
-}
diff --git a/tests/integration/collector.js b/tests/integration/collector.js
new file mode 100644
index 0000000..d36e5dc
--- /dev/null
+++ b/tests/integration/collector.js
@@ -0,0 +1,208 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import System from 'system';
+import {scanHistory} from '../../src/services/history.js';
+import {parseCodexEvent} from '../../src/providers/codex.js';
+import {parseClaudeEvent} from '../../src/providers/claude.js';
+import {commandSpec, findCommand} from '../../src/services/commands.js';
+import {join, migrateLegacyData, readJson, writeJson} from '../../src/services/files.js';
+import {requestJson} from '../../src/services/http.js';
+import {copyLegacySettings} from '../../src/services/migration.js';
+import {RpcClient, runCommand} from '../../src/services/process.js';
+
+function assert(value, message) {
+ if (!value)
+ throw new Error(message);
+}
+const scratch = GLib.dir_make_tmp('usagebeam-integration-XXXXXX');
+const sessions = join(scratch, 'sessions');
+GLib.mkdir_with_parents(sessions, 0o700);
+const fixture = join(sessions, 'synthetic.jsonl');
+const cachePath = join(scratch, 'cache.json');
+const now = new Date('2026-09-06T12:00:00').getTime();
+const event = total => JSON.stringify({type: 'event_msg', timestamp: '2026-09-06T10:00:00Z', payload: {type: 'token_count',
+ info: {total_token_usage: {input_tokens: total, output_tokens: 0, cached_input_tokens: 0, total_tokens: total}}}});
+const text = `${JSON.stringify({type: 'session_meta', payload: {id: 'synthetic'}})}\n${event(100)}\n`;
+GLib.file_set_contents(fixture, text);
+const first = scanHistory('codex', [sessions], parseCodexEvent, {now, cachePath});
+assert(first.days.at(-1).total === 100, 'initial scan');
+const second = scanHistory('codex', [sessions], parseCodexEvent, {now, cachePath});
+assert(second.scannedFiles === 0 && second.days.at(-1).total === 100, 'unchanged file must reuse cache');
+GLib.file_set_contents(fixture, `${text}${event(180)}\n{"partial":`);
+const third = scanHistory('codex', [sessions], parseCodexEvent, {now, cachePath});
+assert(third.days.at(-1).total === 180, 'append must count delta only');
+const cache = readJson(cachePath);
+assert(cache.version === 5, 'versioned cache');
+assert(!JSON.stringify(cache).includes('synthetic'), 'session identity must be sanitized in cache');
+assert(third.days.at(-1).sessions === 1, 'resumed scans must retain one stable private session identity');
+const privateDirectory = join(scratch, 'private-state');
+GLib.mkdir_with_parents(privateDirectory, 0o755);
+GLib.chmod(privateDirectory, 0o755);
+const privateRecord = join(privateDirectory, 'record.json');
+writeJson(privateRecord, {safe: true});
+assert(readJson(privateRecord).safe, 'atomic JSON roundtrip');
+const directoryInfo = Gio.File.new_for_path(privateDirectory).query_info('unix::mode', Gio.FileQueryInfoFlags.NONE, null);
+assert((directoryInfo.get_attribute_uint32('unix::mode') & 0o777) === 0o700, 'private JSON directory mode');
+const stateInfo = Gio.File.new_for_path(privateRecord).query_info('unix::mode', Gio.FileQueryInfoFlags.NONE, null);
+assert((stateInfo.get_attribute_uint32('unix::mode') & 0o777) === 0o600, 'private JSON file mode');
+print('PASS: GJS history initial scan, cached scan, append, partial line, and private JSON cache');
+
+const legacyState = join(scratch, 'legacy-state');
+const legacyCache = join(scratch, 'legacy-cache');
+const migratedState = join(scratch, 'usagebeam-state');
+const migratedCache = join(scratch, 'usagebeam-cache');
+writeJson(join(legacyState, 'codex.json'), {provider: 'codex'});
+writeJson(join(legacyCache, 'history-codex.json'), {version: 4});
+writeJson(join(legacyState, 'unrelated.json'), {private: true});
+assert(migrateLegacyData({legacyState, legacyCache, state: migratedState, cache: migratedCache}) === 2,
+ 'legacy migration must copy only recognized derived data');
+assert(readJson(join(migratedState, 'codex.json')).provider === 'codex', 'provider state migration');
+assert(readJson(join(migratedCache, 'history-codex.json')).version === 4, 'history cache migration');
+assert(!Gio.File.new_for_path(join(migratedState, 'unrelated.json')).query_exists(null),
+ 'legacy migration must ignore unrelated files');
+assert(migrateLegacyData({legacyState, legacyCache, state: migratedState, cache: migratedCache}) === 0,
+ 'legacy migration must not overwrite migrated data');
+print('PASS: GJS legacy derived-data migration');
+
+const currentSettings = new Map([['default-provider', 'claude']]);
+const formerSettings = new Map([['default-provider', 'codex'], ['refresh-interval', 30]]);
+const fakeSettings = values => ({
+ get_user_value: key => values.get(key) ?? null,
+ set_value(key, value) { values.set(key, value); return true; },
+});
+assert(copyLegacySettings(fakeSettings(currentSettings), fakeSettings(formerSettings)) === 1,
+ 'settings migration must copy only missing explicit values');
+assert(currentSettings.get('default-provider') === 'claude' && currentSettings.get('refresh-interval') === 30,
+ 'settings migration must preserve UsageBeam values');
+print('PASS: GJS legacy settings migration policy');
+
+const replacementRoot = join(scratch, 'replacement');
+GLib.mkdir_with_parents(replacementRoot, 0o700);
+const replacement = join(replacementRoot, 'same-size.jsonl');
+const replacementCache = join(scratch, 'replacement-cache.json');
+GLib.file_set_contents(replacement, `${event(100)}\n`);
+assert(scanHistory('codex', [replacementRoot], parseCodexEvent, {now, cachePath: replacementCache})
+ .days.at(-1).total === 100, 'same-size baseline');
+GLib.usleep(2000);
+GLib.file_set_contents(replacement, `${event(200)}\n`);
+assert(scanHistory('codex', [replacementRoot], parseCodexEvent, {now, cachePath: replacementCache})
+ .days.at(-1).total === 200, 'same-size replacement must invalidate the cache');
+Gio.File.new_for_path(replacement).delete(null);
+const saved = scanHistory('codex', [replacementRoot], parseCodexEvent, {now: now + 1000, cachePath: replacementCache});
+assert(saved.status === 'stale' && saved.days.at(-1).total === 200, 'missing source must retain recent cached history');
+print('PASS: GJS history replacement detection and stale-source recovery');
+
+const claudeProjects = join(scratch, 'claude-projects');
+GLib.mkdir_with_parents(claudeProjects, 0o700);
+const claudeLine = JSON.stringify({type: 'assistant', sessionId: 'claude-session', timestamp: '2026-09-06T11:00:00Z',
+ message: {id: 'claude-message', role: 'assistant', model: 'claude-test', usage: {input_tokens: 2,
+ output_tokens: 3, cache_read_input_tokens: 40, cache_creation_input_tokens: 5}}});
+GLib.file_set_contents(join(claudeProjects, 'session.jsonl'), `${claudeLine}\n${claudeLine}\n`);
+const claudeCache = join(scratch, 'claude-cache.json');
+const claude = scanHistory('claude', [claudeProjects], parseClaudeEvent, {now, cachePath: claudeCache});
+assert(claude.days.at(-1).total === 50, 'duplicate Claude messages must count once');
+assert(claude.models[0].cacheRead === 40 && claude.models[0].cacheWrite === 5, 'Claude cache categories');
+assert(!JSON.stringify(readJson(claudeCache)).includes('claude-session'), 'Claude session identity must be sanitized');
+print('PASS: GJS Claude history deduplication, model totals, and private cache');
+
+for (const version of ['v20.12.0', 'v24.16.0']) {
+ const bin = join(scratch, '.local', 'share', 'fnm', 'node-versions', version, 'installation', 'bin');
+ GLib.mkdir_with_parents(bin, 0o700);
+ const command = join(bin, 'fixture-cli');
+ GLib.file_set_contents(command, '#!/usr/bin/env fixture-runtime\n');
+ GLib.chmod(command, 0o700);
+ const runtime = join(bin, 'fixture-runtime');
+ GLib.file_set_contents(runtime, `#!/bin/sh\nprintf '${version}'\n`);
+ GLib.chmod(runtime, 0o700);
+}
+assert(findCommand('fixture-cli', {home: scratch, usePath: false}).includes('v24.16.0'),
+ 'version-manager lookup must choose the newest installed runtime');
+let invalidName = false;
+try { findCommand('..'); } catch { invalidName = true; }
+assert(invalidName, 'command discovery must reject path-like command names');
+const fixtureSpec = commandSpec('fixture-cli', ['status'], {home: scratch, usePath: false});
+assert(fixtureSpec.argv[0].endsWith('/fixture-cli') && fixtureSpec.argv[1] === 'status',
+ 'version-managed command must retain its native entry point');
+assert(fixtureSpec.environment.PATH.split(':')[0].endsWith('/v24.16.0/installation/bin'),
+ 'version-managed command directory must lead the child search path');
+const npmBin = join(scratch, '.npm-global', 'bin');
+GLib.mkdir_with_parents(npmBin, 0o700);
+const runtimeCli = join(npmBin, 'runtime-cli');
+GLib.file_set_contents(runtimeCli, '#!/usr/bin/env fixture-runtime\n');
+GLib.chmod(runtimeCli, 0o700);
+const runtimeSpec = commandSpec('runtime-cli', [],
+ {home: scratch, usePath: false, runtimes: ['fixture-runtime']});
+assert(runtimeSpec.environment.PATH.includes('/v24.16.0/installation/bin'),
+ 'user-local commands must receive a discovered runtime path');
+print('PASS: GJS version-manager command discovery');
+
+const loop = new GLib.MainLoop(null, false);
+let failed = false;
+(async () => {
+ try {
+ assert((await runCommand(['/bin/echo', 'fixture'])).trim() === 'fixture', 'subprocess output');
+ assert((await runCommand(fixtureSpec)).trim() === 'v24.16.0',
+ 'command environment must resolve its sibling runtime');
+ assert((await runCommand(runtimeSpec)).trim() === 'v24.16.0',
+ 'user-local command environment must resolve a version-managed runtime');
+ let invalidEnvironment = false;
+ try {
+ await runCommand({argv: ['/bin/echo', 'fixture'], environment: {PATH: 10}});
+ } catch (error) {
+ invalidEnvironment = error.code === 'INVALID_COMMAND';
+ }
+ assert(invalidEnvironment, 'subprocess environments must contain only strings');
+ let timedOut = false;
+ try {
+ await runCommand(['/bin/sleep', '5'], {timeout: 50});
+ } catch (error) {
+ timedOut = error.code === 'TIMED_OUT';
+ }
+ assert(timedOut, 'subprocess timeout');
+ let limited = false;
+ try {
+ await runCommand(['/bin/echo', 'too-large'], {maxOutputBytes: 3});
+ } catch (error) {
+ limited = error.code === 'OUTPUT_LIMIT';
+ }
+ assert(limited, 'subprocess output limit');
+ const cancelled = new Gio.Cancellable();
+ cancelled.cancel();
+ let cancellationReported = false;
+ try {
+ await runCommand(['/bin/sleep', '5'], {cancellable: cancelled});
+ } catch (error) {
+ cancellationReported = error.code === 'CANCELLED';
+ }
+ assert(cancellationReported, 'pre-cancelled subprocess must report cancellation');
+ const rpc = new RpcClient(['/bin/cat'], cancelled);
+ assert(rpc.closed, 'pre-cancelled RPC client must close during construction');
+ rpc.close();
+ let insecureEndpoint = false;
+ try {
+ await requestJson('http://example.invalid');
+ } catch (error) {
+ insecureEndpoint = error.message.includes('HTTPS');
+ }
+ assert(insecureEndpoint, 'provider requests must reject insecure endpoints before network access');
+ print('PASS: GJS subprocess communication, deadline and termination');
+ } catch (error) {
+ printerr(error.message);
+ failed = true;
+ } finally {
+ loop.quit();
+ }
+})();
+loop.run();
+function remove(file) {
+ if (file.query_file_type(Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) === Gio.FileType.DIRECTORY) {
+ const entries = file.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ let entry;
+ while ((entry = entries.next_file(null)))
+ remove(file.get_child(entry.get_name()));
+ entries.close(null);
+ }
+ file.delete(null);
+}
+remove(Gio.File.new_for_path(scratch));
+System.exit(failed ? 1 : 0);
diff --git a/tests/integration/stress.js b/tests/integration/stress.js
new file mode 100644
index 0000000..4a36ce3
--- /dev/null
+++ b/tests/integration/stress.js
@@ -0,0 +1,213 @@
+// Explicit stress suite, separate from the fast release checks. Synthetic data only.
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import System from 'system';
+import {ThresholdTracker} from '../../src/core/notifications.js';
+import {scanHistory} from '../../src/services/history.js';
+import {parseCodexEvent} from '../../src/providers/codex.js';
+import {join, readJson} from '../../src/services/files.js';
+import {RpcClient, runCommand} from '../../src/services/process.js';
+
+const scratch = GLib.dir_make_tmp('usagebeam-data-stress-XXXXXX');
+const now = new Date('2026-09-06T12:00:00Z').getTime();
+const results = [];
+const assert = (value, message) => { if (!value) throw new Error(message); };
+const event = total => JSON.stringify({type: 'event_msg', timestamp: '2026-09-06T10:00:00Z', payload: {type: 'token_count',
+ info: {total_token_usage: {input_tokens: total, output_tokens: 0, cached_input_tokens: 0, total_tokens: total}}}});
+const total = record => record.days.reduce((sum, day) => sum + day.total, 0);
+const fixture = name => {
+ const root = join(scratch, name);
+ GLib.mkdir_with_parents(root, 0o700);
+ const cachePath = join(scratch, `${name}-cache.json`);
+ return {root, path: join(root, 'fixture.jsonl'), cachePath,
+ scan: () => scanHistory('codex', [root], parseCodexEvent, {now, cachePath})};
+};
+async function test(name, callback) {
+ const start = GLib.get_monotonic_time();
+ try {
+ const details = await callback();
+ results.push({name, ok: true, details});
+ } catch (error) {
+ results.push({name, ok: false, error: error.message});
+ }
+ results.at(-1).milliseconds = Math.round((GLib.get_monotonic_time() - start) / 1000);
+ print(JSON.stringify(results.at(-1)));
+}
+function remove(file) {
+ if (file.query_file_type(Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) === Gio.FileType.DIRECTORY) {
+ const iterator = file.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ try {
+ let entry;
+ while ((entry = iterator.next_file(null)))
+ remove(file.get_child(entry.get_name()));
+ } finally {
+ iterator.close(null);
+ }
+ }
+ file.delete(null);
+}
+
+async function run() {
+ await test('50,000 cumulative events across 50 files; warm cache and privacy', () => {
+ const source = fixture('volume');
+ const contents = Array.from({length: 1000}, (_, index) => event((index + 1) * 10)).join('\n') + '\n';
+ for (let file = 0; file < 50; file++)
+ GLib.file_set_contents(join(source.root, `${file}.jsonl`), contents);
+ const cold = source.scan();
+ assert(total(cold) === 500000 && cold.status === 'ready', 'Cold scan lost cumulative events');
+ const warm = source.scan();
+ assert(total(warm) === 500000 && warm.scannedFiles === 0, 'Warm scan recounted unchanged files');
+ assert(!JSON.stringify(readJson(source.cachePath)).includes(source.root), 'Cache exposes source paths');
+ return {events: 50000, files: 50, total: total(warm)};
+ });
+ await test('same-inode same-length rewrite invalidates cached totals', () => {
+ const source = fixture('rewrite');
+ GLib.file_set_contents(source.path, `${event(100)}\n`);
+ assert(total(source.scan()) === 100, 'Baseline differs');
+ GLib.usleep(2000);
+ const stream = Gio.File.new_for_path(source.path).open_readwrite(null);
+ try {
+ stream.get_output_stream().write_all(new TextEncoder().encode(`${event(200)}\n`), null);
+ } finally {
+ stream.close(null);
+ }
+ const actual = total(source.scan());
+ assert(actual === 200, `Expected 200 after in-place rewrite, got ${actual}`);
+ });
+ await test('oversized local line does not permanently hide later valid usage', () => {
+ const source = fixture('oversized');
+ GLib.file_set_contents(source.path, `${event(100)}\n${' '.repeat(5 * 1024 * 1024)}\n${event(200)}\n`);
+ source.scan();
+ const next = source.scan();
+ assert(total(next) === 200, `Expected recovery to 200, got ${total(next)} (${next.status})`);
+ });
+ await test('growing in-place rewrite verifies the entire committed prefix', () => {
+ const source = fixture('growing-rewrite');
+ GLib.file_set_contents(source.path, `${event(100)}\n`);
+ assert(total(source.scan()) === 100, 'Baseline differs');
+ const stream = Gio.File.new_for_path(source.path).open_readwrite(null);
+ try {
+ stream.get_output_stream().write_all(new TextEncoder().encode(`${event(300)}\n${event(400)}\n`), null);
+ } finally { stream.close(null); }
+ assert(total(source.scan()) === 400, 'Growing rewrite was mistaken for an append');
+ });
+ await test('oversized unterminated record resumes past its later newline', () => {
+ const source = fixture('oversized-append');
+ GLib.file_set_contents(source.path, `${event(100)}\n${' '.repeat(5 * 1024 * 1024)}`);
+ assert(total(source.scan()) === 100, 'Oversized incomplete line changed usage');
+ const output = Gio.File.new_for_path(source.path).append_to(Gio.FileCreateFlags.NONE, null);
+ try {
+ output.write_all(new TextEncoder().encode(`remaining oversized text\n${event(200)}\n`), null);
+ } finally { output.close(null); }
+ const result = source.scan();
+ assert(total(result) === 200 && result.status === 'partial', 'Skip state was not retained across scans');
+ });
+ await test('null or malformed cache is safely rebuilt', () => {
+ const source = fixture('bad-cache');
+ GLib.file_set_contents(source.path, `${event(100)}\n`);
+ for (const value of ['null', '{"files":null}', 'not json']) {
+ GLib.file_set_contents(source.cachePath, value);
+ assert(total(source.scan()) === 100, 'Bad cache prevented source recovery');
+ }
+ });
+ await test('malformed JSON preserves readable events with partial status', () => {
+ const source = fixture('malformed');
+ GLib.file_set_contents(source.path, `${event(100)}\nnot json\n${event(200)}\n`);
+ const result = source.scan();
+ assert(total(result) === 200 && result.status === 'partial', 'Malformed line corrupted readable usage');
+ });
+ await test('incomplete write resumes after append without double counting', () => {
+ const source = fixture('append');
+ const next = `${event(200)}\n`;
+ GLib.file_set_contents(source.path, `${event(100)}\n${next.slice(0, 50)}`);
+ assert(total(source.scan()) === 100, 'Partial write was counted');
+ const output = Gio.File.new_for_path(source.path).append_to(Gio.FileCreateFlags.NONE, null);
+ try {
+ output.write_all(new TextEncoder().encode(next.slice(50)), null);
+ } finally {
+ output.close(null);
+ }
+ assert(total(source.scan()) === 200 && total(source.scan()) === 200, 'Append lost or duplicated usage');
+ });
+ const record = (percent, period = 1, count = 1) => ({id: 'codex', name: 'Codex', accountKey: '0'.repeat(64),
+ limits: {status: 'ready', windows: Array.from({length: count}, (_, index) => ({id: `window-${index}`,
+ label: `Allowance ${index}`, resetsAt: period, usedPercent: percent, unlimited: false}))}});
+ await test('10,000 repeated reads emit each milestone exactly once per period', () => {
+ const tracker = new ThresholdTracker();
+ const alerts = [];
+ for (const percent of [0, 80, 79, 80, 90, 89, 90, 100])
+ alerts.push(...tracker.update(record(percent), 80));
+ for (let index = 0; index < 10000; index++)
+ alerts.push(...tracker.update(record(index % 2 ? 100 : 79), 80));
+ assert(alerts.map(alert => alert.threshold).join(',') === '80,90,100', 'Milestone alerts repeat');
+ });
+ await test('notification state stays bounded after 1,000 quota resets', () => {
+ const tracker = new ThresholdTracker();
+ for (let period = 1; period <= 1000; period++)
+ tracker.update(record(20, period, 32), 80);
+ assert(tracker.previous.size <= 200, `Expected at most 200 retained keys, got ${tracker.previous.size}`);
+ });
+ await test('20 concurrent subprocesses and 20 RPC lifecycles', async () => {
+ const output = await Promise.all(Array.from({length: 20}, () => runCommand(['/bin/echo', 'synthetic'])));
+ assert(output.every(value => value.trim() === 'synthetic'), 'Concurrent output mismatch');
+ for (let index = 0; index < 20; index++) {
+ const rpc = new RpcClient(['/bin/cat'], null, 500);
+ try { await rpc.request('synthetic'); } finally { rpc.close(); }
+ assert(rpc.closed && rpc.pending.size === 0, 'RPC retained requests after close');
+ }
+ });
+ await test('20 in-flight cancellations terminate promptly', async () => {
+ const jobs = Array.from({length: 20}, async () => {
+ const cancellation = new Gio.Cancellable();
+ const promise = runCommand(['/bin/sleep', '5'], {cancellable: cancellation});
+ cancellation.cancel();
+ try { await promise; return false; } catch (error) { return error.code === 'CANCELLED'; }
+ });
+ assert((await Promise.all(jobs)).every(Boolean), 'Cancellation was lost');
+ });
+ await test('stdout cap is enforced before producer exit', async () => {
+ // Finite output and a short deadline: exercises pressure without risking OOM.
+ let code;
+ try {
+ await runCommand(['/usr/bin/python3', '-c',
+ 'import sys,time; sys.stdout.write("x" * 1048576); sys.stdout.flush(); time.sleep(2)'],
+ {maxOutputBytes: 1024, timeout: 1000});
+ } catch (error) { code = error.code; }
+ assert(code === 'OUTPUT_LIMIT', `Expected early OUTPUT_LIMIT, got ${code}`);
+ });
+ await test('Unicode crossing read boundaries and large stdin round-trip intact', async () => {
+ const input = 'x'.repeat(65535) + '🛰'.repeat(20000);
+ const actual = await runCommand(['/bin/cat'], {input});
+ assert(actual === input, 'Chunk boundaries corrupted Unicode');
+ });
+ await test('RPC output and queued writes remain bounded', async () => {
+ const rpc = new RpcClient(['/usr/bin/python3', '-c',
+ 'import sys,time; sys.stdout.write("x" * 9437184); sys.stdout.flush(); time.sleep(2)'], null, 5000);
+ try {
+ let closed = false;
+ try { await rpc.request('synthetic'); } catch (error) { closed = error.code === 'RPC_CLOSED'; }
+ assert(closed && rpc.pending.size === 0, 'Oversized RPC response was not rejected');
+ } finally { rpc.close(); }
+ const stalled = new RpcClient(['/bin/sleep', '5'], null, 500);
+ try {
+ let bounded = false;
+ try {
+ for (let index = 0; index < 65; index++)
+ stalled.notify('synthetic', {value: index});
+ } catch (error) { bounded = error.code === 'OUTPUT_LIMIT'; }
+ assert(bounded, 'RPC notification queue is unbounded');
+ } finally { stalled.close(); }
+ });
+}
+const loop = new GLib.MainLoop(null, false);
+run().catch(error => results.push({name: 'stress harness', ok: false, error: error.message})).finally(() => {
+ try {
+ if (ARGV[0])
+ GLib.file_set_contents(ARGV[0], JSON.stringify(results, null, 2));
+ remove(Gio.File.new_for_path(scratch));
+ } finally {
+ loop.quit();
+ }
+});
+loop.run();
+System.exit(results.every(result => result.ok) ? 0 : 1);
diff --git a/tests/shell/extension.js b/tests/shell/extension.js
new file mode 100644
index 0000000..1ff6e98
--- /dev/null
+++ b/tests/shell/extension.js
@@ -0,0 +1,336 @@
+// Loaded only by tools/smoke-shell.py in its isolated, synthetic desktop.
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import Pango from 'gi://Pango';
+import Shell from 'gi://Shell';
+import St from 'gi://St';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
+import {runStress} from './stress.js';
+
+const UUID = 'usagebeam@oo7kc.github.io';
+const descendants = actor => [actor, ...actor.get_children().flatMap(descendants)];
+const matching = (actor, style) => descendants(actor).filter(child => child.has_style_class_name?.(style));
+const bounds = actor => {
+ const [x, y] = actor.get_transformed_position();
+ const [width, height] = actor.get_transformed_size();
+ return {x, y, width, height};
+};
+// Clutter reports logical sizes; Pango pixel sizes can include monitor scale.
+const textWidth = actor => actor.clutter_text.get_preferred_width(-1)[1];
+const textBaseline = actor => bounds(actor.clutter_text).y +
+ actor.clutter_text.get_layout().get_baseline() / Pango.SCALE;
+const assert = (condition, message) => {
+ if (!condition)
+ throw new Error(message);
+};
+
+export default class UsageBeamUITest extends Extension {
+ enable() {
+ this._timers = new Map();
+ this._output = GLib.getenv('USAGEBEAM_TEST_OUTPUT');
+ if (!this._output)
+ throw new Error('UI tests require a private test output directory');
+ this._run().then(result => this._save({ok: true, ...result})).catch(async error => {
+ try {
+ await this._screenshot('failure');
+ } finally {
+ this._save({ok: false, error: `${error}\n${error.stack ?? ''}`});
+ }
+ });
+ }
+
+ _save(result) {
+ GLib.file_set_contents(`${this._output}/ui-results.json`, JSON.stringify(result, null, 2));
+ }
+
+ _wait(milliseconds = 150) {
+ return new Promise((resolve, reject) => {
+ const id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, milliseconds, () => {
+ this._timers.delete(id);
+ resolve();
+ return GLib.SOURCE_REMOVE;
+ });
+ this._timers.set(id, reject);
+ });
+ }
+
+ async _screenshot(name) {
+ const file = Gio.File.new_for_path(`${this._output}/${name}.png`);
+ const stream = file.replace(null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
+ try {
+ await new Shell.Screenshot().screenshot(false, stream);
+ } finally {
+ stream.close(null);
+ }
+ }
+
+ _displayCall(method, parameters = null) {
+ return new Promise((resolve, reject) => Gio.DBus.session.call('org.gnome.Mutter.DisplayConfig',
+ '/org/gnome/Mutter/DisplayConfig', 'org.gnome.Mutter.DisplayConfig', method,
+ parameters, null, Gio.DBusCallFlags.NONE, 5000, null, (connection, result) => {
+ try {
+ resolve(connection.call_finish(result).deep_unpack());
+ } catch (error) {
+ reject(error);
+ }
+ }));
+ }
+
+ _checkPanelSpacing(indicator, calendar, position, textScale) {
+ const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ const provider = bounds(indicator._panelProvider);
+ const value = bounds(indicator._panelValue);
+ const separator = bounds(indicator._panelSeparator);
+ const reset = bounds(indicator._panelReset);
+ const providerBaseline = textBaseline(indicator._panelProvider);
+ for (const actor of [indicator._panelValue, indicator._panelReset]) {
+ const offset = textBaseline(actor) - providerBaseline;
+ assert(Math.abs(offset) <= 1,
+ `${position}: panel text baseline differs by ${offset}px`);
+ }
+ assert(value.x - provider.x - provider.width <= 6 * scale,
+ `${position}: provider and percentage are spaced too far apart`);
+ assert(Math.abs(separator.x - value.x - value.width -
+ (reset.x - separator.x - separator.width)) <= 1,
+ `${position}: panel separator spacing is uneven`);
+ assert(bounds(indicator.container).width / scale < 180 * textScale,
+ `${position}: panel indicator is not compact`);
+ for (const actor of [indicator._panelProvider, indicator._panelValue, indicator._panelReset]) {
+ assert(!actor.clutter_text.get_layout().is_ellipsized(), `${position}: panel text clipped`);
+ assert(textWidth(actor) <= actor.width + 1,
+ `${position}: panel text exceeds its allocation`);
+ }
+ if (!position.includes('calendar'))
+ return null;
+ const clockLabels = descendants(calendar).filter(actor =>
+ actor instanceof St.Label && actor.visible && actor.mapped && actor.width > 0);
+ assert(clockLabels.length > 0, 'Calendar has no visible label');
+ const clockLeft = Math.min(...clockLabels.map(actor => bounds(actor).x));
+ const clockRight = Math.max(...clockLabels.map(actor => bounds(actor).x + actor.width));
+ // Measure the visible text, not the label's allocated expansion space.
+ const resetRight = reset.x + textWidth(indicator._panelReset);
+ const gap = position === 'left-of-calendar' ? clockLeft - resetRight
+ : bounds(indicator._panelIcon).x - clockRight;
+ assert(gap >= 0 && gap <= 20 * scale,
+ `${position}: calendar/readout visual gap is not compact: ${gap / scale}`);
+ return gap / scale;
+ }
+
+ async _run() {
+ let indicator;
+ for (let attempt = 0; attempt < 80 && !indicator; attempt++) {
+ await this._wait();
+ indicator = Main.panel.statusArea[UUID];
+ }
+ assert(indicator, 'UsageBeam did not appear in the panel');
+ indicator._service.destroy();
+ const [, fixture] = GLib.file_get_contents(`${this._output}/ui-fixtures.json`);
+ const records = JSON.parse(new TextDecoder().decode(fixture));
+ indicator.attach({enabledProviders: ['codex', 'claude'], recordFor: id => records[id],
+ isRefreshing: () => false, refreshAll: () => {}});
+ const settings = indicator._settings;
+ const calendar = Main.panel.statusArea.dateMenu.container;
+ const positions = ['left', 'right', 'left-of-calendar', 'right-of-calendar'];
+ const placement = [];
+ Main.overview.hide();
+ await this._wait(500);
+ // Configure only this private virtual monitor, temporarily. Setting just
+ // St's scale factor would scale lengths without updating the font DPI.
+ const expectedScale = Number(GLib.getenv('USAGEBEAM_TEST_SCALE') ?? 1);
+ if (expectedScale !== 1) {
+ const [serial, monitors] = await this._displayCall('GetCurrentState');
+ const [[connector], modes] = monitors[0];
+ const [mode] = modes.find(item => item[5].includes(expectedScale)) ?? [];
+ assert(mode, `Virtual monitor does not support scale ${expectedScale}`);
+ await this._displayCall('ApplyMonitorsConfig', new GLib.Variant('(uua(iiduba(ssa{sv}))a{sv})',
+ [serial, 1, [[0, 0, expectedScale, 0, true, [[connector, mode, {}]]]], {}]));
+ await this._wait(500);
+ }
+ const [, , logicalMonitors] = await this._displayCall('GetCurrentState');
+ const monitorScale = logicalMonitors[0][2];
+ assert(monitorScale === expectedScale, `Expected monitor scale ${expectedScale}, got ${monitorScale}`);
+ const interfaceSettings = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'});
+ const textScale = interfaceSettings.get_double('text-scaling-factor');
+ if (GLib.getenv('USAGEBEAM_STRESS') === '1')
+ return runStress({indicator, records, settings, calendar,
+ wait: milliseconds => this._wait(milliseconds), screenshot: name => this._screenshot(name),
+ checkPanel: position => this._checkPanelSpacing(indicator, calendar, position, textScale),
+ monitorScale, textScale});
+ for (const position of positions) {
+ settings.set_string('panel-position', position);
+ settings.set_string('default-provider', 'codex');
+ await this._wait();
+ const parent = position === 'left' ? Main.panel._leftBox : position === 'right'
+ ? Main.panel._rightBox : Main.panel._centerBox;
+ assert(indicator.container.get_parent() === parent, `${position}: wrong panel area`);
+ const before = bounds(indicator.container);
+ const clockBefore = bounds(calendar);
+ const gaps = [this._checkPanelSpacing(indicator, calendar, position, textScale)];
+ // Check both a different provider and unavailable quota text.
+ const original = records.claude;
+ for (const available of [true, false]) {
+ records.claude = available ? original : {...original,
+ limits: {...original.limits, windows: []}};
+ settings.set_string('default-provider', 'claude');
+ indicator.render();
+ await this._wait();
+ gaps.push(this._checkPanelSpacing(indicator, calendar, position, textScale));
+ assert(Math.abs(bounds(calendar).x - clockBefore.x) <= 1,
+ `${position}: calendar moved on provider change`);
+ assert(Math.abs(bounds(indicator.container).width - before.width) <= 1,
+ `${position}: indicator width changed`);
+ assert(!indicator._panelProvider.clutter_text.get_layout().is_ellipsized(),
+ `${position}: panel provider name clipped: ${JSON.stringify({
+ status: indicator._panelStatus.width,
+ children: indicator._panelStatus.get_children().map(actor =>
+ ({style: actor.style_class, width: actor.width, preferred: actor.get_preferred_width(-1)})),
+ })}`);
+ }
+ records.claude = original;
+ if (position.includes('calendar')) {
+ const children = parent.get_children();
+ const offset = children.indexOf(indicator.container) - children.indexOf(calendar);
+ assert(offset === (position === 'left-of-calendar' ? -1 : 1), 'Calendar adjacency lost');
+ }
+ if (position.includes('calendar')) {
+ const left = position === 'left-of-calendar' ? before : clockBefore;
+ const right = position === 'left-of-calendar' ? clockBefore : before;
+ const panel = bounds(Main.panel);
+ const gapCenter = (left.x + left.width + right.x) / 2;
+ assert(Math.abs(gapCenter - (panel.x + panel.width / 2)) <= 1,
+ `${position}: calendar/indicator gap is not centered (${gapCenter})`);
+ }
+ settings.set_string('default-provider', 'codex');
+ await this._wait();
+ if (position.includes('calendar'))
+ await this._screenshot(position);
+ placement.push({position, indicator: before, calendar: clockBefore, visualGaps: gaps});
+ }
+ for (const provider of ['codex', 'claude']) {
+ const windows = records[provider].limits.windows;
+ const shortest = windows.reduce((selected, window) =>
+ window.durationMinutes < selected.durationMinutes ? window : selected);
+ const longer = windows.find(window => window.durationMinutes > shortest.durationMinutes);
+ const original = [shortest.usedPercent, longer.usedPercent];
+ settings.set_string('default-provider', provider);
+ shortest.usedPercent = 0;
+ longer.usedPercent = 100;
+ indicator.render();
+ await this._wait();
+ assert(indicator._panelValue.text === '0%',
+ `${provider}: panel did not prefer the freshly reset short window`);
+ shortest.usedPercent = 100;
+ longer.usedPercent = 0;
+ indicator.render();
+ await this._wait();
+ assert(indicator._panelValue.text === '100%',
+ `${provider}: panel dropped the exhausted short window`);
+ [shortest.usedPercent, longer.usedPercent] = original;
+ }
+ settings.set_string('default-provider', 'codex');
+ indicator.menu.open(0);
+ await this._wait();
+ await this._screenshot('collapsed');
+ matching(indicator._contentBox, 'usagebeam-disclosure')[0].emit('clicked', 1);
+ await this._wait();
+ assert(indicator._detailsExpanded, 'Activity button did not expand');
+ const content = indicator._contentBox;
+ const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ const initial = bounds(indicator.menu.actor);
+ assert(initial.height / scale < 720 * textScale, `Expanded menu too tall: ${initial.height / scale}`);
+ assert(!descendants(content).some(actor => actor instanceof St.ScrollView), 'Activity is scrollable');
+ const models = matching(content, 'usagebeam-model-meter');
+ assert(models.length === 3, 'Expected three synthetic models');
+ for (const actor of models) {
+ assert(actor.height / scale >= 27 && actor.height / scale <= 40 * textScale,
+ `Model row height is not compact: ${actor.height / scale}`);
+ assert(actor._fill.width > 0 && actor._fill.height > 0, 'Model fill is empty');
+ }
+ const plots = matching(content, 'usagebeam-chart-plot');
+ assert(plots.length === 7, 'Expected seven chart columns');
+ for (const plot of plots) {
+ assert(plot.height / scale === 52, `Unexpected chart height: ${plot.height / scale}`);
+ assert(plot._fill.visible === (plot._fraction > 0), 'Incorrect zero/nonzero bar visibility');
+ if (plot._fraction > 0)
+ assert(plot._fill.width > 0 && plot._fill.height > 0, 'Daily bar has no area');
+ }
+ for (const window of matching(content, 'usagebeam-window')) {
+ const [row, track, reset] = window.get_children();
+ const percent = matching(row, 'usagebeam-limit-percent')[0];
+ assert(!percent.clutter_text.get_layout().is_ellipsized(), 'Quota percentage clipped');
+ assert(!reset.clutter_text.get_layout().is_ellipsized(), 'Reset description clipped');
+ const trackBox = bounds(track);
+ const resetBox = bounds(reset);
+ const valueBox = bounds(percent);
+ const valueWidth = textWidth(percent);
+ assert(Math.abs(valueBox.x + valueWidth - trackBox.x - trackBox.width) <= 1,
+ 'Quota percentage does not align with the right edge of its bar');
+ assert(resetBox.y >= trackBox.y + trackBox.height && Math.abs(resetBox.x - trackBox.x) <= 1,
+ 'Reset description should be left-aligned below its bar');
+ assert(/^Resets in \d+[dhm]/.test(reset.text), 'Reset description lacks its explanatory prefix');
+ }
+ const dots = [indicator._panelSeparator, ...matching(content, 'usagebeam-separator-dot')];
+ for (const dot of dots) {
+ const dotBounds = bounds(dot);
+ const coreBounds = bounds(dot.get_child());
+ assert(Math.abs(coreBounds.x + coreBounds.width / 2 - (dotBounds.x + dotBounds.width / 2)) <= 1 &&
+ Math.abs(coreBounds.y + coreBounds.height / 2 - (dotBounds.y + dotBounds.height / 2)) <= 1,
+ 'Separator dot is not optically centered');
+ }
+ const limitName = matching(content, 'usagebeam-limit-name')[0];
+ const limitValue = matching(content, 'usagebeam-limit-percent')[0];
+ assert(limitValue.get_theme_node().get_font().get_size() <
+ limitName.get_theme_node().get_font().get_size(), 'Limit metrics lack font hierarchy');
+ assert(matching(content, 'usagebeam-disclosure-summary').length === 0,
+ 'Collapsed activity control should not repeat detail metadata');
+ assert(matching(content, 'usagebeam-disclosure-icon').length === 1,
+ 'Activity control should retain its disclosure affordance');
+ assert(matching(content, 'usagebeam-history-scope').length === 0,
+ 'Expanded activity should not repeat its local source scope');
+ const todayColumns = matching(content, 'usagebeam-today');
+ assert(todayColumns.length === 1,
+ 'Daily activity should distinguish exactly one current-day column');
+ assert(matching(content, 'usagebeam-chart-bar').length === 7,
+ 'Every daily activity column should use the same bar style');
+ for (const severity of ['caution', 'warning', 'danger']) {
+ const actors = matching(content, `usagebeam-${severity}`);
+ assert(actors.some(actor => actor.has_style_class_name('usagebeam-limit-percent')),
+ `${severity}: percentage does not expose quota severity`);
+ assert(actors.some(actor => actor.has_style_class_name('usagebeam-track')),
+ `${severity}: meter does not expose quota severity`);
+ }
+ assert(indicator._panelValue.has_style_class_name('usagebeam-danger'),
+ 'Panel indicator does not expose the shortest quota severity');
+ const modelContent = matching(content, 'usagebeam-model-content')[0];
+ const [modelName, modelTotal] = modelContent.get_children();
+ assert(modelTotal.get_theme_node().get_font().get_size() <
+ modelName.get_theme_node().get_font().get_size(), 'Model row lacks font hierarchy');
+ const heights = models.map(actor => actor.height);
+ for (let frame = 0; frame < 8; frame++) {
+ content.queue_relayout();
+ await this._wait(60);
+ assert(Math.abs(bounds(indicator.menu.actor).height - initial.height) <= 1,
+ 'Menu geometry drifts on repeated layout');
+ models.forEach((actor, index) => assert(actor.height === heights[index], 'Model row grows on layout'));
+ }
+ const font = content.get_theme_node().get_font().to_string();
+ assert(font.includes('SF Pro Text'), `Requested font family was overridden: ${font}`);
+ await this._screenshot('expanded-dark');
+ indicator.menu.actor.remove_style_class_name('usagebeam-dark');
+ indicator.menu.actor.add_style_class_name('usagebeam-light');
+ await this._wait();
+ await this._screenshot('expanded-light');
+ indicator.menu.close(0);
+ return {placement, expanded: initial, modelHeights: heights,
+ font, scale, monitorScale, textScale};
+ }
+
+ disable() {
+ for (const [id, reject] of this._timers) {
+ GLib.source_remove(id);
+ reject(new Error('UI check disabled before completion'));
+ }
+ this._timers.clear();
+ }
+}
diff --git a/tests/shell/metadata.json b/tests/shell/metadata.json
new file mode 100644
index 0000000..37a8e15
--- /dev/null
+++ b/tests/shell/metadata.json
@@ -0,0 +1,6 @@
+{
+ "uuid": "usagebeam-ui-test@tests.invalid",
+ "name": "UsageBeam private UI checks",
+ "description": "Test-only companion; never shipped in the product archive.",
+ "shell-version": ["50"]
+}
diff --git a/tests/shell/stress.js b/tests/shell/stress.js
new file mode 100644
index 0000000..f238a8e
--- /dev/null
+++ b/tests/shell/stress.js
@@ -0,0 +1,184 @@
+// Runs only in the synthetic private desktop; never imported by the product.
+import St from 'gi://St';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+
+const descendants = actor => [actor, ...actor.get_children().flatMap(descendants)];
+const matching = (actor, style) => descendants(actor).filter(child => child.has_style_class_name?.(style));
+const bounds = actor => {
+ const [x, y] = actor.get_transformed_position();
+ const [width, height] = actor.get_transformed_size();
+ return {x, y, width, height};
+};
+const copy = value => JSON.parse(JSON.stringify(value));
+
+export async function runStress({indicator, records, settings, calendar, wait, screenshot,
+ checkPanel, monitorScale, textScale}) {
+ const checks = [];
+ const geometry = [];
+ const check = (name, ok, details = {}) => checks.push({name, ok: Boolean(ok), ...details});
+ const baseline = copy(records);
+ const work = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex);
+ const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ const textFits = actor => actor.clutter_text.get_preferred_width(-1)[1] <= actor.width + 1;
+ const fit = name => {
+ const popup = bounds(indicator.menu.actor);
+ const footer = bounds(indicator._contentBox.get_last_child());
+ const inside = rect => rect.x >= work.x - 1 && rect.y >= work.y - 1 &&
+ rect.x + rect.width <= work.x + work.width + 1 &&
+ rect.y + rect.height <= work.y + work.height + 1;
+ check(`${name}: popup and footer fit work area`, inside(popup) && inside(footer), {popup, footer});
+ geometry.push({name, popup, footer});
+ };
+
+ for (const position of ['left', 'right', 'left-of-calendar', 'right-of-calendar']) {
+ settings.set_string('panel-position', position);
+ settings.set_string('default-provider', 'codex');
+ await wait(150);
+ const clock = bounds(calendar);
+ const slot = bounds(indicator.container);
+ for (const provider of ['claude', 'codex']) {
+ settings.set_string('default-provider', provider);
+ await wait(100);
+ check(`${position}/${provider}: clock and slot stable`,
+ Math.abs(bounds(calendar).x - clock.x) <= 1 &&
+ Math.abs(bounds(indicator.container).width - slot.width) <= 1);
+ check(`${position}/${provider}: panel labels fit`,
+ [indicator._panelProvider, indicator._panelValue, indicator._panelReset].every(textFits));
+ checkPanel(position);
+ }
+ if (position.includes('calendar')) {
+ const panel = bounds(Main.panel);
+ const left = position === 'left-of-calendar' ? slot : clock;
+ const right = position === 'left-of-calendar' ? clock : slot;
+ const center = (left.x + left.width + right.x) / 2;
+ check(`${position}: gap centered`, Math.abs(center - panel.x - panel.width / 2) <= 1);
+ }
+ indicator.menu.open(0);
+ await wait(100);
+ fit(`${position}/collapsed`);
+ indicator.menu.close(0);
+ }
+ indicator.menu.open(0);
+ matching(indicator._contentBox, 'usagebeam-disclosure')[0].emit('clicked', 1);
+ await wait(150);
+ fit('expanded');
+ const normalHeight = bounds(indicator.menu.actor).height;
+ const actorCount = descendants(indicator._contentBox).length;
+ await screenshot('stress-expanded');
+ const plots = matching(indicator._contentBox, 'usagebeam-chart-plot');
+ check('seven daily bars allocate correctly', plots.length === 7 && plots.every(plot =>
+ plot.height / scale === 52 && (plot._fraction === 0 || (plot._fill.width > 0 && plot._fill.height > 0))));
+ check('activity has no nested scrolling', !descendants(indicator._contentBox).some(actor => actor instanceof St.ScrollView));
+ for (let iteration = 0; iteration < 100; iteration++) {
+ settings.set_string('default-provider', iteration % 2 ? 'codex' : 'claude');
+ indicator.render();
+ matching(indicator._contentBox, 'usagebeam-disclosure')[0].emit('clicked', 1);
+ matching(indicator._contentBox, 'usagebeam-disclosure')[0].emit('clicked', 1);
+ await wait(16);
+ }
+ check('100 refresh/switch/expand cycles retain actor count', descendants(indicator._contentBox).length === actorCount);
+ check('100 refresh/switch/expand cycles retain height', Math.abs(bounds(indicator.menu.actor).height - normalHeight) <= 1);
+
+ for (const state of ['loading', 'unavailable', 'missing-auth', 'unsupported', 'stale', 'partial']) {
+ records.codex = copy(baseline.codex);
+ records.codex.limits.status = state;
+ records.codex.history.status = state;
+ records.codex.limits.message = 'Synthetic provider state; no account was contacted.';
+ records.codex.history.message = 'Synthetic local history state.';
+ if (!['stale', 'partial'].includes(state))
+ records.codex.limits.windows = [];
+ indicator.render();
+ await wait(70);
+ fit(state);
+ check(`${state}: panel text fits`, [indicator._panelValue, indicator._panelReset].every(textFits));
+ }
+
+ records.codex = copy(baseline.codex);
+ records.codex.plan = 'A long synthetic subscription plan '.repeat(2);
+ records.codex.limits.windows[0].label = 'A provider-specific allowance with an unusually long display name';
+ records.codex.history.models[0].model = 'synthetic-model-with-a-very-long-name-and-preview-version-2026';
+ indicator.render();
+ await wait(100);
+ fit('long-labels');
+ check('long labels retain bounded popup width', bounds(indicator.menu.actor).width <= work.width);
+
+ records.codex = copy(baseline.codex);
+ records.codex.limits.windows = Array.from({length: 32}, (_, index) =>
+ ({...baseline.codex.limits.windows[0], id: `synthetic-${index}`, label: `Allowance ${index + 1}`}));
+ indicator.render();
+ await wait(100);
+ fit('maximum-32-allowances');
+ const seenAllowances = new Set();
+ for (let page = 0; page < 32; page++) {
+ matching(indicator._contentBox, 'usagebeam-limit-name').forEach(actor => seenAllowances.add(actor.text));
+ const next = descendants(indicator._contentBox).find(actor =>
+ actor.accessible_name === 'Next limits page' && actor.reactive);
+ if (!next)
+ break;
+ next.grab_key_focus();
+ next.emit('clicked', 1);
+ await wait(60);
+ fit(`allowances-page-${page + 2}`);
+ }
+ check('all 32 allowances reachable without scrolling', seenAllowances.size === 32, {seen: seenAllowances.size});
+ check('quota pagination retains keyboard focus', Boolean(global.stage.get_key_focus()?.can_focus));
+ records.codex = copy(baseline.codex);
+ records.codex.history.models = Array.from({length: 12}, (_, index) =>
+ ({...baseline.codex.history.models[0], model: `synthetic-model-${index + 1}`}));
+ indicator._limitPage = 0;
+ indicator._sectionPage = 1;
+ indicator._activityPage = 1;
+ indicator.render();
+ await wait(100);
+ const seenModels = new Set();
+ for (let page = 0; page < 12; page++) {
+ matching(indicator._contentBox, 'usagebeam-model-name').forEach(actor => seenModels.add(actor.text));
+ const next = descendants(indicator._contentBox).find(actor =>
+ actor.accessible_name === 'Next models page' && actor.reactive);
+ if (!next)
+ break;
+ next.emit('clicked', 1);
+ await wait(60);
+ fit(`models-page-${page + 2}`);
+ }
+ check('all model pages reachable without scrolling', seenModels.size === 12, {seen: seenModels.size});
+ indicator._activityPage = indicator._modelPage = 0;
+ records.codex = copy(baseline.codex);
+ indicator.render();
+ await wait(100);
+ indicator.menu.actor.remove_style_class_name('usagebeam-dark');
+ indicator.menu.actor.add_style_class_name('usagebeam-light');
+ await wait(100);
+ fit('light-theme');
+ await screenshot('stress-light');
+ indicator.menu.close(0);
+
+ // Other center-panel extensions must not invalidate our alignment math.
+ const neighbor = new St.Bin({width: 80, height: 20});
+ Main.panel._centerBox.add_child(neighbor);
+ try {
+ for (const position of ['left-of-calendar', 'right-of-calendar']) {
+ settings.set_string('panel-position', position);
+ await wait(100);
+ const slot = bounds(indicator.container);
+ const clock = bounds(calendar);
+ const panel = bounds(Main.panel);
+ const center = position === 'left-of-calendar' ? (slot.x + slot.width + clock.x) / 2
+ : (clock.x + clock.width + slot.x) / 2;
+ check(`${position}: centered with neighboring extension`, Math.abs(center - panel.x - panel.width / 2) <= 1,
+ {offset: center - panel.x - panel.width / 2});
+ neighbor.width = 120;
+ await wait(100);
+ const afterSlot = bounds(indicator.container);
+ const afterClock = bounds(calendar);
+ const afterCenter = position === 'left-of-calendar' ? (afterSlot.x + afterSlot.width + afterClock.x) / 2
+ : (afterClock.x + afterClock.width + afterSlot.x) / 2;
+ check(`${position}: neighboring resize remains centered`, Math.abs(afterCenter - panel.x - panel.width / 2) <= 1);
+ neighbor.width = 80;
+ }
+ } finally {
+ neighbor.destroy();
+ }
+ return {ok: checks.every(result => result.ok), checks, geometry,
+ workArea: {x: work.x, y: work.y, width: work.width, height: work.height}, monitorScale, textScale};
+}
diff --git a/tests/unit/claude.test.js b/tests/unit/claude.test.js
new file mode 100644
index 0000000..e43a948
--- /dev/null
+++ b/tests/unit/claude.test.js
@@ -0,0 +1,68 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {claudeLimits, claudeLogin, collectClaude, parseClaudeEvent} from '../../src/providers/claude.js';
+
+test('reads percent-scaled standard and model-scoped Claude windows', () => {
+ const result = claudeLimits({
+ five_hour: {utilization: 1, resets_at: '2026-09-06T15:00:00Z'},
+ seven_day_oauth_apps: {utilization: 24.5, resets_at: '2026-09-13T10:00:00Z'},
+ limits: [
+ {kind: 'seven_day_scoped', percent: 70, resets_at: '2026-09-13T10:00:00Z',
+ scope: {model: {id: 'opus', display_name: 'Opus'}}},
+ {kind: 'seven_day_scoped', percent: 70, scope: {model: {id: 'opus', display_name: 'Opus'}}},
+ ],
+ }, 100);
+ assert.equal(result.status, 'ready');
+ assert.equal(result.windows.length, 3);
+ assert.equal(result.windows[0].usedPercent, 1);
+ assert.equal(result.windows[0].durationMinutes, 300);
+ assert.equal(result.windows[2].label, 'Opus · Weekly');
+});
+
+test('supports older fractional utilization without turning one percent into full usage', () => {
+ const result = claudeLimits({five_hour: {utilization: 0.25}, seven_day: {utilization: 0.5}});
+ assert.deepEqual(result.windows.map(window => window.usedPercent), [25, 50]);
+ assert.equal(claudeLimits({}).status, 'unavailable');
+});
+
+test('Claude login exposes only the needed token, expiry and display plan', () => {
+ assert.deepEqual(claudeLogin({}), {token: null, expiresAt: null, plan: null});
+ assert.deepEqual(claudeLogin({claudeAiOauth: {accessToken: 'secret', expiresAt: 20,
+ rateLimitTier: 'default_claude_max_20x', subscriptionType: 'max'}}),
+ {token: 'secret', expiresAt: 20, plan: 'Max 20x'});
+});
+
+test('parses Claude cache categories independently and provides stable message identity', () => {
+ const state = {session: 'file'};
+ const event = parseClaudeEvent({type: 'assistant', sessionId: 'session', timestamp: '2026-09-06T10:00:00Z',
+ message: {id: 'message', role: 'assistant', model: 'claude-opus', usage: {input_tokens: 2,
+ output_tokens: 3, cache_read_input_tokens: 40, cache_creation_input_tokens: 5}}}, state);
+ assert.deepEqual({input: event.input, output: event.output, cacheRead: event.cacheRead, cacheWrite: event.cacheWrite},
+ {input: 2, output: 3, cacheRead: 40, cacheWrite: 5});
+ assert.equal(event.id, 'session:message');
+ assert.equal(event.model, 'claude-opus');
+});
+
+test('missing or expired Claude auth preserves local history and skips the endpoint', async () => {
+ const history = {status: 'ready', days: [], models: []};
+ let requests = 0;
+ const io = credentials => ({scan: () => history, credentials: () => credentials,
+ fingerprint: () => 'hash', http: async () => { requests++; return {status: 200, data: {}}; }});
+ const missing = await collectClaude(io(null));
+ assert.equal(missing.limits.status, 'missing-auth');
+ assert.equal(missing.history, history);
+ const expired = await collectClaude(io({claudeAiOauth: {accessToken: 'token', expiresAt: 1}}));
+ assert.equal(expired.limits.status, 'missing-auth');
+ assert.equal(requests, 0);
+ const absent = await collectClaude({...io(null), hasCommand: () => false});
+ assert.equal(absent.limits.status, 'unsupported');
+});
+
+test('Claude endpoint authentication failure remains independent from history', async () => {
+ const result = await collectClaude({scan: () => ({status: 'ready', days: [], models: []}),
+ credentials: () => ({claudeAiOauth: {accessToken: 'token'}}), fingerprint: () => 'hash',
+ http: async () => ({status: 401, data: {}})});
+ assert.equal(result.capabilities.models, true);
+ assert.equal(result.limits.status, 'missing-auth');
+ assert.equal(result.history.status, 'ready');
+});
diff --git a/tests/unit/codex.test.js b/tests/unit/codex.test.js
new file mode 100644
index 0000000..2b596a5
--- /dev/null
+++ b/tests/unit/codex.test.js
@@ -0,0 +1,75 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {codexLimits, collectCodex, parseCodexEvent} from '../../src/providers/codex.js';
+
+const tokenEvent = (input, output, cache, total) => ({timestamp: '2026-09-06T12:00:00Z', type: 'event_msg', payload: {type: 'token_count',
+ info: {total_token_usage: {input_tokens: input, output_tokens: output, cached_input_tokens: cache, total_tokens: total}}}});
+
+test('reads all quota buckets and derives labels from real window durations', () => {
+ const result = codexLimits({rateLimitsByLimitId: {codex: {primary: {usedPercent: 10, windowDurationMins: 300, resetsAt: 1800000000},
+ secondary: {usedPercent: 100, windowDurationMins: 10080}}}}, 100);
+ assert.equal(result.windows.length, 2);
+ assert.equal(result.windows[0].resetsAt, 1800000000000);
+ assert.equal(result.windows[0].durationMinutes, 300);
+ assert.equal(result.windows[1].label, 'Weekly');
+ assert.equal(result.windows[1].usedPercent, 100);
+ assert.equal(result.status, 'ready');
+});
+
+test('empty Codex response is unavailable, not zero usage', () => {
+ assert.equal(codexLimits({}).status, 'unavailable');
+ assert.equal(codexLimits({rateLimits: {primary: {}}}).windows.length, 0);
+});
+
+test('cumulative token deltas and cached input are counted once', () => {
+ const state = {session: 'session-1', model: 'model-a'};
+ const first = parseCodexEvent(tokenEvent(100, 20, 40, 120), state);
+ assert.equal(first.input, 60);
+ assert.equal(first.cacheRead, 40);
+ assert.equal(parseCodexEvent(tokenEvent(100, 20, 40, 120), state), null);
+ const second = parseCodexEvent(tokenEvent(180, 35, 60, 215), state);
+ assert.equal(second.input + second.output + second.cacheRead, 95);
+ const reset = parseCodexEvent(tokenEvent(20, 5, 8, 25), state);
+ assert.equal(reset.input + reset.output + reset.cacheRead, 25);
+ assert.equal(state.cumulativeEpoch, 1);
+ assert.deepEqual(Object.keys(state.cumulative).sort(), [
+ 'cache_write_input_tokens', 'cached_input_tokens', 'input_tokens', 'output_tokens', 'total_tokens',
+ ]);
+});
+
+test('multi-bucket response falls back to the compatible quota view when empty', () => {
+ const result = codexLimits({rateLimitsByLimitId: {}, rateLimits: {
+ primary: {usedPercent: 12, windowDurationMins: 300},
+ }}, 100);
+ assert.equal(result.status, 'ready');
+ assert.equal(result.windows[0].label, 'Session · 5 hours');
+});
+
+test('primary Codex limits remain first when the app server reorders buckets', () => {
+ const result = codexLimits({rateLimitsByLimitId: {
+ reserve: {limitName: 'Reserve', primary: {usedPercent: 1, windowDurationMins: 10080}},
+ codex: {primary: {usedPercent: 2, windowDurationMins: 300}},
+ }});
+ assert.equal(result.windows[0].id, 'codex:primary');
+ assert.equal(result.windows[1].label, 'Reserve · Weekly');
+});
+
+test('record parsing tracks model and stable session identity', () => {
+ const state = {session: 'file-hash'};
+ parseCodexEvent({type: 'session_meta', payload: {id: 'native-session'}}, state);
+ parseCodexEvent({type: 'turn_context', payload: {model: 'model-b'}}, state);
+ const event = parseCodexEvent(tokenEvent(10, 2, 0, 12), state);
+ assert.equal(event.model, 'model-b');
+ assert.equal(event.session, 'native-session');
+});
+
+test('local history survives missing account authentication and closes RPC', async () => {
+ let closed = false;
+ const history = {status: 'ready', days: [], models: []};
+ const result = await collectCodex({scan: () => history,
+ codexClient: () => ({request: async method => method === 'account/read' ? {account: null} : {},
+ notify: () => {}, close: () => { closed = true; }})});
+ assert.equal(result.limits.status, 'missing-auth');
+ assert.equal(result.history, history);
+ assert.equal(closed, true);
+});
diff --git a/tests/unit/layout.test.js b/tests/unit/layout.test.js
new file mode 100644
index 0000000..d87bce8
--- /dev/null
+++ b/tests/unit/layout.test.js
@@ -0,0 +1,25 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {initialLayout, nextLayout, pageSlice} from '../../src/ui/layoutPolicy.js';
+
+test('adaptive layout is finite and preserves readable text rather than scaling it down', () => {
+ for (const expanded of [false, true]) {
+ let layout = initialLayout();
+ let attempts = 0;
+ while (layout) {
+ assert.ok(layout.limits >= 1 && layout.models >= 1);
+ assert.ok(++attempts <= 10, 'Layout must converge without allocation feedback');
+ layout = nextLayout(layout, expanded);
+ }
+ }
+});
+
+test('pagination reaches every item and clamps pages after a data change', () => {
+ const items = Array.from({length: 32}, (_, index) => index);
+ const seen = [];
+ for (let page = 0; page < 11; page++)
+ seen.push(...pageSlice(items, page, 3).items);
+ assert.deepEqual(seen, items);
+ assert.deepEqual(pageSlice([1], 10, 3), {items: [1], page: 0, pages: 1});
+ assert.deepEqual(pageSlice([], -1, 3), {items: [], page: 0, pages: 1});
+});
diff --git a/tests/unit/presentation.test.js b/tests/unit/presentation.test.js
new file mode 100644
index 0000000..23d187e
--- /dev/null
+++ b/tests/unit/presentation.test.js
@@ -0,0 +1,107 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {chartBarGeometry, historyOverview, latestUpdate, panelQuota, periodDays, providerStatus,
+ quotaName, quotaPresentation} from '../../src/ui/presentation.js';
+import {notificationMilestones, quotaSeverity, QUOTA_THRESHOLDS} from '../../src/core/thresholds.js';
+
+test('provider status distinguishes live, local, cached and setup data', () => {
+ const value = {limits: {status: 'ready'}, history: {status: 'ready'}};
+ assert.equal(providerStatus(value), 'LIVE');
+ value.limits.status = 'unavailable';
+ assert.equal(providerStatus(value), 'LOCAL');
+ value.history.status = 'stale';
+ assert.equal(providerStatus(value), 'CACHED');
+ value.history.status = 'unsupported';
+ assert.equal(providerStatus(value), 'SETUP');
+ assert.equal(providerStatus(value, true), 'SYNC');
+ assert.equal(providerStatus(null, true), 'SYNC');
+ value.limits.status = 'ready';
+ assert.equal(providerStatus(value, true), 'LIVE');
+});
+
+test('presentation helpers derive bounded period and freshness labels', () => {
+ assert.equal(latestUpdate({limits: {updatedAt: 10}, history: {updatedAt: 20}}), 20);
+ assert.equal(latestUpdate(null), null);
+ assert.equal(periodDays({start: '2026-08-31', end: '2026-09-06'}), 7);
+ assert.equal(periodDays({start: 'invalid', end: '2026-09-06'}), null);
+});
+
+test('history overview summarizes daily activity without double-counting models', () => {
+ const history = {
+ scope: 'local',
+ period: {start: '2026-08-31', end: '2026-09-06'},
+ days: [{total: 10}, {total: 20}],
+ models: [{total: 30}, {total: 40}],
+ };
+ assert.deepEqual(historyOverview(history), {days: 7, scope: 'local', total: 30});
+ assert.deepEqual(historyOverview({...history, scope: 'account', days: []}),
+ {days: 7, scope: 'account', total: 70});
+ assert.equal(historyOverview({...history, days: [], models: []}), null);
+ assert.equal(historyOverview(null), null);
+});
+
+test('quota presentation keeps names concise and reset descriptions explicit', () => {
+ const now = 1_000_000;
+ const reserve = {id: 'gpt-reserve:primary', label: 'Reserve · Weekly',
+ durationMinutes: 10080, usedPercent: 18.2, resetsAt: now + 4 * 86400000 + 22 * 3600000};
+ assert.equal(quotaName(reserve), 'Weekly Reserve');
+ assert.deepEqual(quotaPresentation(reserve, now), {
+ name: 'Weekly Reserve', value: '18%', reset: 'Resets in 4d 22h',
+ });
+ assert.equal(quotaName({id: 'five-hour', label: 'Session · 5 hours', durationMinutes: 300}),
+ '5H Session');
+ assert.deepEqual(quotaPresentation({id: 'credits', label: 'Credits', unlimited: true}, now), {
+ name: 'Credits', value: 'Unlimited', reset: null,
+ });
+ assert.equal(quotaPresentation({id: 'broken', label: 'Broken'}, now), null);
+ assert.equal(quotaPresentation({...reserve, resetsAt: now - 1}, now).reset,
+ 'Reset due · awaiting update');
+ assert.equal(quotaPresentation({...reserve, resetsAt: null}, now).reset,
+ 'Reset time unavailable');
+});
+
+test('notification milestones follow quota semantics without duplicate levels', () => {
+ assert.deepEqual(notificationMilestones(80), [80, 90, 100]);
+ assert.deepEqual(notificationMilestones(90), [90, 100]);
+ assert.deepEqual(notificationMilestones(95), [95, 100]);
+ assert.deepEqual(notificationMilestones(NaN), [90, 100]);
+});
+
+test('panel quota prefers the shortest current quota window at every usage level', () => {
+ const now = 1_000_000;
+ const record = {limits: {status: 'ready', windows: [
+ {id: 'weekly', usedPercent: 96, durationMinutes: 10080,
+ resetsAt: now + 15 * 3600000 + 59 * 60000},
+ {id: 'five-hour', usedPercent: 15, durationMinutes: 300,
+ resetsAt: now + 3600000},
+ {usedPercent: null, unlimited: true},
+ ]}};
+ assert.deepEqual(panelQuota(record, now), {percent: 15, reset: '1h 0m'});
+ record.limits.windows[1].usedPercent = 100;
+ assert.deepEqual(panelQuota(record, now), {percent: 100, reset: '1h 0m'});
+ record.limits.windows = [
+ {id: 'provider-first', usedPercent: 20},
+ {id: 'provider-second', usedPercent: 90},
+ ];
+ assert.deepEqual(panelQuota(record, now), {percent: 20, reset: null});
+ assert.equal(panelQuota({limits: {...record.limits, status: 'stale'}}, now), null);
+ assert.equal(panelQuota(null, now), null);
+});
+
+test('quota severity uses explicit caution, warning and danger thresholds', () => {
+ assert.deepEqual(QUOTA_THRESHOLDS, {caution: 80, warning: 90, danger: 100});
+ for (const value of [null, undefined, NaN, 0, 79.9])
+ assert.equal(quotaSeverity(value), null);
+ assert.equal(quotaSeverity(80), 'caution');
+ assert.equal(quotaSeverity(89.9), 'caution');
+ assert.equal(quotaSeverity(90), 'warning');
+ assert.equal(quotaSeverity(99.9), 'warning');
+ assert.equal(quotaSeverity(100), 'danger');
+ assert.equal(quotaSeverity(125), 'danger');
+});
+
+test('chart geometry gives every non-zero day visible width and bottom alignment', () => {
+ assert.deepEqual(chartBarGeometry(70, 70, 50, 64), {x: 4, y: 0, width: 42, height: 64});
+ assert.deepEqual(chartBarGeometry(21.7, 70, 50, 64), {x: 4, y: 44, width: 42, height: 20});
+ assert.deepEqual(chartBarGeometry(0, 70, 50, 64), {x: 4, y: 64, width: 42, height: 0});
+});
diff --git a/tests/unit/usage.test.js b/tests/unit/usage.test.js
new file mode 100644
index 0000000..83ce845
--- /dev/null
+++ b/tests/unit/usage.test.js
@@ -0,0 +1,176 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {aggregateEvents, mergeRecord, number, recentDates, record, validTime, validateRecord, windowUsage} from '../../src/core/usage.js';
+import {compactTokens, modelName, resetCountdown, resetTime, tokens} from '../../src/core/format.js';
+import {notificationBody, ThresholdTracker} from '../../src/core/notifications.js';
+
+test('unknown metrics are not coerced to zero', () => {
+ for (const value of [null, undefined, '', ' ', false, -1, Infinity, 'bad', [1], {value: 1}])
+ assert.equal(number(value), null);
+ assert.equal(number(0), 0);
+ assert.equal(windowUsage({id: 'plan', label: 'Plan', used: 0, limit: 0}), null);
+});
+
+test('unknown, exhausted and unlimited windows remain distinct', () => {
+ const exhausted = windowUsage({id: 'x', label: 'X', used: 12, limit: 10});
+ assert.equal(exhausted.usedPercent, 120);
+ assert.equal(exhausted.state, 'exhausted');
+ const unlimited = windowUsage({id: 'x', label: 'X', unlimited: true});
+ assert.equal(unlimited.usedPercent, null);
+ assert.equal(unlimited.unlimited, true);
+ assert.equal(unlimited.state, 'unlimited');
+});
+
+test('contract rejects invalid provider IDs and quota values', () => {
+ assert.throws(() => record('toString'), /Unsupported provider/);
+ assert.throws(() => record('cursor'), /Unsupported provider/);
+ assert.throws(() => validateRecord(record('codex'), 'claude'));
+ const value = record('codex');
+ value.limits.windows.push({id: 'x', label: 'X', state: 'active', usedPercent: null});
+ assert.throws(() => validateRecord(value, 'codex'));
+});
+
+test('contract accepts complete records and rejects unsafe cached shapes', () => {
+ const value = record('codex');
+ value.accountKey = 'a'.repeat(64);
+ value.capabilities = {limits: true, history: true, models: true};
+ value.limits = {status: 'ready', message: '', updatedAt: 100, scope: 'account', source: 'synthetic limits',
+ windows: [windowUsage({id: 'weekly', label: 'Weekly', usedPercent: 20})]};
+ value.history = {status: 'ready', message: '', updatedAt: 100, scope: 'local',
+ source: 'synthetic history', period: {start: '2026-09-05', end: '2026-09-06'},
+ days: [{date: '2026-09-05', total: 0, sessions: 0, events: 0},
+ {date: '2026-09-06', total: 10, sessions: 1, events: 1}],
+ models: [{model: 'test-model', total: 10, input: 6, output: 4, cacheRead: 0, cacheWrite: 0}]};
+ assert.equal(validateRecord(value, 'codex'), value);
+
+ const invalidDate = structuredClone(value);
+ invalidDate.history.days[1].date = '2026-99-99';
+ assert.throws(() => validateRecord(invalidDate, 'codex'), /daily date/);
+ const invalidTotal = structuredClone(value);
+ invalidTotal.history.models[0].total = 11;
+ assert.throws(() => validateRecord(invalidTotal, 'codex'), /model totals/);
+ const rawAccount = structuredClone(value);
+ rawAccount.accountKey = 'user@example.com';
+ assert.throws(() => validateRecord(rawAccount, 'codex'), /account key/);
+ const missingSource = structuredClone(value);
+ missingSource.limits.source = null;
+ assert.throws(() => validateRecord(missingSource, 'codex'), /limits source/);
+ const missingFreshness = structuredClone(value);
+ missingFreshness.history.updatedAt = 0;
+ assert.throws(() => validateRecord(missingFreshness, 'codex'), /history freshness/);
+ const unknownField = structuredClone(value);
+ unknownField.rawResponse = 'must not persist';
+ assert.throws(() => validateRecord(unknownField, 'codex'), /record shape/);
+ const duplicateQuota = structuredClone(value);
+ duplicateQuota.limits.windows.push(structuredClone(duplicateQuota.limits.windows[0]));
+ assert.throws(() => validateRecord(duplicateQuota, 'codex'), /duplicate quota/);
+});
+
+test('failure preserves last successful values as stale, account change discards them', () => {
+ const old = record('codex');
+ old.accountKey = 'one';
+ old.limits = {status: 'ready', updatedAt: 100, windows: [{usedPercent: 50}]};
+ const next = record('codex');
+ next.limits.status = 'unavailable';
+ next.limits.message = 'Disconnected';
+ const merged = mergeRecord(old, next);
+ assert.equal(merged.limits.status, 'stale');
+ assert.equal(merged.limits.updatedAt, 100);
+ assert.equal(merged.limits.windows[0].usedPercent, 50);
+ next.accountKey = 'two';
+ assert.equal(mergeRecord(old, next).limits.windows.length, 0);
+ next.accountKey = null;
+ next.limits.status = 'missing-auth';
+ assert.equal(mergeRecord(old, next).limits.windows.length, 0);
+ assert.equal(mergeRecord(old, next).accountKey, null);
+});
+
+test('stale quota windows are discarded after their reset', () => {
+ const old = record('claude');
+ old.limits = {status: 'ready', updatedAt: 100, windows: [
+ {id: 'expired', resetsAt: 1},
+ {id: 'open', resetsAt: 2000},
+ ]};
+ const next = record('claude');
+ next.limits.status = 'unavailable';
+ const result = mergeRecord(old, next, 1000);
+ assert.equal(result.limits.status, 'stale');
+ assert.deepEqual(result.limits.windows.map(window => window.id), ['open']);
+});
+
+test('daily and model totals use the same period and deduplicate events', () => {
+ const event = {id: 'one', session: 'a', model: 'model-a', date: '2026-09-06', input: 20, output: 5, cacheRead: 10, cacheWrite: 0};
+ const result = aggregateEvents([event, event, {...event, id: 'older', date: '2026-08-01'}], new Date('2026-09-06T12:00:00').getTime());
+ assert.equal(result.days.length, 7);
+ assert.equal(result.days.reduce((sum, d) => sum + d.total, 0), 35);
+ assert.equal(result.models[0].total, 35);
+ assert.equal(result.days.at(-1).sessions, 1);
+ assert.equal(result.period.start, '2026-08-31');
+});
+
+test('calendar buckets are consecutive across month boundaries', () => {
+ assert.deepEqual(recentDates(new Date('2026-03-02T12:00:00').getTime(), 3), ['2026-02-28', '2026-03-01', '2026-03-02']);
+ assert.equal(validTime('1800000000'), 1800000000000);
+});
+
+test('notifications emit every configured milestone once per quota period', () => {
+ const tracker = new ThresholdTracker();
+ const value = record('codex');
+ value.limits = {status: 'ready', windows: [{id: 'weekly', label: 'Weekly', usedPercent: 79, resetsAt: 500}]};
+ assert.deepEqual(tracker.update(value, 80), []);
+ value.limits.windows[0].usedPercent = 80;
+ assert.equal(tracker.update(value, 80)[0].threshold, 80);
+ assert.deepEqual(tracker.update(value, 80), []);
+ value.limits.windows[0].usedPercent = 90;
+ assert.equal(tracker.update(value, 80)[0].threshold, 90);
+ assert.deepEqual(tracker.update(value, 80), []);
+ value.limits.windows[0].usedPercent = 100;
+ assert.equal(tracker.update(value, 80)[0].threshold, 100);
+ assert.deepEqual(tracker.update(value, 80), []);
+ value.limits.windows[0].usedPercent = 50;
+ assert.deepEqual(tracker.update(value, 80), []);
+ value.limits.windows[0].usedPercent = 100;
+ assert.deepEqual(tracker.update(value, 80), []);
+ value.limits.status = 'unavailable';
+ assert.deepEqual(tracker.update(value, 80), []);
+ value.limits.status = 'ready';
+ value.limits.windows[0].resetsAt = 1000;
+ assert.deepEqual(tracker.update(value, 80), []);
+});
+
+test('simultaneous and repeated alerts produce one concise notification body', () => {
+ assert.equal(notificationBody([
+ {provider: 'Codex', label: 'Weekly', threshold: 90},
+ {provider: 'Codex', label: 'Weekly', threshold: 90},
+ {provider: 'Codex', label: '5H Session', threshold: 100},
+ ]), 'Codex: Weekly reached 90%.\nCodex: 5H Session limit reached.');
+ assert.equal(notificationBody([]), '');
+});
+
+test('notification eviction retains recently refreshed windows and accepts fresh partial data', () => {
+ const tracker = new ThresholdTracker();
+ const current = (id, percent) => ({id: 'codex', name: 'Codex', limits: {status: 'partial', windows: [
+ {id, label: id, usedPercent: percent, resetsAt: 1000},
+ ]}});
+ tracker.update(current('active', 79), 80);
+ assert.equal(tracker.update(current('active', 90), 80).length, 1);
+ for (let id = 0; id < 500; id++) {
+ tracker.update(current(String(id), 0), 80);
+ assert.deepEqual(tracker.update(current('active', 90), 80), []);
+ }
+ assert.equal(tracker.previous.size, 200);
+});
+
+test('formatting preserves unknown/reset-due states', () => {
+ assert.equal(tokens(null), '—');
+ assert.equal(tokens(23000000), '23.0M');
+ assert.equal(compactTokens(186000000), '186M');
+ assert.equal(compactTokens(56300000), '56.3M');
+ assert.equal(resetTime(null), 'Reset time unavailable');
+ assert.match(resetTime(100, 200), /awaiting update/);
+ assert.equal(resetCountdown(null), null);
+ assert.equal(resetCountdown(100, 200), 'due');
+ assert.equal(resetCountdown(100 + 4 * 86400000 + 22 * 3600000, 100), '4d 22h');
+ assert.equal(modelName('gpt-5.6-sol'), 'GPT 5.6 Sol');
+ assert.equal(modelName('codex_auto-review'), 'Codex Auto Review');
+});
diff --git a/tools/check.js b/tools/check.js
new file mode 100644
index 0000000..e0d92b9
--- /dev/null
+++ b/tools/check.js
@@ -0,0 +1,87 @@
+import {readFileSync, readdirSync, existsSync} from 'node:fs';
+import {resolve, dirname, join, relative} from 'node:path';
+import {execFileSync} from 'node:child_process';
+
+function walk(directory) {
+ return readdirSync(directory, {withFileTypes: true}).flatMap(entry => entry.isDirectory()
+ ? walk(join(directory, entry.name)) : [join(directory, entry.name)]);
+}
+const files = ['extension.js', 'prefs.js', ...walk('src'), ...walk('tests'), ...walk('tools')].filter(file => file.endsWith('.js'));
+for (const file of walk('src')) {
+ if (!file.endsWith('.js'))
+ throw new Error(`Unexpected non-runtime file under src/: ${file}`);
+}
+const allowedRuntimeImports = {
+ core: new Set(['core']),
+ providers: new Set(['core', 'providers']),
+ services: new Set(['core', 'services']),
+ collector: new Set(['core', 'providers', 'services', 'collector']),
+ ui: new Set(['core', 'ui']),
+};
+const runtimeLayer = file => file.startsWith('src/') ? file.split('/')[1] : null;
+for (const file of files) {
+ const text = readFileSync(file, 'utf8');
+ execFileSync(process.execPath, ['--check', file], {stdio: 'pipe'});
+ if (!text.endsWith('\n') || /[ \t]+$/m.test(text))
+ throw new Error(`${file}: trailing whitespace or missing final newline`);
+ for (const [, target] of text.matchAll(/from ['"]([.][^'"]+)['"]/g)) {
+ const absolute = resolve(dirname(file), target);
+ if (!existsSync(absolute))
+ throw new Error(`${file}: missing import ${target}`);
+ const source = runtimeLayer(file);
+ const destination = runtimeLayer(relative('.', absolute));
+ if (source && destination && !allowedRuntimeImports[source]?.has(destination))
+ throw new Error(`${file}: ${source} modules cannot import the ${destination} layer`);
+ }
+}
+for (const entrypoint of ['extension.js', 'prefs.js']) {
+ if (readFileSync(entrypoint, 'utf8').split('\n').length > 120)
+ throw new Error(`${entrypoint}: GNOME entry points must remain thin`);
+}
+const metadata = JSON.parse(readFileSync('metadata.json', 'utf8'));
+if (metadata.uuid !== 'usagebeam@oo7kc.github.io')
+ throw new Error('Extension identity changed without a migration');
+if (metadata.name !== 'UsageBeam' || metadata['settings-schema'] !== 'org.gnome.shell.extensions.usagebeam')
+ throw new Error('UsageBeam product metadata is inconsistent');
+const version = JSON.parse(readFileSync('package.json', 'utf8')).version;
+if (!readFileSync('meson.build', 'utf8').includes(`version: '${version}'`))
+ throw new Error('Meson and package versions differ');
+
+const required = ['README.md', 'CHANGELOG.md',
+ 'docs/providers/codex.md', 'docs/providers/claude.md'];
+for (const file of required) {
+ if (!existsSync(file))
+ throw new Error(`Missing product documentation: ${file}`);
+}
+const schemas = walk('schemas').filter(file => file.endsWith('.xml'));
+if (schemas.length !== 1 || schemas[0] !== 'schemas/org.gnome.shell.extensions.usagebeam.gschema.xml')
+ throw new Error('Unexpected extension schema input');
+const icons = walk('icons');
+if (icons.length !== 2 || !icons.includes('icons/claude.svg') ||
+ !icons.includes('icons/codex-symbolic.svg'))
+ throw new Error('Unexpected runtime icon input');
+const providerFiles = walk('src/providers').filter(file => file.endsWith('.js')).sort();
+if (providerFiles.join(',') !== 'src/providers/claude.js,src/providers/codex.js')
+ throw new Error(`Unverified provider adapters must not ship: ${providerFiles.join(', ')}`);
+const obsolete = ['plan.md', 'indicator.js', 'src/providers/legacy.js', 'scripts/freeby.sh',
+ 'src/providers/cursor.js', 'src/providers/copilot.js', 'scripts/copilot-setup.sh',
+ 'tests/freeby.bats', 'docs/README.md', 'docs/assets', 'docs/providers/README.md',
+ 'docs/s1.png', 'docs/s2.png', 'CONTRIBUTING.md'];
+for (const file of obsolete) {
+ if (existsSync(file))
+ throw new Error(`Obsolete repository path returned: ${file}`);
+}
+const markdown = ['README.md', 'CHANGELOG.md', ...walk('docs')]
+ .filter(file => file.endsWith('.md'));
+for (const file of markdown) {
+ const text = readFileSync(file, 'utf8');
+ for (const [, target] of text.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)) {
+ if (/^(?:https?:|#|mailto:)/.test(target))
+ continue;
+ const path = target.split('#')[0];
+ if (path && !existsSync(resolve(dirname(file), path)))
+ throw new Error(`${file}: broken relative link ${target}`);
+ }
+}
+execFileSync('glib-compile-schemas', ['--strict', '--dry-run', 'schemas']);
+console.log(`Syntax, imports, formatting, repository layout, links, release metadata and schema checks passed (${files.length} JavaScript files).`);
diff --git a/tools/package.py b/tools/package.py
new file mode 100644
index 0000000..81388c8
--- /dev/null
+++ b/tools/package.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""Create a deterministic GNOME extension archive from an explicit allowlist."""
+
+import argparse
+import json
+import re
+import subprocess
+import tempfile
+import zipfile
+from pathlib import Path
+
+
+def checked_file(source, path):
+ """Return a regular, repository-owned packaging input."""
+ if path.is_symlink() or not path.is_file():
+ raise RuntimeError(f"Invalid packaging input: {path}")
+ try:
+ path.resolve().relative_to(source)
+ except ValueError as error:
+ raise RuntimeError(
+ f"Packaging input escapes the source tree: {path}"
+ ) from error
+ return path
+
+
+def package(source, output):
+ source = source.resolve()
+ metadata_file = checked_file(source, source / "metadata.json")
+ package_file = checked_file(source, source / "package.json")
+ metadata = json.loads(metadata_file.read_text())
+ version = json.loads(package_file.read_text())["version"]
+ uuid = metadata.get("uuid")
+ if not isinstance(uuid, str) or not re.fullmatch(
+ r"[A-Za-z0-9._-]+@[A-Za-z0-9._-]+", uuid
+ ):
+ raise RuntimeError("metadata.json contains an invalid extension UUID")
+ if not isinstance(version, str) or not re.fullmatch(
+ r"[0-9A-Za-z][0-9A-Za-z.+-]*", version
+ ):
+ raise RuntimeError("package.json contains an invalid release version")
+ files = [
+ source / name
+ for name in (
+ "extension.js",
+ "prefs.js",
+ "metadata.json",
+ "stylesheet.css",
+ "LICENSE",
+ )
+ ]
+ files.extend(sorted((source / "src").rglob("*.js")))
+ files.extend(sorted((source / "icons").glob("*.svg")))
+ schema = metadata.get("settings-schema")
+ if schema != "org.gnome.shell.extensions.usagebeam":
+ raise RuntimeError("metadata.json contains an unexpected settings schema")
+ files.append(source / "schemas" / f"{schema}.gschema.xml")
+ files = [checked_file(source, file) for file in files]
+ output.mkdir(parents=True, exist_ok=True)
+ archive = output / f"{uuid}-{version}.zip"
+ if archive.is_symlink():
+ raise RuntimeError(f"Refusing to overwrite archive symlink: {archive}")
+ with tempfile.TemporaryDirectory(prefix="usagebeam-schemas-") as scratch:
+ subprocess.run(
+ [
+ "glib-compile-schemas",
+ "--strict",
+ "--targetdir",
+ scratch,
+ str(source / "schemas"),
+ ],
+ check=True,
+ )
+ payloads = [
+ (file.relative_to(source).as_posix(), file.read_bytes()) for file in files
+ ]
+ payloads.append(
+ (
+ "schemas/gschemas.compiled",
+ (Path(scratch) / "gschemas.compiled").read_bytes(),
+ )
+ )
+ expected = {name for name, _payload in payloads}
+ if len(expected) != len(payloads):
+ raise RuntimeError("Duplicate paths in extension package")
+ with zipfile.ZipFile(
+ archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
+ ) as target:
+ for name, payload in sorted(payloads):
+ info = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
+ info.create_system = 3
+ info.compress_type = zipfile.ZIP_DEFLATED
+ info.external_attr = 0o100644 << 16
+ target.writestr(info, payload)
+ with zipfile.ZipFile(archive) as check:
+ names = check.namelist()
+ if check.testzip() is not None:
+ raise RuntimeError("Extension archive failed its integrity check")
+ if set(names) != expected:
+ raise RuntimeError(
+ "Extension archive contents differ from the runtime allowlist"
+ )
+ if any(
+ name.startswith((".AGENTS/", ".github/", "docs/", "tests/", "tools/"))
+ for name in names
+ ):
+ raise RuntimeError(
+ "Repository-only content entered the extension archive"
+ )
+ print(archive.resolve())
+ return archive
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--source", type=Path, default=Path(__file__).resolve().parent.parent
+ )
+ parser.add_argument("--output", type=Path, default=Path("dist"))
+ args = parser.parse_args()
+ package(args.source.resolve(), args.output.resolve())
diff --git a/tools/smoke-shell.py b/tools/smoke-shell.py
new file mode 100644
index 0000000..cbe0aaf
--- /dev/null
+++ b/tools/smoke-shell.py
@@ -0,0 +1,449 @@
+#!/usr/bin/env python3
+"""Exercise the packaged extension in a private headless GNOME session."""
+
+import argparse
+import json
+import os
+import shutil
+import signal
+import subprocess
+import tempfile
+import time
+import zipfile
+from datetime import date, timedelta
+from pathlib import Path
+from xml.sax.saxutils import escape
+
+UUID = "usagebeam@oo7kc.github.io"
+SCHEMA = "org.gnome.shell.extensions.usagebeam"
+PRODUCT_DIRECTORY = "usagebeam"
+TEST_UUID = "usagebeam-ui-test@tests.invalid"
+
+
+def run(command, env, **options):
+ return subprocess.run(
+ command, env=env, text=True, capture_output=True, timeout=15, **options
+ )
+
+
+def install(source, archive, prefix, destination):
+ extension = prefix / "share" / "gnome-shell" / "extensions" / UUID
+ if archive:
+ with zipfile.ZipFile(archive) as payload:
+ for entry in payload.infolist():
+ parts = Path(entry.filename).parts
+ if entry.is_dir():
+ continue
+ if Path(entry.filename).is_absolute() or ".." in parts:
+ raise RuntimeError(f"Unsafe archive entry: {entry.filename}")
+ target = extension.joinpath(*parts)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(payload.read(entry))
+ return
+ build = destination / "build"
+ subprocess.run(
+ ["meson", "setup", str(build), str(source), f"--prefix={prefix}"],
+ check=True,
+ stdout=subprocess.DEVNULL,
+ )
+ subprocess.run(
+ ["meson", "install", "-C", str(build)], check=True, stdout=subprocess.DEVNULL
+ )
+
+
+def seed_usage(destination):
+ """Populate non-personal records so every primary popup widget is constructed."""
+ now = int(time.time() * 1000)
+ dates = [
+ (date.today() - timedelta(days=offset)).isoformat()
+ for offset in range(6, -1, -1)
+ ]
+
+ def record(provider, name, plan, percentages, models):
+ days = [
+ {
+ "date": day,
+ "total": [0, 0, 0, 0, 0, 70_700_000, 38_500_000][index],
+ "sessions": index + 1,
+ "events": index + 2,
+ }
+ for index, day in enumerate(dates)
+ ]
+ return {
+ "schemaVersion": 2,
+ "id": provider,
+ "name": name,
+ "plan": plan,
+ "accountKey": "0" * 64,
+ "capabilities": {"limits": True, "history": True, "models": True},
+ "limits": {
+ "status": "ready",
+ "message": "",
+ "updatedAt": now,
+ "scope": "account",
+ "source": "Synthetic smoke fixture",
+ "windows": [
+ {
+ "id": f"{provider}:session",
+ "label": "Session · 5 hours",
+ "usedPercent": percentages[0],
+ "used": None,
+ "limit": None,
+ "unit": "percent",
+ "unlimited": False,
+ "state": "exhausted" if percentages[0] >= 100 else "active",
+ "durationMinutes": 300,
+ "resetsAt": now + 7_200_000,
+ },
+ {
+ "id": f"{provider}:weekly",
+ "label": "Weekly",
+ "usedPercent": percentages[1],
+ "used": None,
+ "limit": None,
+ "unit": "percent",
+ "unlimited": False,
+ "state": "exhausted" if percentages[1] >= 100 else "active",
+ "durationMinutes": 10_080,
+ "resetsAt": now + 172_800_000,
+ },
+ {
+ "id": f"{provider}:reserve",
+ "label": "Weekly reserve",
+ "usedPercent": percentages[2],
+ "used": None,
+ "limit": None,
+ "unit": "percent",
+ "unlimited": False,
+ "state": "exhausted" if percentages[2] >= 100 else "active",
+ "durationMinutes": 10_080,
+ "resetsAt": now + 604_800_000,
+ },
+ ],
+ },
+ "history": {
+ "status": "ready",
+ "message": "",
+ "updatedAt": now,
+ "scope": "local",
+ "period": {"start": dates[0], "end": dates[-1]},
+ "days": days,
+ "models": models,
+ "source": "Synthetic smoke fixture",
+ },
+ }
+
+ models = [
+ {
+ "model": "gpt-5.6-sol",
+ "total": 77_200_000,
+ "input": 190_000,
+ "output": 68_000,
+ "cacheRead": 76_942_000,
+ "cacheWrite": 0,
+ },
+ {
+ "model": "codex-auto-review",
+ "total": 24_100_000,
+ "input": 97_000,
+ "output": 8_000,
+ "cacheRead": 23_995_000,
+ "cacheWrite": 0,
+ },
+ {
+ "model": "gpt-6-astra",
+ "total": 7_900_000,
+ "input": 42_000,
+ "output": 62_000,
+ "cacheRead": 7_796_000,
+ "cacheWrite": 0,
+ },
+ ]
+ target = destination / "state" / PRODUCT_DIRECTORY
+ target.mkdir(parents=True, exist_ok=True)
+ records = {
+ "codex": record("codex", "Codex", "Plus", (100, 94, 83), models),
+ "claude": record("claude", "Claude Code", "Pro", (46, 7, 0), models),
+ }
+ for provider, data in records.items():
+ (target / f"{provider}.json").write_text(json.dumps(data))
+ # Separate fixtures survive asynchronous collector updates to the cache.
+ (destination / "ui-fixtures.json").write_text(json.dumps(records))
+
+
+def smoke(source, archive, destination, scale=1, text_scale=1.0, *,
+ width=1280, height=1024, system_fonts=False, stress=False):
+ destination.mkdir(parents=True, exist_ok=True)
+ prefix = destination / "install"
+ install(source, archive, prefix, destination)
+ shutil.copytree(
+ source / "tests/shell", prefix / "share/gnome-shell/extensions" / TEST_UUID
+ )
+ isolated_home = destination / "home"
+ isolated_home.mkdir(parents=True, exist_ok=True)
+ runtime = destination / "runtime"
+ runtime.mkdir(mode=0o700)
+ seed_usage(destination)
+ env = {
+ **os.environ,
+ "HOME": str(isolated_home),
+ "PATH": "/usr/bin:/bin",
+ "XDG_CONFIG_HOME": str(destination / "config"),
+ "XDG_DATA_HOME": str(prefix / "share"),
+ "XDG_CACHE_HOME": str(destination / "cache"),
+ "XDG_STATE_HOME": str(destination / "state"),
+ "GSETTINGS_BACKEND": "keyfile",
+ "XDG_RUNTIME_DIR": str(runtime),
+ "GSETTINGS_SCHEMA_DIR": str(
+ prefix / "share/gnome-shell/extensions" / UUID / "schemas"
+ ),
+ "LIBGL_ALWAYS_SOFTWARE": "1",
+ "USAGEBEAM_TEST_OUTPUT": str(destination),
+ "USAGEBEAM_TEST_SCALE": str(scale),
+ "USAGEBEAM_STRESS": "1" if stress else "0",
+ }
+ # Never let inherited provider paths expose the real account to a test shell.
+ for name in ("CODEX_HOME", "CLAUDE_CONFIG_DIR"):
+ env.pop(name, None)
+ # Use locally installed fonts without copying or distributing licensed files.
+ font = run(
+ ["fc-match", "-f", "%{file}", "SF Pro Text"], os.environ, check=True
+ ).stdout
+ font_config = destination / "fonts.conf"
+ extra_fonts = "" if system_fonts else f"{escape(str(Path(font).parent))} "
+ font_config.write_text(
+ "/etc/fonts/fonts.conf "
+ f"{extra_fonts} "
+ )
+ env["FONTCONFIG_FILE"] = str(font_config)
+ resolved_font = run(["fc-match", "-f", "%{family}", "SF Pro Text"], env, check=True).stdout
+ if system_fonts and "SF Pro" in resolved_font:
+ raise RuntimeError("System-font case still resolves SF Pro; font fallback was not isolated")
+ run(
+ [
+ "gsettings",
+ "set",
+ "org.gnome.desktop.interface",
+ "scaling-factor",
+ str(scale),
+ ],
+ env,
+ check=True,
+ )
+ run(
+ [
+ "gsettings",
+ "set",
+ "org.gnome.desktop.interface",
+ "text-scaling-factor",
+ str(text_scale),
+ ],
+ env,
+ check=True,
+ )
+ run(
+ ["gsettings", "set", SCHEMA, "enabled-providers", "['codex', 'claude']"],
+ env,
+ check=True,
+ )
+ run(
+ ["gsettings", "set", SCHEMA, "panel-position", "'left-of-calendar'"],
+ env,
+ check=True,
+ )
+ run(
+ [
+ "gsettings",
+ "set",
+ "org.gnome.shell",
+ "enabled-extensions",
+ f"['{UUID}', '{TEST_UUID}']",
+ ],
+ env,
+ check=True,
+ )
+ run(
+ [
+ "gsettings",
+ "set",
+ "org.gnome.shell",
+ "welcome-dialog-last-shown-version",
+ "999",
+ ],
+ env,
+ )
+ bus = subprocess.Popen(
+ ["dbus-daemon", "--session", "--nofork", "--print-address=1"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ )
+ env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
+ log_path = destination / "shell.log"
+ shell = None
+ try:
+ with log_path.open("w") as log:
+ shell = subprocess.Popen(
+ [
+ "gnome-shell",
+ "--headless",
+ "--no-x11",
+ "--virtual-monitor",
+ f"{width * scale}x{height * scale}",
+ "--wayland-display",
+ "usagebeam-test",
+ ],
+ env=env,
+ stdout=log,
+ stderr=log,
+ start_new_session=True,
+ )
+ command = [
+ "gdbus",
+ "call",
+ "--session",
+ "--dest",
+ "org.gnome.Shell.Extensions",
+ "--object-path",
+ "/org/gnome/Shell/Extensions",
+ "--method",
+ "org.gnome.Shell.Extensions.GetExtensionInfo",
+ UUID,
+ ]
+ deadline = time.monotonic() + 30
+ info = ""
+ while time.monotonic() < deadline and shell.poll() is None:
+ result = run(command, env)
+ info = result.stdout
+ if "'state': <1.0>" in info:
+ break
+ if "'state': <3.0>" in info:
+ raise RuntimeError(f"Extension failed: {info}")
+ time.sleep(0.4)
+ else:
+ raise RuntimeError(f"Extension did not become active: {info}")
+ print("PASS: packaged extension loads in a private GNOME Shell session")
+ results_path = destination / "ui-results.json"
+ deadline = time.monotonic() + (90 if stress else 25)
+ while (
+ time.monotonic() < deadline
+ and not results_path.exists()
+ and shell.poll() is None
+ ):
+ time.sleep(0.2)
+ if not results_path.exists():
+ raise RuntimeError(f"UI tests did not complete; inspect {log_path}")
+ results = json.loads(results_path.read_text())
+ if not results["ok"] and not stress:
+ raise RuntimeError(f"UI check failed: {results['error']}")
+ if results["ok"]:
+ print("PASS: private-shell UI assertions")
+ print(f"UI geometry and screenshots: {destination}")
+ run(["gnome-extensions", "disable", TEST_UUID], env, check=True)
+ cycles = 10 if stress else 1
+ for cycle in range(cycles):
+ run(["gnome-extensions", "disable", UUID], env, check=True)
+ disabled = run(command, env, check=True).stdout
+ if "'state': <2.0>" not in disabled:
+ raise RuntimeError(f"Disable cycle {cycle + 1} did not reach INACTIVE")
+ run(["gnome-extensions", "enable", UUID], env, check=True)
+ enabled = run(command, env, check=True).stdout
+ if "'state': <1.0>" not in enabled:
+ raise RuntimeError(f"Enable cycle {cycle + 1} did not reach ACTIVE")
+ print(f"PASS: {cycles} verified disable/re-enable cycles")
+ # Leave no collector running while the private shell itself shuts down.
+ run(["gnome-extensions", "disable", UUID], env, check=True)
+ finally:
+ if shell and shell.poll() is None:
+ os.killpg(shell.pid, signal.SIGTERM)
+ try:
+ shell.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ os.killpg(shell.pid, signal.SIGKILL)
+ shell.wait()
+ bus.terminate()
+ bus.wait(timeout=5)
+ print(f"GNOME log: {log_path}")
+ contents = log_path.read_text(errors="replace")
+ if "Gjs-CRITICAL" in contents and UUID in contents:
+ raise RuntimeError(f"Extension emitted a GJS critical; inspect {log_path}")
+ if (
+ "St-WARNING" in contents
+ or "UsageBeam could not" in contents
+ or "JS ERROR" in contents
+ ):
+ raise RuntimeError(f"UI, stylesheet, or JavaScript warning; inspect {log_path}")
+ results.update(resolvedFont=resolved_font, lifecycleCycles=cycles)
+ (destination / "ui-results.json").write_text(json.dumps(results, indent=2))
+ if not results["ok"]:
+ raise RuntimeError(f"UI stress failures; inspect {destination / 'ui-results.json'}")
+
+
+def matrix(source, archive, destination, selected=None):
+ cases = [
+ ("desktop-sf", 1280, 1024, 1, 1.0, False),
+ ("laptop-fallback", 1366, 768, 1, 1.0, True),
+ ("small-fallback", 1024, 600, 1, 1.0, True),
+ ("large-text", 1920, 1080, 1, 1.5, True),
+ ("hidpi-fallback", 1920, 1080, 2, 1.0, True),
+ ("hidpi-large-text", 1280, 1024, 2, 1.25, False),
+ ]
+ outcomes = []
+ for name, width, height, scale, text_scale, fallback in cases:
+ if selected and name != selected:
+ continue
+ print(f"CASE: {name} ({width}x{height} logical, scale {scale}, text {text_scale})", flush=True)
+ try:
+ smoke(source, archive, destination / name, scale, text_scale,
+ width=width, height=height, system_fonts=fallback, stress=True)
+ outcomes.append({"case": name, "ok": True})
+ except (RuntimeError, subprocess.SubprocessError) as error:
+ outcomes.append({"case": name, "ok": False, "error": str(error)})
+ print(f"FAIL: {name}: {error}", flush=True)
+ (destination / "matrix-results.json").write_text(json.dumps(outcomes, indent=2))
+ return all(result["ok"] for result in outcomes)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--source", type=Path, default=Path(__file__).resolve().parent.parent
+ )
+ parser.add_argument(
+ "--archive",
+ type=Path,
+ help="Test an already-built release archive instead of a Meson install",
+ )
+ parser.add_argument("--output", type=Path)
+ parser.add_argument("--matrix", action="store_true", help="Run isolated portability stress cases")
+ parser.add_argument("--case", choices=["desktop-sf", "laptop-fallback", "small-fallback",
+ "large-text", "hidpi-fallback", "hidpi-large-text"], help="Select one matrix case")
+ parser.add_argument(
+ "--scale",
+ type=int,
+ choices=[1, 2],
+ default=1,
+ help="Private virtual-monitor scale",
+ )
+ parser.add_argument(
+ "--text-scale",
+ type=float,
+ choices=[1.0, 1.25],
+ default=1.0,
+ help="Accessibility text scale",
+ )
+ args = parser.parse_args()
+ if args.case and not args.matrix:
+ parser.error("--case requires --matrix")
+ if args.matrix:
+ output = args.output.resolve() if args.output else Path(tempfile.mkdtemp(prefix="usagebeam-stress-"))
+ raise SystemExit(0 if matrix(args.source.resolve(), args.archive.resolve() if args.archive else None, output, args.case) else 1)
+ smoke(
+ args.source.resolve(),
+ args.archive.resolve() if args.archive else None,
+ args.output.resolve()
+ if args.output
+ else Path(tempfile.mkdtemp(prefix="usagebeam-shell-")),
+ args.scale,
+ args.text_scale,
+ )