Improve remote-control daemon UX (#22562)

## Why

`codex remote-control` manages the app-server daemon with
`remote_control` enabled, but it previously only exposed an implicit
start path. Once started, there was no obvious top-level
`remote-control` command for stopping the daemon; users had to know
about the lower-level `codex app-server daemon stop` command.

The startup failure for missing managed installs was also ambiguous.
`codex remote-control` and daemon bootstrap require the standalone Codex
install under `CODEX_HOME/packages/standalone/current/codex`, but the
old error only said to install Codex first, which is unclear when
another `codex` binary is already on PATH. Now we add an explicit
instruction for how to get the standalone Codex install.

## What changed

- Converts `codex remote-control` into a command group while preserving
bare `codex remote-control` as the existing start behavior.
- Adds `codex remote-control start` as the explicit start path.
- Adds `codex remote-control stop`, which maps to app-server daemon
stop.
- Updates the shared daemon managed-install error to name the missing
standalone path, explain why that install is required, provide the
installer command, and tell users to rerun the command they just tried.

## Verification

- `cargo test -p codex-app-server-daemon`
- `cargo test -p codex-cli`
- `./target/debug/codex remote-control --help`
This commit is contained in:
Owen Lin
2026-05-13 18:04:08 -07:00
committed by GitHub
Unverified
parent e33cf9ae28
commit 512f8f8012
2 changed files with 55 additions and 10 deletions
+6 -2
View File
@@ -567,9 +567,13 @@ impl Daemon {
return Ok(());
}
let managed_codex_path = self.managed_codex_bin.display();
Err(anyhow!(
"managed standalone Codex install not found at {}; install Codex first",
self.managed_codex_bin.display()
"managed standalone Codex install not found at {managed_codex_path}\n\n\
This command requires the standalone install managed by the Codex installer, because \
the daemon starts and updates app-server from that fixed path.\n\n\
Install it with:\n curl -fsSL https://chatgpt.com/codex/install.sh | sh\n\n\
Then rerun the command you just tried."
))
}
+49 -8
View File
@@ -131,8 +131,8 @@ enum Subcommand {
/// [experimental] Run the app server or related tooling.
AppServer(AppServerCommand),
/// [experimental] Ensure the app-server daemon is running with remote control enabled.
RemoteControl,
/// [experimental] Manage the app-server daemon with remote control enabled.
RemoteControl(RemoteControlCommand),
/// Launch the Codex desktop app (opens the app installer if missing).
#[cfg(any(target_os = "macos", target_os = "windows"))]
@@ -563,6 +563,21 @@ struct AppServerBootstrapCommand {
remote_control: bool,
}
#[derive(Debug, Args)]
struct RemoteControlCommand {
#[command(subcommand)]
subcommand: Option<RemoteControlSubcommand>,
}
#[derive(Debug, Clone, Copy, clap::Subcommand)]
enum RemoteControlSubcommand {
/// Start the app-server daemon with remote control enabled.
Start,
/// Stop the app-server daemon.
Stop,
}
#[derive(Debug, Args)]
struct GenerateTsCommand {
/// Output directory where .ts files will be written
@@ -1024,14 +1039,25 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
}
}
}
Some(Subcommand::RemoteControl) => {
Some(Subcommand::RemoteControl(remote_control_cli)) => {
let subcommand_name = remote_control_subcommand_name(&remote_control_cli);
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"remote-control",
subcommand_name,
)?;
let output = codex_app_server_daemon::ensure_remote_control_started().await?;
println!("{}", serde_json::to_string(&output)?);
match remote_control_cli
.subcommand
.unwrap_or(RemoteControlSubcommand::Start)
{
RemoteControlSubcommand::Start => {
let output = codex_app_server_daemon::ensure_remote_control_started().await?;
println!("{}", serde_json::to_string(&output)?);
}
RemoteControlSubcommand::Stop => {
print_app_server_daemon_output(AppServerLifecycleCommand::Stop).await?;
}
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
Some(Subcommand::App(app_cli)) => {
@@ -1713,7 +1739,9 @@ fn unsupported_subcommand_name_for_strict_config(
Some(Subcommand::AppServer(app_server)) => {
Some(app_server_subcommand_name(app_server.subcommand.as_ref()))
}
Some(Subcommand::RemoteControl) => Some("remote-control"),
Some(Subcommand::RemoteControl(remote_control)) => {
Some(remote_control_subcommand_name(remote_control))
}
Some(Subcommand::Mcp(_)) => Some("mcp"),
Some(Subcommand::Plugin(_)) => Some("plugin"),
#[cfg(any(target_os = "macos", target_os = "windows"))]
@@ -1766,6 +1794,14 @@ fn reject_remote_mode_for_app_server_subcommand(
reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name)
}
fn remote_control_subcommand_name(command: &RemoteControlCommand) -> &'static str {
match command.subcommand {
None => "remote-control",
Some(RemoteControlSubcommand::Start) => "remote-control start",
Some(RemoteControlSubcommand::Stop) => "remote-control stop",
}
}
fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'static str {
match subcommand {
None => "app-server",
@@ -2659,7 +2695,12 @@ mod tests {
fn reject_remote_flag_for_remote_control() {
let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"])
.expect("parse");
assert_matches!(cli.subcommand, Some(Subcommand::RemoteControl));
assert_matches!(
cli.subcommand,
Some(Subcommand::RemoteControl(RemoteControlCommand {
subcommand: None
}))
);
let err = reject_remote_mode_for_subcommand(
cli.remote.remote.as_deref(),