Skip to content

Commit 0255afc

Browse files
NiveditJainclaude
andauthored
[luv-35] Run workspaces in tmux on a remote machine over SSH (#35)
Point luv at another machine and every workspace command runs there inside a tmux session that survives disconnects. `luv ls` shows what is running across hosts and `luv continue` reattaches. Local luv becomes a thin dispatcher: it re-invokes luv itself on the remote over SSH, so every existing flag works there unchanged. tmux wraps the whole remote invocation rather than just the agent, so the clone and any docker compose start-up are inside the pane from second zero, a dropped connection never loses work, and the existing stop_docker teardown stays bound to the agent exiting instead of the SSH client going away. The three os.execv sites are untouched. The laptop cannot know the workspace number ahead of time -- it comes from gh api on the remote -- so the dispatcher records a random token and the remote stamps it onto the session as an @luv_id tmux option. The registry in ~/.luv/sessions.json joins on that token, so renaming the placeholder session breaks nothing. Reconciliation keeps entries for hosts that did not answer rather than pruning them, distinguishing the cases by ssh's 255 exit code: running `luv ls` offline must not wipe the session list. New commands: luv config (wizard plus set/get/list/unset), luv ls, luv continue. New flags: -s HOST, -i PATH, --local. -nit and --clean skip tmux so stream-json still pipes to a local consumer. --clean now refuses to delete a workspace with a live tmux session unless forced. Docs cover architecture, preparing a fresh Ubuntu box including SSH key setup, the full config reference, the registry internals, and troubleshooting for each concrete failure mode. Claude-Session: https://claude.ai/code/session_01Sw5G6a6KX2rsEPLAy4V6jt Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent cd25901 commit 0255afc

7 files changed

Lines changed: 1824 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A CLI that launches Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments.
44

5-
`luv` clones a repo, creates a branch, and drops you into a Claude session ready to work. When the repo ships a `.luv/settings.json`, it spins up Docker Compose automatically so every command runs in the right environment.
5+
`luv` clones a repo, creates a branch, and drops you into a Claude session ready to work. When the repo ships a `.luv/settings.json`, it spins up Docker Compose automatically so every command runs in the right environment. Point it at a remote machine and the whole thing runs there in a tmux session you can detach from and reattach to later.
66

77
## Install
88

@@ -19,8 +19,8 @@ pip install luv-cli
1919
## Quick start
2020

2121
```bash
22-
# Configure your default GitHub org (one-time setup)
23-
luv --init
22+
# One-time setup: default GitHub org, and optionally a remote machine
23+
luv config
2424

2525
# Create a new workspace and launch Claude
2626
luv my-repo "add user authentication"
@@ -51,6 +51,10 @@ luv -r my-repo 42
5151

5252
# Clean up fully-merged workspaces
5353
luv --clean
54+
55+
# See what's running on your remote machine, and reattach
56+
luv ls
57+
luv continue
5458
```
5559

5660
## How it works
@@ -62,11 +66,18 @@ luv --clean
6266

6367
All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter to avoid collisions.
6468

69+
With a remote host configured, steps 1–4 happen on that machine inside a tmux session instead — see [Remote sessions](#remote-sessions).
70+
6571
## Commands
6672

6773
| Command | Description |
6874
|---------|-------------|
69-
| `luv --init` | Configure default GitHub org |
75+
| `luv config` | Interactive setup: remote host, SSH key, default org |
76+
| `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
77+
| `luv config list` | Show all settings |
78+
| `luv --init` | Configure default GitHub org only |
79+
| `luv ls [--host H] [--prune]` | List live sessions across hosts |
80+
| `luv continue [<repo> [number]]` | Attach to a live session |
7081
| `luv [org/]<repo> [prompt...]` | Create a new workspace and launch Claude (default) |
7182
| `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
7283
| `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
@@ -90,6 +101,9 @@ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issu
90101
| `-m MODEL` | Model to use; Claude defaults to `claude-opus-5`, while Codex uses its configured CLI default |
91102
| `-b BRANCH` | Base a new workspace off `BRANCH` (clone + branch from it); recorded in `git config luv.base` so the PR can target it |
92103
| `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
104+
| `-s HOST` | Run on `HOST` over SSH, overriding the configured remote host |
105+
| `-i PATH` | SSH identity file to use for this invocation |
106+
| `--local` | Force local execution even when a remote host is configured |
93107
| `-f`, `--force` | Skip safety checks (with `--clean`) |
94108
| `--safe` | With `--clean -f`, only delete workspaces older than 24h (mtime) |
95109

@@ -143,6 +157,24 @@ The `dev-environment` service **must** have the selected agent CLI (`claude` or
143157

144158
Docker mode works with all flags: `-n` opens a bash shell in the container, `-r` resumes a Claude session in the container.
145159

160+
## Remote sessions
161+
162+
Point `luv` at another machine and every workspace command runs there, inside a tmux session that survives disconnects.
163+
164+
```bash
165+
luv config # set the remote host, SSH key, and org
166+
luv myrepo "fix the flaky test" # clones and launches Claude on the remote
167+
# ...close the laptop, go home...
168+
luv ls # see what's still running
169+
luv continue # reattach exactly where you left off
170+
```
171+
172+
`luv` re-invokes itself on the remote over SSH, so every flag works there unchanged. The remote needs `luv`, `tmux`, `gh`, and `git` on its `PATH`.
173+
174+
Use `--local` for a one-off local run, `-s HOST` to target a different machine, and `-i PATH` for a different SSH key.
175+
176+
Full guide, including how to prepare a fresh Ubuntu box and set up SSH keys: **[docs/remote-sessions.md](docs/remote-sessions.md)**.
177+
146178
## Workspace cleanup
147179

148180
`luv --clean` scans `~/prs/` and safely removes workspaces that are fully pushed. It checks:
@@ -153,12 +185,28 @@ Docker mode works with all flags: `-n` opens a bash shell in the container, `-r`
153185

154186
Use `luv --clean -f` to skip all safety checks and delete everything. Add `--safe` (i.e. `luv --clean --safe -f`) to restrict force-delete to workspaces whose folder mtime is older than 24 hours, leaving recently-touched workspaces alone.
155187

188+
Workspaces with a live tmux session are skipped unless you pass `-f`.
189+
156190
## Configuration
157191

158-
Run `luv --init` to set your default GitHub org. This saves to `~/.luv/config.json`.
192+
Run `luv config` for interactive setup, or `luv --init` to set just your default GitHub org. Both save to `~/.luv/config.json`:
193+
194+
```json
195+
{
196+
"org": "exosphere",
197+
"prs_dir": "~/prs",
198+
"remote": {
199+
"host": "box",
200+
"identity_file": "~/.ssh/id_ed25519",
201+
"dir": "~/prs"
202+
}
203+
}
204+
```
159205

160206
You can also pass `org/repo` inline to override the default for any command (e.g., `luv other-org/my-repo`).
161207

208+
Every key, with defaults and resolution order: **[docs/configuration.md](docs/configuration.md)**. The session registry that backs `luv ls`: **[docs/sessions.md](docs/sessions.md)**.
209+
162210
## License
163211

164212
MIT

‎docs/configuration.md‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Configuration
2+
3+
luv reads a single JSON file: `~/.luv/config.json`. It is created by
4+
`luv config` and can be edited by hand.
5+
6+
- [The `luv config` command](#the-luv-config-command)
7+
- [Keys](#keys)
8+
- [Resolution order](#resolution-order)
9+
- [Multi-host example](#multi-host-example)
10+
- [Not this file](#not-this-file)
11+
12+
## The `luv config` command
13+
14+
```bash
15+
luv config # interactive wizard
16+
luv config list # show everything, as dotted keys
17+
luv config get remote.host
18+
luv config set remote.host box
19+
luv config unset remote.host # back to local-only
20+
```
21+
22+
`set` parses the value as JSON when it can, so numbers, booleans, and lists work
23+
as you'd expect:
24+
25+
```bash
26+
luv config set remote.port 2222 # → 2222 (number)
27+
luv config set remote.ssh_opts '["-o","Compression=yes"]'
28+
luv config set org exosphere # → "exosphere" (string)
29+
```
30+
31+
Anything that isn't valid JSON is stored as a plain string, which is what you
32+
want for hosts, paths, and org names.
33+
34+
`luv --init` still exists and sets only the org.
35+
36+
## Keys
37+
38+
### `org`
39+
40+
*string.* Default GitHub owner, used when you write `luv myrepo` instead of
41+
`luv someorg/myrepo`. Set by `luv --init` or the wizard.
42+
43+
The **laptop** resolves this before dispatching, so a remote machine never needs
44+
its own `org` configured.
45+
46+
### `prs_dir`
47+
48+
*string, default `~/prs`.* Where workspace folders are created on **this**
49+
machine. `~` is expanded.
50+
51+
### `remote.host`
52+
53+
*string.* SSH destination — an `~/.ssh/config` alias (`box`), a `user@host`, or
54+
a bare hostname. **Setting this turns on remote mode**: every workspace command
55+
then runs there. Unset it, or pass `--local`, to run locally.
56+
57+
### `remote.identity_file`
58+
59+
*string.* Path to the SSH private key, passed as `ssh -i`. `~` is expanded by
60+
luv, since `ssh` won't expand it when luv execs directly rather than through a
61+
shell.
62+
63+
### `remote.port`
64+
65+
*number.* Passed as `ssh -p`. Omit for the default 22.
66+
67+
### `remote.dir`
68+
69+
*string.* Workspace root **on the remote**. Forwarded to the remote luv, which
70+
uses it in place of its own `prs_dir`.
71+
72+
### `remote.luv_bin`
73+
74+
*string.* Full path to `luv` on the remote. Only needed when a login shell
75+
there can't find it on `PATH` — see the
76+
[troubleshooting section](remote-sessions.md#troubleshooting).
77+
78+
### `remote.ssh_opts`
79+
80+
*list of strings.* Extra arguments inserted into every `ssh` invocation, e.g.
81+
`["-o", "ServerAliveInterval=30"]`. Prefer `~/.ssh/config` for anything durable.
82+
83+
### `remote.hosts.<name>`
84+
85+
*object.* Per-host overrides. Any of `identity_file`, `port`, `dir`, `luv_bin`,
86+
or `ssh_opts` may be set here and takes precedence over the `remote.*` value of
87+
the same name. This is what makes `-s otherbox` usable: without it, one global
88+
key would be wrong for every host but the default.
89+
90+
## Resolution order
91+
92+
**Host:** `-s HOST` → `remote.host`. If neither, luv runs locally.
93+
94+
**Per-host settings:** `-i PATH` (identity only) → `remote.hosts.<host>.<key>` →
95+
`remote.<key>`.
96+
97+
**Workspace root:** `_LUV_PRS_DIR` (set by the dispatcher from `remote.dir`) →
98+
`prs_dir` → `~/prs`.
99+
100+
**Org:** `org/repo` written explicitly → `org` from config → error.
101+
102+
`--local` overrides everything and is rejected alongside `-s` or `-i`, since
103+
those combinations contradict each other.
104+
105+
## Multi-host example
106+
107+
```json
108+
{
109+
"org": "exosphere",
110+
"prs_dir": "~/prs",
111+
"remote": {
112+
"host": "box",
113+
"identity_file": "~/.ssh/id_ed25519",
114+
"dir": "~/prs",
115+
"ssh_opts": ["-o", "ServerAliveInterval=30"],
116+
"hosts": {
117+
"gpu-box": {
118+
"identity_file": "~/.ssh/gpu_key",
119+
"port": 2222,
120+
"dir": "/scratch/prs",
121+
"luv_bin": "/opt/venv/bin/luv"
122+
}
123+
}
124+
}
125+
}
126+
```
127+
128+
With this, `luv myrepo "…"` goes to `box` on port 22 with `id_ed25519`, and
129+
`luv -s gpu-box ml "…"` goes to `gpu-box` on port 2222 with `gpu_key`, cloning
130+
into `/scratch/prs` and using an explicit luv binary.
131+
132+
## Not this file
133+
134+
Two other files are sometimes confused with this one:
135+
136+
- **`~/.luv/sessions.json`** — the session registry, written by luv itself. Not
137+
configuration; see [sessions.md](sessions.md).
138+
- **`<repo>/.luv/settings.json`** — a *repo-local* file, committed to the repo,
139+
whose only key is `compose_file`. It turns on Docker mode for that repo and
140+
has nothing to do with `~/.luv/config.json`.

0 commit comments

Comments
 (0)