luv-cli 0.1.0__tar.gz → 0.2.1__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.1.0
3
+ Version: 0.2.1
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
@@ -75,6 +75,10 @@ luv -r my-repo 42
75
75
  # Clean up fully-merged workspaces
76
76
  luv --clean
77
77
 
78
+ # Tear down a finished session: kill its tmux, delete its workspace
79
+ luv rm myrepo-42
80
+ luv rm --merged # everything whose PR has landed
81
+
78
82
  # See what's running on your remote machine, and reattach
79
83
  luv ls
80
84
  luv continue
@@ -99,8 +103,11 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
99
103
  | `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
100
104
  | `luv config list` | Show all settings |
101
105
  | `luv --init` | Configure default GitHub org only |
102
- | `luv ls [--host H] [--prune]` | List live sessions across hosts |
106
+ | `luv ls [--host H] [--prune] [--no-pr]` | List live sessions across hosts, with each one's PR link |
103
107
  | `luv continue [<repo> [number]]` | Attach to a live session |
108
+ | `luv rm <session\|workspace>...` | Kill a session and delete its workspace on its host |
109
+ | `luv rm --merged [--host H] [-f]` | Remove every session whose PR is merged |
110
+ | `luv rm --dead [--host H] [-f]` | Remove workspaces with no live session |
104
111
  | `luv [org/]<repo> [prompt...]` | Create a new workspace and launch Claude (default) |
105
112
  | `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
106
113
  | `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
@@ -210,6 +217,33 @@ Use `luv --clean -f` to skip all safety checks and delete everything. Add `--saf
210
217
 
211
218
  Workspaces with a live tmux session are skipped unless you pass `-f`.
212
219
 
220
+ ### Removing a specific session
221
+
222
+ `luv --clean` is a garbage collector: it decides for itself what is safe to
223
+ delete, and never touches a workspace with a live session. `luv rm` is the
224
+ opposite — you name what goes, and it goes, on whichever host it lives on.
225
+
226
+ ```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
229
+ luv rm --merged # every session whose PR has been merged
230
+ luv rm --dead # workspaces left behind by sessions that already exited
231
+ luv rm --dead --host box # scope either selector to one machine
232
+ ```
233
+
234
+ A named target is taken as intent and removed without further checks —
235
+ including any uncommitted work in it. The `--merged` and `--dead` selectors
236
+ list what they matched and ask first; `-f` skips that prompt.
237
+
238
+ `--dead` is the counterpart to `luv ls`: reconciliation drops a session from the
239
+ registry as soon as its tmux session exits, but the clone it was working in
240
+ stays on the remote disk. Those orphans are invisible to `luv ls` and are
241
+ usually what is actually filling up the box.
242
+
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`.
246
+
213
247
  ## Configuration
214
248
 
215
249
  Run `luv config` for interactive setup, or `luv --init` to set just your default GitHub org. Both save to `~/.luv/config.json`:
@@ -52,6 +52,10 @@ luv -r my-repo 42
52
52
  # Clean up fully-merged workspaces
53
53
  luv --clean
54
54
 
55
+ # Tear down a finished session: kill its tmux, delete its workspace
56
+ luv rm myrepo-42
57
+ luv rm --merged # everything whose PR has landed
58
+
55
59
  # See what's running on your remote machine, and reattach
56
60
  luv ls
57
61
  luv continue
@@ -76,8 +80,11 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
76
80
  | `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
77
81
  | `luv config list` | Show all settings |
78
82
  | `luv --init` | Configure default GitHub org only |
79
- | `luv ls [--host H] [--prune]` | List live sessions across hosts |
83
+ | `luv ls [--host H] [--prune] [--no-pr]` | List live sessions across hosts, with each one's PR link |
80
84
  | `luv continue [<repo> [number]]` | Attach to a live session |
85
+ | `luv rm <session\|workspace>...` | Kill a session and delete its workspace on its host |
86
+ | `luv rm --merged [--host H] [-f]` | Remove every session whose PR is merged |
87
+ | `luv rm --dead [--host H] [-f]` | Remove workspaces with no live session |
81
88
  | `luv [org/]<repo> [prompt...]` | Create a new workspace and launch Claude (default) |
82
89
  | `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
83
90
  | `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
@@ -187,6 +194,33 @@ Use `luv --clean -f` to skip all safety checks and delete everything. Add `--saf
187
194
 
188
195
  Workspaces with a live tmux session are skipped unless you pass `-f`.
189
196
 
197
+ ### Removing a specific session
198
+
199
+ `luv --clean` is a garbage collector: it decides for itself what is safe to
200
+ delete, and never touches a workspace with a live session. `luv rm` is the
201
+ opposite — you name what goes, and it goes, on whichever host it lives on.
202
+
203
+ ```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
206
+ luv rm --merged # every session whose PR has been merged
207
+ luv rm --dead # workspaces left behind by sessions that already exited
208
+ luv rm --dead --host box # scope either selector to one machine
209
+ ```
210
+
211
+ A named target is taken as intent and removed without further checks —
212
+ including any uncommitted work in it. The `--merged` and `--dead` selectors
213
+ list what they matched and ask first; `-f` skips that prompt.
214
+
215
+ `--dead` is the counterpart to `luv ls`: reconciliation drops a session from the
216
+ registry as soon as its tmux session exits, but the clone it was working in
217
+ stays on the remote disk. Those orphans are invisible to `luv ls` and are
218
+ usually what is actually filling up the box.
219
+
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`.
223
+
190
224
  ## Configuration
191
225
 
192
226
  Run `luv config` for interactive setup, or `luv --init` to set just your default GitHub org. Both save to `~/.luv/config.json`:
@@ -343,6 +343,19 @@ happen. Type the prompt into the running agent instead.
343
343
  It shows the last known state rather than pruning; nothing is lost. When a host
344
344
  is gone for good, `luv ls --prune` forgets its entries.
345
345
 
346
+ **Junk like `35;22;1M` appears at my prompt after a dropped connection**
347
+
348
+ That's mouse-tracking reports. The remote tmux/agent turned mouse tracking on
349
+ in *your* terminal and, having been killed along with the connection, never
350
+ turned it off — so every mouse move now types coordinates at your shell.
351
+ Bracketed paste (`200~` around pastes), a missing cursor, and a stuck
352
+ alternate screen come from the same cause.
353
+
354
+ luv now cleans this up itself: it stays alive as the parent of `ssh` and
355
+ restores the terminal whatever happens to the connection. If you land in this
356
+ state from something else, `reset` (or `stty sane` plus
357
+ `printf '\033[?1003l\033[?1006l\033[?2004l'`) clears it.
358
+
346
359
  **A session is wedged**
347
360
 
348
361
  ```bash
@@ -0,0 +1,212 @@
1
+ # The session registry
2
+
3
+ `luv ls` needs to answer "what am I running, and where?" — including for hosts
4
+ that are currently offline. It does that from `~/.luv/sessions.json`, a local
5
+ cache of every session luv has started, reconciled against live tmux state on
6
+ each run.
7
+
8
+ ## Why it exists
9
+
10
+ The obvious approach — just ask each host `tmux ls` — has two gaps:
11
+
12
+ 1. **luv wouldn't know which hosts to ask.** Once you use `-s` for a second
13
+ machine, the config's default host is no longer the whole picture.
14
+ 2. **tmux doesn't remember why a session exists.** The prompt you started with,
15
+ the org, and the agent aren't tmux's business.
16
+
17
+ There's also a sequencing problem. Your laptop can't know the workspace number
18
+ in advance — it comes from `gh api` running *on the remote* — so at dispatch
19
+ time there is nothing stable to record. luv solves that by generating a random
20
+ token, writing it into the registry, and having the remote stamp it onto the
21
+ tmux session as a `@luv_id` option. Matching happens on that token, so renaming
22
+ the session (which luv does as soon as the clone lands) breaks nothing.
23
+
24
+ ## Schema
25
+
26
+ ```json
27
+ {
28
+ "sessions": [
29
+ {
30
+ "id": "k3f9a2c1",
31
+ "host": "box",
32
+ "session": "luv-myrepo-42",
33
+ "org": "exosphere",
34
+ "repo": "myrepo",
35
+ "workspace": "myrepo-42",
36
+ "agent": "claude",
37
+ "prompt": "fix the flaky test",
38
+ "created": 1753401234,
39
+ "last_seen": 1753408899,
40
+ "pr_number": 42,
41
+ "pr_url": "https://github.com/exosphere/myrepo/pull/42",
42
+ "pr_state": "OPEN",
43
+ "pr_checked": 1753408899
44
+ }
45
+ ]
46
+ }
47
+ ```
48
+
49
+ | Field | Meaning |
50
+ |---|---|
51
+ | `id` | Random 8-char token; matches the session's `@luv_id` tmux option |
52
+ | `host` | The `remote.host` value used, or absent for a local session |
53
+ | `session` | tmux session name; starts as `luv-pending-<id>` and is corrected on the first reconcile |
54
+ | `org`, `repo` | GitHub owner and repo, resolved on the laptop |
55
+ | `workspace` | Folder name (`myrepo-42`); `null` until the remote reports it |
56
+ | `agent` | `claude` or `codex` |
57
+ | `prompt` | The prompt you launched with, for the `luv ls` label |
58
+ | `created` | Unix time the entry was written |
59
+ | `last_seen` | Unix time of the last successful reconcile |
60
+ | `pr_number`, `pr_url` | The session's pull request; `null` until one exists |
61
+ | `pr_state` | `OPEN`, `CLOSED` or `MERGED` — what `luv rm --merged` selects on |
62
+ | `pr_checked` | Unix time GitHub was last asked about it |
63
+ | `pr_hint` | PR number known at dispatch (`-l` / `-pr` only); absent otherwise |
64
+
65
+ `attached`, `activity`, and `live` are recomputed on every reconcile and are
66
+ deliberately **not** written back to the file.
67
+
68
+ ## Reconciliation
69
+
70
+ On `luv ls` and `luv continue`, luv groups entries by host and runs one query
71
+ per host (concurrently when there's more than one):
72
+
73
+ ```
74
+ tmux list-sessions -F '#{@luv_id}|#{session_name}|#{@luv_workspace}|#{session_attached}|#{session_activity}'
75
+ ```
76
+
77
+ Each entry then lands in one of three states:
78
+
79
+ | State | Condition | Effect |
80
+ |---|---|---|
81
+ | **live** | Host answered and a session matched | Entry refreshed: name, workspace, attached, activity, `last_seen` |
82
+ | **dead** | Host answered and no session matched | Entry pruned |
83
+ | **unreachable** | Host never answered | Entry kept as-is, shown with `?`, warning printed |
84
+
85
+ Matching prefers `@luv_id` and falls back to the session name, which covers
86
+ sessions created before the option was stamped.
87
+
88
+ **An unreachable host never causes pruning.** This is the rule the whole design
89
+ protects: running `luv ls` on a plane, or while a box is rebooting, must not
90
+ silently erase your session list. luv distinguishes the two cases by ssh's own
91
+ exit code — 255 means ssh failed to connect, anything else means the host
92
+ answered (an empty result from a host with no tmux server is a legitimate
93
+ "nothing is running here").
94
+
95
+ To forget a host you've genuinely retired:
96
+
97
+ ```bash
98
+ luv ls --prune
99
+ ```
100
+
101
+ That drops entries for hosts that didn't answer, in addition to the dead ones
102
+ reconciliation already removed.
103
+
104
+ ## PR links
105
+
106
+ Every luv workspace is one pull request by construction — folder `{repo}-{N}`,
107
+ branch `luv-{N}` — so `luv ls` can show the PR each session is producing:
108
+
109
+ ```
110
+ HOST SESSION WORKSPACE AGENT ATTACHED ACTIVE PR PROMPT
111
+ box luv-myrepo-42 myrepo-42 claude yes 2m ago #42 fix the flaky test
112
+ box luv-myrepo-51 myrepo-51 codex no 1h ago - add rate limiting
113
+ ```
114
+
115
+ `#42` is an OSC 8 terminal hyperlink, so ctrl/cmd-click opens the PR. When
116
+ stdout isn't a terminal there's nothing to click and a bare number would be
117
+ useless, so `luv ls | grep` and redirects get the full URL instead.
118
+
119
+ The lookup asks GitHub for the PR whose head branch is `luv-{N}`:
120
+
121
+ ```
122
+ gh pr list --repo {org}/{repo} --head luv-{N} --state all --limit 1 --json number,url
123
+ ```
124
+
125
+ `gh pr list` rather than the REST endpoint because its `--head` takes a bare
126
+ branch name. REST wants `head={owner}:{branch}`, and the owner luv recorded is
127
+ whatever you typed — which stops matching the moment the org is renamed or the
128
+ repo is transferred.
129
+
130
+ It is a head query and nothing else. Checking whether PR `#N` merely *exists*
131
+ would show a stranger's PR whenever someone took that number between luv
132
+ reserving the folder and the agent pushing — the folder number is only ever the
133
+ *intended* PR number. Sessions started from an existing PR (`luv -l <url>`,
134
+ `luv <repo> -pr <N>`) are the exception: there the number is known at dispatch
135
+ and stored as `pr_hint`, so they resolve with no network call at all. Their
136
+ branch is the PR's own head ref, which the head query would never match.
137
+
138
+ Results are cached in the registry so repeat runs are instant and a link stays
139
+ on screen when GitHub is unreachable — 5 minutes for a PR that was found, 1
140
+ minute when there wasn't one yet (the agent may open it any moment). A session
141
+ with no PR, no org, or a folder that isn't `{repo}-{N}` shows `-`.
142
+
143
+ `luv ls --no-pr` skips the whole thing for a fast, offline-safe listing, which
144
+ is also what happens automatically when `gh` isn't installed. `luv continue`
145
+ renders the cached links but never triggers a lookup of its own.
146
+
147
+ ## Removing sessions
148
+
149
+ `luv rm` is the teardown counterpart to `luv ls`: it kills the tmux session and
150
+ deletes the workspace folder on whichever host the session lives on, then drops
151
+ the registry entry.
152
+
153
+ ```bash
154
+ luv rm myrepo-42 # by workspace, or by session name
155
+ luv rm --merged # every session whose PR state is MERGED
156
+ luv rm --dead # workspaces on a host with no live luv session
157
+ luv rm --dead --host box # scope either selector to one machine
158
+ ```
159
+
160
+ `--merged` reads the `pr_state` the PR lookup already caches, so "clean up
161
+ what's landed" costs nothing beyond what `luv ls` was doing anyway.
162
+
163
+ `--dead` exists because **reconciliation removes a registry entry the moment its
164
+ tmux session dies, but nothing removes the clone**. Those folders are invisible
165
+ to `luv ls` — the registry has already forgotten them — and they are usually
166
+ what is actually consuming the remote's disk. Finding them means asking the host
167
+ directly: `ls -1` the workspace root, `tmux list-sessions`, and take the
168
+ difference.
169
+
170
+ That same asymmetry is why a named target falls back to a folder scan. A session
171
+ you have just finished with is exactly one whose entry reconciliation has
172
+ already dropped, so looking only in the registry would fail for the most common
173
+ case.
174
+
175
+ Two things bound the damage:
176
+
177
+ - Only names matching `{repo}-{N}` are eligible, checked again immediately
178
+ before the `rm -rf` rather than only at selection time. A stray directory in
179
+ the workspace root is never a candidate, and a target that resolves to one is
180
+ an error, not a deletion.
181
+ - A named target is its own confirmation, but `--merged` and `--dead` print what
182
+ they matched and ask, because a selector can sweep up folders on a machine you
183
+ are not looking at. `-f` skips the prompt.
184
+
185
+ A failed delete — unreachable host, permissions — leaves the registry entry in
186
+ place, so the session doesn't silently disappear from `luv ls` while its files
187
+ are still on disk.
188
+
189
+ ## Concurrency
190
+
191
+ Both dispatch (append an entry) and `ls` (prune entries) are read-modify-write,
192
+ which an atomic file replace alone doesn't protect — two luv invocations at once
193
+ would lose one entry. luv takes a lock at `~/.luv/sessions.lock` around the
194
+ whole cycle, using `O_CREAT | O_EXCL`.
195
+
196
+ A lock older than the timeout is assumed to belong to a killed process and is
197
+ broken. If the lock still can't be taken, luv proceeds **without** it: losing a
198
+ registry entry is a far better outcome than refusing to launch an agent.
199
+
200
+ ## Recovery
201
+
202
+ The registry is a cache. Deleting it is safe:
203
+
204
+ ```bash
205
+ rm ~/.luv/sessions.json
206
+ ```
207
+
208
+ You lose the metadata luv can't recover — the prompt text, and which hosts to
209
+ look at — but no sessions. They're still running; find them with
210
+ `ssh <host> tmux ls` and reattach with `tmux attach -t <name>`.
211
+
212
+ A corrupt or unparseable file is treated as empty rather than crashing.