Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
41bbebf
Add Client::Machine and Origin::Machine: another Machine holds only t…
marcellosso Nov 4, 2026
60bdb63
Serve this Machine's Projects to another's app with `localhost remote…
marcellosso Nov 4, 2026
6449509
Keep the list of other Machines in machines.json, and keep each conne…
marcellosso Nov 4, 2026
7984b31
Add an example that views another Machine from a terminal, to try one…
marcellosso Nov 5, 2026
f05a422
Name the ControlMaster's socket relative to its folder, so a long fol…
marcellosso Nov 5, 2026
e6dae7b
Read the Host names of ~/.ssh/config, following Include, to suggest a…
marcellosso Nov 5, 2026
d186639
Show an SSH failure localhost doesn't recognize once and wait for the…
marcellosso Nov 5, 2026
f7fb640
Keep each Machine's Projects and statuses in Machines across reconnec…
marcellosso Nov 5, 2026
d29e100
Give ssh a typed user@host:port as the ssh:// destination it reads a …
marcellosso Nov 5, 2026
466c0fe
Export Machines to the macOS app: its events, controls, logs and Open…
marcellosso Nov 5, 2026
708bc63
Add Machines to the Swift layer: typed calls, a LogSource for either …
marcellosso Nov 5, 2026
4b19270
Show other Machines in the macOS app: Settings › Machines, a sidebar …
marcellosso Nov 5, 2026
a9086a6
Word a Machine's state once, in the core, and say when it waits for t…
marcellosso Nov 5, 2026
a6fc5bd
Take a Machine's state wording from the core in the macOS app
marcellosso Nov 5, 2026
41d4308
Name a Machine's state and event fields in camelCase per variant, as …
marcellosso Nov 5, 2026
286af54
Show other Machines in the Linux and Windows app: Settings › Machines…
marcellosso Nov 5, 2026
0184747
Describe Machines and Forwards in AGENTS.md: the vocabulary, how they…
marcellosso Nov 5, 2026
db4f07f
Install sshd in the Linux core job, so the tests reaching another Mac…
marcellosso Nov 5, 2026
5a401d5
Fix the macOS app's Machine views: restore the selection's Project by…
marcellosso Nov 5, 2026
43a45f7
Say why localhost on another Machine can't serve, in its words, inste…
marcellosso Nov 5, 2026
57134e1
Stack the first-run empty state's three actions at one width, so they…
marcellosso Nov 5, 2026
b914437
Run the Machines tests that fake ssh with a shell script only on Unix
marcellosso Nov 5, 2026
a44f465
Document Machines in the site docs: what they need, Forwards, reconne…
marcellosso Nov 5, 2026
4c8b691
Add Run Projects on another computer to the README, rename the Projec…
marcellosso Nov 5, 2026
5347228
Add a guide to running dev servers on a dev box and opening them from…
marcellosso Nov 5, 2026
7426c8a
Add a Machines section to the landing page, with its place in the dir…
marcellosso Nov 5, 2026
0745adb
Add the 0.5.0 changelog, unreleased: Machines
marcellosso Nov 5, 2026
85de814
Merge pull request #70 from marcellosso/docs/machines
marcellosso Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ jobs:
# Windows runner: clippy doesn't link, so it needs no Windows toolchain.
- name: Clippy for Windows
run: cargo clippy -p localhost-core -p localhost-cli --target x86_64-pc-windows-msvc --all-targets -- -D warnings
# `tests/machines_ssh.rs` reaches another Machine through a private
# sshd on loopback, and skips where there is none.
- name: Install sshd
run: sudo apt-get update && sudo apt-get install -y openssh-server
- run: cargo test

windows:
Expand Down
12 changes: 11 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@ The code, the UI and the docs use the same words. Use these, and avoid the alter
| **Suggestion** | A Service proposed by scanning a folder, applied only once the user checks it. | detected service |
| **Run** | One execution of a Service, launch until its whole process tree has ended. A Service has at most one active Run. | process, session, instance |
| **Execution Target** | Where a Project's Services run: this machine, or a WSL distro. | host, runtime, environment, agent |
| **Machine** | Another computer whose localhost this app shows and controls, reached over the system's `ssh`. Its Projects, Secrets and Runs stay on it. The UI says "Machine" and "Runs on". | host, server, remote, environment |
| **Forward** | A Port of a Machine made reachable on this computer's loopback, through the SSH connection, so Open in Browser works. Same port number unless that one is taken here. | tunnel, proxy |
| **Listener** | Any process listening on a TCP port, attributed to a Run, to a Project (its working directory is inside the folder), or to nobody. | port owner |
| **Unmanaged Listener** | A Listener that belongs to no Run and no Project. localhost can show and stop it, never adopt it. | rogue, stray |
| **Orphan** | A process tree left from a Run of a localhost session that ended unexpectedly. Never re-adopted as Running. | zombie, leftover |
| **Origin** | Who asked for a change: main window, menu bar, CLI, or an agent through `localhost mcp`. | source, actor, caller |
| **Origin** | Who asked for a change: main window, menu bar, CLI, an agent through `localhost mcp`, or another Machine's app through `localhost remote`. | source, actor, caller |
| **Service State** | Exactly one of the six states below. | status (in the UI) |

| State | Meaning |
Expand Down Expand Up @@ -131,6 +133,12 @@ These are product rules, not implementation details. A change that breaks one ne
- Updates are offered, never installed unasked: a check at launch and every few hours (off in Settings), a notice, then Update Now and Restart to Update. Restarting to update stops every Service first, like quitting, after listing them, and retires the shared core so the new version starts its own. A `.deb` or `.rpm` only links to the release. Builds without the updater's public key never check.
- Each active Run is recorded on disk (pid, process group, start time). At the next launch, survivors are offered as Orphans: Stop them (default) or Leave them.

**Machines**
- Add Machine connects first: a Machine is kept only once `ssh <destination> localhost remote --stdio` works, otherwise the typed `MachineProblem` says why and what to do next. The SSH host field suggests the `Host`s of `~/.ssh/config`, read only.
- A lost connection is made again on its own, after 1 s growing to 30 s, or at once when the computer wakes; a problem waiting won't fix (a host key, a sign-in that needs a prompt, no or an old localhost there, anything SSH says that localhost doesn't recognize) is shown once and waits for Reconnect. Reconnecting never starts or stops a Service.
- A Machine's Projects show below this computer's and act like them: Start, Stop, Restart, logs, Open in Browser. They are edited on the Machine. Open in Browser forwards the Port to the same port here first; if that port is taken here, it says so (`PortConflict`) rather than using another.
- Quitting asks first when Services started from here on another Machine are active: that Machine stops them once the connection ends, unless localhost is open on it.

**Detection**
- Scanning only reads: no network, nothing written. It looks at the root, folders up to two levels deep (skipping dependency, build and hidden folders) and folders detectors declare (workspace packages, Cargo members).
- Detectors (Node, Compose, Python, Cargo) are small and independent and return plain command lines. Every Suggestion is reviewed: likely ones checked, the rest folded away, each editable before anything is saved.
Expand Down Expand Up @@ -169,6 +177,7 @@ All state, process management, logs and persistence live in the core, which neve
| `LogStore`, `LogBuffer` | Each Run's output: in-memory buffer and rotated file. |
| `RunRegistry` | Records active Runs on disk, to find Orphans. |
| `Targets`, `remote` | Where each Project runs, behind one interface: this machine through the shared core, a WSL distro through a `localhostd`. Merges every target's statuses and events. |
| `machine` | Other Machines: `Machines` keeps the list (`machines.json`), keeps each connected over an `ssh` ControlMaster running `localhost remote --stdio` there, and mirrors its Projects and statuses for the apps; `relay` is that command's side. Its own versioned protocol, which only grows. |
| `remote::shared` | The shared core: the `remote` protocol on a Unix socket in the storage root, or on Windows a named pipe named after it, started by its first client, one per storage root. Only the transport differs per platform. Decides which Runs a disconnecting client takes with it. |
| `listener` | Finds Listeners (libproc, `/proc`, `GetExtendedTcpTable`, never `lsof`) and attributes them. |
| `ProjectScanner` + detectors | Reads a folder and returns Suggestions. |
Expand Down Expand Up @@ -210,6 +219,7 @@ The hard-to-reverse choices, and why. Changing one is an issue and a discussion
- **Windows Runs in Job Objects, stopped with Ctrl-Break.** Windows has no process-group signals; a job holds the whole tree and dies with the core even on a crash. Ctrl-Break needs a shared console, so the core attaches once to a hidden one. A core an agent starts breaks away from the agent's job, which Node ends with the agent.
- **WSL Projects run by a core inside the distro.** A Windows process sees nothing behind `wsl.exe`. The app copies its `localhostd` into the distro and drives it over stdio: no socket, port or firewall rule. Configuration and Secrets stay on Windows.
- **Configuration stored by localhost, not in the Project folder.** Writing into users' repos is a surprising side effect. The Project file is the explicit bridge.
- **Machines through the system's `ssh`, viewed, never merged.** The user already reaches their other computers with `ssh` (keys, agent, `~/.ssh/config`, `ProxyJump`, Tailscale), so localhost adds no sign-in, listener or port of its own: one `BatchMode` ControlMaster per Machine runs `localhost remote --stdio` there, and Forwards ride on it, bound to loopback. Host keys are checked as configured and never trusted on the user's behalf. Each Machine keeps its Configuration, Secrets and Runs; changes there are made as `Origin::Machine`, and that Machine's core holds only the Runs the connection started, stopping them when it ends unless an app is open there. Two computers often run different versions, so the Machine protocol is versioned on its own and only grows: unknown requests are answered `Unsupported`, unknown messages skipped.
- **Pipes, not a PTY.** Keeping stderr distinct matters for failures and, later, for agents. The colour hints make up for the missing terminal.
- **`$PORT`, and moves scoped to one Run.** It is the existing convention, needs no parser and works the same from any client. A moved port never rewrites the configuration.
- **Run stats sampled on request.** Nothing is sampled unless a stats popover is open (every 1.5 s). The tree includes descendants that left the session. Memory is each platform's honest number rather than RSS, which counts forked workers once each.
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ A **Project** is a folder. A **Service** is one long-running command in it: `pnp
- **Keyboard first**: a command palette, a shortcut for every action, arrow keys across the window. Screen readers, light and dark, reduced motion.
- **Coding agents included**: Claude Code, Cursor, Codex and other MCP clients start, stop and inspect your Services through `localhost mcp` instead of a hidden shell. The agent and the app share one background core, so what the agent starts shows in the app, with its logs. See [Coding agents (MCP)](#coding-agents-mcp).
- **Updates that ask first**: a check at launch and every 6 hours, a notice in the sidebar, and Restart to Update stops your running Services only after asking. See [Updates](#updates).
- **Your other computers too**: a dev box you reach with `ssh` is a **Machine**. Its Projects show below yours, and start, stop and open in your browser from here, while they run on it. See [Run Projects on another computer](#8-run-projects-on-another-computer).
- **Local**: no account, no cloud, works offline. Configuration lives in localhost's own storage, never in your project folders.

A native SwiftUI app on macOS with a menu bar companion; one app for Linux and Windows with a tray icon. On Windows, Projects run on Windows itself or inside a WSL 2 distro. All of them, the `localhost` command and the MCP server run on one Rust core.
Expand Down Expand Up @@ -159,9 +160,20 @@ Before starting a Service with a Port, localhost checks who holds it. If somethi

The **Ports** view (**⌘0**) lists every process listening on a TCP port and whom it belongs to: one of your Services, a process running inside one of your Projects' folders, or nothing localhost knows (other apps and system services are folded below). Select one and press ⌘. to stop it; localhost asks first for anything it didn't start.

### 7. Move a Project to another machine
### 7. Copy a Project's configuration to another computer

**Export Project…** saves a Project as a YAML Project file, and **Import Project…** reads one back, showing what it contains before adding anything; choose the folder if it is somewhere else on this machine. Secret values are never exported: the file lists Secret names, and the import shows which ones to fill in. The format is in [docs/configuration.md](docs/configuration.md).
**Export Project…** saves a Project as a YAML Project file, and **Import Project…** reads one back, showing what it contains before adding anything; choose the folder if it is somewhere else on this computer. Secret values are never exported: the file lists Secret names, and the import shows which ones to fill in. The format is in [docs/configuration.md](docs/configuration.md).

### 8. Run Projects on another computer

A dev box or home server you reach with `ssh` can be a **Machine**: its Projects stay and run there, with its own configuration and Secrets, and this app shows and controls them.

1. Check that `ssh devbox localhost --version` works from a terminal without asking anything (a key, in the SSH agent if it has a passphrase, and a host key you already trusted). It runs without your interactive profile: with zsh, put `localhost` on the `PATH` in `~/.zshenv`, not `~/.zshrc`.
2. Open **Settings → Machines → Add Machine…**. In **SSH host**, type a `Host` from `~/.ssh/config` (the field lists them), or `user@host:port`. localhost connects first, and says why and what to do if it can't.
3. The Machine's Projects show in the sidebar under its name, below this computer's: Start, Stop, Restart, logs. They are edited on the Machine.
4. **Open in Browser** forwards the Service's Port to the same port here, through the SSH connection and on loopback only, then opens it.

Any network `ssh` uses works: a LAN, a VPN, `ProxyJump`, Tailscale. Nothing new listens on either computer. A lost connection is made again on its own; Services you started from here stop on the Machine when the connection ends, unless localhost is open there. More in the [Machines docs](https://www.getlocalhost.app/docs/machines).

## Keyboard shortcuts

Expand Down Expand Up @@ -272,6 +284,7 @@ Inside that folder:
- `projects/`: one JSON file per Project. A file that can't be read is renamed to `….unreadable` and reported at launch, never deleted.
- `logs/`: each Run's output, the last 5 Runs per Service.
- `runs/`: the Runs active right now, to find leftovers after a crash.
- `machines.json`: the Machines you added, their names and SSH destinations; `machines/` holds their SSH connections' sockets while they are open.
- `agent-backups/`: each coding agent's configuration file as it was before localhost first connected it.
- `bin/`: the AppImage's `localhost` command, copied out for coding agents (Linux AppImage only).

Expand Down Expand Up @@ -393,6 +406,10 @@ Add the Project’s folder and localhost suggests Services from `package.json` s

Yes. `localhost mcp` is an MCP server for Claude Code, Cursor, Codex and other coding agents: they list your Projects, start and stop Services, and read their status, logs and ports, instead of running `pnpm dev` in a shell you can’t see. They can also add, edit and remove Projects and Services, checked as the app checks them: removing needs `confirm: true` and never touches a Project’s folder, and `localhost mcp --read-only` leaves those tools out. The agent and the app share one background core, so what the agent starts shows in the app, and the Services it started stop when it disconnects, unless the app is open.

### Can I run my dev servers on a dev box or home server and open them from my laptop?

Yes. Add it in Settings → Machines with the name you give `ssh` (a `Host` from `~/.ssh/config`, or `user@host:port`), and its Projects show in the sidebar below your laptop’s. Start, stop and read their logs from there; Open in Browser forwards the Port to the same port on your laptop through the SSH connection, so `http://localhost:5173` works as if it ran locally. Its Services, configuration and Secrets stay on it. It works over any network `ssh` reaches, such as Tailscale or a VPN, and adds no account or listening port. localhost has to be installed on both computers.

### Is it free and open source?

Yes. localhost is free and open source under the MIT license. There is no account and no backend, and it works offline.
Expand Down
51 changes: 36 additions & 15 deletions apps/desktop/src-tauri/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ pub struct Snapshot {
#[serde(rename_all = "camelCase")]
pub struct StatusView {
#[serde(flatten)]
status: ServiceStatus,
pub(crate) status: ServiceStatus,
failure_message: Option<String>,
url_to_open: Option<String>,
pub(crate) url_to_open: Option<String>,
}

impl StatusView {
Expand All @@ -51,7 +51,7 @@ impl StatusView {
#[serde(rename_all = "camelCase")]
pub struct ProjectState {
project_id: Id,
state: Option<ServiceState>,
pub(crate) state: Option<ServiceState>,
}

/// A command's failure: the core's typed error, and its message for the user.
Expand Down Expand Up @@ -93,6 +93,10 @@ pub enum CoreEvent {
pub struct Subscriptions(Mutex<Vec<JoinHandle<()>>>);

impl Subscriptions {
pub fn push(&self, task: JoinHandle<()>) {
self.0.lock().unwrap().push(task);
}

pub fn close_all(&self) {
for task in self.0.lock().unwrap().drain(..) {
task.abort();
Expand All @@ -107,13 +111,11 @@ pub fn subscribe(state: tauri::State<'_, AppState>, events: Channel<CoreEvent>)
let core = state.core()?;
let (projects, mut library_events) = core.library.subscribe();
let (statuses, mut service_events) = core.targets.subscribe();
let mut view = View { projects, statuses, states: HashMap::new() };
let project_states: Vec<ProjectState> = view.projects.iter().map(|project| view.project_state(project)).collect();
view.states = project_states.iter().map(|state| (state.project_id, state.state)).collect();
let mut view = View::new(projects, statuses);
let snapshot = Snapshot {
projects: view.projects.clone(),
statuses: view.statuses.values().map(|status| view.status_view(status)).collect(),
project_states,
statuses: view.status_views(),
project_states: view.project_states(),
};

let task = tauri::async_runtime::spawn(async move {
Expand Down Expand Up @@ -142,18 +144,37 @@ pub fn subscribe(state: tauri::State<'_, AppState>, events: Channel<CoreEvent>)
}
}
});
state.subscriptions.0.lock().unwrap().push(task);
state.subscriptions.push(task);
Ok(snapshot)
}

/// One subscriber's copy of the state, for the derived Project states.
struct View {
projects: Vec<Project>,
/// One subscriber's copy of the state, for the derived Project states: of
/// this computer's Projects, or another Machine's (`machines`).
pub(crate) struct View {
pub(crate) projects: Vec<Project>,
statuses: HashMap<Id, ServiceStatus>,
states: HashMap<Id, Option<ServiceState>>,
}

impl View {
pub(crate) fn new(projects: Vec<Project>, statuses: HashMap<Id, ServiceStatus>) -> View {
let mut view = View { projects, statuses, states: HashMap::new() };
view.states = view.project_states().into_iter().map(|state| (state.project_id, state.state)).collect();
view
}

pub(crate) fn statuses(&self) -> &HashMap<Id, ServiceStatus> {
&self.statuses
}

pub(crate) fn status_views(&self) -> Vec<StatusView> {
self.statuses.values().map(|status| self.status_view(status)).collect()
}

pub(crate) fn project_states(&self) -> Vec<ProjectState> {
self.projects.iter().map(|project| self.project_state(project)).collect()
}

/// Applies the event; returns the Project whose state may have changed.
fn apply_library(&mut self, event: &ProjectLibraryEvent) -> Option<Id> {
match event {
Expand All @@ -175,7 +196,7 @@ impl View {
}
}

fn status_view(&self, status: &ServiceStatus) -> StatusView {
pub(crate) fn status_view(&self, status: &ServiceStatus) -> StatusView {
let service = self.projects.iter().find_map(|project| project.service(status.service_id));
StatusView::new(status.clone(), service)
}
Expand All @@ -191,13 +212,13 @@ impl View {
.collect()
}

fn apply_status(&mut self, status: &ServiceStatus) -> Option<Id> {
pub(crate) fn apply_status(&mut self, status: &ServiceStatus) -> Option<Id> {
self.statuses.insert(status.service_id, status.clone());
self.projects.iter().find(|project| project.service(status.service_id).is_some()).map(|project| project.id)
}

/// The Project's state if it differs from the last one sent.
fn refresh_state(&mut self, project_id: Id) -> Option<ProjectState> {
pub(crate) fn refresh_state(&mut self, project_id: Id) -> Option<ProjectState> {
let project = self.projects.iter().find(|project| project.id == project_id)?;
let state = self.project_state(project);
(self.states.insert(project_id, state.state) != Some(state.state)).then_some(state)
Expand Down
Loading
Loading