From 67397554ff4f3a1e3687db8f8773076d240ebce0 Mon Sep 17 00:00:00 2001 From: Dequan Zhang Date: Mon, 23 Mar 2026 16:28:44 +0800 Subject: [PATCH 1/2] feat: add codex enhanced multi-account workflow --- AGENTS.md | 4 - README.md | 160 ++++-- codex-rs/Cargo.lock | 17 + codex-rs/Cargo.toml | 2 + codex-rs/cli/Cargo.toml | 2 + codex-rs/cli/src/login.rs | 216 +++++++- codex-rs/cli/src/main.rs | 14 +- codex-rs/ext/Cargo.toml | 23 + codex-rs/ext/README.md | 14 + codex-rs/ext/src/account_pool.rs | 504 ++++++++++++++++++ codex-rs/ext/src/account_signal.rs | 136 +++++ codex-rs/ext/src/host_api.rs | 127 +++++ codex-rs/ext/src/lib.rs | 34 ++ codex-rs/ext/src/managed_account_auth.rs | 284 ++++++++++ codex-rs/ext/src/router.rs | 300 +++++++++++ codex-rs/login/src/server.rs | 3 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 418 +++++++++++++++ codex-rs/tui/src/app_event.rs | 20 + codex-rs/tui/src/chatwidget.rs | 371 ++++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 87 +++ codex-rs/tui_app_server/Cargo.toml | 1 + codex-rs/tui_app_server/src/app.rs | 435 +++++++++++++++ codex-rs/tui_app_server/src/app_event.rs | 20 + codex-rs/tui_app_server/src/chatwidget.rs | 156 ++++++ .../tui_app_server/src/chatwidget/tests.rs | 10 + docs/fork-extension-mvp.md | 70 +++ 27 files changed, 3367 insertions(+), 62 deletions(-) create mode 100644 codex-rs/ext/Cargo.toml create mode 100644 codex-rs/ext/README.md create mode 100644 codex-rs/ext/src/account_pool.rs create mode 100644 codex-rs/ext/src/account_signal.rs create mode 100644 codex-rs/ext/src/host_api.rs create mode 100644 codex-rs/ext/src/lib.rs create mode 100644 codex-rs/ext/src/managed_account_auth.rs create mode 100644 codex-rs/ext/src/router.rs create mode 100644 docs/fork-extension-mvp.md diff --git a/AGENTS.md b/AGENTS.md index 3a287a599..8c63fcf36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,10 +20,6 @@ In the codex-rs folder where the rust code lives: - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. -- If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the - repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change. -- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught - locally before CI. - Bazel does not automatically make source-tree files available to compile-time Rust file access. If you add `include_str!`, `include_bytes!`, `sqlx::migrate!`, or similar build-time file or directory reads, update the crate's `BUILD.bazel` (`compile_data`, `build_script_data`, or test diff --git a/README.md b/README.md index 1e44875f2..1487e285c 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,128 @@ -

npm i -g @openai/codex
or brew install --cask codex

-

Codex CLI is a coding agent from OpenAI that runs locally on your computer. -

- Codex CLI splash -

-
-If you want Codex in your code editor (VS Code, Cursor, Windsurf), install in your IDE. -
If you want the desktop app experience, run codex app or visit the Codex App page. -
If you are looking for the cloud-based agent from OpenAI, Codex Web, go to chatgpt.com/codex.

- ---- - -## Quickstart - -### Installing and running Codex CLI - -Install globally with your preferred package manager: - -```shell -# Install using npm -npm install -g @openai/codex +# Codex Enhanced + +Codex Enhanced is a standalone public distribution of Codex focused on +multi-account ChatGPT operations, fullscreen TUI workflow improvements, and a +smaller long-term fork maintenance surface. + +This repository is maintained as its own GitHub project instead of a GitHub +fork. It tracks upstream Codex where practical, while keeping product-specific +behavior behind a dedicated extension layer so future changes can converge on +plugins instead of repeated invasive rebases. + +## Why This Version Exists + +Upstream Codex already provides a strong local coding agent. The main problem +this project solves is operational: + +- switching between multiple ChatGPT accounts should not require manual file + juggling +- rate-limit and usage-limit handling should be able to fail over to another + account automatically +- fullscreen TUI workflows should expose session and account operations in one + operator-facing control surface +- fork-specific behavior should move toward a stable extension boundary instead + of expanding the patch set in core runtime code + +## What Is Included + +- Managed account storage under `~/.codex/accounts` +- `codex login --auth ` for capturing multiple ChatGPT logins into named + account slots +- Account pool metadata with stable IDs, aliases, cooldown state, and inferred + usage windows +- Threshold-based account routing and one-shot retry on explicit + limit/rejection failures for normal user turns in the fullscreen TUI path +- A `Ctrl-P` control panel with: + - global session picker + - account selection + - alias rename submenu + - current-session fork entry point +- A dedicated `codex-rs/ext` crate for fork-owned extension state and host + compatibility groundwork + +## Current Scope + +The current milestone is a practical MVP for daily use, not the final extension +architecture. + +Implemented now: + +- managed ChatGPT account registry and auth snapshot layout +- account activation and alias management in the TUI +- control-panel-driven session and account operations +- inferred cooldown recording from explicit limit errors +- login-time account registration + +Planned next: + +- broader automatic account routing coverage beyond the current fullscreen TUI + path +- observable switch reasons and richer operator status views +- hook/interceptor expansion +- capability-negotiated WASM plugins built on top of `codex-ext` + +## Repository Layout + +- [codex-rs/ext](./codex-rs/ext) + Fork-owned extension crate for account pool state, auth snapshots, and future + plugin host compatibility. +- [codex-rs/tui](./codex-rs/tui) + Fullscreen local TUI implementation. +- [codex-rs/tui_app_server](./codex-rs/tui_app_server) + App-server-backed TUI implementation that mirrors relevant UX changes. +- [docs/fork-extension-mvp.md](./docs/fork-extension-mvp.md) + Fork proposal, MVP design, and phased roadmap. + +## Build And Run + +Build the Rust CLI locally: + +```bash +cd codex-rs +cargo build -p codex-cli +./target/debug/codex ``` -```shell -# Install using Homebrew -brew install --cask codex -``` +Install it into your shell path: -Then simply run `codex` to get started. +```bash +cd codex-rs +cargo build --release -p codex-cli +sudo ln -sf "$(pwd)/target/release/codex" /usr/local/bin/codex +codex --help +``` -
-You can also go to the latest GitHub Release and download the appropriate binary for your platform. +## Managed Account Quick Start -Each GitHub Release contains many executables, but in practice, you likely want one of these: +Register multiple ChatGPT logins into the managed account pool: -- macOS - - Apple Silicon/arm64: `codex-aarch64-apple-darwin.tar.gz` - - x86_64 (older Mac hardware): `codex-x86_64-apple-darwin.tar.gz` -- Linux - - x86_64: `codex-x86_64-unknown-linux-musl.tar.gz` - - arm64: `codex-aarch64-unknown-linux-musl.tar.gz` +```bash +codex login --auth primary +codex login --auth backup +codex login status +``` -Each archive contains a single entry with the platform baked into the name (e.g., `codex-x86_64-unknown-linux-musl`), so you likely want to rename it to `codex` after extracting it. +Start Codex, then use: -
+- `Ctrl-P -> Sessions` to open the global session picker +- `Ctrl-P -> Accounts` to switch the active managed account +- `Ctrl-P -> Accounts -> Rename` to rename account aliases -### Using Codex with your ChatGPT plan +Managed account state is stored under: -Run `codex` and select **Sign in with ChatGPT**. We recommend signing into your ChatGPT account to use Codex as part of your Plus, Pro, Team, Edu, or Enterprise plan. [Learn more about what's included in your ChatGPT plan](https://help.openai.com/en/articles/11369540-codex-in-chatgpt). +```text +~/.codex/accounts/ +├── account-pool.json +└── / + └── auth.json +``` -You can also use Codex with an API key, but this requires [additional setup](https://developers.openai.com/codex/auth#sign-in-with-an-api-key). +## Upstream Relationship -## Docs +This project is based on OpenAI Codex and keeps upstream history so changes can +be rebased and audited cleanly. The maintenance goal is to keep the fork-owned +delta small, explicit, and increasingly isolated behind `codex-ext`. -- [**Codex Documentation**](https://developers.openai.com/codex) -- [**Contributing**](./docs/contributing.md) -- [**Installing & building**](./docs/install.md) -- [**Open source fund**](./docs/open-source-fund.md) +## License -This repository is licensed under the [Apache-2.0 License](LICENSE). +This repository remains licensed under the [Apache-2.0 License](LICENSE). diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d0917ee38..e1bd42d14 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1660,6 +1660,7 @@ dependencies = [ "anyhow", "assert_cmd", "assert_matches", + "base64 0.22.1", "clap", "clap_complete", "codex-app-server", @@ -1672,6 +1673,7 @@ dependencies = [ "codex-core", "codex-exec", "codex-execpolicy", + "codex-ext", "codex-features", "codex-login", "codex-mcp-server", @@ -2092,6 +2094,19 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "codex-ext" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "codex-app-server-protocol", + "codex-core", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-features" version = "0.0.0" @@ -2602,6 +2617,7 @@ dependencies = [ "codex-client", "codex-cloud-requirements", "codex-core", + "codex-ext", "codex-features", "codex-feedback", "codex-file-search", @@ -2695,6 +2711,7 @@ dependencies = [ "codex-client", "codex-cloud-requirements", "codex-core", + "codex-ext", "codex-features", "codex-feedback", "codex-file-search", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 524b61e3b..dcaf32e1f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -27,6 +27,7 @@ members = [ "hooks", "secrets", "exec", + "ext", "exec-server", "execpolicy", "execpolicy-legacy", @@ -110,6 +111,7 @@ codex-connectors = { path = "connectors" } codex-config = { path = "config" } codex-core = { path = "core" } codex-exec = { path = "exec" } +codex-ext = { path = "ext" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c2fd1300c..028a8991d 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -30,6 +30,7 @@ codex-config = { workspace = true } codex-core = { workspace = true } codex-exec = { workspace = true } codex-execpolicy = { workspace = true } +codex-ext = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } codex-mcp-server = { workspace = true } @@ -65,6 +66,7 @@ codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows- [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } +base64 = { workspace = true } codex-utils-cargo-bin = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index d0cc1a3a1..1e9ae03c4 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -11,9 +11,14 @@ use codex_core::CodexAuth; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::AuthMode; use codex_core::auth::CLIENT_ID; +use codex_core::auth::load_auth_dot_json; use codex_core::auth::login_with_api_key; use codex_core::auth::logout; use codex_core::config::Config; +use codex_ext::AccountPoolStore; +use codex_ext::ManagedAccountSnapshot; +use codex_ext::persist_current_managed_account_snapshot; +use codex_ext::persist_managed_account_auth_snapshot; use codex_login::ServerOptions; use codex_login::run_device_code_login; use codex_login::run_login_server; @@ -22,7 +27,10 @@ use codex_utils_cli::CliConfigOverrides; use std::fs::OpenOptions; use std::io::IsTerminal; use std::io::Read; +use std::path::Path; use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use tracing_appender::non_blocking; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::EnvFilter; @@ -110,13 +118,67 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) { ); } +fn now_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or(i64::MAX) +} + +fn upsert_managed_account_snapshot( + codex_home: &Path, + snapshot: &ManagedAccountSnapshot, + set_active: bool, +) -> std::io::Result<()> { + let account_id = snapshot.profile.id.clone(); + AccountPoolStore::new(codex_home.to_path_buf()).update(|state| { + state.upsert_account(snapshot.profile.clone()); + if set_active { + state.set_active_account(&account_id, now_timestamp()); + } + })?; + Ok(()) +} + +fn snapshot_existing_managed_account_before_login( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let Some(snapshot) = + persist_current_managed_account_snapshot(codex_home, auth_credentials_store_mode)? + else { + return Ok(()); + }; + upsert_managed_account_snapshot(codex_home, &snapshot, /*set_active*/ false) +} + +fn register_current_managed_account_after_login( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + managed_account_alias: Option<&str>, +) -> std::io::Result<()> { + let Some(auth) = load_auth_dot_json(codex_home, auth_credentials_store_mode)? else { + return Ok(()); + }; + let Some(snapshot) = + persist_managed_account_auth_snapshot(codex_home, &auth, managed_account_alias)? + else { + return Ok(()); + }; + upsert_managed_account_snapshot(codex_home, &snapshot, /*set_active*/ true) +} + pub async fn login_with_chatgpt( codex_home: PathBuf, forced_chatgpt_workspace_id: Option, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + managed_account_alias: Option, ) -> std::io::Result<()> { + snapshot_existing_managed_account_before_login(&codex_home, cli_auth_credentials_store_mode)?; + let opts = ServerOptions::new( - codex_home, + codex_home.clone(), CLIENT_ID.to_string(), forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, @@ -125,10 +187,18 @@ pub async fn login_with_chatgpt( print_login_server_start(server.actual_port, &server.auth_url); - server.block_until_done().await + server.block_until_done().await?; + register_current_managed_account_after_login( + &codex_home, + cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) } -pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { +pub async fn run_login_with_chatgpt( + cli_config_overrides: CliConfigOverrides, + managed_account_alias: Option, +) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting browser login flow"); @@ -144,6 +214,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> config.codex_home, forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + managed_account_alias, ) .await { @@ -219,6 +290,7 @@ pub async fn run_login_with_device_code( cli_config_overrides: CliConfigOverrides, issuer_base_url: Option, client_id: Option, + managed_account_alias: Option, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); @@ -227,6 +299,15 @@ pub async fn run_login_with_device_code( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + if let Err(err) = snapshot_existing_managed_account_before_login( + &config.codex_home, + config.cli_auth_credentials_store_mode, + ) { + eprintln!("Error preparing managed account snapshots: {err}"); + std::process::exit(1); + } + + let codex_home = config.codex_home.clone(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( config.codex_home, @@ -239,6 +320,14 @@ pub async fn run_login_with_device_code( } match run_device_code_login(opts).await { Ok(()) => { + if let Err(err) = register_current_managed_account_after_login( + &codex_home, + config.cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) { + eprintln!("Error finalizing managed account login: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -257,6 +346,7 @@ pub async fn run_login_with_device_code_fallback_to_browser( cli_config_overrides: CliConfigOverrides, issuer_base_url: Option, client_id: Option, + managed_account_alias: Option, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); @@ -265,7 +355,15 @@ pub async fn run_login_with_device_code_fallback_to_browser( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + if let Err(err) = snapshot_existing_managed_account_before_login( + &config.codex_home, + config.cli_auth_credentials_store_mode, + ) { + eprintln!("Error preparing managed account snapshots: {err}"); + std::process::exit(1); + } + let codex_home = config.codex_home.clone(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( config.codex_home, @@ -280,6 +378,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( match run_device_code_login(opts.clone()).await { Ok(()) => { + if let Err(err) = register_current_managed_account_after_login( + &codex_home, + config.cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) { + eprintln!("Error finalizing managed account login: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -291,6 +397,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( print_login_server_start(server.actual_port, &server.auth_url); match server.block_until_done().await { Ok(()) => { + if let Err(err) = register_current_managed_account_after_login( + &codex_home, + config.cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) { + eprintln!("Error finalizing managed account login: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -392,6 +506,20 @@ fn safe_format_key(key: &str) -> String { #[cfg(test)] mod tests { + use codex_app_server_protocol::AuthMode as ApiAuthMode; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use codex_ext::AccountPoolStore; + use codex_ext::ManagedAccountAuthStore; + use pretty_assertions::assert_eq; + use serde_json::json; + use tempfile::tempdir; + + use crate::login::register_current_managed_account_after_login; + use crate::login::snapshot_existing_managed_account_before_login; + use super::safe_format_key; #[test] @@ -405,4 +533,86 @@ mod tests { let key = "sk-proj-12345"; assert_eq!(safe_format_key(key), "***"); } + + fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String { + let header = json!({"alg":"none","typ":"JWT"}); + let payload = json!({ + "email": email, + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |value: serde_json::Value| -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&value).expect("serialize")) + }; + format!("{}.{}.sig", encode(header), encode(payload)) + } + + fn chatgpt_auth(account_id: &str, email: &str) -> AuthDotJson { + AuthDotJson { + auth_mode: Some(ApiAuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&fake_jwt( + email, account_id, "pro", + )) + .expect("id token"), + access_token: fake_jwt(email, account_id, "pro"), + refresh_token: "refresh-token".to_string(), + account_id: Some(account_id.to_string()), + }), + last_refresh: None, + } + } + + #[test] + fn snapshot_existing_managed_account_before_login_copies_root_auth() { + let tempdir = tempdir().expect("tempdir"); + let auth = chatgpt_auth("acct-existing", "existing@example.com"); + save_auth(tempdir.path(), &auth, AuthCredentialsStoreMode::File).expect("save root auth"); + + snapshot_existing_managed_account_before_login( + tempdir.path(), + AuthCredentialsStoreMode::File, + ) + .expect("snapshot existing auth"); + + let managed_auth = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()) + .load_account_auth("acct-existing") + .expect("managed auth"); + let pool = AccountPoolStore::new(tempdir.path().to_path_buf()) + .load() + .expect("load account pool"); + + assert_eq!(managed_auth, auth); + assert_eq!(pool.accounts[0].alias, "acct-existing"); + } + + #[test] + fn register_current_managed_account_after_login_saves_alias_and_marks_active() { + let tempdir = tempdir().expect("tempdir"); + let auth = chatgpt_auth("acct-primary", "primary@example.com"); + save_auth(tempdir.path(), &auth, AuthCredentialsStoreMode::File).expect("save root auth"); + + register_current_managed_account_after_login( + tempdir.path(), + AuthCredentialsStoreMode::File, + Some("Primary"), + ) + .expect("register managed account"); + + let managed_auth = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()) + .load_account_auth("acct-primary") + .expect("managed auth"); + let pool = AccountPoolStore::new(tempdir.path().to_path_buf()) + .load() + .expect("load account pool"); + + assert_eq!(managed_auth, auth); + assert_eq!(pool.active_account_id.as_deref(), Some("acct-primary")); + assert_eq!(pool.accounts[0].alias, "Primary"); + } } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8446e457b..f090309ca 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -275,6 +275,13 @@ struct LoginCommand { #[clap(skip)] config_overrides: CliConfigOverrides, + #[arg( + long = "auth", + value_name = "ALIAS", + help = "Save this ChatGPT login into the managed account pool with the given alias" + )] + auth: Option, + #[arg( long = "with-api-key", help = "Read the API key from stdin (e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`)" @@ -749,6 +756,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { login_cli.config_overrides, login_cli.issuer_base_url, login_cli.client_id, + login_cli.auth, ) .await; } else if login_cli.api_key.is_some() { @@ -757,10 +765,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ); std::process::exit(1); } else if login_cli.with_api_key { + if login_cli.auth.is_some() { + eprintln!("The --auth flag is only supported for ChatGPT login flows."); + std::process::exit(1); + } let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; } else { - run_login_with_chatgpt(login_cli.config_overrides).await; + run_login_with_chatgpt(login_cli.config_overrides, login_cli.auth).await; } } } diff --git a/codex-rs/ext/Cargo.toml b/codex-rs/ext/Cargo.toml new file mode 100644 index 000000000..84f3fc634 --- /dev/null +++ b/codex-rs/ext/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "codex-ext" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_ext" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-core = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +base64 = { workspace = true } +codex-app-server-protocol = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/ext/README.md b/codex-rs/ext/README.md new file mode 100644 index 000000000..ad0ac8f99 --- /dev/null +++ b/codex-rs/ext/README.md @@ -0,0 +1,14 @@ +# codex-ext + +`codex-ext` is the fork-owned extension layer for long-lived customization. + +Current MVP scope: + +- stable capability-negotiation shapes for future plugin runtimes +- persisted multi-account pool state +- default account router model for threshold-based fallback +- data structures intentionally decoupled from current TUI/core internals + +This crate does not yet load WASM modules. The immediate goal is to keep the +fork-specific policy surface isolated so future upgrades only touch a narrow set +of host integration points. diff --git a/codex-rs/ext/src/account_pool.rs b/codex-rs/ext/src/account_pool.rs new file mode 100644 index 000000000..dd3f87647 --- /dev/null +++ b/codex-rs/ext/src/account_pool.rs @@ -0,0 +1,504 @@ +use serde::Deserialize; +use serde::Serialize; +use std::fs; +use std::io; +use std::path::PathBuf; + +use crate::account_signal::AccountLimitSignal; +use crate::account_signal::AccountRateLimitSnapshot; +pub const ACCOUNT_POOL_STATE_RELATIVE_PATH: &str = "accounts/account-pool.json"; +const ACCOUNT_POOL_STATE_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountUsageWindowKind { + FiveHour, + Weekly, + Custom, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageEstimateSource { + Manual, + ResponseErrorInference, + LocalHeuristic, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountUsageWindow { + pub kind: AccountUsageWindowKind, + pub label: String, + pub estimated_used_units: u32, + pub estimated_limit_units: Option, + pub reset_at: Option, + pub source: UsageEstimateSource, +} + +impl AccountUsageWindow { + pub fn pressure_permille(&self) -> Option { + let limit = self.estimated_limit_units?; + if limit == 0 { + return None; + } + + let used = self.estimated_used_units.min(limit); + let permille = (u64::from(used) * 1000) / u64::from(limit); + u16::try_from(permille).ok() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRecord { + pub id: String, + pub alias: String, + pub masked_email: Option, + pub plan_label: Option, + pub priority: u32, + pub enabled: bool, + pub cooldown_until: Option, + pub last_limit_error_at: Option, + pub last_selected_at: Option, + pub usage_windows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountManagementProfile { + pub id: String, + pub alias: Option, + pub masked_email: Option, + pub plan_label: Option, + pub priority: Option, +} + +impl AccountRecord { + pub fn display_name(&self) -> &str { + if self.alias.trim().is_empty() { + &self.id + } else { + &self.alias + } + } + + pub fn is_available_at(&self, now_ts: i64) -> bool { + if !self.enabled { + return false; + } + + match self.cooldown_until { + Some(cooldown_until) => cooldown_until <= now_ts, + None => true, + } + } + + pub fn highest_pressure_permille(&self) -> Option { + self.usage_windows + .iter() + .filter_map(AccountUsageWindow::pressure_permille) + .max() + } + + pub fn usage_summary(&self) -> Option { + let windows: Vec = self + .usage_windows + .iter() + .map(|window| { + let prefix = match window.kind { + AccountUsageWindowKind::FiveHour => "5h", + AccountUsageWindowKind::Weekly => "week", + AccountUsageWindowKind::Custom => window.label.as_str(), + }; + match window.estimated_limit_units { + Some(limit) => format!("{prefix} {}/{}", window.estimated_used_units, limit), + None => format!("{prefix} {}", window.estimated_used_units), + } + }) + .collect(); + if windows.is_empty() { + None + } else { + Some(windows.join(" · ")) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountPoolState { + pub version: u32, + pub active_account_id: Option, + pub accounts: Vec, +} + +impl Default for AccountPoolState { + fn default() -> Self { + Self { + version: ACCOUNT_POOL_STATE_VERSION, + active_account_id: None, + accounts: Vec::new(), + } + } +} + +impl AccountPoolState { + pub fn upsert_account(&mut self, profile: AccountManagementProfile) -> bool { + if let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == profile.id) + { + let mut changed = false; + let next_alias = profile.alias.unwrap_or_else(|| account.alias.clone()); + if account.alias != next_alias { + account.alias = next_alias; + changed = true; + } + if account.masked_email != profile.masked_email { + account.masked_email = profile.masked_email; + changed = true; + } + if account.plan_label != profile.plan_label { + account.plan_label = profile.plan_label; + changed = true; + } + if let Some(priority) = profile.priority + && account.priority != priority + { + account.priority = priority; + changed = true; + } + return changed; + } + + self.accounts.push(AccountRecord { + id: profile.id.clone(), + alias: profile.alias.unwrap_or(profile.id.clone()), + masked_email: profile.masked_email, + plan_label: profile.plan_label, + priority: profile.priority.unwrap_or_else(|| { + u32::try_from(self.accounts.len()).unwrap_or(u32::MAX.saturating_sub(1)) + }), + enabled: true, + cooldown_until: None, + last_limit_error_at: None, + last_selected_at: None, + usage_windows: Vec::new(), + }); + if self.active_account_id.is_none() { + self.active_account_id = Some(profile.id); + } + true + } + + pub fn set_active_account(&mut self, account_id: &str, now_ts: i64) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + account.last_selected_at = Some(now_ts); + let should_change = self.active_account_id.as_deref() != Some(account_id); + self.active_account_id = Some(account_id.to_string()); + should_change || account.last_selected_at == Some(now_ts) + } + + pub fn rename_account_alias(&mut self, account_id: &str, alias: String) -> bool { + let normalized = alias.trim(); + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + let next_alias = if normalized.is_empty() { + account.id.clone() + } else { + normalized.to_string() + }; + if account.alias == next_alias { + false + } else { + account.alias = next_alias; + true + } + } + + pub fn apply_rate_limit_snapshot( + &mut self, + account_id: &str, + snapshot: &AccountRateLimitSnapshot, + ) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + let previous = account.usage_windows.clone(); + account.usage_windows = rate_limit_windows(snapshot); + previous != account.usage_windows + } + + pub fn apply_limit_signal(&mut self, account_id: &str, signal: &AccountLimitSignal) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + let mut changed = false; + if account.last_limit_error_at != Some(signal.recorded_at) { + account.last_limit_error_at = Some(signal.recorded_at); + changed = true; + } + + if signal.cooldown_until.is_some_and(|cooldown_until| { + account + .cooldown_until + .is_none_or(|current| cooldown_until > current) + }) { + account.cooldown_until = signal.cooldown_until; + changed = true; + } + + changed + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountPoolStore { + codex_home: PathBuf, +} + +impl AccountPoolStore { + pub fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + pub fn path(&self) -> PathBuf { + self.codex_home.join(ACCOUNT_POOL_STATE_RELATIVE_PATH) + } + + pub fn load(&self) -> io::Result { + let path = self.path(); + let contents = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Ok(AccountPoolState::default()); + } + Err(err) => return Err(err), + }; + + serde_json::from_str(&contents).map_err(io::Error::other) + } + + pub fn save(&self, state: &AccountPoolState) -> io::Result<()> { + let path = self.path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let contents = serde_json::to_string_pretty(state).map_err(io::Error::other)?; + fs::write(path, contents) + } + + pub fn update(&self, updater: F) -> io::Result + where + F: FnOnce(&mut AccountPoolState), + { + let mut state = self.load()?; + updater(&mut state); + self.save(&state)?; + Ok(state) + } +} + +fn rate_limit_windows(snapshot: &AccountRateLimitSnapshot) -> Vec { + [snapshot.primary.as_ref(), snapshot.secondary.as_ref()] + .into_iter() + .flatten() + .map(rate_limit_window_to_usage_window) + .collect() +} + +fn rate_limit_window_to_usage_window( + window: &crate::account_signal::AccountRateLimitWindow, +) -> AccountUsageWindow { + let used_percent = window.used_percent.clamp(0.0, 100.0).round() as u32; + let (kind, label) = match window.window_minutes { + Some(300) => (AccountUsageWindowKind::FiveHour, "5h".to_string()), + Some(10_080) => (AccountUsageWindowKind::Weekly, "week".to_string()), + Some(minutes) => (AccountUsageWindowKind::Custom, format!("{minutes}m")), + None => (AccountUsageWindowKind::Custom, "window".to_string()), + }; + + AccountUsageWindow { + kind, + label, + estimated_used_units: used_percent, + estimated_limit_units: Some(100), + reset_at: window.resets_at, + source: UsageEstimateSource::ResponseErrorInference, + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use super::AccountManagementProfile; + use super::AccountPoolState; + use super::AccountPoolStore; + use super::AccountRecord; + use super::AccountUsageWindow; + use super::AccountUsageWindowKind; + use super::UsageEstimateSource; + use crate::account_signal::AccountLimitSignal; + use crate::account_signal::AccountRateLimitSnapshot; + use crate::account_signal::AccountRateLimitWindow; + use crate::account_signal::LimitSignalKind; + + #[test] + fn missing_account_pool_file_returns_default_state() { + let tempdir = tempdir().expect("tempdir"); + let store = AccountPoolStore::new(tempdir.path().to_path_buf()); + + assert_eq!(store.load().expect("load"), AccountPoolState::default()); + } + + #[test] + fn save_round_trips_account_pool_state() { + let tempdir = tempdir().expect("tempdir"); + let store = AccountPoolStore::new(tempdir.path().to_path_buf()); + let state = AccountPoolState { + version: 1, + active_account_id: Some("acc-primary".to_string()), + accounts: vec![AccountRecord { + id: "acc-primary".to_string(), + alias: "Primary".to_string(), + masked_email: Some("pri***@example.com".to_string()), + plan_label: Some("pro".to_string()), + priority: 0, + enabled: true, + cooldown_until: None, + last_limit_error_at: None, + last_selected_at: Some(10), + usage_windows: vec![AccountUsageWindow { + kind: AccountUsageWindowKind::FiveHour, + label: "5h".to_string(), + estimated_used_units: 12, + estimated_limit_units: Some(40), + reset_at: Some(100), + source: UsageEstimateSource::LocalHeuristic, + }], + }], + }; + + store.save(&state).expect("save"); + + assert_eq!(store.load().expect("reload"), state); + } + + #[test] + fn upsert_and_activate_account() { + let mut state = AccountPoolState::default(); + + assert!(state.upsert_account(AccountManagementProfile { + id: "acc-primary".to_string(), + alias: Some("Primary".to_string()), + masked_email: Some("pri***@example.com".to_string()), + plan_label: Some("pro".to_string()), + priority: Some(0), + })); + assert!(state.set_active_account("acc-primary", 123)); + + assert_eq!(state.active_account_id, Some("acc-primary".to_string())); + assert_eq!(state.accounts[0].alias, "Primary"); + assert_eq!(state.accounts[0].last_selected_at, Some(123)); + } + + #[test] + fn applying_rate_limit_snapshot_replaces_usage_windows() { + let mut state = AccountPoolState { + version: 1, + active_account_id: Some("acc-primary".to_string()), + accounts: vec![AccountRecord { + id: "acc-primary".to_string(), + alias: "Primary".to_string(), + masked_email: None, + plan_label: None, + priority: 0, + enabled: true, + cooldown_until: None, + last_limit_error_at: None, + last_selected_at: None, + usage_windows: Vec::new(), + }], + }; + + assert!(state.apply_rate_limit_snapshot( + "acc-primary", + &AccountRateLimitSnapshot { + limit_name: Some("codex".to_string()), + primary: Some(AccountRateLimitWindow { + used_percent: 87.0, + window_minutes: Some(300), + resets_at: Some(500), + }), + secondary: None, + } + )); + + assert_eq!( + state.accounts[0].usage_windows, + vec![AccountUsageWindow { + kind: AccountUsageWindowKind::FiveHour, + label: "5h".to_string(), + estimated_used_units: 87, + estimated_limit_units: Some(100), + reset_at: Some(500), + source: UsageEstimateSource::ResponseErrorInference, + }] + ); + } + + #[test] + fn limit_signal_updates_cooldown() { + let mut state = AccountPoolState { + version: 1, + active_account_id: Some("acc-primary".to_string()), + accounts: vec![AccountRecord { + id: "acc-primary".to_string(), + alias: "Primary".to_string(), + masked_email: None, + plan_label: None, + priority: 0, + enabled: true, + cooldown_until: None, + last_limit_error_at: None, + last_selected_at: None, + usage_windows: Vec::new(), + }], + }; + + assert!(state.apply_limit_signal( + "acc-primary", + &AccountLimitSignal { + kind: LimitSignalKind::UsageLimit, + recorded_at: 100, + cooldown_until: Some(500), + } + )); + + assert_eq!(state.accounts[0].last_limit_error_at, Some(100)); + assert_eq!(state.accounts[0].cooldown_until, Some(500)); + } +} diff --git a/codex-rs/ext/src/account_signal.rs b/codex-rs/ext/src/account_signal.rs new file mode 100644 index 000000000..a62a09a3e --- /dev/null +++ b/codex-rs/ext/src/account_signal.rs @@ -0,0 +1,136 @@ +use serde::Deserialize; +use serde::Serialize; + +const DEFAULT_GENERIC_LIMIT_COOLDOWN_SECS: i64 = 15 * 60; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRateLimitWindow { + pub used_percent: f64, + pub window_minutes: Option, + pub resets_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRateLimitSnapshot { + pub limit_name: Option, + pub primary: Option, + pub secondary: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LimitSignalKind { + UsageLimit, + RateLimit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountLimitSignal { + pub kind: LimitSignalKind, + pub recorded_at: i64, + pub cooldown_until: Option, +} + +pub fn infer_limit_signal( + kind: LimitSignalKind, + recorded_at: i64, + snapshot: Option<&AccountRateLimitSnapshot>, +) -> AccountLimitSignal { + let cooldown_until = snapshot + .and_then(|snapshot| blocking_resets_at(snapshot, recorded_at)) + .or_else(|| match kind { + LimitSignalKind::UsageLimit => None, + LimitSignalKind::RateLimit => Some(recorded_at + DEFAULT_GENERIC_LIMIT_COOLDOWN_SECS), + }); + + AccountLimitSignal { + kind, + recorded_at, + cooldown_until, + } +} + +fn blocking_resets_at(snapshot: &AccountRateLimitSnapshot, recorded_at: i64) -> Option { + let mut saturated = Vec::new(); + let mut any_future = Vec::new(); + + for window in [snapshot.primary.as_ref(), snapshot.secondary.as_ref()] + .into_iter() + .flatten() + { + let Some(resets_at) = window + .resets_at + .filter(|resets_at| *resets_at > recorded_at) + else { + continue; + }; + + any_future.push(resets_at); + if window.used_percent >= 99.5 { + saturated.push(resets_at); + } + } + + saturated + .into_iter() + .min() + .or_else(|| any_future.into_iter().min()) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::AccountLimitSignal; + use super::AccountRateLimitSnapshot; + use super::AccountRateLimitWindow; + use super::LimitSignalKind; + use super::infer_limit_signal; + + #[test] + fn usage_limit_prefers_saturated_window_reset() { + let signal = infer_limit_signal( + LimitSignalKind::UsageLimit, + 100, + Some(&AccountRateLimitSnapshot { + limit_name: Some("codex".to_string()), + primary: Some(AccountRateLimitWindow { + used_percent: 92.0, + window_minutes: Some(300), + resets_at: Some(500), + }), + secondary: Some(AccountRateLimitWindow { + used_percent: 100.0, + window_minutes: Some(10080), + resets_at: Some(900), + }), + }), + ); + + assert_eq!( + signal, + AccountLimitSignal { + kind: LimitSignalKind::UsageLimit, + recorded_at: 100, + cooldown_until: Some(900), + } + ); + } + + #[test] + fn rate_limit_falls_back_to_short_cooldown_without_snapshot() { + let signal = infer_limit_signal(LimitSignalKind::RateLimit, 100, None); + + assert_eq!( + signal, + AccountLimitSignal { + kind: LimitSignalKind::RateLimit, + recorded_at: 100, + cooldown_until: Some(1000), + } + ); + } +} diff --git a/codex-rs/ext/src/host_api.rs b/codex-rs/ext/src/host_api.rs new file mode 100644 index 000000000..de45f2449 --- /dev/null +++ b/codex-rs/ext/src/host_api.rs @@ -0,0 +1,127 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostCapability { + pub name: String, + pub version: u32, +} + +impl HostCapability { + pub fn new(name: impl Into, version: u32) -> Self { + Self { + name: name.into(), + version, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRequirement { + pub name: String, + pub minimum_version: u32, +} + +impl CapabilityRequirement { + pub fn new(name: impl Into, minimum_version: u32) -> Self { + Self { + name: name.into(), + minimum_version, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostCapabilities { + pub capabilities: Vec, +} + +impl Default for HostCapabilities { + fn default() -> Self { + Self::codex_mvp() + } +} + +impl HostCapabilities { + pub fn codex_mvp() -> Self { + Self { + capabilities: vec![ + HostCapability::new("app-start", /*version*/ 1), + HostCapability::new("session-start", /*version*/ 1), + HostCapability::new("before-turn-start", /*version*/ 1), + HostCapability::new("before-tool-call", /*version*/ 1), + HostCapability::new("after-tool-call", /*version*/ 1), + HostCapability::new("account-routing", /*version*/ 1), + HostCapability::new("control-panel", /*version*/ 1), + ], + } + } + + pub fn supports(&self, requirement: &CapabilityRequirement) -> bool { + self.capabilities.iter().any(|capability| { + capability.name == requirement.name && capability.version >= requirement.minimum_version + }) + } + + pub fn negotiate(&self, requirements: &[CapabilityRequirement]) -> PluginNegotiation { + let mut accepted = Vec::new(); + let mut missing = Vec::new(); + + for requirement in requirements { + if self.supports(requirement) { + accepted.push(requirement.clone()); + } else { + missing.push(requirement.clone()); + } + } + + PluginNegotiation { accepted, missing } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginNegotiation { + pub accepted: Vec, + pub missing: Vec, +} + +impl PluginNegotiation { + pub fn is_compatible(&self) -> bool { + self.missing.is_empty() + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::CapabilityRequirement; + use super::HostCapabilities; + + #[test] + fn codex_mvp_capabilities_support_account_routing() { + let capabilities = HostCapabilities::codex_mvp(); + let negotiation = capabilities.negotiate(&[ + CapabilityRequirement::new("account-routing", 1), + CapabilityRequirement::new("control-panel", 1), + ]); + + assert!(negotiation.is_compatible()); + assert_eq!(negotiation.accepted.len(), 2); + assert_eq!(negotiation.missing.len(), 0); + } + + #[test] + fn negotiation_reports_missing_versions() { + let capabilities = HostCapabilities::codex_mvp(); + let negotiation = + capabilities.negotiate(&[CapabilityRequirement::new("before-turn-start", 2)]); + + assert!(!negotiation.is_compatible()); + assert_eq!(negotiation.accepted.len(), 0); + assert_eq!( + negotiation.missing, + vec![CapabilityRequirement::new("before-turn-start", 2)] + ); + } +} diff --git a/codex-rs/ext/src/lib.rs b/codex-rs/ext/src/lib.rs new file mode 100644 index 000000000..c269ee4eb --- /dev/null +++ b/codex-rs/ext/src/lib.rs @@ -0,0 +1,34 @@ +pub mod account_pool; +pub mod account_signal; +pub mod host_api; +pub mod managed_account_auth; +pub mod router; + +pub use account_pool::AccountManagementProfile; +pub use account_pool::AccountPoolState; +pub use account_pool::AccountPoolStore; +pub use account_pool::AccountRecord; +pub use account_pool::AccountUsageWindow; +pub use account_pool::AccountUsageWindowKind; +pub use account_pool::UsageEstimateSource; +pub use account_signal::AccountLimitSignal; +pub use account_signal::AccountRateLimitSnapshot; +pub use account_signal::AccountRateLimitWindow; +pub use account_signal::LimitSignalKind; +pub use account_signal::infer_limit_signal; +pub use host_api::CapabilityRequirement; +pub use host_api::HostCapabilities; +pub use host_api::HostCapability; +pub use host_api::PluginNegotiation; +pub use managed_account_auth::MANAGED_ACCOUNTS_RELATIVE_DIR; +pub use managed_account_auth::ManagedAccountAuthStore; +pub use managed_account_auth::ManagedAccountSnapshot; +pub use managed_account_auth::activate_managed_account; +pub use managed_account_auth::load_current_managed_account_snapshot; +pub use managed_account_auth::persist_current_managed_account_snapshot; +pub use managed_account_auth::persist_managed_account_auth_snapshot; +pub use router::AccountRouterDecision; +pub use router::AccountRouterDecisionReason; +pub use router::DefaultAccountRouter; +pub use router::RouteTurnRequest; +pub use router::RoutingTrigger; diff --git a/codex-rs/ext/src/managed_account_auth.rs b/codex-rs/ext/src/managed_account_auth.rs new file mode 100644 index 000000000..6de0bf3fb --- /dev/null +++ b/codex-rs/ext/src/managed_account_auth.rs @@ -0,0 +1,284 @@ +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::AuthDotJson; +use codex_core::auth::load_auth_dot_json; +use codex_core::auth::save_auth; +use std::fs; +use std::fs::OpenOptions; +use std::io; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; + +use crate::account_pool::AccountManagementProfile; + +pub const MANAGED_ACCOUNTS_RELATIVE_DIR: &str = "accounts"; +const ACCOUNT_AUTH_FILE_NAME: &str = "auth.json"; + +#[derive(Debug, Clone, PartialEq)] +pub struct ManagedAccountSnapshot { + pub profile: AccountManagementProfile, + pub auth: AuthDotJson, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedAccountAuthStore { + codex_home: PathBuf, +} + +impl ManagedAccountAuthStore { + pub fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + pub fn directory(&self) -> PathBuf { + self.codex_home.join(MANAGED_ACCOUNTS_RELATIVE_DIR) + } + + pub fn account_dir(&self, account_id: &str) -> PathBuf { + self.directory().join(account_id) + } + + pub fn account_auth_path(&self, account_id: &str) -> PathBuf { + self.account_dir(account_id).join(ACCOUNT_AUTH_FILE_NAME) + } + + pub fn load_account_auth(&self, account_id: &str) -> io::Result { + let path = self.account_auth_path(account_id); + let contents = fs::read_to_string(path)?; + serde_json::from_str(&contents).map_err(io::Error::other) + } + + pub fn save_account_auth(&self, account_id: &str, auth: &AuthDotJson) -> io::Result<()> { + let path = self.account_auth_path(account_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let contents = serde_json::to_string_pretty(auth).map_err(io::Error::other)?; + let mut options = OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(contents.as_bytes())?; + file.flush() + } +} + +pub fn activate_managed_account( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + account_id: &str, +) -> io::Result<()> { + let store = ManagedAccountAuthStore::new(codex_home.to_path_buf()); + let auth = store.load_account_auth(account_id)?; + save_auth(codex_home, &auth, auth_credentials_store_mode) +} + +pub fn persist_current_managed_account_snapshot( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> io::Result> { + let Some(snapshot) = + load_current_managed_account_snapshot(codex_home, auth_credentials_store_mode)? + else { + return Ok(None); + }; + let store = ManagedAccountAuthStore::new(codex_home.to_path_buf()); + store.save_account_auth(&snapshot.profile.id, &snapshot.auth)?; + Ok(Some(snapshot)) +} + +pub fn persist_managed_account_auth_snapshot( + codex_home: &Path, + auth: &AuthDotJson, + alias_override: Option<&str>, +) -> io::Result> { + let Some(snapshot) = managed_account_snapshot_from_auth(auth, alias_override) else { + return Ok(None); + }; + let store = ManagedAccountAuthStore::new(codex_home.to_path_buf()); + store.save_account_auth(&snapshot.profile.id, &snapshot.auth)?; + Ok(Some(snapshot)) +} + +pub fn load_current_managed_account_snapshot( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> io::Result> { + let Some(auth_json) = load_auth_dot_json(codex_home, auth_credentials_store_mode)? else { + return Ok(None); + }; + Ok(managed_account_snapshot_from_auth( + &auth_json, /*alias_override*/ None, + )) +} + +fn managed_account_snapshot_from_auth( + auth: &AuthDotJson, + alias_override: Option<&str>, +) -> Option { + let tokens = auth.tokens.as_ref()?; + let id = tokens + .account_id + .clone() + .or_else(|| tokens.id_token.chatgpt_account_id.clone())?; + let alias = alias_override + .map(str::trim) + .filter(|alias| !alias.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| id.clone()); + let profile = AccountManagementProfile { + id, + alias: Some(alias), + masked_email: tokens.id_token.email.as_deref().map(mask_email), + plan_label: tokens + .id_token + .get_chatgpt_plan_type() + .map(|plan_type| plan_type.to_ascii_lowercase()), + priority: None, + }; + Some(ManagedAccountSnapshot { + profile, + auth: auth.clone(), + }) +} + +fn mask_email(email: &str) -> String { + let mut parts = email.split('@'); + let local = parts.next().unwrap_or_default(); + let domain = parts.next().unwrap_or_default(); + if local.is_empty() || domain.is_empty() { + return email.to_string(); + } + + let prefix: String = local.chars().take(3).collect(); + format!("{prefix}***@{domain}") +} + +#[cfg(test)] +mod tests { + use super::ManagedAccountAuthStore; + use super::activate_managed_account; + use super::persist_current_managed_account_snapshot; + use super::persist_managed_account_auth_snapshot; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use codex_app_server_protocol::AuthMode; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::load_auth_dot_json; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use serde_json::json; + + fn chatgpt_auth(account_id: &str, email: &str) -> AuthDotJson { + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&fake_jwt( + email, account_id, "pro", + )) + .expect("id token"), + access_token: fake_jwt(email, account_id, "pro"), + refresh_token: "refresh-token".to_string(), + account_id: Some(account_id.to_string()), + }), + last_refresh: None, + } + } + + fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String { + let header = json!({"alg":"none","typ":"JWT"}); + let payload = json!({ + "email": email, + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |value: serde_json::Value| -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&value).expect("serialize")) + }; + format!("{}.{}.sig", encode(header), encode(payload)) + } + + #[test] + fn persist_snapshot_writes_account_auth_under_accounts_directory() { + let tempdir = tempdir().expect("tempdir"); + let auth = chatgpt_auth("workspace-1", "user@example.com"); + save_auth(tempdir.path(), &auth, AuthCredentialsStoreMode::File).expect("save auth"); + + let snapshot = persist_current_managed_account_snapshot( + tempdir.path(), + AuthCredentialsStoreMode::File, + ) + .expect("persist snapshot") + .expect("snapshot"); + + assert_eq!(snapshot.profile.id, "workspace-1"); + assert_eq!( + ManagedAccountAuthStore::new(tempdir.path().to_path_buf()) + .load_account_auth("workspace-1") + .expect("load snapshot"), + auth + ); + } + + #[test] + fn persist_managed_account_auth_snapshot_uses_alias_override() { + let tempdir = tempdir().expect("tempdir"); + let auth = chatgpt_auth("workspace-1", "user@example.com"); + + let snapshot = + persist_managed_account_auth_snapshot(tempdir.path(), &auth, Some("Primary")) + .expect("persist snapshot") + .expect("snapshot"); + + assert_eq!(snapshot.profile.alias, Some("Primary".to_string())); + assert_eq!( + ManagedAccountAuthStore::new(tempdir.path().to_path_buf()) + .load_account_auth("workspace-1") + .expect("load snapshot"), + auth + ); + } + + #[test] + fn activate_managed_account_restores_root_auth() { + let tempdir = tempdir().expect("tempdir"); + let primary = chatgpt_auth("workspace-1", "primary@example.com"); + let backup = chatgpt_auth("workspace-2", "backup@example.com"); + let store = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()); + store + .save_account_auth("workspace-1", &primary) + .expect("save primary"); + store + .save_account_auth("workspace-2", &backup) + .expect("save backup"); + + save_auth(tempdir.path(), &primary, AuthCredentialsStoreMode::File) + .expect("save root auth"); + activate_managed_account( + tempdir.path(), + AuthCredentialsStoreMode::File, + "workspace-2", + ) + .expect("activate backup"); + + assert_eq!( + load_auth_dot_json(tempdir.path(), AuthCredentialsStoreMode::File) + .expect("load root auth") + .expect("root auth"), + backup + ); + } +} diff --git a/codex-rs/ext/src/router.rs b/codex-rs/ext/src/router.rs new file mode 100644 index 000000000..7ae93bc4a --- /dev/null +++ b/codex-rs/ext/src/router.rs @@ -0,0 +1,300 @@ +use crate::account_pool::AccountPoolState; +use crate::account_pool::AccountRecord; +use serde::Deserialize; +use serde::Serialize; + +const DEFAULT_USAGE_THRESHOLD_PERMILLE: u16 = 850; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RoutingTrigger { + NormalTurn, + RetryAfterHardError, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountRouterDecisionReason { + KeepActiveAccount, + RetryWithFallbackAccount, + ActiveAccountCoolingDown, + ActiveAccountOverThreshold, + PreferredFallbackSelected, + NoHealthyAccount, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteTurnRequest { + pub now_ts: i64, + pub trigger: RoutingTrigger, + pub active_account_id: Option, + pub preferred_account_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRouterDecision { + pub account_id: Option, + pub reason: AccountRouterDecisionReason, + pub retry_immediately: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefaultAccountRouter { + usage_threshold_permille: u16, +} + +impl Default for DefaultAccountRouter { + fn default() -> Self { + Self { + usage_threshold_permille: DEFAULT_USAGE_THRESHOLD_PERMILLE, + } + } +} + +impl DefaultAccountRouter { + pub fn new(usage_threshold_permille: u16) -> Self { + Self { + usage_threshold_permille, + } + } + + pub fn select_account( + &self, + state: &AccountPoolState, + request: &RouteTurnRequest, + ) -> AccountRouterDecision { + let active = request + .active_account_id + .as_deref() + .or(state.active_account_id.as_deref()) + .and_then(|account_id| { + state + .accounts + .iter() + .find(|account| account.id == account_id) + }); + + if request.trigger == RoutingTrigger::NormalTurn + && let Some(active) = active + && self.is_healthy(active, request.now_ts) + { + return AccountRouterDecision { + account_id: Some(active.id.clone()), + reason: AccountRouterDecisionReason::KeepActiveAccount, + retry_immediately: false, + }; + } + + if request.trigger == RoutingTrigger::RetryAfterHardError { + if let Some(fallback) = self.pick_fallback(state, request, active) { + return AccountRouterDecision { + account_id: Some(fallback.id.clone()), + reason: AccountRouterDecisionReason::RetryWithFallbackAccount, + retry_immediately: true, + }; + } + + return AccountRouterDecision { + account_id: None, + reason: AccountRouterDecisionReason::NoHealthyAccount, + retry_immediately: false, + }; + } + + if let Some(active) = active { + let reason = if active.is_available_at(request.now_ts) { + AccountRouterDecisionReason::ActiveAccountOverThreshold + } else { + AccountRouterDecisionReason::ActiveAccountCoolingDown + }; + + if let Some(fallback) = self.pick_fallback(state, request, Some(active)) { + return AccountRouterDecision { + account_id: Some(fallback.id.clone()), + reason, + retry_immediately: false, + }; + } + } + + if let Some(preferred) = request + .preferred_account_id + .as_deref() + .and_then(|account_id| { + state + .accounts + .iter() + .find(|account| account.id == account_id) + }) + .filter(|account| self.is_healthy(account, request.now_ts)) + { + return AccountRouterDecision { + account_id: Some(preferred.id.clone()), + reason: AccountRouterDecisionReason::PreferredFallbackSelected, + retry_immediately: false, + }; + } + + AccountRouterDecision { + account_id: None, + reason: AccountRouterDecisionReason::NoHealthyAccount, + retry_immediately: false, + } + } + + fn is_healthy(&self, account: &AccountRecord, now_ts: i64) -> bool { + account.is_available_at(now_ts) + && account + .highest_pressure_permille() + .is_none_or(|pressure| pressure < self.usage_threshold_permille) + } + + fn pick_fallback<'a>( + &self, + state: &'a AccountPoolState, + request: &RouteTurnRequest, + active: Option<&AccountRecord>, + ) -> Option<&'a AccountRecord> { + let active_id = active.map(|account| account.id.as_str()); + let mut candidates: Vec<&AccountRecord> = state + .accounts + .iter() + .filter(|account| Some(account.id.as_str()) != active_id) + .filter(|account| self.is_healthy(account, request.now_ts)) + .collect(); + + candidates.sort_by_key(|account| (account.priority, account.last_selected_at.unwrap_or(0))); + candidates.into_iter().next() + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::account_pool::AccountPoolState; + use crate::account_pool::AccountRecord; + use crate::account_pool::AccountUsageWindow; + use crate::account_pool::AccountUsageWindowKind; + use crate::account_pool::UsageEstimateSource; + + use super::AccountRouterDecision; + use super::AccountRouterDecisionReason; + use super::DefaultAccountRouter; + use super::RouteTurnRequest; + use super::RoutingTrigger; + + fn account( + id: &str, + priority: u32, + used: u32, + limit: u32, + cooldown_until: Option, + ) -> AccountRecord { + AccountRecord { + id: id.to_string(), + alias: id.to_string(), + masked_email: None, + plan_label: None, + priority, + enabled: true, + cooldown_until, + last_limit_error_at: None, + last_selected_at: None, + usage_windows: vec![AccountUsageWindow { + kind: AccountUsageWindowKind::FiveHour, + label: "5h".to_string(), + estimated_used_units: used, + estimated_limit_units: Some(limit), + reset_at: None, + source: UsageEstimateSource::LocalHeuristic, + }], + } + } + + #[test] + fn normal_turn_keeps_healthy_active_account() { + let router = DefaultAccountRouter::default(); + let state = AccountPoolState { + version: 1, + active_account_id: Some("primary".to_string()), + accounts: vec![ + account("primary", 0, 10, 40, None), + account("backup", 1, 1, 40, None), + ], + }; + let request = RouteTurnRequest { + now_ts: 100, + trigger: RoutingTrigger::NormalTurn, + active_account_id: Some("primary".to_string()), + preferred_account_id: None, + }; + + assert_eq!( + router.select_account(&state, &request), + AccountRouterDecision { + account_id: Some("primary".to_string()), + reason: AccountRouterDecisionReason::KeepActiveAccount, + retry_immediately: false, + } + ); + } + + #[test] + fn retry_after_hard_error_picks_fallback_account() { + let router = DefaultAccountRouter::default(); + let state = AccountPoolState { + version: 1, + active_account_id: Some("primary".to_string()), + accounts: vec![ + account("primary", 0, 38, 40, Some(200)), + account("backup", 1, 1, 40, None), + ], + }; + let request = RouteTurnRequest { + now_ts: 100, + trigger: RoutingTrigger::RetryAfterHardError, + active_account_id: Some("primary".to_string()), + preferred_account_id: None, + }; + + assert_eq!( + router.select_account(&state, &request), + AccountRouterDecision { + account_id: Some("backup".to_string()), + reason: AccountRouterDecisionReason::RetryWithFallbackAccount, + retry_immediately: true, + } + ); + } + + #[test] + fn normal_turn_switches_when_active_is_over_threshold() { + let router = DefaultAccountRouter::default(); + let state = AccountPoolState { + version: 1, + active_account_id: Some("primary".to_string()), + accounts: vec![ + account("primary", 0, 35, 40, None), + account("backup", 1, 4, 40, None), + ], + }; + let request = RouteTurnRequest { + now_ts: 100, + trigger: RoutingTrigger::NormalTurn, + active_account_id: Some("primary".to_string()), + preferred_account_id: None, + }; + + assert_eq!( + router.select_account(&state, &request), + AccountRouterDecision { + account_id: Some("backup".to_string()), + reason: AccountRouterDecisionReason::ActiveAccountOverThreshold, + retry_immediately: false, + } + ); + } +} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b726eeed8..02e673bb9 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -1084,13 +1084,12 @@ pub(crate) async fn obtain_api_key( } #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; - use super::TokenEndpointErrorDetail; use super::parse_token_endpoint_error; use super::redact_sensitive_query_value; use super::redact_sensitive_url_parts; use super::sanitize_url_for_logging; + use pretty_assertions::assert_eq; #[test] fn parse_token_endpoint_error_prefers_error_description() { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 8013b1325..0457d045d 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -37,6 +37,7 @@ codex-chatgpt = { workspace = true } codex-client = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } +codex-ext = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4aa51df21..b1f7b3045 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -38,6 +38,7 @@ use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; +use chrono::Utc; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::InProcessAppServerClient; @@ -70,6 +71,10 @@ use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_ use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_ext::AccountPoolStore; +use codex_ext::HostCapabilities; +use codex_ext::activate_managed_account; +use codex_ext::persist_current_managed_account_snapshot; use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; @@ -1916,6 +1921,298 @@ impl App { self.sync_active_agent_label(); } + fn open_control_panel(&mut self) { + let capabilities = HostCapabilities::codex_mvp(); + let items = vec![ + SelectionItem { + name: "Sessions".to_string(), + description: Some("Resume or switch saved chats.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenResumePickerAll))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Fork Current Session".to_string(), + description: Some("Fork the current thread into a new session.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::ForkCurrentSession))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Accounts".to_string(), + description: Some("Inspect the managed multi-account pool.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenAccountsPanel))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Undo Last User Message".to_string(), + description: Some( + "Planned MVP follow-up: restore the last sent input and roll back one turn." + .to_string(), + ), + is_disabled: true, + disabled_reason: Some("Not wired yet.".to_string()), + ..Default::default() + }, + ]; + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-control-panel"), + title: Some("Control Panel".to_string()), + subtitle: Some(format!( + "Fork extension host: {} negotiated MVP capabilities.", + capabilities.capabilities.len() + )), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + fn open_accounts_panel(&mut self) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let path = store.path(); + let state = self.sync_current_auth_into_account_pool(&store); + let mut items = Vec::new(); + let subtitle = match &state { + Ok(state) => Some(format!( + "{} account(s) configured. Active: {}.", + state.accounts.len(), + state.active_account_id.as_deref().unwrap_or("none") + )), + Err(err) => Some(format!("Failed to read account pool: {err}")), + }; + + match state { + Ok(state) => { + for account in &state.accounts { + let mut description_parts = Vec::new(); + if let Some(masked_email) = &account.masked_email { + description_parts.push(masked_email.clone()); + } + if let Some(plan_label) = &account.plan_label { + description_parts.push(plan_label.clone()); + } + if let Some(usage_summary) = account.usage_summary() { + description_parts.push(usage_summary); + } + if let Some(cooldown_until) = account.cooldown_until { + description_parts.push(format!("cooldown until {cooldown_until}")); + } + + let account_id = account.id.clone(); + let search_value = format!( + "{} {} {}", + account.display_name(), + account.id, + account.masked_email.clone().unwrap_or_default() + ); + items.push(SelectionItem { + name: account.display_name().to_string(), + description: (!description_parts.is_empty()) + .then(|| description_parts.join(" · ")), + is_current: state.active_account_id.as_deref() == Some(account.id.as_str()), + is_disabled: !account.enabled, + disabled_reason: (!account.enabled).then(|| "Disabled".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SetManagedAccountActive(account_id.clone())); + })], + dismiss_on_select: true, + search_value: Some(search_value), + ..Default::default() + }); + } + if !state.accounts.is_empty() { + items.push(SelectionItem { + name: "Rename".to_string(), + description: Some( + "Open alias rename actions for managed accounts.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenManagedAccountRenamePanel) + })], + dismiss_on_select: true, + ..Default::default() + }); + } + } + Err(err) => { + items.push(SelectionItem { + name: "Account pool unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }); + } + } + + if items.is_empty() { + items.push(SelectionItem { + name: "No managed accounts yet".to_string(), + description: Some(format!( + "Run repeated ChatGPT login flows and persist them into {}.", + path.display() + )), + is_disabled: true, + ..Default::default() + }); + } + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-accounts-panel"), + title: Some("Accounts".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_note: Some(Line::from(path.display().to_string()).dim()), + items, + ..Default::default() + }); + } + + fn open_managed_account_rename_panel(&mut self) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let path = store.path(); + let state = self.sync_current_auth_into_account_pool(&store); + let mut items = Vec::new(); + let subtitle = match &state { + Ok(state) => Some(format!( + "Rename aliases for {} managed account(s).", + state.accounts.len() + )), + Err(err) => Some(format!("Failed to read account pool: {err}")), + }; + + match state { + Ok(state) => { + for account in &state.accounts { + let account_id = account.id.clone(); + let current_alias = account.alias.clone(); + let mut description_parts = Vec::new(); + if let Some(alias) = + (!current_alias.is_empty()).then_some(current_alias.clone()) + { + description_parts.push(format!("alias: {alias}")); + } + description_parts.push(account.id.clone()); + if let Some(masked_email) = &account.masked_email { + description_parts.push(masked_email.clone()); + } + items.push(SelectionItem { + name: format!("Rename {}", account.display_name()), + description: Some(description_parts.join(" · ")), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenRenameManagedAccountAliasPrompt { + account_id: account_id.clone(), + current_alias: current_alias.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }); + } + } + Err(err) => { + items.push(SelectionItem { + name: "Account pool unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }); + } + } + + if items.is_empty() { + items.push(SelectionItem { + name: "No managed accounts yet".to_string(), + description: Some(format!( + "Run repeated ChatGPT login flows and persist them into {}.", + path.display() + )), + is_disabled: true, + ..Default::default() + }); + } + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-account-rename-panel"), + title: Some("Rename".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_note: Some(Line::from(path.display().to_string()).dim()), + items, + ..Default::default() + }); + } + + fn sync_current_auth_into_account_pool( + &self, + store: &AccountPoolStore, + ) -> std::io::Result { + let snapshot = persist_current_managed_account_snapshot( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + )?; + store.update(|state| { + if let Some(snapshot) = &snapshot { + state.upsert_account(snapshot.profile.clone()); + } + }) + } + + fn set_managed_account_active(&mut self, account_id: String) { + if let Err(err) = persist_current_managed_account_snapshot( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to snapshot current managed account auth: {err}" + ))); + return; + } + if let Err(err) = activate_managed_account( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + &account_id, + ) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to activate managed account auth: {err}" + ))); + return; + } + self.auth_manager.reload(); + + let store = AccountPoolStore::new(self.config.codex_home.clone()); + if let Err(err) = store.update(|state| { + state.set_active_account(&account_id, Utc::now().timestamp()); + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to update active managed account: {err}" + ))); + return; + } + + self.open_accounts_panel(); + } + + fn save_managed_account_alias(&mut self, account_id: String, alias: String) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + if let Err(err) = store.update(|state| { + state.rename_account_alias(&account_id, alias.clone()); + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to save managed account alias: {err}" + ))); + return; + } + + self.open_managed_account_rename_panel(); + } + /// Marks a cached picker thread closed and recomputes the contextual footer label. /// /// Closing a thread is not the same as removing it: users can still inspect finished agent @@ -2646,6 +2943,28 @@ impl App { async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { + AppEvent::OpenControlPanel => { + self.open_control_panel(); + } + AppEvent::OpenAccountsPanel => { + self.open_accounts_panel(); + } + AppEvent::OpenManagedAccountRenamePanel => { + self.open_managed_account_rename_panel(); + } + AppEvent::SetManagedAccountActive(account_id) => { + self.set_managed_account_active(account_id); + } + AppEvent::OpenRenameManagedAccountAliasPrompt { + account_id, + current_alias, + } => { + self.chat_widget + .open_managed_account_alias_prompt(account_id, current_alias); + } + AppEvent::SaveManagedAccountAlias { account_id, alias } => { + self.save_managed_account_alias(account_id, alias); + } AppEvent::NewSession => { self.start_fresh_session_with_summary_hint(tui).await; } @@ -2754,6 +3073,105 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } + AppEvent::OpenResumePickerAll => { + match crate::resume_picker::run_resume_picker( + tui, + &self.config, + /*show_all*/ true, + ) + .await? + { + SessionSelection::Resume(target_session) => { + let current_cwd = self.config.cwd.clone(); + let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( + tui, + &self.config, + ¤t_cwd, + target_session.thread_id, + &target_session.path, + CwdPromptAction::Resume, + /*allow_prompt*/ true, + ) + .await? + { + crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, + crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), + crate::ResolveCwdOutcome::Exit => { + return Ok(AppRunControl::Exit(ExitReason::UserRequested)); + } + }; + let mut resume_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; + self.apply_runtime_policy_overrides(&mut resume_config); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); + match self + .server + .resume_thread_from_rollout( + resume_config.clone(), + target_session.path.clone(), + self.auth_manager.clone(), + /*parent_trace*/ None, + ) + .await + { + Ok(resumed) => { + self.shutdown_current_thread().await; + self.config = resume_config; + tui.set_notification_method(self.config.tui_notification_method); + self.file_search.update_search_dir(self.config.cwd.clone()); + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); + self.replace_chat_widget(ChatWidget::new_from_existing( + init, + resumed.thread, + resumed.session_configured, + )); + self.reset_thread_event_state(); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + let path_display = target_session.path.display(); + self.chat_widget.add_error_message(format!( + "Failed to resume session from {path_display}: {err}" + )); + } + } + } + SessionSelection::Exit + | SessionSelection::StartFresh + | SessionSelection::Fork(_) => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 71fc7be27..8cc167837 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -74,6 +74,24 @@ pub(crate) struct ConnectorsSnapshot { #[derive(Debug)] pub(crate) enum AppEvent { CodexEvent(Event), + /// Open the fork-owned control panel. + OpenControlPanel, + /// Open the account pool panel inside the control panel flow. + OpenAccountsPanel, + /// Open the managed-account alias rename submenu. + OpenManagedAccountRenamePanel, + /// Mark a managed account as active in the fork-owned registry. + SetManagedAccountActive(String), + /// Open an alias editor for a managed account. + OpenRenameManagedAccountAliasPrompt { + account_id: String, + current_alias: String, + }, + /// Persist a new alias for a managed account. + SaveManagedAccountAlias { + account_id: String, + alias: String, + }, /// Open the agent picker for switching active threads. OpenAgentPicker, /// Switch the active thread to the selected agent. @@ -100,6 +118,8 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, + /// Open the resume picker across all saved sessions, ignoring cwd filtering. + OpenResumePickerAll, /// Fork the current session into a new thread. ForkCurrentSession, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3729778b1..b8d13a3e1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -308,9 +308,21 @@ use crate::streaming::controller::PlanStreamController; use crate::streaming::controller::StreamController; use chrono::Local; +use chrono::Utc; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ThreadManager; +use codex_ext::AccountManagementProfile; +use codex_ext::AccountPoolStore; +use codex_ext::AccountRateLimitSnapshot; +use codex_ext::AccountRateLimitWindow; +use codex_ext::DefaultAccountRouter; +use codex_ext::LimitSignalKind; +use codex_ext::RouteTurnRequest; +use codex_ext::RoutingTrigger; +use codex_ext::activate_managed_account; +use codex_ext::infer_limit_signal; +use codex_ext::persist_current_managed_account_snapshot; use codex_file_search::FileMatch; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelPreset; @@ -546,6 +558,47 @@ fn rate_limit_error_kind(info: &CodexErrorInfo) -> Option { } } +fn account_rate_limit_snapshot(snapshot: &RateLimitSnapshot) -> AccountRateLimitSnapshot { + AccountRateLimitSnapshot { + limit_name: snapshot.limit_name.clone(), + primary: snapshot + .primary + .as_ref() + .map(account_rate_limit_window_from_protocol), + secondary: snapshot + .secondary + .as_ref() + .map(account_rate_limit_window_from_protocol), + } +} + +fn account_rate_limit_snapshot_ref(snapshot: &RateLimitSnapshot) -> AccountRateLimitSnapshot { + account_rate_limit_snapshot(snapshot) +} + +fn account_rate_limit_window_from_protocol( + window: &codex_protocol::protocol::RateLimitWindow, +) -> AccountRateLimitWindow { + AccountRateLimitWindow { + used_percent: window.used_percent, + window_minutes: window.window_minutes, + resets_at: window.resets_at, + } +} + +fn mask_email(email: &str) -> String { + let Some((local, domain)) = email.split_once('@') else { + return email.to_string(); + }; + let visible = local.chars().take(3).collect::(); + let masked = if local.chars().count() > 3 { + format!("{visible}***") + } else { + format!("{visible}*") + }; + format!("{masked}@{domain}") +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] @@ -684,6 +737,9 @@ pub(crate) struct ChatWidget { initial_user_message: Option, token_info: Option, rate_limit_snapshots_by_limit_id: BTreeMap, + latest_codex_rate_limit_snapshot: Option, + last_submitted_user_turn: Option, + managed_account_retry_attempted: bool, plan_type: Option, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, @@ -891,6 +947,12 @@ pub(crate) struct UserMessage { mention_bindings: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UserTurnSubmissionKind { + Normal, + AutoRetry, +} + #[derive(Debug, Clone, PartialEq, Default)] struct ThreadComposerState { text: String, @@ -1749,6 +1811,7 @@ impl ChatWidget { fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { self.submit_pending_steers_after_interrupt = false; + self.managed_account_retry_attempted = false; if let Some(message) = last_agent_message.as_ref() && !message.trim().is_empty() { @@ -2022,6 +2085,10 @@ impl ChatWidget { self.plan_type = snapshot.plan_type.or(self.plan_type); let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); + if is_codex_limit { + self.latest_codex_rate_limit_snapshot = Some(snapshot.clone()); + self.persist_managed_account_rate_limit_snapshot(&snapshot); + } let warnings = if is_codex_limit { self.rate_limit_warnings.take_warnings( snapshot @@ -2078,6 +2145,7 @@ impl ChatWidget { } } else { self.rate_limit_snapshots_by_limit_id.clear(); + self.latest_codex_rate_limit_snapshot = None; } self.refresh_status_surfaces(); } @@ -3647,6 +3715,9 @@ impl ChatWidget { initial_user_message, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), + latest_codex_rate_limit_snapshot: None, + last_submitted_user_turn: None, + managed_account_retry_attempted: false, plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), @@ -3849,6 +3920,9 @@ impl ChatWidget { initial_user_message, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), + latest_codex_rate_limit_snapshot: None, + last_submitted_user_turn: None, + managed_account_retry_attempted: false, plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), @@ -4043,6 +4117,9 @@ impl ChatWidget { initial_user_message, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), + latest_codex_rate_limit_snapshot: None, + last_submitted_user_turn: None, + managed_account_retry_attempted: false, plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), @@ -4164,6 +4241,15 @@ impl ChatWidget { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'p') => { + self.app_event_tx.send(AppEvent::OpenControlPanel); + return; + } KeyEvent { code: KeyCode::Char(c), modifiers, @@ -4903,6 +4989,250 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } + fn current_managed_account_profile(&self) -> Option { + let auth = self.auth_manager.auth_cached().or_else(|| { + CodexAuth::from_auth_storage( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) + .ok() + .flatten() + })?; + if !auth.is_chatgpt_auth() { + return None; + } + + let id = auth.get_account_id()?; + Some(AccountManagementProfile { + id: id.clone(), + alias: Some(id), + masked_email: auth.get_account_email().map(|email| mask_email(&email)), + plan_label: auth + .account_plan_type() + .map(|plan_type| format!("{plan_type:?}").to_ascii_lowercase()), + priority: None, + }) + } + + fn persist_managed_account_rate_limit_snapshot(&self, snapshot: &RateLimitSnapshot) { + let Some(mut profile) = self.current_managed_account_profile() else { + return; + }; + if let Some(plan_type) = snapshot.plan_type { + profile.plan_label = Some(format!("{plan_type:?}").to_ascii_lowercase()); + } + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let account_id = profile.id.clone(); + let rate_limit_snapshot = account_rate_limit_snapshot(snapshot); + if let Err(err) = store.update(|state| { + state.upsert_account(profile); + state.apply_rate_limit_snapshot(&account_id, &rate_limit_snapshot); + }) { + tracing::warn!("failed to persist managed account rate limit snapshot: {err}"); + } + } + + fn persist_managed_account_limit_signal(&self, kind: LimitSignalKind) { + let Some(profile) = self.current_managed_account_profile() else { + return; + }; + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let account_id = profile.id.clone(); + let snapshot = self + .latest_codex_rate_limit_snapshot + .as_ref() + .map(account_rate_limit_snapshot_ref); + let signal = infer_limit_signal(kind, Utc::now().timestamp(), snapshot.as_ref()); + if let Err(err) = store.update(|state| { + state.upsert_account(profile); + state.apply_limit_signal(&account_id, &signal); + }) { + tracing::warn!("failed to persist managed account limit signal: {err}"); + } + } + + fn prepare_managed_account_for_user_turn(&mut self) -> bool { + let current_snapshot = match persist_current_managed_account_snapshot( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + Ok(snapshot) => snapshot, + Err(err) => { + self.add_error_message(format!( + "Failed to snapshot the current managed account before sending: {err}" + )); + return false; + } + }; + let Some(current_snapshot) = current_snapshot else { + return true; + }; + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let mut state = match store.load() { + Ok(state) => state, + Err(err) => { + self.add_error_message(format!("Failed to read managed account pool: {err}")); + return false; + } + }; + state.upsert_account(current_snapshot.profile.clone()); + if state.accounts.is_empty() { + return true; + } + + let now_ts = Utc::now().timestamp(); + let current_account_id = Some(current_snapshot.profile.id); + let decision = DefaultAccountRouter::default().select_account( + &state, + &RouteTurnRequest { + now_ts, + trigger: RoutingTrigger::NormalTurn, + active_account_id: current_account_id.clone(), + preferred_account_id: state.active_account_id.clone(), + }, + ); + let Some(target_account_id) = decision.account_id else { + self.add_error_message( + "No healthy managed ChatGPT account is available for the next turn.".to_string(), + ); + return false; + }; + + if current_account_id.as_deref() == Some(target_account_id.as_str()) { + return true; + } + + let target_label = state + .accounts + .iter() + .find(|account| account.id == target_account_id) + .map(|account| account.display_name().to_string()) + .unwrap_or_else(|| target_account_id.clone()); + if let Err(err) = activate_managed_account( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + &target_account_id, + ) { + self.add_error_message(format!( + "Failed to activate managed account {target_label}: {err}" + )); + return false; + } + self.auth_manager.reload(); + if let Err(err) = store.update(|state| { + state.set_active_account(&target_account_id, now_ts); + }) { + tracing::warn!("failed to update managed account selection after activation: {err}"); + } + self.add_info_message( + format!("Switched to managed account {target_label} before sending."), + /*hint*/ None, + ); + true + } + + fn retry_user_turn_with_managed_account(&mut self) -> bool { + if self.managed_account_retry_attempted { + return false; + } + let Some(user_message) = self.last_submitted_user_turn.clone() else { + return false; + }; + + let current_snapshot = match persist_current_managed_account_snapshot( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + Ok(snapshot) => snapshot, + Err(err) => { + tracing::warn!("failed to snapshot current managed account before retry: {err}"); + return false; + } + }; + let Some(current_snapshot) = current_snapshot else { + return false; + }; + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let mut state = match store.load() { + Ok(state) => state, + Err(err) => { + tracing::warn!("failed to load managed account pool for retry: {err}"); + return false; + } + }; + state.upsert_account(current_snapshot.profile.clone()); + + let now_ts = Utc::now().timestamp(); + let current_account_id = Some(current_snapshot.profile.id); + let decision = DefaultAccountRouter::default().select_account( + &state, + &RouteTurnRequest { + now_ts, + trigger: RoutingTrigger::RetryAfterHardError, + active_account_id: current_account_id.clone(), + preferred_account_id: state.active_account_id.clone(), + }, + ); + let Some(target_account_id) = decision.account_id else { + return false; + }; + if current_account_id.as_deref() == Some(target_account_id.as_str()) { + return false; + } + + let target_label = state + .accounts + .iter() + .find(|account| account.id == target_account_id) + .map(|account| account.display_name().to_string()) + .unwrap_or_else(|| target_account_id.clone()); + if let Err(err) = activate_managed_account( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + &target_account_id, + ) { + tracing::warn!("failed to activate managed account {target_label}: {err}"); + return false; + } + self.auth_manager.reload(); + if let Err(err) = store.update(|state| { + state.set_active_account(&target_account_id, now_ts); + }) { + tracing::warn!("failed to update managed account selection after retry: {err}"); + } + + self.managed_account_retry_attempted = true; + self.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.add_to_history(history_cell::new_info_event( + format!("Retrying the last turn with managed account {target_label}."), + /*hint*/ None, + )); + self.submit_user_message_with_kind(user_message, UserTurnSubmissionKind::AutoRetry); + true + } + + pub(crate) fn open_managed_account_alias_prompt( + &mut self, + account_id: String, + current_alias: String, + ) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Rename managed account".to_string(), + "Type a new alias and press Enter".to_string(), + Some(format!("Current alias: {current_alias}")), + Box::new(move |alias: String| { + tx.send(AppEvent::SaveManagedAccountAlias { + account_id: account_id.clone(), + alias, + }); + }), + ); + + self.bottom_pane.show_view(Box::new(view)); + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } @@ -4966,6 +5296,14 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { + self.submit_user_message_with_kind(user_message, UserTurnSubmissionKind::Normal); + } + + fn submit_user_message_with_kind( + &mut self, + user_message: UserMessage, + submission_kind: UserTurnSubmissionKind, + ) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); @@ -5001,7 +5339,21 @@ impl ChatWidget { return; } - let render_in_history = !self.agent_turn_running; + let is_normal_submission = submission_kind == UserTurnSubmissionKind::Normal; + let render_in_history = is_normal_submission && !self.agent_turn_running; + if is_normal_submission { + self.last_submitted_user_turn = Some(UserMessage { + text: text.clone(), + local_images: local_images.clone(), + remote_image_urls: remote_image_urls.clone(), + text_elements: text_elements.clone(), + mention_bindings: mention_bindings.clone(), + }); + self.managed_account_retry_attempted = false; + if !self.prepare_managed_account_for_user_turn() { + return; + } + } let mut items: Vec = Vec::new(); // Special-case: "!cmd" executes a local shell command instead of sending to the model. @@ -5155,7 +5507,7 @@ impl ChatWidget { } else { None }; - let pending_steer = (!render_in_history).then(|| PendingSteer { + let pending_steer = (is_normal_submission && !render_in_history).then(|| PendingSteer { user_message: UserMessage { text: text.clone(), local_images: local_images.clone(), @@ -5190,7 +5542,7 @@ impl ChatWidget { } // Persist the text to cross-session message history. - if !text.is_empty() { + if is_normal_submission && !text.is_empty() { let encoded_mentions = mention_bindings .iter() .map(|binding| LinkedMention { @@ -5213,7 +5565,7 @@ impl ChatWidget { } // Show replayable user content in conversation history. - if render_in_history && !text.is_empty() { + if is_normal_submission && render_in_history && !text.is_empty() { let local_image_paths = local_images .into_iter() .map(|img| img.path) @@ -5231,7 +5583,7 @@ impl ChatWidget { local_image_paths, remote_image_urls, )); - } else if render_in_history && !remote_image_urls.is_empty() { + } else if is_normal_submission && render_in_history && !remote_image_urls.is_empty() { self.last_rendered_user_message_event = Some(Self::rendered_user_message_event_from_parts( String::new(), @@ -5402,7 +5754,14 @@ impl ChatWidget { self.on_server_overloaded_error(message) } RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_error(message) + self.persist_managed_account_limit_signal(match kind { + RateLimitErrorKind::UsageLimit => LimitSignalKind::UsageLimit, + RateLimitErrorKind::Generic => LimitSignalKind::RateLimit, + RateLimitErrorKind::ServerOverloaded => unreachable!(), + }); + if !self.retry_user_turn_with_managed_account() { + self.on_error(message); + } } } } else { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d837bb8ab..dd75a3c0a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -36,6 +36,7 @@ use codex_core::config_loader::RequirementSource; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::models_manager::manager::ModelsManager; use codex_core::skills::model::SkillMetadata; +use codex_ext::AccountPoolStore; use codex_features::FEATURES; use codex_features::Feature; use codex_otel::RuntimeMetricsSummary; @@ -1870,6 +1871,9 @@ async fn make_chatwidget_manual( initial_user_message: None, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), + latest_codex_rate_limit_snapshot: None, + last_submitted_user_turn: None, + managed_account_retry_attempted: false, plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), @@ -2470,6 +2474,80 @@ async fn rate_limit_switch_prompt_popup_snapshot() { assert_snapshot!("rate_limit_switch_prompt_popup", popup); } +#[tokio::test] +async fn rate_limit_snapshot_persists_managed_account_pool_state() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let codex_home = tempdir().expect("tempdir"); + chat.config.codex_home = codex_home.path().to_path_buf(); + chat.auth_manager = codex_core::test_support::auth_manager_from_auth( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + ); + let future_reset = chrono::Utc::now().timestamp() + 500; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: Some("codex".to_string()), + primary: Some(RateLimitWindow { + used_percent: 87.0, + window_minutes: Some(300), + resets_at: Some(future_reset), + }), + secondary: None, + credits: None, + plan_type: Some(PlanType::Pro), + })); + + let state = AccountPoolStore::new(chat.config.codex_home.clone()) + .load() + .expect("load account pool"); + assert_eq!(state.active_account_id, Some("account_id".to_string())); + assert_eq!(state.accounts.len(), 1); + assert_eq!(state.accounts[0].plan_label, Some("pro".to_string())); + assert_eq!( + state.accounts[0].usage_summary(), + Some("5h 87/100".to_string()) + ); +} + +#[tokio::test] +async fn usage_limit_error_persists_managed_account_cooldown() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + let codex_home = tempdir().expect("tempdir"); + chat.config.codex_home = codex_home.path().to_path_buf(); + chat.auth_manager = codex_core::test_support::auth_manager_from_auth( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + ); + let future_reset = chrono::Utc::now().timestamp() + 900; + let snapshot = RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: Some("codex".to_string()), + primary: Some(RateLimitWindow { + used_percent: 100.0, + window_minutes: Some(300), + resets_at: Some(future_reset), + }), + secondary: None, + credits: None, + plan_type: Some(PlanType::Pro), + }; + chat.on_rate_limit_snapshot(Some(snapshot)); + + chat.dispatch_event_msg( + Some("evt-1".to_string()), + EventMsg::Error(ErrorEvent { + message: "You've hit your usage limit.".to_string(), + codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded), + }), + None, + ); + + let state = AccountPoolStore::new(chat.config.codex_home.clone()) + .load() + .expect("load account pool"); + assert!(state.accounts[0].last_limit_error_at.is_some()); + assert_eq!(state.accounts[0].cooldown_until, Some(future_reset)); +} + #[tokio::test] async fn plan_implementation_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; @@ -6210,6 +6288,15 @@ async fn slash_fork_requests_current_fork() { assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); } +#[tokio::test] +async fn ctrl_p_opens_control_panel() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenControlPanel)); +} + #[tokio::test] async fn slash_rollout_displays_current_path() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui_app_server/Cargo.toml b/codex-rs/tui_app_server/Cargo.toml index 886604205..68e75a5d7 100644 --- a/codex-rs/tui_app_server/Cargo.toml +++ b/codex-rs/tui_app_server/Cargo.toml @@ -41,6 +41,7 @@ codex-chatgpt = { workspace = true } codex-client = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } +codex-ext = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index d4569ae7a..ce58a41e1 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -46,6 +46,7 @@ use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; +use chrono::Utc; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ClientRequest; @@ -78,6 +79,10 @@ use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_ use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_ext::AccountPoolStore; +use codex_ext::HostCapabilities; +use codex_ext::activate_managed_account; +use codex_ext::persist_current_managed_account_snapshot; use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -2500,6 +2505,297 @@ impl App { self.sync_active_agent_label(); } + fn open_control_panel(&mut self) { + let capabilities = HostCapabilities::codex_mvp(); + let items = vec![ + SelectionItem { + name: "Sessions".to_string(), + description: Some("Resume or switch saved chats.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenResumePickerAll))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Fork Current Session".to_string(), + description: Some("Fork the current thread into a new session.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::ForkCurrentSession))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Accounts".to_string(), + description: Some("Inspect the managed multi-account pool.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenAccountsPanel))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Undo Last User Message".to_string(), + description: Some( + "Planned MVP follow-up: restore the last sent input and roll back one turn." + .to_string(), + ), + is_disabled: true, + disabled_reason: Some("Not wired yet.".to_string()), + ..Default::default() + }, + ]; + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-control-panel"), + title: Some("Control Panel".to_string()), + subtitle: Some(format!( + "Fork extension host: {} negotiated MVP capabilities.", + capabilities.capabilities.len() + )), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + fn open_accounts_panel(&mut self) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let path = store.path(); + let state = self.sync_current_auth_into_account_pool(&store); + let mut items = Vec::new(); + let subtitle = match &state { + Ok(state) => Some(format!( + "{} account(s) configured. Active: {}.", + state.accounts.len(), + state.active_account_id.as_deref().unwrap_or("none") + )), + Err(err) => Some(format!("Failed to read account pool: {err}")), + }; + + match state { + Ok(state) => { + for account in &state.accounts { + let mut description_parts = Vec::new(); + if let Some(masked_email) = &account.masked_email { + description_parts.push(masked_email.clone()); + } + if let Some(plan_label) = &account.plan_label { + description_parts.push(plan_label.clone()); + } + if let Some(usage_summary) = account.usage_summary() { + description_parts.push(usage_summary); + } + if let Some(cooldown_until) = account.cooldown_until { + description_parts.push(format!("cooldown until {cooldown_until}")); + } + + let account_id = account.id.clone(); + let search_value = format!( + "{} {} {}", + account.display_name(), + account.id, + account.masked_email.clone().unwrap_or_default() + ); + items.push(SelectionItem { + name: account.display_name().to_string(), + description: (!description_parts.is_empty()) + .then(|| description_parts.join(" · ")), + is_current: state.active_account_id.as_deref() == Some(account.id.as_str()), + is_disabled: !account.enabled, + disabled_reason: (!account.enabled).then(|| "Disabled".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SetManagedAccountActive(account_id.clone())); + })], + dismiss_on_select: true, + search_value: Some(search_value), + ..Default::default() + }); + } + if !state.accounts.is_empty() { + items.push(SelectionItem { + name: "Rename".to_string(), + description: Some( + "Open alias rename actions for managed accounts.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenManagedAccountRenamePanel) + })], + dismiss_on_select: true, + ..Default::default() + }); + } + } + Err(err) => { + items.push(SelectionItem { + name: "Account pool unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }); + } + } + + if items.is_empty() { + items.push(SelectionItem { + name: "No managed accounts yet".to_string(), + description: Some(format!( + "Run repeated ChatGPT login flows and persist them into {}.", + path.display() + )), + is_disabled: true, + ..Default::default() + }); + } + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-accounts-panel"), + title: Some("Accounts".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_note: Some(Line::from(path.display().to_string()).dim()), + items, + ..Default::default() + }); + } + + fn open_managed_account_rename_panel(&mut self) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let path = store.path(); + let state = self.sync_current_auth_into_account_pool(&store); + let mut items = Vec::new(); + let subtitle = match &state { + Ok(state) => Some(format!( + "Rename aliases for {} managed account(s).", + state.accounts.len() + )), + Err(err) => Some(format!("Failed to read account pool: {err}")), + }; + + match state { + Ok(state) => { + for account in &state.accounts { + let account_id = account.id.clone(); + let current_alias = account.alias.clone(); + let mut description_parts = Vec::new(); + if let Some(alias) = + (!current_alias.is_empty()).then_some(current_alias.clone()) + { + description_parts.push(format!("alias: {alias}")); + } + description_parts.push(account.id.clone()); + if let Some(masked_email) = &account.masked_email { + description_parts.push(masked_email.clone()); + } + items.push(SelectionItem { + name: format!("Rename {}", account.display_name()), + description: Some(description_parts.join(" · ")), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenRenameManagedAccountAliasPrompt { + account_id: account_id.clone(), + current_alias: current_alias.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }); + } + } + Err(err) => { + items.push(SelectionItem { + name: "Account pool unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }); + } + } + + if items.is_empty() { + items.push(SelectionItem { + name: "No managed accounts yet".to_string(), + description: Some(format!( + "Run repeated ChatGPT login flows and persist them into {}.", + path.display() + )), + is_disabled: true, + ..Default::default() + }); + } + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-account-rename-panel"), + title: Some("Rename".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_note: Some(Line::from(path.display().to_string()).dim()), + items, + ..Default::default() + }); + } + + fn sync_current_auth_into_account_pool( + &self, + store: &AccountPoolStore, + ) -> std::io::Result { + let snapshot = persist_current_managed_account_snapshot( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + )?; + store.update(|state| { + if let Some(snapshot) = &snapshot { + state.upsert_account(snapshot.profile.clone()); + } + }) + } + + fn set_managed_account_active(&mut self, account_id: String) { + if let Err(err) = persist_current_managed_account_snapshot( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to snapshot current managed account auth: {err}" + ))); + return; + } + if let Err(err) = activate_managed_account( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + &account_id, + ) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to activate managed account auth: {err}" + ))); + return; + } + + let store = AccountPoolStore::new(self.config.codex_home.clone()); + if let Err(err) = store.update(|state| { + state.set_active_account(&account_id, Utc::now().timestamp()); + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to update active managed account: {err}" + ))); + return; + } + + self.open_accounts_panel(); + } + + fn save_managed_account_alias(&mut self, account_id: String, alias: String) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + if let Err(err) = store.update(|state| { + state.rename_account_alias(&account_id, alias.clone()); + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to save managed account alias: {err}" + ))); + return; + } + + self.open_managed_account_rename_panel(); + } + /// Marks a cached picker thread closed and recomputes the contextual footer label. /// /// Closing a thread is not the same as removing it: users can still inspect finished agent @@ -3226,6 +3522,28 @@ impl App { event: AppEvent, ) -> Result { match event { + AppEvent::OpenControlPanel => { + self.open_control_panel(); + } + AppEvent::OpenAccountsPanel => { + self.open_accounts_panel(); + } + AppEvent::OpenManagedAccountRenamePanel => { + self.open_managed_account_rename_panel(); + } + AppEvent::SetManagedAccountActive(account_id) => { + self.set_managed_account_active(account_id); + } + AppEvent::OpenRenameManagedAccountAliasPrompt { + account_id, + current_alias, + } => { + self.chat_widget + .open_managed_account_alias_prompt(account_id, current_alias); + } + AppEvent::SaveManagedAccountAlias { account_id, alias } => { + self.save_managed_account_alias(account_id, alias); + } AppEvent::NewSession => { self.start_fresh_session_with_summary_hint(tui, app_server) .await; @@ -3354,6 +3672,123 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } + AppEvent::OpenResumePickerAll => { + let picker_app_server = match crate::start_app_server_for_picker( + &self.config, + &match self.remote_app_server_url.clone() { + Some(websocket_url) => crate::AppServerTarget::Remote(websocket_url), + None => crate::AppServerTarget::Embedded, + }, + ) + .await + { + Ok(app_server) => app_server, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to start app-server-backed session picker: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; + match crate::resume_picker::run_resume_picker_with_app_server( + tui, + &self.config, + /*show_all*/ true, + picker_app_server, + ) + .await? + { + SessionSelection::Resume(target_session) => { + let current_cwd = self.config.cwd.clone(); + let resume_cwd = if self.remote_app_server_url.is_some() { + current_cwd.clone() + } else { + match crate::resolve_cwd_for_resume_or_fork( + tui, + &self.config, + ¤t_cwd, + target_session.thread_id, + target_session.path.as_deref(), + CwdPromptAction::Resume, + /*allow_prompt*/ true, + ) + .await? + { + crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, + crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), + crate::ResolveCwdOutcome::Exit => { + return Ok(AppRunControl::Exit(ExitReason::UserRequested)); + } + } + }; + let mut resume_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; + self.apply_runtime_policy_overrides(&mut resume_config); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); + match app_server + .resume_thread(resume_config.clone(), target_session.thread_id) + .await + { + Ok(resumed) => { + self.shutdown_current_thread(app_server).await; + self.config = resume_config; + tui.set_notification_method(self.config.tui_notification_method); + self.file_search.update_search_dir(self.config.cwd.clone()); + match self + .replace_chat_widget_with_app_server_thread(tui, resumed) + .await + { + Ok(()) => { + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to attach to resumed app-server thread: {err}" + )); + } + } + } + Err(err) => { + let path_display = target_session.display_label(); + self.chat_widget.add_error_message(format!( + "Failed to resume session from {path_display}: {err}" + )); + } + } + } + SessionSelection::Exit + | SessionSelection::StartFresh + | SessionSelection::Fork(_) => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", diff --git a/codex-rs/tui_app_server/src/app_event.rs b/codex-rs/tui_app_server/src/app_event.rs index a763410dd..e7f7fad0c 100644 --- a/codex-rs/tui_app_server/src/app_event.rs +++ b/codex-rs/tui_app_server/src/app_event.rs @@ -74,6 +74,24 @@ pub(crate) struct ConnectorsSnapshot { #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { + /// Open the fork-owned control panel. + OpenControlPanel, + /// Open the account pool panel inside the control panel flow. + OpenAccountsPanel, + /// Open the managed-account alias rename submenu. + OpenManagedAccountRenamePanel, + /// Mark a managed account as active in the fork-owned registry. + SetManagedAccountActive(String), + /// Open an alias editor for a managed account. + OpenRenameManagedAccountAliasPrompt { + account_id: String, + current_alias: String, + }, + /// Persist a new alias for a managed account. + SaveManagedAccountAlias { + account_id: String, + alias: String, + }, /// Open the agent picker for switching active threads. OpenAgentPicker, /// Switch the active thread to the selected agent. @@ -100,6 +118,8 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, + /// Open the resume picker across all saved sessions, ignoring cwd filtering. + OpenResumePickerAll, /// Fork the current session into a new thread. ForkCurrentSession, diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 82cd0d99b..07a7cee67 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -341,6 +341,14 @@ use crate::streaming::controller::PlanStreamController; use crate::streaming::controller::StreamController; use chrono::Local; +use chrono::Utc; +use codex_core::CodexAuth; +use codex_ext::AccountManagementProfile; +use codex_ext::AccountPoolStore; +use codex_ext::AccountRateLimitSnapshot; +use codex_ext::AccountRateLimitWindow; +use codex_ext::LimitSignalKind; +use codex_ext::infer_limit_signal; use codex_file_search::FileMatch; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelPreset; @@ -587,6 +595,47 @@ fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option AccountRateLimitSnapshot { + AccountRateLimitSnapshot { + limit_name: snapshot.limit_name.clone(), + primary: snapshot + .primary + .as_ref() + .map(account_rate_limit_window_from_protocol), + secondary: snapshot + .secondary + .as_ref() + .map(account_rate_limit_window_from_protocol), + } +} + +fn account_rate_limit_snapshot_ref(snapshot: &RateLimitSnapshot) -> AccountRateLimitSnapshot { + account_rate_limit_snapshot(snapshot) +} + +fn account_rate_limit_window_from_protocol( + window: &codex_protocol::protocol::RateLimitWindow, +) -> AccountRateLimitWindow { + AccountRateLimitWindow { + used_percent: window.used_percent, + window_minutes: window.window_minutes, + resets_at: window.resets_at, + } +} + +fn mask_email(email: &str) -> String { + let Some((local, domain)) = email.split_once('@') else { + return email.to_string(); + }; + let visible = local.chars().take(3).collect::(); + let masked = if local.chars().count() > 3 { + format!("{visible}***") + } else { + format!("{visible}*") + }; + format!("{masked}@{domain}") +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] @@ -726,6 +775,7 @@ pub(crate) struct ChatWidget { status_account_display: Option, token_info: Option, rate_limit_snapshots_by_limit_id: BTreeMap, + latest_codex_rate_limit_snapshot: Option, plan_type: Option, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, @@ -2386,6 +2436,10 @@ impl ChatWidget { self.plan_type = snapshot.plan_type.or(self.plan_type); let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); + if is_codex_limit { + self.latest_codex_rate_limit_snapshot = Some(snapshot.clone()); + self.persist_managed_account_rate_limit_snapshot(&snapshot); + } let warnings = if is_codex_limit { self.rate_limit_warnings.take_warnings( snapshot @@ -2442,6 +2496,7 @@ impl ChatWidget { } } else { self.rate_limit_snapshots_by_limit_id.clear(); + self.latest_codex_rate_limit_snapshot = None; } self.refresh_status_line(); } @@ -2506,6 +2561,11 @@ impl ChatWidget { match info { RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { + self.persist_managed_account_limit_signal(match info { + RateLimitErrorKind::UsageLimit => LimitSignalKind::UsageLimit, + RateLimitErrorKind::Generic => LimitSignalKind::RateLimit, + RateLimitErrorKind::ServerOverloaded => unreachable!(), + }); self.on_error(message) } } @@ -4201,6 +4261,7 @@ impl ChatWidget { status_account_display, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), + latest_codex_rate_limit_snapshot: None, plan_type: initial_plan_type, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), @@ -4314,6 +4375,15 @@ impl ChatWidget { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'p') => { + self.app_event_tx.send(AppEvent::OpenControlPanel); + return; + } KeyEvent { code: KeyCode::Char(c), modifiers, @@ -5040,6 +5110,87 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } + fn current_managed_account_profile(&self) -> Option { + let auth = CodexAuth::from_auth_storage( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) + .ok() + .flatten()?; + if !auth.is_chatgpt_auth() { + return None; + } + + let id = auth.get_account_id()?; + Some(AccountManagementProfile { + id: id.clone(), + alias: Some(id), + masked_email: auth.get_account_email().map(|email| mask_email(&email)), + plan_label: auth + .account_plan_type() + .map(|plan_type| format!("{plan_type:?}").to_ascii_lowercase()), + priority: None, + }) + } + + fn persist_managed_account_rate_limit_snapshot(&self, snapshot: &RateLimitSnapshot) { + let Some(mut profile) = self.current_managed_account_profile() else { + return; + }; + if let Some(plan_type) = snapshot.plan_type { + profile.plan_label = Some(format!("{plan_type:?}").to_ascii_lowercase()); + } + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let account_id = profile.id.clone(); + let rate_limit_snapshot = account_rate_limit_snapshot(snapshot); + if let Err(err) = store.update(|state| { + state.upsert_account(profile); + state.apply_rate_limit_snapshot(&account_id, &rate_limit_snapshot); + }) { + tracing::warn!("failed to persist managed account rate limit snapshot: {err}"); + } + } + + fn persist_managed_account_limit_signal(&self, kind: LimitSignalKind) { + let Some(profile) = self.current_managed_account_profile() else { + return; + }; + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let account_id = profile.id.clone(); + let snapshot = self + .latest_codex_rate_limit_snapshot + .as_ref() + .map(account_rate_limit_snapshot_ref); + let signal = infer_limit_signal(kind, Utc::now().timestamp(), snapshot.as_ref()); + if let Err(err) = store.update(|state| { + state.upsert_account(profile); + state.apply_limit_signal(&account_id, &signal); + }) { + tracing::warn!("failed to persist managed account limit signal: {err}"); + } + } + + pub(crate) fn open_managed_account_alias_prompt( + &mut self, + account_id: String, + current_alias: String, + ) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Rename managed account".to_string(), + "Type a new alias and press Enter".to_string(), + Some(format!("Current alias: {current_alias}")), + Box::new(move |alias: String| { + tx.send(AppEvent::SaveManagedAccountAlias { + account_id: account_id.clone(), + alias, + }); + }), + ); + + self.bottom_pane.show_view(Box::new(view)); + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } @@ -6418,6 +6569,11 @@ impl ChatWidget { self.on_server_overloaded_error(message) } RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { + self.persist_managed_account_limit_signal(match kind { + RateLimitErrorKind::UsageLimit => LimitSignalKind::UsageLimit, + RateLimitErrorKind::Generic => LimitSignalKind::RateLimit, + RateLimitErrorKind::ServerOverloaded => unreachable!(), + }); self.on_error(message) } } diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 639b57da0..6b9475090 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -1892,6 +1892,7 @@ async fn make_chatwidget_manual( status_account_display: None, token_info: None, rate_limit_snapshots_by_limit_id: BTreeMap::new(), + latest_codex_rate_limit_snapshot: None, plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), @@ -6841,6 +6842,15 @@ async fn slash_fork_requests_current_fork() { assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); } +#[tokio::test] +async fn ctrl_p_opens_control_panel() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenControlPanel)); +} + #[tokio::test] async fn slash_rollout_displays_current_path() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/docs/fork-extension-mvp.md b/docs/fork-extension-mvp.md new file mode 100644 index 000000000..6bda86e18 --- /dev/null +++ b/docs/fork-extension-mvp.md @@ -0,0 +1,70 @@ +# Fork Extension MVP + +## Proposal + +Maintain a forked `codex` binary while moving fork-specific behavior behind a +host-owned extension layer. The host stays small and explicit; policy and +product logic move into extension-facing data models and, later, WASM plugins. + +The first fork-owned target is multi-account ChatGPT routing with a fullscreen +control panel entry point. The fork should prefer additive crates and thin +integration shims over invasive edits to `codex-core`, `codex-tui`, or +`codex-tui-app-server`. + +## Design + +### Principles + +- Keep fork logic in a dedicated workspace crate: `codex-ext`. +- Treat host/plugin compatibility as capability negotiation, not lockstep API. +- Keep plugins behind host-controlled APIs so existing approval and sandbox + policies remain authoritative. +- Use the TUI as a consumer of extension state, not the source of truth. +- Route account switching above the provider layer, but below UI-specific code. + +### MVP Scope + +1. Add `codex-ext` with: + - host capability negotiation types + - persisted account-pool state under `CODEX_HOME/accounts/account-pool.json` + - persisted per-account auth snapshots under `CODEX_HOME/accounts//auth.json` + - a default threshold-based account router model + - login-time account snapshotting and alias-aware account registration +2. Add a `Ctrl-P` control panel entry point in both TUI implementations. +3. Show account-pool state in a read-only popup so the fork has a visible, + testable operator surface before auth switching is wired into requests. + +### Deferred + +- WASM runtime and ABI host +- plugin-owned TUI layouts +- undo-last-user-message implementation + +## Phased Todos + +### Phase 1 + +- Land `codex-ext` data model crate. +- Expose `Ctrl-P` control panel in both TUIs. +- Render account pool status from disk. + +### Phase 2 + +- Introduce fork-owned account registry alongside current single-auth storage. +- Add active-account selector and account alias management. +- Record inferred limit/cooldown signals from model errors. + +### Phase 3 + +- Route normal user turns through the default account router. +- Retry one normal user turn on explicit limit/rejection errors. +- Add `codex login --auth ` so browser/device-code login snapshots the + previous root auth and stores the new account under `accounts//`. +- Add observable switch reasons in the control panel. + +### Phase 4 + +- Add a WASM host with capability negotiation. +- Re-express the default account router as a built-in plugin module. +- Add hook/interceptor points for `AppStart`, `SessionStart`, + `BeforeTurnStart`, `BeforeToolCall`, and `AfterToolCall`. From 8a552ed687c9e0c376fd624bc734e4091d705051 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:35:43 +0000 Subject: [PATCH 2/2] chore(deps): bump node from 24-slim to 25-slim in /codex-cli Bumps node from 24-slim to 25-slim. --- updated-dependencies: - dependency-name: node dependency-version: 25-slim dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- codex-cli/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-cli/Dockerfile b/codex-cli/Dockerfile index 21a90a483..8cdb3dc90 100644 --- a/codex-cli/Dockerfile +++ b/codex-cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24-slim +FROM node:25-slim ARG TZ ENV TZ="$TZ"