diff --git a/.gitignore b/.gitignore
index 81ba218..a5cdf6b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,6 @@
# Node / build
node_modules/
dist/
-dist-mcpb/
.svelte-kit/
.pnpm-store/
*.tsbuildinfo
diff --git a/README.md b/README.md
index cd394e0..ad5214c 100644
--- a/README.md
+++ b/README.md
@@ -21,72 +21,25 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
## Using it
-### Shortcuts and tips
-
-
-
-#### Keyboard shortcuts
-
-**Layout**
+### Keyboard shortcuts
| Key | Action |
|---|---|
-| `Ctrl+Shift+E` | Split active pane to the right |
-| `Ctrl+Shift+O` | Split active pane downward |
-| `Ctrl+Shift+W` | Close active pane |
-| `Ctrl+Shift+P` | Promote active pane out one level (turns a nested pane into a full row/column; self-inverse) |
-
-**Navigation**
-
-| Key | Action |
-|---|---|
-| `Ctrl+K` | Open jump-to-pane palette |
-| `Ctrl+Shift+← / → / ↑ / ↓` | Focus neighbour pane in that direction |
-
-**Broadcast**
-
-| Key | Action |
-|---|---|
-| `Ctrl+Shift+B` | Toggle broadcast on active pane |
-| `Ctrl+Shift+Alt+B` | Toggle broadcast on ALL panes (same as titlebar 📡) |
-
-**Font size**
-
-| Key | Action |
-|---|---|
-| `Ctrl+= / Ctrl+- / Ctrl+0` | Zoom active pane in / out / reset |
-| `Ctrl+Shift+= / Ctrl+Shift+- / Ctrl+Shift+0` | Same, applied to every pane |
-
-**Terminal**
-
-| Key | Action |
-|---|---|
-| `Ctrl+Shift+C / Ctrl+Shift+V` | Copy selection / paste in terminal |
-
-**Help**
-
-| Key | Action |
-|---|---|
-| `F1` | Show this help overlay |
-
-#### Tips
-
-- **Per-pane shell picker** — Click the distro chip in any pane's toolbar to switch between WSL distros, PowerShell, or a saved SSH host. The pane respawns with the new shell.
-- **SSH host manager** — Titlebar 🔑 SSH hosts opens the manager. Add hostname / user / port / identity file / jump host / extra ssh args. Saved hosts appear in every pane's dropdown.
-- **Saved passwords** — Optionally save a host's password — stored in Windows Credential Manager (DPAPI-encrypted), never written to hosts.json. When ssh prompts on connect it's typed automatically. Hosts with a saved password show 🔒 in the list.
-- **Clickable links** — http and https URLs in terminal output get underlined and open in your default browser on click.
-- **Drag pane headers to swap** — Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard.
-- **Workspace persistence** — Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.
-- **MCP server (let Claude drive the workspace)** — Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.
-
-
+| `Ctrl+K` | open the jump-to-pane palette (fuzzy match over label / distro / cwd; `↑`/`↓` to move, `Enter` to focus, `Esc` to close) |
+| `Ctrl+Shift+E` | split active pane to the right |
+| `Ctrl+Shift+O` | split active pane downward |
+| `Ctrl+Shift+W` | close active pane |
+| `Ctrl+Shift+P` | promote active pane out one level — turns a nested pane into a full row/column (e.g. nested-right `c` becomes a full-width bottom row). Self-inverse. |
+| `Ctrl+Shift+B` | toggle broadcast on active pane |
+| `Ctrl+Shift+Alt+B` | toggle broadcast on ALL panes (same as the titlebar 📡 button) |
+| `Ctrl+Shift+←` / `→` / `↑` / `↓` | focus neighbour pane in that direction |
+| `Ctrl+=` / `Ctrl+-` / `Ctrl+0` | zoom the active pane in / out / back to default |
+| `Ctrl+Shift+=` / `Ctrl+Shift+-` / `Ctrl+Shift+0` | same, applied to **every** pane (shift = "to all") |
Shortcuts work while a terminal is focused — we capture the key before xterm.js sees it. They don't fire while you're typing into a label edit or the palette input, so those still work normally. `Ctrl` and `⌘` (Cmd) are interchangeable.
Font size persists per pane in `workspace.json`, so a zoomed pane stays zoomed across restarts.
-> The shortcut tables and tips above are generated from `src/lib/shortcuts.ts` (the single source of truth shared with the in-app help overlay). To change them, edit that file and run `pnpm gen:readme`.
-
### Mouse + toolbar
- **Split panes** — `⇥` in the pane toolbar splits right, `⇣` splits down. The new pane inherits the parent's distro; the cwd defaults to `~` in the WSL distro.
@@ -114,25 +67,9 @@ The titlebar 🤖 button opens a small panel that starts an MCP (Model Context P
- **Saved SSH passwords are never exposed** through the MCP surface.
- **Bound to all interfaces** (`0.0.0.0`). The bearer token is the only auth — don't enable the server on an untrusted network.
-#### Claude Desktop setup (one-click via `.mcpb` bundle — recommended)
+#### Claude Code setup (via `mcp-remote` stdio shim)
-The MCP panel has a **Download .mcpb** button that fetches a packaged Claude Desktop extension (an `.mcpb` file). Drag it into Claude Desktop's *Settings → Extensions* pane and Claude will auto-discover tiletopia — no config editing, no copy-pasting tokens.
-
-The bundle ships a tiny wrapper that reads your per-install bearer token straight from `%APPDATA%\com.megaproxy.tiletopia\mcp.json` at launch, so:
-
-- It carries **no secrets** — the same file works for every tiletopia install.
-- **Token regeneration** in the panel keeps working transparently; the next time Claude Desktop launches the extension, it'll pick up the new token.
-- Requires `npx` (Node 18+) on PATH because the wrapper still talks to tiletopia through `mcp-remote` (same reason as the manual recipe below).
-
-You can also rebuild the bundle from source:
-
-```sh
-pnpm run build:mcpb # writes dist-mcpb/tiletopia.mcpb
-```
-
-#### Claude Code setup (via `mcp-remote` stdio shim — fallback / manual recipe)
-
-Claude Code (the terminal CLI) doesn't accept `.mcpb` bundles yet, and its HTTP-MCP client currently tries OAuth discovery and ignores static `headers` auth (Anthropic [#17152](https://github.com/anthropics/claude-code/issues/17152), [#46879](https://github.com/anthropics/claude-code/issues/46879)). The [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio shim transparently proxies the HTTP endpoint with the bearer header attached, sidestepping the OAuth flow.
+Claude Code's HTTP-MCP client currently tries OAuth discovery and ignores static `headers` auth (Anthropic [#17152](https://github.com/anthropics/claude-code/issues/17152), [#46879](https://github.com/anthropics/claude-code/issues/46879)). The [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio shim transparently proxies the HTTP endpoint with the bearer header attached, sidestepping the OAuth flow.
The panel's config snippet uses this shim by default — paste it into your project's `.mcp.json`:
diff --git a/memory.md b/memory.md
index 9f9823c..07efafc 100644
--- a/memory.md
+++ b/memory.md
@@ -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-distro probe via `wsl.exe -d -- pgrep -x claude`, cached 3s on the Rust side. WSL panes only; PS + SSH fall back to legacy always-notify. Watched list hardcoded to `["claude"]` — `[[user-watch-list]]` follow-up below.
-- [ ] **`[[user-watch-list]]` — user-configurable idle-suppress process list.** v1 hardcodes `DEFAULT_WATCH_PROCESSES = ["claude"]` in `src-tauri/src/probe.rs`. Move to a workspace-config field (or dedicated `watch.json`) so users can add `cargo`, `npm test`, `pytest`, etc. without a recompile. Two design notes: (1) the values are passed straight to `pgrep -x`, so user-supplied strings must be validated (no shell metachars / leading `-`) before reaching `probe_one`; (2) the cache key is currently just the distro name — if the watched-list becomes per-pane / per-workspace, key the cache by `(distro, sorted_watch_list)` to prevent stale answers.
+- [ ] **Idle detection: filter by "claude is foreground."** Currently every pane notifies after 5s silence, which fires too eagerly when the user is reading a `claude` response. Want to detect that `claude` (or any user-specified process) is actually running in the pane's shell before notifying. Needs a Rust-side probe over WSL: `wsl.exe -d ps --ppid -o comm=`. Defer to a future polish pass.
- [ ] **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,94 +52,6 @@ Durable memory for this project. Read at session start, update before session en
## Session log
-### 2026-05-26 — Idle filter: suppress when `claude` is running in the distro
-
-The idle indicator used to fire 5s after any silence, regardless of what the pane was doing. While the user reads a long `claude` response the pane is silent (claude is processing or the human is reading) and the red border + titlebar "N idle" count is just noise. Fixed: WSL panes now probe the backend before flagging idle, and stay quiet if `claude` is running anywhere in the distro.
-
-**Granularity is per-distro, not per-pane.** Identifying which Windows pane corresponds to which Linux-side shell inside WSL is too complex (PIDs aren't visible from Windows; ProcMon-style probes are fragile). Agreed trade-off: if claude is running in distro X, ALL panes in distro X suppress. Over-suppression for multi-pane-same-distro users is fine — the previous always-notify bug was worse, and that user pattern is the minority.
-
-**Architecture:**
-
-1. New `src-tauri/src/probe.rs` module with `ProbeCache` — `parking_lot::Mutex>` keyed by distro name, 3s TTL. Sized against the frontend's 1s idle-tick: ~one `wsl.exe` call per distro per 3 ticks even with many panes polling, while reacting to "claude finished" within a few seconds.
-2. Probe command runs `wsl.exe -d -- pgrep -x claude` via `quiet_command_pub` (new public alias of the existing `quiet_command` in pty.rs so cross-module callers don't re-implement the `CREATE_NO_WINDOW` dance). Exit 0 = match, exit 1 = no match, anything else = probe failure.
-3. **Fail-safe is suppression.** Any probe error (wsl.exe missing, distro stopped, pgrep not installed) resolves to `true` → frontend suppresses the idle indicator. Matches the agreed trade-off: over-suppression beats false-positive notifications.
-4. New Tauri command `is_watch_process_running(distro)`. Wrapped in `tokio::task::spawn_blocking` because the shell-out can take 100-300ms — keep it off the async runtime's thread pool.
-5. `LeafPane.tsx` idle-detection effect rewritten: when the tick says "now idle", branch by `shellKind`. WSL → probe backend, suppress if true. PowerShell + SSH → skip the probe and fall back to legacy behaviour (PS has no portable `ps`; SSH processes live on a remote box; out of scope for v1). Includes `inFlight` guard so a slow probe doesn't stack with subsequent ticks, and a `cancelled` flag for the React-18-StrictMode cleanup pattern we always use here.
-
-**Watched list is currently hardcoded.** `DEFAULT_WATCH_PROCESSES: &[&str] = &["claude"]` in probe.rs. Comment marks the v2 follow-up: surface as a workspace-config field, key the cache by `(distro, sorted_list)` if it becomes per-pane, and validate user-supplied strings against `pgrep` shell-injection (no `-` prefix, no shell metachars).
-
-**Files touched:**
-
-- `src-tauri/src/probe.rs` — new module (~150 lines).
-- `src-tauri/src/pty.rs` — `quiet_command_pub` exposed for cross-module use.
-- `src-tauri/src/lib.rs` — register the module, the `ProbeCache` state, and the command in `invoke_handler`.
-- `src-tauri/src/commands.rs` — `is_watch_process_running` Tauri command.
-- `src/ipc.ts` — `isWatchProcessRunning` TS wrapper.
-- `src/lib/layout/LeafPane.tsx` — idle-detection effect now branches on shellKind and gates WSL transitions through the probe.
-
-**Validated:**
-
-- `pnpm check` clean (0 errors).
-- `pnpm test` clean (72 tree.ts tests pass — no UI tests yet, so the React-side change isn't covered automatically).
-- Rust side authored in WSL; user to run `cargo build / cargo check -p tiletopia_lib` from Windows before merging.
-
-Open follow-ups specific to this session:
-
-- **`[[user-watch-list]]` config surface.** See open-questions section above. Probably 30 min of work: add `watchProcesses?: string[]` to workspace.json, validate per-name (no `-`, no shell metachars, length cap), thread through to a new `is_watch_process_running_for` command that takes the list, key the cache by `(distro, sorted_list_hash)`.
-- **Probe latency-as-jitter.** First idle tick after 5s silence triggers a 100-300ms `wsl.exe` shell-out. The user sees the red border flicker on for ~one tick before the probe resolves and clears it. Not visually obvious in practice (the red is already a transient signal), but could pre-warm the cache on a slower interval if it bites.
-- **PowerShell idle filter.** PS has no `ps` equivalent we can probe cheaply; closest is `Get-Process` + a watched-list mapping (`claude` doesn't exist on Windows, but `cargo`, `npm`, `python` do). Defer until someone actually runs a long-running CLI in PS and complains.
-- **Workspace-edit migration of the `LeafPane.svelte` mention** in the open-question section about the 5000ms threshold — file says `.svelte` but we're React now. Drive-by, not done here ("don't refactor unrelated code").
-### 2026-05-26 — 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.
-
-**Marker shape:** plain HTML comments — `` and ``. Markdown viewers render them as nothing (zero visual noise); the generator finds them by literal string match. They live under the new `### Shortcuts and tips` heading in `Using it`, with explanatory prose + a footer pointer below for readers who reach for the file.
-
-**Script:** `scripts/gen-readme-shortcuts.mjs`. Sibling to `pr4-verify.mjs` / `release.sh` / `make-icon.py`. Plain Node + `fs` only — no tsx/esbuild dep. Trick: shortcuts.ts is pure data (no React, no value imports), so the script reads it as text, strips `export interface { ... }` blocks with a brace-walker, drops the `: SomeType[]` annotations on the `export const` declarations, writes the result to a temp `.mjs` file in `os.tmpdir()`, and dynamic-imports it. Cleaner than a regex parser of the array literal because any future shape change in shortcuts.ts (adding a new field, reshuffling sections) Just Works.
-
-**Render style:** mirrors the existing README table — `| Key | Action |` two-column, keys backticked. The TS data is grouped by section, so each section gets a `**Title**` subheading + its own table. TIPS render as a `**Title** — body` bulleted list. Pipes in cell text are escaped to `\|`; newlines collapse to spaces.
-
-**Pnpm script:** `pnpm gen:readme`. Also supports `--check` mode (`node scripts/gen-readme-shortcuts.mjs --check`) which exits 1 if the README would change — wire it into CI later if drift starts mattering again.
-
-**To add or change a shortcut/tip:** edit `src/lib/shortcuts.ts`, run `pnpm gen:readme`. The help overlay updates automatically (it already imports from there); the README marker block updates from the same source. Don't hand-edit anything between the marker comments — your changes will be wiped on the next regen.
-
-**Verified:** ran twice, second run reports "already up to date" with empty `git diff`. `pnpm check` clean (tsc --noEmit, exit 0).
-
-### 2026-05-26 — `.mcpb` Claude Desktop bundle (zero-config token handling)
-
-Long-standing follow-up shipped. Build script + tiny Node wrapper produce `dist-mcpb/tiletopia.mcpb` — a one-click Claude Desktop install replacing the hand-paste of `.mcp.json`.
-
-**Key design choice — per-install token handling.** The `.mcpb` spec offers two ways to handle credentials: `user_config` prompts at install time (copy-paste), or bake them in (wrong). Both lose: copy-paste defeats the whole point of one-click, and token rotation (the Regenerate button) would silently invalidate any saved `user_config` value. Picked a **third option not in the spec docs**: bundle a tiny Node wrapper as `entry_point` that reads `%APPDATA%\com.megaproxy.tiletopia\mcp.json` at launch and execs `npx -y mcp-remote ...` with the live token. Zero secrets in the bundle → safe to publish on the releases page; works for any tiletopia install; transparently picks up the new token after Regenerate without the user re-doing anything.
-
-**Bundle shape (`scripts/build-mcpb.mjs`):**
-- `manifest.json` — `type: "node"`, `entry_point: "server/index.mjs"`, `mcp_config: { command: "node", args: ["${__dirname}/server/index.mjs"] }`, version mirrors `package.json`, icon points at the 128×128 brand PNG.
-- `server/index.mjs` — the wrapper. Reads `mcp.json`, validates port + token, spawns `npx -y mcp-remote http://127.0.0.1:/mcp --allow-http --header "Authorization: Bearer "` with `stdio: "inherit"`, forwards SIGINT/SIGTERM/SIGHUP to clean up the child on extension disable.
-- `icon.png` — copy of `src-tauri/icons/128x128.png`.
-
-**Build path.** `pnpm run build:mcpb` → `dist-mcpb/tiletopia.mcpb` (gitignored). Pure-Node store-only ZIP writer (~70 lines, no `archiver`/`jszip` devDep). Validated end-to-end with Python `zipfile`: 3 entries, valid CRCs, manifest parses. ~9 KB output.
-
-**Distribution.** The script is committed; the artifact isn't (regenerable). The intent is to attach `tiletopia.mcpb` to each Forgejo release alongside the NSIS installer — `scripts/release.sh` doesn't do this yet (follow-up). The new "Download .mcpb" button in `McpPanel` opens the releases page; once the artifact is up there, users grab it from there.
-
-**UI changes.**
-- `McpPanel.tsx`: new "Claude Desktop (one-click install)" field above the .mcp.json snippet with a "Download .mcpb" button (opens the releases URL via `plugin-opener`) and a brief hint explaining zero-config token handling + the regen script. Styled in `McpPanel.css` (`.mcp-mcpb-row`, `.mcp-mcpb-btn`, `.mcp-mcpb-hint`).
-- `McpPanel.css`: also added an explicit `.mcp-hint` style that was previously inheriting (used by both the token hint and the .mcpb hint).
-- `shortcuts.ts`: MCP tip now leads with the `.mcpb` install path; the mcp-remote shim is described as the fallback for Claude Code (the terminal CLI, which doesn't accept `.mcpb` yet).
-- `README.md`: same restructure under the MCP section — Claude Desktop install via `.mcpb` first, Claude Code via mcp-remote second.
-
-**Why no in-app file save dialog?** I considered bundling the `.mcpb` inside the Tauri NSIS as a Rust resource + exposing a `download_mcpb` Tauri command that opens a save dialog. Would let the panel button work fully in-app. Rejected because (a) it'd require Rust changes which I can't compile-check in WSL, (b) it duplicates what releases do for free, and (c) "Download .mcpb" landing on the releases page is the more discoverable distribution flow long-term.
-
-**Confirmed: bundle contains zero secrets.** Scanned both `manifest.json` and `server/index.mjs` for `Bearer ey`, `token=`, `secret`, `password`, `api_key` — all clean. The wrapper reads the token from `%APPDATA%` at runtime on the *user's* machine; nothing is ever baked in.
-
-**`pnpm check` clean, vitest 72/72 passing.**
-
-Open follow-ups specific to this session:
-
-- **Wire `.mcpb` into the release.** `scripts/release.sh` currently uploads only the NSIS installer; it should also run `node scripts/build-mcpb.mjs` and attach the resulting `dist-mcpb/tiletopia.mcpb` to the Forgejo release. Two lines + one `tea releases create --asset` flag. Until that's done, the "Download .mcpb" button lands on a releases page where the asset doesn't exist yet for old tags.
-- **Direct in-app save flow.** If we ever want fully-offline install (no roundtrip through the web), add a Rust-side `download_mcpb` command that returns the bundled bytes + use `@tauri-apps/plugin-dialog` save() in the panel. Not blocking — current flow is sufficient and matches how Tauri apps usually distribute extension files.
-- **Pre-flight on the wrapper.** Could detect missing `npx` / Node 18+ and emit a more directed message. Currently we just let `spawn` fail with whatever Node says. The "make sure Node 18+ is installed and `npx` is on PATH" line in the error path is the band-aid.
-- **`.mcpb` for Claude Code (CLI).** Claude Code doesn't accept `.mcpb` bundles yet — Anthropic may add it. When they do, the same bundle should Just Work since the wrapper is platform-agnostic re: which Claude is calling it.
-- **Bundle compatibility field.** Manifest declares `platforms: ["win32"]` and `runtimes: { node: ">=18.0.0" }`. The wrapper has a hard `%APPDATA%` requirement so this is correct, but if anyone ever wants macOS / Linux tiletopia support, the wrapper needs a portable config-path lookup.
-
### 2026-05-26 — Hard-deny: PowerShell patterns + label list de-duplicated
Mirrors the POSIX hard-deny rules with their Windows/PowerShell equivalents. Four new patterns:
diff --git a/package.json b/package.json
index 9a5f043..d86eeb1 100644
--- a/package.json
+++ b/package.json
@@ -10,8 +10,6 @@
"test": "vitest run",
"test:watch": "vitest",
"check": "tsc --noEmit",
- "build:mcpb": "node scripts/build-mcpb.mjs",
- "gen:readme": "node scripts/gen-readme-shortcuts.mjs",
"tauri": "tauri"
},
"dependencies": {
diff --git a/scripts/build-mcpb.mjs b/scripts/build-mcpb.mjs
deleted file mode 100644
index 0f59462..0000000
--- a/scripts/build-mcpb.mjs
+++ /dev/null
@@ -1,259 +0,0 @@
-#!/usr/bin/env node
-// build-mcpb.mjs — package tiletopia's Claude Desktop MCP bundle.
-//
-// Produces dist-mcpb/tiletopia.mcpb — an .mcpb (MCP Bundle, the format
-// formerly known as DXT) zip containing:
-// manifest.json → declares a node-type server pointing at the wrapper
-// server/index.mjs → the wrapper script that reads %APPDATA% and
-// execs `npx -y mcp-remote ...` (see mcpb-wrapper.mjs)
-// icon.png → 128×128 brand icon
-//
-// Usage:
-// pnpm run build:mcpb
-// or
-// node scripts/build-mcpb.mjs
-//
-// Output:
-// dist-mcpb/tiletopia.mcpb — drag-and-drop this into Claude Desktop's
-// Extensions panel to install.
-//
-// Design notes:
-// - The bundle bakes in NO secrets. The bearer token + port are read at
-// runtime from %APPDATA%\com.megaproxy.tiletopia\mcp.json on the user's
-// own machine. Each install of tiletopia generates its own token; the
-// bundle is the same for everyone.
-// - We write the zip ourselves (store-only, no compression) to avoid a
-// devDep on archiver/jszip/etc. The MCPB spec is just a regular zip;
-// three small files = trivial.
-// - The manifest's `version` mirrors package.json so the panel UI can show
-// "Bundle v0.2.3 — matches running app".
-
-import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
-import { existsSync } from "node:fs";
-import { dirname, join, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-import { deflateRawSync, crc32 } from "node:zlib";
-
-const HERE = dirname(fileURLToPath(import.meta.url));
-const REPO_ROOT = resolve(HERE, "..");
-const PKG_PATH = join(REPO_ROOT, "package.json");
-const WRAPPER_PATH = join(HERE, "mcpb-wrapper.mjs");
-const ICON_PATH = join(REPO_ROOT, "src-tauri", "icons", "128x128.png");
-const OUT_DIR = join(REPO_ROOT, "dist-mcpb");
-const OUT_PATH = join(OUT_DIR, "tiletopia.mcpb");
-
-// ----------------------------------------------------------------------------
-// Read inputs
-// ----------------------------------------------------------------------------
-
-if (!existsSync(WRAPPER_PATH)) {
- console.error(`missing wrapper: ${WRAPPER_PATH}`);
- process.exit(1);
-}
-if (!existsSync(ICON_PATH)) {
- console.error(`missing icon: ${ICON_PATH}`);
- process.exit(1);
-}
-
-const pkg = JSON.parse(await readFile(PKG_PATH, "utf8"));
-const wrapperSrc = await readFile(WRAPPER_PATH);
-const iconBytes = await readFile(ICON_PATH);
-
-// ----------------------------------------------------------------------------
-// Manifest
-//
-// Schema reference: https://github.com/modelcontextprotocol/mcpb/blob/main/MANIFEST.md
-//
-// type=node + entry_point pointing at server/index.mjs + mcp_config.command
-// = "node" matches Claude Desktop's expectations. We avoid a `user_config`
-// block on purpose — the wrapper reads the token from %APPDATA% so the user
-// doesn't have to copy-paste it at install time.
-// ----------------------------------------------------------------------------
-
-const manifest = {
- manifest_version: "0.3",
- name: "tiletopia",
- display_name: "tiletopia (workspace driver)",
- version: pkg.version,
- description:
- "Drive your tiletopia workspace from Claude Desktop — inspect panes, " +
- "read scrollback, reshape the layout, and (with policy approval) send " +
- "commands.",
- long_description:
- "tiletopia is a Windows tiling terminal manager for WSL. This bundle " +
- "lets Claude Desktop connect to a running tiletopia process on the same " +
- "machine via its embedded MCP server. The bundle reads the per-install " +
- "bearer token and port from %APPDATA%\\com.megaproxy.tiletopia\\mcp.json " +
- "at launch, so you don't need to paste any credentials during install. " +
- "Start the MCP server once from tiletopia's 🤖 panel (Server: ON), then " +
- "drop this bundle into Claude Desktop and it will connect automatically. " +
- "All write operations (spawn, write keystrokes, reshape) are gated by " +
- "the per-pane allow-list and the user-editable policy inside tiletopia.",
- author: {
- name: "megaproxy",
- url: "https://git.rdx4.com/megaproxy/tiletopia",
- },
- repository: {
- type: "git",
- url: "https://git.rdx4.com/megaproxy/tiletopia.git",
- },
- homepage: "https://git.rdx4.com/megaproxy/tiletopia",
- documentation: "https://git.rdx4.com/megaproxy/tiletopia#mcp-server-claude-can-drive-the-workspace",
- support: "https://git.rdx4.com/megaproxy/tiletopia/issues",
- icon: "icon.png",
- server: {
- type: "node",
- entry_point: "server/index.mjs",
- mcp_config: {
- command: "node",
- args: ["${__dirname}/server/index.mjs"],
- },
- },
- keywords: ["tiletopia", "wsl", "terminal", "mcp", "claude"],
- license: "Proprietary",
- compatibility: {
- // Claude Desktop runtime requirements — the bundle launches node, which
- // shells out to npx mcp-remote; both need Node 18+ on PATH.
- platforms: ["win32"],
- runtimes: {
- node: ">=18.0.0",
- },
- },
-};
-
-const manifestBytes = Buffer.from(JSON.stringify(manifest, null, 2), "utf8");
-
-// ----------------------------------------------------------------------------
-// Build the .mcpb zip
-//
-// The MCPB spec is a plain ZIP file. We're writing three small files, so a
-// pure-Node store-only writer is simplest. Avoids adding archiver as a
-// devDep. Format reference: APPNOTE.TXT 6.3.4 sections 4.3 (local file
-// header), 4.4 (data descriptor), 4.5 (central directory).
-// ----------------------------------------------------------------------------
-
-const SIG_LFH = 0x04034b50;
-const SIG_CDH = 0x02014b50;
-const SIG_EOCD = 0x06054b50;
-
-function dosTimeDate(date) {
- // DOS time/date format (2-second resolution; epoch 1980-01-01).
- const yr = Math.max(date.getFullYear(), 1980) - 1980;
- const time =
- ((date.getHours() & 0x1f) << 11) |
- ((date.getMinutes() & 0x3f) << 5) |
- ((Math.floor(date.getSeconds() / 2)) & 0x1f);
- const dt =
- ((yr & 0x7f) << 9) |
- (((date.getMonth() + 1) & 0x0f) << 5) |
- (date.getDate() & 0x1f);
- return { time, date: dt };
-}
-
-function buildZip(entries) {
- const now = dosTimeDate(new Date());
- const chunks = [];
- const centralDir = [];
- let offset = 0;
-
- for (const { name, data } of entries) {
- const nameBuf = Buffer.from(name, "utf8");
- const crc = crc32(data); // store-only — uncompressed crc == compressed crc
- const size = data.length;
-
- // Local file header (4.3.7)
- const lfh = Buffer.alloc(30);
- lfh.writeUInt32LE(SIG_LFH, 0);
- lfh.writeUInt16LE(20, 4); // version needed (2.0)
- lfh.writeUInt16LE(0, 6); // general purpose bit flag
- lfh.writeUInt16LE(0, 8); // compression: store
- lfh.writeUInt16LE(now.time, 10);
- lfh.writeUInt16LE(now.date, 12);
- lfh.writeUInt32LE(crc, 14);
- lfh.writeUInt32LE(size, 18); // compressed size (== size for store)
- lfh.writeUInt32LE(size, 22); // uncompressed size
- lfh.writeUInt16LE(nameBuf.length, 26);
- lfh.writeUInt16LE(0, 28); // extra field length
- chunks.push(lfh, nameBuf, data);
-
- // Central directory header (4.4.7)
- const cdh = Buffer.alloc(46);
- cdh.writeUInt32LE(SIG_CDH, 0);
- cdh.writeUInt16LE(20, 4); // version made by
- cdh.writeUInt16LE(20, 6); // version needed
- cdh.writeUInt16LE(0, 8); // gp flag
- cdh.writeUInt16LE(0, 10); // compression
- cdh.writeUInt16LE(now.time, 12);
- cdh.writeUInt16LE(now.date, 14);
- cdh.writeUInt32LE(crc, 16);
- cdh.writeUInt32LE(size, 20); // compressed size
- cdh.writeUInt32LE(size, 24); // uncompressed
- cdh.writeUInt16LE(nameBuf.length, 28);
- cdh.writeUInt16LE(0, 30); // extra len
- cdh.writeUInt16LE(0, 32); // comment len
- cdh.writeUInt16LE(0, 34); // disk number
- cdh.writeUInt16LE(0, 36); // internal attrs
- cdh.writeUInt32LE(0, 38); // external attrs
- cdh.writeUInt32LE(offset, 42); // local header offset
- centralDir.push(cdh, nameBuf);
-
- offset += lfh.length + nameBuf.length + data.length;
- }
-
- const cdStart = offset;
- for (const buf of centralDir) {
- chunks.push(buf);
- offset += buf.length;
- }
- const cdSize = offset - cdStart;
-
- // End of central directory record (4.5)
- const eocd = Buffer.alloc(22);
- eocd.writeUInt32LE(SIG_EOCD, 0);
- eocd.writeUInt16LE(0, 4); // disk number
- eocd.writeUInt16LE(0, 6); // start disk
- eocd.writeUInt16LE(entries.length, 8); // entries on this disk
- eocd.writeUInt16LE(entries.length, 10); // total entries
- eocd.writeUInt32LE(cdSize, 12);
- eocd.writeUInt32LE(cdStart, 16);
- eocd.writeUInt16LE(0, 20); // comment length
- chunks.push(eocd);
-
- // Silence the "unused on store path" lint trip; deflateRawSync stays
- // imported so a future maintainer who wants to add compression doesn't
- // have to re-figure out the right symbol.
- void deflateRawSync;
-
- return Buffer.concat(chunks);
-}
-
-const entries = [
- { name: "manifest.json", data: manifestBytes },
- { name: "server/index.mjs", data: wrapperSrc },
- { name: "icon.png", data: iconBytes },
-];
-
-const zipBytes = buildZip(entries);
-
-await mkdir(OUT_DIR, { recursive: true });
-await writeFile(OUT_PATH, zipBytes);
-
-const sizeKB = (zipBytes.length / 1024).toFixed(1);
-console.log(`wrote ${OUT_PATH} (${sizeKB} KB, ${entries.length} entries)`);
-for (const e of entries) {
- console.log(` ${e.name.padEnd(20)} ${e.data.length} bytes`);
-}
-console.log(
- `manifest version ${manifest.version} (mirrors package.json); ` +
- "to install, drag the .mcpb file into Claude Desktop's Extensions panel.",
-);
-
-// Touch stat() so any "wrote nothing" CI bug surfaces here, not at the user's
-// next install.
-const written = await stat(OUT_PATH);
-if (written.size !== zipBytes.length) {
- console.error(
- `size mismatch: wrote ${zipBytes.length} bytes, file is ${written.size}`,
- );
- process.exit(1);
-}
diff --git a/scripts/gen-readme-shortcuts.mjs b/scripts/gen-readme-shortcuts.mjs
deleted file mode 100644
index d353e0c..0000000
--- a/scripts/gen-readme-shortcuts.mjs
+++ /dev/null
@@ -1,170 +0,0 @@
-#!/usr/bin/env node
-// gen-readme-shortcuts.mjs — regenerate README.md's shortcut + tips section
-// from src/lib/shortcuts.ts (the single source of truth used by the in-app
-// help overlay).
-//
-// Usage:
-// node scripts/gen-readme-shortcuts.mjs # rewrite README
-// node scripts/gen-readme-shortcuts.mjs --check # exit 1 if README would change
-//
-// To extend: add or edit entries in src/lib/shortcuts.ts, then run this
-// script. The README marker block ...
-// is replaced atomically; the rest of the README is left alone.
-
-import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join, dirname, resolve } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-
-const HERE = dirname(fileURLToPath(import.meta.url));
-const REPO_ROOT = resolve(HERE, "..");
-const SHORTCUTS_TS = join(REPO_ROOT, "src", "lib", "shortcuts.ts");
-const README_PATH = join(REPO_ROOT, "README.md");
-
-const START_MARKER = "";
-const END_MARKER = "";
-
-const CHECK_MODE = process.argv.includes("--check");
-
-// ----------------------------------------------------------------------------
-// Load shortcuts.ts as data. The file is pure data exports — no React, no
-// runtime imports — so we can strip TypeScript-only syntax with regex, drop
-// the result into a temp .mjs file, and dynamically import it. Cheaper than
-// pulling in tsx/esbuild as a devDep just for this one script.
-// ----------------------------------------------------------------------------
-async function loadShortcutsModule() {
- const src = await readFile(SHORTCUTS_TS, "utf8");
- // Strip `export interface { ... }` blocks (handles nested braces by
- // walking; the file has flat interfaces today so a brace-counter is enough).
- const stripped = stripInterfaceDecls(src)
- // Drop `: TypeAnnotation` on the export declarations
- // (e.g. `export const SHORTCUT_SECTIONS: ShortcutSection[] = [...]`).
- .replace(/^(export\s+const\s+\w+)\s*:\s*[^=]+?=/gm, "$1 =");
-
- const dir = await mkdtemp(join(tmpdir(), "tiletopia-genreadme-"));
- const tmpFile = join(dir, "shortcuts.mjs");
- try {
- await writeFile(tmpFile, stripped, "utf8");
- return await import(pathToFileURL(tmpFile).href);
- } finally {
- await rm(dir, { recursive: true, force: true });
- }
-}
-
-function stripInterfaceDecls(src) {
- let out = "";
- let i = 0;
- while (i < src.length) {
- const match = src.slice(i).match(/^export\s+interface\s+\w+\s*\{/m);
- if (!match) {
- out += src.slice(i);
- break;
- }
- const localStart = src.indexOf(match[0], i);
- out += src.slice(i, localStart);
- // Walk braces to find the end of the interface block.
- let depth = 0;
- let j = localStart + match[0].length - 1; // points at the opening `{`
- for (; j < src.length; j++) {
- const c = src[j];
- if (c === "{") depth++;
- else if (c === "}") {
- depth--;
- if (depth === 0) {
- j++;
- break;
- }
- }
- }
- i = j;
- }
- return out;
-}
-
-// ----------------------------------------------------------------------------
-// Render the markdown block. Mirrors the README's existing table style:
-// - 2-column `| Key | Action |` table
-// - keys wrapped in backticks
-// - description in plain prose
-// Tips render as a `#### Title` heading plus a paragraph.
-// ----------------------------------------------------------------------------
-function renderBlock({ SHORTCUT_SECTIONS, TIPS }) {
- const lines = [];
- lines.push("");
- lines.push("#### Keyboard shortcuts");
- lines.push("");
- for (const section of SHORTCUT_SECTIONS) {
- lines.push(`**${section.title}**`);
- lines.push("");
- lines.push("| Key | Action |");
- lines.push("|---|---|");
- for (const item of section.items) {
- lines.push(`| \`${escapeCell(item.keys)}\` | ${escapeCell(item.description)} |`);
- }
- lines.push("");
- }
- lines.push("#### Tips");
- lines.push("");
- for (const tip of TIPS) {
- lines.push(`- **${escapeInline(tip.title)}** — ${escapeInline(tip.body)}`);
- }
- lines.push("");
- return lines.join("\n");
-}
-
-// Cell values must not contain raw pipes (would break the table) or newlines.
-function escapeCell(s) {
- return s.replace(/\|/g, "\\|").replace(/\n/g, " ");
-}
-
-// Body text gets newlines collapsed but pipes kept (lists, not tables).
-function escapeInline(s) {
- return s.replace(/\n/g, " ");
-}
-
-// ----------------------------------------------------------------------------
-// Splice the generated block into the README between the markers.
-// ----------------------------------------------------------------------------
-function spliceReadme(readme, block) {
- const startIdx = readme.indexOf(START_MARKER);
- const endIdx = readme.indexOf(END_MARKER);
- if (startIdx === -1 || endIdx === -1) {
- throw new Error(
- `README.md is missing one of the markers (${START_MARKER} / ${END_MARKER}). ` +
- "Add them around the section you want regenerated.",
- );
- }
- if (endIdx < startIdx) {
- throw new Error(`${END_MARKER} appears before ${START_MARKER} in README.md`);
- }
- const before = readme.slice(0, startIdx + START_MARKER.length);
- const after = readme.slice(endIdx);
- return `${before}\n${block}\n${after}`;
-}
-
-// ----------------------------------------------------------------------------
-// Main.
-// ----------------------------------------------------------------------------
-const mod = await loadShortcutsModule();
-const block = renderBlock(mod);
-const readme = await readFile(README_PATH, "utf8");
-const next = spliceReadme(readme, block);
-
-if (CHECK_MODE) {
- if (next !== readme) {
- process.stderr.write(
- "README.md is out of sync with src/lib/shortcuts.ts. " +
- "Run `pnpm gen:readme` to regenerate.\n",
- );
- process.exit(1);
- }
- process.stdout.write("README.md is in sync with src/lib/shortcuts.ts.\n");
- process.exit(0);
-}
-
-if (next === readme) {
- process.stdout.write("README.md already up to date.\n");
-} else {
- await writeFile(README_PATH, next, "utf8");
- process.stdout.write("README.md regenerated.\n");
-}
diff --git a/scripts/mcpb-wrapper.mjs b/scripts/mcpb-wrapper.mjs
deleted file mode 100644
index be86ae8..0000000
--- a/scripts/mcpb-wrapper.mjs
+++ /dev/null
@@ -1,110 +0,0 @@
-#!/usr/bin/env node
-// tiletopia .mcpb wrapper — entry_point for the bundled MCP server.
-//
-// What this is: a thin stdio shim Claude Desktop launches when the user
-// installs `tiletopia.mcpb`. It reads the per-install MCP server settings
-// (port + bearer token) that the running tiletopia app persisted to
-// %APPDATA%\com.megaproxy.tiletopia\mcp.json, then execs `npx -y mcp-remote`
-// with the right URL + Authorization header. Claude talks stdio to us; we
-// proxy through mcp-remote, which talks HTTP to the tiletopia process.
-//
-// Why a wrapper (not just static args in the manifest):
-// - The bearer token is per-install — generated at first server start, also
-// rotated whenever the user clicks "Regenerate" in the MCP panel. We
-// can't bake it into the bundle (that'd be wrong for every other user)
-// and we don't want to make the user paste it into a user_config prompt
-// at install time. Reading it from %APPDATA% at launch makes the whole
-// thing zero-config and survives token rotation transparently.
-// - The port may also drift (if the saved port is taken, tiletopia falls
-// back to an OS-picked one and re-persists). Reading at launch keeps us
-// correct across that too.
-//
-// Failure modes & messages: every error we emit goes to stderr so the user
-// sees it in Claude Desktop's extension log. We deliberately do NOT swallow
-// or transform mcp-remote's own output beyond piping it.
-
-import { spawn } from "node:child_process";
-import { readFileSync, existsSync } from "node:fs";
-import { join } from "node:path";
-
-const APPDATA = process.env.APPDATA;
-if (!APPDATA) {
- console.error(
- "[tiletopia-mcpb] %APPDATA% is unset — this bundle only runs on Windows.",
- );
- process.exit(2);
-}
-
-const CFG_PATH = join(APPDATA, "com.megaproxy.tiletopia", "mcp.json");
-if (!existsSync(CFG_PATH)) {
- console.error(
- `[tiletopia-mcpb] config not found at ${CFG_PATH}. ` +
- "Launch tiletopia, open the 🤖 MCP panel, and click Server: ON at least " +
- "once so the port + token get persisted, then retry.",
- );
- process.exit(3);
-}
-
-let cfg;
-try {
- cfg = JSON.parse(readFileSync(CFG_PATH, "utf8"));
-} catch (e) {
- console.error(`[tiletopia-mcpb] failed to read/parse ${CFG_PATH}: ${e.message}`);
- process.exit(4);
-}
-
-const port = Number(cfg.port);
-const token = String(cfg.token ?? "");
-if (!Number.isInteger(port) || port <= 0 || port > 65535 || !token) {
- console.error(
- `[tiletopia-mcpb] ${CFG_PATH} is missing a valid port or token. ` +
- "Toggle the MCP server off and on in the tiletopia panel to regenerate it.",
- );
- process.exit(5);
-}
-
-const url = `http://127.0.0.1:${port}/mcp`;
-
-// `npx.cmd` on Windows is the actual launcher; bare `npx` is a shim that
-// node spawns from PATH and that's also fine. spawn() with shell:true ensures
-// PATHEXT resolution picks up the .cmd correctly.
-const child = spawn(
- "npx",
- [
- "-y",
- "mcp-remote",
- url,
- "--allow-http",
- "--header",
- `Authorization: Bearer ${token}`,
- ],
- {
- stdio: "inherit",
- shell: true,
- },
-);
-
-child.on("error", (e) => {
- console.error(
- `[tiletopia-mcpb] failed to spawn npx: ${e.message}. ` +
- "Make sure Node.js 18+ is installed and `npx` is on PATH.",
- );
- process.exit(6);
-});
-
-child.on("exit", (code, signal) => {
- if (signal) process.kill(process.pid, signal);
- else process.exit(code ?? 0);
-});
-
-// Forward terminate signals to the child so Claude Desktop's "disable
-// extension" cleans up the mcp-remote subprocess.
-for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
- process.on(sig, () => {
- try {
- child.kill(sig);
- } catch {
- /* child may already be gone */
- }
- });
-}
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index 8e84b7e..dd21f6a 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -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,30 +302,3 @@ pub async fn mcp_policy_save(app: AppHandle, policy: McpPolicy) -> Result<(), St
pub async fn mcp_hard_deny_labels() -> Result, String> {
Ok(crate::mcp_policy::hard_deny_rules().to_vec())
}
-
-// ---- idle-detection filter -------------------------------------------------
-
-/// Probe whether any of the built-in watched processes (currently
-/// `["claude"]`) is running in the given WSL distro. Result is cached
-/// per-distro for ~3s — see {@link ProbeCache}. Fail-safe: any probe error
-/// resolves to `true` so the caller suppresses the idle indicator (the
-/// agreed trade-off; the previous "always notify" bug was worse than the
-/// occasional over-suppression).
-///
-/// Frontend only calls this for WSL panes. PowerShell + SSH skip the probe
-/// and fall back to the legacy always-notify behaviour. Empty distro names
-/// resolve to `true` (no info → fail-safe).
-#[tauri::command]
-pub async fn is_watch_process_running(
- cache: tauri::State<'_, Arc>,
- distro: String,
-) -> Result {
- // Probe shells out — keep it off the async runtime's thread.
- let cache_arc: Arc = (*cache).clone();
- let running = tokio::task::spawn_blocking(move || {
- cache_arc.is_watch_process_running(&distro)
- })
- .await
- .map_err(|e| format!("probe join failed: {e}"))?;
- Ok(running)
-}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 88192f2..40ec343 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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 = Arc::new(PendingActions::default());
- // Idle-filter probe cache: shared across all is_watch_process_running
- // calls so a per-distro answer is reused for a few seconds. See probe.rs.
- let probe_cache: Arc = Arc::new(ProbeCache::new());
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
@@ -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");
diff --git a/src-tauri/src/probe.rs b/src-tauri/src/probe.rs
deleted file mode 100644
index 1f35980..0000000
--- a/src-tauri/src/probe.rs
+++ /dev/null
@@ -1,172 +0,0 @@
-//! "Is a watched process running in distro X?" probe for the idle-detection
-//! filter.
-//!
-//! Background: tiletopia's idle indicator fires whenever a pane goes 5s
-//! without PTY output. When the user is reading a long `claude` response,
-//! the pane is silent but there's nothing actionable to surface — the
-//! indicator becomes noise. This module lets the frontend ask the backend
-//! "is `claude` (or any other watched process) running in this distro?"
-//! before flagging a pane idle, and suppresses the indicator if so.
-//!
-//! Granularity is per-distro, not per-pane. Identifying which Windows pane
-//! corresponds to which Linux-side shell inside the distro is too complex
-//! (PIDs aren't visible from Windows; ProcMon-style probes are fragile). If
-//! `claude` is running anywhere in distro X, idle is suppressed for ALL
-//! panes in distro X. Over-suppression for multi-pane-same-distro users is
-//! the agreed trade-off; the previous bug (always notify) was worse.
-//!
-//! PowerShell + SSH panes don't go through this probe — the frontend short-
-//! circuits to "always idle" for them. (PowerShell has no portable `ps`
-//! equivalent; SSH processes live on a remote box and would need a separate
-//! transport.)
-//!
-//! The probe shells out (`wsl.exe -d -- pgrep -x ...`), which costs
-//! ~100-300ms per call. We cache the answer per-distro for a few seconds so
-//! the frontend can poll on every idle tick without storming `wsl.exe`.
-
-use std::collections::HashMap;
-use std::time::{Duration, Instant};
-
-use parking_lot::Mutex;
-
-/// Built-in list of process names that suppress idle when running. v1 ships
-/// with just `claude`; the user can extend it via the workspace config later.
-///
-/// [[user-watch-list]] TODO: surface this as a user-editable list (workspace
-/// config field or dedicated `watch.json`). For now the constant covers the
-/// only real-world use case (Anthropic's `claude` CLI taking its time on a
-/// long response). Adding entries to the constant is the only knob.
-pub const DEFAULT_WATCH_PROCESSES: &[&str] = &["claude"];
-
-/// How long a per-distro probe result is reused before we re-shell. Sized
-/// against the frontend's 1s idle-tick interval — 3s means roughly one
-/// probe per distro per 3 ticks even with many panes polling, while still
-/// reacting to "claude just finished" within a few seconds. Trade-off: too
-/// short = wsl.exe spam, too long = stale "claude is running" once the
-/// process actually exits.
-const CACHE_TTL: Duration = Duration::from_secs(3);
-
-/// Cache entry: timestamp the probe ran + whether any watched process was
-/// found in the distro.
-#[derive(Clone, Copy)]
-struct CacheEntry {
- at: Instant,
- running: bool,
-}
-
-/// Per-distro probe cache. Keyed by distro name (the same string the user
-/// sees in the shell picker; the same string we pass as `wsl.exe -d`).
-pub struct ProbeCache {
- cache: Mutex>,
-}
-
-impl ProbeCache {
- pub fn new() -> Self {
- Self {
- cache: Mutex::new(HashMap::new()),
- }
- }
-
- /// Returns true iff one of the watched processes is running in the
- /// distro. Cached for {@link CACHE_TTL}; cache misses (or stale entries)
- /// trigger a fresh probe. On probe failure the result is `true` —
- /// **fail-safe is to suppress** the idle indicator, matching the
- /// agreed trade-off ("over-suppression beats the previous always-notify
- /// behaviour").
- pub fn is_watch_process_running(&self, distro: &str) -> bool {
- // Fast path: fresh cached answer.
- {
- let guard = self.cache.lock();
- if let Some(entry) = guard.get(distro) {
- if entry.at.elapsed() < CACHE_TTL {
- return entry.running;
- }
- }
- }
-
- // Slow path: re-probe. Drop the lock before shelling out so other
- // distros' probes aren't blocked.
- let running = probe_distro(distro, DEFAULT_WATCH_PROCESSES);
-
- let mut guard = self.cache.lock();
- guard.insert(
- distro.to_string(),
- CacheEntry {
- at: Instant::now(),
- running,
- },
- );
- running
- }
-}
-
-impl Default for ProbeCache {
- fn default() -> Self {
- Self::new()
- }
-}
-
-/// Run `wsl.exe -d -- pgrep -x ` for each watched name.
-/// Returns true on the first hit. On any failure (wsl.exe missing, distro
-/// not running, pgrep not installed, timeout) returns true — fail-safe is
-/// suppression.
-fn probe_distro(distro: &str, watched: &[&str]) -> bool {
- if !cfg!(windows) {
- // Non-Windows builds don't actually ship the app; pretend no watched
- // process so the idle indicator works for developer test runs.
- return false;
- }
- if distro.is_empty() {
- // We can't probe an empty distro name; treat as "no info" → fail-safe.
- tracing::debug!("probe: empty distro name; defaulting to suppression");
- return true;
- }
-
- for name in watched {
- match probe_one(distro, name) {
- Ok(true) => return true,
- Ok(false) => continue,
- Err(e) => {
- tracing::debug!(
- "probe: wsl pgrep for {name:?} in {distro:?} failed: {e} — suppressing idle"
- );
- return true;
- }
- }
- }
- false
-}
-
-/// Single `pgrep -x ` invocation. Ok(true) on a match, Ok(false) on
-/// exit code 1 (no match), Err on anything else. Wrapped in our standard
-/// `quiet_command` so the console window doesn't flash on the Windows
-/// desktop every probe.
-fn probe_one(distro: &str, name: &str) -> std::io::Result {
- // `pgrep -x` matches the exact comm (no substring), which avoids
- // `claude-something-else` false-positives. Stdout/stderr are silenced
- // — exit code carries the answer.
- //
- // Note: `name` is a compile-time string literal in DEFAULT_WATCH_PROCESSES
- // (no user input), so shell-quoting concerns don't apply. If we ever
- // wire user-supplied process names through here we MUST validate / shell-
- // quote them before this point.
- let out = crate::pty::quiet_command_pub("wsl.exe")
- .args(["-d", distro, "--", "pgrep", "-x", name])
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .output()?;
-
- match out.status.code() {
- Some(0) => Ok(true), // pgrep found at least one match
- Some(1) => Ok(false), // pgrep ran but found nothing
- Some(other) => {
- // 2 = syntax error in pgrep itself; 3 = fatal error; 127 = command
- // not found. None of these mean "definitively no claude running",
- // so treat as a probe failure (caller fails-safe to true).
- Err(std::io::Error::other(format!(
- "pgrep exit code {other}"
- )))
- }
- None => Err(std::io::Error::other("pgrep killed by signal")),
- }
-}
diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs
index 58f1f66..2f90930 100644
--- a/src-tauri/src/pty.rs
+++ b/src-tauri/src/pty.rs
@@ -457,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)]
{
diff --git a/src/components/McpPanel.css b/src/components/McpPanel.css
index 28b2830..4641d2f 100644
--- a/src/components/McpPanel.css
+++ b/src/components/McpPanel.css
@@ -187,55 +187,6 @@
color: #ccd;
}
-/* Inline small-print under inputs — small, muted, tight line-height. Used by
- * the token hint and the .mcpb install hint. */
-.mcp-hint {
- margin: 4px 0 0;
- color: #888;
- font-size: 11px;
- line-height: 1.4;
-}
-.mcp-hint code {
- background: #0c0c0c;
- padding: 1px 4px;
- border-radius: 2px;
- font-family: inherit;
- color: #aac;
-}
-
-/* ---- Claude Desktop .mcpb install row ----------------------------------- */
-
-.mcp-mcpb-row {
- display: flex;
- align-items: flex-start;
- gap: 12px;
-}
-
-.mcp-mcpb-btn {
- font: inherit;
- font-family: inherit;
- font-size: 11px;
- font-weight: 600;
- background: #1a2a3a;
- color: #cce6ff;
- border: 1px solid #2a4a6a;
- border-radius: 3px;
- padding: 6px 14px;
- cursor: pointer;
- flex-shrink: 0;
- white-space: nowrap;
-}
-.mcp-mcpb-btn:hover {
- background: #2a4a6a;
- color: #e0f0ff;
- border-color: #4488cc;
-}
-
-.mcp-mcpb-hint {
- flex: 1 1 auto;
- margin: 0;
-}
-
.mcp-snippet {
font: inherit;
font-family: inherit;
diff --git a/src/components/McpPanel.tsx b/src/components/McpPanel.tsx
index 89a6ea5..d00592f 100644
--- a/src/components/McpPanel.tsx
+++ b/src/components/McpPanel.tsx
@@ -2,18 +2,12 @@ import { useEffect, useState, useCallback } from "react";
import {
writeText as clipboardWriteText,
} from "@tauri-apps/plugin-clipboard-manager";
-import { openUrl } from "@tauri-apps/plugin-opener";
import type { McpStatus, McpAuditEntry } from "../ipc";
import AuditTab from "./AuditTab";
import PolicyTab from "./PolicyTab";
import ErrorBoundary from "./ErrorBoundary";
import "./McpPanel.css";
-// URL of the GitHub-style releases page where each tagged build attaches the
-// prebuilt `.mcpb` bundle (sibling to the NSIS installer). Source bundle is
-// regeneratable via `pnpm run build:mcpb`.
-const MCPB_RELEASES_URL = "https://git.rdx4.com/megaproxy/tiletopia/releases";
-
interface McpPanelProps {
status: McpStatus;
onStart: () => Promise;
@@ -202,31 +196,6 @@ export default function McpPanel({
-
-
-
-
-
- Grab tiletopia.mcpb from the releases
- page, then drag it into Claude Desktop's{" "}
- Settings → Extensions. The bundle reads your
- bearer token from %APPDATA% at launch —
- zero copy-paste, and token regeneration above keeps
- working transparently. (Bundle is regeneratable from
- source via pnpm run build:mcpb.)
-
-
-
-
diff --git a/src/ipc.ts b/src/ipc.ts
index b08a252..e1d48c8 100644
--- a/src/ipc.ts
+++ b/src/ipc.ts
@@ -39,14 +39,6 @@ export interface SshHost {
export const listDistros = (): Promise => invoke("list_distros");
-/** Ask the backend whether any built-in "watched" process (currently just
- * `claude`) is running in the given WSL distro. Cached per-distro for ~3s
- * on the Rust side. Fail-safe: probe failures resolve to `true` so the
- * caller suppresses the idle indicator. Only meaningful for WSL panes —
- * PowerShell + SSH should skip this and fall back to always-notify. */
-export const isWatchProcessRunning = (distro: string): Promise =>
- invoke("is_watch_process_running", { distro });
-
export const spawnPane = (args: {
spec: SpawnSpec;
cols: number;
diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx
index 4ea8c7d..d02f13f 100644
--- a/src/lib/layout/LeafPane.tsx
+++ b/src/lib/layout/LeafPane.tsx
@@ -10,7 +10,7 @@ import {
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
import { useOrchestration } from "./orchestration";
import XtermPane from "../../components/XtermPane";
-import { isWatchProcessRunning, type SpawnSpec } from "../../ipc";
+import type { SpawnSpec } from "../../ipc";
import "./LeafPane.css";
const IDLE_THRESHOLD_MS = 5000;
@@ -116,24 +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
- // the distro. If it is, the silence is "claude thinking / user reading",
- // not "nothing happening" — stay quiet. Probe is per-distro (not per-
- // pane: the inside-WSL PID isn't observable from Windows), so multiple
- // panes in the same distro will all suppress if claude is running in
- // any of them. Agreed trade-off; over-suppression beats the previous
- // always-notify behaviour.
- //
- // PowerShell + SSH skip the probe and fall through to legacy behaviour
- // (PS has no portable `ps`; SSH processes live on the remote box).
const lastDataTimeRef = useRef(Date.now());
const [isIdle, setIsIdle] = useState(false);
- const isWslPane = leaf.shellKind === "wsl";
- // Captures the distro name into the interval callback. Empty string when
- // the leaf doesn't have one yet — the probe treats that as fail-safe true.
- const wslDistro = isWslPane ? (leaf.distro ?? "") : "";
const onDataReceived = useCallback(() => {
lastDataTimeRef.current = Date.now();
setIsIdle((cur) => {
@@ -142,81 +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;
- void isWatchProcessRunning(wslDistro)
- .then((suppress) => {
- if (cancelled) return;
- // If output arrived while the probe was in flight, the next tick
- // (or onDataReceived) will reconcile; don't flip-flop here.
- if (Date.now() - lastDataTimeRef.current < IDLE_THRESHOLD_MS) return;
- if (suppress) {
- // claude (or another watched proc) is running — treat silence
- // as expected and stay out of the idle set.
- setIsIdle((cur) => {
- if (!cur) return cur;
- orch.reportLeafIdle(leaf.id, false);
- return false;
- });
- } else {
- setIsIdle((cur) => {
- if (cur) return cur;
- orch.reportLeafIdle(leaf.id, true);
- return true;
- });
- }
- })
- .catch((e) => {
- // Probe IPC errored — fail-safe to suppression (matches the Rust
- // side's own fail-safe).
- if (cancelled) return;
- // eslint-disable-next-line no-console
- console.debug("idle probe failed", e);
- })
- .finally(() => {
- inFlight = false;
- });
- };
-
- const id = window.setInterval(tick, 1000);
- return () => {
- cancelled = true;
- clearInterval(id);
- };
- }, [leaf.id, orch.reportLeafIdle, isWslPane, wslDistro]);
+ 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);
diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts
index b3692d1..f9db405 100644
--- a/src/lib/shortcuts.ts
+++ b/src/lib/shortcuts.ts
@@ -110,6 +110,6 @@ export const TIPS: TipSpec[] = [
},
{
title: "MCP server (let Claude drive the workspace)",
- body: "Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.",
+ body: "Titlebar 🤖 opens the MCP control panel — start the server and paste the snippet into your Claude Code .mcp.json. The snippet uses npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead; the shim proxies the HTTP endpoint with the bearer baked in. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP. Read-only in v1 (no spawn or write yet).",
},
];