From fd5018e0445ba7d879c5dbda66ecdab4b6da1886 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 18 Aug 2026 00:21:28 +0000 Subject: [PATCH] Add a dedicated `codex agents` dashboard command (#39114) ## What changed - Add `codex agents` to open the shared agents overview without creating a new session. - Start the local background app server automatically on Unix, or connect to a server supplied with `--remote`. - Reject invocation-specific session overrides that cannot apply to shared sessions. - When the overview is opened from an embedded session, offer to start the background server without moving or interrupting the current session. ## Testing - Cover command-line parsing and rejection of incompatible overrides. - Snapshot the embedded-session background-server prompt. GitOrigin-RevId: 60845dfebc48d820dc4ff090626d30452127f062 --- codex-rs/cli/src/main.rs | 132 +++++++++++++++++- codex-rs/cli/tests/app_server.rs | 19 +++ codex-rs/tui/src/app/agents_overview.rs | 90 +++++++++++- codex-rs/tui/src/app/agents_overview_tests.rs | 19 +++ codex-rs/tui/src/app/event_dispatch.rs | 22 ++- codex-rs/tui/src/app/startup.rs | 51 ++++--- codex-rs/tui/src/app/tests/startup.rs | 3 + codex-rs/tui/src/app_event.rs | 8 ++ codex-rs/tui/src/cli.rs | 4 + codex-rs/tui/src/lib.rs | 6 +- codex-rs/tui/src/resume_picker.rs | 1 + ...view__tests__agents_overview_embedded.snap | 12 ++ codex-rs/tui/src/startup_draft.rs | 4 +- codex-rs/tui/src/startup_orchestration.rs | 2 +- 14 files changed, 343 insertions(+), 30 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_embedded.snap diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index a0382dd457..516028f169 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -131,6 +131,9 @@ struct MultitoolCli { #[derive(Debug, clap::Subcommand)] enum Subcommand { + /// Browse all agent sessions on the shared local app-server daemon. + Agents(AgentsCommand), + /// Run Codex non-interactively. #[clap(visible_alias = "e")] Exec(ExecCli), @@ -323,6 +326,20 @@ struct DebugTraceReduceCommand { output: Option, } +#[derive(Debug, Parser)] +struct AgentsCommand { + #[clap(flatten)] + remote: InteractiveRemoteOptions, + + /// Use this directory for new tasks on a remote server. + #[arg(long = "cd", short = 'C', value_name = "DIR")] + cwd: Option, + + /// Disable alternate screen mode. + #[arg(long = "no-alt-screen", default_value_t = false)] + no_alt_screen: bool, +} + #[derive(Debug, Parser)] struct ResumeCommand { /// Session id (UUID) or session name. UUIDs take precedence if it parses. @@ -1009,8 +1026,27 @@ async fn cli_main( // Fold --enable/--disable into config overrides so they flow to all subcommands. let toggle_overrides = feature_toggles.to_overrides()?; root_config_overrides.raw_overrides.extend(toggle_overrides); - let root_remote = remote.remote; - let root_remote_auth_token_env = remote.remote_auth_token_env; + let agents_options = match &subcommand { + Some(Subcommand::Agents(options)) => Some(options), + _ => None, + }; + if let Some(options) = agents_options + && let Some(root_endpoint) = &remote.remote + && let Some(agents_endpoint) = &options.remote.remote + && root_endpoint != agents_endpoint + { + anyhow::bail!("`codex agents` received conflicting remote server endpoints"); + } + let root_remote = agents_options + .and_then(|options| options.remote.remote.clone()) + .or(remote.remote); + let root_remote_auth_token_env = agents_options + .and_then(|options| options.remote.remote_auth_token_env.clone()) + .or(remote.remote_auth_token_env); + if let Some(options) = agents_options { + interactive.cwd = options.cwd.clone().or(interactive.cwd.take()); + interactive.no_alt_screen |= options.no_alt_screen; + } let root_strict_config = interactive.strict_config; interactive .shared @@ -1020,8 +1056,45 @@ async fn cli_main( profile_v2_for_subcommand(&interactive, subcommand)?; } + let open_agents_overview = matches!(&subcommand, Some(Subcommand::Agents(_))); match subcommand { - None => { + None | Some(Subcommand::Agents(_)) => { + if open_agents_overview { + if !root_config_overrides.raw_overrides.is_empty() + || root_strict_config + || interactive.prompt.is_some() + || !interactive.images.is_empty() + || interactive.model.is_some() + || interactive.oss + || interactive.oss_provider.is_some() + || interactive.config_profile_v2.is_some() + || interactive.sandbox_mode.is_some() + || interactive.dangerously_bypass_approvals_and_sandbox + || interactive.bypass_hook_trust + || interactive.cwd.is_some() && root_remote.is_none() + || !interactive.add_dir.is_empty() + || interactive.approval_policy.is_some() + || interactive.web_search + { + anyhow::bail!( + "`codex agents` cannot attach to shared sessions with invocation-specific configuration overrides" + ); + } + if is_workload_identity_selected() { + anyhow::bail!( + "`codex agents` is unavailable while workload identity is active" + ); + } + if root_remote.is_none() { + resolve_remote_endpoint( + /*remote*/ None, + root_remote_auth_token_env.clone(), + )?; + #[cfg(not(unix))] + anyhow::bail!("`codex agents` requires `--remote` on this platform"); + } + interactive.agents_overview = true; + } prepend_config_flags( &mut interactive.config_overrides, root_config_overrides.clone(), @@ -1721,7 +1794,8 @@ fn profile_v2_for_subcommand<'a>( }; match subcommand { - Subcommand::Exec(_) + Subcommand::Agents(_) + | Subcommand::Exec(_) | Subcommand::Review(_) | Subcommand::Resume(_) | Subcommand::Queue(_) @@ -2288,6 +2362,7 @@ fn unsupported_subcommand_name_for_strict_config( ) -> Option<&'static str> { match subcommand { None + | Some(Subcommand::Agents(_)) | Some(Subcommand::Exec(_)) | Some(Subcommand::Review(_)) | Some(Subcommand::McpServer(_)) @@ -2459,6 +2534,22 @@ async fn run_interactive_tui( } } + #[cfg(unix)] + if interactive.agents_overview && remote.is_none() { + if !std::io::stdin().is_terminal() { + return Ok(AppExitInfo::fatal("stdin is not a terminal")); + } + if !std::io::stdout().is_terminal() { + return Ok(AppExitInfo::fatal("stdout is not a terminal")); + } + cloud_config::load_config(&interactive.config_overrides, LoaderOverrides::default()) + .await + .map_err(std::io::Error::other)?; + codex_app_server_daemon::run(AppServerLifecycleCommand::Start) + .await + .map_err(std::io::Error::other)?; + } + let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env) { Ok(remote_endpoint) => remote_endpoint, Err(err) if is_remote_auth_usage_error(&err) => { @@ -4030,6 +4121,39 @@ mod tests { assert_eq!(remote.remote.as_deref(), Some("unix://codex.sock")); } + #[test] + fn agents_subcommand_accepts_remote_session_options() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "agents", + "--remote", + "ws://127.0.0.1:4500", + "--remote-auth-token-env", + "CODEX_REMOTE_AUTH_TOKEN", + "--cd", + "/workspace", + "--no-alt-screen", + ]) + .expect("parse"); + let Some(Subcommand::Agents(options)) = cli.subcommand else { + panic!("expected agents subcommand"); + }; + + assert_eq!( + options.remote.remote.as_deref(), + Some("ws://127.0.0.1:4500") + ); + assert_eq!( + options.remote.remote_auth_token_env.as_deref(), + Some("CODEX_REMOTE_AUTH_TOKEN") + ); + assert_eq!( + options.cwd.as_deref(), + Some(std::path::Path::new("/workspace")) + ); + assert!(options.no_alt_screen); + } + #[test] fn reject_remote_mode_for_non_interactive_subcommands() { let err = reject_remote_mode_for_subcommand( diff --git a/codex-rs/cli/tests/app_server.rs b/codex-rs/cli/tests/app_server.rs index 455c9f668b..6a90759b9f 100644 --- a/codex-rs/cli/tests/app_server.rs +++ b/codex-rs/cli/tests/app_server.rs @@ -32,6 +32,25 @@ foo = "bar" Ok(()) } +#[test] +fn agents_reject_session_overrides_before_starting_the_daemon() -> Result<()> { + let codex_home = TempDir::new()?; + + for args in [ + ["--yolo", "agents"].as_slice(), + ["--search", "agents"].as_slice(), + ["--model", "gpt-5", "agents"].as_slice(), + ] { + let mut cmd = codex_command(codex_home.path())?; + cmd.args(args) + .assert() + .failure() + .stderr(contains("invocation-specific configuration overrides")); + } + + Ok(()) +} + #[test] fn app_server_emits_json_info_events() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 94c139d2f9..bba54d26fa 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -4,6 +4,10 @@ use super::agents_overview_view::AgentsOverviewGroup; use super::agents_overview_view::AgentsOverviewRow; use super::agents_overview_view::AgentsOverviewView; use super::*; +use crate::bottom_pane::SelectionDescriptionLayout; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; use crate::chatwidget::ThreadInputStateRestoreMode; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; @@ -38,10 +42,51 @@ pub(super) struct AgentsOverviewState { impl App { pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) { if matches!(self.app_server_target, AppServerTarget::Embedded) { - self.chat_widget.add_info_message( - "The shared agents dashboard requires a background app server.".to_string(), - /*hint*/ None, - ); + let workload_identity_selected = codex_login::is_workload_identity_selected(); + self.chat_widget.show_selection_view(SelectionViewParams { + title: Some("Shared agents unavailable".to_string()), + subtitle: Some( + if workload_identity_selected { + "The agents dashboard is unavailable while workload identity is active." + } else if cfg!(unix) { + "This session isn’t connected to a shared background server." + } else { + "Connect to a remote background server to use the agents dashboard." + } + .to_string(), + ), + footer_note: (cfg!(unix) && !workload_identity_selected).then(|| { + Line::from( + "Starting a background server will not interrupt or move this session." + .dim(), + ) + }), + footer_hint: Some(standard_popup_hint_line_for_keymap(&self.keymap.list)), + items: [ + #[cfg(unix)] + (!workload_identity_selected).then(|| SelectionItem { + name: "Start background server".to_string(), + description: Some( + "Open `codex agents` in another terminal afterward.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::StartAgentsDaemon))], + dismiss_on_select: true, + ..Default::default() + }), + Some(SelectionItem { + name: "Return to this session".to_string(), + dismiss_on_select: true, + ..Default::default() + }), + ] + .into_iter() + .flatten() + .collect(), + description_layout: SelectionDescriptionLayout::StackBelowWhenNarrow { + min_description_width: 28, + }, + ..Default::default() + }); return; } @@ -647,6 +692,43 @@ impl App { self.refresh_agents_overview_threads(app_server); } } + + #[cfg(unix)] + pub(super) fn start_agents_daemon(&self) { + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = async { + let current_executable = + std::env::current_exe().map_err(|error| error.to_string())?; + let executable = if current_executable + .file_stem() + .is_some_and(|name| name == "codex-tui") + { + current_executable.with_file_name("codex") + } else { + current_executable + }; + let output = tokio::process::Command::new(executable) + .args(["app-server", "daemon", "start"]) + .output() + .await + .map_err(|error| error.to_string())?; + if output.status.success() { + Ok(()) + } else { + let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if message.is_empty() { + format!("daemon process exited with {}", output.status) + } else { + message + }) + } + } + .await; + + app_event_tx.send(AppEvent::AgentsDaemonStarted { result }); + }); + } } #[cfg(test)] diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 9eb42048f9..416a1e914b 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -254,6 +254,25 @@ async fn shared_overview_shows_only_root_sessions() { assert!(app.last_rendered_history_tail.is_some()); } +#[cfg(unix)] +#[tokio::test] +async fn embedded_sessions_offer_to_start_a_background_server_without_migrating() { + let mut app = make_test_app().await; + let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + + app.open_agents_overview(&app_server); + + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!( + "agents_overview_embedded", + render_bottom_popup(&app.chat_widget, /*width*/ 96) + ); + }); + app_server.shutdown().await.expect("shutdown app server"); +} + #[tokio::test] async fn failed_root_switch_keeps_background_requests_on_the_active_session() -> Result<()> { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index be21f4280a..4afd41a949 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -191,7 +191,9 @@ impl App { } } } - SessionSelection::Exit | SessionSelection::StartFresh => { + SessionSelection::Exit + | SessionSelection::StartFresh + | SessionSelection::AgentsOverview => { self.refresh_in_memory_config_from_disk_best_effort( "closing the session picker", ) @@ -2326,6 +2328,9 @@ impl App { .select_agents_overview_thread(tui, app_server, thread_id) .await? { + AppRunControl::Continue if self.primary_thread_id.is_none() => { + self.open_agents_overview(app_server); + } AppRunControl::Continue => {} AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)), } @@ -2348,6 +2353,21 @@ impl App { self.stop_agents_overview_thread(app_server, thread_id) .await; } + #[cfg(unix)] + AppEvent::StartAgentsDaemon => { + self.start_agents_daemon(); + } + #[cfg(unix)] + AppEvent::AgentsDaemonStarted { result } => match result { + Ok(()) => self.chat_widget.add_info_message( + "Background server started. Run `codex agents` in another terminal; this session remains unchanged." + .to_string(), + /*hint*/ None, + ), + Err(error) => self + .chat_widget + .add_error_message(format!("Failed to start the background server: {error}")), + }, AppEvent::OpenAgentPicker => { self.open_agent_picker(app_server).await; } diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 1039fd9472..010c7c6374 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -122,7 +122,9 @@ impl App { let bootstrap_ms = bootstrap.duration.as_millis(); if matches!( &session_selection, - SessionSelection::StartFresh | SessionSelection::Exit + SessionSelection::StartFresh + | SessionSelection::Exit + | SessionSelection::AgentsOverview ) { apply_managed_new_thread_defaults( &mut config, @@ -222,23 +224,33 @@ impl App { &session_selection, SessionSelection::StartFresh | SessionSelection::Exit ); + let start_in_agents_overview = + matches!(&session_selection, SessionSelection::AgentsOverview); let (mut chat_widget, initial_started_thread) = match session_selection { - SessionSelection::StartFresh | SessionSelection::Exit => { - spawn_startup_thread_start(&app_server, config.clone(), app_event_tx.clone()); + SessionSelection::StartFresh + | SessionSelection::Exit + | SessionSelection::AgentsOverview => { + if !start_in_agents_overview { + spawn_startup_thread_start(&app_server, config.clone(), app_event_tx.clone()); + } // Count a startup tooltip once the initial chat widget can render it. - let startup_tooltip_override = match startup_draft - .run_until( - tui, - prepare_startup_tooltip_override( - &mut config, - &available_models, - is_first_run, - ), - ) - .await - { - Ok(tooltip_override) => tooltip_override, - Err(err) => return shutdown_on_startup_error(app_server, err).await, + let startup_tooltip_override = if start_in_agents_overview { + None + } else { + match startup_draft + .run_until( + tui, + prepare_startup_tooltip_override( + &mut config, + &available_models, + is_first_run, + ), + ) + .await + { + Ok(tooltip_override) => tooltip_override, + Err(err) => return shutdown_on_startup_error(app_server, err).await, + } }; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), @@ -268,7 +280,9 @@ impl App { session_telemetry: session_telemetry.clone(), }; let mut chat_widget = ChatWidget::new_with_app_event(init); - chat_widget.set_queue_submissions_until_session_configured(/*queue*/ true); + chat_widget.set_queue_submissions_until_session_configured( + /*queue*/ !start_in_agents_overview, + ); (chat_widget, None) } SessionSelection::Resume(target_session) => { @@ -451,6 +465,9 @@ See the Codex keymap documentation for supported actions and examples." pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }; + if start_in_agents_overview { + app.open_agents_overview(&app_server); + } if let Some(entry) = startup_hooks_browser { app.chat_widget.open_hooks_browser(entry); } diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 0317c6ead8..b0e14416e8 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -40,6 +40,9 @@ fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { App::should_wait_for_initial_session(&SessionSelection::Exit), true ); + assert!(!App::should_wait_for_initial_session( + &SessionSelection::AgentsOverview + )); assert_eq!( App::should_wait_for_initial_session(&SessionSelection::Resume( crate::resume_picker::SessionTarget { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 506778538a..85938623a4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -220,6 +220,14 @@ pub(crate) enum AppEvent { StopAgentsOverviewThread { thread_id: ThreadId, }, + /// Start the shared app-server daemon without moving the current embedded session. + #[cfg(unix)] + StartAgentsDaemon, + /// Report whether starting the shared app-server daemon succeeded. + #[cfg(unix)] + AgentsDaemonStarted { + result: Result<(), String>, + }, /// Open the agent picker for switching active threads. OpenAgentPicker, /// Merge a completed root-scoped agent-picker refresh without blocking terminal input. diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 2273973bbf..641dd621e4 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -37,6 +37,10 @@ pub struct Cli { #[clap(skip)] pub resume_include_non_interactive: bool, + /// Internal: open the daemon-wide agents overview instead of starting a thread. + #[clap(skip)] + pub agents_overview: bool, + // Internal controls set by the top-level `codex fork` subcommand. // These are not exposed as user flags on the base `codex` command. #[clap(skip)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8a4b208ccd..ee4fd03051 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1226,7 +1226,7 @@ async fn run_ratatui_app( initial_config }; startup_draft.apply_config(&config); - if !(cli.resume_picker || cli.fork_picker) + if !(cli.resume_picker || cli.fork_picker || cli.agents_overview) && let Err(err) = startup_draft.show(&mut tui) { shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; @@ -1254,7 +1254,9 @@ async fn run_ratatui_app( }; let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some(); - let session_selection = if use_fork { + let session_selection = if cli.agents_overview { + resume_picker::SessionSelection::AgentsOverview + } else if use_fork { if let Some(id_str) = cli.fork_session_id.as_deref() { let Some(startup_app_server) = app_server.as_mut() else { unreachable!("app server should be initialized for --fork "); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 0438e3bb13..54d9295b8f 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -120,6 +120,7 @@ impl SessionTarget { #[derive(Debug, Clone)] pub enum SessionSelection { StartFresh, + AgentsOverview, Resume(SessionTarget), Fork(SessionTarget), Exit, diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_embedded.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_embedded.snap new file mode 100644 index 0000000000..972757c6d2 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_embedded.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 96)" +--- + Shared agents unavailable + This session isn’t connected to a shared background server. + +› 1. Start background server Open `codex agents` in another terminal afterward. + 2. Return to this session + + Starting a background server will not interrupt or move this session. + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index 983443f342..cd599b9eda 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -183,7 +183,9 @@ impl StartupDraftPump { session_selection: &SessionSelection, ) -> io::Result<()> { let session_action = match session_selection { - SessionSelection::StartFresh | SessionSelection::Exit => StartupDraftSessionAction::New, + SessionSelection::StartFresh + | SessionSelection::Exit + | SessionSelection::AgentsOverview => StartupDraftSessionAction::New, SessionSelection::Resume(_) => StartupDraftSessionAction::Resume, SessionSelection::Fork(_) => StartupDraftSessionAction::Fork, }; diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 04f61bb03a..8df90803eb 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -148,7 +148,7 @@ pub(super) async fn run_main_inner( && loader_overrides_are_default(&launch_loader_overrides) && !strict_config && !cli.bypass_hook_trust; - let initial_screen = if cli.resume_picker || cli.fork_picker { + let initial_screen = if cli.resume_picker || cli.fork_picker || cli.agents_overview { startup_draft::StartupDraftInitialScreen::SessionPicker } else if !cli.oss && explicit_remote_endpoint.is_none()