@vibelord/lanes 1.0.4 → 1.1.1

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 (38) 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 +41 -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 +93 -4
  11. package/payload/lanes/docs/INSTALL.md +296 -43
  12. package/payload/lanes/docs/RUNBOOK.md +506 -39
  13. package/payload/lanes/docs/USERGUIDE.md +465 -81
  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 +151 -22
  36. package/payload/lanes/web/mobile.js +95 -55
  37. package/payload/lanes/workspaces.example/README.md +6 -1
  38. 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
 
@@ -575,14 +834,24 @@ The frontend chooses once per page load, by capability:
575
834
  the client's own menu, where Lanes adds **Text size** (remembered on the phone). The light/dark toggle repaints the
576
835
  whole terminal.
577
836
  - The keyboard opens only when you tap **Type here** or another input. Swipes, live updates and reconnects leave it
578
- closed.
837
+ closed. **Type here** is an ordinary text field: the normal keyboard with its suggestion bar, never the password
838
+ keyboard. A suggestion you tap arrives as the whole word; long-press the field and choose **Paste** to paste.
579
839
  - The lane picker shows three rows per lane: the viewing mark, agent glyph and name; then what the agent needs, since
580
840
  when and the harness (`Needs you · 25m · claude`; a shell says `Quiet · 2h`); then the agent's task. Its first line
581
841
  counts the lanes that need you (`◆1 need approval · ◇2 need you · ▸6 working`). The footer says
582
842
  `Swipe to scroll · Tap to open`. The picker has no upload strip.
583
- - On a lane's own tab a row of quick keys sits above the dock: `⏎ y n Esc ^C ↑ ↓`. A tap sends the key without opening
584
- the keyboard; `n` and `^C` need a 400 ms hold (a short tap says "Hold to send").
585
- - The filter bar on the picker sends its text as UTF-8, so non-ASCII filters match.
843
+ - On a lane's own tab a row of quick keys sits above the dock: `⏎ y n Esc ^C ↑ ↓`. A tap sends the key to the lane
844
+ without opening the keyboard; `n` and `^C` need a 400 ms hold (a short tap says "Hold to send" in the header for
845
+ about a second and a half).
846
+ - **Copy** and **Paste** sit in the header, beside **More**. **Copy** puts the text on the screen on the clipboard
847
+ (Zellij's frames and bars included; a selection instead, when the terminal has one). **Paste** sends the clipboard to
848
+ the lane as one paste, line breaks kept; iOS first shows its own **Paste** button to confirm. The header then says
849
+ `Copied`, `Pasted` or `Clipboard blocked`. Neither closes the keyboard. The lane picker takes no paste (it would read
850
+ it as a file drop): there **Paste** is hidden, and a paste into **Type here** says `Open a lane to paste`.
851
+ - **More** opens the client's menu; tap it again to close the menu.
852
+ - The filter bar on the picker filters the lane list as you type, non-ASCII letters included: it sends each change
853
+ (a typed letter, a backspace) once, and clearing the field or pressing Esc closes the filter. On a picker tab the dock
854
+ shows the filter in place of **Panes**. Leaving the picker forgets the filter.
586
855
  - `+` and `×` on picker rows add and close unregistered sessions, as on the desktop; tapping `+` then a workspace
587
856
  needs no typing.
588
857
 
@@ -592,7 +861,8 @@ The frontend chooses once per page load, by capability:
592
861
  the same agents; the server keeps serving when your Mac sleeps.
593
862
  - Does not: a public URL (Tailscale only), uploads from a phone browser (the drop helper is a desktop client feature),
594
863
  approving an agent's permission prompt from a push notification.
595
- - Not yet verified on a device: the quick keys on a real iPhone, the light/dark terminal repaint, the counts that the
864
+ - Not yet verified on a device: the quick keys, the lane filter, the text keyboard, paste, **Copy** and **Paste** on a
865
+ real iPhone (the [runbook](RUNBOOK.md) lists the check), the light/dark terminal repaint, the counts that the
596
866
  picker's pane title carries to the Workspaces sheet and the **Lanes** button (a phone view created before that
597
867
  change needs `lanes rebuild <device>`; `lanes doctor` lists such devices), and an iPad Safari pass of the tablet mode
598
868
  (top bar, left panel, whole-tab view). The mode choice itself is unit-tested.
@@ -636,6 +906,25 @@ Full Disk Access is not needed; the allowlist and macOS's folder permission are
636
906
  Transfers have an idle timeout (two minutes without incoming data), not a total-duration limit. Logs:
637
907
  `~/.local/state/lanes-client/helper-service.log` (macOS) or `journalctl --user -u lanes-helper` (Linux).
638
908
 
909
+ **The upload helper and a passphrase** (Linux). A systemd user service cannot ask for a passphrase, so with a
910
+ protected key the helper logs in through your SSH agent — the one the systemd user manager holds, looked up again
911
+ before every login. The service starts at boot (`default.target`), often before your desktop session hands its agent
912
+ to the user manager (GNOME or KDE keyring, uwsm); it uses that agent from its next login attempt, at most a minute
913
+ later, without a restart. The installer hands the agent of the terminal it runs in to
914
+ the user manager (`systemctl --user import-environment SSH_AUTH_SOCK`) before it restarts the helper, and appends one
915
+ block between `# >>> lanes agent hand-off >>>` and `# <<< lanes agent hand-off <<<` to the file your login shell reads
916
+ (zsh `~/.zprofile`; bash the first of `~/.bash_profile`, `~/.bash_login`, `~/.profile` that exists; other shells
917
+ `~/.profile`). At each local login the block hands that login's agent over and restarts the helper — only when the
918
+ user manager does not already have that agent, and never from an SSH login (`SSH_CONNECTION`), whose forwarded agent
919
+ disappears with it. A re-run adds nothing; `--uninstall` and `--no-uploads` remove the block (backup kept). Installed
920
+ over SSH, nothing is handed over until your next local login. fish reads no POSIX profile: the installer names the
921
+ line to add instead.
922
+ A profile the installer cannot write (a read-only dotfiles checkout, a root-owned or immutable file, a full disk) is
923
+ left exactly as it was — a write that stops part-way is put back from its backup — and the installer names the line
924
+ to add, hands this terminal's agent over and finishes; `--uninstall` likewise leaves the block in such a file and says
925
+ which lines to delete.
926
+ The agent must hold the key (`AddKeysToAgent yes` in the `lanes` alias adds it on the first interactive login).
927
+
639
928
  | Strip says | Meaning | Do |
640
929
  | --- | --- | --- |
641
930
  | `✗ 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 +946,9 @@ catalog.json ──► workspaces.<workspace>.json ──► <repo>/.vscode/term
657
946
 
658
947
  The site directory is `$LANES_WORKSPACES`, else `~/.config/lanes/workspaces/` when it holds a `catalog.json`, else
659
948
  `workspaces/` in the checkout. Start from `lanes/workspaces.example/` ([reference](../workspaces.example/README.md)).
660
- After any edit: `lanes sync && lanes doctor`.
949
+ For a board with one repository, `lanes workspaces add <id> --container <name> --repo /home/coder/<repo> --write`
950
+ writes both files below. Without `--write` it prints the diff only. An id already in the catalog is checked, never
951
+ rewritten. A repository that another board already declares is refused. After any edit: `lanes sync && lanes doctor`.
661
952
 
662
953
  **`catalog.json`**: `version` (1), `workspaces`: `{id, title, manifest}` in tab order. The `id` is also the upload
663
954
  and watch-folder name; `title` is the tab. Changing a title renames the existing tab. Optional `sshWorkspaces` adds
@@ -668,7 +959,9 @@ fixed terminals on another host ([README](../README.md#register-a-workspace-once
668
959
  to lane titles), `displayNames` (display overrides keyed by terminal name), `bindingOverrides` (a tmux binding for a
669
960
  terminal that has none), and `cursor` (a second file to project the workspace into). `remoteShell.cwd` sets the
670
961
  working directory; `monitor` adds the Throttle monitor tab; `discoveryRoots` extends the directories that associate
671
- unregistered sessions with the workspace.
962
+ unregistered sessions with the workspace. A repository belongs to one board: `lanes cursor-sync` rewrites each
963
+ `registry` (and `cursor` file) with the lane ids of the board that names it, so a file two boards name flips on every
964
+ run.
672
965
 
673
966
  **`.vscode/terminals.json`** (in the repository, inside the container):
674
967
 
@@ -786,6 +1079,13 @@ Names must be non-empty and free of control characters. The id, resolve file and
786
1079
  lanes bound to a numeric session name, more than 4 lanes on one container's tmux socket `default`, orphan tmux
787
1080
  socket files in a container (report only; inside the container `~/.lz/bin/lane gc --sockets` removes the ones tmux
788
1081
  is refused on), and records left in the retired `legacy-sessions/` directory.
1082
+ - Browser and phone views: `lanes-web` / `lanes-mobile` active (`✓`), not enabled (`⚠`: no browser view yet, or
1083
+ running without being enabled), enabled but not running (`✗`); the login-token database the running `lanes-web`
1084
+ reads, from its own environment: the one `lanes token` uses (`✓`), another one (`✗`, see
1085
+ [Tokens](#tokens)), or not compared (`⚠`); the Tailscale Serve mapping `https:8443 -> http://127.0.0.1:8083`
1086
+ present (`✓`), missing or pointing elsewhere (`✗` once a web unit is enabled, `⚠` before), or not checked (`⚠`: no
1087
+ tailscale, no answer, or no Tailscale name on the host — then `lanes web` configures no Serve and prints a loopback
1088
+ URL, so doctor does not ask for the mapping either).
789
1089
 
790
1090
  ## 7. Agents in lanes
791
1091
 
@@ -804,9 +1104,39 @@ anything, picks the first signal that applies:
804
1104
 
805
1105
  A Codex title left on a bare shell with no child process is `shell` (the harness ended).
806
1106
 
1107
+ ### A user's agents in one step: `lanes onboard`
1108
+
1109
+ On the server, one command gives a new user's workspace container what its agents need. Without `--write` it prints
1110
+ every diff and command and changes nothing:
1111
+
1112
+ ```sh
1113
+ lanes onboard anna --container ws-anna --workspace anna-tools --repo /home/coder/tools # the plan
1114
+ lanes onboard anna --container ws-anna --workspace anna-tools --repo /home/coder/tools --write # apply it
1115
+ ```
1116
+
1117
+ | Step | What the agents get |
1118
+ | --- | --- |
1119
+ | Board | A workspace tab for the repository (`lanes workspaces add`, section 6) on every device's view |
1120
+ | Contract | `lane`, `lane-run`, `lane-signal` and the hook definitions in the container's `~/.lz` (`lanes install-container`) |
1121
+ | Hooks | The lane-signal hooks for Claude Code and Codex ("Hooks" below), so the sidebar's agent glyphs are event-driven |
1122
+ | 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) |
1123
+ | 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) |
1124
+ | Sync and doctor | The registry knows the new lanes; `lanes doctor`'s result is reported, never fatal |
1125
+
1126
+ The receipt is `~/.local/state/lanes/onboard/<login>-<UTC>.json`, and running the command again changes nothing that
1127
+ is already done. `--no-mcp` leaves out `lane-mcp`, the in-container MCP server, which has not shipped yet. Every step
1128
+ and status: [install guide](INSTALL.md#onboard-a-users-container).
1129
+
1130
+ **Laptop agents on the pool (operator-gated).** Agents a user runs on their own laptop, outside any lane, can use the
1131
+ same Throttle pool over the tailnet once the operator shares it (`throttle remote share --tailnet`, unpublished). When
1132
+ the server's `~/.config/lanes/site.conf` names `LANES_THROTTLE_REMOTE_URL`, `lanes onboard` prints and records that
1133
+ step for the user: `throttle remote join <pairing code>` on the laptop
1134
+ ([install guide](INSTALL.md#laptop-agents-on-the-throttle-pool-operator-gated)).
1135
+
807
1136
  ### Hooks (opt-in, per container, dry run first)
808
1137
 
809
- Hooks make the state event-driven. Nothing installs one for you.
1138
+ Hooks make the state event-driven. Nothing installs one unasked: `lanes onboard --write` (above) installs both, or
1139
+ install them one by one.
810
1140
 
811
1141
  ```sh
812
1142
  lanes install-container <name> # puts lane-signal in ~/.lz/bin
@@ -816,7 +1146,9 @@ lanes hooks install --agent codex --container <name> --write # ~/.codex/hook
816
1146
  ```
817
1147
 
818
1148
  `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
1149
+ unless `--write`; your own hooks and keys stay, and an earlier lane-signal install is replaced, not duplicated. An
1150
+ install that is already current is left where it is, even when another tool (Throttle's `setup --install-clients`)
1151
+ has put its own group before or after it or re-sorted the file's keys, so running it again writes nothing. For
820
1152
  Codex it also adds a top-level `notify` to `~/.codex/config.toml` only when none exists. Codex asks you to review and
821
1153
  trust new hooks; Lanes never writes that trust. Expect hooks to reach agent sessions started after the install
822
1154
  (inferred from how both harnesses read their configuration, not measured). Undo:
@@ -930,8 +1262,11 @@ Covered in section 3: one notification per lane entering Needs you, from the sid
930
1262
  ### Push notifications (opt-in, operator gate)
931
1263
 
932
1264
  `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
1265
+ (`Needs you`, `Needs approval`, `Error`). It reads only `status.json`, the registry and `enrol-requests/`, every 3 s.
1266
+ A push says
1267
+ `<lane name>: <agent task> — <status>` and `lanes-launch lane <id>`. It also pushes once per new enrolment request
1268
+ (`Enrol request`: `<name or login> · <device>` and `lanes enrol approve <id>`), never muted by presence, once per
1269
+ request even across restarts. It sends lane names, task titles and requesters' names and device names to the server
935
1270
  you configure, so enabling it is your decision. Not yet run against a real ntfy server or phone.
936
1271
 
937
1272
  `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` at install time renders another path), `key=value` lines:
@@ -940,7 +1275,7 @@ you configure, so enabling it is your decision. Not yet run against a real ntfy
940
1275
  | --- | --- | --- |
941
1276
  | `url` | required | The ntfy server, `https://…` |
942
1277
  | `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) |
1278
+ | `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
1279
  | `quiet_when_device_active_s` | `120` | Presence window; `0` never suppresses |
945
1280
  | `action_url` | unset | A URL template over `{lane}`; becomes the tap target and an **Open lane** button |
946
1281
 
@@ -981,8 +1316,9 @@ the per-device flags; the variables matter when you run the sidebar by hand.
981
1316
 
982
1317
  **Ghostty palette** (opt-in): by default the lanes window inherits your everyday Ghostty look. `./install.sh --palette`
983
1318
  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`.
1319
+ newer when your everyday config sets no colours explicitly, else the dark palette) and includes it last; the choice
1320
+ is remembered, and `--no-palette` removes it. Not yet verified on a real Ghostty: the window background should then
1321
+ be `#101318`.
986
1322
 
987
1323
  **Fonts**: no Nerd Font is needed. Any monospace font with Latin-1, Box Drawing, Geometric Shapes, Dingbats, Arrows and
988
1324
  `⋮ ⏎ ⚠ …` renders the cockpit; `--ascii` is the remedy for any other font.
@@ -1036,12 +1372,13 @@ All units run in `lanes.slice` and are rendered by the server installer from `la
1036
1372
  | `lanes-sync.timer` / `.service` | Every 2 min | Server install | `lanes sync` |
1037
1373
  | `lanes-ensure.timer` / `.service` | Every 1 min | Server install | `lanes ensure`: recreate missing device view sessions |
1038
1374
  | `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` |
1375
+ | `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)) |
1376
+ | `lanes-web.service` | Always on | `enable-web.sh` (via `lanes web` / `enable-phone.sh`) | Zellij web server on `127.0.0.1:8082` |
1040
1377
  | `lanes-mobile.service` | Always on | `enable-web.sh` | Caddy frontend on `127.0.0.1:8083` |
1041
1378
  | `lanes-studio-host.service` | Always on | `enable-studio-host.sh` (opt-in) | The co-located Throttle Studio Host |
1042
1379
  | `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
1380
  | `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 |
1381
+ | `lanes-notify.service` | Every 3 s | Nobody (opt-in, operator gate) | ntfy push on needs-you transitions and new enrolment requests |
1045
1382
  | `lanes-tmpreap.timer` / `.service` | Hourly | Nobody (opt-in) | `lanes tmp reap`; a dry run until `~/.local/state/lanes/flags/tmpreap.armed` exists |
1046
1383
  | `lanes-guard.timer` / `.service` | Every 60 s | Nobody (opt-in) | `lanes guard --once`: raise `oom_score_adj` of builds in the containers |
1047
1384
 
@@ -1059,9 +1396,11 @@ Clients also get `lanes-helper.service` and `lanes-upload.path`/`.service` (Linu
1059
1396
  | Registry | `lanes register <lane-id> [workspace]` | Add an unregistered session to a workspace without restarting it |
1060
1397
  | Registry | `lanes rename <lane-id> <new-name>` | Change a lane's display name (not in the usage text) |
1061
1398
  | Registry | `lanes workspaces` | Print the workspace ids (used by the client installer) |
1399
+ | 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
1400
  | 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
1401
  | 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) |
1402
+ | Views | `lanes web <device> [--desktop\|--mobile] [--viewports N] [--token \| --print-only]` | Browser view in one step, Tailscale Serve included (section 4) |
1403
+ | Views | `lanes token list` / `revoke <device>` / `revoke --name NAME` / `revoke-all [--yes]` | Web login tokens and their devices (section 4) |
1065
1404
  | Views | `lanes open <lane-id>` | Attach this terminal straight to one lane |
1066
1405
  | Views | `lanes layout <device> [--mobile]` | Print the generated Zellij layout |
1067
1406
  | Views | `lanes rebuild <device>` | Recreate a device's view (agents never touched) |
@@ -1073,11 +1412,19 @@ Clients also get `lanes-helper.service` and `lanes-upload.path`/`.service` (Linu
1073
1412
  | Views | `lanes preview --focused [--device D]` | What `Ctrl-Alt-m` runs |
1074
1413
  | Editor | `lanes cursor-sync [ws…] [--write] [--diff] [--canonicalize] [--identity]` | Section 6 |
1075
1414
  | Editor | `lanes cursor-commit [ws…] [--push]` | Section 6 |
1076
- | Containers | `lanes install-container <name>` | Install the `lane` contract (section 2) |
1415
+ | 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 |
1416
+ | 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
1417
  | Containers | `lanes hooks install --agent claude\|codex (--container <name> \| --home <dir>) [--dry-run \| --write]` | Section 7 |
1078
1418
  | Uploads | `lanes upload <ws> \| --active <device> [--paste]` | Read a tar stream into that workspace's `uploads/` |
1079
1419
  | Client | `lanes hello [device]` | Prove path, key and server version |
1080
1420
  | Client | `lanes client-config` | Site facts for clients (`LANES_*` lines from `~/.config/lanes/site.conf`) |
1421
+ | 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/` |
1422
+ | 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)) |
1423
+ | 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") |
1424
+ | Access | `lanes invite list \| revoke <id>` | The invites and their state; refuse a code from now on |
1425
+ | Access | `lanes enrol list [--all] \| approve <id> [--as <device>] \| deny <id> [--as <device>]` | The enrolment requests; decide one as `/enrol/pending` does |
1426
+ | Access | `lanes enrol mode [request\|invite\|auto]` | Pin request mode across restarts (or say what is pinned) |
1427
+ | 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
1428
  | Health | `lanes doctor` | Full health check (section 6) |
1082
1429
  | Health | `lanes pressure` | Memory / io / cpu pressure, swap, per-container memory and `/tmp`; plain files only; exit 1 unless ok |
1083
1430
  | Health | `lanes invariant save\|check` | Snapshot / verify every live pane's pid and window size |
@@ -1096,9 +1443,12 @@ The client helpers call three more verbs (`rebuilt`, `upload-requests`, `upload-
1096
1443
  | --- | --- |
1097
1444
  | `lanes-window [--ascii] [--light\|--dark] [suffix]` | Open the lanes window (section 3) |
1098
1445
  | `lanes-launch [device \| workspace \| lane <id> \| ls]` | The reconnecting client (section 3) |
1446
+ | `lanes-launch web phone\|laptop\|<device> [--desktop\|--mobile] [--no-qr]` | A browser view plus a login token, in this terminal (section 4) |
1447
+ | `lanes-launch update [install.sh options]` | The newest client from npm with your remembered install options (section 2) |
1099
1448
  | `lanes-launch --helper <device> [--service \| --while-pid PID]` | Run only the upload helper (the service form is what the installer sets up) |
1100
1449
  | `lanes-upload …` | Uploads (section 5) |
1101
- | `sh install/enable-phone.sh …` | Phone or browser view plus token (section 4) |
1450
+ | `sh install/enable-phone.sh …` | Phone or browser view plus token; on the server a wrapper over `lanes web … --token` (section 4) |
1451
+ | `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
1452
  | `./install.sh …` | Install, check, update, uninstall (section 2) |
1103
1453
 
1104
1454
  ### State files
@@ -1118,13 +1468,22 @@ Server (`~/.local/state/lanes/`, `$LANES_STATE`):
1118
1468
  | `user-sessions/<id>.json` | Names of unregistered sessions |
1119
1469
  | `upload-requests/<device>/` | Upload hand-off between sidebar and helper |
1120
1470
  | `economy.json`, `economy.cursor.json` | The opt-in economy projection and its log cursor |
1471
+ | `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
1472
  | `flags/` | `coexist`, `readonly`, `tmpreap.armed`, `sockets-lost` |
1122
1473
  | `cursor-sync-backups/` | Backups taken by `cursor-sync --write` |
1123
1474
  | `invariant.json` | `lanes invariant save` |
1124
1475
  | `tmpscan/` | `lanes tmp` plans and `deleted.log` |
1125
1476
  | `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
1477
+ | `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 |
1478
+ | `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 |
1479
+ | `enrol-requests/<id>.json` | Enrolment requests waiting for, or past, a member's approval (24 h) |
1480
+ | `enrol.log` | The enrolment audit log, one line per attempt, invite, revocation, offboard and mode change (0600, append-only; never a code) |
1481
+ | `enrol-mode` | Request mode as `lanes enrol mode request\|invite` pinned it (0600; absent = auto); the enrolment service reads it on every request |
1482
+ | `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") |
1483
+ | `onboard/<login>-<UTC>.json` | One receipt per `lanes onboard` run: each step with its status (section 7) |
1484
+
1485
+ Other server paths: `~/.config/lanes/` (`workspaces/`, `site.conf`, `notify.conf`, and the optional invite key files
1486
+ `resend.key` and `tailscale-api.key`), `~/.local/share/lanes/` (pinned
1128
1487
  binaries in `bin/`, the `current` checkout link, npm-staged `pkg/<version>/`), and `~/.lz/<container>` (links to each
1129
1488
  container's `.lz`).
1130
1489
 
@@ -1132,9 +1491,9 @@ In each container (`/home/coder/.lz/`): `bin/` (`lane`, `lane-run`, `lane-signal
1132
1491
  definitions), `env.sh`, `registry.json` (the projection `lanes sync` writes), `state/hooks/` (hook sidecars),
1133
1492
  `state/<zmx name>.json` (`lane-run` phase records), `meta/` and `s/` (zmx metadata and sockets).
1134
1493
 
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/`.
1494
+ Client: `~/.config/lanes/` (`device`, `install-options`, `ghostty.conf`, `client-site.conf`, `client-known-hosts`,
1495
+ `site.cache`, `upload-allow`, `uploads-dir`, and `enrol-request` while an access request is open),
1496
+ `~/.local/state/lanes-client/` (`launch-<target>.log`, `helper.alive`, `helper-service.log`), `~/.local/share/lanes/client/`.
1138
1497
 
1139
1498
  ### NEVER
1140
1499
 
@@ -1162,8 +1521,19 @@ From the [RUNBOOK](RUNBOOK.md#never):
1162
1521
  | 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
1522
  | Phone shows no counts | Phone view laid out before the picker change | `lanes rebuild <device>` | [RUNBOOK](RUNBOOK.md#web--phone) |
1164
1523
  | `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) |
1524
+ | `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) |
1525
+ | `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) |
1526
+ | `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
1527
  | 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
1528
  | Upload `✗ no upload helper` | Helper service not serving | `./install.sh --check` on the client | [RUNBOOK](RUNBOOK.md#what-you-see-and-what-it-means) |
1529
+ | 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) |
1530
+ | 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) |
1531
+ | 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) |
1532
+ | 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) |
1533
+ | 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) |
1534
+ | `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) |
1535
+ | `/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) |
1536
+ | 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
1537
  | 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
1538
  | 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
1539
  | 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 +1560,15 @@ Server and views:
1190
1560
  | `LANES_NOTIFY_CONF` | `libexec/notify.py` | `~/.config/lanes/notify.conf` | Push notifier config path |
1191
1561
  | `LANES_HOST_GUARD_CONF` | `sudo/host-guard.sh` | `/etc/lanes-host-guard.conf` | Root guard config |
1192
1562
  | `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 |
1563
+ | `LANES_ENROL_PORT` | `libexec/enrold.py` | `8184` | The enrolment service's loopback port (the Caddyfile's `/enrol/*` route must follow) |
1564
+ | `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 |
1565
+ | `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") |
1566
+ | `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) |
1567
+ | `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
1568
  | `LANES_TESTS_FORCE` | `tests/guards.py` | unset | `1` runs live suites on a busy host (on purpose only) |
1194
1569
 
1195
- `LANES_THROTTLE_*` and `LANES_NOTIFY_CONF` set in the installer's environment are rendered into their units only.
1570
+ `LANES_THROTTLE_*` and `LANES_NOTIFY_CONF` set in the installer's environment are rendered into their units only. The
1571
+ `LANES_ENROL_*` settings go in a drop-in, `systemctl --user edit lanes-enrold.service`, which a re-install keeps.
1196
1572
  `LANES_REPO`, `LANES_ZELLIJ` and `LANES_WS` are set by Lanes itself for its units and panes.
1197
1573
 
1198
1574
  Client:
@@ -1200,8 +1576,12 @@ Client:
1200
1576
  | Variable | Read by | Default | Effect |
1201
1577
  | --- | --- | --- | --- |
1202
1578
  | `LANES_HOST` | `lanes-launch`, `lanes-upload` | `lanes` | SSH alias of the server |
1203
- | `LANES_FALLBACK_HOST` | `lanes-launch` | `lanes-public` | Fallback SSH alias |
1579
+ | `LANES_FALLBACK_HOST` | `lanes-launch` | `lanes-ip lanes-public` | Fallback SSH aliases, tried in order (each only when the SSH config defines it) |
1204
1580
  | `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME` | `install.sh`, client scripts | `~/.config`, `~/.local/share`, `~/.local/state` | Where client files go |
1581
+ | `LANES_TAILSCALE_WAIT` | `install.sh` | `120` | Seconds an install waits for this machine to join the tailnet (a `.ts.net` profile) |
1582
+ | `LANES_INVITE` | `install.sh`, `npm/lanes` wrapper | unset | An invite code, as `--invite` (the wrapper picks the site from its prefix) |
1583
+ | `LANES_ENROL_WAIT`, `LANES_ENROL_POLL` | `install.sh` | `600`, `5` | Seconds an install waits for an access request's approval, and between two looks |
1584
+ | `LANES_ENROL_URL` | `install.sh` | `https://<LANES_SERVER_NAME>:8443` | The enrolment service's base URL (development and tests) |
1205
1585
  | `LANES_PKG_ROOT` | `npm/lanes` wrapper | `~/.local/share/lanes/pkg` | Where npm-staged trees go |
1206
1586
  | `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
1587
 
@@ -1221,16 +1601,20 @@ Container:
1221
1601
 
1222
1602
  | Command | Codes |
1223
1603
  | --- | --- |
1224
- | `install.sh`, `npx @vibelord/lanes` | `0` ready · `3` key not yet authorized · `4` server unreachable or too slow · `1` error |
1604
+ | `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
1605
  | `install.sh --server` (`host-install.sh`) | `0` · `1` missing prerequisite, not Linux x86_64, or run as root · `2` unknown option |
1226
1606
  | `lanes <verb>` | `0` · `1` error (message `lanes: …`) or unknown verb (usage printed) |
1227
1607
  | `lanes sync` | `1` when any workspace's registry had errors (previous lanes kept) |
1228
1608
  | `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` |
1609
+ | `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` |
1610
+ | `lanes token revoke-all` without `--yes` | `2` (nothing changed) |
1230
1611
  | `lanes preview` / `next` / `prev` without `--focused` | `2` |
1231
1612
  | `lanes studio-host status` | `0` only when running enrolled; `1` otherwise; `2` usage |
1613
+ | `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 |
1614
+ | `lanes invite revoke <id>` | `0` revoked or already revoked · `1` already redeemed (offboard the device) or no such invite |
1615
+ | `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
1616
  | `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 |
1617
+ | `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
1618
  | `enable-web.sh` | `0` · `2` usage · `1` Caddy missing |
1235
1619
  | `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
1620
  | `lanes-window` | `64` invalid suffix · `69` no Ghostty (macOS) or neither Ghostty nor Foot (Linux) |