Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions crates/kild-core/src/notify/backends/linux.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Linux notification backend using notify-send (libnotify).

use crate::notify::errors::NotifyError;
use crate::notify::traits::NotificationBackend;

/// Linux notification backend via `notify-send` (libnotify).
pub struct LinuxNotificationBackend;

impl NotificationBackend for LinuxNotificationBackend {
fn name(&self) -> &'static str {
"linux"
}

fn is_available(&self) -> bool {
cfg!(target_os = "linux") && which::which("notify-send").is_ok()
}

fn send(&self, title: &str, message: &str) -> Result<(), NotifyError> {
let output = std::process::Command::new("notify-send")
.arg(title)
.arg(message)
.output()
.map_err(|e| NotifyError::SendFailed {
message: format!("notify-send exec failed: {}", e),
})?;

if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(NotifyError::SendFailed {
message: format!("notify-send exit {}: {}", output.status, stderr.trim()),
})
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn linux_backend_name() {
let backend = LinuxNotificationBackend;
assert_eq!(backend.name(), "linux");
}

#[test]
fn linux_backend_availability_matches_platform() {
let backend = LinuxNotificationBackend;
if !cfg!(target_os = "linux") {
assert!(!backend.is_available());
}
}
}
61 changes: 61 additions & 0 deletions crates/kild-core/src/notify/backends/macos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! macOS notification backend using osascript (Notification Center).

use crate::escape::applescript_escape;
use crate::notify::errors::NotifyError;
use crate::notify::traits::NotificationBackend;

/// macOS notification backend via `osascript` (Notification Center).
pub struct MacOsNotificationBackend;

impl NotificationBackend for MacOsNotificationBackend {
fn name(&self) -> &'static str {
"macos"
}

fn is_available(&self) -> bool {
cfg!(target_os = "macos")
}

fn send(&self, title: &str, message: &str) -> Result<(), NotifyError> {
let escaped_title = applescript_escape(title);
let escaped_message = applescript_escape(message);
let script = format!(
r#"display notification "{}" with title "{}""#,
escaped_message, escaped_title
);

let output = std::process::Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
.map_err(|e| NotifyError::SendFailed {
message: format!("osascript exec failed: {}", e),
})?;

if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(NotifyError::SendFailed {
message: format!("osascript exit {}: {}", output.status, stderr.trim()),
})
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn macos_backend_name() {
let backend = MacOsNotificationBackend;
assert_eq!(backend.name(), "macos");
}

#[test]
fn macos_backend_availability_matches_platform() {
let backend = MacOsNotificationBackend;
assert_eq!(backend.is_available(), cfg!(target_os = "macos"));
}
}
7 changes: 7 additions & 0 deletions crates/kild-core/src/notify/backends/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! Notification backend implementations.

mod linux;
mod macos;

pub use linux::LinuxNotificationBackend;
pub use macos::MacOsNotificationBackend;
56 changes: 56 additions & 0 deletions crates/kild-core/src/notify/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//! Notification error types.

use crate::errors::KildError;

#[derive(Debug, thiserror::Error)]
pub enum NotifyError {
#[error("Notification tool not found: {tool}")]
ToolNotFound { tool: String },

#[error("Notification failed: {message}")]
SendFailed { message: String },
}

impl KildError for NotifyError {
fn error_code(&self) -> &'static str {
match self {
NotifyError::ToolNotFound { .. } => "NOTIFY_TOOL_NOT_FOUND",
NotifyError::SendFailed { .. } => "NOTIFY_SEND_FAILED",
}
}

fn is_user_error(&self) -> bool {
matches!(self, NotifyError::ToolNotFound { .. })
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_tool_not_found() {
let error = NotifyError::ToolNotFound {
tool: "notify-send".to_string(),
};
assert_eq!(
error.to_string(),
"Notification tool not found: notify-send"
);
assert_eq!(error.error_code(), "NOTIFY_TOOL_NOT_FOUND");
assert!(error.is_user_error());
}

#[test]
fn test_send_failed() {
let error = NotifyError::SendFailed {
message: "osascript exited with code 1".to_string(),
};
assert_eq!(
error.to_string(),
"Notification failed: osascript exited with code 1"
);
assert_eq!(error.error_code(), "NOTIFY_SEND_FAILED");
assert!(!error.is_user_error());
}
}
109 changes: 20 additions & 89 deletions crates/kild-core/src/notify/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@
//! Best-effort notifications — failures are logged but never propagate.
//! Used by `kild agent-status --notify` to alert when an agent enters
//! `Waiting` or `Error` status.
//!
//! Notifications are dispatched via the [`NotificationBackend`] trait,
//! with platform-specific backends registered in [`registry`].

pub mod backends;
pub mod errors;
pub mod registry;
pub mod traits;

pub use errors::NotifyError;
pub use traits::NotificationBackend;

use kild_protocol::AgentStatus;
use tracing::{info, warn};

#[cfg(not(target_os = "macos"))]
use tracing::debug;

/// Returns `true` if a notification should be sent for the given status.
///
/// Only `Waiting` and `Error` require user attention.
Expand All @@ -18,16 +26,17 @@ pub fn should_notify(notify: bool, status: AgentStatus) -> bool {
}

/// Format the notification message for an agent status change.
///
/// The message body always reads "needs input" regardless of status.
/// This covers both `Waiting` (literal input required) and `Error`
/// (user must inspect and unblock the agent).
pub fn format_notification_message(agent: &str, branch: &str, status: AgentStatus) -> String {
format!("Agent {} in {} needs input ({})", agent, branch, status)
}

/// Send a platform-native desktop notification (best-effort).
///
/// - macOS: `osascript` (Notification Center)
/// - Linux: `notify-send` (requires libnotify)
/// - Other: no-op
///
/// Dispatches to the first available [`NotificationBackend`] via the registry.
/// Failures are logged at warn level but never returned as errors.
pub fn send_notification(title: &str, message: &str) {
info!(
Expand All @@ -36,82 +45,12 @@ pub fn send_notification(title: &str, message: &str) {
message = message,
);

send_platform_notification(title, message);
}

#[cfg(target_os = "macos")]
fn send_platform_notification(title: &str, message: &str) {
use crate::escape::applescript_escape;

let escaped_title = applescript_escape(title);
let escaped_message = applescript_escape(message);
let script = format!(
r#"display notification "{}" with title "{}""#,
escaped_message, escaped_title
);

match std::process::Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
{
Ok(output) if output.status.success() => {
info!(event = "core.notify.send_completed", title = title);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
event = "core.notify.send_failed",
title = title,
stderr = %stderr,
);
}
Err(e) => {
warn!(
event = "core.notify.send_failed",
title = title,
error = %e,
);
}
}
}

#[cfg(target_os = "linux")]
fn send_platform_notification(title: &str, message: &str) {
match which::which("notify-send") {
Ok(_) => {}
Err(which::Error::CannotFindBinaryPath) => {
debug!(
event = "core.notify.send_skipped",
reason = "notify-send not found",
);
return;
}
Err(e) => {
warn!(
event = "core.notify.send_failed",
title = title,
error = %e,
);
return;
}
}

match std::process::Command::new("notify-send")
.arg(title)
.arg(message)
.output()
{
Ok(output) if output.status.success() => {
match registry::send_via_backend(title, message) {
Ok(true) => {
info!(event = "core.notify.send_completed", title = title);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
event = "core.notify.send_failed",
title = title,
stderr = %stderr,
);
Ok(false) => {
// No backend available — already logged at debug in registry
}
Err(e) => {
warn!(
Expand All @@ -123,14 +62,6 @@ fn send_platform_notification(title: &str, message: &str) {
}
}

#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn send_platform_notification(_title: &str, _message: &str) {
debug!(
event = "core.notify.send_skipped",
reason = "unsupported platform",
);
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading