luv-cli 0.2.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: luv-cli
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments
5
5
  Project-URL: Homepage, https://github.com/exospherehost/luv
6
6
  Project-URL: Repository, https://github.com/exospherehost/luv
@@ -63,7 +63,7 @@ luv other-org/my-repo "fix the bug"
63
63
  # Reopen workspace #42
64
64
  luv my-repo 42
65
65
 
66
- # Open any GitHub PR by URL
66
+ # Clone any GitHub PR by URL into a fresh workspace
67
67
  luv -l https://github.com/org/repo/pull/123
68
68
 
69
69
  # Open a shell instead of Claude
@@ -76,7 +76,7 @@ luv -r my-repo 42
76
76
  luv --clean
77
77
 
78
78
  # Tear down a finished session: kill its tmux, delete its workspace
79
- luv rm myrepo-42
79
+ luv rm myrepo-mbp-42
80
80
  luv rm --merged # everything whose PR has landed
81
81
 
82
82
  # See what's running on your remote machine, and reattach
@@ -86,15 +86,33 @@ luv continue
86
86
 
87
87
  ## How it works
88
88
 
89
- 1. Clones the repo into `~/prs/{repo}-{number}/`
90
- 2. Creates a new branch `luv-{number}`
89
+ 1. Clones the repo into `~/prs/{repo}-{machine}-{number}/`
90
+ 2. Creates a new branch `luv-{machine}-{number}`
91
91
  3. Configures the selected agent with the workspace's PR conventions
92
92
  4. Launches Claude with Opus 5 at max effort, or Codex in YOLO mode
93
93
 
94
- All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter to avoid collisions.
94
+ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter, and `{machine}` is a short name for the machine that created the workspace — by default the hostname, or whatever you set with `luv config set machine mbp`.
95
+
96
+ The machine name is what keeps two computers apart. Each one works out the next number from GitHub independently, so your laptop and your box will happily pick the same one; without the slug they would both push a branch called `luv-43`. Folders and branches created before this keep working unchanged.
95
97
 
96
98
  With a remote host configured, steps 1–4 happen on that machine inside a tmux session instead — see [Remote sessions](#remote-sessions).
97
99
 
100
+ ### Opening an existing PR
101
+
102
+ `luv -l <PR URL>` gives you the PR as it is *right now*: a full clone into a
103
+ workspace of its own, checked out on the PR's head branch. It never reuses a
104
+ folder that happens to be sitting there — a URL you paste is a request to look
105
+ at that PR, not at whatever was checked out in `myrepo-mbp-123` last week. Run
106
+ it twice on the same PR and you get `myrepo-mbp-123` and `myrepo-mbp-123_2`,
107
+ which are independent workspaces you can run side by side. When the PR comes
108
+ from a fork, the base repo is added as `upstream` and fetched too, so the branch
109
+ you need to diff or rebase against is there.
110
+
111
+ `-r` is the exception: resuming means picking up a conversation, and a
112
+ conversation lives in the folder it was held in, so `luv -l <URL> -r` reopens the
113
+ newest workspace for that PR instead of cloning another. `luv <repo> -pr <N>`
114
+ also keeps reusing its workspace — it names a workspace, where a URL names a PR.
115
+
98
116
  ## Commands
99
117
 
100
118
  | Command | Description |
@@ -103,8 +121,9 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
103
121
  | `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
104
122
  | `luv config list` | Show all settings |
105
123
  | `luv --init` | Configure default GitHub org only |
106
- | `luv ls [--host H] [--prune] [--no-pr]` | List live sessions across hosts, with each one's PR link |
124
+ | `luv ls [--host H] [--prune] [--no-pr]` | List every live session on every host, with each one's PR link |
107
125
  | `luv continue [<repo> [number]]` | Attach to a live session |
126
+ | `luv handover [<repo> [n]] --to HOST` | Move a session to another machine and resume it there |
108
127
  | `luv rm <session\|workspace>...` | Kill a session and delete its workspace on its host |
109
128
  | `luv rm --merged [--host H] [-f]` | Remove every session whose PR is merged |
110
129
  | `luv rm --dead [--host H] [-f]` | Remove workspaces with no live session |
@@ -112,8 +131,8 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
112
131
  | `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
113
132
  | `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
114
133
  | `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
115
- | `luv -l <PR URL> [prompt]` | Open any GitHub PR by URL |
116
- | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
134
+ | `luv -l <PR URL> [prompt]` | Clone any GitHub PR by URL into a fresh workspace |
135
+ | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number, reusing its workspace |
117
136
  | `luv --clean` | Delete workspaces where the branch is fully pushed/merged |
118
137
  | `luv --clean -f` | Force delete all workspaces |
119
138
  | `luv --clean --safe -f` | Force delete only workspaces older than 24h |
@@ -134,9 +153,20 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
134
153
  | `-s HOST` | Run on `HOST` over SSH, overriding the configured remote host |
135
154
  | `-i PATH` | SSH identity file to use for this invocation |
136
155
  | `--local` | Force local execution even when a remote host is configured |
137
- | `-f`, `--force` | Skip safety checks (with `--clean`) |
156
+ | `-f`, `--force` | Skip safety checks (with `--clean`); replace an existing destination folder (with `handover`) |
138
157
  | `--safe` | With `--clean -f`, only delete workspaces older than 24h (mtime) |
139
158
 
159
+ Handover flags:
160
+
161
+ | Flag | Description |
162
+ |------|-------------|
163
+ | `--to HOST` | Destination machine; `local` for the one you're on |
164
+ | `--from HOST` | Source machine, when luv can't tell from its session registry |
165
+ | `--no-agent-state` | Move the workspace only; the agent starts a new conversation |
166
+ | `--no-attach` | Leave the destination session running detached |
167
+ | `--purge` | Delete the source folder once the copy is verified |
168
+ | `-y` | Skip the "an agent may still be running" confirmation |
169
+
140
170
  ## Docker dev environments
141
171
 
142
172
  If a repo contains `.luv/settings.json` with a `compose_file` key, `luv` automatically starts a Docker Compose environment and runs Claude inside the `dev-environment` container.
@@ -179,7 +209,7 @@ The `dev-environment` service **must** have the selected agent CLI (`claude` or
179
209
 
180
210
  1. Detects `.luv/settings.json` with `compose_file` key
181
211
  2. Tears down any stale environment from a previous run
182
- 3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{number}`) for network/volume isolation
212
+ 3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{machine}-{number}`) for network/volume isolation
183
213
  4. Verifies the `dev-environment` service is running
184
214
  5. Runs the selected agent inside the container via `docker compose exec`
185
215
  6. The repo is volume-mounted, so all file changes and git commits are visible on the host
@@ -201,8 +231,24 @@ luv continue # reattach exactly where you left off
201
231
 
202
232
  `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`.
203
233
 
234
+ `luv ls` lists what is running on every host it knows about, not only the sessions this machine started — a session dispatched from your laptop shows up on your desktop, and `luv continue` and `luv rm` reach it from either.
235
+
204
236
  Use `--local` for a one-off local run, `-s HOST` to target a different machine, and `-i PATH` for a different SSH key.
205
237
 
238
+ ### Handing a session to another machine
239
+
240
+ Start something on your laptop, then move it — workspace, uncommitted work, and the agent's conversation — to a machine that won't go to sleep:
241
+
242
+ ```bash
243
+ luv --local myrepo "start something" # working locally
244
+ luv handover myrepo 43 --to box # resumes mid-conversation on box
245
+ luv handover myrepo 43 --to local # ...and back again later
246
+ ```
247
+
248
+ The whole folder crosses over, including untracked and gitignored files, so a workspace that ran before the move still runs after it. The agent's transcript moves too, so it picks up where it left off rather than starting fresh. Bytes are relayed through the machine you run the command on, so the two ends never need SSH access to each other.
249
+
250
+ The source folder is left on disk (`--purge` deletes it), and the workspace keeps the name it was created with.
251
+
206
252
  Full guide, including how to prepare a fresh Ubuntu box and set up SSH keys: **[docs/remote-sessions.md](docs/remote-sessions.md)**.
207
253
 
208
254
  ## Workspace cleanup
@@ -224,8 +270,8 @@ delete, and never touches a workspace with a live session. `luv rm` is the
224
270
  opposite — you name what goes, and it goes, on whichever host it lives on.
225
271
 
226
272
  ```bash
227
- luv rm myrepo-42 # kill its tmux session, delete ~/prs/myrepo-42 on its host
228
- luv rm luv-myrepo-42 # the session name works too
273
+ luv rm myrepo-mbp-42 # kill its tmux session, delete the folder on its host
274
+ luv rm luv-myrepo-mbp-42 # the session name works too
229
275
  luv rm --merged # every session whose PR has been merged
230
276
  luv rm --dead # workspaces left behind by sessions that already exited
231
277
  luv rm --dead --host box # scope either selector to one machine
@@ -240,9 +286,10 @@ registry as soon as its tmux session exits, but the clone it was working in
240
286
  stays on the remote disk. Those orphans are invisible to `luv ls` and are
241
287
  usually what is actually filling up the box.
242
288
 
243
- Only folders named `{repo}-{N}` are ever eligible — anything else in the
244
- workspace root is left alone, and a target that doesn't resolve to one is an
245
- error rather than an `rm -rf`.
289
+ Only folders ending in `-{N}` are ever eligible — `{repo}-{machine}-{N}`, a
290
+ `luv -l` copy `{repo}-{machine}-{N}_2`, and pre-slug `{repo}-{N}` alike.
291
+ Anything else in the workspace root is left alone, and a target that doesn't
292
+ resolve to one is an error rather than an `rm -rf`.
246
293
 
247
294
  ## Configuration
248
295
 
@@ -40,7 +40,7 @@ luv other-org/my-repo "fix the bug"
40
40
  # Reopen workspace #42
41
41
  luv my-repo 42
42
42
 
43
- # Open any GitHub PR by URL
43
+ # Clone any GitHub PR by URL into a fresh workspace
44
44
  luv -l https://github.com/org/repo/pull/123
45
45
 
46
46
  # Open a shell instead of Claude
@@ -53,7 +53,7 @@ luv -r my-repo 42
53
53
  luv --clean
54
54
 
55
55
  # Tear down a finished session: kill its tmux, delete its workspace
56
- luv rm myrepo-42
56
+ luv rm myrepo-mbp-42
57
57
  luv rm --merged # everything whose PR has landed
58
58
 
59
59
  # See what's running on your remote machine, and reattach
@@ -63,15 +63,33 @@ luv continue
63
63
 
64
64
  ## How it works
65
65
 
66
- 1. Clones the repo into `~/prs/{repo}-{number}/`
67
- 2. Creates a new branch `luv-{number}`
66
+ 1. Clones the repo into `~/prs/{repo}-{machine}-{number}/`
67
+ 2. Creates a new branch `luv-{machine}-{number}`
68
68
  3. Configures the selected agent with the workspace's PR conventions
69
69
  4. Launches Claude with Opus 5 at max effort, or Codex in YOLO mode
70
70
 
71
- All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter to avoid collisions.
71
+ All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter, and `{machine}` is a short name for the machine that created the workspace — by default the hostname, or whatever you set with `luv config set machine mbp`.
72
+
73
+ The machine name is what keeps two computers apart. Each one works out the next number from GitHub independently, so your laptop and your box will happily pick the same one; without the slug they would both push a branch called `luv-43`. Folders and branches created before this keep working unchanged.
72
74
 
73
75
  With a remote host configured, steps 1–4 happen on that machine inside a tmux session instead — see [Remote sessions](#remote-sessions).
74
76
 
77
+ ### Opening an existing PR
78
+
79
+ `luv -l <PR URL>` gives you the PR as it is *right now*: a full clone into a
80
+ workspace of its own, checked out on the PR's head branch. It never reuses a
81
+ folder that happens to be sitting there — a URL you paste is a request to look
82
+ at that PR, not at whatever was checked out in `myrepo-mbp-123` last week. Run
83
+ it twice on the same PR and you get `myrepo-mbp-123` and `myrepo-mbp-123_2`,
84
+ which are independent workspaces you can run side by side. When the PR comes
85
+ from a fork, the base repo is added as `upstream` and fetched too, so the branch
86
+ you need to diff or rebase against is there.
87
+
88
+ `-r` is the exception: resuming means picking up a conversation, and a
89
+ conversation lives in the folder it was held in, so `luv -l <URL> -r` reopens the
90
+ newest workspace for that PR instead of cloning another. `luv <repo> -pr <N>`
91
+ also keeps reusing its workspace — it names a workspace, where a URL names a PR.
92
+
75
93
  ## Commands
76
94
 
77
95
  | Command | Description |
@@ -80,8 +98,9 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
80
98
  | `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
81
99
  | `luv config list` | Show all settings |
82
100
  | `luv --init` | Configure default GitHub org only |
83
- | `luv ls [--host H] [--prune] [--no-pr]` | List live sessions across hosts, with each one's PR link |
101
+ | `luv ls [--host H] [--prune] [--no-pr]` | List every live session on every host, with each one's PR link |
84
102
  | `luv continue [<repo> [number]]` | Attach to a live session |
103
+ | `luv handover [<repo> [n]] --to HOST` | Move a session to another machine and resume it there |
85
104
  | `luv rm <session\|workspace>...` | Kill a session and delete its workspace on its host |
86
105
  | `luv rm --merged [--host H] [-f]` | Remove every session whose PR is merged |
87
106
  | `luv rm --dead [--host H] [-f]` | Remove workspaces with no live session |
@@ -89,8 +108,8 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
89
108
  | `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
90
109
  | `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
91
110
  | `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
92
- | `luv -l <PR URL> [prompt]` | Open any GitHub PR by URL |
93
- | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
111
+ | `luv -l <PR URL> [prompt]` | Clone any GitHub PR by URL into a fresh workspace |
112
+ | `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number, reusing its workspace |
94
113
  | `luv --clean` | Delete workspaces where the branch is fully pushed/merged |
95
114
  | `luv --clean -f` | Force delete all workspaces |
96
115
  | `luv --clean --safe -f` | Force delete only workspaces older than 24h |
@@ -111,9 +130,20 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
111
130
  | `-s HOST` | Run on `HOST` over SSH, overriding the configured remote host |
112
131
  | `-i PATH` | SSH identity file to use for this invocation |
113
132
  | `--local` | Force local execution even when a remote host is configured |
114
- | `-f`, `--force` | Skip safety checks (with `--clean`) |
133
+ | `-f`, `--force` | Skip safety checks (with `--clean`); replace an existing destination folder (with `handover`) |
115
134
  | `--safe` | With `--clean -f`, only delete workspaces older than 24h (mtime) |
116
135
 
136
+ Handover flags:
137
+
138
+ | Flag | Description |
139
+ |------|-------------|
140
+ | `--to HOST` | Destination machine; `local` for the one you're on |
141
+ | `--from HOST` | Source machine, when luv can't tell from its session registry |
142
+ | `--no-agent-state` | Move the workspace only; the agent starts a new conversation |
143
+ | `--no-attach` | Leave the destination session running detached |
144
+ | `--purge` | Delete the source folder once the copy is verified |
145
+ | `-y` | Skip the "an agent may still be running" confirmation |
146
+
117
147
  ## Docker dev environments
118
148
 
119
149
  If a repo contains `.luv/settings.json` with a `compose_file` key, `luv` automatically starts a Docker Compose environment and runs Claude inside the `dev-environment` container.
@@ -156,7 +186,7 @@ The `dev-environment` service **must** have the selected agent CLI (`claude` or
156
186
 
157
187
  1. Detects `.luv/settings.json` with `compose_file` key
158
188
  2. Tears down any stale environment from a previous run
159
- 3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{number}`) for network/volume isolation
189
+ 3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{machine}-{number}`) for network/volume isolation
160
190
  4. Verifies the `dev-environment` service is running
161
191
  5. Runs the selected agent inside the container via `docker compose exec`
162
192
  6. The repo is volume-mounted, so all file changes and git commits are visible on the host
@@ -178,8 +208,24 @@ luv continue # reattach exactly where you left off
178
208
 
179
209
  `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`.
180
210
 
211
+ `luv ls` lists what is running on every host it knows about, not only the sessions this machine started — a session dispatched from your laptop shows up on your desktop, and `luv continue` and `luv rm` reach it from either.
212
+
181
213
  Use `--local` for a one-off local run, `-s HOST` to target a different machine, and `-i PATH` for a different SSH key.
182
214
 
215
+ ### Handing a session to another machine
216
+
217
+ Start something on your laptop, then move it — workspace, uncommitted work, and the agent's conversation — to a machine that won't go to sleep:
218
+
219
+ ```bash
220
+ luv --local myrepo "start something" # working locally
221
+ luv handover myrepo 43 --to box # resumes mid-conversation on box
222
+ luv handover myrepo 43 --to local # ...and back again later
223
+ ```
224
+
225
+ The whole folder crosses over, including untracked and gitignored files, so a workspace that ran before the move still runs after it. The agent's transcript moves too, so it picks up where it left off rather than starting fresh. Bytes are relayed through the machine you run the command on, so the two ends never need SSH access to each other.
226
+
227
+ The source folder is left on disk (`--purge` deletes it), and the workspace keeps the name it was created with.
228
+
183
229
  Full guide, including how to prepare a fresh Ubuntu box and set up SSH keys: **[docs/remote-sessions.md](docs/remote-sessions.md)**.
184
230
 
185
231
  ## Workspace cleanup
@@ -201,8 +247,8 @@ delete, and never touches a workspace with a live session. `luv rm` is the
201
247
  opposite — you name what goes, and it goes, on whichever host it lives on.
202
248
 
203
249
  ```bash
204
- luv rm myrepo-42 # kill its tmux session, delete ~/prs/myrepo-42 on its host
205
- luv rm luv-myrepo-42 # the session name works too
250
+ luv rm myrepo-mbp-42 # kill its tmux session, delete the folder on its host
251
+ luv rm luv-myrepo-mbp-42 # the session name works too
206
252
  luv rm --merged # every session whose PR has been merged
207
253
  luv rm --dead # workspaces left behind by sessions that already exited
208
254
  luv rm --dead --host box # scope either selector to one machine
@@ -217,9 +263,10 @@ registry as soon as its tmux session exits, but the clone it was working in
217
263
  stays on the remote disk. Those orphans are invisible to `luv ls` and are
218
264
  usually what is actually filling up the box.
219
265
 
220
- Only folders named `{repo}-{N}` are ever eligible — anything else in the
221
- workspace root is left alone, and a target that doesn't resolve to one is an
222
- error rather than an `rm -rf`.
266
+ Only folders ending in `-{N}` are ever eligible — `{repo}-{machine}-{N}`, a
267
+ `luv -l` copy `{repo}-{machine}-{N}_2`, and pre-slug `{repo}-{N}` alike.
268
+ Anything else in the workspace root is left alone, and a target that doesn't
269
+ resolve to one is an error rather than an `rm -rf`.
223
270
 
224
271
  ## Configuration
225
272
 
@@ -43,6 +43,28 @@ want for hosts, paths, and org names.
43
43
  The **laptop** resolves this before dispatching, so a remote machine never needs
44
44
  its own `org` configured.
45
45
 
46
+ ### `machine`
47
+
48
+ *string, default: this machine's hostname.* A short name for **this** machine,
49
+ used in the workspace folder (`myrepo-mbp-43`) and branch (`luv-mbp-43`) of
50
+ everything created here.
51
+
52
+ It exists because every machine works out the next workspace number from
53
+ GitHub's issue counter independently, so a laptop and a box will happily pick
54
+ the same one — and without the slug they would push the same branch. The value
55
+ is lowercased, stripped to letters and digits, and capped at 8 characters, so
56
+ `Niveds-MacBook.local` becomes `nivedsma`; set it explicitly for something
57
+ readable:
58
+
59
+ ```bash
60
+ luv config set machine mbp
61
+ ```
62
+
63
+ The slug is stamped when a workspace is created and never changes afterwards —
64
+ `luv handover` moves `myrepo-mbp-43` to another machine under that same name,
65
+ because it records where the workspace came from and its branch may already be
66
+ pushed. Workspaces created before slugs existed keep working.
67
+
46
68
  ### `prs_dir`
47
69
 
48
70
  *string, default `~/prs`.* Where workspace folders are created on **this**
@@ -107,6 +129,7 @@ those combinations contradict each other.
107
129
  ```json
108
130
  {
109
131
  "org": "exosphere",
132
+ "machine": "mbp",
110
133
  "prs_dir": "~/prs",
111
134
  "remote": {
112
135
  "host": "box",
@@ -9,6 +9,7 @@ still up.
9
9
  - [Preparing a new remote machine (Ubuntu)](#preparing-a-new-remote-machine-ubuntu)
10
10
  - [Configuring luv](#configuring-luv)
11
11
  - [Daily use](#daily-use)
12
+ - [Handing a session to another machine](#handing-a-session-to-another-machine)
12
13
  - [Session lifecycle](#session-lifecycle)
13
14
  - [Session naming and identity](#session-naming-and-identity)
14
15
  - [What runs where](#what-runs-where)
@@ -28,9 +29,9 @@ luv myrepo "fix the bug"
28
29
  └─ ssh -t box 'bash -lc "…"' ────► tmux new-session -A -s luv-pending-3f9a
29
30
  └─ luv exosphere/myrepo "fix the bug"
30
31
  ├─ gh api → next number = 42
31
- ├─ git clone → ~/prs/myrepo-42
32
- ├─ git checkout -b luv-42
33
- ├─ tmux rename-session → luv-myrepo-42
32
+ ├─ git clone → ~/prs/myrepo-box-42
33
+ ├─ git checkout -b luv-box-42
34
+ ├─ tmux rename-session → luv-myrepo-box-42
34
35
  └─ exec claude …
35
36
  (session keeps running after you disconnect)
36
37
  ```
@@ -210,19 +211,24 @@ On your laptop:
210
211
  luv config
211
212
  ```
212
213
 
213
- The wizard asks for the host, the SSH identity file, and the remote workspace
214
- directory, then offers to set your default GitHub org. It finishes by checking
215
- the remote for `tmux`, `luv`, `gh`, and `git` and telling you exactly what's
216
- missing.
214
+ The wizard asks for this machine's name, the host, the SSH identity file, and
215
+ the remote workspace directory, then offers to set your default GitHub org. It
216
+ finishes by checking the remote for `tmux`, `luv`, `gh`, and `git` and telling
217
+ you exactly what's missing.
217
218
 
218
219
  Non-interactively:
219
220
 
220
221
  ```bash
222
+ luv config set machine mbp
221
223
  luv config set remote.host box
222
224
  luv config set remote.identity_file ~/.ssh/id_ed25519
223
225
  luv config list
224
226
  ```
225
227
 
228
+ Setting `machine` on each machine you use is worth the ten seconds: it is the
229
+ label that ends up in every workspace folder and branch name, and the hostname
230
+ default is rarely the nicest one.
231
+
226
232
  Full key reference: [configuration.md](configuration.md).
227
233
 
228
234
  ## Daily use
@@ -233,7 +239,7 @@ about how you use luv changes:
233
239
  ```bash
234
240
  luv myrepo "fix the flaky test" # new workspace on the remote
235
241
  luv myrepo 42 "keep going" # reopen workspace 42 there
236
- luv ls # what's running, across all hosts
242
+ luv ls # what's running, on every host, whoever started it
237
243
  luv continue # attach to a session
238
244
  luv continue myrepo # attach to the newest myrepo session
239
245
  luv continue myrepo 42 # attach to a specific workspace
@@ -250,36 +256,124 @@ luv -i ~/.ssh/other_key myrepo # use a different key this once
250
256
  Detach from a session with `Ctrl-b d` (tmux's default prefix). The agent keeps
251
257
  running.
252
258
 
259
+ ## Handing a session to another machine
260
+
261
+ `luv handover` relocates a running workspace. The usual direction is laptop →
262
+ box: you start something locally, then decide it should keep running somewhere
263
+ that doesn't sleep.
264
+
265
+ ```bash
266
+ luv --local myrepo "start something" # → ~/prs/myrepo-mbp-43, branch luv-mbp-43
267
+ luv handover myrepo 43 --to box # attaches to it, now running on box
268
+ luv handover myrepo 43 --to local # and back down again later
269
+ ```
270
+
271
+ ```
272
+ LAPTOP (the one you run the command on)
273
+ │
274
+ ssh box tar -c ─┤─ ssh gpu tar -x ← bytes are relayed through here, so
275
+ │ box and gpu need no keys for each other
276
+ BOX GPU-BOX
277
+ tmux kill-session luv-myrepo-mbp-43
278
+ docker compose -p … down
279
+ ~/prs/myrepo-mbp-43 ───────────────► ~/prs/myrepo-mbp-43 (whole folder)
280
+ ~/.claude/projects/-home-u-prs-… ───► ~/.claude/projects/… (transcript)
281
+ (folder left in place) tmux new-session -A -s luv-myrepo-mbp-43
282
+ └─ luv exo/myrepo 43 -r → claude --resume
283
+ ```
284
+
285
+ **Everything crosses over.** The folder is copied byte for byte — `.git`, staged
286
+ and unstaged edits, untracked files, and gitignored ones like `.env` and
287
+ `node_modules`. A workspace that ran before the move still runs after it.
288
+
289
+ **So does the conversation.** Claude's transcript
290
+ (`~/.claude/projects/<path-slug>/*.jsonl`) and Codex's rollouts
291
+ (`~/.codex/sessions/**/rollout-*.jsonl`) travel with the workspace, and the
292
+ absolute path recorded inside them is rewritten for the new machine — the two
293
+ rarely agree on it. The destination then starts with `-r`, so `claude --resume`
294
+ or `codex resume --last` continues the thread rather than opening a new one.
295
+ This part depends on each agent's on-disk layout, so luv says what it found and
296
+ carries on with a fresh conversation if it finds nothing; `--no-agent-state`
297
+ skips it deliberately.
298
+
299
+ **The agent is briefly down.** luv checks everything it can first — the
300
+ destination is reachable, has luv, and has no folder in the way — then stops the
301
+ agent, because copying a workspace out from under a running one is the only way
302
+ to get a torn tree. Killing the tmux session SIGHUPs the pane, so luv also tears
303
+ the Docker environment down explicitly rather than relying on the agent's exit.
304
+
305
+ **Nothing is deleted.** The source folder stays on disk, so a failed transfer
306
+ costs nothing: luv prints the command to restart the session where it was. Use
307
+ `--purge` to delete it once the copy verifies, or `luv --clean` on that machine
308
+ later.
309
+
310
+ **The name doesn't change.** `myrepo-mbp-43` stays `myrepo-mbp-43` on the box —
311
+ the slug records where a workspace came from, and its branch may already be
312
+ pushed. That is also what makes the move safe: a workspace created on the box
313
+ would be `myrepo-box-43`, so the two can sit side by side.
314
+
315
+ Handover works on a workspace whose agent has already exited, too — name the
316
+ machine with `--from` and luv will move the folder and start it fresh.
317
+
318
+ | Flag | |
319
+ |---|---|
320
+ | `--to HOST` | Destination; `local` for the machine you're on. Required. |
321
+ | `--from HOST` | Source, when luv's registry doesn't already know |
322
+ | `--no-agent-state` | Move the workspace only |
323
+ | `--no-attach` | Leave the new session detached |
324
+ | `--purge` | Delete the source folder after the copy verifies |
325
+ | `-f` | Replace an existing folder on the destination |
326
+ | `-y` | Skip the "an agent may still be running" confirmation |
327
+
253
328
  ## Session lifecycle
254
329
 
255
330
  | Step | What happens |
256
331
  |---|---|
257
332
  | `luv myrepo "…"` | Laptop records a registry entry, opens SSH, creates the tmux session, remote luv clones and launches the agent |
258
333
  | `Ctrl-b d` or lost connection | tmux session keeps running; agent unaffected; Docker containers stay up |
259
- | `luv ls` | Laptop queries each host's tmux and refreshes the registry |
334
+ | `luv ls` | Laptop queries every known host's tmux and refreshes the registry, adopting sessions another machine started |
260
335
  | `luv continue` | Reattaches; other clients are detached so the pane isn't size-clamped |
261
336
  | Agent exits | The pane's command ends, tmux session disappears, Docker environment is torn down, `luv ls` prunes the entry on its next run |
262
337
 
263
338
  ## Session naming and identity
264
339
 
265
- Sessions are named after the workspace folder: `~/prs/myrepo-42` becomes
266
- `luv-myrepo-42`. tmux forbids `.` and `:` in session names, so a repo like
267
- `foo.js` becomes `luv-foo_js-42`.
268
-
269
- When the workspace is already known — reopening by number, `-pr`, or `-l` — the
270
- laptop uses that name directly and `tmux new-session -A` doubles as "attach if
271
- it's already running".
272
-
273
- When it isn't known — a brand new workspace, or bare `-n`/`-r` — the laptop uses
274
- a placeholder `luv-pending-<id>` and the remote renames it once the clone lands.
340
+ Workspace folders are `{repo}-{machine}-{number}` and branches
341
+ `luv-{machine}-{number}`, where `{machine}` names whichever machine created the
342
+ workspace (config key `machine`, defaulting to the hostname). Each machine works
343
+ out the next number from GitHub's issue counter on its own, so without that slug
344
+ a laptop and a box would routinely pick the same one and push the same branch.
345
+ Folders and branches created before slugs existed keep working — luv looks for
346
+ its own name first, then any other machine's, then the plain `{repo}-{number}`.
347
+
348
+ `luv -l <PR URL>` clones into a folder of its own every time, so a second run on
349
+ the same PR gets `{repo}-{machine}-{number}_2`, a third `_3`. The suffix is part
350
+ of the number's segment, so those folders are still workspace `{number}`
351
+ everywhere it matters — `luv ls`, `luv rm`, `--clean`, handover. Where a number
352
+ has several copies, the newest wins: that is the one you just made.
353
+
354
+ Sessions are named after the workspace folder: `~/prs/myrepo-box-42` becomes
355
+ `luv-myrepo-box-42`. tmux forbids `.` and `:` in session names, so a repo like
356
+ `foo.js` becomes `luv-foo_js-box-42`.
357
+
358
+ When the workspace is already known — reopening by number, `-pr`, or `-l -r` —
359
+ the laptop pins the session name down before dispatching, so `tmux new-session
360
+ -A` doubles as "attach if it's already running". It can't compute that name,
361
+ though, since the slug belongs to the machine holding the folder: it takes it
362
+ from the session registry, or asks the host directly, and falls back to the
363
+ `luv-pending-<id>` rename-on-arrival path if the host doesn't answer.
364
+
365
+ When it isn't known — a brand new workspace, plain `luv -l` (whose folder the
366
+ remote only picks once it sees what is already there), or bare `-n`/`-r` — the
367
+ laptop uses a placeholder `luv-pending-<id>` and the remote renames it once the
368
+ clone lands.
275
369
  If the target name is already taken by another live session, luv keeps the
276
- session under `luv-myrepo-42-2` and warns rather than failing.
370
+ session under `luv-myrepo-box-42-2` and warns rather than failing.
277
371
 
278
372
  Each session also carries two tmux options, which is what `luv ls` reads:
279
373
 
280
374
  ```bash
281
- tmux show-options -t luv-myrepo-42 -v @luv_id
282
- tmux show-options -t luv-myrepo-42 -v @luv_workspace
375
+ tmux show-options -t luv-myrepo-box-42 -v @luv_id
376
+ tmux show-options -t luv-myrepo-box-42 -v @luv_workspace
283
377
  ```
284
378
 
285
379
  ## What runs where
@@ -288,6 +382,7 @@ tmux show-options -t luv-myrepo-42 -v @luv_workspace
288
382
  |---|---|---|
289
383
  | `luv config`, `luv --init` | Always local | – |
290
384
  | `luv ls`, `luv continue` | Local, queries remote over SSH | – |
385
+ | `luv handover` | Local, drives both machines over SSH | starts one on the destination |
291
386
  | `luv <repo> [prompt]` | Remote | yes |
292
387
  | `luv <repo> <n>`, `-pr`, `-l` | Remote | yes |
293
388
  | `luv <repo> -n` / `-r` | Remote | yes |
@@ -304,7 +399,7 @@ invocation, `docker compose up` runs *inside* the pane:
304
399
 
305
400
  - Detaching leaves the containers running.
306
401
  - Teardown happens when the agent exits, not when your SSH client goes away.
307
- - The compose project name is still `luv-{repo}-{number}`, so two workspaces on
402
+ - The compose project name is still `luv-{repo}-{machine}-{number}`, so two workspaces on
308
403
  the same repo don't collide.
309
404
 
310
405
  Install Docker on the remote and add your user to the `docker` group:
@@ -340,8 +435,10 @@ discards the command — so the prompt is dropped. luv prints a note when this c
340
435
  happen. Type the prompt into the running agent instead.
341
436
 
342
437
  **`luv ls` says a host is unreachable**
343
- It shows the last known state rather than pruning; nothing is lost. When a host
344
- is gone for good, `luv ls --prune` forgets its entries.
438
+ It shows that host's last known state rather than pruning; nothing is lost. A
439
+ configured host with no entries yet has no state to show, so it just says its
440
+ sessions aren't listed. When a host is gone for good, `luv ls --prune` forgets
441
+ its entries — and `luv config unset remote.hosts.<name>` stops the scan.
345
442
 
346
443
  **Junk like `35;22;1M` appears at my prompt after a dropped connection**
347
444
 
@@ -356,11 +453,29 @@ restores the terminal whatever happens to the connection. If you land in this
356
453
  state from something else, `reset` (or `stty sane` plus
357
454
  `printf '\033[?1003l\033[?1006l\033[?2004l'`) clears it.
358
455
 
456
+ **Handover says the destination already has that folder**
457
+ Something with the same name is already there — either the same workspace from
458
+ an earlier handover, or one created on the machine that made this one. Look at
459
+ it first (`ssh box ls ~/prs`), then either `luv --clean` it or pass `-f` to
460
+ replace it.
461
+
462
+ **After a handover the agent started a fresh conversation**
463
+ luv prints a warning when it can't find a transcript for the workspace. The
464
+ usual cause is the workspace having been opened under a different path than the
465
+ one the agent recorded, or a Codex rollout stored somewhere other than
466
+ `~/.codex/sessions`. The workspace itself is unaffected — the code, branch, and
467
+ uncommitted work all moved.
468
+
469
+ **Handover left the session down**
470
+ The source folder is never deleted without `--purge`, so restart it where it
471
+ was: `luv <repo> <n> -r -s <source-host>`. luv prints that exact command before
472
+ it starts copying.
473
+
359
474
  **A session is wedged**
360
475
 
361
476
  ```bash
362
477
  ssh box tmux ls # see everything, not just luv's
363
- ssh box tmux kill-session -t luv-myrepo-42
478
+ ssh box tmux kill-session -t luv-myrepo-box-42
364
479
  ```
365
480
 
366
481
  **I'm already inside tmux locally**