The edit-host split
Editing on another machine the usual way — running the whole editor over there and a thin client here — means every keystroke round-trips the network before the cursor moves. That lag is structural, not tunable: the editing state machine lives on the far side of the wire.
The edit-host split moves the network boundary below the editor instead. The
editor and Lua run locally; only an nxvim --daemon serving the filesystem,
processes, and file-watching runs on the remote. So typing, motions, operators, and
undo are all local — zero round-trips — and only fs / process / watch / LSP
traffic crosses the wire, the work that was always going to feel like a spinner
anyway. It’s the direction VS Code Remote takes: the text model is local, the heavy
I/O is remote.
The same nxvim --daemon serves the Browser editor too — one
remote, reached natively here or from a browser tab over WebTransport.
The two halves
editor + Lua (LOCAL, your machine) ──network──▶ nxvim --daemon = fs + processes + watch (REMOTE)
- The local half is the same embedded editor as a normal
nxvim, with its host seams —HostFs,HostProc, the asyncHostFsAsync, the LSP transport, and the Lua-facingLuaFs— pointed at the daemon instead of the local disk. - The remote half is
nxvim --daemon: it reads and writes the remote machine’s files, spawns its processes, and watches its files. Nothing about editing lives there.
Running it
Two transports reach a daemon — ssh (simple) and QUIC (multiplexed, and the same transport the browser uses).
Over ssh (stdio)
Point a local editor at a daemon spawned over ssh:
NXVIM_DAEMON_CMD="ssh user@host nxvim --daemon" nxvim --connect-daemon path/to/file
NXVIM_DAEMON_CMD is run through sh -c, so any command line ending in
nxvim --daemon over stdio works. Left unset, --connect-daemon spawns this same
binary locally (nxvim --daemon) — a two-process local split, handy for testing.
In the GUI, do it at runtime with :connect [user@]host[:port][/file] — it spawns
ssh … nxvim --daemon and routes any password / passphrase prompt to a native dialog
via SSH_ASKPASS, so it works from a windowed launch with no tty.
Over QUIC / WebTransport
Run a standalone listener on the remote — the same wtransport-on-quinn stack the
browser dials:
nxvim --daemon --listen # binds 127.0.0.1:8765, prints a connect URI
nxvim --daemon --listen 0.0.0.0:9000 # accept off-host connections
It prints a connect URI — nxvim://HOST:PORT/TOKEN?cert=HASH. Dial it from a local
editor (the nxvim://… scheme selects the QUIC path; --connect-daemon is optional):
nxvim nxvim://HOST:PORT/TOKEN?cert=HASH path/to/file
In the GUI, :connect nxvim://… does the same at runtime.
What crosses the wire (and what doesn’t)
| Stays local (zero round-trips) | Goes remote (over the wire) |
|---|---|
| Every keystroke, motion, operator, undo | Opening / saving files and the file explorer |
| The Lua VM and the redraw | Processes — vim.system / jobstart / :terminal |
| Your config + shada (default; see below) | File-watching — :checktime / 'autoread' / FileChangedShell |
| LSP requests |
Only the things that were always going to feel like a spinner cross the wire.
Local or remote config (and shada)
By default a daemon session runs your local config and keeps shada (marks /
registers / history) local — only I/O crosses the wire. Pass --remote-config to
run the daemon’s config + plugins instead: the daemon’s config is fetched over the
wire (one config_bundle request, materialized into a per-process cache and run
locally, since Lua’s synchronous require can’t await the network), and shada follows
the same choice — a remote-config session keeps its shada on the daemon, so a
remote workspace’s editor state travels with it. The browser client has no local disk,
so it is always remote-config. See examples/remote-config.
The split-brain filesystem (for Lua)
One subtlety the split forces: which filesystem does Lua see? nxvim splits it on
purpose. Project-facing fs APIs route to the daemon — vim.fn.glob /
filereadable / readblob / executable, root detection, a picker’s previewer, a
VCS-status provider — so they see the project on the remote. Config and shada stay
local by default, or move to the daemon with --remote-config (see above). (This
is the LuaFs seam.)
Staying connected (auto-reconnect)
Because the editor runs local and only the seams cross the wire, a dropped connection — a laptop sleeping past the QUIC idle timeout, an ssh hop dropping, a network blip — does not tear the session down. The link re-dials underneath the seam handles the editor already holds: your buffers, undo, cursor, windows and Lua state are untouched, and the transport beneath them reconnects. While the link is down, remote ops (save, read, LSP, watch, terminal) fail loud rather than hanging.
- Automatic. A drop is retried a few times with backoff (≈0.5 → 8 s). On success the
seams rebind and editing resumes with no action from you. If the budget is spent the
link parks disconnected and tells you to run
:reconnect. :reconnectre-dials now (and resets the retry budget);:disconnectdrops the link on demand. Both are server-side ex-commands, so they work on the TUI too.- Status is a first-class API:
nx.daemon.status()returns"connected"/"reconnecting"/"disconnected"(ornilfor a local session), and aUser DaemonStatusChangedautocmd fires on every change — so a statusline component can render it (green / yellow / red). Seeexamples/daemon-status. - On reconnect the editor re-syncs what a fresh daemon doesn’t know about: it
re-opens LSP servers, re-arms file watches, and re-stats open files — a file changed
while the link was down is detected (an unmodified buffer autoreloads; an edited one is
flagged as a conflict). Remote terminals/jobs do not survive a drop (the PTYs die with
the link) and are surfaced as exited; reopen them with
:terminal.
All three transports reconnect — ssh/stdio, QUIC, and the browser’s WebTransport. (Daemon- side session survival — keeping terminals/jobs alive across a drop — is out of scope.)
Auth & identity (QUIC)
A daemon executes arbitrary processes, so an open listener is remote code execution
by design — the TLS cert buys encryption, not authorization. Two mechanisms, both
minted at --daemon --listen launch and presented by the local half on connect:
- Bearer token — 32 random bytes, carried on the connect URI’s path. The daemon compares it constant-time and drops the session without accepting on a mismatch, so a bad token is a failed connect, never a half-open session.
- Server identity — a self-signed cert whose SHA-256 hash the daemon prints; the
client pins that hash on first use (the known-hosts / TOFU model, no CA). The
browser passes the same hash to its
WebTransportconstructor.
The default loopback bind (127.0.0.1) is defense-in-depth — the token is the real
gate. Bind 0.0.0.0 only when the token (and, ideally, a trusted network) is doing
its job.
How it works (in brief)
- One code path. The local half is the same embedded editor as the default
role; the boundary is the same RPC every client speaks. Only its host seams
(
HostFs/HostProc/HostFsAsync/ the LSP transport /LuaFs) are repointed at the daemon — the editing logic is untouched. - ssh vs. QUIC. ssh stdio carries every leg over one ordered stream — simple, but
a process flood (a fuzzy-finder’s
rg, annpm install) can head-of-line-block a file save queued behind it. QUIC gives each traffic class its own stream, removing that coupling at the protocol level: the legs ride four independently flow-controlled bidi streams by latency class — Control (fs/config/nx.fs), Proc, Lsp, Term — each prefixed with a one-byte group tag the daemon dispatches on. Because it’s the samewtransport/quinnstack the browser’s WebTransport uses, native and browser unify on one daemon (the browser opens the same four streams). See the multi-stream plan. :connectswaps the backend, not the window. In the GUI,:connectbuilds a fresh local server whose seams point at the daemon and re-attaches the same window. The editor transport is always the in-process duplex, so the window never notices.
See also
- Browser editor — the same daemon reached from a browser tab.
- Architecture → Embedded vs. remote — where this sits in the client–server model.
- The edit-host & browser-Lua plan — the design and its phased implementation.