From 771403ea4f2e338c166a5b6e0c8629da94891deb Mon Sep 17 00:00:00 2001 From: Fajar Maulana Firdaus Date: Sat, 3 Oct 2026 07:46:17 +0700 Subject: [PATCH 1/4] fix(switch): stop the Codex CLI daemon when switching accounts --- src-tauri/src/commands/account.rs | 11 +++ src-tauri/src/commands/cli_daemon.rs | 132 +++++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + 3 files changed, 144 insertions(+) create mode 100644 src-tauri/src/commands/cli_daemon.rs diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index e914ab4d..a0db8a0c 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -8,6 +8,7 @@ use crate::auth::{ }; use crate::types::{AccountInfo, AccountsStore, AuthData, ImportAccountsSummary, StoredAccount}; +use super::cli_daemon::stop_cli_daemon_if_running; use super::process::ensure_codex_not_running; use anyhow::Context; @@ -154,6 +155,16 @@ pub async fn switch_account_by_id(account_id: &str) -> Result<(), String> { ensure_codex_not_running()?; + // The CLI daemon keeps the account it loaded at start. Stop it before + // auth.json changes; the next CLI session starts a new daemon. + let daemon_stop = tokio::task::spawn_blocking(stop_cli_daemon_if_running).await; + if let Err(err) = daemon_stop + .map_err(anyhow::Error::from) + .and_then(|stopped| stopped) + { + println!("[Account] Failed to stop the Codex CLI daemon: {err:#}"); + } + // ChatGPT rotates single-use refresh tokens. Preserve the latest token // before replacing auth.json, otherwise switching back restores a stale one. if let Some(auth) = read_current_auth().map_err(|e| e.to_string())? { diff --git a/src-tauri/src/commands/cli_daemon.rs b/src-tauri/src/commands/cli_daemon.rs new file mode 100644 index 00000000..029291c9 --- /dev/null +++ b/src-tauri/src/commands/cli_daemon.rs @@ -0,0 +1,132 @@ +//! Codex CLI app-server daemon support. +//! CLI sessions share one daemon that reads auth.json only when it starts. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context}; + +use crate::auth::get_codex_home; + +/// Whether the managed CLI daemon for the current Codex home is running. +pub(crate) fn is_cli_daemon_running() -> bool { + get_codex_home() + .ok() + .and_then(|codex_home| running_daemon_pid(&codex_home)) + .is_some() +} + +/// Stop the managed CLI daemon so it cannot keep the previous account. +/// The next CLI session starts a new daemon from its own environment. +/// Returns false when no daemon is running. +pub(crate) fn stop_cli_daemon_if_running() -> anyhow::Result { + let codex_home = get_codex_home()?; + if running_daemon_pid(&codex_home).is_none() { + return Ok(false); + } + + let output = Command::new(managed_codex_path(&codex_home)) + .args(["app-server", "daemon", "stop"]) + .env("CODEX_HOME", &codex_home) + .output() + .context("Failed to run codex app-server daemon stop")?; + if !output.status.success() { + bail!( + "codex app-server daemon stop failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + Ok(true) +} + +fn running_daemon_pid(codex_home: &Path) -> Option { + let pid_file = codex_home.join("app-server-daemon").join("daemon.pid"); + let pid = parse_daemon_pid(&std::fs::read_to_string(pid_file).ok()?)?; + let command = read_process_command(pid)?; + is_managed_daemon_command(&command).then_some(pid) +} + +fn managed_codex_path(codex_home: &Path) -> PathBuf { + codex_home + .join("packages") + .join("app-server-daemon") + .join("current") + .join("bin") + .join("codex") +} + +#[cfg(unix)] +fn read_process_command(pid: u32) -> Option { + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "command="]) + .output() + .ok()?; + let command = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (output.status.success() && !command.is_empty()).then_some(command) +} + +// The daemon listens on a Unix socket; other platforms are not detected yet. +#[cfg(not(unix))] +fn read_process_command(_pid: u32) -> Option { + None +} + +fn parse_daemon_pid(contents: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(contents).ok()?; + let pid = u32::try_from(value.get("pid")?.as_u64()?).ok()?; + (pid > 0).then_some(pid) +} + +fn is_managed_daemon_command(command: &str) -> bool { + let mut tokens = command.split_whitespace(); + tokens.clone().any(|token| token == "app-server") + && tokens.any(|token| token == "--managed-daemon") +} + +#[cfg(test)] +mod tests { + use super::{is_managed_daemon_command, parse_daemon_pid}; + + const RELEASE_BIN: &str = + "/Users/me/.codex/packages/app-server-daemon/releases/0.160.0-aarch64-apple-darwin/bin/codex"; + + #[test] + fn reads_the_pid_from_the_daemon_pid_file() { + let contents = r#"{"pid":99744,"processStartTime":"Fri Oct 2 03:58:07 2026","processIdentity":{"uniqueId":10620751}}"#; + assert_eq!(parse_daemon_pid(contents), Some(99744)); + } + + #[test] + fn rejects_an_unreadable_daemon_pid_file() { + for contents in [ + "", + "not json", + r#"{"pid":"99744"}"#, + r#"{"pid":0}"#, + r#"{}"#, + ] { + assert_eq!(parse_daemon_pid(contents), None, "{contents}"); + } + } + + #[test] + fn detects_the_managed_daemon_command() { + let command = format!("{RELEASE_BIN} app-server --listen unix:// --managed-daemon"); + assert!(is_managed_daemon_command(&command)); + } + + #[test] + fn ignores_other_codex_commands() { + for command in [ + format!("{RELEASE_BIN} app-server daemon pid-update-loop"), + "/opt/codex-acp/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex app-server".to_string(), + "/Applications/ChatGPT.app/Contents/Resources/codex app-server".to_string(), + "codex --managed-daemon-docs".to_string(), + "codex".to_string(), + String::new(), + ] { + assert!(!is_managed_daemon_command(&command), "{command}"); + } + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0d8a128a..9b429ed8 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod account; pub mod account_stats; +pub mod cli_daemon; pub mod oauth; pub mod process; pub mod usage; From ccd3efd52d92cc67e85ce3485b1351b17b4f17a4 Mon Sep 17 00:00:00 2001 From: Fajar Maulana Firdaus Date: Sat, 3 Oct 2026 07:46:17 +0700 Subject: [PATCH 2/4] feat(ui): show when the Codex CLI daemon is running --- src-tauri/src/commands/process.rs | 5 +++++ src/App.tsx | 10 ++++++++++ src/types/index.ts | 1 + 3 files changed, 16 insertions(+) diff --git a/src-tauri/src/commands/process.rs b/src-tauri/src/commands/process.rs index a4616e7b..65150c0d 100644 --- a/src-tauri/src/commands/process.rs +++ b/src-tauri/src/commands/process.rs @@ -7,6 +7,8 @@ use std::time::{Duration, Instant}; mod desktop_reopen; pub use desktop_reopen::*; +use super::cli_daemon::is_cli_daemon_running; + #[cfg(any(windows, test))] use anyhow::Context; @@ -48,6 +50,8 @@ pub struct CodexProcessInfo { pub can_switch: bool, /// Process IDs of active Codex app instances pub pids: Vec, + /// Whether the Codex CLI daemon runs; switching stops it + pub cli_daemon_running: bool, } /// Summary of a close operation for active Codex processes. @@ -81,6 +85,7 @@ pub async fn check_codex_processes() -> Result { background_count: bg_count, can_switch: count == 0, pids, + cli_daemon_running: is_cli_daemon_running(), }) } diff --git a/src/App.tsx b/src/App.tsx index 0dacc7b0..3a7f652b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -431,6 +431,7 @@ function App() { prev.can_switch === info.can_switch && prev.count === info.count && prev.background_count === info.background_count && + prev.cli_daemon_running === info.cli_daemon_running && prev.pids.length === info.pids.length && prev.pids.every((pid, index) => pid === info.pids[index]) ) { @@ -1429,6 +1430,15 @@ function App() { : "0 Codex running"} + {processInfo.cli_daemon_running && ( + + + CLI daemon running + + )} {hasRunningProcesses && (