diff --git a/crates/codex-plus-core/src/computer_use_guard.rs b/crates/codex-plus-core/src/computer_use_guard.rs index fa3c09ff..bb8b9f29 100644 --- a/crates/codex-plus-core/src/computer_use_guard.rs +++ b/crates/codex-plus-core/src/computer_use_guard.rs @@ -1160,3 +1160,27 @@ mod tests { assert_eq!(guard_staging_count, 1); } } + + /// Kill orphaned SkyComputerUseClient processes on macOS. + /// + /// On macOS, Codex spawns a `SkyComputerUseClient` subprocess for each + /// Computer Use session via the bundled openai-bundled computer-use plugin. + /// Codex does not reliably clean these up when conversations end — they + /// accumulate and consume significant memory (~20MB RSS each), eventually + /// causing swap pressure and UI freezes. + /// + /// This function kills all `SkyComputerUseClient` processes it can find. + /// Codex re-spawns them lazily on the next Computer Use session, so killing + /// them is safe and does not affect active conversations. + /// + /// We intentionally leave `node_repl` processes alone — they are lightweight + /// (~1MB RSS) and killing them could disrupt in-flight code execution. + #[cfg(target_os = "macos")] + pub fn kill_orphaned_computer_use_processes() { + let _ = std::process::Command::new("pkill") + .arg("-f") + .arg("SkyComputerUseClient") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index e4618115..0ba96db9 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -689,11 +689,11 @@ impl LaunchHooks for DefaultLaunchHooks { &self, settings: &BackendSettings, ) -> anyhow::Result<()> { - if !settings.computer_use_guard_enabled { - return Ok(()); - } #[cfg(windows)] { + if !settings.computer_use_guard_enabled { + return Ok(()); + } let home = crate::relay_config::default_codex_home_dir(); let artifacts = self.computer_use_guard_artifacts.lock().await.clone(); let (shutdown, mut shutdown_rx) = tokio::sync::oneshot::channel(); @@ -710,6 +710,30 @@ impl LaunchHooks for DefaultLaunchHooks { let _ = runtime.task.await; } } + #[cfg(target_os = "macos")] + { + let _ = &settings; + let (shutdown, mut shutdown_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + _ = tokio::time::sleep(std::time::Duration::from_secs(120)) => { + crate::computer_use_guard::kill_orphaned_computer_use_processes(); + } + } + } + }); + if let Some(runtime) = self + .computer_use_guard_watchdog + .lock() + .await + .replace(ComputerUseGuardWatchdogRuntime { shutdown, task }) + { + let _ = runtime.shutdown.send(()); + let _ = runtime.task.await; + } + } Ok(()) } diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 0c0b606a..da8938fb 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1230,11 +1230,12 @@ fn normalize_duplicate_toml_text(contents: &str) -> String { if in_root && !trimmed.is_empty() && !trimmed.starts_with('#') - && let Some((key, _)) = trimmed.split_once('=') { - let key = key.trim(); - if !key.is_empty() && !key.contains('.') && !seen_root_keys.insert(key.to_string()) { - continue; + if let Some((key, _)) = trimmed.split_once('=') { + let key = key.trim(); + if !key.is_empty() && !key.contains('.') && !seen_root_keys.insert(key.to_string()) { + continue; + } } } @@ -1271,10 +1272,12 @@ fn strip_common_config_text_fallback(config_text: &str, common_config: &str) -> if !trimmed.is_empty() && !trimmed.starts_with('#') - && let Some((key, _)) = trimmed.split_once('=') - && anchors.root_keys.contains(key.trim()) { - continue; + if let Some((key, _)) = trimmed.split_once('=') { + if anchors.root_keys.contains(key.trim()) { + continue; + } + } } kept.push(line); @@ -1304,11 +1307,12 @@ fn common_config_anchors(common_config: &str) -> CommonConfigAnchors { if in_root && !trimmed.is_empty() && !trimmed.starts_with('#') - && let Some((key, _)) = trimmed.split_once('=') { - let key = key.trim(); - if !key.is_empty() { - root_keys.insert(key.to_string()); + if let Some((key, _)) = trimmed.split_once('=') { + let key = key.trim(); + if !key.is_empty() { + root_keys.insert(key.to_string()); + } } } }