Compare commits

...

2 Commits

Author SHA1 Message Date
Javier Soto
11028280dc WIP 2025-11-03 11:33:17 -08:00
Javi
84a3cddc76 WIP 2025-10-29 14:24:08 -07:00
17 changed files with 875 additions and 31 deletions

View File

@@ -55,6 +55,18 @@
"path": "codex-responses-api-proxy.exe"
}
}
},
"codex-notifier": {
"platforms": {
"macos-aarch64": {
"regex": "^codex-notifier-aarch64-apple-darwin\\.zst$",
"path": "codex-notifier"
},
"macos-x86_64": {
"regex": "^codex-notifier-x86_64-apple-darwin\\.zst$",
"path": "codex-notifier"
}
}
}
}
}

View File

@@ -100,6 +100,10 @@ jobs:
- name: Cargo build
run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy
- if: ${{ matrix.runner == 'macos-15-xlarge' }}
name: Build notifier helper
run: cargo build --target ${{ matrix.target }} --release -p codex-notifier
- if: ${{ matrix.runner == 'macos-15-xlarge' }}
name: Configure Apple code signing
shell: bash
@@ -201,7 +205,12 @@ jobs:
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
fi
for binary in codex codex-responses-api-proxy; do
binaries=(codex codex-responses-api-proxy)
if [[ -f "target/${{ matrix.target }}/release/codex-notifier" ]]; then
binaries+=(codex-notifier)
fi
for binary in "${binaries[@]}"; do
path="target/${{ matrix.target }}/release/${binary}"
codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path"
done
@@ -268,6 +277,9 @@ jobs:
notarize_binary "codex"
notarize_binary "codex-responses-api-proxy"
if [[ -f "target/${{ matrix.target }}/release/codex-notifier" ]]; then
notarize_binary "codex-notifier"
fi
- name: Stage artifacts
shell: bash
@@ -281,6 +293,9 @@ jobs:
else
cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}"
cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}"
if [[ -f target/${{ matrix.target }}/release/codex-notifier ]]; then
cp target/${{ matrix.target }}/release/codex-notifier "$dest/codex-notifier-${{ matrix.target }}"
fi
fi
- if: ${{ matrix.runner == 'windows-11-arm' }}

View File

@@ -16,13 +16,14 @@ RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" /
CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript"
PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = {
"codex": ["codex", "rg"],
"codex": ["codex", "codex-notifier", "rg"],
"codex-responses-api-proxy": ["codex-responses-api-proxy"],
"codex-sdk": ["codex"],
}
COMPONENT_DEST_DIR: dict[str, str] = {
"codex": "codex",
"codex-responses-api-proxy": "codex-responses-api-proxy",
"codex-notifier": "codex-notifier",
"rg": "path",
}

View File

@@ -34,8 +34,11 @@ BINARY_TARGETS = (
@dataclass(frozen=True)
class BinaryComponent:
artifact_prefix: str # matches the artifact filename prefix (e.g. codex-<target>.zst)
dest_dir: str # directory under vendor/<target>/ where the binary is installed
binary_basename: str # executable name inside dest_dir (before optional .exe)
dest_dir: str # directory under vendor/<target>/ where the binary/artifact is installed
binary_basename: str # executable or directory name inside dest_dir (before optional .exe)
archive_format: str = "zst" # one of "zst", "zip", "tar.gz"
archive_member: str | None = None # optional archive member to extract
supported_targets: tuple[str, ...] | None = None # restrict installation to these targets
BINARY_COMPONENTS = {
@@ -49,6 +52,15 @@ BINARY_COMPONENTS = {
dest_dir="codex-responses-api-proxy",
binary_basename="codex-responses-api-proxy",
),
"codex-notifier": BinaryComponent(
artifact_prefix="codex-notifier",
dest_dir="codex-notifier",
binary_basename="codex-notifier",
supported_targets=(
"x86_64-apple-darwin",
"aarch64-apple-darwin",
),
),
}
RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
@@ -101,7 +113,7 @@ def main() -> int:
vendor_dir = codex_cli_root / VENDOR_DIR_NAME
vendor_dir.mkdir(parents=True, exist_ok=True)
components = args.components or ["codex", "rg"]
components = args.components or ["codex", "codex-notifier", "rg"]
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
if not workflow_url:
@@ -218,11 +230,19 @@ def install_binary_components(
return
for component in selected_components:
component_targets = [
target
for target in targets
if not component.supported_targets or target in component.supported_targets
]
if not component_targets:
continue
print(
f"Installing {component.binary_basename} binaries for targets: "
+ ", ".join(targets)
+ ", ".join(component_targets)
)
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
@@ -232,7 +252,7 @@ def install_binary_components(
target,
component,
): target
for target in targets
for target in component_targets
}
for future in as_completed(futures):
installed_path = future.result()
@@ -246,7 +266,7 @@ def _install_single_binary(
component: BinaryComponent,
) -> Path:
artifact_subdir = artifacts_dir / target
archive_name = _archive_name_for_target(component.artifact_prefix, target)
archive_name = _archive_name_for_target(component, target)
archive_path = artifact_subdir / archive_name
if not archive_path.exists():
raise FileNotFoundError(f"Expected artifact not found: {archive_path}")
@@ -255,20 +275,29 @@ def _install_single_binary(
dest_dir.mkdir(parents=True, exist_ok=True)
binary_name = (
f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename
f"{component.binary_basename}.exe"
if "windows" in target and component.archive_format == "zst"
else component.binary_basename
)
dest = dest_dir / binary_name
dest.unlink(missing_ok=True)
extract_archive(archive_path, "zst", None, dest)
if "windows" not in target:
extract_archive(
archive_path, component.archive_format, component.archive_member, dest
)
if "windows" not in target and dest.is_file():
dest.chmod(0o755)
return dest
def _archive_name_for_target(artifact_prefix: str, target: str) -> str:
def _archive_name_for_target(component: BinaryComponent, target: str) -> str:
fmt = component.archive_format
if fmt == "zip":
return f"{component.artifact_prefix}-{target}.zip"
if fmt == "tar.gz":
return f"{component.artifact_prefix}-{target}.tar.gz"
if "windows" in target:
return f"{artifact_prefix}-{target}.exe.zst"
return f"{artifact_prefix}-{target}.zst"
return f"{component.artifact_prefix}-{target}.exe.zst"
return f"{component.artifact_prefix}-{target}.zst"
def _fetch_single_rg(
@@ -346,16 +375,25 @@ def extract_archive(
return
if archive_format == "zip":
if not archive_member:
raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.")
with zipfile.ZipFile(archive_path) as archive:
try:
with archive.open(archive_member) as src, open(dest, "wb") as out:
shutil.copyfileobj(src, out)
except KeyError as exc:
raise RuntimeError(
f"Entry '{archive_member}' not found in archive {archive_path}."
) from exc
if archive_member:
try:
with archive.open(archive_member) as src, open(dest, "wb") as out:
shutil.copyfileobj(src, out)
except KeyError as exc:
raise RuntimeError(
f"Entry '{archive_member}' not found in archive {archive_path}."
) from exc
else:
with tempfile.TemporaryDirectory() as tmp_dir_str:
tmp_dir = Path(tmp_dir_str)
archive.extractall(tmp_dir)
entries = [p for p in tmp_dir.iterdir() if not p.name.startswith(".__")]
if len(entries) != 1:
raise RuntimeError(
f"Expected a single top-level entry in {archive_path}, found {len(entries)}"
)
shutil.move(str(entries[0]), dest)
return
raise RuntimeError(f"Unsupported archive format '{archive_format}'.")

34
codex-rs/Cargo.lock generated
View File

@@ -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",
@@ -1289,6 +1299,16 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-notifier"
version = "0.0.0"
dependencies = [
"anyhow",
"cc",
"serde",
"serde_json",
]
[[package]]
name = "codex-ollama"
version = "0.0.0"
@@ -3616,6 +3636,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 +4093,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
dependencies = [
"bitflags 2.9.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]

View File

@@ -12,6 +12,7 @@ members = [
"cloud-tasks",
"cloud-tasks-client",
"cli",
"notifier",
"common",
"core",
"exec",

View File

@@ -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 installs a signed helper app (`~/.codex/bin/CodexNotifier.app`) that delivers Notification Center alerts with the Codex icon, so youll get trusted banners as soon as a turn completes—no extra tooling required. Set `CODEX_NOTIFIER_APP` if you need to point at a custom build. If you want to customise 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

View File

@@ -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]

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -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<Vec<String>>,
#[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(&notification) else {
error!("failed to serialise notification payload");
return;
return false;
};
let mut command = std::process::Command::new(&notify_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<Vec<String>>) -> 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,345 @@ mod tests {
Ok(())
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::UserNotification;
use crate::config;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use mac_notification_sys::Notification;
use mac_notification_sys::NotificationResponse;
use serde_json;
use std::env;
use std::fs;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use tempfile::NamedTempFile;
use tracing::debug;
use tracing::warn;
const HELPER_ENV: &str = "CODEX_NOTIFIER_APP";
const HELPER_BUNDLE_NAME: &str = "CodexNotifier.app";
const HELPER_EXECUTABLE: &str = "Contents/MacOS/codex-notifier";
const INFO_PLIST_TEMPLATE: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../notifier/Resources/Info.plist"
));
const ICON_BYTES: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../notifier/Resources/Codex.icns"
));
#[derive(Debug)]
pub(crate) struct MacNotifier {
helper: Option<HelperApp>,
legacy: LegacyNotifier,
}
impl MacNotifier {
pub(crate) fn new() -> Self {
let helper = match HelperApp::discover() {
Ok(app) => {
debug!(
"using Codex helper notifier at {}",
app.bundle_path.display()
);
Some(app)
}
Err(err) => {
debug!("no helper notifier available: {err:?}");
None
}
};
Self {
helper,
legacy: LegacyNotifier::new(),
}
}
pub(crate) fn notify(&self, notification: &UserNotification) {
if env::var("CODEX_SANDBOX").is_ok() {
return;
}
if env::var("CI").is_ok() {
return;
}
if let Some(helper) = &self.helper {
if let Err(err) = helper.notify(notification) {
warn!("failed to send macOS notification via helper: {err:?}");
} else {
return;
}
}
self.legacy.notify(notification);
}
}
#[derive(Debug)]
struct HelperApp {
bundle_path: PathBuf,
}
impl HelperApp {
fn discover() -> Result<Self> {
if let Ok(custom) = env::var(HELPER_ENV) {
let candidate = PathBuf::from(custom);
if Self::is_valid_bundle(&candidate) {
return Ok(Self {
bundle_path: candidate,
});
}
}
for path in Self::candidate_paths()? {
if Self::is_valid_bundle(&path) {
return Ok(Self { bundle_path: path });
}
}
if let Ok(installed) = Self::install_to_codex_home() {
if Self::is_valid_bundle(&installed) {
return Ok(Self {
bundle_path: installed,
});
}
}
Err(anyhow!("helper bundle not found"))
}
fn candidate_paths() -> Result<Vec<PathBuf>> {
let mut candidates = Vec::new();
if let Ok(home) = config::find_codex_home() {
candidates.push(home.join(HELPER_BUNDLE_NAME));
candidates.push(home.join("bin").join(HELPER_BUNDLE_NAME));
}
if let Ok(exe) = env::current_exe() {
if let Some(parent) = exe.parent() {
candidates.push(parent.join(HELPER_BUNDLE_NAME));
if let Some(grand) = parent.parent() {
candidates.push(grand.join(HELPER_BUNDLE_NAME));
}
}
}
Ok(candidates)
}
fn install_to_codex_home() -> Result<PathBuf> {
let mut base = config::find_codex_home()?;
base.push("bin");
fs::create_dir_all(&base)?;
let bundle_path = base.join(HELPER_BUNDLE_NAME);
if !Self::is_valid_bundle(&bundle_path) {
Self::write_bundle(&bundle_path)?;
}
Ok(bundle_path)
}
fn write_bundle(destination: &Path) -> Result<()> {
let contents = destination.join("Contents");
let macos_dir = contents.join("MacOS");
let resources_dir = contents.join("Resources");
fs::create_dir_all(&macos_dir)?;
fs::create_dir_all(&resources_dir)?;
fs::write(contents.join("Info.plist"), INFO_PLIST_TEMPLATE)
.context("failed to write Info.plist")?;
fs::write(resources_dir.join("Codex.icns"), ICON_BYTES)
.context("failed to write Codex.icns")?;
let binary_src = Self::locate_binary().context("codex-notifier binary not found")?;
let binary_dest = macos_dir.join("codex-notifier");
fs::copy(&binary_src, &binary_dest)
.with_context(|| format!("failed to copy notifier binary from {binary_src:?}"))?;
let mut perms = fs::metadata(&binary_dest)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&binary_dest, perms)?;
Ok(())
}
fn locate_binary() -> Result<PathBuf> {
for candidate in Self::binary_candidate_paths()? {
if candidate.is_file() {
return Ok(candidate);
}
if candidate.is_dir() {
let nested = candidate.join("codex-notifier");
if nested.is_file() {
return Ok(nested);
}
}
}
Err(anyhow!("codex-notifier binary unavailable"))
}
fn binary_candidate_paths() -> Result<Vec<PathBuf>> {
let mut paths = Vec::new();
if let Ok(exe) = env::current_exe() {
if let Some(parent) = exe.parent() {
paths.push(parent.join("codex-notifier"));
paths.push(parent.join("../codex-notifier"));
paths.push(parent.join("../../codex-notifier"));
if let Some(grand) = parent.parent() {
paths.push(grand.join("codex-notifier"));
paths.push(grand.join("release").join("codex-notifier"));
}
}
}
if let Ok(home) = config::find_codex_home() {
paths.push(home.join("bin").join("codex-notifier"));
}
Ok(paths)
}
fn is_valid_bundle(path: &Path) -> bool {
let exec = path.join(HELPER_EXECUTABLE);
exec.is_file()
}
fn notify(&self, notification: &UserNotification) -> Result<()> {
let payload = serde_json::to_vec(notification)?;
let mut temp = NamedTempFile::new().context("failed to create temp payload file")?;
temp.as_file_mut()
.write_all(&payload)
.context("failed to write payload")?;
temp.flush().ok();
let temp_path = temp.into_temp_path();
let payload_path = temp_path
.keep()
.context("failed to persist payload file for helper app")?;
let status = Command::new("open")
.arg("-n")
.arg(&self.bundle_path)
.arg("--args")
.arg("--payload-file")
.arg(&payload_path)
.status()
.with_context(|| {
format!("failed to invoke open for {}", self.bundle_path.display())
})?;
if !status.success() {
let _ = fs::remove_file(&payload_path);
return Err(anyhow!("open exited with status {status:?}"));
}
Ok(())
}
}
#[derive(Debug)]
struct LegacyNotifier {
icon_path: Option<PathBuf>,
}
impl LegacyNotifier {
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 }
}
fn notify(&self, notification: &UserNotification) {
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 via legacy path: {err}"),
}
}
fn ensure_icon() -> anyhow::Result<PathBuf> {
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)?;
let mut perms = fs::metadata(&path)?.permissions();
perms.set_mode(0o644);
fs::set_permissions(&path, perms)?;
}
Ok(path)
}
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "codex-notifier"
version = { workspace = true }
edition = "2024"
[dependencies]
anyhow = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[build-dependencies]
cc = "1.0"

Binary file not shown.

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>codex-notifier</string>
<key>CFBundleIdentifier</key>
<string>com.openai.codex.notifier</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Codex Notifier</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>CFBundleIconFile</key>
<string>Codex</string>
<key>NSUserNotificationUsageDescription</key>
<string>Codex CLI uses notifications to tell you when a turn completes.</string>
</dict>
</plist>

View File

@@ -0,0 +1,21 @@
fn main() {
#[cfg(target_os = "macos")]
{
use std::path::PathBuf;
let source = PathBuf::from("src/macos/notification.mm");
println!("cargo:rerun-if-changed={}", source.display());
let mut build = cc::Build::new();
build
.cpp(true)
.flag("-fobjc-arc")
.flag("-fmodules")
.file(&source);
build.compile("codex_macos_notification");
println!("cargo:rustc-link-lib=framework=UserNotifications");
println!("cargo:rustc-link-lib=framework=Foundation");
println!("cargo:rustc-link-lib=framework=AppKit");
}
}

View File

@@ -0,0 +1,153 @@
#include <AppKit/AppKit.h>
#include <UserNotifications/UserNotifications.h>
#include <dispatch/dispatch.h>
#include <stdio.h>
#include <stdlib.h>
@interface CodexNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
@end
@implementation CodexNotificationDelegate
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler {
completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound);
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))completionHandler {
completionHandler();
}
@end
static void install_delegate(UNUserNotificationCenter *center) {
static dispatch_once_t onceToken;
static CodexNotificationDelegate *delegate = nil;
dispatch_once(&onceToken, ^{
delegate = [CodexNotificationDelegate new];
center.delegate = delegate;
});
}
static BOOL ensure_authorization(UNUserNotificationCenter *center) {
dispatch_semaphore_t settings_wait = dispatch_semaphore_create(0);
__block UNAuthorizationStatus status = UNAuthorizationStatusNotDetermined;
[center
getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {
status = settings.authorizationStatus;
dispatch_semaphore_signal(settings_wait);
}];
dispatch_semaphore_wait(settings_wait, DISPATCH_TIME_FOREVER);
if (status == UNAuthorizationStatusDenied) {
fprintf(stderr, "[codex-notifier] notification authorization currently denied\n");
return NO;
}
if (status == UNAuthorizationStatusAuthorized ||
status == UNAuthorizationStatusProvisional) {
return YES;
}
dispatch_semaphore_t request_wait = dispatch_semaphore_create(0);
__block BOOL granted = NO;
[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound |
UNAuthorizationOptionBadge)
completionHandler:^(BOOL success, NSError *error) {
granted = success;
if (error) {
fprintf(stderr, "[codex-notifier] authorization request error: %s\n",
error.localizedDescription.UTF8String);
}
dispatch_semaphore_signal(request_wait);
}];
dispatch_semaphore_wait(request_wait, DISPATCH_TIME_FOREVER);
return granted;
}
extern "C" int codex_post_user_notification(const char *title_c,
const char *subtitle_c,
const char *body_c,
const char *icon_path_c) {
setenv("__CFBundleIdentifier", "com.openai.codex.notifier", 0);
@autoreleasepool {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
if (!center) {
fprintf(stderr, "[codex-notifier] UNUserNotificationCenter unavailable\n");
return -10;
}
install_delegate(center);
if (!ensure_authorization(center)) {
return -11;
}
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
if (!content) {
fprintf(stderr, "[codex-notifier] failed to allocate notification content\n");
return -12;
}
if (title_c && title_c[0] != '\0') {
content.title = [NSString stringWithUTF8String:title_c];
} else {
content.title = @"Codex CLI";
}
if (subtitle_c && subtitle_c[0] != '\0') {
content.subtitle = [NSString stringWithUTF8String:subtitle_c];
}
if (body_c && body_c[0] != '\0') {
content.body = [NSString stringWithUTF8String:body_c];
}
if (icon_path_c && icon_path_c[0] != '\0') {
NSError *attachmentError = nil;
NSString *path = [NSString stringWithUTF8String:icon_path_c];
UNNotificationAttachment *attachment =
[UNNotificationAttachment attachmentWithIdentifier:@"codex-icon"
URL:[NSURL fileURLWithPath:path]
options:nil
error:&attachmentError];
if (attachment) {
content.attachments = @[attachment];
} else if (attachmentError) {
fprintf(stderr, "[codex-notifier] attachment error: %s\n",
attachmentError.localizedDescription.UTF8String);
}
}
UNTimeIntervalNotificationTrigger *trigger =
[UNTimeIntervalNotificationTrigger triggerWithTimeInterval:0.1 repeats:NO];
NSString *identifier =
[NSString stringWithFormat:@"com.openai.codex.notifier.%@", [[NSUUID UUID] UUIDString]];
UNNotificationRequest *request =
[UNNotificationRequest requestWithIdentifier:identifier content:content trigger:trigger];
dispatch_semaphore_t submit_wait = dispatch_semaphore_create(0);
__block NSError *submit_error = nil;
[center addNotificationRequest:request
withCompletionHandler:^(NSError *error) {
submit_error = error;
dispatch_semaphore_signal(submit_wait);
}];
dispatch_semaphore_wait(submit_wait, DISPATCH_TIME_FOREVER);
if (submit_error) {
fprintf(stderr, "[codex-notifier] addNotificationRequest error: %s\n",
submit_error.localizedDescription.UTF8String);
return -13;
}
return 0;
}
}

View File

@@ -0,0 +1,161 @@
#![cfg_attr(not(target_os = "macos"), allow(unused))]
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use serde::Deserialize;
use std::env;
use std::ffi::CString;
use std::fs;
use std::io::Read;
use std::io::{self};
use std::os::raw::c_char;
use std::path::Path;
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum NotificationPayload {
#[serde(rename_all = "kebab-case")]
AgentTurnComplete {
thread_id: String,
turn_id: String,
cwd: String,
#[serde(default)]
input_messages: Vec<String>,
#[serde(default)]
last_assistant_message: Option<String>,
},
}
#[cfg(target_os = "macos")]
unsafe extern "C" {
fn codex_post_user_notification(
title: *const c_char,
subtitle: *const c_char,
body: *const c_char,
icon_path: *const c_char,
) -> i32;
}
fn main() -> Result<()> {
real_main()
}
#[cfg(target_os = "macos")]
fn real_main() -> Result<()> {
let payload = read_payload()?;
let (title, subtitle, body) = render_notification(&payload)?;
dispatch_notification(&title, subtitle.as_deref(), &body)?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn real_main() -> Result<()> {
bail!("codex-notifier is only supported on macOS");
}
#[cfg(target_os = "macos")]
fn read_payload() -> Result<NotificationPayload> {
let mut args = env::args().skip(1);
let mut payload_json: Option<String> = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"--payload" => {
let Some(value) = args.next() else {
bail!("missing value for --payload");
};
payload_json = Some(value);
break;
}
"--payload-file" => {
let Some(path) = args.next() else {
bail!("missing value for --payload-file");
};
payload_json = Some(
fs::read_to_string(Path::new(&path))
.with_context(|| format!("failed to read payload file at {path}"))?,
);
// Best-effort cleanup to avoid cluttering temp directories.
let _ = fs::remove_file(path);
break;
}
// Ignore arguments injected by `open -a`.
arg if arg.starts_with("-Apple") || arg == "-NSDocumentRevisionsDebugMode" => {
let _ = args.next(); // consume companion value if present
}
_ => {
// Unrecognised argument continue scanning in case the payload flag appears later.
}
}
}
let json = if let Some(payload) = payload_json {
payload
} else {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.context("failed to read payload from stdin")?;
buf
};
serde_json::from_str(&json).context("failed to parse notification payload JSON")
}
#[cfg(target_os = "macos")]
fn render_notification(payload: &NotificationPayload) -> Result<(String, Option<String>, String)> {
match payload {
NotificationPayload::AgentTurnComplete {
input_messages,
last_assistant_message,
cwd,
..
} => {
let title = "Codex CLI".to_string();
let subtitle = last_assistant_message
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| Some("Turn complete".to_string()));
let mut body = String::new();
if !input_messages.is_empty() {
body.push_str(&input_messages.join(" "));
}
if body.is_empty() {
body.push_str("Agent turn finished");
}
body.push_str("\n");
body.push_str(cwd);
Ok((title, subtitle, body))
}
}
}
#[cfg(target_os = "macos")]
fn dispatch_notification(title: &str, subtitle: Option<&str>, body: &str) -> Result<()> {
let title_c = CString::new(title)?;
let subtitle_c = subtitle
.map(|s| CString::new(s))
.transpose()
.context("invalid subtitle string")?;
let body_c = CString::new(body)?;
let icon_ptr = std::ptr::null();
let code = unsafe {
codex_post_user_notification(
title_c.as_ptr(),
subtitle_c.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()),
body_c.as_ptr(),
icon_ptr,
)
};
if code != 0 {
bail!("codex_post_user_notification returned error code {code}");
}
Ok(())
}

View File

@@ -615,6 +615,10 @@ 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 youll 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.
The macOS build installs a background agent bundle at `~/.codex/bin/CodexNotifier.app`. Codex writes the notification payload to a temporary JSON file and launches the agent via `open -n ... --args --payload-file`. The agent removes the file after reading it. Set `CODEX_NOTIFIER_APP` to point at a different bundle if you maintain your own build.
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