Skip to content

Commit 919a5e3

Browse files
NiveditJainclaude
andauthored
[luv-legion-43] Hand back the exact 'luv continue' when a session breaks (#43)
* Hand back the exact 'luv continue' when a session breaks A dropped connection already left the terminal usable; it still left you working out which of several live sessions you had just lost, and with what arguments. luv knows both, so it now prints the command — repo and number filled in — on its way out, on a line of its own to be copied and run. Only on a bad exit: a clean one is a detach or the agent finishing, and neither wants advice. The detached-start notices print the same command instead of a bare 'luv continue'. Where a hint can't be complete it gets shorter rather than wrong — a repo alone takes its newest session — and where the agent took the tmux session down with it, 'luv continue' now passes you on to 'luv <repo> <n> -r' rather than stopping at "no live sessions". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCiSTa4QuwBDrecuSUTBZ3 * Bump to 0.5.1 Same class of change as 0.2.1: polish on the dropped-connection path rather than a new subsystem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCiSTa4QuwBDrecuSUTBZ3 * Remind on commit when the branch's version still matches main Releases here are cut from the version in pyproject.toml: a push to main whose version has no tag yet publishes itself. A branch that changes the tool and forgets the bump therefore merges into a main that publishes nothing, and the change sits unreleased until somebody notices. A failproofai convention policy — .failproofai/policies/ is auto-loaded, no install step, so everyone working in the repo gets it. It instructs rather than blocks, fires only while the working tree's version equals the base branch's, and goes quiet the moment it is bumped. Never on main, never on a repo with no manifest or no base ref, and every git call fails open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCiSTa4QuwBDrecuSUTBZ3 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 2826f77 commit 919a5e3

5 files changed

Lines changed: 325 additions & 15 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* version-policies.mjs — remind the agent to bump the release version.
3+
*
4+
* Auto-loaded by failproofai from .failproofai/policies/ — no install step.
5+
*
6+
* Publishing here is driven by the version in the manifest: a push to main
7+
* whose version has no tag yet releases itself (.github/workflows/publish.yml).
8+
* So a branch that changes the tool and forgets the version merges into a main
9+
* that publishes nothing, and the change sits unreleased until someone notices.
10+
*
11+
* So this says so on `git commit`, and only while the branch's version still
12+
* matches the base branch's — it is context, not a block, and bumping the
13+
* version is what turns it off for the rest of the branch.
14+
*/
15+
import { execFileSync } from "node:child_process";
16+
import { existsSync, readFileSync } from "node:fs";
17+
import { join } from "node:path";
18+
19+
import { customPolicies, allow, instruct } from "failproofai";
20+
21+
// First match wins, so the order is "most likely to be the release manifest".
22+
const MANIFESTS = [
23+
{ file: "pyproject.toml", re: /^\s*version\s*=\s*["']([^"']+)["']/m },
24+
{ file: "package.json", re: /"version"\s*:\s*"([^"]+)"/ },
25+
{ file: "Cargo.toml", re: /^\s*version\s*=\s*["']([^"']+)["']/m },
26+
];
27+
28+
const BASE_REFS = ["origin/main", "main", "origin/master", "master"];
29+
30+
// A `git … commit` that starts a command, so a shell segment is allowed to
31+
// carry flags and their values (`git -C dir commit`) but a `commit` merely
32+
// spoken about (`echo "run git commit later"`) is not mistaken for one.
33+
const GIT_COMMIT = /(?:^|[;&|\n(]|&&|\|\|)\s*(?:\w+=\S+\s+)*git\b[^;&|\n]*?\bcommit\b/;
34+
35+
/** git, or null on any failure — this policy never gets in the way. */
36+
function git(cwd, args) {
37+
try {
38+
return execFileSync("git", args, {
39+
cwd,
40+
encoding: "utf8",
41+
stdio: ["ignore", "pipe", "ignore"],
42+
timeout: 5000,
43+
}).trim();
44+
} catch {
45+
return null;
46+
}
47+
}
48+
49+
function versionIn(text, re) {
50+
const m = text == null ? null : text.match(re);
51+
return m ? m[1] : null;
52+
}
53+
54+
/** The manifest this repo releases from, with the version on both sides. */
55+
function releaseVersions(cwd) {
56+
const base = BASE_REFS.find((ref) =>
57+
git(cwd, ["rev-parse", "--verify", "--quiet", ref]) !== null);
58+
if (!base) return null;
59+
60+
for (const { file, re } of MANIFESTS) {
61+
if (!existsSync(join(cwd, file))) continue;
62+
let head;
63+
try {
64+
head = versionIn(readFileSync(join(cwd, file), "utf8"), re);
65+
} catch {
66+
return null;
67+
}
68+
// No version on the base side means the manifest is new on this branch;
69+
// there is nothing to bump relative to.
70+
const baseVersion = versionIn(git(cwd, ["show", `${base}:${file}`]), re);
71+
if (!head || !baseVersion) return null;
72+
return { file, base, head, baseVersion };
73+
}
74+
return null;
75+
}
76+
77+
customPolicies.add({
78+
name: "remind-version-bump-on-commit",
79+
description: "On git commit, remind that the release version is still the base branch's",
80+
match: { events: ["PreToolUse"] },
81+
fn: async (ctx) => {
82+
if (ctx.toolName !== "Bash") return allow();
83+
const cmd = String(ctx.toolInput?.command ?? "");
84+
if (!GIT_COMMIT.test(cmd)) return allow();
85+
86+
const cwd = ctx.session?.cwd ?? process.cwd();
87+
88+
// Committing on the base branch is a different problem, and one the
89+
// block-work-on-main policy already has an opinion about.
90+
const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
91+
if (branch === null || branch === "main" || branch === "master") return allow();
92+
93+
const found = releaseVersions(cwd);
94+
if (!found) return allow();
95+
if (found.head !== found.baseVersion) return allow(); // already bumped
96+
97+
return instruct(
98+
`${found.file} still says version ${found.head}, the same as ${found.base}. ` +
99+
"Releases are cut from that version when this branch lands, so a merge " +
100+
"with it unchanged publishes nothing. Bump it in this branch — patch for " +
101+
"a fix or polish, minor for a feature — unless the change is docs, tests, " +
102+
"or CI only, in which case say so and carry on."
103+
);
104+
},
105+
});
106+
107+
export { customPolicies };

docs/remote-sessions.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,19 @@ luv -i ~/.ssh/other_key myrepo # use a different key this once
256256
Detach from a session with `Ctrl-b d` (tmux's default prefix). The agent keeps
257257
running.
258258

259+
When a session ends on its own — the connection drops, ssh is killed, the pane
260+
dies — luv prints the way back in, already filled in for that session:
261+
262+
```
263+
luv: session ended unexpectedly — continue it with:
264+
265+
luv continue myrepo 42
266+
```
267+
268+
Copy that line and run it. If the agent took the tmux session down with it,
269+
there is nothing left to attach to; `luv continue` says so and hands you
270+
`luv myrepo 42 -r`, which reopens the workspace and resumes the conversation.
271+
259272
## Reaching servers the agent started
260273

261274
An agent working on the remote starts servers there — a dev server, a compose
@@ -409,7 +422,7 @@ machine with `--from` and luv will move the folder and start it fresh.
409422
| Step | What happens |
410423
|---|---|
411424
| `luv myrepo "…"` | Laptop records a registry entry, opens SSH, creates the tmux session, remote luv clones and launches the agent |
412-
| `Ctrl-b d` or lost connection | tmux session keeps running; agent unaffected; Docker containers stay up |
425+
| `Ctrl-b d` or lost connection | tmux session keeps running; agent unaffected; Docker containers stay up. A connection that broke leaves the exact `luv continue <repo> <n>` for it in your terminal |
413426
| `luv ls` | Laptop queries every known host's tmux and refreshes the registry, adopting sessions another machine started |
414427
| `luv continue` | Reattaches; other clients are detached so the pane isn't size-clamped |
415428
| Agent exits | The pane's command ends, tmux session disappears, Docker environment is torn down, `luv ls` prunes the entry on its next run |

luv/__init__.py

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,7 +1585,36 @@ def terminal_guard():
15851585
os.write(fd, TERM_RESET.encode())
15861586

15871587

1588-
def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
1588+
def continue_hint(repo: str | None, workspace: str | None = None) -> str:
1589+
"""The 'luv continue …' line to hand back when a session breaks.
1590+
1591+
As narrow as what we actually know, and no narrower: the number is read off
1592+
the workspace folder, which a session dispatched before the remote picked
1593+
one doesn't have yet. Every shorter form is still a command that works —
1594+
a repo alone takes its newest session, and bare 'luv continue' asks.
1595+
"""
1596+
parts = ["luv", "continue"]
1597+
if repo:
1598+
parts.append(repo)
1599+
number = workspace_number(repo, workspace or "")
1600+
if number is not None:
1601+
parts.append(str(number))
1602+
return " ".join(parts)
1603+
1604+
1605+
def reopen_hint(args: list[str]) -> str:
1606+
"""The 'luv <repo> [n] -r' line for a session whose tmux is already gone.
1607+
1608+
Takes the same [<repo> [number]] grammar 'luv continue' does, so whatever
1609+
was just typed carries straight over to the command that still works.
1610+
"""
1611+
repo = args[0].rstrip("/").rsplit("/", 1)[-1]
1612+
number = args[1] if len(args) > 1 and args[1].isdigit() else None
1613+
return " ".join(["luv", repo] + ([number] if number else []) + ["-r"])
1614+
1615+
1616+
def hand_over(argv: list[str], *, restore: bool = True, watch=None,
1617+
hint: str | None = None) -> None:
15891618
"""Give the terminal to a child and exit with its status. Never returns.
15901619
15911620
Without `restore` this is a plain execv, which is the better deal when
@@ -1604,6 +1633,12 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
16041633
the agent starts ten minutes in. It runs on a daemon thread and touches
16051634
neither the terminal nor termios — this path is the one that has to stay
16061635
boring.
1636+
1637+
`hint` is the way back in, printed when the child exits badly. A clean exit
1638+
is either a detach or the agent finishing, and neither wants advice; a bad
1639+
one is usually ssh losing the connection out from under a session that is
1640+
still running on the other side, and the terminal it lands back in should
1641+
not have to be told twice how to get there.
16071642
"""
16081643
if not restore:
16091644
os.execv(argv[0], argv)
@@ -1620,19 +1655,25 @@ def hand_over(argv: list[str], *, restore: bool = True, watch=None) -> None:
16201655
# The child got this same Ctrl-C from the tty and decides
16211656
# for itself what to do with it; outliving it is the point.
16221657
continue
1658+
if code != 0 and hint:
1659+
# After the guard, not inside it: a terminal still in the remote
1660+
# program's modes is no place to print something to be copied.
1661+
print(f"\nluv: session ended unexpectedly — continue it with:\n\n"
1662+
f" {hint}\n", file=sys.stderr)
16231663
sys.exit(code)
16241664

16251665

1626-
def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None) -> None:
1666+
def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True, watch=None,
1667+
hint: str | None = None) -> None:
16271668
"""Hand the terminal to ssh. Never returns."""
16281669
ssh_bin = shutil.which("ssh")
16291670
if not ssh_bin:
16301671
die("'ssh' not found in PATH")
16311672
argv = ssh_base(hc, tty=tty) + [remote_shell(remote_cmd)]
1632-
hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch)
1673+
hand_over([ssh_bin] + argv[1:], restore=tty, watch=watch, hint=hint)
16331674

16341675

1635-
def attach_session(hc: dict | None, name: str) -> None:
1676+
def attach_session(hc: dict | None, name: str, *, hint: str | None = None) -> None:
16361677
"""Attach to a tmux session, locally or over ssh. Never returns.
16371678
16381679
-d detaches other clients so the pane isn't size-clamped to a stale window
@@ -1642,11 +1683,11 @@ def attach_session(hc: dict | None, name: str) -> None:
16421683
tmux_bin = shutil.which("tmux")
16431684
if not tmux_bin:
16441685
die("'tmux' not found in PATH")
1645-
hand_over([tmux_bin, "attach", "-d", "-t", name])
1686+
hand_over([tmux_bin, "attach", "-d", "-t", name], hint=hint)
16461687
return
16471688
print(f"luv: attaching {name} on {hc['host']}")
16481689
exec_ssh(hc, shlex.join(["tmux", "attach", "-d", "-t", name]),
1649-
watch=port_watch(hc, session=name))
1690+
watch=port_watch(hc, session=name), hint=hint)
16501691

16511692

16521693
def remote_prompt(args: list[str]) -> str | None:
@@ -1758,14 +1799,18 @@ def dispatch_remote(hc: dict, remote_args: list[str], *, workspace: str | None =
17581799
record_session({**meta, "id": sid, "host": hc["host"], "session": session,
17591800
"workspace": workspace, "created": int(time.time())})
17601801

1802+
# Only a tmux session is there to come back to; -nit and --clean run to
1803+
# completion over ssh and have nothing to continue.
1804+
hint = continue_hint((meta or {}).get("repo"), workspace) if use_tmux else None
17611805
print(f"luv: {hc['host']}{session or 'no tmux'}")
17621806
if detach:
17631807
r = run(ssh_base(hc, batch=True) + [remote_shell(cmd)])
17641808
if r.returncode != 0:
17651809
die(f"could not start {session} on {hc['host']}: {r.stderr.strip()}")
1766-
print(f"luv: started detached — 'luv continue' to attach")
1810+
print(f"luv: started detached — attach with: {hint or 'luv continue'}")
17671811
return
1768-
exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session))
1812+
exec_ssh(hc, cmd, tty=tty, watch=port_watch(hc, sid=sid, session=session),
1813+
hint=hint)
17691814

17701815

17711816
def cmd_paths() -> None:
@@ -3145,14 +3190,21 @@ def cmd_continue(args: list[str], identity: str | None = None) -> None:
31453190
live, label = filter_sessions([s for s in sessions if s.get("live")], args)
31463191

31473192
if not live:
3148-
die(f"no live luv sessions{label}")
3193+
# This is where a hint printed after a crash lands when the agent took
3194+
# the tmux session down with it, so send it on rather than stopping at
3195+
# the bad news: the workspace outlives the session, and -r reopens it.
3196+
named = bool(args) and not args[0].startswith("-")
3197+
die(f"no live luv sessions{label}"
3198+
+ (f" — if its workspace is still there: {reopen_hint(args)}"
3199+
if named else ""))
31493200
live.sort(key=session_sort_key, reverse=True)
31503201

31513202
# An explicit repo means "the newest one for it".
31523203
target = live[0] if (len(live) == 1 or args) else choose_session(live)
31533204

31543205
host = target.get("host")
3155-
attach_session(resolve_host(host, identity) if host else None, target["session"])
3206+
attach_session(resolve_host(host, identity) if host else None, target["session"],
3207+
hint=continue_hint(target.get("repo"), target.get("workspace")))
31563208

31573209

31583210
def start_local_session(workspace: str, luv_args: list[str], meta: dict,
@@ -3170,13 +3222,14 @@ def start_local_session(workspace: str, luv_args: list[str], meta: dict,
31703222
shutil.which("luv") or "luv"] + luv_args
31713223
argv = [tmux_bin, "new-session"] + ([] if attach else ["-d"]) + \
31723224
["-A", "-s", session, "--"] + inner
3225+
hint = continue_hint(meta.get("repo"), workspace)
31733226
print(f"luv: local — {session}")
31743227
if attach:
3175-
os.execv(tmux_bin, argv)
3228+
hand_over(argv, hint=hint)
31763229
r = subprocess.run(argv)
31773230
if r.returncode != 0:
31783231
die(f"could not start {session}")
3179-
print("luv: started detached — 'luv continue' to attach")
3232+
print(f"luv: started detached — attach with: {hint}")
31803233

31813234

31823235
def workspace_origin(hc: dict | None, ws: Path) -> tuple[str, str] | None:
@@ -3471,7 +3524,8 @@ def take_value(flag: str, what: str) -> str | None:
34713524
Once 'luv config' has a remote host, every workspace command runs there inside
34723525
a tmux session that survives disconnects. 'luv ls' shows what is running on
34733526
every host — including sessions started from another machine — and
3474-
'luv continue' reattaches. Use --local for a one-off local run.
3527+
'luv continue' reattaches. A session that ends badly prints the exact
3528+
'luv continue <repo> <n>' for itself. Use --local for a one-off local run.
34753529
'luv handover' moves a running session — workspace, uncommitted work, and the
34763530
agent's conversation — to another machine, then resumes it there.
34773531
Requires luv, tmux, gh and git on the remote. See docs/remote-sessions.md.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "luv-cli"
7-
version = "0.5.0"
7+
version = "0.5.1"
88
description = "Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments"
99
requires-python = ">=3.10"
1010
license = "MIT"

0 commit comments

Comments
 (0)