Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ luv continue

## How it works

1. Clones the repo into `~/prs/{repo}-{number}/`
2. Creates a new branch `luv-{number}`
1. Clones the repo into `~/prs/{repo}-{machine}-{number}/`
2. Creates a new branch `luv-{machine}-{number}`
3. Configures the selected agent with the workspace's PR conventions
4. Launches Claude with Opus 5 at max effort, or Codex in YOLO mode

All workspaces live under `~/prs/`. The number comes from the repo's GitHub issue counter to avoid collisions.
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`.

The machine name is what keeps two computers apart. Each one works out the next number from GitHub independently, so your laptop and your box will happily pick the same one; without the slug they would both push a branch called `luv-43`. Folders and branches created before this keep working unchanged.

With a remote host configured, steps 1–4 happen on that machine inside a tmux session instead — see [Remote sessions](#remote-sessions).

Expand All @@ -78,6 +80,7 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
| `luv --init` | Configure default GitHub org only |
| `luv ls [--host H] [--prune]` | List live sessions across hosts |
| `luv continue [<repo> [number]]` | Attach to a live session |
| `luv handover [<repo> [n]] --to HOST` | Move a session to another machine and resume it there |
| `luv [org/]<repo> [prompt...]` | Create a new workspace and launch Claude (default) |
| `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
| `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
Expand All @@ -104,9 +107,20 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
| `-s HOST` | Run on `HOST` over SSH, overriding the configured remote host |
| `-i PATH` | SSH identity file to use for this invocation |
| `--local` | Force local execution even when a remote host is configured |
| `-f`, `--force` | Skip safety checks (with `--clean`) |
| `-f`, `--force` | Skip safety checks (with `--clean`); replace an existing destination folder (with `handover`) |
| `--safe` | With `--clean -f`, only delete workspaces older than 24h (mtime) |

Handover flags:

| Flag | Description |
|------|-------------|
| `--to HOST` | Destination machine; `local` for the one you're on |
| `--from HOST` | Source machine, when luv can't tell from its session registry |
| `--no-agent-state` | Move the workspace only; the agent starts a new conversation |
| `--no-attach` | Leave the destination session running detached |
| `--purge` | Delete the source folder once the copy is verified |
| `-y` | Skip the "an agent may still be running" confirmation |

## Docker dev environments

If a repo contains `.luv/settings.json` with a `compose_file` key, `luv` automatically starts a Docker Compose environment and runs Claude inside the `dev-environment` container.
Expand Down Expand Up @@ -149,7 +163,7 @@ The `dev-environment` service **must** have the selected agent CLI (`claude` or

1. Detects `.luv/settings.json` with `compose_file` key
2. Tears down any stale environment from a previous run
3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{number}`) for network/volume isolation
3. Starts `docker compose up -d --build` with a unique project name (`luv-{repo}-{machine}-{number}`) for network/volume isolation
4. Verifies the `dev-environment` service is running
5. Runs the selected agent inside the container via `docker compose exec`
6. The repo is volume-mounted, so all file changes and git commits are visible on the host
Expand All @@ -173,6 +187,20 @@ luv continue # reattach exactly where you left off

Use `--local` for a one-off local run, `-s HOST` to target a different machine, and `-i PATH` for a different SSH key.

### Handing a session to another machine

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:

```bash
luv --local myrepo "start something" # working locally
luv handover myrepo 43 --to box # resumes mid-conversation on box
luv handover myrepo 43 --to local # ...and back again later
```

The whole folder crosses over, including untracked and gitignored files, so a workspace that ran before the move still runs after it. The agent's transcript moves too, so it picks up where it left off rather than starting fresh. Bytes are relayed through the machine you run the command on, so the two ends never need SSH access to each other.

The source folder is left on disk (`--purge` deletes it), and the workspace keeps the name it was created with.

Full guide, including how to prepare a fresh Ubuntu box and set up SSH keys: **[docs/remote-sessions.md](docs/remote-sessions.md)**.

## Workspace cleanup
Expand Down
23 changes: 23 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,28 @@ want for hosts, paths, and org names.
The **laptop** resolves this before dispatching, so a remote machine never needs
its own `org` configured.

### `machine`

*string, default: this machine's hostname.* A short name for **this** machine,
used in the workspace folder (`myrepo-mbp-43`) and branch (`luv-mbp-43`) of
everything created here.

It exists because every machine works out the next workspace number from
GitHub's issue counter independently, so a laptop and a box will happily pick
the same one — and without the slug they would push the same branch. The value
is lowercased, stripped to letters and digits, and capped at 8 characters, so
`Niveds-MacBook.local` becomes `nivedsma`; set it explicitly for something
readable:

```bash
luv config set machine mbp
```

The slug is stamped when a workspace is created and never changes afterwards —
`luv handover` moves `myrepo-mbp-43` to another machine under that same name,
because it records where the workspace came from and its branch may already be
pushed. Workspaces created before slugs existed keep working.

### `prs_dir`

*string, default `~/prs`.* Where workspace folders are created on **this**
Expand Down Expand Up @@ -107,6 +129,7 @@ those combinations contradict each other.
```json
{
"org": "exosphere",
"machine": "mbp",
"prs_dir": "~/prs",
"remote": {
"host": "box",
Expand Down
139 changes: 122 additions & 17 deletions docs/remote-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ still up.
- [Preparing a new remote machine (Ubuntu)](#preparing-a-new-remote-machine-ubuntu)
- [Configuring luv](#configuring-luv)
- [Daily use](#daily-use)
- [Handing a session to another machine](#handing-a-session-to-another-machine)
- [Session lifecycle](#session-lifecycle)
- [Session naming and identity](#session-naming-and-identity)
- [What runs where](#what-runs-where)
Expand All @@ -28,9 +29,9 @@ luv myrepo "fix the bug"
└─ ssh -t box 'bash -lc "…"' ────► tmux new-session -A -s luv-pending-3f9a
└─ luv exosphere/myrepo "fix the bug"
├─ gh api → next number = 42
├─ git clone → ~/prs/myrepo-42
├─ git checkout -b luv-42
├─ tmux rename-session → luv-myrepo-42
├─ git clone → ~/prs/myrepo-box-42
├─ git checkout -b luv-box-42
├─ tmux rename-session → luv-myrepo-box-42
└─ exec claude …
(session keeps running after you disconnect)
```
Expand Down Expand Up @@ -210,19 +211,24 @@ On your laptop:
luv config
```

The wizard asks for the host, the SSH identity file, and the remote workspace
directory, then offers to set your default GitHub org. It finishes by checking
the remote for `tmux`, `luv`, `gh`, and `git` and telling you exactly what's
missing.
The wizard asks for this machine's name, the host, the SSH identity file, and
the remote workspace directory, then offers to set your default GitHub org. It
finishes by checking the remote for `tmux`, `luv`, `gh`, and `git` and telling
you exactly what's missing.

Non-interactively:

```bash
luv config set machine mbp
luv config set remote.host box
luv config set remote.identity_file ~/.ssh/id_ed25519
luv config list
```

Setting `machine` on each machine you use is worth the ten seconds: it is the
label that ends up in every workspace folder and branch name, and the hostname
default is rarely the nicest one.

Full key reference: [configuration.md](configuration.md).

## Daily use
Expand Down Expand Up @@ -250,6 +256,75 @@ luv -i ~/.ssh/other_key myrepo # use a different key this once
Detach from a session with `Ctrl-b d` (tmux's default prefix). The agent keeps
running.

## Handing a session to another machine

`luv handover` relocates a running workspace. The usual direction is laptop →
box: you start something locally, then decide it should keep running somewhere
that doesn't sleep.

```bash
luv --local myrepo "start something" # → ~/prs/myrepo-mbp-43, branch luv-mbp-43
luv handover myrepo 43 --to box # attaches to it, now running on box
luv handover myrepo 43 --to local # and back down again later
```

```
LAPTOP (the one you run the command on)
ssh box tar -c ─┤─ ssh gpu tar -x ← bytes are relayed through here, so
│ box and gpu need no keys for each other
BOX GPU-BOX
tmux kill-session luv-myrepo-mbp-43
docker compose -p … down
~/prs/myrepo-mbp-43 ───────────────► ~/prs/myrepo-mbp-43 (whole folder)
~/.claude/projects/-home-u-prs-… ───► ~/.claude/projects/… (transcript)
(folder left in place) tmux new-session -A -s luv-myrepo-mbp-43
└─ luv exo/myrepo 43 -r → claude --resume
```

**Everything crosses over.** The folder is copied byte for byte — `.git`, staged
and unstaged edits, untracked files, and gitignored ones like `.env` and
`node_modules`. A workspace that ran before the move still runs after it.

**So does the conversation.** Claude's transcript
(`~/.claude/projects/<path-slug>/*.jsonl`) and Codex's rollouts
(`~/.codex/sessions/**/rollout-*.jsonl`) travel with the workspace, and the
absolute path recorded inside them is rewritten for the new machine — the two
rarely agree on it. The destination then starts with `-r`, so `claude --resume`
or `codex resume --last` continues the thread rather than opening a new one.
This part depends on each agent's on-disk layout, so luv says what it found and
carries on with a fresh conversation if it finds nothing; `--no-agent-state`
skips it deliberately.

**The agent is briefly down.** luv checks everything it can first — the
destination is reachable, has luv, and has no folder in the way — then stops the
agent, because copying a workspace out from under a running one is the only way
to get a torn tree. Killing the tmux session SIGHUPs the pane, so luv also tears
the Docker environment down explicitly rather than relying on the agent's exit.

**Nothing is deleted.** The source folder stays on disk, so a failed transfer
costs nothing: luv prints the command to restart the session where it was. Use
`--purge` to delete it once the copy verifies, or `luv --clean` on that machine
later.

**The name doesn't change.** `myrepo-mbp-43` stays `myrepo-mbp-43` on the box —
the slug records where a workspace came from, and its branch may already be
pushed. That is also what makes the move safe: a workspace created on the box
would be `myrepo-box-43`, so the two can sit side by side.

Handover works on a workspace whose agent has already exited, too — name the
machine with `--from` and luv will move the folder and start it fresh.

| Flag | |
|---|---|
| `--to HOST` | Destination; `local` for the machine you're on. Required. |
| `--from HOST` | Source, when luv's registry doesn't already know |
| `--no-agent-state` | Move the workspace only |
| `--no-attach` | Leave the new session detached |
| `--purge` | Delete the source folder after the copy verifies |
| `-f` | Replace an existing folder on the destination |
| `-y` | Skip the "an agent may still be running" confirmation |

## Session lifecycle

| Step | What happens |
Expand All @@ -262,24 +337,35 @@ running.

## Session naming and identity

Sessions are named after the workspace folder: `~/prs/myrepo-42` becomes
`luv-myrepo-42`. tmux forbids `.` and `:` in session names, so a repo like
`foo.js` becomes `luv-foo_js-42`.
Workspace folders are `{repo}-{machine}-{number}` and branches
`luv-{machine}-{number}`, where `{machine}` names whichever machine created the
workspace (config key `machine`, defaulting to the hostname). Each machine works
out the next number from GitHub's issue counter on its own, so without that slug
a laptop and a box would routinely pick the same one and push the same branch.
Folders and branches created before slugs existed keep working — luv looks for
its own name first, then any other machine's, then the plain `{repo}-{number}`.

Sessions are named after the workspace folder: `~/prs/myrepo-box-42` becomes
`luv-myrepo-box-42`. tmux forbids `.` and `:` in session names, so a repo like
`foo.js` becomes `luv-foo_js-box-42`.

When the workspace is already known — reopening by number, `-pr`, or `-l` — the
laptop uses that name directly and `tmux new-session -A` doubles as "attach if
it's already running".
laptop pins the session name down before dispatching, so `tmux new-session -A`
doubles as "attach if it's already running". It can't compute that name, though,
since the slug belongs to the machine holding the folder: it takes it from the
session registry, or asks the host directly, and falls back to the
`luv-pending-<id>` rename-on-arrival path if the host doesn't answer.

When it isn't known — a brand new workspace, or bare `-n`/`-r` — the laptop uses
a placeholder `luv-pending-<id>` and the remote renames it once the clone lands.
If the target name is already taken by another live session, luv keeps the
session under `luv-myrepo-42-2` and warns rather than failing.
session under `luv-myrepo-box-42-2` and warns rather than failing.

Each session also carries two tmux options, which is what `luv ls` reads:

```bash
tmux show-options -t luv-myrepo-42 -v @luv_id
tmux show-options -t luv-myrepo-42 -v @luv_workspace
tmux show-options -t luv-myrepo-box-42 -v @luv_id
tmux show-options -t luv-myrepo-box-42 -v @luv_workspace
```

## What runs where
Expand All @@ -288,6 +374,7 @@ tmux show-options -t luv-myrepo-42 -v @luv_workspace
|---|---|---|
| `luv config`, `luv --init` | Always local | – |
| `luv ls`, `luv continue` | Local, queries remote over SSH | – |
| `luv handover` | Local, drives both machines over SSH | starts one on the destination |
| `luv <repo> [prompt]` | Remote | yes |
| `luv <repo> <n>`, `-pr`, `-l` | Remote | yes |
| `luv <repo> -n` / `-r` | Remote | yes |
Expand All @@ -304,7 +391,7 @@ invocation, `docker compose up` runs *inside* the pane:

- Detaching leaves the containers running.
- Teardown happens when the agent exits, not when your SSH client goes away.
- The compose project name is still `luv-{repo}-{number}`, so two workspaces on
- The compose project name is still `luv-{repo}-{machine}-{number}`, so two workspaces on
the same repo don't collide.

Install Docker on the remote and add your user to the `docker` group:
Expand Down Expand Up @@ -343,11 +430,29 @@ happen. Type the prompt into the running agent instead.
It shows the last known state rather than pruning; nothing is lost. When a host
is gone for good, `luv ls --prune` forgets its entries.

**Handover says the destination already has that folder**
Something with the same name is already there — either the same workspace from
an earlier handover, or one created on the machine that made this one. Look at
it first (`ssh box ls ~/prs`), then either `luv --clean` it or pass `-f` to
replace it.

**After a handover the agent started a fresh conversation**
luv prints a warning when it can't find a transcript for the workspace. The
usual cause is the workspace having been opened under a different path than the
one the agent recorded, or a Codex rollout stored somewhere other than
`~/.codex/sessions`. The workspace itself is unaffected — the code, branch, and
uncommitted work all moved.

**Handover left the session down**
The source folder is never deleted without `--purge`, so restart it where it
was: `luv <repo> <n> -r -s <source-host>`. luv prints that exact command before
it starts copying.

**A session is wedged**

```bash
ssh box tmux ls # see everything, not just luv's
ssh box tmux kill-session -t luv-myrepo-42
ssh box tmux kill-session -t luv-myrepo-box-42
```

**I'm already inside tmux locally**
Expand Down
24 changes: 19 additions & 5 deletions docs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ the session (which luv does as soon as the clone lands) breaks nothing.
{
"id": "k3f9a2c1",
"host": "box",
"session": "luv-myrepo-42",
"session": "luv-myrepo-box-42",
"org": "exosphere",
"repo": "myrepo",
"workspace": "myrepo-42",
"workspace": "myrepo-box-42",
"agent": "claude",
"model": null,
"prompt": "fix the flaky test",
"created": 1753401234,
"last_seen": 1753408899
Expand All @@ -48,8 +49,9 @@ the session (which luv does as soon as the clone lands) breaks nothing.
| `host` | The `remote.host` value used, or absent for a local session |
| `session` | tmux session name; starts as `luv-pending-<id>` and is corrected on the first reconcile |
| `org`, `repo` | GitHub owner and repo, resolved on the laptop |
| `workspace` | Folder name (`myrepo-42`); `null` until the remote reports it |
| `workspace` | Folder name (`myrepo-box-42`); `null` until the remote reports it. The middle part names the machine that created it — see [configuration.md](configuration.md#machine) |
| `agent` | `claude` or `codex` |
| `model` | The `-m` value, or `null` for the agent's default; replayed by `luv handover` |
| `prompt` | The prompt you launched with, for the `luv ls` label |
| `created` | Unix time the entry was written |
| `last_seen` | Unix time of the last successful reconcile |
Expand All @@ -59,8 +61,8 @@ deliberately **not** written back to the file.

## Reconciliation

On `luv ls` and `luv continue`, luv groups entries by host and runs one query
per host (concurrently when there's more than one):
On `luv ls`, `luv continue`, and `luv handover`, luv groups entries by host and
runs one query per host (concurrently when there's more than one):

```
tmux list-sessions -F '#{@luv_id}|#{session_name}|#{@luv_workspace}|#{session_attached}|#{session_activity}'
Expand Down Expand Up @@ -93,6 +95,18 @@ luv ls --prune
That drops entries for hosts that didn't answer, in addition to the dead ones
reconciliation already removed.

## Handover

`luv handover` replaces the moved session's entry rather than editing it: the
old one is dropped and the destination writes a fresh entry, with a new `id` and
`@luv_id`, carrying the original `org`, `repo`, `agent`, `model`, and `prompt`.
The `workspace` name is unchanged, slug and all.

Note that the registry only ever learns about sessions luv **dispatched to a
host** — a `luv --local` run records nothing. So a workspace on the machine
you're sitting at is normally absent from it, which is why handover falls back
to looking on disk instead of treating a miss as an error.

## Concurrency

Both dispatch (append an entry) and `ls` (prune entries) are read-modify-write,
Expand Down
Loading