feat(tui): prevent macOS idle sleep while turns run (#11711)

## Summary
- add a shared `codex-core` sleep inhibitor that uses native macOS IOKit
assertions (`IOPMAssertionCreateWithName` / `IOPMAssertionRelease`)
instead of spawning `caffeinate`
- wire sleep inhibition to turn lifecycle in `tui` (`TurnStarted`
enables; `TurnComplete` and abort/error finalization disable)
- gate this behavior behind a `/experimental` feature toggle
(`[features].prevent_idle_sleep`) instead of a dedicated `[tui]` config
flag
- expose the toggle in `/experimental` on macOS; keep it under
development on other platforms
- keep behavior no-op on non-macOS targets

<img width="1326" height="577" alt="image"
src="https://github.com/user-attachments/assets/73fac06b-97ae-46a2-800a-30f9516cf8a3"
/>

## Testing
- `cargo check -p codex-core -p codex-tui`
- `cargo test -p codex-core sleep_inhibitor::tests -- --nocapture`
- `cargo test -p codex-core
tui_config_missing_notifications_field_defaults_to_enabled --
--nocapture`
- `cargo test -p codex-core prevent_idle_sleep_is_ -- --nocapture`

## Semantics and API references
- This PR targets `caffeinate -i` semantics: prevent *idle system sleep*
while allowing display idle sleep.
- `caffeinate -i` mapping in Apple open source (`assertionMap`):
  - `kIdleAssertionFlag -> kIOPMAssertionTypePreventUserIdleSystemSleep`
- Source:
https://github.com/apple-oss-distributions/PowerManagement/blob/PowerManagement-1846.60.12/caffeinate/caffeinate.c#L52-L54
- Apple IOKit docs for assertion types and API:
-
https://developer.apple.com/documentation/iokit/iopmlib_h/iopmassertiontypes
-
https://developer.apple.com/documentation/iokit/1557092-iopmassertioncreatewithname
  - https://developer.apple.com/library/archive/qa/qa1340/_index.html

## Codex Electron vs this PR (full stack path)
- Codex Electron app requests sleep blocking with
`powerSaveBlocker.start("prevent-app-suspension")`:
-
https://github.com/openai/codex/blob/main/codex/codex-vscode/electron/src/electron-message-handler.ts
- Electron maps that string to Chromium wake lock type
`kPreventAppSuspension`:
-
https://github.com/electron/electron/blob/main/shell/browser/api/electron_api_power_save_blocker.cc
- Chromium macOS backend maps wake lock types to IOKit assertion
constants and calls IOKit:
  - `kPreventAppSuspension -> kIOPMAssertionTypeNoIdleSleep`
- `kPreventDisplaySleep / kPreventDisplaySleepAllowDimming ->
kIOPMAssertionTypeNoDisplaySleep`
-
https://github.com/chromium/chromium/blob/main/services/device/wake_lock/power_save_blocker/power_save_blocker_mac.cc

## Why this PR uses a different macOS constant name
- This PR uses `"PreventUserIdleSystemSleep"` directly, via
`IOPMAssertionCreateWithName`, in
`codex-rs/core/src/sleep_inhibitor.rs`.
- Apple’s IOKit header documents `kIOPMAssertionTypeNoIdleSleep` as
deprecated and recommends `kIOPMAssertPreventUserIdleSystemSleep` /
`kIOPMAssertionTypePreventUserIdleSystemSleep`:
-
https://github.com/apple-oss-distributions/IOKitUser/blob/IOKitUser-100222.60.2/pwr_mgt.subproj/IOPMLib.h#L1000-L1030
- So Chromium and this PR are using different constant names, but
semantically equivalent idle-system-sleep prevention behavior.

## Future platform support
The architecture is intentionally set up for multi-platform extensions:
- UI code (`tui`) only calls `SleepInhibitor::set_turn_running(...)` on
turn lifecycle boundaries.
- Platform-specific behavior is isolated in
`codex-rs/core/src/sleep_inhibitor.rs` behind `cfg(...)` blocks.
- Feature exposure is centralized in `core/src/features.rs` and surfaced
via `/experimental`.
- Adding new OS backends should not require additional TUI wiring; only
the backend internals and feature stage metadata need to change.

Potential follow-up implementations:
- Windows:
- Add a backend using Win32 power APIs
(`SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` as
baseline).
- Optionally move to `PowerCreateRequest` / `PowerSetRequest` /
`PowerClearRequest` for richer assertion semantics.
- Linux:
- Add a backend using logind inhibitors over D-Bus
(`org.freedesktop.login1.Manager.Inhibit` with `what="sleep"`).
  - Keep a no-op fallback where logind/D-Bus is unavailable.

This PR keeps the cross-platform API surface minimal so future PRs can
add Windows/Linux support incrementally with low churn.

---------

Co-authored-by: jif-oai <jif@openai.com>
This commit is contained in:
Yaroslav Volovich 2026-02-13 18:31:39 +00:00 committed by GitHub
parent 851fcc377b
commit 32da5eb358
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 374 additions and 0 deletions

10
codex-rs/Cargo.lock generated
View file

@ -2297,6 +2297,7 @@ dependencies = [
"codex-utils-oss",
"codex-utils-pty",
"codex-utils-sandbox-summary",
"codex-utils-sleep-inhibitor",
"codex-windows-sandbox",
"color-eyre",
"crossterm",
@ -2496,6 +2497,15 @@ dependencies = [
"regex",
]
[[package]]
name = "codex-utils-sleep-inhibitor"
version = "0.0.0"
dependencies = [
"core-foundation 0.9.4",
"libc",
"tracing",
]
[[package]]
name = "codex-utils-string"
version = "0.0.0"

View file

@ -54,6 +54,7 @@ members = [
"utils/elapsed",
"utils/sandbox-summary",
"utils/sanitizer",
"utils/sleep-inhibitor",
"utils/approval-presets",
"utils/oss",
"utils/fuzzy-match",
@ -131,6 +132,7 @@ codex-utils-readiness = { path = "utils/readiness" }
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
codex-utils-sandbox-summary = { path = "utils/sandbox-summary" }
codex-utils-sanitizer = { path = "utils/sanitizer" }
codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" }
codex-utils-string = { path = "utils/string" }
codex-windows-sandbox = { path = "windows-sandbox-rs" }
core_test_support = { path = "core/tests/common" }

View file

@ -239,6 +239,9 @@
"powershell_utf8": {
"type": "boolean"
},
"prevent_idle_sleep": {
"type": "boolean"
},
"remote_models": {
"type": "boolean"
},
@ -1366,6 +1369,9 @@
"powershell_utf8": {
"type": "boolean"
},
"prevent_idle_sleep": {
"type": "boolean"
},
"remote_models": {
"type": "boolean"
},

View file

@ -133,6 +133,8 @@ pub enum Feature {
CollaborationModes,
/// Enable personality selection in the TUI.
Personality,
/// Prevent idle system sleep while a turn is actively running.
PreventIdleSleep,
/// Use the Responses API WebSocket transport for OpenAI by default.
ResponsesWebsockets,
/// Enable Responses API websocket v2 mode.
@ -604,6 +606,20 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::PreventIdleSleep,
key: "prevent_idle_sleep",
stage: if cfg!(target_os = "macos") {
Stage::Experimental {
name: "Prevent sleep while running",
menu_description: "Keep your computer awake while Codex is running a thread.",
announcement: "NEW: Prevent sleep while running is now available in /experimental.",
}
} else {
Stage::UnderDevelopment
},
default_enabled: false,
},
FeatureSpec {
id: Feature::ResponsesWebsockets,
key: "responses_websockets",

View file

@ -46,6 +46,7 @@ codex-utils-elapsed = { workspace = true }
codex-utils-fuzzy-match = { workspace = true }
codex-utils-oss = { workspace = true }
codex-utils-sandbox-summary = { workspace = true }
codex-utils-sleep-inhibitor = { workspace = true }
color-eyre = { workspace = true }
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
derive_more = { workspace = true, features = ["is_variant"] }

View file

@ -131,6 +131,7 @@ use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use codex_utils_sleep_inhibitor::SleepInhibitor;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@ -520,6 +521,7 @@ pub(crate) struct ChatWidget {
skills_initial_state: Option<HashMap<PathBuf, bool>>,
last_unified_wait: Option<UnifiedExecWaitState>,
unified_exec_wait_streak: Option<UnifiedExecWaitStreak>,
turn_sleep_inhibitor: SleepInhibitor,
task_complete_pending: bool,
unified_exec_processes: Vec<UnifiedExecProcessSummary>,
/// Tracks whether codex-core currently considers an agent turn to be in progress.
@ -1275,6 +1277,7 @@ impl ChatWidget {
fn on_task_started(&mut self) {
self.agent_turn_running = true;
self.turn_sleep_inhibitor.set_turn_running(true);
self.saw_plan_update_this_turn = false;
self.saw_plan_item_this_turn = false;
self.plan_delta_buffer.clear();
@ -1332,6 +1335,7 @@ impl ChatWidget {
// Mark task stopped and request redraw now that all content is in history.
self.pending_status_indicator_restore = false;
self.agent_turn_running = false;
self.turn_sleep_inhibitor.set_turn_running(false);
self.update_task_running_state();
self.running_commands.clear();
self.suppressed_exec_calls.clear();
@ -1568,6 +1572,7 @@ impl ChatWidget {
self.finalize_active_cell_as_failed();
// Reset running state and clear streaming buffers.
self.agent_turn_running = false;
self.turn_sleep_inhibitor.set_turn_running(false);
self.update_task_running_state();
self.running_commands.clear();
self.suppressed_exec_calls.clear();
@ -2544,6 +2549,7 @@ impl ChatWidget {
let model = model.filter(|m| !m.trim().is_empty());
let mut config = config;
config.model = model.clone();
let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep);
let mut rng = rand::rng();
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
@ -2611,6 +2617,7 @@ impl ChatWidget {
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
unified_exec_wait_streak: None,
turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep),
task_complete_pending: false,
unified_exec_processes: Vec::new(),
agent_turn_running: false,
@ -2710,6 +2717,7 @@ impl ChatWidget {
let model = model.filter(|m| !m.trim().is_empty());
let mut config = config;
config.model = model.clone();
let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep);
let mut rng = rand::rng();
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
@ -2776,6 +2784,7 @@ impl ChatWidget {
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
unified_exec_wait_streak: None,
turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep),
task_complete_pending: false,
unified_exec_processes: Vec::new(),
agent_turn_running: false,
@ -2862,6 +2871,7 @@ impl ChatWidget {
otel_manager,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep);
let mut rng = rand::rng();
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
@ -2930,6 +2940,7 @@ impl ChatWidget {
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
unified_exec_wait_streak: None,
turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep),
task_complete_pending: false,
unified_exec_processes: Vec::new(),
agent_turn_running: false,
@ -5950,6 +5961,11 @@ impl ChatWidget {
if feature == Feature::Personality {
self.sync_personality_command_enabled();
}
if feature == Feature::PreventIdleSleep {
self.turn_sleep_inhibitor = SleepInhibitor::new(enabled);
self.turn_sleep_inhibitor
.set_turn_running(self.agent_turn_running);
}
#[cfg(target_os = "windows")]
if matches!(
feature,

View file

@ -1030,6 +1030,7 @@ async fn make_chatwidget_manual(
if let Some(model) = model_override {
cfg.model = Some(model.to_string());
}
let prevent_idle_sleep = cfg.features.enabled(Feature::PreventIdleSleep);
let otel_manager = test_otel_manager(&cfg, resolved_model.as_str());
let mut bottom = BottomPane::new(BottomPaneParams {
app_event_tx: app_event_tx.clone(),
@ -1086,6 +1087,7 @@ async fn make_chatwidget_manual(
skills_initial_state: None,
last_unified_wait: None,
unified_exec_wait_streak: None,
turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep),
task_complete_pending: false,
unified_exec_processes: Vec::new(),
agent_turn_running: false,

View file

@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "sleep-inhibitor",
crate_name = "codex_utils_sleep_inhibitor",
)

View file

@ -0,0 +1,13 @@
[package]
name = "codex-utils-sleep-inhibitor"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.9"
libc = { workspace = true }
tracing = { workspace = true }

View file

@ -0,0 +1,16 @@
use crate::PlatformSleepInhibitor;
#[derive(Debug, Default)]
pub(crate) struct DummySleepInhibitor;
impl DummySleepInhibitor {
pub(crate) fn new() -> Self {
Self
}
}
impl PlatformSleepInhibitor for DummySleepInhibitor {
fn acquire(&mut self) {}
fn release(&mut self) {}
}

View file

@ -0,0 +1,94 @@
//! Cross-platform helper for preventing idle sleep while a turn is running.
//!
//! On macOS this uses native IOKit power assertions instead of spawning
//! `caffeinate`, so assertion lifecycle is tied directly to Rust object lifetime.
#[cfg(not(target_os = "macos"))]
mod dummy;
#[cfg(target_os = "macos")]
mod macos_inhibitor;
use std::fmt::Debug;
/// Keeps the machine awake while a turn is in progress when enabled.
#[derive(Debug)]
pub struct SleepInhibitor {
enabled: bool,
platform: Box<dyn PlatformSleepInhibitor>,
}
pub(crate) trait PlatformSleepInhibitor: Debug {
fn acquire(&mut self);
fn release(&mut self);
}
impl SleepInhibitor {
pub fn new(enabled: bool) -> Self {
#[cfg(target_os = "macos")]
let platform: Box<dyn PlatformSleepInhibitor> =
Box::new(macos_inhibitor::MacOsSleepInhibitor::new());
#[cfg(not(target_os = "macos"))]
let platform: Box<dyn PlatformSleepInhibitor> = Box::new(dummy::DummySleepInhibitor::new());
Self { enabled, platform }
}
/// Update the active turn state; turns sleep prevention on/off as needed.
pub fn set_turn_running(&mut self, turn_running: bool) {
if !self.enabled {
self.release();
return;
}
if turn_running {
self.acquire();
} else {
self.release();
}
}
fn acquire(&mut self) {
self.platform.acquire();
}
fn release(&mut self) {
self.platform.release();
}
}
#[cfg(test)]
mod tests {
use super::SleepInhibitor;
#[test]
fn sleep_inhibitor_toggles_without_panicking() {
let mut inhibitor = SleepInhibitor::new(true);
inhibitor.set_turn_running(true);
inhibitor.set_turn_running(false);
}
#[test]
fn sleep_inhibitor_disabled_does_not_panic() {
let mut inhibitor = SleepInhibitor::new(false);
inhibitor.set_turn_running(true);
inhibitor.set_turn_running(false);
}
#[test]
fn sleep_inhibitor_multiple_true_calls_are_idempotent() {
let mut inhibitor = SleepInhibitor::new(true);
inhibitor.set_turn_running(true);
inhibitor.set_turn_running(true);
inhibitor.set_turn_running(true);
inhibitor.set_turn_running(false);
}
#[test]
fn sleep_inhibitor_can_toggle_multiple_times() {
let mut inhibitor = SleepInhibitor::new(true);
inhibitor.set_turn_running(true);
inhibitor.set_turn_running(false);
inhibitor.set_turn_running(true);
inhibitor.set_turn_running(false);
}
}

View file

@ -0,0 +1,192 @@
use crate::PlatformSleepInhibitor;
use core_foundation::base::TCFType;
use core_foundation::string::CFString;
use core_foundation::string::CFStringRef;
use std::fmt::Debug;
use std::sync::OnceLock;
use tracing::warn;
const ASSERTION_REASON: &str = "Codex is running an active turn";
const MACOS_IDLE_SLEEP_ASSERTION_TYPE: &str = "PreventUserIdleSystemSleep";
const IOKIT_FRAMEWORK_BINARY: &[u8] = b"/System/Library/Frameworks/IOKit.framework/IOKit\0";
const IOPM_ASSERTION_CREATE_WITH_NAME_SYMBOL: &[u8] = b"IOPMAssertionCreateWithName\0";
const IOPM_ASSERTION_RELEASE_SYMBOL: &[u8] = b"IOPMAssertionRelease\0";
const IOKIT_ASSERTION_API_UNAVAILABLE: &str = "IOKit power assertion APIs are unavailable";
type IOPMAssertionReleaseFn = unsafe extern "C" fn(assertion_id: IOPMAssertionID) -> IOReturn;
type IOPMAssertionID = u32;
type IOPMAssertionLevel = u32;
type IOReturn = i32;
const K_IOPM_ASSERTION_LEVEL_ON: IOPMAssertionLevel = 255;
const K_IORETURN_SUCCESS: IOReturn = 0;
#[derive(Debug, Default)]
pub(crate) struct MacOsSleepInhibitor {
assertion: Option<MacSleepAssertion>,
}
impl MacOsSleepInhibitor {
pub(crate) fn new() -> Self {
Self {
..Default::default()
}
}
}
impl PlatformSleepInhibitor for MacOsSleepInhibitor {
fn acquire(&mut self) {
if self.assertion.is_some() {
return;
}
match MacSleepAssertion::create(ASSERTION_REASON) {
Ok(assertion) => {
self.assertion = Some(assertion);
}
Err(error) => match error {
MacSleepAssertionError::ApiUnavailable(reason) => {
warn!(reason, "Failed to create macOS sleep-prevention assertion");
}
MacSleepAssertionError::Iokit(code) => {
warn!(
iokit_error = code,
"Failed to create macOS sleep-prevention assertion"
);
}
},
}
}
fn release(&mut self) {
self.assertion = None;
}
}
#[derive(Debug)]
struct MacSleepAssertion {
id: IOPMAssertionID,
}
impl MacSleepAssertion {
fn create(name: &str) -> Result<Self, MacSleepAssertionError> {
let Some(api) = MacSleepApi::get() else {
return Err(MacSleepAssertionError::ApiUnavailable(
IOKIT_ASSERTION_API_UNAVAILABLE,
));
};
let assertion_type = CFString::new(MACOS_IDLE_SLEEP_ASSERTION_TYPE);
let assertion_name = CFString::new(name);
let mut id: IOPMAssertionID = 0;
let result = unsafe {
(api.create_with_name)(
assertion_type.as_concrete_TypeRef(),
K_IOPM_ASSERTION_LEVEL_ON,
assertion_name.as_concrete_TypeRef(),
&mut id,
)
};
if result == K_IORETURN_SUCCESS {
Ok(Self { id })
} else {
Err(MacSleepAssertionError::Iokit(result))
}
}
}
impl Drop for MacSleepAssertion {
fn drop(&mut self) {
let Some(api) = MacSleepApi::get() else {
warn!(
reason = IOKIT_ASSERTION_API_UNAVAILABLE,
"Failed to release macOS sleep-prevention assertion"
);
return;
};
let result = unsafe { (api.release)(self.id) };
if result != K_IORETURN_SUCCESS {
warn!(
iokit_error = result,
"Failed to release macOS sleep-prevention assertion"
);
}
}
}
#[derive(Debug, Clone, Copy)]
enum MacSleepAssertionError {
ApiUnavailable(&'static str),
Iokit(IOReturn),
}
type IOPMAssertionCreateWithNameFn = unsafe extern "C" fn(
assertion_type: CFStringRef,
assertion_level: IOPMAssertionLevel,
assertion_name: CFStringRef,
assertion_id: *mut IOPMAssertionID,
) -> IOReturn;
struct MacSleepApi {
// Keep the dlopen handle alive for the lifetime of the loaded symbols.
// This prevents accidental dlclose while function pointers are in use.
_iokit_handle: usize,
create_with_name: IOPMAssertionCreateWithNameFn,
release: IOPMAssertionReleaseFn,
}
impl MacSleepApi {
fn get() -> Option<&'static Self> {
static API: OnceLock<Option<MacSleepApi>> = OnceLock::new();
API.get_or_init(Self::load).as_ref()
}
fn load() -> Option<Self> {
let handle = unsafe {
libc::dlopen(
IOKIT_FRAMEWORK_BINARY.as_ptr().cast(),
libc::RTLD_LOCAL | libc::RTLD_LAZY,
)
};
if handle.is_null() {
warn!(framework = "IOKit", "Failed to open IOKit framework");
return None;
}
let create_with_name = unsafe {
libc::dlsym(
handle,
IOPM_ASSERTION_CREATE_WITH_NAME_SYMBOL.as_ptr().cast(),
)
};
if create_with_name.is_null() {
warn!(
symbol = "IOPMAssertionCreateWithName",
"Failed to load IOKit symbol"
);
let _ = unsafe { libc::dlclose(handle) };
return None;
}
let release = unsafe { libc::dlsym(handle, IOPM_ASSERTION_RELEASE_SYMBOL.as_ptr().cast()) };
if release.is_null() {
warn!(
symbol = "IOPMAssertionRelease",
"Failed to load IOKit symbol"
);
let _ = unsafe { libc::dlclose(handle) };
return None;
}
let create_with_name: IOPMAssertionCreateWithNameFn =
unsafe { std::mem::transmute(create_with_name) };
let release: IOPMAssertionReleaseFn = unsafe { std::mem::transmute(release) };
Some(Self {
_iokit_handle: handle as usize,
create_with_name,
release,
})
}
}