supercode-cli 0.5.58

Volter Harness — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! UX-22: turn-finish notifications (desktop + optional email).
//!
//! supercode gives no signal when a long turn finishes — no bell, no
//! desktop popup. This module adds both, entirely opt-in and best-effort:
//!
//! - **Desktop**: shells out to `notify-send` (the standard libnotify CLI
//!   on Linux desktops), the same zero-new-dependency shape as
//!   `terminal_title.rs`'s raw OSC escapes — no `notify-rust`/dbus crate.
//!   `osascript` (macOS) / a toast API (Windows) are out of scope: this
//!   codebase only ships/tests on Linux (see `Cargo.toml`), and the box
//!   this was built on is headless Linux with no guaranteed dbus session —
//!   see [`fire_desktop`]'s doc for exactly how that's handled.
//! - **Bell**: a plain `BEL` (`\x07`) to stderr, the low-tech fallback the
//!   backlog's approach sketch pairs with the desktop notification.
//! - **Email** (optional/stretch per the backlog item): a minimal,
//!   dependency-free SMTP client — see [`fire_email`]'s doc for its scope
//!   and honest limitations (no TLS/STARTTLS).
//!
//! ## Gating (the AC)
//!
//! [`should_notify`] is the single choke point, mirroring
//! `terminal_title::should_set_title` / `spinner::should_show_spinner`'s
//! "one pure predicate, every call site funnels through it" shape:
//! off unless explicitly enabled (flag/env/config), never for a machine
//! output format (`json`/`stream-json`), never when stderr isn't a real
//! terminal (piped/CI — the AC's "non-interactive/piped" case), and only
//! for turns that actually ran long enough to matter.
//!
//! ## Never blocks, never breaks the turn
//!
//! [`maybe_fire`] is called AFTER a turn's own result is already decided
//! (the reply is already printed/persisted by the caller) — everything it
//! does is either near-instant (the `notify-send` `spawn()` call itself:
//! fork+exec, no wait for the child) or pushed onto a detached background
//! thread ([`fire_desktop`]'s reap, all of [`fire_email`]) whose failure
//! or slowness is invisible to (and can never fail) the turn that already
//! finished. Nothing here ever panics or returns a `Result` the caller
//! must handle — every failure mode (notifier absent, PATH lookup fails,
//! SMTP connect refused, ...) is swallowed at the source.
//!
//! ## Honest headless caveat
//!
//! This box has no guaranteed dbus session / `notify-send` binary. The
//! integration test (`crates/cli/tests/notify_cli.rs`) proves the WIRING —
//! a fake `notify-send` script on `PATH` receives the exact title/body —
//! and separately proves the "no `notify-send` on `PATH` at all" case
//! degrades to a silent no-op. Neither proves a real desktop popup was
//! ever rendered on a real display; that half is unverifiable here by
//! construction (no display, no dbus, no windowing system on this box).

use std::io::Write;
use std::process::{Command, Stdio};
use std::time::Duration;

/// Default "long turn" threshold (seconds) — a turn that finishes faster
/// than this is assumed to still be watched, so no notification fires.
/// Overridable via `--notify-threshold-secs` / `notify_threshold_secs`
/// config.
pub const DEFAULT_THRESHOLD_SECS: u64 = 30;

/// Cap on the reply/prompt preview folded into a notification body — a
/// short "safe summary" per the driver directive, never the full session.
pub const SUMMARY_MAX_CHARS: usize = 80;

/// Resolved notification settings for one CLI invocation — already merged
/// (flag > env > config > default), the same shape as `main.rs`'s other
/// `effective_*` helpers (e.g. `effective_reduced`).
#[derive(Debug, Clone, Default)]
pub struct NotifySettings {
    pub enabled: bool,
    pub threshold: Duration,
    pub email: Option<EmailConfig>,
    /// P5-7: the resolved lifecycle-hook set for this invocation, carried
    /// here because `NotifySettings` is already threaded to every turn-finish
    /// site. The `notification` hook (CC `Notification`, type
    /// `agent_completed`) fires from [`maybe_fire`] on each completed turn.
    /// Default (no `notification` command configured) = a hard no-op, so an
    /// invocation with no `[hooks]` set is byte-identical to before.
    pub hooks: crate::hooks::HookSet,
    /// P5-7: honored by the `notification` hook's chrome suppression (a
    /// SUCCESSFUL hook's own stdout is suppressed under `--quiet`; a
    /// failure/timeout is always reported).
    pub quiet: bool,
}

/// SMTP settings for the optional email channel. The account password is
/// deliberately NOT a field here (see `fire_email`'s doc) — it's read only
/// from the `SUPERCODE_NOTIFY_EMAIL_PASSWORD` env var at send time, so it
/// never has to live in a plaintext `config.toml`.
#[derive(Debug, Clone)]
pub struct EmailConfig {
    pub smtp_host: String,
    pub smtp_port: u16,
    pub from: String,
    pub to: String,
    pub username: Option<String>,
}

/// Pure gating decision — no I/O, unit-testable without a real tty,
/// notify-send, or network. A notification is suppressed unless ALL of:
///
/// - `enabled` — explicit opt-in (`--notify`, `SUPERCODE_NOTIFY`, or
///   `notify = true` in config). Off by default (AC dev/02).
/// - `!machine_format` — `--output-format json`/`stream-json` never
///   notifies. Those are scripted/wrapper callers; a stray notify-send
///   spawn (or, worse, any byte near stdout) is exactly the kind of side
///   effect a machine caller doesn't want.
/// - `stderr_is_tty` — the rest of "non-interactive/piped" (AC dev/02):
///   redirected/piped stderr (or CI) means nobody's at the terminal to
///   see a desktop notification anyway — the same non-tty gate
///   `terminal_title::should_set_title` uses.
/// - `elapsed >= threshold` — only turns long enough that the user might
///   plausibly have looked away (the backlog's "long-running turn").
pub fn should_notify(
    enabled: bool,
    machine_format: bool,
    stderr_is_tty: bool,
    elapsed: Duration,
    threshold: Duration,
) -> bool {
    enabled && !machine_format && stderr_is_tty && elapsed >= threshold
}

/// Strip control characters and cap length. A crafted assistant reply (or
/// user prompt) must never be able to smuggle control bytes into a
/// notify-send argument or an SMTP header, and the AC/driver directive
/// both call for a short SAFE summary, not the raw session content.
fn sanitize_summary(s: &str, max_chars: usize) -> String {
    let cleaned: String = s.chars().filter(|c| !c.is_control()).collect();
    let trimmed = cleaned.trim();
    if trimmed.chars().count() > max_chars {
        let truncated: String = trimmed.chars().take(max_chars).collect();
        format!("{truncated}…")
    } else {
        trimmed.to_string()
    }
}

/// Build the `(title, body)` for a finished turn. `reply_preview` is the
/// model's OWN final reply text — truncated and control-stripped so at
/// most a short, safe preview ever leaves the process, never the full
/// history/session.
pub fn build_payload(model: &str, elapsed: Duration, reply_preview: &str) -> (String, String) {
    let title = "supercode: turn finished".to_string();
    let secs = elapsed.as_secs();
    let summary = sanitize_summary(reply_preview, SUMMARY_MAX_CHARS);
    let body = if summary.is_empty() {
        format!("{model} · {secs}s")
    } else {
        format!("{model} · {secs}s · {summary}")
    };
    (title, body)
}

/// Desktop notification via `notify-send`. The `Command::spawn()` call
/// itself runs SYNCHRONOUSLY on the caller's thread — that's just a
/// PATH-lookup + fork+exec, sub-millisecond in practice, and critically
/// must happen before this function returns: if it were pushed onto a
/// background thread instead, a fast-exiting `run` invocation could reach
/// `std::process::exit` before the OS ever scheduled that thread, and the
/// notify-send process would never launch at all. Once spawned, though,
/// `notify-send` is a fully independent OS process — supercode exiting
/// (or this Rust process's threads dying) afterward can't stop it or
/// prevent it from actually showing the popup.
///
/// Only the REAPING (`.wait()`, so the child doesn't sit as a zombie for
/// the rest of a long-lived `chat` REPL process) is pushed to a detached
/// background thread — that part is allowed to be slow/never-observed
/// without affecting anything (worst case for a short-lived `run`
/// process: the zombie is reparented to init on exit and reaped there,
/// same as any other orphaned child).
///
/// Degrades completely silently when `notify-send` isn't on `PATH`, dbus
/// isn't reachable, or spawning fails for any other reason — `spawn()`
/// returning `Err` is the ordinary "notifier absent" case, not an error
/// condition worth surfacing (AC dev/02: "never break the run if the
/// notifier is absent").
pub fn fire_desktop(title: &str, body: &str) {
    let child = Command::new("notify-send")
        .arg("--app-name=supercode")
        .arg(title)
        .arg(body)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn();
    if let Ok(mut child) = child {
        std::thread::spawn(move || {
            let _ = child.wait();
        });
    }
    // Err (not found / permission / etc.): silently no-op — the
    // "notifier absent" degrade-gracefully case, by construction.
}

/// Best-effort terminal bell (`BEL`) — the backlog's approach sketch pairs
/// this with the desktop notification. Stderr only, never stdout, and only
/// ever called from behind the same `should_notify` gate as the desktop
/// notification, so it inherits the same non-machine/interactive-only
/// discipline.
pub fn ring_bell() {
    let mut err = std::io::stderr();
    let _ = err.write_all(b"\x07");
    let _ = err.flush();
}

/// Fire-and-forget email notification (optional/stretch channel): a
/// minimal, dependency-free SMTP client (EHLO / optional AUTH LOGIN /
/// MAIL FROM / RCPT TO / DATA / QUIT) over a plain `TcpStream`.
///
/// **Honest scope**: this speaks UNENCRYPTED SMTP only — no STARTTLS, no
/// implicit TLS. It works against a local unauthenticated relay
/// (postfix/msmtp/sendmail on port 25) or a relay that accepts AUTH LOGIN
/// over a plaintext channel — it will NOT work against a TLS-required
/// provider like `smtp.gmail.com:587`. Adding STARTTLS would need a TLS
/// implementation (a new dependency, cargo-deny-relevant), which is out
/// of scope for a "prefer no new dep" stretch channel. This matches the
/// backlog's own framing of email as a stretch, config-gated addition,
/// not a production MTA client.
///
/// Runs entirely on a detached background thread — the SMTP round trip is
/// real network I/O (bounded by a 5s connect/read/write timeout, but
/// still potentially slow), so it must never run on the turn's own
/// thread. Any failure (DNS, connect refused, timeout, relay rejection)
/// is swallowed inside [`send_email_blocking`]'s `Result` and never
/// propagates — email is a best-effort side channel, same discipline as
/// the desktop notification.
///
/// Known limitation: because this is fully backgrounded, a single-shot
/// `run` invocation that exits immediately after printing its result can
/// race the SMTP conversation to completion — there's no bounded join at
/// the call site (unlike the desktop path's synchronous `spawn()`, the
/// email path has no equivalent "at least got launched" guarantee against
/// process exit). `chat`'s longer-lived REPL process gives this far more
/// headroom in practice. Config-gated and off by default, so this is a
/// documented trade-off rather than a silent one.
pub fn fire_email(cfg: EmailConfig, subject: &str, body: &str) {
    let subject = subject.to_string();
    let body = body.to_string();
    std::thread::spawn(move || {
        let _ = send_email_blocking(&cfg, &subject, &body);
    });
}

/// Dispatch whatever's configured (bell + desktop + optional email) for a
/// finished turn, subject to [`should_notify`]'s gating. The single call
/// site every command path (`run`, `chat`, `resume`) should use.
pub fn maybe_fire(
    settings: &NotifySettings,
    machine_format: bool,
    stderr_is_tty: bool,
    elapsed: Duration,
    model: &str,
    reply_preview: &str,
) {
    // P5-7: the `notification` lifecycle hook (CC `Notification`, type
    // `agent_completed`) fires at every completed turn — its OWN opt-in (a
    // configured command), independent of the desktop-notify `enabled`/tty/
    // threshold gate below, so a consumer can react to turn completions
    // without also enabling desktop pop-ups. Still suppressed for
    // machine-format runs (json/stream-json), matching the "no side effects
    // for a scripted caller" discipline the desktop path uses. A hard no-op
    // when no `notification` command is configured (`fire_notification`).
    if !machine_format {
        crate::hooks::fire_notification(
            &settings.hooks,
            model,
            elapsed.as_secs(),
            reply_preview,
            settings.quiet,
        );
    }
    if !should_notify(
        settings.enabled,
        machine_format,
        stderr_is_tty,
        elapsed,
        settings.threshold,
    ) {
        return;
    }
    let (title, body) = build_payload(model, elapsed, reply_preview);
    ring_bell();
    fire_desktop(&title, &body);
    if let Some(email) = &settings.email {
        fire_email(email.clone(), &title, &body);
    }
}

// ---- minimal SMTP client ----------------------------------------------

fn write_line(w: &mut impl Write, line: &str) -> std::io::Result<()> {
    w.write_all(line.as_bytes())?;
    w.write_all(b"\r\n")?;
    w.flush()
}

/// Read one SMTP reply (possibly multi-line, `XXX-` continuation until a
/// final `XXX ` line) and return the 3-digit status code. Bails with an
/// `InvalidData` error on a code outside 200-399 (SMTP failure), so the
/// caller's `?`-chain naturally aborts the conversation on the first
/// rejected step rather than plowing ahead with garbage state.
fn read_reply(r: &mut impl std::io::BufRead) -> std::io::Result<u32> {
    let mut code: u32;
    loop {
        let mut line = String::new();
        if r.read_line(&mut line)? == 0 {
            return Err(std::io::Error::new(
                std::io::ErrorKind::UnexpectedEof,
                "SMTP connection closed mid-reply",
            ));
        }
        let bytes = line.as_bytes();
        if bytes.len() < 4 {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "malformed SMTP reply line",
            ));
        }
        code = std::str::from_utf8(&bytes[..3])
            .ok()
            .and_then(|s| s.parse().ok())
            .ok_or_else(|| {
                std::io::Error::new(std::io::ErrorKind::InvalidData, "non-numeric SMTP code")
            })?;
        let last = bytes[3] != b'-'; // '-' = continuation line, ' '/'\t' = final.
        if last {
            break;
        }
    }
    if !(200..400).contains(&code) {
        return Err(std::io::Error::other(format!(
            "SMTP command rejected, code {code}"
        )));
    }
    Ok(code)
}

fn send_email_blocking(cfg: &EmailConfig, subject: &str, body: &str) -> std::io::Result<()> {
    use base64::Engine;
    use std::io::BufReader;
    use std::net::{TcpStream, ToSocketAddrs};

    let addr = (cfg.smtp_host.as_str(), cfg.smtp_port)
        .to_socket_addrs()?
        .next()
        .ok_or_else(|| {
            std::io::Error::new(std::io::ErrorKind::NotFound, "SMTP host did not resolve")
        })?;
    let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?;
    stream.set_read_timeout(Some(Duration::from_secs(5)))?;
    stream.set_write_timeout(Some(Duration::from_secs(5)))?;
    let mut reader = BufReader::new(stream.try_clone()?);
    let mut writer = stream;

    read_reply(&mut reader)?; // 220 greeting
    write_line(&mut writer, "EHLO supercode.local")?;
    read_reply(&mut reader)?;

    if let Some(user) = &cfg.username {
        let password = std::env::var("SUPERCODE_NOTIFY_EMAIL_PASSWORD").unwrap_or_default();
        let b64 = base64::engine::general_purpose::STANDARD;
        write_line(&mut writer, "AUTH LOGIN")?;
        read_reply(&mut reader)?;
        write_line(&mut writer, &b64.encode(user))?;
        read_reply(&mut reader)?;
        write_line(&mut writer, &b64.encode(password))?;
        read_reply(&mut reader)?;
    }

    write_line(&mut writer, &format!("MAIL FROM:<{}>", cfg.from))?;
    read_reply(&mut reader)?;
    write_line(&mut writer, &format!("RCPT TO:<{}>", cfg.to))?;
    read_reply(&mut reader)?;
    write_line(&mut writer, "DATA")?;
    read_reply(&mut reader)?;
    write_line(&mut writer, &format!("Subject: {subject}"))?;
    write_line(&mut writer, &format!("From: {}", cfg.from))?;
    write_line(&mut writer, &format!("To: {}", cfg.to))?;
    write_line(&mut writer, "")?;
    write_line(&mut writer, body)?;
    write_line(&mut writer, ".")?;
    read_reply(&mut reader)?;
    write_line(&mut writer, "QUIT")?;
    let _ = read_reply(&mut reader);
    Ok(())
}