diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9d7d9ddc08..7d54062f8e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -614,6 +614,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.6.2" @@ -1086,6 +1095,7 @@ dependencies = [ "keyring", "landlock", "libc", + "mac-notification-sys", "maplit", "mcp-types", "openssl-sys", @@ -3616,6 +3626,18 @@ dependencies = [ "url", ] +[[package]] +name = "mac-notification-sys" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119c8490084af61b44c9eda9d626475847a186737c0378c85e32d77c33a01cd4" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "maplit" version = "1.0.2" @@ -4061,6 +4083,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ "bitflags 2.9.1", + "block2", + "libc", "objc2", "objc2-core-foundation", ] diff --git a/codex-rs/README.md b/codex-rs/README.md index e7dfadb789..84b08920e3 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -46,7 +46,7 @@ Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.t ### Notifications -You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. +On macOS, the CLI now ships with a signed Notification Center integration, so you’ll get trusted desktop alerts (with the Codex icon) as soon as a turn completes—no extra tooling required. If you want to customize the behaviour or are on another platform, you can still point `notify` at your own script; see the [notify documentation](../docs/config.md#notify) for an example. ### `codex exec` to run Codex programmatically/non-interactively diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 921ca2843a..48b56b92ed 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -91,6 +91,7 @@ seccompiler = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" +mac-notification-sys = "0.6.6" # Build OpenSSL from source for musl builds. [target.x86_64-unknown-linux-musl.dependencies] diff --git a/codex-rs/core/assets/codex-notification.png b/codex-rs/core/assets/codex-notification.png new file mode 100644 index 0000000000..089a23410b Binary files /dev/null and b/codex-rs/core/assets/codex-notification.png differ diff --git a/codex-rs/core/src/user_notification.rs b/codex-rs/core/src/user_notification.rs index 7bbd1d9564..9e7be95096 100644 --- a/codex-rs/core/src/user_notification.rs +++ b/codex-rs/core/src/user_notification.rs @@ -2,9 +2,11 @@ use serde::Serialize; use tracing::error; use tracing::warn; -#[derive(Debug, Default)] +#[derive(Debug)] pub(crate) struct UserNotifier { notify_command: Option>, + #[cfg(target_os = "macos")] + native: macos::MacNotifier, } impl UserNotifier { @@ -12,14 +14,19 @@ impl UserNotifier { if let Some(notify_command) = &self.notify_command && !notify_command.is_empty() { - self.invoke_notify(notify_command, notification) + if self.invoke_notify(notify_command, notification) { + return; + } } + + #[cfg(target_os = "macos")] + self.native.notify(notification); } - fn invoke_notify(&self, notify_command: &[String], notification: &UserNotification) { + fn invoke_notify(&self, notify_command: &[String], notification: &UserNotification) -> bool { let Ok(json) = serde_json::to_string(¬ification) else { error!("failed to serialise notification payload"); - return; + return false; }; let mut command = std::process::Command::new(¬ify_command[0]); @@ -29,18 +36,30 @@ impl UserNotifier { command.arg(json); // Fire-and-forget – we do not wait for completion. - if let Err(e) = command.spawn() { - warn!("failed to spawn notifier '{}': {e}", notify_command[0]); + match command.spawn() { + Ok(_) => true, + Err(e) => { + warn!("failed to spawn notifier '{}': {e}", notify_command[0]); + false + } } } pub(crate) fn new(notify: Option>) -> Self { Self { notify_command: notify, + #[cfg(target_os = "macos")] + native: macos::MacNotifier::new(), } } } +impl Default for UserNotifier { + fn default() -> Self { + Self::new(None) + } +} + /// User can configure a program that will receive notifications. Each /// notification is serialized as JSON and passed as an argument to the /// program. @@ -85,3 +104,118 @@ mod tests { Ok(()) } } + +#[cfg(target_os = "macos")] +mod macos { + use super::UserNotification; + use crate::config; + use mac_notification_sys::Notification; + use mac_notification_sys::NotificationResponse; + use std::fs; + use std::path::PathBuf; + use tracing::debug; + use tracing::warn; + + #[derive(Debug)] + pub(crate) struct MacNotifier { + icon_path: Option, + } + + impl MacNotifier { + pub(crate) fn new() -> Self { + if let Err(err) = mac_notification_sys::set_application("com.openai.codex") { + warn!("failed to register bundle id for notifications: {err}"); + } + + let icon_path = Self::ensure_icon() + .map_err(|err| { + warn!("failed to prepare macOS notification icon: {err}"); + }) + .ok(); + + Self { icon_path } + } + + pub(crate) fn notify(&self, notification: &UserNotification) { + if std::env::var("CODEX_SANDBOX").is_ok() { + // Avoid firing real notifications when running inside our sandboxed Seatbelt harness. + return; + } + + if std::env::var("CI").is_ok() { + // Skip macOS notifications when running in CI environments. + return; + } + + let (title, subtitle, message) = match notification { + UserNotification::AgentTurnComplete { + last_assistant_message, + input_messages, + .. + } => { + let title = "Codex CLI"; + let subtitle = last_assistant_message + .as_ref() + .map(std::string::String::as_str) + .unwrap_or("Turn complete"); + let message = if input_messages.is_empty() { + String::from("Agent turn finished") + } else { + input_messages.join(" ") + }; + (title.to_string(), subtitle.to_string(), message) + } + }; + + let mut payload = Notification::new(); + payload.title(&title); + payload.maybe_subtitle(Some(&subtitle)); + payload.message(&message); + payload.default_sound(); + + if let Some(icon_path) = self.icon_path.as_ref().and_then(|p| p.to_str()) { + payload.app_icon(icon_path); + } + + match payload.send() { + Ok(NotificationResponse::ActionButton(action)) => { + debug!("Codex notification action pressed: {action}"); + } + Ok(NotificationResponse::CloseButton(label)) => { + debug!("Codex notification dismissed via '{label}' button"); + } + Ok(NotificationResponse::Reply(body)) => { + debug!("Codex notification reply entered (ignored): {body}"); + } + Ok(NotificationResponse::Click) => { + debug!("Codex notification clicked"); + } + Ok(NotificationResponse::None) => {} + Err(err) => warn!("failed to deliver macOS notification: {err}"), + } + } + + fn ensure_icon() -> anyhow::Result { + const ICON_BYTES: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/codex-notification.png" + )); + + let mut path = config::find_codex_home()?; + path.push("assets"); + fs::create_dir_all(&path)?; + path.push("codex-notification.png"); + + let needs_write = match fs::read(&path) { + Ok(existing) => existing != ICON_BYTES, + Err(_) => true, + }; + + if needs_write { + fs::write(&path, ICON_BYTES)?; + } + + Ok(path) + } + } +} diff --git a/docs/config.md b/docs/config.md index 0d81a64efc..8e3fd3df16 100644 --- a/docs/config.md +++ b/docs/config.md @@ -615,6 +615,8 @@ function without the extra dependencies. ### notify +On macOS, Codex now emits native Notification Center alerts by default for supported events. The CLI packages a signed notification helper and a Codex icon, so you’ll see trusted banners without needing to install `terminal-notifier`. If you prefer to take full control, or are on another platform, you can still wire up your own script via the `notify` setting described below. Providing a `notify` command overrides the built-in macOS integration. + Specify a program that will be executed to get notified about events generated by Codex. Note that the program will receive the notification argument as a string of JSON, e.g.: ```json