Skip to content

Directory-keyed accounts for Codex and Claude (SOU-285 phase 1)#103

Merged
tsouth89 merged 9 commits into
mainfrom
sou-285-codex-accounts
Jul 22, 2026
Merged

Directory-keyed accounts for Codex and Claude (SOU-285 phase 1)#103
tsouth89 merged 9 commits into
mainfrom
sou-285-codex-accounts

Conversation

@tsouth89

@tsouth89 tsouth89 commented Jul 21, 2026

Copy link
Copy Markdown
Owner

SOU-285 phase 1: first-class multi-account switching for Codex and Claude, plus the log attribution it unlocks (SOU-297).

An account is a directory, not a token

Codex was excluded from TokenAccountSupport; Claude's entry there is sessionKey-cookie paste. Neither fits, because both CLIs rotate their OAuth credential in place — a copy Ceiling stored would go stale within hours and there is no refresh flow to renew it.

So an account is a config directory: CODEX_HOME / CLAUDE_CONFIG_DIR. The CLI keeps the credential fresh, Ceiling never holds a provider credential of its own, and it is not extra setup — each CLI supports one login per directory, so anyone with two accounts already has two directories. Claude being the second instance of the shape put the list/store/switch machinery in a generic account_dirs module both providers parameterize.

Switching stays automatic. With no accounts configured, both providers follow whichever account the CLI is signed in as, exactly as today. Configuring accounts is what opts into tracking specific ones.

Bugs found on the way

  • Claude's credential path ignored CLAUDE_CONFIG_DIR while the log scanners already honored it, so a second profile's activity was readable but its credentials were not — and anyone already running with it set was reading the wrong credential.
  • Only Grok ever populated account_email. Both CapacityEventObserver and EnforcementTracker scope by it, so every Codex/Claude account hashed to the same scope: two accounts would have shared one capacity baseline and a switch would have inherited the outgoing seat's history. Both providers now stamp identity, and the observer re-baselines per scope on its own, so there is no reset step to write.
  • The Codex credential cache was a single shared slot, so two accounts would evict each other on every alternating fetch.

No leaking between seats

An explicitly configured account never falls back to CODEXBAR_CLAUDE_OAUTH_TOKEN or the OS credential store. Both are single global slots with no way to tell which account they hold, so serving one under a chosen account's label would show another seat's usage as that account's. A seat that is not signed in reports that instead. Switching, adding and removing all drop the provider's cached reading before refetching.

Attribution ledger (SOU-297)

I re-checked rather than trusting the earlier finding: the ChatGPT account uuid appears nowhere in ~/.codex/sessions, the only email keys there are GitHub API responses inside conversation content, and Claude's transcript keys carry no user/account/org field. Separate directories solve attribution, but most people keep one and re-run codex login.

Ceiling already reads those credentials every refresh, so it now records when the identity in a directory changes and builds a timeline. What it refuses to answer is the point: a timestamp before the first observation is Unknown, not guessed, so existing history stays unlabeled; a timestamp in the gap between seeing one account and the next is Ambiguous; only timestamps inside an observed stretch are Exact. Account keys prefer the stable uuid over the email, and a plan bucket is explicitly not a key.

UI

An Accounts tab. "Following the CLI" is its own rendered state naming the directory followed, because an empty list reads as broken when it is the correct default. Adding is check-then-add: it reports whose account is in a directory, whether it is signed in, and whether it is already configured under another label, then seeds the label. Each provider card names the account it is reporting, tinted when a color is set, and stays silent while following the CLI.

Behavior change to note

Predictive warnings start working for Codex and Claude. Corrected after merge: this was wrong.

RawSettings -> Settings hard-forces predictive_pace_warning_enabled = false on every load (rust/src/settings/raw.rs:537), with the note that the feature was experimental and is no longer exposed. There is no UI control for it. So predictive warnings cannot fire regardless of the account email, and this PR changes nothing about them. No behavior change ships here.

Verification

Checked end to end against real directories, not just fixtures: Codex resolves tsouth2@gmail.com (prolite), Claude bts@cssi.us (max). The org-restates-email case was only visible that way. Computed styles verified against the real stylesheet (tint resolves, untinted cards stay transparent, no horizontal overflow at 640px); a pixel screenshot was not possible, the browser pane hangs on capture, so the Accounts tab is worth eyeballing in a real build.

1106 Rust + 279 frontend tests passing, clippy -D warnings clean. Zero-accounts behavior is unchanged for both providers.

Codex was explicitly excluded from TokenAccountSupport, so it had no way to
track more than one seat. The other providers model an account as a pasted
token, but that does not work here: the Codex CLI rotates its OAuth tokens in
place, so a copy Ceiling stored would go stale within hours and Ceiling has no
refresh flow to renew it.

So a Codex account is a directory instead. Each CODEX_HOME already owns exactly
one auth.json that the CLI keeps fresh, which means Ceiling never has to hold an
OpenAI credential of its own, and anyone running two Codex logins on one machine
already has two homes because the CLI supports no other arrangement.

Accounts label themselves. A home's id_token carries email, chatgpt_account_id
and chatgpt_plan_type, so adding an account reads its own name rather than
asking the user to type one. The payload is decoded but not signature-verified;
it is a local file the user controls and the values are display-only. Nothing
here reads access_token or refresh_token.

With no accounts configured, everything resolves to the ambient home exactly as
before, so this is inert until the UI lands.

Also fixes a cache bug that only appears once a second account exists: the Codex
credential cache was a single shared slot, so alternating fetches between two
homes would evict each other every time and never hit. It is now keyed by path,
with expired entries dropped on insert. Collapsing the CODEX_HOME lookup into
core::ambient_codex_home also removed the third copy of that env-var logic and
made config.toml resolve per account, so a per-account base URL override no
longer leaks across seats.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8ed18be5-75f2-4602-ad05-862ea078c5db

📥 Commits

Reviewing files that changed from the base of the PR and between 812d36e and 83e89a4.

📒 Files selected for processing (20)
  • apps/desktop-tauri/src-tauri/src/auto_refresh.rs
  • apps/desktop-tauri/src-tauri/src/capacity_events.rs
  • apps/desktop-tauri/src-tauri/src/commands/bridge.rs
  • apps/desktop-tauri/src-tauri/src/commands/providers.rs
  • apps/desktop-tauri/src-tauri/src/enforcement.rs
  • apps/desktop-tauri/src-tauri/src/powertoys.rs
  • apps/desktop-tauri/src-tauri/src/tray_bridge.rs
  • apps/desktop-tauri/src/components/MenuCard.test.tsx
  • apps/desktop-tauri/src/components/MenuCard.tsx
  • apps/desktop-tauri/src/i18n/keys.ts
  • apps/desktop-tauri/src/lib/tauri.ts
  • apps/desktop-tauri/src/styles.css
  • apps/desktop-tauri/src/surfaces/Settings.tsx
  • apps/desktop-tauri/src/surfaces/settings/accounts/AccountsPanel.tsx
  • apps/desktop-tauri/src/types/bridge.ts
  • rust/src/core/account_ledger.rs
  • rust/src/core/configured_accounts.rs
  • rust/src/core/mod.rs
  • rust/src/locale.rs
  • rust/src/locale/en-US.ftl
📝 Walkthrough

Walkthrough

Adds persisted Codex and Claude directory-backed accounts, account-scoped credential and usage fetching, CLI integration, and Tauri commands for probing, adding, removing, switching, and editing accounts.

Changes

Directory-backed account model

Layer / File(s) Summary
Account model and provider identities
rust/src/core/account_dirs.rs, rust/src/core/claude_accounts.rs, rust/src/core/codex_accounts.rs, rust/src/core/configured_accounts.rs
Adds generic persisted account data, provider identity extraction, active-account selection, and provider-specific configuration stores with tests.
Provider credential scoping
rust/src/core/provider.rs, rust/src/providers/claude/..., rust/src/providers/codex/...
Scopes Claude and Codex credentials, caches, token persistence, usage snapshots, and identity metadata to selected account directories.
Account-aware fetch integration
apps/desktop-tauri/src-tauri/src/commands/providers.rs, rust/src/cli/{diagnose,serve,usage}.rs
Loads configured accounts and applies provider-specific directories to desktop refresh, diagnostic, server, and CLI usage fetches.
Desktop account command lifecycle
apps/desktop-tauri/src-tauri/src/commands/accounts.rs, apps/desktop-tauri/src-tauri/src/main.rs, apps/desktop-tauri/src-tauri/src/commands/mod.rs
Adds account bridge DTOs, validation, probe and mutation commands, cache eviction, provider refresh, IPC registration, and command tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Desktop as Desktop UI
  participant Tauri as Tauri account command
  participant Store as DirectoryAccountStore
  participant Refresh as Provider refresh
  participant Provider as Scoped provider

  Desktop->>Tauri: add or switch directory account
  Tauri->>Store: load, mutate, and save account data
  Tauri->>Refresh: evict cached reading and refresh providers
  Refresh->>Provider: fetch usage with account_config_dir
  Provider-->>Refresh: account-scoped usage snapshot
  Refresh-->>Tauri: updated provider snapshot
  Tauri-->>Desktop: account and usage state
Loading

Suggested reviewers: finesssee

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: directory-keyed accounts for Codex and Claude.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sou-285-codex-accounts

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
ceiling 83e89a4 Commit Preview URL

Branch Preview URL
Jul 22 2026, 03:27 AM

…G_DIR

Claude authenticates the same way Codex does: a CLI owns a config directory
holding one signed-in credential and rotates it in place. So it gets the same
model, and with a second instance to compare against, the account list, store,
switching and directory normalization move into a generic account_dirs module
that both providers parameterize by identity type.

The Claude side had a real gap behind it. Ceiling's log scanners already honored
CLAUDE_CONFIG_DIR, but the OAuth credential path was hardcoded to
~/.claude/.credentials.json, so a second Claude profile's activity was readable
while its credentials were not. Both sides resolve through
ambient_claude_config_dir now, which also fixes reading the wrong credential
today for anyone who already runs with CLAUDE_CONFIG_DIR set.

An explicitly configured account never falls back to the environment token or
the OS credential store. Both are single global slots with no way to tell which
account they hold, so serving one under a chosen account's label would show
another seat's usage as that account's, which is precisely the leak switching
must not have. A seat that is not signed in now says so instead. Naming the
ambient directory explicitly still counts as the ambient account, so the global
sources keep working where they always did.

Claude accounts self-label from oauthAccount in .claude.json, which carries
email, organization name and uuids. Note that file sits beside the default
config directory but inside an explicit one, so both placements are checked. A
personal account's organization is auto-named after its own email, so that name
is dropped from the label rather than rendering
"person@example.com (person@example.com's Organization)"; the subscription tier
is used instead.

Verified end to end against real directories: Codex resolves
"tsouth2@gmail.com (prolite)" and Claude "bts@cssi.us (max)".
@tsouth89 tsouth89 changed the title Add a Codex account store keyed by CODEX_HOME (SOU-285 phase 1) Directory-keyed accounts for Codex and Claude (SOU-285 phase 1) Jul 22, 2026
tsouth89 added 3 commits July 21, 2026 22:02
Threads the active account's config directory through FetchContext so both
providers read the credential of the account Ceiling is tracking rather than
whichever one the CLI happens to be signed in as.

ConfiguredAccounts holds both providers' stores and answers "which directory do
I fetch this provider from", loaded once per refresh cycle instead of per
provider. The desktop app and the CLI share it, so `codexbar usage` and the
panel can never disagree about which account they are reporting.

It deliberately answers None rather than the ambient directory when nothing is
configured. Pinning to a resolved ambient path would snapshot it when the
refresh cycle starts, so a `codex login` mid-cycle would be missed; leaving it
None lets the provider resolve at fetch time exactly as it always has. That also
keeps the no-accounts path byte-for-byte unchanged.

Providers scope themselves per fetch by cloning the reqwest client rather than
building a second connection pool.

Codex's "not signed in" error now names the directory it looked in, which is
useless detail with one account and necessary with several.
Six commands over both providers' stores: list, probe, add, remove, switch,
and relabel/tint. Only the directory path and display identity cross the
bridge, never token material.

probe_account_directory is what makes adding an account not a guessing game.
The user picks a folder and Ceiling reads back whose account it is before they
commit to it, including whether it is signed in at all and whether that folder
is already configured under another label.

Two details the UI depends on. Accounts report identity as the directory reads
right now rather than as cached when it was added, so signing into a different
account inside a folder is reflected instead of showing a stale name. And the
snapshot carries following_cli plus the ambient directory, so the UI can say
"following the CLI" as its own state rather than rendering an empty list that
looks like something is broken.

Tints are validated as plain hex. They end up interpolated into the UI, so
"red", "var(--accent)" and anything carrying a semicolon are rejected rather
than passed through.

is_signed_in is a trait method rather than a shared file-exists check, because
the two providers disagree: Claude can be signed in through the OS credential
store with no file in the directory at all.
…line

Capacity baselines and enforcement expectations are both scoped by
observation_scope, which keys on provider, source, account email and
organization. Only Grok ever populated those fields, so for Codex and Claude the
email and organization were always empty and every account hashed to the same
scope. Two accounts would therefore have shared one baseline, and switching
would have inherited the outgoing seat's history rather than re-baselining.

Both providers now stamp the identity of the account they just read. That is
enough on its own: the observer re-baselines any scope on its first live
reading, so the incoming account starts clean with no reset step to forget.
Codex falls back to the account id when the token carries no email claim, since
that still separates seats.

Switching, adding and removing all drop the provider's cached reading before
refetching. All three change which account is tracked, so without that the panel
would keep showing the outgoing seat's numbers under the incoming one's name
until the next cycle landed.

Worth noting a behavior change this unblocks: predictive warnings require an
account email and so have been inert for Codex and Claude. They will start
firing for those providers now that the email is present.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop-tauri/src-tauri/Cargo.toml`:
- Around line 33-34: Confirm the addition of the tempfile dev-dependency in the
Cargo.toml [dev-dependencies] section, retaining tempfile = "3" only if it is
required by the config-directory account command tests and approved under the
dependency guidelines.

In `@rust/src/core/account_dirs.rs`:
- Around line 217-218: Update the existing-account merge logic around
existing.identity and DirectoryAccount::new so a fresh None identity does not
overwrite the cached identity preserved by refresh_identity. Only replace
existing.identity when account.identity contains a value; retain the current
cached value when the fresh read is empty, while preserving the existing label
update.

In `@rust/src/core/claude_accounts.rs`:
- Around line 123-138: Update suggested_label so qualifier is discarded when it
equals the selected base value, preventing labels like “Acme Inc (Acme Inc)”.
Preserve the existing organization and subscription fallback behavior for
non-duplicate qualifiers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 846a32f0-6332-453b-b99b-73398888e599

📥 Commits

Reviewing files that changed from the base of the PR and between aa05de2 and 812d36e.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • apps/desktop-tauri/src-tauri/Cargo.toml
  • apps/desktop-tauri/src-tauri/src/commands/accounts.rs
  • apps/desktop-tauri/src-tauri/src/commands/mod.rs
  • apps/desktop-tauri/src-tauri/src/commands/providers.rs
  • apps/desktop-tauri/src-tauri/src/main.rs
  • rust/src/cli/diagnose.rs
  • rust/src/cli/serve.rs
  • rust/src/cli/usage.rs
  • rust/src/core/account_dirs.rs
  • rust/src/core/claude_accounts.rs
  • rust/src/core/codex_accounts.rs
  • rust/src/core/configured_accounts.rs
  • rust/src/core/mod.rs
  • rust/src/core/provider.rs
  • rust/src/providers/claude/mod.rs
  • rust/src/providers/claude/oauth/credentials_store.rs
  • rust/src/providers/claude/oauth/mod.rs
  • rust/src/providers/codex/api.rs
  • rust/src/providers/codex/mod.rs

Comment on lines +33 to +34
# Real directories for the config-directory account command tests.
tempfile = "3"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Confirm the new dev-dependency. tempfile = "3" is added to [dev-dependencies]. It is already used elsewhere in the workspace, so this is low-risk, but please confirm the addition. As per coding guidelines: "Avoid adding dependencies or tooling without confirmation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop-tauri/src-tauri/Cargo.toml` around lines 33 - 34, Confirm the
addition of the tempfile dev-dependency in the Cargo.toml [dev-dependencies]
section, retaining tempfile = "3" only if it is required by the config-directory
account command tests and approved under the dependency guidelines.

Source: Coding guidelines

Comment on lines +217 to +218
existing.label = account.label;
existing.identity = account.identity;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Re-adding a directory can blank a cached identity.

existing.identity = account.identity overwrites unconditionally, but account.identity is I::read(&config_dir) from DirectoryAccount::new, which is None while the credential is mid-rotation. This drops the cached identity that refresh_identity (Line 96) deliberately preserves and that the field doc promises to keep for labeling.

🛠️ Preserve cached identity when the fresh read is empty
             existing.label = account.label;
-            existing.identity = account.identity;
+            if account.identity.is_some() {
+                existing.identity = account.identity;
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
existing.label = account.label;
existing.identity = account.identity;
existing.label = account.label;
if account.identity.is_some() {
existing.identity = account.identity;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/src/core/account_dirs.rs` around lines 217 - 218, Update the
existing-account merge logic around existing.identity and DirectoryAccount::new
so a fresh None identity does not overwrite the cached identity preserved by
refresh_identity. Only replace existing.identity when account.identity contains
a value; retain the current cached value when the fresh read is empty, while
preserving the existing label update.

Comment on lines +123 to +138
fn suggested_label(&self) -> Option<String> {
let base = self
.email
.clone()
.or_else(|| self.organization_name.clone())
.or_else(|| self.account_uuid.clone())?;
let qualifier = self
.organization_name
.clone()
.filter(|org| !restates_email(org, self.email.as_deref()))
.or_else(|| self.subscription_type.clone());
match qualifier {
Some(qualifier) => Some(format!("{base} ({qualifier})")),
None => Some(base),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Label can duplicate the base value.

When email is None but organization_name is set, base becomes the org name and qualifier resolves to the same org (since restates_email returns false for a None email), yielding "Acme Inc (Acme Inc)". Drop the qualifier when it equals the base.

🛠️ Guard against a redundant qualifier
         let qualifier = self
             .organization_name
             .clone()
             .filter(|org| !restates_email(org, self.email.as_deref()))
-            .or_else(|| self.subscription_type.clone());
+            .or_else(|| self.subscription_type.clone())
+            .filter(|qualifier| *qualifier != base);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/src/core/claude_accounts.rs` around lines 123 - 138, Update
suggested_label so qualifier is discarded when it equals the selected base
value, preventing labels like “Acme Inc (Acme Inc)”. Preserve the existing
organization and subscription fallback behavior for non-duplicate qualifiers.

tsouth89 added 4 commits July 21, 2026 22:53
Neither Codex rollouts nor Claude transcripts carry an account identifier. I
re-checked rather than trusting the earlier finding: the ChatGPT account uuid
appears nowhere in ~/.codex/sessions, the only "email" keys there are GitHub API
responses sitting inside conversation content, and Claude's transcript keys have
no user, account or organization field at all.

Separate config directories solve attribution, since each directory's logs sit
next to the credential naming its account. But most people keep one directory
and re-run `codex login` to switch, and that case needs something else.

Ceiling already reads those credentials every refresh, so it now notes when the
identity in a directory changes and builds a timeline. Any log record can be
attributed by its timestamp.

The honest part is what it refuses to answer. A timestamp before the first
observation is Unknown, not guessed, so existing history stays unlabeled rather
than being attributed to whoever happens to be signed in today. A timestamp
falling in the gap between an observation of one account and an observation of
the next is Ambiguous: the switch happened somewhere in there, and saying which
side would be invention. Only timestamps inside an observed stretch are Exact.

Switching away and back opens a third stretch rather than widening the first, so
interleaved use stays separable. Out-of-order observations extend a stretch
forward only, so a clock change cannot rewind one.

Persisted only when a switch is detected. An extended last_seen is rebuilt by
the next observation after a restart, but a missed switch never is.

Account keys prefer the provider's stable account uuid over the email, since an
email can be changed on an account that is otherwise the same. A plan bucket is
explicitly not a key: prolite and plus were the same account at different times.
A settings tab listing each provider's config-directory accounts, with switch,
add, remove, and a check step before adding.

"Following the CLI" is its own rendered state rather than an empty list, because
an empty list reads as something being broken when it is in fact the default and
correct behavior. It names the directory being followed so there is no mystery
about where the numbers come from.

Adding an account is a check-then-add rather than a blind path entry. Check
reports whose account lives in that directory, whether it is signed in at all,
and whether it is already configured under another label, then seeds the label
so the common case needs no typing.

Each card shows the directory itself, since with several accounts the path is
the thing that distinguishes them. A signed-out account is labeled rather than
hidden, so a directory whose credential expired explains itself instead of just
reporting no data. The tint renders as an accent stripe, defaulting to a
transparent border so tinted and untinted cards keep identical geometry.

Verified computed styles against the real stylesheet rather than shipping CSS
blind: the tint resolves, untinted cards stay transparent, the first provider
drops its separator, and the page does not scroll horizontally at 640px. A
pixel screenshot was not possible, the browser pane hangs on capture here.
Every reading now carries the label and tint of the account it came from, so a
card can say which seat it is reporting instead of leaving the user to infer it
from the Accounts tab.

Attached during the refresh cycle rather than looked up per surface, so the
tray, floatbar and panel cannot disagree about which account a number belongs
to. Set on failed readings too: a card that cannot fetch should still say which
account it was trying to read, which is the case where the question actually
matters.

Nothing renders while a provider is following the CLI. There is no chosen
account to name then, and inventing one would misrepresent the default state.

The tint colors the label only. It is user-supplied, so it is validated as a
plain hex color when stored rather than trusted here.
Measuring the tray card layout caught a regression from the previous commit.
The tray name group is a flex row, so adding a third element made all three
share the width and the provider name itself truncated: "Codex" rendered into
35px and clipped.

The provider name is the primary identifier and must never be the thing that
gives way, so it no longer shrinks. A long custom label ellipsizes instead,
which is the right priority.

Also stops printing the email twice. Labels are seeded from the directory and
so usually already contain the email, which rendered
"person@example.com (prolite)  person@example.com" in one row.

Verified by measurement rather than by eye, since screenshots hang here: with a
label the name is 37px unclipped and the email is gone; with a 52-character
label the name is still 37px unclipped and the label ellipsizes; with no label
the row is identical to today.
@tsouth89
tsouth89 merged commit 717ae9a into main Jul 22, 2026
10 of 11 checks passed
@tsouth89
tsouth89 deleted the sou-285-codex-accounts branch July 22, 2026 03:38
tsouth89 added a commit that referenced this pull request Jul 22, 2026
Fixes the missed overnight reset notification reported on 1.4.0.

## What was happening

A reset occurring while Ceiling was not running was silently absorbed
into the new baseline. `seen_scopes` is `#[serde(skip)]`, so the first
reading of a scope after launch overwrote the persisted baseline and
returned nothing — deliberately, per its own doc comment:

> Every provider/account scope is re-baselined on its first live reading
after launch so changes that happened while Ceiling was closed are not
emitted as if they just occurred.

Two further gates sat on top: the startup arming gate in the refresh
path, and the same check inside the toast emitter.

The intent was right and the outcome was wrong. The design offered only
"announce as live" or "say nothing" and chose silence, so an overnight
reset was invisible. There is now a third option: announce it, and say
when.

## Only scheduled resets qualify

The live path emits scheduled resets immediately but holds surprise,
partial and shift events for a **confirming second reading**, because
one reading can be anomalous. After a restart there is no earlier
reading this session to confirm against, so anything needing
confirmation stays silent and is absorbed as before.

A scheduled reset needs no second opinion: the window's own reset time
having passed is corroboration independent of the usage number.

Two existing tests were guarding exactly this —
`restart_rebaselines_without_replaying_persisted_history` and
`startup_at_a_partially_restored_value_does_not_replay_an_alert`. My
first attempt broke both by emitting every kind. They pass unchanged
now, and a new test pins down why the unconfirmable case stays quiet so
nobody loosens it later.

## Details

- Events carry `while_away` and report the **window's own reset time**,
not when Ceiling noticed, so the copy reads *"This happened at 2:00 AM,
while Ceiling was closed"* rather than implying it just occurred.
- They bypass the startup gate, since they are detected on precisely the
cycle it covers and would otherwise never be reported at all. Every
other guard, including the toast circuit breaker, still applies.
- A baseline older than **24 hours** is not announced against. Reopening
after a week should not replay a reset nobody is waiting to hear about;
reopening the morning after should.

This also covers the one-time re-baseline that #103 introduced by
putting the account email into the scope key — worth fixing rather than
putting a caveat in release notes.

## Note on predictive warnings

While here I confirmed that `predictive_pace_warning_enabled` is
hard-forced to `false` on every load (`settings/raw.rs:537`) because the
feature was retired, and has no UI control. It cannot fire. My earlier
claim that #103 would turn it on was wrong; both that PR description and
the Linear issue have been corrected.

## Tests

6 new tests: away reset reported with the real time, stale baseline not
replayed, no-reset restart stays quiet, first-ever launch stays quiet,
away events emitted only once per launch, and the unconfirmable drop
staying silent. Workspace: 1113 passing, clippy clean.

Co-authored-by: tsouth89 <tsouth89@users.noreply.github.com>
tsouth89 added a commit that referenced this pull request Jul 22, 2026
A cross-seat leak that would have shown up the first time anyone
switched accounts.

## What I found

Quota history is **already** scoped by account email
(`usage_history::provider_history` → `scope_key`), and #103 started
populating that email for Codex and Claude for the first time. So their
quota charts become per-account from now on with no further work — that
part is free.

But the lookup had a fallback that undoes it:

```rust
// If the exact scope is absent, use the freshest local series for that provider
```

After switching accounts, the new account has no series yet, so
"freshest series for that provider" resolves to **the seat you just
switched away from**. The new account's chart would show the previous
account's history until it accumulated its own.

## The fix

The fallback still bridges to this provider's **anonymous** series — the
same account observed from a source that reports no email, which is what
the fallback was written for. It no longer reaches into a *different*
account's series.

## Testability

Selection moved into a pure `select_series` function. The store is a
disk-backed global singleton, which is why the existing tests only
covered pure helpers like `normalize_id` and `scope_key`, and why this
behavior had no coverage at all.

Four new tests: switching accounts does not chart the previous seat, an
identified read still bridges to the same account recorded anonymously,
an exact account match wins over a fresher anonymous series, and another
provider's series is never selected.

Workspace: 1117 passing, clippy clean.

Co-authored-by: tsouth89 <tsouth89@users.noreply.github.com>
@tsouth89 tsouth89 mentioned this pull request Jul 22, 2026
tsouth89 added a commit that referenced this pull request Jul 22, 2026
Version bump and changelog for 1.5.0.

Ships three merged changes:
- **#103** — multi-account switching for Codex and Claude (SOU-285 phase
1), plus the attribution ledger (SOU-297)
- **#105** — report resets that happened while Ceiling was closed
(SOU-307)
- **#106** — stop the quota chart falling back to another account's
history

## Not verified before release

Two things a human should check, because I could not:

1. **The Accounts tab has never been looked at.** Layout was verified by
measuring computed styles and geometry (the browser pane hangs on
screenshot in this environment), so spacing, color and whether the
check-then-add flow reads clearly are unconfirmed.
2. **The multi-account path has not been run end to end.** Creating a
second `CODEX_HOME`, signing in, and switching is the one flow that
exercises cache eviction, re-baselining and the ledger together.

## Deliberately not in this release

Per-account grouping for local token and cost totals is
[SOU-308](https://linear.app/southforge-ai/issue/SOU-308/group-local-activity-tokens-and-cost-by-account-in-charts).
The ledger only started recording when #103 merged, so every existing
record attributes to "Before tracking" and the chart would render one
bucket. It needs accumulated data before the UI can be judged.

Quota history is already per-account and does ship here.

Co-authored-by: tsouth89 <tsouth89@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant