luv-cli 0.6.0__tar.gz → 0.6.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.6.0
3
+ Version: 0.6.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
@@ -265,6 +265,12 @@ luv: session ended unexpectedly — continue it with:
265
265
  luv continue myrepo 42
266
266
  ```
267
267
 
268
+ The number is there even when you never typed one: `luv myrepo "…"` doesn't
269
+ know which workspace it will get — the remote reads that off `gh api` — so the
270
+ line is worked out at the point it is printed, by asking the session which
271
+ folder it ended up in. Without it the hint would name the repo alone, and that
272
+ attaches to whichever of its sessions is newest, which may not be this one.
273
+
268
274
  Copy that line and run it. If the agent took the tmux session down with it,
269
275
  there is nothing left to attach to; `luv continue` says so and hands you
270
276
  `luv myrepo 42 -r`, which reopens the workspace and resumes the conversation.
@@ -13,6 +13,7 @@ import sys
13
13
  import tempfile
14
14
  import threading
15
15
  import time
16
+ from collections.abc import Callable
16
17
  from pathlib import Path
17
18
 
18
19
  try:
@@ -1680,6 +1681,19 @@ def continue_hint(repo: str | None, workspace: str | None = None) -> str:
1680
1681
  return " ".join(parts)
1681
1682
 
1682
1683
 
1684
+ def dispatched_workspace(hc: dict, sid: str) -> str | None:
1685
+ """The workspace a dispatched session landed in, asked of the host.
1686
+
1687
+ Dispatch only knows the folder name when the number was on the command
1688
+ line; otherwise the remote reads it off gh api and stamps it onto its own
1689
+ tmux session, and asking is the only way back to it from here.
1690
+ """
1691
+ for row in query_tmux(hc) or []:
1692
+ if row["id"] == sid:
1693
+ return row["workspace"] or None
1694
+ return None
1695
+
1696
+
1683
1697
  def reopen_hint(args: list[str]) -> str:
1684
1698
  """The 'luv <repo> [n] -r' line for a session whose tmux is already gone.
1685
1699
 
@@ -1692,7 +1706,7 @@ def reopen_hint(args: list[str]) -> str:
1692
1706
 
1693
1707
 
1694
1708
  def hand_over(argv: list[str], *, restore: bool = True, watch=None,
1695
- hint: str | None = None) -> None:
1709
+ hint: str | Callable[[], str] | None = None) -> None:
1696
1710
  """Give the terminal to a child and exit with its status. Never returns.
1697
1711
 
1698
1712
  Without `restore` this is a plain execv, which is the better deal when
@@ -1716,7 +1730,9 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None,
1716
1730
  is either a detach or the agent finishing, and neither wants advice; a bad
1717
1731
  one is usually ssh losing the connection out from under a session that is
1718
1732
  still running on the other side, and the terminal it lands back in should
1719
- not have to be told twice how to get there.
1733
+ not have to be told twice how to get there. It may be a callable, for the
1734
+ hint that cannot be written until the session it describes has run: only
1735
+ the bad exit pays for working it out, and only once.
1720
1736
  """
1721
1737
  if not restore:
1722
1738
  os.execv(argv[0], argv)
@@ -1734,15 +1750,16 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None,
1734
1750
  # for itself what to do with it; outliving it is the point.
1735
1751
  continue
1736
1752
  if code != 0 and hint:
1753
+ line = hint() if callable(hint) else hint
1737
1754
  # After the guard, not inside it: a terminal still in the remote
1738
1755
  # program's modes is no place to print something to be copied.
1739
1756
  print(f"\nluv: session ended unexpectedly — continue it with:\n\n"
1740
- f" {hint}\n", file=sys.stderr)
1757
+ f" {line}\n", file=sys.stderr)
1741
1758
  sys.exit(code)
1742
1759
 
1743
1760
 
1744
1761
  def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None,
1745
- hint: str | None = None) -> None:
1762
+ hint: str | Callable[[], str] | None = None) -> None:
1746
1763
  """Hand the terminal to ssh. Never returns."""
1747
1764
  ssh_bin = shutil.which("ssh")
1748
1765
  if not ssh_bin:
@@ -1751,7 +1768,8 @@ def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None,
1751
1768
  hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch, hint=hint)
1752
1769
 
1753
1770
 
1754
- def attach_session(hc: dict | None, name: str, *, hint: str | None = None) -> None:
1771
+ def attach_session(hc: dict | None, name: str, *,
1772
+ hint: str | Callable[[], str] | None = None) -> None:
1755
1773
  """Attach to a tmux session, locally or over ssh. Never returns.
1756
1774
 
1757
1775
  -d detaches other clients so the pane isn't size-clamped to a stale window
@@ -1879,7 +1897,8 @@ def dispatch_remote(hc: dict, remote_args: list[str], *, workspace: str | None =
1879
1897
 
1880
1898
  # Only a tmux session is there to come back to; -nit and --clean run to
1881
1899
  # completion over ssh and have nothing to continue.
1882
- hint = continue_hint((meta or {}).get("repo"), workspace) if use_tmux else None
1900
+ repo = (meta or {}).get("repo")
1901
+ hint = continue_hint(repo, workspace) if use_tmux else None
1883
1902
  print(f"luv: {hc['host']} — {session or 'no tmux'}")
1884
1903
  if detach:
1885
1904
  r = run(ssh_base(hc, batch=True) + [remote_shell(cmd)])
@@ -1887,6 +1906,16 @@ def dispatch_remote(hc: dict, remote_args: list[str], *, workspace: str | None =
1887
1906
  die(f"could not start {session} on {hc['host']}: {r.stderr.strip()}")
1888
1907
  print(f"luv: started detached — attach with: {hint or 'luv continue'}")
1889
1908
  return
1909
+ def late_hint() -> str:
1910
+ """The same hint, worked out after the session rather than before it."""
1911
+ return continue_hint(repo, dispatched_workspace(hc, sid))
1912
+
1913
+ # A dispatch without a number is the ordinary case, and the repo on its own
1914
+ # points at whichever of its sessions is newest — which by then may not be
1915
+ # this one. Deferring costs a round trip only when a session breaks, and by
1916
+ # then the remote has long since picked its number and stamped it on tmux.
1917
+ if use_tmux and repo and not workspace:
1918
+ hint = late_hint
1890
1919
  exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session),
1891
1920
  hint=hint)
1892
1921
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "luv-cli"
7
- version = "0.6.0"
7
+ version = "0.6.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"
@@ -479,6 +479,54 @@ class ContinueHintTests(unittest.TestCase):
479
479
  self.assertEqual(hand_over.call_args.kwargs.get("hint"),
480
480
  "luv continue myrepo 42")
481
481
 
482
+ def _dispatch_hint(self, tmux_rows, **kwargs):
483
+ """The hint a numberless dispatch hands back, against a host that
484
+ answers with `tmux_rows`."""
485
+ with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
486
+ patch.object(luv, "record_session"),
487
+ patch.object(luv, "port_watch", return_value=None),
488
+ patch.object(luv, "query_tmux", return_value=tmux_rows) as query,
489
+ patch.object(luv, "hand_over") as hand_over,
490
+ contextlib.redirect_stdout(io.StringIO())):
491
+ luv.dispatch_remote({"host": "box"}, ["myrepo"],
492
+ meta={"repo": "myrepo", "org": "acme"}, **kwargs)
493
+ hint = hand_over.call_args.kwargs.get("hint")
494
+ # Nothing is asked of the host until the hint is actually needed.
495
+ self.assertFalse(query.called)
496
+ return hint() if callable(hint) else hint
497
+
498
+ def test_a_session_dispatched_without_a_number_finds_its_own(self):
499
+ # The number comes off gh api on the remote, so the only way to name it
500
+ # is to ask the session that picked it — once, and only if it breaks.
501
+ rows = [{"id": "other123", "session": "luv-myrepo-box-7",
502
+ "workspace": "myrepo-box-7", "attached": False, "activity": 0}]
503
+ with patch.object(luv, "new_session_id", return_value="abc12345"):
504
+ rows.append({"id": "abc12345", "session": "luv-myrepo-box-42",
505
+ "workspace": "myrepo-box-42", "attached": True,
506
+ "activity": 0})
507
+ hint = self._dispatch_hint(rows)
508
+
509
+ self.assertEqual(hint, "luv continue myrepo 42")
510
+
511
+ def test_an_unreachable_host_still_hands_back_a_runnable_line(self):
512
+ # query_tmux returns None when the host never answered — which is the
513
+ # usual reason a session broke in the first place.
514
+ self.assertEqual(self._dispatch_hint(None), "luv continue myrepo")
515
+
516
+ def test_a_detached_start_does_not_wait_on_a_number(self):
517
+ # Nothing has been cloned yet, so there is no number to find; the line
518
+ # printed here has to be one, not a deferred callable.
519
+ with (patch.object(luv, "run", return_value=_FakeProc(0)),
520
+ patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
521
+ patch.object(luv, "record_session"),
522
+ patch.object(luv, "query_tmux") as query,
523
+ contextlib.redirect_stdout(io.StringIO()) as out):
524
+ luv.dispatch_remote({"host": "box"}, ["myrepo"], detach=True,
525
+ meta={"repo": "myrepo", "org": "acme"})
526
+
527
+ self.assertFalse(query.called)
528
+ self.assertIn("attach with: luv continue myrepo", out.getvalue())
529
+
482
530
  def test_a_run_with_no_session_behind_it_gets_no_hint(self):
483
531
  # -nit streams to a pipe and exits; there is nothing to continue.
484
532
  with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes