@vibelord/lanes 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +45 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +618 -13
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +603 -72
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +777 -136
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +83 -24
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +44 -32
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
package/README.md CHANGED
@@ -30,6 +30,12 @@ which redeems it at the server's enrolment service: `invite: redeemed — this m
30
30
  <login> (device <device>)`. An unknown prefix is refused with the bundled list; `--site` given explicitly wins. The
31
31
  code is never printed, stored, or put on a command line. `LANES_INVITE` works like `--invite`.
32
32
 
33
+ The code is redeemed by the server's own release: when the server (already reachable over the tailnet) runs a newer
34
+ release than this package, the installer says `server runs 1.2.0: fetching that installer first` before it installs
35
+ anything, and the wrapper runs `npx --yes @vibelord/lanes@1.2.0` with the same options (the code through the
36
+ environment). Without npx and npm on the PATH, or when npm does not have that release yet, it says so in one line and
37
+ this package installs as before. `LANES_NO_SELF_UPDATE=1` always installs this package.
38
+
33
39
  **Without a code:**
34
40
 
35
41
  ```sh
@@ -50,7 +56,8 @@ the server (a refused code, an unanswered request, or a server without the enrol
50
56
  the printed command again with `--device NAME` or a key of its own — the invite is still valid) · `4` installed, but
51
57
  this machine is not in the
52
58
  tailnet (the installer names the Tailscale install command, or opens `tailscale login` and waits up to 120 s) or the
53
- server is unreachable · `1` error.
59
+ server is unreachable · `1` error. When the installer hands over to the server's release, the exit status is that
60
+ release's installer's.
54
61
 
55
62
  ## Server
56
63
 
@@ -59,8 +66,17 @@ On the Linux x86_64 workspace host, as the workspace account:
59
66
  ```sh
60
67
  npx @vibelord/lanes --server --check
61
68
  npx @vibelord/lanes --server
69
+ npx @vibelord/lanes@latest --server --update-only # an update of an installed server: install, restart what runs, record the run
62
70
  ```
63
71
 
72
+ The server install enables `lanes-update.timer`: every 30 minutes `lanes update --timer` asks npm for the newest release
73
+ and installs it the same way (`--update-only`), with the npx this install ran with (recorded in
74
+ `~/.local/state/lanes/update/npx`) — publishing a release deploys it. An update with `--update-only` enables nothing,
75
+ the timer included: a server set up before 1.2.0 needs the full `npx @vibelord/lanes@latest --server` once (or
76
+ `systemctl --user enable --now lanes-update.timer`). `lanes update --now` checks by hand, `lanes update --status` prints
77
+ the record, `lanes update --rollback` goes back to the release before; `channel=stable` in `~/.config/lanes/update.conf`
78
+ follows the `lanes-stable` dist-tag instead of `latest`.
79
+
64
80
  The server install also enables the enrolment service, `lanes-enrold`, that redeems invites and takes access
65
81
  requests. Once its operator check passes, pin request mode with `lanes enrol mode request` (`invite` for invites
66
82
  only), so no restart changes it. Members then invite from the server, in their own terminal, with `lanes invite <name>
@@ -71,10 +87,25 @@ says the server is older.
71
87
 
72
88
  ## What the wrapper does
73
89
 
74
- `lanes-install` copies the bundled tree to `~/.local/share/lanes/pkg/<version>/` (`$LANES_PKG_ROOT` overrides the
75
- parent) and runs `install.sh` from there. The server's systemd user units and `~/.local/share/lanes/current` name
76
- that stable path, never the npx cache. An identical tree is reused; the newest three versions and any version the
77
- server still runs from are kept.
90
+ `lanes-install` copies the bundled tree to `~/.local/share/lanes/pkg/<version>/` (`$XDG_DATA_HOME/lanes/pkg/` when
91
+ that is set; `$LANES_PKG_ROOT` overrides the parent, under any name — the stamp the wrapper writes into the tree marks
92
+ it as staged) and runs `install.sh` from there. `~/.local/share/lanes/current`
93
+ names that stable path, never the npx cache, and the server's systemd user units, `~/.local/bin/lanes` and view
94
+ layouts name the link (moved last, in one step, by the server install; `current.previous` keeps the tree it named
95
+ before); the link is always under your home directory, whatever `XDG_DATA_HOME` says. An identical tree is reused.
96
+ Where that link does not exist (a client) the newest three versions are kept and older ones deleted; where it does
97
+ (a server, even with a broken link) the wrapper deletes nothing — the server's `lanes gc` (daily) prunes its trees,
98
+ keeping the newest three of each folder and every older one still in use: the tree `current` or `current.previous`
99
+ names, one a view layout or a running daemon's run record names, one a process of the account uses.
100
+
101
+ It runs `install.sh` with `LANES_WRAPPER_REPLY` naming a fresh temporary file. With an invite, `install.sh` asks the
102
+ site's enrolment service for the server's release first; a newer one than this package ends that run before anything
103
+ is installed (`update-to=<release>` in the file, exit 5). The wrapper then asks `npm view
104
+ @vibelord/lanes@<release> version` with the npm beside npx and, when npm has it, runs `npx --yes
105
+ @vibelord/lanes@<release>` with the same arguments, the code moved to `LANES_INVITE` and `LANES_NO_SELF_UPDATE=1`
106
+ (that installer never hands over again), and ends with its status. Otherwise — no npx with its npm, a release npm
107
+ does not have yet, npm not answering — `install.sh` runs once more from this package without the question.
108
+ `LANES_NO_SELF_UPDATE` or `LANES_NO_AUTOUPDATE` (any value) turns the hand-over off.
78
109
 
79
110
  | Option | Effect |
80
111
  | --- | --- |
@@ -5,14 +5,34 @@
5
5
  // The package carries the committed `lanes/` tree (payload/lanes) plus a manifest written at pack time. This
6
6
  // wrapper copies that tree to a STABLE, versioned directory and runs its own `install.sh` from there:
7
7
  //
8
- // ~/.local/share/lanes/pkg/<version>/ (or $LANES_PKG_ROOT/<version>)
8
+ // ~/.local/share/lanes/pkg/<version>/ ($XDG_DATA_HOME/lanes/pkg/<version>/ when that is set, or
9
+ // $LANES_PKG_ROOT/<version>)
9
10
  //
10
- // A stable path matters for the server: `install/services.py` writes the resolved checkout path into the
11
- // systemd user units and links ~/.local/share/lanes/current to it, so the tree must outlive the npx cache.
12
- // Clients copy their commands out of the tree, so for them the location is incidental.
11
+ // A stable path matters for the server: `install/services.py` links ~/.local/share/lanes/current to the resolved
12
+ // tree and writes that link into the systemd user units, ~/.local/bin/lanes and (through lanes_lib.CURRENT) the view
13
+ // layouts (W9 U1), so the tree must outlive the npx cache. That link is always under the home directory
14
+ // (lanes_lib.CURRENT and prune() read it there, plan.mjs `currentLink`). The STAMP written into each staged tree is
15
+ // what tells lanes_lib.staged_tree a staged tree from a checkout under a $LANES_PKG_ROOT of any name (DEF-1757).
16
+ // Clients copy their commands out of the tree, so for them the location is incidental — and only there does this
17
+ // wrapper prune old trees: where the link exists, `lanes gc` does (W9 U4, LNS-92).
13
18
  //
14
19
  // Nothing here touches SSH, keys or services itself — install.sh keeps that authority, its options and its
15
20
  // exit codes (0 ready · 3 key not yet authorized · 4 server unreachable · 1 error).
21
+ //
22
+ // The enrolment-first update (W9 K3, LNS-88): an invite is redeemed once, so the installer that redeems it should be
23
+ // the server's own release. install.sh is run with LANES_WRAPPER_REPLY naming a fresh file; with an invite it asks the
24
+ // site's enrolment service which release the server runs before it installs or sends anything, and a newer one than
25
+ // this package ends that run with `update-to=<release>` in the file and exit 5. This wrapper then runs `npx --yes
26
+ // @vibelord/lanes@<release>` with the same arguments (the code moved to LANES_INVITE) and LANES_NO_SELF_UPDATE=1, which
27
+ // never hands over again, and ends with its status. When that cannot run — no npx with its npm on PATH, a release npm
28
+ // does not have (yet), npm not answering, a reply that names no newer release — install.sh runs once more from this
29
+ // package with LANES_NO_SELF_UPDATE=1: the run that handed over installed and redeemed nothing. LANES_NO_SELF_UPDATE or
30
+ // LANES_NO_AUTOUPDATE (any value) keeps the hand-over off; an exit 5 without a reply is install.sh's own status.
31
+ //
32
+ // A server install records the npx it ran with (W9 U3, LNS-91): after `--server` (not --check or --help) ends 0 or 4
33
+ // (installed), the npx on PATH — with its npm beside it, as the hand-over finds them — is written to
34
+ // $LANES_STATE/update/npx (~/.local/state/lanes/update/npx). `lanes update` and lanes-update.timer, whose unit PATH holds
35
+ // no Node.js, install the next release with exactly that npx.
16
36
 
17
37
  import { spawnSync } from 'node:child_process';
18
38
  import fs from 'node:fs';
@@ -20,7 +40,10 @@ import os from 'node:os';
20
40
  import path from 'node:path';
21
41
  import { fileURLToPath } from 'node:url';
22
42
 
23
- import { inferredSite, installArgs, platformError, prunable, splitArgs } from '../lib/plan.mjs';
43
+ import {
44
+ currentLink, handOverReply, inferredSite, installArgs, npmAnswer, npxRecord, platformError, prunable, recordsNpx,
45
+ relayArgv, relayEnv, releaseNewer, splitArgs,
46
+ } from '../lib/plan.mjs';
24
47
 
25
48
  const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
49
  const payload = path.join(packageDir, 'payload');
@@ -31,6 +54,15 @@ function fail(message, code = 1) {
31
54
  process.exit(code);
32
55
  }
33
56
 
57
+ // Written at once (fs.writeSync): a line said before a blocking spawnSync must reach the terminal before the child's.
58
+ function say(message) {
59
+ fs.writeSync(1, `lanes-install: ${message}\n`);
60
+ }
61
+
62
+ function note(message) {
63
+ fs.writeSync(2, `lanes-install: ${message}\n`);
64
+ }
65
+
34
66
  function readManifest() {
35
67
  try {
36
68
  return JSON.parse(fs.readFileSync(path.join(payload, 'manifest.json'), 'utf8'));
@@ -87,19 +119,22 @@ function stage(manifest) {
87
119
  return target;
88
120
  }
89
121
 
122
+ // A client only (W9 U4, LNS-92; DEF-1768): with the server's `current` link present — even dangling — nothing is
123
+ // deleted here, since what still runs from an old tree is visible only on the server; `lanes gc` prunes it there.
90
124
  function prune(root, target) {
91
- const inUse = new Set([target]);
92
- const data = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share');
125
+ let server = false;
93
126
  try {
94
- inUse.add(fs.realpathSync(path.join(data, 'lanes', 'current'))); // what the server units point at
95
- } catch { /* no server install */ }
127
+ // install/services.py writes the link under $HOME whatever $XDG_DATA_HOME says (W9 U0, DEF-1783).
128
+ fs.lstatSync(currentLink(os.homedir()));
129
+ server = true;
130
+ } catch { /* no link: a client */ }
96
131
  const entries = [];
97
132
  for (const name of fs.readdirSync(root)) {
98
133
  const dir = path.join(root, name);
99
134
  if (name.startsWith('.') || !stampOf(dir)) continue; // only trees this wrapper staged
100
135
  entries.push({ path: dir, mtimeMs: fs.statSync(dir).mtimeMs });
101
136
  }
102
- for (const dir of prunable(entries, inUse)) fs.rmSync(dir, { recursive: true, force: true });
137
+ for (const dir of prunable(entries, new Set([target]), { server })) fs.rmSync(dir, { recursive: true, force: true });
103
138
  }
104
139
 
105
140
  function usage(manifest) {
@@ -118,12 +153,100 @@ Wrapper options
118
153
 
119
154
  Bundled site profiles: ${manifest.sites.join(', ') || 'none'} (or --site /path/to/site.conf)
120
155
  Invite prefixes: ${Object.entries(manifest.site_codes || {}).map(([p, site]) => `${p} → ${site}`).join(', ') || 'none'} (an explicit --site wins)
156
+ With an invite, a server that runs a newer release gets that release's installer first (npx @vibelord/lanes@<release>,
157
+ once npm has it); LANES_NO_SELF_UPDATE=1 installs this one.
121
158
  Every other option goes to install.sh; its own help follows.
122
159
 
123
160
  `);
124
161
  }
125
162
 
163
+ // An executable regular file named `name` in an absolute PATH directory (never the working directory), or null.
164
+ function onPath(name, searchPath = process.env.PATH || '') {
165
+ for (const dir of searchPath.split(path.delimiter)) {
166
+ if (!path.isAbsolute(dir)) continue;
167
+ const file = path.join(dir, name);
168
+ if (executable(file)) return file;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ function executable(file) {
174
+ try {
175
+ fs.accessSync(file, fs.constants.X_OK);
176
+ return fs.statSync(file).isFile();
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ // install.sh handed over to `release` (W9 K3): run that release's installer through npx and return its status, or null
183
+ // (said why) when it cannot run, so this package's installer runs instead. npx from PATH and the npm beside it (Node.js
184
+ // installs them in one folder), as lanes-launch's self-update finds them; only a release npm has is run (npx would
185
+ // fail on any other only after its own registry round trip: DEF-1786's lesson).
186
+ function handOver(release, manifest, passthrough) {
187
+ const instead = `installing ${manifest.version} from this package instead`;
188
+ if (release === null) {
189
+ note(`install.sh asked for an update that names no release: ${instead}`);
190
+ return null;
191
+ }
192
+ if (!releaseNewer(release, manifest.version)) {
193
+ note(`install.sh asked for ${release}, which is not newer than this package: ${instead}`);
194
+ return null;
195
+ }
196
+ const npx = onPath('npx');
197
+ const npm = npx && path.join(path.dirname(npx), 'npm');
198
+ if (!npx || !executable(npm)) {
199
+ note(`the server runs ${release}, and no npx with its npm is on PATH to fetch that installer: ${instead}`);
200
+ return null;
201
+ }
202
+ const { argv, invite } = relayArgv(passthrough, process.env);
203
+ const env = relayEnv(process.env, invite, path.dirname(npx));
204
+ const asked = spawnSync(npm, ['view', `@vibelord/lanes@${release}`, 'version', '--fetch-retries=0'], {
205
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15000, env,
206
+ });
207
+ const answer = npmAnswer(asked, release);
208
+ if (answer.state === 'missing') {
209
+ note(`the server runs ${release}, which npm does not have (yet): ${instead}`);
210
+ return null;
211
+ }
212
+ if (answer.state === 'failed') {
213
+ note(`the server runs ${release}; asking npm for it failed (${answer.why}): ${instead}`);
214
+ return null;
215
+ }
216
+ say(`the server runs ${release}: npx --yes @vibelord/lanes@${release}, with the same options`);
217
+ const relayed = spawnSync(npx, ['--yes', `@vibelord/lanes@${release}`, ...argv], { stdio: 'inherit', env });
218
+ if (relayed.error) {
219
+ note(`could not run npx (${relayed.error.code || relayed.error.message}): ${instead}`);
220
+ return null;
221
+ }
222
+ return relayed.status ?? 1;
223
+ }
224
+
225
+ // Record the npx of this server install for `lanes update` (W9 U3); a failure to record is a note, never the install's.
226
+ function recordNpx() {
227
+ const npx = onPath('npx');
228
+ const file = npxRecord(process.env, os.homedir());
229
+ if (!npx || !executable(path.join(path.dirname(npx), 'npm'))) {
230
+ note(`no npx with its npm on PATH: ${file} is not written, so lanes update cannot install the next release`);
231
+ return;
232
+ }
233
+ try {
234
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
235
+ const temporary = `${file}.${process.pid}.tmp`;
236
+ fs.writeFileSync(temporary, `${npx}\n`, { mode: 0o600 });
237
+ fs.renameSync(temporary, file);
238
+ } catch (error) {
239
+ note(`could not record npx in ${file} (${error.code || error.message}): lanes update cannot install the next release`);
240
+ }
241
+ }
242
+
126
243
  function main() {
244
+ const status = installMain();
245
+ if (recordsNpx(splitArgs(process.argv.slice(2)), status)) recordNpx();
246
+ return status;
247
+ }
248
+
249
+ function installMain() {
127
250
  const platform = platformError(process.platform);
128
251
  if (platform) fail(platform);
129
252
  const manifest = readManifest();
@@ -156,9 +279,39 @@ function main() {
156
279
  }
157
280
  if (inferred) process.stdout.write(`site ${inferred.site}, inferred from the invite code's prefix ${inferred.prefix}\n`);
158
281
  // Run through the staged path (not the npx cache), so install.sh resolves its checkout to the stable tree.
159
- const result = spawnSync('sh', [path.join(staged, 'install.sh'), ...args], { stdio: 'inherit', cwd: staged });
160
- if (result.error) fail(`could not run install.sh: ${result.error.message}`);
161
- return result.status ?? 1;
282
+ const install = (env) => {
283
+ const result = spawnSync('sh', [path.join(staged, 'install.sh'), ...args], { stdio: 'inherit', cwd: staged, env });
284
+ if (result.error) {
285
+ note(`could not run install.sh: ${result.error.message}`);
286
+ return 1;
287
+ }
288
+ return result.status ?? 1;
289
+ };
290
+ const plain = { ...process.env };
291
+ delete plain.LANES_WRAPPER_REPLY;
292
+ if (process.env.LANES_NO_SELF_UPDATE || process.env.LANES_NO_AUTOUPDATE) return install(plain);
293
+ let replyDir;
294
+ try {
295
+ replyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lanes-install-'));
296
+ } catch (error) {
297
+ note(`no hand-over file (${error.code || error.message}): the server's release is not asked for`);
298
+ return install(plain);
299
+ }
300
+ try {
301
+ const reply = path.join(replyDir, 'reply');
302
+ const status = install({ ...plain, LANES_WRAPPER_REPLY: reply });
303
+ if (status !== 5) return status;
304
+ let text = '';
305
+ try {
306
+ text = fs.readFileSync(reply, 'utf8');
307
+ } catch { /* no hand-over: install.sh's own exit 5 */ }
308
+ const asked = handOverReply(text);
309
+ if (!asked.asked) return status;
310
+ const relayed = handOver(asked.release, manifest, passthrough);
311
+ return relayed ?? install({ ...plain, LANES_NO_SELF_UPDATE: '1' });
312
+ } finally {
313
+ fs.rmSync(replyDir, { recursive: true, force: true });
314
+ }
162
315
  }
163
316
 
164
317
  process.exit(main());
package/lib/plan.mjs CHANGED
@@ -4,6 +4,8 @@
4
4
  // that tree's own `install.sh`. These functions decide only the platform gate, the wrapper's own flags and how
5
5
  // the passthrough arguments are rewritten.
6
6
 
7
+ import path from 'node:path';
8
+
7
9
  export const SUPPORTED_PLATFORMS = ['darwin', 'linux'];
8
10
 
9
11
  export function platformError(platform) {
@@ -90,9 +92,117 @@ export function resolveSite(value, stagedDir, sites) {
90
92
  return `${stagedDir}/site/${value}/site.conf`;
91
93
  }
92
94
 
93
- // Which staged version directories to delete: keep the newest `keep` (by mtime) and anything still in use.
94
- // Server user units name the resolved staged path, so a version a running service came from is kept.
95
- export function prunable(entries, inUse, keep = 3) {
95
+ // The server's `current` link: install/services.py writes it under the HOME directory, never under $XDG_DATA_HOME (the
96
+ // units are rendered from $HOME), and lanes_lib.CURRENT reads the same place — so this link, wherever the staged trees
97
+ // themselves live, is what tells the wrapper it runs on a server (W9 U0, LNS-82; DEF-1783; W9 U4, LNS-92).
98
+ export function currentLink(home) {
99
+ return path.join(home, '.local/share', 'lanes', 'current');
100
+ }
101
+
102
+ // Which staged version directories the wrapper deletes after staging one: on a client, the ones past the newest `keep`
103
+ // (by mtime) that are not in `inUse` (the tree just staged) — a client copies its commands out of the tree, so nothing
104
+ // runs from an old one. On a server (`server`: the `current` link exists, even dangling) none: what still runs from an
105
+ // old tree there — `current.previous` (the rollback target), a view layout built before W9 U1 naming `pkg/<version>`,
106
+ // a process started through `current` before the last flip — is visible only on the server itself, so `lanes gc`
107
+ // prunes it (bin/lanes staged_tree_plan, with the same newest-three rule; W9 U4, LNS-92, DEF-1768).
108
+ export function prunable(entries, inUse, { keep = 3, server = false } = {}) {
109
+ if (server) return [];
96
110
  const sorted = [...entries].sort((a, b) => b.mtimeMs - a.mtimeMs);
97
111
  return sorted.slice(keep).filter((entry) => !inUse.has(entry.path)).map((entry) => entry.path);
98
112
  }
113
+
114
+ // ---- the enrolment-first update (W9 K3, LNS-88) ---------------------------------------------------------------------
115
+ // With an invite, install.sh asks the site's enrolment service which release the server runs before it installs or
116
+ // sends anything. A newer one than this package ends that run: install.sh writes `update-to=<release>` to the file the
117
+ // wrapper named in LANES_WRAPPER_REPLY and exits 5, and the wrapper runs `npx --yes @vibelord/lanes@<release>` with the
118
+ // same arguments instead — the code moved to LANES_INVITE — and LANES_NO_SELF_UPDATE=1, so it never hands over again.
119
+
120
+ // A release as `lanes hello` (bin/lanes version_key) and install.sh (release_key) read one: major.minor.patch, an
121
+ // optional -/+ suffix that is not compared, at most 32 characters, each number at most 9 digits (install.sh's bound,
122
+ // so the two client halves agree exactly). [major, minor, patch], or null for anything else.
123
+ const RELEASE = /^([0-9]{1,9})\.([0-9]{1,9})\.([0-9]{1,9})(?:[-+][A-Za-z0-9.]+)?$/;
124
+
125
+ export function releaseKey(text) {
126
+ if (typeof text !== 'string' || text.length > 32) return null;
127
+ const match = RELEASE.exec(text);
128
+ return match ? match.slice(1, 4).map(Number) : null;
129
+ }
130
+
131
+ // Whether `server` is a newer release than `mine`: both are releases, and major.minor.patch is higher as numbers.
132
+ export function releaseNewer(server, mine) {
133
+ const a = releaseKey(server);
134
+ const b = releaseKey(mine);
135
+ if (!a || !b) return false;
136
+ for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] > b[i];
137
+ return false;
138
+ }
139
+
140
+ // install.sh's hand-over file: {asked: false} without an `update-to=` line (an exit 5 that is install.sh's own status),
141
+ // else {asked: true, release} — release null when the value is no release (it is never shown then).
142
+ export function handOverReply(text) {
143
+ const line = String(text ?? '').split('\n').find((each) => each.startsWith('update-to='));
144
+ if (line === undefined) return { asked: false, release: null };
145
+ const release = line.slice('update-to='.length).trim();
146
+ return { asked: true, release: releaseKey(release) ? release : null };
147
+ }
148
+
149
+ // The relayed command line: the same arguments without the invite code, which moves to LANES_INVITE — install.sh and
150
+ // this wrapper read it there the same way (the last `--invite CODE`, else LANES_INVITE) — so the code is on no
151
+ // command line of the second installer's processes (a process list shows any user's argv, never another's environment).
152
+ export function relayArgv(passthrough, env = {}) {
153
+ const invite = inviteCode(passthrough, env);
154
+ const argv = [];
155
+ for (let i = 0; i < passthrough.length; i += 1) {
156
+ if (passthrough[i] === '--invite') {
157
+ i += 1;
158
+ continue;
159
+ }
160
+ argv.push(passthrough[i]);
161
+ }
162
+ return { argv, invite };
163
+ }
164
+
165
+ // The environment of `npm view` and the relayed npx: this one, with LANES_NO_SELF_UPDATE=1 (the newer installer never
166
+ // hands over again), the code in LANES_INVITE, npx's folder first on PATH (node lives beside it), and without this run's
167
+ // hand-over file or the options of the npx that started this wrapper — `npx -p <pkg>` exports npm_config_package and
168
+ // `npx -c` npm_config_call (npm reads either case), with which the nested npx runs `@vibelord/lanes@<v>` as a command
169
+ // (exit 127) or refuses (EUSAGE). MEASURED with npm 11; user configuration such as npm_config_registry stays.
170
+ export function relayEnv(env, invite, npxDir) {
171
+ const out = {};
172
+ for (const [key, value] of Object.entries(env)) {
173
+ if (key === 'LANES_WRAPPER_REPLY' || /^npm_config_(call|package)$/i.test(key)) continue;
174
+ out[key] = value;
175
+ }
176
+ out.LANES_NO_SELF_UPDATE = '1';
177
+ if (invite !== null && invite !== undefined) out.LANES_INVITE = invite;
178
+ if (npxDir) out.PATH = [npxDir, env.PATH].filter(Boolean).join(path.delimiter);
179
+ return out;
180
+ }
181
+
182
+ // What `npm view @vibelord/lanes@<release> version --fetch-retries=0` said, read as lanes-launch's self-update reads it
183
+ // (on_npm, W9 K2): it printed exactly the release → 'has'; npm 10's `code E404`, or an earlier npm's empty exit 0 → 'missing'
184
+ // (not published, yet); anything else → 'failed', with why (exit status and npm's error code, or the 15 s bound).
185
+ export function npmAnswer({ status, stdout, stderr, error }, release) {
186
+ const printed = String(stdout ?? '');
187
+ if (printed.split('\n').some((line) => line.trim() === release)) return { state: 'has' };
188
+ const code = /\bcode (E[A-Z0-9_]+)/.exec(String(stderr ?? ''))?.[1] ?? null;
189
+ if (code === 'E404' || (!error && status === 0 && !printed.trim())) return { state: 'missing' };
190
+ if (error) return { state: 'failed', why: error.code === 'ETIMEDOUT' ? 'no answer within 15 s' : (error.code || error.message) };
191
+ return { state: 'failed', why: `exit ${status ?? 'by signal'}${code ? `, ${code}` : ''}` };
192
+ }
193
+
194
+ // ---- lanes update's npx (W9 U3, LNS-91) -----------------------------------------------------------------------------
195
+ // `lanes update` (and lanes-update.timer, whose unit PATH holds no Node.js) installs the next release with the npx a
196
+ // server install ran with, which the wrapper records once that install installed something: `--server` (whatever its
197
+ // position: installArgs moves it first), neither `--check` (changes nothing) nor `--help`, and none of the wrapper's own
198
+ // flags (each ends the run before install.sh), ending 0 (installed, or ok) or 4 (an update that installed and then ended
199
+ // partial). `split` is splitArgs' {own, passthrough}.
200
+ export function recordsNpx({ own, passthrough }, status) {
201
+ return own.size === 0 && passthrough.includes('--server')
202
+ && !['--check', '--help', '-h'].some((flag) => passthrough.includes(flag)) && (status === 0 || status === 4);
203
+ }
204
+
205
+ // Where: install.sh's state directory ($LANES_STATE, else ~/.local/state/lanes — host-install.sh's `state`), update/npx.
206
+ export function npxRecord(env, home) {
207
+ return path.join(env.LANES_STATE || path.join(home, '.local/state/lanes'), 'update', 'npx');
208
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Installer for Lanes: shared agent terminals over SSH with Zellij views, registry sync, uploads and a phone interface.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -114,37 +114,48 @@ The installer starts an empty catalog safely; it does not assume anyone's contai
114
114
 
115
115
  | Action | Command or control |
116
116
  | --- | --- |
117
- | Join a site from a new machine | `npx @vibelord/lanes --invite <code>` with the code from a member's invite: installs the client, authorizes this machine, then `lanes-launch`, `lanes-launch web phone`, `lanes-launch web laptop`. Without a code the installer asks the site's members for access ([guide](docs/USERGUIDE.md#2-join-a-site)) |
117
+ | Join a site from a new machine | `npx @vibelord/lanes --invite <code>` with the code from a member's invite: installs the client, authorizes this machine, then `lanes-launch`, `lanes-launch web phone`, `lanes-launch web laptop`. When the server runs a newer release (and npm has it), that release's installer runs first and redeems the code (`LANES_NO_SELF_UPDATE=1`: the package you ran). Without a code the installer asks the site's members for access ([guide](docs/USERGUIDE.md#2-join-a-site)) |
118
118
  | Open desktop view | `lanes-window` or the Lanes app; `lanes-launch` inside any terminal |
119
119
  | Independent second view | `lanes-window 2` |
120
120
  | Two or three panes per workspace | `lanes-window w2` / `lanes-window w3` |
121
121
  | Switch workspace | Ctrl–Alt–Left / Right |
122
122
  | Next or previous lane | Ctrl–Alt–Down / Up (the focused pane moves to the next / previous lane of its workspace; nothing is typed, the focus stays), or tap the sidebar |
123
- | Plain-ASCII glyphs, light or dark tokens | `lanes-window --ascii`, `lanes-window --light` / `--dark` (remembered per device in `devices/<device>.json`; a running sidebar picks it up within one tick) |
124
- | Lock the view (plain keys and Zellij's mode keys reach the lane) | Ctrl–Alt–G, again to unlock; the bar shows LOCKED in red. The Ctrl–Alt keys of `zellij/config.kdl`'s `shared` block (←/→/↓/↑, `/`, U, C, S, PageUp/PageDown/End) stay active while locked. Ctrl–G itself always reaches the lane (Claude Code's editor key) |
123
+ | Plain-ASCII glyphs, light or dark tokens | `lanes-window --ascii`, `lanes-window --light` / `--dark`. On a device with an owner the theme, colours and glyphs go to that person's profile, which every sidebar of all their devices paints within a tick (as a Done lane opened on one leaves Done on all); `lanes-window --light --device-only` keeps the look on this device (`devices/<device>.json`, where every look of a device without an owner goes), and a running sidebar picks it up within one tick. A terminal without 24-bit colour: `lanes attach <device> --color 256` (the fixed xterm-256 set; `--color 16` uses your terminal's own palette, `--color none` glyphs only) ([guide](docs/USERGUIDE.md#9-appearance)) |
124
+ | Lock the view (plain keys and Zellij's mode keys reach the lane) | Ctrl–Alt–G, again to unlock; the bar shows LOCKED in red. The Ctrl–Alt keys of `zellij/config.kdl`'s `shared` block (←/→/↓/↑, `/`, U, L, C, S, PageUp/PageDown/End) stay active while locked. Ctrl–G itself always reaches the lane (Claude Code's editor key) |
125
125
  | Zellij modes | Ctrl–Alt–P pane · Ctrl–Alt–T tab · Ctrl–Alt–N resize · Ctrl–Alt–H move · Ctrl–Alt–F scroll · Ctrl–Alt–O session (`d` detaches) · Ctrl–Alt–B tmux-style; Esc returns to normal. Every key: [user guide](docs/USERGUIDE.md#keybindings) |
126
- | Search lanes | Ctrl–Alt–/, or `/` in the sidebar: fuzzy (the letters in order, substring and prefix matches first); Enter opens the match under the cursor, Esc clears |
126
+ | Search lanes | Ctrl–Alt–/ (from anywhere in the tab: the focus moves to the sidebar and its filter starts; nothing is typed into the lane), or `/` in the sidebar: fuzzy (the letters in order, substring and prefix matches first); Enter opens the match under the cursor, Esc clears |
127
+ | Focus the sidebar | Ctrl–Alt–L; while the sidebar has the keyboard its footer says ` ▌ keys go to sidebar · Esc → lane` (Esc, or any action, gives it back to the lane) |
127
128
  | Select a lane for pane 2 or 3 | `2` / `3` in the sidebar of a wide view |
128
129
  | Open one lane directly | `lanes-launch lane <lane-id>`; find IDs with `lanes-launch ls` |
130
+ | Your settings on every device you own | Once a device has an owner (enrolled through an invite or a request, or `lanes device claim <device> --login <login>` for one from before), your look, the lanes you have opened, your board order and hidden boards and your pinned and hidden lanes live in one profile on the server, `users/<login>.json`, shared by all your devices; `--device-only` keeps a look on one device, and `--notify` / `--dim` always stay per device. `lanes user show <login>` prints it ([guide](docs/USERGUIDE.md#your-profile)) |
131
+ | Pick up where you left off on another device | `lanes-launch here` shows in this machine's view the lane you opened last on any of your devices (its tab comes to the front); asked for, never automatic. On the server `lanes here <device>` |
132
+ | Arrange your boards and lanes on all your devices | `lanes workspaces hide\|show\|order <id…>` and `lanes lane pin\|unpin\|hide\|show <lane-id>` on the server, each `--device <your device>` (or `--login <login>`): kept in your profile, which every device you own reads ([guide](docs/USERGUIDE.md#your-profile)) |
129
133
  | Read a markdown file a lane mentioned | Ctrl–Alt–M opens a floating **Preview** pane over the focused lane: the `.md` paths it printed in its last 300 lines that exist in its container (relative ones resolved against its working directory). One is shown at once; several give a numbered menu, newest first (Enter = the newest, `q` closes). Rendered by `glow` in the view's dark/light theme (the server installer adds a pinned glow unless the view server's PATH — `~/.local/share/lanes/bin`, the checkout's `bin`, `/usr/local/bin`, `/usr/bin`, `/bin` — already has one; a glow only in `~/.local/bin` or `~/go/bin` does not count), else `less -R`; `lanes doctor` names which (`preview renderer:`); quitting the pager closes the pane |
134
+ | Edit a lane's AGENTS.md, CLAUDE.md or .env.local | Ctrl–Alt–E opens a floating **Edit** pane over the focused lane: the AGENTS.md and CLAUDE.md of its git checkout (and of the main checkout when it works in a worktree) and the main checkout's .env.local, each a key (`1`–`5`; Enter when only one is there; `q`/Esc closes; a missing file is shown `not present`, none is created). The key starts an editor inside the lane's container as its own user (`lanes attach <device> --editor micro\|hx\|nvim\|vim` picks it per device, or `lanes-launch <device> --editor E` from your machine; else the first of micro, hx, nvim, vim and vi there: Lanes installs none, vi is the floor); quitting the editor closes the pane. One Edit pane per view: pressing it again goes back to the open editor (or names the tab it is in) and never opens a second one on the same file. Off while the view is locked, like Ctrl–Alt–M |
130
135
  | Create a persistent workspace shell | The **+ New shell** row under **Unregistered** (desktop sidebar and phone picker), or `+` in the sidebar |
131
136
  | Register an existing session | **+** beside an unregistered session, or select it and press `a`; choose a workspace (`lanes register <lane-id> [workspace]` on the server) |
132
- | Declare a session in the repository | `P` in the sidebar, then `y`: writes the session's binding into the workspace's one repository `terminals.json`, so it becomes a repository lane `<workspace>--<slug>` |
133
- | Rename a lane | `R` in the sidebar (Enter saves, Esc cancels), or `lanes rename <lane-id> <name>` on the server; the lane id never changes |
137
+ | Unregister a session you added | `u` in the sidebar (or `lanes unregister <lane-id>` on the server): it moves back to **Unregistered**, still running, same id and name, and the cursor goes with it; ` Unregistered ‘name’ · z undo` — `z` within 10 s adds it back (refused if the session was replaced meanwhile). A lane declared in a repository's `terminals.json` leaves that file (its terminal is removed, the session keeps running and is listed under **Unregistered** after the sync `u` starts; no `z`: the file's `.bak-<UTC>` copy keeps it), refused for the file's last terminal; `x` on any registered lane says ` registered: u to unregister first` |
138
+ | Declare a session in the repository | `P` in the sidebar: the name is asked first (prefilled; Enter accepts, or edit it), then `y` and Enter (any other key cancels). Writes the session's binding into the workspace's one repository `terminals.json` under that name, so it becomes a repository lane `<workspace>--<slug>`; the row stays put with a dim `…` until the sync lists the lane, then moves into the list with the cursor on it |
139
+ | Rename a lane | `R` in the sidebar (Enter saves, Esc cancels; arrows and Tab type nothing in the name, a click on the list cancels it first), or `lanes rename <lane-id> <name>` on the server; the lane id never changes, and your name wins over a `displayName` the repository's `terminals.json` declares |
134
140
  | Add a workspace (board) | `lanes workspaces add <id> --container <name> --repo /home/coder/<repo> [--title T]` on the server prints the catalog and manifest diff; add `--write` to apply, then `lanes sync` ([install](docs/INSTALL.md#2-configure-workspaces-and-editor-sync)) |
135
141
  | Onboard a user's container | `lanes onboard <login> --container <name> --workspace <id> --repo /home/coder/<repo>` on the server prints every step (board, contract, Claude and Codex hooks, Throttle attachment, canonical recipes, sync, doctor); add `--write` to apply them and keep a receipt ([install](docs/INSTALL.md#onboard-a-users-container)) |
136
- | See which agents need you | The sidebar sorts **Needs you** (◆ approval, ✗ error, ◇ waiting for a prompt), **Working** (▸) and **Done** (✓, until you open the lane) above the rest; the header counts them in the same glyphs (`◆1 ◇2 ▸6 ✓1`, zeros left out; `N lanes` when no lane runs an agent) |
137
- | Read a sidebar row | `▌` cursor · agent glyph · `›` the lane this pane shows (`²`/`³`: shown in pane 2/3) · the name (`·` per other viewer, `…` when clipped) · an **age column** on every row (agent turn, else last output: `45s`, ` 3m`, ` 2h`, ` 4d`; amber in **Needs you**) · stream glyph · one tail cell by priority (`?` not observed right now > `⚠` starving: producing output but no request for over 10 min, or a 429 > `↓` downgraded > `$` mostly paid — the economy cells need the opt-in economy poller; a dim `⚠`/`↓` is a frozen last-known figure, not a live alarm) · `×` close. `⋮ N more` ends a list that continues below |
138
- | End an unregistered session and its jobs | **×** beside the session, or select it and press `x`; confirm with `y` (any other key cancels) |
139
- | Every sidebar key | `?` replaces the list with the key sheet until the next key (that key only closes it). The footer legend says ` ⏎ open · / find · x close · ? keys`; prompts share one shape (` › close ‘name’? y/n`, ` › Rename: …█`, ` › Register ‘name’? y/n`) and a filter counts its matches (` /abc · 3 matches`) |
140
- | Jump to the next lane that needs you | `Tab` / `Shift-Tab` in the sidebar (cycles **Needs you** from the lane the pane shows; the pane title carries the counts and a lane that starts needing you raises one OSC 9 desktop notification on desktop layouts — `LANES_NOTIFY=0` in the sidebar's environment silences it, see the [user guide](docs/USERGUIDE.md#9-appearance) for how environment variables reach a sidebar) |
142
+ | Let an agent see and manage its lanes | The container's agents get the Lanes MCP server, `lane-mcp`, from `lanes install-container <name>` (on by default; `--no-mcp` leaves it out). `lanes_whoami`, `lanes_list`, `lanes_status` and `lanes_capture` read the lanes of that container. `lanes_register`, `lanes_rename`, `lanes_unregister` and `lanes_new` change a lane's declaration, shown on the boards within about 15 s. `lanes_send` types into a sibling lane, and `lanes_signal` reports the agent's own status. Ending, interrupting or attaching a lane stays yours ([architecture](docs/ARCHITECTURE.md#agent-api), [guide](docs/USERGUIDE.md#agent-tools-mcp)) |
143
+ | See which agents need you | The sidebar sorts **Needs you** (◆ approval, ✗ error, ◇ waiting for a prompt), **Working** (▸) and **Done** (✓, until you open the lane) above the rest; the header counts them in the same glyphs (`◆1 ◇2 ▸6 ✓1`, zeros left out; `N lanes` when no lane runs an agent). The queue holds still: each section keeps the order its lanes entered it, a lane moves up a section at once but down only after the new state has held 8 s (leaving Needs you) or 5 s (leaving Working or Done), so an agent that asks again within seconds of an answer does not jump; the Needs you and Working headers stay drawn, `(0)` included, and the cursor stays on its lane ([how](docs/ARCHITECTURE.md#attention-queue)) |
144
+ | Read a sidebar row | `▌` cursor · agent glyph · `›` the lane this pane shows (`²`/`³`: shown in pane 2/3; `▲` pinned) · the name (`·` per other viewer, `…` when clipped) · an **age column** on every row (agent turn, else last output: `45s`, ` 3m`, ` 2h`, ` 4d`; amber in **Needs you**) · stream glyph · one tail cell by priority (`?` not observed right now > `⚠` starving: producing output but no request for over 10 min, or a 429 > `↓` downgraded > `$` mostly paid — the economy cells need the opt-in economy poller; a dim `⚠`/`↓` is a frozen last-known figure, not a live alarm) · `×` close. `⋮ N more` ends a list that continues below |
145
+ | End an unregistered session and its jobs | **×** beside the session, or select it and press `x`: ` › close ‘name’ — y⏎ confirm · Esc cancel`; `y` arms (` — ⏎ confirm`), Enter closes; any other key cancels (typed text closes nothing unless it is `x`, `y`, Enter in a row) |
146
+ | Every sidebar key | `?` replaces the list with the key sheet until the next key (that key only closes it): the short form of the [user guide's table](docs/USERGUIDE.md#the-sidebar), first `lanes <installed> · view <running>`, then companion keys on one line (`J K`, `Tab S-Tab`, `+ U`, `u z`, `p h`, `H o`, `r ?`), ending with Ctrl–Alt–L, the chord that brings the keyboard to the sidebar from a lane. The footer legend says ` ⏎ open · / find · x close · ? keys`; one prompt at a time, in one shape on the desktop and the phone (` › close ‘name’ — y⏎ confirm · Esc cancel`, ` › Rename: …█`, ` › Register as: …█` then ` › register ‘name’ — y⏎ confirm`) and a filter counts its matches (` /abc · 3 matches`). What an action says in the footer (` Closed sh`, ` Cancelled`, why it failed) stays 3 s or until your next action, not just until the next key. After an action — a close, rename, register, cancel, `Esc`, `r`, closing `?`, a click that opens no lane — the keyboard goes back to the lane, so what you type next reaches the lane, not the sidebar; a prompt, the filter and the armed upload strip keep it |
147
+ | Pin, hide | `p` pins the cursor lane (it leads its section, `▲`, and never falls below `⋮ N more`), `h` hides it, `o` shows the hidden lanes again (faint), `H` then `y` `Enter` hides the whole workspace — its tab closes, its lanes keep running. They are yours, not the device's: every device you own follows your profile on its next tick; a session under **Unregistered** is never hidden ([guide](docs/USERGUIDE.md#the-sidebar)) |
148
+ | Jump to the next lane that needs you | `Tab` / `Shift-Tab` in the sidebar (cycles **Needs you** from the lane the pane shows; the pane title carries the counts and a lane that starts needing you raises one OSC 9 desktop notification on desktop layouts — `lanes-launch <device> --notify 0` (or `lanes attach <device> --notify 0` on the server) silences it for that device, live; `LANES_NOTIFY=0` in the shell that creates a view session does the same for it, see the [user guide](docs/USERGUIDE.md#9-appearance)) |
141
149
  | Other workspaces that need you | The row above the footer rule: ` ◆1 Worlds · ◇2 Code` (blank and absent when none; panes of 18 rows or more) |
142
150
  | What the fleet spends | With the opt-in economy poller ([RUNBOOK](docs/RUNBOOK.md#economy-projection-opt-in)) the same row is the fleet economy strip instead: ` $8.5k/16k 53% · F2 S1 · spill 76% · free 3/19` — overflow spend against the daily cap (amber at the alert level), new upstream 429s, this workspace's lanes by model tier (`F` fable, `O` opus, `S` sonnet, `H` haiku, `G` gpt), the pool's spill fraction, free accounts and the next 5 h reset, whole parts dropped from the end to fit; a leading `?` means the figures are frozen — the poller could not reach the daemon, or it stopped (no rewrite for over 3 min) |
143
- | Upload | Click the one-row upload strip above the footer (` ↑ uploads · click, then drop (U)`) or press `U`, then drop anywhere on the window; or use `~/Lanes Uploads/<workspace>/`, or `lanes-upload <files>` |
151
+ | Upload | Click the one-row upload strip above the footer (` ↑ uploads · click, then drop (U)`), press `U` in the sidebar or Ctrl–Alt–U from anywhere in the tab, then drop anywhere on the window; or use `~/Lanes Uploads/<workspace>/`, or `lanes-upload <files>` |
144
152
  | Phone view from a client | `lanes-launch web phone` in your own terminal: prepares `<your device>-phone` (phone layout), makes Tailscale Serve forward private HTTPS 8443 when the mapping is missing, prints the private URL with a QR code of it and a login token; scan the code or open the URL in Safari. `sh install/enable-phone.sh --host lanes` is the older one-shot for the device `phone` ([guide](docs/USERGUIDE.md#phone-safari-home-screen)) |
145
153
  | Laptop or tablet browser view | `lanes-launch web laptop` (`<your device>-web`, desktop layout) or `lanes-launch web <device> [--desktop\|--mobile]` from a client; on the server `lanes web <device> [--desktop\|--mobile] [--viewports N] [--token \| --print-only]` (prepares the view, enables the loopback web services, adds the Tailscale Serve `https:8443` mapping when missing, prints the private URL) ([guide](docs/INSTALL.md#laptop-and-tablet-browser)) |
146
154
  | Web login tokens | `lanes token list` (each token with the device it was created for), `lanes token revoke <device>`, `lanes token revoke-all --yes` ([runbook](docs/RUNBOOK.md#login-tokens)) |
147
- | Update the client | `lanes-launch update` (the newest client from npm with your remembered install options; flags you add win). A client older than the server says so in one dim line when it attaches |
155
+ | Update the client | Automatic: before a window connects, at most every 6 h, a client older than the server installs exactly the server's release once npm has it (`npx --yes @vibelord/lanes@<version>` with your remembered install options) and starts again, once; a release npm does not have yet is only logged and asked about again 6 h later; a failure is one dim line and the window goes on, and closing the window ends the whole install (`LANES_NO_AUTOUPDATE=1`: off; a checkout's client never does). By hand: `lanes-launch update` (the newest client from npm with your remembered install options; flags you add win). A client older than the server says so in one dim line when it attaches |
156
+ | Update the server | Automatic on an npm-installed server: `lanes-update.timer` (enabled by the full server install; `--update-only` never enables it, so on a server set up before 1.2.0 run `npx @vibelord/lanes@latest --server` once, or `systemctl --user enable --now lanes-update.timer`; `lanes doctor` warns while it is off) checks npm 15 min after a boot and every 30 min and installs a newer release (`npx --yes @vibelord/lanes@<version> --server --update-only` with the npx the install recorded; never a downgrade; `channel=stable` in `~/.config/lanes/update.conf` follows the `lanes-stable` tag). By hand: `lanes update --now`; `lanes update --status` prints the record of the last check and update; `lanes update --rollback` goes back to the release before and marks the one left bad. A checkout server updates with `git pull` ([guide](docs/USERGUIDE.md#update)) |
157
+ | Which release the server runs | `lanes version` on the server: `lanes 1.2.0 (39998ed) · tree pkg/1.2.0 · current ✓ · statusd runs 1.1.0 ✗` — the release, its tree, whether `current` names it, and `✗` for each service whose last start ran an older tree or, on a checkout, came before its `git pull` (`started before its tree changed ✗`): restart it; `--json` for scripts. `lanes doctor` judges the same facts (only processes that run now; a daemon or sidebar with no record started from an older release) and adds the device layouts and the units, each `✗` with its fix ([user guide](docs/USERGUIDE.md#versions)) |
158
+ | Which release this client or phone runs | `lanes-launch --version` (`lanes-launch 1.2.0`); its connecting line says it too (`lanes 1.2.0: connecting to lanes…`). On a phone or tablet, **More** shows the server's release above **Text size** (**Lanes v1.2.0**) |
148
159
  | Switch workspace or lane in a laptop browser | The **Lanes** chip (top right) opens the workspace and lane pickers |
149
160
  | Invite a teammate, approve a machine, remove one | On the server, in your own terminal: `lanes invite <name> --email <addr>` prints a five-line welcome kit with a one-time code (shown once, on a terminal only); `lanes enrol list` / `lanes enrol approve <id>` decide machines that asked without a code; `lanes enrol mode request` pins request mode once the Serve header check passed; `lanes offboard <device> --yes` revokes a machine's key, signs its browsers and phones out and ends its view ([guide](docs/USERGUIDE.md#invite-a-user-administrator), [install](docs/INSTALL.md#offboard-a-machine)) |
150
161
  | Detach | Close the window, or Ctrl–Alt–O then `d`; the agents keep running |
@@ -156,6 +167,9 @@ session reuses it; closing a view leaves it running. Registered lanes have no **
156
167
  **Add to workspace** keeps the session running and moves it into the selected workspace's registered list.
157
168
  The picker offers workspaces using the same container. Registration keeps its name and open views, persists
158
169
  across sync/reconnects, and removes its close control. On a phone, tap the **+** and a workspace; no typing is needed.
170
+ `u` takes it out again (the session keeps running under **Unregistered**); `z` within 10 seconds undoes that.
171
+ Closing from the phone needs no keyboard either: tap **×**, and while the picker asks, a short `⏎ y Esc` key row sits
172
+ above the dock (`y` then `⏎` closes, `Esc` cancels).
159
173
 
160
174
  On iPhone, open the printed private HTTPS URL in Safari and choose **Share → Add to Home Screen**.
161
175
  The interface provides Workspaces, Lanes and Panes controls, safe areas, portrait/landscape layout and adjustable
@@ -174,8 +188,8 @@ long (agent since-time, or last output). Glyph then ASCII twin (`LANES_GLYPHS=as
174
188
 
175
189
  | Agent (column 2) | | Terminal (column 33) | | Marks |
176
190
  | --- | --- | --- | --- | --- |
177
- | `◆` `!` needs approval | `◇` `?` needs input | `●` `*` output in the last 20 s | `○` `o` quiet | `▌` `\|` cursor · `›` `=` viewing · `²` `2` / `³` `3` shown in viewport 2/3 |
178
- | `▸` `>` working | `✓` `v` done (until viewed) | `–` `-` no session right now | `!` `!` pane dead | `·` `.` viewers · `…` `~` clipped · `×` `x` close |
191
+ | `◆` `!` needs approval | `◇` `?` needs input | `●` `*` output in the last 20 s | `○` `o` quiet | `▌` `\|` cursor · `›` `=` viewing · `²` `2` / `³` `3` shown in viewport 2/3 · `▲` `^` pinned |
192
+ | `▸` `>` working | `✓` `v` done (until viewed) | `–` `-` no session right now | `!` `!` pane dead | `·` `.` viewers · `…` `~` clipped (end of a name) or on its way — closing, registering, a new shell (column 3, dim) · `×` `x` close |
179
193
  | `✗` `X` errored | `_` `_` shell · `·` `.` idle | `✗` `x` container down | `?` `?` not observed · `·` `.` helper | `⋮` `:` more rows · `↑` `^` uploads · `↓` `v` drop now |
180
194
 
181
195
  Column 34 is one tail cell: `?` stale (last known state shown) > `⚠` `!` starving > `↓` `v` downgraded > `$` mostly
@@ -239,13 +253,22 @@ See [workspace configuration](workspaces.example/README.md) and the [architectur
239
253
 
240
254
  After `git pull`, clients re-run `./install.sh` (it repeats the options remembered in
241
255
  `~/.config/lanes/install-options`); servers re-run `./install.sh --server` from their installed checkout. npm
242
- client installations update with `lanes-launch update` (`npx @vibelord/lanes@latest` with those options given again
243
- as flags); a server updates with `npx @vibelord/lanes@latest --server` on the host. After a server
244
- update, restart `lanes-statusd.service` (and `lanes-web`/`lanes-mobile` when their units or the pinned Zellij
245
- changed); existing views keep their layout until `lanes rebuild <device>`. `lanes hello` prints the server's
246
- version ([user guide](docs/USERGUIDE.md#update)). The server installer resolves the checkout's actual path,
247
- including `bw-tools/lanes`; no `~/lanes` clone is required. It preserves configured workspaces and existing agent
248
- sessions.
256
+ client installations update themselves to the server's release when a window opens (at most every 6 h, once npm has it), or by hand
257
+ with `lanes-launch update` (`npx @vibelord/lanes@latest` with those options given again as flags); an npm-installed server updates itself (`lanes-update.timer`, which the full `--server` install enables and `--update-only` never does: `lanes update --timer` every 30 min installs each newer npm release; `lanes update --now` by hand, `lanes update --rollback` back one release) with `npx @vibelord/lanes@<version> --server --update-only`, which you can also run on the host, and which
258
+ then restarts statusd and every optional service that runs, re-installs the container contract, asks the viewports to
259
+ reload, rebuilds each view whose layout changed and that no client is attached to (one you are looking at says
260
+ `update ready · reload (r)` in its sidebar footer) and records the run in `~/.local/state/lanes/update/last.json` — every step runs even when one before it
261
+ failed ([user guide](docs/USERGUIDE.md#update)). After a full `--server` install instead, restart
262
+ `lanes-statusd.service` (and `lanes-web`/`lanes-mobile` when their units or the pinned Zellij changed); existing
263
+ views keep their layout until `lanes rebuild <device>` — one built by 1.2.0 or later runs its
264
+ sidebar and viewports through `~/.local/share/lanes/current`, so each starts the new release when it next starts,
265
+ and `lanes sync` keeps recognising the tabs of either kind. `lanes version` prints the server's
266
+ version and marks `✗` each service whose last start ran an older tree or, on a checkout, came before the pull
267
+ (`lanes hello` prints the version alone; [user guide](docs/USERGUIDE.md#versions)); `lanes doctor` names what still
268
+ runs older code — processes, view layouts, units — with the fix for each. `lanes-launch --version` prints a client's.
269
+ The server installer resolves
270
+ the checkout's actual path, including `bw-tools/lanes`; no `~/lanes` clone is required. It preserves configured
271
+ workspaces and existing agent sessions.
249
272
 
250
273
  ```sh
251
274
  ./install.sh --check # client connectivity
@@ -1 +1 @@
1
- 1.1.1
1
+ 1.2.0