@vibelord/lanes 1.0.3 → 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 +55 -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 +91 -33
  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 +88 -3
  11. package/payload/lanes/docs/INSTALL.md +297 -43
  12. package/payload/lanes/docs/RUNBOOK.md +458 -31
  13. package/payload/lanes/docs/USERGUIDE.md +1612 -0
  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
@@ -0,0 +1,1612 @@
1
+ # Lanes user guide
2
+
3
+ Lanes keeps long-running agent terminals on one Linux workspace host and lets every device you use look at the
4
+ same terminals: a terminal window on a Mac or Linux desktop, Safari on a phone, a browser on a laptop or tablet, and
5
+ the Cursor / VS Code terminals inside the workspace containers. This guide is the day-to-day reference for operators
6
+ and teammates, and the install and update path for the administrator. It describes the tree it ships in; run
7
+ `cat lanes/VERSION` (or `lanes hello` on the server) for the version.
8
+
9
+ Companion documents: [README](../README.md) (overview and quick start) · [INSTALL](INSTALL.md) (full setup) ·
10
+ [RUNBOOK](RUNBOOK.md) (operations and incidents) · [ARCHITECTURE](ARCHITECTURE.md) (how the parts fit) ·
11
+ [DESIGN](DESIGN.md) (tokens, glyphs, budgets) · [SHARING](SHARING.md) (Throttle sharing and the Studio Host) ·
12
+ [HOST-GUARD](HOST-GUARD.md) (root memory protection).
13
+
14
+ Labels used below:
15
+
16
+ - **Opt-in**: installed (the unit file or command exists) but enabled by nothing; you turn it on.
17
+ - **Operator gate**: turning it on is a deliberate operator decision; the consequence is named next to it.
18
+ - **Not yet verified on a device**: covered by unit tests in this tree, not yet checked on real hardware.
19
+
20
+ ## Contents
21
+
22
+ 1. [What Lanes is](#1-what-lanes-is)
23
+ 2. [Join a site](#2-join-a-site)
24
+ 3. [Desktop use](#3-desktop-use)
25
+ 4. [Phone, tablet and laptop browser](#4-phone-tablet-and-laptop-browser)
26
+ 5. [Uploads](#5-uploads)
27
+ 6. [Workspaces and registry](#6-workspaces-and-registry)
28
+ 7. [Agents in lanes](#7-agents-in-lanes)
29
+ 8. [Economy and notifications](#8-economy-and-notifications)
30
+ 9. [Appearance](#9-appearance)
31
+ 10. [Sharing and Studio](#10-sharing-and-studio)
32
+ 11. [Operations reference](#11-operations-reference)
33
+ 12. [Appendix A: environment variables](#appendix-a-environment-variables)
34
+ 13. [Appendix B: exit codes](#appendix-b-exit-codes)
35
+
36
+ ## 1. What Lanes is
37
+
38
+ ```text
39
+ Mac / Linux terminal ──── SSH (alias "lanes") ─────┐
40
+ Phone / tablet / laptop ── HTTPS (Tailscale :8443) ┤
41
+ ▼
42
+ Linux workspace host (one unprivileged account)
43
+ one Zellij view session per device: workspace tabs, lane sidebar, 1–3 viewports
44
+ lanes-statusd · registry sync · upload intake · web services
45
+ │ /proc/<container init pid>/root
46
+ ▼
47
+ Workspace containers (home /home/coder, uid 1000)
48
+ tmux servers that hold the lanes; the agents run here
49
+ ~/.lz: the `lane` command, the registry projection, hook sidecars
50
+ Cursor / VS Code terminals reach the same lanes with `lane open <lane-id>`
51
+ ```
52
+
53
+ | Layer | Owns | Lifetime |
54
+ | --- | --- | --- |
55
+ | Host | Views, the registry, status, uploads, web services | Services restart freely; nothing here holds an agent |
56
+ | Container | Each lane's tmux session and the agent in it | Survives every view, reconnect and rebuild |
57
+ | View | One Zellij session per device (`mac`, `phone`, `browser`, …) | Disposable: `lanes rebuild <device>` recreates it, agents untouched |
58
+
59
+ **A lane** is one long-lived terminal, normally a tmux session on a socket inside a workspace container, with a
60
+ stable id `<workspace>--<slug>`. Closing a window, losing the network or rebuilding a view never ends it. Lanes itself
61
+ ends one only on an explicit close of an unregistered session (`×` / `x`) or `lane kill` inside the container; a
62
+ Throttle Studio **Stop** or a `tmux kill-session` run in the container ends one too.
63
+
64
+ **A workspace** (also called a board) is one tab of the view: the lanes of one or more repositories in one container.
65
+ The server's catalog orders the tabs.
66
+
67
+ **Registered and unregistered lanes**
68
+
69
+ | Kind | Comes from | In the sidebar |
70
+ | --- | --- | --- |
71
+ | Repository lane | A terminal declared in a repository's `.vscode/terminals.json` | The workspace's list; no close control |
72
+ | Registered session | An observed session adopted with **Add to workspace** or `lanes register` (saved in `registered-sessions.json`) | The workspace's list; no close control |
73
+ | Unregistered session | Any other tmux session observed in the workspace's container: a **New shell**, a session you started by hand, a Throttle Studio `st_…` session | **Unregistered (N)**; can be opened, renamed, added, promoted or closed |
74
+
75
+ **The terminal status** (sidebar column 33) is one of six words `lanes-statusd` publishes, plus `helper` for a tool
76
+ view:
77
+
78
+ | Glyph | ASCII | Word | Meaning |
79
+ | --- | --- | --- | --- |
80
+ | `●` | `*` | active | Output in the last 20 s |
81
+ | `○` | `o` | quiet | Alive, no recent output |
82
+ | `–` | `-` | absent | The server answered and has no such session right now (for example a finished orchestrator turn) |
83
+ | `!` | `!` | dead | The pane died |
84
+ | `✗` | `x` | missing | The container is down |
85
+ | `?` | `?` | unknown | Never observed yet |
86
+ | `·` | `.` | helper | A tool view, not a lane |
87
+
88
+ A lane whose tmux server did not answer in time keeps its last glyph, dimmed, with `?` in column 34: not observed
89
+ right now, never "gone". A lane is dead only on a positive observation (`–`, `!`, `✗`).
90
+
91
+ **The agent state** (sidebar column 2) comes from the agent running in the lane and sorts the sidebar into
92
+ attention sections:
93
+
94
+ | Section | Glyph | ASCII | Status | Meaning |
95
+ | --- | --- | --- | --- | --- |
96
+ | Needs you | `◆` | `!` | needs_approval | A permission prompt is blocking |
97
+ | Needs you | `✗` | `X` | errored | The turn failed |
98
+ | Needs you | `◇` | `?` | needs_input | The turn finished; waiting for your prompt |
99
+ | Working | `▸` | `>` | working | The agent is working |
100
+ | Done | `✓` | `v` | done | The turn finished; stays until you open the lane from this device |
101
+ | (rest) | `_` | `_` | shell | The harness ended into a shell |
102
+ | (rest) | `·` | `.` | idle | The harness is up, no turn |
103
+ | (rest) | blank | blank | — | A plain shell, no agent signal |
104
+
105
+ **What Throttle adds.** Lanes runs without Throttle. With the opt-in economy poller (section 8) the sidebar shows a
106
+ fleet economy strip (overflow spend against the daily cap, new 429s, model tiers, spill, free accounts) and one
107
+ economy cell per lane (`⚠` starving, `↓` downgraded, `$` mostly paid). Throttle's **Shared lanes** console and the
108
+ co-located Studio Host (section 10) give read-only and browser access to registered lanes through Throttle SSO.
109
+
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.
115
+
116
+ ### What you need
117
+
118
+ | Role | Requirements |
119
+ | --- | --- |
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 |
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 |
122
+ | Containers | Homes mounted at `/home/coder` with uid 1000; Bash, Python 3 and tmux (compatible with the host's tmux) |
123
+ | Browser access (optional) | Caddy and Tailscale (with HTTPS) on the server |
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
+
201
+ ### Client from npm
202
+
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.
207
+
208
+ ```sh
209
+ npx @vibelord/lanes --site beyondwork # install or update the client
210
+ lanes-window # open the desktop view
211
+ ```
212
+
213
+ | Wrapper option | Effect |
214
+ | --- | --- |
215
+ | `--site NAME` | A bundled profile (`beyondwork`); a value containing `/` or ending in `.conf` is your own file |
216
+ | `--stage-only` | Copy the bundled tree to `~/.local/share/lanes/pkg/<version>/` and print the path; run nothing |
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) |
219
+ | `--wrapper-help` | Wrapper help only |
220
+
221
+ Every other option goes to `install.sh`. The wrapper stages the tree under `~/.local/share/lanes/pkg/<version>/`
222
+ (`$LANES_PKG_ROOT` overrides the parent), reuses an identical tree, and keeps the newest three versions plus any
223
+ version a server unit still runs from. See [`npm/lanes/README.md`](../../npm/lanes/README.md).
224
+
225
+ ### Client from a checkout
226
+
227
+ For working on Lanes, or for a site without a bundled profile:
228
+
229
+ ```sh
230
+ git clone https://github.com/bwllaming/bw-tools.git
231
+ cd bw-tools/lanes
232
+ ./install.sh --site /path/to/site.conf # Beyond Work: ./install.sh --site site/beyondwork/site.conf
233
+ lanes-window
234
+ ```
235
+
236
+ | `install.sh` option | Effect |
237
+ | --- | --- |
238
+ | (none) | Install or update the client on this machine |
239
+ | `--check` | Verify the commands, SSH alias, key, upload helper and server; changes nothing |
240
+ | `--uninstall` | Remove what the client installer added (your SSH key, the known_hosts entry and `~/Lanes Uploads` are kept) |
241
+ | `--server [--check \| --no-start]` | Install or update the Linux server instead (must be the first argument; see below) |
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) |
244
+ | `--key PATH` | SSH private key (default: the one already configured, else `~/.ssh/id_ed25519`, else a new `~/.ssh/lanes_ed25519`) |
245
+ | `--link` | Symlink the client into this checkout instead of copying it (for working on Lanes) |
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) |
253
+ | `-h`, `--help` | Print the option summary |
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
+
273
+ What a client install changes: `lanes-launch`, `lanes-window` and `lanes-upload` in `~/.local/bin` (copied to
274
+ `~/.local/share/lanes/client` unless `--link`); the SSH alias `lanes` in `~/.ssh/config.d/lanes.conf` plus 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);
279
+ the server's host key pinned from the profile's `known_hosts`; a separate Ghostty configuration
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
287
+
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 |
300
+
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.
309
+
310
+ ### Server
311
+
312
+ On the Linux host, as the workspace account:
313
+
314
+ ```sh
315
+ sudo loginctl enable-linger <account> # administrator, once: services stay up after logout
316
+ ./install.sh --server --check # prerequisites only; changes nothing
317
+ ./install.sh --server # or: npx @vibelord/lanes --server
318
+ ```
319
+
320
+ `./install.sh --server` runs `install/host-install.sh`, which takes:
321
+
322
+ | Option | Effect |
323
+ | --- | --- |
324
+ | `--check` | Check prerequisites, the user manager and Docker; print `Server prerequisites: ready`; change nothing |
325
+ | `--no-start` | Install files and units without contacting Docker or starting services |
326
+ | `-h`, `--help` | Usage |
327
+
328
+ A normal server install verifies and installs the pinned binaries from `holders.lock` (Zellij 0.45.1, zmx 0.8.1, and
329
+ glow 3.0.0 unless a glow is already on the view server's PATH) into `~/.local/share/lanes/bin`, adds the
330
+ `xterm-lanesview` terminfo alias, renders every unit in `lanes/systemd/` into the user manager's unit directory,
331
+ links `~/.local/share/lanes/current` and `~/.local/bin/lanes` to this checkout, creates an empty catalog only when no
332
+ configuration exists, runs `lanes sync`, and enables `lanes-statusd.service`, `lanes-sync.timer`,
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,
336
+ the `/tmp` reaper and the OOM guards stay off (section 11 lists each unit).
337
+
338
+ Then configure the workspaces (section 6) and install the container contract:
339
+
340
+ ```sh
341
+ lanes install-container <container> # once per workspace container
342
+ lanes sync
343
+ lanes doctor
344
+ ```
345
+
346
+ `lanes install-container <name>` is additive. In that container's home it copies `lane`, `lane-run` and
347
+ `lane-signal` into `~/.lz/bin/`, the agent hook definitions into `~/.lz/hooks/`, and `env.sh` into `~/.lz/env.sh`;
348
+ it creates `~/.tmp` and appends one marker-guarded line (`# lanes-env`) to the login files (`.bash_profile` or
349
+ `.profile`, `.bashrc`, and `.zshrc` when it exists) so new shells keep temp files off the RAM-backed `/tmp`; it copies
350
+ the pinned zmx binaries without overwriting a running one; and it links `~/.lz/<container>` on the host. It installs
351
+ no agent hook (section 7).
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
+
400
+ ### Update
401
+
402
+ | Where | Update | Then |
403
+ | --- | --- | --- |
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` |
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 |
407
+ | Server (checkout) | `git pull` in the installed checkout, then `./install.sh --server` (configured workspaces and agent sessions are preserved) | the restarts below |
408
+
409
+ After a server update:
410
+
411
+ - `systemctl --user restart lanes-statusd.service` (the status observer runs the old code until restarted).
412
+ - When web unit definitions or the pinned Zellij changed: `systemctl --user restart lanes-web.service
413
+ lanes-mobile.service`; browsers reconnect.
414
+ - When the Studio Host is enabled: re-run `sh install/enable-studio-host.sh` (it checks, then restarts the Host).
415
+ - Re-run `lanes install-container <container>` to update the in-container `lane` contract; an older `lane` refuses a
416
+ newer registry with exactly that hint.
417
+ - Existing view sessions keep their layout. `lanes upgrade-session` (also run by every `lanes sync`) adds new tabs and
418
+ renames titles without a rebuild; a changed pane layout needs `lanes rebuild <device>` (agents are never touched).
419
+
420
+ Back up the catalog (`~/.config/lanes/workspaces/`, including `registered-sessions.json`) and the running checkout
421
+ before a server update. To roll back, restore those files; do not rebuild device sessions as a rollback shortcut.
422
+
423
+ ### Versions
424
+
425
+ - `lanes/VERSION` is the release version; the npm package version equals it (`npx @vibelord/lanes --version`).
426
+ - `lanes hello [device]` on the server prints `ok device=<device> version=<version> boards=<n>`. The client
427
+ installer calls it to prove the SSH path, the key and the server version; an answer without that greeting is
428
+ reported as "an older server?".
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.
441
+
442
+ ### Uninstall
443
+
444
+ - Client: `./install.sh --uninstall` or `npx @vibelord/lanes --uninstall`. Your view session on the server stays until
445
+ an administrator removes it; it holds no agents.
446
+ - Server: disable `lanes-statusd.service`, `lanes-sync.timer`, `lanes-ensure.timer`, `lanes-gc.timer` and any
447
+ opt-in units you enabled; for the web endpoint also run `tailscale serve --https=8443 off`. Keep the state and the
448
+ container lane data until you have decided which sessions to retain. See
449
+ [INSTALL §6](INSTALL.md#6-updates-optional-operations-and-removal).
450
+
451
+ ## 3. Desktop use
452
+
453
+ ### Open a view
454
+
455
+ | Command | What it opens |
456
+ | --- | --- |
457
+ | `lanes-window` | Your device's view in its own Ghostty instance (Linux: Ghostty, else Foot with the same window class) |
458
+ | `lanes-window 2` | An independent second view, device `<device>-2` (another monitor) |
459
+ | `lanes-window w2` / `lanes-window w3` | A wide view, device `<device>-w2` / `-w3`, with two or three viewports per workspace |
460
+ | `lanes-window --ascii` / `--light` / `--dark` [suffix] | The same, with that look recorded for the device (section 9) |
461
+ | macOS **Lanes** app | `~/Applications/Lanes.app` (Spotlight: Lanes) runs `lanes-window` |
462
+ | Omarchy | `Super+Shift+L` opens or focuses the window; `Super+Shift+Alt+L` opens the `w2` wide view |
463
+ | `lanes-launch` | Your device's view inside the current terminal |
464
+ | `lanes-launch <workspace>` | Your device's view with that workspace's tab in front |
465
+ | `lanes-launch <device> --viewports N` | A view with N (2–3) viewports per workspace |
466
+ | `lanes-launch <device> [--ascii] [--theme dark\|light\|auto] [--glyphs M] [--color C]` | The view with a recorded look |
467
+ | `lanes-launch lane <lane-id>` | One lane straight in this terminal, no multiplexer (native terminal scrollback) |
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>`.
473
+
474
+ Closing the window detaches; nothing on the server stops. `lanes-launch` never ends by itself: when the connection
475
+ drops, the server drops a slow client, or the server refuses, it says so, waits with a countdown (backing off up to
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
479
+ `~/.local/state/lanes-client/launch-<target>.log`. When the server rebuilt the view, the client reconnects straight
480
+ away.
481
+
482
+ ### Devices and views
483
+
484
+ - A **device** is the name of one view session on the server. The default is `<user>-<hostname>`, saved in
485
+ `~/.config/lanes/device`; `lanes-window 2` and `w2` derive suffixed devices from it.
486
+ - Each view has one tab per workspace in catalog order, then SSH split workspaces and tool tabs (the **Throttle**
487
+ monitor when a manifest configures `monitor`), then **Host** (a login shell on the server). An `sshWorkspaces`
488
+ entry with `layout: "lanes"` sits just before Host unless its `position` is `workspace`.
489
+ - Phones and browsers are separate devices (`phone`, `browser`, or a name you choose); a device keeps the layout
490
+ (desktop or phone) it was created with.
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);
494
+ `lanes device forget <name>` stops `lanes ensure` recreating it; `lanes rebuild <device>` recreates a stuck view.
495
+
496
+ ### Panes and viewports
497
+
498
+ A desktop workspace tab is the sidebar (36 columns) beside one to three viewports. `Enter` in the sidebar shows the
499
+ cursor lane in the first viewport; in a wide view `2` and `3` show it in the second or third. The sidebar marks
500
+ what the other viewports show with `²` and `³`. In a single-viewport view `2`/`3` do nothing; `3` in a two-viewport
501
+ view says `this view has no such viewport (open a wide view: lanes-window w2)`.
502
+
503
+ ### Keybindings
504
+
505
+ `lanes/zellij/config.kdl` clears Zellij's defaults and puts every Zellij action behind **Ctrl-Alt**, so ordinary keys
506
+ reach the lane. `Ctrl-g` is never bound (Claude Code opens its editor on it) and plain `Ctrl-b` reaches the lane's own
507
+ tmux.
508
+
509
+ Keys in the `shared` block work in every mode, locked included:
510
+
511
+ | Key | Action |
512
+ | --- | --- |
513
+ | `Ctrl-Alt-←` / `Ctrl-Alt-→` | Previous / next workspace tab |
514
+ | `Ctrl-Alt-↓` / `Ctrl-Alt-↑` | Point the focused viewport (the first one when the sidebar has the focus) at the next / previous lane of its workspace in registry order, wrapping. Runs `lanes next\|prev --focused` in a floating pane that closes at once; nothing is typed and the focus stays. Does nothing on Host or tool tabs |
515
+ | `Ctrl-Alt-/` | Find: moves the focus to the sidebar and starts its filter |
516
+ | `Ctrl-Alt-u` | Arm the sidebar's upload strip, then drop files anywhere on the window |
517
+ | `Ctrl-Alt-c` | Copy the selection |
518
+ | `Ctrl-Alt-PageUp` / `Ctrl-Alt-PageDown` | Page up / down and enter scroll mode |
519
+ | `Ctrl-Alt-End` | Scroll to the bottom and return to normal mode |
520
+ | `Ctrl-Alt-s` | Zellij's session manager (floating) |
521
+
522
+ `Ctrl-Alt-/` and `Ctrl-Alt-u` walk the focus left to the sidebar first; on a tab without a sidebar (Host, Throttle,
523
+ the phone's view tab) the character goes to that tab's pane.
524
+
525
+ Normal mode:
526
+
527
+ | Key | Action |
528
+ | --- | --- |
529
+ | `Ctrl-Alt-g` | Lock the view; press again to unlock. The mode ribbon shows LOCKED in the urgent accent. While locked, the mode keys below and `Ctrl-Alt-m` reach the lane; the `shared` keys above stay active |
530
+ | `Ctrl-Alt-m` | Markdown preview of the focused lane (every mode except locked; see below) |
531
+ | `Ctrl-Alt-p` | Pane mode |
532
+ | `Ctrl-Alt-t` | Tab mode |
533
+ | `Ctrl-Alt-n` | Resize mode |
534
+ | `Ctrl-Alt-h` | Move mode |
535
+ | `Ctrl-Alt-o` | Session mode (`d` detaches) |
536
+ | `Ctrl-Alt-b` | tmux-style mode (Zellij's, not the lane's tmux) |
537
+ | `Ctrl-Alt-f` | Scroll mode |
538
+
539
+ Inside a mode (`Esc` returns to normal; `Enter` too, except while typing a search):
540
+
541
+ | Mode | Keys |
542
+ | --- | --- |
543
+ | Pane | Arrows or `h j k l` move focus · `n` new pane · `d` / `r` / `s` new pane down / right / stacked · `x` close the focused pane · `f` fullscreen · `F` fullscreen without UI · `e` float/embed · `w` show floating panes · `z` frames · `i` pin · `c` rename · `p` / `;` switch / last pane |
544
+ | Tab | `←` `↑` `h` `k` previous · `→` `↓` `l` `j` next · `1`–`9` go to tab · `Tab` toggle last tab · `n` new · `r` rename · `x` close · `s` sync input · `b` `[` `]` break the pane out |
545
+ | Resize | Arrows or `h j k l` grow · `H J K L` shrink · `+` `=` grow · `-` shrink |
546
+ | Move | Arrows or `h j k l` move the pane · `n` / `Tab` next · `p` previous |
547
+ | Session | `d` detach · `w` session manager · `a` about · `c` configuration · `l` layout manager · `p` plugin manager · `s` share |
548
+ | tmux-style | Arrows or `h j k l` focus · `"` / `%` split · `c` new tab · `n` / `p` tabs · `,` rename tab · `[` scroll · `z` fullscreen · `o` next pane · `space` next layout · `x` close pane · `d` detach · `Ctrl-b` sends `Ctrl-b` to the pane |
549
+ | Scroll | `↑` `↓` `j` `k` line · `←` `→` `h` `l` `PageUp` `PageDown` page · `u` / `d` half page · `g` `G` `Home` `End` top / bottom · `s` search · `e` open the scrollback in `vi` · `c` copy the last command's output · `y` copy · `[` `]` previous / next prompt |
550
+ | Search | `n` / `p` next / previous · `c` case · `w` wrap · `o` whole word |
551
+
552
+ In every mode other than normal and locked, `Alt`-arrows or `Alt-h/j/k/l` move the focus (or the tab), `Alt-n` adds
553
+ a pane, `Alt-f` toggles floating panes, `Ctrl-p/t/n/h/o/s/b` switch modes, and `Ctrl-q` quits the view session.
554
+
555
+ Closing a pane, a tab or the session affects this view only: lanes keep running. `lanes rebuild <device>` restores
556
+ the layout, and `lanes-ensure.timer` recreates a known device session that is missing within a minute. Tab titles
557
+ belong to the catalog: `lanes sync` renames tabs to their catalog titles, so rename a workspace in the catalog
558
+ (section 6), not with tab mode's `r`.
559
+
560
+ To detach: close the window, or `Ctrl-Alt-o` then `d`.
561
+
562
+ ### The sidebar
563
+
564
+ Top to bottom, a desktop sidebar is:
565
+
566
+ 1. **Header**: the workspace title, then the attention counts in the column-2 glyphs, e.g. ` Sakio ◆1 ◇2 ▸6 ✓1`
567
+ (needs approval, needs input, working, done; zeros left out; a `done` you have opened is not counted). Without
568
+ agents it says `N lanes`. The counts are dimmed while the footer warns that status is old.
569
+ 2. **Body**: **Needs you** (`◆` `✗` `◇`, most urgent then oldest first), **Working** (oldest first), **Done** (newest
570
+ first), then the workspace's other lanes under its own title (`── Sakio (25) ──`). The sections appear only when at
571
+ least one lane needs you, is working or is done. Then `zmx lanes (N)` when any exist, then **Unregistered (N)**
572
+ with its `+ New shell` row. A list longer than the pane ends in `⋮ N more`; moving onto that row or clicking it
573
+ scrolls instead of opening.
574
+ 3. **Upload strip** (panes of 14 rows or more): ` ↑ uploads · click, then drop (U)` (section 5).
575
+ 4. **Strip row** (panes of 18 rows or more): the fleet economy line when the opt-in poller runs (section 8), otherwise
576
+ the other workspaces that need you, e.g. ` ◆1 Worlds · ◇2 Code`; blank when there is nothing to say.
577
+ 5. A rule, then the **footer**: one line, by priority.
578
+
579
+ A workspace whose lanes live on another SSH host (`sshWorkspaces` with `layout: "lanes"`) is attach-only: it has no
580
+ Unregistered section, no upload strip and no `+`, and its footer has no `x close`.
581
+
582
+ **Row anatomy** (every row is 36 cells; column 36 is never written):
583
+
584
+ | Column | Shows |
585
+ | --- | --- |
586
+ | 1 | `▌` the cursor |
587
+ | 2 | The agent glyph (table in section 1); dimmed while the lane is stale |
588
+ | 3 | `›` the lane this pane shows (not on the cursor row, where the bold name says it) · `²` / `³` shown in viewport 2 / 3 |
589
+ | 4–27 | The name in its identity colour, bold while shown; up to three `·` when other sessions view the lane; a clipped name ends in `…` |
590
+ | 28 | `+` on an unregistered tmux session that can be added to a workspace |
591
+ | 29–31 | Age: the agent's `since`, else the last output: `45s`, ` 3m`, ` 2h`, ` 4d` (at most `99d`); blank when unknown; amber inside **Needs you** |
592
+ | 33 | The terminal status glyph (section 1); dimmed when an agent glyph is in column 2 (`▸` with `○` is a long tool call, not a stuck lane) |
593
+ | 34 | One tail cell by priority: `?` not observed right now > `⚠` starving > `↓` downgraded > `$` mostly paid (section 8) |
594
+ | 35 | `×` close, on unregistered sessions only |
595
+
596
+ Marks and their ASCII twins: `▌` `|` cursor · `›` `=` viewing · `²` `2` / `³` `3` other viewport · `·` `.` viewer ·
597
+ `…` `~` clipped · `×` `x` close · `+` `+` new / add · `⋮` `:` more rows · `↑` `^` uploads · `↓` `v` drop now ·
598
+ `⚠` `!` warning or starving · `↓` `v` downgraded · `$` `$` paid. The full tables, colours and mockups are in
599
+ [DESIGN.md](DESIGN.md).
600
+
601
+ **Footer**, highest priority first: the close prompt ` › close ‘name’? y/n` · the rename prompt ` › Rename: …█` ·
602
+ the promote prompt ` › Register ‘name’? y/n` · the Add to workspace prompt ` Choose a workspace · Esc cancels` · the
603
+ filter ` /abc · 3 matches` · the health banner (` ⚠ host under memory pressure · status 3 m old`, or
604
+ ` ⚠ lane sockets deleted · lanes run; on the server: lanes sockets repair`) · ` ⚠ registry: …` · the last message ·
605
+ the legend ` ⏎ open · / find · x close · ? keys`. An empty workspace shows `no lanes declared · + New shell` on its
606
+ New shell row.
607
+
608
+ **Sidebar keys** (`?` shows this sheet in place of the list until the next key; that key only closes the sheet):
609
+
610
+ | Key | Action |
611
+ | --- | --- |
612
+ | `↑` `↓` `j` `k` | Move the cursor |
613
+ | `PageUp` / `PageDown` | Move the cursor ten rows |
614
+ | `Enter` `→` `Space` | Open the cursor lane (in this pane's viewport) |
615
+ | `J` / `K` | Open the next / previous lane relative to the one shown |
616
+ | `Tab` / `Shift-Tab` | Open the next / previous lane that needs you, cycling from the lane shown (` nothing needs you` when the section is empty) |
617
+ | `2` / `3` | Show the cursor lane in viewport 2 / 3 (wide views) |
618
+ | `/` | Find a lane |
619
+ | `Esc` | Clear the filter; cancel a prompt, a rename, Add to workspace or an armed upload strip |
620
+ | `g` / `G` | First / last row |
621
+ | `+` | New persistent shell in this workspace |
622
+ | `a` | Add the cursor session to a workspace |
623
+ | `x` | Close the cursor session (asks `y/n`) |
624
+ | `R` | Rename the cursor lane |
625
+ | `P` | Promote (register) the cursor session in the repository's `terminals.json` |
626
+ | `U` | Arm the upload strip |
627
+ | `r` | Reload and repaint |
628
+ | `?` | The key sheet |
629
+
630
+ Mouse: a click opens a lane; the wheel moves the cursor three rows; a click on `×` asks to close, on `+` starts Add to
631
+ workspace, on the upload strip arms it. The cursor follows the lane, not the row: when a lane changes section the
632
+ cursor and scroll position go with it.
633
+
634
+ ### Find, rename, add, promote, new, close
635
+
636
+ - **Find** (`/` or `Ctrl-Alt-/`): type a few letters. Matching is fuzzy (the letters in order; whole-substring and
637
+ prefix matches rank first, word starts score higher). The list flattens (sections drop, column 2 stays) and the
638
+ footer counts matches. `Enter` opens the match under the cursor (the top one unless you moved), `Backspace` edits,
639
+ `Esc` clears. No hits:
640
+ ` no lane matches ‘abc’ · Esc`.
641
+ - **Rename** (`R`, or `lanes rename <lane-id> <new-name>` on the server): edit in the footer, `Enter` saves, `Esc`
642
+ cancels. The lane id never changes, so view targets and Cursor's `lane open <id>` keep working. Where the name is
643
+ stored depends on the lane kind (section 6).
644
+ - **Add to workspace** (click `+` on the row, or `a`): choose a workspace that uses the session's container (the
645
+ current one first), or **Cancel** / `Esc`. The session keeps its id, name, programs, scrollback and views, moves to
646
+ that workspace's list, loses its `×`, and survives sync and reconnects.
647
+ - **Promote** (`P`): asks ` › Register ‘name’? y/n`. `y` declares the unregistered tmux session in the workspace's
648
+ repository `terminals.json` (only when the workspace has exactly one repository registry) and syncs; the lane then
649
+ gets a repository id `<workspace>--<slug>`. Refused on a lane that is already registered.
650
+ - **New shell** (`+`, or the `+ New shell` row): starts a persistent shell in the workspace container on the socket
651
+ `lanes-shells-<workspace>` and shows it. Reopening it attaches to the same shell.
652
+ - **Close** (`x`, or a click on `×`): unregistered sessions only. ` › close ‘name’? y/n`: `y` ends the session and its
653
+ jobs and clears views showing it; any other key cancels. A session others are grouped onto (a Studio browser
654
+ `view-…` viewer) has those viewers ended first; any other grouped session makes the close refuse and say why.
655
+
656
+ ### Markdown preview
657
+
658
+ `Ctrl-Alt-m` opens a floating **Preview** pane over the focused lane. It reads the lane's last 300 lines, keeps the
659
+ `.md` paths that exist in its container (relative ones resolved against the lane's working directory), and shows
660
+ one at once; several give a numbered menu, newest first (`Enter` takes the newest, `q` closes). It renders with
661
+ `glow` in the view's dark or light theme, else `less -R`; quitting the pager closes the pane. The server installer
662
+ adds a pinned glow unless the view server's PATH already has one; `lanes doctor` names the renderer
663
+ (`preview renderer:`). Preview reads tmux lanes only; on a Host or tool tab, or a lane that named no existing `.md`
664
+ file, it says so and closes on `Enter`.
665
+
666
+ ### Scroll, mouse and copy
667
+
668
+ - A tmux lane keeps its own history and draws on the alternate screen, so scroll inside the lane (mouse wheel or the
669
+ lane's tmux copy mode); Zellij's scroll mode has little to show for it.
670
+ - For a single lane with your terminal's native scrollback, use `lanes-launch lane <lane-id>`.
671
+ - Selecting with the mouse copies to the system clipboard (`copy_on_select`); `Ctrl-Alt-c` copies explicitly.
672
+ Ghostty's own config for the window allows clipboard writes and asks before clipboard reads.
673
+ - On Omarchy the window class `org.omarchy.lanes` makes `Super+C` / `Super+V` copy and paste.
674
+
675
+ ### Pane title and desktop notifications
676
+
677
+ The sidebar pane's title is `WORKSPACE · ◆1 ◇2` (the header counts, rewritten only when they change). A lane that
678
+ starts needing you (`◆` `✗` `◇`) raises one desktop notification `name: task` through OSC 9: once per transition,
679
+ never for the lane this pane shows, never on a phone layout. A sidebar that starts while lanes already need you
680
+ announces each once. Ghostty shows OSC 9 only while the lanes window is not focused (`desktop-notifications = true`
681
+ in the window's config). Not yet verified on a Mac.
682
+
683
+ ## 4. Phone, tablet and laptop browser
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
+
698
+ ### How it is served
699
+
700
+ ```text
701
+ browser ── https://<host>.<tailnet>.ts.net:8443/<device> ── Tailscale Serve
702
+ ── lanes-mobile.service (Caddy, 127.0.0.1:8083: the Lanes frontend in web/)
703
+ ── lanes-web.service (Zellij web server, 127.0.0.1:8082: login, assets, WebSockets)
704
+ ```
705
+
706
+ Everything listens on loopback; Tailscale Serve is the only way in. **There is no public fallback by design**: a
707
+ browser needs Tailscale. Zellij's own token and cookie checks are the login; Lanes adds no second login system.
708
+ A token grants terminal access as the server account.
709
+
710
+ ### One-time server setup (administrator)
711
+
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.
714
+ 2. Allow the workspace account to manage Tailscale Serve: `sudo sh sudo/one-time-root.sh --user <account>` (it changes
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).
720
+
721
+ ### Phone (Safari, Home Screen)
722
+
723
+ From an installed client, in your own terminal (the token is shown only there):
724
+
725
+ ```sh
726
+ lanes-launch web phone # this machine's phone view: <your device>-phone, phone layout
727
+ ```
728
+
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`:
737
+
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
757
+ default, device `phone`) or `--desktop` (device `browser`) picks the layout. On macOS `--host lanes` is assumed when
758
+ neither is given. An SSH-only phone client can instead run `lanes attach phone --mobile`.
759
+
760
+ ### Laptop and tablet
761
+
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`:
771
+
772
+ ```sh
773
+ lanes web ipad --desktop # desktop sidebar layout (a new device's default); prints the private URL
774
+ lanes web ipad --desktop --token # the same, then a fresh login token in this terminal
775
+ lanes web ipad --print-only # the exact prepare call, URL and Serve step; changes nothing
776
+ ```
777
+
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.
794
+
795
+ On a captive-portal network (hotel, train, aircraft): complete the portal sign-in first, then connect Tailscale, then
796
+ open the URL and paste the token.
797
+
798
+ ### Tokens
799
+
800
+ | Task | Command |
801
+ | --- | --- |
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.
819
+
820
+ ### The three shell modes
821
+
822
+ The frontend chooses once per page load, by capability:
823
+
824
+ | Mode | Chosen when | What you get |
825
+ | --- | --- | --- |
826
+ | Phone | An iPhone / iPod / Android phone user agent, or a touch screen at most 600 px wide or 500 px tall | The phone shell: **Workspaces**, **Lanes**, **Panes**, **Type here** and **More** controls; the lane picker |
827
+ | Tablet | Any other touch screen | The same shell with the controls in a top bar and the workspace list as a left panel; the whole tab is shown, so a desktop view's sidebar sits beside its lanes (**More → Single pane** shows one pane for that load) |
828
+ | Desktop | A fine pointer (mouse, trackpad) | The plain Zellij web client plus a **Lanes** chip (top right) that opens workspace and lane pickers in an overlay bar; the terminal keeps its size and every key |
829
+
830
+ ### Phone controls
831
+
832
+ - **Workspaces** (or the title in the header) opens the workspace sheet with a search field (each card shows its
833
+ counts, `◆ 1 · ◇ 2`); **Lanes** opens the current workspace's lane picker; **Panes** switches panes; **More** opens
834
+ the client's own menu, where Lanes adds **Text size** (remembered on the phone). The light/dark toggle repaints the
835
+ whole terminal.
836
+ - The keyboard opens only when you tap **Type here** or another input. Swipes, live updates and reconnects leave it
837
+ closed.
838
+ - The lane picker shows three rows per lane: the viewing mark, agent glyph and name; then what the agent needs, since
839
+ when and the harness (`Needs you · 25m · claude`; a shell says `Quiet · 2h`); then the agent's task. Its first line
840
+ counts the lanes that need you (`◆1 need approval · ◇2 need you · ▸6 working`). The footer says
841
+ `Swipe to scroll · Tap to open`. The picker has no upload strip.
842
+ - 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
843
+ the keyboard; `n` and `^C` need a 400 ms hold (a short tap says "Hold to send").
844
+ - The filter bar on the picker sends its text as UTF-8, so non-ASCII filters match.
845
+ - `+` and `×` on picker rows add and close unregistered sessions, as on the desktop; tapping `+` then a workspace
846
+ needs no typing.
847
+
848
+ ### What works, what does not, what is unverified
849
+
850
+ - Works: everything the desktop view shows, through the same Zellij sessions; the phone has its own view selection of
851
+ the same agents; the server keeps serving when your Mac sleeps.
852
+ - Does not: a public URL (Tailscale only), uploads from a phone browser (the drop helper is a desktop client feature),
853
+ approving an agent's permission prompt from a push notification.
854
+ - Not yet verified on a device: the quick keys on a real iPhone, the light/dark terminal repaint, the counts that the
855
+ picker's pane title carries to the Workspaces sheet and the **Lanes** button (a phone view created before that
856
+ change needs `lanes rebuild <device>`; `lanes doctor` lists such devices), and an iPad Safari pass of the tablet mode
857
+ (top bar, left panel, whole-tab view). The mode choice itself is unit-tested.
858
+
859
+ ## 5. Uploads
860
+
861
+ Files land in the workspace's `uploads/` folder in its container, the same folder Cursor users drop files into. The
862
+ in-container path ends up on your clipboard and in a notification, and can be typed into the lane for the agent.
863
+
864
+ | Way | How |
865
+ | --- | --- |
866
+ | Upload strip | Click the strip above the footer (or press `U` in the sidebar, `Ctrl-Alt-u` from anywhere in the tab); it turns into ` ↓ DROP FILES NOW · anywhere · Esc`. Drop the files anywhere on the window. The file goes to that tab's workspace and its path is typed into the lane. While sending: ` ↑ sending 2 files · 12.4 MiB`; the result stays 8 s as ` ✓ name` or ` ✗ reason` |
867
+ | Watch folder | `~/Lanes Uploads/` → the workspace your view shows; `~/Lanes Uploads/<workspace>/` → that workspace. Sent files move to `.sent/<date>/`; a failed upload stays for a retry |
868
+ | Command | `lanes-upload [-w <workspace>] [--paste] <files…>` · `lanes-upload --clipboard [-w <workspace>] [--paste]` (clipboard image or copied files) |
869
+ | macOS droplet | `~/Applications/Lanes Upload.app`: drop files on it; double-click uploads the clipboard screenshot |
870
+ | Omarchy | `Super+Shift+U` uploads the clipboard and types the path into the lane |
871
+ | Server | `lanes upload <workspace> \| --active <device> [--paste]` reads a tar stream on stdin |
872
+
873
+ **Where files land.** `<workspace folder>/uploads/<name>` in the container, where the workspace folder is its first
874
+ lane's repository folder (else the workspace's working directory). An existing name is never overwritten: the next
875
+ one becomes `<name>-1.<ext>`. The folder is created on first use and, in a git repository that does not ignore it
876
+ yet, added to the repository-local `.git/info/exclude`, so uploads are never committed.
877
+
878
+ **Why click first.** A terminal delivers a drop as a paste of the local path into the focused pane, so the strip must
879
+ hold the focus when you let go. The server receives only the path; the **upload helper** on your machine sends the
880
+ file.
881
+
882
+ **The upload helper and its allowlist.** The helper is the security boundary for drops. It reads only from folders
883
+ listed in `~/.config/lanes/upload-allow` (created with Desktop, Downloads, Pictures, Documents, Lanes Uploads and the
884
+ temp folders), asks on macOS once per new folder (**Deny**, **Allow once**, **Always allow**), and never reads from
885
+ `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.kube`, `~/.config`, `~/.docker`, keychains, `.git` or `/etc`.
886
+ `lanes-upload --allow <dir>` adds a folder; `lanes-upload --allowed` lists them. The allowlist applies to drops served
887
+ by the helper; a `lanes-upload <files>` you type sends what you name.
888
+
889
+ The helper runs as one background service per machine (launchd `ai.beyondwork.lanes-helper` on macOS,
890
+ `lanes-helper.service` on Linux), so it survives closing windows; while no service serves, an open window runs its
891
+ own. On macOS the service runs as `~/Applications/Lanes Helper.app`: choose **Allow** when it asks for Downloads,
892
+ Desktop or Documents, or enable it under System Settings → Privacy & Security → Files and Folders → Lanes Helper.
893
+ Full Disk Access is not needed; the allowlist and macOS's folder permission are separate checks.
894
+
895
+ Transfers have an idle timeout (two minutes without incoming data), not a total-duration limit. Logs:
896
+ `~/.local/state/lanes-client/helper-service.log` (macOS) or `journalctl --user -u lanes-helper` (Linux).
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
+
917
+ | Strip says | Meaning | Do |
918
+ | --- | --- | --- |
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 |
920
+ | `✗ macOS denied folder access: allow Lanes Helper in System Settings > …` | macOS refused the helper | Allow Lanes Helper in Files and Folders |
921
+ | `✗ no upload helper on this device (reopen lanes-launch)` | No helper heartbeat for this device | `./install.sh --check`; reopen the window |
922
+ | `✗ that was not a file drop` | The paste was not a list of absolute paths | Drop files, not text |
923
+ | `✗ upload timed out` | No progress for 120 s | Check the helper log |
924
+
925
+ ## 6. Workspaces and registry
926
+
927
+ ### The chain
928
+
929
+ ```text
930
+ catalog.json ──► workspaces.<workspace>.json ──► <repo>/.vscode/terminals.json (in the container)
931
+ (tabs, order) (container, repositories) (the terminals = the lanes)
932
+ + registered-sessions.json (adopted sessions)
933
+ lanes sync ──► ~/.local/state/lanes/registry.json, resolve/, and ~/.lz/registry.json in each container
934
+ ```
935
+
936
+ The site directory is `$LANES_WORKSPACES`, else `~/.config/lanes/workspaces/` when it holds a `catalog.json`, else
937
+ `workspaces/` in the checkout. Start from `lanes/workspaces.example/` ([reference](../workspaces.example/README.md)).
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`.
941
+
942
+ **`catalog.json`**: `version` (1), `workspaces`: `{id, title, manifest}` in tab order. The `id` is also the upload
943
+ and watch-folder name; `title` is the tab. Changing a title renames the existing tab. Optional `sshWorkspaces` adds
944
+ fixed terminals on another host ([README](../README.md#register-a-workspace-once)).
945
+
946
+ **`workspaces.<workspace>.json`** (the manifest): `repositories[]`, each with `id`, `host` (the container),
947
+ `registry` (the repository's `.vscode/terminals.json` path inside the container), and optionally `prefix` (prepended
948
+ to lane titles), `displayNames` (display overrides keyed by terminal name), `bindingOverrides` (a tmux binding for a
949
+ terminal that has none), and `cursor` (a second file to project the workspace into). `remoteShell.cwd` sets the
950
+ working directory; `monitor` adds the Throttle monitor tab; `discoveryRoots` extends the directories that associate
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.
954
+
955
+ **`.vscode/terminals.json`** (in the repository, inside the container):
956
+
957
+ ```json
958
+ {
959
+ "terminals": [
960
+ { "name": "Shell", "command": "exec /home/coder/.lz/bin/lane open product--shell", "focus": true }
961
+ ],
962
+ "lanes": {
963
+ "version": 1,
964
+ "terminals": {
965
+ "Shell": {
966
+ "holder": "tmux",
967
+ "tmux": { "socketName": "project", "sessionName": "shell" },
968
+ "create": "TMUX= tmux -L project new -ADs shell bash -l"
969
+ }
970
+ }
971
+ }
972
+ }
973
+ ```
974
+
975
+ | Key | Meaning |
976
+ | --- | --- |
977
+ | `terminals[]` | The terminals the editor extension opens and Lanes turns into lanes: `name` (the lane title; the id is its slug), `command`, optional `focus`, `color` (`terminal.ansi<Colour>` / `terminal.ansiBright<Colour>`), `icon` (a codicon name) |
978
+ | `lanes.terminals[<name>]` | The exact binding Lanes uses: `holder`, `tmux: {socketName, sessionName}`, `id` (added by `cursor-sync`), and `create` (the recipe `lane open` runs when the lane does not exist; `createPrevious` keeps an earlier one after `cursor-sync --canonicalize`/`--identity`). Written by `lanes cursor-sync`; ignored by the editor extension |
979
+ | `throttle.terminals[<name>].tmux` | A binding Lanes also accepts (Throttle Studio's), used when `lanes.terminals` has none |
980
+
981
+ Every terminal needs an explicit tmux binding; there is no guessed command parsing. Display names are not set in
982
+ `terminals.json`: the manifest's `displayNames` holds them.
983
+
984
+ **`registered-sessions.json`** (site directory, version 1): `sessions[]` of `{id, workspace, name, container,
985
+ tmux: {socket, session}, folder}`, written by Add to workspace and `lanes register`. Back it up with the catalog.
986
+
987
+ ### Lane ids
988
+
989
+ | Lane | Id |
990
+ | --- | --- |
991
+ | Repository lane | `<workspace>--<slug of prefix + declared terminal name>` |
992
+ | Unregistered or adopted session | `<workspace>--x-<slug>-<digest>` (an adopted session keeps it) |
993
+ | zmx lane | `<workspace>--z-<name>` |
994
+
995
+ A lane id never changes on rename. `lanes ls [--json] [workspace]` lists registry lanes with their state;
996
+ `lanes ls --unregistered [--json]` lists unregistered sessions; `lanes resolve <lane-id>` prints one lane's exact
997
+ target as JSON.
998
+
999
+ ### Sync
1000
+
1001
+ - `lanes-sync.timer` runs `lanes sync` every 2 minutes (30 s after boot). It is read-only toward repositories: it
1002
+ rebuilds the registry, the resolve files and each container's `~/.lz/registry.json`, then reconciles the tabs of
1003
+ every known live device (`lanes upgrade-session`). A sync with nothing new rewrites nothing.
1004
+ - **Hot-sync**: `lanes-statusd` starts one `lanes sync` when a referenced `terminals.json` changes, at most one per
1005
+ 10 s.
1006
+ - `lanes-ensure.timer` runs `lanes ensure` every minute: it recreates any known device session that is missing, and
1007
+ never when Zellij did not answer.
1008
+ - A repository whose registry is temporarily invalid keeps its previous lanes; the sidebar footer and `lanes doctor`
1009
+ say why. `lanes sync` exits 1 then (the timer treats that as informational).
1010
+ - A device with no attached client gets new tabs on its next connection and the next ensure tick.
1011
+
1012
+ ### Cursor and VS Code
1013
+
1014
+ Cursor's Terminals Manager opens what `.vscode/terminals.json` lists. `lanes cursor-sync` normalizes how each terminal
1015
+ reaches its lane, so Cursor and Lanes attach to the same tmux session:
1016
+
1017
+ | Command | Effect |
1018
+ | --- | --- |
1019
+ | `lanes cursor-sync [workspace…]` | Dry run: the summary and a short diff per file |
1020
+ | `lanes cursor-sync [workspace…] --diff` | Dry run with the full diff |
1021
+ | `lanes cursor-sync [workspace…] --write` | Apply: each `command` becomes `exec /home/coder/.lz/bin/lane open <lane-id>`, the original kept as the `create` recipe; backups under `~/.local/state/lanes/cursor-sync-backups/` |
1022
+ | `… --canonicalize` | Also replace a recipe that cannot create its lane (none, attach-only, the retired path, a missing script) with the canonical recipe (section 7) |
1023
+ | `… --identity` | Also add the identity `-e` flags to a working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD |
1024
+ | `lanes cursor-commit [workspace…]` | Dry run of committing the normalized file on top of each repository's remote branch tip |
1025
+ | `lanes cursor-commit [workspace…] --push` | Commit with git plumbing (no working tree or index touched) and push; a protected branch gets a `lanes/terminals-lane-open` branch and a pull request |
1026
+
1027
+ `cursor-commit` reads `commit-targets.json` in the site directory. Neither command runs automatically.
1028
+ **Operator gate:** `--canonicalize --write` or `--identity --write` on a live workspace rewrites operator-declared
1029
+ recipes and produces the first `lanes/<ws>/<id>` trace; decide lane-level against per-session proxy fairness first
1030
+ (DEF-1560, [RUNBOOK](RUNBOOK.md#cursor--vs-code-terminals--the-same-lanes)). The rewrite changes the file only: a
1031
+ running lane gains its identity when it is next created.
1032
+
1033
+ ### Rename rules
1034
+
1035
+ | Lane kind | `R` / `lanes rename <lane-id> <name>` writes |
1036
+ | --- | --- |
1037
+ | Repository lane | `repositories[i].displayNames` in `workspaces.<workspace>.json` (a `.bak-<UTC>` copy on the first write), then syncs. Renaming back to the terminal's own title removes the override |
1038
+ | Registered session | Its record's `name` in `registered-sessions.json` |
1039
+ | Unregistered session | `~/.local/state/lanes/user-sessions/<id>.json` |
1040
+
1041
+ Names must be non-empty and free of control characters. The id, resolve file and `lane open <id>` never change.
1042
+
1043
+ ### Register, promote, unregister
1044
+
1045
+ - **Register** (Add to workspace, or `lanes register <lane-id> [workspace]` with the id from
1046
+ `lanes ls --unregistered`): saves the session's existing id, name and binding in `registered-sessions.json`. It
1047
+ has no create recipe and does not edit any editor terminal list; an ended registered session stays absent until
1048
+ someone starts it. Only workspaces using the session's container are offered.
1049
+ - **Promote** (`P`): declares the session in the workspace's single repository `terminals.json` (a terminal entry
1050
+ and its `lanes.terminals` binding), so it gets a repository id, editor integration and a saved recipe when one was
1051
+ known.
1052
+ - **Unregister** a registered session: remove its record from `sessions` and run `lanes sync`; the terminal keeps
1053
+ running and returns to Unregistered. Remove a repository lane by deleting its terminal from `terminals.json`.
1054
+ - If a repository later declares the same binding as a registered session, the repository declaration wins.
1055
+
1056
+ ### `lanes doctor`
1057
+
1058
+ `lanes doctor` is the full health check (every external call bounded; exit 1 on any `✗`). It reports:
1059
+
1060
+ - `✓`/`✗`: Zellij installed, registry present, clean and up to date (no `terminals.json` newer than the last sync),
1061
+ each container running, the statusd heartbeat, all registry lanes observed and present (stale lanes counted as
1062
+ "shown with their last known state"), the process table, no host-side tmux servers, coexistence mode, Zellij
1063
+ reachable, phone picker panes current, and the host not under critical memory pressure.
1064
+ - Information: lanes per create-recipe class (`idempotent`, `attach-only`, `null`, `external`, `retired-path`), how many
1065
+ create with their identity, where the `lane` contract is installed, zmx-held lanes, the preview renderer, the live
1066
+ view sessions, and the pressure summary.
1067
+ - Warnings (`⚠`, never a `✗`): lanes that `lane open` cannot recreate (fix: `lanes cursor-sync --canonicalize`),
1068
+ lanes bound to a numeric session name, more than 4 lanes on one container's tmux socket `default`, orphan tmux
1069
+ socket files in a container (report only; inside the container `~/.lz/bin/lane gc --sockets` removes the ones tmux
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).
1078
+
1079
+ ## 7. Agents in lanes
1080
+
1081
+ ### How the agent state is derived
1082
+
1083
+ `lanes-statusd` reads each container through `/proc/<init pid>/root` every cycle (3 s) and, without starting
1084
+ anything, picks the first signal that applies:
1085
+
1086
+ 1. A **hook sidecar** `~/.lz/state/hooks/<lane id>.json` written by `lane-signal` (when hooks are installed), while
1087
+ its agent process is alive and its last event is under an hour old.
1088
+ 2. A **Claude Code session file** `~/.claude/sessions/<pid>.json` naming the pane, for a live pid (recycled pids are
1089
+ rejected).
1090
+ 3. The **Codex pane title**: `[ ! ] Action Required` is needs approval, a braille spinner is working, a task title is
1091
+ idle.
1092
+ 4. The **window bell**: an idle title plus a bell is done.
1093
+
1094
+ A Codex title left on a bare shell with no child process is `shell` (the harness ended).
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
+
1125
+ ### Hooks (opt-in, per container, dry run first)
1126
+
1127
+ Hooks make the state event-driven. Nothing installs one unasked: `lanes onboard --write` (above) installs both, or
1128
+ install them one by one.
1129
+
1130
+ ```sh
1131
+ lanes install-container <name> # puts lane-signal in ~/.lz/bin
1132
+ lanes hooks install --agent claude --container <name> # prints the diff to ~/.claude/settings.json
1133
+ lanes hooks install --agent claude --container <name> --write # applies it (backup settings.json.lanes-bak-<time>)
1134
+ lanes hooks install --agent codex --container <name> --write # ~/.codex/hooks.json (+ notify in config.toml)
1135
+ ```
1136
+
1137
+ `lanes hooks install --agent claude|codex (--container <name> | --home <dir>) [--dry-run | --write]`: a dry run
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
1141
+ Codex it also adds a top-level `notify` to `~/.codex/config.toml` only when none exists. Codex asks you to review and
1142
+ trust new hooks; Lanes never writes that trust. Expect hooks to reach agent sessions started after the install
1143
+ (inferred from how both harnesses read their configuration, not measured). Undo:
1144
+ restore the `.lanes-bak-*` file, or delete the groups whose command contains `lane-signal`.
1145
+
1146
+ | Harness | Event | Status |
1147
+ | --- | --- | --- |
1148
+ | Claude Code | `UserPromptSubmit`, `PostToolUse` | working |
1149
+ | Claude Code | `Stop`, `Notification` (idle prompt) | needs_input |
1150
+ | Claude Code | `PermissionRequest`, `Notification` (permission prompt) | needs_approval |
1151
+ | Claude Code | `StopFailure` | errored |
1152
+ | Claude Code | `SessionEnd` | shell |
1153
+ | Codex | `SessionStart` | idle |
1154
+ | Codex | `UserPromptSubmit`, `PostToolUse` | working |
1155
+ | Codex | `PermissionRequest` | needs_approval |
1156
+ | Codex | `Stop`, `notify` (turn complete) | done |
1157
+ | Codex | `SessionEnd` | shell |
1158
+
1159
+ Known limit: after you approve a permission prompt the lane reads needs approval until the approved tool call
1160
+ returns, because neither harness signals "approved".
1161
+
1162
+ ### The in-container `lane` command
1163
+
1164
+ Installed at `/home/coder/.lz/bin/lane` by `lanes install-container`. Viewers, editors and scripts use only these
1165
+ verbs, so the holder stays swappable.
1166
+
1167
+ | Verb | Effect |
1168
+ | --- | --- |
1169
+ | `lane open <lane-id>` | The editor entry point: attach to the lane; if it does not exist, run its recorded create recipe. Refuses to nest inside tmux; attaches with `ignore-size` while a lanes view shows the lane |
1170
+ | `lane attach <id>` | Attach this terminal; never creates; refuses to nest |
1171
+ | `lane send <id> [--no-enter] <text…>` | Type into the lane: tmux literally, then Enter unless `--no-enter`; zmx sends the bytes as given |
1172
+ | `lane capture <id> [--lines N] [--vt]` | Print the scrollback (plain, or with escapes under `--vt`) |
1173
+ | `lane ls [--json] [--all]` | zmx lanes; `--all` adds the registry's tmux lanes with `alive` |
1174
+ | `lane status <id>` | JSON: alive, the hook agent, metadata; exit 1 when not running |
1175
+ | `lane kill <id> --yes [--force]` | End the lane (the only verb that ends an agent); refuses while the hook sidecar says `working` unless `--force` |
1176
+ | `lane new <id> [--ws W] [--title T] [--cwd DIR] [--icon I] [--color C] -- <command…>` | Create a zmx-held lane |
1177
+ | `lane gc --sockets` | Unlink this container's tmux socket files that tmux is refused on (no server listens); a live, empty or silent server keeps its file |
1178
+
1179
+ `lane-run` is the root process of a zmx lane: it runs the agent command, then a login shell in the same directory,
1180
+ forever ("lanes never die on agent exit"). `~/.lz/env.sh`, sourced by new shells, points `TMPDIR` and
1181
+ `CLAUDE_CODE_TMPDIR` at `/home/coder/.tmp` (`LANES_TMP_DIR` names another; `LANES_TMP_OPTOUT` opts out); tmux sockets
1182
+ stay in `/tmp/tmux-<uid>`.
1183
+
1184
+ ### Lane identity and the canonical recipe
1185
+
1186
+ A lane's identity is its environment: `LANE_ID` (`<ws>--<slug>`), `LANE_WS` (`<ws>`), and the Throttle trace
1187
+ `lanes/<ws>/<id>` as `THROTTLE_TRACE` and as `ANTHROPIC_CUSTOM_HEADERS='x-throttle-trace: lanes/<ws>/<id>'`. A tmux
1188
+ lane gets it only from the `-e` flags of its create recipe; `lane open` deliberately exports none (the client that
1189
+ starts a shared tmux server would hand its environment to every later session there). The canonical recipe is:
1190
+
1191
+ ```sh
1192
+ tmux -L <socket> new -ADs <session> -c <folder> -e LANE_ID=<id> -e LANE_WS=<ws> \
1193
+ -e ANTHROPIC_CUSTOM_HEADERS='x-throttle-trace: lanes/<ws>/<id>' -e THROTTLE_TRACE=lanes/<ws>/<id> bash -l
1194
+ ```
1195
+
1196
+ `-A` attaches when the session exists, so the identity lands only on a real create. A running lane gains it when it is
1197
+ next created. A trace makes the whole lane one proxy fairness bucket (operator gate above, DEF-1560).
1198
+
1199
+ ### zmx
1200
+
1201
+ zmx is an optional, separately pinned holder (`lane new`, `holders/zmx.sh`). Installing it migrates nothing: the
1202
+ registry's lanes stay on tmux. zmx lanes appear in their own `zmx lanes (N)` section; a hook sidecar does not reach a
1203
+ zmx lane's agent state yet (DEF-1573). Gate results: [ZMX-RING0.md](ZMX-RING0.md).
1204
+
1205
+ ## 8. Economy and notifications
1206
+
1207
+ ### Economy projection (opt-in, operator gate)
1208
+
1209
+ `lanes-economy.service` polls the Throttle daemon's control port (`GET /status` and `/sessions`, default
1210
+ `http://127.0.0.1:19181`) every 10 s, folds today's `proxy.log`, and writes `~/.local/state/lanes/economy.json`. It is
1211
+ installed with the other units and enabled by nothing: it reads the production daemon, so turning it on is your call.
1212
+ It runs on the host, so check what it can reach first:
1213
+
1214
+ ```sh
1215
+ LANES_THROTTLE_CONTROL=none LANES_THROTTLE_CONTAINER=<container> lanes economy --once --dry-run # writes nothing
1216
+ systemctl --user edit lanes-economy.service # put the same settings in a drop-in ([Service] Environment=)
1217
+ systemctl --user enable --now lanes-economy.service
1218
+ ```
1219
+
1220
+ `lanes economy --once [--dry-run] [--status-file F --sessions-file F]` runs one poll; stderr names each source it
1221
+ could not read. Settings: `LANES_THROTTLE_CONTROL` (base URL, or `none` for no request), `LANES_THROTTLE_TOKEN_FILE`
1222
+ (a bearer in a file), `LANES_THROTTLE_CONTAINER` (read the log through that container's home volume),
1223
+ `LANES_THROTTLE_LOG_DIR` (where `proxy.log` lives). A daemon bound to loopback inside its container is not reachable
1224
+ from the host: use `LANES_THROTTLE_CONTROL=none` there (the log fold still works). Details:
1225
+ [RUNBOOK](RUNBOOK.md#economy-projection-opt-in).
1226
+
1227
+ **The fleet strip** (the strip row, panes of 18 rows or more), parts in priority order, whole parts dropped to fit:
1228
+
1229
+ | Part | Meaning |
1230
+ | --- | --- |
1231
+ | `$8.5k/16k 53%` | Overflow spend today against the daily cap; amber at or over the alert level |
1232
+ | `429 +4` | New upstream 429s since the last poll (only when above 0) |
1233
+ | `F2 S1` | This workspace's lanes by model tier: `F` fable, `O` opus, `S` sonnet, `H` haiku, `G` gpt |
1234
+ | `spill 76%` | The pool's spill fraction; amber unless the pressure phase is closed |
1235
+ | `free 3/19` | Effectively free accounts of all accounts |
1236
+ | `reset 2h` | The soonest 5 h reset |
1237
+
1238
+ A leading `?` and an all-dim line mean the figures are frozen: `/status` failed, or the poller stopped (no rewrite for
1239
+ over 3 minutes). Without the poller, or with `LANES_THROTTLE_CONTROL=none`, the strip row shows fleet attention
1240
+ instead.
1241
+
1242
+ **Per-lane cells** (column 34): `⚠` starving (the pane is producing output but the proxy saw no request from the lane
1243
+ for over 10 minutes, or a 429 is in its recent requests) · `↓` downgraded in the last 5 minutes · `$` more than half of
1244
+ its recent requests paid. A dim `⚠` or `↓` is a frozen last-known figure, not a live alarm. A lane the poller has not
1245
+ joined has a blank cell.
1246
+
1247
+ ### Desktop notifications (OSC 9)
1248
+
1249
+ Covered in section 3: one notification per lane entering Needs you, from the sidebar itself, on desktop layouts.
1250
+
1251
+ ### Push notifications (opt-in, operator gate)
1252
+
1253
+ `lanes-notify.service` sends one push to an [ntfy](https://ntfy.sh) topic when a lane's agent enters needs you
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
1259
+ you configure, so enabling it is your decision. Not yet run against a real ntfy server or phone.
1260
+
1261
+ `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` at install time renders another path), `key=value` lines:
1262
+
1263
+ | Key | Default | Meaning |
1264
+ | --- | --- | --- |
1265
+ | `url` | required | The ntfy server, `https://…` |
1266
+ | `topic` | required | `[A-Za-z0-9_-]{1,64}`; on a public server the topic name is the only secret |
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) |
1268
+ | `quiet_when_device_active_s` | `120` | Presence window; `0` never suppresses |
1269
+ | `action_url` | unset | A URL template over `{lane}`; becomes the tap target and an **Open lane** button |
1270
+
1271
+ Nothing is sent when the status did not change, when the service just started (no burst on restart), or when a desk
1272
+ device was present within the presence window: a `lanes attach` from any device, or a lane opened from a desk
1273
+ device's sidebar. Opening a lane from the phone never mutes pushes. A failed send is one journal line, not retried.
1274
+
1275
+ ```sh
1276
+ $EDITOR ~/.config/lanes/notify.conf
1277
+ python3 ~/.local/share/lanes/current/libexec/notify.py --test # one test push; exit 1 = not sent
1278
+ systemctl --user enable --now lanes-notify.service
1279
+ ```
1280
+
1281
+ ## 9. Appearance
1282
+
1283
+ One token table, `libexec/tokens.py`, defines every colour and glyph. It generates marked blocks in
1284
+ `zellij/config.kdl` (the `lanes-dark` / `lanes-light` themes and the web client palette), `web/mobile.css`,
1285
+ `web/mobile.js`, `libexec/tokens.sh` (the viewer's status screens), `docs/DESIGN.md` and the optional Ghostty palette
1286
+ files. `python3 libexec/tokens.py --check` fails on drift; `--write` regenerates all blocks; `--kdl`, `--css`, `--sh`,
1287
+ `--md`, `--js`, `--ghostty-dark` and `--ghostty-light` print one block.
1288
+
1289
+ | Switch | Values | Default |
1290
+ | --- | --- | --- |
1291
+ | Colour (`--color`, `LANES_COLOR`) | `truecolor` · `16` · `none` | `truecolor` when `COLORTERM` is `truecolor`/`24bit` (the view's config sets it for every pane), else `16`; `NO_COLOR` set means `none` |
1292
+ | Theme (`--theme`, `LANES_THEME`) | `dark` · `light` · `auto` | `auto`: the sidebar asks the terminal's background once (OSC 11) and paints dark without an answer in 0.3 s |
1293
+ | Glyphs (`--glyphs`, `--ascii`, `LANES_GLYPHS`) | `unicode` · `ascii` · `emoji` | `unicode`; `ascii` when no locale variable names UTF-8. `emoji` adds an icon column from the lane's `icon` |
1294
+ | Dim (`LANES_DIM`) | `90` · `2` | `90`; 16-colour mode only: `2` for palettes whose colour 8 is near the background |
1295
+
1296
+ **Set the look per device**, live: `lanes attach <device> --ascii` (or `--glyphs M`, `--theme T`, `--color C`) records
1297
+ it in `~/.local/state/lanes/devices/<device>.json` for a device that already has that file; every running sidebar of
1298
+ the device picks it up within a tick, and the choice survives `lanes ensure` and `lanes rebuild`. From a client,
1299
+ `lanes-window --ascii|--light|--dark [suffix]` and `lanes-launch <device> --ascii|--theme T|--glyphs M|--color C`
1300
+ pass the same flags. The device file wins over the environment.
1301
+
1302
+ The environment variables reach a sidebar only through the view server's environment when its session is created,
1303
+ and `bin/lanes` starts view sessions from a clean environment that does not forward them. On a standard server use
1304
+ the per-device flags; the variables matter when you run the sidebar by hand.
1305
+
1306
+ **Ghostty palette** (opt-in): by default the lanes window inherits your everyday Ghostty look. `./install.sh --palette`
1307
+ installs `~/.config/lanes/ghostty-palette.conf` generated from the tokens (a light/dark theme pair on Ghostty 1.1 or
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`.
1311
+
1312
+ **Fonts**: no Nerd Font is needed. Any monospace font with Latin-1, Box Drawing, Geometric Shapes, Dingbats, Arrows and
1313
+ `⋮ ⏎ ⚠ …` renders the cockpit; `--ascii` is the remedy for any other font.
1314
+
1315
+ The legend, token table, mockups and budgets: [DESIGN.md](DESIGN.md).
1316
+
1317
+ ## 10. Sharing and Studio
1318
+
1319
+ **Shared lanes (read-only).** Throttle's **Shared lanes** console shows selected workspaces to people in your
1320
+ organization through Throttle SSO. An organization administrator chooses who may share; a workspace owner exposes a
1321
+ workspace and picks people or teams. Viewers see the workspace's registered lanes (names, observed activity, and for
1322
+ tmux lanes the latest 2,000 lines of a pane, capped at 256 KiB) as escaped plain text, refreshed every five seconds;
1323
+ there is no input, upload, start/stop, resize or terminal connection. Each workspace is private until enabled. It
1324
+ needs a platform and Studio Host built with Lanes sharing support. See [SHARING.md](SHARING.md).
1325
+
1326
+ **Co-located Studio Host (opt-in).** One Throttle Studio Host can serve every workspace of a Lanes server from the
1327
+ workspace account, supervised as `lanes-studio-host.service` with the retry timer `lanes-studio-host-enrol.timer`.
1328
+ The server installer renders the units but never enables them or downloads the binary.
1329
+
1330
+ ```sh
1331
+ lanes studio-host install --from ./throttle-rc --sha256 <digest> # select a sha256-verified binary
1332
+ sh install/enable-studio-host.sh --server https://throttle.example.com --dry-run
1333
+ sh install/enable-studio-host.sh --server https://throttle.example.com
1334
+ lanes studio-host status
1335
+ ```
1336
+
1337
+ | Command | Effect |
1338
+ | --- | --- |
1339
+ | `lanes studio-host status [--json] [--check]` | Binary digest, config mode, enrolment and units; exit 0 only when it runs enrolled |
1340
+ | `lanes studio-host install [--from PATH [--sha256 HEX]]` | Select the Host binary (the `holders.lock` opt-in pin, or a local build); restarts nothing |
1341
+ | `lanes studio-host re-key [--yes] [--dry-run]` | After the owner revoked this machine: stop the Host, remove the machine key and its dead enrolment, publish a new public key; nothing without `--yes` |
1342
+ | `sh install/enable-studio-host.sh [options]` | Write the Host config, publish the public key, adopt a deploy receipt or enrol with a registration key, enable the units (exit 0 running or waiting, 2 usage, 3 refused, 1 failure); `--help` lists the options |
1343
+
1344
+ Throttle Studio is a second client of the same lanes. It reads the registry, the repositories' terminal lists and
1345
+ the live tmux servers and writes none of the registry files; on a workspace's tmux server it adds only its own
1346
+ browser viewer sessions (`view-…`, shown as `·` marks after a lane's name) and the lanes it starts
1347
+ (`st_<workspace>_<slug>`, listed under Unregistered until declared). Details:
1348
+ [SHARING.md](SHARING.md#co-located-studio-host-one-host-per-lanes-server) and
1349
+ [RUNBOOK](RUNBOOK.md#throttle-studio-as-a-client).
1350
+
1351
+ ## 11. Operations reference
1352
+
1353
+ ### systemd user units
1354
+
1355
+ All units run in `lanes.slice` and are rendered by the server installer from `lanes/systemd/`.
1356
+
1357
+ | Unit | Schedule | Enabled by | Does |
1358
+ | --- | --- | --- | --- |
1359
+ | `lanes.slice` | — | (used by every unit) | High CPU weight and `MemoryLow` for the harness, so it stays scheduled on a thrashing host |
1360
+ | `lanes-statusd.service` | Always on | Server install | Observes every lane (never touches one); writes `status.json` and `health`; hot-sync |
1361
+ | `lanes-sync.timer` / `.service` | Every 2 min | Server install | `lanes sync` |
1362
+ | `lanes-ensure.timer` / `.service` | Every 1 min | Server install | `lanes ensure`: recreate missing device view sessions |
1363
+ | `lanes-gc.timer` / `.service` | Daily | Server install | `lanes gc`: truncate zmx logs over 5 MB, drop stale dynamic resolve files |
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` |
1366
+ | `lanes-mobile.service` | Always on | `enable-web.sh` | Caddy frontend on `127.0.0.1:8083` |
1367
+ | `lanes-studio-host.service` | Always on | `enable-studio-host.sh` (opt-in) | The co-located Throttle Studio Host |
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 |
1369
+ | `lanes-economy.service` | Every 10 s | Nobody (opt-in, operator gate) | Economy projection `economy.json` |
1370
+ | `lanes-notify.service` | Every 3 s | Nobody (opt-in, operator gate) | ntfy push on needs-you transitions and new enrolment requests |
1371
+ | `lanes-tmpreap.timer` / `.service` | Hourly | Nobody (opt-in) | `lanes tmp reap`; a dry run until `~/.local/state/lanes/flags/tmpreap.armed` exists |
1372
+ | `lanes-guard.timer` / `.service` | Every 60 s | Nobody (opt-in) | `lanes guard --once`: raise `oom_score_adj` of builds in the containers |
1373
+
1374
+ Clients also get `lanes-helper.service` and `lanes-upload.path`/`.service` (Linux) or the launchd agents
1375
+ `ai.beyondwork.lanes-helper` and `ai.beyondwork.lanes-upload` (macOS).
1376
+
1377
+ ### `lanes` commands (server)
1378
+
1379
+ | Group | Command | Does |
1380
+ | --- | --- | --- |
1381
+ | Registry | `lanes sync` | Refresh the registry and reconcile workspaces on every known device |
1382
+ | Registry | `lanes ls [--json] [--unregistered] [workspace]` | Lanes with live status (`?` after a state = last known) |
1383
+ | Registry | `lanes resolve <lane-id>` | One lane's exact target as JSON |
1384
+ | Registry | `lanes status` | One line per workspace: state counts and the `status.json` age |
1385
+ | Registry | `lanes register <lane-id> [workspace]` | Add an unregistered session to a workspace without restarting it |
1386
+ | Registry | `lanes rename <lane-id> <new-name>` | Change a lane's display name (not in the usage text) |
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) |
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 |
1390
+ | Views | `lanes prepare <device> [--mobile\|--desktop] [--viewports N]` | Create a view without attaching; a different explicit layout is refused |
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) |
1393
+ | Views | `lanes open <lane-id>` | Attach this terminal straight to one lane |
1394
+ | Views | `lanes layout <device> [--mobile]` | Print the generated Zellij layout |
1395
+ | Views | `lanes rebuild <device>` | Recreate a device's view (agents never touched) |
1396
+ | Views | `lanes upgrade-session [device…]` | Sync catalog tabs and titles into live views without a rebuild |
1397
+ | Views | `lanes ensure` | Recreate known device sessions that are missing |
1398
+ | Views | `lanes reattach` | Make every view re-attach its lane |
1399
+ | Views | `lanes device list` / `lanes device forget <name>` | The devices `ensure` keeps / stop keeping one |
1400
+ | Views | `lanes next\|prev --focused [--device D]` | What `Ctrl-Alt-↓` / `Ctrl-Alt-↑` run |
1401
+ | Views | `lanes preview --focused [--device D]` | What `Ctrl-Alt-m` runs |
1402
+ | Editor | `lanes cursor-sync [ws…] [--write] [--diff] [--canonicalize] [--identity]` | Section 6 |
1403
+ | Editor | `lanes cursor-commit [ws…] [--push]` | Section 6 |
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) |
1406
+ | Containers | `lanes hooks install --agent claude\|codex (--container <name> \| --home <dir>) [--dry-run \| --write]` | Section 7 |
1407
+ | Uploads | `lanes upload <ws> \| --active <device> [--paste]` | Read a tar stream into that workspace's `uploads/` |
1408
+ | Client | `lanes hello [device]` | Prove path, key and server version |
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) |
1417
+ | Health | `lanes doctor` | Full health check (section 6) |
1418
+ | Health | `lanes pressure` | Memory / io / cpu pressure, swap, per-container memory and `/tmp`; plain files only; exit 1 unless ok |
1419
+ | Health | `lanes invariant save\|check` | Snapshot / verify every live pane's pid and window size |
1420
+ | Health | `lanes sockets [check\|repair]` | tmux servers whose socket file was deleted; `repair` sends tmux's SIGUSR1, nothing restarts |
1421
+ | Housekeeping | `lanes gc` | What the daily timer runs |
1422
+ | Housekeeping | `lanes tmp scan\|plan\|apply\|reap\|caches [container…] [--approve] [--home]` | What fills the containers' RAM-backed `/tmp`, and removing the dead part ([RUNBOOK](RUNBOOK.md#free-the-ram-lanes-tmp)) |
1423
+ | Housekeeping | `lanes guard [--dry-run] [container…]` | One OOM-guard pass by hand |
1424
+ | Throttle | `lanes economy --once [--dry-run] [--status-file F --sessions-file F]` | One economy poll (section 8) |
1425
+ | Throttle | `lanes studio-host status\|install\|re-key …` | Section 10 |
1426
+
1427
+ The client helpers call three more verbs (`rebuilt`, `upload-requests`, `upload-result`); you do not run them.
1428
+
1429
+ ### Client commands
1430
+
1431
+ | Command | Does |
1432
+ | --- | --- |
1433
+ | `lanes-window [--ascii] [--light\|--dark] [suffix]` | Open the lanes window (section 3) |
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) |
1437
+ | `lanes-launch --helper <device> [--service \| --while-pid PID]` | Run only the upload helper (the service form is what the installer sets up) |
1438
+ | `lanes-upload …` | Uploads (section 5) |
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) |
1441
+ | `./install.sh …` | Install, check, update, uninstall (section 2) |
1442
+
1443
+ ### State files
1444
+
1445
+ Server (`~/.local/state/lanes/`, `$LANES_STATE`):
1446
+
1447
+ | Path | Holds |
1448
+ | --- | --- |
1449
+ | `registry.json`, `registry.synced`, `resolve/`, `resolve.dynamic/` | The published registry, the last sync time, per-lane targets |
1450
+ | `status.json`, `health` | statusd's snapshot and the one-line health record |
1451
+ | `containers.json`, `containers/` | Container init pids and home volumes |
1452
+ | `devices/<device>.json` | Each known device: layout, viewports, base device, look |
1453
+ | `layouts/<device>.kdl` | The layout a view session was built from |
1454
+ | `views/<device>/` | Per-viewport target, pid and error files |
1455
+ | `clients/` | Client pids, `.ready`, `.rebuilt`, `<device>.seen.json` (opened `done` lanes), `.theme`, `.glyphs` |
1456
+ | `defaults/<workspace>` | The lane a workspace's first viewport shows by default (its `focus` lane, else the first) |
1457
+ | `user-sessions/<id>.json` | Names of unregistered sessions |
1458
+ | `upload-requests/<device>/` | Upload hand-off between sidebar and helper |
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 |
1461
+ | `flags/` | `coexist`, `readonly`, `tmpreap.armed`, `sockets-lost` |
1462
+ | `cursor-sync-backups/` | Backups taken by `cursor-sync --write` |
1463
+ | `invariant.json` | `lanes invariant save` |
1464
+ | `tmpscan/` | `lanes tmp` plans and `deleted.log` |
1465
+ | `studio-host/` | Optional Studio Host: public key, receipt, enrolment record |
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
1476
+ binaries in `bin/`, the `current` checkout link, npm-staged `pkg/<version>/`), and `~/.lz/<container>` (links to each
1477
+ container's `.lz`).
1478
+
1479
+ In each container (`/home/coder/.lz/`): `bin/` (`lane`, `lane-run`, `lane-signal`, `zmx-*`), `hooks/` (hook
1480
+ definitions), `env.sh`, `registry.json` (the projection `lanes sync` writes), `state/hooks/` (hook sidecars),
1481
+ `state/<zmx name>.json` (`lane-run` phase records), `meta/` and `s/` (zmx metadata and sockets).
1482
+
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/`.
1486
+
1487
+ ### NEVER
1488
+
1489
+ From the [RUNBOOK](RUNBOOK.md#never):
1490
+
1491
+ - Restart or stop a container, docker, or `user@<uid>.service`: every lane dies with its container, every view with
1492
+ the user manager. No symptom in the runbook is fixed by a restart.
1493
+ - Touch `/tmp/tmux-1000` inside a container, by hand, by a cleaner, or by a "free space" one-liner: those are the
1494
+ lanes' sockets.
1495
+ - `systemd-oomd` on docker scopes · `memory.oom.group=1` · `swapoff` under pressure · a `memory.max` below current
1496
+ usage · tightening any limit before usage has dropped.
1497
+ - Live test suites, `docker exec`, `du` or a server rollout on the busy host.
1498
+ - Recreate a lane because the sidebar shows `?`, or because a window showed `[terminated]`.
1499
+
1500
+ ### Troubleshooting
1501
+
1502
+ | You see | Likely cause | Do | Details |
1503
+ | --- | --- | --- | --- |
1504
+ | A lane's glyph dimmed with `?` | Its tmux server did not answer in time; last known state shown | Wait; `lanes invariant check`, `lanes doctor` | [RUNBOOK](RUNBOOK.md#a-dead-lane-or-a-blind-harness) |
1505
+ | Footer `⚠ host under memory pressure · status 3 m old` (or `host busy`) | statusd's health is busy/critical, or older than 30 s | `lanes pressure`; nothing is lost | [RUNBOOK](RUNBOOK.md#what-you-see-and-what-it-means) |
1506
+ | Footer `⚠ lane sockets deleted` | Someone emptied a container's `/tmp` | `lanes sockets repair` | [RUNBOOK](RUNBOOK.md#tmux-server--is-not-running-on-every-lane-at-once-deleted-sockets) |
1507
+ | Viewport: THIS VIEWER DISCONNECTED | The viewer's tmux client dropped; it retries | Wait; if it keeps saying `is not provisioned`, pick another lane | [RUNBOOK](RUNBOOK.md#what-you-see-and-what-it-means) |
1508
+ | A view session you do not recognise in `lanes doctor`'s `view sessions:` (an old device, a mistyped device name) | A device file keeps it alive | `lanes device list`, `lanes device forget <name>` | [RUNBOOK](RUNBOOK.md#server-as-the-workspace-account) |
1509
+ | A tablet shows the phone lane picker | The device was created with the phone layout | `lanes web <new-device> --desktop` | [RUNBOOK](RUNBOOK.md#web--phone) |
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) |
1511
+ | Phone shows no counts | Phone view laid out before the picker change | `lanes rebuild <device>` | [RUNBOOK](RUNBOOK.md#web--phone) |
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) |
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) |
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) |
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 |
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) |
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) |
1529
+
1530
+ ## Appendix A: environment variables
1531
+
1532
+ Server and views:
1533
+
1534
+ | Variable | Read by | Default | Effect |
1535
+ | --- | --- | --- | --- |
1536
+ | `LANES_STATE` | `bin/lanes`, `libexec/*`, `holders/*`, `host-install.sh`, `services.py` | `~/.local/state/lanes` | State directory; rendered into every unit |
1537
+ | `LANES_SHARE` | `libexec/lanes_lib.py`, `host-install.sh`, `services.py`, `enable-phone.sh` | `~/.local/share/lanes` | Pinned binaries and installed files |
1538
+ | `LANES_WORKSPACES` | `libexec/lanes_lib.py`, `host-install.sh`, `services.py` | unset | Site directory; unset means `~/.config/lanes/workspaces` (if it has a catalog), else the checkout's `workspaces/` |
1539
+ | `LANES_STATUS_INTERVAL` | `libexec/statusd.py` | `3` | Seconds between statusd cycles |
1540
+ | `LANES_CONTAINER_UID` | `holders/tmux.sh`, `libexec/view.sh` | `1000` | The containers' tmux uid in socket paths |
1541
+ | `LANES_COLOR`, `LANES_THEME`, `LANES_GLYPHS` | `libexec/sidebar.py` | see section 9 | Sidebar look; the device file wins; not forwarded into view sessions by `bin/lanes` |
1542
+ | `LANES_DIM` | `libexec/tokens.py` (in the sidebar) | `90` | `2` uses SGR 2 for dim text in 16-colour mode; not forwarded into view sessions |
1543
+ | `LANES_NOTIFY` | `libexec/sidebar.py` | unset (on) | `0` silences OSC 9; not forwarded into view sessions |
1544
+ | `NO_COLOR`, `COLORTERM`, `LC_ALL`, `LC_CTYPE`, `LANG` | `libexec/sidebar.py` | — | Colour and glyph defaults |
1545
+ | `LANES_THROTTLE_CONTROL` | `libexec/economy_poll.py` | `http://127.0.0.1:19181` | Throttle control URL; `none` sends no request |
1546
+ | `LANES_THROTTLE_TOKEN_FILE` | `libexec/economy_poll.py` | unset | File holding a bearer token |
1547
+ | `LANES_THROTTLE_CONTAINER` | `libexec/economy_poll.py` | unset | Read the daemon log through this container's home volume |
1548
+ | `LANES_THROTTLE_LOG_DIR` | `libexec/economy_poll.py` | `~/.local/share/throttle` | Where `proxy.log` is read; wins over the container |
1549
+ | `LANES_NOTIFY_CONF` | `libexec/notify.py` | `~/.config/lanes/notify.conf` | Push notifier config path |
1550
+ | `LANES_HOST_GUARD_CONF` | `sudo/host-guard.sh` | `/etc/lanes-host-guard.conf` | Root guard config |
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 |
1557
+ | `LANES_TESTS_FORCE` | `tests/guards.py` | unset | `1` runs live suites on a busy host (on purpose only) |
1558
+
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.
1561
+ `LANES_REPO`, `LANES_ZELLIJ` and `LANES_WS` are set by Lanes itself for its units and panes.
1562
+
1563
+ Client:
1564
+
1565
+ | Variable | Read by | Default | Effect |
1566
+ | --- | --- | --- | --- |
1567
+ | `LANES_HOST` | `lanes-launch`, `lanes-upload` | `lanes` | SSH alias of the server |
1568
+ | `LANES_FALLBACK_HOST` | `lanes-launch` | `lanes-ip lanes-public` | Fallback SSH aliases, tried in order (each only when the SSH config defines it) |
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) |
1574
+ | `LANES_PKG_ROOT` | `npm/lanes` wrapper | `~/.local/share/lanes/pkg` | Where npm-staged trees go |
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 |
1576
+
1577
+ Container:
1578
+
1579
+ | Variable | Read by | Default | Effect |
1580
+ | --- | --- | --- | --- |
1581
+ | `LANE_ROOT` | `lane`, `lane-run` | `/home/coder/.lz` | Root of the in-container contract |
1582
+ | `LANE_ZMX_VERSION` | `lane` | `0.8.1` | zmx binary version |
1583
+ | `LANES_TMP_DIR` | `env.sh` | `/home/coder/.tmp` | Temp directory for new shells |
1584
+ | `LANES_TMP_OPTOUT` | `env.sh` | unset | Set (any value) to keep `/tmp` |
1585
+ | `LANE_ID`, `LANE_WS`, `THROTTLE_TRACE`, `ANTHROPIC_CUSTOM_HEADERS` | Set by the canonical recipe; `lane-signal` reads `LANE_ID`; `lane` strips them | — | Lane identity (section 7) |
1586
+ | `LANE_HARNESS`, `CLAUDE_SESSION_ID`, `CODEX_THREAD_ID` | `lane-signal` | — | Fallbacks for the harness name and session id |
1587
+ | `TMUX_TMPDIR` | `lane gc --sockets` | `/tmp` | Where tmux socket files are looked for |
1588
+
1589
+ ## Appendix B: exit codes
1590
+
1591
+ | Command | Codes |
1592
+ | --- | --- |
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) |
1594
+ | `install.sh --server` (`host-install.sh`) | `0` · `1` missing prerequisite, not Linux x86_64, or run as root · `2` unknown option |
1595
+ | `lanes <verb>` | `0` · `1` error (message `lanes: …`) or unknown verb (usage printed) |
1596
+ | `lanes sync` | `1` when any workspace's registry had errors (previous lanes kept) |
1597
+ | `lanes doctor`, `lanes pressure`, `lanes invariant check`, `lanes sockets` | `1` when something needs attention |
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) |
1600
+ | `lanes preview` / `next` / `prev` without `--focused` | `2` |
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 |
1605
+ | `enable-studio-host.sh` | `0` running or waiting for enrolment · `2` usage · `3` typed refusal · `1` failure |
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 |
1607
+ | `enable-web.sh` | `0` · `2` usage · `1` Caddy missing |
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 |
1609
+ | `lanes-window` | `64` invalid suffix · `69` no Ghostty (macOS) or neither Ghostty nor Foot (Linux) |
1610
+ | `lanes-upload` | `64` usage · `66` no such file or empty clipboard · otherwise the transfer's status |
1611
+ | `lane status` | `1` when the lane is not running |
1612
+ | Live test suites (`tests/*.py`) | `75` host busy, not started |