From fb370bc37e9a2b96e17d213869bda0b476604db7 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 09:49:00 +0000
Subject: [PATCH 01/44] feat: add accessibility, system theme support, CI, and
tests
- Accessible names for screen readers on panel indicator, dots, and summaries
- 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)
---
.github/workflows/ci.yml | 35 +++++++++++++++++++++++
CHANGELOG.md | 13 +++++++++
CONTRIBUTING.md | 61 ++++++++++++++++++++++++++++++++++++++++
README.md | 6 ++++
indicator.js | 12 ++++----
meson.build | 2 +-
metadata.json | 2 +-
stylesheet.css | 13 ++++++---
tests/freeby.bats | 42 +++++++++++++++++++++++++++
9 files changed, 174 insertions(+), 12 deletions(-)
create mode 100644 .github/workflows/ci.yml
create mode 100644 CONTRIBUTING.md
create mode 100644 tests/freeby.bats
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7c78902
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: CI
+
+on:
+ push:
+ branches: [main, dev]
+ pull_request:
+ branches: [main, dev]
+
+jobs:
+ shellcheck:
+ 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
+ - name: Install dependencies
+ run: sudo apt-get install -y bats python3 curl
+ - name: Run tests
+ run: bats tests/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67dda60..b0bf72a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## v1.0.1
+
+### 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)
+
+### 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
### Fixed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..d65d184
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,61 @@
+# Contributing
+
+Thanks for your interest in Freeby.
+
+## Development
+
+Clone and install locally:
+
+```bash
+git clone https://github.com/kcnewman/freeby.git && cd freeby
+git checkout dev
+meson setup build --prefix=$HOME/.local
+meson install -C build
+```
+
+Test your changes by restarting GNOME Shell (Alt+F2, type `r`, Enter) and enabling the extension:
+
+```bash
+gnome-extensions enable freeby@kelvin.local
+```
+
+## 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
+
+Test the shell script directly:
+
+```bash
+bash ~/.local/bin/freeby.sh | python3 -m json.tool
+```
+
+Run shellcheck:
+
+```bash
+shellcheck scripts/freeby.sh scripts/copilot-setup.sh
+```
+
+## 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 43929f5..9898fd6 100644
--- a/README.md
+++ b/README.md
@@ -111,4 +111,10 @@ journalctl -f -o cat /usr/bin/gnome-shell
---
+## Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
+
+---
+
[MIT](LICENSE)
diff --git a/indicator.js b/indicator.js
index f323d71..ac688b5 100644
--- a/indicator.js
+++ b/indicator.js
@@ -21,18 +21,18 @@ class FreebyIndicator extends PanelMenu.Button {
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' });
- this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label' });
+ 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' });
- const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER });
+ 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 });
+ 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);
@@ -43,7 +43,7 @@ class FreebyIndicator extends PanelMenu.Button {
}
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
- this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false });
+ this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false, accessible_name: 'Refresh status' });
this._statusItem.label.add_style_class_name('freeby-status');
this._statusItem.connect('activate', () => { this._refresh(); this.menu.open(); });
this.menu.addMenuItem(this._statusItem);
diff --git a/meson.build b/meson.build
index 8e46ec0..22d6b06 100644
--- a/meson.build
+++ b/meson.build
@@ -1,4 +1,4 @@
-project('freeby', version: '1.0.0', license: 'MIT',
+project('freeby', version: '1.0.1', license: 'MIT',
meson_version: '>= 0.56.0',
default_options: ['warning_level=0'])
diff --git a/metadata.json b/metadata.json
index f8ccf99..5a8b29a 100644
--- a/metadata.json
+++ b/metadata.json
@@ -3,7 +3,7 @@
"name": "Freeby",
"description": "Minimal panel indicator for local AI coding tool usage (Codex, Cursor, Copilot)",
"shell-version": ["45", "46", "47", "48"],
- "version": 1,
+ "version": 2,
"settings-schema": "org.gnome.shell.extensions.freeby",
"url": "https://github.com/kcnewman/freeby"
}
diff --git a/stylesheet.css b/stylesheet.css
index 0fde099..ac2912b 100644
--- a/stylesheet.css
+++ b/stylesheet.css
@@ -6,6 +6,7 @@
font-weight: bold;
font-size: 10pt;
min-width: 16px;
+ color: currentColor;
}
.freeby-panel-green {
@@ -30,7 +31,8 @@
}
.freeby-dot-off {
- color: #888;
+ color: currentColor;
+ opacity: 0.4;
}
.freeby-dot-green {
@@ -49,15 +51,17 @@
font-weight: bold;
font-size: 9pt;
min-width: 52px;
+ color: currentColor;
}
.freeby-item-summary {
font-size: 9pt;
- color: #ccc;
+ color: currentColor;
+ opacity: 0.8;
}
.freeby-dim {
- color: #888;
+ opacity: 0.4;
}
.freeby-green {
@@ -74,5 +78,6 @@
.freeby-status {
font-size: 9pt;
- color: #bbb;
+ color: currentColor;
+ opacity: 0.7;
}
diff --git a/tests/freeby.bats b/tests/freeby.bats
new file mode 100644
index 0000000..b092960
--- /dev/null
+++ b/tests/freeby.bats
@@ -0,0 +1,42 @@
+#!/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" ]
+}
From caa0346212acaba9417959ab5211380df3d1e32f Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 09:52:27 +0000
Subject: [PATCH 02/44] fix: resolve shellcheck SC2002 (useless cat)
---
scripts/freeby.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/freeby.sh b/scripts/freeby.sh
index 0a9e53a..4da906b 100755
--- a/scripts/freeby.sh
+++ b/scripts/freeby.sh
@@ -113,7 +113,7 @@ fetch_copilot() {
token="$(gh auth token 2>/dev/null | tr -d '[:space:]')"
fi
if [ -z "$token" ] && [ -f "$HOME/.config/freeby/copilot-token" ]; then
- token="$(cat "$HOME/.config/freeby/copilot-token" 2>/dev/null | tr -d '[:space:]')"
+ 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
From adfd377d567ba8283d8470caca37e39cba0f9f4c Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 09:55:00 +0000
Subject: [PATCH 03/44] docs: rewrite README and update CONTRIBUTING for v1.0.1
---
CONTRIBUTING.md | 42 ++++++++++++++++-----
README.md | 98 +++++++++++++++++++++++++++++--------------------
2 files changed, 90 insertions(+), 50 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d65d184..27606ca 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,8 +4,6 @@ Thanks for your interest in Freeby.
## Development
-Clone and install locally:
-
```bash
git clone https://github.com/kcnewman/freeby.git && cd freeby
git checkout dev
@@ -13,12 +11,35 @@ meson setup build --prefix=$HOME/.local
meson install -C build
```
-Test your changes by restarting GNOME Shell (Alt+F2, type `r`, Enter) and enabling the extension:
+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.
@@ -36,18 +57,19 @@ Use [conventional commits](https://www.conventionalcommits.org/):
## Testing
-Test the shell script directly:
-
```bash
-bash ~/.local/bin/freeby.sh | python3 -m json.tool
-```
-
-Run shellcheck:
+# test the data script
+bash scripts/freeby.sh | python3 -m json.tool
-```bash
+# 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
diff --git a/README.md b/README.md
index 9898fd6..8457680 100644
--- a/README.md
+++ b/README.md
@@ -1,62 +1,75 @@
-# Freeby
-
-> GNOME Shell extension that tracks remaining usage across your free-tier AI coding tools.
-
-
-
-  |
-  |
-
-
+
+
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
-- **Fast** — all providers queried in parallel (~2s)
-- **Configurable** — adjust refresh interval and notifications in settings
+| | Feature | Description |
+|---|---|---|
+|  | **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 |
---
-### Providers
+### Supported providers
-| | Provider | Source |
-|---|---|---|
-|  | **Codex** | `~/.codex/auth.json` → `codex-check` CLI |
-|  | **Cursor** | `~/.config/cursor/auth.json` → Cursor API |
-|  | **Copilot** | `gh` CLI → GitHub API |
+| | 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 |
---
-### Prerequisites
+### Install
+
+**Prerequisites**
- GNOME Shell 45+ (Wayland or X11)
-- `python3`
-- `curl`
-- `meson` and `ninja-build` (for building)
-- `glib-compile-schemas` (usually pre-installed)
-- `npx` (for Codex usage tracking)
-- `gh` CLI (for Copilot usage tracking, optional)
+- `python3`, `curl`, `meson`, `ninja-build`
+- `npx` (for Codex)
+- `gh` CLI (for Copilot, optional)
-Install build dependencies on Fedora:
+
+Fedora
```bash
sudo dnf install meson ninja-build python3 curl glib2-devel
```
+
-On Ubuntu/Debian:
+
+Ubuntu / Debian
```bash
sudo apt install meson ninja-build python3 curl libglib2.0-dev-bin
```
+
----
-
-### Install
+**Build and install**
```bash
git clone https://github.com/kcnewman/freeby.git && cd freeby
@@ -64,7 +77,7 @@ meson setup build --prefix=$HOME/.local
meson install -C build
```
-Restart your session, then:
+Then restart your session and enable:
```bash
gnome-extensions enable freeby@kelvin.local
@@ -76,10 +89,10 @@ gnome-extensions enable freeby@kelvin.local
### Settings
-| Setting | Default | |
-|---|---|---|
-| Refresh interval | `120s` | min 30s |
-| Notifications | `on` | alerts on limit hit |
+| Setting | Default | Range | Description |
+|---|---|---|---|
+| Refresh interval | `120s` | 30–3600s | How often to check usage |
+| Notifications | `on` | — | Alert when a provider hits its limit |
Configure via Extension Manager or CLI:
@@ -105,16 +118,21 @@ rm -f ~/.local/share/glib-2.0/schemas/gschemas.compiled
### Debug
```bash
+# test the data script
bash ~/.local/bin/freeby.sh | python3 -m json.tool
+
+# watch extension logs
journalctl -f -o cat /usr/bin/gnome-shell
```
---
-## Contributing
+### Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
---
+### License
+
[MIT](LICENSE)
From 10f39f2ccbdcc4b8bd33ba6a82577043c04ec35d Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 09:56:23 +0000
Subject: [PATCH 04/44] fix: replace removed messageTray.get_source for GNOME
50
---
indicator.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/indicator.js b/indicator.js
index ac688b5..9f0eb5c 100644
--- a/indicator.js
+++ b/indicator.js
@@ -77,8 +77,8 @@ class FreebyIndicator extends PanelMenu.Button {
_notify(title, body) {
if (!this._settings.get_boolean('notifications-enabled')) return;
try {
- let src = Main.messageTray.get_source('Freeby');
- if (!src) { src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic'); Main.messageTray.add(src); }
+ 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'); }
}
From 820b28e8d50367805900eaec2827a6400383d51c Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:12:41 +0000
Subject: [PATCH 05/44] docs: fix broken image links with inline SVG data URIs
---
README.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 8457680..f57a5cf 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
| | Feature | Description |
|---|---|---|
-|  | **Panel indicator** | Colored `ai·N` shows available providers at a glance |
+|
| **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 |
@@ -38,9 +38,9 @@
| | 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 |
+|
| **Codex** | `~/.codex/auth.json` | `codex-check` CLI |
+|
| **Cursor** | `~/.config/cursor/auth.json` | Cursor API |
+|
| **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API |
---
From bad080694e122b54171267018a1785171ab92ec4 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:14:27 +0000
Subject: [PATCH 06/44] docs: simplify tables, use emoji for provider colors
---
README.md | 28 +++++++++++++---------------
1 file changed, 13 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index f57a5cf..3e42e07 100644
--- a/README.md
+++ b/README.md
@@ -21,26 +21,24 @@
### Features
-| | Feature | Description |
-|---|---|---|
-|
| **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 |
+- **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 |
+| 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 |
---
From e85ee4feae1b3db19eafbc903c9addbe4e7ff254 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:22:37 +0000
Subject: [PATCH 07/44] docs: remove dividers for seamless flow
---
README.md | 16 ----------------
1 file changed, 16 deletions(-)
diff --git a/README.md b/README.md
index 3e42e07..8ecdf50 100644
--- a/README.md
+++ b/README.md
@@ -17,8 +17,6 @@
Releases
----
-
### Features
- **Panel indicator** — colored `ai·N` shows available providers at a glance
@@ -30,8 +28,6 @@
- **Accessible** — screen reader support for all UI elements
- **Theme-aware** — works with light and dark GNOME themes
----
-
### Supported providers
| Provider | Auth source | Data source |
@@ -40,8 +36,6 @@
| 🟢 **Cursor** | `~/.config/cursor/auth.json` | Cursor API |
| 🟠 **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API |
----
-
### Install
**Prerequisites**
@@ -83,8 +77,6 @@ gnome-extensions enable freeby@kelvin.local
> **Copilot users:** If `gh` isn't installed, run `copilot-setup.sh` first.
----
-
### Settings
| Setting | Default | Range | Description |
@@ -99,8 +91,6 @@ gsettings --schemadir ~/.local/share/glib-2.0/schemas \
set org.gnome.shell.extensions.freeby refresh-interval 60
```
----
-
### Uninstall
```bash
@@ -111,8 +101,6 @@ rm -f ~/.local/share/glib-2.0/schemas/org.gnome.shell.extensions.freeby.gschema.
rm -f ~/.local/share/glib-2.0/schemas/gschemas.compiled
```
----
-
### Debug
```bash
@@ -123,14 +111,10 @@ bash ~/.local/bin/freeby.sh | python3 -m json.tool
journalctl -f -o cat /usr/bin/gnome-shell
```
----
-
### Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
----
-
### License
[MIT](LICENSE)
From c552a4373398630de2299cea3b2c5c5a1e9a413d Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:42:54 +0000
Subject: [PATCH 08/44] fix: add GNOME 50 support and remove deprecated version
field
---
metadata.json | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/metadata.json b/metadata.json
index 5a8b29a..f131767 100644
--- a/metadata.json
+++ b/metadata.json
@@ -2,8 +2,7 @@
"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"],
- "version": 2,
+ "shell-version": ["45", "46", "47", "48", "49", "50"],
"settings-schema": "org.gnome.shell.extensions.freeby",
"url": "https://github.com/kcnewman/freeby"
}
From 095efaf8d6e45103763aad46ff068a95f4f2271f Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:48:35 +0000
Subject: [PATCH 09/44] fix: remove invalid accessible_name from PopupMenuItem
---
indicator.js | 244 +++++++++++++++++++++++++--------------------------
1 file changed, 122 insertions(+), 122 deletions(-)
diff --git a/indicator.js b/indicator.js
index 9f0eb5c..3ad2559 100644
--- a/indicator.js
+++ b/indicator.js
@@ -13,128 +13,128 @@ 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 };
+ 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(); this.menu.open(); });
+ this.menu.addMenuItem(this._statusItem);
+
+ this._timerId = null;
+ this._setupTwimer();
+ this._refresh();
+ this._monitorWake();
}
- this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
- this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false, accessible_name: 'Refresh status' });
- this._statusItem.label.add_style_class_name('freeby-status');
- this._statusItem.connect('activate', () => { this._refresh(); this.menu.open(); });
- 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'; }
+ _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; });
}
- 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();
- }
-});
+
+ _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();
+ }
+ });
From d2c2d8624d19dffa18ab54de03fa42dda5ef14f4 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:54:18 +0000
Subject: [PATCH 10/44] fix: keep dropdown open when clicking refresh
---
indicator.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/indicator.js b/indicator.js
index 3ad2559..b2aaa8a 100644
--- a/indicator.js
+++ b/indicator.js
@@ -45,11 +45,11 @@ export const FreebyIndicator = GObject.registerClass(
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(); this.menu.open(); });
+ 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._setupTwimer();
+ this._setupTimer();
this._refresh();
this._monitorWake();
}
From 822d1636a7acc0c75a47ebfc663ed1428979bd4b Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 26 Jul 2026 10:55:43 +0000
Subject: [PATCH 11/44] release: v1.0.2
---
CHANGELOG.md | 8 ++++++++
meson.build | 2 +-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0bf72a..5c3f947 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## v1.0.2
+
+### 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
### Added
diff --git a/meson.build b/meson.build
index 22d6b06..6af6f8f 100644
--- a/meson.build
+++ b/meson.build
@@ -1,4 +1,4 @@
-project('freeby', version: '1.0.1', license: 'MIT',
+project('freeby', version: '1.0.2', license: 'MIT',
meson_version: '>= 0.56.0',
default_options: ['warning_level=0'])
From 8416f323bcb291718b2b99a133160443e5d8568c Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 6 Sep 2026 01:33:10 +0000
Subject: [PATCH 12/44] docs: record usage monitoring roadmap and project rules
---
AGENTS.md | 43 ++++++++
docs/planning/v1-notes.md | 153 +++++++++++++++++++++++++++
plan.md | 210 ++++++++++++++++++++++++++++++++++++++
3 files changed, 406 insertions(+)
create mode 100644 AGENTS.md
create mode 100644 docs/planning/v1-notes.md
create mode 100644 plan.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..71bf8c9
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,43 @@
+# Project guidance
+
+Applies to the whole repository. Read [plan.md](plan.md) and [CONTRIBUTING.md](CONTRIBUTING.md) before implementation.
+
+## Product priorities
+
+- Build a reliable native GNOME usage monitor for Codex, Claude Code, Cursor, and Copilot, including free and paid plans.
+- Complete usage monitoring before cost breakdowns, estimates, budgets, or billing features.
+- Keep the Freeby name, icon, extension UUID, and settings schema identity during the usage milestones. Defer rebranding until the system works.
+- Provide preferences for panel position, providers, refresh behavior, notifications, and history as scheduled in the plan.
+- Use `reference-images/` for layout inspiration; adapt to GNOME themes, keyboard access, screen readers, and display scaling.
+
+## Architecture and correctness
+
+- Keep GNOME entry points thin; separate provider adapters, usage logic, collection services, UI, and preferences.
+- Prefer GJS, Gio/GLib, and native GNOME APIs. Use Meson for installation and packaging; keep development dependencies out of the runtime archive.
+- Replace the legacy Bash/embedded-Python collector incrementally. Do not introduce runtime package downloads or unpinned `npx` execution.
+- Keep expensive history scans out of the Shell UI process. Bound subprocess lifetimes, prevent overlapping refreshes, and cancel work on disable.
+- Store numeric metrics, units, periods, capabilities, source scope, and freshness explicitly. Never infer state from display text.
+- Unknown, unsupported, stale, unauthenticated, and exhausted are distinct states. An empty response is not zero usage.
+- Keep account-wide limits separate from local activity; never infer subscription usage from token totals or add overlapping data sources.
+- Handle cumulative counters, repeated events, partial records, timezone boundaries, and provider-specific cache-token semantics.
+- Validate provider APIs and CLI versions against current documentation and fixtures; support partial results without hiding failures.
+- Keep credentials and transcript content out of logs, fixtures, caches, and releases. Persist only required usage metadata in XDG locations.
+
+## Verification
+
+- Add meaningful regression tests for changed behavior. Default automated tests to synthetic or sanitized fixtures without live accounts or network access.
+- While legacy scripts remain, use `bash -n scripts/freeby.sh scripts/copilot-setup.sh`, `shellcheck scripts/freeby.sh scripts/copilot-setup.sh`, and `bats tests/` as appropriate.
+- Validate schemas with `glib-compile-schemas --strict --dry-run schemas`; use an isolated temporary prefix for build/install checks.
+- Add and document GJS/pure-JavaScript test and lint commands as the new modules land. Do not claim unavailable checks passed.
+- Test only as broadly as the change requires; documentation-only changes need document/link checks, not a live desktop installation.
+- Verify lifecycle, wake handling, accessibility, packaging, and each advertised GNOME version before a release. Label unsupported or unverified combinations honestly.
+
+## Milestones and releases
+
+- Follow the ordered milestones in `plan.md`; keep its checkboxes and evidence current. Historical notes under `docs/planning/` are context, not the active roadmap.
+- Use conventional commits and stage only intentional changes. Preserve unrelated edits, local credentials, and user reference assets.
+- After each completed milestone, commit the tested changes, tag the milestone, and publish an installable GitHub prerelease as agreed with the user.
+- Resolve the intended repository and release branch before publishing. Release artifacts must match the tagged commit and pass the milestone gate.
+- Include installation/upgrade instructions, release notes, verification results, known limitations, and a downgrade path. Verify the published tag and downloadable artifact.
+- If a required check or release operation is blocked, report the exact blocker and leave the milestone incomplete; never label an unfinished milestone released.
+- Planning/documentation setup alone is not a completed product milestone and does not warrant a prerelease.
diff --git a/docs/planning/v1-notes.md b/docs/planning/v1-notes.md
new file mode 100644
index 0000000..38e1e86
--- /dev/null
+++ b/docs/planning/v1-notes.md
@@ -0,0 +1,153 @@
+# Freeby v1 planning archive
+
+Preserved from the original local `plan.md` on 2026-09-06. These are historical
+investigation notes, not verified descriptions of the current implementation.
+Some claims about disabled providers, completed fixes, quota periods, and
+packaging differ from the current code. Revalidate any provider-specific
+assumptions before using them. The active roadmap is [../../plan.md](../../plan.md).
+
+---
+
+# Freeby Plan
+
+## Current state
+
+- **v1.0.2** released and merged to main
+- 2 providers: Codex, Copilot (Cursor disabled — see below)
+- Panel shows `ai·N` (count of providers with remaining quota)
+- Panel text colored green/yellow/red by availability
+- Click panel to refresh
+- Dropdown shows per-provider usage and reset countdown
+- Desktop notifications on limit hit
+- Auto-refresh on wake from sleep
+- Configurable refresh interval (gsettings)
+- Settings UI in Extension Manager
+- Parallel provider fetches (~2s)
+- Architecture: extension.js (entry) + indicator.js (UI) + prefs.js (settings)
+- CI/CD: GitHub Actions (shellcheck, meson build, bats tests)
+- Accessibility: screen reader names, theme-aware CSS
+
+## Known limitations
+
+- Codex: uses `codex-check` CLI (npx), can be slow on first run
+- Codex: limit is monthly (30 days), not 5h as formatted output suggests
+- Codex: uses JSON output (`--json` flag) for reliable parsing
+- Copilot: free tier is very limited (200 chat credits/month)
+- No persistent storage — usage history not tracked
+
+## Cursor investigation (disabled in v1.0.2)
+
+### Problem
+The Cursor API (`GetCurrentPeriodUsage`) only tracks paid plan usage. When `overallLimit == 0` and `remainingBonus == false`, it means:
+- No paid subscription (`overallLimit: 0`)
+- Free bonus tokens exhausted (`remainingBonus: false`)
+
+The API returns `totalPercentUsed: 0` which is "0% of nothing" — misleading.
+
+### API response when no subscription
+```json
+{
+ "planUsage": { "totalPercentUsed": 0, "remainingBonus": false },
+ "spendLimitUsage": { "overallLimit": 0, "pooledLimit": 0 },
+ "displayMessage": "You've used 0% of your included usage"
+}
+```
+
+### Why Cursor CLI shows "get a subscription"
+It correctly detects: no paid plan + exhausted free bonus = needs subscription.
+
+### What the user experienced
+- Used free bonus tokens recently (worked fine)
+- Now bonus is exhausted, Cursor CLI says "get subscription"
+- Extension was showing "0% used, resets in 3d" — misleading
+
+### Decision
+Disable Cursor for now. Re-enable in v2.0.0 with paid subscription detection:
+- `overallLimit > 0` → show usage percentage
+- `overallLimit == 0 && remainingBonus == false` → "no subscription"
+- `overallLimit == 0 && remainingBonus == true` → "free tier, bonus remaining"
+
+## Things to watch out for
+
+### API changes
+- Cursor API is unofficial — could break without notice
+- GitHub Copilot API could change endpoints or auth
+- Codex API format might change in new `codex-check` versions (but JSON output is more stable)
+
+### Auth expiry
+- Copilot tokens via `gh` stay valid as long as `gh auth` is active
+- Copilot tokens from `copilot-setup` may expire after ~30 days
+
+### Rate limiting
+- Script runs every 2 minutes — shouldn't hit rate limits
+- But if user manually refreshes repeatedly, APIs might throttle
+
+### GNOME Shell compatibility
+- Currently targets GNOME 45+ (ESM imports)
+- New Shell versions may deprecate APIs (already fixed GNOME 50 messageTray change)
+- Test on new GNOME releases before claiming support
+
+## Done
+
+- [x] Tooltip on hover — removed (PanelMenu.Button consumes hover events)
+- [x] Color panel text — green/yellow/red based on availability
+- [x] Click to refresh — panel click triggers refresh
+- [x] Notifications — desktop alert on limit hit
+- [x] Auto-refresh on wake — monitors systemd PrepareForSleep
+- [x] Configurable refresh interval — gsettings (min 30s)
+- [x] Settings UI — prefs.js with Extension Manager gear icon
+- [x] Parallel fetches — background processes, ~2s total
+- [x] Accessibility — screen reader names for all UI elements
+- [x] Theme-aware CSS — uses currentColor and opacity
+- [x] CI/CD — GitHub Actions with shellcheck, meson build, bats tests
+- [x] Documentation — README, CONTRIBUTING, CHANGELOG
+- [x] GNOME 50 support — added to shell-version, fixed messageTray API
+- [x] Disable Cursor — free tier unsupported, misleading data
+
+## EGO submission (extensions.gnome.org)
+
+### Blocking issues
+
+1. **Scripts must be written in GJS** — Our `freeby.sh` uses bash + curl + python3. The guidelines say scripts "MUST be written in GJS, unless absolutely necessary." Options:
+ - **A) Rewrite in GJS** — Use Soup.Session for HTTP, native JSON.parse(). Cleanest for review but significant rewrite (~200 lines GJS replacing ~190 lines bash/python). Still needs subprocess for `npx codex-check`.
+ - **B) Keep bash, drop python3** — Replace python3 JSON parsing with grep/sed/awk. Removes one dependency but adds ~150 lines of fragile bash. Strengthens "scripts are necessary" argument.
+ - **C) Keep as-is** — Argue bash is necessary because we call system tools (npx, curl). Risky, may be rejected.
+
+2. **Unnecessary files** — EGO zip should only contain extension files. Currently ships tests/, .github/, CONTRIBUTING.md, CHANGELOG.md, meson.build, build-aux/.
+
+### Non-blocking fixes needed
+
+3. **Remove `version` from metadata.json** — Deprecated for EGO (they set it).
+
+4. **Disconnect button-press-event handler** — `this.connect('button-press-event', ...)` in indicator.js not disconnected in destroy().
+
+5. **Cancel subprocess in disable** — If disable called while refresh in-flight, async callback could fire after cleanup.
+
+### Nice-to-have
+
+6. ESLint for code style consistency
+7. UI following GNOME HIG more closely
+
+## Future features (v2.0.0+)
+
+### Cursor paid subscription support
+- Re-enable Cursor provider
+- Detect paid subscription via `overallLimit > 0`
+- Show usage percentage for paid users
+- Show "no subscription" for free users with exhausted bonus
+- Track `remainingBonus` for free tier status
+
+### Larger features
+- **Historical usage graph** — store daily snapshots and show a small sparkline in the dropdown
+- **More providers** — Windsurf, Gemini, Groq, etc.
+- **Flatpak support** — detect if tools are installed via Flatpak (different config paths)
+- **Custom provider support** — user-added APIs via config
+
+## Release checklist
+
+- [ ] Update version in metadata.json
+- [ ] Test all providers
+- [ ] Verify countdown accuracy
+- [ ] Check panel indicator updates
+- [ ] Take screenshot for README
+- [ ] Create GitHub release with tag
diff --git a/plan.md b/plan.md
new file mode 100644
index 0000000..4f52b15
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,210 @@
+# Freeby usage monitor roadmap
+
+Updated: 2026-09-06. Status: roadmap agreed; implementation milestones have not started.
+
+## Agreed direction
+
+Build a reliable native GNOME usage monitor for **Codex, Claude Code, Cursor,
+and Copilot**, covering free and paid plans. The first stable release is about
+usage: quota windows, reset times, activity history, and model breakdowns where
+the provider exposes them.
+
+Complete and verify the system before introducing a new name or icon. Keep the
+current extension UUID and GSettings schema identity throughout these milestones.
+Cost breakdowns, provider spending/balances, estimates, and budgets are deferred
+to a later phase. Subscription percentages must not be derived from token totals.
+
+Every completed implementation milestone ends with a conventional commit, a
+version tag, and an installable GitHub prerelease. The first stable release must
+cover all four providers; earlier prereleases introduce them incrementally.
+
+## Reference analysis and interface
+
+The [Claude reference](reference-images/1.jpeg) and
+[Codex reference](reference-images/2.png) share this structure:
+
+1. Provider mark, name, and reported plan.
+2. A selector for connected/enabled providers; omit the selector when only one exists.
+3. Independently labeled quota windows with percent-used meters and reset countdowns.
+4. Seven daily token totals, with today emphasized.
+5. Model totals shown as horizontal bars.
+
+Adapt that hierarchy to GNOME using native St/Clutter widgets, theme-aware colors,
+aligned/tabular numerals, restrained separators, visible focus, and accessible
+labels. Add a footer with last-successful-update time, refresh, and preferences.
+Limit popup height to the monitor work area and scroll content when needed.
+
+- Quota bars use a fixed 0–100% scale. Activity bars scale against the largest value in the displayed period.
+- Daily and model breakdowns must clearly state their period and whether the source is local or account-wide.
+- Show data-supported sections only; keep connection errors and recovery guidance visible.
+- Distinguish zero activity from unavailable history. Never fabricate models, plans, usage, or historical coverage.
+- Show cached data immediately while independently refreshing providers. Mark stale sections with their timestamps.
+- Show a useful setup state when no provider is connected. Do not silently hide the extension after a failure.
+
+## Architecture
+
+Keep a thin native GNOME extension with modular GJS collection and a versioned
+usage contract. Provider code must not manipulate UI actors. UI code must not
+parse provider transcripts or depend on provider endpoint response shapes.
+Expensive history work belongs in a managed GJS subprocess, not the Shell UI
+process. Avoid a persistent background daemon in this first iteration.
+
+```text
+freeby/
+├── AGENTS.md
+├── plan.md
+├── extension.js # GNOME lifecycle entry
+├── prefs.js # Preferences entry
+├── metadata.json
+├── stylesheet.css
+├── src/
+│ ├── core/ # Contract, validation, aggregation, formatting
+│ ├── providers/ # Codex, Claude, Cursor, Copilot adapters
+│ ├── services/ # Scheduling, cache, processes, notifications
+│ ├── collector/ # Managed GJS collection entry
+│ ├── ui/ # Indicator, selector, meters, charts
+│ └── preferences/ # Configuration pages
+├── schemas/
+├── assets/
+├── tests/
+│ ├── unit/
+│ ├── integration/
+│ └── fixtures/
+├── tools/ # Development and packaging commands
+├── docs/ # Architecture, provider contracts, release guide
+├── reference-images/ # Development references, excluded from archives
+├── meson.build
+└── .github/workflows/
+```
+
+This is a target layout, not a claim that these modules already exist. Move code
+in reviewable steps and preserve existing behavior until its replacement is
+verified. Keep transitional scripts only while needed; do not ship unused code.
+
+### Usage contract
+
+- Include a schema version, stable provider identity, optional account/plan identity, and explicit capabilities.
+- Represent each quota window with its unit, reported usage/limit or percentage, duration, reset timestamp, and state.
+- Represent activity with dated totals and model aggregates, including provider-specific input/output/cache semantics.
+- Track source, account/local scope, measurement period, coverage, and last-successful-update timestamps.
+- Track limits and history status independently so local history remains useful when an account endpoint fails.
+- Distinguish ready, missing authentication, unsupported data, unavailable data, stale data, and exhausted quota.
+- Leave unknown values absent/null; never coerce an empty response to zero or an unlimited allowance to exhaustion.
+
+### Collection and storage
+
+- Prefer documented provider interfaces and existing supported CLI authentication; verify versions and account eligibility.
+- For Codex, evaluate app-server account/rate-limit reads and available account-usage reads; probe compatibility before using optional methods.
+- Use local usage records for supported history/model data; retain explicit scope when account-wide activity is also available.
+- Validate Claude, Cursor, and Copilot sources independently. Preserve their real units and advertise only verified capabilities.
+- Replace automatic `npx` downloads and the hardcoded helper path with packaged, extension-relative resources.
+- Give each provider a deadline, bounded retries/backoff, and independent success/failure handling.
+- Parse changed records incrementally; handle repeated events, cumulative counters, partial writes, rotation, and timezone boundaries.
+- Keep private usage metadata in XDG state/cache locations using atomic writes, bounded retention, and schema migrations.
+- Do not retain credentials, prompts, responses, or complete transcripts in Freeby's history or diagnostics.
+
+## Known regressions to address
+
+The preceding repository analysis identified these issues; add focused regression
+coverage during the foundation work:
+
+- Notification construction uses the tray instance as a constructor namespace.
+- The sleep signal condition refreshes before suspend rather than on resume.
+- An in-flight refresh can apply data after the indicator is destroyed.
+- Repeated refreshes can leave overlapping collection processes.
+- Empty Codex/Cursor objects can display successful zero usage.
+- Summary-text matching can show an exhausted Cursor quota as a green row.
+- Initial/error states can trigger incorrect quota-limit notifications.
+- The Codex `npx` path has no explicit collection deadline.
+- The uninstall instructions remove the shared compiled schema file instead of recompiling remaining schemas.
+
+## Implementation milestones
+
+### 1. Foundation and Codex — v2.0.0-alpha.1
+
+- [ ] Establish the target module boundaries, versioned contract, development commands, and fixture-based tests.
+- [ ] Repair the known lifecycle, notification, quota-state, and install/uninstall regressions.
+- [ ] Implement bounded Codex collection, cached results, available quota windows, daily activity, and model totals.
+- [ ] Build the native provider header, quota meters, activity/model views, status footer, and loading/error/setup states.
+- [ ] Keep existing provider behavior working until deliberately migrated; document any verified data limitations.
+- [ ] Verify the complete Codex path, temporary installation, disable/re-enable cleanup, and offline/partial behavior.
+- [ ] Commit, tag, publish, and verify the installable alpha.1 prerelease.
+
+### 2. Claude Code — v2.0.0-alpha.2
+
+- [ ] Implement Claude detection, supported authentication/limits, and incremental usage history.
+- [ ] Add provider switching and independent partial-data handling using the shared contract.
+- [ ] Verify quota windows, daily/model totals, duplicate handling, missing credentials, and expired authentication.
+- [ ] Commit, tag, publish, and verify the installable alpha.2 prerelease.
+
+### 3. Cursor — v2.0.0-alpha.3
+
+- [ ] Revalidate current Cursor authentication and usage sources for free and paid accounts.
+- [ ] Handle paid allowance, remaining bonus, absent subscription, and unsupported metrics without fabricated percentages.
+- [ ] Add all verified usage capabilities to the shared interface and document integration limits.
+- [ ] Verify fixtures for empty responses, expired authentication, inaccessible endpoints, and quota exhaustion.
+- [ ] Commit, tag, publish, and verify the installable alpha.3 prerelease.
+
+### 4. Copilot — v2.0.0-alpha.4
+
+- [ ] Revalidate current Copilot authentication, entitlement data, and usage units for supported account types.
+- [ ] Implement usage collection and available history/model metrics without assuming every account exposes them.
+- [ ] Keep request counts, tokens, allowances, and unknown/unlimited states distinct.
+- [ ] Verify all four providers together, including partial outages and missing integrations.
+- [ ] Commit, tag, publish, and verify the installable alpha.4 prerelease.
+
+### 5. Preferences and interface polish — v2.0.0-alpha.5
+
+- [ ] Expand the existing preferences entry into organized native libadwaita pages.
+- [ ] Add left/center/right panel placement and ordering within the selected panel area, with immediate updates.
+- [ ] Add provider enablement/order, default provider, refresh controls, and connection diagnostics.
+- [ ] Add notification thresholds, deduplication per account/window, and history retention/clear controls.
+- [ ] Preserve valid existing settings and document new defaults and migrations.
+- [ ] Verify keyboard navigation, screen-reader labels, dark/light themes, long content, scrolling, and display scaling.
+- [ ] Commit, tag, publish, and verify the installable alpha.5 prerelease using the existing name and icon.
+
+### 6. Stabilization — v2.0.0-beta.1, then v2.0.0
+
+- [ ] Validate supported GNOME versions; keep metadata aligned with tested compatibility.
+- [ ] Verify history accuracy, bounded collection, responsiveness, and all four provider capability declarations.
+- [ ] Test clean install, upgrade, disable/re-enable, uninstall, and downgrade with an isolated installation prefix.
+- [ ] Finalize README, setup/troubleshooting, architecture/provider docs, screenshots, and release instructions.
+- [ ] Commit, tag, publish, and verify beta.1; issue further beta prereleases when feedback requires changes.
+- [ ] Resolve release-blocking feedback and publish the first stable usage-monitoring release.
+
+## Verification and release gate
+
+Every milestone requires applicable lint/syntax checks, deterministic unit and
+integration tests, strict schema validation, and a packaged-install smoke test.
+GUI/lifecycle changes also require relevant GNOME desktop checks. Live provider
+verification is separate from credential-free CI and must identify which account
+types and client versions were actually tested.
+
+1. Confirm the intended repository and release branch; preserve unrelated worktree changes.
+2. Update this plan, the changelog, and the project release version. Do not use the deprecated metadata `version` field as the release source of truth.
+3. Complete the milestone checks and record results, remaining limitations, and any unavailable checks.
+4. Commit only the intended, tested milestone changes using conventional commits.
+5. Tag the commit and build the downloadable extension archive from that exact revision. Exclude tests, development tools, references, and credentials from the runtime archive.
+6. Publish the GitHub release as a prerelease for alpha/beta tags, with installation/upgrade instructions, verification evidence, known limitations, and a downgrade path.
+7. Verify the remote tag, release status, downloadable artifact, and archive contents; record the tag/commit/release link here.
+
+Do not mark a milestone complete until its code, checks, commit, and prerelease
+are complete. A blocked required check or publication remains explicitly open.
+Documentation setup is preparatory work and does not trigger a product prerelease.
+
+## After usage monitoring is reliable
+
+- Decide the new name and icon direction, then plan branding and any required identity/settings migration.
+- Design cost breakdowns separately, distinguishing provider-reported charges from estimates and including currency, period, and pricing provenance.
+- Consider budgets, additional providers, multiple accounts, cross-device history, and a separate dashboard only as later scoped work.
+
+## References and historical context
+
+- [Omarchy Agents architecture and panel behavior](https://github.com/omacom/omarchy/blob/quattro/shell/plugins/agents/README.md)
+- [Codex app-server documentation](https://learn.chatgpt.com/docs/app-server)
+- [GNOME extension review guidelines](https://gjs.guide/extensions/review-guidelines/review-guidelines.html)
+- [Archived v1 planning notes](docs/planning/v1-notes.md), including a Cursor no-subscription investigation that requires revalidation.
+
+Sources describe the state inspected during planning; verify them again before
+depending on a provider interface or publishing compatibility claims. If upstream
+code or assets are reused, preserve the applicable license and attribution.
From a280d8b33e7c2bb0d09982055832342f6a63d3b9 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 6 Sep 2026 08:17:08 +0000
Subject: [PATCH 13/44] feat: add Codex usage monitoring foundation
---
.editorconfig | 12 ++
.github/workflows/ci.yml | 38 ++---
extension.js | 19 ++-
meson.build | 11 +-
metadata.json | 4 +-
package.json | 16 ++
....gnome.shell.extensions.freeby.gschema.xml | 19 +++
src/collector/main.js | 65 ++++++++
src/core/format.js | 31 ++++
src/core/notifications.js | 24 +++
src/core/usage.js | 140 ++++++++++++++++
src/providers/codex.js | 88 ++++++++++
src/providers/legacy.js | 45 +++++
src/services/files.js | 47 ++++++
src/services/history.js | 135 +++++++++++++++
src/services/http.js | 21 +++
src/services/process.js | 137 +++++++++++++++
src/services/usageService.js | 115 +++++++++++++
src/ui/indicator.js | 156 ++++++++++++++++++
src/ui/widgets.js | 35 ++++
stylesheet.css | 108 +++---------
tests/integration/collector.js | 65 ++++++++
tests/unit/codex.test.js | 52 ++++++
tests/unit/usage.test.js | 82 +++++++++
tools/check.js | 27 +++
tools/package.py | 42 +++++
tools/smoke-shell.py | 95 +++++++++++
27 files changed, 1512 insertions(+), 117 deletions(-)
create mode 100644 .editorconfig
create mode 100644 package.json
create mode 100644 src/collector/main.js
create mode 100644 src/core/format.js
create mode 100644 src/core/notifications.js
create mode 100644 src/core/usage.js
create mode 100644 src/providers/codex.js
create mode 100644 src/providers/legacy.js
create mode 100644 src/services/files.js
create mode 100644 src/services/history.js
create mode 100644 src/services/http.js
create mode 100644 src/services/process.js
create mode 100644 src/services/usageService.js
create mode 100644 src/ui/indicator.js
create mode 100644 src/ui/widgets.js
create mode 100644 tests/integration/collector.js
create mode 100644 tests/unit/codex.test.js
create mode 100644 tests/unit/usage.test.js
create mode 100644 tools/check.js
create mode 100644 tools/package.py
create mode 100644 tools/smoke-shell.py
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..202ad28 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,29 +7,23 @@ on:
branches: [main, dev]
jobs:
- shellcheck:
- 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:
+ verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ 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: Verify isolated install
+ run: |
+ meson setup build --prefix="$RUNNER_TEMP/freeby-install"
+ meson install -C build
+ test -f "$RUNNER_TEMP/freeby-install/share/gnome-shell/extensions/freeby@kelvin.local/schemas/gschemas.compiled"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: freeby-extension
+ path: dist/*.zip
diff --git a/extension.js b/extension.js
index 00aae09..1bbc5e7 100644
--- a/extension.js
+++ b/extension.js
@@ -1,16 +1,29 @@
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 {FreebyIndicator} from './src/ui/indicator.js';
+import {UsageService} from './src/services/usageService.js';
export default class FreebyExtension 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);
+ this._indicator = new FreebyIndicator(this._settings, () => this.openPreferences());
Main.panel.addToStatusArea(this.uuid, this._indicator);
+ this._service = new UsageService(this._settings, this.path, () => this._indicator?.render(), alerts => {
+ for (const alert of alerts)
+ Main.notify('Freeby usage alert', `${alert.provider}: ${alert.label} ${alert.threshold === 100 ? 'limit reached' : `reached ${alert.threshold}%`}.`);
+ });
+ this._indicator.attach(this._service);
+ this._settingsId = this._settings.connect('changed', () => {
+ this._service.configure();
+ this._indicator.render();
+ this._service.refreshAll();
+ });
+ this._service.refreshAll();
}
disable() {
if (this._settingsId) { this._settings.disconnect(this._settingsId); this._settingsId = null; }
+ this._service?.destroy();
+ this._service = null;
this._indicator?.destroy();
this._indicator = null;
this._settings = null;
diff --git a/meson.build b/meson.build
index 6af6f8f..6c0968f 100644
--- a/meson.build
+++ b/meson.build
@@ -1,19 +1,16 @@
-project('freeby', version: '1.0.2', license: 'MIT',
+project('freeby', version: '2.0.0-alpha.1', license: 'MIT',
meson_version: '>= 0.56.0',
default_options: ['warning_level=0'])
uuid = 'freeby@kelvin.local'
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_data('schemas/org.gnome.shell.extensions.freeby.gschema.xml',
install_dir: schemadir)
diff --git a/metadata.json b/metadata.json
index f131767..6febcdd 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"],
+ "description": "AI coding usage monitor for account limits, reset windows, local token activity and model breakdowns. Codex is verified; additional providers arrive through version 2 prereleases.",
+ "shell-version": ["50"],
"settings-schema": "org.gnome.shell.extensions.freeby",
"url": "https://github.com/kcnewman/freeby"
}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..efe5214
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "freeby",
+ "version": "2.0.0-alpha.1",
+ "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",
+ "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/schemas/org.gnome.shell.extensions.freeby.gschema.xml b/schemas/org.gnome.shell.extensions.freeby.gschema.xml
index 4af8f75..982981b 100644
--- a/schemas/org.gnome.shell.extensions.freeby.gschema.xml
+++ b/schemas/org.gnome.shell.extensions.freeby.gschema.xml
@@ -13,5 +13,24 @@
Enable notifications
Show desktop notifications when a provider hits its limit
+
+ ['codex']
+ Enabled providers in display order
+
+
+ 'codex'
+ Selected usage provider
+
+
+ 30
+
+ Days of usage metadata to retain
+
+
+ 90
+
+ Usage percentage that triggers a warning
+
+
diff --git a/src/collector/main.js b/src/collector/main.js
new file mode 100644
index 0000000..fd7b87a
--- /dev/null
+++ b/src/collector/main.js
@@ -0,0 +1,65 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import GLibUnix from 'gi://GLibUnix';
+import System from 'system';
+import {record, section} from '../core/usage.js';
+import {collectCodex} from '../providers/codex.js';
+import {collectLegacy} from '../providers/legacy.js';
+import {fingerprint, findCommand, join, readJson} from '../services/files.js';
+import {scanHistory} from '../services/history.js';
+import {requestJson} from '../services/http.js';
+import {RpcClient, runCommand} from '../services/process.js';
+
+const id = ARGV[0];
+if (!['codex', 'cursor', 'copilot'].includes(id)) {
+ printerr('Usage: gjs -m src/collector/main.js [retention-days]');
+ System.exit(2);
+}
+const retention = Math.max(7, Math.min(90, Number(ARGV[1]) || 30));
+const cancellable = new Gio.Cancellable();
+const loop = new GLib.MainLoop(null, false);
+const signal = GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, 15, () => {
+ cancellable.cancel();
+ return GLib.SOURCE_CONTINUE;
+});
+const io = {
+ fingerprint,
+ scan(provider, suffixes, parser) {
+ const root = GLib.getenv('CODEX_HOME') || join(GLib.get_home_dir(), '.codex');
+ return scanHistory(provider, suffixes.map(suffix => join(root, suffix)), parser, {retention});
+ },
+ codexClient: () => new RpcClient([findCommand('codex'), 'app-server'], cancellable),
+ http: (url, options) => requestJson(url, {...options, cancellable}),
+ async token(provider) {
+ if (provider === 'cursor') {
+ const auth = readJson(join(GLib.get_user_config_dir(), 'cursor', 'auth.json'));
+ return auth?.accessToken || null;
+ }
+ try {
+ const value = await runCommand([findCommand('gh'), 'auth', 'token'], {timeout: 5000, cancellable});
+ if (value.trim())
+ return value.trim();
+ } catch { /* Standalone setup token is also supported. */ }
+ try {
+ const [, bytes] = Gio.File.new_for_path(join(GLib.get_user_config_dir(), 'freeby', 'copilot-token')).load_contents(null);
+ return new TextDecoder().decode(bytes).trim();
+ } catch { return null; }
+ },
+};
+
+(async () => {
+ try {
+ const result = id === 'codex' ? await collectCodex(io) : await collectLegacy(id, io);
+ 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.')};
+ if (!cancellable.is_cancelled())
+ print(JSON.stringify(result));
+ } finally {
+ 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..6c889aa
--- /dev/null
+++ b/src/core/format.js
@@ -0,0 +1,31 @@
+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 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 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';
+ return minutes < 60 ? `Updated ${minutes}m ago` : `Updated ${Math.floor(minutes / 60)}h ago`;
+}
diff --git a/src/core/notifications.js b/src/core/notifications.js
new file mode 100644
index 0000000..0ebb512
--- /dev/null
+++ b/src/core/notifications.js
@@ -0,0 +1,24 @@
+export class ThresholdTracker {
+ constructor() {
+ this.previous = new Map();
+ }
+
+ update(record, threshold = 90) {
+ if (record.limits.status !== 'ready')
+ return [];
+ const alerts = [];
+ 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 = window.usedPercent >= 100 ? 100 : window.usedPercent >= threshold ? threshold : 0;
+ if (previous !== undefined && reached > previous)
+ alerts.push({provider: record.name, label: window.label, threshold: reached});
+ this.previous.set(key, Math.max(previous ?? 0, reached));
+ }
+ if (this.previous.size > 200)
+ this.previous.delete(this.previous.keys().next().value);
+ return alerts;
+ }
+}
diff --git a/src/core/usage.js b/src/core/usage.js
new file mode 100644
index 0000000..d1a95cd
--- /dev/null
+++ b/src/core/usage.js
@@ -0,0 +1,140 @@
+export const SCHEMA_VERSION = 1;
+export const NAMES = {codex: 'Codex', cursor: 'Cursor', copilot: 'Copilot'};
+export const STATES = new Set(['loading', 'ready', 'partial', 'stale', 'missing-auth', 'unsupported', 'unavailable']);
+export const WINDOW_STATES = new Set(['active', 'exhausted', 'unlimited']);
+
+export function number(value) {
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean')
+ 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 record(id) {
+ return {
+ schemaVersion: SCHEMA_VERSION, id, name: NAMES[id] ?? id, plan: null, accountKey: null,
+ capabilities: {limits: false, history: false, models: false},
+ limits: {...section(), scope: 'account', 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 ms = typeof value === 'number' ? value : Date.parse(value);
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
+}
+
+export function validateRecord(value, expectedId) {
+ if (!value || value.schemaVersion !== SCHEMA_VERSION || value.id !== expectedId || !NAMES[value.id])
+ throw new Error('Unsupported usage record');
+ if (!value.capabilities || ['limits', 'history', 'models'].some(name => typeof value.capabilities[name] !== 'boolean'))
+ throw new Error('Invalid provider capabilities');
+ for (const name of ['limits', 'history']) {
+ if (!value[name] || !STATES.has(value[name].status))
+ throw new Error(`Invalid ${name} state`);
+ }
+ if (!Array.isArray(value.limits.windows) || !Array.isArray(value.history.days) || !Array.isArray(value.history.models))
+ throw new Error('Invalid usage arrays');
+ for (const item of value.limits.windows) {
+ if (typeof item.id !== 'string' || typeof item.label !== 'string' || !WINDOW_STATES.has(item.state) ||
+ (!item.unlimited && number(item.usedPercent) === null))
+ throw new Error('Invalid quota window');
+ }
+ for (const item of [...value.history.days, ...value.history.models]) {
+ if (number(item.total) === null)
+ throw new Error('Invalid token count');
+ }
+ return value;
+}
+
+export function mergeRecord(previous, next) {
+ 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 (['unavailable', 'missing-auth'].includes(current.status) && old?.updatedAt &&
+ ['ready', 'partial', 'stale'].includes(old.status)) {
+ result[key] = {...old, status: 'stale', message: current.message};
+ }
+ }
+ result.plan ??= previous.plan;
+ result.accountKey ??= previous.accountKey;
+ return result;
+}
+
+export function highestUsage(value) {
+ if (!value || value.limits.status !== 'ready')
+ return null;
+ const values = value.limits.windows.filter(w => !w.unlimited && number(w.usedPercent) !== null).map(w => w.usedPercent);
+ return values.length ? Math.max(...values) : null;
+}
+
+export function localDate(time) {
+ 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/codex.js b/src/providers/codex.js
new file mode 100644
index 0000000..3dcb939
--- /dev/null
+++ b/src/providers/codex.js
@@ -0,0 +1,88 @@
+import {localDate, number, record, section, windowUsage} from '../core/usage.js';
+
+export function codexLimits(response, now = Date.now()) {
+ const buckets = response?.rateLimitsByLimitId && typeof response.rateLimitsByLimitId === 'object'
+ ? Object.entries(response.rateLimitsByLimitId)
+ : [['codex', response?.rateLimits]];
+ const windows = [];
+ for (const [bucketId, bucket] of buckets) {
+ for (const key of ['primary', 'secondary']) {
+ const w = bucket?.[key];
+ if (!w)
+ continue;
+ const mins = number(w.windowDurationMins);
+ 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 item = windowUsage({id: `${bucketId}:${key}`,
+ label: buckets.length > 1 ? `${bucket.limitName || bucketId} · ${duration}` : duration,
+ usedPercent: w.usedPercent, durationMinutes: mins,
+ resetsAt: number(w.resetsAt) ? Number(w.resetsAt) * 1000 : null});
+ if (item)
+ windows.push(item);
+ }
+ }
+ return windows.length
+ ? {...section('ready'), updatedAt: now, scope: 'account', windows}
+ : {...section('unavailable', 'Codex did not report quota windows for this account.'), scope: 'account', 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 total = Number(cumulative.total_tokens);
+ if (state.cumulative && total <= state.cumulative.total_tokens)
+ return null;
+ const previous = state.cumulative ?? {};
+ usage = Object.fromEntries(['input_tokens', 'output_tokens', 'cached_input_tokens', 'cache_write_input_tokens']
+ .map(k => [k, Math.max(0, (number(cumulative[k]) ?? 0) - (number(previous[k]) ?? 0))]));
+ state.cumulative = cumulative;
+ identity = `total:${total}`;
+ } 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 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: 'freeby', 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 = account.account.planType ?? account.account.type ?? null;
+ result.accountKey = io.fingerprint(account.account.email || account.account.chatgptAccountId || result.plan);
+ const limits = await rpc.request('account/rateLimits/read', {});
+ result.limits = {...codexLimits(limits), source: 'Codex app-server'};
+ result.plan = limits?.rateLimits?.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/providers/legacy.js b/src/providers/legacy.js
new file mode 100644
index 0000000..a3b0659
--- /dev/null
+++ b/src/providers/legacy.js
@@ -0,0 +1,45 @@
+import {number, record, section, windowUsage} from '../core/usage.js';
+
+export async function collectLegacy(id, io) {
+ const result = record(id);
+ result.capabilities.limits = true;
+ const token = await io.token(id);
+ if (!token) {
+ result.limits = {...result.limits, ...section('missing-auth', `Sign in to ${result.name} to read usage.`)};
+ return result;
+ }
+ const response = id === 'cursor'
+ ? await io.http('https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage',
+ {method: 'POST', headers: {Authorization: `Bearer ${token}`, 'Connect-Protocol-Version': '1'}, body: {}})
+ : await io.http('https://api.github.com/copilot_internal/user', {headers: {Authorization: `Bearer ${token}`, Accept: 'application/json'}});
+ const {status, data} = response;
+ if (status !== 200 || !data || typeof data !== 'object') {
+ result.limits = {...result.limits, ...section([401, 403].includes(status) ? 'missing-auth' : 'unavailable',
+ [401, 403].includes(status) ? `Reconnect ${result.name} to read usage.` : `Usage endpoint unavailable (HTTP ${status}).`)};
+ return result;
+ }
+ if (id === 'cursor') {
+ const noPlan = number(data.spendLimitUsage?.overallLimit) === 0;
+ const percent = noPlan ? null : number(data.planUsage?.totalPercentUsed);
+ const window = windowUsage({id: 'plan', label: 'Included usage', usedPercent: percent,
+ resetsAt: number(data.billingCycleEnd)});
+ if (window)
+ result.limits.windows.push(window);
+ } else {
+ for (const [key, quota] of Object.entries(data.quota_snapshots ?? {})) {
+ const limit = number(quota.entitlement);
+ const remaining = number(quota.remaining);
+ const item = windowUsage({id: key, label: key.replaceAll('_', ' '), unit: 'requests', limit,
+ used: limit !== null && remaining !== null ? Math.max(0, limit - remaining) : null,
+ unlimited: quota.unlimited === true, resetsAt: data.quota_reset_date_utc});
+ if (item)
+ result.limits.windows.push(item);
+ }
+ result.plan = data.copilot_plan ?? null;
+ }
+ result.limits = {...result.limits, ...section(result.limits.windows.length ? 'ready' : 'unavailable',
+ result.limits.windows.length ? '' : 'No supported allowance was reported. Check the provider dashboard.'),
+ updatedAt: result.limits.windows.length ? Date.now() : null};
+ result.accountKey = io.fingerprint(token);
+ return result;
+}
diff --git a/src/services/files.js b/src/services/files.js
new file mode 100644
index 0000000..0351529
--- /dev/null
+++ b/src/services/files.js
@@ -0,0 +1,47 @@
+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);
+
+export function readJson(path, fallback = null, maxBytes = 16 * 1024 * 1024) {
+ 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);
+ return JSON.parse(new TextDecoder().decode(bytes));
+ } catch {
+ return fallback;
+ }
+}
+
+export function writeJson(path, value) {
+ const file = Gio.File.new_for_path(path);
+ const parent = file.get_parent();
+ GLib.mkdir_with_parents(parent.get_path(), 0o700);
+ file.replace_contents(new TextEncoder().encode(JSON.stringify(value)), null, false,
+ Gio.FileCreateFlags.PRIVATE | Gio.FileCreateFlags.REPLACE_DESTINATION, null);
+}
+
+export function findCommand(name) {
+ const found = GLib.find_program_in_path(name);
+ if (found)
+ return found;
+ for (const directory of ['.local/bin', '.cargo/bin', '.npm-global/bin']) {
+ const path = join(GLib.get_home_dir(), directory, name);
+ if (GLib.file_test(path, GLib.FileTest.IS_EXECUTABLE))
+ return path;
+ }
+ const error = new Error(`${name} is not installed`);
+ error.code = 'NOT_FOUND';
+ throw error;
+}
+
+export function stateDirectory() {
+ return join(GLib.get_user_state_dir(), 'freeby');
+}
+
+export function cacheDirectory() {
+ return join(GLib.get_user_cache_dir(), 'freeby');
+}
diff --git a/src/services/history.js b/src/services/history.js
new file mode 100644
index 0000000..1c8e406
--- /dev/null
+++ b/src/services/history.js
@@ -0,0 +1,135 @@
+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';
+
+function listFiles(root, output, depth = 0) {
+ if (depth > 12 || output.length > 20000)
+ 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',
+ Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ try {
+ let info;
+ while ((info = iterator.next_file(null))) {
+ if (info.get_is_symlink())
+ continue;
+ const path = join(root, info.get_name());
+ if (info.get_file_type() === Gio.FileType.DIRECTORY)
+ listFiles(path, output, depth + 1);
+ else if (info.get_name().endsWith('.jsonl'))
+ output.push({path, size: info.get_size(), mtime: info.get_attribute_uint64('time::modified')});
+ }
+ } finally {
+ iterator.close(null);
+ }
+ return true;
+}
+
+export function scanHistory(id, roots, parse, {retention = 30, now = Date.now(), cachePath = null} = {}) {
+ const cutoff = recentDates(now, retention)[0];
+ const destination = cachePath ?? join(cacheDirectory(), `history-${id}.json`);
+ let cached = readJson(destination, {}, 64 * 1024 * 1024);
+ const identity = fingerprint(roots.join('\n'));
+ if (cached.version !== 2 || cached.identity !== identity || typeof cached.files !== 'object')
+ cached = {version: 2, identity, files: {}};
+ const files = [];
+ let detected = false;
+ let partial = false;
+ let parsed = 0;
+ const started = GLib.get_monotonic_time();
+ for (const root of roots) {
+ try { detected = listFiles(root, files) || detected; } catch { partial = true; }
+ }
+ for (const file of files) {
+ if ((GLib.get_monotonic_time() - started) / 1000000 > 20) {
+ partial = true;
+ break;
+ }
+ const key = fingerprint(file.path);
+ let previous = cached.files[key];
+ if (previous?.size === file.size && previous?.mtime === file.mtime)
+ continue;
+ // Changed or truncated files are rebuilt; append-only files resume at the last complete line.
+ if (!previous || file.size <= previous.size)
+ previous = {offset: 0, state: {session: key}, events: {}};
+ let input;
+ try {
+ input = Gio.File.new_for_path(file.path).read(null);
+ 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;
+ try {
+ const oldSession = previous.state.session;
+ const event = parse(JSON.parse(new TextDecoder().decode(line)), previous.state);
+ // Provider session identifiers are useful only for
+ // deduplication. Hash them before either parser state or
+ // derived events reach Freeby's private cache.
+ if (previous.state.session !== oldSession)
+ previous.state.session = fingerprint(`${id}:${previous.state.session}`);
+ if (event && event.date >= cutoff) {
+ const sanitized = {...event,
+ id: fingerprint(`${id}:${event.id}`),
+ session: fingerprint(`${id}:${event.session ?? event.id}`)};
+ previous.events[sanitized.id] = sanitized;
+ }
+ } catch { partial = true; }
+ }
+ tail = buffer.slice(start);
+ if (tail.length > 4 * 1024 * 1024 || (GLib.get_monotonic_time() - started) / 1000000 > 20) {
+ partial = true;
+ stop = true;
+ }
+ }
+ previous.offset = offset;
+ // A time-limited scan must resume even if the source file has not changed.
+ previous.size = stop ? -1 : file.size;
+ previous.mtime = file.mtime;
+ cached.files[key] = previous;
+ parsed++;
+ } catch { partial = true; } finally { input?.close(null); }
+ }
+ const events = [];
+ for (const [key, file] of Object.entries(cached.files)) {
+ if (!file.events || typeof file.events !== 'object') {
+ delete cached.files[key];
+ partial = true;
+ continue;
+ }
+ for (const [eventKey, event] of Object.entries(file.events)) {
+ if (event.date < cutoff)
+ delete file.events[eventKey];
+ else
+ events.push(event);
+ }
+ }
+ try { writeJson(destination, cached); } catch { partial = true; }
+ const result = {...section(detected ? partial ? 'partial' : 'ready' : 'unsupported',
+ partial ? 'Some local records could not be read. Totals may be incomplete.' :
+ detected ? 'Local records only; activity on other devices is not included.' : 'No local usage records found.'),
+ updatedAt: detected ? now : null, scope: 'local', source: `${id} local usage records`,
+ ...aggregateEvents(events, now), scannedFiles: parsed};
+ if (!detected && !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..da349fd
--- /dev/null
+++ b/src/services/http.js
@@ -0,0 +1,21 @@
+import GLib from 'gi://GLib';
+import Soup from 'gi://Soup?version=3.0';
+
+export async function requestJson(url, {method = 'GET', headers = {}, body = null, cancellable = null} = {}) {
+ const session = new Soup.Session({timeout: 12});
+ const message = Soup.Message.new(method, url);
+ 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))));
+ try {
+ 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); }
+ }));
+ 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/process.js b/src/services/process.js
new file mode 100644
index 0000000..965fc10
--- /dev/null
+++ b/src/services/process.js
@@ -0,0 +1,137 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+
+export async function runCommand(argv, {input = null, timeout = 15000, cancellable = null} = {}) {
+ const proc = Gio.Subprocess.new(argv, Gio.SubprocessFlags.STDOUT_PIPE |
+ Gio.SubprocessFlags.STDERR_SILENCE | (input === null ? 0 : Gio.SubprocessFlags.STDIN_PIPE));
+ const local = new Gio.Cancellable();
+ const externalId = cancellable?.connect(() => local.cancel());
+ if (cancellable?.is_cancelled())
+ local.cancel();
+ let timer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, () => {
+ timer = 0;
+ local.cancel();
+ return GLib.SOURCE_REMOVE;
+ });
+ try {
+ const text = await new Promise((resolve, reject) => {
+ proc.communicate_utf8_async(input, local, (p, result) => {
+ try {
+ const [, stdout] = p.communicate_utf8_finish(result);
+ resolve(stdout);
+ } catch (error) {
+ reject(error);
+ }
+ });
+ });
+ if (!proc.get_successful())
+ throw new Error('Command failed');
+ return text;
+ } finally {
+ // Give managed collectors time to cancel and reap their own CLI child.
+ if (local.is_cancelled()) {
+ proc.send_signal(15);
+ await new Promise(resolve => GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => {
+ resolve();
+ return GLib.SOURCE_REMOVE;
+ }));
+ }
+ proc.force_exit();
+ proc.wait_async(null, (p, result) => { try { p.wait_finish(result); } catch { /* Reap best effort. */ } });
+ if (timer)
+ GLib.source_remove(timer);
+ if (externalId)
+ cancellable.disconnect(externalId);
+ }
+}
+
+export class RpcClient {
+ constructor(argv, cancellable, timeout = 8000) {
+ this.proc = Gio.Subprocess.new(argv, Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE);
+ this.input = new Gio.DataInputStream({base_stream: this.proc.get_stdout_pipe()});
+ this.cancellable = new Gio.Cancellable();
+ this.parent = cancellable;
+ this.parentId = cancellable?.connect(() => this.close());
+ this.timeout = timeout;
+ this.nextId = 0;
+ this.pending = new Map();
+ this.closed = false;
+ this.read();
+ }
+
+ send(value) {
+ if (this.closed)
+ throw new Error('RPC closed');
+ this.proc.get_stdin_pipe().write_all(new TextEncoder().encode(`${JSON.stringify(value)}\n`), this.cancellable);
+ }
+
+ notify(method, params) {
+ this.send({method, params});
+ }
+
+ request(method, params = {}) {
+ 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 Error('RPC timeout'));
+ 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);
+ }
+ });
+ }
+
+ read() {
+ this.input.read_line_async(GLib.PRIORITY_DEFAULT, this.cancellable, (stream, result) => {
+ try {
+ const [bytes] = stream.read_line_finish(result);
+ if (bytes === null)
+ throw new Error('RPC exited');
+ const value = JSON.parse(new TextDecoder().decode(bytes));
+ const pending = this.pending.get(value.id);
+ if (pending) {
+ this.pending.delete(value.id);
+ GLib.source_remove(pending.timer);
+ if (value.error)
+ pending.reject(new Error('RPC request unavailable'));
+ else
+ pending.resolve(value.result);
+ }
+ if (!this.closed)
+ this.read();
+ } catch {
+ 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 Error('RPC 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/usageService.js b/src/services/usageService.js
new file mode 100644
index 0000000..a0c10df
--- /dev/null
+++ b/src/services/usageService.js
@@ -0,0 +1,115 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import {NAMES, mergeRecord, record, section, validateRecord} from '../core/usage.js';
+import {ThresholdTracker} from '../core/notifications.js';
+import {findCommand, join, readJson, stateDirectory, writeJson} from './files.js';
+import {runCommand} from './process.js';
+
+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, 15, () => {
+ 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);
+ });
+ }
+
+ configure() {
+ this.enabled = [...new Set(this.settings.get_strv('enabled-providers'))].filter(id => NAMES[id]);
+ for (const [id, job] of this.jobs) {
+ if (!this.enabled.includes(id)) {
+ job.cancel();
+ this.jobs.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 (cached[key].updatedAt)
+ 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(3600, this.settings.get_int('refresh-interval') * 2 ** (this.failures.get(id) ?? 0));
+ if (elapsed >= 0 && elapsed < (force ? 15000 : wait * 1000))
+ return;
+ const job = new Gio.Cancellable();
+ this.jobs.set(id, job);
+ this.attempts.set(id, now);
+ this.changed();
+ try {
+ const stdout = await runCommand([findCommand('gjs'), '-m', join(this.directory, 'src', 'collector', 'main.js'),
+ id, String(this.settings.get_int('history-retention-days'))], {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.alerts(alerts);
+ } catch {
+ if (!this.closed && !job.is_cancelled()) {
+ const failed = record(id);
+ failed.limits = {...failed.limits, ...section('unavailable', 'Collection failed or timed out. Retry from the panel.')};
+ failed.history = {...failed.history, ...section('unavailable', 'History could not be refreshed.')};
+ this.records[id] = mergeRecord(this.records[id], failed);
+ 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.changed();
+ }
+ }
+
+ destroy() {
+ this.closed = true;
+ if (this.timer)
+ GLib.source_remove(this.timer);
+ if (this.sleepId)
+ Gio.DBus.system.signal_unsubscribe(this.sleepId);
+ for (const job of this.jobs.values())
+ job.cancel();
+ this.jobs.clear();
+ this.changed = () => {};
+ this.alerts = () => {};
+ }
+}
diff --git a/src/ui/indicator.js b/src/ui/indicator.js
new file mode 100644
index 0000000..970c628
--- /dev/null
+++ b/src/ui/indicator.js
@@ -0,0 +1,156 @@
+import Clutter from 'gi://Clutter';
+import GObject from 'gi://GObject';
+import St from 'gi://St';
+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, highestUsage, recentDates} from '../core/usage.js';
+import {age, resetTime, tokens} from '../core/format.js';
+import {button, label, meter, row} from './widgets.js';
+
+export const FreebyIndicator = GObject.registerClass(class FreebyIndicator extends PanelMenu.Button {
+ _init(settings, openPreferences) {
+ super._init(0.0, 'Freeby usage monitor');
+ this._settings = settings;
+ this._openPreferences = openPreferences;
+ this._service = null;
+ this._panelLabel = label('AI', 'freeby-panel-label');
+ this.add_child(this._panelLabel);
+ this.menu.actor.add_style_class_name('freeby-menu');
+ 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: 'freeby-content', x_expand: true});
+ this._scroll = new St.ScrollView({hscrollbar_policy: St.PolicyType.NEVER, vscrollbar_policy: St.PolicyType.AUTOMATIC,
+ overlay_scrollbars: true});
+ // St.ScrollView is a Clutter actor in GNOME 50. Its inherited
+ // set_child() targets ClutterContent, so add the scrollable actor as a
+ // child explicitly.
+ this._scroll.add_child(this._contentBox);
+ section.box.add_child(this._scroll);
+ this.menu.connect('open-state-changed', (_menu, open) => {
+ if (open) {
+ this.resize();
+ this._service?.refreshAll();
+ this.render();
+ }
+ });
+ this.render();
+ }
+
+ resize() {
+ const monitor = Main.layoutManager.findMonitorForActor(this) ?? Main.layoutManager.primaryMonitor;
+ const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ this._scroll.style = `max-height: ${Math.max(180, Math.floor(monitor.height / scale) - 120)}px;`;
+ this._contentBox.style = `width: ${Math.min(420, Math.floor(monitor.width / scale) - 64)}px;`;
+ }
+
+ attach(service) {
+ this._service = service;
+ this.render();
+ }
+
+ render() {
+ if (!this._contentBox)
+ return;
+ const focus = global.stage.get_key_focus();
+ const restoreName = focus && this._contentBox.contains(focus) ? focus.accessible_name : null;
+ this._contentBox.destroy_all_children();
+ const enabled = this._service?.enabled ?? [];
+ const requested = this._settings.get_string('default-provider');
+ const id = enabled.includes(requested) ? requested : enabled[0];
+ const record = this._service?.records[id];
+ const percent = highestUsage(record);
+ this._panelLabel.text = percent === null ? 'AI' : `AI ${Math.round(percent)}%`;
+ this._panelLabel.style_class = `freeby-panel-label${percent >= 100 ? ' freeby-danger' : percent >= 90 ? ' freeby-warning' : ''}`;
+ this.accessible_name = `Freeby, ${record?.name ?? 'usage monitor'}${percent === null ? '' : `, ${Math.round(percent)} percent used`}`;
+ const header = new St.BoxLayout({vertical: true, style_class: 'freeby-header'});
+ header.add_child(label(record?.name ?? 'Freeby', 'freeby-title'));
+ header.add_child(label(record?.plan ? String(record.plan).toUpperCase() : 'USAGE MONITOR', 'freeby-caption'));
+ this._contentBox.add_child(header);
+ if (enabled.length > 1) {
+ const selector = new St.BoxLayout({style_class: 'freeby-selector', x_expand: true});
+ for (const provider of enabled)
+ selector.add_child(button(NAMES[provider], () => this._settings.set_string('default-provider', provider),
+ {active: provider === id, name: `Show ${NAMES[provider]} usage`}));
+ this._contentBox.add_child(selector);
+ }
+ if (!record) {
+ this._contentBox.add_child(label('Choose your providers in preferences to get started.', 'freeby-message'));
+ } else {
+ this.heading('LIMITS');
+ for (const window of record.limits.windows) {
+ const box = new St.BoxLayout({vertical: true, style_class: 'freeby-window'});
+ const value = window.unlimited ? 'Unlimited' : `${Math.round(window.usedPercent)}%`;
+ box.add_child(row(window.label, value));
+ if (!window.unlimited)
+ box.add_child(meter(window.usedPercent / 100, `${window.label}: ${value} used`,
+ window.usedPercent >= 100 ? 'freeby-danger' : window.usedPercent >= 90 ? 'freeby-warning' : ''));
+ box.add_child(label(resetTime(window.resetsAt), 'freeby-caption'));
+ this._contentBox.add_child(box);
+ }
+ if (record.limits.status !== 'ready')
+ this.message(record.limits.message || (record.limits.status === 'loading' ? 'Checking account limits…' : 'Limits unavailable'));
+ if (record.limits.status === 'stale')
+ this.message(`Saved limits · ${age(record.limits.updatedAt).toLowerCase()}`);
+ if (record.history.days.length) {
+ this.heading('TOKENS BY DAY');
+ this._contentBox.add_child(label(`${record.history.scope === 'account' ? 'Account activity' : 'This device'} · ${record.history.period.start} – ${record.history.period.end}`, 'freeby-caption'));
+ const max = Math.max(1, ...record.history.days.map(day => day.total));
+ const today = recentDates(Date.now(), 1)[0];
+ for (const day of record.history.days) {
+ const line = new St.BoxLayout({style_class: 'freeby-day-row', x_expand: true});
+ const dayName = day.date === today ? 'Today' : new Date(`${day.date}T12:00:00`).toLocaleDateString(undefined, {weekday: 'short'});
+ line.add_child(label(dayName, 'freeby-day'));
+ const bar = meter(day.total / max, `${day.date}: ${tokens(day.total)} tokens`);
+ bar.y_align = Clutter.ActorAlign.CENTER;
+ line.add_child(bar);
+ line.add_child(label(tokens(day.total), 'freeby-day-total'));
+ line.accessible_name = `${dayName}, ${day.total} tokens, ${day.sessions ?? 0} sessions`;
+ this._contentBox.add_child(line);
+ }
+ }
+ if (record.history.models.length) {
+ this.heading('TOKENS BY MODEL');
+ this._contentBox.add_child(label('Same period as daily activity', 'freeby-caption'));
+ const max = Math.max(1, ...record.history.models.map(model => model.total));
+ for (const model of record.history.models) {
+ const box = new St.BoxLayout({vertical: true, style_class: 'freeby-model'});
+ box.add_child(row(model.model, tokens(model.total)));
+ box.add_child(meter(model.total / max, `${model.model}: ${model.total} tokens`));
+ box.add_child(label(`Input ${tokens(model.input)} · Output ${tokens(model.output)} · Cache ${tokens(model.cacheRead + model.cacheWrite)}`, 'freeby-caption'));
+ this._contentBox.add_child(box);
+ }
+ }
+ if (record.history.message)
+ this.message(record.history.message);
+ }
+ this.heading('');
+ const busy = this._service?.jobs.has(id);
+ this._contentBox.add_child(label(busy ? 'Refreshing…' : age(record?.limits.updatedAt || record?.history.updatedAt), 'freeby-caption'));
+ const actions = new St.BoxLayout({style_class: 'freeby-selector'});
+ actions.add_child(button('Refresh', () => this._service?.refreshAll(true), {name: 'Refresh usage'}));
+ actions.add_child(button('Preferences', () => { this.menu.close(); this._openPreferences(); }));
+ this._contentBox.add_child(actions);
+ if (restoreName) {
+ const restore = actor => {
+ if (actor.can_focus && actor.accessible_name === restoreName)
+ actor.grab_key_focus();
+ for (const child of actor.get_children())
+ restore(child);
+ };
+ restore(this._contentBox);
+ }
+ }
+
+ heading(text) {
+ this._contentBox.add_child(label(text, 'freeby-section-title'));
+ }
+
+ message(text) {
+ const message = label(text, 'freeby-message');
+ message.clutter_text.line_wrap = true;
+ this._contentBox.add_child(message);
+ }
+});
diff --git a/src/ui/widgets.js b/src/ui/widgets.js
new file mode 100644
index 0000000..9d33005
--- /dev/null
+++ b/src/ui/widgets.js
@@ -0,0 +1,35 @@
+import Atk from 'gi://Atk';
+import Clutter from 'gi://Clutter';
+import St from 'gi://St';
+
+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 row(left, right, style = 'freeby-row') {
+ const box = new St.BoxLayout({style_class: style, x_expand: true});
+ box.add_child(label(left, '', true));
+ box.add_child(label(right, 'freeby-number'));
+ return box;
+}
+
+export function meter(fraction, name, style = '') {
+ const ratio = Math.max(0, Math.min(1, Number(fraction) || 0));
+ const track = new St.Widget({style_class: `freeby-track ${style}`, x_expand: true,
+ layout_manager: new Clutter.BinLayout(), accessible_name: name, accessible_role: Atk.Role.PROGRESS_BAR});
+ const fill = new St.Widget({style_class: 'freeby-fill', x_align: Clutter.ActorAlign.START,
+ y_expand: true, y_align: Clutter.ActorAlign.FILL});
+ track.add_child(fill);
+ track.connect('notify::allocation', () => { fill.width = Math.round(track.width * ratio); });
+ return track;
+}
+
+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: 'freeby-button', x_expand: true});
+ if (active)
+ actor.add_style_pseudo_class('checked');
+ actor.connect('clicked', callback);
+ return actor;
+}
diff --git a/stylesheet.css b/stylesheet.css
index ac2912b..0965c8e 100644
--- a/stylesheet.css
+++ b/stylesheet.css
@@ -1,83 +1,25 @@
-.freeby-panel {
- spacing: 4px;
-}
-
-.freeby-panel-label {
- font-weight: bold;
- font-size: 10pt;
- min-width: 16px;
- color: currentColor;
-}
-
-.freeby-panel-green {
- color: #4ade80;
-}
-
-.freeby-panel-yellow {
- color: #facc15;
-}
-
-.freeby-panel-red {
- color: #f87171;
-}
-
-.freeby-item-box {
- spacing: 8px;
-}
-
-.freeby-dot {
- font-size: 8pt;
- min-width: 10px;
-}
-
-.freeby-dot-off {
- color: currentColor;
- opacity: 0.4;
-}
-
-.freeby-dot-green {
- color: #4ade80;
-}
-
-.freeby-dot-yellow {
- color: #facc15;
-}
-
-.freeby-dot-red {
- color: #f87171;
-}
-
-.freeby-item-name {
- font-weight: bold;
- font-size: 9pt;
- min-width: 52px;
- color: currentColor;
-}
-
-.freeby-item-summary {
- font-size: 9pt;
- color: currentColor;
- opacity: 0.8;
-}
-
-.freeby-dim {
- opacity: 0.4;
-}
-
-.freeby-green {
- color: #4ade80;
-}
-
-.freeby-yellow {
- color: #facc15;
-}
-
-.freeby-red {
- color: #f87171;
-}
-
-.freeby-status {
- font-size: 9pt;
- color: currentColor;
- opacity: 0.7;
-}
+.freeby-panel-label { font-weight: bold; font-size: 10pt; }
+.freeby-content { padding: 8px 14px 12px; spacing: 12px; }
+.freeby-header { spacing: 4px; padding: 4px 0 8px; }
+.freeby-title { font-size: 18pt; font-weight: bold; }
+.freeby-caption { font-size: 9pt; opacity: 0.65; }
+.freeby-selector { spacing: 8px; }
+.freeby-button { padding: 9px 12px; border-radius: 8px; background-color: rgba(128, 128, 128, 0.12); }
+.freeby-button:hover, .freeby-button:focus { background-color: rgba(128, 128, 128, 0.24); }
+.freeby-button:checked { background-color: rgba(128, 128, 128, 0.3); font-weight: bold; }
+.freeby-button:focus { box-shadow: inset 0 0 0 2px -st-accent-color; }
+.freeby-section-title { font-size: 9pt; font-weight: bold; opacity: 0.65; border-top: 1px solid rgba(128, 128, 128, 0.25); padding-top: 16px; }
+.freeby-row { spacing: 12px; }
+.freeby-number { font-family: monospace; }
+.freeby-window { spacing: 7px; }
+.freeby-track { height: 6px; min-width: 20px; border-radius: 3px; background-color: rgba(128, 128, 128, 0.25); }
+.freeby-fill { background-color: -st-accent-color; border-radius: 3px; }
+.freeby-warning { color: #e5a50a; }
+.freeby-danger { color: #e01b24; }
+.freeby-warning .freeby-fill { background-color: #e5a50a; }
+.freeby-danger .freeby-fill { background-color: #e01b24; }
+.freeby-day-row { spacing: 12px; }
+.freeby-day { min-width: 44px; font-size: 10pt; }
+.freeby-day-total { min-width: 64px; text-align: right; font-family: monospace; font-size: 10pt; }
+.freeby-model { spacing: 6px; }
+.freeby-message { font-size: 9pt; opacity: 0.75; }
diff --git a/tests/integration/collector.js b/tests/integration/collector.js
new file mode 100644
index 0000000..6b32b3d
--- /dev/null
+++ b/tests/integration/collector.js
@@ -0,0 +1,65 @@
+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 {readJson, writeJson, join} from '../../src/services/files.js';
+import {runCommand} from '../../src/services/process.js';
+
+function assert(value, message) {
+ if (!value)
+ throw new Error(message);
+}
+const scratch = GLib.dir_make_tmp('freeby-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 === 2, 'versioned cache');
+assert(!JSON.stringify(cache).includes('synthetic'), 'session identity must be sanitized in cache');
+writeJson(join(scratch, 'record.json'), {safe: true});
+assert(readJson(join(scratch, 'record.json')).safe, 'atomic JSON roundtrip');
+print('PASS: GJS history initial scan, cached scan, append, partial line, and private JSON cache');
+
+const loop = new GLib.MainLoop(null, false);
+let failed = false;
+(async () => {
+ try {
+ assert((await runCommand(['/bin/echo', 'fixture'])).trim() === 'fixture', 'subprocess output');
+ let timedOut = false;
+ try { await runCommand(['/bin/sleep', '5'], {timeout: 50}); } catch { timedOut = true; }
+ assert(timedOut, 'subprocess timeout');
+ 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/unit/codex.test.js b/tests/unit/codex.test.js
new file mode 100644
index 0000000..261b45e
--- /dev/null
+++ b/tests/unit/codex.test.js
@@ -0,0 +1,52 @@
+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);
+});
+
+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/usage.test.js b/tests/unit/usage.test.js
new file mode 100644
index 0000000..159cd26
--- /dev/null
+++ b/tests/unit/usage.test.js
@@ -0,0 +1,82 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {aggregateEvents, highestUsage, mergeRecord, number, recentDates, record, validateRecord, windowUsage} from '../../src/core/usage.js';
+import {resetTime, tokens} from '../../src/core/format.js';
+import {ThresholdTracker} from '../../src/core/notifications.js';
+
+test('unknown metrics are not coerced to zero', () => {
+ for (const value of [null, undefined, '', false, -1, Infinity, 'bad'])
+ assert.equal(number(value), null);
+ assert.equal(number(0), 0);
+ assert.equal(windowUsage({id: 'plan', label: 'Plan', used: 0, limit: 0}), null);
+ assert.equal(highestUsage(record('codex')), 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(() => validateRecord(record('codex'), 'cursor'));
+ const value = record('codex');
+ value.limits.windows.push({id: 'x', label: 'X', state: 'active', usedPercent: null});
+ assert.throws(() => validateRecord(value, 'codex'));
+});
+
+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);
+ assert.equal(highestUsage(merged), null);
+ next.accountKey = 'two';
+ assert.equal(mergeRecord(old, next).limits.windows.length, 0);
+});
+
+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']);
+});
+
+test('notifications require a verified crossing and deduplicate warning and limit separately', () => {
+ const tracker = new ThresholdTracker();
+ const value = record('codex');
+ value.limits = {status: 'ready', windows: [{id: 'weekly', label: 'Weekly', usedPercent: 95, resetsAt: 500}]};
+ assert.deepEqual(tracker.update(value), []);
+ value.limits.windows[0].usedPercent = 100;
+ assert.equal(tracker.update(value)[0].threshold, 100);
+ assert.deepEqual(tracker.update(value), []);
+ value.limits.status = 'unavailable';
+ assert.deepEqual(tracker.update(value), []);
+ value.limits.status = 'ready';
+ value.limits.windows[0].resetsAt = 1000;
+ assert.deepEqual(tracker.update(value), []);
+});
+
+test('formatting preserves unknown/reset-due states', () => {
+ assert.equal(tokens(null), '—');
+ assert.equal(tokens(23000000), '23.0M');
+ assert.equal(resetTime(null), 'Reset time unavailable');
+ assert.match(resetTime(100, 200), /awaiting update/);
+});
diff --git a/tools/check.js b/tools/check.js
new file mode 100644
index 0000000..2ad9d35
--- /dev/null
+++ b/tools/check.js
@@ -0,0 +1,27 @@
+import {readFileSync, readdirSync, existsSync} from 'node:fs';
+import {resolve, dirname, join} 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 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)) {
+ if (!existsSync(resolve(dirname(file), target)))
+ throw new Error(`${file}: missing import ${target}`);
+ }
+}
+const metadata = JSON.parse(readFileSync('metadata.json', 'utf8'));
+if (metadata.uuid !== 'freeby@kelvin.local')
+ throw new Error('Extension identity changed without a migration');
+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');
+execFileSync('glib-compile-schemas', ['--strict', '--dry-run', 'schemas']);
+console.log(`Syntax, imports, formatting, 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..c68eaed
--- /dev/null
+++ b/tools/package.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+"""Create a deterministic GNOME extension archive from an explicit allowlist."""
+import argparse
+import json
+from pathlib import Path
+import subprocess
+import tempfile
+import zipfile
+
+
+def package(source, output):
+ metadata = json.loads((source / 'metadata.json').read_text())
+ version = json.loads((source / 'package.json').read_text())['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 / 'schemas').glob('*.xml')))
+ output.mkdir(parents=True, exist_ok=True)
+ archive = output / f'{metadata["uuid"]}-{version}.zip'
+ with tempfile.TemporaryDirectory(prefix='freeby-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()))
+ with zipfile.ZipFile(archive, 'w', compression=zipfile.ZIP_DEFLATED) as target:
+ for name, payload in sorted(payloads):
+ info = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
+ info.compress_type = zipfile.ZIP_DEFLATED
+ info.external_attr = 0o100644 << 16
+ target.writestr(info, payload)
+ with zipfile.ZipFile(archive) as check:
+ assert check.testzip() is None
+ assert 'schemas/gschemas.compiled' in check.namelist()
+ assert all(not name.startswith(('tests/', 'tools/', 'reference-images/')) for name in check.namelist())
+ 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..4dc32ca
--- /dev/null
+++ b/tools/smoke-shell.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Exercise the packaged extension in a private headless GNOME session."""
+import argparse
+import os
+from pathlib import Path
+import signal
+import subprocess
+import tempfile
+import time
+import zipfile
+
+
+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' / 'freeby@kelvin.local'
+ 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 smoke(source, archive, destination):
+ destination.mkdir(parents=True, exist_ok=True)
+ prefix = destination / 'install'
+ install(source, archive, prefix, destination)
+ env = {**os.environ, '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',
+ 'GSETTINGS_SCHEMA_DIR': str(prefix / 'share/gnome-shell/extensions/freeby@kelvin.local/schemas'),
+ 'LIBGL_ALWAYS_SOFTWARE': '1'}
+ # Empty provider list prevents personal authentication/data access during UI tests.
+ run(['gsettings', 'set', 'org.gnome.shell.extensions.freeby', 'enabled-providers', '[]'], env, check=True)
+ run(['gsettings', 'set', 'org.gnome.shell', 'enabled-extensions', "['freeby@kelvin.local']"], 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', '1280x1024',
+ '--wayland-display', 'freeby-test', '--debug-control'], 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', 'freeby@kelvin.local']
+ 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')
+ run(['gnome-extensions', 'disable', 'freeby@kelvin.local'], env, check=True)
+ run(['gnome-extensions', 'enable', 'freeby@kelvin.local'], env, check=True)
+ print('PASS: extension disable/re-enable')
+ 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}')
+
+
+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)
+ args = parser.parse_args()
+ smoke(args.source.resolve(), args.archive.resolve() if args.archive else None,
+ args.output or Path(tempfile.mkdtemp(prefix='freeby-shell-')))
From 34f273e1268732cba146ebbe2819c8aa345d16dc Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 6 Sep 2026 08:17:15 +0000
Subject: [PATCH 14/44] docs: prepare the alpha.1 milestone release
---
CHANGELOG.md | 108 ++++++++++----------------------
CONTRIBUTING.md | 99 +++++++++---------------------
README.md | 159 ++++++++++++++++++++----------------------------
plan.md | 20 +++---
4 files changed, 143 insertions(+), 243 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c3f947..e9f80d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,89 +1,47 @@
# Changelog
-## v1.0.2
+All notable changes are documented here. Freeby uses semantic prerelease versions
+while version 2 is developed on the `dev` branch.
-### 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
+## 2.0.0-alpha.1 - 2026-09-06
### 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)
-
-### 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
-
-### 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
-
-### Security
-- Fixed Python code injection risk when `$HOME` contains single quotes
-- Removed unused `json_escape` function
-## v0.5.0
+- Versioned provider-neutral usage contract with explicit capabilities, source
+ scope, freshness, quota states, reset timestamps, and token categories.
+- Bounded Codex app-server collection for account rate limits.
+- Incremental local Codex history scanning with seven-day and model aggregates.
+- Private XDG state/cache persistence, stale-data recovery, and refresh backoff.
+- Native provider header, quota meters, daily activity, model totals, status
+ footer, setup/error states, and accessible controls.
+- Deterministic archive packaging, unit tests, GJS integration tests, and an
+ isolated headless GNOME lifecycle smoke test.
### Fixed
-- Dropdown now stays open when clicking the refresh icon
-- Consistent percentage display for Copilot (was showing token counts)
-## v0.4.0
+- Refresh now occurs after resume rather than before suspend.
+- Provider jobs no longer overlap or update destroyed UI.
+- Notifications require a verified threshold crossing and are deduplicated per
+ account, quota window, and reset period.
+- Missing or empty provider responses are no longer reported as zero usage.
+- Runtime `npx` downloads and unbounded collection processes were removed.
+- Schemas install inside the extension and uninstall no longer requires deleting
+ a shared compiled schema file.
-### 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)
-
-### Changed
-- Split extension.js into extension.js + indicator.js + prefs.js
-
-## v0.3.0
-
-### Added
-- Click panel to refresh
-- Clickable refresh icon in dropdown
-- `has_remaining` field for accurate provider count
-
-### Fixed
-- Consistent comma separator and countdown format
-- Codex parsed via `--json` flag for reliable parsing
+### Known limitations
-## v0.2.0
+- GNOME Shell 50 is the only compatibility target verified for alpha.1.
+- Codex activity is local to this device. Account quota limits and local token
+ history intentionally remain separate scopes.
+- Cursor and Copilot adapters are preview-only until alpha.3 and alpha.4.
+- Claude Code support is scheduled for alpha.2.
-### 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 release history remains available in the repository's Git tags and
+GitHub releases.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 27606ca..b5aa942 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,83 +1,44 @@
-# Contributing
+# Contributing to Freeby
-Thanks for your interest in Freeby.
+Development targets the `dev` branch. `main` is reserved for stable releases.
+Keep provider changes small enough to verify independently and use conventional
+commit messages such as `feat:`, `fix:`, `test:`, `docs:`, and `chore:`.
-## Development
+## Development environment
-```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:
+Install Node.js 20 or newer, GJS, Soup 3 introspection data, Meson, Ninja, and the
+GLib schema compiler. Then run:
```bash
-gnome-extensions enable freeby@kelvin.local
+git clone https://github.com/kcnewman/freeby.git
+cd freeby
+git switch dev
+npm run check
+npm run pack
```
-## 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
+For an isolated installation:
```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/
+meson setup build --prefix=/tmp/freeby-install
+meson install -C build
```
-CI runs automatically on push to `main` or `dev`, and on all pull requests.
-
-## Branches
+`python3 tools/smoke-shell.py` additionally verifies lifecycle behavior in a
+private headless GNOME Shell session when GNOME Shell is available locally.
-- `main` — stable releases
-- `dev` — active development
+## Design and architecture
-## Pull requests
+- Keep `extension.js` and `prefs.js` thin GNOME entry points.
+- Keep provider response parsing in `src/providers/`, orchestration and storage
+ in `src/services/`, shared contracts in `src/core/`, and actors in `src/ui/`.
+- Preserve source scope and provider units. Unknown data stays unknown; account
+ limits never derive from local token totals.
+- Keep collectors bounded and cancellable. Never download runtime dependencies.
+- Fixtures must be synthetic or sanitized. Do not commit tokens, account IDs,
+ prompts, responses, or complete provider transcripts.
+- Preserve GNOME theme behavior, keyboard focus, accessible names, and readable
+ scaling when changing the panel UI.
-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`
+The authoritative milestone gates and release process are in [plan.md](plan.md).
+Project-wide agent guidance is in [AGENTS.md](AGENTS.md).
diff --git a/README.md b/README.md
index 8ecdf50..9353d41 100644
--- a/README.md
+++ b/README.md
@@ -1,120 +1,95 @@
-
-
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
+# Freeby
-```bash
-sudo dnf install meson ninja-build python3 curl glib2-devel
-```
-
+Freeby is a native GNOME Shell usage monitor for AI coding tools. It keeps
+account limits, reset windows, recent local token activity, and model totals one
+click away in the top panel.
-
-Ubuntu / Debian
+Version 2 is being delivered provider by provider. `v2.0.0-alpha.1` is the Codex
+milestone: Codex limits and local activity are verified end to end. Cursor and
+Copilot adapters remain available as opt-in previews while their dedicated
+milestones are completed; Claude Code follows next.
-```bash
-sudo apt install meson ninja-build python3 curl libglib2.0-dev-bin
-```
-
+## What the Codex milestone includes
+
+- Account quota windows from the installed Codex CLI's app-server interface.
+- Reset countdowns without guessing missing values.
+- Seven-day local token activity and per-model input, output, and cache totals.
+- Cached results shown as stale while a provider independently refreshes.
+- Explicit unavailable, unsupported, missing-authentication, and exhausted states.
+- Bounded collectors, refresh backoff, wake refresh, cancellation on disable, and
+ threshold-crossing notifications.
+- A native, theme-aware, keyboard-focusable GNOME panel interface.
-**Build and install**
+Freeby stores only derived usage metadata under the standard XDG state/cache
+directories. It does not copy prompts, responses, transcripts, or credentials.
+Local activity covers this device only and must not be interpreted as billing or
+subscription usage.
+
+## Requirements
+
+- GNOME Shell 50 (the version verified for this prerelease).
+- Codex CLI installed and signed in for account limits.
+- GJS with Gio/GLib and Soup 3 introspection data.
+- Meson, Ninja, and `glib-compile-schemas` when installing from source.
+
+## Install from source
+
+Development happens on the `dev` branch:
```bash
-git clone https://github.com/kcnewman/freeby.git && cd freeby
-meson setup build --prefix=$HOME/.local
+git clone https://github.com/kcnewman/freeby.git
+cd freeby
+git switch dev
+meson setup build --prefix="$HOME/.local"
meson install -C build
+gnome-extensions enable freeby@kelvin.local
```
-Then restart your session and enable:
+Log out and back in if GNOME Shell has not discovered the extension. An archive
+from a GitHub release can instead be installed with:
```bash
-gnome-extensions enable freeby@kelvin.local
+gnome-extensions install --force freeby@kelvin.local-2.0.0-alpha.1.zip
```
-> **Copilot users:** If `gh` isn't installed, run `copilot-setup.sh` first.
-
-### Settings
+## Settings
-| Setting | Default | Range | Description |
-|---|---|---|---|
-| Refresh interval | `120s` | 30–3600s | How often to check usage |
-| Notifications | `on` | — | Alert when a provider hits its limit |
+The alpha.1 preferences window controls refresh frequency and notifications.
+The underlying schema also supports the default provider, ordered enabled
+providers, history retention, and notification threshold; these receive their
+full preferences interface in alpha.5.
-Configure via Extension Manager or CLI:
+To opt into a preview adapter during development:
```bash
-gsettings --schemadir ~/.local/share/glib-2.0/schemas \
- set org.gnome.shell.extensions.freeby refresh-interval 60
+gsettings set org.gnome.shell.extensions.freeby enabled-providers "['codex', 'cursor', 'copilot']"
```
-### Uninstall
+Preview providers are not part of the alpha.1 compatibility promise.
+
+## Verify a checkout
```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
+npm run check
+npm run pack
+python3 tools/smoke-shell.py
```
-### Debug
+The final command starts a private headless GNOME session and does not enable the
+extension in the active desktop. Live-provider checks are intentionally separate
+from credential-free automated fixtures.
-```bash
-# test the data script
-bash ~/.local/bin/freeby.sh | python3 -m json.tool
+## Uninstall or downgrade
-# watch extension logs
-journalctl -f -o cat /usr/bin/gnome-shell
+```bash
+gnome-extensions disable freeby@kelvin.local
+rm -rf "$HOME/.local/share/gnome-shell/extensions/freeby@kelvin.local"
```
-### Contributing
-
-See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
-
-### License
+The extension-local schema is removed with that directory; shared system schema
+artifacts are never deleted. To downgrade, install an older release archive with
+`gnome-extensions install --force` and restart the session.
-[MIT](LICENSE)
+See [CONTRIBUTING.md](CONTRIBUTING.md) for development practices and
+[plan.md](plan.md) for the ordered provider milestones. Freeby is licensed under
+the [MIT License](LICENSE).
diff --git a/plan.md b/plan.md
index 4f52b15..a183157 100644
--- a/plan.md
+++ b/plan.md
@@ -1,6 +1,7 @@
# Freeby usage monitor roadmap
-Updated: 2026-09-06. Status: roadmap agreed; implementation milestones have not started.
+Updated: 2026-09-06. Status: foundation and Codex implementation complete;
+alpha.1 release publication pending.
## Agreed direction
@@ -122,14 +123,19 @@ coverage during the foundation work:
### 1. Foundation and Codex — v2.0.0-alpha.1
-- [ ] Establish the target module boundaries, versioned contract, development commands, and fixture-based tests.
-- [ ] Repair the known lifecycle, notification, quota-state, and install/uninstall regressions.
-- [ ] Implement bounded Codex collection, cached results, available quota windows, daily activity, and model totals.
-- [ ] Build the native provider header, quota meters, activity/model views, status footer, and loading/error/setup states.
-- [ ] Keep existing provider behavior working until deliberately migrated; document any verified data limitations.
-- [ ] Verify the complete Codex path, temporary installation, disable/re-enable cleanup, and offline/partial behavior.
+- [x] Establish the target module boundaries, versioned contract, development commands, and fixture-based tests.
+- [x] Repair the known lifecycle, notification, quota-state, and install/uninstall regressions.
+- [x] Implement bounded Codex collection, cached results, available quota windows, daily activity, and model totals.
+- [x] Build the native provider header, quota meters, activity/model views, status footer, and loading/error/setup states.
+- [x] Keep existing provider behavior working until deliberately migrated; document any verified data limitations.
+- [x] Verify the complete Codex path, temporary installation, disable/re-enable cleanup, and offline/partial behavior.
- [ ] Commit, tag, publish, and verify the installable alpha.1 prerelease.
+Evidence: 13 deterministic unit tests, GJS incremental-history and process
+integration checks, strict schema validation, deterministic packaging, an
+isolated GNOME Shell 50 load/disable/re-enable smoke test, and a redacted live
+Codex CLI check confirming account quota, seven-day activity, and model data.
+
### 2. Claude Code — v2.0.0-alpha.2
- [ ] Implement Claude detection, supported authentication/limits, and incremental usage history.
From fc3827c9f1f7d55234921af0b94b95040002a807 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 6 Sep 2026 08:18:16 +0000
Subject: [PATCH 15/44] chore: use canonical repository URL
---
CONTRIBUTING.md | 2 +-
README.md | 2 +-
metadata.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b5aa942..58ed454 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,7 +10,7 @@ Install Node.js 20 or newer, GJS, Soup 3 introspection data, Meson, Ninja, and t
GLib schema compiler. Then run:
```bash
-git clone https://github.com/kcnewman/freeby.git
+git clone https://github.com/oo7kc/freeby.git
cd freeby
git switch dev
npm run check
diff --git a/README.md b/README.md
index 9353d41..95f1538 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ subscription usage.
Development happens on the `dev` branch:
```bash
-git clone https://github.com/kcnewman/freeby.git
+git clone https://github.com/oo7kc/freeby.git
cd freeby
git switch dev
meson setup build --prefix="$HOME/.local"
diff --git a/metadata.json b/metadata.json
index 6febcdd..2a7c896 100644
--- a/metadata.json
+++ b/metadata.json
@@ -4,5 +4,5 @@
"description": "AI coding usage monitor for account limits, reset windows, local token activity and model breakdowns. Codex is verified; additional providers arrive through version 2 prereleases.",
"shell-version": ["50"],
"settings-schema": "org.gnome.shell.extensions.freeby",
- "url": "https://github.com/kcnewman/freeby"
+ "url": "https://github.com/oo7kc/freeby"
}
From ce641464e7ca43bbe7718f6d84ef290ca3f2acc8 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 6 Sep 2026 08:19:24 +0000
Subject: [PATCH 16/44] ci: update GitHub actions to Node 24 releases
---
.github/workflows/ci.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 202ad28..a73669d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,8 +10,8 @@ jobs:
verify:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
with:
node-version: 22
- name: Install dependencies
@@ -23,7 +23,7 @@ jobs:
meson setup build --prefix="$RUNNER_TEMP/freeby-install"
meson install -C build
test -f "$RUNNER_TEMP/freeby-install/share/gnome-shell/extensions/freeby@kelvin.local/schemas/gschemas.compiled"
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v7
with:
name: freeby-extension
path: dist/*.zip
From 6a4bad80cf75a6ff49af16d010b79a34eea8eade Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 6 Sep 2026 08:22:27 +0000
Subject: [PATCH 17/44] docs: record the alpha.1 prerelease
---
plan.md | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/plan.md b/plan.md
index a183157..6f978ec 100644
--- a/plan.md
+++ b/plan.md
@@ -1,7 +1,7 @@
# Freeby usage monitor roadmap
-Updated: 2026-09-06. Status: foundation and Codex implementation complete;
-alpha.1 release publication pending.
+Updated: 2026-09-06. Status: alpha.1 released; Claude Code is the active
+alpha.2 milestone.
## Agreed direction
@@ -129,12 +129,16 @@ coverage during the foundation work:
- [x] Build the native provider header, quota meters, activity/model views, status footer, and loading/error/setup states.
- [x] Keep existing provider behavior working until deliberately migrated; document any verified data limitations.
- [x] Verify the complete Codex path, temporary installation, disable/re-enable cleanup, and offline/partial behavior.
-- [ ] Commit, tag, publish, and verify the installable alpha.1 prerelease.
+- [x] Commit, tag, publish, and verify the installable alpha.1 prerelease.
Evidence: 13 deterministic unit tests, GJS incremental-history and process
integration checks, strict schema validation, deterministic packaging, an
isolated GNOME Shell 50 load/disable/re-enable smoke test, and a redacted live
Codex CLI check confirming account quota, seven-day activity, and model data.
+Released from commit `ce64146` on `dev` as
+[`v2.0.0-alpha.1`](https://github.com/oo7kc/freeby/releases/tag/v2.0.0-alpha.1).
+The downloaded 22,488-byte archive matched the tagged CI artifact at SHA-256
+`eef63b1978b13f2adda9fe3c172cebfe7584835461f1b07f2b8ffd8b887b56fb`.
### 2. Claude Code — v2.0.0-alpha.2
From 7bd9f2021cbfe88de183f146f38f92dcb3b77bad Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 6 Sep 2026 08:27:49 +0000
Subject: [PATCH 18/44] feat: add Claude Code usage monitoring
---
meson.build | 2 +-
metadata.json | 2 +-
package.json | 2 +-
....gnome.shell.extensions.freeby.gschema.xml | 2 +-
src/collector/main.js | 22 ++-
src/core/usage.js | 11 +-
src/providers/claude.js | 150 ++++++++++++++++++
tests/integration/collector.js | 14 ++
tests/unit/claude.test.js | 68 ++++++++
tests/unit/usage.test.js | 16 +-
10 files changed, 277 insertions(+), 12 deletions(-)
create mode 100644 src/providers/claude.js
create mode 100644 tests/unit/claude.test.js
diff --git a/meson.build b/meson.build
index 6c0968f..684694c 100644
--- a/meson.build
+++ b/meson.build
@@ -1,4 +1,4 @@
-project('freeby', version: '2.0.0-alpha.1', license: 'MIT',
+project('freeby', version: '2.0.0-alpha.2', license: 'MIT',
meson_version: '>= 0.56.0',
default_options: ['warning_level=0'])
diff --git a/metadata.json b/metadata.json
index 2a7c896..959159c 100644
--- a/metadata.json
+++ b/metadata.json
@@ -1,7 +1,7 @@
{
"uuid": "freeby@kelvin.local",
"name": "Freeby",
- "description": "AI coding usage monitor for account limits, reset windows, local token activity and model breakdowns. Codex is verified; additional providers arrive through version 2 prereleases.",
+ "description": "AI coding usage monitor for account limits, reset windows, local token activity and model breakdowns. Supports Codex and Claude Code; additional providers arrive through version 2 prereleases.",
"shell-version": ["50"],
"settings-schema": "org.gnome.shell.extensions.freeby",
"url": "https://github.com/oo7kc/freeby"
diff --git a/package.json b/package.json
index efe5214..ec38f15 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "freeby",
- "version": "2.0.0-alpha.1",
+ "version": "2.0.0-alpha.2",
"private": true,
"type": "module",
"scripts": {
diff --git a/schemas/org.gnome.shell.extensions.freeby.gschema.xml b/schemas/org.gnome.shell.extensions.freeby.gschema.xml
index 982981b..93a06c8 100644
--- a/schemas/org.gnome.shell.extensions.freeby.gschema.xml
+++ b/schemas/org.gnome.shell.extensions.freeby.gschema.xml
@@ -14,7 +14,7 @@
Show desktop notifications when a provider hits its limit
- ['codex']
+ ['codex', 'claude']
Enabled providers in display order
diff --git a/src/collector/main.js b/src/collector/main.js
index fd7b87a..1512131 100644
--- a/src/collector/main.js
+++ b/src/collector/main.js
@@ -3,6 +3,7 @@ import GLib from 'gi://GLib';
import GLibUnix from 'gi://GLibUnix';
import System from 'system';
import {record, section} from '../core/usage.js';
+import {collectClaude} from '../providers/claude.js';
import {collectCodex} from '../providers/codex.js';
import {collectLegacy} from '../providers/legacy.js';
import {fingerprint, findCommand, join, readJson} from '../services/files.js';
@@ -11,8 +12,8 @@ import {requestJson} from '../services/http.js';
import {RpcClient, runCommand} from '../services/process.js';
const id = ARGV[0];
-if (!['codex', 'cursor', 'copilot'].includes(id)) {
- printerr('Usage: gjs -m src/collector/main.js [retention-days]');
+if (!['codex', 'claude', 'cursor', 'copilot'].includes(id)) {
+ printerr('Usage: gjs -m src/collector/main.js [retention-days]');
System.exit(2);
}
const retention = Math.max(7, Math.min(90, Number(ARGV[1]) || 30));
@@ -24,10 +25,22 @@ const signal = GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, 15, () => {
});
const io = {
fingerprint,
+ hasCommand(name) {
+ try { findCommand(name); return true; } catch { return false; }
+ },
scan(provider, suffixes, parser) {
- const root = GLib.getenv('CODEX_HOME') || join(GLib.get_home_dir(), '.codex');
+ const root = provider === 'codex'
+ ? GLib.getenv('CODEX_HOME') || join(GLib.get_home_dir(), '.codex')
+ : GLib.getenv('CLAUDE_CONFIG_DIR') || join(GLib.get_home_dir(), '.claude');
return scanHistory(provider, suffixes.map(suffix => join(root, suffix)), parser, {retention});
},
+ credentials(provider) {
+ if (provider === 'claude') {
+ const root = GLib.getenv('CLAUDE_CONFIG_DIR') || join(GLib.get_home_dir(), '.claude');
+ return readJson(join(root, '.credentials.json'));
+ }
+ return null;
+ },
codexClient: () => new RpcClient([findCommand('codex'), 'app-server'], cancellable),
http: (url, options) => requestJson(url, {...options, cancellable}),
async token(provider) {
@@ -49,7 +62,8 @@ const io = {
(async () => {
try {
- const result = id === 'codex' ? await collectCodex(io) : await collectLegacy(id, io);
+ const result = id === 'codex' ? await collectCodex(io)
+ : id === 'claude' ? await collectClaude(io) : await collectLegacy(id, io);
if (!cancellable.is_cancelled())
print(JSON.stringify(result));
} catch {
diff --git a/src/core/usage.js b/src/core/usage.js
index d1a95cd..dedaaba 100644
--- a/src/core/usage.js
+++ b/src/core/usage.js
@@ -1,5 +1,5 @@
export const SCHEMA_VERSION = 1;
-export const NAMES = {codex: 'Codex', cursor: 'Cursor', copilot: 'Copilot'};
+export const NAMES = {codex: 'Codex', claude: 'Claude Code', cursor: 'Cursor', copilot: 'Copilot'};
export const STATES = new Set(['loading', 'ready', 'partial', 'stale', 'missing-auth', 'unsupported', 'unavailable']);
export const WINDOW_STATES = new Set(['active', 'exhausted', 'unlimited']);
@@ -42,7 +42,8 @@ export function windowUsage({id, label, usedPercent, used, limit, unit = 'percen
export function validTime(value) {
if (value === null || value === undefined || value === '')
return null;
- const ms = typeof value === 'number' ? value : Date.parse(value);
+ 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;
}
@@ -78,7 +79,11 @@ export function mergeRecord(previous, next) {
const current = next[key];
if (['unavailable', 'missing-auth'].includes(current.status) && old?.updatedAt &&
['ready', 'partial', 'stale'].includes(old.status)) {
- result[key] = {...old, status: 'stale', message: current.message};
+ const preserved = key === 'limits'
+ ? {...old, windows: old.windows.filter(window => !window.resetsAt || window.resetsAt > Date.now())}
+ : old;
+ if (key !== 'limits' || preserved.windows.length)
+ result[key] = {...preserved, status: 'stale', message: current.message};
}
}
result.plan ??= previous.plan;
diff --git a/src/providers/claude.js b/src/providers/claude.js
new file mode 100644
index 0000000..737fd96
--- /dev/null
+++ b/src/providers/claude.js
@@ -0,0 +1,150 @@
+import {localDate, number, record, section, validTime, windowUsage} from '../core/usage.js';
+
+const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
+
+function planLabel(tier, subscription) {
+ const match = String(tier || '').match(/max_(\d+x)/i);
+ if (match)
+ return `Max ${match[1]}`;
+ const value = String(subscription || '').trim();
+ 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: String(login.accessToken || '') || null,
+ 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', 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 = [];
+ 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) {
+ const model = item?.scope?.model;
+ const name = String(model?.display_name || model?.id || '').trim();
+ const kind = String(item?.kind || '').trim();
+ 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('ready'), updatedAt: now, scope: 'account', source: 'Anthropic OAuth usage', windows}
+ : {...section('unavailable', 'Claude did not report any supported quota windows.'), scope: 'account', 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 (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 = 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 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 <= Date.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);
+ } 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/tests/integration/collector.js b/tests/integration/collector.js
index 6b32b3d..903def4 100644
--- a/tests/integration/collector.js
+++ b/tests/integration/collector.js
@@ -3,6 +3,7 @@ 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 {readJson, writeJson, join} from '../../src/services/files.js';
import {runCommand} from '../../src/services/process.js';
@@ -34,6 +35,19 @@ writeJson(join(scratch, 'record.json'), {safe: true});
assert(readJson(join(scratch, 'record.json')).safe, 'atomic JSON roundtrip');
print('PASS: GJS history initial scan, cached scan, append, partial line, and private JSON cache');
+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');
+
const loop = new GLib.MainLoop(null, false);
let failed = false;
(async () => {
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/usage.test.js b/tests/unit/usage.test.js
index 159cd26..ec14532 100644
--- a/tests/unit/usage.test.js
+++ b/tests/unit/usage.test.js
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import {aggregateEvents, highestUsage, mergeRecord, number, recentDates, record, validateRecord, windowUsage} from '../../src/core/usage.js';
+import {aggregateEvents, highestUsage, mergeRecord, number, recentDates, record, validTime, validateRecord, windowUsage} from '../../src/core/usage.js';
import {resetTime, tokens} from '../../src/core/format.js';
import {ThresholdTracker} from '../../src/core/notifications.js';
@@ -45,6 +45,19 @@ test('failure preserves last successful values as stale, account change discards
assert.equal(mergeRecord(old, next).limits.windows.length, 0);
});
+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: Date.now() + 60000},
+ ]};
+ const next = record('claude');
+ next.limits.status = 'unavailable';
+ const result = mergeRecord(old, next);
+ 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());
@@ -57,6 +70,7 @@ test('daily and model totals use the same period and deduplicate events', () =>
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 require a verified crossing and deduplicate warning and limit separately', () => {
From 48b28acf925efd0aea0fad158ff24a4080e9d8a7 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 6 Sep 2026 08:27:58 +0000
Subject: [PATCH 19/44] docs: prepare the alpha.2 milestone release
---
CHANGELOG.md | 25 ++++++++++++++++++++++++
README.md | 28 ++++++++++++++++-----------
docs/providers/claude.md | 41 ++++++++++++++++++++++++++++++++++++++++
plan.md | 15 ++++++++++-----
4 files changed, 93 insertions(+), 16 deletions(-)
create mode 100644 docs/providers/claude.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9f80d6..a89cead 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,31 @@
All notable changes are documented here. Freeby uses semantic prerelease versions
while version 2 is developed on the `dev` branch.
+## 2.0.0-alpha.2 - 2026-09-06
+
+### Added
+
+- Claude Code detection and saved OAuth sign-in handling, including explicit
+ missing, absent, and expired authentication states.
+- Anthropic 5-hour, weekly, and model-scoped quota parsing with both current
+ percentage and older fractional utilization normalization.
+- Incremental Claude Code transcript scanning with message deduplication and
+ per-model input, output, cache-read, and cache-write totals.
+- Provider switching between Codex and Claude Code using the shared native UI.
+
+### Changed
+
+- New installations enable Codex and Claude Code by default.
+- Expired cached quota windows are discarded instead of being displayed as stale
+ after their reset time.
+
+### Verification limits
+
+- Claude Code 2.1.218 detection and the unauthenticated path were exercised on
+ this machine. Quota responses, expired authentication, duplicates, and cache
+ semantics were verified with synthetic fixtures against the current upstream
+ interface; no signed-in Claude account was available for a live quota probe.
+
## 2.0.0-alpha.1 - 2026-09-06
### Added
diff --git a/README.md b/README.md
index 95f1538..1b2735e 100644
--- a/README.md
+++ b/README.md
@@ -4,16 +4,18 @@ Freeby is a native GNOME Shell usage monitor for AI coding tools. It keeps
account limits, reset windows, recent local token activity, and model totals one
click away in the top panel.
-Version 2 is being delivered provider by provider. `v2.0.0-alpha.1` is the Codex
-milestone: Codex limits and local activity are verified end to end. Cursor and
-Copilot adapters remain available as opt-in previews while their dedicated
-milestones are completed; Claude Code follows next.
+Version 2 is being delivered provider by provider. `v2.0.0-alpha.2` adds Claude
+Code to the Codex foundation. Cursor and Copilot adapters remain available as
+opt-in previews while their dedicated milestones are completed.
-## What the Codex milestone includes
+## What the current prerelease includes
- Account quota windows from the installed Codex CLI's app-server interface.
+- Claude Code 5-hour, weekly, and model-scoped limits through its saved OAuth
+ sign-in, with clear missing/expired-authentication states.
- Reset countdowns without guessing missing values.
-- Seven-day local token activity and per-model input, output, and cache totals.
+- Seven-day local Codex and Claude Code activity with per-model input, output,
+ cache-read, and cache-write totals.
- Cached results shown as stale while a provider independently refreshes.
- Explicit unavailable, unsupported, missing-authentication, and exhausted states.
- Bounded collectors, refresh backoff, wake refresh, cancellation on disable, and
@@ -28,7 +30,9 @@ subscription usage.
## Requirements
- GNOME Shell 50 (the version verified for this prerelease).
-- Codex CLI installed and signed in for account limits.
+- Codex CLI installed and signed in for Codex account limits.
+- Claude Code installed and signed in for Claude account limits; local Claude
+ transcripts remain useful independently.
- GJS with Gio/GLib and Soup 3 introspection data.
- Meson, Ninja, and `glib-compile-schemas` when installing from source.
@@ -49,12 +53,12 @@ Log out and back in if GNOME Shell has not discovered the extension. An archive
from a GitHub release can instead be installed with:
```bash
-gnome-extensions install --force freeby@kelvin.local-2.0.0-alpha.1.zip
+gnome-extensions install --force freeby@kelvin.local-2.0.0-alpha.2.zip
```
## Settings
-The alpha.1 preferences window controls refresh frequency and notifications.
+The alpha.2 preferences window controls refresh frequency and notifications.
The underlying schema also supports the default provider, ordered enabled
providers, history retention, and notification threshold; these receive their
full preferences interface in alpha.5.
@@ -62,10 +66,12 @@ full preferences interface in alpha.5.
To opt into a preview adapter during development:
```bash
-gsettings set org.gnome.shell.extensions.freeby enabled-providers "['codex', 'cursor', 'copilot']"
+gsettings set org.gnome.shell.extensions.freeby enabled-providers "['codex', 'claude', 'cursor', 'copilot']"
```
-Preview providers are not part of the alpha.1 compatibility promise.
+Preview providers are not part of the alpha.2 compatibility promise. See the
+[Claude provider notes](docs/providers/claude.md) for scope and compatibility
+details.
## Verify a checkout
diff --git a/docs/providers/claude.md b/docs/providers/claude.md
new file mode 100644
index 0000000..9ce8fb1
--- /dev/null
+++ b/docs/providers/claude.md
@@ -0,0 +1,41 @@
+# Claude Code provider
+
+Freeby keeps Claude's account limits and local activity as separate sources.
+Failure of one source does not erase valid data from the other.
+
+## Account limits
+
+The collector reads only the `claudeAiOauth` fields needed from
+`$CLAUDE_CONFIG_DIR/.credentials.json` (or `~/.claude/.credentials.json`) and
+sends the access token only to `https://api.anthropic.com/api/oauth/usage`.
+It supports the current 5-hour, weekly, and model-scoped response buckets. The
+token itself is never cached or logged; only a SHA-256 account fingerprint may
+be retained to prevent stale data crossing accounts.
+
+This OAuth usage interface is used by current Claude Code integrations but is
+not a stable public Anthropic API contract. Freeby treats unknown/empty payloads
+as unavailable, retains only unexpired stale windows after transient failures,
+and keeps local history visible when sign-in expires or the endpoint is offline.
+
+## Local activity
+
+Claude Code assistant-message usage is scanned incrementally from
+`$CLAUDE_CONFIG_DIR/projects/**/*.jsonl` or `~/.claude/projects/**/*.jsonl`.
+Freeby reads timestamps, model identifiers, message/session identifiers, and the
+four usage counters only. Prompt and response content is ignored.
+
+`input_tokens`, `output_tokens`, `cache_read_input_tokens`, and
+`cache_creation_input_tokens` are independent Anthropic counters, so Freeby adds
+all four to activity totals without subtracting cache tokens from input. Repeated
+message IDs are counted once. Session identifiers are hashed before derived data
+is written to Freeby's XDG cache.
+
+History is local to this device, covers the displayed seven-day period, and is
+not a measure of subscription quota or billable cost.
+
+## Alpha.2 verification
+
+- Claude Code 2.1.218 installation detection and missing-auth behavior: live.
+- Standard/scoped limits, percentage normalization, expired auth, endpoint auth
+ errors, duplicate messages, and token categories: deterministic fixtures.
+- Signed-in account quota probe: not available on the development machine.
diff --git a/plan.md b/plan.md
index 6f978ec..245802a 100644
--- a/plan.md
+++ b/plan.md
@@ -1,7 +1,7 @@
# Freeby usage monitor roadmap
-Updated: 2026-09-06. Status: alpha.1 released; Claude Code is the active
-alpha.2 milestone.
+Updated: 2026-09-06. Status: Claude Code implementation complete; alpha.2
+release publication pending.
## Agreed direction
@@ -142,11 +142,16 @@ The downloaded 22,488-byte archive matched the tagged CI artifact at SHA-256
### 2. Claude Code — v2.0.0-alpha.2
-- [ ] Implement Claude detection, supported authentication/limits, and incremental usage history.
-- [ ] Add provider switching and independent partial-data handling using the shared contract.
-- [ ] Verify quota windows, daily/model totals, duplicate handling, missing credentials, and expired authentication.
+- [x] Implement Claude detection, supported authentication/limits, and incremental usage history.
+- [x] Add provider switching and independent partial-data handling using the shared contract.
+- [x] Verify quota windows, daily/model totals, duplicate handling, missing credentials, and expired authentication.
- [ ] Commit, tag, publish, and verify the installable alpha.2 prerelease.
+Evidence: Claude Code 2.1.218 detection and missing-auth collection were checked
+locally. Synthetic quota, expired-auth, repeated-message, cache-token, and
+private-cache fixtures pass. An authenticated Claude account was not available,
+so the OAuth endpoint path is upstream- and fixture-validated rather than live.
+
### 3. Cursor — v2.0.0-alpha.3
- [ ] Revalidate current Cursor authentication and usage sources for free and paid accounts.
From 24b513ae2aea5f60545fb562c318c9f5814b7247 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 6 Sep 2026 08:30:23 +0000
Subject: [PATCH 20/44] docs: record the alpha.2 prerelease
---
plan.md | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/plan.md b/plan.md
index 245802a..c70b40b 100644
--- a/plan.md
+++ b/plan.md
@@ -1,7 +1,7 @@
# Freeby usage monitor roadmap
-Updated: 2026-09-06. Status: Claude Code implementation complete; alpha.2
-release publication pending.
+Updated: 2026-09-06. Status: alpha.2 released; Cursor is the active alpha.3
+milestone.
## Agreed direction
@@ -145,12 +145,16 @@ The downloaded 22,488-byte archive matched the tagged CI artifact at SHA-256
- [x] Implement Claude detection, supported authentication/limits, and incremental usage history.
- [x] Add provider switching and independent partial-data handling using the shared contract.
- [x] Verify quota windows, daily/model totals, duplicate handling, missing credentials, and expired authentication.
-- [ ] Commit, tag, publish, and verify the installable alpha.2 prerelease.
+- [x] Commit, tag, publish, and verify the installable alpha.2 prerelease.
Evidence: Claude Code 2.1.218 detection and missing-auth collection were checked
locally. Synthetic quota, expired-auth, repeated-message, cache-token, and
private-cache fixtures pass. An authenticated Claude account was not available,
so the OAuth endpoint path is upstream- and fixture-validated rather than live.
+Released from commit `48b28ac` on `dev` as
+[`v2.0.0-alpha.2`](https://github.com/oo7kc/freeby/releases/tag/v2.0.0-alpha.2).
+The downloaded 25,249-byte archive matched the tagged CI artifact at SHA-256
+`04f8417dd0fd0cafdc636293134ce80bf1477a0e6b39342e9f3681bffbef6fae`.
### 3. Cursor — v2.0.0-alpha.3
From 50620737f8270f56f49beaba561d3be991632699 Mon Sep 17 00:00:00 2001
From: kcnewman
Date: Sun, 6 Sep 2026 08:37:34 +0000
Subject: [PATCH 21/44] chore: organize repository structure
---
.AGENTS/README.md | 17 ++
.../plans/archive}/v1-notes.md | 2 +-
plan.md => .AGENTS/plans/roadmap.md | 44 +++--
.AGENTS/references/claude-usage.jpeg | Bin 0 -> 47094 bytes
.AGENTS/references/codex-usage.png | Bin 0 -> 82644 bytes
.AGENTS/rules/architecture.md | 27 +++
.AGENTS/rules/quality.md | 24 +++
.AGENTS/rules/releases.md | 22 +++
.gitignore | 12 +-
AGENTS.md | 45 +----
CHANGELOG.md | 17 ++
CONTRIBUTING.md | 5 +-
README.md | 5 +-
docs/README.md | 11 ++
docs/architecture.md | 48 +++++
docs/assets/README.md | 8 +
.../screenshots/v1/panel-indicator.png} | Bin
.../screenshots/v1/usage-menu.png} | Bin
docs/providers/README.md | 12 ++
indicator.js | 140 -------------
meson.build | 2 +-
package.json | 2 +-
scripts/copilot-setup.sh | 79 --------
scripts/freeby.sh | 187 ------------------
src/collector/main.js | 6 +-
src/providers/copilot.js | 33 ++++
src/providers/cursor.js | 29 +++
src/providers/legacy.js | 45 -----
tests/freeby.bats | 42 ----
tools/check.js | 32 ++-
tools/package.py | 3 +-
31 files changed, 332 insertions(+), 567 deletions(-)
create mode 100644 .AGENTS/README.md
rename {docs/planning => .AGENTS/plans/archive}/v1-notes.md (98%)
rename plan.md => .AGENTS/plans/roadmap.md (89%)
create mode 100644 .AGENTS/references/claude-usage.jpeg
create mode 100644 .AGENTS/references/codex-usage.png
create mode 100644 .AGENTS/rules/architecture.md
create mode 100644 .AGENTS/rules/quality.md
create mode 100644 .AGENTS/rules/releases.md
create mode 100644 docs/README.md
create mode 100644 docs/architecture.md
create mode 100644 docs/assets/README.md
rename docs/{s1.png => assets/screenshots/v1/panel-indicator.png} (100%)
rename docs/{s2.png => assets/screenshots/v1/usage-menu.png} (100%)
create mode 100644 docs/providers/README.md
delete mode 100644 indicator.js
delete mode 100644 scripts/copilot-setup.sh
delete mode 100755 scripts/freeby.sh
create mode 100644 src/providers/copilot.js
create mode 100644 src/providers/cursor.js
delete mode 100644 src/providers/legacy.js
delete mode 100644 tests/freeby.bats
diff --git a/.AGENTS/README.md b/.AGENTS/README.md
new file mode 100644
index 0000000..2c33f61
--- /dev/null
+++ b/.AGENTS/README.md
@@ -0,0 +1,17 @@
+# Agent workspace
+
+This directory is the single home for agent guidance, active plans, historical
+planning context, and implementation reference material. The root `AGENTS.md`
+exists only because agent tooling discovers that conventional filename.
+
+Read these always-applicable rules before making changes:
+
+1. [Product and architecture](rules/architecture.md)
+2. [Quality, privacy, and verification](rules/quality.md)
+3. [Version control and releases](rules/releases.md)
+
+Use [plans/roadmap.md](plans/roadmap.md) as the active milestone record. Material
+under [plans/archive/](plans/archive/) is historical and must not override the
+active roadmap. Visuals under [references/](references/) are design references,
+not runtime assets, and are excluded from extension archives by the packaging
+allowlist.
diff --git a/docs/planning/v1-notes.md b/.AGENTS/plans/archive/v1-notes.md
similarity index 98%
rename from docs/planning/v1-notes.md
rename to .AGENTS/plans/archive/v1-notes.md
index 38e1e86..ed28286 100644
--- a/docs/planning/v1-notes.md
+++ b/.AGENTS/plans/archive/v1-notes.md
@@ -4,7 +4,7 @@ Preserved from the original local `plan.md` on 2026-09-06. These are historical
investigation notes, not verified descriptions of the current implementation.
Some claims about disabled providers, completed fixes, quota periods, and
packaging differ from the current code. Revalidate any provider-specific
-assumptions before using them. The active roadmap is [../../plan.md](../../plan.md).
+assumptions before using them. The active roadmap is [../roadmap.md](../roadmap.md).
---
diff --git a/plan.md b/.AGENTS/plans/roadmap.md
similarity index 89%
rename from plan.md
rename to .AGENTS/plans/roadmap.md
index c70b40b..1b6e2be 100644
--- a/plan.md
+++ b/.AGENTS/plans/roadmap.md
@@ -1,7 +1,7 @@
# Freeby usage monitor roadmap
-Updated: 2026-09-06. Status: alpha.2 released; Cursor is the active alpha.3
-milestone.
+Updated: 2026-09-06. Status: post-alpha.2 repository organization complete;
+Cursor is the active alpha.3 milestone.
## Agreed direction
@@ -21,8 +21,8 @@ cover all four providers; earlier prereleases introduce them incrementally.
## Reference analysis and interface
-The [Claude reference](reference-images/1.jpeg) and
-[Codex reference](reference-images/2.png) share this structure:
+The [Claude reference](../references/claude-usage.jpeg) and
+[Codex reference](../references/codex-usage.png) share this structure:
1. Provider mark, name, and reported plan.
2. A selector for connected/enabled providers; omit the selector when only one exists.
@@ -52,35 +52,31 @@ process. Avoid a persistent background daemon in this first iteration.
```text
freeby/
-├── AGENTS.md
-├── plan.md
+├── .AGENTS/ # Rules, roadmap, archive, design references
+├── AGENTS.md # Tool-discovery entrypoint
├── extension.js # GNOME lifecycle entry
├── prefs.js # Preferences entry
├── metadata.json
├── stylesheet.css
├── src/
│ ├── core/ # Contract, validation, aggregation, formatting
-│ ├── providers/ # Codex, Claude, Cursor, Copilot adapters
+│ ├── providers/ # One adapter per provider
│ ├── services/ # Scheduling, cache, processes, notifications
│ ├── collector/ # Managed GJS collection entry
│ ├── ui/ # Indicator, selector, meters, charts
-│ └── preferences/ # Configuration pages
├── schemas/
-├── assets/
├── tests/
│ ├── unit/
-│ ├── integration/
-│ └── fixtures/
+│ └── integration/
├── tools/ # Development and packaging commands
-├── docs/ # Architecture, provider contracts, release guide
-├── reference-images/ # Development references, excluded from archives
+├── docs/ # Architecture, provider docs, historical screenshots
├── meson.build
└── .github/workflows/
```
-This is a target layout, not a claim that these modules already exist. Move code
-in reviewable steps and preserve existing behavior until its replacement is
-verified. Keep transitional scripts only while needed; do not ship unused code.
+Keep this layout current as modules land. Add a directory only when it owns real
+content, preserve existing behavior during moves, and do not retain superseded
+runtime implementations.
### Usage contract
@@ -156,6 +152,20 @@ Released from commit `48b28ac` on `dev` as
The downloaded 25,249-byte archive matched the tagged CI artifact at SHA-256
`04f8417dd0fd0cafdc636293134ce80bf1477a0e6b39342e9f3681bffbef6fae`.
+### Repository organization checkpoint
+
+- [x] Consolidate agent rules, active/historical plans, and visual references
+ under `.AGENTS/` while retaining the root discovery entrypoint.
+- [x] Remove obsolete v1 runtime/test files and split provider ownership cleanly.
+- [x] Add architecture/documentation indexes and enforce the intended layout in
+ development checks.
+- [x] Verify and commit the organization checkpoint on `dev` before alpha.3.
+
+Evidence: `npm run verify` passed 20 unit tests, the Codex/Claude GJS history
+and subprocess integration suite, strict schema/layout/link validation, and a
+deterministic alpha.3-dev package. The exact archive loaded, disabled, and
+re-enabled without extension errors in an isolated GNOME Shell 50 session.
+
### 3. Cursor — v2.0.0-alpha.3
- [ ] Revalidate current Cursor authentication and usage sources for free and paid accounts.
@@ -222,7 +232,7 @@ Documentation setup is preparatory work and does not trigger a product prereleas
- [Omarchy Agents architecture and panel behavior](https://github.com/omacom/omarchy/blob/quattro/shell/plugins/agents/README.md)
- [Codex app-server documentation](https://learn.chatgpt.com/docs/app-server)
- [GNOME extension review guidelines](https://gjs.guide/extensions/review-guidelines/review-guidelines.html)
-- [Archived v1 planning notes](docs/planning/v1-notes.md), including a Cursor no-subscription investigation that requires revalidation.
+- [Archived v1 planning notes](archive/v1-notes.md), including a Cursor no-subscription investigation that requires revalidation.
Sources describe the state inspected during planning; verify them again before
depending on a provider interface or publishing compatibility claims. If upstream
diff --git a/.AGENTS/references/claude-usage.jpeg b/.AGENTS/references/claude-usage.jpeg
new file mode 100644
index 0000000000000000000000000000000000000000..65a16234f2a3f74ade20b31e8074f2c5994b6cbc
GIT binary patch
literal 47094
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