feat: track usage per provider profile #1

Merged
dusan merged 4 commits from feat/provider-profiles into main 2026-08-11 16:40:55 +00:00
Owner

Makes the polled unit a provider profile instead of a hardcoded harness, so several accounts of the same harness can be tracked at once.

Neither harness takes an account argument — both select an account by the config directory they read. A profile is therefore a name plus the two paths that identify one account:

Harness Config dir How the profile reaches it
Claude Code ~/.claude reads <configPath>/.credentials.json
Codex ~/.codex passes CODEX_HOME=<configPath> to the app-server child

An empty path means the harness default, so the built-in profiles carry no absolute paths that could go stale.

What changed

  • Model and storage — src/harness/profiles.ts (pure, unit-tested) plus src/lib/profiles-store.ts as the only GSettings gateway. One provider-profiles key holding a JSON array, and profiles-seeded so that deleting every profile is a decision that survives a restart.
  • Migration — on first run the defaults are built from enabled-harnesses and codex-cli-path. Both keys stay in the schema, marked deprecated, read once. An existing install comes up looking exactly as it did.
  • Polling — states key on profile id. Harnesses poll concurrently, profiles within one harness in turn: three Codex profiles at once would mean three app-server children competing. A snapshot is dropped when its profile's paths change, since it then describes a different account. Renaming or reordering redraws from what is known instead of re-polling.
  • Preferences — a Providers list: expander per profile with name, executable, config directory (with folder picker), enable switch, move up/down, and remove with confirmation, plus an Add provider dialog. Warnings are advisory: missing executable, missing folder, folder with no sign-in, and "reads the same account as X".
  • Sign-in remedies carry the profile's own paths, e.g. CLAUDE_CONFIG_DIR=/home/you/.claude-work claude auth login. The bare command would sign the default account in again and leave the account the user was told about untouched.
  • Drive-by fix — Soup.Message.get_status() throws in GJS for codes outside the Soup.Status enum, so the existing 429 branch was unreachable: a rate limit surfaced as 429 is not a valid value for enumeration Status. Read from the status-code property instead. Found while probing; more accounts on one interval makes 429s likelier.

Verification

  • 19 new unit tests, 71 total pass; just check clean; just pack includes the new modules.
  • Migration exercised against a throwaway keyfile GSettings backend: legacy upgrade, idempotent reseeding, emptied list stays empty, garbage JSON yields an empty list.
  • A six-profile poll end to end: both real accounts read, a bogus Claude config dir → not-signed-in, an alternate CODEX_HOME → not-signed-in (the child confirmed codexHome), unknown-harness profile skipped, disabled profile absent.
  • The preferences UI driven headlessly against the real libadwaita widgets — rows, warnings, switch, rename, reorder, both dialogs. This caught one bug, since fixed: clearing a name restored a label captured when the row was built.

Not covered by automated checks: panel chips and dropdown tab rendering, which need the extension loaded in a Shell.

Breaking change

enabled-harnesses and codex-cli-path no longer control polling; they are read once to seed the initial profiles.

Makes the polled unit a **provider profile** instead of a hardcoded harness, so several accounts of the same harness can be tracked at once. Neither harness takes an account argument — both select an account by the config directory they read. A profile is therefore a name plus the two paths that identify one account: | Harness | Config dir | How the profile reaches it | |---|---|---| | Claude Code | `~/.claude` | reads `<configPath>/.credentials.json` | | Codex | `~/.codex` | passes `CODEX_HOME=<configPath>` to the `app-server` child | An empty path means the harness default, so the built-in profiles carry no absolute paths that could go stale. ## What changed - **Model and storage** — `src/harness/profiles.ts` (pure, unit-tested) plus `src/lib/profiles-store.ts` as the only GSettings gateway. One `provider-profiles` key holding a JSON array, and `profiles-seeded` so that deleting every profile is a decision that survives a restart. - **Migration** — on first run the defaults are built from `enabled-harnesses` and `codex-cli-path`. Both keys stay in the schema, marked deprecated, read once. An existing install comes up looking exactly as it did. - **Polling** — states key on profile id. Harnesses poll concurrently, profiles within one harness in turn: three Codex profiles at once would mean three app-server children competing. A snapshot is dropped when its profile's paths change, since it then describes a different account. Renaming or reordering redraws from what is known instead of re-polling. - **Preferences** — a Providers list: expander per profile with name, executable, config directory (with folder picker), enable switch, move up/down, and remove with confirmation, plus an *Add provider* dialog. Warnings are advisory: missing executable, missing folder, folder with no sign-in, and "reads the same account as X". - **Sign-in remedies** carry the profile's own paths, e.g. `CLAUDE_CONFIG_DIR=/home/you/.claude-work claude auth login`. The bare command would sign the default account in again and leave the account the user was told about untouched. - **Drive-by fix** — `Soup.Message.get_status()` throws in GJS for codes outside the `Soup.Status` enum, so the existing 429 branch was unreachable: a rate limit surfaced as `429 is not a valid value for enumeration Status`. Read from the `status-code` property instead. Found while probing; more accounts on one interval makes 429s likelier. ## Verification - 19 new unit tests, 71 total pass; `just check` clean; `just pack` includes the new modules. - Migration exercised against a throwaway keyfile GSettings backend: legacy upgrade, idempotent reseeding, emptied list stays empty, garbage JSON yields an empty list. - A six-profile poll end to end: both real accounts read, a bogus Claude config dir → `not-signed-in`, an alternate `CODEX_HOME` → `not-signed-in` (the child confirmed `codexHome`), unknown-harness profile skipped, disabled profile absent. - The preferences UI driven headlessly against the real libadwaita widgets — rows, warnings, switch, rename, reorder, both dialogs. This caught one bug, since fixed: clearing a name restored a label captured when the row was built. Not covered by automated checks: panel chips and dropdown tab rendering, which need the extension loaded in a Shell. ## Breaking change `enabled-harnesses` and `codex-cli-path` no longer control polling; they are read once to seed the initial profiles.
Soup.Message.get_status() marshals its return value into the Soup.Status
enum, which throws for any code the enum does not list — 429 among them.
The 429 branch in fromStatus() could therefore never be reached: instead
of "rate limited (429, retry after 3568s)" the user got a thrown "429 is
not a valid value for enumeration Status" surfaced as a poll error.

Read the status-code property instead, which marshals as a plain uint.
Verified against the live endpoint while it was rate limiting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A harness has no account argument: both Claude Code and Codex select an
account by the config directory they read (CLAUDE_CONFIG_DIR, CODEX_HOME).
A provider profile is therefore a name plus the two paths that identify one
account, which is what lets several accounts of the same harness be tracked
at once.

Profiles live in one `provider-profiles` key as a JSON array rather than a
GVariant tuple, because the shape will grow and a tuple would make every
added field a schema migration. The parser fails soft field by field: this
runs during enable(), so a value from a newer version, a truncated write or
a hand edit in dconf must cost a profile at worst, never the extension.

`profiles-seeded` records that the defaults have been written, carrying over
`enabled-harnesses` and `codex-cli-path` once. Both legacy keys stay in the
schema, marked deprecated, so a downgrade still finds what it expects. The
flag rather than an emptiness check is what makes "I deleted every profile"
a decision that survives a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The polled unit becomes a profile instead of a harness. Every adapter is
handed the profile's own executable and config directory: Claude Code reads
the credentials file inside it, Codex passes it to the app-server child as
CODEX_HOME. Chips, tabs and dropdown pages key on the profile id and are
named by its label, so two accounts on one harness are told apart.

Harnesses are polled concurrently, profiles within a harness in turn: three
Codex profiles at once would mean three app-server children competing, and
two simultaneous reads of one usage endpoint are no faster than two
consecutive ones.

Two details that would otherwise misreport whose usage is whose:

- A snapshot is discarded when its profile's paths change, since it then
  describes a different account.
- Claude Code's unexpected-response strike counter is keyed by profile, so
  one account's odd response cannot send the user to the issue tracker over
  another account that is answering fine.

A sign-in remedy now carries the profile's own paths in the command it
offers, e.g. `CLAUDE_CONFIG_DIR=~/.claude-work claude auth login` — the bare
command would sign the default account in again and leave the account the
user was told about untouched.

Renaming or reordering a profile no longer re-polls: it changes nothing
about the accounts, and a request per profile per preferences keystroke is
how a rate limit is earned.

BREAKING CHANGE: `enabled-harnesses` and `codex-cli-path` no longer control
polling. They are read once to create the initial profiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the harness switches and the single executable entry with a
Providers list: one expander per profile holding its name, executable,
config directory and a folder chooser, plus an enable switch, move up/down
(profile order is panel order) and a remove button that confirms first.

The whole list is written back as one value, so there is no partial state to
reconcile. Structural edits rebuild the rows; editing a field only refreshes
the row it belongs to, because rebuilding under the cursor would collapse the
expander being typed in. The section ignores its own change signal by
remembering the last value it wrote.

Warnings are advisory, never blocking — a folder can be created and an
account signed into later, and two profiles sharing a directory is a
legitimate if odd setup. They cover a missing executable, a missing folder,
a folder with no sign-in in it, and a profile that reads the same account as
another.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dusan merged commit fea9fe75d7 into main 2026-08-11 16:40:55 +00:00
dusan deleted branch feat/provider-profiles 2026-08-11 16:40:55 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dusan/gnome-shell-extension-clankers!1
No description provided.