What you get
A single picker that lists every project you have going, across every machine you own, with a live indicator of which ones are running. You select one and you are in it. If the work lives on the machine you are sitting at, you attach locally. If it lives on a machine three networks away, you land there over SSH. You never type a hostname, and you never think about where anything is.
Then the same registry gives you fan out: run one command across every project on every machine, in parallel, with all the output and exit codes collected together. Bulk operations across a fleet stop being a scripting exercise.
The tailnet is what makes this ordinary rather than clever. Stable names that work identically from home, from a coffee shop, and from a phone tether are the entire foundation. Without them you are maintaining a list of addresses that goes stale every time something moves.
How it works
Three layers, each boring on its own.
The bottom layer is naming. Every machine has a stable name on the tailnet, and that name resolves the same way regardless of which network any of them is on. That is what lets a static registry stay correct.
The middle layer is a registry: a plain text file mapping a project name to a host, a directory, and a start command. Not a service, not a database. A file you can read.
The top layer is a picker that reads the registry, checks which sessions are live, and dispatches. Same machine means a local terminal multiplexer switch. Different machine means an SSH hop into the multiplexer session there. The dispatch decision is one comparison: does the host column match this machine.
Build it
-
Confirm names resolve before building anything on top of them. From any machine, reach every other by name alone.
tailscale status ssh node-b uptimeIf that needs an address or a config entry, fix naming first. Everything below assumes plain names work.
-
Write the registry. One line per project: a name, the host that owns it, the directory, and the command that starts the work. Tab separated is enough.
api-rewrite node-a /home/pi/Projects/api nvim . log-triage lab-vm-1 /home/pi/Projects/logs ./watch.sh site-build cloud-1 /home/pi/Projects/site npm run dev -
Make sessions persistent. Each project gets a named terminal multiplexer session on its own host, so work survives disconnects and reboots. This is what turns a picker into a workspace.
-
Write the dispatch. For a selected project, compare the host column against this machine. Equal means attach or switch locally. Not equal means hop:
if [ "$host" = "$(hostname -s)" ]; then tmux switch-client -t "$name" 2>/dev/null || tmux attach -t "$name" else ssh -t "$host" "tmux attach -t '$name' || tmux new -s '$name' -c '$dir'" fi -
Recreate sessions at boot, idempotently, so a machine that restarts overnight comes back with its work running. A user level service unit on Linux, with lingering enabled so it starts without a login. The equivalent launch agent on macOS.
-
Add fan out. Same registry, different verb: run one command in every project directory on every host at once, remote ones over SSH, in parallel, and collect the output and exit codes together. Preview the list and confirm before it runs, because this is a loaded gun pointed at every machine you own.
-
Optional, and the part that makes it feel like one machine: have an interactive login on a fleet machine drop you straight into the picker when you are not already in a session.
Verify it
- From machine A, pick a project that lives on machine B. You should land in it without typing a name, and the session should already have your work in it.
- Detach, then attach again from a third machine. Same session, same state.
- Reboot a machine. Its sessions should come back on their own. If they do not, the boot unit is wired but not enabled, or lingering is off.
- Run fan out with something harmless like
git status --shortand confirm every host reports, including the ones you forgot you had.
Gotchas
-
The system hostname and the tailnet name can disagree, and this design breaks when they do. On one machine
hostname -sreturned a longer internal name while the name that actually resolved over the tailnet was a shorter one. Because the host column serves as both the self check and the SSH target, the two must be the same string. Pick which name is canonical, then make it true everywhere: either register the tailnet name and override what the machine calls itself, or add an alias so the system name resolves too. This is a fifteen minute problem that presents as an hour of confusion, because the picker will cheerfully SSH from a machine to itself and hang. -
A registry entry pointing at a directory that does not exist creates an empty session that looks fine. Validate the directory as part of status, or you will attach to a shell in the wrong place and not notice.
-
Fan out is genuinely dangerous. One command against every machine you own is exactly as destructive as it sounds. Preview the target list, require confirmation, support filtering to a subset, and never let it run unattended the first time.
-
Nested multiplexer sessions confuse everyone. Hopping into a remote session from inside a local one means your key prefix now has two possible owners. Decide on a convention early.
-
If you use Tailscale SSH for the hop, remember that policy changes cut live sessions. An access policy edit while you are attached will drop you, which is correct behavior and still surprising the first time.
Where to take it next
- Add a live status column that distinguishes running, stopped, and unreachable, so a machine that is off is visibly different from a project that simply is not started.
- Cache fleet status briefly. Probing every host on every keystroke of a fuzzy search is a good way to make a picker feel slow.
- Teach fan out to detect completion rather than just collecting output, so you can use it for long running work rather than only quick queries.
- Keep the registry in version control and treat adding a machine to the fleet as a reviewable change.