Idle filter: pivot per-distro → per-pane via TILETOPIA_PANE_ID env marker
Per-distro suppression (shipped earlier today) broke tiletopia's primary use case — multiple claude panes per distro means as soon as one runs claude, ALL Ubuntu panes go silent. Tested live: user couldn't reproduce idle on any pane because PID 46848 (their main session) tripped the gate. New mechanism, per-pane via env-var marker: 1. pty.rs tags every WSL spawn with TILETOPIA_PANE_ID=<id> as a Windows env var, plus WSLENV=...TILETOPIA_PANE_ID/u (appended to any pre- existing WSLENV) so the var forwards into the distro. Pane id is now reserved BEFORE build_command so the tag is available at spawn time. 2. probe.rs rewritten — is_watch_process_running(distro, pane_id) runs a bash one-liner that pgreps for each watched name, then for each PID checks /proc/<pid>/environ for the matching TILETOPIA_PANE_ID line. Env inheritance does the work: shell inherits from wsl.exe, claude inherits from shell. Cache keyed by (distro, pane_id). 3. Fail-safe INVERTED: probe failure now returns false (don't suppress) instead of true (suppress). A transient error should never silence the idle indicator permanently. Frontend catch updated to match. 4. LeafPane tracks PaneId in paneIdRef set by onPaneSpawned; idle ticks before spawn-completion pass 0, which won't match any real marker so the pane idles normally. Existing panes won't have the marker until respawned — they'll always show idle (since probe never matches). User opens fresh panes once after deploying this. Documented in memory.md follow-ups. pnpm check clean. Rust validation: cargo test --lib on Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d3474d33b0
commit
6772b8db37
6 changed files with 230 additions and 124 deletions
42
memory.md
42
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] ~~**M4 — orchestration.** Broadcast input, idle notifications, Ctrl+K palette.~~ Done 2026-05-22.
|
||||||
- [x] ~~**Auto-save debouncing.**~~ 500ms timer in `App.svelte` `$effect`.
|
- [x] ~~**Auto-save debouncing.**~~ 500ms timer in `App.svelte` `$effect`.
|
||||||
- [x] ~~**HMR distro picker reset.**~~ No longer an issue — per-pane distro selection.
|
- [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 <distro> -- 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=<id>` propagated via `WSLENV`; the probe runs `pgrep -x claude` in the distro then reads each match's `/proc/<pid>/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.
|
- [ ] **`[[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.
|
- [ ] **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.
|
- [ ] **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
|
## 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=<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 <name>`s for each watched process, then for each PID checks `/proc/<pid>/environ` for an exact `TILETOPIA_PANE_ID=<target>` 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
|
### 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -320,11 +320,12 @@ pub async fn mcp_hard_deny_labels() -> Result<Vec<&'static str>, String> {
|
||||||
pub async fn is_watch_process_running(
|
pub async fn is_watch_process_running(
|
||||||
cache: tauri::State<'_, Arc<ProbeCache>>,
|
cache: tauri::State<'_, Arc<ProbeCache>>,
|
||||||
distro: String,
|
distro: String,
|
||||||
|
pane_id: PaneId,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
// Probe shells out — keep it off the async runtime's thread.
|
// Probe shells out — keep it off the async runtime's thread.
|
||||||
let cache_arc: Arc<ProbeCache> = (*cache).clone();
|
let cache_arc: Arc<ProbeCache> = (*cache).clone();
|
||||||
let running = tokio::task::spawn_blocking(move || {
|
let running = tokio::task::spawn_blocking(move || {
|
||||||
cache_arc.is_watch_process_running(&distro)
|
cache_arc.is_watch_process_running(&distro, pane_id)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("probe join failed: {e}"))?;
|
.map_err(|e| format!("probe join failed: {e}"))?;
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,66 @@
|
||||||
//! "Is a watched process running in distro X?" probe for the idle-detection
|
//! "Is a watched process running in THIS pane?" probe for the idle filter.
|
||||||
//! filter.
|
|
||||||
//!
|
//!
|
||||||
//! Background: tiletopia's idle indicator fires whenever a pane goes 5s
|
//! Background: tiletopia's idle indicator fires when a pane goes 5s without
|
||||||
//! without PTY output. When the user is reading a long `claude` response,
|
//! PTY output. When the user is reading a long `claude` response the pane
|
||||||
//! the pane is silent but there's nothing actionable to surface — the
|
//! is silent but nothing actionable is happening — the indicator becomes
|
||||||
//! indicator becomes noise. This module lets the frontend ask the backend
|
//! noise. This module lets the frontend ask "is `claude` running in pane N?"
|
||||||
//! "is `claude` (or any other watched process) running in this distro?"
|
//! before flagging idle, and suppresses if so.
|
||||||
//! before flagging a pane idle, and suppresses the indicator if so.
|
|
||||||
//!
|
//!
|
||||||
//! Granularity is per-distro, not per-pane. Identifying which Windows pane
|
//! ## Per-pane granularity (revised v2 design)
|
||||||
//! 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-
|
//! v1 of this module was per-distro: one `pgrep` in the distro answered for
|
||||||
//! circuits to "always idle" for them. (PowerShell has no portable `ps`
|
//! all panes. That was wrong for tiletopia's primary use case — running
|
||||||
//! equivalent; SSH processes live on a remote box and would need a separate
|
//! multiple claude sessions across panes in the same distro is THE point of
|
||||||
//! transport.)
|
//! 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 <distro> -- pgrep -x ...`), which costs
|
//! How it works:
|
||||||
//! ~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`.
|
//! 1. `pty.rs` tags every WSL spawn with `TILETOPIA_PANE_ID=<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/<claude_pid>/environ`.
|
||||||
|
//! 2. This probe runs `pgrep -x <name>` for each watched process, then for
|
||||||
|
//! each PID it returns reads `/proc/<pid>/environ` (null-separated) and
|
||||||
|
//! checks for an exact `TILETOPIA_PANE_ID=<target>` 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::collections::HashMap;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
/// Built-in list of process names that suppress idle when running. v1 ships
|
/// Built-in list of process names whose presence in a pane suppresses idle.
|
||||||
/// 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
|
/// [[user-watch-list]] TODO: surface this as a user-editable list
|
||||||
/// config field or dedicated `watch.json`). For now the constant covers the
|
/// (workspace config field or dedicated `watch.json`). For now the constant
|
||||||
/// only real-world use case (Anthropic's `claude` CLI taking its time on a
|
/// covers the only real-world use case (Anthropic's `claude` CLI taking its
|
||||||
/// long response). Adding entries to the constant is the only knob.
|
/// time on a long response). Adding entries to the constant is the only
|
||||||
|
/// knob today.
|
||||||
pub const DEFAULT_WATCH_PROCESSES: &[&str] = &["claude"];
|
pub const DEFAULT_WATCH_PROCESSES: &[&str] = &["claude"];
|
||||||
|
|
||||||
/// How long a per-distro probe result is reused before we re-shell. Sized
|
/// How long a probe result is reused before we re-shell. Sized against the
|
||||||
/// against the frontend's 1s idle-tick interval — 3s means roughly one
|
/// frontend's 1s idle-tick interval — 3s means ~one `wsl.exe` call per
|
||||||
/// probe per distro per 3 ticks even with many panes polling, while still
|
/// (distro, pane) per 3 ticks while reacting to "claude finished" within a
|
||||||
/// reacting to "claude just finished" within a few seconds. Trade-off: too
|
/// few seconds. Too short = wsl.exe spam; too long = stale answer once
|
||||||
/// short = wsl.exe spam, too long = stale "claude is running" once the
|
/// claude actually exits.
|
||||||
/// process actually exits.
|
|
||||||
const CACHE_TTL: Duration = Duration::from_secs(3);
|
const CACHE_TTL: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
/// Cache entry: timestamp the probe ran + whether any watched process was
|
/// Cache entry: timestamp the probe ran + whether any watched process was
|
||||||
/// found in the distro.
|
/// found in this specific pane.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct CacheEntry {
|
struct CacheEntry {
|
||||||
at: Instant,
|
at: Instant,
|
||||||
running: bool,
|
running: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-distro probe cache. Keyed by distro name (the same string the user
|
/// Probe cache keyed by `(distro, pane_id)` so panes in the same distro
|
||||||
/// sees in the shell picker; the same string we pass as `wsl.exe -d`).
|
/// running different processes get independent answers.
|
||||||
pub struct ProbeCache {
|
pub struct ProbeCache {
|
||||||
cache: Mutex<HashMap<String, CacheEntry>>,
|
cache: Mutex<HashMap<(String, u64), CacheEntry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProbeCache {
|
impl ProbeCache {
|
||||||
|
|
@ -67,17 +70,19 @@ impl ProbeCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true iff one of the watched processes is running in the
|
/// Returns true iff one of the watched processes is running in pane
|
||||||
/// distro. Cached for {@link CACHE_TTL}; cache misses (or stale entries)
|
/// `pane_id` of `distro`. Cached for {@link CACHE_TTL}. On probe failure
|
||||||
/// trigger a fresh probe. On probe failure the result is `true` —
|
/// returns `false` — **fail-safe is to NOT suppress**. The v1 fail-safe
|
||||||
/// **fail-safe is to suppress** the idle indicator, matching the
|
/// of "suppress on error" was wrong: a transient probe failure shouldn't
|
||||||
/// agreed trade-off ("over-suppression beats the previous always-notify
|
/// silence the idle indicator. Better to occasionally over-notify than
|
||||||
/// behaviour").
|
/// permanently silence.
|
||||||
pub fn is_watch_process_running(&self, distro: &str) -> bool {
|
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.
|
// Fast path: fresh cached answer.
|
||||||
{
|
{
|
||||||
let guard = self.cache.lock();
|
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 {
|
if entry.at.elapsed() < CACHE_TTL {
|
||||||
return entry.running;
|
return entry.running;
|
||||||
}
|
}
|
||||||
|
|
@ -85,12 +90,12 @@ impl ProbeCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: re-probe. Drop the lock before shelling out so other
|
// Slow path: re-probe. Drop the lock before shelling out so other
|
||||||
// distros' probes aren't blocked.
|
// probes aren't blocked.
|
||||||
let running = probe_distro(distro, DEFAULT_WATCH_PROCESSES);
|
let running = probe_pane(distro, pane_id, DEFAULT_WATCH_PROCESSES);
|
||||||
|
|
||||||
let mut guard = self.cache.lock();
|
let mut guard = self.cache.lock();
|
||||||
guard.insert(
|
guard.insert(
|
||||||
distro.to_string(),
|
key,
|
||||||
CacheEntry {
|
CacheEntry {
|
||||||
at: Instant::now(),
|
at: Instant::now(),
|
||||||
running,
|
running,
|
||||||
|
|
@ -106,67 +111,88 @@ impl Default for ProbeCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `wsl.exe -d <distro> -- pgrep -x <name>` for each watched name.
|
/// Bash one-liner: for each watched process name, `pgrep -x` for it; for
|
||||||
/// Returns true on the first hit. On any failure (wsl.exe missing, distro
|
/// each matching PID, check `/proc/<pid>/environ` for an exact
|
||||||
/// not running, pgrep not installed, timeout) returns true — fail-safe is
|
/// `TILETOPIA_PANE_ID=<target>` entry (null-separated, so we `tr` it to
|
||||||
/// suppression.
|
/// newlines and exact-line-match with `grep -xF`). Exit 0 = match, 1 = no
|
||||||
fn probe_distro(distro: &str, watched: &[&str]) -> bool {
|
/// 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) {
|
if !cfg!(windows) {
|
||||||
// Non-Windows builds don't actually ship the app; pretend no watched
|
// Non-Windows builds don't ship the app; pretend no watched process
|
||||||
// process so the idle indicator works for developer test runs.
|
// so developer test runs see the idle indicator working.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if distro.is_empty() {
|
if distro.is_empty() {
|
||||||
// We can't probe an empty distro name; treat as "no info" → fail-safe.
|
tracing::debug!("probe: empty distro name; treating as not-running");
|
||||||
tracing::debug!("probe: empty distro name; defaulting to suppression");
|
return false;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in watched {
|
// Compose args: bash -c <script> _ <pane_id> <watch>...
|
||||||
match probe_one(distro, name) {
|
// The `_` is `$0` for the script, then watch names are `$@`.
|
||||||
Ok(true) => return true,
|
let mut args: Vec<String> = vec![
|
||||||
Ok(false) => continue,
|
"-d".to_string(),
|
||||||
Err(e) => {
|
distro.to_string(),
|
||||||
tracing::debug!(
|
"--".to_string(),
|
||||||
"probe: wsl pgrep for {name:?} in {distro:?} failed: {e} — suppressing idle"
|
"bash".to_string(),
|
||||||
);
|
"-c".to_string(),
|
||||||
return true;
|
PROBE_SCRIPT.to_string(),
|
||||||
}
|
"_".to_string(),
|
||||||
}
|
pane_id.to_string(),
|
||||||
}
|
];
|
||||||
false
|
args.extend(watched.iter().map(|s| s.to_string()));
|
||||||
}
|
|
||||||
|
|
||||||
/// Single `pgrep -x <name>` invocation. Ok(true) on a match, Ok(false) on
|
let out = match crate::pty::quiet_command_pub("wsl.exe")
|
||||||
/// exit code 1 (no match), Err on anything else. Wrapped in our standard
|
.args(&args)
|
||||||
/// `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())
|
.stdout(std::process::Stdio::null())
|
||||||
.stderr(std::process::Stdio::null())
|
.stderr(std::process::Stdio::null())
|
||||||
.output()?;
|
.output()
|
||||||
|
{
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"probe: wsl.exe spawn for distro={distro:?} pane={pane_id} failed: {e}"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match out.status.code() {
|
match out.status.code() {
|
||||||
Some(0) => Ok(true), // pgrep found at least one match
|
Some(0) => true, // watched process matching this pane found
|
||||||
Some(1) => Ok(false), // pgrep ran but found nothing
|
Some(1) => false, // no match
|
||||||
Some(other) => {
|
Some(other) => {
|
||||||
// 2 = syntax error in pgrep itself; 3 = fatal error; 127 = command
|
tracing::debug!(
|
||||||
// not found. None of these mean "definitively no claude running",
|
"probe: distro={distro:?} pane={pane_id} bash exit={other} — treating as not-running"
|
||||||
// so treat as a probe failure (caller fails-safe to true).
|
);
|
||||||
Err(std::io::Error::other(format!(
|
false
|
||||||
"pgrep exit code {other}"
|
}
|
||||||
)))
|
None => {
|
||||||
|
tracing::debug!(
|
||||||
|
"probe: distro={distro:?} pane={pane_id} killed by signal — treating as not-running"
|
||||||
|
);
|
||||||
|
false
|
||||||
}
|
}
|
||||||
None => Err(std::io::Error::other("pgrep killed by signal")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,32 @@ impl PtyManager {
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (cmd, spawn_err) = build_command(&spec)?;
|
// Reserve the pane id BEFORE spawning so we can tag the shell's
|
||||||
|
// env with it — see TILETOPIA_PANE_ID below. We still insert into
|
||||||
|
// the panes map further down, after the reader thread is wired.
|
||||||
|
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let (mut cmd, spawn_err) = build_command(&spec)?;
|
||||||
|
// WSL panes get a TILETOPIA_PANE_ID env marker so the idle-filter
|
||||||
|
// probe (probe.rs) can tell which descendant processes belong to
|
||||||
|
// which pane — inheritance does the work: the shell inherits from
|
||||||
|
// wsl.exe via WSLENV, and every child (e.g. claude) inherits from
|
||||||
|
// the shell, so checking `/proc/<pid>/environ` for the marker
|
||||||
|
// answers "is this process running in pane N?" exactly.
|
||||||
|
if matches!(spec, SpawnSpec::Wsl { .. }) {
|
||||||
|
cmd.env("TILETOPIA_PANE_ID", id.to_string());
|
||||||
|
// WSLENV controls which Windows-side env vars are forwarded into
|
||||||
|
// the distro. Append our marker rather than clobbering — users
|
||||||
|
// may have their own WSLENV set up. `/u` = always pass through
|
||||||
|
// as a Unix-style env var.
|
||||||
|
let existing = std::env::var("WSLENV").unwrap_or_default();
|
||||||
|
let combined = if existing.is_empty() {
|
||||||
|
"TILETOPIA_PANE_ID/u".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{existing}:TILETOPIA_PANE_ID/u")
|
||||||
|
};
|
||||||
|
cmd.env("WSLENV", combined);
|
||||||
|
}
|
||||||
let child = pair.slave.spawn_command(cmd).context(spawn_err)?;
|
let child = pair.slave.spawn_command(cmd).context(spawn_err)?;
|
||||||
|
|
||||||
// We need to keep the master alive (drop = close the PTY), but we
|
// We need to keep the master alive (drop = close the PTY), but we
|
||||||
|
|
@ -170,8 +195,6 @@ impl PtyManager {
|
||||||
let writer: SharedWriter = Arc::new(Mutex::new(writer_raw));
|
let writer: SharedWriter = Arc::new(Mutex::new(writer_raw));
|
||||||
let ring: Arc<Mutex<PaneRing>> = Arc::new(Mutex::new(PaneRing::new()));
|
let ring: Arc<Mutex<PaneRing>> = Arc::new(Mutex::new(PaneRing::new()));
|
||||||
|
|
||||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
|
||||||
|
|
||||||
self.panes.lock().insert(
|
self.panes.lock().insert(
|
||||||
id,
|
id,
|
||||||
PaneHandle {
|
PaneHandle {
|
||||||
|
|
|
||||||
17
src/ipc.ts
17
src/ipc.ts
|
|
@ -40,12 +40,17 @@ export interface SshHost {
|
||||||
export const listDistros = (): Promise<string[]> => invoke("list_distros");
|
export const listDistros = (): Promise<string[]> => invoke("list_distros");
|
||||||
|
|
||||||
/** Ask the backend whether any built-in "watched" process (currently just
|
/** Ask the backend whether any built-in "watched" process (currently just
|
||||||
* `claude`) is running in the given WSL distro. Cached per-distro for ~3s
|
* `claude`) is running in THIS specific pane (not just somewhere in the
|
||||||
* on the Rust side. Fail-safe: probe failures resolve to `true` so the
|
* distro). Per-pane detection works via a `TILETOPIA_PANE_ID` env marker
|
||||||
* caller suppresses the idle indicator. Only meaningful for WSL panes —
|
* injected at spawn — see src-tauri/src/probe.rs. Cached per (distro,
|
||||||
* PowerShell + SSH should skip this and fall back to always-notify. */
|
* pane_id) for ~3s. Probe failures resolve to `false` (don't suppress) —
|
||||||
export const isWatchProcessRunning = (distro: string): Promise<boolean> =>
|
* better to occasionally over-notify than permanently silence. Only
|
||||||
invoke("is_watch_process_running", { distro });
|
* meaningful for WSL panes; PS + SSH should skip this. */
|
||||||
|
export const isWatchProcessRunning = (
|
||||||
|
distro: string,
|
||||||
|
paneId: number,
|
||||||
|
): Promise<boolean> =>
|
||||||
|
invoke("is_watch_process_running", { distro, paneId });
|
||||||
|
|
||||||
export const spawnPane = (args: {
|
export const spawnPane = (args: {
|
||||||
spec: SpawnSpec;
|
spec: SpawnSpec;
|
||||||
|
|
|
||||||
|
|
@ -117,23 +117,28 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
// Local boolean for the red border + status text on this pane; reported
|
// 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.
|
// 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
|
// Filter: for WSL panes, before flagging idle we probe the backend to see
|
||||||
// see if any "watched" process (currently just `claude`) is running in
|
// if any "watched" process (currently just `claude`) is running in THIS
|
||||||
// the distro. If it is, the silence is "claude thinking / user reading",
|
// pane specifically — per-pane, not per-distro. Per-pane is essential for
|
||||||
// not "nothing happening" — stay quiet. Probe is per-distro (not per-
|
// tiletopia's primary use case (multiple claude sessions across panes in
|
||||||
// pane: the inside-WSL PID isn't observable from Windows), so multiple
|
// the same distro). The backend matches by reading `TILETOPIA_PANE_ID`
|
||||||
// panes in the same distro will all suppress if claude is running in
|
// out of each candidate process's `/proc/<pid>/environ` (the env var is
|
||||||
// any of them. Agreed trade-off; over-suppression beats the previous
|
// injected at spawn time; see src-tauri/src/pty.rs WSLENV setup).
|
||||||
// always-notify behaviour.
|
|
||||||
//
|
//
|
||||||
// PowerShell + SSH skip the probe and fall through to legacy behaviour
|
// PowerShell + SSH skip the probe and fall through to legacy behaviour
|
||||||
// (PS has no portable `ps`; SSH processes live on the remote box).
|
// (PS has no portable `ps`; SSH processes live on a remote box).
|
||||||
const lastDataTimeRef = useRef(Date.now());
|
const lastDataTimeRef = useRef(Date.now());
|
||||||
const [isIdle, setIsIdle] = useState(false);
|
const [isIdle, setIsIdle] = useState(false);
|
||||||
const isWslPane = leaf.shellKind === "wsl";
|
const isWslPane = leaf.shellKind === "wsl";
|
||||||
// Captures the distro name into the interval callback. Empty string when
|
// 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.
|
// the leaf doesn't have one yet — the probe returns "not running" for
|
||||||
|
// empty input so the pane goes idle normally.
|
||||||
const wslDistro = isWslPane ? (leaf.distro ?? "") : "";
|
const wslDistro = isWslPane ? (leaf.distro ?? "") : "";
|
||||||
|
// Backend pane id (PaneId, the u64 used inside Rust). Set by the
|
||||||
|
// XtermPane onSpawn callback; null until the spawn round-trip completes.
|
||||||
|
// Idle ticks before that point pass 0 — won't match any real pane's
|
||||||
|
// TILETOPIA_PANE_ID env, so the probe returns false (no suppression).
|
||||||
|
const paneIdRef = useRef<number | null>(null);
|
||||||
const onDataReceived = useCallback(() => {
|
const onDataReceived = useCallback(() => {
|
||||||
lastDataTimeRef.current = Date.now();
|
lastDataTimeRef.current = Date.now();
|
||||||
setIsIdle((cur) => {
|
setIsIdle((cur) => {
|
||||||
|
|
@ -177,15 +182,16 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
// WSL path. Don't stack probes — one in flight per pane at a time.
|
// WSL path. Don't stack probes — one in flight per pane at a time.
|
||||||
if (inFlight) return;
|
if (inFlight) return;
|
||||||
inFlight = true;
|
inFlight = true;
|
||||||
void isWatchProcessRunning(wslDistro)
|
const paneIdForProbe = paneIdRef.current ?? 0;
|
||||||
|
void isWatchProcessRunning(wslDistro, paneIdForProbe)
|
||||||
.then((suppress) => {
|
.then((suppress) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
// If output arrived while the probe was in flight, the next tick
|
// If output arrived while the probe was in flight, the next tick
|
||||||
// (or onDataReceived) will reconcile; don't flip-flop here.
|
// (or onDataReceived) will reconcile; don't flip-flop here.
|
||||||
if (Date.now() - lastDataTimeRef.current < IDLE_THRESHOLD_MS) return;
|
if (Date.now() - lastDataTimeRef.current < IDLE_THRESHOLD_MS) return;
|
||||||
if (suppress) {
|
if (suppress) {
|
||||||
// claude (or another watched proc) is running — treat silence
|
// claude (or another watched proc) is running in THIS pane —
|
||||||
// as expected and stay out of the idle set.
|
// treat the silence as expected; stay out of the idle set.
|
||||||
setIsIdle((cur) => {
|
setIsIdle((cur) => {
|
||||||
if (!cur) return cur;
|
if (!cur) return cur;
|
||||||
orch.reportLeafIdle(leaf.id, false);
|
orch.reportLeafIdle(leaf.id, false);
|
||||||
|
|
@ -200,8 +206,9 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
// Probe IPC errored — fail-safe to suppression (matches the Rust
|
// Probe IPC errored — don't flip idle either way; next tick retries.
|
||||||
// side's own fail-safe).
|
// The Rust side now also fails-safe to "not running" so the pane
|
||||||
|
// will flag idle eventually if the probe stays broken.
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.debug("idle probe failed", e);
|
console.debug("idle probe failed", e);
|
||||||
|
|
@ -243,6 +250,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
|
|
||||||
const onPaneSpawned = useCallback(
|
const onPaneSpawned = useCallback(
|
||||||
(paneId: number) => {
|
(paneId: number) => {
|
||||||
|
paneIdRef.current = paneId;
|
||||||
orch.registerPaneId(leaf.id, paneId);
|
orch.registerPaneId(leaf.id, paneId);
|
||||||
},
|
},
|
||||||
[orch.registerPaneId, leaf.id],
|
[orch.registerPaneId, leaf.id],
|
||||||
|
|
@ -252,7 +260,10 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
// which broke broadcast routing (peers found, but their paneIds
|
// which broke broadcast routing (peers found, but their paneIds
|
||||||
// had been silently removed from the map).
|
// had been silently removed from the map).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => orch.registerPaneId(leaf.id, null);
|
return () => {
|
||||||
|
paneIdRef.current = null;
|
||||||
|
orch.registerPaneId(leaf.id, null);
|
||||||
|
};
|
||||||
}, [orch.registerPaneId, leaf.id]);
|
}, [orch.registerPaneId, leaf.id]);
|
||||||
|
|
||||||
const onXtermFocus = useCallback(
|
const onXtermFocus = useCallback(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue