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.
-
-
-
-
-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-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"
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