# Codex Account Switcher — Cinnamon Tray App + `cdx` Router

## 1. Problem & Context

User juggles multiple Codex CLI (`~/.npm-global/bin/codex`, v0.142.4) ChatGPT accounts on one Linux machine (Cinnamon 6.6.8, X11) — different accounts may belong to different real people (e.g. handing a device-auth code to the actual account owner to complete). Codex CLI has no `--account`/`--profile` flag and no auth-path override (confirmed: `codex --help`, `login --help`, `app-server --help`, `doctor` all expose no such flag/env var; `doctor` reports a fixed `auth file: ~/.codex/auth.json` tied to `CODEX_HOME`). It reads exactly one file, `<CODEX_HOME>/auth.json` (mode 0600), as its active credential. Starting set of 3 accounts:

- royachiron@gmail.com — plan `plus`
- rafi@os-mo.com — plan `plus`
- meirtedgi.w@gmail.com — plan `prolite`

Goal: (a) a `cdx` CLI wrapper/router that picks the right account per invocation — by explicit systray selection today, by per-project/global rule in a near-term follow-up — and execs real `codex` against that account's isolated credential with zero risk of divergence or revocation; (b) a Cinnamon systray app showing live usage/rate-limit info and token health per account, switching the systray-selected default account via a click; (c) one-click repair of a broken/expired token via a device-auth dialog; (d) add/remove/rename accounts — all without ever revoking a session server-side and without disturbing already-running `codex` background agents.

**Hard constraint — no revocation.** `codex logout` mutates server-side session state. Never invoked.

**Hard constraint — never disrupt running codex processes.** The user routinely has multiple `codex exec`/TUI processes running in the background. Account data lives in **per-account isolated directories**, each usable as its own `CODEX_HOME`.

**Reality check on "tooltip with a dropdown."** Tray tooltips are plain, non-interactive text (StatusNotifierItem/Ayatana has no embedded-widget-in-tooltip mechanism). Real equivalent, same pattern as Slack/Element: static hover text + click-menu.

**Rate limits & health — verified live.** `codex app-server`'s JSON-RPC method `account/rateLimits/read` is a plain authenticated GET, **no model-turn cost**. Verified live:
- Healthy token → real usage data back (5h/weekly window %, plan, credits).
- Broken token → JSON-RPC error, e.g. `{"error":{"code":-32603,"message":"...401 Unauthorized...\"code\": \"token_revoked\""},"id":2}` — authoritative "this token is dead" signal. Verified live against real backup files: **2 of the 3 existing accounts (`roy`, `avi`) are already server-revoked**; only `rafa` is currently healthy.
- Verified this call never rewrites `auth.json` (md5-identical before/after, both success and error path).

**Device-auth repair — verified live, and a critical live finding.** `codex login --device-auth` (run against an isolated `CODEX_HOME`) prints a URL + one-time code (ANSI-wrapped, must be stripped). Two live-tested findings:
1. Starting this command **immediately deletes** that `CODEX_HOME`'s existing `auth.json`, before the user enters the code.
2. **Completing this command server-side revokes that same account's OTHER active sessions/tokens, globally — instantly, independent of which local directory performs the login.** Confirmed by direct test: a device-auth run against a scratch copy of the `rafa` credential broke the user's real, concurrently-active `rafa` session (proven via byte-identical token + a live 401 `token_revoked` JSON-RPC call against the untouched original), which was then repaired. **This means re-running device-auth for an account slug is NOT a safe "just get a fresh token" operation if that account has another live session anywhere (this machine or elsewhere) — it is a hard, global, one-way action on that account's auth state.**

**Live-observed concurrency finding.** During this design's own verification, `~/.codex/auth.json` vanished mid-session while an unrelated background `codex exec` process (a different, independently-running agent job under the same shared `~/.codex/`) was active — restored from a backup copy. This is direct, current-state proof that multiple processes sharing one mutable `auth.json` under one `CODEX_HOME` is already unsafe today, before any multi-account complexity is added. This is the core problem `cdx` + per-account isolated `CODEX_HOME`s solves: **every account gets its own real, never-shared, never-symlinked `auth.json` and its own identity-bearing runtime state — there is no longer a single mutable file for concurrent processes to race on.**

**Known limitation, accepted by user:** already-running `codex` processes hold their token in memory from process start; switching only affects the *next* invocation. No detection/warning for this (declined).

## 2. Scope

In scope:
- `cdx` executable (new, separately-named — never shadows/replaces the real `codex` binary on `PATH`): resolves which account to use for this invocation, then `exec`s real `codex` with `CODEX_HOME` set to that account's isolated directory. Resolution order: per-project override → global default → global fallback chain (broken-or-quota-exhausted triggers fallback; every fallback event is logged/notified, never silent — see §3 "Routing rule engine").
- Tray icon (custom SVG, "AI sparkles" style), always visible when running.
- Click opens menu: one entry per known account (alias + plan + live rate-limit %, or a broken-token indicator), the current **global default** account checked, "Manage accounts" submenu, Quit.
- Selecting an account in the tray sets it as the new **global default** (a pointer-file write, not a credential copy — see §3 "Storage layout").
- Selecting an account flagged unhealthy opens the device-auth repair dialog instead of switching.
- Tooltip reflects the current global default account.
- Background refresh per account: rate limits + token health, on menu-open (if cache stale) AND on a fixed 1-hour timer regardless of menu state. This is also the health snapshot `cdx` reads (see §3) — `cdx` never makes a live network call itself.
- Device-auth repair dialog: clickable link (opens Firefox directly), one-click "copy code," one-click "copy full text."
- Manage submenu: Add account (device-auth dialog, fresh empty account dir; guarded per §3 "Add-account collision mitigations"), Rename alias, Remove account.
- Autostart on Cinnamon login.
- Routing-rule config: per-project override table (scaffolded now: `zync.is → avi`, `automixer → rafa`, `multideal → roy`), global default (`rafa`), global fallback chain (`[roy, avi]`) — see §3 "Routing rule engine". **Current real-account caveat:** `roy` and `avi` are both server-revoked as of this writing, so the fallback chain has zero healthy members until they're repaired.

Out of scope:
- Any call to `codex logout` anywhere, ever.
- Re-running device-auth against an account slug that might have another live session, without an explicit user-facing warning (see §3 "Add-account collision mitigations" — this applies to repair too, not just add).
- Warning about already-running codex processes holding stale tokens (declined).
- Cross-desktop portability beyond Cinnamon/Ayatana (X11 only).
- Any browser other than Firefox for the repair/add link.
- Mid-session fallback (a `cdx`-launched `codex` process that later exhausts quota keeps running under its original account — `cdx` only resolves once, at launch).

## 3. Architecture

Two artifacts share one on-disk account store:
1. **`cdx`** — a thin, dependency-free Python (or shell) script on `PATH`, invoked instead of `codex` for anything that should honor account routing.
2. **The tray app** — Python 3, GTK3 + `AyatanaAppIndicator3`, long-lived under the GTK main loop, owns the account store, health snapshot, and global-default pointer. All subprocess I/O runs off the GTK thread.

### Storage layout

```
~/.codex-tray/
  accounts.json                       # registry — slug/alias pairs
  default_slug                        # plain text, one line: current global default slug
  routing_rules.json                  # per-project overrides + global default + fallback chain
  health_cache.json                   # tray-maintained snapshot cdx reads (no live calls from cdx)
  accounts/
    <slug>/
      CODEX_HOME/                     # full isolated codex home for this account
        auth.json                    # this account's ONLY real credential file — never symlinked
        auth.json.bak                # present only mid-repair
        config.toml -> ~/.codex/config.toml        # symlink, shared tier
        skills -> ~/.codex/skills                  # symlink, shared tier
        plugins -> ~/.codex/plugins                # symlink, shared tier
        commands -> ~/.codex/commands               # symlink, shared tier
        rules -> ~/.codex/rules                     # symlink, shared tier
        context-mode -> ~/.codex/context-mode        # symlink, shared tier
        AGENTS.md -> ~/.codex/AGENTS.md               # symlink, shared tier
        RTK.md -> ~/.codex/RTK.md                     # symlink, shared tier
        models_cache.json -> ~/.codex/models_cache.json  # symlink, shared tier
        cache -> ~/.codex/cache                       # symlink, shared tier
        installation_id -> ~/.codex/installation_id    # symlink, shared tier
        version.json -> ~/.codex/version.json          # symlink, shared tier
        # everything else (sessions/, history.jsonl, memories*, goals_*.sqlite,
        # state_*.sqlite, logs_*.sqlite, log/, internal_storage.json,
        # .personality_migration, .tmp/, tmp/, shell_snapshots/) is REAL,
        # per-account, never shared — these carry account-scoped identity
        # (confirmed: state_*.sqlite has an account_id-keyed table) or
        # session/memory content the user does not want bleeding cross-account.
```

`~/.codex/auth.json` remains a real (not symlinked) file, kept as a courtesy default for any bare `codex` invocation that bypasses `cdx` (muscle memory, third-party tooling) — it always mirrors whatever the tray's current global default account is, written the same pointer-safe way as before (never symlinked, so a token refresh on it can never silently clobber a symlink into an account dir).

**Why this replaces the old single-`auth.json`-copy model entirely:** the old design had exactly one mutable `~/.codex/auth.json`, requiring careful "save back before overwrite" logic (old `switch_to`) to avoid losing a refreshed token when switching away from an account. Under this model each account's `CODEX_HOME/auth.json` is the ONE AND ONLY real copy of that account's credential, ever. There is no second copy to diverge from, so there is nothing to save back. "Switching" is now: write a slug string into `default_slug` (tray) or resolve a routing rule (`cdx`) — never a credential copy.

**Symlink-farm setup/sync.** An idempotent `sync_shared_links(account_dir)` step (run at migration and at every Add-account) creates any missing shared-tier symlink for entries currently present at the top level of `~/.codex/`, and leaves existing real per-account entries alone. New top-level entries that later appear directly inside an account's own `CODEX_HOME` (created by a newer `codex` version, e.g. a hypothetical `state_6.sqlite`) are NOT retroactively symlinked — this is a known, accepted limitation (documented, not silently swallowed); `sync_shared_links` only ever adds symlinks for names already known at run time.

### `cdx` router

```
cdx [codex args...]
  1. resolve_account() -> slug
       a. cwd_project = basename(git rev-parse --show-toplevel, or cwd if not a git repo)
       b. routing_rules.json["projects"].get(cwd_project) -> per-project override, if present and that
          account is not BROKEN per health_cache.json -> use it
       c. else: candidate = routing_rules.json["default"]
       d. read health_cache.json for candidate: BROKEN or quota-exhausted (both 5h and weekly
          windows at/above the configured threshold) -> walk routing_rules.json["fallback_chain"]
          in order, taking the first entry that is neither BROKEN nor quota-exhausted; log a
          desktop notification ("cdx: <default> unavailable, using <fallback> instead") every
          time a fallback fires — never silent, since accounts may belong to different real
          people and this reroutes work (and quota) onto one of them without an interactive prompt
       e. no healthy candidate anywhere in the chain -> print an error to stderr naming every
          candidate tried and its status, exit 1 (never guess, never silently run unauthenticated)
  2. os.execvpe("codex", ["codex", *args], env={**os.environ, "CODEX_HOME": accounts/<slug>/CODEX_HOME})
```

- Zero network I/O — resolution reads only `routing_rules.json`, `health_cache.json` (both maintained by the tray, refreshed hourly + on menu-open), never spawns `app-server` itself.
- `health_cache.json` missing or older than a staleness threshold (2 hours) → `cdx` proceeds with the assigned account anyway (never blocks a launch on the tray being closed) but prints a one-line stderr notice that health data is stale.
- Precedence is exactly what the user specified: **per-project overrides global** (default + fallback chain together are "global").

### `routing_rules.json` schema

```json
{
  "projects": {"zync.is": "avi", "automixer": "rafa", "multideal": "roy"},
  "default": "rafa",
  "fallback_chain": ["roy", "avi"],
  "fallback_trigger": "broken_or_quota_exhausted",
  "quota_exhausted_threshold_pct": 100
}
```

Editable by hand (plain JSON) or via a future tray UI (not in this scope — scaffolded now per user's explicit request, no rule-editing UI yet).

### `accounts.json` schema

```json
{"accounts": [{"slug": "roy", "alias": "Roy (work)"}]}
```

`slug` is an immutable directory key. `alias` is freely-renamable. Email/plan/account_id are NOT stored — always re-derived live from that account's `CODEX_HOME/auth.json`.

### Component: `AccountRegistry`

Owns `accounts.json`, `default_slug`, and the `accounts/` tree. Only component that touches `~/.codex-tray/` directly.

```python
class Account:
    slug: str
    alias: str
    codex_home: Path   # ~/.codex-tray/accounts/<slug>/CODEX_HOME/
    email: str         # decoded live from codex_home/auth.json id_token JWT claim "email"
    plan: str           # claim "https://api.openai.com/auth".chatgpt_plan_type
    account_id: str     # claim "https://api.openai.com/auth".chatgpt_account_id

class AccountRegistry:
    def list(self) -> list[Account]
    def default_slug(self) -> str | None
    def set_default(self, account: Account) -> None   # writes default_slug pointer file (atomic
                                                        # temp+os.replace) + refreshes the courtesy
                                                        # ~/.codex/auth.json copy (real file copy of
                                                        # account.codex_home/auth.json, never a symlink)
    def rename(self, slug: str, new_alias: str) -> None
    def remove(self, slug: str) -> None
    def new_slug(self, alias: str) -> str
    def add_dir(self, slug: str, alias: str) -> Path   # mkdir accounts/<slug>/CODEX_HOME/,
                                                        # sync_shared_links(), append registry entry
    def sync_shared_links(self, codex_home: Path) -> None
```

An account whose `auth.json` fails to decode is still returned from `list()` (logged to stderr), `email`/`plan`/`account_id` as `None` — surfaces as a known, unhealthy account in the menu rather than silently dropped.

### Component: `AccountHealthClient`

Unchanged responsibility from the prior design — for one account directory, fetch rate limits AND detect token health, off the GTK thread. The single source of truth for "is this token alive," and now also the sole writer of `health_cache.json` (which `cdx` reads).

```python
class HealthStatus(Enum):
    OK = "ok"
    BROKEN = "broken"
    UNKNOWN = "unknown"

class AccountSnapshot:
    status: HealthStatus
    primary_used_pct: int | None
    secondary_used_pct: int | None

class AccountHealthClient:
    def fetch(self, codex_home: Path) -> AccountSnapshot
        # spawn `codex app-server --stdio`, env CODEX_HOME=codex_home
        # initialize, then account/rateLimits/read; result -> OK, error -> BROKEN,
        # timeout/spawn failure -> UNKNOWN (caller never downgrades OK on UNKNOWN)
```

Every account now reads its health from its own real `codex_home` unconditionally — **the old "active account reads `~/.codex/auth.json` directly" special case is gone**, because there is no more shared active-vs-isolated split. One rule, no exceptions.

- Cache TTL 3 minutes for menu-open-triggered refresh; unconditional hourly timer regardless of menu state.
- After every refresh pass, the tray writes the full snapshot set to `~/.codex-tray/health_cache.json` — this is the file `cdx` reads.
- Only a `BROKEN` result changes an account's health flag; `UNKNOWN` never flips a previously-`OK` account to broken.

### Component: `DeviceAuthFlow`

Unchanged mechanics from the prior design (shared by Add and Repair): backup/restore around `codex login --device-auth`, ANSI parsing. One added constraint below.

```python
class DeviceAuthPrompt:
    url: str
    code: str
    raw_text: str

class DeviceAuthFlow:
    def start(self, codex_home: Path, backup_existing: bool) -> DeviceAuthSession
    def read_prompt(self, session) -> DeviceAuthPrompt
    def await_completion(self, session) -> bool
    def commit(self, codex_home: Path) -> None
    def rollback(self, codex_home: Path) -> None
```

### Add-account collision mitigations (agreed, now specified)

The exposed risk: Add-account's login-then-check-collision ordering means a healthy *existing* account can be broken by the server-side global revocation (§1 finding 2) before the safety check ever runs — the new login silently kills a different, already-working account. Three mitigations, all required:

1. **Explicit warning copy in the Add-account dialog**, shown before the device-auth link is even displayed: *"If this ChatGPT account is already logged in anywhere else (this tray, another machine, a browser session), completing this will immediately sign that session out. Only continue if you're adding a brand-new account or intentionally re-authenticating one you control."*
2. **Show-existing-emails guard**: the Add-account dialog lists every currently-known account's email address (decoded from each `CODEX_HOME/auth.json`) above the alias entry field, so the user can visually confirm the account they're about to authenticate isn't already one of these before starting the flow.
3. **Immediate collision flagging on the pre-existing duplicate**: today the code only rolls back the *new* slug on collision. Now, on collision, ALSO immediately mark the *pre-existing* account matching that `account_id` as `BROKEN` in the health cache (its session was just killed by the new login, per finding 2 — no need to wait for the next hourly probe to discover it) and refresh its menu label right away, in addition to rolling back the new slug and notifying.

### Component: `Indicator` (tray + menu)

Presentation only — delegates to `AccountRegistry` / `AccountHealthClient` / `DeviceAuthFlow`.

- Tooltip/title: `"Codex: <alias> (<plan>)"` for `registry.default_slug()`'s account; `"Codex: unknown account"` if `None`.
- Menu: one `Gtk.RadioMenuItem` per `AccountRegistry.list()`, labeled per health status; the current default pre-checked.
- Radio-item activate: healthy → `AccountRegistry.set_default`, rebuild tooltip, `notify-send`. `BROKEN` → open repair dialog instead.
- Menu `show` → background-refresh stale accounts. Startup + hourly timer → refresh all, write `health_cache.json`.

### Icon asset, Autostart

Unchanged from prior design (see plan for exact assets).

## 4. Error Handling

| Condition | Behavior |
|---|---|
| `~/.codex-tray/` missing, `~/.codex/auth.*.json` also missing | Tray starts; empty account list; menu shows only Manage/Quit. `cdx` with no accounts registered → error naming zero candidates, exit 1. |
| An account's `auth.json` malformed/unreadable | Still listed, shown `BROKEN`. |
| Switch (`set_default`) source file vanishes mid-click (race) | Catch `FileNotFoundError`; `notify-send` error; atomic write means no partial/corrupt pointer or courtesy-copy file. |
| `AccountHealthClient.fetch` times out | `UNKNOWN`, never downgrades a healthy account. |
| `AccountHealthClient.fetch` returns a JSON-RPC error | Authoritative `BROKEN`. |
| Device-auth subprocess exits nonzero / 15-min expiry, no completion | `rollback()`; `notify-send` failure; dialog offers Retry. |
| Device-auth succeeds but new account_id collides with an existing account | Roll back new slug; **also** flag the pre-existing account `BROKEN` immediately (§3 mitigation 3); `notify-send` naming both. |
| `cdx` invoked, `health_cache.json` stale (>2h) or missing | Proceeds with assigned account anyway; stderr notice, not a hard failure. |
| `cdx` fallback fires (broken or quota-exhausted) | Always a desktop notification naming the original and the fallback account — never silent. |
| `cdx` exhausts the entire fallback chain with nothing healthy | stderr error naming every candidate + status, exit 1. |
| Firefox spawn fails | `notify-send` the OS error; link/code still copyable manually. |

## 5. Testing

Manual verification (single-user desktop utility with filesystem/subprocess side effects):

1. First launch with only legacy `~/.codex/auth.{roy,rafa,avi}.json` → migration creates isolated `CODEX_HOME`s with real `auth.json` + synced shared-tier symlinks; legacy files untouched; menu lists all 3.
2. Hover → tooltip matches default account.
3. Click an unchecked healthy account → `default_slug` updates, courtesy `~/.codex/auth.json` copy matches, no other account's `auth.json` touched.
4. Open menu, wait → labels gain rate-limit suffixes; cross-check one against a direct manual JSON-RPC call.
5. Corrupt one account's `auth.json` → next refresh flags `BROKEN`; click opens repair dialog.
6. Repair dialog copy/link buttons behave correctly; abandoning restores `.bak`, stays `BROKEN`.
7. Complete a real repair (test account) → health flips `OK`.
8. Add account with the new warning shown + existing-emails list visible before the link appears.
9. Trigger an intentional Add-account collision (re-login same account) → confirm the *pre-existing* account is immediately flagged `BROKEN` in the menu, not just the new slug rolled back.
10. Remove/rename flows unchanged from prior verification.
11. Leave tray running 1+ hour, unopened → hourly refresh still fires, `health_cache.json` updates.
12. `cdx` from inside a project directory named `zync.is` (or a throwaway repo with that basename) → confirm it resolves to `avi`'s `CODEX_HOME` (`echo $CODEX_HOME` proxy check via a wrapped debug command, or inspect the exec'd process env).
13. `cdx` from a directory with no project override → resolves to global default (`rafa`).
14. Force the global default account `BROKEN` (simulate) → `cdx` falls through to the fallback chain, notification fires naming both accounts.
15. Exhaust the entire fallback chain (all `BROKEN`) → `cdx` exits 1 with a clear per-candidate error, does not silently launch unauthenticated.
16. Log out / back into Cinnamon → tray reappears.

## Architecture Decisions

- **`AccountRegistry`**: deletion test fails → earns its boundary. Deep. Now simpler than before — `set_default` is a pointer write + one real-file courtesy copy, not a two-step save-back-then-overwrite; the old divergence handling is gone because there is exactly one real credential file per account, permanently.
- **`AccountHealthClient`**: single-adapter, justified as before; now doubles as the sole writer of `health_cache.json`, the file `cdx` depends on — still one component, one responsibility (fetch + cache), no new boundary needed for that.
- **`DeviceAuthFlow`**: unchanged justification — shared by Add/Repair, would otherwise duplicate.
- **`cdx`**: deletion test fails hard — its resolution logic (project override → global default → fallback chain, health-aware) would otherwise be re-derived ad hoc by whatever launches `codex`, with no single source of truth. Deep: callers just run `cdx`, never see the resolution logic.
- **`Indicator`**: pure presentation seam, deep, unchanged.
- Symlink-farm sync (`sync_shared_links`) collapsed into `AccountRegistry` rather than its own class — single call-site (migration + add_dir), no independent state, would be decorative as a separate module.
- Routing-rule resolution lives inside `cdx` itself, not a separate Python class shared with the tray — the tray never resolves a routing rule (it only ever sets `default_slug` directly via explicit user click); a shared class would have exactly one real caller today (single-adapter, collapse).
