Run luv workspaces on another machine, inside tmux sessions that survive
disconnects. Close your laptop mid-task, reopen it later, and luv continue
puts you back exactly where you were — agent still running, Docker environment
still up.
- How it works
- Preparing a new remote machine (Ubuntu)
- Configuring luv
- Daily use
- Handing a session to another machine
- Session lifecycle
- Session naming and identity
- What runs where
- Docker repos
- Troubleshooting
luv on your laptop is a thin dispatcher. It doesn't reimplement anything for remote use — it re-invokes luv itself on the remote machine over SSH, inside a tmux session:
LAPTOP REMOTE MACHINE
luv myrepo "fix the bug"
│
└─ ssh -t box 'bash -lc "…"' ────► tmux new-session -A -s luv-pending-3f9a
└─ luv exosphere/myrepo "fix the bug"
├─ gh api → next number = 42
├─ git clone → ~/prs/myrepo-box-42
├─ git checkout -b luv-box-42
├─ tmux rename-session → luv-myrepo-box-42
└─ exec claude …
(session keeps running after you disconnect)
Two things are worth knowing because they explain most of the behaviour:
tmux wraps the whole remote invocation, not just the agent. The clone,
docker compose up, and the agent all live inside the pane from the first
moment. A connection that drops during a slow clone or image build loses
nothing, and container teardown stays tied to the agent exiting rather than to
your SSH client going away.
The remote stamps identity onto the session. Your laptop can't know the
workspace number ahead of time — it comes from gh api on the remote — so it
records a random token and the remote writes that token onto the tmux session as
a @luv_id option. That's how luv ls matches your local registry to live
sessions even after the session is renamed. See sessions.md.
The remote needs four things on PATH: git, tmux, gh, and luv.
Run these on the remote machine.
sudo apt update
sudo apt install -y git tmux curlgh isn't in Ubuntu's default repos, so add GitHub's:
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
| sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update && sudo apt install -y ghThen authenticate. luv uses gh to number workspaces and to clone, so this must
be done as the user you SSH in as, not as root:
gh auth login # choose HTTPS, and let it set up git credentials
gh auth status # verifyFor an unattended machine, a token works too:
echo "ghp_yourtoken" | gh auth login --with-token# with uv (recommended)
curl -LsSf https://astral.sh/uv/install.sh | sh
uv tool install luv-cli
# or with pip
pip install --user luv-cliBoth install into ~/.local/bin. Confirm a login shell finds it, since
that's what luv uses over SSH:
bash -lc 'command -v luv tmux gh git'If luv is missing from that output, ~/.local/bin isn't on your login PATH.
Add it:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile(Ubuntu's default ~/.profile already adds ~/.local/bin, but only if the
directory existed when the shell started — so a fresh install often needs one
logout/login, or the line above.)
Alternatively, skip the PATH problem entirely by telling luv the full path:
# on your laptop
luv config set remote.luv_bin /home/youruser/.local/bin/luvInstall whichever agent you launch — Claude Code and/or Codex — on the remote, and sign in there once:
npm install -g @anthropic-ai/claude-code
claude # sign in, then exitRun this part on your laptop.
Create a key if you don't have one. Ed25519 is the right default:
ssh-keygen -t ed25519 -C "laptop -> luv remote"
# press Enter for the default path (~/.ssh/id_ed25519)Use a passphrase, and add it to your agent so luv isn't prompted on every connection:
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519Copy the public key to the remote:
ssh-copy-id youruser@203.0.113.10If ssh-copy-id isn't available, do it by hand:
ssh youruser@203.0.113.10 'mkdir -p ~/.ssh && chmod 700 ~/.ssh'
cat ~/.ssh/id_ed25519.pub | ssh youruser@203.0.113.10 \
'cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'Permissions matter — sshd refuses keys on a world-writable ~/.ssh or
authorized_keys. Verify login works without a password:
ssh youruser@203.0.113.10 true && echo OKGive the host a short alias in ~/.ssh/config. This is the preferred place
for per-host SSH settings; luv's identity_file and port options exist for
convenience, not to replace it:
Host box
HostName 203.0.113.10
User youruser
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 30
ServerAliveCountMax 6
ServerAliveInterval is worth setting: without it a NAT or Wi-Fi hiccup leaves
a half-dead SSH client hanging instead of dropping cleanly (your tmux session is
unaffected either way, but the terminal feels stuck).
Now ssh box should just work, and you can use box as luv's host name.
A laptop-as-remote will suspend and kill your sessions. On a server this is usually already correct, but if in doubt:
sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.targetOn your laptop:
luv configThe wizard asks for this machine's name, the host, the SSH identity file, and
the remote workspace directory, then offers to set your default GitHub org. It
finishes by checking the remote for tmux, luv, gh, and git and telling
you exactly what's missing.
Non-interactively:
luv config set machine mbp
luv config set remote.host box
luv config set remote.identity_file ~/.ssh/id_ed25519
luv config listSetting machine on each machine you use is worth the ten seconds: it is the
label that ends up in every workspace folder and branch name, and the hostname
default is rarely the nicest one.
Full key reference: configuration.md.
Once a host is configured, every workspace command runs there. Nothing else about how you use luv changes:
luv myrepo "fix the flaky test" # new workspace on the remote
luv myrepo 42 "keep going" # reopen workspace 42 there
luv ls # what's running, across all hosts
luv continue # attach to a session
luv continue myrepo # attach to the newest myrepo session
luv continue myrepo 42 # attach to a specific workspaceEscape hatches:
luv --local myrepo "quick thing" # ignore the remote, run here
luv -s gpu-box ml "train it" # use a different host this once
luv -i ~/.ssh/other_key myrepo # use a different key this onceDetach from a session with Ctrl-b d (tmux's default prefix). The agent keeps
running.
luv handover relocates a running workspace. The usual direction is laptop →
box: you start something locally, then decide it should keep running somewhere
that doesn't sleep.
luv --local myrepo "start something" # → ~/prs/myrepo-mbp-43, branch luv-mbp-43
luv handover myrepo 43 --to box # attaches to it, now running on box
luv handover myrepo 43 --to local # and back down again later LAPTOP (the one you run the command on)
│
ssh box tar -c ─┤─ ssh gpu tar -x ← bytes are relayed through here, so
│ box and gpu need no keys for each other
BOX GPU-BOX
tmux kill-session luv-myrepo-mbp-43
docker compose -p … down
~/prs/myrepo-mbp-43 ───────────────► ~/prs/myrepo-mbp-43 (whole folder)
~/.claude/projects/-home-u-prs-… ───► ~/.claude/projects/… (transcript)
(folder left in place) tmux new-session -A -s luv-myrepo-mbp-43
└─ luv exo/myrepo 43 -r → claude --resume
Everything crosses over. The folder is copied byte for byte — .git, staged
and unstaged edits, untracked files, and gitignored ones like .env and
node_modules. A workspace that ran before the move still runs after it.
So does the conversation. Claude's transcript
(~/.claude/projects/<path-slug>/*.jsonl) and Codex's rollouts
(~/.codex/sessions/**/rollout-*.jsonl) travel with the workspace, and the
absolute path recorded inside them is rewritten for the new machine — the two
rarely agree on it. The destination then starts with -r, so claude --resume
or codex resume --last continues the thread rather than opening a new one.
This part depends on each agent's on-disk layout, so luv says what it found and
carries on with a fresh conversation if it finds nothing; --no-agent-state
skips it deliberately.
The agent is briefly down. luv checks everything it can first — the destination is reachable, has luv, and has no folder in the way — then stops the agent, because copying a workspace out from under a running one is the only way to get a torn tree. Killing the tmux session SIGHUPs the pane, so luv also tears the Docker environment down explicitly rather than relying on the agent's exit.
Nothing is deleted. The source folder stays on disk, so a failed transfer
costs nothing: luv prints the command to restart the session where it was. Use
--purge to delete it once the copy verifies, or luv --clean on that machine
later.
The name doesn't change. myrepo-mbp-43 stays myrepo-mbp-43 on the box —
the slug records where a workspace came from, and its branch may already be
pushed. That is also what makes the move safe: a workspace created on the box
would be myrepo-box-43, so the two can sit side by side.
Handover works on a workspace whose agent has already exited, too — name the
machine with --from and luv will move the folder and start it fresh.
| Flag | |
|---|---|
--to HOST |
Destination; local for the machine you're on. Required. |
--from HOST |
Source, when luv's registry doesn't already know |
--no-agent-state |
Move the workspace only |
--no-attach |
Leave the new session detached |
--purge |
Delete the source folder after the copy verifies |
-f |
Replace an existing folder on the destination |
-y |
Skip the "an agent may still be running" confirmation |
| Step | What happens |
|---|---|
luv myrepo "…" |
Laptop records a registry entry, opens SSH, creates the tmux session, remote luv clones and launches the agent |
Ctrl-b d or lost connection |
tmux session keeps running; agent unaffected; Docker containers stay up |
luv ls |
Laptop queries each host's tmux and refreshes the registry |
luv continue |
Reattaches; other clients are detached so the pane isn't size-clamped |
| Agent exits | The pane's command ends, tmux session disappears, Docker environment is torn down, luv ls prunes the entry on its next run |
Workspace folders are {repo}-{machine}-{number} and branches
luv-{machine}-{number}, where {machine} names whichever machine created the
workspace (config key machine, defaulting to the hostname). Each machine works
out the next number from GitHub's issue counter on its own, so without that slug
a laptop and a box would routinely pick the same one and push the same branch.
Folders and branches created before slugs existed keep working — luv looks for
its own name first, then any other machine's, then the plain {repo}-{number}.
Sessions are named after the workspace folder: ~/prs/myrepo-box-42 becomes
luv-myrepo-box-42. tmux forbids . and : in session names, so a repo like
foo.js becomes luv-foo_js-box-42.
When the workspace is already known — reopening by number, -pr, or -l — the
laptop pins the session name down before dispatching, so tmux new-session -A
doubles as "attach if it's already running". It can't compute that name, though,
since the slug belongs to the machine holding the folder: it takes it from the
session registry, or asks the host directly, and falls back to the
luv-pending-<id> rename-on-arrival path if the host doesn't answer.
When it isn't known — a brand new workspace, or bare -n/-r — the laptop uses
a placeholder luv-pending-<id> and the remote renames it once the clone lands.
If the target name is already taken by another live session, luv keeps the
session under luv-myrepo-box-42-2 and warns rather than failing.
Each session also carries two tmux options, which is what luv ls reads:
tmux show-options -t luv-myrepo-box-42 -v @luv_id
tmux show-options -t luv-myrepo-box-42 -v @luv_workspace| Command | Where it runs | tmux? |
|---|---|---|
luv config, luv --init |
Always local | – |
luv ls, luv continue |
Local, queries remote over SSH | – |
luv handover |
Local, drives both machines over SSH | starts one on the destination |
luv <repo> [prompt] |
Remote | yes |
luv <repo> <n>, -pr, -l |
Remote | yes |
luv <repo> -n / -r |
Remote | yes |
luv <repo> -nit |
Remote | no — streams stream-json back to your terminal |
luv --clean |
Remote (that's where the workspaces are) | no |
--clean refuses to delete a folder with a live tmux session unless you pass
-f.
Repos with .luv/settings.json work unchanged. Because tmux wraps the whole
invocation, docker compose up runs inside the pane:
- Detaching leaves the containers running.
- Teardown happens when the agent exits, not when your SSH client goes away.
- The compose project name is still
luv-{repo}-{machine}-{number}, so two workspaces on the same repo don't collide.
Install Docker on the remote and add your user to the docker group:
sudo apt install -y docker.io docker-compose-v2
sudo usermod -aG docker $USER # log out and back inbash: luv: command not found
Your login shell can't find luv. Check with ssh box 'bash -lc "command -v luv"'.
Fix the remote's PATH (see step 3 above) or set
luv config set remote.luv_bin /full/path/to/luv.
bash: tmux: command not found
sudo apt install tmux on the remote.
Permission denied (publickey)
The key isn't installed or isn't being offered. Test with ssh -v box. Check
~/.ssh/authorized_keys on the remote and its permissions (700 on ~/.ssh,
600 on the file). Point luv at the right key with
luv config set remote.identity_file ~/.ssh/id_ed25519, or -i for one run.
gh: To get started with GitHub CLI, please run: gh auth login
gh is authenticated on your laptop but not on the remote. luv numbers
workspaces with gh api on the remote, so it needs its own login there.
My prompt didn't get sent Reopening a workspace that already has a live session attaches to it, and tmux discards the command — so the prompt is dropped. luv prints a note when this can happen. Type the prompt into the running agent instead.
luv ls says a host is unreachable
It shows the last known state rather than pruning; nothing is lost. When a host
is gone for good, luv ls --prune forgets its entries.
Handover says the destination already has that folder
Something with the same name is already there — either the same workspace from
an earlier handover, or one created on the machine that made this one. Look at
it first (ssh box ls ~/prs), then either luv --clean it or pass -f to
replace it.
After a handover the agent started a fresh conversation
luv prints a warning when it can't find a transcript for the workspace. The
usual cause is the workspace having been opened under a different path than the
one the agent recorded, or a Codex rollout stored somewhere other than
~/.codex/sessions. The workspace itself is unaffected — the code, branch, and
uncommitted work all moved.
Handover left the session down
The source folder is never deleted without --purge, so restart it where it
was: luv <repo> <n> -r -s <source-host>. luv prints that exact command before
it starts copying.
A session is wedged
ssh box tmux ls # see everything, not just luv's
ssh box tmux kill-session -t luv-myrepo-box-42I'm already inside tmux locally
That's fine — ssh into a remote tmux from a local one nests, and Ctrl-b
goes to the outer session. Use Ctrl-b Ctrl-b for the inner one, or change the
prefix on one of them.