Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 128 additions & 4 deletions codex-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -323,6 +326,20 @@ struct DebugTraceReduceCommand {
output: Option<PathBuf>,
}

#[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<PathBuf>,

/// 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.
Expand Down Expand Up @@ -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
Expand All @@ -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(),
Expand Down Expand Up @@ -1721,7 +1794,8 @@ fn profile_v2_for_subcommand<'a>(
};

match subcommand {
Subcommand::Exec(_)
Subcommand::Agents(_)
| Subcommand::Exec(_)
| Subcommand::Review(_)
| Subcommand::Resume(_)
| Subcommand::Queue(_)
Expand Down Expand Up @@ -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(_))
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(
Expand Down
19 changes: 19 additions & 0 deletions codex-rs/cli/tests/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
Expand Down
90 changes: 86 additions & 4 deletions codex-rs/tui/src/app/agents_overview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)]
Expand Down
19 changes: 19 additions & 0 deletions codex-rs/tui/src/app/agents_overview_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 21 additions & 1 deletion codex-rs/tui/src/app/event_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down Expand Up @@ -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)),
}
Expand All @@ -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;
}
Expand Down
Loading
Loading