diff --git a/.gitignore b/.gitignore index 9c60438..5fdac2c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ src-tauri/WixTools/ *.swp *.swo .idea/ + +# Machine-list exports (contain real hostnames) +eldrun-machines.json diff --git a/AGENTS.md b/AGENTS.md index 50a92c2..6a6d967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,29 @@ ## Purpose -ProjectEldrun, or Eldrun, is a Tauri desktop workspace for AI-assisted +ProjectEldrun, or Eldrun, is a Tauri 2 desktop workspace for AI-assisted development. It manages a root control terminal, one terminal per active -project, a bottom project switcher, a right-side project file browser, global -app launching, file opening, time tracking, and optional workspace backend -integration. +project, a bottom project switcher, a right-side project file viewer, global +app launching, file opening, time tracking, and optional workspace/backend +integration in one window. + +The product thesis is project-scoped desktop context: opening a project should +swap the relevant terminals, files, apps, downloads/default-app behavior, and +time tracking together. + +## Load-Bearing Docs + +- Root project context: `CLAUDE.md`. +- Frontend file map: `src/CLAUDE.md`. +- Backend file map: `src-tauri/CLAUDE.md`. +- Architecture/user documentation: `DOCUMENTATION.md`. +- Work plan and grouped TODO conventions: `TODO.md` and `todo/`. +- Remote/multi-host plan: `docs/multi_host_remote_plan.md`. +- Remote lockstep case matrix: `docs/git_lockstep_case_matrix.md`. +- Container plan: `docs/docker_projects_plan.md`. + +Read the relevant map before editing unfamiliar code. The maps intentionally +list only load-bearing files; the tree is still the source of truth. ## Current Architecture @@ -18,8 +36,10 @@ integration. - Terminal UI lives in `src/components/terminal/TerminalView.tsx`; terminal backend commands live in `src-tauri/src/commands/terminal.rs` and `src-tauri/src/terminal/`. -- File browsing UI lives in `src/components/files/`; filesystem commands live - mostly in `src-tauri/src/commands/projects.rs`. +- File browsing/viewing UI lives in `src/components/files/` and is shared by + the right panel, Files tabs, and per-subwindow file sidebars. +- Filesystem commands live mostly in `src-tauri/src/commands/projects.rs` and + `src-tauri/src/commands/fs.rs`. - External app launching and tracked windows live in `src-tauri/src/commands/apps.rs`. - Settings and persisted schema types live in `src/stores/`, `src/types/`, and @@ -33,68 +53,198 @@ integration. - Managed projects normally live under `~/eldrun/projects//`. The root terminal uses `~/eldrun/root/`. - Global state lives in `~/.local/share/eldrun/`, especially `projects.json`, - `settings.json`, `default_apps.json`, `time_log.json`, and - `active_session.json`. -- Project-local state lives in each project's `project.json`; current open-app - metadata is stored in `project.json["open_apps"]`. + `settings.json`, `default_apps.json`, `time_log.json`, + `active_session.json`, and `usage_stats.json`. +- Usage stats are local-only rolling hour/day counters behind the daily recap. + Do not mix them with time (`time_summary.json`), network bytes + (`net_usage.json`), or git stats, which are read from their own sources. +- Project-local state lives in each project's `project.json`. This includes + `open_apps`, `tab_layout`, `tab_groups`, remote specs, runtime/container + settings, and per-project file-viewer settings. - New/imported projects are scaffolded with `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.claude/settings.json`, `.gitignore`, `TODO.md`, `ROADMAP.md`, - `STATUS.md`, and `DOCUMENTATION.md`. + `STATUS.md`, and `README.md` when missing. +- Box agent docs contain generated link blocks between + `` and ``; preserve + user edits outside those blocks. - `TODO.md` uses grouped IDs such as `G1.1`. When adding a TODO, place it in the matching group, create a new group if no current group fits, or merge groups if the TODO depends on distinct areas that should be tracked together. - Avoid unrelated rewrites in docs, generated state, built assets, project metadata, `dist/`, `target/`, and backup files. -## Development Workflow +## Runtime Safety -- Prefer small, focused changes that match the existing React/TypeScript and - Rust/Tauri style. -- Use `rg` for code search. - Do not launch Eldrun from an agent terminal for verification. Opening a - second Eldrun instance can corrupt workspace state; ask the user to run or - restart the existing instance when runtime validation is needed. + second Eldrun instance can corrupt workspace state. - Do not run `npm run tauri`, `npm run dev`, `cargo tauri dev`, or `./start-eldrun-tauri.sh` unless the user explicitly asks and confirms it is safe to launch a new instance. +- Frontend (`src/`) changes hot-reload in the running instance, so do not ask + the user to restart just to see TSX/CSS edits. +- Backend (`src-tauri/`) changes require the user to rebuild/restart the + existing instance for runtime validation. Ask for that only when runtime QA is + needed. +- Runtime launch commands are human-only by default. + +## Development Workflow + +- Use `rtk` before shell commands, per the included RTK instructions. +- Prefer small, focused changes that match the existing React/TypeScript and + Rust/Tauri style. +- Use `rg` for code search. - When changing frontend behavior, prefer local component state and existing Zustand stores over adding new global state. - When changing backend commands, keep Tauri command payload names compatible with the camelCase keys used by the frontend. - Preserve Python-era JSON shapes where the Rust schema already supports them; existing user state should round-trip cleanly. +- Keep service modules `AppHandle`-free and unit-testable where that is the + established boundary. +- Git commit authors for this public-bound repo must use the GitHub noreply + email, never the user's real address. + +## Remote, Sync, And Runtime Model + +- Remote SSH projects are mount-free. Agent/terminal tabs run on the host over + SSH, file browsing/I/O goes over SFTP, and git runs on the host over SSH, all + using pooled ControlMaster/SFTP sessions in `services::remote`. +- Remoteness is explicit: use `services::remote::remote_target_for{,_dir}` and + host-aware variants. Do not infer it from path conventions. +- A remote project's `remote` is the primary host. Extra `compute_hosts` are + worker machines for experiments; each has its own pool entry, connection lamp, + and tab locality (`host:`). +- Worker sync is one-way code fan-out from the mirror to the worker for tracked + files only. It uses git bundle/reset, never `git clean`, and avoids the + bidirectional divergence/local-loss path. +- Shared-filesystem workers are the default in the add-machine UI. They see the + primary folder at their own `remote_path`; do not run git init/reset/fan-out on + them. Tabs simply `cd` into the shared folder. +- Git lockstep owns git-tracked files and moves commits/refs semantically via + bundles. Byte-sync owns everything else and moves raw bytes. Keep the + `drop_tracked` split intact so the two transports never race for one file. +- With lockstep on, a saved edit to a tracked file reaches the peer only after + it is committed. Do not "fix" this back into continuous byte mirroring. +- Byte-sync is opt-in per path from the explicit manifest. It does not read + `.gitignore`; preview and confirm large scopes before pulling big host trees. +- Local-loss warnings are file-backed, not events. Destructive background git or + sync moves must record through `services::local_loss` so losses surface after + relaunch. +- Passwords are never persisted by default. Saved SSH/OpenVPN credentials are + keyed by host/config target, not project id; a blank password can mean "use + saved credential". +- Remote auto-connect and VPN auto-connect must never prompt. Check the + silent-connect predicates before attempting them. +- OpenVPN tunnels are machine-wide. The header `VpnIndicator` owns visibility + and lifecycle across projects; project UI must not imply the tunnel is + project-scoped. +- Never elevate for a connect that cannot succeed silently. `pkexec` prompts + before OpenVPN validates config/credentials. +- Containerized projects use one session-lived Docker container per local + project and bind-mount the project at the identical absolute path. File + viewers/git/usage watchers keep reading host bytes. The toggle is local-only + and hidden/refused on unsupported platforms. + +## Tabs, Agents, And Restore + +- Shell/files tabs restore on relaunch. Claude and Codex agent tabs with a + `sessionId` are resumable and restored; Gemini and Vibe restore behavior is + more limited unless the relevant code explicitly says otherwise. +- Eldrun installs Claude/Codex session hooks and also has hook-free Codex + binding. Codex user hooks may need one-time trust via `/hooks`. +- Agent authority has three axes: project container sandbox, tab location + (local/primary/worker), and optional Plan/Auto agent mode. +- `components/tabs/agentModes.ts` is a capability table. Add an agent there only + if it has an absolute mode flag and a working resume path for the respawn that + mode switching causes. +- Plan/Auto is a launch flag, persisted per tab, and re-applied when args are + rebuilt from layout state. Do not persist raw args as the source of truth. + +## Frontend Notes + +- The right panel and Files (Project) tab share `ProjectFilesView`; keep file + viewer features in the shared component so surfaces do not drift. +- `ProjectFilesPane` owns the tree/sort/source mechanics; panel/tab hosts own + only identity, active state, browsed folder, and chrome slots. +- Remote/SFTP/git probes must be gated when disconnected; synchronous Tauri + commands against a dead session can freeze the window. +- GPU UI reports whole-device memory and optional sensors from `gpustat`, not + only Ollama model memory. Omit missing sensor readings; do not render fake + zeroes. +- File viewer parsers such as YAML and table editing are text-preserving views. + Keep edits surgical so comments, delimiters, quoting, and line endings survive. +- Python Run/Debug opens a terminal tab and asks the backend for interpreter + precedence. Do not duplicate interpreter-ranking logic in the frontend. +- Experimental features should use `useExperimental`; unset flags fall back to + debug mode. + +## Backend Notes + +- `commands/` expose Tauri command handlers; `services/` hold reusable runtime + logic; `schema/` mirrors persisted JSON. +- `services::remote` is the source of truth for SSH/SFTP pooling and host-aware + remote resolution. +- `services::worker_sync` is push-only source-to-worker code sync. Shared-FS + workers must be skipped defensively even if a command reaches the service. +- `services::git_peer` owns lockstep and must preserve the tracked/untracked + boundary with byte-sync. +- `services::openvpn` tracks both headless tunnels and interactive terminal + tunnels armed with Eldrun-owned pid files. +- `services::sandbox` owns Docker runtime lifecycle. Tab close must reap + in-container processes via the existing wrapper/pidfile mechanism. +- Terminal `kill`/`kill_all` must reap the child process subtree, not just the + shell leader. +- Remote GPU snapshots are parsed through the same local `gpustat` parsers so + host readings match local readings field-for-field. ## Verification -Before handing off code changes, run the checks that apply to the files you -changed: +Run checks that apply to the files changed before handing off: ```bash -npm run build +rtk npm run build ``` ```bash -cd src-tauri && cargo test +rtk cargo test --manifest-path src-tauri/Cargo.toml ``` -If `cargo` is unavailable in the environment, state that clearly in the final -handoff. For whitespace checks, also run: +For whitespace checks, also run: ```bash -git diff --check +rtk git diff --check +``` + +For frontend-only risky changes, also consider targeted Vitest/TypeScript +checks if faster than a full build. If `cargo` or another tool is unavailable, +state that clearly in the final handoff. + +Before every push, run the privacy/secret scan on staged changes and stop if it +reports anything real: + +```bash +rtk git add -A +rtk scripts/privacy-check.sh ``` Every push to GitHub should generate a new packaged artifact from -`.github/workflows/ci-cd.yml`; the local equivalent is `npm run package`, -which installs the packaged AppImage outside the checkout so branch switches -do not affect the running app. GitHub Releases are only published for -`v0..0` tags, so patch-only bumps like `0.1.1 -> 0.1.2` do not create -new releases. +`.github/workflows/ci-cd.yml`. The local equivalent is `npm run package`, which +installs the packaged AppImage outside the checkout so branch switches do not +affect the running app. + +Version bumping is automatic on push when `.githooks/` is enabled with +`git config core.hooksPath .githooks`: the pre-push hook patch-bumps +`package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`, commits +that bump, and re-pushes. To bump minor/major, run +`scripts/bump-version.sh minor|major` and commit before pushing. + +GitHub Releases are cut manually only when a `v*` tag is pushed; ordinary branch +pushes do not publish releases. ## Running Locally -Human-only by default. Agents must not run these commands while an Eldrun -instance may already be active. +Human-only by default. Agents must not run these while an Eldrun instance may +already be active. Preferred Tauri launcher: @@ -107,3 +257,6 @@ Development run: ```bash npm run tauri dev ``` + +Useful keys: `F11` toggles fullscreen; `Super` toggles panels while Eldrun is +focused. diff --git a/CLAUDE.md b/CLAUDE.md index 8e7881b..3dbd825 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,20 @@ Both list only the load-bearing files; the tree is the source of truth. **different notions of scope**, so marking a folder auto-sync is the one click that can haul a multi-GB tree into the mirror; the file tree prices the host subtree first (`sync_auto_preview`) and confirms when it is large. +- **Passwords are never persisted by default**, and the opt-in that persists them is + the same in every remote menu — the Connect modal *and* the new-project / + extend-to-remote dialogs (`useRemoteSession`, rendered by `RemoteProjectSection`). + It can be, because the keychain is keyed by **host target** (`ssh:user@host:port`) + and **config path**, never by project id: there is one saved credential per host and + per tunnel, whichever menu saved it. Hence the toggle is *pre-ticked* when the target + already has one (an untick is an explicit delete, so connecting with it unticked + would clear another project's saved password), and a blank password field means "use + the saved one", not "authenticate with nothing". The credential a create/extend + dialog authenticated with is also handed to that project's **first pooled connect** + (`stashRemotePassword`, single-use, never written to disk): connecting it + password-less would work — it rides the ControlMaster the dialog left up — but the + backend reads "no password given, none saved" as *key* auth and would record + `key_auth: true` on a password host, which auto-connect later believes. - A remote project connects **on demand** (the pill's connection lamp opens the `RemoteConnectDialog`) — *unless* it opts into `remote.auto_connect`, which connects it on launch and on activation and **never prompts**. The toggle is only @@ -112,7 +126,14 @@ Both list only the load-bearing files; the tree is the source of truth. a holder refcount — `releaseVpn` means a project logging out never pulls a tunnel out from under another project) and surfaced in the header by `VpnIndicator`, which is always present, lists every stored `.ovpn`, and can bring a tunnel **up or down with - no project behind it**. Every UI that can start a tunnel says so before it does. + no project behind it**. Every UI that can start a tunnel says so before it does — + and none of them offers a *second* one: while a tunnel is up machine-wide, every + project-scoped OpenVPN block (the Connect modal, and the SSH section the + new-project and extend-to-remote dialogs share) collapses to a one-line + "tunnel already up" notice pointing at the header, via the shared + `useVpnSectionVisible` gate + `VpnTunnelUpNotice`. The exception the gate keeps: + a tunnel *that* dialog itself brought up stays expanded, so its log and its + Disconnect remain where the user started it. Interactive (non-headless) tunnels are *armed* at command-build time — `interactive_connect_command` appends a `--writepid` Eldrun owns and registers it — so a tunnel typed into a terminal tab is as visible and as killable as a headless @@ -152,6 +173,77 @@ Both list only the load-bearing files; the tree is the source of truth. and the backend injects `codex resume `. **Codex caveat:** user-level Codex hooks need a one-time trust (`/hooks` in Codex) before they run. Gemini and Vibe are still dropped (TODO 39d). +- **A remote project can reach N machines, not one** (`docs/multi_host_remote_plan.md`, + `services::worker_sync`). The project's `remote` is the **primary** — it still owns + files, git, the mirror, and full bidirectional sync, unchanged. Extra **worker** + hosts (`Project.compute_hosts`, mirrored into `projects.json`'s + `extra["compute_hosts"]`) are experiment machines: their code is kept **one-way** in + sync from the mirror (source→worker, tracked files only, `reset --hard`, **never + `git clean`**), their files are read-only, and their experiment **outputs** (untracked + paths) stay on the worker until a user-initiated, size-confirmed **Pull outputs** — the + only worker→local byte path. Because worker sync is push-only it dodges the entire + divergence/conflict/local-loss half of lockstep. Each host has its own pool entry + (`remote::conn_key`, keyed `(project, host)`), its own pill lamp, and its own tab + locality (`host:`); the primary is the implicit `"primary"` id, so every existing + file/git/sync caller keeps meaning "the primary" with no change. A tab runs on a host + via its `host:` location → `PtyOptions.remote_host_id`. Managed from the pill's + "Remote machines…" (`RemoteMachinesWindow`). + - **A worker can instead SHARE the primary's folder over a shared filesystem — and + this is now the DEFAULT for a newly added machine** (`ComputeHost.shared_fs`, + schema default `false` for back-compat but the "Add machine" form ticks it on; + untick "Sync a copy" ⇒ shared). A shared-fs worker sees the primary's project + folder at its own `remote_path` (an HPC compute node on a shared home), so Eldrun + copies **nothing** and **never runs git on it** — a tab just `cd`s into that folder + and runs there (`wrap_pty_options` already uses `spec.remote_path`, so tab spawn is + unchanged). This is load-bearing for safety: the code path that would `git init` + + `reset --hard` a synced worker's tree must **never** fire on a shared-fs host, or it + corrupts the primary's real working tree. Three guards enforce it — `remote_connect` + skips `on_worker_connect`, `worker_sync::fan_out` skips via `wants_code_fanout` + (`!shared_fs && sync_code`), and `sync_worker` itself early-returns a no-op skip for a + shared-fs host so even a stray `worker_sync_now` is harmless. "Sync code now" / "Pull + outputs" / "Auto-sync code" are hidden for a shared-fs worker (its outputs are already + in the primary's folder, moved by the primary's own sync). +- **A shell/script tab runs inside a tmux session so a long run survives** (#85, + `docs/tmux_remote_plan.md`) — decoupled from the disposable channel, the tab + **reattaches** on relaunch. It covers **two axes**: + - **Remote** (on the SSH host): survives an SSH drop, a laptop sleep, a VPN drop, + or Eldrun quitting. **Default ON** per remote project + (`RemoteSpec.persist_sessions !== false`; opt out via the pill's "Persistent + sessions (tmux)"). `ssh_exec::wrap_pty_options` nests the existing `exec …` + inside `tmux new-session -A -D -s `. + - **Local** (on this machine, Unix only — no tmux on Windows): survives an + **Eldrun crash** (the tmux server is a daemon; the PTY only holds a client). + **Default ON** via `settings.persist_local_sessions`. `services::tmux_local` + rewrites the local spawn's `{cmd,args}` into a `tmux` argv in + `commands::terminal::pty_spawn`, *after* the ssh/docker branch so only a + genuinely local tab is wrapped. + Scoped to **shell tabs** (Python runs open one; a command runs inside the + session's login shell, which outlives it → the run reattaches, not re-runs) and + never the root scope — **agent tabs are excluded** (they resume via their own + session). The session name is a **uuid the frontend mints once per shell tab and + persists** (`TabEntry.tmuxSession`) — *not* derived from the PTY id, which + `loadFromLayout` regenerates on restore (a derived name would fork a second + session on relaunch instead of reattaching); `tmux_attach` overrides it for a + Sessions-view attach. **Kill vs. detach**: closing a tab **always detaches** — + `lib/closeRemoteTab.ts`'s `closeTabWithConfirm` just `removeTab`s, killing only the + ssh/PTY client, so the session lives on under its tmux daemon; an app-exit, + disconnect, crash, or respawn likewise **leave the session alive**. Two explicit + actions terminate a session — a session's **×** (kill) in the Sessions view + (`remote_tmux_kill`/`local_tmux_kill`), and an **active disconnect** of the whole + host, which ends *every* session on it at once (`remote_kill_all_jobs`: `tmux + kill-server` over the host, best-effort). The active disconnect is the "Disconnect" + on a **global machine** (`MachinesIndicator`, + `ssh_close_master` to drop any + shared master) and the "Disconnect & end jobs" beside a project host's plain + Disconnect (`RemoteConnectDialog`, then the ordinary `remote_disconnect` teardown). + Both are **explicit-click only** — `remote_kill_all_jobs` must never run on + deactivation or relaunch, or persistent sessions would stop surviving a restart. + Because a session outlives its tab, a host + can hold runs no tab points at; the **Sessions view** (`☰` toggle in + `ProjectFilesView`, mirrors the Orange view) makes them discoverable — + **multi-host** (aggregated across the primary and every connected worker via + `remote_tmux_list`, each row host-tagged), click a row to attach, per-row **×** + (kill) and **Rename** (`remote_tmux_rename`, updates the owning tab's persisted + name). tmux-absent falls back to today's plain `exec` + a notice. - **A project can run in a container** (#38, `services::sandbox`, `docs/docker_projects_plan.md`): with the pill's "Run this project in a container" toggle on, every shell/agent tab `docker exec`s into ONE @@ -170,14 +262,17 @@ Both list only the load-bearing files; the tree is the source of truth. - **Agent authority has three axes**, and they compose: the project container `sandbox` (OS containment), the tab's `location` (where the process runs), and — behind the experimental `agent_mode_toggle` setting, default off — its `agentMode`: **Plan** - (`--permission-mode plan`) vs **Auto** (`acceptEdits`). The mode is a *launch - flag*, so flipping it rewrites the tab's `args`, which respawns the PTY - (`TerminalView`'s spawn effect keys on them) — non-destructive only because the - backend rewrites `--session-id` → `--resume` and the conversation comes back. - That is exactly why `components/tabs/agentModes.ts` is a **capability table, not - a universal field**: an agent belongs in it only if it has both an absolute mode - flag *and* a working resume. Claude has both; Gemini has the flag but no resume - (a toggle would destroy the chat), Codex resumes but has no plan mode. The mode + vs **Auto** (Claude `--permission-mode plan`/`acceptEdits`; Gemini + `--approval-mode plan`/`auto_edit`). The mode is a *launch flag*, so flipping it + rewrites the tab's `args`, which respawns the PTY (`TerminalView`'s spawn effect + keys on them) — non-destructive only because the tab resumes its conversation on + respawn. That is exactly why `components/tabs/agentModes.ts` is a **capability + table, not a universal field**: an agent belongs in it only if it has both an + absolute mode flag *and* a working resume. Claude (resume-by-id) and Gemini + (continue-last) both qualify; Codex resumes but has no plan mode. Gemini's + continue-last resume carries one accepted caveat — with two Gemini tabs in a + project a respawn reattaches to the project's latest session, not necessarily + this tab's (the same ambiguity their ordinary restore already has). The mode is persisted per tab, and re-applied onto the rebuilt args in `loadFromLayout` — args are NOT persisted, so without that the split would silently die on restart. - New/imported projects receive `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, diff --git a/Cargo.lock b/Cargo.lock index 94a4d1c..9d76107 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,7 +1129,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "eldrun" -version = "0.1.21" +version = "0.1.31" dependencies = [ "arboard", "base64 0.22.1", diff --git a/README.md b/README.md index 78a2e14..3c60b7c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ > **You don't open applications — you open projects.** > Eldrun is a project-centric desktop layer that swaps your whole working -> context as one unit, with AI agent terminals and in-app file viewers built in. +> context as one unit, with AI agent terminals and in-app file viewers built in +> — and it runs that work on remote machines and HPC clusters, not just your +> laptop. Eldrun is a **project-centric desktop layer**, not just an app that launches or embeds other apps: projects own their windows and desktop context, and selecting @@ -24,6 +26,11 @@ on top, living *inside* a project once its desktop is restored. Built with native workspace, app-launch, default-app, and download integration today; macOS runs as a shell with a no-op workspace backend (on the roadmap). +Eldrun is also **strong at working across machines**: it manages a fleet of +remote hosts, runs agents, shells, and jobs on them over SSH, and drives HPC / +SLURM clusters — all from the same project cockpit. See +[Remote machines & HPC clusters](#remote-machines--hpc-clusters). + --- ## Why Eldrun @@ -263,6 +270,37 @@ update-desktop-database ~/.local/share/applications/ pointer hover and disappear when the pointer leaves, keeping the center terminal unobstructed; the right panel can also be pinned permanently open. +### Remote machines & HPC clusters + +Eldrun treats remote hosts as first-class: it **manages a fleet of machines** and +**runs your work on them**, from a single SSH box to a full HPC cluster. + +- **Machine hub**: register SSH hosts once (independent of any project) in the + header's machines indicator — see a live CPU/GPU usage bar per machine, + connect/disconnect, arm silent auto-connect on launch, and drag a machine onto + a project to attach it as a compute host. +- **Run on the remote host**: a project can point at a host and run its agent, + shell, and Python tabs *on that host* over SSH, with the file tree, Git, and + in-app viewers all reading the remote tree. VPN-gated hosts bring up an OpenVPN + tunnel first. +- **Many machines per project**: beyond the primary host, add extra *worker* + machines — their code is kept in sync from the project (one-way, tracked files + only) and their experiment outputs are pulled back on demand. A worker on a + **shared filesystem** (e.g. an HPC compute node on a shared home) is used in + place, with nothing copied and no Git run on it. +- **Persistent sessions**: a long run lives in a tmux session per shell tab, so + a job survives an SSH drop, a laptop sleep, a VPN drop, or Eldrun quitting, and + the tab reattaches on relaunch. A **Sessions view** lists every running session + across all connected machines, with per-row attach/rename/kill. +- **Remote system monitor**: a host's CPU, memory, and GPUs (AMD + NVIDIA, + including per-process GPU memory) are sampled over the same SSH connection and + shown alongside the local machine's. +- **HPC / SLURM** *(built, but untested pending real-cluster QA)*: submit a batch + script with `sbatch`, watch its live log, list and cancel your queued jobs, and + open an interactive compute-node shell via `srun` — without memorizing the + commands. A guided **HPC pipeline wizard** walks a cluster newcomer through + login → project → data upload → job → watch. + ### In-app file viewers Drag a file from the tree onto a subwindow's tab bar to open it in a tab; the @@ -271,11 +309,13 @@ app until they land. | Viewer | Extensions | Status | Notes | | ------ | ---------- | ------ | ----- | -| **Text / code** | `.txt` `.json` `.py` `.rs` `.ts` `.bib` + many more, plus extensionless files like `Dockerfile` | ✅ Shipping | Editable editor: line-number gutter, syntax highlighting, Tab/Shift+Tab indent, undo/redo (`Ctrl+Z`/`Ctrl+Shift+Z`), find (`Ctrl+F`) and find-and-replace (`Ctrl+R`) with match nav + case toggle, save (`Ctrl+S`); unsaved lines marked; non-destructive auto-reload banner; opt-in local autocomplete and grammar check. | +| **Text / code** | `.txt` `.toml` `.py` `.rs` `.ts` `.bib` + many more, plus extensionless files like `Dockerfile` | ✅ Shipping | Editable editor: line-number gutter, syntax highlighting, Tab/Shift+Tab indent, undo/redo (`Ctrl+Z`/`Ctrl+Shift+Z`), find (`Ctrl+F`) and find-and-replace (`Ctrl+R`) with match nav + case toggle, save (`Ctrl+S`); unsaved lines marked; non-destructive auto-reload banner; opt-in local autocomplete and grammar check. | | **Markdown** | `.md` `.markdown` `.mdx` | ✅ Shipping | Rendered preview with an Edit/Preview toggle; links to local files are clickable. | +| **YAML / JSON** | `.yaml` `.yml` `.json` | ✅ Shipping | Editable structure tree with a Tree/Source toggle: retype a value, rename a key, add a key or list item (with a type picker), reorder, delete. Both of YAML's syntaxes are first-class — block (`key:`) and flow (`{a: 1}`, which is exactly JSON, on one line or spread over many) — and each keeps the style it is written in. The tree edits the file's own text, so comments, quoting and layout survive an edit; it withholds the affordance rather than botch a construct it can't rewrite (anchors, merge keys). Source is the full code editor. | | **LaTeX** | `.tex` | ✅ Shipping | Code editor + compile (when a TeX engine is on `PATH`, shell-escape stripped); follows `\input{…}`/`\includegraphics{…}`, `\ref`/`\cite` completion from `\label` keys and `.bib` entries; parsed compile errors jump to the line; bidirectional SyncTeX sync across tiled or detached panes. | | **PDF** | `.pdf` | ✅ Shipping | Rendered with a themed zoom toolbar. | -| **Images** | `.png` `.jpg` `.gif` `.webp` … | ✅ Shipping | Zoom-to-cursor / pan; draggable out as an OS drop source. | +| **Images** | `.png` `.jpg` `.bmp` `.webp` … | ✅ Shipping | Zoom-to-cursor / pan; draggable out as an OS drop source. | +| **Animated GIF** | `.gif` | ✅ Shipping | Frame-level transport on top of the image viewer's zoom/pan: play/pause, frame stepping, scrubber, playback speed, loop toggle, frame/delay readout. Decoded in-app (pure LZW decoder), so it works over SFTP too; a GIF the decoder can't handle degrades to the native animated ``. | | **Table / CSV** | `.csv` `.tsv` | ✅ Shipping | Read-only grid (RFC 4180-style parse); large files are windowed to keep the webview responsive. | | **Jupyter notebook** | `.ipynb` | ✅ Shipping | Read-only render of cells top-to-bottom — markdown cells, Python-highlighted code cells, and their classified outputs. | | **Diff / patch** | `.diff` `.patch` | ✅ Shipping | Color-coded add/del rendering that reads in light and dark themes. | diff --git a/docs/Features.md b/docs/Features.md index d9f26ae..0231490 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -61,7 +61,7 @@ only `src-tauri/` changes need a rebuild/restart. | Feature | Done | Partial | Issue | Notes | |---------|:----:|:-------:|:-----:|-------| | SSH hardening (#28c) | | | | two **Critical**: remote cmd injection in `ssh_list_dir`/`ssh_default_dir`; remote `--resume` ordering bug | -| Agent resume generalization (#39d) | | | | Gemini + Vibe still dropped | +| Agent resume generalization (#39d) | ✅ | | | Claude/Codex resume-by-id; Gemini (`--resume latest`) + Mistral/vibe (`--continue`) via continue-last | | KDE Wayland backend (#18) | | | | show/hide are explicit no-ops (KWin scripting pending) | | Windows backend | | | | stub (`platform/windows.rs`) | | Boxes phases 3–4 (#41) | | | | merged file tree + relation graph deferred | diff --git a/docs/multi_host_remote_plan.md b/docs/multi_host_remote_plan.md new file mode 100644 index 0000000..88158e1 --- /dev/null +++ b/docs/multi_host_remote_plan.md @@ -0,0 +1,418 @@ +# Multi-Host Remote Projects — Plan (experiment-worker model) + +**Goal.** Let one remote project reach **N** remote machines, not just one, so the +**same code** can be run as experiments across several hosts (e.g. different GPU +boxes). A tab's `local / remote` toggle becomes a **list**: +`local | primary | gpu-2 | gpu-3 …`. + +**Model (decided).** +- **One primary host, N extra "worker" hosts.** The primary stays exactly as + today: it owns files, git, the local mirror, and full bidirectional sync. Extra + hosts are **experiment workers**. +- **Extra hosts stay in sync with the canonical code — one-way.** Code (git-tracked + files) is pushed *source → each extra*; it is never pulled back. So all machines + run the same commit. +- **Files on extra hosts are read-only.** You never *edit* on a worker; the UI + offers no write there, and a re-sync overwrites any drift. This is precisely what + lets the sync be one-way: with no edits on the workers, there is **no merge + conflict, no divergence, and no destructive local-loss** — the entire scary half + of the existing lockstep machinery is dodged. +- **Experiments still write their outputs.** "Read-only" means *code* is read-only, + **not** the filesystem. A run writes checkpoints / logs / metrics freely; the sync + never touches untracked/gitignored paths (it does `fetch + reset --hard` but + **never** `git clean`), so outputs survive every sync. +- **Outputs stay on each worker, pulled on demand** (recommended default; §4.4). + Auto-pulling multi-GB checkpoints from N machines is the byte-sync-scope footgun + CLAUDE.md already warns about, so it is opt-in per machine. +- **Per-host lamps, on-demand connect.** Each host has its own connection lamp in + the pill. Auto-connect is an opt-in per host. + +**Two load-bearing insights.** +1. **The bidirectional subsystem is untouched.** Everything that owns the primary's + files/git/sync — `services/git_peer.rs` (~200 KB), `remote_sync.rs`, + `sync_auto.rs`, all of `commands/fs.rs` + `commands/git.rs` + `commands/sync.rs` + — resolves *the* host through `remote_target_for(project_id)` / + `remote_target_for_dir(project_dir)`, both reading the single `extra["remote"]`. + Store extra hosts under a **separate** key (`extra["compute_hosts"]`) and that + whole subsystem keeps meaning "the primary" with zero change. +2. **Worker sync is push-only, so it inherits none of the hard parts.** The + existing lockstep is bidirectional (pull, divergence, conflict, local-loss + auditing). The worker fan-out reuses only the *outbound* primitives — + bundle-create + apply — and never the inbound half. It is a new, deliberately + thin push-only path, not a second full lockstep. + +--- + +## 1. Data model + +### 1.1 Backend — `schema/project.rs` + +An extra host is a `RemoteSpec` + a stable id + a display label. Reusing +`RemoteSpec` verbatim means every existing execution helper (`ssh_exec`, `sftp`, +`remote_usage`, monitor/disk/gpu/python) works unchanged — they already take +`&RemoteSpec`. + +```rust +/// An additional SSH machine a project runs experiments on. Its code is kept in +/// one-way sync from the canonical source (the local mirror); its files are +/// read-only (edits forbidden). It never owns git/sync/mirror state of its own — +/// the primary `remote` does. `id` is stable (referenced by tab locations + the +/// pool key + the fan-out state); `label` is the pill/tab display name. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComputeHost { + pub id: String, // stable, e.g. "h1"; primary is implicit "primary" + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, // "gpu-2"; falls back to host + /// Keep this worker's tracked tree synced to the source HEAD (default true). + #[serde(default = "default_true")] + pub sync_code: bool, + /// Pull this worker's experiment OUTPUTS back on demand only, never + /// automatically (default false — outputs stay on the worker). + #[serde(default)] + pub pull_outputs: bool, + #[serde(flatten)] + pub spec: RemoteSpec, // user/host/port/remote_path/openvpn/auto_connect/key_auth +} +``` + +Add to `Project`: + +```rust +#[serde(default, skip_serializing_if = "Vec::is_empty")] +pub compute_hosts: Vec, +``` + +**Migration: none.** Existing projects have no `compute_hosts`; `#[serde(default)]` +yields `[]`. The primary is still `Project.remote`, unchanged. + +### 1.2 Mirroring into `projects.json` — `commands/projects.rs` + +`project_extra(...)` (~`:2352`) mirrors `remote` + `mirror` into the always-local +`projects.json` entry so resolvers stay synchronous. Add a mirror of +`compute_hosts` under `extra["compute_hosts"]` (array). That is the only place +workers need to reach the local `projects.json`; every resolver below reads it. + +New host CRUD commands (mirror the existing `patch_remote_spec` at `:851`): +- `add_compute_host(project_id, ComputeHost)` — append + write `project.json` + **and** `extra["compute_hosts"]`, minting `id` if absent. +- `remove_compute_host(project_id, host_id)` — disconnect it, stop its fan-out, + then drop it. +- `patch_compute_host(project_id, host_id, patch)` — for `auto_connect` / + `key_auth` / `openvpn` / `sync_code` / `pull_outputs`. + +### 1.3 Frontend — `types/index.ts` + +```ts +export interface ComputeHost extends RemoteSpec { id: string; label?: string; sync_code?: boolean; pull_outputs?: boolean; } +export interface ProjectEntry { /* … */ remote?: RemoteSpec; compute_hosts?: ComputeHost[]; } +``` + +--- + +## 2. Worker code sync — one-way, code-only (the heart of this change) + +**Canonical source = the local mirror.** It is already lockstep-paired with the +primary (`git_peer`), so the local mirror always holds the project's current +committed code. The workers are fed **from the mirror**, one-way. (Feeding from the +mirror, not primary→worker directly, means Eldrun — which already holds SSH to both +legs — is the courier; it assumes no primary↔worker network path or shared keys.) + +**Direction & grain.** +- Source → worker only. A worker's tree is **never** read back into the mirror + (that is what makes it conflict-free). +- Tracked files only. The push makes the worker's tracked tree equal the source + HEAD; untracked/gitignored paths (experiment outputs, datasets staged on the + worker) are left alone. + +**Mechanism (reuses `git_peer` outbound primitives; a new push-only mode).** +Per worker, first sync bootstraps a bare-ish working clone at `worker:remote_path` +(git init + fetch, or clone from a bundle Eldrun ships over SFTP — same +`remote_bundle_path` + apply primitives lockstep uses local↔primary). Each +subsequent sync: +1. On the mirror, `git bundle` the current HEAD (or an incremental bundle since the + worker's last-known head). +2. Ship the bundle to `worker:remote_path/.git/eldrun-worker.bundle` over SFTP. +3. On the worker: `git fetch` the bundle, then `git checkout -f ` / + `reset --hard ` — **tracked files only**. +4. **Never `git clean`.** Untracked outputs survive by construction. (Contrast: the + primary's lockstep *does* clean in some refused-FF cases and audits the loss; + the worker path deliberately never does, because a worker holds irreplaceable + run outputs, not a recoverable mirror.) + +**Per-worker state.** A tiny `worker_sync.json` under the worker's slot in the +project state dir records its `last_head` (the commit last pushed) so an +incremental bundle is cheap. No manifest, no byte-walk — code is git, and git +already knows what changed. + +**Triggers.** +- On worker **connect** (bring it up to the current HEAD). +- On a new **commit** to the mirror/primary (the same signal `git_peer` already + watches via the `.git` watcher) → fan out to every connected, `sync_code` + worker. +- Manual **"Push code to machines"** action in the pill. + +Because the primary already commits-then-syncs (lockstep is commit-gated), a worker +receives an edit at the same moment the primary does: **on commit**, not on save. +This is the identical mental model the primary already has (CLAUDE.md, +`git_lockstep_case_matrix.md` #5/#7) — extended to N workers. + +**Reusing infrastructure vs. new code.** New: a `WorkerSyncRegistry` (per +`(project, host)`), the push-only fan-out loop, `worker_sync.json`, +`fetch + reset` (no clean) on the worker. Reused unchanged: bundle create/ship +primitives in `git_peer`, the `.git` commit watcher, `run_git_remote` / +`run_remote_script` over the worker's ControlMaster. + +--- + +## 3. Connection layer + +### 3.1 Already N-host-safe (no change) + +- **ControlMaster sockets** are `cm-%C`, hashed per user/host/port + (`ssh_exec.rs:63-70`, `ssh_common.rs:88`). Two hosts → two sockets, free. +- **Keychain credentials** keyed by host target `ssh:user@host:port` + (`remote_credentials.rs:28`) — each worker saves its own password, no collision. +- **VPN** machine-wide, config-keyed, holder-refcounted (`vpnStatus.ts`, + `openvpn.rs`). A worker on a different tunnel just adds a holder/config. + +### 3.2 Pool — `services/remote.rs` + +Re-key `RemotePool.conns` from `HashMap` to a composite key: + +```rust +fn conn_key(project_id: &str, host_id: &str) -> String { format!("{project_id}\u{1}{host_id}") } +``` + +- `connect(pool, project_id, host_id, password)` — resolve the spec: `"primary"` → + `remote_target_for` (unchanged), else the `compute_hosts` entry. **Only the + primary touches the mirror** (`mirror_dir` at `:166` stays behind the primary + branch). +- `disconnect` / `is_connected` / `pooled_sftp` thread `host_id`. File/git/sync + callers pass the constant `"primary"` (they *are* the primary subsystem). +- `connected_ids` → `connected_targets -> Vec<(project_id, host_id)>`. + +### 3.3 Connect / disconnect commands — `commands/remote.rs` + +- `remote_connect(…, project_id, host_id: Option, password)` (default + `"primary"`). Primary connect keeps wiring `git_peer` + `sync_auto` (`:73-90`). + A **worker** connect instead kicks the **worker code-sync** fan-out (§2) and + skips byte-sync/lockstep. +- `remote_disconnect(…, project_id, host_id)` — primary stops git_peer + sync_auto; + a worker stops only its fan-out + pool entry. + +### 3.4 Traffic accounting — `services/net_usage.rs` (phase 3, optional) + +Sum all of a project's hosts under the project bucket initially (keeps the existing +per-project shape); per-`(project,host)` buckets can come later. + +--- + +## 4. Frontend + +### 4.1 Tab location — `stores/tabs.ts` + +```ts +export type TabLocation = "local" | "remote" | `host:${string}`; // local | primary | worker id +``` + +- `remoteHostIdOf(loc)` → `"primary"` for `"remote"`, the id for `"host:"`, + `null` for `"local"`. +- `defaultLocationForKind` (`:3596`) unchanged; `localTabCwd` (`:3627`) unchanged + (only `"local"` uses the mirror; a worker locality returns `fallback`). +- `SavedTabEntry.location` (`:368`) is already a string → new values round-trip. A + location naming a deleted host falls back to `"remote"` on load. + +### 4.2 PTY spawn — backend + +- `PtyOptions` (`terminal/mod.rs:36`): add + `#[serde(default)] pub remote_host_id: Option`. +- `wrap_pty_options` (`ssh_exec.rs:472`): resolve the spec by host id — primary via + `remote_target_for` (unchanged), else the worker spec; `remote_dir = + spec.remote_path`; `ssh_pty_args(&spec, cmd)` as today. +- `commands/terminal.rs:141` passes `opts.remote_host_id`; `TerminalView.tsx:29` + passes it from `remoteHostIdOf(tab location)`. + +### 4.3 Connection state + pill — `stores/remoteStatus.ts`, `RemoteConnMenu.tsx` + +- `byProject: Record>`; `setSsh/​setVpn` + gain `hostId`; add `sshOf(projectId, "primary")` so untouched readers change in + one line. `clear(projectId)` wipes all hosts; add `clearHost`. +- Pill shows **one lamp per host** (primary + each worker). Clicking a lamp opens + the connect flow for that host. `connectDialog` opens with `{ projectId, hostId }` + (default `"primary"`); `RemoteConnectDialog` / `useRemoteReconnect` parametrize by + `hostId` (their `liveTerms`, `pendingRemotePassword`, lamp reads become + `(projectId, hostId)`-keyed). + +### 4.4 Managing workers + outputs — pill + +A "Remote machines…" section lists hosts with **Add machine** (reuses +`RemoteProjectSection` / `useRemoteSession` to collect user/host/port/remote_path + +optional VPN → `add_compute_host`) and per-worker controls: +- **Sync code now** (manual fan-out trigger) and a "last synced: " line. +- **Pull outputs…** — a one-shot, on-demand pull of the worker's *untracked* output + paths into a local `outputs//` folder (never automatic). This is the + one place worker→local bytes ever move, and it is user-initiated. The + `sync_auto_preview`-style size-confirm applies (a checkpoint dir can be huge). +- Toggles: `sync_code` (default on), `pull_outputs`-on-demand-only (default), and + auto-connect. + +### 4.5 Tab UI + +- **TabBar badge** (`TabBar.tsx:1156`): the 2-state `local↔remote` toggle becomes a + small menu (`Local`, `Primary (host)`, each worker label) → `setTabLocation`. +- **Hold gate** (`CenterPanel.tsx:976`): read the lamp of *the tab's* host + (`remoteHostIdOf`), not the single project lamp — a pane on gpu-2 holds iff gpu-2 + is down, independent of the primary. `remoteHost` label = that host's label. +- **TabHoverCard** / **RemotePaneHold** show the target host's label. + +### 4.6 Read-only enforcement on workers + +Workers never expose an editing surface: +- No worker file tree/git/source-switch (the file UI stays `Local | Remote(primary)` + — §6). If a *read-only* worker file browser is ever wanted, it is a later, + separate SFTP-read view; not in scope here. +- Any code drift on a worker is silently overwritten by the next `reset --hard` + (§2) — which is safe *because* editing is forbidden, so drift can only be + accidental, and outputs (untracked) are never in `reset`'s path. + +--- + +## 5. Monitoring per worker + +`system_monitor_snapshot` (`monitor.rs:25`), `disk_usage_scan` (`disk_usage.rs:66`), +the remote GPU snapshot (`sysstat::REMOTE_SNAPSHOT_SCRIPT`), and the python probe +(`python.rs:389`) each already resolve *a* spec from `project_id`; add an optional +`host_id` that selects the worker (primary when absent). The monitor / disk panes +gain a host selector; a run tab on gpu-2 probes gpu-2's interpreter. + +--- + +## 6. Explicitly NOT changing + +- `remote_target_for` / `remote_target_for_dir` / `RemoteTarget` stay + **primary-only**. All file/git/sync keeps calling them. +- `git_peer.rs` (bidirectional lockstep), `remote_sync.rs`, `sync_auto.rs`, + `local_loss.rs`, the mirror, `commands/fs.rs` / `git.rs` / `sync.rs` — the + primary path is **untouched**. The worker fan-out (§2) is *new, additive, + push-only* code that reuses their bundle primitives. +- File tree / file-source switch / `fileSourcePref` stay `Local | Remote(primary)`. + +--- + +## 7. Phasing & tests + +**Phase 1 — connect to N workers + run tabs on them.** +Schema `ComputeHost` + `compute_hosts` + `projects.json` mirror + CRUD (§1); pool +composite key (§3.2); `TabLocation` `host:` + `PtyOptions.remote_host_id` + +`wrap_pty_options` resolve + TerminalView (§4.1-4.2); `remoteStatus` re-key + +per-host lamps + connect-dialog `hostId` + TabBar badge menu (§4.3-4.5). +Rust tests: two hosts one project — independent connect/disconnect/`is_connected`; +`conn_key` uniqueness; `wrap_pty_options` builds `ssh` args to a worker; primary +path unchanged (extend `remote.rs` tests at `:315`). + +**Phase 2 — worker code sync (§2).** +Push-only fan-out (bundle → ship → `fetch`+`reset --hard`, no clean), +`worker_sync.json`, commit-watch + connect + manual triggers, "last synced" UI. +Rust tests (drive like `examples/lockstep_drv.rs`): a commit on the mirror reaches +the worker's tracked tree; an untracked output file on the worker **survives** a +sync; a worker-side code edit is overwritten; sync is never triggered +worker→mirror. + +**Phase 3 — outputs + monitoring + polish.** +On-demand **Pull outputs** with size-confirm (§4.4); per-worker monitoring (§5); +per-worker auto-connect; per-host net_usage; stale-host-id fallback. + +**Live QA (hand to user — I can't launch Eldrun):** add gpu-2; connect it while the +primary stays down; open a shell + a Claude tab on gpu-2, confirm `hostname`; +commit on the primary and confirm the change appears on gpu-2's tracked tree; +create an output file on gpu-2, sync again, confirm it survives; try to edit a +tracked file on gpu-2 and confirm the next sync reverts it; confirm the primary's +file tree / git / sync are visibly unaffected; disconnect gpu-2 and confirm only +its panes hold. + +## 8. Risks / edge cases + +- **"Read-only" ≠ read-only filesystem.** The single most important invariant: + the worker sync must **never `git clean`** and never touch untracked paths, or it + eats experiment output. Enforced in §2 step 4 and tested in Phase 2. +- **Output pull-back is the only worker→local byte path** and is user-initiated + + size-confirmed (§4.4) — never a background pass. +- **Uncommitted local edits don't reach workers.** Same commit-gated model as the + primary (a worker gets code on commit, not on save). Document, as CLAUDE.md does + for the primary. +- **Tab location naming a deleted worker** → fall back to primary (§4.1). +- **VPN**: a worker sharing the primary's config is free (refcount); a worker + needing a *different* `redirect-gateway` config is a machine-wide routing + conflict the existing `VpnIndicator` already surfaces — note it in the add-host + UI, no new mechanism. +- **Agent resume on a worker** works (spec-agnostic `ssh -tt` + `ELDRUN_TAB_UID`), + but moving a tab worker→primary respawns it on the primary as a fresh + conversation — the same caveat the current local↔remote switch has. Document. +- **First-worker connectivity assumption**: Eldrun couriers code mirror→worker, so + it needs SSH to the worker (it already does, to run tabs) but **no** primary↔worker + path — deliberately, since workers are often on isolated cluster networks. + +--- + +## 8b. Shared-filesystem workers (added 2026-07-19, now the default) + +Many clusters give the login node (primary) and every compute node a **shared +filesystem** — the same project folder is visible on all of them at one path. Copying +the code to each worker and one-way syncing it is then pure waste: the worker already +*is* looking at the primary's tree. So a worker gains a `shared_fs` flag +(`schema::project::ComputeHost.shared_fs`), and **a newly added machine defaults to it** +(the schema default stays `false` for back-compat — existing synced workers are +untouched on load — but the "Add machine" form ticks it on; "Sync a copy" is the opt-out +into the §2 synced-copy worker, for hosts that don't share storage). + +- **What it does.** Nothing is copied and **git never runs on the host**. A tab on a + shared-fs worker just `cd`s into `spec.remote_path` (the project folder as visible on + that machine) and runs there — the process executes *on the worker*, reading/writing the + shared tree. `wrap_pty_options` already resolves the spec by host id and uses + `spec.remote_path`, so tab spawn needed **no** change. +- **The load-bearing safety invariant.** The §2 push (`git init` + `fetch` + + `reset --hard`) must **never** fire on a shared-fs host — that path IS the primary's + real working tree, and a `reset --hard` there would clobber the primary's state. Three + guards enforce it: (1) `remote_connect` skips `on_worker_connect` for a shared host; + (2) `fan_out` filters through the pure `wants_code_fanout(host) = !shared_fs && sync_code` + (unit-tested); (3) `sync_worker` itself early-returns a no-op skip for a shared host, so + even a stray manual `worker_sync_now` is harmless. +- **No Sync / Pull outputs.** A shared-fs worker's outputs land in the primary's own + folder (moved by the primary's ordinary sync), so "Sync code now" / "Pull outputs" / + "Auto-sync code" are hidden for it in `RemoteMachinesWindow`; the row carries a "Shared + filesystem" badge instead of a "last synced" line. + +## 9. Implementation status (2026-07-19) + +**Phases 1 & 2 fully implemented; Phase 3 implemented except per-worker monitoring.** +All `cargo test` (853) + `vitest` (1703) green; live QA (§7) still owed (I can't launch +Eldrun). + +**Backend.** `schema::project::ComputeHost` + `Project.compute_hosts` (§1.1); +`projects.rs` mirror into `extra["compute_hosts"]` + CRUD `add_/remove_/patch_compute_host` +(§1.2). `services::remote` re-keyed to a composite `conn_key(project, host)` with +`PRIMARY_HOST` wrappers keeping every file/git/sync caller unchanged; `connect_host`/ +`disconnect_host`/`disconnect_project`/`is_connected_host`/`pooled_sftp_host`/ +`connected_targets`/`remote_target_for_host`/`compute_hosts_for` (§3.2). `commands::remote` +threads `host_id` (default primary) through `remote_connect`/`remote_disconnect`, adds +`remote_disconnect_all_hosts`, `remote_connected_targets`, `worker_sync_now`, +`worker_outputs_preview`, `worker_pull_outputs` (§3.3). `PtyOptions.remote_host_id` + +`wrap_pty_options` resolve-by-host (§4.2). `services::worker_sync` = the whole §2 push-only +fan-out + §4.4 output pull; wired into `git_peer::start`/`poll_loop` so a mirror commit +fans out to connected workers. + +**Frontend.** `TabLocation` gains `` `host:${string}` `` + `remoteHostIdOf` (§4.1); +`remoteStatus` split primary(`byProject`)/workers(`byHost`) with `sshOf`/`vpnOf` (§4.3); +`TerminalView.remoteHostId` from `TabPane` (§4.2); per-host pill lamps (`RemoteConnMenu`) + +`connectDialog.hostId` + `useRemoteReconnect(project, host?)` + `RemoteConnectDialog` +host-aware (§4.3); TabBar locality badge → Local/Primary/worker **menu** (§4.5); +`CenterPanel` hold gate per-host (§4.5); `RemoteMachinesWindow` = the §4.4 pill section +(add machine, connect, sync-now, pull-outputs w/ size-confirm, toggles); deactivation +disconnects all hosts; `AppShell` lamp-reconcile uses `remote_connected_targets`. + +**Deferred (documented).** Per-worker **monitoring** host selector (§5) — `SystemMonitorPane` +is mid-refactor by another working copy (pre-existing tsc errors), so not touched; the +backend monitor commands still take only `project_id`. Per-`(project,host)` **net_usage** +buckets (§3.4) — `connected_ids` sums a project's hosts under one bucket as the plan +allows. Per-worker **auto-connect on launch** — the toggle persists via `patch_compute_host`, +but `autoConnectRemote` (`stores/projects`) still probes the primary only. diff --git a/docs/tmux_remote_plan.md b/docs/tmux_remote_plan.md new file mode 100644 index 0000000..0034f85 --- /dev/null +++ b/docs/tmux_remote_plan.md @@ -0,0 +1,317 @@ +# Persistent Remote Sessions (tmux) — Implementation Plan (TODO #85) + +Status: **implemented** (Phases 0–4; live-QA on a real host still pending — Done ≠ Tested). + +**What shipped vs. this plan.** Two deliberate changes from the design below: + +1. **Default ON**, not opt-in behind the experimental flag (per the user). + `RemoteSpec.persist_sessions` means "default on unless explicitly `false`", the + pill's "Persistent sessions (tmux)" item is the opt-**out**, and the per-spawn + decision is `PtyOptions.tmux_session` (set only for remote `shell` tabs — Python + runs open a shell tab — never agents). + +2. **The session name is a frontend-minted uuid persisted on the tab** + (`TabEntry.tmuxSession`, `newTmuxSessionName`), *not* derived from the tab id + (§Data model option 1). The reason: `loadFromLayout` regenerates a tab's key on + every restart, so a name derived from the PTY id (`:`) would + change on relaunch and **fork a second session instead of reattaching** — the + opposite of the goal. `ELDRUN_TAB_UID` (the plan's suggested source) only exists + for agent tabs anyway. Minting once and persisting it makes the name stable + across a relaunch, which is what makes restart-reattach work. + +Key symbols: backend `ssh_exec::{TmuxWrap, tmux_wrap_exec, +tmux_kill_session_script, tmux_rename_session_script, tmux_ls_script, +parse_tmux_ls, TmuxSession, valid_tmux_session_name}` + `services::tmux_local` + +`PtyOptions.{tmux_session,tmux_attach}`, commands +`remote_tmux_{list,kill,rename}`/`local_tmux_{list,kill,rename}`/`set_project_persist_sessions`; +frontend `lib/tmuxSession.ts` (mint + gate), `lib/closeRemoteTab.ts`, the persisted +`TabEntry.tmuxSession`/`tmuxAttach`, and the Sessions view in `ProjectFilesView.tsx`. + +### Post-v1 additions (this pass) + +- **Local persistence (Unix)** — the plan below scoped tmux to *remote* tabs, calling + local "impossible on Windows and low-value elsewhere." Shipped anyway (per the + user): a **local** project's shell/script tab is wrapped in a tmux session on the + machine so a run survives an **Eldrun crash** and reattaches on restart. Works + because the tmux server is a daemon the PTY-held client is separate from; **Unix + only** (no tmux on Windows → `services::tmux_local` no-ops), **default ON** via + `settings.persist_local_sessions`. The wrap is an argv rewrite in `pty_spawn` + (not a `$SHELL -c` string like the remote path), applied only when ssh/docker + wrapping did **not** fire. +- **Worker-host sessions** — the Sessions view now aggregates across the primary + **and every connected worker** (`docs/multi_host_remote_plan.md`), each row + host-tagged; attach/kill/rename act on the row's host. The wrap itself was already + host-agnostic (it runs per-host via `remote_host_id`), so only the view changed. +- **Rename** — per-row Rename in the Sessions view (`remote_tmux_rename` / + `local_tmux_rename`, gated on `valid_tmux_session_name`) also updates the owning + tab's persisted `tmuxSession`/`tmuxAttach` so it reattaches to the new name after a + restart (the live client stays attached — tmux rename never drops it). +- **Resumable command tabs** — largely already satisfied: a script/command runs in a + persistent shell tab (the `lib/pythonRun.ts` pattern — a login shell that outlives + the command), so it restores and reattaches to the *completed* run rather than + re-running. For a directly-`cmd`'d local command tab, `local_tmux_args` keeps a + login shell **after** the command for the same guarantee. + +## The problem, in one sentence + +A remote shell or script is a child of the `ssh -tt` channel, so it dies — +silently, with `SIGHUP` — the instant that channel breaks: a network blip, a +laptop sleep, a VPN drop, or Eldrun quitting. For a long Python training run +that is the worst possible outcome, and it is exactly the case Eldrun cannot +survive today. + +The fix is to run the remote shell/script **inside a tmux server on the host**, +decoupled from the SSH channel. The SSH client becomes a disposable viewer; the +work lives on the server. Reconnect — or relaunch Eldrun — and the same command +reattaches to the still-running session, output and all. + +## Why this is server-side, and what that means for Windows/macOS + +tmux runs on the **remote host** (a Linux box — essentially always, for a +compute/experiment host). The local machine only ever runs the `ssh` client. So +a Windows or macOS Eldrun connecting to that host gets **identical** +persistence: the feature is entirely server-side and is *not* obsolete on those +platforms — it is the whole point that the run is decoupled from your laptop's +OS, network, and Eldrun process. The only local-OS wrinkle is cosmetic and +already handled elsewhere (Windows skips ControlMaster, `ssh_exec.rs:187`); the +tmux path is untouched by it. + +**Scope is therefore remote projects only.** Local (non-remote) tabs are a +weaker, harder story — the process is Eldrun's own child, not decoupled by SSH, +so surviving an Eldrun restart would need tmux/dtach *locally* too, which is +impossible on Windows and low-value elsewhere. Out of scope for this item. + +## The one seam + +Every remote tab's command is built by **`remote_command`** (`ssh_exec.rs:117`), +called from **`wrap_pty_options`** (`ssh_exec.rs:500`). Today it emits: + +``` +cd && export K= … && exec "${SHELL:-/bin/bash}" -l # shell tab +cd && export K= … && exec "${SHELL:-/bin/bash}" -lc '' # command/agent tab +``` + +The tmux wrap replaces only the final `exec …` with a tmux launch. Everything +before it — the `cd`, the sorted env exports, the `remote_agents` bootstrap +prelude (`ssh_exec.rs:159`) — is preserved verbatim, nested *inside* tmux: + +``` +cd && export … && exec tmux new-session -A -D -s eldrun- -- \ + "${SHELL:-/bin/bash}" -l # shell tab +cd && export … && exec tmux new-session -A -D -s eldrun- -- \ + "${SHELL:-/bin/bash}" -lc '' # command/agent tab +``` + +Flags, each load-bearing: + +- **`-A`** — attach if the session exists, create it if not. This single command + is *both* "start" and "resume": on first spawn it creates and runs the target; + on a reconnect/relaunch it reattaches and the target arg is ignored (the + process is already running). This is what makes restart-resume free. +- **`-D`** — on attach, detach any *other* client first. Stops a stale ssh + client (ControlPersist can outlive a reload) or a second Eldrun window from + mirror-sharing the pane and fighting over its size. +- **`-s eldrun-`** — a **stable per-tab session name**. The one piece of + new persisted state (see Data model). If it is not stable across restarts, + reattach silently creates a *second* session instead of resuming. + +`ssh_pty_args` (`ssh_exec.rs:176`) is unchanged: it still forces `-tt`, and it +deliberately omits `BatchMode`, so a first-run interactive prompt (host key, +passphrase) still works through the terminal even with tmux in the middle. + +## Data model — one field, no migration + +A per-tab boolean plus a derived session name. Two options: + +1. **Reuse the existing tab identity.** The frontend already mints + `ELDRUN_TAB_UID` (persisted, and already exported to the host). Derive + `eldrun-` in `wrap_pty_options` — no schema change at all, and the name + is automatically stable across restarts because the uid is. +2. **Explicit `tmuxSession` on the tab**, mirrored like `sessionId` + (`tabs.ts:372`), if we ever want a human-named session decoupled from the + tab. Not needed for v1. + +A tab that **attaches** to an arbitrary named session (from the Sessions view, +below) carries that name explicitly — `tmuxAttach: Option` on the tab — +so `wrap_pty_options` builds an attach rather than a fresh spawn, and the tab +restores/reattaches to *that* session across a restart. This is the one case +where the session name is not derived from the tab uid (the session predates the +tab), so it must be persisted on the tab. + +Go with (1) for tabs Eldrun starts. The opt-in is a per-project toggle +(`RemoteSpec` gains +`persist_sessions: Option`, mirrored into `projects.json` `extra` and the +`types/index.ts` mirror, defaulting to unset ⇒ off) so it upgrades every +existing remote project in place with no migration — same pattern as the sandbox +toggle. Gate the whole thing behind the experimental flag +(`lib/experimental.ts`) until live-QA'd. + +## Restart-resume — most of the machinery already exists + +The reattach path is already there; tmux just makes it *mean something* for +remote work. + +- A remote **shell** tab is *already* restorable — `isRestorableKind` + (`tabs.ts:3416`) is true for shell/files kinds, so on relaunch Eldrun already + respawns its ssh command. Today that respawn gets a *fresh* shell (the prior + run is gone). With tmux, the respawned command is + `tmux new-session -A -s eldrun-` → it **reattaches** to the session that + kept running. No new restore code for shells — the wrap alone delivers it. +- **Scripts** run *inside* a shell/command tab (this is already true — + `lib/pythonRun.ts` opens a terminal tab and types the command, precisely so it + inherits tab locality). So a persistent shell tab carries a running Python + process through a restart for free. A dedicated resumable *command* tab (like + `RESUMABLE_AGENTS`, `tabs.ts:3452`) is a possible follow-up but not required + for v1. + +## Kill vs. detach — the one genuinely new decision + +Today closing a tab reaps the whole remote subtree (backend `CLAUDE.md`, +`terminal/mod.rs` `kill`). With tmux, closing the ssh client must **not** kill +the session — that would defeat the entire feature. Split the intent: + +- **Close tab** (an explicit user action) → kill the session: + `tmux kill-session -t eldrun-`, fired as a one-shot over the pooled + ControlMaster via **`run_remote_script`** (`ssh_exec.rs:300`) — the transport + already exists, no new plumbing. The local ssh child is reaped as today. +- **Eldrun quit / disconnect / connection drop** → leave the session alive on + the host. This is the whole point. + +The seam: the tab-close path (`kill`) needs to know whether the tab was a +persistent remote tab, and if so run the kill-session one-shot *before* reaping +the ssh child. App-exit (`kill_all`) and remote-disconnect must **not**. + +## tmux presence & fallback + +Detect and degrade gracefully, reusing the `remote_agents.rs::bootstrap_prelude` +pattern (`command -v tmux >/dev/null || …`): + +- **Present** → wrap as above. +- **Absent** → run the plain `exec` (today's behavior) and surface a one-line + notice that persistence is off because tmux is missing. Note tmux is *usually* + preinstalled on compute/HPC hosts, but userspace-installing it (no sudo) is + harder than an npm-installable agent CLI, so the realistic fallback is + "run without persistence + tell the user," **not** auto-install. + +## UX cost — the honest trade + +tmux intercepts scrollback (its own copy-mode) and draws a status bar, which can +make an xterm.js pane feel less native. Mitigations, shipped as session options: + +- `set -g status off` — reclaim the status line; Eldrun already provides tabs and + layout, so tmux's status bar is redundant chrome. +- `set -g mouse on` — wheel scrolls tmux history so scrollback still feels normal. + +**Alternative worth weighing: `dtach`/`abduco`.** These give pure detach/attach +with *no* status bar, no windows, and no scrollback interception — a strictly +more transparent fit for Eldrun's model, since we already own tabs and layout. +The trade is availability: tmux is far more likely to be preinstalled on a +random compute host. Recommendation: **build on tmux** (what the user asked for, +and the common denominator), but keep the wrap behind a tiny "session backend" +indirection so a `dtach` backend can be added later without touching the seam. + +## Composition with the other axes + +- **Docker containers** are mutually exclusive with ssh wrapping + (`terminal.rs:116`) — a container project is local. No interaction. +- **Worker hosts** (multi-host) each get their own pool entry and could each run + their own tmux session (`eldrun-` is already unique per tab, and tab + locality is `host:`); the wrap is host-agnostic. v1 can apply it to the + primary only and extend to workers trivially later. +- **Agent tabs** — the `remote_agents` bootstrap prelude nests *inside* the tmux + target command untouched. A remote Claude/Codex tab could run inside tmux too, + but agent *conversation* resume is already handled by `RESUMABLE_AGENTS`; the + tmux value there is narrower (surviving a mid-response disconnect). v1 can + scope tmux to shell/command tabs and leave agent tabs as-is. + +## Surfacing "your run is still alive" — the Sessions view + +Because a persistent session outlives the tab that started it, the host can hold +sessions no open tab points at: a run from a crashed/relaunched Eldrun, a +different machine, or one the user started by hand in a plain ssh terminal. Those +must be **discoverable and reattachable**, or the persistence is a trap. This is +the primary UI surface for the feature. + +**A "Sessions" view in the file viewer**, alongside Files / Git / Search / Apps / +Orange (`ProjectFilesView.tsx:33,607`). It is the exact analog of the **Orange +(diverged)** view (`ProjectFilesView.tsx:619-632`): a toolbar toggle gated on +`project?.remote && projectId`, badged with a live count, that swaps the tree for +a backend-fed list. Because `ProjectFilesView` renders *twice* (RightPanel and +the Files (Project) tab), the view — and its click-to-open — appears in **both** +the right-side files panel and the Files tab with no extra wiring, exactly as the +Orange view does. + +- **Toolbar button:** `☰ N` (session count), remote-only, next to the `±` orange + toggle. Toggles `view === "sessions"`. +- **List rows:** one per host tmux session. Each row shows the session **name**, + window/pane count, created/idle time, and an **attached** dot (another client + is on it right now). Rows Eldrun created (`eldrun-` prefix) are labelled + with the originating project/tab when known; foreign sessions show their raw + name — both are first-class. +- **Click a row → open the running session.** Opens a shell tab whose remote + command is an **attach**, not a fresh shell: `tmux new-session -A -D -s ` + with no target command (idempotent: attaches the existing session; `-D` + detaches any other client so keystrokes aren't mirrored). The tab carries the + session name so it restores and reattaches like any persistent tab. This is why + attach must accept an **arbitrary** name, not just `eldrun-` — a + hand-started `train` session opens the same way. +- **Per-row actions:** **Kill** (`tmux kill-session -t `, confirmed) and, + for an already-open session, "reveal the tab that owns it". Both ride the same + one-shot transport. + +**Backend:** one new pure-ish command `remote_tmux_list(project_id, host_id)` → +`tmux ls -F '#{session_name}\t#{session_windows}\t#{session_created}\t#{session_attached}'` +over `run_remote_script` (`ssh_exec.rs:300`), parsed into a `Vec` +(empty, not an error, when tmux is absent or no server is running — `tmux ls` +exits non-zero with "no server running", which the parser treats as zero +sessions). Polled on the same cadence as the orange/status poll, so the count +badge stays live. The attach path reuses the existing `wrap_pty_options` seam — +the tab just declares "attach to session ``" instead of "run ``". + +**Close confirm.** On **close** of a persistent remote tab, confirm ("This will +terminate the remote session `eldrun-` and any process running in it") — the +mirror of the container toggle's non-resumable-conversation confirm, and in the +spirit of `LocalLossDialog`: the tab is a window onto a live process, and closing +it (vs. detaching) is destructive. + +## Phases + +- **Phase 0 — wrap + flag.** `remote_command` grows a `persist: bool` param; + `wrap_pty_options` passes the project's `persist_sessions` (gated on the + experimental flag). Pure, unit-testable: assert the exact tmux argv for + shell/command tabs, and that env/`cd`/bootstrap-prelude nesting is unchanged + when off. **No runtime behavior change until the flag/toggle is on.** +- **Phase 1 — kill semantics.** Thread "is a persistent remote tab" into the + `kill` path; fire `tmux kill-session` via `run_remote_script` on explicit + close only. App-exit and disconnect leave sessions alive. Unit-test the + branch selection (kill-session fired vs. not) without a real host. +- **Phase 2 — presence + transparency.** tmux-detect fallback prelude; ship the + `status off` / `mouse on` options; the missing-tmux notice. +- **Phase 3 — UI: the toggle + close-confirm.** The per-project persistence + toggle in the remote menu; the close-confirm on a persistent tab. +- **Phase 4 — the Sessions view.** `remote_tmux_list` command + `TmuxSession` + type; the `☰ N` toolbar toggle and list view in `ProjectFilesView` (mirrors + the Orange view); click-to-attach opening a shell tab via the attach seam; + per-row kill/reveal. This is the item's headline surface, not a stretch goal. +- **Phase 5 (deferred).** `dtach` backend behind the session-backend + indirection; worker-host sessions; resumable remote *command* tabs. + +## Tests + +- **Automated (mine):** argv builders in `ssh_exec.rs` (tmux wrap on/off, both + tab kinds, nesting preserved, session name stable from uid; **attach** argv for + an arbitrary session name); `tmux ls` output parsing (normal list, "no server + running" → zero, tmux-absent → zero); kill-path branch selection; frontend + toggle/persistence (tsc + a `tabs` restore test proving both a persistent + remote shell tab and an *attach* tab restore to the same session name). +- **Manual (the user's — Done ≠ Tested; I can't launch Eldrun or reach a host):** + 1. Remote project, toggle on, start `python -u long_run.py` in a shell tab; + kill the network / sleep the laptop → reconnect → same output continues. + 2. Quit Eldrun mid-run → relaunch → the shell tab reattaches, run still going. + 3. **Explicitly close** the tab → confirm the session is gone on the host + (`tmux ls` shows no `eldrun-`). + 4. Host without tmux → tab still works, notice shown, no persistence. + 5. Two Eldrun windows / a stale client → `-D` detaches the other, no mirror. + 6. **Sessions view:** start a `tmux` session by hand on the host in a plain + terminal → it appears in the `☰` list → click it → a tab attaches and shows + the live process → per-row Kill removes it and it drops from the list. diff --git a/index.html b/index.html index 2296a86..e3afdc4 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,21 @@ Eldrun +
diff --git a/package-lock.json b/package-lock.json index aea5f33..cb8bd9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,19 @@ { "name": "eldrun", - "version": "0.1.21", + "version": "0.1.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eldrun", - "version": "0.1.21", + "version": "0.1.26", "license": "MIT OR Apache-2.0", "dependencies": { "@crabnebula/tauri-plugin-drag": "^2.1.0", "@dnd-kit/core": "^6", "@dnd-kit/sortable": "^8", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-notification": "^2.3.3", @@ -903,6 +905,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -919,6 +938,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -935,6 +971,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -1016,6 +1069,22 @@ } } }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -5614,6 +5683,397 @@ } } }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", @@ -5640,6 +6100,49 @@ } } }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", diff --git a/package.json b/package.json index 26b4f98..a3727b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "eldrun", "private": true, - "version": "0.1.22", + "version": "0.1.32", "license": "MIT OR Apache-2.0", "type": "module", "scripts": { @@ -21,6 +21,8 @@ "@crabnebula/tauri-plugin-drag": "^2.1.0", "@dnd-kit/core": "^6", "@dnd-kit/sortable": "^8", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-notification": "^2.3.3", diff --git a/src-tauri/CLAUDE.md b/src-tauri/CLAUDE.md index dce9949..6c2bae2 100644 --- a/src-tauri/CLAUDE.md +++ b/src-tauri/CLAUDE.md @@ -12,6 +12,7 @@ workflow); see `src/CLAUDE.md` for the frontend file map. | `storage.rs` | JSON persistence helpers (read/write state files). | | `paths.rs` | Canonical Eldrun directory paths. | | `sysstat.rs` | Per-process CPU sampling via `/proc` (`descendant_pids`). | +| `gpustat.rs` | Whole-**device** GPU memory (the header row, the monitor pane, the model menu's headroom line) — Ollama's `/api/ps` figure is now one line of its breakdown, not the reading. Reports a GPU's **two** pools separately because on an APU only one is real: the dedicated VRAM carve-out (~512 MB — the framebuffer, permanently ~full, so it alone says nothing) and the shared pool mapped out of system RAM (amdgpu's GTT, where a model actually lands); callers sum them, and on a discrete card the shared half is 0 so the sum collapses to plain VRAM. Sources: **DRM sysfs** (`mem_info_*` + `gpu_busy_percent` — no tool, no root) and **`nvidia-smi`** (the only portable NVIDIA read; a process spawn, so its *absence* is remembered rather than re-paid every poll). A card is only reported when it states a `vram_total` — Intel's `i915` exposes none, and a zero is not a measurement. One ~1 s cache serves all three surfaces. Each `GpuSample` also carries the card's **sensors** — temperature, power (draw + cap), core/mem clocks, fan %, PCIe link, driver version — read from the *same* cheap sysfs (a card's `hwmon` subdir + `pp_dpm_*`/`current_link_*`) / `nvidia-smi` pass, so every surface gets them free; each is `None` when the driver won't say (never a fake zero). **Per-process** GPU memory is deliberately separate (`process_snapshot` → `gpu_process_snapshot`, monitor pane only, *not* cached into the shared snapshot): a heavier read (a `/proc` `fdinfo` walk deduped by `drm-client-id` for amdgpu, an `nvidia-smi --query-compute-apps` spawn) the always-visible header must not pay for. A **remote** project samples its host's GPUs too: `sysstat::REMOTE_SNAPSHOT_SCRIPT` ships the host's DRM sysfs files (`AMD\t…` lines) and raw `nvidia-smi` CSV (`NVSMI`/`NVPROC`) over the one SSH round-trip, and `parse_remote_snapshot` runs them through these *same* `parse_drm_card`/`parse_nvidia_smi`/`parse_nvidia_apps` parsers — so a host GPU reads field-for-field like a local one. Remote per-process is NVIDIA-only (the AMD `fdinfo` walk is too heavy to run per remote poll). | **Commands (`commands/`)** — Tauri command handlers exposed to the frontend. @@ -22,12 +23,12 @@ workflow); see `src/CLAUDE.md` for the frontend file map. | `fs_watch.rs` | Filesystem watch start/stop + change events. | | `git.rs` | Git status/history/commit/push. | | `git_publish.rs` | Publish a project's repo to GitHub (`gh`) or GitLab (`glab`). | -| `terminal.rs` | Terminal/PTY command surface (delegates to `terminal/mod.rs`). | +| `terminal.rs` | Terminal/PTY command surface (delegates to `terminal/mod.rs`). Also the **local** tmux-session commands (TODO #85): `local_tmux_{list,kill,rename}`, and the `pty_spawn` local-tmux wrap (`services::tmux_local`). | | `apps.rs` | App launching, `run_script_detached`, `open_file`, external window tracking. | | `default_apps.rs` | Per-file-type default-app mapping. | | `downloads.rs` | Per-project download routing. | -| `ssh.rs` | SSH commands for remote projects (`ssh_connect`, `ssh_default_dir`, `ssh_list_dir`, `ssh_tooling_status`). | -| `remote.rs` | Pooled SSH/SFTP connection lifecycle (`remote_connect`/`remote_disconnect`) for the active remote project. | +| `ssh.rs` | SSH commands for remote projects (`ssh_connect`, `ssh_default_dir`, `ssh_list_dir`, `ssh_tooling_status`). Also the **active-disconnect** pair (explicit-click only, never on relaunch): `remote_kill_all_jobs` (`tmux kill-server` on a host — ends *every* running session/job, best-effort, rides a live master or authenticates ad-hoc like `global_machine_monitor_snapshot`) and `ssh_close_master` (`ssh -O exit` on the shared `cm-%C` socket, for a project-free global machine that pools nothing; a project host tears its pool down via `remote_disconnect` instead). | +| `remote.rs` | Pooled SSH/SFTP connection lifecycle (`remote_connect`/`remote_disconnect`) for the active remote project. Also `remote_upload_file` (HPC wizard "Load data" step, `docs/quirky-knitting-umbrella`): streams a local file to `/` over the pooled SFTP (`upload_file_streaming_on`), primary host, dest_rel sanitized to stay inside the root. Also the tmux-session commands (TODO #85): `remote_tmux_list` (Sessions view, `tmux ls` → `Vec`; called per-host so the view aggregates every connected worker), `remote_tmux_kill` (explicit-close / per-row kill), and `remote_tmux_rename` (per-row rename), all over the pooled ControlMaster. | | `openvpn.rs` | OpenVPN tunnel connect/store-config commands. | | `ollama.rs` | Ollama model list/pull/delete + local autocomplete. | | `tex.rs` | TeX compile + SyncTeX (shell-escape defense). | @@ -39,6 +40,8 @@ workflow); see `src/CLAUDE.md` for the frontend file map. | `workspace.rs` | KDE/X11 workspace switch commands. | | `settings.rs` | Settings read/update. | | `project_runtime.rs` | Project-switch runtime command wrapper (off-UI-thread). | +| `slurm.rs` | **SLURM run/watch core for HPC projects** (`docs/quirky-knitting-umbrella` plan): `slurm_available`/`slurm_submit`/`slurm_queue`/`slurm_job_out`/`slurm_cancel`. Mirrors `python.rs`'s dispatch — resolves `project_dir` (+ optional `host_id`, primary default) to a remote target and runs a POSIX-`sh` script over the pooled ControlMaster (`run_remote_script`), or a local shell for a login node that itself has SLURM. **Security**: the script is embedded verbatim, so every path is `shell_quote`d (the shared `ssh_exec` helper) and a job id is validated numeric before it touches `scontrol`/`scancel`. `slurm_submit` chains `sbatch --parsable` → `scontrol show job` in ONE round trip (the id comes from sbatch, so nothing user-controlled is interpolated) and returns the SLURM-resolved absolute out/err/work paths. Pure parsers (`parse_submit_jobid`, `parse_scontrol_paths`, `parse_squeue`, `split_script_rel` — which preserves absolute paths so the frontend can pass a file's absolute path and skip any remote-vs-local rel computation) are unit-tested. | +| `python.rs` | Which Python the viewer's Run/Debug run (#87), and the **single** source of that precedence — the frontend asks, it never re-derives. A project's pinned `python_interpreter` always wins (and costs no probing); otherwise auto-detect ranks in-tree venv → poetry → active `VIRTUAL_ENV`/`CONDA_PREFIX` → pyenv → system. A **named conda env is offered but never auto-picked**: choosing one of N unrelated envs on the user's behalf is a guess, and a wrong one here is indistinguishable from a bug. Remote projects probe the **host** (one constant `sh` script over `run_remote_script`) — the interpreter that matters is the one on the machine the run tab runs on. | | `pdf_clip.rs` | In-memory transfer slot for dragged/copied PDF *pages*. Two Eldrun windows are separate WebViews with separate JS heaps, so the bytes must cross the process boundary; events carry only the token. | | `crash.rs` | Receives frontend renderer crash reports. | | `debug.rs` | Debug-mode helpers. | @@ -48,15 +51,17 @@ workflow); see `src/CLAUDE.md` for the frontend file map. | File | Purpose | |------|---------| | `ssh_common.rs` | Shared SSH argv + validation helpers (`validate_arg`, `ssh_*_base_args`, `ssh_target`, `sshpass_available`). | -| `remote.rs` | Explicit remoteness resolver (`remote_target_for`/`_for_dir`) + pooled `Sftp`/ControlMaster registry (mount-free remote). | +| `remote.rs` | Explicit remoteness resolver (`remote_target_for`/`_for_dir`) + pooled `Sftp`/ControlMaster registry (mount-free remote). **Multi-host**: the pool is keyed by a composite `(project, host)` [`conn_key`], so a project's primary and each extra worker connect independently; the primary path is the `PRIMARY_HOST` (`"primary"`) constant and every file/git/sync caller (which *is* the primary subsystem) keeps its old single-arg signature via thin wrappers. `remote_target_for_host` / `compute_hosts_for` resolve a worker's spec from `projects.json`'s `extra["compute_hosts"]`. | | `sftp.rs` | Native SFTP session: list + read/write/create/delete/rename/mkdir/download (pooled `*_on` + one-shot). | -| `ssh_exec.rs` | Remote command execution over SSH (PTY tabs, git-over-ssh, remote `mkdir`; ControlMaster). | +| `ssh_exec.rs` | Remote command execution over SSH (PTY tabs, git-over-ssh, remote `mkdir`; ControlMaster). **Persistent sessions (tmux, TODO #85):** when `PtyOptions.tmux_session`/`tmux_attach` is set, `wrap_pty_options` nests the final `exec …` inside `tmux new-session -A -D -s ` (`TmuxWrap::{Session,Attach}` → `tmux_wrap_exec`), preserving the `cd`/env/agent-prelude prefix verbatim — so the run is decoupled from the disposable ssh channel and reattaches on reconnect/relaunch. `-A` = start-or-resume, `-D` = evict a stale client, `-s ` = the **frontend-minted, persisted** per-tab session name (a uuid — stable across a relaunch, unlike the regenerated PTY id, which is what makes reattach work). tmux-absent falls back to today's plain `exec` + a notice; `set -g status off`/`mouse on` reclaim the status line. `tmux_kill_session_script` (explicit close only), `tmux_ls_script`+`parse_tmux_ls`+`TmuxSession` (Sessions view) ride `run_remote_script`. | | `remote_agents.rs` | Remote agent bootstrap/resume for SSH projects. | | `remote_sync.rs` | Selective byte-sync core for remote projects: mirror paths, manifest, host/mirror walks, divergence + push/pull primitives. | | `sync_auto.rs` | Auto-sync engine on top of `remote_sync` (watcher + interval trigger, safe-direction policy). Skips the git-tracked set when lockstep owns it. | | `git_peer.rs` | Git lockstep: keeps the local mirror and host repo in step **semantically** (commits/refs via `git bundle`, never `.git` bytes). **Lockstep owns the git-tracked tree; byte-sync owns everything else** — the invariant that keeps the two transports from racing for the same file (#28p). One consequence worth knowing: enabling lockstep converts a **tracked** file from a continuous byte mirror to commit-gated — a saved edit no longer reaches the peer until it's committed (`docs/git_lockstep_case_matrix.md` #5/#7). | +| `worker_sync.rs` | **Multi-host remote** (`docs/multi_host_remote_plan.md`): the push-only, code-only fan-out that keeps each extra "worker" host's tracked tree at the primary's HEAD. Reuses `git_peer`'s bundle primitives (`bundle_create_args`, `Peer`) but only the OUTBOUND half — source(mirror)→worker only, so it inherits none of lockstep's divergence/conflict/local-loss machinery. `git init`→`fetch bundle`→`reset --hard FETCH_HEAD`, **never `git clean`** (untracked experiment outputs survive by construction — the one load-bearing invariant, tested). Triggers: worker connect, a `.git` commit on the mirror (via `git_peer::poll_loop`'s watcher branch, which calls `fan_out`), and the manual `worker_sync_now`. `worker_sync.json` records each worker's `last_head` for incremental bundles. **Output pull-back** (`preview_outputs`/`pull_outputs`) is the ONE worker→local byte path — user-initiated, size-confirmed, `git ls-files --others` → per-file SFTP download into `outputs/