Directory-keyed accounts for Codex and Claude (SOU-285 phase 1)#103
Conversation
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.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 6 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (20)
📝 WalkthroughWalkthroughAdds 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. ChangesDirectory-backed account model
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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Deploying with
|
| 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)".
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.
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (19)
apps/desktop-tauri/src-tauri/Cargo.tomlapps/desktop-tauri/src-tauri/src/commands/accounts.rsapps/desktop-tauri/src-tauri/src/commands/mod.rsapps/desktop-tauri/src-tauri/src/commands/providers.rsapps/desktop-tauri/src-tauri/src/main.rsrust/src/cli/diagnose.rsrust/src/cli/serve.rsrust/src/cli/usage.rsrust/src/core/account_dirs.rsrust/src/core/claude_accounts.rsrust/src/core/codex_accounts.rsrust/src/core/configured_accounts.rsrust/src/core/mod.rsrust/src/core/provider.rsrust/src/providers/claude/mod.rsrust/src/providers/claude/oauth/credentials_store.rsrust/src/providers/claude/oauth/mod.rsrust/src/providers/codex/api.rsrust/src/providers/codex/mod.rs
| # Real directories for the config-directory account command tests. | ||
| tempfile = "3" |
There was a problem hiding this comment.
📐 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
| existing.label = account.label; | ||
| existing.identity = account.identity; |
There was a problem hiding this comment.
🎯 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.
| 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.
| fn suggested_label(&self) -> Option<String> { | ||
| let base = self | ||
| .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), | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 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.
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.
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>
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>
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>
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 genericaccount_dirsmodule 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_CONFIG_DIRwhile 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.account_email. BothCapacityEventObserverandEnforcementTrackerscope 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.No leaking between seats
An explicitly configured account never falls back to
CODEXBAR_CLAUDE_OAUTH_TOKENor 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 onlyemailkeys 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-runcodex 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 -> Settingshard-forcespredictive_pace_warning_enabled = falseon 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), Claudebts@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 warningsclean. Zero-accounts behavior is unchanged for both providers.