luv-cli 0.6.0__tar.gz → 0.6.2__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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: luv-cli
3
- Version: 0.6.0
3
+ Version: 0.6.2
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
@@ -89,7 +89,7 @@ luv continue
89
89
  1. Clones the repo into `~/prs/{repo}-{machine}-{number}/`
90
90
  2. Creates a new branch `luv-{machine}-{number}`
91
91
  3. Configures the selected agent with the workspace's PR conventions
92
- 4. Launches Claude with Opus 5 at max effort, or Codex in YOLO mode
92
+ 4. Launches Claude with Opus 5.5 at max effort, or Codex in YOLO mode
93
93
 
94
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
95
 
@@ -149,7 +149,7 @@ also keeps reusing its workspace — it names a workspace, where a URL names a P
149
149
  | `-r` | Resume: resume the selected agent's last session |
150
150
  | `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
151
151
  | `-nit` | Non-interactive: run the selected agent and exit (no REPL); Claude streams `stream-json` events to stdout |
152
- | `-m MODEL` | Model to use; Claude defaults to `claude-opus-5`, while Codex uses its configured CLI default |
152
+ | `-m MODEL` | Model to use; Claude defaults to `claude-opus-5-5`, while Codex uses its configured CLI default |
153
153
  | `-b BRANCH` | Base a new workspace off `BRANCH` (clone + branch from it); recorded in `git config luv.base` so the PR can target it |
154
154
  | `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
155
155
  | `-s HOST` | Run on `HOST` over SSH, overriding the configured remote host |
@@ -66,7 +66,7 @@ luv continue
66
66
  1. Clones the repo into `~/prs/{repo}-{machine}-{number}/`
67
67
  2. Creates a new branch `luv-{machine}-{number}`
68
68
  3. Configures the selected agent with the workspace's PR conventions
69
- 4. Launches Claude with Opus 5 at max effort, or Codex in YOLO mode
69
+ 4. Launches Claude with Opus 5.5 at max effort, or Codex in YOLO mode
70
70
 
71
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
72
 
@@ -126,7 +126,7 @@ also keeps reusing its workspace — it names a workspace, where a URL names a P
126
126
  | `-r` | Resume: resume the selected agent's last session |
127
127
  | `-p` | Launch Claude in plan permission mode (default: `bypassPermissions`) |
128
128
  | `-nit` | Non-interactive: run the selected agent and exit (no REPL); Claude streams `stream-json` events to stdout |
129
- | `-m MODEL` | Model to use; Claude defaults to `claude-opus-5`, while Codex uses its configured CLI default |
129
+ | `-m MODEL` | Model to use; Claude defaults to `claude-opus-5-5`, while Codex uses its configured CLI default |
130
130
  | `-b BRANCH` | Base a new workspace off `BRANCH` (clone + branch from it); recorded in `git config luv.base` so the PR can target it |
131
131
  | `-e` | Env: pass `LUV_*` environment variables (with prefix stripped) into the session |
132
132
  | `-s HOST` | Run on `HOST` over SSH, overriding the configured remote host |
@@ -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:
@@ -1203,7 +1204,7 @@ def resume(clone_dir: Path, extra_env: dict[str, str] | None = None,
1203
1204
 
1204
1205
  if agent == "claude":
1205
1206
  agent_cmd = ["claude", "--dangerously-skip-permissions",
1206
- "--model", model or "claude-opus-5",
1207
+ "--model", model or "claude-opus-5-5",
1207
1208
  "--effort", "max", "--resume",
1208
1209
  "--remote-control",
1209
1210
  "--remote-control-session-name-prefix", clone_dir.name]
@@ -1259,7 +1260,7 @@ def launch(clone_dir: Path, prompt: str | None, plan_mode: bool = False,
1259
1260
  agent_cmd.append(prompt)
1260
1261
  else:
1261
1262
  common_flags = ["--dangerously-skip-permissions",
1262
- "--model", model or "claude-opus-5",
1263
+ "--model", model or "claude-opus-5-5",
1263
1264
  "--effort", "max",
1264
1265
  "--remote-control",
1265
1266
  "--remote-control-session-name-prefix", clone_dir.name]
@@ -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
 
@@ -3557,7 +3586,7 @@ Flags:
3557
3586
  -r resume: resume the selected agent's last session
3558
3587
  -p launch Claude in plan permission mode (default: bypassPermissions)
3559
3588
  -nit non-interactive: run the selected agent and exit (no REPL)
3560
- -m MODEL model to use (Claude default: claude-opus-5; Codex: CLI default)
3589
+ -m MODEL model to use (Claude default: claude-opus-5-5; Codex: CLI default)
3561
3590
  -b BRANCH base a new workspace off BRANCH (clone + branch from it; recorded in git config luv.base)
3562
3591
  -e env: pass LUV_* environment variables (with prefix stripped) into the session
3563
3592
  -s HOST run on HOST over SSH (overrides the configured remote host)
@@ -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.2"
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"
@@ -66,7 +66,7 @@ class AgentLaunchTests(unittest.TestCase):
66
66
  _, argv = self._exec_args(luv.launch, self.clone_dir, None)
67
67
 
68
68
  self.assertEqual(argv[0], "/bin/claude")
69
- self.assertIn("claude-opus-5", argv)
69
+ self.assertIn("claude-opus-5-5", argv)
70
70
  self.assertIn("--dangerously-skip-permissions", argv)
71
71
 
72
72
  def test_main_routes_codex_to_existing_workspace(self):
@@ -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