Revert idle "claude foreground" filter — back to legacy 5s notify

Reverts in one combined commit:
- 9931a92 (inline pane_id + watch list into bash script)
- 6772b8d (pivot per-distro → per-pane via TILETOPIA_PANE_ID env)
- f51033a (original per-distro idle filter)

End-to-end probe never worked correctly against the real running app
even after fixing the wsl.exe-drops-positional-args bug. Probe script
ran fine in isolation but kept returning false-negative when called
through tiletopia's wsl.exe spawn. Rather than keep iterating, back
out cleanly — pane behaviour is now the original "go idle after 5s of
silence regardless of what's running."

memory.md session log notes the lessons for a future retry: don't ship
per-distro again (CLAUDE.md explicitly says multi-claude-per-distro is
the primary use case); prove the probe end-to-end before wiring into
the idle effect (a "Test probe" button in MCP panel would have caught
this in minutes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-26 18:33:11 +01:00
parent 9931a92c5f
commit 50fbd0e531
7 changed files with 27 additions and 486 deletions

View file

@ -29,8 +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-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.
- [ ] **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 <distro> ps --ppid <shell_pid> -o comm=`. Defer to a future polish pass.
- [ ] **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.
@ -53,83 +52,25 @@ 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)
### 2026-05-26 — Backed out idle "claude foreground" filter (kept legacy 5s notify)
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.
Shipped earlier today as per-distro, pivoted to per-pane via `TILETOPIA_PANE_ID` env marker, then a probe-script bug surfaced (positional args dropped by `wsl.exe -- bash -c "..." _ <id>`). Fixed the arg-passing by inlining values, but on real-app test the pane still showed idle while claude was running — and at that point the user (rightly) called credit waste and asked to back the whole feature out.
Fix: pivot to per-pane detection via env-var marker.
**Reverted commits** (in one combined revert):
- `9931a92` — inline pane_id / watch list into script (drop positional args)
- `6772b8d` — pivot per-distro → per-pane via TILETOPIA_PANE_ID env marker
- `f51033a` — original per-distro idle filter
**Mechanism:**
Now back to "every pane goes idle after 5s of silence" — the behaviour that worked before today's fan-out attempt. The `[[user-watch-list]]` marker in the open-questions section is removed; the original idle-filter TODO is restored.
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.
**Lessons for if/when we attempt this again:**
**Verification path** (user runs):
- The per-distro design fundamentally doesn't fit tiletopia (CLAUDE.md: "manage multiple claude sessions across projects in parallel"). Don't ship per-distro again.
- Per-pane via env-var marker is the right shape, BUT the probe still didn't work end-to-end in the real app even after the inline-args fix. The `pgrep` exit + `/proc/<pid>/environ` reads worked in isolation (verified manually from PowerShell) — something about how tiletopia's `wsl.exe` spawn differs from a manual invocation. Could be: stdin handling, working directory, environment context. Worth a from-scratch design rather than another fix-on-fix iteration.
- If we retry, prove the probe end-to-end against the running app FIRST (e.g. add a temporary "Test probe" button in the MCP panel that calls the Tauri command and shows the result) before wiring it into the idle effect. Validates the whole IPC path without the timing complications of the idle tick.
```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.
```
Restored the original idle-filter open question in the TODO section.
**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.
**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<HashMap<String, (Instant, bool)>>` 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 <distro> -- 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 — README shortcut table now generated from `shortcuts.ts`
The keyboard-shortcut table in README and the in-app help overlay used to be hand-mirrored copies maintained by "keep in sync" comments. They drifted (most recently the navigation/font-size entries diverged). Now `src/lib/shortcuts.ts` is the single source of truth and README's section is generated from it.

View file

@ -10,7 +10,6 @@ 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";
@ -303,31 +302,3 @@ 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,
pane_id: PaneId,
) -> 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, pane_id)
})
.await
.map_err(|e| format!("probe join failed: {e}"))?;
Ok(running)
}

View file

@ -5,13 +5,11 @@ 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() {
@ -42,9 +40,6 @@ 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())
@ -53,7 +48,6 @@ 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,
@ -76,7 +70,6 @@ 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");

View file

@ -1,230 +0,0 @@
//! "Is a watched process running in THIS pane?" probe for the idle filter.
//!
//! 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.
//!
//! ## Per-pane granularity (revised v2 design)
//!
//! 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.
//!
//! How it works:
//!
//! 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::time::{Duration, Instant};
use parking_lot::Mutex;
/// 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 today.
pub const DEFAULT_WATCH_PROCESSES: &[&str] = &["claude"];
/// 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 this specific pane.
#[derive(Clone, Copy)]
struct CacheEntry {
at: Instant,
running: bool,
}
/// Probe cache keyed by `(distro, pane_id)` so panes in the same distro
/// running different processes get independent answers.
pub struct ProbeCache {
cache: Mutex<HashMap<(String, u64), CacheEntry>>,
}
impl ProbeCache {
pub fn new() -> Self {
Self {
cache: Mutex::new(HashMap::new()),
}
}
/// 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(&key) {
if entry.at.elapsed() < CACHE_TTL {
return entry.running;
}
}
}
// Slow path: re-probe. Drop the lock before shelling out so other
// probes aren't blocked.
let running = probe_pane(distro, pane_id, DEFAULT_WATCH_PROCESSES);
tracing::debug!(
target: "tiletopia_lib::probe",
distro = %distro,
pane_id,
running,
"probed"
);
let mut guard = self.cache.lock();
guard.insert(
key,
CacheEntry {
at: Instant::now(),
running,
},
);
running
}
}
impl Default for ProbeCache {
fn default() -> Self {
Self::new()
}
}
/// Build a single-line bash script with the target pane_id and watch-list
/// **interpolated directly** into the script text. The earlier design
/// passed these as positional args (`bash -c "..." _ <id> <names...>`)
/// but `wsl.exe`'s arg-passing layer silently dropped everything after the
/// `-c` script string, so `$1`/`$@` were always empty inside bash.
/// Interpolating from Rust sidesteps the whole arg-passing path.
///
/// Both inputs are safe to inline: `pane_id` is a `u64` (no metachars) and
/// `watched` is a compile-time const list. If future code wires user-supplied
/// process names through here, validate/escape them first.
///
/// Returns exit 0 if any watched process running in this specific pane has
/// the matching `TILETOPIA_PANE_ID` env marker; exit 1 otherwise.
fn build_probe_script(pane_id: u64, watched: &[&str]) -> String {
let watch_list = watched
.iter()
.map(|n| format!("\"{n}\""))
.collect::<Vec<_>>()
.join(" ");
format!(
r#"target_id={pane_id}; for name in {watch_list}; do for pid in $(pgrep -x "$name" 2>/dev/null); 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; done; exit 1"#
)
}
fn probe_pane(distro: &str, pane_id: u64, watched: &[&str]) -> bool {
if !cfg!(windows) {
// 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() {
tracing::debug!("probe: empty distro name; treating as not-running");
return false;
}
let script = build_probe_script(pane_id, watched);
let args: Vec<String> = vec![
"-d".to_string(),
distro.to_string(),
"--".to_string(),
"bash".to_string(),
"-c".to_string(),
script,
];
let out = match crate::pty::quiet_command_pub("wsl.exe")
.args(&args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
{
Ok(o) => o,
Err(e) => {
tracing::info!(
"probe: wsl.exe spawn for distro={distro:?} pane={pane_id} failed: {e}"
);
return false;
}
};
match out.status.code() {
Some(0) => true,
Some(1) => false,
Some(other) => {
tracing::debug!(
"probe: distro={distro:?} pane={pane_id} bash exit={other} — treating as not-running"
);
false
}
None => {
tracing::debug!(
"probe: distro={distro:?} pane={pane_id} killed by signal — treating as not-running"
);
false
}
}
}
#[cfg(test)]
mod tests {
use super::build_probe_script;
/// Regression: positional args (`bash -c "..." _ <id> <names>`) were
/// silently dropped by wsl.exe's arg-passing layer, so `$1`/`$@` were
/// always empty inside bash. The script must inline pane_id + watch
/// names directly. Lock that in.
#[test]
fn build_probe_script_inlines_pane_id_and_watch_list() {
let s = build_probe_script(42, &["claude"]);
assert!(s.contains("target_id=42"), "pane_id not inlined: {s}");
assert!(s.contains("\"claude\""), "watch name not inlined: {s}");
// No "$1" or "$@" — those are the trap.
assert!(!s.contains("$1"), "script still references $1: {s}");
assert!(
!s.contains("\"$@\""),
"script still references $@: {s}"
);
// Sanity: matches the exact env marker shape pty.rs sets.
assert!(
s.contains("TILETOPIA_PANE_ID=$target_id"),
"marker lookup malformed: {s}"
);
}
#[test]
fn build_probe_script_multiple_watch_names() {
let s = build_probe_script(7, &["claude", "vim", "less"]);
assert!(s.contains("\"claude\" \"vim\" \"less\""), "watch list bad: {s}");
}
}

View file

@ -154,32 +154,7 @@ impl PtyManager {
_ => None,
};
// 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 (cmd, spawn_err) = build_command(&spec)?;
let child = pair.slave.spawn_command(cmd).context(spawn_err)?;
// We need to keep the master alive (drop = close the PTY), but we
@ -195,6 +170,8 @@ impl PtyManager {
let writer: SharedWriter = Arc::new(Mutex::new(writer_raw));
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(
id,
PaneHandle {
@ -480,13 +457,6 @@ 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)]
{

View file

@ -39,19 +39,6 @@ export interface SshHost {
export const listDistros = (): Promise<string[]> => invoke("list_distros");
/** Ask the backend whether any built-in "watched" process (currently just
* `claude`) is running in THIS specific pane (not just somewhere in the
* distro). Per-pane detection works via a `TILETOPIA_PANE_ID` env marker
* injected at spawn see src-tauri/src/probe.rs. Cached per (distro,
* pane_id) for ~3s. Probe failures resolve to `false` (don't suppress)
* better to occasionally over-notify than permanently silence. Only
* 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: {
spec: SpawnSpec;
cols: number;

View file

@ -10,7 +10,7 @@ import {
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
import { useOrchestration } from "./orchestration";
import XtermPane from "../../components/XtermPane";
import { isWatchProcessRunning, type SpawnSpec } from "../../ipc";
import type { SpawnSpec } from "../../ipc";
import "./LeafPane.css";
const IDLE_THRESHOLD_MS = 5000;
@ -116,29 +116,8 @@ 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 THIS
// pane specifically — per-pane, not per-distro. Per-pane is essential for
// tiletopia's primary use case (multiple claude sessions across panes in
// the same distro). The backend matches by reading `TILETOPIA_PANE_ID`
// out of each candidate process's `/proc/<pid>/environ` (the env var is
// injected at spawn time; see src-tauri/src/pty.rs WSLENV setup).
//
// PowerShell + SSH skip the probe and fall through to legacy behaviour
// (PS has no portable `ps`; SSH processes live on a 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 returns "not running" for
// empty input so the pane goes idle normally.
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(() => {
lastDataTimeRef.current = Date.now();
setIsIdle((cur) => {
@ -147,83 +126,17 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
});
}, [orch.reportLeafIdle, leaf.id]);
useEffect(() => {
// Guard against late-resolving probes after unmount or another tick
// already shipping a fresher answer.
let cancelled = false;
let inFlight = false;
const tick = () => {
const id = window.setInterval(() => {
const dt = Date.now() - lastDataTimeRef.current;
const nowIdle = dt >= IDLE_THRESHOLD_MS;
// 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;
const paneIdForProbe = paneIdRef.current ?? 0;
void isWatchProcessRunning(wslDistro, paneIdForProbe)
.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 in THIS pane —
// treat the silence as expected; 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 — don't flip idle either way; next tick retries.
// 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;
// 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]);
setIsIdle((cur) => {
if (cur === nowIdle) return cur;
orch.reportLeafIdle(leaf.id, nowIdle);
return nowIdle;
});
}, 1000);
return () => clearInterval(id);
}, [leaf.id, orch.reportLeafIdle]);
// Clear from the app-level idle set when this pane unmounts.
useEffect(() => {
return () => orch.reportLeafIdle(leaf.id, false);
@ -250,7 +163,6 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const onPaneSpawned = useCallback(
(paneId: number) => {
paneIdRef.current = paneId;
orch.registerPaneId(leaf.id, paneId);
},
[orch.registerPaneId, leaf.id],
@ -260,10 +172,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
// which broke broadcast routing (peers found, but their paneIds
// had been silently removed from the map).
useEffect(() => {
return () => {
paneIdRef.current = null;
orch.registerPaneId(leaf.id, null);
};
return () => orch.registerPaneId(leaf.id, null);
}, [orch.registerPaneId, leaf.id]);
const onXtermFocus = useCallback(