@vibelord/lanes 1.2.0 → 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 (35) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +4 -3
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +301 -45
  5. package/payload/lanes/container/lane +42 -1
  6. package/payload/lanes/container/lane-mcp +65 -6
  7. package/payload/lanes/container/lane_core.py +178 -0
  8. package/payload/lanes/docs/ARCHITECTURE.md +192 -20
  9. package/payload/lanes/docs/DESIGN.md +6 -3
  10. package/payload/lanes/docs/INSTALL.md +25 -8
  11. package/payload/lanes/docs/RUNBOOK.md +369 -8
  12. package/payload/lanes/docs/USERGUIDE.md +201 -25
  13. package/payload/lanes/holders.lock +5 -0
  14. package/payload/lanes/install/enable-docs.sh +78 -0
  15. package/payload/lanes/install/host-install.sh +39 -5
  16. package/payload/lanes/libexec/docs_store.py +995 -0
  17. package/payload/lanes/libexec/docsd.py +2010 -0
  18. package/payload/lanes/libexec/mdlite.py +575 -0
  19. package/payload/lanes/libexec/members.py +5 -0
  20. package/payload/lanes/libexec/notify.py +65 -5
  21. package/payload/lanes/libexec/sidebar.py +106 -13
  22. package/payload/lanes/libexec/statusd.py +97 -5
  23. package/payload/lanes/libexec/tokens.py +31 -9
  24. package/payload/lanes/libexec/update.py +94 -22
  25. package/payload/lanes/libexec/uploads.py +538 -0
  26. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  27. package/payload/lanes/web/Caddyfile +24 -1
  28. package/payload/lanes/web/README.md +86 -8
  29. package/payload/lanes/web/browser.js +2 -4
  30. package/payload/lanes/web/docs.css +52 -0
  31. package/payload/lanes/web/docs.js +36 -0
  32. package/payload/lanes/web/mobile.js +3 -2
  33. package/payload/lanes/web/upload.css +17 -0
  34. package/payload/lanes/web/upload.js +84 -0
  35. package/payload/manifest.json +4 -3
@@ -41,7 +41,7 @@ Phone / tablet / laptop ── HTTPS (Tailscale :8443) ┤
41
41
  ▼
42
42
  Linux workspace host (one unprivileged account)
43
43
  one Zellij view session per device: workspace tabs, lane sidebar, 1–3 viewports
44
- lanes-statusd · registry sync · upload intake · web services
44
+ lanes-statusd · registry sync · upload intake · docs service · web services
45
45
  │ /proc/<container init pid>/root
46
46
  ▼
47
47
  Workspace containers (home /home/coder, uid 1000)
@@ -52,7 +52,7 @@ Phone / tablet / laptop ── HTTPS (Tailscale :8443) ┤
52
52
 
53
53
  | Layer | Owns | Lifetime |
54
54
  | --- | --- | --- |
55
- | Host | Views, the registry, status, uploads, web services | Services restart freely; nothing here holds an agent |
55
+ | Host | Views, the registry, status, uploads, the docs store, web services | Services restart freely; nothing here holds an agent |
56
56
  | Container | Each lane's tmux session and the agent in it | Survives every view, reconnect and rebuild |
57
57
  | View | One Zellij session per device (`mac`, `phone`, `browser`, …) | Disposable: `lanes rebuild <device>` recreates it, agents untouched |
58
58
 
@@ -442,7 +442,7 @@ redeemed from a second machine yet.
442
442
 
443
443
  After a server update — with `--update-only` the installer runs these steps itself once the new release is switched
444
444
  in, in this order: `systemctl --user daemon-reload`, `lanes sync`, the statusd restart, a `try-restart` of each
445
- optional service that runs (economy, notify, enrolment, `lanes-mobile`, `lanes-web` and the Studio Host — restarted,
445
+ optional service that runs (economy, notify, enrolment, docs, `lanes-mobile`, `lanes-web` and the Studio Host — restarted,
446
446
  not re-checked as `enable-studio-host.sh` does; one that is off stays off), `lanes install-container` for every
447
447
  container `lanes sync` recorded, `lanes reload-views` and `lanes ensure --if-layout-changed`. A step that fails is
448
448
  reported and the next one still runs. `lanes sync` exiting 1 — a board with an error, such as a container that no longer exists, whose
@@ -550,7 +550,7 @@ would put the bad release back, is refused (install it by hand with `npx @vibelo
550
550
  tree's `VERSION` and the commit of its npm stamp (none for a checkout), the tree (`pkg/<version>` for an npm
551
551
  install), whether `~/.local/share/lanes/current` names that tree (`current pkg/1.2.0 ✗` when it names another: this
552
552
  `lanes` was started from an old tree), then the tree each long-running process recorded when it last started
553
- (`~/.local/state/lanes/run/<name>.tree`: statusd, enrold, notify, economy, studio_host and the newest sidebar).
553
+ (`~/.local/state/lanes/run/<name>.tree`: statusd, enrold, docsd, notify, economy, studio_host and the newest sidebar).
554
554
  Each `✗` there names a process that does not run the current code: its last start ran another tree
555
555
  (`statusd runs 1.1.0 ✗`), or ran the current tree before its code last changed (`statusd started before its tree
556
556
  changed ✗` — a checkout updated in place keeps its path, so `lanes version` compares the record's time with the
@@ -564,7 +564,8 @@ would put the bad release back, is refused (install it by hand with `npx @vibelo
564
564
  - The client installer prints `lanes client <version>` and copies `VERSION` into `~/.local/share/lanes/client/`.
565
565
  `lanes-launch --version` prints it (`lanes-launch 1.2.0`; a checkout's client reads `lanes/VERSION`; `dev` without
566
566
  either), and the dim line a window shows while it connects names it: `lanes 1.2.0: connecting to lanes…`.
567
- - On a phone or tablet, **More** shows the server's release as its first row, **Lanes v1.2.0**, above **Text size**:
567
+ - On a phone or tablet, **More** shows the server's release as its first row, **Lanes v1.2.0**, above **Docs** and
568
+ **Text size**:
568
569
  the shell asks the enrolment service's `/enrol/site` once when it opens (the release that service runs; the server
569
570
  installer restarts it on every install). Nothing is shown when the service does not answer.
570
571
  - `lanes-launch` passes that version to `lanes attach` (`--client-version`). A client older than the server gets one
@@ -822,7 +823,14 @@ Top to bottom, a desktop sidebar is:
822
823
  would leave the pane. Then `zmx lanes (N)` when any exist, then **Unregistered (N)**
823
824
  with its `+ New shell` row. A list longer than the pane ends in `⋮ N more`; moving onto that row or clicking it
824
825
  scrolls instead of opening.
825
- 3. **Upload strip** (panes of 14 rows or more): ` ↑ uploads · click, then drop (U)` (section 5).
826
+ 3. **Upload strip** (panes of 14 rows or more): ` ↑ uploads · click, then drop (U)` (section 5). Once this
827
+ workspace's lanes have published documents (the docs cockpit) it reads ` ↑ uploads · ▤ docs 3 (U)` — the count
828
+ is every document of its lanes, whoever published it — and the whole strip is a link to the docs page,
829
+ `https://<server>:8443/lanes-docs/` (⌘-click in Ghostty and iTerm2, Ctrl-click elsewhere); a plain click still
830
+ arms the upload. The cursor row's name is a link to that lane's own documents
831
+ (`/lanes-docs/?ws=<workspace>&lane=<lane id>`) while it has any, and `d` shows the link in the footer. The links
832
+ need the server's Tailscale name (statusd learns it as the enrolment service does): without it the strip still
833
+ counts and `d` shows the path alone. What the page holds and who sees what: section 4, [Docs](#docs).
826
834
  4. **Strip row** (panes of 18 rows or more): the fleet economy line when the opt-in poller runs (section 8), otherwise
827
835
  the other workspaces that need you, e.g. ` ◆1 Worlds · ◇2 Code`; blank when there is nothing to say.
828
836
  5. A rule, then the **footer**: one line, by priority.
@@ -845,7 +853,7 @@ Unregistered section, no upload strip and no `+`, and its footer has no `x close
845
853
  | 35 | `×` close, on unregistered sessions only |
846
854
 
847
855
  Marks and their ASCII twins: `▌` `|` cursor · `›` `=` viewing · `²` `2` / `³` `3` other viewport · `▲` `^` pinned · `·` `.` viewer ·
848
- `…` `~` clipped · `×` `x` close · `+` `+` new / add · `⋮` `:` more rows · `↑` `^` uploads · `↓` `v` drop now ·
856
+ `…` `~` clipped · `×` `x` close · `+` `+` new / add · `⋮` `:` more rows · `↑` `^` uploads · `↓` `v` drop now · `▤` `#` docs ·
849
857
  `⚠` `!` warning or starving · `↓` `v` downgraded · `$` `$` paid. The full tables, colours and mockups are in
850
858
  [DESIGN.md](DESIGN.md).
851
859
 
@@ -863,7 +871,7 @@ draws the same footer (its picker says ` Choose a workspace · Tap Cancel`, and
863
871
  idle). An empty workspace shows `no lanes declared · + New shell` on its New shell row.
864
872
 
865
873
  **Sidebar keys** (`?` shows the short form of this table in place of the list until the next key; that key only closes
866
- the sheet). Its first row says which lanes: `lanes <installed version> · view <the version this sidebar runs>`. The sheet puts companion keys on one line (`J K`, `Tab S-Tab`, `+ U`, `u z`, `p h`, `H o`, `r ?`), leaves `PageUp` / `PageDown`
874
+ the sheet). Its first row says which lanes: `lanes <installed version> · view <the version this sidebar runs>`. The sheet puts companion keys on one line (`J K`, `Tab S-Tab`, `+ U d`, `u z`, `p h`, `H o`, `r ?`), leaves `PageUp` / `PageDown`
867
875
  to this table and ends with `Ctrl-Alt-l` — not a sidebar key but the chord that brings the keyboard here from a lane
868
876
  (section [Keybindings](#keybindings)). Every key below does something in the sidebar, and no other key does
869
877
  (`tests/unit/test_sidebar.py` `DocumentedKeys` presses each one and every other key a keyboard sends):
@@ -891,6 +899,7 @@ to this table and ends with `Ctrl-Alt-l` — not a sidebar key but the chord tha
891
899
  | `H` | Hide this workspace on every device you own: `y` arms, `Enter` hides — its tab closes, its lanes keep running (the last workspace shown stays) |
892
900
  | `o` | Show / hide again the lanes you hid (shown faint) |
893
901
  | `U` | Arm the upload strip |
902
+ | `d` | Show the cursor lane's docs link in the footer for 3 s: `https://<server>:8443/lanes-docs/?ws=<workspace>&lane=<lane id>`, as much as the row holds, the whole line one link to all of it (⌘/Ctrl-click, or your terminal's Copy Link); then the keyboard goes back to the lane. ` no docs in ‘name’` on a lane without documents |
894
903
  | `r` | Reload and repaint; while the footer says `update ready · reload (r)`, rebuild this view on the installed release (the window reconnects, agents are untouched); the keyboard goes back to the lane |
895
904
  | `?` | The key sheet |
896
905
 
@@ -929,7 +938,7 @@ back clears it at once. On the phone the picker is a whole tab and has no such l
929
938
  **A message stays 3 seconds, or until your next action.** What an action says in the footer (` Closed sh`,
930
939
  ` Renamed → …`, ` Cancelled`, ` view not running yet`, or why a close failed) stays after the keyboard has gone back
931
940
  to the lane, for 3 seconds (within a second more: the sidebar looks once a second), then the legend returns. The next
932
- action you start in the sidebar — opening a lane, `x`, `R`, `P`, `a`, `u`, `z`, `/`, `U`, `+`, `r`, `?`, `Tab`, a chord —
941
+ action you start in the sidebar — opening a lane, `x`, `R`, `P`, `a`, `u`, `z`, `/`, `U`, `+`, `r`, `?`, `d`, `Tab`, a chord —
933
942
  replaces it at once. Moving the cursor, typing in a field, `Esc`, the wheel, a mouse release or the focus leaving and
934
943
  coming back does not clear it.
935
944
 
@@ -1220,13 +1229,14 @@ The frontend chooses once per page load, by capability:
1220
1229
  | --- | --- | --- |
1221
1230
  | Phone | An iPhone / iPod / Android phone user agent, or a touch screen at most 600 px wide or 500 px tall | The phone shell: **Workspaces**, **Lanes**, **Panes**, **Type here** and **More** controls; the lane picker |
1222
1231
  | Tablet | Any other touch screen | The same shell with the controls in a top bar and the workspace list as a left panel; the whole tab is shown, so a desktop view's sidebar sits beside its lanes (**More → Single pane** shows one pane for that load) |
1223
- | Desktop | A fine pointer (mouse, trackpad) | The plain Zellij web client plus a **Lanes** chip (top right) that opens workspace and lane pickers in an overlay bar; the terminal keeps its size and every key |
1232
+ | Desktop | A fine pointer (mouse, trackpad) | The plain Zellij web client plus a **Lanes** chip (top right) that opens workspace and lane pickers and a **Docs** link (the docs page in a new tab) in an overlay bar; the terminal keeps its size and every key |
1224
1233
 
1225
1234
  ### Phone controls
1226
1235
 
1227
1236
  - **Workspaces** (or the title in the header) opens the workspace sheet with a search field (each card shows its
1228
1237
  counts, `◆ 1 · ◇ 2`); **Lanes** opens the current workspace's lane picker; **Panes** switches panes; **More** opens
1229
- the client's own menu, where Lanes adds **Text size** (remembered on the phone) and, above it, the server's release
1238
+ the client's own menu, where Lanes adds **Text size** (remembered on the phone), above it **Docs** — the docs page,
1239
+ `/lanes-docs/`, in a new tab, so the terminal stays where it is — and at the top the server's release
1230
1240
  (**Lanes v1.2.0**, see [Versions](#versions)). The light/dark toggle repaints the whole terminal.
1231
1241
  - The keyboard opens only when you tap **Type here** or another input. Swipes, live updates and reconnects leave it
1232
1242
  closed. **Type here** is an ordinary text field: the normal keyboard with its suggestion bar, never the password
@@ -1259,12 +1269,110 @@ The frontend chooses once per page load, by capability:
1259
1269
  on a row while the picker asks cancels the question or the name first, then opens that lane, so nothing is left
1260
1270
  half-asked on the picker when you come back to it.
1261
1271
 
1272
+ ### Docs
1273
+
1274
+ The Docs page is where an agent shows you its work and where you hand files to an agent — every item with a sha256
1275
+ receipt the server computed. There is one per server, `https://<server>:8443/lanes-docs/`: on the tailnet only, and
1276
+ open only to your own tailnet login. No token and no second password: you see your own uploads and what was
1277
+ published for you, and nobody else's documents.
1278
+
1279
+ **Open it.**
1280
+
1281
+ | From | How |
1282
+ | --- | --- |
1283
+ | Phone or tablet | **More → Docs**: the page opens in a new tab, so the terminal stays where it was |
1284
+ | Laptop browser | The **Lanes** chip (top right), then **Docs** in its bar (a new tab) |
1285
+ | Desktop sidebar | The upload strip says ` ↑ uploads · ▤ docs 3 (U)` once the workspace's lanes have documents: ⌘-click (Ghostty, iTerm2) or Ctrl-click it for the page; the cursor row's name is a link to that lane's documents, and `d` shows that link in the footer ([The sidebar](#the-sidebar)) |
1286
+ | A push notification | A `published` push opens the document when the server's `action_url` uses `{doc}` (section 8) |
1287
+ | An address you type, bookmark or open from a terminal | `/lanes-docs/` everything you may see · `/lanes-docs/?ws=<workspace>&lane=<lane id>` one lane (either key alone works) · `/lanes-docs/d/<id>` one document · `/lanes-docs/d/<id>/raw` its bytes · `/lanes-docs/receipt/<id>.json` its receipt |
1288
+
1289
+ Keep the slash: `/lanes-docs` without it is a view's address and opens the terminal page instead.
1290
+
1291
+ **What it shows.** The index lists your documents by workspace, then lane, newest first, with counts, and above them
1292
+ the upload form. A document opens by its kind:
1293
+
1294
+ | Kind | Opens as |
1295
+ | --- | --- |
1296
+ | `md` (`.md`, `.markdown`) | Rendered on the page, with ```` ```mermaid ```` fences drawn as diagrams (a fence Mermaid cannot read stays as its source); over 1 MiB it is not rendered: download it |
1297
+ | `html`, `svg` | A link, **Open the HTML artifact** (or SVG): the artifact on a page of its own, in a sandbox (below) |
1298
+ | `file` (anything else) | Its facts and a download |
1299
+
1300
+ Every document page also has **Download**, **Receipt** and the facts: kind, name, size, workspace, lane, time, who
1301
+ put it there, how it came (`publish`, `web`, `ssh`) and its sha256.
1302
+
1303
+ **Publish from a lane.** An agent publishes with the MCP tool `lanes_publish` (`path`, optional `title`, `for`,
1304
+ `kind`; section 7, [Agent tools (MCP)](#agent-tools-mcp)); in a lane's shell you do it with the `lane` command:
1305
+
1306
+ ```sh
1307
+ ~/.lz/bin/lane publish report.md # the kind follows the extension
1308
+ ~/.lz/bin/lane publish out/queue.svg --title 'Queue design' --for anna@example.com
1309
+ ```
1310
+
1311
+ `lane publish <file> [--title T] [--for <login>] [--kind md|html|svg|file]` takes a file of the container under
1312
+ `/home/coder`, at most 32 MiB, and copies nothing: it hashes the file once, leaves a small request in
1313
+ `~/.lz/state/publish/<lane id>/` and prints it as JSON — the request `id`, `path`, `kind`, `bytes`, `sha256`, the
1314
+ `receipt` path the answer will have, `picked_up_by: "lanes-docsd"` and `expected_within_s: 5`. Within about 5 s the
1315
+ server reads the file out of the container, checks that its sha256 is the one the request named (a file changed in
1316
+ between is refused, not stored), keeps its own copy and writes the receipt beside the request: `outcome: "ok"` with
1317
+ the document's `id`, `sha256`, `bytes` and `url` (`/lanes-docs/d/<id>`), or `outcome: "refused"` with a `reason`
1318
+ (RUNBOOK "Docs service", publish pick-up). The agent sees that answer as `published` in `lanes_status` of its lane.
1319
+ The document is listed under the lane it came from, and the sidebar's ` ▤ docs N` counts it within a few seconds.
1320
+ Change the file only after the receipt is there; publishing it again makes a new document.
1321
+
1322
+ **Who sees what.**
1323
+
1324
+ - An upload: your login alone.
1325
+ - A published document: the login `--for` names (it must be a member's); without `--for`, the person who last opened
1326
+ that lane, on any of their devices; when nobody has, everyone whose member record grants the lane's workspace. A
1327
+ record that lists no workspace grants every workspace the server serves.
1328
+ - Anyone else gets *not found*, exactly as for a document that does not exist. The one exception is the count:
1329
+ everyone who sees a lane's ` ▤ docs N` sees how many documents it holds (a number, never a title).
1330
+
1331
+ **Upload, with a receipt.** On the Docs page pick the lane, drop files on the dashed area — or tap it to choose them,
1332
+ several at once, 32 MiB in all — and **Upload**. Each file lands in that workspace's `uploads/` folder in its
1333
+ container, exactly as every other upload does ([Uploads](#5-uploads)), and the page lists its receipt: the name (a
1334
+ link to the document), size, sha256 and where it landed (`/home/coder/<repo>/uploads/<name>`) — give the agent that
1335
+ path. Every other way in (the sidebar's strip, the watch folder, `lanes-upload`, the macOS droplet) records the same
1336
+ receipt for you (section 5, "Receipts").
1337
+
1338
+ **The sandbox.** Nothing an agent publishes is trusted. Markdown is rendered on the server by Lanes's own renderer:
1339
+ raw HTML in it stays text, a link opens in a new tab and tells the target nothing of where it came from, and an image
1340
+ shows only from the Docs pages themselves or inline. An HTML or SVG artifact is never shown inside a Docs page:
1341
+ **Open the … artifact** loads it as a page of its own under a browser sandbox, where its scripts run on an opaque
1342
+ origin of their own — they cannot read the Docs pages, cannot reach your terminal session or its sign-in, and cannot
1343
+ type into a lane. No Docs page can be framed by another page. That is how the server serves it (unit-tested); a check
1344
+ in a real browser is the operator's (RUNBOOK checklist; DEF-1853).
1345
+
1346
+ **Encrypted at rest.** Off by default. With the operator's `age` switch on, the server keeps new store copies
1347
+ encrypted; your receipts stay the sha256 of what you sent and the files in `uploads/` stay plain for the agents
1348
+ (section 5, "Encrypted at rest").
1349
+
1350
+ **For the administrator.**
1351
+
1352
+ - The full server install, `npx @vibelord/lanes@<release> --server`, enables the docs service (`lanes-docsd`) and
1353
+ installs the page's pinned Mermaid (`install/enable-docs.sh`). An update with `--update-only` enables nothing, so a
1354
+ server set up before the docs service needs one full install, or `systemctl --user enable --now
1355
+ lanes-docsd.service`. `lanes install-container <name>` gives a container `lane publish` and the `lanes_publish`
1356
+ tool (new agent sessions load it).
1357
+ - **The pin gate.** The Docs page answers nobody until the Serve header check has run and its result is pinned with
1358
+ `lanes enrol mode request` (RUNBOOK "Enrolment service", Operator verification). Until then every request is
1359
+ refused `not-pinned`.
1360
+ - `lanes doctor` prints five docs lines (section 6): the service answering on its loopback port, the pin gate, the
1361
+ store's size, the Mermaid pin and the `age` switch.
1362
+ - `lanes docs push <id>` prints one document's receipt and exits 2, `not configured`: the hook for pushing documents
1363
+ to the data service's publication surface, which is not built yet.
1364
+ - The unit, `docs.log`, the routes, the limits and the operator's live checklist:
1365
+ [RUNBOOK](RUNBOOK.md#docs-service-lanes-docsd-w10-w3).
1366
+
1367
+ Not yet verified on a device: the Docs page on a real phone (the rendering, a Mermaid diagram, the drop area), the
1368
+ artifact sandbox in a real browser (DEF-1853), and the Mermaid route through a running Caddy (DEF-1871).
1369
+
1262
1370
  ### What works, what does not, what is unverified
1263
1371
 
1264
1372
  - Works: everything the desktop view shows, through the same Zellij sessions; the phone has its own view selection of
1265
- the same agents; the server keeps serving when your Mac sleeps.
1266
- - Does not: a public URL (Tailscale only), uploads from a phone browser (the drop helper is a desktop client feature),
1267
- approving an agent's permission prompt from a push notification.
1373
+ the same agents; the server keeps serving when your Mac sleeps; uploads from the phone and what agents publish, on
1374
+ the Docs page (**More → Docs**, [Docs](#docs)).
1375
+ - Does not: a public URL (Tailscale only), approving an agent's permission prompt from a push notification.
1268
1376
  - Not yet verified on a device: the quick keys, the lane filter, the text keyboard, paste, **Copy** and **Paste** on a
1269
1377
  real iPhone (the [runbook](RUNBOOK.md) lists the check), the light/dark terminal repaint, the counts that the
1270
1378
  picker's pane title carries to the Workspaces sheet and the **Lanes** button (a phone view created before that
@@ -1274,7 +1382,10 @@ The frontend chooses once per page load, by capability:
1274
1382
  ## 5. Uploads
1275
1383
 
1276
1384
  Files land in the workspace's `uploads/` folder in its container, the same folder Cursor users drop files into. The
1277
- in-container path ends up on your clipboard and in a notification, and can be typed into the lane for the agent.
1385
+ in-container path ends up on your clipboard and in a notification, and can be typed into the lane for the agent. Every
1386
+ upload, whichever way it came, also gets a **receipt**: its sha256 computed on the server as the bytes arrived, kept
1387
+ with a copy in the docs store, so it shows on the Docs page (`/lanes-docs/`, section 4, [Docs](#docs)) and in the
1388
+ sidebar's ` ▤ docs N` count.
1278
1389
 
1279
1390
  | Way | How |
1280
1391
  | --- | --- |
@@ -1283,12 +1394,41 @@ in-container path ends up on your clipboard and in a notification, and can be ty
1283
1394
  | Command | `lanes-upload [-w <workspace>] [--paste] <files…>` · `lanes-upload --clipboard [-w <workspace>] [--paste]` (clipboard image or copied files) |
1284
1395
  | macOS droplet | `~/Applications/Lanes Upload.app`: drop files on it; double-click uploads the clipboard screenshot |
1285
1396
  | Omarchy | `Super+Shift+U` uploads the clipboard and types the path into the lane |
1397
+ | Docs page (phone, laptop) | **More → Docs** on the phone, **Docs** in the laptop bar, or `https://<server>:8443/lanes-docs/`: pick the lane, then drop files on the dashed area or tap it to choose them (several at once, 32 MiB in all), **Upload**. Each file's receipt is listed: its name (a link to the document), size, sha256 and where it landed in the container (`/home/coder/<repo>/uploads/<name>`) — tell the agent that path |
1286
1398
  | Server | `lanes upload <workspace> \| --active <device> [--paste]` reads a tar stream on stdin |
1287
1399
 
1288
1400
  **Where files land.** `<workspace folder>/uploads/<name>` in the container, where the workspace folder is its first
1289
1401
  lane's repository folder (else the workspace's working directory). An existing name is never overwritten: the next
1290
- one becomes `<name>-1.<ext>`. The folder is created on first use and, in a git repository that does not ignore it
1291
- yet, added to the repository-local `.git/info/exclude`, so uploads are never committed.
1402
+ one becomes `<name>-1.<ext>`. The folder is created on first use and, in a git repository whose `.git/info/exclude`
1403
+ does not name it yet, added there, so uploads are never committed. The Docs page lands a file exactly the same way
1404
+ (its name keeps only letters, digits, `.`, `_` and `-`). The server refuses to follow a symbolic link anywhere on the
1405
+ way, so an agent cannot redirect an upload outside its container: a folder reached through a link answers
1406
+ `uploads/ … not a directory (a symbolic link is refused)` (or, on the Docs page, *cannot be written now*).
1407
+
1408
+ **Receipts.** Each file is also a document in the docs store with a receipt — `sha256`, size, time and its page
1409
+ `/lanes-docs/d/<id>` — for you only (`for` your login): the Docs page lists it under its workspace and lane, and the
1410
+ sidebar counts it. `lanes upload` prints one `# docs: <name> <size> B sha256 <hash> <id>` line per file on stderr (the
1411
+ helper's log, or your terminal with `lanes-upload`). The kind follows the extension: `.md` opens rendered (with Mermaid
1412
+ diagrams), `.html`/`.svg` open as sandboxed artifacts, anything else as a download. The store keeps its own copy, so
1413
+ an agent editing its `uploads/` file never changes what the receipt names. What gets no receipt, though it still
1414
+ lands: a file over 32 MiB sent with `lanes upload` (the store keeps at most 32 MiB a file), a device no member record
1415
+ owns (the server logs its hash in `docs.log`), and files past the 64th of one `lanes upload`. A file name taken in only
1416
+ one of the two places (an agent's own file in `uploads/`, or an earlier upload you removed from the store) can get
1417
+ `-1` in one and not the other; the receipt names both.
1418
+
1419
+ **Encrypted at rest (optional).** The operator can have the server keep its docs-store copies encrypted with
1420
+ [`age`](https://age-encryption.org) (`LANES_DOCS_AGE_RECIPIENT`, [RUNBOOK](RUNBOOK.md#docs-service-lanes-docsd-w10-w3)).
1421
+ Nothing changes in `uploads/` — the agent reads plain files there — and receipts stay the hash of what you sent. When
1422
+ the docs service also holds the key, documents open as usual; when it does not, a download answers *encrypted at
1423
+ rest* and you fetch the file from the workspace's `uploads/` folder instead.
1424
+
1425
+ **Opening the Docs page from elsewhere.** Any link to a document opens it: the shell's **Docs** link, the sidebar's
1426
+ links, a notification, the address bar, and a link on another website — a web mail, a chat in the browser, ntfy's web
1427
+ app. What the docs service refuses from another website is everything that is not you following a link: a page's own
1428
+ background requests, images and scripts, and an upload — so an HTML artifact cannot read anything of yours, and only
1429
+ the Docs page itself uploads. A browser too old to say where a request came from sees *Refused: this request came from
1430
+ another site*. Viewing very quickly (over 300 pages a minute) answers *Too many requests* for a
1431
+ minute; an upload that trickles in slower than about 64 KiB/s is stopped and says so, with nothing stored.
1292
1432
 
1293
1433
  **Why click first.** A terminal delivers a drop as a paste of the local path into the focused pane, so the strip must
1294
1434
  hold the focus when you let go. The server receives only the path; the **upload helper** on your machine sends the
@@ -1516,10 +1656,15 @@ Names must be non-empty and free of control characters. The id, resolve file and
1516
1656
  present (`✓`), missing or pointing elsewhere (`✗` once a web unit is enabled, `⚠` before), or not checked (`⚠`: no
1517
1657
  tailscale, no answer, or no Tailscale name on the host — then `lanes web` configures no Serve and prints a loopback
1518
1658
  URL, so doctor does not ask for the mapping either).
1659
+ - The docs service (W10): `lanes-docsd` enabled, active and answering on its loopback port (`✓`; `⚠` not enabled; `✗`
1660
+ enabled but not running, or not answering), the pin gate (`✓ docs: pinned`, else `⚠ docs: refuses until the Serve
1661
+ header check is pinned`), the store (documents, bytes, `docs.log`'s size), the pinned Mermaid (`✓`, `⚠` not installed
1662
+ — `install/enable-docs.sh`, which every server install and update runs —, `✗` a file that is not the pinned build) and
1663
+ the `age` switch (`✗` when it is set without an `age` binary); RUNBOOK "Docs service" has every line.
1519
1664
  - The release that runs, against `~/.local/share/lanes/current` (the tree an install makes current; without the link,
1520
1665
  a `⚠` and this `lanes`'s own tree): the link names a tree that exists, whose `VERSION` agrees with its
1521
1666
  `pkg/<version>` name and its npm stamp, and that this `lanes` runs (`✗` with the fix: run the current one, or re-run
1522
- the server installer). Every lanes process running now (statusd, enrold, notify, economy, a sidebar — found in the
1667
+ the server installer). Every lanes process running now (statusd, enrold, docsd, notify, economy, a sidebar — found in the
1523
1668
  same process-table sweep by the command Lanes starts it with, `python3 -S -E <tree>/libexec/<script>`, so a file
1524
1669
  open in an editor or a one-shot such as `notify.py --test` is not one) has a [run record](#versions) of the current
1525
1670
  code (`✗ … statusd runs 1.1.0`, or `statusd runs without a run record (started by an older release)`, then
@@ -1655,6 +1800,7 @@ verbs, so the holder stays swappable.
1655
1800
  | `lane new <id> [--ws W] [--title T] [--cwd DIR] [--icon I] [--color C] -- <command…>` | Create a zmx-held lane |
1656
1801
  | `lane gc --sockets` | Unlink this container's tmux socket files that tmux is refused on (no server listens); a live, empty or silent server keeps its file |
1657
1802
  | `lane mcp install [--agent claude\|codex\|all] [--write]` | Add the Lanes MCP server to `~/.claude.json` and `~/.codex/config.toml` ("Agent tools (MCP)" below); a dry run until `--write` |
1803
+ | `lane publish <file> [--title T] [--for <login>] [--kind md\|html\|svg\|file]` | Put a file of this container on the docs page from the lane it runs in ("Publish to the docs page" below); prints the request as JSON |
1658
1804
 
1659
1805
  Every tmux client these verbs start runs with `-N`, so none of them can start a tmux server: an attach to a lane
1660
1806
  whose server has just ended fails instead of leaving an empty server and its socket file behind.
@@ -1675,6 +1821,18 @@ file is written in place (it keeps its mode and owner), swap and backup files go
1675
1821
  beside the file, modelines are off, a file without a final newline keeps none, and no `~/.viminfo` is written.
1676
1822
  Everything it creates is private (`umask 077`). Quitting the editor closes the pane.
1677
1823
 
1824
+ ### Publish to the docs page
1825
+
1826
+ An agent (or you, in a lane's shell) puts a file on the [Docs page](#docs) with `~/.lz/bin/lane publish <file>` or
1827
+ the MCP tool `lanes_publish`: Markdown with Mermaid diagrams is rendered there, an HTML or SVG artifact opens
1828
+ sandboxed on its own page, and any other file is a download, each with the sha256 the server computed. The verb writes
1829
+ a small request in `~/.lz/state/publish/<lane>/` and copies nothing; within about 5 s the docs service on the server
1830
+ reads the file from the container, checks its hash against the request's (a file changed in between is refused, not
1831
+ stored) and writes `<id>.receipt.json` beside the request — `lanes_status` shows it as `published`, with the
1832
+ document's link. The file must be under `/home/coder` and at most 32 MiB. Who sees it (`--for`, else the person who
1833
+ last opened the lane, else everyone whose member record grants its workspace), the receipt's fields and how each kind
1834
+ opens: section 4, [Docs](#docs).
1835
+
1678
1836
  ### Agent tools (MCP)
1679
1837
 
1680
1838
  `lane-mcp` is an MCP server in each container (`~/.lz/bin/lane-mcp`). Claude Code and Codex start it from their own
@@ -1691,7 +1849,7 @@ only read:
1691
1849
  Where the answers come from: `~/.lz/registry.json`, which `lanes sync` writes, and `~/.lz/status.json`, which the
1692
1850
  status daemon writes. A container that has neither yet answers with empty lists and says why.
1693
1851
 
1694
- Six tools write. Each reaches the lanes of its own container only, and answers what it changed, what picks the
1852
+ Seven tools write. Each reaches the lanes of its own container only, and answers what it changed, what picks the
1695
1853
  change up (`picked_up_by`) and how soon (`expected_within_s`):
1696
1854
 
1697
1855
  | Tool | Changes | Seen on the boards |
@@ -1701,6 +1859,7 @@ change up (`picked_up_by`) and how soon (`expected_within_s`):
1701
1859
  | `lanes_rename` | Sets the lane's `displayName` in its terminals.json; the id never changes. The lane's own title removes it. A name the host sets for the lane in its workspace manifest still wins, and the answer says so (`host_override`) | same |
1702
1860
  | `lanes_new` | `name`, optional `cwd`, `command`, `workspace`, `repo`. Declares a lane on the board's shell socket `lanes-shells-<workspace>` and starts its session (detached, 100x30) with the lane's identity; the workspace defaults to the agent's own board, the folder to the repository's, the command to `bash -l`. When that starts the board's shell server, the server gets the account and locale variables (and `TMUX_TMPDIR`), never the agent's own environment, which every later shell on the board would inherit | the session at once; the lane after the next `lanes sync` |
1703
1861
  | `lanes_send` | `lane`, `text`, optional `enter` (default true), `force`. Types printable text into the lane, then Enter half a second later as a separate keystroke, so an agent's prompt submits it instead of taking it as a paste | at once |
1862
+ | `lanes_publish` | `path` (an absolute path under `/home/coder`), optional `title`, `for` (a login), `kind` (`md`, `html`, `svg`, `file`; default from the extension). Writes a publish request for the file from the agent's own lane ("Publish to the docs page" below); the docs service reads the file itself and answers with a receipt that `lanes_status` shows as `published` | on the docs page within about 5 s |
1704
1863
  | `lanes_signal` | `status` (the agent words: `working`, `needs_input`, `needs_approval`, `done`, `errored`, `shell`, `idle`), optional `note`. Reports the agent's own status through `lane-signal`; the note (one line, at most 120 characters) is shown as the lane's task line. The report stands until the agent does something new: its next tool call, a new prompt, a permission prompt, a failed turn or the session's end. The end of its turn keeps it (a `working` report ends there), so an agent reports `needs_input`, `done` or `errored` as the last thing before it stops. A `working` note stays while the agent keeps working | on the next status cycle, within about 5 s |
1705
1864
 
1706
1865
  What they refuse. A refusal is `isError` with `{"refused": <code>, "why", "human"}`: `human` says what a person can
@@ -1715,7 +1874,10 @@ refuses an unregistered session (register it first) and a name with a control ch
1715
1874
  folder outside `/home/coder` or one that does not exist (`invalid_cwd`), a name or session in use and a command with
1716
1875
  a control character. `lanes_send` types nothing while the lane's agent is working or waiting for an approval
1717
1876
  (`agent_working`, `agent_needs_approval`) unless `force` is true, and never a control character: no newline, tab,
1718
- escape or Ctrl-C (`invalid_text`). Every tool refuses a lane of another container (`not_in_container`). The edits go
1877
+ escape or Ctrl-C (`invalid_text`). `lanes_publish` refuses a file outside `/home/coder` once every link is resolved
1878
+ (`outside_home`), anything but a regular file (`not_a_file`), a file over 32 MiB (`too_large`), a title or login that
1879
+ is not one printable line (`invalid_title`, `invalid_for`), and an agent outside a lane (`not_in_lane`). Every tool
1880
+ refuses a lane of another container (`not_in_container`). The edits go
1719
1881
  through the same minimal editor as everything else here: one span of strict JSON, the file re-read just before the
1720
1882
  atomic write, never the root `throttle` object, never a rename or reorder of terminals. The file is the
1721
1883
  repository's, so its history is git's: no backup file is written.
@@ -1845,7 +2007,7 @@ you configure, so enabling it is your decision. Not yet run against a real ntfy
1845
2007
  | `topic` | required | `[A-Za-z0-9_-]{1,64}`; on a public server the topic name is the only secret |
1846
2008
  | `token_file` | unset | A file holding an ntfy access token (an unreadable file disables sending; written, rotated or made readable later, it is picked up within 3 s) |
1847
2009
  | `quiet_when_device_active_s` | `120` | Presence window; `0` never suppresses — except for the one person present when their profile sets a `notify.quiet_s` of its own |
1848
- | `action_url` | unset | A URL template over `{lane}`; becomes the tap target and an **Open lane** button |
2010
+ | `action_url` | unset | A URL template over `{lane}`; becomes the tap target and an **Open lane** button. `{doc}` is the docs page of the document a `published` push names (`/lanes-docs/d/<id>`, so `https://<server>:8443{doc}` opens it) and empty in a needs-you push. The docs service sends a `published` push (lane name, kind and size, never the title) when it stores a document an agent published, at most one per lane per minute, muted like the others while you are at a desk |
1849
2011
 
1850
2012
  Nothing is sent when the status did not change, when the service just started (no burst on restart), or when a desk
1851
2013
  device was present within the presence window: a `lanes attach` from any device, or a lane opened from a desk
@@ -1975,6 +2137,7 @@ All units run in `lanes.slice` and are rendered by the server installer from `la
1975
2137
  | `lanes-gc.timer` / `.service` | Daily | Server install | `lanes gc`: truncate zmx logs over 5 MB, drop stale dynamic resolve files, prune old npm-staged trees — each folder keeps its 3 newest and every older tree `current`, `current.previous`, a view layout, a running daemon's run record or a process of this account uses; none when `/proc` cannot be read ([INSTALL](INSTALL.md#6-updates-optional-operations-and-removal)) |
1976
2138
  | `lanes-update.timer` / `.service` | 15 min after boot, then every 30 min (+0–5 min) | Server install (the full one: `--update-only` enables nothing — `systemctl --user enable --now lanes-update.timer` on a server set up before 1.2.0) | `lanes update --timer`: install the newest npm release when it is newer ([Update](#update)); a checkout server is left alone; stopped after 1 h |
1977
2139
  | `lanes-enrold.service` | Always on | Server install (enabled and restarted by every one) | Enrolment service on `127.0.0.1:8184`, reached as `https://<host>:8443/enrol/*`: invite redemption, enrolment requests, `/enrol/pending` approvals ([RUNBOOK](RUNBOOK.md#enrolment-service-lanes-enrold-w8-a3)) |
2140
+ | `lanes-docsd.service` | Always on | Server install (enabled and restarted by every one) | Docs service on `127.0.0.1:8185`, reached as `https://<host>:8443/lanes-docs/`: login-bound documents, sandboxed artifacts and uploads with receipts; refuses every request until `lanes enrol mode request` is pinned ([RUNBOOK](RUNBOOK.md#docs-service-lanes-docsd-w10-w3)) |
1978
2141
  | `lanes-web.service` | Always on | `enable-web.sh` (via `lanes web` / `enable-phone.sh`) | Zellij web server on `127.0.0.1:8082` |
1979
2142
  | `lanes-mobile.service` | Always on | `enable-web.sh` | Caddy frontend on `127.0.0.1:8083` |
1980
2143
  | `lanes-studio-host.service` | Always on | `enable-studio-host.sh` (opt-in) | The co-located Throttle Studio Host |
@@ -2027,6 +2190,7 @@ Clients also get `lanes-helper.service` and `lanes-upload.path`/`.service` (Linu
2027
2190
  | Containers | `lanes onboard <login> --container <name> [--workspace <id> --repo <path> [--title T]] [--write] [--no-mcp]` | One user's container in one command: board, contract, Claude and Codex hooks, Throttle attachment, canonical recipes, sync, doctor, and a receipt (a dry run until `--write`; section 2) |
2028
2191
  | Containers | `lanes hooks install --agent claude\|codex (--container <name> \| --home <dir>) [--dry-run \| --write]` | Section 7 |
2029
2192
  | Uploads | `lanes upload <ws> \| --active <device> [--paste]` | Read a tar stream into that workspace's `uploads/` |
2193
+ | Uploads | `lanes docs push <id>` | Print one docs-store document's receipt (any login's: you are the operator) and exit 2 `not configured`: the hook for the data service's publication surface, not built yet ([Docs](#docs)) |
2030
2194
  | Client | `lanes hello [device] [--client-version V]` | Prove path, key and server version; ` update=<version>` when V is older |
2031
2195
  | Client | `lanes client-config` | Site facts for clients (`LANES_*` lines from `~/.config/lanes/site.conf`) |
2032
2196
  | Access | `lanes keys list \| add <device> <pubkey-file\|-> \| revoke <device> \| adopt [--dry-run] [--login LOGIN]` | Who may log in: the lanes-managed block of `~/.ssh/authorized_keys`, projected from `members/`; `--login` names the owner of every key adopt moves in and folds their views into that profile |
@@ -2073,7 +2237,7 @@ Server (`~/.local/state/lanes/`, `$LANES_STATE`):
2073
2237
  | --- | --- |
2074
2238
  | `registry.json`, `registry.synced`, `resolve/`, `resolve.dynamic/` | The published registry, the last sync time, per-lane targets |
2075
2239
  | `status.json`, `health` | statusd's snapshot and the one-line health record |
2076
- | `run/<name>.tree` | The tree each long-running process started from — `statusd`, `enrold`, `economy`, `notify`, `studio_host` (the enable step its enrolment timer runs) and `sidebar` (the most recently started one): one resolved path, rewritten at every start, so a record that differs from where `~/.local/share/lanes/current` points is a process still running an older version |
2240
+ | `run/<name>.tree` | The tree each long-running process started from — `statusd`, `enrold`, `docsd`, `economy`, `notify`, `studio_host` (the enable step its enrolment timer runs) and `sidebar` (the most recently started one): one resolved path, rewritten at every start, so a record that differs from where `~/.local/share/lanes/current` points is a process still running an older version |
2077
2241
  | `containers.json`, `containers/` | Container init pids and home volumes |
2078
2242
  | `devices/<device>.json` | Each known device: layout, viewports, base device, owner, look (colour, theme, glyphs, dim; `device_only` lists the keys `--device-only` kept for this device alone), desktop notifications on/off and the quick-edit editor |
2079
2243
  | `layouts/<device>.kdl` | The layout a view session was built from |
@@ -2095,7 +2259,9 @@ Server (`~/.local/state/lanes/`, `$LANES_STATE`):
2095
2259
  | `invites/<id>.json` | Invites minted by `lanes invite`: the code's hash only (never the code), who minted it, expiry, invitee, and the redemption or revocation |
2096
2260
  | `enrol-requests/<id>.json` | Enrolment requests waiting for, or past, a member's approval (24 h) |
2097
2261
  | `enrol.log` | The enrolment audit log, one line per attempt, invite, revocation, offboard and mode change (0600, append-only; never a code) |
2098
- | `enrol-mode` | Request mode as `lanes enrol mode request\|invite` pinned it (0600; absent = auto); the enrolment service reads it on every request |
2262
+ | `enrol-mode` | Request mode as `lanes enrol mode request\|invite` pinned it (0600; absent = auto); the enrolment service and the docs service read it on every request (docsd serves only while it says `request`) |
2263
+ | `docs/<login>/<ws>/<lane>/<id>/`, `docs/counts.json` | The docs store: each document with its `meta.json` and sha256 `receipt.json` (0700/0600; `<name>.age` when encrypted at rest); counts per lane. Written by every upload (the Docs page and `lanes upload`) and by the docs service's publish pick-up (`lane publish`, `lanes_publish`), read by the docs service ([RUNBOOK](RUNBOOK.md#docs-service-lanes-docsd-w10-w3)) |
2264
+ | `docs.log` | The docs audit log: one line per intake, removal, read, upload refusal, publish request and docs-service denial (0600, append-only; never content, a title or a name) |
2099
2265
  | `web-tokens.json`, `web-tokens.lock` | Which view — and which machine — each web login token `lanes web --token` minted was for (names and creation times only, never a token; `lanes offboard` signs a machine out by it) and the lock every mint and revocation holds (section 4, "Tokens") |
2100
2266
  | `onboard/<login>-<UTC>.json` | One receipt per `lanes onboard` run: each step with its status (section 7) |
2101
2267
  | `update/last.json`, `update/last.log`, `update/.lock` | The last `install.sh --server --update-only` run: `{checked_at, latest, installed, previous, result, steps: [{name, rc}], log}` with `result` `ok`, `partial`, `refused` or `failed` (a step whose exit status is a warning — `lanes sync` reporting a board with an error — carries `"warning": true`; keys it does not own are kept), and that run's full output; the run lock every server install and update holds while it runs (`{pid, mode, started}` of the last one to take it). `lanes update` adds `check` — its last check, `{at, by: timer\|hand, channel, tag, latest, installed, outcome, said[, exit]}`, `outcome` one of `current`, `older`, `bad`, `unanswered`, `refused`, `installing`, `installed`, `failed` — `bad` (the release a rollback left, written with the record of its flip, before its post-commit) and `rerun` (the release whose post-commit it ran again; a rollback drops it); a rollback's run is recorded with the step `rollback` first, `partial` until its post-commit has run |
@@ -2105,7 +2271,8 @@ Server (`~/.local/state/lanes/`, `$LANES_STATE`):
2105
2271
  Other server paths: `~/.config/lanes/` (`workspaces/`, `site.conf`, `notify.conf`, `update.conf` — `channel=stable`
2106
2272
  makes `lanes update` follow the `lanes-stable` dist-tag — and the optional invite key files
2107
2273
  `resend.key` and `tailscale-api.key`), `~/.local/share/lanes/` (pinned
2108
- binaries in `bin/`, the `current` link to the installed tree — the path units and layouts name —,
2274
+ binaries in `bin/`, the docs page's pinned Mermaid in `vendor/` — `install/enable-docs.sh`, which every server install and update runs —, the `current`
2275
+ link to the installed tree — the path units and layouts name —,
2109
2276
  `current.previous`, the tree it named before the last install that moved it, and `.install.lock`, the lock installs
2110
2277
  and unit re-renders take turns on; npm-staged `pkg/<version>/`), and
2111
2278
  `~/.lz/<container>` (links to each
@@ -2193,12 +2360,18 @@ Server and views:
2193
2360
  | `LANES_ENROL_PORT` | `libexec/enrold.py` | `8184` | The enrolment service's loopback port (the Caddyfile's `/enrol/*` route must follow) |
2194
2361
  | `LANES_ENROL_DOMAINS` | `libexec/enrold.py` | `beyondwork.ai` | Login domains that may file an access request or approve one on `/enrol/pending` (comma or space separated; `*` = any); invites are not gated |
2195
2362
  | `LANES_ENROL_REQUEST_MODE` | `libexec/enrold.py` | unset | `1` forces request mode on, `0` forces invite-only, over any `lanes enrol mode` pin; unset, the pin decides, and without one the first request through Tailscale Serve after a start (RUNBOOK "Enrolment service") |
2363
+ | `LANES_DOCS_PORT` | `libexec/docsd.py` | `8185` | The docs service's loopback port (the Caddyfile's `/lanes-docs/*` route must follow) |
2364
+ | `LANES_DOCS_DOMAINS` | `libexec/docsd.py` | `LANES_ENROL_DOMAINS`, else `beyondwork.ai` | Login domains the docs service answers (comma or space separated; `*` = any) |
2365
+ | `LANES_DOCS_AGE_RECIPIENT` | `libexec/docs_store.py` (the docs service and `lanes upload`) | unset (off) | Keep new docs-store copies as `age -r <recipient>` output; the server install refuses it without an `age` binary ([RUNBOOK](RUNBOOK.md#docs-service-lanes-docsd-w10-w3)) |
2366
+ | `LANES_DOCS_AGE_IDENTITY` | `libexec/docsd.py` | unset | The `age` identity file the docs service decrypts with; without it an encrypted document's download answers 409 |
2196
2367
  | `LANES_DEVICE` | `libexec/enrol.py` (`lanes invite`) | unset | Recorded as `minted_by.device` of an invite when it is a device name (a claim, not an identity) |
2197
2368
  | `SSH_CLIENT` | `libexec/enrol.py` (`lanes enrol approve\|deny`) | set by sshd | Its address is recorded in the approver `cli[:<device>]@<address>`; `local` without one |
2198
2369
  | `LANES_TESTS_FORCE` | `tests/guards.py` | unset | `1` runs live suites on a busy host (on purpose only) |
2199
2370
 
2200
2371
  `LANES_THROTTLE_*` and `LANES_NOTIFY_CONF` set in the installer's environment are rendered into their units only. The
2201
- `LANES_ENROL_*` settings go in a drop-in, `systemctl --user edit lanes-enrold.service`, which a re-install keeps.
2372
+ `LANES_ENROL_*` settings go in a drop-in, `systemctl --user edit lanes-enrold.service`, which a re-install keeps; the
2373
+ `LANES_DOCS_*` settings in lanes-docsd's, `systemctl --user edit lanes-docsd.service` (`lanes upload` reads the age
2374
+ switch from the same files).
2202
2375
  `LANES_REPO`, `LANES_ZELLIJ` and `LANES_WS` are set by Lanes itself for its units and panes.
2203
2376
 
2204
2377
  Client:
@@ -2248,6 +2421,7 @@ Container:
2248
2421
  | `lanes preview` / `next` / `prev` / `edit` without `--focused` | `2` |
2249
2422
  | `lanes edit --focused` | the editor's own status once a file is picked (`docker exec` passes on `lane-edit`'s) · `0` closed with `q`/`Esc`, nothing to edit, or another Edit pane in this tab now has the focus · `1` another Edit pane is open in another tab (or zellij would not focus it), zellij did not answer, a lane that is not a tmux lane, a container that is not running or has no `lane-edit`, no git checkout, docker could not be started, or another error |
2250
2423
  | `lanes version` | `0` (a stale process is shown, not an exit status) · `2` an argument other than `--json` |
2424
+ | `lanes docs push <id>` | `2` not configured (the receipt printed, nothing sent) · `1` no document with that id, an id that is not one, or another argument (the usage) |
2251
2425
  | `lanes update` | `0` nothing to do (up to date, not due, an older or bad release) or installed `ok` · `4` partial (the install's, or a post-commit run again) · `3` refused, nothing done: another update or install is running, a checkout server, no installed release, a bad `update.conf`, no release to roll back to — under `--timer` these are `0` · `1` failed: the registry gave no release, no usable recorded npx, the install failed or recorded nothing · `2` usage; `--status`: `0`, or `1` when nothing is recorded yet |
2252
2426
  | `lanes studio-host status` | `0` only when running enrolled; `1` otherwise; `2` usage |
2253
2427
  | `lanes invite <name>` | `0` minted (the kit printed; a failed email or Tailscale mint is a note, not an error) · `2` not on a terminal, nothing minted · `1` error |
@@ -2256,9 +2430,11 @@ Container:
2256
2430
  | `enable-studio-host.sh` | `0` running or waiting for enrolment · `2` usage · `3` typed refusal · `1` failure |
2257
2431
  | `enable-phone.sh` | `0` · `2` usage · `64` not in your own terminal, Tailscale Serve refused, or the running `lanes-web` reads another token database (from `lanes web`) · `1` a `lanes web` error |
2258
2432
  | `enable-web.sh` | `0` · `2` usage · `1` Caddy missing |
2433
+ | `enable-docs.sh` | `0` the pinned Mermaid installed or already there · `1` a prerequisite or the download failed · `2` usage · `3` refused (no valid pin, a digest mismatch, the member missing): nothing installed |
2259
2434
  | `lanes-launch` | `64` invalid name or option (it never exits on its own otherwise); server exits 64–78 are refusals it retries after 60 s |
2260
2435
  | `lanes-window` | `64` invalid suffix, or `--device-only` without `--ascii`/`--light`/`--dark` · `69` no Ghostty (macOS) or neither Ghostty nor Foot (Linux) |
2261
2436
  | `lanes-upload` | `64` usage · `66` no such file or empty clipboard · otherwise the transfer's status |
2262
2437
  | `lane status` | `1` when the lane is not running |
2438
+ | `lane publish <file>` | `0` the request is written (the receipt follows within about 5 s) · `1` refused or a usage error (one line on stderr) |
2263
2439
  | `lane-edit <file>` (container) | the editor's own status · `2` usage · `127` no editor on PATH; on a terminal both refusals wait for `Enter` first |
2264
2440
  | Live test suites (`tests/*.py`) | `75` host busy, not started |
@@ -16,3 +16,8 @@ glow 3.0.0 13e05e4b2acc18d2aee44291aefe6325b077ec321b631a0cfa780e8e3bc33f78 http
16
16
  # a locally built binary with `lanes studio-host install --from PATH [--sha256 HEX]` (also sha256-verified).
17
17
  # Template (fill from the release's two .sha256 sidecars, then uncomment):
18
18
  # throttle-rc node-<sha12> <tarball-sha256> https://github.com/bwllaming/bw-tools/releases/download/node/<sha12>/throttle-rc-x86_64-unknown-linux-gnu.tar.gz opt-in throttle-rc-x86_64-unknown-linux-gnu/throttle-rc <binary-sha256>
19
+ # mermaid 11.17.2 (the docs page's diagrams, plan W10 W6, LNS-107): the npm tarball, 17,796,365 B, whose sha512 is the
20
+ # registry's dist.integrity; the member is the classic-script (IIFE) build, 3,572,661 B, that web/docs.js loads with
21
+ # this digest as its SRI. Both sha256s measured 2026-10-08. install/enable-docs.sh installs it into
22
+ # ~/.local/share/lanes/vendor/; to bump it see docs/RUNBOOK.md "Mermaid pin".
23
+ mermaid 11.17.2 6ad2f42c3fc26bbf9e45cbb6d11898972573ea52b33a5f4ff51952899f950ffd https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz opt-in package/dist/mermaid.min.js 581ed7d74bd9048d0e3a91363927d72ef22942d7722546b27f7cc29e35390eb8
@@ -0,0 +1,78 @@
1
+ #!/bin/sh
2
+ # The docs page's pinned Mermaid (plan W10 W6, LNS-107; docs/RUNBOOK.md "Mermaid pin"). Every server install runs it
3
+ # right after lanes-docsd's restart and every update (--update-only) as the post-commit step `enable-docs` (W8, LNS-109:
4
+ # host-install.sh, libexec/update.py DOCS_STEP), never fatally; it runs by hand as well. As the workspace account, never
5
+ # root. Reads holders.lock's `mermaid` opt-in pin, downloads its npm tarball into a temporary
6
+ # directory, checks the tarball's sha256, extracts ONLY the pinned member (never a link), checks the member's sha256,
7
+ # and installs it as $LANES_SHARE/vendor/mermaid-<version>.min.js (0644, by rename) with the relative link
8
+ # mermaid.min.js -> that file. Caddy serves the directory at /lanes-assets/vendor/ (web/Caddyfile); web/docs.js
9
+ # loads the versioned name with the member digest as its SRI. Idempotent: an installed file whose digest is the pin's
10
+ # is kept and nothing is downloaded; one that differs is replaced from a verified download. Older versions stay.
11
+ # Exit status: 0 installed, or already present; 1 a prerequisite is missing or the download failed; 2 usage;
12
+ # 3 refused — holders.lock has no valid mermaid pin, a digest differs, or the member is missing or not a regular file.
13
+ # A refusal or failure installs nothing.
14
+ set -eu
15
+ repo=$(cd "$(dirname "$0")/.." && pwd)
16
+ usage='usage: enable-docs.sh [--help]'
17
+ case "$#:${1:-}" in
18
+ 0:) ;;
19
+ 1:-h|1:--help)
20
+ echo "$usage"
21
+ echo 'Installs the Mermaid that holders.lock pins (opt-in line `mermaid`) into ~/.local/share/lanes/vendor/'
22
+ echo '($LANES_SHARE/vendor), after checking the sha256 of the npm tarball and of its member.'
23
+ echo 'Exit status: 0 installed or already present; 1 a prerequisite is missing or the download failed;'
24
+ echo '2 usage; 3 refused (no valid pin, a sha256 mismatch, the member missing or not a regular file).'
25
+ exit 0 ;;
26
+ *) echo "$usage" >&2; exit 2 ;;
27
+ esac
28
+ [ "$(id -u)" != 0 ] || { echo 'Run as the workspace account, not root.' >&2; exit 1; }
29
+ for command in curl tar sha256sum install mktemp; do
30
+ command -v "$command" >/dev/null 2>&1 || { echo "Missing prerequisite: $command (see docs/INSTALL.md)" >&2; exit 1; }
31
+ done
32
+ share=${LANES_SHARE:-$HOME/.local/share/lanes}
33
+ lock=$repo/holders.lock
34
+ refuse() { echo "enable-docs: refused, nothing was installed: $*" >&2; exit 3; }
35
+ hex64() { [ "${#1}" -eq 64 ] && case $1 in *[!0-9a-f]*) false ;; esac; }
36
+
37
+ # The pin: exactly one `mermaid` line of the 7-field opt-in grammar (holders.lock's header; studio_host.parse_lock).
38
+ pins=$(grep -v '^[[:space:]]*#' "$lock" | awk '$1 == "mermaid"') || :
39
+ [ -n "$pins" ] || refuse "$lock has no mermaid line"
40
+ [ "$(printf '%s\n' "$pins" | wc -l)" -eq 1 ] || refuse "$lock pins mermaid more than once"
41
+ # shellcheck disable=SC2086 # the line's words are the fields; no pathname expansion of them
42
+ set -f
43
+ set -- $pins
44
+ set +f
45
+ [ "$#" -eq 7 ] && [ "$5" = opt-in ] || refuse "$lock: the mermaid line is not 'name version sha256 url opt-in member sha256'"
46
+ version=$2 sha=$3 url=$4 member=$6 member_sha=$7
47
+ case $version in ''|*[!A-Za-z0-9._-]*) refuse "$lock: mermaid version '$version' is not a plain word" ;; esac
48
+ hex64 "$sha" || refuse "$lock: the mermaid tarball digest is not 64 lowercase hex"
49
+ hex64 "$member_sha" || refuse "$lock: the mermaid member digest is not 64 lowercase hex"
50
+ case $url in https://*) ;; *) refuse "$lock: the mermaid url is not https" ;; esac
51
+ case /$member/ in //*|*/../*|*/./*|*//*) refuse "$lock: member '$member' is not a plain relative path" ;; esac
52
+
53
+ vendor=$share/vendor
54
+ target=$vendor/mermaid-$version.min.js
55
+ if [ -f "$target" ] && [ ! -L "$target" ] && [ "$(sha256sum "$target" | cut -d' ' -f1)" = "$member_sha" ]; then
56
+ ln -sfn "mermaid-$version.min.js" "$vendor/mermaid.min.js"
57
+ echo "ok mermaid $version ($target)"
58
+ exit 0
59
+ fi
60
+
61
+ temporary=$(mktemp -d)
62
+ trap 'rm -rf "$temporary"' EXIT
63
+ trap 'exit 130' INT
64
+ trap 'exit 143' TERM HUP
65
+ curl -fsSL --retry 3 -o "$temporary/mermaid.tgz" "$url" || { echo "enable-docs: download failed: $url" >&2; exit 1; }
66
+ actual=$(sha256sum "$temporary/mermaid.tgz" | cut -d' ' -f1)
67
+ [ "$actual" = "$sha" ] || refuse "sha256 mismatch for the mermaid $version tarball: $actual, pinned $sha"
68
+ mkdir "$temporary/x"
69
+ tar -xzf "$temporary/mermaid.tgz" -C "$temporary/x" "$member" 2>/dev/null || refuse "the tarball has no $member"
70
+ file=$temporary/x/$member
71
+ [ -f "$file" ] && [ ! -L "$file" ] || refuse "$member in the tarball is not a regular file"
72
+ actual=$(sha256sum "$file" | cut -d' ' -f1)
73
+ [ "$actual" = "$member_sha" ] || refuse "sha256 mismatch for $member: $actual, pinned $member_sha"
74
+ mkdir -p "$vendor"
75
+ install -m 0644 "$file" "$vendor/.mermaid-$version.min.js.$$"
76
+ mv -f "$vendor/.mermaid-$version.min.js.$$" "$target"
77
+ ln -sfn "mermaid-$version.min.js" "$vendor/mermaid.min.js"
78
+ echo "installed $target (sha256 $member_sha)"