@vibelord/lanes 1.1.1 → 1.3.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 (71) 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 +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  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 +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -3
@@ -15,17 +15,28 @@ at the repository root; this page condenses it for daily use and never overrides
15
15
  2. **Never colour alone:** every state has a glyph, a tone and an ASCII twin, and a fixed column; `LANES_COLOR=none`
16
16
  leaves a fully readable sidebar.
17
17
  3. **One token set, many renderers:** `libexec/tokens.py` is the only place a colour or glyph is defined; every
18
- other surface is a generated projection pinned by a drift test.
19
- 4. **Theme-agnostic by construction:** the sidebar paints foreground only, learns dark/light once (OSC 11), falls
20
- back to dark, and keeps lane identity colours in your own 16-colour palette.
21
- 5. **Dim is a colour, not an attribute:** no SGR 2 in any frame; ink-dim in truecolor, bright black (`90`) in
22
- 16-colour mode.
18
+ other surface is a generated projection pinned by a drift test, and no colour sits outside a surface's generated
19
+ block (`test_tokens.GeneratedSurfaces`, cockpit v3 P5). The phone shell's `theme-color` metas and manifest colours
20
+ are hand-written copies of the `surface` tokens, held to them by the same test; the app icon is a brand asset
21
+ ([`web/README.md`](../web/README.md)).
22
+ 4. **Theme-agnostic by construction:** every painted role is a foreground but one. `selected`, the cursor row, is a
23
+ foreground-and-background role: the `accent-wash` value under the row's own foregrounds, in truecolor and 256
24
+ colours only and closed before column 36, so the terminal's own background shows everywhere else (cockpit v3 P2).
25
+ 16 colours and `none` paint no background at all; the cursor there is the bold `▌`. The sidebar learns dark/light
26
+ once (OSC 11), falls back to dark, and keeps lane identity colours in your own 16-colour palette.
27
+ 5. **Dim is a colour, not an attribute:** ink-dim is the table's value in truecolor and 256 colours and bright black
28
+ (`90`) in 16; a frame carries SGR 2 only when you choose `LANES_DIM=2` (16 colours only). The band is a surface
29
+ like any other: every text role, dim included, holds 4.5:1 on `selected` (`test_tokens.Contrast`; 4.2:1 for the
30
+ 256 approximations, `test_tokens.Xterm256`).
23
31
  6. **Restraint:** one accent, for selection, viewing marks and the brand only; no emoji in rows, one upload line, a
24
32
  one-line legend plus the `?` key sheet.
25
33
  7. **Every row says how long:** a 3-cell age on every row — the agent's `since`, else the last output.
26
34
  8. **Marks never lose to names:** viewer dots and viewport marks are reserved first; the name is clipped and ends
27
35
  in `…`.
28
- 9. **The cursor follows the lane, not the index:** re-sorts never move your focus in a list that scrolls.
36
+ 9. **The cursor follows the lane, not the index, and the list holds still:** a lane changing section moves the cursor
37
+ with it, never the list (`top` is pinned; it scrolls only to keep the cursor's lane on screen). Sections keep entry
38
+ order (`stable_since`), and the Needs you / Working headers stay drawn once a workspace has agents, `(0)` with a
39
+ dimmed glyph — so a lane entering or leaving one never shifts the rows below it (cockpit v3 S2).
29
40
  10. **Performance is a design property:** cached row bytes, one width walk per name, frames written only when dirty,
30
41
  OSC 2/9/11 in the same write; budgets below.
31
42
  11. **Pinned bytes change deliberately:** a moved byte assertion is rewritten with a role name, never loosened.
@@ -35,16 +46,24 @@ at the repository root; this page condenses it for daily use and never overrides
35
46
 
36
47
  ## Modes
37
48
 
38
- Four switches pick the look; `devices/<device>.json` (written by `lanes attach DEVICE --ascii|--theme|--glyphs|--color`
39
- and by `lanes-window --ascii|--light|--dark`) wins over the environment and reaches a running sidebar within one tick.
49
+ Five switches pick the look and its announcements; `devices/<device>.json` (written by `lanes attach DEVICE
50
+ --ascii|--theme|--glyphs|--color|--notify|--dim` and by `lanes-window --ascii|--light|--dark` — on a device with an
51
+ owner, theme, colour and glyphs only with `--device-only`, which names them in the file's `device_only` list, else
52
+ they go to the owner's profile `users/<login>.json`, which every sidebar of that person's devices layers over its
53
+ device file — cockpit v3 R3, `sidebar.layered_look`: the device's `--device-only` keys, then the profile, then a key
54
+ from before the profile (`lanes device claim` folds it in), then environment, then default)
55
+ wins over the environment and reaches a running sidebar within one tick. The variables reach a sidebar through the
56
+ view server's
57
+ environment: `bin/lanes` `clean_env()` forwards the five, when set, from the shell that creates the session (cockpit
58
+ v3 P4, DEF-1577); a running session keeps the environment it was created with.
40
59
 
41
60
  | Switch | Values | Default | Where read |
42
61
  |---|---|---|---|
43
- | `LANES_COLOR` | `truecolor` · `16` · `none` | `truecolor` when `COLORTERM` ∈ {`truecolor`, `24bit`} (config.kdl's `env` block forces it for every pane), else `16`; `NO_COLOR` set ⇒ `none` | `sidebar.py` at start **and on every tick**: `devices/<device>.json` is part of `reload()`'s mtime stamp (one `stat()`), its `color` key wins over env, and a changed file re-runs `resolve_modes()` → `sgr_table()`/`glyph_set()` and clears the row cache (`apply_look()`). Zellij panes inherit the *server's* environment at session creation, so an environment variable can never reach a running sidebar; the device file is the one channel that does |
44
- | `LANES_THEME` | `dark` · `light` · `auto` | `auto`: one OSC 11 query `\033]11;?\033\\` written at start; the reply (`\033]11;rgb:RRRR/GGGG/BBBB\033\\` or BEL-terminated, 8- or 16-bit components) is parsed in `handle()` and **swallowed** — it never cancels a filter or prompt; relative luminance < 0.5 ⇒ dark; no reply within 300 ms ⇒ dark; no re-ask | same; `devices/<device>.json` key `theme` (per tick, as above); `STATE/clients/<device>.theme` is written (`dark`/`light`, only on change) for `glow` |
45
- | `LANES_GLYPHS` | `unicode` · `ascii` · `emoji` | `unicode`; `ascii` automatically when none of `LC_ALL`/`LC_CTYPE`/`LANG` contains `UTF-8` | same; `devices/<device>.json` key `glyphs` (per tick, as above); `lanes attach DEVICE --ascii` / `--theme light` write them and every running sidebar of that device picks them up within one tick; `lanes-window --ascii`/`--light`/`--dark` pass them through. The sidebar publishes the resolved mode as `STATE/clients/<device>.glyphs` (`unicode`/`ascii`/`emoji` + newline, written on change or when absent) — the file `view.sh` reads with a builtin `read` for its status screens (§7) |
46
- | `LANES_DIM` | `90` · `2` | `90` | 16-colour mode only: `2` is the documented escape hatch for palettes whose colour 8 is near the background (classic Solarized dark) |
47
- | `LANES_NOTIFY` | `1` · `0` | `1` on desktop devices, `0` on mobile | OSC 9 emission (§4.9) |
62
+ | `LANES_COLOR` | `truecolor` · `256` · `16` · `none` | `truecolor` when `COLORTERM` ∈ {`truecolor`, `24bit`} (config.kdl's `env` block forces it for every pane), else `256` when `TERM` contains `256color` (cockpit v3 P2), else `16`; `NO_COLOR` set ⇒ `none`. Inside a view 256 colours are chosen, never detected (cockpit v3 P5): every pane has `COLORTERM=truecolor`, read before `TERM`, so the `TERM` rule applies only outside Zellij (a `libexec/sidebar` started by hand), as does `NO_COLOR`, which `clean_env()` does not forward. A terminal without 24-bit colour chooses `lanes attach DEVICE --color 256` (live) or `LANES_COLOR=256` in the shell that creates the session | `sidebar.py` at start **and on every tick**: `devices/<device>.json` is part of `reload()`'s mtime stamp (one `stat()`), its `color` key wins over env, and a changed file re-runs `resolve_modes()` → `sgr_table()`/`glyph_set()` and clears the row cache (`apply_look()`). Zellij panes inherit the *server's* environment at session creation, so an environment variable can never reach a running sidebar; the device file is the one channel that does. The sidebar publishes the resolved mode as `STATE/clients/<device>.color` (`truecolor`/`256`/`16`/`none` + newline, compared by content on every resolution: a stamp an earlier session left is replaced, an equal one is not rewritten), beside `.theme` and `.glyphs`: `view.sh` picks its status screens' colour set by it and `.theme` (cockpit v3 P3, below) |
63
+ | `LANES_THEME` | `dark` · `light` · `auto` | `auto`: one OSC 11 query `\033]11;?\033\\` written at start; the reply (`\033]11;rgb:RRRR/GGGG/BBBB\033\\` or BEL-terminated, 8- or 16-bit components) is parsed in `handle()` and **swallowed** — it never cancels a filter or prompt; relative luminance < 0.5 ⇒ dark; no reply within 300 ms ⇒ dark; no re-ask | same; `devices/<device>.json` key `theme` (per tick, as above); `STATE/clients/<device>.theme` is written (`dark`/`light`) for `glow` and `view.sh` on every resolution — an explicit theme, the answer, or the grace (run()'s tick, no frame needed) — compared by content, not by whether the variant changed in memory (DEF-1775): a later attach whose answer is the pending dark still replaces an earlier light terminal's `light`; until a sidebar resolves, the earlier stamp stands |
64
+ | `LANES_GLYPHS` | `unicode` · `ascii` · `emoji` | `unicode`; `ascii` automatically when none of `LC_ALL`/`LC_CTYPE`/`LANG` contains `UTF-8` | same; `devices/<device>.json` key `glyphs` (per tick, as above); `lanes attach DEVICE --ascii` / `--theme light` write them and every running sidebar of that device picks them up within one tick; `lanes-window --ascii`/`--light`/`--dark` pass them through. The sidebar publishes the resolved mode as `STATE/clients/<device>.glyphs` (`unicode`/`ascii`/`emoji` + newline, compared by content on every resolution) — the file `view.sh` reads with a builtin `read` for its status screens (§7) |
65
+ | `LANES_DIM` | `90` · `2` | `90` | 16-colour mode only: `2` is the documented escape hatch for palettes whose colour 8 is near the background (classic Solarized dark). `sidebar.py` `resolve_dim_notify()` in `apply_look()`: `devices/<device>.json` key `dim` (`lanes attach DEVICE --dim 90\|2`, per tick) wins over env, and the resolved SGR is handed to `sgr_table(dim16=…)` for every table the sidebar builds (the OSC 11 answer's included); `view.sh`'s status screens keep 90 (the generated `tokens.sh` pins it) |
66
+ | `LANES_NOTIFY` | `1` · `0` | `1` on desktop devices, `0` on mobile | OSC 9 emission (§4.9). Same resolution: `devices/<device>.json` key `notify` (`lanes attach DEVICE --notify 1\|0`, per tick) wins over env, `osc_bytes()` reads the resolved `Sidebar.notify`; a mobile device never notifies, whatever the key says |
48
67
 
49
68
  ## Tokens and glyphs
50
69
 
@@ -52,28 +71,81 @@ Every glyph table is `key -> (unicode, ascii, tone)`: agent glyphs are drawn onl
52
71
  column 33, marks only where the row anatomy puts them. `LANES_GLYPHS=ascii` swaps in the ASCII column, so an ASCII
53
72
  frame is seven-bit. The `ANSI-16` column is what `LANES_COLOR=16` emits; truecolor uses the Dark or Light value.
54
73
 
74
+ The palette is v2 (cockpit v3 P1, LNS-69): vivid tones in both variants, every text role ≥ 4.5:1 on `surface`,
75
+ `surface-raised`, `accent-wash` and `selected` (the tightest pair is light `on-accent-urgent` on `accent`, 4.54:1).
76
+ `selected` is the cursor row's background — the `accent-wash` value with ink on it, so it has no CSS variable of its
77
+ own. `256 dark` / `256 light` are the xterm-256 indices `sgr_table('256', …)` emits (`38;5;N`; `48;5;N` for
78
+ `selected`): always 16–255, never the user's 0–15, and each the nearest entry in its token's hue family that keeps
79
+ 4.2:1 on the surfaces and the 256 `selected` band (`test_tokens.Xterm256` re-derives all eighteen). In 16 colours
80
+ `selected` paints nothing and the cursor stays the bold `▌`. `libexec/tokens.sh` carries the 16-colour set when
81
+ sourced and `lz_tones MODE VARIANT` for the others: `lz_tones none` takes one argument, and any other call —
82
+ `lz_tones 16`, a bare `lz_tones` — restores 16; every spelling is safe under view.sh's `set -u`, and every set says
83
+ `LZ_VIVID` (`1` in `truecolor` and `256`, `tokens.VIVID`, the sidebar's `VIVID`; empty in 16 and none). The slot
84
+ table is the lane palette Lanes defines for the phone's web client and the optional Ghostty file: slots 1–4 are the
85
+ tones, 7 and 15 the ink pair, 0 and 8 greys, and the colour slots 5, 6 and 9–14 are re-saturated in v2 (more chroma
86
+ than v1 in each, pinned by `test_tokens.PaletteV2`, which holds the tones and the accent to the same rule).
87
+
88
+ How the sidebar spends it (cockpit v3 P2, LNS-70). In `truecolor` and `256` — the colours Lanes defines — a section
89
+ header says its words in the section's tone (Needs you attention, Working working, Done done; an empty `(0)` section
90
+ stays the dim placeholder) with the `(n)` ink-dim; a lane name is ink unless the lane has an identity colour; the age
91
+ takes its section's tone and is ink-dim only outside the three attention sections; the stream glyph keeps its own tone
92
+ beside an agent and is ink-dim only on a stale row (the shell `_`, idle `·` and quiet `○` glyphs are ink-dim by their
93
+ token); the cursor row lies on `selected` (`48;2;…` / `48;5;N`), re-opened after every reset in the row and closed by a
94
+ plain reset before the row's `\033[K`, so column 36 stays the terminal's own; the `▌` stays in column 1. `16` (your
95
+ terminal's palette — its yellow may be unreadable on a light background) and `none` keep v1's bytes exactly
96
+ (`ColourUsage.test_sixteen_colour_rows_are_unchanged`); the phone's section headers follow the desktop's, and the
97
+ phone has no cursor row to band.
98
+
99
+ How the viewer spends it (cockpit v3 P3, LNS-71). `view.sh`'s status screens read the device's
100
+ `clients/<device>.color` and `.theme` stamps on every screen with the builtin `read` (beside `.glyphs`; no fork) and
101
+ call `lz_tones "$color" "$theme"`. In `truecolor` and `256` of a known variant the hint line is that set's ink-dim
102
+ and the lead (`⚠ THIS VIEWER DISCONNECTED — …`) takes the `warning` mark's tone, attention, as the sidebar's health
103
+ banner does; `none` keeps bold alone (no SGR 90 on the hint); `16`, no stamp, an unknown word, or a vivid mode
104
+ whose `.theme` is missing or not `dark`/`light` is the 16-colour screen byte for byte — the user's palette, never a
105
+ guessed variant on what may be a light background (`test_sidebar.ViewStatusScreen`).
106
+
55
107
  <!-- BEGIN lanes tokens (generated by libexec/tokens.py --md; do not edit by hand) -->
56
- | Role | Dark | Light | ANSI-16 | CSS variable |
108
+ | Role | Dark | Light | 256 dark | 256 light | ANSI-16 | CSS variable |
109
+ |---|---|---|---|---|---|---|
110
+ | surface | `#101318` | `#f3f5f2` | `—` | `—` | `—` | `--lanes-surface` |
111
+ | surface-raised | `#191e25` | `#ffffff` | `—` | `—` | `—` | `--lanes-surface-raised` |
112
+ | line | `#2c333d` | `#d5ded6` | `—` | `—` | `—` | `--lanes-line` |
113
+ | accent-wash | `#1c3326` | `#e5f0e6` | `—` | `—` | `—` | `--lanes-accent-wash` |
114
+ | selected | `#1c3326` | `#e5f0e6` | `235` | `254` | `—` | `--lanes-accent-wash` |
115
+ | accent-line | `#4b6955` | `#acc9b4` | `—` | `—` | `—` | `--lanes-accent-line` |
116
+ | press | `#26332c` | `#d8e9db` | `—` | `—` | `—` | `--lanes-press` |
117
+ | chip | `#ffffff0f` | `#edf3ec` | `—` | `—` | `—` | `--lanes-chip` |
118
+ | ink | `#f2f5f8` | `#111a14` | `255` | `233` | `39` | `--lanes-ink` |
119
+ | ink-dim | `#a6b1be` | `#4a5661` | `249` | `240` | `90` | `--lanes-ink-dim` |
120
+ | ink-faint | `#6f7a87` | `#6f7b87` | `243` | `243` | `90` | `--lanes-ink-faint` |
121
+ | accent | `#8fe3a6` | `#1f6f3f` | `115` | `22` | `1;39` | `--lanes-accent` |
122
+ | on-accent | `#101318` | `#ffffff` | `—` | `—` | `—` | `--lanes-on-accent` |
123
+ | on-accent-urgent | `#7a1a12` | `#ffd3cc` | `—` | `—` | `—` | `--lanes-on-accent-urgent` |
124
+ | tone-urgent | `#ff7b6e` | `#c41c1c` | `209` | `160` | `31` | `--lanes-tone-urgent` |
125
+ | tone-attention | `#ffc233` | `#a04d00` | `215` | `94` | `33` | `--lanes-tone-attention` |
126
+ | tone-working | `#6fb6ff` | `#1a56c4` | `75` | `26` | `34` | `--lanes-tone-working` |
127
+ | tone-done | `#5fdc8a` | `#0d7338` | `78` | `22` | `32` | `--lanes-tone-done` |
128
+ | scrim | `#000000a6` | `#1a222b80` | `—` | `—` | `—` | `--lanes-scrim` |
129
+ | shadow | `0 -16px 80px #0007` | `0 -16px 80px #0003` | `—` | `—` | `—` | `--lanes-shadow` |
130
+
131
+ | Slot | ANSI | Dark | Light | Role |
57
132
  |---|---|---|---|---|
58
- | surface | `#101318` | `#f3f5f2` | `—` | `--lanes-surface` |
59
- | surface-raised | `#191e25` | `#ffffff` | `—` | `--lanes-surface-raised` |
60
- | line | `#2c333d` | `#d5ded6` | `—` | `--lanes-line` |
61
- | accent-wash | `#1a2721` | `#e5f0e6` | `—` | `--lanes-accent-wash` |
62
- | accent-line | `#4b6955` | `#acc9b4` | `—` | `--lanes-accent-line` |
63
- | press | `#26332c` | `#d8e9db` | `—` | `--lanes-press` |
64
- | chip | `#ffffff0f` | `#edf3ec` | `—` | `--lanes-chip` |
65
- | ink | `#edf1f5` | `#17241b` | `39` | `--lanes-ink` |
66
- | ink-dim | `#9ca7b5` | `#56616d` | `90` | `--lanes-ink-dim` |
67
- | ink-faint | `#6b7683` | `#7f8a96` | `90` | `--lanes-ink-faint` |
68
- | accent | `#b4e3bf` | `#2f6b45` | `1;39` | `--lanes-accent` |
69
- | on-accent | `#101318` | `#ffffff` | `—` | `--lanes-on-accent` |
70
- | on-accent-urgent | `#7a1a12` | `#ffd3cc` | `—` | `--lanes-on-accent-urgent` |
71
- | tone-urgent | `#f28b7d` | `#b7322a` | `31` | `--lanes-tone-urgent` |
72
- | tone-attention | `#e5b84a` | `#8a5a00` | `33` | `--lanes-tone-attention` |
73
- | tone-working | `#7fb3f2` | `#2456a8` | `34` | `--lanes-tone-working` |
74
- | tone-done | `#6fd39a` | `#1b7142` | `32` | `--lanes-tone-done` |
75
- | scrim | `#000000a6` | `#1a222b80` | `—` | `--lanes-scrim` |
76
- | shadow | `0 -16px 80px #0007` | `0 -16px 80px #0003` | `—` | `--lanes-shadow` |
133
+ | 0 | black | `#8792a0` | `#2f3b34` | — |
134
+ | 1 | red | `#ff7b6e` | `#c41c1c` | tone-urgent |
135
+ | 2 | green | `#5fdc8a` | `#0d7338` | tone-done |
136
+ | 3 | yellow | `#ffc233` | `#a04d00` | tone-attention |
137
+ | 4 | blue | `#6fb6ff` | `#1a56c4` | tone-working |
138
+ | 5 | magenta | `#c58bff` | `#8228c4` | — |
139
+ | 6 | cyan | `#3fd9cf` | `#006f73` | — |
140
+ | 7 | white | `#a6b1be` | `#4a5661` | ink-dim |
141
+ | 8 | bright black | `#97a3b1` | `#55625b` | — |
142
+ | 9 | bright red | `#ff9a8e` | `#d0221a` | — |
143
+ | 10 | bright green | `#86f0ad` | `#0e7a3a` | — |
144
+ | 11 | bright yellow | `#ffd866` | `#a85200` | — |
145
+ | 12 | bright blue | `#98cbff` | `#1f5fd8` | — |
146
+ | 13 | bright magenta | `#dcb2ff` | `#9133d0` | — |
147
+ | 14 | bright cyan | `#7deee6` | `#00797c` | — |
148
+ | 15 | bright white | `#f2f5f8` | `#111a14` | ink |
77
149
 
78
150
  | Glyph | Codepoint | ASCII | Tone | Meaning |
79
151
  |---|---|---|---|---|
@@ -111,8 +183,13 @@ frame is seven-bit. The `ANSI-16` column is what `LANES_COLOR=16` emits; truecol
111
183
  | `·` | U+00B7 | `-` | ink-dim | mark `sep` |
112
184
  | `‘` | U+2018 | `'` | ink | mark `quote-open` |
113
185
  | `’` | U+2019 | `'` | ink | mark `quote-close` |
186
+ | `—` | U+2014 | `-` | ink-dim | mark `dash` |
187
+ | `→` | U+2192 | `->` | accent | mark `arrow` |
188
+ | `…` | U+2026 | `~` | ink-dim | mark `pending` |
189
+ | `▲` | U+25B2 | `^` | accent | mark `pinned` |
114
190
  | `✓` | U+2713 | `OK` | tone-done | mark `ok` |
115
191
  | `✗` | U+2717 | `X` | tone-urgent | mark `fail` |
192
+ | `▤` | U+25A4 | `#` | ink-dim | mark `docs` |
116
193
  | `⚠` | U+26A0 | `!` | tone-attention | economy `starving` |
117
194
  | `↓` | U+2193 | `v` | tone-attention | economy `downgraded` |
118
195
  | `$` | U+0024 | `$` | ink-dim | economy `paid` |
@@ -124,14 +201,14 @@ frame is seven-bit. The `ANSI-16` column is what `LANES_COLOR=16` emits; truecol
124
201
 
125
202
  | col | content | tone |
126
203
  |---|---|---|
127
- | 1 | ▌ cursor or space | accent |
204
+ | 1 | ▌ cursor or space; the cursor row lies on the `selected` band (truecolor / 256) up to column 35 | accent |
128
205
  | 2 | `AGENT_GLYPHS[agent.status]`; blank without `agent{}`; ink-dim when stale | its tone |
129
- | 3 | › viewing (not on the cursor row) · ² / ³ another viewport shows it · else space | accent |
130
- | 4–27 | name (24 cells) in `38;5;N` or ink, **bold when viewing**; viewer dots reserved first, name clipped to `24 − len(dots)` and ending in `…` when clipped; truncation detected by `len(clipped) < len(name)` (no second width walk) | identity colour |
206
+ | 3 | `pending` … (ASCII `~`) while an action is on its way: `x` closing the session, `P` registering it until the registry lists its lane (cockpit v3 S8), and on the New shell row a shell being created (cockpit v3 S9; the empty workspace's New shell row carries it in column 2) · › viewing (not on the cursor row) · ² / ³ another viewport shows it · else space | ink-dim for `pending`; else accent |
207
+ | 4–27 | name (24 cells) in `38;5;N` or ink, **bold when viewing**; viewer dots reserved first, name clipped to `24 − len(dots)` and ending in `…` when clipped; truncation detected by `len(clipped) < len(name)` (no second width walk) | identity colour, else ink (painted in truecolor / 256) |
131
208
  | 28 | `+` register control on an unregistered row that can be added to a workspace (closable and `R.eligible`; CHA-positioned at `cols − 8`; hit box `cols − 8 ≤ x < cols − 4`), else space — amended by Task 23 (§10, DEF-1509) | tone-done |
132
- | 29–31 | age, right-aligned: `NNs` < 60 s, `NNm` < 60 min, `NNh` < 24 h, `NNd` ≤ 99 d; blank when nothing is known; source `agent.since` else `activity` | ink-dim; attention inside Needs you |
209
+ | 29–31 | age, right-aligned: `NNs` < 60 s, `NNm` < 60 min, `NNh` < 24 h, `NNd` ≤ 99 d; blank when nothing is known; source `agent.since` else `activity` | ink-dim; attention inside Needs you; truecolor / 256: its section's tone in Working and Done too |
133
210
  | 32 | space | |
134
- | 33 | `GLYPHS[state]` — full tone without an agent, ink-dim with one (▸ + ○ is the long-tool-call case and stays readable) | its tone |
211
+ | 33 | `GLYPHS[state]` — full tone without an agent; with one ink-dim in 16 colours, its own tone in truecolor / 256 (▸ + ○ is the long-tool-call case and stays readable); ink-dim when stale | its tone |
135
212
  | 34 | tail: stale `?` > starving ⚠ > downgraded ↓ > paid `$` > space | ink-dim / attention |
136
213
  | 35 | × on closable rows, else space | urgent |
137
214
  | 36 | never written | |
@@ -187,8 +264,10 @@ real list scrolls where `⋮` is:
187
264
  ⏎ open · / find · x close · ? keys
188
265
  ```
189
266
 
190
- Row 3 is the cursor row (▌, no ›, bold name); row 4 is the viewing lane in this slot (›, bold); row 7 is shown in
191
- viewport b (²) with two viewers (··). The same frame in ASCII mode, first rows:
267
+ Row 3 is the cursor row (▌, no ›, bold name; on the `selected` band in truecolor and 256); row 4 is the viewing lane in this slot (›, bold); row 7 is shown in
268
+ viewport b (²) with two viewers (··). Once the workspace's lanes have published documents (plan W10 W7) the upload
269
+ strip reads ` ↑ uploads · ▤ docs 3 (U)`, one OSC 8 link to the docs page, and the cursor row's name links to its
270
+ lane's documents; `d` puts that link in the footer. A link changes no cell of the frame. The same frame in ASCII mode, first rows:
192
271
 
193
272
  ```
194
273
  Sakio !1 ?2 >6 v1
@@ -199,7 +278,9 @@ viewport b (²) with two viewers (··). The same frame in ASCII mode, first row
199
278
  ```
200
279
 
201
280
  Phone, the `render_mobile` picker at 48+ columns with three rows per lane (spec §4.10): row A is the viewing mark,
202
- the agent glyph and the name; row B the status word, age and harness; row C the agent's task.
281
+ the agent glyph and the name; row B the status word, age and harness; row C the agent's task. Row A draws `+` and `×`
282
+ at the desktop row's columns, `cols − 8` and `cols − 1` (cockpit v3 S5; `cols − 6` and `cols − 2` until then), inside
283
+ the same hit boxes, and never writes column `cols`.
203
284
 
204
285
  ```
205
286
  ◆1 need approval · ◇2 need you · ▸6 working
@@ -211,7 +292,7 @@ the agent glyph and the name; row B the status word, age and harness; row C the
211
292
  ◇ Runtime: supervisor··
212
293
  Needs you · 12m · codex
213
294
  Verify fleet plan mapping
214
- ◇ granite_games ×
295
+ ◇ granite_games ×
215
296
  Needs you · 4m · claude
216
297
  Port the brand migration
217
298
  ── ▸ Working (6) ───────────────────────────────
@@ -232,14 +313,14 @@ Each budget is pinned by a test; a budget that is not met is an open defect row,
232
313
  | Budget | Pin |
233
314
  |---|---|
234
315
  | `render(35 lanes + 5 unregistered, 36×60)` median of 50 after 5 warm-ups ≤ 2.0 ms hard (CI, loose); design target ≤ 0.9× the same-run baseline cold (today 0.459 ms here, binary-safe read) and ≤ 0.10 ms warm (row cache) — MEASURED 2026-10-04 after Task 23: cold 0.64× met; warm 0.27–0.31 ms at load ~23–30, NOT met, DEF-1510 open; the target is not raised | `test_sidebar_budget.test_desktop_render_time_and_frame` (time) + bench numbers in the commit message of every task touching `render()` |
235
- | Frame ≤ 9216 B truecolor, ≤ 5632 B in `LANES_COLOR=16` at 36×60 (today 4330 B in both on the DEF-1417 fixture, 4297 B before it — `LANES_COLOR` is not read before Task 22; truecolor prefixes are the whole delta) | same test, `len(frame)` |
316
+ | Frame ≤ 9216 B truecolor, ≤ 5632 B in `LANES_COLOR=16`, ≤ 6144 B (16 + 512) in `LANES_COLOR=256` at 36×60, the cursor on a registrable ghost row (today, cockpit v3 P2, MEASURED 2026-10-07: truecolor 7310 B dark / 6890 B light, 16 4618 B, 256 5821 B dark / 5805 B light; 7034 / 6598 / 4618 B before P2 with the same cursor; 4330 B in both on the DEF-1417 fixture, 4297 B before it — `LANES_COLOR` is not read before Task 22). Docs cockpit (plan W10 W7, MEASURED 2026-10-08) with its OSC 8 links on the strip and the cursor row and a 53-character origin: truecolor 7483 B, 16 4791 B, 256 5994 B dark / 5978 B light; with `d`'s footer link 7576 / 4884 / 6087 / 6071 B — no budget raised | same test, `len(frame)`; `test_the_d_footer_link_keeps_every_frame_budget` |
236
317
  | Exactly `viewports` `open()` calls of target files per render (12 over 6 renders at 2 viewports today); `subprocess.Popen`/`run` never called | `test_render_opens_only_target_files_and_never_forks` (open counter on the view dir, Popen/run patched to raise) |
237
318
  | Every emitted row ≤ 36 cells and column 36 blank on every row that is not made of the `rule` glyph alone. At Task 21 (before the one-row strip) the four upload-box rows are exempt as well — rows starting with `─ ┌ │ └`; Task 24 narrows the exemption to the rule when it removes the box | `test_no_row_exceeds_36_cells_and_column_36_is_blank` (ANSI- and OSC-stripped, binary-safe sink read, `cell_width`; and, because the strip hides positioned writes, every CHA/CUP column in the raw frame < 36 — today's maximum is 34; amendment 2026-10-04; and, because text positioned left of 36 can still run into it, every raw row walked cell by cell — `highest_cell` — writes no cell past 35, 36 on the exempt rows, over a fixture whose registrable ghosts carry both the `+` and the `×`; DEF-1417) with its detectors proven by `test_sidebar_budget_sabotage` |
238
- | Per tick ≤ 7 `stat()` at one viewport — registry, status, target, health, `flags/sockets-lost` (today's five), economy (Task 12), `devices/<device>.json` (Task 22) — plus one per extra viewport; zero timers added; the OSC 11 query is one write at start | `test_tick_stats_at_most_seven_files` (one viewport); `test_theme_query_is_written_once` |
319
+ | Per tick ≤ 8 `stat()` at one viewport — registry, status, target, health, `flags/sockets-lost` (today's five), economy (Task 12), `devices/<device>.json` (Task 22), `views/<device>/<ws>.sidebar.cmd` (cockpit v3 S4: 7 → 8, a deliberate amendment — `Ctrl-Alt-/` and `Ctrl-Alt-u` hand their key over as that file instead of typing it; measured 8 on 2026-10-06) — plus one per extra viewport; zero timers added; the OSC 11 query and the focus-report request (`?1004h`) are one write at start; the sidebar.cmd file is stat()ed once more per read of keys (never per tick) | `test_tick_stats_at_most_eight_files` (one viewport); `test_theme_query_is_written_once` |
239
320
  | Upload: select timeout stays 1.0 while uploading; `dirty` only on `.progress` mtime change | `test_upload_does_not_shorten_the_tick` (Task 24) |
240
- | `render_mobile(35, 48×60)` ≤ 2.0 ms hard, frame ≤ 6144 B; target ≤ 0.45 ms cold / ≤ 0.10 ms warm (today 0.352 ms / 1663 B) | `test_mobile_render_time_and_frame` |
321
+ | `render_mobile(35, 48×60)` ≤ 2.0 ms hard, frame ≤ 6144 B; target ≤ 0.45 ms cold / ≤ 0.10 ms warm (today 0.352 ms / 1663 B; 4058 B truecolor after cockpit v3 P2, 3991 B before) | `test_mobile_render_time_and_frame` |
241
322
  | `reload(force)` unchanged (0.14 ms at 35 / 0.39 ms at 183); fleet attention computed inside it from the loaded dict — NOT shown met: Task 24 measured 0.26–0.44 ms at 35 / 0.69–0.80 ms at 183 and Task 12 0.296 → 0.386 ms at 35 (load 30–62; the 0.14/0.39 figures are quiet-host figures), DEF-1564 open; the budget is not raised | bench in Task 24's and Task 12's commit messages; DEF-1564 |
242
- | Phone: exactly one `setInterval(`, zero `setTimeout(`, no `backdrop-filter`, a painted `box-shadow` only in `.lanes-sheet-panel` and `.zj-mobile-menu` (a `box-shadow: none` override, as on the security modal, is not paint work and is not counted); `fetch(` absent; mobile.css grows ≤ 2048 B over today's 16 934 B, mobile.js ≤ 3072 B over today's 19 102 B (measured 2026-10-03; the caps are baseline + allowance, quoted in the test); `index.html` keeps its 9 SRI-pinned upstream assets | `test_web.test_js_timers_unchanged`, `test_web.test_no_scroll_time_effects`, `test_web.test_bundle_growth`, `test_web.test_integrity_pins_untouched` |
323
+ | Phone: exactly one `setInterval(`, zero `setTimeout(`, no `backdrop-filter`, a painted `box-shadow` only in `.lanes-sheet-panel` and `.zj-mobile-menu` (a `box-shadow: none` override, as on the security modal, is not paint work and is not counted); `fetch(` absent until W9 V3's one `/enrol/site`; mobile.css grows ≤ 2048 B over today's 16 934 B, mobile.js ≤ 3072 B over today's 19 102 B (measured 2026-10-03; the caps are baseline + allowance, quoted in the test), raised since by exactly what phone shell input v2, its review fixes, the cockpit v3 sync and the docs cockpit's **Docs** row (plan W10 W7, +135 B) MEASURED: the cap equals today's 24 747 B (spec §8 has the steps); `index.html` keeps its 9 SRI-pinned upstream assets | `test_web.test_js_timers_unchanged`, `test_web.test_no_scroll_time_effects`, `test_web.test_bundle_growth`, `test_web.test_integrity_pins_untouched` |
243
324
  | Zellij: config.kdl grows by the theme + ui block only (~4 KB), `load_plugins {}` empty, no `rename-tab` from the sidebar | `test_keybinds.test_theme_block_and_plugin_free`, `test_tokens.test_generated_blocks_match_committed_files` |
244
325
  | Unit suite stays under 75 s wall (MEASURED 2026-10-04 after Task 21: 833 tests, 124–176 s at load ~30–60 — exceeded, DEF-1415 open; the budget is not raised); new pins are exact-string asserts, no sleeps; `tokens.py --check` ≤ 50 ms, contrast test ≤ 10 ms | observed in each task's run step |
245
326
 
@@ -22,7 +22,9 @@ and writes nothing (exit 1), `lanes hooks install --write` into a container refu
22
22
  `lanes doctor` warns about each container. Run them from a host account with UID 1000.
23
23
  Containers need Bash, Python 3 and tmux. Use compatible tmux versions on the host and containers (tested with
24
24
  tmux 3.4): the host client attaches directly to the container server. Zmx is installed as an optional holder,
25
- not a migration of existing lanes.
25
+ not a migration of existing lanes. Quick edit (`Ctrl-Alt-e`) runs an editor the container already has:
26
+ `$LANES_EDITOR`, else the first of micro, hx, nvim, vim and vi on its PATH. Lanes installs no editor, so vi (or vim)
27
+ is the floor; a container with none of them cannot quick-edit (`lane-edit: no editor on PATH`).
26
28
 
27
29
  For services to stay available after logout, an administrator enables the account's user manager:
28
30
 
@@ -41,8 +43,15 @@ cd bw-tools/lanes
41
43
 
42
44
  `--server --no-start` installs files and units without contacting Docker or starting services. The normal install
43
45
  verifies archive checksums, installs the pinned binaries, registers this checkout's absolute paths, and enables
44
- `lanes-statusd`, `lanes-sync.timer`, `lanes-ensure.timer` and `lanes-gc.timer`. It creates an empty workspace catalog
46
+ `lanes-statusd`, `lanes-sync.timer`, `lanes-ensure.timer`, `lanes-gc.timer` and `lanes-update.timer` (which updates an
47
+ npm-installed server by itself and leaves a checkout alone, section 6). It creates an empty workspace catalog
45
48
  only if no configuration exists. Host protection, temporary-file deletion and web access remain opt-in.
49
+ It also enables and restarts the enrolment service (`lanes-enrold`) and the docs service (`lanes-docsd`), then
50
+ installs the docs page's pinned Mermaid with `install/enable-docs.sh` (a download from npm checked against both
51
+ digests in `holders.lock`, into `~/.local/share/lanes/vendor/`; a re-run finds it and downloads nothing). That step is
52
+ never fatal: the install's last lines say `Mermaid for the docs page: ok`, `skipped (<why>)` (no network, a missing
53
+ prerequisite: diagrams show as source until `sh ~/.local/share/lanes/current/install/enable-docs.sh` succeeds) or
54
+ `refused (<why>)` (a digest that is not the pin: nothing installed).
46
55
 
47
56
  ## 2. Configure workspaces and editor sync
48
57
 
@@ -108,7 +117,9 @@ account with UID 1000. With `--write` it then applies these steps in order:
108
117
  1. **Board.** Runs `lanes workspaces add`, when `--workspace` and `--repo` are given. `--title` defaults to the id
109
118
  or to the title the board already has. A board that already disagrees stops the run before the container is
110
119
  touched.
111
- 2. **Contract.** Runs `lanes install-container`.
120
+ 2. **Contract.** Runs `lanes install-container`: the `lane` contract, and the Lanes MCP server (`lane-mcp`) in
121
+ Claude Code's `~/.claude.json` and Codex's `~/.codex/config.toml` (only where it is missing; the old file is kept
122
+ as `<file>.bak-<UTC time>`). The `lane-mcp` row of the table then says whether that changed either file.
112
123
  3. **Agent hooks.** Runs `lanes hooks install --write`, first for Claude and then for Codex. Your own hooks and
113
124
  keys are kept, with a backup of each changed file. A Lanes hook group that is already installed stays where
114
125
  it is, even when Throttle's setup has added its own group around it.
@@ -135,9 +146,51 @@ The run ends with a `key: value` table and a receipt in `~/.local/state/lanes/on
135
146
  `changed`, `unchanged`, `ok`, `skipped`, `manual` (a step left for a person, with its command), `warn`, `failed` or
136
147
  `not-run`. When a step stops the run, every step after it is still listed, as `not-run`, in the table and the receipt.
137
148
  The exit code is 1 only when a step failed. Running the command again changes nothing that is already done.
138
- `--no-mcp` leaves out `lane-mcp`, the in-container MCP server, which has not shipped yet. Until it does, the
139
- receipt records that step as skipped.
140
- Each user's board still shows on every device's view. A per-device subset of boards is a separate follow-up.
149
+ `--no-mcp` leaves out `lane-mcp` and its two entries; the receipt then records that step as skipped, and nothing an
150
+ earlier run wrote is removed ([user guide](USERGUIDE.md#agent-tools-mcp)).
151
+ Each user's board shows on every device's view. A person who owns a device can hide boards or reorder them on all of
152
+ their devices at once (`lanes workspaces hide|show|order`, kept in their profile; [user guide](USERGUIDE.md#your-profile)).
153
+
154
+ ### Agent tools
155
+
156
+ What `lanes install-container <name>` puts into the container's home volume, `/home/coder/.lz/` (additive: it
157
+ replaces only its own files, by rename, and never a `zmx-*` binary a running lane uses):
158
+
159
+ | Path | What it is |
160
+ | --- | --- |
161
+ | `bin/lane`, `bin/lane_core.py`, `bin/lane-run`, `bin/lane-signal` | The in-container `lane` contract, its shared reader core, the zmx lane root process and the hook sidecar writer |
162
+ | `bin/lane-edit`, `vimrc` | What quick edit (`Ctrl-Alt-e`) runs there, and the settings it gives vim and nvim; no editor is installed |
163
+ | `bin/lane-mcp` | The Lanes MCP server for the agents (left out with `--no-mcp`) |
164
+ | `bin/zmx-*` | The pinned zmx holder (optional lanes only) |
165
+ | `VERSION`, `hooks/`, `env.sh` | The release that installed them, the hook definitions `lanes hooks install` merges, and the shell settings new shells source |
166
+
167
+ It installs no agent hook (that is `lanes hooks install`, opt-in) and, unless `--no-mcp`, adds the agents' MCP entries
168
+ below. A re-run after a server update (the update does it itself for every container `lanes sync` knows) brings every
169
+ file to the installed release.
170
+
171
+ `lanes install-container <name>` (and so `lanes onboard`, step 2 above) also gives the container's agents the Lanes
172
+ MCP server, `~/.lz/bin/lane-mcp`. With it, the agent in a lane can do these things in its own container:
173
+
174
+ - see the lanes there: `lanes_whoami`, `lanes_list`, `lanes_status`, `lanes_capture`;
175
+ - declare, rename or remove a lane's terminal, or start a new lane;
176
+ - type into a sibling lane;
177
+ - report its own status on the boards.
178
+
179
+ Ending, interrupting or attaching a lane stays with people
180
+ ([what agents may and may not do](ARCHITECTURE.md#agent-api)).
181
+
182
+ - It is on by default. The entry is added to Claude Code's `~/.claude.json` and Codex's `~/.codex/config.toml` only
183
+ where it is missing, and each file is kept first as `<file>.bak-<UTC time>`. `--no-mcp` leaves both out and
184
+ removes nothing.
185
+ - Check each container once with the [protocol smoke](RUNBOOK.md#protocol-smoke): two frames in, two out. Then start
186
+ a new agent session and look at `/mcp` in Claude Code or `codex mcp list`. Sessions that were already running keep
187
+ the tools they started with.
188
+ - Point your agents at the [`lanes-agent-tools` skill](../../skills/lanes-agent-tools/SKILL.md). It says which tool
189
+ to call when, never to try to end a lane, and how to hand work to a sibling lane. Lanes bootstrapped from this
190
+ repository's `.bw-tools/` are told about it in `.bw-tools/context.md`.
191
+ - Uninstall and troubleshooting: [runbook](RUNBOOK.md#install-check-uninstall).
192
+ - It is not a Throttle tool-gateway package: agents start it directly, and the catalog does not list it
193
+ ([why](ARCHITECTURE.md#gateway-adoption-documented-not-registered)).
141
194
 
142
195
  ### Laptop agents on the Throttle pool (operator-gated)
143
196
 
@@ -219,7 +272,13 @@ request that a member approves (the installer waits up to 10 minutes; `--resume`
219
272
  `https://<LANES_SERVER_NAME>:8443` (with curl, or python3 without it; `--resolve` to the tailnet address when MagicDNS is
220
273
  off). What the client reads of lanes-enrold's wire contract is in [the architecture](ARCHITECTURE.md#joining-a-site).
221
274
  The code is never shown, stored, or put on a command line. A malformed code, or one with another site's prefix, is refused before anything is installed
222
- (exit 1).
275
+ (exit 1). The code is redeemed by the server's own release (W9 K3): with an invite, the npm installer first reads the
276
+ server's release from `GET /enrol/site` (`version`; on a tailnet site only when the machine is already connected) and,
277
+ when it is newer than the package, ends before anything is installed or redeemed (`install.sh` exit 5 to the wrapper)
278
+ while the wrapper runs `npx --yes @vibelord/lanes@<release>` with the same options and `LANES_NO_SELF_UPDATE=1`; no
279
+ npx, a release npm does not have yet, or npm not answering: one line, and the package that ran installs as before
280
+ ([user guide](USERGUIDE.md#join-with-an-invite)). A server whose release is not on npm yet therefore costs a joining
281
+ machine nothing but that line: publish before telling members to join with a new release's features.
223
282
 
224
283
  Without the service, after a refused code, or with no code and no terminal, authorize the printed public key on the
225
284
  server, as the workspace account: `lanes keys add <device> <file.pub>` (or `lanes keys add <device> -` and paste the
@@ -298,7 +357,9 @@ lanes enrol list --all # also the approved, denied and ex
298
357
  On a phone or laptop on the tailnet, `https://<server>:8443/enrol/pending` lists the same requests with **Approve**
299
358
  and **Deny**. It admits a tailnet login that owns a member record, and keys added by hand (`lanes keys add`, `lanes
300
359
  keys adopt`) record no owner, so name yourself once on one of your own devices: `lanes keys owner <your-device>
301
- <your tailnet login>`. With the opt-in push notifier (`lanes-notify.service`), each new request arrives as one push
360
+ <your tailnet login>` (or adopt your own keys with `lanes keys adopt --login <your tailnet login>`). Naming an owner
361
+ also folds those devices' views into that person's profile; a view no member record speaks for gets its owner with
362
+ `lanes device claim <device> --login <login>`. With the opt-in push notifier (`lanes-notify.service`), each new request arrives as one push
302
363
  naming the requester, the device and the approve command. The CLI records where a decision came from
303
364
  (`cli[:<device>]@<address>`), not who made it (everyone over SSH is the one workspace account); the web page records
304
365
  the real tailnet login. Requests need request mode, which needs Tailscale Serve to hand the service a tailnet
@@ -345,7 +406,7 @@ refuse; the Debian 12 and Ubuntu 24.04 packages are 2.6) and Tailscale on the se
345
406
  its tailnet. The web services use
346
407
  loopback ports **8082** (native Zellij) and **8083** (Lanes frontend); private HTTPS uses **8443**. The enrolment
347
408
  service every server install runs (`lanes-enrold`) listens on loopback **8184** and is reached as `/enrol/*` through
348
- the frontend.
409
+ the frontend; the docs service (`lanes-docsd`, W10) listens on loopback **8185** and is reached as `/lanes-docs/*`.
349
410
  Reserve those ports for Lanes on this host. Caddy runs as a separate user service with its admin API disabled.
350
411
 
351
412
  Once, a server administrator allows the workspace account to manage Tailscale Serve:
@@ -476,19 +537,115 @@ credential; the server installer warns when its shell's roots differ from the re
476
537
 
477
538
  ## 6. Updates, optional operations and removal
478
539
 
479
- A server installed with `npx @vibelord/lanes --server` runs from the staged tree `~/.local/share/lanes/pkg/<version>/`; updating is `npx @vibelord/lanes@latest --server`, and the previous versions a service may still run from are kept.
540
+ A server installed with `npx @vibelord/lanes --server` runs from the staged tree `~/.local/share/lanes/pkg/<version>/`; updating is `npx @vibelord/lanes@latest --server --update-only`, and the previous versions a service may still run from are kept.
541
+
542
+ Old staged trees on a server are pruned by `lanes gc` (the daily `lanes-gc.timer`; W9 U4), never by the npm installer,
543
+ which prunes only a machine without `~/.local/share/lanes/current` (a client). Each folder of staged trees keeps its 3
544
+ newest and every older tree something still uses: the one `current` names, the rollback target `current.previous`
545
+ names, the one the running `lanes` came from, one a view layout names (`~/.local/state/lanes/layouts/<device>.kdl`: a
546
+ view built before 1.2.0 runs its `pkg/<version>` tree until `lanes rebuild <device>`), one a running daemon's
547
+ `run/<name>.tree` names (a service started through `current` before an update whose restart failed), and one any
548
+ process of this account uses — read from `/proc` (its argv, working directory, executable and open files), so an
549
+ installer running from a tree or a viewport started before the update keeps it. When `/proc` cannot be read, no tree
550
+ is pruned. `lanes gc` prints `pruned <tree>`, and `kept <tree>: <why>` for each older tree it keeps.
551
+
552
+ That update runs by itself (W9 U3): the full install enables `lanes-update.timer`, which runs `lanes update --timer`
553
+ 15 minutes after a boot and then every 30 minutes (up to 5 minutes later, at random; the unit is stopped after an
554
+ hour). One `lanes update` runs at a time (`~/.local/state/lanes/locks/update.lock`). On an npm-installed server it first
555
+ runs the post-commit of the installed release again when the last update ended `partial` (once per release from the
556
+ timer), then — unless a check answered less than 20 minutes ago; `lanes update --now` asks anyway — asks the npm
557
+ registry (`curl -fsS --max-time 20 https://registry.npmjs.org/@vibelord/lanes`) which release the dist-tag `latest`
558
+ names, or `lanes-stable` with `channel=stable` in `~/.config/lanes/update.conf` (publish a release to the stable channel
559
+ with `npm dist-tag add @vibelord/lanes@<release> lanes-stable`). A release newer than the installed one (major.minor.patch
560
+ as numbers; never a downgrade) that no rollback marked bad is installed with the npx the npm installer recorded when it
561
+ installed the server, `~/.local/state/lanes/update/npx`: `npx --yes @vibelord/lanes@<release> --server --update-only`.
562
+ Publishing a release is therefore deploying it to every npm-installed server within about 30 minutes; `channel=stable`
563
+ and a `lanes-stable` tag that trails `latest` are the soak valve. The settings the installed units carry — `LANES_WORKSPACES`,
564
+ `LANES_THROTTLE_*`, `LANES_NOTIFY_CONF`, which an install renders from its own environment — are given to that install
565
+ again, so a timer's update renders them as the install that set them did. Every check is recorded in
566
+ `update/last.json` as `check`, beside the installer's record; `lanes update --status` prints the file and `lanes
567
+ doctor` says how long ago the last check was and how the last update ended. When the recorded npx is gone (an nvm or
568
+ Node.js upgrade moved it) the update fails and says so: run `npx @vibelord/lanes --server --update-only` once from a
569
+ shell whose npx works, which records it again. A server run from a checkout is left alone by the timer: update it with
570
+ `git pull` and `./install.sh --server --update-only`.
571
+
572
+ Only the full install enables `lanes-update.timer`: `--update-only` enables nothing, the timer included. A server set
573
+ up before 1.2.0 (the first release with `lanes update`) and updated since with `npx @vibelord/lanes@latest --server
574
+ --update-only` has it off and never updates by itself: run the full `npx @vibelord/lanes@latest --server` once, or
575
+ `systemctl --user enable --now lanes-update.timer`. `lanes doctor` warns (`lanes-update.timer is not enabled`) until
576
+ then.
577
+
578
+ To go back one release: `lanes update --rollback`. It needs no network: this release's `install/services.py` renders the
579
+ units of the tree `~/.local/share/lanes/current.previous` names and moves `current` back to it (the same commit as an
580
+ install, under the same locks), the release left is marked `bad` in `update/last.json`, so no update installs it
581
+ again (the next newer release is installed as usual), and the post-commit of that release runs. The mark is written
582
+ in the same record as the flip, the result `partial` until the post-commit has run (Ctrl-C and hangups wait for that
583
+ write): a rollback stopped during its post-commit keeps the mark, and the next timer tick runs the post-commit again
584
+ instead of installing the release left. The record of the rollback lists the step `rollback` first. A second
585
+ rollback, which would put the bad release back, is refused.
586
+
587
+ `--update-only` (W9 U2) is the update path of a server a full `--server` install set up: it restarts what that install
588
+ enabled and enables nothing, so where no release is installed (`~/.local/share/lanes/current` names no tree) it is
589
+ refused before anything is fetched. It runs in three phases. Pre-commit: the prerequisite checks, the pinned binaries
590
+ (sha256-verified), terminfo and the catalog — a failure here leaves the installed release running as it was.
591
+ Commit: `install/services.py --install` writes the units and moves `current` (below). Post-commit, from the release
592
+ just installed (`libexec/update.py`): `systemctl --user daemon-reload`; `lanes sync`; `systemctl --user restart
593
+ lanes-statusd.service`; `systemctl --user try-restart` of `lanes-economy`, `lanes-notify`, `lanes-enrold`,
594
+ `lanes-docsd`, `lanes-mobile`, `lanes-web` and `lanes-studio-host` (each restarted only when it runs; nothing is
595
+ enabled), with `sh install/enable-docs.sh` right after `lanes-docsd`'s (the docs page's Mermaid, step `enable-docs`);
596
+ `lanes install-container <name>` for each container `lanes sync` recorded with a home volume; `lanes
597
+ reload-views`; `lanes ensure --if-layout-changed` (each view whose tabs this release lays out differently is rebuilt when no client is
598
+ attached, and otherwise marked `update ready · reload (r)` in its sidebar footer until it is rebuilt — by `r`, by
599
+ `lanes rebuild <device>`, or by `lanes-ensure.timer` once its last client has left). Every post-commit step runs
600
+ whatever the one before it returned, and each is stopped after 300 s. `lanes
601
+ sync` exiting 1 — a board with an error, which it reports while keeping that board's previous lanes — is a warning
602
+ (`WARN`, `"warning": true` in the record) that leaves the result `ok`; a sync that does not answer or cannot start is a
603
+ failure like any other step. The Mermaid step's exit 1 (a prerequisite or the download), a download stopped after
604
+ 300 s, and exit 3 (refused: no valid pin, a digest mismatch, nothing installed) are warnings too, recorded with
605
+ `outcome` (`skipped`, `refused`) and `reason` (the script's last line), and the summary line names it (`mermaid ok`,
606
+ `mermaid skipped: <reason>`); any other status of it is a failure. The terminal or the journal gets one line per phase and step;
607
+ `~/.local/state/lanes/update/last.log` holds the full output and `update/last.json` the outcome: `{checked_at,
608
+ latest, installed, previous, result, steps: [{name, rc[, warning][, outcome, reason]}], log}`, `result` being `ok`, `partial` (installed, a step
609
+ after the switch failed: exit 4), `refused` (a prerequisite, no installed release or another install said no, nothing
610
+ changed: exit 3) or `failed` (nothing was installed: exit 1). `--update-only` takes neither `--check` nor
611
+ `--no-start`. A full install (no `--update-only`) writes no record; a failing `lanes sync` there is a warning too, and
612
+ the services are still enabled.
613
+
614
+ One install or update runs at a time. Every server install and update except `--check` first takes the run lock
615
+ `~/.local/state/lanes/update/.lock` and holds it until it ends (a crash releases it); a second run started meanwhile —
616
+ the update timer and a hand-run `npx`, two shells — changes nothing, writes neither the log nor the record of the run
617
+ that holds it, prints one line naming that run (its pid, whether it is an update or an install, and since when) and
618
+ exits 3. Run it again once the other has ended.
480
619
 
481
620
  Keep the server checkout at a stable location. To move it, run `./install.sh --server` from the new location:
482
621
  service definitions and `~/.local/share/lanes/current` are regenerated. Existing workspace configuration is preserved.
622
+
623
+ The units, `~/.local/bin/lanes` and the view layouts name `~/.local/share/lanes/current`, never the tree itself, and
624
+ the installer moves that link last, in one step — the commit point of an install or update. Every unit is written
625
+ before it (a failure there leaves the running release installed), and `~/.local/share/lanes/current.previous` then
626
+ names the tree `current` named before (unless it is the same tree or no longer exists). Only the server installer
627
+ moves the link: `install/enable-web.sh` (`lanes web <device>`) and `install/enable-studio-host.sh` re-render the units of
628
+ the release `current` names and leave every link as it is, so running them from a tree an update has moved past (a
629
+ shell or a view that still has the old tree on its PATH) never puts that release back; they say so on stderr. Installs
630
+ and re-renders take turns on `~/.local/share/lanes/.install.lock` (an install that finds it held for 60 s stops
631
+ without changing anything).
632
+
483
633
  Services get the explicit `LANES_SHARE` and `LANES_STATE` paths supplied at installation, or the defaults below;
484
634
  use the same overrides when reinstalling. `LANES_WORKSPACES` is also carried into the generated user services
485
635
  when supplied; the standard configuration directory is the simplest default.
486
636
 
487
- After updating server code, restart the status observer with `systemctl --user restart lanes-statusd.service`.
488
- The installer itself restarts the enrolment service (`lanes-enrold`) and a running web frontend (`lanes-mobile`, so
637
+ After updating server code with a full install, restart the status observer with `systemctl --user restart
638
+ lanes-statusd.service` (`--update-only` does that and the restarts below). The installer itself restarts the enrolment service (`lanes-enrold`), the docs service (`lanes-docsd`, which answers nothing until that same pin is set — RUNBOOK "Docs service") and a running web frontend (`lanes-mobile`, so
489
639
  Caddy reads the new tree's Caddyfile; browser clients reconnect). On the first server install with enrolment, run the
490
640
  runbook's operator verification and pin its result with `lanes enrol mode request` (or `invite`) — see
491
641
  [Approve a request](#approve-a-request); a pinned mode survives this restart and every later one.
642
+ The docs service answers only once that pin says `request` (an `invite` pin, or none, refuses every docs request).
643
+ On the first server install with the docs service (operator gate 1 of the docs plan: the full `npx
644
+ @vibelord/lanes@<release> --server`, which enables `lanes-docsd` and installs Mermaid), re-run the Serve header check
645
+ so the pin is `request`; until then `lanes doctor` says `⚠ docs: refuses until the Serve header check is pinned`, and
646
+ afterwards `✓ docs: pinned`, with `lanes-docsd` answering on its port and the Mermaid pin's state (RUNBOOK "Docs
647
+ service"). Then `lanes install-container <name>` per container gives its agents `lane publish` and the
648
+ `lanes_publish` MCP tool (an update does that for every recorded container).
492
649
  If you enabled the Studio Host and selected a new `throttle-rc` or edited its config, restart
493
650
  `lanes-studio-host` (re-running `install/enable-studio-host.sh` does that, after `studio host --check`).
494
651
  If web service definitions or the pinned Zellij binary changed, restart `lanes-web` and `lanes-mobile` too;
@@ -499,13 +656,13 @@ Normal paths:
499
656
 
500
657
  | Path | Contents |
501
658
  | --- | --- |
502
- | `~/.config/lanes/` | Site profile, client settings and server workspace configuration |
503
- | `~/.local/share/lanes/` | Client copy, pinned binaries, download cache, installed checkout link and npm-staged trees (`pkg/<version>/`) |
504
- | `~/.local/state/lanes/` | Host registry, device selections, status, upload handoff and housekeeping projections; `members/`, `invites/`, `enrol-requests/` and the append-only `enrol.log` of the enrolment service |
659
+ | `~/.config/lanes/` | Site profile, client settings and server workspace configuration; `update.conf` (`channel=stable`: `lanes update` follows the `lanes-stable` dist-tag) |
660
+ | `~/.local/share/lanes/` | Client copy, pinned binaries, download cache, the docs page's pinned Mermaid (`vendor/`, from `install/enable-docs.sh`, which every server install and update runs), npm-staged trees (`pkg/<version>/`; on a server `lanes gc` prunes them), the `current` link to the installed tree (the one path the units, `~/.local/bin/lanes` and the view layouts name), `current.previous` (the tree `current` named before the last install that moved it) and `.install.lock` (the lock installs and unit re-renders take turns on) |
661
+ | `~/.local/state/lanes/` | Host registry, device selections, status, upload handoff and housekeeping projections; `members/`, `invites/`, `enrol-requests/` and the append-only `enrol.log` of the enrolment service; `run/<name>.tree`, the tree each service started from (`lanes version` compares them with `current`; `lanes doctor` flags each one whose process runs older code); `update/last.json` and `update/last.log`, the outcome and output of the last `--update-only` run (with `lanes update`'s last `check`, a rollback's `bad`), and `update/.lock`, the run lock every server install and update holds while it runs; `update/npx`, the npx the npm installer recorded for `lanes update`, and `locks/update.lock`, held by the one `lanes update` that runs |
505
662
  | `~/.local/state/lanes/studio-host/` | Optional Studio Host: published public key, deploy receipt, enrolment record, migrated unit text (`lanes studio-host re-key --yes` clears the key half after a revoke) |
506
663
  | `~/.config/throttle-rc/`, `~/.local/share/throttle-rc/` | Optional Studio Host config, machine key and enrolment receipt (throttle-rc's own files) |
507
- | `~/.local/state/lanes-client/` | Client helper heartbeat and logs |
508
- | `/home/coder/.lz/` in each container | Lane helper, bindings and optional zmx metadata |
664
+ | `~/.local/state/lanes-client/` | Client helper heartbeat and logs; `checked`, the time of a window's last update question (W9 K2: at most one every 6 h); `checking/`, while one window asks, its claim with the server's and npm's answers |
665
+ | `/home/coder/.lz/` in each container | What `lanes install-container` installs ([table](#agent-tools)), the registry and status projections `lanes sync` and the status daemon write there, hook sidecars and optional zmx metadata |
509
666
 
510
667
  `lanes pressure`, `lanes sockets check` and `lanes tmp plan` are inspection tools. Review
511
668
  [the runbook](RUNBOOK.md) before applying cleanup/repair; read [host protection](HOST-GUARD.md) before enabling
@@ -514,7 +671,7 @@ its root guard. These operations are included in the package but are not an inst
514
671
  Clients uninstall with `./install.sh --uninstall`; SSH keys, `~/Lanes Uploads`, and any server workspace
515
672
  configuration and state on the same account are kept.
516
673
  To stop the host services, disable `lanes-statusd.service`, `lanes-sync.timer`, `lanes-ensure.timer`,
517
- `lanes-gc.timer`, and any optional units you enabled (`lanes-studio-host.service` and
674
+ `lanes-gc.timer`, `lanes-update.timer`, and any optional units you enabled (`lanes-studio-host.service` and
518
675
  `lanes-studio-host-enrol.timer` included; revoke the machine in the console if it should not come back). For the web endpoint, also run
519
676
  `tailscale serve --https=8443 off`. Keep the state and container lane data until you have decided which
520
677
  sessions to retain; no broad deletion command is part of uninstall.