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:
megaproxy 2026-05-26 17:58:51 +01:00
parent d3474d33b0
commit 6772b8db37
6 changed files with 230 additions and 124 deletions

View file

@ -117,23 +117,28 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
// 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.
// 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 the remote box).
// (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 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 ?? "") : "";
// 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) => {
@ -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.
if (inFlight) return;
inFlight = true;
void isWatchProcessRunning(wslDistro)
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 — treat silence
// as expected and stay out of the idle set.
// 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);
@ -200,8 +206,9 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
}
})
.catch((e) => {
// Probe IPC errored — fail-safe to suppression (matches the Rust
// side's own fail-safe).
// 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);
@ -243,6 +250,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const onPaneSpawned = useCallback(
(paneId: number) => {
paneIdRef.current = paneId;
orch.registerPaneId(leaf.id, paneId);
},
[orch.registerPaneId, leaf.id],
@ -252,7 +260,10 @@ 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 () => orch.registerPaneId(leaf.id, null);
return () => {
paneIdRef.current = null;
orch.registerPaneId(leaf.id, null);
};
}, [orch.registerPaneId, leaf.id]);
const onXtermFocus = useCallback(