diff --git a/memory.md b/memory.md index 07efafc..38e831e 100644 --- a/memory.md +++ b/memory.md @@ -29,7 +29,8 @@ Durable memory for this project. Read at session start, update before session en - [x] ~~**M4 — orchestration.** Broadcast input, idle notifications, Ctrl+K palette.~~ Done 2026-05-22. - [x] ~~**Auto-save debouncing.**~~ 500ms timer in `App.svelte` `$effect`. - [x] ~~**HMR distro picker reset.**~~ No longer an issue — per-pane distro selection. -- [ ] **Idle detection: filter by "claude is foreground."** Currently every pane notifies after 5s silence, which fires too eagerly when the user is reading a `claude` response. Want to detect that `claude` (or any user-specified process) is actually running in the pane's shell before notifying. Needs a Rust-side probe over WSL: `wsl.exe -d ps --ppid -o comm=`. Defer to a future polish pass. +- [x] ~~**Idle detection: filter by "claude is foreground."** Currently every pane notifies after 5s silence, which fires too eagerly when the user is reading a `claude` response. Want to detect that `claude` (or any user-specified process) is actually running in the pane's shell before notifying.~~ Done 2026-05-26 — per-distro probe via `wsl.exe -d -- pgrep -x claude`, cached 3s on the Rust side. WSL panes only; PS + SSH fall back to legacy always-notify. Watched list hardcoded to `["claude"]` — `[[user-watch-list]]` follow-up below. +- [ ] **`[[user-watch-list]]` — user-configurable idle-suppress process list.** v1 hardcodes `DEFAULT_WATCH_PROCESSES = ["claude"]` in `src-tauri/src/probe.rs`. Move to a workspace-config field (or dedicated `watch.json`) so users can add `cargo`, `npm test`, `pytest`, etc. without a recompile. Two design notes: (1) the values are passed straight to `pgrep -x`, so user-supplied strings must be validated (no shell metachars / leading `-`) before reaching `probe_one`; (2) the cache key is currently just the distro name — if the watched-list becomes per-pane / per-workspace, key the cache by `(distro, sorted_watch_list)` to prevent stale answers. - [ ] **Native OS notifications.** Right now toasts only show while the app is focused. `tauri-plugin-notification` would push to Windows Action Center; useful for "claude finished" when the app is minimized. Worth adding if/when the user actually backgrounds the app while waiting for sessions. - [ ] **Configurable idle threshold.** Hardcoded 5000ms in `LeafPane.svelte`. Should move into a settings panel; M5 territory. - [x] ~~**Logic tests for `tree.ts`.**~~ Vitest, 43 cases, runs via `pnpm test`. Done 2026-05-22. @@ -52,6 +53,44 @@ Durable memory for this project. Read at session start, update before session en ## Session log +### 2026-05-26 — Idle filter: suppress when `claude` is running in the distro + +The idle indicator used to fire 5s after any silence, regardless of what the pane was doing. While the user reads a long `claude` response the pane is silent (claude is processing or the human is reading) and the red border + titlebar "N idle" count is just noise. Fixed: WSL panes now probe the backend before flagging idle, and stay quiet if `claude` is running anywhere in the distro. + +**Granularity is per-distro, not per-pane.** Identifying which Windows pane corresponds to which Linux-side shell inside WSL is too complex (PIDs aren't visible from Windows; ProcMon-style probes are fragile). Agreed trade-off: if claude is running in distro X, ALL panes in distro X suppress. Over-suppression for multi-pane-same-distro users is fine — the previous always-notify bug was worse, and that user pattern is the minority. + +**Architecture:** + +1. New `src-tauri/src/probe.rs` module with `ProbeCache` — `parking_lot::Mutex>` keyed by distro name, 3s TTL. Sized against the frontend's 1s idle-tick: ~one `wsl.exe` call per distro per 3 ticks even with many panes polling, while reacting to "claude finished" within a few seconds. +2. Probe command runs `wsl.exe -d -- pgrep -x claude` via `quiet_command_pub` (new public alias of the existing `quiet_command` in pty.rs so cross-module callers don't re-implement the `CREATE_NO_WINDOW` dance). Exit 0 = match, exit 1 = no match, anything else = probe failure. +3. **Fail-safe is suppression.** Any probe error (wsl.exe missing, distro stopped, pgrep not installed) resolves to `true` → frontend suppresses the idle indicator. Matches the agreed trade-off: over-suppression beats false-positive notifications. +4. New Tauri command `is_watch_process_running(distro)`. Wrapped in `tokio::task::spawn_blocking` because the shell-out can take 100-300ms — keep it off the async runtime's thread pool. +5. `LeafPane.tsx` idle-detection effect rewritten: when the tick says "now idle", branch by `shellKind`. WSL → probe backend, suppress if true. PowerShell + SSH → skip the probe and fall back to legacy behaviour (PS has no portable `ps`; SSH processes live on a remote box; out of scope for v1). Includes `inFlight` guard so a slow probe doesn't stack with subsequent ticks, and a `cancelled` flag for the React-18-StrictMode cleanup pattern we always use here. + +**Watched list is currently hardcoded.** `DEFAULT_WATCH_PROCESSES: &[&str] = &["claude"]` in probe.rs. Comment marks the v2 follow-up: surface as a workspace-config field, key the cache by `(distro, sorted_list)` if it becomes per-pane, and validate user-supplied strings against `pgrep` shell-injection (no `-` prefix, no shell metachars). + +**Files touched:** + +- `src-tauri/src/probe.rs` — new module (~150 lines). +- `src-tauri/src/pty.rs` — `quiet_command_pub` exposed for cross-module use. +- `src-tauri/src/lib.rs` — register the module, the `ProbeCache` state, and the command in `invoke_handler`. +- `src-tauri/src/commands.rs` — `is_watch_process_running` Tauri command. +- `src/ipc.ts` — `isWatchProcessRunning` TS wrapper. +- `src/lib/layout/LeafPane.tsx` — idle-detection effect now branches on shellKind and gates WSL transitions through the probe. + +**Validated:** + +- `pnpm check` clean (0 errors). +- `pnpm test` clean (72 tree.ts tests pass — no UI tests yet, so the React-side change isn't covered automatically). +- Rust side authored in WSL; user to run `cargo build / cargo check -p tiletopia_lib` from Windows before merging. + +Open follow-ups specific to this session: + +- **`[[user-watch-list]]` config surface.** See open-questions section above. Probably 30 min of work: add `watchProcesses?: string[]` to workspace.json, validate per-name (no `-`, no shell metachars, length cap), thread through to a new `is_watch_process_running_for` command that takes the list, key the cache by `(distro, sorted_list_hash)`. +- **Probe latency-as-jitter.** First idle tick after 5s silence triggers a 100-300ms `wsl.exe` shell-out. The user sees the red border flicker on for ~one tick before the probe resolves and clears it. Not visually obvious in practice (the red is already a transient signal), but could pre-warm the cache on a slower interval if it bites. +- **PowerShell idle filter.** PS has no `ps` equivalent we can probe cheaply; closest is `Get-Process` + a watched-list mapping (`claude` doesn't exist on Windows, but `cargo`, `npm`, `python` do). Defer until someone actually runs a long-running CLI in PS and complains. +- **Workspace-edit migration of the `LeafPane.svelte` mention** in the open-question section about the 5000ms threshold — file says `.svelte` but we're React now. Drive-by, not done here ("don't refactor unrelated code"). + ### 2026-05-26 — Hard-deny: PowerShell patterns + label list de-duplicated Mirrors the POSIX hard-deny rules with their Windows/PowerShell equivalents. Four new patterns: diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dd21f6a..8e84b7e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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, 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>, + distro: String, +) -> Result { + // Probe shells out — keep it off the async runtime's thread. + let cache_arc: Arc = (*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) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 40ec343..88192f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 = 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 = 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"); diff --git a/src-tauri/src/probe.rs b/src-tauri/src/probe.rs new file mode 100644 index 0000000..1f35980 --- /dev/null +++ b/src-tauri/src/probe.rs @@ -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 -- 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")), + } +} diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 2f90930..58f1f66 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -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)] { diff --git a/src/ipc.ts b/src/ipc.ts index e1d48c8..b08a252 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -39,6 +39,14 @@ export interface SshHost { export const listDistros = (): Promise => invoke("list_distros"); +/** Ask the backend whether any built-in "watched" process (currently just + * `claude`) is running in the given WSL distro. Cached per-distro for ~3s + * on the Rust side. Fail-safe: probe failures resolve to `true` so the + * caller suppresses the idle indicator. Only meaningful for WSL panes — + * PowerShell + SSH should skip this and fall back to always-notify. */ +export const isWatchProcessRunning = (distro: string): Promise => + invoke("is_watch_process_running", { distro }); + export const spawnPane = (args: { spec: SpawnSpec; cols: number; diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index d02f13f..4ea8c7d 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -10,7 +10,7 @@ import { import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; import { useOrchestration } from "./orchestration"; import XtermPane from "../../components/XtermPane"; -import type { SpawnSpec } from "../../ipc"; +import { isWatchProcessRunning, type SpawnSpec } from "../../ipc"; import "./LeafPane.css"; const IDLE_THRESHOLD_MS = 5000; @@ -116,8 +116,24 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { // ---- idle detection ---------------------------------------------------- // Local boolean for the red border + status text on this pane; reported // up to App via orch.reportLeafIdle for the titlebar's "N idle" badge. + // + // Filter: for WSL panes, before flagging idle we probe the backend to + // see if any "watched" process (currently just `claude`) is running in + // the distro. If it is, the silence is "claude thinking / user reading", + // not "nothing happening" — stay quiet. Probe is per-distro (not per- + // pane: the inside-WSL PID isn't observable from Windows), so multiple + // panes in the same distro will all suppress if claude is running in + // any of them. Agreed trade-off; over-suppression beats the previous + // always-notify behaviour. + // + // PowerShell + SSH skip the probe and fall through to legacy behaviour + // (PS has no portable `ps`; SSH processes live on the remote box). const lastDataTimeRef = useRef(Date.now()); const [isIdle, setIsIdle] = useState(false); + const isWslPane = leaf.shellKind === "wsl"; + // Captures the distro name into the interval callback. Empty string when + // the leaf doesn't have one yet — the probe treats that as fail-safe true. + const wslDistro = isWslPane ? (leaf.distro ?? "") : ""; const onDataReceived = useCallback(() => { lastDataTimeRef.current = Date.now(); setIsIdle((cur) => { @@ -126,17 +142,81 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { }); }, [orch.reportLeafIdle, leaf.id]); useEffect(() => { - const id = window.setInterval(() => { + // Guard against late-resolving probes after unmount or another tick + // already shipping a fresher answer. + let cancelled = false; + let inFlight = false; + + const tick = () => { const dt = Date.now() - lastDataTimeRef.current; const nowIdle = dt >= IDLE_THRESHOLD_MS; - setIsIdle((cur) => { - if (cur === nowIdle) return cur; - orch.reportLeafIdle(leaf.id, nowIdle); - return nowIdle; - }); - }, 1000); - return () => clearInterval(id); - }, [leaf.id, orch.reportLeafIdle]); + + // Transitioning out of idle is unconditional — fresh output beats + // any probe answer. + if (!nowIdle) { + setIsIdle((cur) => { + if (!cur) return cur; + orch.reportLeafIdle(leaf.id, false); + return false; + }); + return; + } + + // Transitioning into idle. Non-WSL panes: report immediately (legacy + // behaviour). WSL panes: gate on the probe; suppress if a watched + // process is running in the distro. + if (!isWslPane) { + setIsIdle((cur) => { + if (cur) return cur; + orch.reportLeafIdle(leaf.id, true); + return true; + }); + return; + } + + // WSL path. Don't stack probes — one in flight per pane at a time. + if (inFlight) return; + inFlight = true; + void isWatchProcessRunning(wslDistro) + .then((suppress) => { + if (cancelled) return; + // If output arrived while the probe was in flight, the next tick + // (or onDataReceived) will reconcile; don't flip-flop here. + if (Date.now() - lastDataTimeRef.current < IDLE_THRESHOLD_MS) return; + if (suppress) { + // claude (or another watched proc) is running — treat silence + // as expected and stay out of the idle set. + setIsIdle((cur) => { + if (!cur) return cur; + orch.reportLeafIdle(leaf.id, false); + return false; + }); + } else { + setIsIdle((cur) => { + if (cur) return cur; + orch.reportLeafIdle(leaf.id, true); + return true; + }); + } + }) + .catch((e) => { + // Probe IPC errored — fail-safe to suppression (matches the Rust + // side's own fail-safe). + if (cancelled) return; + // eslint-disable-next-line no-console + console.debug("idle probe failed", e); + }) + .finally(() => { + inFlight = false; + }); + }; + + const id = window.setInterval(tick, 1000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [leaf.id, orch.reportLeafIdle, isWslPane, wslDistro]); // Clear from the app-level idle set when this pane unmounts. useEffect(() => { return () => orch.reportLeafIdle(leaf.id, false);