@vibelord/lanes 1.0.4 → 1.1.0

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.
Files changed (37) hide show
  1. package/README.md +52 -15
  2. package/bin/lanes-install.mjs +8 -2
  3. package/lib/plan.mjs +34 -1
  4. package/package.json +2 -2
  5. package/payload/lanes/README.md +39 -18
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +994 -49
  8. package/payload/lanes/client/lanes-launch +160 -14
  9. package/payload/lanes/client/ssh.stanza.in +11 -2
  10. package/payload/lanes/docs/ARCHITECTURE.md +86 -2
  11. package/payload/lanes/docs/INSTALL.md +296 -43
  12. package/payload/lanes/docs/RUNBOOK.md +453 -27
  13. package/payload/lanes/docs/USERGUIDE.md +449 -76
  14. package/payload/lanes/install/enable-phone.sh +8 -20
  15. package/payload/lanes/install/enable-web.sh +2 -2
  16. package/payload/lanes/install/host-install.sh +6 -0
  17. package/payload/lanes/install.sh +888 -52
  18. package/payload/lanes/libexec/enrol.py +695 -0
  19. package/payload/lanes/libexec/enrold.py +1264 -0
  20. package/payload/lanes/libexec/keys.py +644 -0
  21. package/payload/lanes/libexec/lanes_lib.py +14 -6
  22. package/payload/lanes/libexec/members.py +297 -0
  23. package/payload/lanes/libexec/notify.py +146 -21
  24. package/payload/lanes/libexec/qr.py +217 -0
  25. package/payload/lanes/libexec/site_edit.py +237 -0
  26. package/payload/lanes/libexec/web_tokens.py +315 -0
  27. package/payload/lanes/site/README.md +13 -1
  28. package/payload/lanes/site/beyondwork/site.conf +3 -0
  29. package/payload/lanes/site/site.example.conf +5 -0
  30. package/payload/lanes/sudo/one-time-root.sh +1 -1
  31. package/payload/lanes/systemd/lanes-enrold.service +22 -0
  32. package/payload/lanes/systemd/lanes-notify.service +4 -4
  33. package/payload/lanes/systemd/lanes-web.service +4 -0
  34. package/payload/lanes/web/Caddyfile +10 -0
  35. package/payload/lanes/web/README.md +8 -2
  36. package/payload/lanes/workspaces.example/README.md +6 -1
  37. package/payload/manifest.json +7 -4
@@ -20,7 +20,7 @@ Labels used below:
20
20
  ## Contents
21
21
 
22
22
  1. [What Lanes is](#1-what-lanes-is)
23
- 2. [Install and update](#2-install-and-update)
23
+ 2. [Join a site](#2-join-a-site)
24
24
  3. [Desktop use](#3-desktop-use)
25
25
  4. [Phone, tablet and laptop browser](#4-phone-tablet-and-laptop-browser)
26
26
  5. [Uploads](#5-uploads)
@@ -107,24 +107,105 @@ fleet economy strip (overflow spend against the daily cap, new 429s, model tiers
107
107
  economy cell per lane (`⚠` starving, `↓` downgraded, `$` mostly paid). Throttle's **Shared lanes** console and the
108
108
  co-located Studio Host (section 10) give read-only and browser access to registered lanes through Throttle SSO.
109
109
 
110
- ## 2. Install and update
110
+ ## 2. Join a site
111
+
112
+ A site is one Lanes server and the people who use it. Joining takes three things: the site's tailnet, the client, and
113
+ this machine's key authorized on the server. With an invite, one command does the last two. The administrator's side
114
+ (the server install, invites, approvals, updates) follows the client's.
111
115
 
112
116
  ### What you need
113
117
 
114
118
  | Role | Requirements |
115
119
  | --- | --- |
116
- | Client (Mac or Linux desktop) | macOS or Linux, OpenSSH, a route to the server (Tailscale for the supplied profile). Node.js 18+ only for the npm path. Ghostty is recommended for a separate window (Linux falls back to Foot); `lanes-launch` works in any terminal |
120
+ | Client (Mac or Linux desktop) | macOS or Linux, OpenSSH, a route to the server (Tailscale for the supplied profile; the installer checks it and opens its login, see "Tailscale" below). Node.js 18+ only for the npm path. Ghostty is recommended for a separate window (Linux falls back to Foot); `lanes-launch` works in any terminal |
117
121
  | Server (workspace host) | Linux x86_64, Python 3.9+, systemd user services, OpenSSH server, Docker, tmux (tested with 3.4), `curl`, `tar`, `sha256sum`, `infocmp`, `tic`, `install`, `systemctl`. Run as an unprivileged account with Docker access, never as root |
118
122
  | Containers | Homes mounted at `/home/coder` with uid 1000; Bash, Python 3 and tmux (compatible with the host's tmux) |
119
123
  | Browser access (optional) | Caddy and Tailscale (with HTTPS) on the server |
120
124
 
125
+ ### Join with an invite
126
+
127
+ A member of the site sends you an invite: five lines from `lanes invite` ("Invite a user" below) with a one-time
128
+ **invite code**. Then, on your Mac or Linux machine:
129
+
130
+ 1. **Join the tailnet**, on this machine and on your phone: open the Tailscale link in the invite (or ask the member
131
+ for one). Without Tailscale, install it first (macOS: `brew install --cask tailscale`; Linux:
132
+ [tailscale.com/download](https://tailscale.com/download)). Logging in can wait: the installer opens `tailscale login`
133
+ itself ("Tailscale" below).
134
+ 2. **Run the installer with the code:**
135
+
136
+ ```sh
137
+ npx @vibelord/lanes --invite LN-xxxx-xxxxxxxxxxxx # the code's prefix picks the site (LN = beyondwork)
138
+ ```
139
+
140
+ 3. **Open Lanes**, in your own terminal (the two web commands show a login token there):
141
+
142
+ ```sh
143
+ lanes-launch # your desktop view in this terminal (lanes-window: in its own window)
144
+ lanes-launch web phone # your phone's view: a QR code of its private URL, then a login token
145
+ lanes-launch web laptop # this laptop's browser view, the same way
146
+ ```
147
+
148
+ | Step | The installer prints |
149
+ | --- | --- |
150
+ | Site | `site beyondwork, inferred from the invite code's prefix LN` (npm only; an explicit `--site` wins) |
151
+ | Tailscale | `tailscale: connected as <login> (<address>)`, after opening the login when needed ("Tailscale" below) |
152
+ | SSH alias | `ssh: host alias 'lanes' -> dev@<MagicDNS name> (fallback lanes-ip -> <tailnet address>), key …` |
153
+ | Invite | `invite: redeemed — this machine's key is now authorized for <login> (device <device>)` |
154
+ | Server | `server: ok device=<device> version=<version> boards=<n>`, then `Ready.` and the commands of step 3 |
155
+
156
+ `LANES_INVITE=<code>` works like `--invite`. The code stays secret until it is redeemed: the installer sends it in the
157
+ request body (never on a command line) and never prints or stores it, and a code typed at the prompt below is read
158
+ with echo off. A code whose prefix belongs to another site is refused before anything is installed. Phone and
159
+ browser views: section 4. Uploads: section 5.
160
+
161
+ When the server already has a device of this machine's name — two laptops with one hostname, or a replaced laptop
162
+ whose old enrolment is still there — it answers `409 (device-taken)` and uses nothing up: the installer says so and
163
+ names the re-run, `npx @vibelord/lanes --invite <code> --device <another name>` (it remembers the name). If the old
164
+ machine is gone, a member can end it first with `lanes offboard <device> --yes`. `409 (key-taken)` means the server
165
+ holds this key for another device: the installer names a key of its own (`--key ~/.ssh/lanes_ed25519`). Retrying
166
+ with a valid code this way never pauses the machine: only wrong codes count toward the 10-minute limit.
167
+
168
+ **Operator gate**: the server must run the enrolment service `lanes-enrold` behind its private HTTPS front (installed
169
+ and enabled by `npx @vibelord/lanes --server`). **Not yet verified on a device**: no code has been redeemed from a
170
+ second machine yet.
171
+
172
+ ### No code: ask for access
173
+
174
+ Run the installer in your own terminal without a code (`npx @vibelord/lanes --site beyondwork`, or `./install.sh`
175
+ from a checkout). When the server does not know this machine's key, it asks `Invite code (Enter = ask the members of
176
+ this site for access instead):`. Press Enter: the installer files an access request — `request: filed as <id> for
177
+ <device> — a member approves it on their phone (https://<server>:8443/enrol/pending) or on the server: lanes enrol
178
+ approve <id>` — and counts down up to 10 minutes (`LANES_ENROL_WAIT`) while a member decides:
179
+
180
+ | A member decides | Where |
181
+ | --- | --- |
182
+ | On a phone or laptop on the tailnet | `https://<server>:8443/enrol/pending`: **Approve** or **Deny** next to your login and device (open to a tailnet login that owns a member device) |
183
+ | On the server or over `ssh lanes` | `lanes enrol list`, then `lanes enrol approve <id>` (or `lanes enrol deny <id>`) |
184
+ | From a push | With the opt-in push notifier on, each new request arrives as one notification naming you, your device and that command (section 8) |
185
+
186
+ Approved, the installer says `request: approved — this machine's key is now authorized for <login> (device <device>)`
187
+ and goes on to the server check and `Ready.` If nobody answers in time, it exits 3 and the request stays open for 24
188
+ hours: `npx @vibelord/lanes --resume` (or `./install.sh --resume`) waits again, and so does a plain re-run in a
189
+ terminal. A site whose enrolment service cannot see tailnet logins admits by invite only: there Enter says `request:
190
+ this site admits by invite only — ask a member for an invite, then ./install.sh --invite CODE`.
191
+
192
+ ### No enrolment service: the administrator authorizes the key
193
+
194
+ A server without `lanes-enrold`, a refused, used or expired code, or an install with no code and no terminal (`--yes`,
195
+ a pipe) ends with this machine's **public** key printed for the administrator (exit 3), as before enrolment existed.
196
+ The administrator authorizes it on the server with `lanes keys add <device> <file.pub>` (or `lanes keys add <device> -`
197
+ and paste the line), which writes it into the lanes-managed block of the server account's `~/.ssh/authorized_keys`
198
+ (the client never edits that file). Then run `npx @vibelord/lanes --check` (or `./install.sh --check`) and
199
+ `lanes-launch`.
200
+
121
201
  ### Client from npm
122
202
 
123
- The package `@vibelord/lanes` is restricted to the `@vibelord` npm organization. It carries the committed `lanes/`
124
- tree and runs that tree's own `install.sh` with the same options and exit codes.
203
+ Without an invite code, name the site yourself; the same command updates an installed client. The package
204
+ `@vibelord/lanes` is public — no npm login or token. It carries the committed `lanes/` tree and runs that
205
+ tree's own `install.sh` with the same options and exit codes; its only site facts are the bundled profiles' public host
206
+ keys and tailnet names and addresses.
125
207
 
126
208
  ```sh
127
- npm login --scope=@vibelord # once per machine (or a read-only token in ~/.npmrc)
128
209
  npx @vibelord/lanes --site beyondwork # install or update the client
129
210
  lanes-window # open the desktop view
130
211
  ```
@@ -134,6 +215,7 @@ lanes-window # open the desktop view
134
215
  | `--site NAME` | A bundled profile (`beyondwork`); a value containing `/` or ending in `.conf` is your own file |
135
216
  | `--stage-only` | Copy the bundled tree to `~/.local/share/lanes/pkg/<version>/` and print the path; run nothing |
136
217
  | `--version` | Print the package version |
218
+ | `--invite CODE` | Hand the code to the installer; without `--site`, the code's prefix picks the bundled profile ("Join with an invite" above) |
137
219
  | `--wrapper-help` | Wrapper help only |
138
220
 
139
221
  Every other option goes to `install.sh`. The wrapper stages the tree under `~/.local/share/lanes/pkg/<version>/`
@@ -142,6 +224,8 @@ version a server unit still runs from. See [`npm/lanes/README.md`](../../npm/lan
142
224
 
143
225
  ### Client from a checkout
144
226
 
227
+ For working on Lanes, or for a site without a bundled profile:
228
+
145
229
  ```sh
146
230
  git clone https://github.com/bwllaming/bw-tools.git
147
231
  cd bw-tools/lanes
@@ -155,32 +239,73 @@ lanes-window
155
239
  | `--check` | Verify the commands, SSH alias, key, upload helper and server; changes nothing |
156
240
  | `--uninstall` | Remove what the client installer added (your SSH key, the known_hosts entry and `~/Lanes Uploads` are kept) |
157
241
  | `--server [--check \| --no-start]` | Install or update the Linux server instead (must be the first argument; see below) |
158
- | `--site FILE` | Server profile; default `site/site.conf`, else the remembered `~/.config/lanes/client-site.conf` |
159
- | `--device NAME` | This machine's view session name (default `<user>-<hostname>`, lowercased, at most 32 characters; kept on re-install) |
242
+ | `--site FILE` | Server profile; default the remembered one, else `site/site.conf`, else the installed `~/.config/lanes/client-site.conf` |
243
+ | `--device NAME` | This machine's view session name (default `<user>-<hostname>`, lowercased, at most 32 characters) |
160
244
  | `--key PATH` | SSH private key (default: the one already configured, else `~/.ssh/id_ed25519`, else a new `~/.ssh/lanes_ed25519`) |
161
245
  | `--link` | Symlink the client into this checkout instead of copying it (for working on Lanes) |
162
- | `--coder-launch` | Also provide the command name `coder-launch` (an existing one is kept as `coder-launch-legacy`) |
163
- | `--no-desktop` | Skip Lanes.app, the `.desktop` entry and Hyprland bindings |
164
- | `--no-uploads` | Skip the uploads watch folder and the upload helper service |
165
- | `--palette` | Give the lanes window the cockpit's own Ghostty palette (section 9); a re-run without it puts your everyday look back |
166
- | `--yes`, `-y` | Never prompt (a newly created key then has no passphrase) |
246
+ | `--coder-launch` / `--no-coder-launch` | Also provide the command name `coder-launch` (an existing one is kept as `coder-launch-legacy`) / take it away again (the kept one returns) |
247
+ | `--no-desktop` / `--desktop` | No Lanes.app, `.desktop` entry or Hyprland bindings — an earlier install's are removed (Hyprland line with a backup) / back on |
248
+ | `--no-uploads` / `--uploads` | No uploads watch-folder agent or upload helper service — an earlier install's are stopped and removed, `~/Lanes Uploads` and its files stay / back on |
249
+ | `--palette` / `--no-palette` | Give the lanes window the cockpit's own Ghostty palette (section 9) / put your everyday look back |
250
+ | `--invite CODE` | Join with an invite code when the server does not know this machine's key yet ("Join with an invite" above; also `LANES_INVITE`); not remembered |
251
+ | `--resume` | Wait again for this machine's open access request (`~/.config/lanes/enrol-request`; "No code: ask for access" above); not remembered |
252
+ | `--yes`, `-y` | Never prompt (a newly created key then has no passphrase; no invite prompt either) |
167
253
  | `-h`, `--help` | Print the option summary |
168
254
 
255
+ **Remembered options.** Every install writes its choices — `--device`, `--key`, `--site`, `--coder-launch`,
256
+ `--no-desktop`, `--no-uploads`, `--palette` and their opposites — to `~/.config/lanes/install-options` (plain
257
+ `KEY=VALUE`, mode 0600, read without executing it). A re-run without flags, and so every update, repeats the last
258
+ install; a flag given again overrides the remembered value and is remembered from then on. A profile bundled with
259
+ the package (`--site beyondwork`) is remembered by its name, so an update reads the new package's copy and its pinned
260
+ host key; your own profile file is remembered by its path (when that file is gone, the installed copy is used).
261
+ `--check` only reads the file, `--uninstall` removes it. `--link` and `--yes` are per run. A client installed before
262
+ the file existed (1.0.x) is read once from what it left behind: the palette include, the `coder-launch` link, and
263
+ desktop or uploads as off only when nothing of them is left (no app, launcher entry, Hyprland line or bind file; no
264
+ watch-folder README, agent or service) — a launcher entry you deleted by hand while keeping the key binding leaves the
265
+ desktop on, so that first update takes nothing away. What is installed follows the remembered choice both ways: an
266
+ option turned off takes away what an earlier install added for it — `--no-uploads` stops and removes the watch-folder
267
+ agent and the upload helper service (on macOS the two launchd agents, Lanes Helper.app and the Lanes Upload droplet)
268
+ and the agent hand-off block, keeping `~/Lanes Uploads` and its files; `--no-desktop` removes Lanes.app or the
269
+ launcher entry and the Hyprland line (backup `.pre-lanes-off.<UTC>`) with its bind file; `--no-palette` and
270
+ `--no-coder-launch` likewise. Nothing is touched on a machine that never had them, and `--check` reports the upload
271
+ helper as off.
272
+
169
273
  What a client install changes: `lanes-launch`, `lanes-window` and `lanes-upload` in `~/.local/bin` (copied to
170
274
  `~/.local/share/lanes/client` unless `--link`); the SSH alias `lanes` in `~/.ssh/config.d/lanes.conf` plus one
171
- `Include` line at the top of `~/.ssh/config` (backup kept), with a `lanes-public` fallback when the site provides one;
275
+ `Include` line at the top of `~/.ssh/config` (backup kept), with a `lanes-public` fallback when the site provides one —
276
+ when this machine resolves the profile's MagicDNS name (`LANES_SERVER_NAME`), `lanes` names the server by it and a
277
+ second alias `lanes-ip` holds the tailnet address (otherwise `lanes` holds the address, as before; both pin the same
278
+ host key, keyed by the profile's alias);
172
279
  the server's host key pinned from the profile's `known_hosts`; a separate Ghostty configuration
173
- `~/.config/lanes/ghostty.conf` (your everyday Ghostty config is read, never written); desktop integration (macOS
174
- `~/Applications/Lanes.app`; Linux a "Lanes" launcher entry and, on Omarchy/Hyprland, validated key bindings);
175
- and the uploads watch folder plus the upload helper service (section 5).
280
+ `~/.config/lanes/ghostty.conf` (your everyday Ghostty config is read, never written); `~/.config/lanes/install-options`;
281
+ desktop integration (macOS `~/Applications/Lanes.app`; Linux a "Lanes" launcher entry and, on Omarchy/Hyprland,
282
+ validated key bindings); and the uploads watch folder plus the upload helper service (section 5) — on Linux with a
283
+ key that has a passphrase also one marked block in your login profile (section 5, "The upload helper and a
284
+ passphrase").
285
+
286
+ ### Tailscale
176
287
 
177
- Exit status (same for `npx @vibelord/lanes`): `0` ready · `3` installed, but this machine's key is not yet
178
- authorized on the server · `4` installed, but the server is unreachable (Tailscale?) or did not answer in time ·
179
- `1` error.
288
+ A profile whose server has a Tailscale name (`LANES_SERVER_NAME` ends in `.ts.net`, as the supplied
289
+ `beyondwork` profile does) is reached only through the tailnet, so the installer looks at Tailscale before it tries
290
+ the server (a profile without such a name skips this):
291
+
292
+ | The installer prints | What happened | What you do |
293
+ | --- | --- | --- |
294
+ | `tailscale: not installed — macOS: brew install --cask tailscale · Linux: https://tailscale.com/download` | No `tailscale` command on `PATH` (on macOS also none inside `Tailscale.app` in `~/Applications` or `/Applications`). The client is installed; exit 4 | Install Tailscale, then run the installer again |
295
+ | ``tailscale: needs login — opening `tailscale login`… (approve in the browser)`` | This machine is logged out of the tailnet; the installer runs `tailscale login` once (its login URL is printed where no browser opens) and waits up to 120 s, with a countdown, until the machine is `Running` and online | Approve the login in the browser |
296
+ | `` tailscale: `tailscale login` failed `` | The login gave up at once — on Linux, an account without operator rights (`Access denied`) | `sudo tailscale up` once (`sudo tailscale set --operator=$USER` lets you log in without sudo), then run the installer again |
297
+ | `tailscale: installed, but not running` / `switched off` / `waits for a tailnet admin` | `tailscaled` does not answer / Tailscale is switched off / the tailnet requires device approval | Start the app (Linux: `sudo systemctl enable --now tailscaled`) / switch it on / ask the admin; the installer keeps counting down meanwhile |
298
+ | `tailscale: not on the tailnet after 120 s (state: …)` | The wait ran out; the client is installed and the server was not tried; exit 4 | Connect Tailscale, then run the installer again |
299
+ | `tailscale: connected as <login> (<address>)` | This machine is in the tailnet | Nothing: the server check follows |
180
300
 
181
- **Authorize the machine.** On exit 3 the installer prints this machine's **public** key. The administrator appends
182
- that line to the server account's `~/.ssh/authorized_keys` (the client never edits that file). Then run
183
- `./install.sh --check` (or `npx @vibelord/lanes --check`) and `lanes-window`.
301
+ `LANES_TAILSCALE_WAIT=<seconds>` changes the 120-second bound; Ctrl-C ends the wait and the login it opened.
302
+ `--check` only looks: it never opens a login and never waits (a logged-out machine is exit 4 with the advice above).
303
+
304
+ ### Exit status
305
+
306
+ The same for `./install.sh` and `npx @vibelord/lanes`: `0` ready · `3` installed, but this machine's key is not yet
307
+ authorized on the server · `4` installed, but this machine is not in the tailnet, or the server is unreachable or
308
+ did not answer in time · `1` error.
184
309
 
185
310
  ### Server
186
311
 
@@ -205,7 +330,9 @@ glow 3.0.0 unless a glow is already on the view server's PATH) into `~/.local/sh
205
330
  `xterm-lanesview` terminfo alias, renders every unit in `lanes/systemd/` into the user manager's unit directory,
206
331
  links `~/.local/share/lanes/current` and `~/.local/bin/lanes` to this checkout, creates an empty catalog only when no
207
332
  configuration exists, runs `lanes sync`, and enables `lanes-statusd.service`, `lanes-sync.timer`,
208
- `lanes-ensure.timer` and `lanes-gc.timer`. Web access, the Studio Host, the economy poller, push notifications,
333
+ `lanes-ensure.timer`, `lanes-gc.timer` and the enrolment service `lanes-enrold.service` (restarted by every install, so
334
+ it runs the installed code; it restarts a running `lanes-mobile` too, so Caddy reads the new `/enrol/*` route). Web
335
+ access, the Studio Host, the economy poller, push notifications,
209
336
  the `/tmp` reaper and the OOM guards stay off (section 11 lists each unit).
210
337
 
211
338
  Then configure the workspaces (section 6) and install the container contract:
@@ -223,12 +350,59 @@ it creates `~/.tmp` and appends one marker-guarded line (`# lanes-env`) to the l
223
350
  the pinned zmx binaries without overwriting a running one; and it links `~/.lz/<container>` on the host. It installs
224
351
  no agent hook (section 7).
225
352
 
353
+ For a new user's container, `lanes onboard <login> --container <name> --workspace <id> --repo /home/coder/<repo>`
354
+ prints every step and changes nothing. It checks the container first, so an unknown container stops it before the
355
+ board is written. Add `--write` to apply the steps in order: the board, `install-container`, the
356
+ Claude and Codex hooks, `throttle setup --install-clients` inside the container when Throttle is installed and
357
+ configured there (otherwise it prints the one-time setup to run on a terminal), `lanes sync`,
358
+ `cursor-sync --canonicalize --write` when the repository has a
359
+ `.vscode/terminals.json`, and `lanes doctor`. It ends with a table and a receipt under
360
+ `~/.local/state/lanes/onboard/`. Running it again changes nothing that is already done
361
+ ([install guide](INSTALL.md#onboard-a-users-container); what the agents get: section 7).
362
+
363
+ ### Invite a user (administrator)
364
+
365
+ Any member can invite a new person from the server, or over `ssh lanes`, **in their own terminal** — the code is
366
+ shown once and only to a terminal (piped or redirected, `lanes invite` exits `2` and mints nothing):
367
+
368
+ ```text
369
+ $ lanes invite anna --email anna@example.com --workspaces bw-tools,throttle
370
+ Invite for anna — valid 7 d, one redemption
371
+ 1. Join the tailnet (laptop and phone): ask your admin for the Tailscale invite
372
+ 2. On your Mac/Linux: npx @vibelord/lanes --invite LN-7q4k-m2xpf9c3d8e2
373
+ 3. Then: lanes-launch · lanes-launch web phone · lanes-launch web laptop
374
+ Takes ~5 min.
375
+ ```
376
+
377
+ Send those five lines to the person (`--md` prints them as Markdown to paste). The code `LN-<id>-<secret>` is
378
+ case-insensitive and redeems **once**, within 7 days (`--expires 3d`, `--expires 12h`; 1 h to 30 d); the server keeps
379
+ only its hash in `invites/<id>.json`. `--email` records who the invite is for — on a server whose enrolment service
380
+ cannot see tailnet logins (invite-only) that address becomes the new member's owner, so give it — and, when
381
+ `~/.config/lanes/resend.key` exists, mails the five lines — put a `from=Name <addr>` line on a domain verified in
382
+ Resend under the key, or the kit goes from Resend's test sender, which delivers only to the Resend account's own
383
+ address (anyone else: Resend answers 403, and the note names the missing line). Step 1's link is
384
+ `--tailscale-link URL` (an invite from the Tailscale admin console), else one minted with
385
+ `~/.config/lanes/tailscale-api.key`, else the person asks you for one.
386
+ Both key files are Lanes' own and optional (RUNBOOK "Invite, approve, offboard"). Step 2 redeems the code at the
387
+ enrolment service through the client's `--invite` (W8 B3, the same release). **Operator gate**: the server needs
388
+ `lanes-enrold` installed (`npx @vibelord/lanes --server`). **Not yet verified on a device**: no code has been
389
+ redeemed from a second machine yet.
390
+
391
+ | Command | Does |
392
+ | --- | --- |
393
+ | `lanes invite list` | Every invite: `live`, `redeemed` (by which device), `revoked`, `expired` |
394
+ | `lanes invite revoke <id>` | The code is refused from now on (a redeemed invite is not revoked: offboard the device) |
395
+ | `lanes enrol list [--all]` | Requests filed by machines without a code; `--all` adds decided and expired ones |
396
+ | `lanes enrol approve <id> [--as <device>]` / `deny <id>` | Decide one, as `/enrol/pending` does on the phone; `--as` names your own device ([install guide](INSTALL.md#approve-a-request)) |
397
+ | `lanes enrol mode [request\|invite\|auto]` | Pin whether the server takes access requests, across restarts — `request` once the Serve header check passed, `invite` for invites only ([install guide](INSTALL.md#approve-a-request)) |
398
+ | `lanes offboard <device> --yes` | In your own terminal: revoke its key, forget the device, sign its browsers and phones out (the login tokens of the device, its windows and its `lanes-launch web` views), end its view session; it names its windows (`<device>-2`) to offboard by name ([install guide](INSTALL.md#offboard-a-machine)) |
399
+
226
400
  ### Update
227
401
 
228
402
  | Where | Update | Then |
229
403
  | --- | --- | --- |
230
- | Client (npm) | `npx @vibelord/lanes@latest` with the same options | `npx @vibelord/lanes --check` |
231
- | Client (checkout) | `git pull`, then `./install.sh` | `./install.sh --check` |
404
+ | Client (npm) | `lanes-launch update`, which runs `npx --yes @vibelord/lanes@latest` with every choice in `~/.config/lanes/install-options` given again as its flag (the command is printed first; flags you add follow them and win, e.g. `lanes-launch update --no-palette`); or `npx @vibelord/lanes@latest` yourself (your remembered options are repeated; add a flag only to change one) | `npx @vibelord/lanes --check` |
405
+ | Client (checkout) | `git pull`, then `./install.sh` (`lanes-launch update` refuses a client that runs from a checkout: npm would replace it with a copy) | `./install.sh --check` |
232
406
  | Server (npm) | `npx @vibelord/lanes@latest --server` (runs from `~/.local/share/lanes/pkg/<version>/`; older trees a service still uses are kept) | the restarts below |
233
407
  | Server (checkout) | `git pull` in the installed checkout, then `./install.sh --server` (configured workspaces and agent sessions are preserved) | the restarts below |
234
408
 
@@ -253,6 +427,17 @@ before a server update. To roll back, restore those files; do not rebuild device
253
427
  installer calls it to prove the SSH path, the key and the server version; an answer without that greeting is
254
428
  reported as "an older server?".
255
429
  - The client installer prints `lanes client <version>` and copies `VERSION` into `~/.local/share/lanes/client/`.
430
+ - `lanes-launch` passes that version to `lanes attach` (`--client-version`). A client older than the server gets one
431
+ dim line before the view opens, and again after it detaches: `lanes client 1.0.4 is older than the server (1.1.0) —
432
+ run lanes-launch update`. A `lanes-launch` from before the flag (1.0.4 or earlier; it has no `update` verb) is told
433
+ `update it: npx @vibelord/lanes@latest` only by a server newer than 1.0.4 — a 1.0.4 server says nothing to it.
434
+ Only major.minor.patch is compared (`1.1.0-rc.1` counts as `1.1.0`); a `dev` version on either side, and your own
435
+ `lanes attach` on the server, get no line.
436
+ - `lanes-launch web` asks the server's `lanes hello` first, on the same connection. A 1.0.x server does not know the
437
+ options a 1.1.0 client adds (`--default-device`, `--no-qr`), so the client leaves them out there and says so in one
438
+ line: `the server runs lanes 1.0.4, older than this client: no QR code, and the upload box of this view does not
439
+ reach this machine` — the view and its login token still work. The client updates itself first (public npm), the
440
+ server when its operator runs `npx @vibelord/lanes@latest --server`, so this mixed window is expected.
256
441
 
257
442
  ### Uninstall
258
443
 
@@ -281,11 +466,16 @@ before a server update. To roll back, restore those files; do not rebuild device
281
466
  | `lanes-launch <device> [--ascii] [--theme dark\|light\|auto] [--glyphs M] [--color C]` | The view with a recorded look |
282
467
  | `lanes-launch lane <lane-id>` | One lane straight in this terminal, no multiplexer (native terminal scrollback) |
283
468
  | `lanes-launch ls` | List the lanes with live status (`lanes ls` on the server) |
469
+ | `lanes-launch web phone` / `laptop` / `<device>` | Not a terminal view: a browser view's private URL and a login token (section 4) |
470
+
471
+ `web`, `update`, `lane` and `ls` are verbs: a device or workspace with one of those names is not reached through
472
+ `lanes-launch <name>`.
284
473
 
285
474
  Closing the window detaches; nothing on the server stops. `lanes-launch` never ends by itself: when the connection
286
475
  drops, the server drops a slow client, or the server refuses, it says so, waits with a countdown (backing off up to
287
- 60 s; `Enter` reconnects now) and reattaches. After two quick transport failures it uses the `lanes-public` fallback
288
- when the site defines one. Exits 64–78 are server refusals and retry after 60 s. Its log is
476
+ 60 s; `Enter` reconnects now) and reattaches. After two quick transport failures it uses the next fallback the SSH
477
+ configuration defines, first `lanes-ip` (the server's tailnet address, when `lanes` names it by its MagicDNS name), then
478
+ `lanes-public` (when the site defines one), and after the last it goes back to `lanes`. Exits 64–78 are server refusals and retry after 60 s. Its log is
289
479
  `~/.local/state/lanes-client/launch-<target>.log`. When the server rebuilt the view, the client reconnects straight
290
480
  away.
291
481
 
@@ -298,7 +488,9 @@ away.
298
488
  entry with `layout: "lanes"` sits just before Host unless its `position` is `workspace`.
299
489
  - Phones and browsers are separate devices (`phone`, `browser`, or a name you choose); a device keeps the layout
300
490
  (desktop or phone) it was created with.
301
- - On the server: `lanes device list` shows each device (phone or desktop with its viewports, running or not);
491
+ - On the server: `lanes device list` shows each device (phone or desktop with its viewports, the owner's login once
492
+ the machine was enrolled through an invite or a request — the current member's, so a handed-over machine shows its
493
+ new owner — running or not);
302
494
  `lanes device forget <name>` stops `lanes ensure` recreating it; `lanes rebuild <device>` recreates a stuck view.
303
495
 
304
496
  ### Panes and viewports
@@ -490,6 +682,19 @@ in the window's config). Not yet verified on a Mac.
490
682
 
491
683
  ## 4. Phone, tablet and laptop browser
492
684
 
685
+ From an installed client, in your own terminal (the login token is shown only there), one command per browser:
686
+
687
+ ```sh
688
+ lanes-launch web phone # your phone: the view <your device>-phone, phone layout
689
+ lanes-launch web laptop # this laptop's browser: the view <your device>-web, desktop layout
690
+ lanes-launch web ipad # any other name is that view (a new one gets the desktop layout)
691
+ ```
692
+
693
+ Each prints the private URL with a QR code of it, then a fresh login token. Open the URL on the device (scan the code
694
+ with a phone's camera), sign in with the token, and on a phone add it to the Home Screen. The server does the rest in
695
+ the same step: it prepares the view, enables the web services and adds the Tailscale Serve mapping when it is
696
+ missing. The device must be on the tailnet ("Join with an invite", step 1).
697
+
493
698
  ### How it is served
494
699
 
495
700
  ```text
@@ -504,47 +709,88 @@ A token grants terminal access as the server account.
504
709
 
505
710
  ### One-time server setup (administrator)
506
711
 
507
- 1. Install Caddy and Tailscale on the server and enable Tailscale HTTPS for the tailnet. Reserve ports 8082, 8083 and
508
- 8443 for Lanes.
712
+ 1. Install Caddy (2.5 or later: the `/enrol/*` route uses `trusted_proxies`) and Tailscale on the server and enable
713
+ Tailscale HTTPS for the tailnet. Reserve ports 8082, 8083, 8443 and 8184 (the enrolment service) for Lanes.
509
714
  2. Allow the workspace account to manage Tailscale Serve: `sudo sh sudo/one-time-root.sh --user <account>` (it changes
510
715
  SSH daemon settings only with `--ssh-keepalive`).
716
+ 3. Nothing more: the first `lanes-launch web …` from a client (or `lanes web <device>` on the server) enables the
717
+ loopback web services and adds the Tailscale Serve mapping `https:8443 -> http://127.0.0.1:8083` when it is missing
718
+ ("Laptop and tablet" below has the rules). When Tailscale refuses the account it exits 64 and prints the step of
719
+ item 2. The same front carries the enrolment service's `/enrol/*` routes (section 2).
511
720
 
512
721
  ### Phone (Safari, Home Screen)
513
722
 
514
723
  From an installed client, in your own terminal (the token is shown only there):
515
724
 
516
725
  ```sh
517
- sh install/enable-phone.sh --host lanes # or on the server: sh install/enable-phone.sh --local
726
+ lanes-launch web phone # this machine's phone view: <your device>-phone, phone layout
518
727
  ```
519
728
 
520
- It enables the web services, prepares the `phone` view (phone layout), starts private Tailscale Serve and prints a
521
- fresh token. On the phone: connect Tailscale, open `https://<server>.<tailnet>.ts.net:8443/phone` in Safari, paste
522
- only the token value (the last line is `NAME: TOKEN`), then **Share → Add to Home Screen**.
729
+ The server prepares the view `<your device>-phone` (for example `anna-mbp-phone`), enables the web services, makes
730
+ Tailscale Serve forward private HTTPS 8443 to them (added when missing), prints the private URL with a QR code of it
731
+ and then a fresh token. On the phone: connect Tailscale, scan the code with the camera (or open the printed
732
+ `https://<server>.<tailnet>.ts.net:8443/<your device>-phone` in Safari), paste only the token value (Zellij prints
733
+ `NAME: TOKEN`), then **Share → Add to Home Screen**.
734
+
735
+ `lanes-launch web phone|laptop|<device> [--desktop|--mobile] [--no-qr]` runs `lanes web <view> <layout> --token
736
+ --default-device <your device>` on the server over `ssh -t lanes`:
523
737
 
524
- `enable-phone.sh [--host SSH_ALIAS | --local] [--desktop|--mobile] [device]`: `--host` connects with the key the
525
- client installer chose (no password fallback, no agent forwarding); `--local` runs on the server; `--mobile` (the
738
+ | Target | View on the server | Layout |
739
+ | --- | --- | --- |
740
+ | `phone` | `<your device>-phone` | phone (`--mobile`) |
741
+ | `laptop` | `<your device>-web` | desktop (`--desktop`) |
742
+ | any other name | that device, as given | a known device keeps its layout; a new one is desktop |
743
+
744
+ `--desktop` or `--mobile` overrides the layout. `phone` and `laptop` are named after this machine's device (from
745
+ `~/.config/lanes/device`), so the server records this machine as the view's base, as `lanes attach` does for `mac-2`;
746
+ a name over 32 characters is refused here (name the view yourself: `lanes-launch web <name>`). Any other option is
747
+ refused before a connection (exit 64). The exit status is the server's: 64 when ssh got no terminal for the token,
748
+ when a running view has the other layout (choose another name), or when the server could not make the Tailscale
749
+ Serve mapping (it prints the operator's step). Before the token, the server draws a QR code of the
750
+ URL (only the URL: the token does not exist yet) so the phone's camera opens it; `--no-qr` leaves it out. A device
751
+ literally named `phone` or `laptop` (the `phone` view `enable-phone.sh`
752
+ makes) is reached with `lanes web phone` on the server.
753
+
754
+ `enable-phone.sh [--host SSH_ALIAS | --local] [--desktop|--mobile] [device]` is the older one-shot for the device
755
+ `phone`: `--host` connects with the key the client installer chose (no password fallback, no agent forwarding);
756
+ `--local` runs on the server, where it is a thin wrapper over `lanes web <device> <layout> --token`; `--mobile` (the
526
757
  default, device `phone`) or `--desktop` (device `browser`) picks the layout. On macOS `--host lanes` is assumed when
527
758
  neither is given. An SSH-only phone client can instead run `lanes attach phone --mobile`.
528
759
 
529
760
  ### Laptop and tablet
530
761
 
531
- Give each browser its own device. On the server:
762
+ Give each browser its own device. From an installed client, in your own terminal:
763
+
764
+ ```sh
765
+ lanes-launch web laptop # this laptop's browser view: <your device>-web, desktop layout
766
+ lanes-launch web ipad # any other name is that device (a new one gets the desktop layout)
767
+ lanes-launch web ipad --mobile # the phone layout instead
768
+ ```
769
+
770
+ On the server the same command is `lanes web`:
532
771
 
533
772
  ```sh
534
773
  lanes web ipad --desktop # desktop sidebar layout (a new device's default); prints the private URL
535
774
  lanes web ipad --desktop --token # the same, then a fresh login token in this terminal
536
- lanes web ipad --print-only # the exact prepare call and URL; changes nothing
775
+ lanes web ipad --print-only # the exact prepare call, URL and Serve step; changes nothing
537
776
  ```
538
777
 
539
- From a client: `sh install/enable-phone.sh --host lanes --desktop <device>` (without a name, `--desktop` uses the
540
- device `browser`).
541
-
542
- `lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only]` prepares the view, enables the
543
- loopback services through `install/enable-web.sh`, and prints `key: value` lines (`device`, `layout`, `state`,
544
- `prepare`, `services`, `url`, and `note` / `next` when they apply). Without `--desktop`/`--mobile` a known device
545
- keeps its recorded layout; only a new device defaults to desktop. A running view of the other layout is refused,
546
- never rebuilt: choose another device name. `--token` needs your own terminal (exit 64 otherwise). Without a
547
- Tailscale name the URL is `http://127.0.0.1:8083/<device>`, which opens only on the host itself.
778
+ From a client: `lanes-launch web laptop` (the view `<your device>-web`), `lanes-launch web <device> --desktop`, or
779
+ `sh install/enable-phone.sh --host lanes --desktop <device>` (without a name, `--desktop` uses the device `browser`).
780
+
781
+ `lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only] [--default-device NAME] [--no-qr]`
782
+ prepares the view (before it prints anything), enables the loopback services through `install/enable-web.sh`, makes
783
+ Tailscale Serve forward `https:8443` to `http://127.0.0.1:8083`, and prints `key: value` lines (`device`, `layout`,
784
+ `state`, `prepare`, `services`, `url`, then `note` / `next` / `serve` when they apply). Without `--desktop`/`--mobile`
785
+ a known device keeps its recorded layout; only a new device defaults to desktop. A running view of the other layout
786
+ is refused, never rebuilt: choose another device name. `--token` needs your own terminal (exit 64 otherwise); it then
787
+ draws a QR code of the URL — `qrencode -t ANSIUTF8` when the server has it, else a built-in encoder for URLs up to
788
+ 106 bytes — and none for a loopback URL or with `--no-qr`; the token is never in it. The Serve step leaves a present
789
+ mapping alone (`serve: present …`), adds a missing one (`serve: added …`), and never replaces a mapping of 8443 to
790
+ anything else; when it cannot make the mapping it exits 64 and prints the operator's step (`sudo tailscale set
791
+ --operator=<account>`, or the command that frees the port). `--default-device` (what `lanes-launch web` passes)
792
+ records the view's base machine; `--print-only` records nothing. Without a Tailscale name the URL is
793
+ `http://127.0.0.1:8083/<device>`, which opens only on the host itself, and Serve is not touched.
548
794
 
549
795
  On a captive-portal network (hotel, train, aircraft): complete the portal sign-in first, then connect Tailscale, then
550
796
  open the URL and paste the token.
@@ -553,10 +799,23 @@ open the URL and paste the token.
553
799
 
554
800
  | Task | Command |
555
801
  | --- | --- |
556
- | Create (shown once, in your terminal) | `lanes web <device> --token`, or `enable-phone.sh` |
557
- | Revoke a named token | `~/.local/share/lanes/bin/zellij-0.45.1 web --revoke-token NAME` |
558
-
559
- Lanes never prints or logs a token itself. Existing tokens stay valid when you create a new one.
802
+ | Create (shown once, in your terminal) | `lanes-launch web phone\|laptop\|<device>` on a client, `lanes web <device> --token` on the server, or `enable-phone.sh` |
803
+ | List, with the device each was created for | `lanes token list` |
804
+ | Sign one device out | `lanes token revoke <device>` (`--name token_N` for a token Lanes did not record) |
805
+ | Sign a whole machine out when it leaves (its phone and browser views too) | `lanes offboard <device> --yes` |
806
+ | Sign every browser and phone out | `lanes token revoke-all --yes` |
807
+
808
+ Lanes never prints or logs a token itself. Existing tokens stay valid when you create a new one. Zellij 0.45.1
809
+ names tokens `token_1`, `token_2`, … and cannot name them after a device, so Lanes records which device each
810
+ `lanes web --token` was for (`~/.local/state/lanes/web-tokens.json`). Revoking any token but the newest would stop
811
+ Zellij creating new ones; `lanes token revoke` refuses that case and names `revoke-all`
812
+ ([RUNBOOK](RUNBOOK.md#login-tokens)).
813
+
814
+ The tokens live in `~/.local/share/zellij/tokens.db` on the server: `lanes-web.service` pins
815
+ `XDG_DATA_HOME=%h/.local/share` and the token commands use the same root, whatever your shell exports. If the
816
+ running server reads another database (one started before that pin, under a user manager with its own
817
+ `XDG_DATA_HOME`), `lanes doctor` shows a `✗`, and `lanes web --token` (exit 64) and `lanes token` refuse: run
818
+ `lanes web <device>`, then `systemctl --user restart lanes-web.service`, then mint the token again.
560
819
 
561
820
  ### The three shell modes
562
821
 
@@ -636,6 +895,25 @@ Full Disk Access is not needed; the allowlist and macOS's folder permission are
636
895
  Transfers have an idle timeout (two minutes without incoming data), not a total-duration limit. Logs:
637
896
  `~/.local/state/lanes-client/helper-service.log` (macOS) or `journalctl --user -u lanes-helper` (Linux).
638
897
 
898
+ **The upload helper and a passphrase** (Linux). A systemd user service cannot ask for a passphrase, so with a
899
+ protected key the helper logs in through your SSH agent — the one the systemd user manager holds, looked up again
900
+ before every login. The service starts at boot (`default.target`), often before your desktop session hands its agent
901
+ to the user manager (GNOME or KDE keyring, uwsm); it uses that agent from its next login attempt, at most a minute
902
+ later, without a restart. The installer hands the agent of the terminal it runs in to
903
+ the user manager (`systemctl --user import-environment SSH_AUTH_SOCK`) before it restarts the helper, and appends one
904
+ block between `# >>> lanes agent hand-off >>>` and `# <<< lanes agent hand-off <<<` to the file your login shell reads
905
+ (zsh `~/.zprofile`; bash the first of `~/.bash_profile`, `~/.bash_login`, `~/.profile` that exists; other shells
906
+ `~/.profile`). At each local login the block hands that login's agent over and restarts the helper — only when the
907
+ user manager does not already have that agent, and never from an SSH login (`SSH_CONNECTION`), whose forwarded agent
908
+ disappears with it. A re-run adds nothing; `--uninstall` and `--no-uploads` remove the block (backup kept). Installed
909
+ over SSH, nothing is handed over until your next local login. fish reads no POSIX profile: the installer names the
910
+ line to add instead.
911
+ A profile the installer cannot write (a read-only dotfiles checkout, a root-owned or immutable file, a full disk) is
912
+ left exactly as it was — a write that stops part-way is put back from its backup — and the installer names the line
913
+ to add, hands this terminal's agent over and finishes; `--uninstall` likewise leaves the block in such a file and says
914
+ which lines to delete.
915
+ The agent must hold the key (`AddKeysToAgent yes` in the `lanes` alias adds it on the first interactive login).
916
+
639
917
  | Strip says | Meaning | Do |
640
918
  | --- | --- | --- |
641
919
  | `✗ not allowed on this machine: … - run: lanes-upload --allow FOLDER` | The file's folder is not on the allowlist (or you chose Deny) | `lanes-upload --allow <folder>`, then drop again |
@@ -657,7 +935,9 @@ catalog.json ──► workspaces.<workspace>.json ──► <repo>/.vscode/term
657
935
 
658
936
  The site directory is `$LANES_WORKSPACES`, else `~/.config/lanes/workspaces/` when it holds a `catalog.json`, else
659
937
  `workspaces/` in the checkout. Start from `lanes/workspaces.example/` ([reference](../workspaces.example/README.md)).
660
- After any edit: `lanes sync && lanes doctor`.
938
+ For a board with one repository, `lanes workspaces add <id> --container <name> --repo /home/coder/<repo> --write`
939
+ writes both files below. Without `--write` it prints the diff only. An id already in the catalog is checked, never
940
+ rewritten. A repository that another board already declares is refused. After any edit: `lanes sync && lanes doctor`.
661
941
 
662
942
  **`catalog.json`**: `version` (1), `workspaces`: `{id, title, manifest}` in tab order. The `id` is also the upload
663
943
  and watch-folder name; `title` is the tab. Changing a title renames the existing tab. Optional `sshWorkspaces` adds
@@ -668,7 +948,9 @@ fixed terminals on another host ([README](../README.md#register-a-workspace-once
668
948
  to lane titles), `displayNames` (display overrides keyed by terminal name), `bindingOverrides` (a tmux binding for a
669
949
  terminal that has none), and `cursor` (a second file to project the workspace into). `remoteShell.cwd` sets the
670
950
  working directory; `monitor` adds the Throttle monitor tab; `discoveryRoots` extends the directories that associate
671
- unregistered sessions with the workspace.
951
+ unregistered sessions with the workspace. A repository belongs to one board: `lanes cursor-sync` rewrites each
952
+ `registry` (and `cursor` file) with the lane ids of the board that names it, so a file two boards name flips on every
953
+ run.
672
954
 
673
955
  **`.vscode/terminals.json`** (in the repository, inside the container):
674
956
 
@@ -786,6 +1068,13 @@ Names must be non-empty and free of control characters. The id, resolve file and
786
1068
  lanes bound to a numeric session name, more than 4 lanes on one container's tmux socket `default`, orphan tmux
787
1069
  socket files in a container (report only; inside the container `~/.lz/bin/lane gc --sockets` removes the ones tmux
788
1070
  is refused on), and records left in the retired `legacy-sessions/` directory.
1071
+ - Browser and phone views: `lanes-web` / `lanes-mobile` active (`✓`), not enabled (`⚠`: no browser view yet, or
1072
+ running without being enabled), enabled but not running (`✗`); the login-token database the running `lanes-web`
1073
+ reads, from its own environment: the one `lanes token` uses (`✓`), another one (`✗`, see
1074
+ [Tokens](#tokens)), or not compared (`⚠`); the Tailscale Serve mapping `https:8443 -> http://127.0.0.1:8083`
1075
+ present (`✓`), missing or pointing elsewhere (`✗` once a web unit is enabled, `⚠` before), or not checked (`⚠`: no
1076
+ tailscale, no answer, or no Tailscale name on the host — then `lanes web` configures no Serve and prints a loopback
1077
+ URL, so doctor does not ask for the mapping either).
789
1078
 
790
1079
  ## 7. Agents in lanes
791
1080
 
@@ -804,9 +1093,39 @@ anything, picks the first signal that applies:
804
1093
 
805
1094
  A Codex title left on a bare shell with no child process is `shell` (the harness ended).
806
1095
 
1096
+ ### A user's agents in one step: `lanes onboard`
1097
+
1098
+ On the server, one command gives a new user's workspace container what its agents need. Without `--write` it prints
1099
+ every diff and command and changes nothing:
1100
+
1101
+ ```sh
1102
+ lanes onboard anna --container ws-anna --workspace anna-tools --repo /home/coder/tools # the plan
1103
+ lanes onboard anna --container ws-anna --workspace anna-tools --repo /home/coder/tools --write # apply it
1104
+ ```
1105
+
1106
+ | Step | What the agents get |
1107
+ | --- | --- |
1108
+ | Board | A workspace tab for the repository (`lanes workspaces add`, section 6) on every device's view |
1109
+ | Contract | `lane`, `lane-run`, `lane-signal` and the hook definitions in the container's `~/.lz` (`lanes install-container`) |
1110
+ | Hooks | The lane-signal hooks for Claude Code and Codex ("Hooks" below), so the sidebar's agent glyphs are event-driven |
1111
+ | Throttle | `throttle setup --install-clients` inside the container when Throttle is installed and configured there, so the agents' requests go through the pool; otherwise the one-time setup to run on a terminal is printed (a first setup signs in, which needs a person; Lanes runs without Throttle) |
1112
+ | Recipes | `lanes cursor-sync <id> --canonicalize --write` when the repository has a `.vscode/terminals.json`: each lane's create recipe gives it its identity and trace ("Lane identity" below) |
1113
+ | Sync and doctor | The registry knows the new lanes; `lanes doctor`'s result is reported, never fatal |
1114
+
1115
+ The receipt is `~/.local/state/lanes/onboard/<login>-<UTC>.json`, and running the command again changes nothing that
1116
+ is already done. `--no-mcp` leaves out `lane-mcp`, the in-container MCP server, which has not shipped yet. Every step
1117
+ and status: [install guide](INSTALL.md#onboard-a-users-container).
1118
+
1119
+ **Laptop agents on the pool (operator-gated).** Agents a user runs on their own laptop, outside any lane, can use the
1120
+ same Throttle pool over the tailnet once the operator shares it (`throttle remote share --tailnet`, unpublished). When
1121
+ the server's `~/.config/lanes/site.conf` names `LANES_THROTTLE_REMOTE_URL`, `lanes onboard` prints and records that
1122
+ step for the user: `throttle remote join <pairing code>` on the laptop
1123
+ ([install guide](INSTALL.md#laptop-agents-on-the-throttle-pool-operator-gated)).
1124
+
807
1125
  ### Hooks (opt-in, per container, dry run first)
808
1126
 
809
- Hooks make the state event-driven. Nothing installs one for you.
1127
+ Hooks make the state event-driven. Nothing installs one unasked: `lanes onboard --write` (above) installs both, or
1128
+ install them one by one.
810
1129
 
811
1130
  ```sh
812
1131
  lanes install-container <name> # puts lane-signal in ~/.lz/bin
@@ -816,7 +1135,9 @@ lanes hooks install --agent codex --container <name> --write # ~/.codex/hook
816
1135
  ```
817
1136
 
818
1137
  `lanes hooks install --agent claude|codex (--container <name> | --home <dir>) [--dry-run | --write]`: a dry run
819
- unless `--write`; your own hooks and keys stay, and an earlier lane-signal install is replaced, not duplicated. For
1138
+ unless `--write`; your own hooks and keys stay, and an earlier lane-signal install is replaced, not duplicated. An
1139
+ install that is already current is left where it is, even when another tool (Throttle's `setup --install-clients`)
1140
+ has put its own group before or after it or re-sorted the file's keys, so running it again writes nothing. For
820
1141
  Codex it also adds a top-level `notify` to `~/.codex/config.toml` only when none exists. Codex asks you to review and
821
1142
  trust new hooks; Lanes never writes that trust. Expect hooks to reach agent sessions started after the install
822
1143
  (inferred from how both harnesses read their configuration, not measured). Undo:
@@ -930,8 +1251,11 @@ Covered in section 3: one notification per lane entering Needs you, from the sid
930
1251
  ### Push notifications (opt-in, operator gate)
931
1252
 
932
1253
  `lanes-notify.service` sends one push to an [ntfy](https://ntfy.sh) topic when a lane's agent enters needs you
933
- (`Needs you`, `Needs approval`, `Error`). It reads only `status.json` and the registry, every 3 s. A push says
934
- `<lane name>: <agent task> — <status>` and `lanes-launch lane <id>`. It sends lane names and task titles to the server
1254
+ (`Needs you`, `Needs approval`, `Error`). It reads only `status.json`, the registry and `enrol-requests/`, every 3 s.
1255
+ A push says
1256
+ `<lane name>: <agent task> — <status>` and `lanes-launch lane <id>`. It also pushes once per new enrolment request
1257
+ (`Enrol request`: `<name or login> · <device>` and `lanes enrol approve <id>`), never muted by presence, once per
1258
+ request even across restarts. It sends lane names, task titles and requesters' names and device names to the server
935
1259
  you configure, so enabling it is your decision. Not yet run against a real ntfy server or phone.
936
1260
 
937
1261
  `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` at install time renders another path), `key=value` lines:
@@ -940,7 +1264,7 @@ you configure, so enabling it is your decision. Not yet run against a real ntfy
940
1264
  | --- | --- | --- |
941
1265
  | `url` | required | The ntfy server, `https://…` |
942
1266
  | `topic` | required | `[A-Za-z0-9_-]{1,64}`; on a public server the topic name is the only secret |
943
- | `token_file` | unset | A file holding an ntfy access token (an unreadable file disables sending) |
1267
+ | `token_file` | unset | A file holding an ntfy access token (an unreadable file disables sending; written, rotated or made readable later, it is picked up within 3 s) |
944
1268
  | `quiet_when_device_active_s` | `120` | Presence window; `0` never suppresses |
945
1269
  | `action_url` | unset | A URL template over `{lane}`; becomes the tap target and an **Open lane** button |
946
1270
 
@@ -981,8 +1305,9 @@ the per-device flags; the variables matter when you run the sidebar by hand.
981
1305
 
982
1306
  **Ghostty palette** (opt-in): by default the lanes window inherits your everyday Ghostty look. `./install.sh --palette`
983
1307
  installs `~/.config/lanes/ghostty-palette.conf` generated from the tokens (a light/dark theme pair on Ghostty 1.1 or
984
- newer when your everyday config sets no colours explicitly, else the dark palette) and includes it last; a plain
985
- re-run removes it. Not yet verified on a real Ghostty: the window background should then be `#101318`.
1308
+ newer when your everyday config sets no colours explicitly, else the dark palette) and includes it last; the choice
1309
+ is remembered, and `--no-palette` removes it. Not yet verified on a real Ghostty: the window background should then
1310
+ be `#101318`.
986
1311
 
987
1312
  **Fonts**: no Nerd Font is needed. Any monospace font with Latin-1, Box Drawing, Geometric Shapes, Dingbats, Arrows and
988
1313
  `⋮ ⏎ ⚠ …` renders the cockpit; `--ascii` is the remedy for any other font.
@@ -1036,12 +1361,13 @@ All units run in `lanes.slice` and are rendered by the server installer from `la
1036
1361
  | `lanes-sync.timer` / `.service` | Every 2 min | Server install | `lanes sync` |
1037
1362
  | `lanes-ensure.timer` / `.service` | Every 1 min | Server install | `lanes ensure`: recreate missing device view sessions |
1038
1363
  | `lanes-gc.timer` / `.service` | Daily | Server install | `lanes gc`: truncate zmx logs over 5 MB, drop stale dynamic resolve files |
1039
- | `lanes-web.service` | Always on | `enable-web.sh` (via `enable-phone.sh` / `lanes web`) | Zellij web server on `127.0.0.1:8082` |
1364
+ | `lanes-enrold.service` | Always on | Server install (enabled and restarted by every one) | Enrolment service on `127.0.0.1:8184`, reached as `https://<host>:8443/enrol/*`: invite redemption, enrolment requests, `/enrol/pending` approvals ([RUNBOOK](RUNBOOK.md#enrolment-service-lanes-enrold-w8-a3)) |
1365
+ | `lanes-web.service` | Always on | `enable-web.sh` (via `lanes web` / `enable-phone.sh`) | Zellij web server on `127.0.0.1:8082` |
1040
1366
  | `lanes-mobile.service` | Always on | `enable-web.sh` | Caddy frontend on `127.0.0.1:8083` |
1041
1367
  | `lanes-studio-host.service` | Always on | `enable-studio-host.sh` (opt-in) | The co-located Throttle Studio Host |
1042
1368
  | `lanes-studio-host-enrol.timer` / `.service` | Every 5 min | `enable-studio-host.sh` (opt-in) | Adopt a new receipt or key, then start the Host |
1043
1369
  | `lanes-economy.service` | Every 10 s | Nobody (opt-in, operator gate) | Economy projection `economy.json` |
1044
- | `lanes-notify.service` | Every 3 s | Nobody (opt-in, operator gate) | ntfy push on needs-you transitions |
1370
+ | `lanes-notify.service` | Every 3 s | Nobody (opt-in, operator gate) | ntfy push on needs-you transitions and new enrolment requests |
1045
1371
  | `lanes-tmpreap.timer` / `.service` | Hourly | Nobody (opt-in) | `lanes tmp reap`; a dry run until `~/.local/state/lanes/flags/tmpreap.armed` exists |
1046
1372
  | `lanes-guard.timer` / `.service` | Every 60 s | Nobody (opt-in) | `lanes guard --once`: raise `oom_score_adj` of builds in the containers |
1047
1373
 
@@ -1059,9 +1385,11 @@ Clients also get `lanes-helper.service` and `lanes-upload.path`/`.service` (Linu
1059
1385
  | Registry | `lanes register <lane-id> [workspace]` | Add an unregistered session to a workspace without restarting it |
1060
1386
  | Registry | `lanes rename <lane-id> <new-name>` | Change a lane's display name (not in the usage text) |
1061
1387
  | Registry | `lanes workspaces` | Print the workspace ids (used by the client installer) |
1388
+ | Registry | `lanes workspaces add <id> --container <name> --repo <path> [--title T] [--session S] [--write]` | Add a one-repository board to the site catalog (a dry run until `--write`; section 6) |
1062
1389
  | Views | `lanes attach <device> [--client ID] [--mobile] [--viewports N] [--ascii] [--glyphs …] [--theme …] [--color …]` | Attach this terminal to the device's view (create it if needed); record the look |
1063
1390
  | Views | `lanes prepare <device> [--mobile\|--desktop] [--viewports N]` | Create a view without attaching; a different explicit layout is refused |
1064
- | Views | `lanes web <device> [--desktop\|--mobile] [--viewports N] [--token \| --print-only]` | Browser view in one step (section 4) |
1391
+ | Views | `lanes web <device> [--desktop\|--mobile] [--viewports N] [--token \| --print-only]` | Browser view in one step, Tailscale Serve included (section 4) |
1392
+ | Views | `lanes token list` / `revoke <device>` / `revoke --name NAME` / `revoke-all [--yes]` | Web login tokens and their devices (section 4) |
1065
1393
  | Views | `lanes open <lane-id>` | Attach this terminal straight to one lane |
1066
1394
  | Views | `lanes layout <device> [--mobile]` | Print the generated Zellij layout |
1067
1395
  | Views | `lanes rebuild <device>` | Recreate a device's view (agents never touched) |
@@ -1073,11 +1401,19 @@ Clients also get `lanes-helper.service` and `lanes-upload.path`/`.service` (Linu
1073
1401
  | Views | `lanes preview --focused [--device D]` | What `Ctrl-Alt-m` runs |
1074
1402
  | Editor | `lanes cursor-sync [ws…] [--write] [--diff] [--canonicalize] [--identity]` | Section 6 |
1075
1403
  | Editor | `lanes cursor-commit [ws…] [--push]` | Section 6 |
1076
- | Containers | `lanes install-container <name>` | Install the `lane` contract (section 2) |
1404
+ | Containers | `lanes install-container <name> [--no-mcp]` | Install the `lane` contract (section 2); `--no-mcp` is for the future `lane-mcp` and changes nothing today |
1405
+ | Containers | `lanes onboard <login> --container <name> [--workspace <id> --repo <path> [--title T]] [--write] [--no-mcp]` | One user's container in one command: board, contract, Claude and Codex hooks, Throttle attachment, canonical recipes, sync, doctor, and a receipt (a dry run until `--write`; section 2) |
1077
1406
  | Containers | `lanes hooks install --agent claude\|codex (--container <name> \| --home <dir>) [--dry-run \| --write]` | Section 7 |
1078
1407
  | Uploads | `lanes upload <ws> \| --active <device> [--paste]` | Read a tar stream into that workspace's `uploads/` |
1079
1408
  | Client | `lanes hello [device]` | Prove path, key and server version |
1080
1409
  | Client | `lanes client-config` | Site facts for clients (`LANES_*` lines from `~/.config/lanes/site.conf`) |
1410
+ | Access | `lanes keys list \| add <device> <pubkey-file\|-> \| revoke <device> \| adopt [--dry-run]` | Who may log in: the lanes-managed block of `~/.ssh/authorized_keys`, projected from `members/` |
1411
+ | Access | `lanes keys owner <device> <login> [--name NAME] [--email ADDR]` | Name the person behind a member; a key added by hand has none, and `/enrol/pending` lets in only a login a member names ([RUNBOOK](RUNBOOK.md#enrolment-service-lanes-enrold-w8-a3)) |
1412
+ | Access | `lanes invite <name> [--email ADDR] [--expires 7d] [--workspaces a,b] [--tailscale-link URL] [--md]` | Mint a one-time code and print the welcome kit, on a terminal only (section 2, "Invite a user") |
1413
+ | Access | `lanes invite list \| revoke <id>` | The invites and their state; refuse a code from now on |
1414
+ | Access | `lanes enrol list [--all] \| approve <id> [--as <device>] \| deny <id> [--as <device>]` | The enrolment requests; decide one as `/enrol/pending` does |
1415
+ | Access | `lanes enrol mode [request\|invite\|auto]` | Pin request mode across restarts (or say what is pinned) |
1416
+ | Access | `lanes offboard <device> --yes` | Revoke the key, forget the device, sign out the web login tokens of the machine and its views, end its view (own terminal only) |
1081
1417
  | Health | `lanes doctor` | Full health check (section 6) |
1082
1418
  | Health | `lanes pressure` | Memory / io / cpu pressure, swap, per-container memory and `/tmp`; plain files only; exit 1 unless ok |
1083
1419
  | Health | `lanes invariant save\|check` | Snapshot / verify every live pane's pid and window size |
@@ -1096,9 +1432,12 @@ The client helpers call three more verbs (`rebuilt`, `upload-requests`, `upload-
1096
1432
  | --- | --- |
1097
1433
  | `lanes-window [--ascii] [--light\|--dark] [suffix]` | Open the lanes window (section 3) |
1098
1434
  | `lanes-launch [device \| workspace \| lane <id> \| ls]` | The reconnecting client (section 3) |
1435
+ | `lanes-launch web phone\|laptop\|<device> [--desktop\|--mobile] [--no-qr]` | A browser view plus a login token, in this terminal (section 4) |
1436
+ | `lanes-launch update [install.sh options]` | The newest client from npm with your remembered install options (section 2) |
1099
1437
  | `lanes-launch --helper <device> [--service \| --while-pid PID]` | Run only the upload helper (the service form is what the installer sets up) |
1100
1438
  | `lanes-upload …` | Uploads (section 5) |
1101
- | `sh install/enable-phone.sh …` | Phone or browser view plus token (section 4) |
1439
+ | `sh install/enable-phone.sh …` | Phone or browser view plus token; on the server a wrapper over `lanes web … --token` (section 4) |
1440
+ | `npx @vibelord/lanes --invite <code>` / `--site NAME` / `--resume` | Join a site with an invite, install or update without one, wait again for an access request (section 2) |
1102
1441
  | `./install.sh …` | Install, check, update, uninstall (section 2) |
1103
1442
 
1104
1443
  ### State files
@@ -1118,13 +1457,22 @@ Server (`~/.local/state/lanes/`, `$LANES_STATE`):
1118
1457
  | `user-sessions/<id>.json` | Names of unregistered sessions |
1119
1458
  | `upload-requests/<device>/` | Upload hand-off between sidebar and helper |
1120
1459
  | `economy.json`, `economy.cursor.json` | The opt-in economy projection and its log cursor |
1460
+ | `notify.enrol-seen.json` | The enrolment requests the opt-in push notifier already announced (0600, pruned to the requests on disk); deleting it announces the ones still pending once more |
1121
1461
  | `flags/` | `coexist`, `readonly`, `tmpreap.armed`, `sockets-lost` |
1122
1462
  | `cursor-sync-backups/` | Backups taken by `cursor-sync --write` |
1123
1463
  | `invariant.json` | `lanes invariant save` |
1124
1464
  | `tmpscan/` | `lanes tmp` plans and `deleted.log` |
1125
1465
  | `studio-host/` | Optional Studio Host: public key, receipt, enrolment record |
1126
-
1127
- Other server paths: `~/.config/lanes/` (`workspaces/`, `site.conf`, `notify.conf`), `~/.local/share/lanes/` (pinned
1466
+ | `members/<device>.json`, `members.lock` | Who each enrolled machine key belongs to (`lanes keys`; the managed block of `~/.ssh/authorized_keys` is its projection) and the lock every writer of it holds |
1467
+ | `invites/<id>.json` | Invites minted by `lanes invite`: the code's hash only (never the code), who minted it, expiry, invitee, and the redemption or revocation |
1468
+ | `enrol-requests/<id>.json` | Enrolment requests waiting for, or past, a member's approval (24 h) |
1469
+ | `enrol.log` | The enrolment audit log, one line per attempt, invite, revocation, offboard and mode change (0600, append-only; never a code) |
1470
+ | `enrol-mode` | Request mode as `lanes enrol mode request\|invite` pinned it (0600; absent = auto); the enrolment service reads it on every request |
1471
+ | `web-tokens.json`, `web-tokens.lock` | Which view — and which machine — each web login token `lanes web --token` minted was for (names and creation times only, never a token; `lanes offboard` signs a machine out by it) and the lock every mint and revocation holds (section 4, "Tokens") |
1472
+ | `onboard/<login>-<UTC>.json` | One receipt per `lanes onboard` run: each step with its status (section 7) |
1473
+
1474
+ Other server paths: `~/.config/lanes/` (`workspaces/`, `site.conf`, `notify.conf`, and the optional invite key files
1475
+ `resend.key` and `tailscale-api.key`), `~/.local/share/lanes/` (pinned
1128
1476
  binaries in `bin/`, the `current` checkout link, npm-staged `pkg/<version>/`), and `~/.lz/<container>` (links to each
1129
1477
  container's `.lz`).
1130
1478
 
@@ -1132,9 +1480,9 @@ In each container (`/home/coder/.lz/`): `bin/` (`lane`, `lane-run`, `lane-signal
1132
1480
  definitions), `env.sh`, `registry.json` (the projection `lanes sync` writes), `state/hooks/` (hook sidecars),
1133
1481
  `state/<zmx name>.json` (`lane-run` phase records), `meta/` and `s/` (zmx metadata and sockets).
1134
1482
 
1135
- Client: `~/.config/lanes/` (`device`, `ghostty.conf`, `client-site.conf`, `client-known-hosts`, `site.cache`,
1136
- `upload-allow`, `uploads-dir`), `~/.local/state/lanes-client/` (`launch-<target>.log`, `helper.alive`,
1137
- `helper-service.log`), `~/.local/share/lanes/client/`.
1483
+ Client: `~/.config/lanes/` (`device`, `install-options`, `ghostty.conf`, `client-site.conf`, `client-known-hosts`,
1484
+ `site.cache`, `upload-allow`, `uploads-dir`, and `enrol-request` while an access request is open),
1485
+ `~/.local/state/lanes-client/` (`launch-<target>.log`, `helper.alive`, `helper-service.log`), `~/.local/share/lanes/client/`.
1138
1486
 
1139
1487
  ### NEVER
1140
1488
 
@@ -1162,8 +1510,19 @@ From the [RUNBOOK](RUNBOOK.md#never):
1162
1510
  | Phone keyboard does not open, or opens unexpectedly | The keyboard opens only from **Type here** or an input | Tap **Type here**; quick keys never open it | [RUNBOOK](RUNBOOK.md#web--phone) |
1163
1511
  | Phone shows no counts | Phone view laid out before the picker change | `lanes rebuild <device>` | [RUNBOOK](RUNBOOK.md#web--phone) |
1164
1512
  | `lanes web` printed a `127.0.0.1` URL | The host has no Tailscale name | Connect Tailscale on the host; there is no public fallback | [INSTALL](INSTALL.md#laptop-and-tablet-browser) |
1513
+ | `lanes web` exits 64 and prints `sudo tailscale set --operator=…` | The workspace account may not manage Tailscale Serve (or HTTPS is off for the tailnet) | Run the printed step as the server admin, then `lanes web` again | [RUNBOOK](RUNBOOK.md#tailscale-serve) |
1514
+ | `lanes doctor`: `✗ lanes-web.service reads login tokens from …`, or a fresh token is refused at sign-in | The running web server reads another token database than `lanes token` (started before the `XDG_DATA_HOME` pin) | `lanes web <device>`, `systemctl --user restart lanes-web.service`, then `lanes web <device> --token` | [RUNBOOK](RUNBOOK.md#login-tokens) |
1515
+ | `lanes web --token` says the next token name exists | Zellij 0.45.1 names tokens `token_<count + 1>`; an older token was revoked | `lanes token revoke-all --yes` (every browser signs in again) | [RUNBOOK](RUNBOOK.md#login-tokens) |
1165
1516
  | Upload `✗ not allowed on this machine` | Folder not on the helper's allowlist | `lanes-upload --allow <folder>` | [RUNBOOK](RUNBOOK.md#uploads-the-cursor-uploads-folder-without-cursor) |
1166
1517
  | Upload `✗ no upload helper` | Helper service not serving | `./install.sh --check` on the client | [RUNBOOK](RUNBOOK.md#what-you-see-and-what-it-means) |
1518
+ | Installer: `tailscale: not installed`, `needs login` or `not on the tailnet after 120 s`, exit 4 | This machine is not in the tailnet the profile's server lives in | Install or connect Tailscale (the installer opens `tailscale login` itself), then run the installer again | [Section 2](#tailscale) |
1519
+ | Installer: `invite: not redeemed — the server answered …` or `enrolment: no answer from …`, exit 3 | The code was refused (used, expired, revoked) or the server runs no enrolment service yet | Ask a member for a new invite, or send the printed public key to the administrator | [Section 2](#no-enrolment-service-the-administrator-authorizes-the-key) |
1520
+ | Installer: `request: no answer within 10:00 — it stays open`, exit 3 | No member has approved the access request yet | `npx @vibelord/lanes --resume` (or `./install.sh --resume`) waits again | [Section 2](#no-code-ask-for-access) |
1521
+ | A phone or browser of an offboarded machine still signs in | Its token is not attributed to the machine: minted before 1.1.0, outside `lanes web --token`, or `lanes offboard` warned or ended incomplete at step 3 | Read offboard's step 3 line; `lanes token list`, then `lanes token revoke --name token_N` (or `lanes token revoke-all --yes`) | [install guide](INSTALL.md#offboard-a-machine) |
1522
+ | Installer: `invite: not redeemed — the server answered 409 (device-taken)` (or `key-taken`), exit 3 | The server already has a device of that name (two machines with one hostname, or an earlier enrolment), or holds this key for another device | The installer names the re-run: the same command with `--device NAME` (the invite is still valid) or with a key of its own; if the old machine is gone, a member runs `lanes offboard <device> --yes` | [Section 2](#join-with-an-invite) |
1523
+ | `lanes-launch web`: "the server runs lanes 1.0.x, older than this client" | This client is newer than the server (a client updates itself first) | The view and its token work; there is no QR code and the view's upload box does not reach this machine until the server runs `npx @vibelord/lanes@latest --server` | [Versions](#versions) |
1524
+ | `/enrol/pending`: "Refused: the browser did not say where the form came from (no Sec-Fetch-Site)" | The Approve/Deny POST came without the browser's `Sec-Fetch-Site` header: curl or a script, or a browser that does not send it | Approve from a current browser, or on the server: `lanes enrol approve <id>` | [install guide](INSTALL.md#approve-a-request) |
1525
+ | Nobody can file an access request after the server restarted (`requests-disabled`), although it worked before | Request mode is unpinned and the first request through Serve after the restart carried no tailnet identity (a tagged device) | On the server: `lanes enrol mode request` (after the header check) | [install guide](INSTALL.md#approve-a-request) |
1167
1526
  | Installer: "an older server?" / `lane open`: "registry is newer than this lane" / "site file … newer schema" | Client, server or container contract at different versions | Update the server (`./install.sh --server`), re-run `lanes install-container <name>`, update the client checkout | Section 2 |
1168
1527
  | Client window holds with a countdown | Connection lost or refused | Leave it open; `Enter` retries now | [RUNBOOK](RUNBOOK.md#what-you-see-and-what-it-means) |
1169
1528
  | Wrong colours in Codex's input box | Codex sampled another client's background at start | Use the same light/dark choice on every client | [RUNBOOK](RUNBOOK.md#tuis-and-the-terminals-colours-codexs-dark-box-the-stars) |
@@ -1190,9 +1549,15 @@ Server and views:
1190
1549
  | `LANES_NOTIFY_CONF` | `libexec/notify.py` | `~/.config/lanes/notify.conf` | Push notifier config path |
1191
1550
  | `LANES_HOST_GUARD_CONF` | `sudo/host-guard.sh` | `/etc/lanes-host-guard.conf` | Root guard config |
1192
1551
  | `LANES_PROC_ROOT`, `LANES_CGROUP_ROOT` | `bin/lanes`, `libexec/guard.py`, `libexec/view.sh` (the first); `libexec/guard.py` (the second) | `/proc`, `/sys/fs/cgroup` | Test injection only |
1552
+ | `LANES_ENROL_PORT` | `libexec/enrold.py` | `8184` | The enrolment service's loopback port (the Caddyfile's `/enrol/*` route must follow) |
1553
+ | `LANES_ENROL_DOMAINS` | `libexec/enrold.py` | `beyondwork.ai` | Login domains that may file an access request or approve one on `/enrol/pending` (comma or space separated; `*` = any); invites are not gated |
1554
+ | `LANES_ENROL_REQUEST_MODE` | `libexec/enrold.py` | unset | `1` forces request mode on, `0` forces invite-only, over any `lanes enrol mode` pin; unset, the pin decides, and without one the first request through Tailscale Serve after a start (RUNBOOK "Enrolment service") |
1555
+ | `LANES_DEVICE` | `libexec/enrol.py` (`lanes invite`) | unset | Recorded as `minted_by.device` of an invite when it is a device name (a claim, not an identity) |
1556
+ | `SSH_CLIENT` | `libexec/enrol.py` (`lanes enrol approve\|deny`) | set by sshd | Its address is recorded in the approver `cli[:<device>]@<address>`; `local` without one |
1193
1557
  | `LANES_TESTS_FORCE` | `tests/guards.py` | unset | `1` runs live suites on a busy host (on purpose only) |
1194
1558
 
1195
- `LANES_THROTTLE_*` and `LANES_NOTIFY_CONF` set in the installer's environment are rendered into their units only.
1559
+ `LANES_THROTTLE_*` and `LANES_NOTIFY_CONF` set in the installer's environment are rendered into their units only. The
1560
+ `LANES_ENROL_*` settings go in a drop-in, `systemctl --user edit lanes-enrold.service`, which a re-install keeps.
1196
1561
  `LANES_REPO`, `LANES_ZELLIJ` and `LANES_WS` are set by Lanes itself for its units and panes.
1197
1562
 
1198
1563
  Client:
@@ -1200,8 +1565,12 @@ Client:
1200
1565
  | Variable | Read by | Default | Effect |
1201
1566
  | --- | --- | --- | --- |
1202
1567
  | `LANES_HOST` | `lanes-launch`, `lanes-upload` | `lanes` | SSH alias of the server |
1203
- | `LANES_FALLBACK_HOST` | `lanes-launch` | `lanes-public` | Fallback SSH alias |
1568
+ | `LANES_FALLBACK_HOST` | `lanes-launch` | `lanes-ip lanes-public` | Fallback SSH aliases, tried in order (each only when the SSH config defines it) |
1204
1569
  | `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME` | `install.sh`, client scripts | `~/.config`, `~/.local/share`, `~/.local/state` | Where client files go |
1570
+ | `LANES_TAILSCALE_WAIT` | `install.sh` | `120` | Seconds an install waits for this machine to join the tailnet (a `.ts.net` profile) |
1571
+ | `LANES_INVITE` | `install.sh`, `npm/lanes` wrapper | unset | An invite code, as `--invite` (the wrapper picks the site from its prefix) |
1572
+ | `LANES_ENROL_WAIT`, `LANES_ENROL_POLL` | `install.sh` | `600`, `5` | Seconds an install waits for an access request's approval, and between two looks |
1573
+ | `LANES_ENROL_URL` | `install.sh` | `https://<LANES_SERVER_NAME>:8443` | The enrolment service's base URL (development and tests) |
1205
1574
  | `LANES_PKG_ROOT` | `npm/lanes` wrapper | `~/.local/share/lanes/pkg` | Where npm-staged trees go |
1206
1575
  | `LANES_NPM_ENV_FILE`, `LANES_NPM_TOKEN_KEY` | `npm/lanes/scripts/publish.sh` | `.env.local`, `NPMJS2` | Maintainers only: the publish token's file and key |
1207
1576
 
@@ -1221,16 +1590,20 @@ Container:
1221
1590
 
1222
1591
  | Command | Codes |
1223
1592
  | --- | --- |
1224
- | `install.sh`, `npx @vibelord/lanes` | `0` ready · `3` key not yet authorized · `4` server unreachable or too slow · `1` error |
1593
+ | `install.sh`, `npx @vibelord/lanes` | `0` ready · `3` key not yet authorized (no enrolment service, a refused invite, or an unanswered request) · `4` not in the tailnet, server unreachable or too slow · `1` error (also: a malformed invite code, or one for another site) |
1225
1594
  | `install.sh --server` (`host-install.sh`) | `0` · `1` missing prerequisite, not Linux x86_64, or run as root · `2` unknown option |
1226
1595
  | `lanes <verb>` | `0` · `1` error (message `lanes: …`) or unknown verb (usage printed) |
1227
1596
  | `lanes sync` | `1` when any workspace's registry had errors (previous lanes kept) |
1228
1597
  | `lanes doctor`, `lanes pressure`, `lanes invariant check`, `lanes sockets` | `1` when something needs attention |
1229
- | `lanes attach <workspace-id>` without a default device, `lanes web --token` without a terminal | `64` |
1598
+ | `lanes attach <workspace-id>` without a default device, `lanes web --token` without a terminal or while the running `lanes-web.service` reads another token database, `lanes web` when Tailscale Serve cannot be set (operator step printed) | `64` |
1599
+ | `lanes token revoke-all` without `--yes` | `2` (nothing changed) |
1230
1600
  | `lanes preview` / `next` / `prev` without `--focused` | `2` |
1231
1601
  | `lanes studio-host status` | `0` only when running enrolled; `1` otherwise; `2` usage |
1602
+ | `lanes invite <name>` | `0` minted (the kit printed; a failed email or Tailscale mint is a note, not an error) · `2` not on a terminal, nothing minted · `1` error |
1603
+ | `lanes invite revoke <id>` | `0` revoked or already revoked · `1` already redeemed (offboard the device) or no such invite |
1604
+ | `lanes offboard <device> --yes` | `0` · `2` without `--yes` or not on a terminal (the plan printed, nothing changed) · `1` stopped at the key or device step, a key line left outside the block, the web login tokens could not be revoked, or the view could not be ended |
1232
1605
  | `enable-studio-host.sh` | `0` running or waiting for enrolment · `2` usage · `3` typed refusal · `1` failure |
1233
- | `enable-phone.sh` | `0` · `2` usage · `64` not in your own terminal · `1` missing Tailscale or Serve permission |
1606
+ | `enable-phone.sh` | `0` · `2` usage · `64` not in your own terminal, Tailscale Serve refused, or the running `lanes-web` reads another token database (from `lanes web`) · `1` a `lanes web` error |
1234
1607
  | `enable-web.sh` | `0` · `2` usage · `1` Caddy missing |
1235
1608
  | `lanes-launch` | `64` invalid name or option (it never exits on its own otherwise); server exits 64–78 are refusals it retries after 60 s |
1236
1609
  | `lanes-window` | `64` invalid suffix · `69` no Ghostty (macOS) or neither Ghostty nor Foot (Linux) |