@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.
- package/README.md +55 -15
- package/bin/lanes-install.mjs +8 -2
- package/lib/plan.mjs +34 -1
- package/package.json +2 -2
- package/payload/lanes/README.md +91 -33
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +994 -49
- package/payload/lanes/client/lanes-launch +160 -14
- package/payload/lanes/client/ssh.stanza.in +11 -2
- package/payload/lanes/docs/ARCHITECTURE.md +88 -3
- package/payload/lanes/docs/INSTALL.md +297 -43
- package/payload/lanes/docs/RUNBOOK.md +458 -31
- package/payload/lanes/docs/USERGUIDE.md +1612 -0
- package/payload/lanes/install/enable-phone.sh +8 -20
- package/payload/lanes/install/enable-web.sh +2 -2
- package/payload/lanes/install/host-install.sh +6 -0
- package/payload/lanes/install.sh +888 -52
- package/payload/lanes/libexec/enrol.py +695 -0
- package/payload/lanes/libexec/enrold.py +1264 -0
- package/payload/lanes/libexec/keys.py +644 -0
- package/payload/lanes/libexec/lanes_lib.py +14 -6
- package/payload/lanes/libexec/members.py +297 -0
- package/payload/lanes/libexec/notify.py +146 -21
- package/payload/lanes/libexec/qr.py +217 -0
- package/payload/lanes/libexec/site_edit.py +237 -0
- package/payload/lanes/libexec/web_tokens.py +315 -0
- package/payload/lanes/site/README.md +13 -1
- package/payload/lanes/site/beyondwork/site.conf +3 -0
- package/payload/lanes/site/site.example.conf +5 -0
- package/payload/lanes/sudo/one-time-root.sh +1 -1
- package/payload/lanes/systemd/lanes-enrold.service +22 -0
- package/payload/lanes/systemd/lanes-notify.service +4 -4
- package/payload/lanes/systemd/lanes-web.service +4 -0
- package/payload/lanes/web/Caddyfile +10 -0
- package/payload/lanes/web/README.md +8 -2
- package/payload/lanes/workspaces.example/README.md +6 -1
- 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 |
|