diff --git a/memory.md b/memory.md index 9f9823c..5f9d997 100644 --- a/memory.md +++ b/memory.md @@ -29,7 +29,7 @@ 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. -- [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. +- [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-pane** probe (revised from initial per-distro design which broke tiletopia's primary use case of multiple claude panes per distro). Each WSL spawn tags itself with `TILETOPIA_PANE_ID=` propagated via `WSLENV`; the probe runs `pgrep -x claude` in the distro then reads each match's `/proc//environ` for the matching marker. Cached 3s by `(distro, pane_id)` 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. @@ -53,6 +53,46 @@ Durable memory for this project. Read at session start, update before session en ## Session log +### 2026-05-26 — Idle filter pivot: per-distro → per-pane (env-var marker) + +The per-distro probe shipped earlier today (see entry below) had the wrong granularity for tiletopia's actual use case. CLAUDE.md says the app is "built primarily to manage multiple `claude` sessions across projects in parallel" — i.e. multiple claude panes per distro is THE point. Per-distro suppression silenced every pane the moment one ran claude. Tested live: user saw all Ubuntu panes stop reporting idle because one pane (this session) was running claude. + +Fix: pivot to per-pane detection via env-var marker. + +**Mechanism:** + +1. `pty.rs` — every WSL spawn now sets `TILETOPIA_PANE_ID=` as a Windows-side env var on the `wsl.exe` invocation, plus `WSLENV=TILETOPIA_PANE_ID/u` (appended to any pre-existing WSLENV) so the var gets forwarded into the distro. Reserves the `id` BEFORE `build_command` instead of after (since the env tag needs to know the id at spawn time). +2. `probe.rs` — rewritten. New shape: `is_watch_process_running(distro, pane_id)`. Runs a bash one-liner inside the distro that `pgrep -x `s for each watched process, then for each PID checks `/proc//environ` for an exact `TILETOPIA_PANE_ID=` line (using `tr '\0' '\n' | grep -qxF`). Inheritance does the work — claude inherits env from the shell, shell inherits from wsl.exe via WSLENV. Cache keyed by `(distro, pane_id)`. +3. **Fail-safe inverted.** v1 returned `true` (suppress) on probe failure — meant a transient error silenced idle forever until the cache TTL turned over and re-failed. v2 returns `false` (don't suppress) — better to occasionally over-notify than permanently silence. Frontend `catch` also no longer flips to suppression. +4. `commands.rs` + `ipc.ts` + `LeafPane.tsx` updated to thread `pane_id` through. LeafPane tracks the backend PaneId in a ref (`paneIdRef`), set by `onPaneSpawned`. Ticks before the spawn completes pass `0` — won't match any real pane's marker, so probe returns false and the pane idles normally. + +**Verification path** (user runs): + +```powershell +# In one Ubuntu pane: launch claude. Wait 5s. +# Expect: red border does NOT appear (this pane has claude). +# In another Ubuntu pane: do nothing. Wait 5s. +# Expect: red border DOES appear (this pane has no claude). +# Exit claude in the first pane. Wait 5s. +# Expect: red border appears there too. +``` + +**Files touched:** + +- `src-tauri/src/pty.rs` — env tagging on WSL spawns (~25 lines). +- `src-tauri/src/probe.rs` — rewritten (~150 lines, similar size). +- `src-tauri/src/commands.rs` — sig change (1 extra arg). +- `src/ipc.ts` — sig change + doc comment. +- `src/lib/layout/LeafPane.tsx` — paneIdRef + pass to probe call + updated comments. + +**Validated:** `pnpm check` clean. Rust validation needs `cargo build / cargo test --lib` from Windows. + +Open follow-ups specific to this session: + +- **WSLENV escaping.** If a user has `WSLENV` already set with weird chars (spaces, semicolons, embedded `:`), the simple `format!("{existing}:TILETOPIA_PANE_ID/u")` may or may not behave as expected. Most users have no WSLENV set; if it becomes an issue, parse/validate before appending. +- **Probe ergonomics on minimal distros.** New fail-safe is "no match" instead of "suppress", so a distro missing `pgrep` or `bash` (rare but possible for stripped Alpine etc.) just gets always-notify. Acceptable; document if anyone hits it. +- **Tagging existing panes.** The env tag only applies to NEW spawns. Panes already running from before this change won't have the marker — they'll always show idle (since the probe won't find their TILETOPIA_PANE_ID). User needs to close + respawn each WSL pane once after deploying this fix. Worth mentioning in the upgrade note if we ever cut a release. + ### 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. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8e84b7e..f3c7a0e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -320,11 +320,12 @@ pub async fn mcp_hard_deny_labels() -> Result, String> { pub async fn is_watch_process_running( cache: tauri::State<'_, Arc>, distro: String, + pane_id: PaneId, ) -> 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) + cache_arc.is_watch_process_running(&distro, pane_id) }) .await .map_err(|e| format!("probe join failed: {e}"))?; diff --git a/src-tauri/src/probe.rs b/src-tauri/src/probe.rs index 1f35980..4e422ab 100644 --- a/src-tauri/src/probe.rs +++ b/src-tauri/src/probe.rs @@ -1,63 +1,66 @@ -//! "Is a watched process running in distro X?" probe for the idle-detection -//! filter. +//! "Is a watched process running in THIS pane?" probe for the idle 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. +//! Background: tiletopia's idle indicator fires when a pane goes 5s without +//! PTY output. When the user is reading a long `claude` response the pane +//! is silent but nothing actionable is happening — the indicator becomes +//! noise. This module lets the frontend ask "is `claude` running in pane N?" +//! before flagging idle, and suppresses 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. +//! ## Per-pane granularity (revised v2 design) //! -//! 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.) +//! v1 of this module was per-distro: one `pgrep` in the distro answered for +//! all panes. That was wrong for tiletopia's primary use case — running +//! multiple claude sessions across panes in the same distro is THE point of +//! the app, and per-distro suppression silenced every pane the moment one +//! ran claude. Revised: per-pane via env-var marker. //! -//! 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`. +//! How it works: +//! +//! 1. `pty.rs` tags every WSL spawn with `TILETOPIA_PANE_ID=` propagated +//! into the distro via `WSLENV`. The user's shell inherits it; every +//! descendant process inherits from the shell. So `claude` running in +//! pane N has `TILETOPIA_PANE_ID=N` in `/proc//environ`. +//! 2. This probe runs `pgrep -x ` for each watched process, then for +//! each PID it returns reads `/proc//environ` (null-separated) and +//! checks for an exact `TILETOPIA_PANE_ID=` entry. +//! 3. Cache keyed by `(distro, pane_id)`; ~3s TTL. +//! +//! PowerShell + SSH panes still skip the probe (frontend short-circuits). +//! No `/proc` on the remote side for SSH, no parallel concept on Windows. 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. +/// Built-in list of process names whose presence in a pane suppresses idle. /// -/// [[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. +/// [[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 today. 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. +/// How long a probe result is reused before we re-shell. Sized against the +/// frontend's 1s idle-tick interval — 3s means ~one `wsl.exe` call per +/// (distro, pane) per 3 ticks while reacting to "claude finished" within a +/// few seconds. Too short = wsl.exe spam; too long = stale answer once +/// claude 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. +/// found in this specific pane. #[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`). +/// Probe cache keyed by `(distro, pane_id)` so panes in the same distro +/// running different processes get independent answers. pub struct ProbeCache { - cache: Mutex>, + cache: Mutex>, } impl ProbeCache { @@ -67,17 +70,19 @@ impl ProbeCache { } } - /// 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 { + /// Returns true iff one of the watched processes is running in pane + /// `pane_id` of `distro`. Cached for {@link CACHE_TTL}. On probe failure + /// returns `false` — **fail-safe is to NOT suppress**. The v1 fail-safe + /// of "suppress on error" was wrong: a transient probe failure shouldn't + /// silence the idle indicator. Better to occasionally over-notify than + /// permanently silence. + pub fn is_watch_process_running(&self, distro: &str, pane_id: u64) -> bool { + let key = (distro.to_string(), pane_id); + // Fast path: fresh cached answer. { let guard = self.cache.lock(); - if let Some(entry) = guard.get(distro) { + if let Some(entry) = guard.get(&key) { if entry.at.elapsed() < CACHE_TTL { return entry.running; } @@ -85,12 +90,12 @@ impl ProbeCache { } // 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); + // probes aren't blocked. + let running = probe_pane(distro, pane_id, DEFAULT_WATCH_PROCESSES); let mut guard = self.cache.lock(); guard.insert( - distro.to_string(), + key, CacheEntry { at: Instant::now(), running, @@ -106,67 +111,88 @@ impl Default for ProbeCache { } } -/// 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 { +/// Bash one-liner: for each watched process name, `pgrep -x` for it; for +/// each matching PID, check `/proc//environ` for an exact +/// `TILETOPIA_PANE_ID=` entry (null-separated, so we `tr` it to +/// newlines and exact-line-match with `grep -xF`). Exit 0 = match, 1 = no +/// match, anything else = probe failure (treated as `false` upstream — +/// see fail-safe note on `is_watch_process_running`). +/// +/// `bash` (not `sh`) is required for process substitution `< <(pgrep ...)`. +/// Both bash and pgrep are installed by default on every WSL distro +/// tiletopia targets; if a minimal distro is missing them the probe falls +/// to "not running" and the pane goes idle normally (better than the v1 +/// fail-safe which kept suppressing forever). +const PROBE_SCRIPT: &str = r#" +target_id="$1" +shift +for name in "$@"; do + while IFS= read -r pid; do + [ -z "$pid" ] && continue + if [ -r "/proc/$pid/environ" ]; then + if tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | grep -qxF "TILETOPIA_PANE_ID=$target_id"; then + exit 0 + fi + fi + done < <(pgrep -x "$name" 2>/dev/null) +done +exit 1 +"#; + +fn probe_pane(distro: &str, pane_id: u64, 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. + // Non-Windows builds don't ship the app; pretend no watched process + // so developer test runs see the idle indicator working. 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; + tracing::debug!("probe: empty distro name; treating as not-running"); + return false; } - 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 -} + // Compose args: bash -c