{"type":"meta","schema":"session-state/v1","slug":"codex-account-switcher","topic":"cdx account router + Cinnamon systray Codex CLI multi-account switcher","created":"2026-07-01","exec_mode":"ship","scheduler":"dag-parallel","land_mode":"merge-to-main","base_branch":"main","status_enum":["BLOCKED","PENDING","WORKING","WRITTEN","REVIEWED","COMMITTED"],"truth_rule":"this file is ground truth for INTENT (goals/directions/task list); git is ground truth for DONE-NESS — reconcile statuses against git, never trust a COMMITTED line with no commit","refresh_how":"native Read of THIS file — NEVER ctx_execute/ctx_search (the point is re-injecting intent into the main thread; a context-mode call keeps bytes OUT and silently kills the feature)","refresh_what":"meta + every goal/methodology/intent/direction record + all WORKING and BLOCKED tasks (skip COMMITTED lines)","refresh_when":["wave/phase boundary","any task status change","after a compaction system-reminder","before calling advisor"],"update_rule":"on status change, rewrite that task's single line in place; append new tasks/session_memory as new lines","report_rule":"at session end emit brief: done / blocked / deferred / canceled — NO 'next action' (if you know it, do it)","preflight":{"task_graph_hash":"aca44a2bd1b41c2422e6055e0fd352c288cf3b70c4f82817043805fe530b877c","route":{"w1.p1.t1":"cursor","w1.p1.t2":"north","w1.p1.t3":"cursor","w1.p1.t4":"north","w1.p1.t5":"north","w1.p1.t6":"north","w2.p1.t7":"cursor","w2.p1.t8":"north","w2.p1.t9":"north","w3.p1.t10":"cursor","w4.p1.t11":"cursor","w5.p1.t12":"north","w6.p1.t13":"cursor","w7.p1.t14":"north"},"route_hash":"aca44a2bd1b41c2422e6055e0fd352c288cf3b70c4f82817043805fe530b877c"}}
{"type": "goal", "text": "cdx account-router CLI: resolves which Codex account to use per-invocation (per-project override -> global default -> health-aware fallback chain) and execs real codex with CODEX_HOME pointed at that account's isolated, never-shared credential."}
{"type": "goal", "text": "Cinnamon tray app: click-menu to set the global default Codex CLI ChatGPT account (roy/rafa/avi + user-added), never revoking tokens, never disturbing running codex background processes."}
{"type": "goal", "text": "Live per-account rate-limit/usage display and token-health monitoring at zero model-quota cost, via codex app-server JSON-RPC account/rateLimits/read; tray-maintained health_cache.json is what cdx reads (cdx never makes a live call itself)."}
{"type": "goal", "text": "One-click repair of a broken/expired account via codex login --device-auth, dialog with Firefox-specific clickable link, copy-code, copy-full-text buttons, plus explicit collision-safety warnings."}
{"type": "goal", "text": "Account manager: add/remove accounts, rename display aliases, scales to N accounts with zero code changes."}
{"type": "methodology", "text": "Contract-level plan; ship skill (fresh subagent per task + two-stage review); commit after each task."}
{"type": "intent", "text": "User juggles multiple real ChatGPT/Codex accounts (sometimes belonging to different real people) across concurrent background codex agents; manual auth.json cp today is error-prone and no CLI account-switch exists."}
{"type": "direction", "verbatim": "scalaility should be about adding accounts, and not about \"multi-user\"."}
{"type": "direction", "verbatim": "per-account subfolder would be safer. isn't codex open source? just find how to get the /status from the code"}
{"type": "direction", "verbatim": "need to include \"token health\" to make sure token is still valid, ping each account with /status every 1h to refresh rate limits + verify token health."}
{"type": "direction", "verbatim": "make sure the link is clickable so i can just click it and it will open a new tab in firefox"}
{"type": "direction", "verbatim": "not codex, but \"cdx\" executable will be not just a wrapper, but also an account router. and the selection will be via the systray first, later i will introduce rules about it"}
{"type": "direction", "verbatim": "the rules will be: which project use which account, with fallback settings. can be per project, can be global, according to my setting. \"per project\" overrides global\nactualy we can already introduct this rules, zync.is -> avi, automixer -> rafa, multideal -> roy, so scaffold rules for now. then for global, main is rafa, fallback chain: [roy, avi]"}
{"type": "checkpoint", "text": "Advisor-flagged gaps resolved and verified live before the cdx pivot: (1) account/rateLimits/read never rewrites auth.json (confirmed via md5 diff, both OK and 401 paths). (2) No auth-path-override flag/env var exists in codex (--help/login --help/app-server --help/doctor all checked; doctor shows a fixed 'auth file: ~/.codex/auth.json' tied to CODEX_HOME) -- confirms the isolated-CODEX_HOME-per-account model (not a lighter auth-file-only override) is required. (3) Symlinking non-auth entries (tested: config.toml) into a separate CODEX_HOME works fine for read-only commands. (4) state_5.sqlite has an account_id-keyed table (remote_control_enrollments) -- confirms identity-bearing runtime state must stay real-per-account, not shared; resolved by splitting the farm into a shared tier (skills/plugins/commands/rules/context-mode/config.toml/AGENTS.md/RTK.md/cache/models_cache.json/installation_id/version.json) and a real-per-account tier (auth.json + all sessions/history/memories/goals/state/logs sqlite + tmp)."}
{"type": "checkpoint", "text": "Live-tested finding: 2 of 3 existing backup auth files (roy, avi) are already server-revoked (token_revoked/token_invalidated); only rafa is currently healthy. Real confirmation the repair flow is needed, not hypothetical. Fallback chain [roy, avi] therefore currently has zero healthy members until repaired."}
{"type": "checkpoint", "text": "Critical live finding during this session's own verification: codex login --device-auth server-side revokes an account's OTHER live sessions globally and instantly, independent of which local directory performs the login -- proven by a live test that broke the user's real active rafa session (immediately repaired). This directly motivated the cdx + per-account isolated CODEX_HOME redesign (replacing the old single-shared ~/.codex/auth.json divergence-handling model) and the three Add-account collision mitigations (explicit warning copy, show-existing-emails guard, immediate BROKEN-flagging of the pre-existing duplicate account)."}
{"type": "checkpoint", "text": "Separately, ~/.codex/auth.json was observed to vanish mid-session while an unrelated background codex exec process (different agent job, PID 2037117) was running against the same shared ~/.codex/ -- restored from backup. Direct live proof that concurrent processes sharing one mutable auth.json is already unsafe today, independent of any multi-account complexity -- the core problem the per-account isolated CODEX_HOME model solves."}
{"type": "gated", "id": "g1", "category": "policy", "needs": "cdx fallback-chain trigger semantics: broken-only vs broken-or-quota-exhausted", "why": "accounts may belong to different real people; quota-based fallback silently reroutes work (and quota consumption) onto a different account without an interactive prompt", "options": ["proceed", "abort"], "default": "proceed", "status": "RESOLVED", "answer": "proceed", "resolved_by": "user", "source": "author", "binds_meta": null}
{"type": "anchor", "path": "docs/specs/2026-07-01-codex-account-switcher-design.md", "what": "spec"}
{"type": "anchor", "path": "docs/plans/2026-07-01-codex-account-switcher.md", "what": "plan"}
{"type": "session_memory", "note": "User's answer to the fallback-trigger question: broken_or_quota_exhausted (not broken-only, despite that being the recommended/safer default) -- every fallback event must still surface a desktop notification naming both accounts, never silent, per RoutingResolver/cdx contract in Task 4/9."}
{"type":"task","id":"w1.p1.t1","wave":1,"phase":1,"desc":"AccountRegistry + migration + shared-tier symlink sync","status":"COMMITTED","deps":[],"requires_decision":null,"files":["account_registry.py"],"blocker":null}
{"type":"task","id":"w1.p1.t2","wave":1,"phase":1,"desc":"AccountHealthClient + health_cache.json writer","status":"COMMITTED","deps":[],"requires_decision":null,"files":["health_client.py"],"blocker":null}
{"type":"task","id":"w1.p1.t3","wave":1,"phase":1,"desc":"DeviceAuthFlow","status":"COMMITTED","deps":[],"requires_decision":null,"files":["device_auth.py"],"blocker":null}
{"type":"task","id":"w1.p1.t4","wave":1,"phase":1,"desc":"RoutingResolver (per-project/global/fallback-chain resolution)","status":"COMMITTED","deps":[],"requires_decision":null,"files":["routing_resolver.py"],"blocker":null}
{"type":"task","id":"w1.p1.t5","wave":1,"phase":1,"desc":"Icon asset","status":"COMMITTED","deps":[],"requires_decision":null,"files":["icons/codex-account-switcher.svg"],"blocker":null}
{"type":"task","id":"w1.p1.t6","wave":1,"phase":1,"desc":"Create repo-tracked Cinnamon autostart .desktop template at packaging/codex-account-switcher.desktop (XDG desktop-entry format: Type=Application, Exec=path to systray_codex_switcher.py entrypoint, X-GNOME-Autostart-enabled=true), then install by copying it to ~/.config/autostart/codex-account-switcher.desktop (mkdir -p parent first). Mirrors the icon-asset create-then-install pattern (Task 5). MUST commit the packaging/ file to git -- the installed copy outside the repo is a side effect, not the deliverable.","status":"COMMITTED","deps":[],"requires_decision":null,"files":["packaging/codex-account-switcher.desktop"],"blocker":null}
{"type":"task","id":"w2.p1.t7","wave":2,"phase":1,"desc":"Indicator core (tray/menu/tooltip/set-default)","status":"COMMITTED","deps":["w1.p1.t1","w1.p1.t2"],"requires_decision":null,"files":["indicator.py"],"blocker":null}
{"type":"task","id":"w2.p1.t8","wave":2,"phase":1,"desc":"Device-auth dialog UI + add-account collision warning","status":"COMMITTED","deps":["w1.p1.t3"],"requires_decision":null,"files":["device_auth_dialog.py"],"blocker":null}
{"type":"task","id":"w2.p1.t9","wave":2,"phase":1,"desc":"cdx router entrypoint","status":"COMMITTED","deps":["w1.p1.t4"],"requires_decision":"g1","files":["cdx.py"],"blocker":null}
{"type":"task","id":"w3.p1.t10","wave":3,"phase":1,"desc":"Repair-account flow","status":"COMMITTED","deps":["w2.p1.t7","w2.p1.t8","w1.p1.t3"],"requires_decision":null,"files":["indicator.py"],"blocker":null}
{"type":"task","id":"w4.p1.t11","wave":4,"phase":1,"desc":"Add-account flow with collision mitigations","status":"COMMITTED","deps":["w3.p1.t10","w1.p1.t1","w1.p1.t3","w2.p1.t8"],"requires_decision":null,"files":["indicator.py"],"blocker":null}
{"type":"task","id":"w5.p1.t12","wave":5,"phase":1,"desc":"Rename/Remove flow","status":"COMMITTED","deps":["w4.p1.t11","w1.p1.t1"],"requires_decision":null,"files":["indicator.py"],"blocker":null}
{"type":"task","id":"w6.p1.t13","wave":6,"phase":1,"desc":"Background health refresh + health_cache.json writes","status":"COMMITTED","deps":["w5.p1.t12","w1.p1.t2"],"requires_decision":null,"files":["indicator.py"],"blocker":null}
{"type":"task","id":"w7.p1.t14","wave":7,"phase":1,"desc":"Entrypoint wiring + seed routing_rules.json","status":"COMMITTED","deps":["w1.p1.t1","w1.p1.t2","w1.p1.t3","w1.p1.t5","w2.p1.t9","w6.p1.t13"],"requires_decision":null,"files":["systray_codex_switcher.py"],"blocker":null}
{"type":"task","id":"w8.p1.t15","wave":8,"phase":1,"desc":"Manual verification pass","status":"COMMITTED","deps":["w7.p1.t14"],"requires_decision":null,"files":[],"blocker":null}
{"type": "gated", "id": "g2", "category": "irreversible", "needs": "crash/hang recovery during DeviceAuthFlow backup/restore", "why": "already spec'd: start() backs up auth.json to .bak before spawn, rollback() restores on any failure/timeout path; commit()/rollback() are the only two exits from a session", "options": ["proceed", "abort"], "status": "RESOLVED", "answer": "proceed", "resolved_by": "scan-autoresolve", "source": "scan", "binds_meta": null}
{"type": "gated", "id": "g3", "category": "architecture", "needs": "symlink vs copy for shared config/skills/plugins", "why": "already decided and documented in spec \u00a73 as the tiered farm: shared/static entries symlinked, identity-bearing runtime state (incl. state_5.sqlite's account_id-keyed table) kept real per-account -- this is the resolution, not an open question", "options": ["proceed", "abort"], "status": "RESOLVED", "answer": "proceed", "resolved_by": "scan-autoresolve", "source": "scan", "binds_meta": null}
{"type": "gated", "id": "g4", "category": "policy", "needs": "autostart on Cinnamon login", "why": "in original spec scope from session start, user-requested feature, not new", "options": ["proceed", "abort"], "status": "RESOLVED", "answer": "proceed", "resolved_by": "scan-autoresolve", "source": "scan", "binds_meta": null}
{"type": "gated", "id": "g5", "category": "architecture", "needs": "courtesy ~/.codex/auth.json overwritten by set_default", "why": "deliberate documented design (spec \u00a73): a real, never-symlinked mirror for bare-codex fallback use, explicitly reasoned about as a courtesy default, not silent scope creep", "options": ["proceed", "abort"], "status": "RESOLVED", "answer": "proceed", "resolved_by": "scan-autoresolve", "source": "scan", "binds_meta": null}
{"type": "gated", "id": "g6", "category": "irreversible", "needs": "repair-account device-auth revokes that same account's other live sessions", "why": "this is the account's OWN re-auth -- the known, live-tested, spec-documented finding (\u00a71 finding 2) this whole redesign exists around; inherent to repairing a broken token, not an avoidable side effect", "options": ["proceed", "abort"], "status": "RESOLVED", "answer": "proceed", "resolved_by": "scan-autoresolve", "source": "scan", "binds_meta": null}
{"type": "gated", "id": "g7", "category": "irreversible", "needs": "add-account device-auth risk + auto-flagging pre-existing duplicate BROKEN", "why": "same inherent re-auth risk as g6; auto-BROKEN-flagging is one of the three mitigations the user explicitly agreed to earlier this session (spec \u00a73 'Add-account collision mitigations')", "options": ["proceed", "abort"], "status": "RESOLVED", "answer": "proceed", "resolved_by": "scan-autoresolve", "source": "scan", "binds_meta": null}
{"type": "gated", "id": "g8", "category": "irreversible", "needs": "remove-account rmtree needs a confirm step", "why": "already spec'd: Task 12 behavior is a Gtk.MessageDialog YES_NO confirmation before registry.remove() is ever called", "options": ["proceed", "abort"], "status": "RESOLVED", "answer": "proceed", "resolved_by": "scan-autoresolve", "source": "scan", "binds_meta": null}
{"type": "gated", "id": "g9", "category": "policy", "needs": "real project/account names in seeded routing_rules.json", "why": "CORRECTED, not an open question: this file is written to ~/.codex-tray/ (runtime user-data dir) at first run, never committed to git -- the git allow-list only ever stages .py/.svg/test files, so no real identifier reaches the repo", "options": ["proceed", "abort"], "status": "RESOLVED", "answer": "proceed", "resolved_by": "scan-autoresolve", "source": "scan", "binds_meta": null}
{"type": "gated", "id": "g10", "category": "irreversible", "needs": "manual-verification step 9 deliberately triggers a real collision, revoking a real account's other sessions", "why": "the whole point of that step is to test the g7 mitigation live; caution noted directly in the plan's Task 15 step 9 -- run only against an account the user is prepared to re-authenticate elsewhere afterward", "options": ["proceed", "abort"], "status": "RESOLVED", "answer": "proceed", "resolved_by": "scan-autoresolve", "source": "scan", "binds_meta": null}
