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).",
   },
 ];