use std::io::{self, Read};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::thread;
use crate::config::{Config, NtfyConfig};
use crate::models::{AttentionState, Provider, Session};
pub fn alert(session: &Session, cfg: &Config, phone_push: bool) {
let title = match session.state {
AttentionState::Blocked => "needs your input",
AttentionState::Error => "error",
_ => return,
};
let label = session_label(session);
let preview = session_preview(session);
if phone_push && let Some(ntfy) = cfg.ntfy.as_ref() {
ntfy_push(ntfy, &label, title);
}
if let Some(notifier) = triage_notify_path() {
send_via_triage_notify(
notifier,
title,
&label,
&preview,
session.pane.as_ref(),
cfg,
);
return;
}
send_via_osascript(title, &label, &preview);
}
pub fn notify_session_done(session: &Session, cfg: &Config, phone_push: bool) {
let title = "finished";
let label = session_label(session);
let preview = session_preview(session);
if phone_push && let Some(ntfy) = cfg.ntfy.as_ref() {
ntfy_push(ntfy, &label, title);
}
if let Some(notifier) = triage_notify_path() {
send_via_triage_notify(
notifier,
title,
&label,
&preview,
session.pane.as_ref(),
cfg,
);
return;
}
send_via_osascript(title, &label, &preview);
}
pub fn push_to_phone(session: &Session, cfg: &Config) {
let title = match session.state {
AttentionState::Blocked => "needs your input",
AttentionState::Error => "error",
_ => return,
};
let label = session_label(session);
if let Some(ntfy) = cfg.ntfy.as_ref() {
ntfy_push(ntfy, &label, title);
}
}
fn session_label(session: &Session) -> String {
let label = session
.name
.clone()
.or_else(|| {
session
.cwd
.file_name()
.map(|n| n.to_string_lossy().into_owned())
})
.unwrap_or_else(|| "session".to_string());
match session.provider {
Provider::Claude => label,
Provider::Codex => format!("cx {label}"),
}
}
fn session_preview(session: &Session) -> String {
session
.headline
.as_deref()
.or(session.last_prompt.as_deref())
.map(|s| s.replace('\n', " "))
.map(|s| s.chars().take(140).collect::<String>())
.unwrap_or_default()
}
const NOTIFY_USAGE: &str = "usage: triage notify [--title T] [--tags T] [--desktop-only | --phone-only] <message...>\n\
\x20 default: both a macOS desktop banner and an ntfy phone push (phone skipped if [ntfy] unconfigured)";
pub fn cli_notify(args: &[String]) -> io::Result<()> {
let mut title: Option<String> = None;
let mut tags: Option<String> = None;
let mut desktop_only = false;
let mut phone_only = false;
let mut positional: Vec<String> = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--help" | "-h" => {
println!("{NOTIFY_USAGE}");
return Ok(());
}
"--desktop-only" => {
desktop_only = true;
i += 1;
}
"--phone-only" => {
phone_only = true;
i += 1;
}
"--title" => {
title = Some(
args.get(i + 1)
.cloned()
.ok_or_else(|| io::Error::other("--title needs a value"))?,
);
i += 2;
}
"--tags" => {
tags = Some(
args.get(i + 1)
.cloned()
.ok_or_else(|| io::Error::other("--tags needs a value"))?,
);
i += 2;
}
_ => {
positional.push(args[i].clone());
i += 1;
}
}
}
if desktop_only && phone_only {
return Err(io::Error::other(
"--desktop-only and --phone-only are mutually exclusive",
));
}
let message = if positional.len() == 1 && positional[0] == "-" {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf)?;
buf.trim_end_matches('\n').to_string()
} else {
positional.join(" ")
};
if message.is_empty() {
return Err(io::Error::other(NOTIFY_USAGE));
}
let cfg = Config::load();
let title = title.as_deref().unwrap_or("triage agent");
let tags = tags.as_deref().unwrap_or("information");
let want_desktop = !phone_only;
let want_phone = !desktop_only;
if want_desktop {
send_desktop_banner(title, &message);
}
if want_phone {
match cfg.ntfy.as_ref() {
Some(ntfy) => cli_phone_push(ntfy, title, tags, &message)?,
None if phone_only => {
return Err(io::Error::other(
"ntfy not configured. Add an [ntfy] block with `url=` (and optional \
`user=`/`token=`) to ~/.config/triage/config.toml.",
));
}
None => {}
}
}
Ok(())
}
fn cli_phone_push(ntfy: &NtfyConfig, title: &str, tags: &str, message: &str) -> io::Result<()> {
let mut cmd = Command::new("curl");
cmd.args(["-fsSL", "-m", "5", "-X", "POST"]);
if let (Some(user), Some(token)) = (ntfy.user.as_deref(), ntfy.token.as_deref()) {
cmd.arg("-u").arg(format!("{user}:{token}"));
}
cmd.arg("-H").arg(format!("Title: {title}"));
cmd.arg("-H").arg(format!("Tags: {tags}"));
cmd.args(["-d", message]);
cmd.arg(&ntfy.url);
let status = cmd
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.status()
.map_err(|e| io::Error::other(format!("failed to invoke curl: {e}")))?;
if !status.success() {
return Err(io::Error::other(format!(
"curl exited {} posting to {}",
status.code().unwrap_or(-1),
ntfy.url,
)));
}
Ok(())
}
fn send_desktop_banner(title: &str, message: &str) {
if let Some(bundle_path) = triage_notify_path() {
let mut cmd = Command::new("open");
cmd.args(["-na", bundle_path]);
cmd.arg("--args");
cmd.args(["--title", "triage"]);
cmd.args(["--subtitle", title]);
cmd.args(["--message", message]);
cmd.args(["--timeout", "20"]);
spawn_detached(cmd);
return;
}
send_via_osascript(title, message, "");
}
fn ntfy_push(ntfy: &NtfyConfig, label: &str, state: &str) {
let body = format!("{label} · {state}");
let mut cmd = Command::new("curl");
cmd.args(["-fsSL", "-m", "5", "-X", "POST"]);
if let (Some(user), Some(token)) = (ntfy.user.as_deref(), ntfy.token.as_deref()) {
cmd.arg("-u").arg(format!("{user}:{token}"));
}
cmd.args(["-H", "Title: triage"]);
cmd.args(["-H", "Tags: warning"]);
cmd.args(["-d", &body]);
cmd.arg(&ntfy.url);
spawn_detached(cmd);
}
fn send_via_triage_notify(
bundle_path: &str,
title: &str,
label: &str,
preview: &str,
pane: Option<&crate::models::Pane>,
cfg: &Config,
) {
let mut cmd = Command::new("open");
cmd.args(["-na", bundle_path]);
cmd.arg("--args");
cmd.args(["--title", "triage"]);
cmd.args(["--subtitle", &format!("{label} — {title}")]);
cmd.args(["--message", preview]);
if let (Some(pane), Some(tmux)) = (pane, tmux_path()) {
let session_name = pane.tmux_session.as_str();
let activate_cmd = detected_terminal_bundle(cfg)
.map(|bundle| {
format!("/usr/bin/open -b {} && ", shell_quote(bundle))
})
.unwrap_or_default();
let action = format!(
"unset TMUX; {activate}{tmux} switch-client -t {session} && {tmux} select-pane -t {target}",
activate = activate_cmd,
tmux = shell_quote(tmux),
session = shell_quote(session_name),
target = shell_quote(&pane.target),
);
cmd.args(["--action", &action]);
}
cmd.args(["--timeout", "20"]);
spawn_detached(cmd);
}
fn send_via_osascript(title: &str, label: &str, preview: &str) {
let body = if preview.is_empty() {
label.to_string()
} else {
format!("{label} — {preview}")
};
let script = format!(
"display notification {body} with title {title}",
body = applescript_string(&body),
title = applescript_string(&format!("triage — {title}")),
);
let mut cmd = Command::new("osascript");
cmd.args(["-e", &script]);
spawn_detached(cmd);
}
fn spawn_detached(mut cmd: Command) {
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
thread::spawn(move || {
if let Ok(mut child) = cmd.spawn() {
let _ = child.wait();
}
});
}
fn detected_terminal_bundle(cfg: &Config) -> Option<&'static str> {
static CACHED: OnceLock<Option<&'static str>> = OnceLock::new();
*CACHED.get_or_init(|| {
if let Some(forced) = forced_terminal_bundle_id(cfg) {
return Some(forced);
}
if let Some(b) = bundle_from_env() {
return Some(b);
}
bundle_from_proc_tree()
})
}
fn forced_terminal_bundle_id(cfg: &Config) -> Option<&'static str> {
let forced = cfg.notifications.terminal_bundle.as_deref()?;
let trimmed = forced.trim();
if trimmed.is_empty() {
return None;
}
Some(Box::leak(trimmed.to_string().into_boxed_str()) as &'static str)
}
fn bundle_from_env() -> Option<&'static str> {
if std::env::var_os("KITTY_WINDOW_ID").is_some() {
return Some("net.kovidgoyal.kitty");
}
if std::env::var_os("GHOSTTY_RESOURCES_DIR").is_some() {
return Some("com.mitchellh.ghostty");
}
if std::env::var_os("WEZTERM_PANE").is_some() {
return Some("com.github.wez.wezterm");
}
if std::env::var_os("ALACRITTY_LOG").is_some() {
return Some("org.alacritty");
}
match std::env::var("TERM_PROGRAM").ok().as_deref() {
Some("iTerm.app") => Some("com.googlecode.iterm2"),
Some("Apple_Terminal") => Some("com.apple.Terminal"),
Some("WezTerm") => Some("com.github.wez.wezterm"),
Some("ghostty") => Some("com.mitchellh.ghostty"),
_ => None,
}
}
fn bundle_from_proc_tree() -> Option<&'static str> {
let mut pid = std::process::id();
for _ in 0..16 {
let ppid = parent_pid(pid)?;
if ppid <= 1 {
break;
}
let cmd = command_of(ppid).unwrap_or_default();
let lower = cmd.to_lowercase();
if lower.contains("kitty") {
return Some("net.kovidgoyal.kitty");
}
if lower.contains("ghostty") {
return Some("com.mitchellh.ghostty");
}
if lower.contains("wezterm") {
return Some("com.github.wez.wezterm");
}
if lower.contains("alacritty") {
return Some("org.alacritty");
}
if lower.contains("iterm") {
return Some("com.googlecode.iterm2");
}
if lower.ends_with("/terminal") || lower.contains("/terminal.app/") {
return Some("com.apple.Terminal");
}
pid = ppid;
}
None
}
fn parent_pid(pid: u32) -> Option<u32> {
let out = Command::new("ps")
.args(["-o", "ppid=", "-p", &pid.to_string()])
.output()
.ok()?;
String::from_utf8(out.stdout)
.ok()?
.trim()
.parse::<u32>()
.ok()
}
fn command_of(pid: u32) -> Option<String> {
let out = Command::new("ps")
.args(["-o", "command=", "-p", &pid.to_string()])
.output()
.ok()?;
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn triage_notify_path() -> Option<&'static str> {
static CACHED: OnceLock<Option<String>> = OnceLock::new();
CACHED
.get_or_init(|| {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if let Some(home) = std::env::var_os("HOME") {
candidates
.push(std::path::PathBuf::from(home).join(".config/triage/triage-notify.app"));
}
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
candidates.push(exe_dir.join("../../scripts/triage-notify/triage-notify.app"));
candidates.push(exe_dir.join("../scripts/triage-notify/triage-notify.app"));
candidates.push(exe_dir.join("triage-notify.app"));
for c in &candidates {
if let Ok(p) = c.canonicalize()
&& p.is_dir()
{
return Some(p.display().to_string());
}
}
None
})
.as_deref()
}
fn tmux_path() -> Option<&'static str> {
static CACHED: OnceLock<Option<String>> = OnceLock::new();
CACHED.get_or_init(|| which("tmux")).as_deref()
}
fn which(cmd: &str) -> Option<String> {
let out = Command::new("which").arg(cmd).output().ok()?;
if !out.status.success() {
return None;
}
let path = String::from_utf8(out.stdout).ok()?.trim().to_string();
(!path.is_empty()).then_some(path)
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn applescript_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out.push('"');
out
}