//! "Is a watched process running in distro X?" probe for the idle-detection //! filter. //! //! Background: tiletopia's idle indicator fires whenever a pane goes 5s //! without PTY output. When the user is reading a long `claude` response, //! the pane is silent but there's nothing actionable to surface — the //! indicator becomes noise. This module lets the frontend ask the backend //! "is `claude` (or any other watched process) running in this distro?" //! before flagging a pane idle, and suppresses the indicator if so. //! //! Granularity is per-distro, not per-pane. Identifying which Windows pane //! corresponds to which Linux-side shell inside the distro is too complex //! (PIDs aren't visible from Windows; ProcMon-style probes are fragile). If //! `claude` is running anywhere in distro X, idle is suppressed for ALL //! panes in distro X. Over-suppression for multi-pane-same-distro users is //! the agreed trade-off; the previous bug (always notify) was worse. //! //! PowerShell + SSH panes don't go through this probe — the frontend short- //! circuits to "always idle" for them. (PowerShell has no portable `ps` //! equivalent; SSH processes live on a remote box and would need a separate //! transport.) //! //! The probe shells out (`wsl.exe -d -- pgrep -x ...`), which costs //! ~100-300ms per call. We cache the answer per-distro for a few seconds so //! the frontend can poll on every idle tick without storming `wsl.exe`. use std::collections::HashMap; use std::time::{Duration, Instant}; use parking_lot::Mutex; /// Built-in list of process names that suppress idle when running. v1 ships /// with just `claude`; the user can extend it via the workspace config later. /// /// [[user-watch-list]] TODO: surface this as a user-editable list (workspace /// config field or dedicated `watch.json`). For now the constant covers the /// only real-world use case (Anthropic's `claude` CLI taking its time on a /// long response). Adding entries to the constant is the only knob. pub const DEFAULT_WATCH_PROCESSES: &[&str] = &["claude"]; /// How long a per-distro probe result is reused before we re-shell. Sized /// against the frontend's 1s idle-tick interval — 3s means roughly one /// probe per distro per 3 ticks even with many panes polling, while still /// reacting to "claude just finished" within a few seconds. Trade-off: too /// short = wsl.exe spam, too long = stale "claude is running" once the /// process actually exits. const CACHE_TTL: Duration = Duration::from_secs(3); /// Cache entry: timestamp the probe ran + whether any watched process was /// found in the distro. #[derive(Clone, Copy)] struct CacheEntry { at: Instant, running: bool, } /// Per-distro probe cache. Keyed by distro name (the same string the user /// sees in the shell picker; the same string we pass as `wsl.exe -d`). pub struct ProbeCache { cache: Mutex>, } impl ProbeCache { pub fn new() -> Self { Self { cache: Mutex::new(HashMap::new()), } } /// Returns true iff one of the watched processes is running in the /// distro. Cached for {@link CACHE_TTL}; cache misses (or stale entries) /// trigger a fresh probe. On probe failure the result is `true` — /// **fail-safe is to suppress** the idle indicator, matching the /// agreed trade-off ("over-suppression beats the previous always-notify /// behaviour"). pub fn is_watch_process_running(&self, distro: &str) -> bool { // Fast path: fresh cached answer. { let guard = self.cache.lock(); if let Some(entry) = guard.get(distro) { if entry.at.elapsed() < CACHE_TTL { return entry.running; } } } // Slow path: re-probe. Drop the lock before shelling out so other // distros' probes aren't blocked. let running = probe_distro(distro, DEFAULT_WATCH_PROCESSES); let mut guard = self.cache.lock(); guard.insert( distro.to_string(), CacheEntry { at: Instant::now(), running, }, ); running } } impl Default for ProbeCache { fn default() -> Self { Self::new() } } /// Run `wsl.exe -d -- pgrep -x ` for each watched name. /// Returns true on the first hit. On any failure (wsl.exe missing, distro /// not running, pgrep not installed, timeout) returns true — fail-safe is /// suppression. fn probe_distro(distro: &str, watched: &[&str]) -> bool { if !cfg!(windows) { // Non-Windows builds don't actually ship the app; pretend no watched // process so the idle indicator works for developer test runs. return false; } if distro.is_empty() { // We can't probe an empty distro name; treat as "no info" → fail-safe. tracing::debug!("probe: empty distro name; defaulting to suppression"); return true; } for name in watched { match probe_one(distro, name) { Ok(true) => return true, Ok(false) => continue, Err(e) => { tracing::debug!( "probe: wsl pgrep for {name:?} in {distro:?} failed: {e} — suppressing idle" ); return true; } } } false } /// Single `pgrep -x ` invocation. Ok(true) on a match, Ok(false) on /// exit code 1 (no match), Err on anything else. Wrapped in our standard /// `quiet_command` so the console window doesn't flash on the Windows /// desktop every probe. fn probe_one(distro: &str, name: &str) -> std::io::Result { // `pgrep -x` matches the exact comm (no substring), which avoids // `claude-something-else` false-positives. Stdout/stderr are silenced // — exit code carries the answer. // // Note: `name` is a compile-time string literal in DEFAULT_WATCH_PROCESSES // (no user input), so shell-quoting concerns don't apply. If we ever // wire user-supplied process names through here we MUST validate / shell- // quote them before this point. let out = crate::pty::quiet_command_pub("wsl.exe") .args(["-d", distro, "--", "pgrep", "-x", name]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output()?; match out.status.code() { Some(0) => Ok(true), // pgrep found at least one match Some(1) => Ok(false), // pgrep ran but found nothing Some(other) => { // 2 = syntax error in pgrep itself; 3 = fatal error; 127 = command // not found. None of these mean "definitively no claude running", // so treat as a probe failure (caller fails-safe to true). Err(std::io::Error::other(format!( "pgrep exit code {other}" ))) } None => Err(std::io::Error::other("pgrep killed by signal")), } }