# Codex Account Switcher + `cdx` Router Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** `cdx` account-router CLI + Cinnamon systray app. `cdx` execs real `codex` with `CODEX_HOME` pointed at the resolved account's isolated directory (per-project override → global default → health-aware fallback chain). The tray sets the global default via a click, shows live per-account rate-limit/health, and repairs broken accounts via a device-auth dialog — never revoking tokens, never disturbing running codex background processes, never sharing a single mutable `auth.json` across accounts.

**Architecture:** Every account gets a full isolated `CODEX_HOME` under `~/.codex-tray/accounts/<slug>/CODEX_HOME/`: a real, never-shared `auth.json` plus symlinks back to `~/.codex/` for the shared tier (skills/plugins/commands/rules/context-mode/config.toml/AGENTS.md/RTK.md/cache/models_cache.json/installation_id/version.json). Everything else (sessions, history, memories, goals/state/logs sqlite, tmp) stays real per-account — confirmed to carry account-scoped identity, never symlinked. Five independent logic modules (`AccountRegistry`, `AccountHealthClient`, `DeviceAuthFlow`, `RoutingResolver`, plus a dialog helper) with no GTK dependency; a GTK-dependent `Indicator` wires them into the tray; `systray_codex_switcher.py` is the tray entrypoint; `cdx.py` is the router entrypoint. All subprocess I/O runs off the GTK thread via worker threads + `GLib.idle_add`.

**Tech Stack:** Python 3, PyGObject (`gi`), GTK3, `AyatanaAppIndicator3`, `codex` CLI (`app-server --stdio` JSON-RPC, `login --device-auth`), pytest.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1, Task 2, Task 3, Task 4, Task 5, Task 6 | `account_registry.py`, `health_client.py`, `device_auth.py`, `routing_resolver.py`, `icons/codex-account-switcher.svg` (+install), `packaging/codex-account-switcher.desktop` (+install) | ✅ no overlap |
| 2 | Task 7, Task 8, Task 9 | `indicator.py`, `device_auth_dialog.py`, `cdx.py` | ✅ no overlap |
| 3 | Task 10 | `indicator.py` | single task (file overlap w/ Task 7) |
| 4 | Task 11 | `indicator.py` | single task (file overlap w/ Task 10) |
| 5 | Task 12 | `indicator.py` | single task (file overlap w/ Task 11) |
| 6 | Task 13 | `indicator.py` | single task (file overlap w/ Task 12) |
| 7 | Task 14 | `systray_codex_switcher.py` | single task |
| 8 | Task 15 | — (manual verification only) | single task |

`meta.scheduler = dag-parallel` (Wave 1 has 6 independent tasks, Wave 2 has 3).

---

## File Structure

- `account_registry.py` — `Account`, `AccountRegistry`: owns `~/.codex-tray/` (registry JSON, `default_slug` pointer, per-account `CODEX_HOME` dirs), migration, shared-tier symlink sync, `set_default`.
- `health_client.py` — `HealthStatus`, `AccountSnapshot`, `AccountHealthClient`: spawns `codex app-server --stdio`, JSON-RPC health/rate-limit fetch; also writes `health_cache.json`.
- `device_auth.py` — `DeviceAuthPrompt`, `DeviceAuthSession`, `DeviceAuthFlow`: spawns `codex login --device-auth`, parses prompt, backup/restore lifecycle.
- `routing_resolver.py` — `RoutingRules`, `RoutingResolver`: loads `routing_rules.json`, resolves project/default/fallback-chain against `health_cache.json`; pure logic, no subprocess, shared by `cdx.py` and its tests.
- `cdx.py` — router entrypoint: detects current project (git toplevel basename or cwd basename), calls `RoutingResolver`, `os.execvpe`s real `codex` with `CODEX_HOME` set.
- `device_auth_dialog.py` — `DeviceAuthDialog` (GTK): renders a `DeviceAuthPrompt` with Firefox link, copy-code, copy-full-text, plus (new) the existing-accounts warning/list for Add-account; no business logic.
- `indicator.py` — `Indicator` (GTK): tray icon, menu, tooltip, set-default/repair/add/rename/remove flows, background refresh timers. Pure orchestration.
- `systray_codex_switcher.py` — entrypoint: constructs registry/health-client/indicator, runs migration once, starts `Gtk.main()`.
- `icons/codex-account-switcher.svg` — source icon asset (repo-tracked); installed to `~/.local/share/icons/hicolor/scalable/apps/codex-account-switcher.svg`.
- `tests/test_account_registry.py`, `tests/test_health_client.py`, `tests/test_device_auth.py`, `tests/test_routing_resolver.py` — unit tests for the four GTK-free modules (health/device-auth tests mock `subprocess.Popen`, not real `codex`).

Git allow-list for commit steps: `account_registry.py`, `health_client.py`, `device_auth.py`, `routing_resolver.py`, `cdx.py`, `device_auth_dialog.py`, `indicator.py`, `systray_codex_switcher.py`, `icons/codex-account-switcher.svg`, `packaging/codex-account-switcher.desktop`, `tests/*.py`. Never stage `docs/`.

---

### Task 1: `AccountRegistry` + migration + shared-tier symlink sync

**Wave:** 1
**Blocks:** Task 7, Task 10, Task 11, Task 14
**Blocked by:** —

**Files:**
- Create: `account_registry.py` — `Account`, `AccountRegistry`, migration, `sync_shared_links`
- Test: `tests/test_account_registry.py`

**Contract (pin exactly):**
```python
SHARED_TIER_NAMES = [
    "config.toml", "skills", "plugins", "commands", "rules", "context-mode",
    "AGENTS.md", "RTK.md", "models_cache.json", "cache", "installation_id",
    "version.json",
]

@dataclass
class Account:
    slug: str
    alias: str
    codex_home: Path   # ~/.codex-tray/accounts/<slug>/CODEX_HOME/
    email: str | None
    plan: str | None
    account_id: str | None

class AccountRegistry:
    def __init__(self, base_dir: Path = Path.home() / ".codex-tray",
                 legacy_codex_home: Path = Path.home() / ".codex"): ...
    def migrate_legacy(self) -> None
    def list(self) -> list[Account]
    def default_slug(self) -> str | None
    def set_default(self, account: Account) -> None
    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
    def sync_shared_links(self, codex_home: Path) -> None
```

`accounts.json` shape: `{"accounts": [{"slug": "roy", "alias": "Roy (work)"}]}`. `default_slug` file: plain text, one line, the slug string (no JSON wrapper).

**Behavior:**
- `sync_shared_links(codex_home)`: for each name in `SHARED_TIER_NAMES`, if `legacy_codex_home / name` exists AND `codex_home / name` does not exist, create a symlink `codex_home / name -> legacy_codex_home / name`. Never touches a name that already exists at the target (real or symlink) — idempotent, additive only.
- `migrate_legacy()`: for each `<legacy_codex_home>/auth.<name>.json` with no existing `accounts/<name>/` entry: `mkdir accounts/<name>/CODEX_HOME/`, copy (not move) the legacy file to `accounts/<name>/CODEX_HOME/auth.json`, call `sync_shared_links(accounts/<name>/CODEX_HOME)`, add `{"slug": name, "alias": name}` to the registry. If `default_slug` file doesn't exist yet after migration, write the account matching legacy `<legacy_codex_home>/auth.json`'s current `account_id` (decode both, compare) as the initial default; if no match, leave `default_slug` absent. Idempotent — legacy source files never deleted/modified.
- `list()`: one `Account` per registry entry, in registry order. Missing/undecodable `codex_home/auth.json` → `email`/`plan`/`account_id` are `None`, entry still returned. Claims: `email`, `https://api.openai.com/auth.chatgpt_plan_type`, `https://api.openai.com/auth.chatgpt_account_id` on `tokens.id_token` (decode-only, no signature check).
- `default_slug()`: read the `default_slug` pointer file; return its contents (stripped) or `None` if the file is missing/empty/names an unknown slug.
- `set_default(account)`: (1) atomically write `account.slug` into the `default_slug` pointer file (temp-file same dir + `os.replace`); (2) atomically copy `account.codex_home/auth.json` to `<legacy_codex_home>/auth.json` (temp-file + `os.replace` — a real file copy, never a symlink, so the courtesy fallback file can never be silently clobbered by a token-refresh-through-symlink). Raises `FileNotFoundError` if `account.codex_home/auth.json` is missing at copy time (race) — caller catches this, does not suppress it here. No save-back step of any kind — there is exactly one real copy of each account's credential (`account.codex_home/auth.json`); the legacy file is purely a courtesy mirror, never a source of truth.
- `rename(slug, new_alias)`: updates `alias` only. Raises `KeyError` if unknown.
- `remove(slug)`: deletes `accounts/<slug>/` tree (`shutil.rmtree`, which removes the account's real files and simply unlinks its symlinks without following them) and the registry entry. Never touches `<legacy_codex_home>/auth.json` or `default_slug` even if `slug` was the default (leaves the pointer dangling — caller/`Indicator` handles a dangling default by falling back to `"unknown account"` display, matching Task 12's Remove behavior).
- `new_slug(alias)`: lowercase, non-`[a-z0-9-]` → `-`, collapse repeats, strip; append `-2`, `-3`, ... on collision.
- `add_dir(slug, alias)`: `mkdir(accounts/<slug>/CODEX_HOME/, parents=True)` (raises `FileExistsError` if it already exists), calls `sync_shared_links` on it, appends `{"slug": slug, "alias": alias}` to the registry.

**Acceptance:**
- Run: `pytest tests/test_account_registry.py -v`
- Expected: PASS, covering: migration is additive+idempotent, leaves legacy files byte-identical, and creates the shared-tier symlinks; `sync_shared_links` never overwrites an existing real file or symlink; `list()` returns `None`-field entries for undecodable auth; `set_default` writes both the pointer file and the courtesy copy via `os.replace` (assert via temp-file-name pattern or monkeypatched slow-write); `set_default` never touches any OTHER account's `codex_home/auth.json`; `rename`/`remove`/`new_slug`/`add_dir` each covered directly; `remove` on the current default leaves `default_slug` pointing at the now-gone slug (dangling, not auto-cleared).

- [ ] Write tests covering the behavior above
- [ ] Implement to satisfy the contract + acceptance
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add account_registry.py tests/test_account_registry.py && git commit -m "feat: add AccountRegistry with tiered symlink farm and pointer-based default"`

---

### Task 2: `AccountHealthClient` + health cache writer

**Wave:** 1
**Blocks:** Task 7, Task 12, Task 14
**Blocked by:** —

**Files:**
- Create: `health_client.py` — `HealthStatus`, `AccountSnapshot`, `AccountHealthClient`
- Test: `tests/test_health_client.py`

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

@dataclass
class AccountSnapshot:
    status: HealthStatus
    primary_used_pct: int | None    # 5-hour window
    secondary_used_pct: int | None  # weekly window

class AccountHealthClient:
    def fetch(self, codex_home: Path, timeout_secs: float = 10.0) -> AccountSnapshot
    def write_cache(self, cache_path: Path, snapshots: dict[str, AccountSnapshot]) -> None
```

JSON-RPC requests, one per line to the spawned process's stdin:
```json
{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-tray","version":"1.0"}}}
{"id":2,"method":"account/rateLimits/read","params":{}}
```

**Behavior:**
- `fetch`: spawns `codex app-server --stdio` with `env["CODEX_HOME"] = str(codex_home)`, writes both lines, reads stdout until the `id:2` response or `timeout_secs` elapses, terminates the subprocess. `result` key → `OK` + `.primary/.secondary.usedPercent` (either may be absent → `None`). `error` key (any message/code) → `BROKEN`, both `None`. Timeout/spawn failure/early exit → `UNKNOWN`, both `None`.
- Every account (including whatever the tray's current default is) is fetched via its own `codex_home` unconditionally — no active-vs-isolated special case; there is only ever one real credential file per account now.
- `write_cache(cache_path, snapshots)`: serializes `{slug: {"status": ..., "primary_used_pct": ..., "secondary_used_pct": ..., "checked_at": <caller-supplied via snapshots, not this method's concern>}}` to `cache_path` via temp-file + `os.replace` (atomic — `cdx` must never read a half-written cache).

**Acceptance:**
- Run: `pytest tests/test_health_client.py -v`
- Expected: PASS — mock `subprocess.Popen` for `fetch`'s four branches (`OK`/`BROKEN`/timeout/`FileNotFoundError` → `UNKNOWN`); `write_cache` produces valid JSON readable back via `json.load`, and a monkeypatched slow-write proves atomicity (temp file then rename, never a truncated target file mid-write).

- [ ] Write tests covering the behavior above
- [ ] Implement to satisfy the contract + acceptance
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add health_client.py tests/test_health_client.py && git commit -m "feat: add AccountHealthClient with atomic health_cache.json writer"`

---

### Task 3: `DeviceAuthFlow`

**Wave:** 1
**Blocks:** Task 8, Task 10, Task 11, Task 14
**Blocked by:** —

**Files:**
- Create: `device_auth.py` — `DeviceAuthPrompt`, `DeviceAuthSession`, `DeviceAuthFlow`
- Test: `tests/test_device_auth.py`

**Contract (pin exactly):**
```python
@dataclass
class DeviceAuthPrompt:
    url: str
    code: str
    raw_text: str

@dataclass
class DeviceAuthSession:
    process: subprocess.Popen
    codex_home: Path
    had_backup: bool

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

Parsing regexes (pin exactly): ANSI strip `\x1b\[[0-9;]*m`; URL `https://\S+`; code `[A-Z0-9]{4}-[A-Z0-9]{4,5}`.

**Behavior:**
- `start`: if `backup_existing` and `(codex_home / "auth.json").exists()`, `os.replace` it to `codex_home / "auth.json.bak"`. Spawns `codex login --device-auth` with `env["CODEX_HOME"] = str(codex_home)`, stdout/stderr piped.
- `read_prompt`: reads incrementally until URL + code both matched (ANSI-stripped) or `timeout_secs` elapses (raises `TimeoutError`). `raw_text` spans process start through the match point.
- `await_completion`: blocks until subprocess exit or `timeout_secs` (900s). Returns `True` iff exit 0 AND `codex_home/auth.json` exists AND its `id_token` decodes. Timeout → terminate subprocess, return `False`.
- `commit`: deletes `codex_home/auth.json.bak` if present.
- `rollback`: if `.bak` exists, `os.replace` back to `auth.json`; no-op (by design) when `backup_existing=False` never created one — caller (Add-account) is responsible for removing the empty dir itself.

**Acceptance:**
- Run: `pytest tests/test_device_auth.py -v`
- Expected: PASS — fake subprocess fixture reproducing the exact live-verified ANSI-wrapped output, covering: parse match against the pinned example (`https://auth.openai.com/codex/device`, `H6NU-AJGZ0`); backup-before-spawn; rollback byte-identical restore; commit removes `.bak` only; timeout returns `False` without raising.

- [ ] Write tests covering the behavior above
- [ ] Implement to satisfy the contract + acceptance
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add device_auth.py tests/test_device_auth.py && git commit -m "feat: add DeviceAuthFlow with backup/restore around codex login --device-auth"`

---

### Task 4: `RoutingResolver`

**Wave:** 1
**Blocks:** Task 9
**Blocked by:** —

**Files:**
- Create: `routing_resolver.py` — `RoutingRules`, `RoutingResolver`
- Test: `tests/test_routing_resolver.py`

**Contract (pin exactly):**
```python
@dataclass
class RoutingRules:
    projects: dict[str, str]        # project basename -> slug
    default: str
    fallback_chain: list[str]
    fallback_trigger: str           # "broken_or_quota_exhausted" (only supported value for now)
    quota_exhausted_threshold_pct: int

@dataclass
class ResolvedRoute:
    slug: str
    fallback_used: bool
    fallback_from: str | None       # the originally-targeted slug, if fallback_used

class RoutingResolver:
    def __init__(self, rules: RoutingRules, health: dict[str, "AccountSnapshot"]): ...
    def resolve(self, project_name: str | None) -> ResolvedRoute
```

Default `routing_rules.json` seeded at first run (Task 5 owns writing the file; this task's contract just defines the shape consumed):
```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}
```

**Behavior:**
- `resolve(project_name)`: candidate = `rules.projects.get(project_name, rules.default)`. A candidate is "unavailable" iff its `health[slug].status == BROKEN`, OR (`fallback_trigger == "broken_or_quota_exhausted"` AND (`health[slug].primary_used_pct >= threshold` OR `secondary_used_pct >= threshold`)). If the resolved candidate came from `projects` (a per-project override) and is unavailable, it does NOT fall through to `fallback_chain` — per-project pins are absolute once assigned; only the *global default* path consults the fallback chain (per-project override precedence is about which account is picked, not about inheriting the global safety net). If candidate came from `default` and is unavailable, walk `fallback_chain` in order, return the first available slug with `fallback_used=True, fallback_from=rules.default`. If every entry in `fallback_chain` (and `default` itself) is unavailable, raise `NoHealthyAccountError` listing every slug tried + its status/pct. Missing `health` entry for a slug (never fetched yet) is treated as available (optimistic — `cdx` would rather try than block on a cold cache).

**Acceptance:**
- Run: `pytest tests/test_routing_resolver.py -v`
- Expected: PASS — covers: exact-match project override returns that slug when healthy; unavailable per-project override raises (does not fall back); healthy default returns default with `fallback_used=False`; broken default falls to first available fallback-chain entry with `fallback_used=True`; quota-exhausted default (pct at threshold) also triggers fallback; all-unavailable raises `NoHealthyAccountError` naming every candidate.

- [ ] Write tests covering the behavior above
- [ ] Implement to satisfy the contract + acceptance
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add routing_resolver.py tests/test_routing_resolver.py && git commit -m "feat: add RoutingResolver for per-project/global/fallback-chain account resolution"`

---

### Task 5: Icon asset

**Wave:** 1
**Blocks:** Task 14
**Blocked by:** —

**Files:**
- Create: `icons/codex-account-switcher.svg`

**Literal — apply inline (LOC≤LOP), no dispatch:**
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
  <path fill="#8ab4f8" d="M12 2 L13.8 9.2 L21 11 L13.8 12.8 L12 20 L10.2 12.8 L3 11 L10.2 9.2 Z"/>
  <path fill="#8ab4f8" d="M19 2 L19.7 4.3 L22 5 L19.7 5.7 L19 8 L18.3 5.7 L16 5 L18.3 4.3 Z"/>
  <path fill="#8ab4f8" d="M5 15 L5.6 17 L7.5 17.6 L5.6 18.2 L5 20.2 L4.4 18.2 L2.5 17.6 L4.4 17 Z"/>
</svg>
```
- [ ] Write the file above, then install: `mkdir -p ~/.local/share/icons/hicolor/scalable/apps && cp icons/codex-account-switcher.svg ~/.local/share/icons/hicolor/scalable/apps/codex-account-switcher.svg && gtk-update-icon-cache -f ~/.local/share/icons/hicolor 2>/dev/null; true`
- [ ] Verify: `test -f ~/.local/share/icons/hicolor/scalable/apps/codex-account-switcher.svg && echo OK`
- [ ] Commit: `git add icons/codex-account-switcher.svg && git commit -m "feat: add tray icon asset"`

---

### Task 6: Autostart entry

**Wave:** 1
**Blocks:** —
**Blocked by:** —

**Files:**
- Create: `packaging/codex-account-switcher.desktop` — repo-tracked template (source of truth)
- Install (outside repo, not committed): `~/.config/autostart/codex-account-switcher.desktop` — a copy of the template

**Literal — apply inline (LOC≤LOP), no dispatch:**
```ini
[Desktop Entry]
Type=Application
Name=Codex Account Switcher
Exec=python3 /home/user/Projects/systray-ai/systray_codex_switcher.py
Icon=codex-account-switcher
X-GNOME-Autostart-enabled=true
NoDisplay=true
```
- [ ] Write the file above to `packaging/codex-account-switcher.desktop` (repo-tracked, mirrors the icon-asset pattern in Task 5).
- [ ] Install: `mkdir -p ~/.config/autostart && cp packaging/codex-account-switcher.desktop ~/.config/autostart/codex-account-switcher.desktop`.
- [ ] Verify: `test -f ~/.config/autostart/codex-account-switcher.desktop && echo OK` (functional reappear-after-relogin check is manual, Task 15 step 18).
- [ ] Commit: `git add packaging/codex-account-switcher.desktop && git commit -m "feat: add autostart desktop entry template"`

---

### Task 7: `Indicator` core — tray, menu, tooltip, set-default-on-click

**Wave:** 2
**Blocks:** Task 10
**Blocked by:** Task 1, Task 2

**Files:**
- Create: `indicator.py` — `Indicator`

**Contract (pin exactly):**
```python
class Indicator:
    def __init__(self, registry: AccountRegistry, health_client: AccountHealthClient): ...
    def build(self) -> None
    def run(self) -> None
```

**Behavior:**
- `AyatanaAppIndicator3.Indicator.new(...)`, category `ApplicationStatus`, status `ACTIVE`, icon `"codex-account-switcher"`.
- Tooltip/title: `"Codex: <alias> (<plan>)"` for `registry.default_slug()`'s account; `"Codex: unknown account"` if `None` or dangling.
- Menu: one `Gtk.RadioMenuItem` per `registry.list()` entry:
  - Label while unrefreshed: `"<alias> · <plan>"` (rate-limit suffix/`BROKEN` labeling lands in Task 12).
  - Entry matching `registry.default_slug()` pre-checked, built with signal handling blocked during construction.
  - Separator, placeholder disabled `"Manage accounts"` (populated Task 11/13), separator, `"Quit"` → `Gtk.main_quit`.
- Radio-item `"activate"` (guarded to fire only when `item.get_active()` is `True`): `registry.set_default(account)`, rebuild tooltip, `notify-send` (swallow `FileNotFoundError`, never crash on a missing notifier).
- All calls here run synchronously on the GTK thread (a single instant pointer write + file copy, not network I/O) — worker-thread + `GLib.idle_add` pattern starts at Task 12 for the network-bound health fetches.

**Acceptance:**
- Automated smoke: `python3 -c "from indicator import Indicator; print('ok')"`
- Expected: prints `ok`, no traceback.

- [ ] Implement to satisfy the contract + behavior above
- [ ] Run smoke check → expected output above
- [ ] Commit: `git add indicator.py && git commit -m "feat: add Indicator tray core with menu and set-default-on-click"`

---

### Task 8: Device-auth dialog UI (+ Add-account safety copy)

**Wave:** 2
**Blocks:** Task 10
**Blocked by:** Task 3

**Files:**
- Create: `device_auth_dialog.py` — `DeviceAuthDialog`

**Contract (pin exactly):**
```python
class DeviceAuthDialog(Gtk.Dialog):
    def __init__(self, prompt: DeviceAuthPrompt, on_retry: Callable[[], None] | None = None,
                 existing_accounts: list[tuple[str, str]] | None = None): ...
                 # existing_accounts: list of (alias, email) shown ONLY for the add-account case
                 # (None/empty for repair, where showing "existing accounts" is meaningless —
                 # the account being repaired already exists)
    def show_failure(self, retry_available: bool) -> None
    def close_success(self) -> None
```

**Behavior:**
- Displays URL as clickable `Gtk.LinkButton`/markup (click spawns `subprocess.Popen(["firefox", prompt.url])` directly, never `xdg-open`); redundant "Open in Firefox" button; code shown large/monospace with "Copy code" (`Gtk.Clipboard...set_text(prompt.code, -1)`); "Copy full text" copies `prompt.raw_text`; static "expires in 15 minutes" label.
- **When `existing_accounts` is non-empty**: a warning banner shown ABOVE the link, exact copy: *"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."* — followed by a plain list of `"<alias> — <email>"` for each entry in `existing_accounts`.
- Non-modal (`set_modal(False)`).
- `show_failure`/`close_success`/Firefox-spawn-failure handling unchanged from prior design (inline fallback label, never crashes).

**Acceptance:**
- Automated smoke: `python3 -c "from device_auth_dialog import DeviceAuthDialog; print('ok')"`
- Expected: prints `ok`, no traceback. (Interactive behavior verified manually in Task 15 steps 6, 8.)

- [ ] Implement to satisfy the contract + behavior above
- [ ] Run smoke check → expected output above
- [ ] Commit: `git add device_auth_dialog.py && git commit -m "feat: add device-auth dialog with add-account collision warning"`

---

### Task 9: `cdx` router entrypoint

**Wave:** 2
**Blocks:** Task 14
**Blocked by:** Task 4

**Files:**
- Create: `cdx.py`

**Contract (pin exactly):**
```python
def detect_project_name(cwd: Path = None) -> str | None
    # git rev-parse --show-toplevel from cwd (default Path.cwd()); on success, basename of that
    # path. On non-zero exit (not a git repo), basename of cwd itself. Never raises.

def load_rules(path: Path = Path.home() / ".codex-tray" / "routing_rules.json") -> RoutingRules
def load_health(path: Path = Path.home() / ".codex-tray" / "health_cache.json") -> dict[str, AccountSnapshot]
    # missing/unreadable/stale (mtime older than 2h) -> returns {} AND prints a one-line stderr
    # staleness notice; never raises, never blocks

def main(argv: list[str]) -> int
```

**Behavior:**
- `main`: `project = detect_project_name()`; `rules = load_rules()`; `health = load_health()`; `route = RoutingResolver(rules, health).resolve(project)`.
  - `route.fallback_used` → `subprocess.Popen(["notify-send", "cdx", f"{route.fallback_from} unavailable, using {route.slug} instead"])` (swallow `FileNotFoundError`), then proceed.
  - `codex_home = ~/.codex-tray/accounts/<route.slug>/CODEX_HOME`; `os.execvpe("codex", ["codex", *argv[1:]], {**os.environ, "CODEX_HOME": str(codex_home)})`.
  - `NoHealthyAccountError` from `resolve` → print its per-candidate message to stderr, return `1` (no `execvpe`, no accounts silently tried).
- No network calls anywhere in this file — resolution is pure local file reads + in-memory logic.

**Acceptance:**
- Automated: `tests/test_cdx.py`, monkeypatching `os.execvpe` (never actually exec in the test process) and `subprocess.Popen`, plus fixture `routing_rules.json`/`health_cache.json` files, asserting: (a) a project-matched dir resolves `CODEX_HOME` to that override's slug; (b) a non-matched dir resolves to `default`; (c) a broken default triggers the fallback path AND a `notify-send` call; (d) all-unhealthy raises/returns `1` with a stderr message naming every candidate, `execvpe` never called.
- Run: `pytest tests/test_cdx.py -v`
- Expected: PASS.

- [ ] Write the test above
- [ ] Implement to satisfy the contract + behavior
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add cdx.py tests/test_cdx.py && git commit -m "feat: add cdx account-router entrypoint"`

---

### Task 10: Repair-account flow

**Wave:** 3
**Blocks:** Task 11
**Blocked by:** Task 7, Task 8, Task 3

**Files:**
- Modify: `indicator.py` — repair-trigger wiring in the radio-item handler, `_repair_account(account)`

**Contract (pin exactly):**
```python
class Indicator:
    def _repair_account(self, account: Account) -> None
```

**Behavior:**
- Radio-item `"activate"` handler gains a health-status branch (health state lands in Task 12, branch point added now): `BROKEN` account → do NOT `set_default` — call `_repair_account(account)` instead, re-check the previously-default radio item.
- `_repair_account`: worker thread, `DeviceAuthFlow.start(account.codex_home, backup_existing=True)` → `read_prompt()`; GTK thread (`GLib.idle_add`) shows `DeviceAuthDialog(prompt, on_retry=lambda: self._repair_account(account))` — `existing_accounts=None` (repair, not add).
- Worker thread `await_completion()`, result via `GLib.idle_add`:
  - Success → `commit(account.codex_home)`; if re-decoded `account_id` differs from `account.account_id`, non-blocking `notify-send` warning, proceed anyway. **If `registry.default_slug() == account.slug`, also call `registry.set_default(account)`** so the repaired credential reaches the courtesy `~/.codex/auth.json` copy. `dialog.close_success()`, `notify-send "Re-authenticated <alias>"`, rebuild menu (re-check radio, tooltip; full health-label refresh is Task 12).
  - Failure/timeout → `rollback(account.codex_home)`, `dialog.show_failure(retry_available=True)`.

**Acceptance:**
- Automated: `tests/test_indicator_repair.py`, fake registry/`DeviceAuthFlow` (monkeypatched, call `_repair_account` directly, no real GTK loop), asserting: (a) success while repaired account is default → `set_default` called once with it; (b) success while not default → `set_default` never called; (c) failure → `rollback` called, `set_default` never called.
- Run: `pytest tests/test_indicator_repair.py -v`
- Expected: PASS.

- [ ] Write the test above
- [ ] Implement to satisfy the contract + behavior
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add indicator.py tests/test_indicator_repair.py && git commit -m "feat: wire repair-account flow, propagate fix to default account"`

---

### Task 11: Add-account flow (with collision mitigations)

**Wave:** 4
**Blocks:** Task 12
**Blocked by:** Task 10, Task 1, Task 3, Task 8

**Files:**
- Modify: `indicator.py` — "Manage accounts" → "Add account…", `_add_account()`

**Contract (pin exactly):**
```python
class Indicator:
    def _add_account(self) -> None
```

**Behavior:**
- "Add account…" opens a `Gtk.Dialog`: one `Gtk.Entry` for alias (placeholder `"e.g. Personal 2"`), OK/Cancel. Cancel does nothing.
- OK (alias non-empty, else re-show with inline error): `slug = registry.new_slug(alias)`, `dir = registry.add_dir(slug, alias)`.
- Worker thread: `DeviceAuthFlow.start(dir, backup_existing=False)` → `read_prompt()` → show `DeviceAuthDialog(prompt, on_retry=<re-invoke same start/read against dir>, existing_accounts=[(a.alias, a.email) for a in registry.list() if a.email])` — this is mitigation 1+2 (warning banner + existing-emails list) from spec §3.
- `await_completion()` result via `GLib.idle_add`:
  - Success → re-decode `dir/auth.json`'s `account_id`. If it matches any *other* existing account's `account_id` (collision): (i) `notify-send "Already added as <existing alias>"`; (ii) `registry.remove(slug)` (the new duplicate slug); (iii) **immediately mark the pre-existing matching account `BROKEN`** in the in-memory health cache (its session was just server-side revoked by this login, per spec §1 finding 2 — do not wait for the next hourly probe) and patch its menu label right away; (iv) `dialog.close_success()`. No collision → `notify-send "Added <alias>"`, rebuild menu; new account never auto-set-as-default.
  - Failure/timeout → `registry.remove(slug)` (`rollback` is a no-op here, `backup_existing=False`), `notify-send` failure, `dialog.show_failure(retry_available=False)` (dir already gone — user re-opens "Add account…" fresh).

**Acceptance:**
- Automated: `tests/test_indicator_add.py`, fake registry/`DeviceAuthFlow`, calling `_add_account`'s post-`await_completion` logic directly (factored into a plain function taking the result), asserting: (a) success + no collision → `remove` never called, menu rebuild triggered; (b) success + collision → `remove` called once for the new slug AND the pre-existing colliding account's cached status is set to `BROKEN`; (c) failure → `remove` called once, `rollback` never called.
- Run: `pytest tests/test_indicator_add.py -v`
- Expected: PASS.

- [ ] Write the test above
- [ ] Implement to satisfy the contract + behavior
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add indicator.py tests/test_indicator_add.py && git commit -m "feat: add add-account flow with collision warning, email list, and immediate BROKEN flagging"`

---

### Task 12: Rename/Remove flow

**Wave:** 5
**Blocks:** Task 13
**Blocked by:** Task 11, Task 1

**Files:**
- Modify: `indicator.py` — "Rename…"/"Remove…" submenu items

**Contract (pin exactly):**
```python
class Indicator:
    def _rename_account(self, account: Account) -> None
    def _remove_account(self, account: Account) -> None
```

**Behavior:**
- "Rename…": dialog listing accounts with a text entry pre-filled with current alias; OK → `registry.rename(slug, new_alias)`, rebuild menu; Cancel does nothing.
- "Remove…": confirmation dialog (`Gtk.MessageDialog`, `QUESTION`, `YES_NO`) naming the alias; confirm → `registry.remove(slug)`, rebuild menu. If removed account was the default, tooltip falls back to `"Codex: unknown account"` (per `default_slug()` now dangling/unmatched) — no forced re-pick, matching spec §4.

**Acceptance:**
- Automated: `tests/test_indicator_manage.py`, fake registry, asserting `_rename_account` confirm path calls `registry.rename(slug, new_alias)` exactly once; `_remove_account` confirm path calls `registry.remove(slug)` exactly once; both no-op on cancel/No.
- Run: `pytest tests/test_indicator_manage.py -v`
- Expected: PASS.

- [ ] Write the test above
- [ ] Implement to satisfy the contract + behavior
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add indicator.py tests/test_indicator_manage.py && git commit -m "feat: add rename/remove account management flows"`

---

### Task 13: Background health refresh (menu-open + hourly) + `health_cache.json` writes

**Wave:** 6
**Blocks:** Task 14
**Blocked by:** Task 12, Task 2

**Files:**
- Modify: `indicator.py` — refresh scheduling + label patching + cache write

**Contract (pin exactly):**
```python
class Indicator:
    def _refresh_account(self, account: Account) -> None
    def _refresh_all(self, force: bool = False) -> None
```

**Behavior:**
- Each `Account` gets an in-memory `last_fetched: float | None` and cached `AccountSnapshot` (dict keyed by slug inside `Indicator`).
- `_refresh_account(account)`: worker thread, `health_client.fetch(account.codex_home)` — every account uses its own `codex_home` unconditionally now (no active-vs-isolated branch; that special case is gone). Result via `GLib.idle_add`: `BROKEN` sets cached status `BROKEN`; `OK` sets `OK` + percentages; `UNKNOWN` updates `last_fetched` only, cached status unchanged. After updating cache, patch that item's label (`"<alias> · <plan> · 5h X% / wk Y%"` / `"<alias> · <plan>"` / `"<alias> · ⚠ needs re-login"`).
- After every `_refresh_all` pass completes (all accounts fetched), call `health_client.write_cache(~/.codex-tray/health_cache.json, {slug: snapshot for ...})` — this is the file `cdx` reads.
- `_refresh_all(force)`: iterates `registry.list()`, calls `_refresh_account` for entries with `last_fetched is None`, or `force`, or `time.time() - last_fetched > 180`.
- Menu `"show"` → `_refresh_all(force=False)`. `GLib.timeout_add_seconds(3600, ...)` registered in `build()` → `_refresh_all(force=True)`, unconditional hourly. `build()` also calls `_refresh_all(force=True)` once at startup.

**Acceptance:**
- Automated: `tests/test_indicator_refresh.py`, fake `AccountHealthClient.fetch` with canned snapshots, asserting: (a) `fetch` is called with `account.codex_home` for every account, including the current default (no special-cased second path); (b) `UNKNOWN` following cached `OK` leaves it `OK`; (c) `_refresh_all(force=False)` skips an account under 180s old, `force=True` never skips; (d) `write_cache` is called once per `_refresh_all` pass with a dict covering every account.
- Run: `pytest tests/test_indicator_refresh.py -v`
- Expected: PASS.

- [ ] Write the test above
- [ ] Implement to satisfy the contract + behavior
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add indicator.py tests/test_indicator_refresh.py && git commit -m "feat: add hourly/on-open health refresh writing health_cache.json for cdx"`

---

### Task 14: Entrypoint wiring

**Wave:** 7
**Blocks:** Task 15
**Blocked by:** Task 1, Task 2, Task 3, Task 5, Task 9, Task 13

**Files:**
- Create: `systray_codex_switcher.py`

**Contract (pin exactly):**
```python
def main() -> None: ...

if __name__ == "__main__":
    main()
```

**Behavior:**
- Constructs `AccountRegistry()` (defaults), calls `registry.migrate_legacy()`, constructs `AccountHealthClient()`, constructs `Indicator(registry, health_client)`, `indicator.build()`, `indicator.run()` (blocks on `Gtk.main()`).
- Any uncaught startup exception (before `Gtk.main()`) → full traceback to stderr, exit nonzero — no silent failure, since autostart execs exactly this script.
- Also writes `~/.codex-tray/routing_rules.json` on first run if it doesn't exist yet, seeded with the exact default from Task 4's contract (`zync.is→avi, automixer→rafa, multideal→roy, default=rafa, fallback_chain=[roy,avi]`) — this is the one-time scaffold the user asked for; a hand-edit or future UI can change it afterward, this entrypoint never overwrites an existing file.

**Acceptance:**
- Run: `python3 -c "import ast; ast.parse(open('systray_codex_switcher.py').read())" && echo SYNTAX_OK`
- Run: `timeout 3 python3 systray_codex_switcher.py; test $? -eq 124 && echo RAN_WITHOUT_CRASHING`
- Run: `test -f ~/.codex-tray/routing_rules.json && python3 -c "import json; d=json.load(open('$HOME/.codex-tray/routing_rules.json')); assert d['default']=='rafa' and d['fallback_chain']==['roy','avi']; print('RULES_OK')"`
- Expected: all three print their `_OK`/`_WITHOUT_CRASHING` lines.

- [ ] Implement to satisfy the contract + behavior
- [ ] Run all three acceptance checks → expected output above
- [ ] Commit: `git add systray_codex_switcher.py && git commit -m "feat: add entrypoint wiring registry, health client, indicator, and seeded routing rules"`

---

### Task 15: Manual verification pass

**Wave:** 8
**Blocks:** —
**Blocked by:** Task 14

**Files:** none (verification only)

**Behavior:** Run every step on the real desktop session.

- [ ] 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 (`ls -la` confirms `config.toml`/`skills`/etc are symlinks, `auth.json` is a regular file); legacy files byte-identical (`diff`); menu lists all 3.
- [ ] 2. Hover tray icon → tooltip matches default account.
- [ ] 3. Click an unchecked healthy account → `default_slug` updates; courtesy `~/.codex/auth.json` 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; `~/.codex-tray/health_cache.json` reflects the same values.
- [ ] 5. Corrupt one account's `auth.json` → next refresh flags `BROKEN`; click opens repair dialog instead of switching.
- [ ] 6. Repair dialog: copy-code/copy-full-text/Firefox-link all work; abandoning restores `.bak`, stays `BROKEN`.
- [ ] 7. Complete a real repair (test account) → health flips `OK`.
- [ ] 8. Add account: warning banner + existing-account email list visible before the device-auth link appears.
- [ ] 9. Trigger an intentional Add-account collision (re-auth an account already added) → new slug rolled back AND the pre-existing colliding account immediately shows `BROKEN` in the menu (not just after the next hourly probe).
- [ ] 10. Repair the *currently-default* broken account → confirm courtesy `~/.codex/auth.json` decodes to the new token afterward.
- [ ] 11. Remove an account (including the current default) → entry disappears; `~/.codex/auth.json` untouched; tooltip falls back to "unknown account".
- [ ] 12. Rename an account → label updates immediately, slug/directory unchanged.
- [ ] 13. Leave tray running 1+ hour, unopened → hourly refresh fires, `health_cache.json` mtime updates.
- [ ] 14. `cd` into (or create) a directory named `zync.is`, run `cdx login status` → confirm (via a debug env-print wrapper, or `ps`/`/proc/<pid>/environ` on the exec'd process) `CODEX_HOME` resolved to `avi`'s directory.
- [ ] 15. From a directory with no project override, run `cdx` → resolves to global default (`rafa`).
- [ ] 16. Simulate the global default `BROKEN` (corrupt its `auth.json`, wait for a refresh) → `cdx` falls to the fallback chain; a desktop notification names both accounts.
- [ ] 17. Simulate the entire fallback chain `BROKEN` → `cdx` exits 1, stderr names every candidate and its status, no `codex` process launched.
- [ ] 18. Log out / back into Cinnamon → tray reappears (autostart fired).

No commit step (verification only) — any failed step: fix the relevant task's code, re-run its automated acceptance check plus this step before proceeding.

---

## Architecture Decisions

Carried and updated from the spec's own self-review (spec §"Architecture Decisions"):

- `AccountRegistry`, `AccountHealthClient`, `DeviceAuthFlow` each earn their boundary (deletion test fails). `AccountRegistry`'s `set_default` is now strictly simpler than the old `switch_to` — one pointer write + one one-way courtesy copy, no save-back/divergence handling, because there is exactly one real credential file per account, permanently.
- `RoutingResolver`/`cdx.py`: deletion test fails hard — without this boundary, every launcher of `codex` would re-derive project/default/fallback resolution ad hoc. `RoutingResolver` is pure logic (testable without subprocesses); `cdx.py` is the thin OS-level shell around it (project detection + `execvpe`).
- `Indicator` is a deep, pure-presentation seam, unchanged.
- Rename/Remove and Add/Repair remain thin orchestration methods on `Indicator`, not separate classes — unchanged reasoning.
- `device_auth_dialog.py` stays split out from `indicator.py` for Wave-2 parallelism (zero dependency between Task 7 and Task 8/9's dialog work) — file-boundary choice, not a new spec component.
- Symlink-farm sync (`sync_shared_links`) stays inside `AccountRegistry` — single call-site (migration + `add_dir`), no independent state, would be decorative as its own module.
