Skip to content
Open
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
11 changes: 11 additions & 0 deletions src-tauri/src/commands/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())? {
Expand Down
138 changes: 138 additions & 0 deletions src-tauri/src/commands/cli_daemon.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//! 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.
/// None where the daemon cannot be detected.
pub(crate) fn is_cli_daemon_running() -> Option<bool> {
if cfg!(not(unix)) {
return None;
}

let running = get_codex_home()
.ok()
.and_then(|codex_home| running_daemon_pid(&codex_home))
.is_some();
Some(running)
}

/// 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<bool> {
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<u32> {
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<String> {
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<String> {
None
}

fn parse_daemon_pid(contents: &str) -> Option<u32> {
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}");
}
}
}
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pub mod account;
pub mod account_stats;
pub mod cli_daemon;
pub mod oauth;
pub mod process;
pub mod usage;
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/commands/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -48,6 +50,9 @@ pub struct CodexProcessInfo {
pub can_switch: bool,
/// Process IDs of active Codex app instances
pub pids: Vec<u32>,
/// Whether the Codex CLI daemon runs; switching stops it.
/// None where the daemon cannot be detected.
pub cli_daemon_running: Option<bool>,
}

/// Summary of a close operation for active Codex processes.
Expand Down Expand Up @@ -81,6 +86,7 @@ pub async fn check_codex_processes() -> Result<CodexProcessInfo, String> {
background_count: bg_count,
can_switch: count == 0,
pids,
cli_daemon_running: is_cli_daemon_running(),
})
}

Expand Down
22 changes: 22 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
) {
Expand Down Expand Up @@ -1429,6 +1430,27 @@ function App() {
: "0 Codex running"}
</span>
</span>
{processInfo.cli_daemon_running !== null && (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs border ${processInfo.cli_daemon_running
? "bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:border-sky-700"
: "bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700"
}`}
title={
processInfo.cli_daemon_running
? "The Codex CLI daemon keeps the account it started with. Switching stops it, and the next CLI session starts it again with the new account."
: "The Codex CLI daemon is not running. The next CLI session starts it with the active account."
}
>
<span
className={`inline-block w-1.5 h-1.5 rounded-full ${processInfo.cli_daemon_running ? "bg-sky-500" : "bg-gray-400"
}`}
></span>
<span>
{processInfo.cli_daemon_running ? "CLI daemon on" : "CLI daemon off"}
</span>
</span>
)}
{hasRunningProcesses && (
<button
onClick={() => {
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export interface CodexProcessInfo {
background_count: number;
can_switch: boolean;
pids: number[];
cli_daemon_running: boolean | null;
}

export interface WarmupSummary {
Expand Down