Idle filter: suppress when watched process (claude) is running in distro

Probes wsl.exe -d <distro> -- pgrep -x claude before flagging a WSL pane
idle, with a 3s per-distro cache on the Rust side. If claude is running
anywhere in the distro, all panes in that distro stay out of the idle set
(per-pane granularity is out of scope — PIDs aren't observable from
Windows). PowerShell + SSH panes skip the probe and keep the legacy
always-notify behaviour.
This commit is contained in:
megaproxy 2026-05-26 17:24:46 +01:00
parent 5b970f8b48
commit f51033a142
7 changed files with 352 additions and 11 deletions

View file

@ -10,6 +10,7 @@ use crate::creds;
use crate::hosts::{self, SshHost, SshHostView};
use crate::mcp::{self, McpMirror, McpServerHandle, McpState, PendingActions, RunningServer};
use crate::mcp_policy::McpPolicy;
use crate::probe::ProbeCache;
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
const WORKSPACE_FILE: &str = "workspace.json";
@ -302,3 +303,30 @@ pub async fn mcp_policy_save(app: AppHandle, policy: McpPolicy) -> Result<(), St
pub async fn mcp_hard_deny_labels() -> Result<Vec<&'static str>, String> {
Ok(crate::mcp_policy::hard_deny_rules().to_vec())
}
// ---- idle-detection filter -------------------------------------------------
/// Probe whether any of the built-in watched processes (currently
/// `["claude"]`) is running in the given WSL distro. Result is cached
/// per-distro for ~3s — see {@link ProbeCache}. Fail-safe: any probe error
/// resolves to `true` so the caller suppresses the idle indicator (the
/// agreed trade-off; the previous "always notify" bug was worse than the
/// occasional over-suppression).
///
/// Frontend only calls this for WSL panes. PowerShell + SSH skip the probe
/// and fall back to the legacy always-notify behaviour. Empty distro names
/// resolve to `true` (no info → fail-safe).
#[tauri::command]
pub async fn is_watch_process_running(
cache: tauri::State<'_, Arc<ProbeCache>>,
distro: String,
) -> Result<bool, String> {
// Probe shells out — keep it off the async runtime's thread.
let cache_arc: Arc<ProbeCache> = (*cache).clone();
let running = tokio::task::spawn_blocking(move || {
cache_arc.is_watch_process_running(&distro)
})
.await
.map_err(|e| format!("probe join failed: {e}"))?;
Ok(running)
}

View file

@ -5,11 +5,13 @@ mod creds;
mod hosts;
mod mcp;
mod mcp_policy;
mod probe;
mod pty;
use std::sync::Arc;
use crate::mcp::{McpServerHandle, McpState, PendingActions};
use crate::probe::ProbeCache;
use crate::pty::PtyManager;
pub fn run() {
@ -40,6 +42,9 @@ pub fn run() {
// Pending action registry — separate managed state so mcp_action_reply can
// grab it without needing to lock McpState or reach into TileService.
let pending_actions: Arc<PendingActions> = Arc::new(PendingActions::default());
// Idle-filter probe cache: shared across all is_watch_process_running
// calls so a per-distro answer is reused for a few seconds. See probe.rs.
let probe_cache: Arc<ProbeCache> = Arc::new(ProbeCache::new());
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
@ -48,6 +53,7 @@ pub fn run() {
.manage(mcp_state)
.manage(McpServerHandle::default())
.manage(pending_actions)
.manage(probe_cache)
.invoke_handler(tauri::generate_handler![
commands::list_distros,
commands::spawn_pane,
@ -70,6 +76,7 @@ pub fn run() {
commands::mcp_policy_load,
commands::mcp_policy_save,
commands::mcp_hard_deny_labels,
commands::is_watch_process_running,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

172
src-tauri/src/probe.rs Normal file
View file

@ -0,0 +1,172 @@
//! "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 <distro> -- 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<HashMap<String, CacheEntry>>,
}
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 <distro> -- pgrep -x <name>` 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 <name>` 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<bool> {
// `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")),
}
}

View file

@ -457,6 +457,13 @@ fn looks_like_password_prompt(buf: &[u8]) -> bool {
/// Run a process without flashing a console window on Windows.
fn quiet_command(program: &str) -> std::process::Command {
quiet_command_pub(program)
}
/// Public variant for cross-module callers (currently {@link crate::probe}).
/// Same behaviour as the in-module `quiet_command`; the wrapper exists so
/// other modules don't each re-implement the CREATE_NO_WINDOW dance.
pub fn quiet_command_pub(program: &str) -> std::process::Command {
let mut c = std::process::Command::new(program);
#[cfg(windows)]
{