luv-cli 0.2.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.2.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
@@ -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
@@ -12,6 +12,11 @@ import tempfile
12
12
  import time
13
13
  from pathlib import Path
14
14
 
15
+ try:
16
+ import termios
17
+ except ImportError: # not POSIX — the ssh/tmux handoffs don't run there either
18
+ termios = None
19
+
15
20
  LUV_DIR = Path.home() / ".luv"
16
21
  CONFIG_FILE = LUV_DIR / "config.json"
17
22
  SESSIONS_FILE = LUV_DIR / "sessions.json"
@@ -1153,21 +1158,109 @@ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool
1153
1158
  launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model, agent=agent)
1154
1159
 
1155
1160
 
1156
- def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True) -> None:
1157
- """Hand the terminal to ssh, replacing this process.
1161
+ # Terminal modes a full-screen program switches on and is expected to switch
1162
+ # off again on its way out. A connection that dies mid-session never gets to,
1163
+ # and the leftovers are user-visible: mouse tracking turns every mouse move
1164
+ # into "35;22;1M" junk at the shell prompt, bracketed paste wraps pastes in
1165
+ # "200~", and the alternate screen swallows the scrollback.
1166
+ TERM_RESET = (
1167
+ "\x1b[?1000l\x1b[?1001l\x1b[?1002l\x1b[?1003l" # mouse tracking off
1168
+ "\x1b[?1004l" # focus reporting off
1169
+ "\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l" # mouse report encodings off
1170
+ "\x1b[?2004l" # bracketed paste off
1171
+ "\x1b[?1049l" # back to the primary screen
1172
+ "\x1b[?1l\x1b>" # normal cursor keys, keypad
1173
+ "\x1b[r" # full-height scroll region
1174
+ "\x1b[?7h\x1b[4l\x1b[?25h\x1b[0m" # wrap, replace, cursor, colours
1175
+ )
1176
+
1177
+
1178
+ def terminal_fd() -> int | None:
1179
+ """The fd our terminal is on, or None when there isn't one.
1180
+
1181
+ stdin first: it is the one a redirect is least likely to have taken away,
1182
+ and termios wants the terminal device rather than whichever stream happens
1183
+ to still point at it.
1184
+ """
1185
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
1186
+ try:
1187
+ fd = stream.fileno()
1188
+ except (AttributeError, ValueError, OSError):
1189
+ continue
1190
+ if os.isatty(fd):
1191
+ return fd
1192
+ return None
1193
+
1194
+
1195
+ @contextlib.contextmanager
1196
+ def terminal_guard():
1197
+ """Restore the terminal on the way out, however we get there.
1158
1198
 
1159
- execv (rather than subprocess.run) is what gives correct TTY handling,
1160
- Ctrl-C, and exit-code passthrough for free — same as the local agent paths.
1199
+ Two layers, because a killed program skips two different kinds of cleanup:
1200
+ termios settings (raw mode, echo) that ssh itself normally puts back, and
1201
+ the DEC private modes the *remote* program turned on, which nothing on this
1202
+ side knows about. Both are cheap no-ops when nothing was broken.
1203
+ """
1204
+ fd = terminal_fd()
1205
+ saved = None
1206
+ if fd is not None and termios is not None:
1207
+ with contextlib.suppress(termios.error, OSError):
1208
+ saved = termios.tcgetattr(fd)
1209
+ try:
1210
+ yield
1211
+ finally:
1212
+ if fd is not None:
1213
+ for stream in (sys.stdout, sys.stderr):
1214
+ with contextlib.suppress(ValueError, OSError):
1215
+ stream.flush()
1216
+ if saved is not None:
1217
+ with contextlib.suppress(termios.error, OSError):
1218
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
1219
+ with contextlib.suppress(OSError):
1220
+ os.write(fd, TERM_RESET.encode())
1221
+
1222
+
1223
+ def hand_over(argv: list[str], *, restore: bool = True) -> None:
1224
+ """Give the terminal to a child and exit with its status. Never returns.
1225
+
1226
+ Without `restore` this is a plain execv, which is the better deal when
1227
+ there is no terminal to leave in a bad state: no process in the middle, and
1228
+ TTY handling, Ctrl-C and the exit code all pass through for free.
1229
+
1230
+ With it, we stay alive as a parent whose only job is to clean up. That
1231
+ costs one process and buys the case this exists for — ssh dying on a broken
1232
+ pipe, taking a remote tmux and its agent TUI with it, with nobody left to
1233
+ turn mouse tracking back off. The child is still in our foreground process
1234
+ group, so it keeps receiving Ctrl-C and SIGWINCH from the terminal driver
1235
+ exactly as it did under execv.
1161
1236
  """
1237
+ if not restore:
1238
+ os.execv(argv[0], argv)
1239
+ else:
1240
+ with terminal_guard():
1241
+ proc = subprocess.Popen(argv)
1242
+ while True:
1243
+ try:
1244
+ code = proc.wait()
1245
+ break
1246
+ except KeyboardInterrupt:
1247
+ # The child got this same Ctrl-C from the tty and decides
1248
+ # for itself what to do with it; outliving it is the point.
1249
+ continue
1250
+ sys.exit(code)
1251
+
1252
+
1253
+ def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True) -> None:
1254
+ """Hand the terminal to ssh. Never returns."""
1162
1255
  ssh_bin = shutil.which("ssh")
1163
1256
  if not ssh_bin:
1164
1257
  die("'ssh' not found in PATH")
1165
1258
  argv = ssh_base(hc, tty=tty) + [remote_shell(remote_cmd)]
1166
- os.execv(ssh_bin, [ssh_bin] + argv[1:])
1259
+ hand_over([ssh_bin] + argv[1:], restore=tty)
1167
1260
 
1168
1261
 
1169
1262
  def attach_session(hc: dict | None, name: str) -> None:
1170
- """Attach to a tmux session, locally or over ssh. Replaces this process.
1263
+ """Attach to a tmux session, locally or over ssh. Never returns.
1171
1264
 
1172
1265
  -d detaches other clients so the pane isn't size-clamped to a stale window
1173
1266
  left open elsewhere; these are all the same user's sessions.
@@ -1176,7 +1269,8 @@ def attach_session(hc: dict | None, name: str) -> None:
1176
1269
  tmux_bin = shutil.which("tmux")
1177
1270
  if not tmux_bin:
1178
1271
  die("'tmux' not found in PATH")
1179
- os.execv(tmux_bin, [tmux_bin, "attach", "-d", "-t", name])
1272
+ hand_over([tmux_bin, "attach", "-d", "-t", name])
1273
+ return
1180
1274
  print(f"luv: attaching {name} on {hc['host']}")
1181
1275
  exec_ssh(hc, shlex.join(["tmux", "attach", "-d", "-t", name]))
1182
1276
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "luv-cli"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
@@ -122,11 +122,11 @@ class RemoteDispatchTests(unittest.TestCase):
122
122
  with (patch.object(sys, "argv", ["luv"] + argv),
123
123
  patch.dict(luv.os.environ, env, clear=False),
124
124
  patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
125
- patch.object(luv.os, "execv") as execv,
125
+ patch.object(luv, "hand_over") as hand_over,
126
126
  contextlib.redirect_stdout(io.StringIO()),
127
127
  contextlib.redirect_stderr(io.StringIO())):
128
128
  luv.main()
129
- return execv.call_args.args[1] if execv.called else None
129
+ return hand_over.call_args.args[0] if hand_over.called else None
130
130
 
131
131
  def test_new_workspace_gets_pending_session(self):
132
132
  argv = self._dispatch(["myrepo", "fix it"])
@@ -219,6 +219,129 @@ class RemoteDispatchTests(unittest.TestCase):
219
219
  self._dispatch(["--local", "-s", "gpu", "myrepo"])
220
220
 
221
221
 
222
+ class _FakeProc:
223
+ """A child that can raise KeyboardInterrupt before it finally exits."""
224
+
225
+ def __init__(self, returncode=0, interrupts=0):
226
+ self.returncode = returncode
227
+ self.interrupts = interrupts
228
+
229
+ def wait(self):
230
+ if self.interrupts:
231
+ self.interrupts -= 1
232
+ raise KeyboardInterrupt
233
+ return self.returncode
234
+
235
+
236
+ class TerminalRestoreTests(unittest.TestCase):
237
+ """A connection that dies must not leave the terminal in the remote
238
+ program's modes — that's the "35;22;1M" junk at the shell prompt."""
239
+
240
+ FD = 42
241
+ SAVED = ["saved", "termios", "attrs"]
242
+
243
+ def _hand_over(self, argv, returncode=0, interrupts=0, **kwargs):
244
+ proc = _FakeProc(returncode, interrupts)
245
+ self.writes = []
246
+ with (patch.object(luv, "terminal_fd", return_value=self.FD),
247
+ patch.object(luv.subprocess, "Popen", return_value=proc) as popen,
248
+ patch.object(luv.termios, "tcgetattr", return_value=self.SAVED),
249
+ patch.object(luv.termios, "tcsetattr") as tcsetattr,
250
+ patch.object(luv.os, "execv") as execv,
251
+ patch.object(luv.os, "write",
252
+ side_effect=lambda fd, data: self.writes.append((fd, data))),
253
+ self.assertRaises(SystemExit) as exit_ctx):
254
+ luv.hand_over(argv, **kwargs)
255
+ self.popen, self.tcsetattr, self.execv = popen, tcsetattr, execv
256
+ return exit_ctx.exception.code
257
+
258
+ def _reset_bytes(self):
259
+ return b"".join(data for fd, data in self.writes if fd == self.FD)
260
+
261
+ def test_broken_connection_still_restores_the_terminal(self):
262
+ # 255 is what ssh exits with when the connection drops under it.
263
+ code = self._hand_over(["/bin/ssh", "box", "tmux attach"], returncode=255)
264
+
265
+ self.assertEqual(code, 255, "the child's exit code must still pass through")
266
+ self.assertTrue(self.popen.called, "restore mode must not exec the child away")
267
+ self.assertIn(b"\x1b[?1003l", self._reset_bytes(), "mouse tracking left on")
268
+ self.assertIn(b"\x1b[?1006l", self._reset_bytes(), "SGR mouse reports left on")
269
+ self.assertIn(b"\x1b[?2004l", self._reset_bytes(), "bracketed paste left on")
270
+ self.assertIn(b"\x1b[?1049l", self._reset_bytes(), "alternate screen left on")
271
+ self.assertEqual(self.tcsetattr.call_args.args[0], self.FD)
272
+ self.assertEqual(self.tcsetattr.call_args.args[2], self.SAVED)
273
+
274
+ def test_clean_exit_restores_too(self):
275
+ code = self._hand_over(["/bin/tmux", "attach"], returncode=0)
276
+
277
+ self.assertEqual(code, 0)
278
+ self.assertIn(b"\x1b[?1003l", self._reset_bytes())
279
+
280
+ def test_ctrl_c_does_not_kill_the_parent_before_cleanup(self):
281
+ # Ctrl-C reaches the child through the tty; the parent must outlive it
282
+ # or there is nobody left to clean up after it.
283
+ code = self._hand_over(["/bin/ssh", "box"], returncode=130, interrupts=2)
284
+
285
+ self.assertEqual(code, 130)
286
+ self.assertIn(b"\x1b[?1003l", self._reset_bytes())
287
+
288
+ def test_no_tty_handoff_still_execs(self):
289
+ # -nit streams stream-json into a pipe: no terminal to restore, so keep
290
+ # the cheaper exec and don't leave a process in the middle.
291
+ writes = []
292
+ with (patch.object(luv, "terminal_fd", return_value=self.FD),
293
+ patch.object(luv.os, "execv") as execv,
294
+ patch.object(luv.os, "write", side_effect=writes.append),
295
+ patch.object(luv.subprocess, "Popen") as popen):
296
+ luv.hand_over(["/bin/ssh", "box"], restore=False)
297
+
298
+ self.assertEqual(execv.call_args.args, ("/bin/ssh", ["/bin/ssh", "box"]))
299
+ self.assertFalse(popen.called)
300
+ self.assertEqual(writes, [])
301
+
302
+ def test_guard_is_a_noop_without_a_terminal(self):
303
+ with (patch.object(luv, "terminal_fd", return_value=None),
304
+ patch.object(luv.os, "write") as write,
305
+ patch.object(luv.termios, "tcsetattr") as tcsetattr):
306
+ with luv.terminal_guard():
307
+ pass
308
+
309
+ self.assertFalse(write.called)
310
+ self.assertFalse(tcsetattr.called)
311
+
312
+ def test_restore_survives_a_child_that_never_started(self):
313
+ # An OSError out of Popen must not skip the cleanup either.
314
+ writes = []
315
+ with (patch.object(luv, "terminal_fd", return_value=self.FD),
316
+ patch.object(luv.termios, "tcgetattr", return_value=self.SAVED),
317
+ patch.object(luv.termios, "tcsetattr"),
318
+ patch.object(luv.os, "write",
319
+ side_effect=lambda fd, data: writes.append(data)),
320
+ patch.object(luv.subprocess, "Popen", side_effect=OSError("boom"))):
321
+ with self.assertRaises(OSError):
322
+ luv.hand_over(["/bin/ssh", "box"])
323
+
324
+ self.assertIn(b"\x1b[?1003l", b"".join(writes))
325
+
326
+ def test_local_attach_goes_through_the_guard(self):
327
+ with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
328
+ patch.object(luv, "hand_over") as hand_over):
329
+ luv.attach_session(None, "luv-myrepo-42")
330
+
331
+ self.assertEqual(hand_over.call_args.args[0],
332
+ ["/bin/tmux", "attach", "-d", "-t", "luv-myrepo-42"])
333
+
334
+ def test_remote_attach_asks_for_a_tty_and_restores(self):
335
+ with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
336
+ patch.object(luv, "hand_over") as hand_over,
337
+ contextlib.redirect_stdout(io.StringIO())):
338
+ luv.attach_session({"host": "box"}, "luv-myrepo-42")
339
+
340
+ argv = hand_over.call_args.args[0]
341
+ self.assertIn("-t", argv)
342
+ self.assertNotEqual(hand_over.call_args.kwargs.get("restore"), False)
343
+
344
+
222
345
  class SessionNameTests(unittest.TestCase):
223
346
  def test_illegal_tmux_characters_are_replaced(self):
224
347
  self.assertEqual(luv.tmux_session_name("foo.js-7"), "luv-foo_js-7")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes