use std::process::Command;
use anyhow::Result;
#[cfg(windows)]
use anyhow::Context;
#[cfg(windows)]
use std::collections::HashSet;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[cfg(windows)]
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "PascalCase")]
struct WindowsCodexProcess {
name: String,
process_id: u32,
parent_process_id: u32,
#[serde(default)]
command_line: String,
#[serde(default)]
main_window_title: String,
}
#[derive(Debug, Clone)]
pub struct CodexProcessInfo {
pub count: usize,
pub background_count: usize,
pub can_switch: bool,
pub pids: Vec<u32>,
}
pub fn check_codex_processes() -> Result<CodexProcessInfo> {
let (pids, background_count) = find_codex_processes()?;
let count = pids.len();
Ok(CodexProcessInfo {
count,
background_count,
can_switch: count == 0,
pids,
})
}
pub fn ensure_can_switch() -> Result<()> {
let info = check_codex_processes()?;
if !info.can_switch {
let pids = info
.pids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"Detected {} active Codex process(es) (pid: {pids}). Close Codex before switching accounts. Ignored {} background process(es).",
info.count,
info.background_count
);
}
Ok(())
}
fn find_codex_processes() -> Result<(Vec<u32>, usize)> {
#[cfg(unix)]
{
let mut pids = Vec::new();
let mut background_count = 0;
let output = Command::new("ps")
.args(["-axo", "pid=,tty=,command="])
.output();
if let Ok(output) = output {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
let Some(pid_str) = parts.next() else {
continue;
};
let Some(tty) = parts.next() else {
continue;
};
let command = parts.collect::<Vec<_>>().join(" ");
if command.is_empty() {
continue;
}
let lowercase_command = command.to_ascii_lowercase();
if lowercase_command.contains("codex-switcher")
|| lowercase_command.contains("codex-switch")
{
continue;
}
let first_token = command.split_whitespace().next().unwrap_or("");
let is_codex_cli = first_token == "codex" || first_token.ends_with("/codex");
let is_codex_desktop = command.contains(".app/Contents/MacOS/Codex")
&& !command.contains("Codex Helper")
&& !command.contains("CodexBar");
if !is_codex_cli && !is_codex_desktop {
continue;
}
let Ok(pid) = pid_str.parse::<u32>() else {
continue;
};
if pid == std::process::id() || pids.contains(&pid) {
continue;
}
let is_ide_plugin = is_ide_plugin_process(&lowercase_command);
let is_app_server = lowercase_command.contains("codex app-server");
let has_tty = tty != "??" && tty != "?";
if is_ide_plugin || is_app_server {
background_count += 1;
continue;
}
if is_codex_desktop || has_tty {
pids.push(pid);
} else {
background_count += 1;
}
}
}
pids.sort_unstable();
pids.dedup();
return Ok((pids, background_count));
}
#[cfg(windows)]
{
return find_windows_codex_processes();
}
#[allow(unreachable_code)]
Ok((Vec::new(), 0))
}
#[cfg(windows)]
fn find_windows_codex_processes() -> Result<(Vec<u32>, usize)> {
const POWERSHELL_SCRIPT: &str = r#"
$windowTitles = @{}
Get-Process -Name Codex -ErrorAction SilentlyContinue | ForEach-Object {
$windowTitles[[uint32]$_.Id] = $_.MainWindowTitle
}
Get-CimInstance Win32_Process |
Where-Object { $_.Name -ieq 'Codex.exe' -or $_.Name -ieq 'codex.exe' } |
ForEach-Object {
[PSCustomObject]@{
Name = $_.Name
ProcessId = [uint32]$_.ProcessId
ParentProcessId = [uint32]$_.ParentProcessId
CommandLine = if ($_.CommandLine) { $_.CommandLine } else { '' }
MainWindowTitle = if ($windowTitles.ContainsKey([uint32]$_.ProcessId)) {
[string]$windowTitles[[uint32]$_.ProcessId]
} else {
''
}
}
} |
ConvertTo-Json -Compress
"#;
let output = Command::new("powershell.exe")
.creation_flags(CREATE_NO_WINDOW)
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
POWERSHELL_SCRIPT,
])
.output()
.context("failed to query Windows process list")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("PowerShell process query failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let processes = parse_windows_codex_processes(&stdout)?;
let mut active_pids = Vec::new();
let mut ignored_count = 0;
for process in processes
.iter()
.filter(|process| is_windows_codex_root_process(process))
{
let command = process.command_line.to_ascii_lowercase();
if is_ide_plugin_process(&command) {
ignored_count += 1;
continue;
}
let has_window = !process.main_window_title.trim().is_empty();
let has_renderer =
windows_has_descendant_matching(process.process_id, &processes, |child| {
child
.command_line
.to_ascii_lowercase()
.contains("--type=renderer")
});
let has_app_server =
windows_has_descendant_matching(process.process_id, &processes, |child| {
let command = child.command_line.to_ascii_lowercase();
command.contains("resources\\codex.exe") && command.contains("app-server")
});
if has_window || has_renderer || has_app_server {
active_pids.push(process.process_id);
} else {
ignored_count += 1;
}
}
active_pids.sort_unstable();
active_pids.dedup();
Ok((active_pids, ignored_count))
}
#[cfg(windows)]
fn parse_windows_codex_processes(stdout: &str) -> Result<Vec<WindowsCodexProcess>> {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let value: serde_json::Value =
serde_json::from_str(trimmed).context("failed to parse Windows process JSON")?;
match value {
serde_json::Value::Array(values) => values
.into_iter()
.map(|value| {
serde_json::from_value(value)
.context("failed to deserialize Windows Codex process entry")
})
.collect(),
value => {
Ok(vec![serde_json::from_value(value).context(
"failed to deserialize Windows Codex process entry",
)?])
}
}
}
#[cfg(windows)]
fn is_windows_codex_root_process(process: &WindowsCodexProcess) -> bool {
let name = process.name.to_ascii_lowercase();
let command = process.command_line.to_ascii_lowercase();
name == "codex.exe"
&& !command.contains("codex-switcher")
&& !command.contains("codex-switch")
&& !command.contains("--type=")
&& !command.contains("resources\\codex.exe")
}
#[cfg(any(unix, windows))]
fn is_ide_plugin_process(command: &str) -> bool {
command.contains(".antigravity")
|| command.contains("openai.chatgpt")
|| command.contains(".vscode")
}
#[cfg(windows)]
fn windows_has_descendant_matching<F>(
root_pid: u32,
processes: &[WindowsCodexProcess],
mut predicate: F,
) -> bool
where
F: FnMut(&WindowsCodexProcess) -> bool,
{
let mut queue = vec![root_pid];
let mut visited = HashSet::new();
while let Some(parent_pid) = queue.pop() {
for process in processes
.iter()
.filter(|process| process.parent_process_id == parent_pid)
{
if !visited.insert(process.process_id) {
continue;
}
if predicate(process) {
return true;
}
queue.push(process.process_id);
}
}
false
}