luv-cli 0.4.0__tar.gz → 0.5.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.
- luv_cli-0.5.1/.failproofai/policies/version-policies.mjs +107 -0
- luv_cli-0.5.1/.github/workflows/publish.yml +117 -0
- {luv_cli-0.4.0 → luv_cli-0.5.1}/PKG-INFO +18 -2
- {luv_cli-0.4.0 → luv_cli-0.5.1}/README.md +17 -1
- {luv_cli-0.4.0 → luv_cli-0.5.1}/docs/configuration.md +24 -0
- {luv_cli-0.4.0 → luv_cli-0.5.1}/docs/remote-sessions.md +93 -1
- {luv_cli-0.4.0 → luv_cli-0.5.1}/docs/sessions.md +7 -1
- {luv_cli-0.4.0 → luv_cli-0.5.1}/luv/__init__.py +932 -17
- {luv_cli-0.4.0 → luv_cli-0.5.1}/pyproject.toml +1 -1
- {luv_cli-0.4.0 → luv_cli-0.5.1}/tests/test_agents.py +581 -0
- luv_cli-0.4.0/.github/workflows/publish.yml +0 -29
- {luv_cli-0.4.0 → luv_cli-0.5.1}/.claude/settings.json +0 -0
- {luv_cli-0.4.0 → luv_cli-0.5.1}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.4.0 → luv_cli-0.5.1}/.gitignore +0 -0
- {luv_cli-0.4.0 → luv_cli-0.5.1}/LICENSE +0 -0
- {luv_cli-0.4.0 → luv_cli-0.5.1}/claude-output.txt +0 -0
- {luv_cli-0.4.0 → luv_cli-0.5.1}/tests/__init__.py +0 -0
|
@@ -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 };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Two ways in.
|
|
4
|
+
#
|
|
5
|
+
# * A push to main publishes when the version in pyproject.toml is one no tag
|
|
6
|
+
# exists for yet. That is what makes a merged version bump release itself.
|
|
7
|
+
# * A v* tag publishes whatever it points at, kept so a release can still be
|
|
8
|
+
# cut by hand.
|
|
9
|
+
#
|
|
10
|
+
# The tag is created here rather than by a separate workflow because a tag
|
|
11
|
+
# pushed with GITHUB_TOKEN does not trigger other workflows — GitHub's recursion
|
|
12
|
+
# guard. Tagging elsewhere and relying on the v* trigger to fire would leave the
|
|
13
|
+
# tag in place and nothing published, which is the worst of both.
|
|
14
|
+
#
|
|
15
|
+
# Deliberately no `paths:` filter. It would be evaluated for tag pushes as well,
|
|
16
|
+
# so `paths: [pyproject.toml]` would silently drop a hand-pushed tag whose commit
|
|
17
|
+
# did not happen to touch that file. The "is this already tagged" check below is
|
|
18
|
+
# the real guard; a filter would only be an optimisation, and a lossy one.
|
|
19
|
+
|
|
20
|
+
on:
|
|
21
|
+
push:
|
|
22
|
+
tags:
|
|
23
|
+
- "v*"
|
|
24
|
+
branches:
|
|
25
|
+
- main
|
|
26
|
+
|
|
27
|
+
concurrency:
|
|
28
|
+
group: publish
|
|
29
|
+
cancel-in-progress: false
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
decide:
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
permissions:
|
|
35
|
+
contents: read
|
|
36
|
+
outputs:
|
|
37
|
+
publish: ${{ steps.resolve.outputs.publish }}
|
|
38
|
+
version: ${{ steps.resolve.outputs.version }}
|
|
39
|
+
needs_tag: ${{ steps.resolve.outputs.needs_tag }}
|
|
40
|
+
steps:
|
|
41
|
+
# Its own job so the pypi environment — which may require a reviewer — is
|
|
42
|
+
# never entered by a run that turns out to have nothing to publish.
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
with:
|
|
45
|
+
fetch-depth: 0 # tags, so the guard below can see them
|
|
46
|
+
|
|
47
|
+
- uses: actions/setup-python@v5
|
|
48
|
+
with:
|
|
49
|
+
python-version: "3.12"
|
|
50
|
+
|
|
51
|
+
- name: Work out whether there is a release to make
|
|
52
|
+
id: resolve
|
|
53
|
+
run: |
|
|
54
|
+
set -euo pipefail
|
|
55
|
+
|
|
56
|
+
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
|
57
|
+
version="${GITHUB_REF_NAME#v}"
|
|
58
|
+
needs_tag=false
|
|
59
|
+
else
|
|
60
|
+
version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"
|
|
61
|
+
needs_tag=true
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
if [ -z "${version}" ]; then
|
|
65
|
+
echo "could not determine a version to publish" >&2
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
if [ "${needs_tag}" = true ] && git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then
|
|
70
|
+
echo "v${version} is already tagged — nothing to release"
|
|
71
|
+
echo "publish=false" >> "$GITHUB_OUTPUT"
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
echo "releasing ${version}"
|
|
76
|
+
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
|
77
|
+
echo "needs_tag=${needs_tag}" >> "$GITHUB_OUTPUT"
|
|
78
|
+
echo "publish=true" >> "$GITHUB_OUTPUT"
|
|
79
|
+
|
|
80
|
+
publish:
|
|
81
|
+
needs: decide
|
|
82
|
+
if: needs.decide.outputs.publish == 'true'
|
|
83
|
+
runs-on: ubuntu-latest
|
|
84
|
+
environment: pypi
|
|
85
|
+
permissions:
|
|
86
|
+
contents: write # push the tag a merged version bump implies
|
|
87
|
+
id-token: write # PyPI trusted publishing
|
|
88
|
+
steps:
|
|
89
|
+
- uses: actions/checkout@v4
|
|
90
|
+
with:
|
|
91
|
+
fetch-depth: 0
|
|
92
|
+
|
|
93
|
+
- uses: actions/setup-python@v5
|
|
94
|
+
with:
|
|
95
|
+
python-version: "3.12"
|
|
96
|
+
|
|
97
|
+
# Before the build, so a tag that cannot be pushed — protected tags, say —
|
|
98
|
+
# fails the run rather than publishing a version with no tag to show for it.
|
|
99
|
+
- name: Tag the release
|
|
100
|
+
if: needs.decide.outputs.needs_tag == 'true'
|
|
101
|
+
env:
|
|
102
|
+
VERSION: ${{ needs.decide.outputs.version }}
|
|
103
|
+
run: |
|
|
104
|
+
set -euo pipefail
|
|
105
|
+
git config user.name "github-actions[bot]"
|
|
106
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
107
|
+
git tag -a "v${VERSION}" -m "v${VERSION}"
|
|
108
|
+
git push origin "v${VERSION}"
|
|
109
|
+
|
|
110
|
+
- name: Install build tools
|
|
111
|
+
run: pip install build
|
|
112
|
+
|
|
113
|
+
- name: Build package
|
|
114
|
+
run: python -m build
|
|
115
|
+
|
|
116
|
+
- name: Publish to PyPI
|
|
117
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: luv-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -121,7 +121,9 @@ also keeps reusing its workspace — it names a workspace, where a URL names a P
|
|
|
121
121
|
| `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
|
|
122
122
|
| `luv config list` | Show all settings |
|
|
123
123
|
| `luv --init` | Configure default GitHub org only |
|
|
124
|
-
| `luv ls [--host H] [--prune] [--no-pr]` | List every live session on every host, with each one's PR link |
|
|
124
|
+
| `luv ls [--host H] [--prune] [--no-pr] [--no-ports]` | List every live session on every host, with each one's PR link and detected ports |
|
|
125
|
+
| `luv ports [<repo> [n]] [--watch [N]]` | Show ports the sessions are listening on; naming one forwards it to localhost |
|
|
126
|
+
| `luv ports --off [<repo> [n]]` | Drop forwards again |
|
|
125
127
|
| `luv continue [<repo> [number]]` | Attach to a live session |
|
|
126
128
|
| `luv handover [<repo> [n]] --to HOST` | Move a session to another machine and resume it there |
|
|
127
129
|
| `luv rm <session\|workspace>...` | Kill a session and delete its workspace on its host |
|
|
@@ -235,6 +237,20 @@ luv continue # reattach exactly where you left off
|
|
|
235
237
|
|
|
236
238
|
Use `--local` for a one-off local run, `-s HOST` to target a different machine, and `-i PATH` for a different SSH key.
|
|
237
239
|
|
|
240
|
+
### Reaching servers the agent started
|
|
241
|
+
|
|
242
|
+
Agents working on a remote start servers there. luv finds them and forwards them to this machine on the same port number wherever it's free, so the URL in the agent's output is the URL that works for you.
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
luv ls # PORTS column: what was detected, on every session
|
|
246
|
+
luv ports myrepo 42 # forward that session's ports to localhost
|
|
247
|
+
luv ports --off myrepo 42 # drop them again
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The session you're **attached to** is forwarded automatically as servers come and go, and announces each one on the tmux status line (`luv: localhost:3000 → dashboard`) — nothing to run. Everything else is opted in by name, because a busy host carries dozens of sessions and they shouldn't all claim local ports uninvited.
|
|
251
|
+
|
|
252
|
+
Both processes under the session's tmux pane and Docker Compose stacks belonging to the workspace are found; forwards bind `127.0.0.1`, and they outlive detaching so a long-running session stays reachable.
|
|
253
|
+
|
|
238
254
|
### Handing a session to another machine
|
|
239
255
|
|
|
240
256
|
Start something on your laptop, then move it — workspace, uncommitted work, and the agent's conversation — to a machine that won't go to sleep:
|
|
@@ -98,7 +98,9 @@ also keeps reusing its workspace — it names a workspace, where a URL names a P
|
|
|
98
98
|
| `luv config set\|get\|unset <key> [value]` | Read or write a single setting |
|
|
99
99
|
| `luv config list` | Show all settings |
|
|
100
100
|
| `luv --init` | Configure default GitHub org only |
|
|
101
|
-
| `luv ls [--host H] [--prune] [--no-pr]` | List every live session on every host, with each one's PR link |
|
|
101
|
+
| `luv ls [--host H] [--prune] [--no-pr] [--no-ports]` | List every live session on every host, with each one's PR link and detected ports |
|
|
102
|
+
| `luv ports [<repo> [n]] [--watch [N]]` | Show ports the sessions are listening on; naming one forwards it to localhost |
|
|
103
|
+
| `luv ports --off [<repo> [n]]` | Drop forwards again |
|
|
102
104
|
| `luv continue [<repo> [number]]` | Attach to a live session |
|
|
103
105
|
| `luv handover [<repo> [n]] --to HOST` | Move a session to another machine and resume it there |
|
|
104
106
|
| `luv rm <session\|workspace>...` | Kill a session and delete its workspace on its host |
|
|
@@ -212,6 +214,20 @@ luv continue # reattach exactly where you left off
|
|
|
212
214
|
|
|
213
215
|
Use `--local` for a one-off local run, `-s HOST` to target a different machine, and `-i PATH` for a different SSH key.
|
|
214
216
|
|
|
217
|
+
### Reaching servers the agent started
|
|
218
|
+
|
|
219
|
+
Agents working on a remote start servers there. luv finds them and forwards them to this machine on the same port number wherever it's free, so the URL in the agent's output is the URL that works for you.
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
luv ls # PORTS column: what was detected, on every session
|
|
223
|
+
luv ports myrepo 42 # forward that session's ports to localhost
|
|
224
|
+
luv ports --off myrepo 42 # drop them again
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The session you're **attached to** is forwarded automatically as servers come and go, and announces each one on the tmux status line (`luv: localhost:3000 → dashboard`) — nothing to run. Everything else is opted in by name, because a busy host carries dozens of sessions and they shouldn't all claim local ports uninvited.
|
|
228
|
+
|
|
229
|
+
Both processes under the session's tmux pane and Docker Compose stacks belonging to the workspace are found; forwards bind `127.0.0.1`, and they outlive detaching so a long-running session stays reachable.
|
|
230
|
+
|
|
215
231
|
### Handing a session to another machine
|
|
216
232
|
|
|
217
233
|
Start something on your laptop, then move it — workspace, uncommitted work, and the agent's conversation — to a machine that won't go to sleep:
|
|
@@ -109,6 +109,30 @@ or `ssh_opts` may be set here and takes precedence over the `remote.*` value of
|
|
|
109
109
|
the same name. This is what makes `-s otherbox` usable: without it, one global
|
|
110
110
|
key would be wrong for every host but the default.
|
|
111
111
|
|
|
112
|
+
### `ports`
|
|
113
|
+
|
|
114
|
+
*object.* Port forwarding. Servers an agent starts on a remote host are
|
|
115
|
+
detected and tunnelled to this machine — see
|
|
116
|
+
[remote-sessions.md](remote-sessions.md#reaching-servers-the-agent-started).
|
|
117
|
+
Every key is optional:
|
|
118
|
+
|
|
119
|
+
| Key | Default | Meaning |
|
|
120
|
+
| --- | --- | --- |
|
|
121
|
+
| `auto` | `true` | Forward the session you are attached to, as servers come and go. `false` leaves everything to `luv ports`. |
|
|
122
|
+
| `interval` | `10` | Seconds between re-checks while attached. Minimum 2. |
|
|
123
|
+
| `bind` | `"127.0.0.1"` | Local address the forwards listen on. `"0.0.0.0"` would publish the agent's dev server to your whole network. |
|
|
124
|
+
| `min` | `1024` | Ports below this are ignored; a dev server is not on 80. |
|
|
125
|
+
| `ignore` | `[]` | Specific ports never to forward. |
|
|
126
|
+
| `max_per_session` | `12` | Cap on forwards per session, so one stack cannot eat the local port space. |
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
luv config set ports.interval 5
|
|
130
|
+
luv config set ports.ignore '[5432, 6379]'
|
|
131
|
+
luv config set ports.auto false
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Unlike `remote.*`, these are global — there is no `remote.hosts.<name>.ports`.
|
|
135
|
+
|
|
112
136
|
## Resolution order
|
|
113
137
|
|
|
114
138
|
**Host:** `-s HOST` → `remote.host`. If neither, luv runs locally.
|
|
@@ -256,6 +256,98 @@ luv -i ~/.ssh/other_key myrepo # use a different key this once
|
|
|
256
256
|
Detach from a session with `Ctrl-b d` (tmux's default prefix). The agent keeps
|
|
257
257
|
running.
|
|
258
258
|
|
|
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
|
+
|
|
272
|
+
## Reaching servers the agent started
|
|
273
|
+
|
|
274
|
+
An agent working on the remote starts servers there — a dev server, a compose
|
|
275
|
+
stack, a database. luv finds them and tunnels them to this machine, on the same
|
|
276
|
+
port number wherever it is free, so the URL the agent prints in its own output
|
|
277
|
+
is the URL that works here.
|
|
278
|
+
|
|
279
|
+
**The session you are attached to is forwarded automatically.** It is re-checked
|
|
280
|
+
every few seconds, so a server started ten minutes into a session is reachable
|
|
281
|
+
shortly after it comes up, without running anything. When a forward appears the
|
|
282
|
+
session says so on its tmux status line:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
luv: localhost:3000 → dashboard, localhost:8080 → server
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The ports are also set on the tmux session as `@luv_ports`, if you would rather
|
|
289
|
+
have them permanently in your own `status-right`.
|
|
290
|
+
|
|
291
|
+
**Everything else is opted in by name.** A busy host carries dozens of sessions
|
|
292
|
+
and they should not all get a piece of this machine's port space uninvited:
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
luv ls # PORTS column: what was detected, forwarded or not
|
|
296
|
+
luv ports # detail view; refreshes what is already forwarded
|
|
297
|
+
luv ports myrepo 42 # forward this session's ports
|
|
298
|
+
luv ports myrepo 42 --watch # ...and keep it up to date, printing each change
|
|
299
|
+
luv ports --off myrepo 42 # drop them again
|
|
300
|
+
luv ports --off # drop everything
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
HOST WORKSPACE REMOTE LOCAL URL SERVICE STATE
|
|
305
|
+
box myrepo-box-42 3000 3001 http://localhost:3001 dashboard up
|
|
306
|
+
box myrepo-box-42 5173 5173 http://localhost:5173 vite up
|
|
307
|
+
box other-box-17 5432 5432 - postgres detected
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Forwards outlive detaching — the session is still running and `localhost:3000`
|
|
311
|
+
should keep working — so `luv ports myrepo 42` is also how a long-running
|
|
312
|
+
session stays reachable after you close the window.
|
|
313
|
+
|
|
314
|
+
`REMOTE` and `LOCAL` differ when the local number was already taken, by another
|
|
315
|
+
session or by something of your own; luv walks up from the remote number and
|
|
316
|
+
remembers what it chose, so the URL stays put across runs.
|
|
317
|
+
|
|
318
|
+
### What gets found
|
|
319
|
+
|
|
320
|
+
Two sources, because neither sees everything:
|
|
321
|
+
|
|
322
|
+
- **Processes under the session's tmux pane.** A server the agent started
|
|
323
|
+
itself — `npm run dev`, `python -m http.server` — is a descendant of the pane,
|
|
324
|
+
which is what ties the port to a session. Only your own processes are visible
|
|
325
|
+
this way, so other people's services on a shared box never appear.
|
|
326
|
+
- **Docker Compose stacks belonging to the workspace.** A published port is held
|
|
327
|
+
by `docker-proxy` rather than by anything under the pane, so the Compose
|
|
328
|
+
project label answers instead. Both the project luv starts (`luv-<workspace>`)
|
|
329
|
+
and the one an agent gets by running `docker compose up` itself (named after
|
|
330
|
+
the workspace folder) count as the workspace's.
|
|
331
|
+
|
|
332
|
+
A port that is only *exposed* to the Compose network — `9009/tcp` with no host
|
|
333
|
+
mapping — has nothing on the host to point a tunnel at and is not offered. Add a
|
|
334
|
+
`ports:` entry to the compose file if you want it.
|
|
335
|
+
|
|
336
|
+
Forwards bind `127.0.0.1` on this machine, so nothing is republished to your
|
|
337
|
+
network. See [`ports.*`](configuration.md#ports) to change the interval, the
|
|
338
|
+
bind address, or which ports are skipped, and `ports.auto` to turn the automatic
|
|
339
|
+
half off entirely.
|
|
340
|
+
|
|
341
|
+
### When it doesn't work
|
|
342
|
+
|
|
343
|
+
- **Nothing detected, and the host is otherwise fine.** The host needs a luv new
|
|
344
|
+
enough to know `luv --listening`; an older one reports nothing rather than
|
|
345
|
+
failing. Upgrade luv there.
|
|
346
|
+
- **`warning: <host> refuses port forwarding`.** That host's `sshd_config` has
|
|
347
|
+
`AllowTcpForwarding no`.
|
|
348
|
+
- **A forward that stopped working.** The watcher logs to `~/.luv/ports.log`; it
|
|
349
|
+
cannot print while a full-screen agent UI owns the terminal.
|
|
350
|
+
|
|
259
351
|
## Handing a session to another machine
|
|
260
352
|
|
|
261
353
|
`luv handover` relocates a running workspace. The usual direction is laptop →
|
|
@@ -330,7 +422,7 @@ machine with `--from` and luv will move the folder and start it fresh.
|
|
|
330
422
|
| Step | What happens |
|
|
331
423
|
|---|---|
|
|
332
424
|
| `luv myrepo "…"` | Laptop records a registry entry, opens SSH, creates the tmux session, remote luv clones and launches the agent |
|
|
333
|
-
| `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 |
|
|
334
426
|
| `luv ls` | Laptop queries every known host's tmux and refreshes the registry, adopting sessions another machine started |
|
|
335
427
|
| `luv continue` | Reattaches; other clients are detached so the pane isn't size-clamped |
|
|
336
428
|
| Agent exits | The pane's command ends, tmux session disappears, Docker environment is torn down, `luv ls` prunes the entry on its next run |
|
|
@@ -42,7 +42,10 @@ the session (which luv does as soon as the clone lands) breaks nothing.
|
|
|
42
42
|
"pr_number": 42,
|
|
43
43
|
"pr_url": "https://github.com/exosphere/myrepo/pull/42",
|
|
44
44
|
"pr_state": "OPEN",
|
|
45
|
-
"pr_checked": 1753408899
|
|
45
|
+
"pr_checked": 1753408899,
|
|
46
|
+
"ports": [3000, 5173],
|
|
47
|
+
"forwards": [{"remote": 3000, "local": 3001, "label": "dashboard"}],
|
|
48
|
+
"ports_checked": 1753408899
|
|
46
49
|
}
|
|
47
50
|
]
|
|
48
51
|
}
|
|
@@ -65,6 +68,9 @@ the session (which luv does as soon as the clone lands) breaks nothing.
|
|
|
65
68
|
| `pr_checked` | Unix time GitHub was last asked about it |
|
|
66
69
|
| `pr_hint` | PR number known at dispatch (`-l` / `-pr` only); absent otherwise. Set even when the folder isn't yet known, which is always the case for `-l` |
|
|
67
70
|
| `adopted` | Present when this machine found the session rather than starting it |
|
|
71
|
+
| `ports` | Ports the session was last seen listening on, on its own host |
|
|
72
|
+
| `forwards` | Live tunnels to this machine: `remote` port, the `local` one it landed on, and the service `label`. Persisted so a URL stays put across runs, and so a forward survives detaching |
|
|
73
|
+
| `ports_checked` | Unix time the forwards were last brought in line |
|
|
68
74
|
|
|
69
75
|
`attached`, `activity`, and `live` are recomputed on every reconcile and are
|
|
70
76
|
deliberately **not** written back to the file.
|