@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.
- package/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +47 -23
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2753 -202
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +147 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1150 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1318 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +793 -16
- package/payload/lanes/docs/DESIGN.md +127 -46
- package/payload/lanes/docs/INSTALL.md +175 -18
- package/payload/lanes/docs/RUNBOOK.md +968 -76
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +967 -150
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +197 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +21 -2
- package/payload/lanes/libexec/notify.py +169 -29
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2232 -365
- package/payload/lanes/libexec/statusd.py +231 -12
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +148 -35
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +1044 -0
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +165 -28
- package/payload/lanes/web/browser.js +4 -8
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +45 -32
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- package/payload/manifest.json +6 -3
|
@@ -16,12 +16,15 @@ measurements; substitute your account, containers and paths in operational comma
|
|
|
16
16
|
| One lane, no multiplexer, native Ghostty scrollback | `lanes-launch lane <lane-id>` (ids: `lanes-launch ls`) |
|
|
17
17
|
| Switch workspace | `Ctrl-Alt-←/→` (zellij tabs) |
|
|
18
18
|
| Next / previous lane | `Ctrl-Alt-↓` / `Ctrl-Alt-↑` (works from inside the lane: `lanes next\|prev --focused` in a floating pane that closes at once points the FOCUSED viewport — the first one when the sidebar has the focus — at the next / previous lane of its workspace in registry order, wrapping; nothing is typed and the focus stays where it was; on a tab without a workspace, e.g. Host, it does nothing). In the sidebar `J` / `K` still step through the list as it is drawn |
|
|
19
|
-
| Lock the view | `Ctrl-Alt-g` locks (the lane gets every key except the `shared` chords that stay live while locked — the Ctrl-Alt arrows, `/`, `u`, `c`, `s`, PageUp/PageDown/End; the mode ribbon shows LOCKED in on-accent-urgent) and unlocks. `Ctrl-g` is never bound: it always reaches the lane |
|
|
20
|
-
| Find a lane by name | `Ctrl-Alt-/`, type a few letters, `Enter` |
|
|
21
|
-
|
|
|
19
|
+
| Lock the view | `Ctrl-Alt-g` locks (the lane gets every key except the `shared` chords that stay live while locked — the Ctrl-Alt arrows, `/`, `u`, `l`, `c`, `s`, PageUp/PageDown/End; the mode ribbon shows LOCKED in on-accent-urgent) and unlocks. `Ctrl-g` is never bound: it always reaches the lane |
|
|
20
|
+
| Find a lane by name | `Ctrl-Alt-/`, type a few letters, `Enter` (the chord types nothing: it moves the focus to the sidebar and runs `lanes sidebar-key filter --focused` in a floating pane that closes at once; on Host or a tool tab it does nothing) |
|
|
21
|
+
| Focus the sidebar | `Ctrl-Alt-l` (three `MoveFocus "left"`, nothing typed); the footer then reads ` ▌ keys go to sidebar · Esc → lane` and `Esc` gives the keyboard back |
|
|
22
|
+
| Browse the sidebar | `Ctrl-Alt-l` (or click it), then `↑/↓` or `j/k`, `Enter`; opening a lane, and every other action that ends, gives the keyboard back to the lane (a prompt, the filter and the armed upload strip keep it). `?` shows the key sheet; the [user guide](USERGUIDE.md#the-sidebar) has every key |
|
|
22
23
|
| Go to the next lane that needs you | `Tab` in the sidebar opens the next lane in **Needs you** (`Shift-Tab` the previous), cycling from the lane this pane shows — or from the cursor lane when the shown one does not need you; ` nothing needs you` when the section is empty |
|
|
23
24
|
| Scroll a lane | mouse wheel / tmux copy-mode as before (history lives in the lane, not in the viewer) |
|
|
25
|
+
| Edit a lane's AGENTS.md, CLAUDE.md or .env.local | `Ctrl-Alt-e`: a floating **Edit** pane runs `lanes edit --focused`, one key picks (`1`–`5`; `q`/`Esc` closes), then `docker exec -it -u coder -w <checkout> <container> ~/.lz/bin/lane-edit <file>` replaces it — the editor runs in the lane's container as uid 1000, the server only checks the paths through `/proc/<init>/root` and copies nothing out ([guide](USERGUIDE.md#quick-edit)). `lanes edit: lane-edit is not installed in <c>` → `lanes install-container <c>`; `lane-edit: no editor on PATH` (exit 127) → the container has none of micro, hx, nvim, vim, vi (Lanes installs none); `container <c> is not running` → nothing to edit until it runs; `no git checkout in or above <dir>` → the lane works outside a checkout. The editor is the device's `lanes attach <device> --editor E` choice |
|
|
24
26
|
| Throttle monitor | its own full-size tab, `Throttle` (next to `Host`); it starts when you first open the tab and restarts itself if it exits. It is no longer an entry in every board's sidebar. |
|
|
27
|
+
| Open what an agent published, or upload from a phone | the Docs page, `https://<server>:8443/lanes-docs/`: phone **More → Docs**, the laptop bar's **Docs**, a ⌘/Ctrl-click on the sidebar's ` ↑ uploads · ▤ docs N` strip, or `d` on a lane for its own link; an agent publishes with `~/.lz/bin/lane publish <file>` or `lanes_publish` (section "Docs service"; the [user guide's Docs](USERGUIDE.md#docs)) |
|
|
25
28
|
| Detach | `Ctrl-Alt-o` then `d` — or just close the window. Nothing on the server stops. |
|
|
26
29
|
|
|
27
30
|
Sidebar glyphs: see the legend in [`docs/DESIGN.md`](DESIGN.md) (agent column `◆ ◇ ▸ ✓ ✗ _ ·`, terminal column
|
|
@@ -31,15 +34,15 @@ every row is 36 cells and column 36 is never written):
|
|
|
31
34
|
|
|
32
35
|
| Column | Shows |
|
|
33
36
|
| --- | --- |
|
|
34
|
-
| 1 | `▌` the cursor (no underline) |
|
|
35
|
-
| 2 | the agent, from `status.json`'s additive `agent{}` (never from `state`): `◆` approval prompt blocking · `✗` errored · `◇` turn finished, waiting for your prompt · `▸` working · `✓` done, until you open the lane from this device (`clients/<device>.seen.json`) · `_` harness ended into a shell · `·` harness up, no turn · blank = a plain shell; dimmed when the lane is stale |
|
|
36
|
-
| 3 | `›` the lane this pane shows — never on the cursor row, where the bold name says it · `²` / `³` shown in pane 2 / 3 of a wide view |
|
|
37
|
-
| 4–27 | the name in its identity colour, bold while this pane shows it; up to three `·` (other sessions view it) are reserved first, and a longer name ends in `…` |
|
|
37
|
+
| 1 | `▌` the cursor (no underline); in `truecolor` / `256` the cursor row also lies on the `selected` background up to column 35 |
|
|
38
|
+
| 2 | the agent, from `status.json`'s additive `agent{}` (never from `state`): `◆` approval prompt blocking · `✗` errored · `◇` turn finished, waiting for your prompt · `▸` working · `✓` done, until you open the lane from this device (`clients/<device>.seen.json`) — or, on a device with an owner, from any device of theirs (`users/<login>.json`) · `_` harness ended into a shell · `·` harness up, no turn · blank = a plain shell; dimmed when the lane is stale |
|
|
39
|
+
| 3 | `›` the lane this pane shows — never on the cursor row, where the bold name says it · `²` / `³` shown in pane 2 / 3 of a wide view · `…` (ink-dim; ASCII `~`) an action on its way, the row refusing every verb meanwhile: a session `x` is closing (until the worker answers), a session `P` is registering (it stays listed until the registry lists its lane: `PENDING_S` = 80 s, and never while the sync the sidebar started still runs), a shell `+` is creating (on the New shell row) · `▲` (accent; ASCII `^`) a lane its owner pinned (`p`, cockpit v3 R5: it leads its section and never falls below `⋮ N more`), the lowest of these marks |
|
|
40
|
+
| 4–27 | the name in its identity colour (else ink, painted in `truecolor` / `256`), bold while this pane shows it, faint for a lane its owner hid while `o` shows the hidden lanes; up to three `·` (other sessions view it) are reserved first, and a longer name ends in `…` |
|
|
38
41
|
| 28 | `+` on an unregistered tmux session that can be added to a workspace (click it, or `a`) |
|
|
39
|
-
| 29–31 | the age: the agent's `since`, else the last output — `45s`, ` 3m`, ` 2h`, ` 4d` (at most `99d`); blank when nothing is known; amber inside **Needs you** |
|
|
40
|
-
| 33 | the stream: `●` output in the last 20 s · `○` quiet · `–` absent (no such session right now, e.g. a finished orchestrator turn) · `!` pane dead · `✗` container down · `·` helper · `?` never observed; dimmed when an agent glyph sits in column 2 (`▸` + `○` is a long tool call, not a stuck lane)
|
|
42
|
+
| 29–31 | the age: the agent's `since`, else the last output — `45s`, ` 3m`, ` 2h`, ` 4d` (at most `99d`); blank when nothing is known; amber inside **Needs you**; in `truecolor` / `256` its section's tone in **Working** and **Done** too |
|
|
43
|
+
| 33 | the stream: `●` output in the last 20 s · `○` quiet · `–` absent (no such session right now, e.g. a finished orchestrator turn) · `!` pane dead · `✗` container down · `·` helper · `?` never observed; dimmed when stale, and in 16 colours also when an agent glyph sits in column 2 (`▸` + `○` is a long tool call, not a stuck lane) |
|
|
41
44
|
| 34 | one tail cell by priority: `?` not observed right now, last known state shown (see "When the host is overloaded") > `⚠` starving (the pane is `active` but the proxy saw no request from the lane for over 600 s, or a 429 in its recent-request ring) > `↓` downgraded in the last 5 min > `$` more than half its recent requests paid. The three economy cells come from `economy.json` ("Economy projection" below); a lane the poller has not joined — or no poller — has a blank cell |
|
|
42
|
-
| 35 | `×` close, on unregistered sessions only: it asks ` › close ‘name
|
|
45
|
+
| 35 | `×` close, on unregistered sessions only: it asks ` › close ‘name’ — y⏎ confirm · Esc cancel` first; `y` arms (` — ⏎ confirm`), `Enter` closes, any other key cancels |
|
|
43
46
|
|
|
44
47
|
The cursor is a lane, not a row: when a lane changes section (it starts needing you, its turn finishes, you open a
|
|
45
48
|
`done` lane) the cursor and the scroll position go with it. A list that fits the pane is never scrolled: its
|
|
@@ -52,8 +55,11 @@ session laid out after Task 26: an existing phone device keeps the old picker pa
|
|
|
52
55
|
(`upgrade-session` and `ensure` add missing tabs and rename titles, they never re-lay-out a tab; `lanes doctor` names
|
|
53
56
|
such a device). Whether it
|
|
54
57
|
reaches the phone is still unchecked in a browser (DEF-1531). A lane that
|
|
55
|
-
STARTS needing you (`◆` `✗` `◇`) raises one desktop notification, `name: task`, through OSC 9 — once per
|
|
56
|
-
|
|
58
|
+
STARTS needing you (`◆` `✗` `◇`) raises one desktop notification, `name: task`, through OSC 9 — once per entry into
|
|
59
|
+
Needs you (keyed on `agent.stable_since`, so a word that moves inside the section — input, approval, input again —
|
|
60
|
+
is not news; from a statusd older than cockpit v3 S1, once per status as before),
|
|
61
|
+
never for the lane this pane shows, never on a phone, and not at all for a device whose `devices/<device>.json` says
|
|
62
|
+
`notify: "0"` (`lanes attach DEVICE --notify 0`, live) or, without that key, under `LANES_NOTIFY=0`. A sidebar that starts
|
|
57
63
|
(or restarts) while lanes already need you announces each of them once. Both are bytes in the frame the sidebar
|
|
58
64
|
already writes: no process is started. Ghostty shows OSC 9 only while the lanes window is NOT focused
|
|
59
65
|
(`desktop-notifications = true` in `client/ghostty/common.conf`); on a terminal or Zellij build that drops OSC 9 the
|
|
@@ -61,9 +67,19 @@ notification is a silent no-op and nothing else changes. Mac check: not yet run
|
|
|
61
67
|
on the Mac — record the Zellij and Ghostty versions and the outcome here).
|
|
62
68
|
|
|
63
69
|
Header: the workspace title, then `◆1 ◇2 ▸6 ✓1` in the column-2 glyphs (zeros omitted; `N lanes` without agents;
|
|
64
|
-
dimmed while the footer warns that status is old). Sections: **Needs you** (◆, ✗, ◇ —
|
|
65
|
-
(
|
|
66
|
-
|
|
70
|
+
dimmed while the footer warns that status is old). Sections: **Needs you** (◆, ✗, ◇ — most urgent first, then in the
|
|
71
|
+
order the lanes entered the section), **Working** (entry order), **Done** (newest entry first), then the workspace's
|
|
72
|
+
other lanes under its own header. Once any lane of the workspace runs an agent, the Needs you and Working headers are
|
|
73
|
+
always drawn, `(0)` included (an empty header's glyph is dimmed; a header with lanes says its words in its section's
|
|
74
|
+
tone in `truecolor` / `256`), so a lane moving between them adds or removes no
|
|
75
|
+
header row; Done only while it has a lane; without agents the list is the plain one. A filter flattens the sections
|
|
76
|
+
and keeps column 2. The order key is `agent.stable_since`, never `agent.since` (which restarts on every status change
|
|
77
|
+
inside a section — cockpit v3 S2, DEF-1811); ties by registry position. A lane moves UP a section at once and DOWN only after the
|
|
78
|
+
lower word has held for 8 s (leaving Needs you) or 5 s (leaving Working or Done); a lane whose agent signal vanishes
|
|
79
|
+
keeps its glyph 5 s — `lanes-statusd` debounces before it publishes (`agent_state.settle`; `agent.raw_status` in
|
|
80
|
+
`status.json` is the undebounced word, `agent.stable_since` when the lane entered its section). The list does not
|
|
81
|
+
scroll when a lane changes section above the cursor: the cursor goes with its lane, and the list moves only when that
|
|
82
|
+
lane would leave the pane (`top` is pinned). A list longer than the pane ends in `⋮ N more`; moving onto
|
|
67
83
|
that row, or clicking it, scrolls instead of opening the lane behind it. **Unregistered** lists every observed tmux
|
|
68
84
|
session associated with this workspace but absent from its registry — nothing alive is ever hidden, and a session
|
|
69
85
|
whose NAME could not be declared (parentheses, `/` and `,` are outside the lane grammar) is listed, opened and closed
|
|
@@ -102,7 +118,23 @@ and survives sync and reconnects. Registered lanes have no close control.
|
|
|
102
118
|
From the server CLI, use `lanes ls --unregistered` to find the ID, then
|
|
103
119
|
`lanes register <lane-id> [workspace-id]`; omitting the workspace keeps the current assignment.
|
|
104
120
|
The site's version-1 `registered-sessions.json` is durable configuration: back it up with the catalog.
|
|
105
|
-
To undo registration,
|
|
121
|
+
To undo registration, press `u` on the lane in the sidebar or run `lanes unregister <lane-id>`
|
|
122
|
+
(`registrations.unregister`: the record leaves `sessions` under the registry lock and the registry is republished;
|
|
123
|
+
the terminal keeps running and is listed under Unregistered again with the same id, its name handed back through
|
|
124
|
+
`user-sessions/<id>.json` and the workspace and id it was registered under through `handback/<binding digest>.json`,
|
|
125
|
+
which statusd's first listing of that session reads — so it stays in the same workspace under the same id even when
|
|
126
|
+
its directory now belongs to another one). `z` in the sidebar within 10 s restores the record byte for byte
|
|
127
|
+
(`registrations.reregister`, which re-checks the session's tmux identity exactly as registration does and refuses
|
|
128
|
+
a session closed and re-created under the same name meanwhile). While tmux gives no usable answer for the session (a
|
|
129
|
+
timeout or connection error on a busy host) the unregister is refused, nothing written:
|
|
130
|
+
` Session could not be checked; nothing was unregistered` — retry once the host has breath. If saving succeeded and
|
|
131
|
+
handing back or publishing then failed (a full disk), the message is ` Unregistered; run lanes sync to finish
|
|
132
|
+
publishing: …`; `z` still restores the record (and publishes), otherwise `lanes sync` finishes it, and the CLI prints
|
|
133
|
+
the `lanes register` undo for once the session is listed again. A record no workspace lists any more (its workspace
|
|
134
|
+
left the catalog or uses another container, or a repository declares the binding: `lanes sync` reports or ignores
|
|
135
|
+
it) is removed by `lanes unregister <its id>` as well, without asking tmux. Removing the record by hand and running
|
|
136
|
+
`lanes sync` still works. A repository lane is unregistered by editing its `terminals.json` (the sidebar and the CLI
|
|
137
|
+
name the file).
|
|
106
138
|
If publication fails after saving, `lanes sync` finishes it. Invalid/newer file versions are refused without
|
|
107
139
|
overwriting the file. These registrations have no restart recipe: an ended session stays absent until the operator
|
|
108
140
|
starts it. Add a repository terminal declaration when editor integration and a saved create recipe are wanted.
|
|
@@ -155,18 +187,102 @@ lanes sync re-read registries now lanes ls [--json] lanes
|
|
|
155
187
|
lanes rebuild <device> recreate a view session lanes ensure recreate missing view sessions
|
|
156
188
|
lanes invariant save|check prove no live pane changed (pid + window size)
|
|
157
189
|
lanes device list the devices ensure keeps, with owners lanes device forget <name> stop recreating one
|
|
190
|
+
lanes device claim <device> --login L own a device from before enrolment lanes user list | show <login> profiles
|
|
191
|
+
lanes workspaces hide|show|order <id…> --device D | --login L a person's boards lanes lane pin|unpin|hide|show <id> … lanes
|
|
192
|
+
lanes here <device> show there the lane its owner opened last on another device (lanes-launch here from a client)
|
|
158
193
|
lanes keys list who may log in (members/) lanes keys add <device> <file.pub|-> authorize a machine
|
|
159
|
-
lanes keys revoke <device> remove one machine lanes keys adopt [--dry-run] move hand-added
|
|
194
|
+
lanes keys revoke <device> remove one machine lanes keys adopt [--dry-run] [--login L] move hand-added keys in
|
|
160
195
|
lanes invite <name> --email ADDR one-time join code (own terminal) lanes invite list | revoke <id>
|
|
161
196
|
lanes enrol list open access requests lanes enrol approve <id> | deny <id> decide one
|
|
162
197
|
lanes offboard <device> --yes key, device, view (own terminal) lanes token revoke <view> sign a browser out
|
|
163
198
|
lanes onboard <login> --container <name> [--workspace <id> --repo <path>] [--write] a user's container, dry run first
|
|
164
199
|
systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-web lanes-enrold
|
|
200
|
+
lanes update --status the last update and the last check (update/last.json) lanes update --now check and install now
|
|
201
|
+
lanes update --rollback the release before again, the one left marked bad systemctl --user status lanes-update.timer
|
|
165
202
|
lanes studio-host status [--check] the optional co-located Studio Host
|
|
166
203
|
lanes studio-host re-key --yes after the owner revoked the Host: a new machine key for the next deploy
|
|
167
204
|
(refused while lanes-sharing-host or throttle-rc-studio-host still runs)
|
|
168
205
|
```
|
|
169
206
|
|
|
207
|
+
- **A server update (W9 U2):** `npx @vibelord/lanes@<release> --server --update-only` (from a checkout:
|
|
208
|
+
`./install.sh --server --update-only`). One line per phase and step on the terminal (or in the journal), then the
|
|
209
|
+
outcome; the full output is `~/.local/state/lanes/update/last.log`, the outcome `update/last.json`. Read it with
|
|
210
|
+
`python3 -m json.tool ~/.local/state/lanes/update/last.json`: `result` `ok` (a step with `"warning": true` — `sync`
|
|
211
|
+
exiting 1 for a board with an error, e.g. a removed container — needs the board fixed, not the update: `lanes sync`
|
|
212
|
+
names it; `enable-docs` with `"outcome": "skipped"` or `"refused"` and its `reason` is the docs page's Mermaid, never
|
|
213
|
+
fatal — see "Mermaid pin" — and the summary line says `mermaid ok`, `mermaid skipped: <reason>` or `mermaid
|
|
214
|
+
refused: <reason>`); `partial` (exit 4) — the release is installed and a step after the switch failed: each step with a
|
|
215
|
+
non-zero `rc` is the one to run by hand (`sync` → `lanes sync`, `restart lanes-statusd` →
|
|
216
|
+
`systemctl --user restart lanes-statusd.service`, `install-container <c>` → `lanes install-container <c>`,
|
|
217
|
+
`reload-views` → `lanes reload-views`, `ensure` → `lanes ensure --if-layout-changed`, `enable-docs` with `"outcome":
|
|
218
|
+
"failed"` → `sh ~/.local/share/lanes/current/install/enable-docs.sh`); `refused` (exit 3) — nothing changed: the last step names what said no
|
|
219
|
+
(`preflight`: a prerequisite, the user manager or Docker, or no installed release — `no release is installed here`:
|
|
220
|
+
run the full `./install.sh --server` / `npx @vibelord/lanes@<release> --server` first; `commit`: another install
|
|
221
|
+
holds `~/.local/share/lanes/.install.lock`, or a link is no symlink). A run that printed only
|
|
222
|
+
`refused: another lanes update is running (pid N, since T)` found the run lock `~/.local/state/lanes/update/.lock`
|
|
223
|
+
held and recorded nothing — `last.json` is the other run's; wait for pid N to end (`ps -p N`), then run it again.
|
|
224
|
+
`failed` (exit 1) — nothing was installed (a download or a checksum, `binaries`). A `commit` step with a non-zero
|
|
225
|
+
`rc` and result `partial` means the switch happened but nothing was restarted: fix what the log names (the
|
|
226
|
+
`~/.local/bin/lanes` link), then run the update again. `rc` 124 is a step stopped after 300 s.
|
|
227
|
+
- **Automatic updates (W9 U3):** `lanes-update.timer` runs `lanes update --timer` 15 min after a boot, then every
|
|
228
|
+
30 min; `journalctl --user -u lanes-update.service` shows each run (the check, then the installer's lines). Only the
|
|
229
|
+
full server install enables the timer — an update (`--update-only`) enables nothing — so a server set up before
|
|
230
|
+
1.2.0 and updated since with `--update-only` has it off: `lanes doctor` says `⚠ updates: … lanes-update.timer is not
|
|
231
|
+
enabled` (`systemctl --user is-enabled lanes-update.timer` says `disabled`); fix it with `systemctl --user enable
|
|
232
|
+
--now lanes-update.timer`, or one full `npx @vibelord/lanes@latest --server`.
|
|
233
|
+
`lanes update --status` prints `update/last.json`; its `check` is the last check and `check.outcome` says what came
|
|
234
|
+
of it: `current` (the newest release is installed), `older` / `bad` (the tag names an older release, or the one a
|
|
235
|
+
rollback left: not installed, by design), `unanswered` (the registry gave no release: `check.said` has curl's line —
|
|
236
|
+
a network or registry outage, or with `channel=stable` no `lanes-stable` tag: `npm dist-tag add
|
|
237
|
+
@vibelord/lanes@<release> lanes-stable`), `refused` (`~/.config/lanes/update.conf` names a channel other than
|
|
238
|
+
`stable` or `latest`), `installing` (an install that is running, or one that was stopped: read `last.log`), `installed`
|
|
239
|
+
(npx ran the installer; the record's own `result` says how it ended, as in the bullet above) or `failed` (nothing was
|
|
240
|
+
installed: no recorded npx or one that is gone — run `npx @vibelord/lanes --server --update-only` once from a shell
|
|
241
|
+
whose npx works, which records it again in `update/npx` — or npx ended before the installer recorded a run: the
|
|
242
|
+
journal has npx's own error). A `partial` result is run again by the next timer tick, once per release (`rerun` names
|
|
243
|
+
the release); after that the unit fails each tick until `lanes update` by hand ends `ok`. A run that prints
|
|
244
|
+
`another lanes update is running` or `refused: another lanes install is running` did nothing: wait for it.
|
|
245
|
+
`lanes update --rollback` goes back to the release `current.previous` names, without the network, and marks the one
|
|
246
|
+
left `bad` (`check.outcome` `bad` from then on for that release); `lanes doctor` names the mark. The mark is written
|
|
247
|
+
with the record of the flip (`result` `partial`, steps `[rollback]`), before the post-commit: a rollback stopped in
|
|
248
|
+
its post-commit (Ctrl-C, a dropped SSH session; its exit is 130/143/129) keeps it, and the next timer tick — or
|
|
249
|
+
`lanes update` by hand — runs the post-commit again and does not install the release left. A checkout server is
|
|
250
|
+
never touched by the timer.
|
|
251
|
+
- **`lanes reload-views`:** asks each viewport to start again from the installed release at its next lane switch
|
|
252
|
+
(SIGUSR2, only to a `view.sh` that handles it); it names the devices whose viewports run an older `view.sh` — which
|
|
253
|
+
that signal would end — and leaves them alone: `lanes rebuild <device>` moves them. Agents are never touched. Two
|
|
254
|
+
checks decide, both required (`bin/lanes` `cmd_reload_views`): the release of the tree the viewport RUNS is 1.2.0 or
|
|
255
|
+
later (`VIEW_RELOAD_SINCE`, the first release whose `view.sh` traps USR2; `view_tree` reads the script file the
|
|
256
|
+
shell holds open under `/proc/<pid>/fd` — resolved when it started, so a `current` path names the tree it named
|
|
257
|
+
then, not the one an update or rollback flipped it to since — else the command line's path; `view_reloads` compares
|
|
258
|
+
that tree's VERSION as major.minor.patch, and a tree with no VERSION or `dev` never qualifies), and the process has a
|
|
259
|
+
USR2 handler installed now (SigCgt in `/proc/<pid>/status`).
|
|
260
|
+
- **`lanes ensure --if-layout-changed` (W9 U6), the update's last step:** compares each running view's
|
|
261
|
+
`layouts/<device>.kdl` with the layout the installed release generates. Only a tab both still have counts, line for
|
|
262
|
+
line, plus the tab template: a tab `lanes sync` added, renamed or moved since the view was built is no change
|
|
263
|
+
(upgrade-session does that live). A changed view is rebuilt when `zellij -s <device> action list-clients` lists no
|
|
264
|
+
client (`<device>: its layout changed and no client is attached: rebuilding it`); with a client, or when zellij does
|
|
265
|
+
not answer, it keeps running and gets `~/.local/state/lanes/clients/<device>.update-ready` (`… left running, its
|
|
266
|
+
sidebars offer update ready · reload (r)`). The sidebar looks for that file every 30 s and shows ` update ready ·
|
|
267
|
+
reload (r)` in the footer; `r` then runs `lanes rebuild <device>` (the window reconnects). `lanes-ensure.timer`
|
|
268
|
+
compares a stamped view again every minute and rebuilds it once its last client has left; a layout that is current
|
|
269
|
+
again (after `lanes update --rollback`) drops the file. A view that cannot be rebuilt is named and the run exits 1
|
|
270
|
+
(the update records `ensure` failed: run `lanes ensure --if-layout-changed` again); the other views are still done.
|
|
271
|
+
A rebuild ends the view's Host tab shell like `lanes rebuild` does; lanes are never touched.
|
|
272
|
+
- **The view-session lock (DEF-1792):** `~/.local/state/lanes/locks/sessions.lock`. Whatever lists the view sessions
|
|
273
|
+
and then creates, kills or deletes one holds it from that list to its last zellij call: `lanes ensure` (the whole
|
|
274
|
+
run), `lanes rebuild` (also the sidebar's `r`), the create of `lanes attach`, `prepare` and `web`, the end
|
|
275
|
+
`lanes offboard` gives a view, and `lanes device forget`. So no `ensure` finds a view missing while a rebuild has
|
|
276
|
+
killed it and not yet created it again, and starts a second one. A run that finds it held prints
|
|
277
|
+
`lanes: <who> (pid N, since <UTC>) is acting on the view sessions; waiting up to N s`. `ensure
|
|
278
|
+
--if-layout-changed`, `rebuild`, `prepare`/`web`, offboard and `device forget` wait 120 s, then exit 1 (offboard:
|
|
279
|
+
its step `failed`) naming the holder; run them again. The minute timer's plain `ensure` waits 10 s and then skips
|
|
280
|
+
the run (`ensure: skipped: …`, exit 0); `attach` waits 10 s and then attaches without creating anything. A lock that
|
|
281
|
+
stays held: `cat ~/.local/state/lanes/locks/sessions.lock` names the process (`{pid, doing, since}`; empty when
|
|
282
|
+
nobody holds it). The lock dies with its process. Unit ordering cannot do this: `After=` orders only jobs queued
|
|
283
|
+
together, `Conflicts=` would stop a running update, and `lanes rebuild`, the sidebar and an update by hand are no
|
|
284
|
+
units.
|
|
285
|
+
|
|
170
286
|
- **What `lanes doctor` says about the boards' recipes (plan Task 18):** one line counts the tmux lanes per create
|
|
171
287
|
recipe class — `idempotent` (a `tmux new -A…`: creates, or attaches when the session exists; the canonical recipe),
|
|
172
288
|
`attach-only` (`attach-session` and no `new`), `null` (no recipe), `external` (a script or anything else, run as
|
|
@@ -183,6 +299,25 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
|
|
|
183
299
|
not answer within 5 s, or refuses for another reason (EACCES, …) keeps its file. The errno decides, never tmux's
|
|
184
300
|
wording: tmux 3.4 prints `no server running on <path>` for ECONNREFUSED and `error connecting to <path>
|
|
185
301
|
(<strerror>)` only for other errnos (DEF-1540).
|
|
302
|
+
- **What `lanes doctor` says about the release that runs (W9 V3, LNS-85), after an update:** four lines (five with the
|
|
303
|
+
information line below) just before `PRESSURE`. `current → pkg/1.2.0 (1.2.0), the release this lanes runs` — else a
|
|
304
|
+
`✗` (the link names a missing tree, one without `VERSION`, one whose `VERSION` disagrees with its `pkg/<version>`
|
|
305
|
+
name or npm stamp, or not the tree this `lanes` runs: run `~/.local/share/lanes/current/bin/lanes`; re-run the
|
|
306
|
+
server installer when `~/.local/bin/lanes` is the old one), or a `⚠` without the link. `processes run the current
|
|
307
|
+
release` — a `✗` names each process that runs now (doctor's one `ps` sweep, this account's processes, by the argv
|
|
308
|
+
Lanes starts them with: `python3 -S -E <tree>/libexec/<script>`, read from `/proc/<pid>/cmdline`; an editor or a
|
|
309
|
+
grep on the file, or a one-shot such as `statusd.py --once` or `notify.py --test`, is no daemon, DEF-1785) with an
|
|
310
|
+
older run record or none at all (`statusd runs without a run record (started by an older release)`), with
|
|
311
|
+
`systemctl --user restart lanes-<name>.service` or `lanes rebuild <device>` for a sidebar; when `ps` did not answer,
|
|
312
|
+
every daemon's older record counts. A record whose process is not running is an information line (it stays until
|
|
313
|
+
the process starts again; studio_host's is always that: it records a oneshot step). There is one sidebar record, the newest sidebar's: an older sidebar still open on another device shows up
|
|
314
|
+
through that device's layout line, not this one. `view layouts run the current release` — a `✗` names each device
|
|
315
|
+
whose `layouts/<device>.kdl` runs another tree (`lanes rebuild <device>`), a `⚠` each one that names the current
|
|
316
|
+
tree by another path than `lanes rebuild` writes now (since 1.2.0 a layout names `~/.local/share/lanes/current`; one
|
|
317
|
+
built before names its `pkg/<version>` tree, which the next update leaves it on). `✓ lanes units run the current
|
|
318
|
+
release (N units)` — a `✗` counts the units whose `ExecStart` runs another tree (units name `current` since 1.2.0, so
|
|
319
|
+
only units an older installer wrote, naming their tree itself, stay behind), or names none (edited by hand): re-run
|
|
320
|
+
the server installer.
|
|
186
321
|
- **A device you no longer use (an old laptop, a phone you replaced):** `lanes device forget <name>` removes
|
|
187
322
|
`devices/<name>.json`, so `lanes ensure` (`lanes-ensure.timer`) stops recreating its view session and `sync` /
|
|
188
323
|
`upgrade-session` stop updating it. A view session that is running is left alone and says so; once it ends, nothing
|
|
@@ -190,7 +325,8 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
|
|
|
190
325
|
each device, phone or desktop (viewports), its owner's login (blank for none) and whether its view session runs.
|
|
191
326
|
- **Who a device belongs to (W8 A2):** `~/.local/state/lanes/members/<device>.json` records, beside the key, the
|
|
192
327
|
owner `{login, name, email}` (the tailnet login; `null` for a key added with `lanes keys add|adopt`), `via`
|
|
193
|
-
(`invite`|`request`|`manual`), `approved_by`, the `invite_id` or `request_id
|
|
328
|
+
(`invite`|`request`|`manual`), `approved_by`, the `invite_id` or `request_id`, the `workspaces` and, for a device
|
|
329
|
+
that enrolled, the `client_version` its installer sent (W9 K1; absent when it sent none). A device's view
|
|
194
330
|
session records that owner in `devices/<device>.json` when it is created (`lanes attach`, `prepare`, `web`, or
|
|
195
331
|
`ensure`/`rebuild`) and `lanes attach` brings it up to date — for a window such as `mac-2`, whose base `mac` attach
|
|
196
332
|
learnt, the owner of `members/mac.json`. Whenever such a member record exists it decides, also when it names nobody:
|
|
@@ -199,7 +335,20 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
|
|
|
199
335
|
the next attach or recreation (DEF-1702). After a revoke with no new record, the last owner is kept like `base`
|
|
200
336
|
(`lanes device forget` clears it with the file); a member file that cannot be read is no evidence of another
|
|
201
337
|
owner, so the recorded one stays too. Writing a member never creates `devices/*`: `lanes ensure` would build a view
|
|
202
|
-
session for it. A
|
|
338
|
+
session for it. A view no member record speaks for (a device from before enrolment) gets an owner with
|
|
339
|
+
`lanes device claim <device> --login <login> [--name N]`: it writes `owner` into `devices/<device>.json` and into
|
|
340
|
+
its windows' files (views whose recorded base is the device and that have no member record of their own), where
|
|
341
|
+
every rewrite keeps it while no member record exists, then folds those views' look and opened-lane marks into the
|
|
342
|
+
person's profile `users/<login>.json` (their theme, colour and glyphs leave the device files). A member record
|
|
343
|
+
decides whenever one exists: a claim that disagrees with it (another login, or a key added by hand that names
|
|
344
|
+
nobody) is refused with `lanes keys owner <device> <login>`, one that agrees writes no owner and only folds; a
|
|
345
|
+
member file that does not load refuses the claim too. Exit 1 when the profile could not be written (its lock was
|
|
346
|
+
busy, or a newer release wrote it: `lanes user show <login>` says why); the device's owner is recorded and the same
|
|
347
|
+
claim finishes the fold — with or without `--name`: a view the login owns already keeps the name (and email) it
|
|
348
|
+
records unless `--name` replaces the name, so running a claim again rewrites nothing. `lanes keys adopt --login` and `lanes keys owner` fold the devices they name the same way.
|
|
349
|
+
`lanes doctor` names the devices without an owner (⚠, never ✗); `lanes user list` prints each person with their
|
|
350
|
+
devices, `lanes user show <login> [--json]` one profile (read-only; exit 1 for a profile file this release does not
|
|
351
|
+
read). A member file whose owner, `via`, ids or workspaces do not validate (a control or format character
|
|
203
352
|
such as a bidi mark or a zero-width space, a lone surrogate, a blank in a login, an `invite_id` on a request) is not
|
|
204
353
|
rendered into `authorized_keys` and is named, like a bad key.
|
|
205
354
|
- **Who may log in (`lanes keys`, W8 A1):** client access is a projection of `~/.local/state/lanes/members/<device>.json`
|
|
@@ -221,7 +370,8 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
|
|
|
221
370
|
authorized and says so; the same `add` again renders the line. `lanes keys list` shows each member with its
|
|
222
371
|
fingerprint (the `SHA256:` form sshd logs on `Accepted publickey`), whether the block is in sync with `members/`, and
|
|
223
372
|
hand-added `lanes:<device>` lines outside it. `lanes keys adopt [--dry-run]` moves those plain hand-added lines into
|
|
224
|
-
`members/` and the block, and re-renders the block
|
|
373
|
+
`members/` and the block, and re-renders the block (`--login <login>` records that owner on every key it moves in; a
|
|
374
|
+
member that exists keeps its own); a line with options, another key type, or a device/key conflict
|
|
225
375
|
stays where it is and is named, and the first adopt keeps `authorized_keys.bak-<UTC>`. Malformed markers refuse every
|
|
226
376
|
verb before anything, `members/` included, is written (fix the file by hand); a member file that does not validate is
|
|
227
377
|
not rendered (fail closed) and is named on
|
|
@@ -238,28 +388,174 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
|
|
|
238
388
|
statusd lists every `unregistered` and `zmx` row id under `lanes` as well; reading the other two keys is a
|
|
239
389
|
defence, not a second source. The mtime alone is no liveness signal: an unregistered session's file is rewritten only when its target or name
|
|
240
390
|
changes, so a live one is as old as the session. `removed stale <id>` for a session you can see is a defect.
|
|
391
|
+
Since W9 U4 it also prunes the server's old npm-staged trees (the npm installer prunes only a machine without
|
|
392
|
+
`~/.local/share/lanes/current`): in each folder of staged trees (the one `current`'s tree is in, `current.previous`'s,
|
|
393
|
+
the running `lanes`'s, `$LANES_PKG_ROOT` or `$XDG_DATA_HOME/lanes/pkg` when set, and `~/.local/share/lanes/pkg`) the
|
|
394
|
+
3 newest by mtime stay, and so does every older tree `current` or `current.previous` names, the running `lanes` came
|
|
395
|
+
from, a `layouts/*.kdl` names, a running daemon's `run/<name>.tree` names, or a process of this account uses
|
|
396
|
+
(`/proc/<pid>/cmdline`, `cwd`, `exe`, `fd/*`; none is pruned when `/proc` cannot be listed). A tree is renamed to
|
|
397
|
+
`.pruning-<version>-…` before it is removed, so a removal cut short never leaves a half tree under its version name;
|
|
398
|
+
the next run removes the rest. `kept <tree>: <why>` says what holds an old tree (`process <pid> uses it`: look at that
|
|
399
|
+
process; `the view layout of <device> names it`: `lanes rebuild <device>` moves the view to `current`).
|
|
400
|
+
- **What a container sees of its own lanes (`~/.lz/status.json`, LNS-32):** statusd writes each container's slice of
|
|
401
|
+
`status.json` into that container's `~/.lz` (only where `~/.lz/bin/lane` is installed), on a change and at least
|
|
402
|
+
every 15 s. Inside the container, `python3 -c 'import json,time; d=json.load(open("/home/coder/.lz/status.json"));
|
|
403
|
+
print(round(time.time()-d["at"]), "s old", len(d["lanes"]), "lanes")'`: much older than 15 s means statusd is not
|
|
404
|
+
writing there. No file = no lane contract (`lanes install-container <name>`), or a failure statusd logs once per
|
|
405
|
+
distinct error: `journalctl --user -u lanes-statusd | grep 'container status'` (`NotADirectoryError` or
|
|
406
|
+
`Too many levels of symbolic links` = a symlink where `~/.lz` or a directory above it should be, which is refused,
|
|
407
|
+
never followed: make it a real directory). `lanes sync` refuses such a `~/.lz` the same way for the registry
|
|
408
|
+
projection: it prints `! container <name>: ~/.lz is not a real directory (…)` and exits 1, every other projection is
|
|
409
|
+
still published, and that container's `lane open` keeps its previous `registry.json` until it is fixed (DEF-1796).
|
|
410
|
+
A register or rename that publishes (Add to workspace, sidebar `R`, `lanes register`, `lanes rename`) reports the
|
|
411
|
+
same refusal at once instead of leaving it to the timer sync's journal: `… saved; run lanes sync to finish
|
|
412
|
+
publishing: container <name>: …` (the CLI exits 1; the sidebar adds or renames the lane and shows it in the footer).
|
|
241
413
|
- **Give a registered lane a different display name:** sidebar `R`, or `lanes rename <lane-id> <name>`. It writes
|
|
242
414
|
`repositories[i].displayNames` in the board's `workspaces.<board>.json` (a `.bak-<UTC>` copy is kept on the first
|
|
243
|
-
write) and syncs; the lane id, its resolve file and Cursor's `lane open <id>` stay the same.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
`
|
|
415
|
+
write) and syncs; the lane id, its resolve file and Cursor's `lane open <id>` stay the same. That host override wins
|
|
416
|
+
over the repository's own `lanes.terminals[<name>].displayName` in its `terminals.json` (LNS-33), which rename never
|
|
417
|
+
writes. Renaming back to the lane's default (that `displayName` when declared, else the terminal's own title)
|
|
418
|
+
removes the override. A `displayName` another lane on the board already answers to (its title, override or own
|
|
419
|
+
`displayName`) is dropped without a board error, so renaming one lane onto another lane's repository name moves
|
|
420
|
+
that lane back to its title until the override goes. `lanes cursor-sync` carries `displayName` unchanged. A
|
|
421
|
+
session added through Add to workspace / `lanes register` is renamed in its `registered-sessions.json` record
|
|
422
|
+
instead (same id, same binding); an unregistered session in its `user-sessions/<id>.json`.
|
|
247
423
|
- **Promote (`P`) vs Add to workspace (`a`):** `P` declares an UNREGISTERED tmux session in the board's single
|
|
248
424
|
repository `terminals.json` (it then gets a repository lane id, `<ws>--<slug>`); `a` keeps the session's id. `P` is
|
|
249
|
-
refused on any lane that is already registered — a repository lane or an added session
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
425
|
+
refused on any lane that is already registered — a repository lane or an added session — and the writer also refuses
|
|
426
|
+
a session the file itself already binds under any terminal name (`lanes_lib.declared_tmux`, as `build_registry`
|
|
427
|
+
reads it): ` Already declared as 'name'; lanes sync lists it`, nothing written. The registry lists a promoted session
|
|
428
|
+
only after the next sync, so until DEF-1762 a `P` on another device, or on this one after its row let go, declared a
|
|
429
|
+
second terminal bound to the same session and the sync built two lanes for it. It asks the name first
|
|
430
|
+
(` › Register as: name█`, prefilled; `Enter` accepts — the terminal is declared under that name), then confirms in
|
|
431
|
+
the footer's one confirmation shape: ` › register ‘name’ — y⏎ confirm`, `y` arms (` › register ‘name’ — ⏎
|
|
432
|
+
confirm`), `Enter` writes; any other key — an arrow and a click included, as at a close — cancels. The name
|
|
433
|
+
reaches `session_actions.promote_to_registered(…, name=)`, which returns the id sync is expected to give the lane
|
|
434
|
+
(`promoted_id`: `lanes_lib.lane_id_for` over the repository prefix + the name). The sidebar starts `lanes sync`,
|
|
435
|
+
keeps its handle, and does NOT reload: the row stays in Unregistered under its old id with the new name and the
|
|
436
|
+
`pending` mark (`Sidebar.pending` old id → expected id; `pending_rows`) — kept even when one status.json omits it, and
|
|
437
|
+
also when the sync cannot be started (statusd's hot sync starts one on the changed file) — until `reload()` sees the
|
|
438
|
+
registry list a lane holding the session's binding (`settle_pending`; the binding decides, since sync suffixes an id
|
|
439
|
+
the board already has with `-2`), and the cursor on the row lands on that lane. Not listed after `PENDING_S` with the
|
|
440
|
+
sidebar's own sync ended (`expire_pending` on the tick, which `poll()`s and reaps it): the mark goes, ` ‘name’ not
|
|
441
|
+
listed yet — lanes sync`. `PENDING_S` = 80 s is the latest a sync can still list it, summed from the bounds where they
|
|
442
|
+
are enforced: the sidebar's sync can find the lock held (the 2-minute timer's sync on a slow docker daemon) and end
|
|
443
|
+
after `lanes_lib.REGISTRY_LOCK_S` (5 s) with nothing published; statusd starts the next at its first post-cycle check
|
|
444
|
+
`HOT_SYNC_EVERY_S` (10 s) admits — after its slowest pause (12 s) and one cycle that can hold a docker inspect
|
|
445
|
+
(`lanes_lib.DOCKER_INSPECT_S`, 20 s) and a probe round (7 s) — and that sync waits for the lock and docker again
|
|
446
|
+
(25 s); + 6 s for the files. It was 30 s until DEF-1762, which a slow docker overran. While pending the row takes no
|
|
447
|
+
verb but open (` registering ‘name’ — a moment`). `a` keeps the cursor on the session it registers; Esc / Cancel in
|
|
448
|
+
the picker put it back on the session (`cancel_registration`).
|
|
449
|
+
- **Slow verbs run on the sidebar's worker thread** (cockpit v3 S9, DEF-1763): the close (`session_actions.close_session`:
|
|
450
|
+
up to `lanes_lib.REGISTRY_LOCK_S` = 5 s for `registry.lock`, then up to three tmux answers of 5 s), `P`'s write
|
|
451
|
+
(`promote_to_registered` and the `lanes sync` spawn, `sidebar.promote_job`) and New shell (`session_actions.new_shell`:
|
|
452
|
+
`docker exec` up to 15 s) used to run inside the key's handler and froze the frame for as long. `sidebar.Worker` is
|
|
453
|
+
one daemon thread, started by the first such verb, serial (two closes never contend for the lock inside one
|
|
454
|
+
process); a job's backend is bound when the key is pressed and the job never touches the sidebar's state. The answers
|
|
455
|
+
go to a `queue.Queue` that `run()`'s tick drains without waiting (`Sidebar.finish_actions`, after `reload()`, before
|
|
456
|
+
`expire_pending()`) — so an answer shows within one tick (≤ 1 s, or at the next key). `Sidebar.actions` maps a job's
|
|
457
|
+
key (the row's id, or `<ws>--new-shell`) to what it acts on: the row carries the `pending` mark (`closing_rows`,
|
|
458
|
+
S8's `promoted_rows`, the New shell row) and `still_pending` refuses every verb there — ` closing ‘name’ — a moment`,
|
|
459
|
+
` registering ‘name’ — a moment`, ` creating a shell — a moment` — keeping the keyboard; a row being closed refuses
|
|
460
|
+
to open too. A close and a `P` hand the keyboard back at the `Enter` (S3). The close's session lives until the
|
|
461
|
+
worker's kill, so `close_lane` first points every viewport of this view that shows it at `!empty` (`empty_views`,
|
|
462
|
+
`sidebar.EMPTY_VIEW` — the target `clear_viewers` writes — via `lanes_lib.show_lane`, which wakes view.sh): the
|
|
463
|
+
hand-off lands on view.sh's `No lane selected` screen, whose wait discards keys, never in the shell being killed
|
|
464
|
+
(S9 fix cycle 1, DEF-1767; before S9 `clear_viewers` ran before `release_focus`). `clear_viewers` still empties other
|
|
465
|
+
devices' views after the kill; a refusal points each viewport still at `!empty` back at the session (`closed`,
|
|
466
|
+
`give_back`). A target that cannot be written (a full disk) refuses the close at the `Enter` — nothing handed to the
|
|
467
|
+
worker, the keyboard kept — and a give-back that cannot be written leaves the view on `No lane selected`. A new
|
|
468
|
+
shell keeps the keyboard and is opened by its answer only while the keyboard is still on the sidebar (`keys_here`),
|
|
469
|
+
nothing is open there, and its viewport still shows what it showed at `+` — else it is listed and the cursor moves
|
|
470
|
+
onto it from the New shell row. A shell statusd has already listed by the tick that takes the answer (that tick's
|
|
471
|
+
`reload()` runs first) is that row — matched by binding (`listed_row`), its observation kept — never a second one
|
|
472
|
+
(DEF-1767). A `P`'s row is held from the `Enter` (`hold_promoted` with no id yet); its answer adds the expected id
|
|
473
|
+
and the sync's handle, a refusal lets the row go with the writer's words; `expire_pending` never lets go while the
|
|
474
|
+
write is out. Any exception a job raises is its answer (` <message>`, or the exception's type): the worker lives on.
|
|
475
|
+
Lock waits stay bounded where the lock is taken and happen on the worker; the UI thread never waits for the worker.
|
|
476
|
+
(`a`, `u`, `z` and `R` on an added session still take the lock on the UI thread, bounded by the same 5 s — unchanged
|
|
477
|
+
by S9.) The process exits with its daemon thread: a sidebar that ends in the middle of a close can leave that
|
|
478
|
+
session's `resolve.dynamic/<id>` and `user-sessions/<id>.json` behind, as a sidebar killed during a close always
|
|
479
|
+
could.
|
|
480
|
+
- **The sidebar footer** (bottom-up: footer, rule, strip row, upload strip; spec §4.6) shows ONE line by priority, from
|
|
481
|
+
ONE `Sidebar.footer_text()` that the phone draws too (cockpit v3 S6; until then the phone showed no Rename, Register
|
|
482
|
+
as or register confirmation, DEF-1821): the open prompt — the sidebar holds one, `Sidebar.prompt` = `{verb, entry,
|
|
483
|
+
stage, value, armed}`; a name field ` › Rename: …█` / ` › Register as: …█` (a long name shows its end after `…`), a
|
|
484
|
+
confirmation ` › close ‘name’ — y⏎ confirm · Esc cancel` / ` › register ‘name’ — …` (`⏎ confirm` once `y` armed it;
|
|
485
|
+
` cancel`, then ` · Esc` give way and then the name is clipped before the keys would be) > ` Choose a workspace ·
|
|
486
|
+
Esc cancels` (phone: `Tap Cancel`) > the filter ` /abc · 3 matches` > the health banner > the version note
|
|
487
|
+
` server 1.2.0 · view 1.1.0 — press ?` (cockpit v3 V2: `Sidebar.version_note`, while `current/VERSION` — re-read every
|
|
488
|
+
30 s by `recheck_version`, never per tick — differs from the VERSION of the tree the sidebar runs; ` — ?` at 36
|
|
489
|
+
columns; not while an update is ready, whose rung says what to do) > ` ⚠ registry: …` > the last
|
|
490
|
+
message > the undo offer ` Unregistered ‘name’ · z undo` (`Sidebar.undo`, 10 s after `u`: `UNDO_S`,
|
|
491
|
+
`Sidebar.expire_undo` on the tick) > ` update ready · reload (r)` (W9 U6: an update changed this view's layout while
|
|
492
|
+
it was attached — `clients/<device>.update-ready`, looked at once per `UPDATE_POLL` 30 s; `r` rebuilds the view; desktop
|
|
493
|
+
and phone) > the legend ` ⏎ open · / find · x close · ? keys` (phone: ` Swipe to scroll ·
|
|
494
|
+
Tap to open`). The last
|
|
495
|
+
message lasts 3 s (`MESSAGE_S`, measured on the 1 s tick: `Sidebar.expire_message`) or until the next action begins
|
|
496
|
+
(`Sidebar.begin_action`: open, `x`/`×`, `R`, `P`, `a`/`+`, `u`, `z`, `p`, `h`, `H`, `o`, `/`, `U`, `+`, `r`, `?`, `Tab`, a chord) — never just until
|
|
497
|
+
a key: a cursor move, typing in a field, `Esc`, a focus report, the theme's OSC 11 answer, a wheel tick or a mouse
|
|
498
|
+
release leaves it (DEF-1822; DEF-1815 before it kept it from the focus report only). `?` shows
|
|
499
|
+
the key sheet (` lanes <current> · view <tree>` first — cockpit v3 V2 — then `sidebar.KEY_SHEET`, 18 lines since R5's
|
|
500
|
+
`p h` / `H o` and V2's `u z`: the user guide's "Sidebar keys" table in short — companion keys on one line,
|
|
501
|
+
PageUp/PageDown left to the table — ending with `Ctrl-Alt-l`) in place of the list until the next key; the body of a
|
|
502
|
+
pane under 24 rows is shorter than the sheet and cuts its last lines. Every key on it and in the guide's table acts in `handle()`, and no other
|
|
503
|
+
key a keyboard sends does (`test_sidebar.DocumentedKeys`, cockpit v3 S10). A filter with no hits says ` no lane matches ‘abc’ · Esc`; a
|
|
255
504
|
workspace with no lanes says ` no lanes declared · + New shell`. Above the rule, when the pane has 18 rows or more,
|
|
256
505
|
the strip row is the fleet economy line when `economy.json` knows a fleet fact (` $8.5k/16k 53% · F2 S1 · spill 76%`,
|
|
257
506
|
see "Economy projection"); otherwise it names the OTHER workspaces whose agents need you (` ◆1 Worlds · ◇2 Code`,
|
|
258
|
-
from the status.json already read). Under `LANES_GLYPHS=ascii` every glyph, separator and quote has a seven-bit twin
|
|
259
|
-
`
|
|
507
|
+
from the status.json already read). Under `LANES_GLYPHS=ascii` every glyph, separator and quote has a seven-bit twin
|
|
508
|
+
(` > close 'sh' - y+Enter confirm` — `⏎` is the word `Enter`, so `y` and it are joined by `+` — then
|
|
509
|
+
` > close 'sh' - Enter confirm - Esc` once armed, and ` Enter open - / find - x close - ? keys`).
|
|
510
|
+
- **Typed text landing in the sidebar** (a sentence became `R`ename, `P` or `x` + `y`): since cockpit v3 S3 the sidebar
|
|
511
|
+
hands the keyboard back to its viewport after every action that ends (open, close, rename, register, cancel, `Esc`,
|
|
512
|
+
`r`, closing `?`, a drop, a click that opens no lane) with ONE focus move (`zellij action move-focus right` to the
|
|
513
|
+
pane's own viewport; on a phone nothing moves — the picker tab keeps the keyboard and only opening a lane switches
|
|
514
|
+
tabs), and drops the rest of the read the action came in. A prompt, the filter, the armed upload strip and a
|
|
515
|
+
refused key keep the keyboard; the wheel and a mouse release never move it (they reach the pane under the pointer,
|
|
516
|
+
focused or not). Close and register need `y` THEN `Enter`. If keys still land in the sidebar, the focus was put
|
|
517
|
+
there by a click, `Ctrl-Alt-l`, `Ctrl-Alt-/` or `Ctrl-Alt-u`; since cockpit v3 S4 the footer says so while it is
|
|
518
|
+
there (` ▌ keys go to sidebar · Esc → lane`, from Zellij's focus reports, else for 3 s after a key).
|
|
519
|
+
- **A key typed after leaving a Rename / Register-as name went into the name** (`R`, then `→` or a click on a row, then
|
|
520
|
+
`j` and `Enter` renamed the session `shj`): fixed in cockpit v3 S6 fix cycle 1 (DEF-1824). No prompt outlives the
|
|
521
|
+
keyboard: `Sidebar.prompt_sequence` answers every escape sequence under the prompt at either stage — a release, a
|
|
522
|
+
wheel tick, motion or a cut report is no answer (consumed; nothing scrolls); a press ends the prompt first and then
|
|
523
|
+
acts (a click on a row opens that lane), except a click on the upload strip beside a name field, which arms the strip
|
|
524
|
+
and leaves the field; at a confirmation every other sequence cancels too, while a name field types nothing for
|
|
525
|
+
`→` `↑` `↓` `PageUp` `PageDown` `Shift-Tab` `←` `Home` `End` `Delete` (Enter, Space, `j`/`k`/`J`/`K` and `Tab`
|
|
526
|
+
already belonged to it). `Sidebar.hand_off` closes any prompt still open, silently, before the focus moves. If a
|
|
527
|
+
field still survives a lane opening, that sidebar process started before the fix and keeps its code (its wrapper,
|
|
528
|
+
`libexec/sidebar`, restarts `sidebar.py` only when it exits): `lanes rebuild <device>` starts it afresh.
|
|
529
|
+
- **`Ctrl-Alt-/` or `Ctrl-Alt-u` did nothing:** the chord runs `lanes sidebar-key filter|upload --focused`, which
|
|
530
|
+
writes `~/.local/state/lanes/views/<device>/<workspace>.sidebar.cmd` only when the active tab has a sidebar that
|
|
531
|
+
holds the focus after the three lefts (not on Host or a tool tab, not when the focus was in another floating pane);
|
|
532
|
+
the sidebar acts on it within a second, or at the next key. `lanes sidebar-key filter --focused --device <device>`
|
|
533
|
+
on the server says what it found on that device's active tab (exit 1 with the reason). A terminal that sends
|
|
534
|
+
`Ctrl-Alt-/` as the legacy `Esc` + `Ctrl-_` pair never triggers the bind (Zellij 0.45.1 reads it as other keys).
|
|
535
|
+
- **What a paste does in the sidebar:** a bracketed paste is routed by what is open when it arrives,
|
|
536
|
+
at its place in the read (`Sidebar.pasted`). The armed upload strip takes it as the drop; an open ` › Rename: `,
|
|
537
|
+
` › Register as: ` or filter field types it (line breaks and tabs as one space, never `Enter`; other control
|
|
538
|
+
characters dropped) and keeps the keyboard; with no field open (a confirmation has none) it is a drop.
|
|
260
539
|
- **A newer layout added a tab (e.g. `Throttle`):** `lanes upgrade-session` adds it to the live sessions without a
|
|
261
540
|
rebuild and leaves you on the tab you were on. It never changes an existing tab's panes: a changed pane (e.g. the
|
|
262
541
|
phone picker after plan Task 26, which `lanes doctor` flags) needs `lanes rebuild <device>`.
|
|
542
|
+
- **After an update flipped `current` (cockpit v3 U5):** a sidebar started through `…/current/libexec/sidebar` re-reads
|
|
543
|
+
`current/VERSION` every 30 s (`Sidebar.recheck_version`, through W9 U0's readers: `lanes_lib.installed_tree()` is
|
|
544
|
+
the `current` link whatever it names now, `lanes_lib.tree_version` its VERSION, first line and control characters
|
|
545
|
+
dropped for the screen by `sidebar.shown_version`); when it differs from its own tree's, the path it started
|
|
546
|
+
through now resolves to another tree (`LAUNCH_TREE` vs `lanes_lib.REPO`, a realpath on that tick only) and the
|
|
547
|
+
sidebar is idle (`Sidebar.idle`: no prompt, typed filter, armed strip, picker, key sheet, half-read paste, worker
|
|
548
|
+
action, held promotion, undo offer, upload, profile retry or `r` rebuild on its way — W9 U6's rebuild ends the
|
|
549
|
+
sidebar with its view), it writes `views/<device>/<ws>.<slot>.sidebar.state` (0600) and exits
|
|
550
|
+
75; `libexec/sidebar` re-executes itself at once from its logical directory (`pwd -L`), so the new tree's wrapper and
|
|
551
|
+
sidebar run, and `__init__` takes and deletes the state (cursor lane, `top`, seen marks merged, filter). Never from a
|
|
552
|
+
forced reload, never in the first 30 s of a process (no exec loop), never when the start path is a `pkg/<version>`
|
|
553
|
+
tree (the footer's version note stays instead — `lanes rebuild <device>`). A full disk writes no state and exits
|
|
554
|
+
nothing; the next recheck tries again. `view.sh` traps `USR2` before it writes its pid file: the signal only sets a
|
|
555
|
+
flag, and the next NATURAL exit of its child (the lane's viewer detaching, a `USR1` switch, a status screen's wait)
|
|
556
|
+
runs `exec "$0" <device> <ws> <slot> [--mobile]` — same pid, the path it was started by (through `current` since U1)
|
|
557
|
+
— unless that script is gone. A `view.sh` from before U5 has no `USR2` trap: the default action ends it, so only a
|
|
558
|
+
view whose tree has the trap may be sent the signal.
|
|
263
559
|
- **A new workspace:** `lanes workspaces add <id> --container <name> --repo /home/coder/<repo>` (dry run), the same
|
|
264
560
|
command with `--write`, then `lanes install-container <name>` and `lanes sync`. Sync adds the tab to running views
|
|
265
561
|
(`upgrade-session`). A view with no attached client gets it at its next connection. Without `--write` nothing is
|
|
@@ -323,6 +619,9 @@ fails first. A question that got no answer is never published as "the lane is go
|
|
|
323
619
|
| Viewport: **THIS VIEWER DISCONNECTED — the lane's tmux server is still running** | The viewer's tmux client was dropped or could not attach in time; the lane's socket is still there. That is ALL this headline claims: the retry below it is what learns whether the session itself survived, and when the holder then answers `is not provisioned`, nothing says the lane is untouched (a lane killed elsewhere — a Throttle Studio `Stop`, an operator's `kill-session` — leaves the server up, and the old headline claimed the lane still ran, DEF-965). The reason and a health hint follow; it retries by itself. | Nothing, unless the retries keep saying `is not provisioned` — then the lane is gone: pick another in the sidebar. |
|
|
324
620
|
| Viewport status screen WITHOUT that line | The lane's tmux socket is really gone. | "A dead lane, or a blind harness?" below. |
|
|
325
621
|
| Client window: `connecting…`, later a hold screen with a countdown | `lanes-launch` never closes the window by itself. ssh failed, or the server dropped a slow client (zellij does that with exit 0), or the server refused (exit 64–78): it holds, re-attaches when the countdown ends, backs off up to 60 s. `Enter` = now; closing the window = quit. Log: `~/.local/state/lanes-client/launch-<target>.log`. | Leave it open. |
|
|
622
|
+
| Client window: `lanes: the update to <version> did not finish (exit N) — this window goes on with <old>; windows try again in 6 h, or now: npx @vibelord/lanes@<version>`, or `… asking npm for it failed (exit N, <npm code>) …`, or `… no npx and npm (Node.js 18 or newer) …`, or `… not updating by itself: … install-options was written by a newer installer …` | The window's self-update could not install the server's release: before it connects, at most every 6 h, a window whose client is older than the server (`lanes hello --client-version` answered ` update=<version>`) asks npm whether it has that release (`npm view @vibelord/lanes@<version> version --fetch-retries=0`, 15 s) and then runs `npx --yes @vibelord/lanes@<version>` with the remembered install options (10 min; when that runs out, or the window closes, every process of the install is ended, not only npx). npm, the npm wrapper or `install.sh` failed or ran out of time (their own output is above the line; exits 3 and 4 count as installed), npm could not be asked (offline, a proxy, the registry: the code is npm's), no npx with its npm beside it was found on the window's PATH nor through the login shell's profile, or `install-options` has a newer `SCHEMA`. The window goes on with the client it has; the next question comes 6 h after this one (`~/.local/state/lanes-client/checked`). | `npx @vibelord/lanes@<version>` in a terminal (the whole install, on screen; your remembered options are read again) — `lanes-launch update` installs npm's `@latest`, which need not be that release; the `self-update:` lines in `~/.local/state/lanes-client/launch-<target>.log`. `LANES_NO_AUTOUPDATE=1` in the window's environment turns it off. |
|
|
623
|
+
| Every client keeps attaching with `lanes client <old> is older than the server (<version>) — run lanes-launch update`, nothing updates by itself, and `lanes-launch update` changes nothing; the client log has `self-update: the server runs <version>, which npm does not have (yet): not installed` | The server runs a release that is not on npm: a checkout server names its `lanes/VERSION` as soon as it is pulled, which can be before `npm publish` (the publish is an operator gate; CI only packs the package). The windows install nothing and say nothing about it (no client can change it); each asks npm again 6 h after its last question. | Publish that release (`npm view @vibelord/lanes versions` shows what npm has): the clients follow within 6 h, with no step on them. Or keep the server on the published release until the publish. |
|
|
624
|
+
| Joining with an invite: `lanes-install: the server runs <version>, which npm does not have (yet): installing <old> from this package instead`, or `… asking npm for it failed (exit N, <npm code>) …`, or `… no npx with its npm is on PATH to fetch that installer …` | The enrolment-first update (W9 K3): the installer read the server's release from `/enrol/site`, found it newer than the package, and could not fetch that release's installer — it is not on npm (a checkout server names its `lanes/VERSION` before the publish), npm did not answer, or the machine has no npx beside its npm. Nothing was lost: the package that ran installed the client and redeemed the code as before (`server runs <version>: fetching that installer first` before it is that run's hand-over, which installed nothing). | Nothing on the client: a window updates it once npm has the release (`lanes-launch` self-update). If the release is not on npm, publishing it is the operator's step. `LANES_NO_SELF_UPDATE=1` skips the question. |
|
|
326
625
|
| Upload strip ` ✗ no upload helper` (the old upload box: `no upload helper`) while windows were closing | The helper is a per-machine service now (launchd `ai.beyondwork.lanes-helper` / `systemctl --user status lanes-helper`) and survives the window. It counts as serving while `~/.local/state/lanes-client/helper.alive` is younger than 90 s. | `./install.sh --check` on the client. |
|
|
327
626
|
| Linux client, key with a passphrase: `journalctl --user -u lanes-helper` shows `Permission denied (publickey)` | The user manager has no SSH agent (or one without the key). The helper looks up the agent the user manager holds before every login, so an agent the desktop session hands over after boot (GNOME or KDE keyring, uwsm) is used from its next attempt, at most a minute later — the journal then says `upload helper: SSH agent of the user manager: <socket>`. The installer hands one over and the `# >>> lanes agent hand-off >>>` block in the login profile repeats it at each local login, but only when `SSH_AUTH_SOCK` is set there — an agent started later that never reaches the user manager (for example `ssh-agent` from a compositor's autostart) is not seen. | From a terminal whose agent holds the key: `systemctl --user import-environment SSH_AUTH_SOCK` (the helper takes it at its next attempt; `systemctl --user restart lanes-helper` for at once), or re-run `./install.sh`. |
|
|
328
627
|
| `./install.sh --check` warns `upload helper: uploads are off, but the service still runs` | Uploads are off — `install-options` says `UPLOADS=0`, or `--check` was given `--no-uploads` — while `lanes-helper` is still active: an install that turned uploads off could not stop it, or the service was started by hand. | `./install.sh --no-uploads` (it takes away what uploads-off leaves running; a bare `./install.sh` repeats the remembered choice, which may still be `UPLOADS=1`); by hand: `systemctl --user disable --now lanes-helper.service lanes-upload.path` (macOS: `launchctl bootout gui/$(id -u)/ai.beyondwork.lanes-helper`). |
|
|
@@ -447,7 +746,8 @@ Coexistence mode can be turned back on at any time with `touch ~/.local/state/la
|
|
|
447
746
|
(`LANES_GLYPHS=ascii` for any font; the blocks a font needs are listed under "TUIs and the terminal's colours"
|
|
448
747
|
below). The Zellij bar runs with `simplified_ui true`, so it draws no Private-Use-Area chevrons either.
|
|
449
748
|
- Look of the window: `lanes-window --ascii`, `--light`, `--dark` (combinable with a suffix: `lanes-window --ascii w2`)
|
|
450
|
-
— see "TUIs and the terminal's colours" below.
|
|
749
|
+
— see "TUIs and the terminal's colours" below. On a device with an owner it goes to that person's profile;
|
|
750
|
+
`--device-only` keeps it on this device (`lanes-window --dark --device-only w2`).
|
|
451
751
|
- Hyprland: one line `require("hypr.lanes")` at the end of `~/.config/hypr/hyprland.lua` + `~/.config/hypr/lanes.lua`
|
|
452
752
|
(validated with `hyprctl configerrors`; backup `hyprland.lua.pre-lanes.*`). Pin it to a workspace by uncommenting
|
|
453
753
|
the `o.window` line in `lanes.lua`.
|
|
@@ -484,35 +784,74 @@ night sky of single-dot Braille "stars". Started with no client attached it pain
|
|
|
484
784
|
lanes window (Ghostty `theme = light:…,dark:…` follows the OS and therefore flips twice a day).
|
|
485
785
|
|
|
486
786
|
The sidebar's own colours and glyphs come from one token table (`libexec/tokens.py`; spec
|
|
487
|
-
`docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md` §2.4) and
|
|
787
|
+
`docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md` §2.4) and these switches, resolved per device (the
|
|
788
|
+
notification switch `--notify` / `LANES_NOTIFY` resolves the same way, above):
|
|
488
789
|
|
|
489
790
|
| Switch | Values | Default |
|
|
490
791
|
|---|---|---|
|
|
491
|
-
| `LANES_COLOR` / `--color` | `truecolor` · `16` · `none` | `truecolor` when `COLORTERM` is `truecolor`/`24bit`, else `16` (your terminal's own palette); `NO_COLOR` set ⇒ `none` (glyphs only) |
|
|
792
|
+
| `LANES_COLOR` / `--color` | `truecolor` · `256` · `16` · `none` | `truecolor` when `COLORTERM` is `truecolor`/`24bit`, else `256` when `TERM` contains `256color` (fixed xterm-256 colours 16–255, never your palette's 0–15), else `16` (your terminal's own palette); `NO_COLOR` set ⇒ `none` (glyphs only). Inside a view that default is always `truecolor`: see "256 colours inside a view" below |
|
|
492
793
|
| `LANES_THEME` / `--theme` | `dark` · `light` · `auto` | `auto`: the sidebar asks the terminal's background once at start (OSC 11) and paints dark until the answer; no answer within 0.3 s ⇒ dark |
|
|
493
794
|
| `LANES_GLYPHS` / `--ascii`, `--glyphs` | `unicode` · `ascii` · `emoji` | `unicode`; `ascii` when none of `LC_ALL`/`LC_CTYPE`/`LANG` names UTF-8 |
|
|
494
|
-
| `LANES_DIM` | `90` · `2` | `90`; 16-colour mode only: `2` is the escape hatch for a palette whose colour 8 is near the background |
|
|
495
|
-
|
|
496
|
-
- Set it per device, live: `lanes attach DEVICE --ascii` (or `--glyphs M`, `--theme T`, `--color C
|
|
497
|
-
choice in `~/.local/state/lanes/devices/<device>.json` (only for a device that already has
|
|
498
|
-
refuses before anything is created). Every running sidebar of that device re-reads the file
|
|
499
|
-
restart. The choice survives a recreated session (`lanes ensure` after a reboot,
|
|
500
|
-
variables never reach a running sidebar: Zellij panes keep the server's environment
|
|
501
|
-
was created.
|
|
502
|
-
|
|
503
|
-
|
|
795
|
+
| `LANES_DIM` / `--dim` | `90` · `2` | `90`; 16-colour mode only: `2` is the escape hatch for a palette whose colour 8 is near the background |
|
|
796
|
+
|
|
797
|
+
- Set it per device, live: `lanes attach DEVICE --ascii` (or `--glyphs M`, `--theme T`, `--color C`, `--notify N`,
|
|
798
|
+
`--dim D`) records the choice in `~/.local/state/lanes/devices/<device>.json` (only for a device that already has
|
|
799
|
+
that file; a bad value refuses before anything is created). Every running sidebar of that device re-reads the file
|
|
800
|
+
within one tick — no restart. The choice survives a recreated session (`lanes ensure` after a reboot,
|
|
801
|
+
`lanes rebuild`). Environment variables never reach a running sidebar: Zellij panes keep the server's environment
|
|
802
|
+
from the moment the session was created. `bin/lanes` `clean_env()` forwards `LANES_COLOR`, `LANES_THEME`,
|
|
803
|
+
`LANES_GLYPHS`, `LANES_DIM` and `LANES_NOTIFY`, when set, from the shell that creates a session (`lanes attach` of
|
|
804
|
+
a device without a running session, `lanes ensure`, `lanes rebuild`; cockpit v3 P4, DEF-1577), so
|
|
805
|
+
`LANES_NOTIFY=0 lanes rebuild DEVICE` silences a device until its next recreation. The device file wins over the
|
|
806
|
+
environment.
|
|
807
|
+
- The sidebar publishes what it resolved: `clients/<device>.theme` (`dark`/`light`; for `glow` and `view.sh`),
|
|
808
|
+
`clients/<device>.glyphs` (`unicode`/`ascii`/`emoji`; read by `view.sh`) and `clients/<device>.color`
|
|
809
|
+
(`truecolor`/`256`/`16`/`none`; the stamp `view.sh` picks its colour set by, with `.theme`, cockpit v3 P3). Each is
|
|
810
|
+
compared by content whenever the sidebar resolves (start, a device-file change, the OSC 11 answer or its 0.3 s
|
|
811
|
+
grace), so a stamp an earlier attach left — a light terminal's `light` — is replaced within about a second of the
|
|
812
|
+
next sidebar start, also when that terminal answers dark (DEF-1775); an equal stamp is not rewritten.
|
|
813
|
+
- In `truecolor` and `256` the sidebar spends the palette: section words in their section's tone with the `(n)` dim,
|
|
814
|
+
lane names in ink (an identity colour still wins), the age in its section's tone (dim only outside Needs you /
|
|
815
|
+
Working / Done), the stream glyph in its own tone beside an agent (dim only when stale), and the cursor row on the
|
|
816
|
+
`selected` background — the `▌` stays, and column 36 is never painted. `16` and `none` look exactly as before:
|
|
817
|
+
the 16 colours are your terminal's, and its yellow may be unreadable on a light background.
|
|
818
|
+
- 256 colours inside a view are chosen, never detected (cockpit v3 P5). config.kdl's `env` block gives every pane
|
|
819
|
+
`COLORTERM=truecolor`, and the sidebar reads `COLORTERM` before `TERM`, so the `256color` rule applies
|
|
820
|
+
only outside Zellij — a `libexec/sidebar` started by hand in a plain terminal; so does `NO_COLOR`, which
|
|
821
|
+
`clean_env()` does not forward. On a terminal that cannot show 24-bit colour, choose the 256 set:
|
|
822
|
+
`lanes attach DEVICE --color 256` (live, per device, kept across `lanes ensure` and `lanes rebuild`), or
|
|
823
|
+
`LANES_COLOR=256 lanes rebuild DEVICE` (the environment of the shell that creates the session, until its next
|
|
824
|
+
recreation). It is fixed xterm colours 16–255, the `256 dark` / `256 light` columns of `docs/DESIGN.md`, the cursor
|
|
825
|
+
row's band included; `clients/<device>.color` then says `256`, and the viewport's status screens follow.
|
|
826
|
+
`--color none` is the glyphs-only look for a view. Proven minus zellij by
|
|
827
|
+
`test_server_cli.LookEnvironment.test_inside_a_view_256_colours_are_chosen_never_detected`.
|
|
504
828
|
- The terminal's answer to the OSC 11 question is taken out of the input before any key is read, also when it is
|
|
505
829
|
split across two reads, so it never cancels a filter or a prompt.
|
|
506
830
|
- Dim text is SGR 90 in 16-colour mode (never SGR 2, which several terminals render as invisible). A palette whose
|
|
507
|
-
colour 8 is close to the background (classic Solarized dark) can set `LANES_DIM=2`
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
`lanes
|
|
511
|
-
|
|
831
|
+
colour 8 is close to the background (classic Solarized dark) can set `LANES_DIM=2` (or `lanes attach DEVICE
|
|
832
|
+
--dim 2`) as the escape hatch.
|
|
833
|
+
- From the Mac or Omarchy window: `lanes-window --ascii`, `--light` or `--dark` (with `--device-only`, before an
|
|
834
|
+
optional suffix: `lanes-window --dark w2`) hands `--ascii` / `--theme light|dark` (and `--device-only`) to
|
|
835
|
+
`lanes-launch`, which passes them to `lanes attach` — recorded as above: per device on a device without an owner or
|
|
836
|
+
with `--device-only`, else in the owner's profile, which every sidebar of that person's devices paints within a tick
|
|
837
|
+
(beneath a device's own `--device-only` keys; above the environment, and above a look a device recorded before
|
|
838
|
+
its profile existed — `lanes user show <login>` names each device's own look, `lanes device claim <device> --login
|
|
839
|
+
<login>` folds one from before the profile). `lanes-window --device-only`
|
|
840
|
+
alone refuses (exit 64). `lanes-launch DEVICE --ascii|--theme T|--glyphs M|--color C|--notify N|--dim D|--device-only`
|
|
841
|
+
takes them directly, and `--editor E` too (the editor `Ctrl-Alt-e` starts, always per device: `micro`, `hx`, `nvim`,
|
|
842
|
+
`vim`, or `auto` to take the choice back); a bad value refuses before any connection, an unknown option is named and
|
|
843
|
+
dropped.
|
|
512
844
|
- The viewport's own status screens (`view.sh`: "THIS VIEWER DISCONNECTED", "No lane selected", "retrying in …") use
|
|
513
845
|
the same tokens (`libexec/tokens.sh`, sourced once, no fork): a bold lead with `⚠` — `!` when
|
|
514
|
-
`clients/<device>.glyphs` says `ascii
|
|
515
|
-
|
|
846
|
+
`clients/<device>.glyphs` says `ascii` — and a dim hint line. The device's stamps are read on every screen with the
|
|
847
|
+
shell's builtin `read`, so `lanes attach DEVICE --ascii|--color C|--theme T` reaches the next status screen without
|
|
848
|
+
a restart. `.color` and `.theme` pick the colour set (cockpit v3 P3): in `truecolor` and `256` of the device's
|
|
849
|
+
variant the hint is that set's ink-dim and the lead takes the warning's amber; `none` keeps bold only; `16`, a
|
|
850
|
+
missing stamp, or a vivid `.color` whose `.theme` is not (yet) `dark`/`light` is the 16-colour screen as before —
|
|
851
|
+
the hint in SGR 90 (never SGR 2) and the lead plain bold. Stuck on 16 colours on a truecolor device? On the server,
|
|
852
|
+
`cd ~/.local/state/lanes/clients && cat <device>.color <device>.theme`: no `.theme` means the sidebar has not
|
|
853
|
+
resolved a variant yet. A `.theme` that disagrees with the sidebar beside it for more than a second or two after a
|
|
854
|
+
sidebar start is a defect (DEF-1775 closed that path), not a stale attach to wait out.
|
|
516
855
|
- Fonts: no Nerd Font is needed. Any monospace font with Latin-1, Box Drawing (U+2500 `─`), Geometric Shapes
|
|
517
856
|
(U+25A0–25FF `● ○ ◆ ◇ ▸ ▌ █`), Dingbats (`✓ ✗`), Arrows (`↑ ↓`) and `⋮ ⏎ ⚠ …` renders the cockpit (JetBrains Mono,
|
|
518
857
|
SF Mono, Menlo, Cascadia, DejaVu Sans Mono all do); `--ascii` is the complete remedy for any other font.
|
|
@@ -623,7 +962,7 @@ What each event writes (`agent{}` vocabulary):
|
|
|
623
962
|
|
|
624
963
|
| Harness | Event | status |
|
|
625
964
|
|---|---|---|
|
|
626
|
-
| Claude Code | `UserPromptSubmit`, `PostToolUse` | `working` |
|
|
965
|
+
| Claude Code | `UserPromptSubmit`, `PreToolUse`, `PostToolUse` | `working` |
|
|
627
966
|
| Claude Code | `Stop`, `Notification` (`idle_prompt`) | `needs_input` |
|
|
628
967
|
| Claude Code | `PermissionRequest`, `Notification` (`permission_prompt`) | `needs_approval` |
|
|
629
968
|
| Claude Code | `StopFailure` | `errored` |
|
|
@@ -645,11 +984,157 @@ Codex 0.160 has no `codex --help hooks`; the names come from the binary. Not yet
|
|
|
645
984
|
loads a USER-level `~/.codex/hooks.json` the way it loads a project's (the only hooks.json files measured here are
|
|
646
985
|
project files), and what a hook's parent process is under each harness (lane-signal skips one shell either way).
|
|
647
986
|
|
|
648
|
-
Known limit (DEF-
|
|
987
|
+
Known limit (DEF-1571): after you approve a permission prompt, the lane shows `needs_approval` until the approved
|
|
649
988
|
tool call returns (`PostToolUse`), because neither harness signals "approved". A long approved command therefore
|
|
650
|
-
reads as waiting for you until it finishes.
|
|
989
|
+
reads as waiting for you until it finishes. Claude's `PreToolUse` (cockpit v3) does not close that window — it fires
|
|
990
|
+
before the permission check (a PreToolUse hook may itself allow or deny the call: `permissionDecision` is part of its
|
|
991
|
+
output schema in the 2.1.290 binary) — but it does end a `needs_approval` the turn has moved past at the start of the
|
|
992
|
+
next tool call: after a denied prompt, or an approved call that failed (`PermissionDenied` and `PostToolUseFailure`
|
|
993
|
+
write nothing). A lane whose agent crashed loses its sidecar at once (dead pid); one
|
|
651
994
|
whose agent is alive but silent keeps the last word for up to an hour.
|
|
652
995
|
|
|
996
|
+
## The agents' MCP entries (`lane-mcp`, on by default)
|
|
997
|
+
|
|
998
|
+
`lanes install-container <name>` ships `~/.lz/bin/lane-mcp` (with `lane_core.py`) and adds it to both agents in that
|
|
999
|
+
container: `mcpServers.lanes = {"command": "/home/coder/.lz/bin/lane-mcp"}` in `~/.claude.json`, and
|
|
1000
|
+
`[mcp_servers.lanes]` with that `command` and `env_vars = ["LANE_ID", "LANE_WS", "TMUX", "TMUX_PANE"]` in
|
|
1001
|
+
`~/.codex/config.toml`. `--no-mcp` leaves all of that out.
|
|
1002
|
+
Codex starts an MCP server with a cleared environment (its own short list: `HOME`, `PATH`, `USER`, `SHELL`, `TERM`,
|
|
1003
|
+
`LANG`…, plus the table's `env_vars` and `env`; MEASURED with codex-cli 0.160.1). Without the `env_vars` line,
|
|
1004
|
+
`lanes_whoami` from a Codex lane answers `lane: null` ("no LANE_ID and no TMUX/TMUX_PANE") while `lanes_list` still
|
|
1005
|
+
works. A table with our `command` that lacks any of the four names (`codex mcp add`'s, or an earlier hand edit) is
|
|
1006
|
+
not "present": the install adds the line, or appends the missing names to a one-line list, and reports `wrote`.
|
|
1007
|
+
`install-container` writes nothing through a symbolic link in the container's home (DEF-1798): a link at `~/.lz`
|
|
1008
|
+
or one of its directories stops the install with its name, a link at a contract file is replaced, and a linked
|
|
1009
|
+
dotfile (`~/.bashrc` from a dotfile manager) does not get the `env.sh` line; the command prints that line to add by
|
|
1010
|
+
hand.
|
|
1011
|
+
|
|
1012
|
+
- Only a missing entry is written. The file is kept as `<file>.bak-<UTC time>` first and replaced atomically with
|
|
1013
|
+
its own mode (a new file is 0600). The file is re-read just before the write, and Claude Code rewrites
|
|
1014
|
+
`~/.claude.json` while it runs; when the bytes changed, the edit is made once more on the new bytes, and a second
|
|
1015
|
+
change refuses. Run it again then.
|
|
1016
|
+
- Left alone, with `! <file>: <reason>` and exit 1, while the other agent still gets its entry: a file that is not
|
|
1017
|
+
strict JSON or valid TOML, an entry named `lanes` that starts something else, a `lanes` server Codex's file
|
|
1018
|
+
defines some other way than its own `[mcp_servers.lanes]` table, or an `env_vars` there that misses a name and is
|
|
1019
|
+
not one line of plain names (a multi-line list, `{name, source}` tables). Remove or fix it by hand, then run it
|
|
1020
|
+
again.
|
|
1021
|
+
- On a uid split (this account or the home volume is not uid 1000) no entry is written: the agent could not read
|
|
1022
|
+
the file. Same exit 1 and reason as `lanes hooks install` (DEF-1727). A symbolic link at `~/.claude.json`,
|
|
1023
|
+
`~/.codex` or `config.toml` is refused too, also by `lane mcp install`: from the host it would resolve outside
|
|
1024
|
+
the container. Add the entry to the file it points at by hand.
|
|
1025
|
+
- Inside the container: `~/.lz/bin/lane mcp install [--agent claude|codex|all]` prints the diff, `--write` applies it.
|
|
1026
|
+
- New agent sessions load the server; a running one keeps its tools. Check from a lane with the agent's own MCP
|
|
1027
|
+
listing (`/mcp` in Claude Code). The server's log is `~/.lz/s/logs/lane-mcp.log` (one line per request, rotated
|
|
1028
|
+
at 1 MiB; never a capture's text, a sent text or a note).
|
|
1029
|
+
- Undo: restore the `.bak-*` file, or delete the `lanes` entry (`claude mcp remove lanes -s user` does the same for
|
|
1030
|
+
Claude Code). The whole procedure, Codex included, is "Install, check, uninstall" below.
|
|
1031
|
+
|
|
1032
|
+
### Install, check, uninstall
|
|
1033
|
+
|
|
1034
|
+
What agents may and may not do through it, and the wire contract, are in
|
|
1035
|
+
[ARCHITECTURE "Agent API"](ARCHITECTURE.md#agent-api); how an agent should use it is
|
|
1036
|
+
[`skills/lanes-agent-tools`](../../skills/lanes-agent-tools/SKILL.md).
|
|
1037
|
+
|
|
1038
|
+
Install, per container (the plan's operator gate 3):
|
|
1039
|
+
|
|
1040
|
+
1. On the host: `lanes install-container <container>`, or `lanes onboard <login> --container <container> --write`
|
|
1041
|
+
for a new user. It ships `~/.lz/bin/lane-mcp` with `lane_core.py` and `VERSION`, then adds the two entries where
|
|
1042
|
+
they are missing, each file kept first as `<file>.bak-<UTC time>`. Read its `! <file>: <reason>` lines: an entry
|
|
1043
|
+
it could not write is exit 1, and the other agent still gets its own.
|
|
1044
|
+
2. Or inside the container, as the lane user: `~/.lz/bin/lane mcp install` prints the diff to both files;
|
|
1045
|
+
`--agent claude|codex` narrows it, `--write` applies it.
|
|
1046
|
+
|
|
1047
|
+
Check, in this order:
|
|
1048
|
+
|
|
1049
|
+
1. The [protocol smoke](#protocol-smoke) below: two frames in, two frames out.
|
|
1050
|
+
2. A NEW agent session: `/mcp` in Claude Code lists `lanes` as connected, and `codex mcp list` shows `lanes` with
|
|
1051
|
+
`lane-mcp` as its command. A session that was running before the install keeps the tools it started with.
|
|
1052
|
+
3. From that session, `lanes_whoami` names its lane. Each start of the server writes a
|
|
1053
|
+
`start lane-mcp <version> wire 1` line to `~/.lz/s/logs/lane-mcp.log`.
|
|
1054
|
+
|
|
1055
|
+
Uninstall, per container, as the lane user:
|
|
1056
|
+
|
|
1057
|
+
1. Claude Code: `claude mcp remove lanes -s user`.
|
|
1058
|
+
2. Codex: `codex mcp remove lanes`, or delete the `[mcp_servers.lanes]` table from `~/.codex/config.toml` by hand.
|
|
1059
|
+
3. Instead of 1 and 2, the `.bak-<UTC time>` files written at install restore each file as it was then. Do that only
|
|
1060
|
+
when nothing else in the file has changed since; Claude Code rewrites `~/.claude.json` while it runs.
|
|
1061
|
+
4. On the host, every later `lanes install-container <container>` and `lanes onboard` for this container needs
|
|
1062
|
+
`--no-mcp`. Without it, the next install writes the entries again. `--no-mcp` removes nothing by itself.
|
|
1063
|
+
5. Optional: `rm ~/.lz/bin/lane-mcp ~/.lz/s/logs/lane-mcp.log*`. Keep `lane_core.py`: `lane` imports it.
|
|
1064
|
+
|
|
1065
|
+
Agent sessions started afterwards have no `lanes_*` tools; running ones keep theirs until they end. Nothing on the
|
|
1066
|
+
host changes: `lanes sync` and `lanes-statusd` keep writing `~/.lz/registry.json` and `~/.lz/status.json` for `lane`.
|
|
1067
|
+
|
|
1068
|
+
### What an agent can change through it
|
|
1069
|
+
|
|
1070
|
+
Six of lane-mcp's tools write (LNS-35; the user guide's "Agent tools (MCP)" lists them with their refusals). What
|
|
1071
|
+
lands where, and how to take it back:
|
|
1072
|
+
|
|
1073
|
+
- `lanes_register`, `lanes_unregister`, `lanes_rename` and `lanes_new` edit a repository's `.vscode/terminals.json`
|
|
1074
|
+
inside the container: one `terminals[]` item and its `lanes.terminals` entry, or one `displayName`. The root
|
|
1075
|
+
`throttle` object, the other terminals and their order stay byte for byte. `lanes-statusd` notices the changed
|
|
1076
|
+
file within a cycle and starts one `lanes sync` (at most one every 10 s), so the boards show it within about 15 s;
|
|
1077
|
+
the 2-minute `lanes sync` timer catches anything else. Undo: `git diff` / `git checkout` the file in the
|
|
1078
|
+
repository — no `.bak` file is written. A lane an agent removed by mistake comes back with `lanes_register` from
|
|
1079
|
+
its still-running session, or the sidebar's `P`.
|
|
1080
|
+
- `lanes_new` also starts a tmux session on `lanes-shells-<board>` inside the container: the one client lane-mcp runs
|
|
1081
|
+
without `-N`, with an allow-listed environment (account, locale, `TMUX_TMPDIR`, TERM/COLORTERM as the host's
|
|
1082
|
+
`new_shell` sets them), so a server it starts hands neither a lane identity nor the agent's own variables
|
|
1083
|
+
(`CLAUDECODE`, `CLAUDE_CODE_*`, `ANTHROPIC_*`) to later sessions (DEF-1559, DEF-1802); the new session gets its
|
|
1084
|
+
own identity through `-e`, as the canonical recipe gives it. Check a board's shell server with
|
|
1085
|
+
`tmux -L lanes-shells-<board> show-environment -g` in the container.
|
|
1086
|
+
- `lanes_send` types into a lane's active pane: printable text, then Enter half a second later as its own keystroke
|
|
1087
|
+
(DEF-1803), never a control key, and not while the lane's agent is working or waiting for an approval unless the
|
|
1088
|
+
agent passes `force`. When the Enter alone fails, the answer says the text is waiting at the prompt.
|
|
1089
|
+
- `lanes_signal` writes the agent's own hook sidecar through `lane-signal` (status, and an optional note shown as the
|
|
1090
|
+
lane's task line). The report stands until the agent's next tool call, prompt or permission prompt; the end of
|
|
1091
|
+
its turn keeps it unless it is `working` (DEF-1801). `cat ~/.lz/state/hooks/<lane id>.json` shows it: `event`
|
|
1092
|
+
stays `lanes_signal` while it stands.
|
|
1093
|
+
- `lanes_publish` (and `lane publish <file>` in a lane's shell) writes a publish request,
|
|
1094
|
+
`~/.lz/state/publish/<lane id>/<id>.json`, naming a file under `/home/coder`; the docs service takes it within
|
|
1095
|
+
about 5 s and answers in `<id>.receipt.json` beside it ("Docs service", "Publish pick-up"). `lanes_status` shows the
|
|
1096
|
+
newest receipt as `published`. The file is read by the host, never sent by the agent.
|
|
1097
|
+
- Nothing an agent does ends, interrupts or attaches a lane, uploads a file, runs `lanes sync`, `lanes gc` or
|
|
1098
|
+
`lanes ensure`, renames a tmux session, or touches a session added with `lanes register` (`registered-sessions.json`
|
|
1099
|
+
on the host).
|
|
1100
|
+
- Every call is one line in `~/.lz/s/logs/lane-mcp.log` with the lane and the outcome; a refusal carries its code
|
|
1101
|
+
(`isError refused=agent_working`), so `grep refused= ~/.lz/s/logs/lane-mcp.log` shows what agents were stopped
|
|
1102
|
+
from doing.
|
|
1103
|
+
- From the host, a declared lane leaves its file through the same editor: `session_actions.unregister_lane(<id>)`
|
|
1104
|
+
(the sidebar's `u` and `lanes unregister <id>` on a declared lane, which then start / run `lanes sync`) refuses a
|
|
1105
|
+
symbolic link in the container's home, the last terminal and an adopted session with the codes the tool uses.
|
|
1106
|
+
|
|
1107
|
+
### Troubleshooting lane-mcp
|
|
1108
|
+
|
|
1109
|
+
| Symptom | Cause | What to do |
|
|
1110
|
+
| --- | --- | --- |
|
|
1111
|
+
| `/mcp` does not list `lanes`, or lists it as failed | No entry: the install skipped it (`--no-mcp`, a uid split, a foreign `lanes` entry, a file that is not strict JSON or valid TOML), or `~/.lz/bin/lane-mcp` is gone; or the session started before the install | Read the install's `! <file>: <reason>` line or run `~/.lz/bin/lane mcp install` (a dry run), fix what it names, then start a new session. The [protocol smoke](#protocol-smoke) tells a broken server from a missing entry |
|
|
1112
|
+
| `lanes_whoami` answers `lane: null` | Its reason says which. Under Codex, a table without `env_vars` (Codex clears the server's environment); outside tmux; a session `lanes sync` or the status daemon has not published yet | `~/.lz/bin/lane mcp install --agent codex --write` adds `env_vars`; otherwise wait one sync (about 15 s) and ask again |
|
|
1113
|
+
| A tool answers `contract_outdated` or "newer than this contract" | The host's Lanes is newer than the container's `lane` contract | On the host: `lanes install-container <container>` |
|
|
1114
|
+
| `lanes_list` says `fresh` is false | `~/.lz/status.json` has not been rewritten for over 60 s: the status daemon is down or cannot write into this container | On the host: `systemctl --user status lanes-statusd`; `lanes sync` prints `! container <name>: …` for a container whose `~/.lz` it refuses. Until then the states are last known, not live |
|
|
1115
|
+
| A write answers `not_in_container` | The lane belongs to another container. Each container's `lane-mcp` sees only its own lanes | Ask a person, or the agent in that container |
|
|
1116
|
+
| A write answered success but the boards do not show it after 15 s | `lanes sync` refused the board (`lanes doctor` shows board errors), or the edited file is not one the registry names, which hot sync does not watch | `lanes doctor` on the host; the 2-minute `lanes-sync.timer` retries. `git diff` the terminals.json in the container to see the edit |
|
|
1117
|
+
| `lanes_send` answered `tmux_failed` with `changed` | The text was typed but its Enter failed: it waits at the target's prompt | A person presses Enter in that lane, or clears the line |
|
|
1118
|
+
| Agents keep being refused | `grep refused= ~/.lz/s/logs/lane-mcp.log` lists every refusal with its code | Each refusal's `human` says what a person does instead |
|
|
1119
|
+
|
|
1120
|
+
### Protocol smoke
|
|
1121
|
+
|
|
1122
|
+
Two JSON-RPC frames in, two frames out, with no agent involved. In the container, as the lane user:
|
|
1123
|
+
|
|
1124
|
+
```sh
|
|
1125
|
+
printf '%s\n' \
|
|
1126
|
+
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' \
|
|
1127
|
+
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
|
1128
|
+
| ~/.lz/bin/lane-mcp
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
It prints two frames, one line each, exit 0 and nothing on stderr. Frame `id` 1 carries `protocolVersion`
|
|
1132
|
+
`2025-06-18` and `serverInfo` `{"name": "lanes", "version": <~/.lz/VERSION>}`. Frame `id` 2 lists the eleven tools in
|
|
1133
|
+
wire order (`lanes_publish`, W10 W4, last), the same tools `lanes/tests/unit/golden/lane-mcp-tools.json` pins. The list is deterministic: run the
|
|
1134
|
+
command twice through `sha256sum` and the two digests match. The log gains a `start lane-mcp <version> wire 1` line.
|
|
1135
|
+
No output at all means the server could not start, for example because `lane_core.py` is missing beside it; run
|
|
1136
|
+
`lanes install-container <container>` on the host. `test_docs.AgentApi` runs this block exactly as printed here.
|
|
1137
|
+
|
|
653
1138
|
## Economy projection (opt-in)
|
|
654
1139
|
|
|
655
1140
|
`~/.local/state/lanes/economy.json` is what each lane costs and how hard the fleet is pushing the Throttle pool:
|
|
@@ -746,8 +1231,9 @@ the next poll re-folds today. For a look without the daemon: `LANES_THROTTLE_CON
|
|
|
746
1231
|
same set the sidebar's Needs you section and its OSC 9 use — and one when someone files an enrolment request
|
|
747
1232
|
(below). It is the away-from-every-terminal half of the sidebar's OSC 9 ("Title and notifications" above): it reads
|
|
748
1233
|
only `~/.local/state/lanes/status.json` (checked every 3 s, re-read when its mtime changes), `registry.json` for
|
|
749
|
-
lane names and `enrol-requests
|
|
750
|
-
|
|
1234
|
+
lane names and `enrol-requests/` — and, when a lane enters needs-you, the presence files below (`clients/`,
|
|
1235
|
+
`devices/`, `members/`, `users/`); the one file it writes is `notify.enrol-seen.json` beside them. It never
|
|
1236
|
+
touches a lane, a session, a request, a profile or the Throttle daemon.
|
|
751
1237
|
|
|
752
1238
|
What a push says: `<lane name>: <agent task> — <status>` and, on a second line, `lanes-launch lane <id>` (the
|
|
753
1239
|
command that opens that lane from a client). No client registers a URL handler for that command, so a push is
|
|
@@ -756,7 +1242,8 @@ tappable only when you set `action_url` (below).
|
|
|
756
1242
|
When nothing is sent:
|
|
757
1243
|
|
|
758
1244
|
- the status did not change, or the lane left needs-you (a later return is news again); a change from
|
|
759
|
-
`Needs you` to `Needs approval` is news
|
|
1245
|
+
`Needs you` to `Needs approval` is news. `agent.status` is debounced by `lanes-statusd` (the sidebar's sections,
|
|
1246
|
+
above): a lane whose agent goes back to work for less than 8 s never left needs-you, so its return is not news;
|
|
760
1247
|
- the service just started: lanes already waiting when it (re)starts are not announced (no burst on restart —
|
|
761
1248
|
unlike a starting sidebar's OSC 9);
|
|
762
1249
|
- a desk device was present within `quiet_when_device_active_s` (default 120 s): a `lanes attach` from any device
|
|
@@ -768,6 +1255,22 @@ When nothing is sent:
|
|
|
768
1255
|
SSH-only phone that ran `lanes attach phone --mobile` (below) is still a mobile device, so opening a lane from
|
|
769
1256
|
the phone's picker never mutes pushes — you keep getting them while you are away from every desk (DEF-1533).
|
|
770
1257
|
Only a phone's own `lanes attach` in the last two minutes counts, like any attach.
|
|
1258
|
+
Since cockpit v3 R7 a person's profile counts too: `users/<login>.json` changed within the window by a desk
|
|
1259
|
+
device (its `updated_by_device`; same desk rule as the seen file) — the sidebar writes the mark of an opened lane
|
|
1260
|
+
there as well, so presence does not depend on `clients/<device>.seen.json`. A phone's write and a change no device
|
|
1261
|
+
made never count. A change is the device's only when it was made AT that device — its sidebar, `lanes attach`,
|
|
1262
|
+
`lanes next|prev`, `lanes here`; the admin verbs `lanes workspaces hide|show|order`, `lanes lane
|
|
1263
|
+
pin|unpin|hide|show` (with `--device`, `$LANES_DEVICE` or `--login`) and the fold of `lanes device claim`, `lanes
|
|
1264
|
+
keys adopt --login` and `lanes keys owner` stamp the profile `cli:<command>` / `cli:migration` (its
|
|
1265
|
+
`updated_by_device`, shown by `lanes user show`), so running one over SSH for somebody's desk device never mutes
|
|
1266
|
+
that person's pushes (W9 review, DEF-1833). Each piece of presence belongs to a person (the device's owner — its
|
|
1267
|
+
member record, `lanes keys owner`, `lanes device claim` — or the profile's login) and is recent within that person's
|
|
1268
|
+
own window: the profile's `notify.quiet_s` when it sets one (0 = never mute my pushes), else
|
|
1269
|
+
`quiet_when_device_active_s`.
|
|
1270
|
+
When the recent presence is one person's alone, that is presence; when it is several people's (a device nobody
|
|
1271
|
+
owns counts as one more), `quiet_when_device_active_s` decides for all of them. No `lanes` verb sets `quiet_s`
|
|
1272
|
+
yet: it is the profile's `"notify": {"quiet_s": N}` (integer 0-86400), written by hand only while no sidebar or
|
|
1273
|
+
verb is writing the same profile.
|
|
771
1274
|
|
|
772
1275
|
A failed send (server down, `HTTP 429`, no network) is one journal line naming the lane and the failure class — the
|
|
773
1276
|
topic and the token are never logged — and is not retried.
|
|
@@ -802,8 +1305,8 @@ that becomes readable switches the pushes on within one tick and a removed one s
|
|
|
802
1305
|
| `url` | required | the ntfy server, `https://…` (self-hosted or `https://ntfy.sh`) |
|
|
803
1306
|
| `topic` | required | `[A-Za-z0-9_-]{1,64}`; on a public server the topic name is the only secret — pick a long random one |
|
|
804
1307
|
| `token_file` | unset | a file holding an ntfy access token (sent as a bearer). An unreadable file disables sending rather than sending unauthenticated (or with the token it held before). Keep the token in that file (`chmod 600`), never in `notify.conf` |
|
|
805
|
-
| `quiet_when_device_active_s` | `120` | presence window above; `0` never suppresses |
|
|
806
|
-
| `action_url` | unset | a URL template over `{lane}` (the lane id, URL-quoted), e.g. a page of yours that opens it; becomes the push's tap target and an **Open lane** button |
|
|
1308
|
+
| `quiet_when_device_active_s` | `120` | presence window above; `0` never suppresses — except for the one person present when their profile sets a `notify.quiet_s` of its own |
|
|
1309
|
+
| `action_url` | unset | a URL template over `{lane}` (the lane id, URL-quoted), e.g. a page of yours that opens it; becomes the push's tap target and an **Open lane** button. `{doc}` (plan W10 W7) is the docs page of the document a `published` push names, `/lanes-docs/d/<id>` (`https://<server>:8443{doc}` opens it), empty in a needs-you push. The `published` push (plan W10 W4) is sent by the docs service's publish pick-up, not by this unit: same config, same desk-presence rule, `{title: Published, message: <lane name> published a document (<kind>, <size>) + its page, priority 2}`, at most one per lane per minute, never the document's title or name |
|
|
807
1310
|
|
|
808
1311
|
Without a valid `url` and `topic` the service only tracks transitions and sends nothing. Turn it on:
|
|
809
1312
|
|
|
@@ -879,12 +1382,27 @@ The Studio's design of record is `docs/design/throttle-studio.md` (§1.1 "The la
|
|
|
879
1382
|
## Uploads (the Cursor `uploads/` folder, without Cursor)
|
|
880
1383
|
|
|
881
1384
|
Files land in `<board folder>/uploads/` on the server — the same folder you drop into from Cursor. The folder is
|
|
882
|
-
created on first use and kept out of commits through the repo-local `.git/info/exclude` when
|
|
883
|
-
|
|
1385
|
+
created on first use (0755) and kept out of commits through the repo-local `.git/info/exclude` when that file does not
|
|
1386
|
+
name it yet. Afterwards the in-container path is on your clipboard and in a notification. Every upload — these ways
|
|
1387
|
+
and the Docs page's drop area ("Docs service" below) — also becomes a docs-store document with a sha256 receipt (W10
|
|
1388
|
+
W5): one module, `libexec/uploads.py`, lands the file and hands the same bytes to `docs_store.intake` in one pass, so
|
|
1389
|
+
the receipt is of exactly what arrived; the store keeps a copy (never a hard link to the container's file, which an
|
|
1390
|
+
agent could rewrite under an unchanged receipt). `lanes upload` says each receipt on stderr (`# docs: <name> <bytes> B
|
|
1391
|
+
sha256 <hash> <id>`, in the helper's log) and records it for the login that owns the asking device, under the lane its
|
|
1392
|
+
view shows; a device nobody owns gets a `docs.log` line (`upload - - <lane> <bytes> <sha256> no-owner`) and no
|
|
1393
|
+
document; a file over 32 MiB lands without one (`intake … too-large`). stdout and the spool's `<request>.done` are what
|
|
1394
|
+
they always were.
|
|
1395
|
+
|
|
1396
|
+
The host never follows a symbolic link in the container's folder (an absolute link would resolve against the host's
|
|
1397
|
+
root: DEF-1891) and runs no git there (`git check-ignore` runs the repository's `core.fsmonitor`, a command the
|
|
1398
|
+
container chose: DEF-1890). A folder or `uploads/` reached through a link is refused (`lanes: uploads/ in the
|
|
1399
|
+
workspace folder is not a directory (a symbolic link is refused)`): replace the link with the real directory in the
|
|
1400
|
+
container. The tar stream is unpacked member by member: links that leave the upload, `..`, devices and FIFOs refuse
|
|
1401
|
+
the whole upload, as tar's data filter did.
|
|
884
1402
|
|
|
885
1403
|
| Way | How |
|
|
886
1404
|
|---|---|
|
|
887
|
-
| Upload strip in the sidebar | The one row above the footer rule, ` ↑ uploads · click, then drop (U)
|
|
1405
|
+
| Upload strip in the sidebar | The one row above the footer rule, ` ↑ uploads · click, then drop (U)` — ` ↑ uploads · ▤ docs 3 (U)` once the workspace's lanes have published documents, the whole row then a link to the docs page (plan W10 W7). Click it (or press `U` in the sidebar, `Ctrl-Alt-u` from anywhere in the tab) — it turns into ` ↓ DROP FILES NOW · anywhere · Esc` — then **drop the files anywhere on the window**. The file goes to the board of that tab and its path is typed into the lane, ready for the agent. `Esc` cancels. While it sends the strip says ` ↑ sending 2 files · 12.4 MiB` and repaints only when the helper reports progress (no spinner, no fast tick); the result stays 8 s as ` ✓ name, name` or ` ✗ reason`. |
|
|
888
1406
|
| Watch folder | `~/Lanes Uploads/` → the board your view shows; `~/Lanes Uploads/<board>/` → that board. Sent files move to `.sent/<date>/`. Point CleanShot's save folder here for instant screenshot upload. |
|
|
889
1407
|
| Command | `lanes-upload [-w <board>] [--paste] <files…>` · `lanes-upload --clipboard [--paste]` (clipboard image or copied files) |
|
|
890
1408
|
| macOS droplet | `~/Applications/Lanes Upload.app`: drop files on it (Dock); double-click uploads the clipboard screenshot |
|
|
@@ -965,7 +1483,16 @@ the Zellij source) delivers as that frame plus the query again, so `abc` filtere
|
|
|
965
1483
|
now never puts an Esc and other bytes in one frame: a new query is a lone `Esc`, then at least 60 ms later `/` and the
|
|
966
1484
|
text; each edit after that sends only its backspaces and new letters; clearing sends a lone `Esc`. Leaving the picker
|
|
967
1485
|
forgets the query and sends nothing, because the lane has the focus by then and an `Esc` can interrupt its agent
|
|
968
|
-
(DEF-1752).
|
|
1486
|
+
(DEF-1752; cockpit v3 S5 fixed the same symptom on its own line as DEF-1817, and the main sync kept both detectors).
|
|
1487
|
+
Cockpit v3 S5 adds, on top of those frames: the query belongs to the picker it was typed on, so moving to another
|
|
1488
|
+
workspace's picker from the Workspaces sheet leaves it behind and that picker's bar starts empty (DEF-1820); the
|
|
1489
|
+
clearing `Esc` of an emptied field or of Esc is owed to that picker and sent only while it is the current tab, and a
|
|
1490
|
+
picker left filtered gets that one `Esc` the next time it is current, so its list is whole again.
|
|
1491
|
+
|
|
1492
|
+
On the picker tab the quick row appears only while the sidebar asks for a key (a close, the Register confirmation, Add
|
|
1493
|
+
to workspace), as `⏎ y Esc`; never while a name is being typed (Rename, Register as), where a `y` would land in the
|
|
1494
|
+
name (DEF-1819). It follows the picker's pane title (` · › close`), so it shares the title path's open browser check
|
|
1495
|
+
(DEF-1531).
|
|
969
1496
|
|
|
970
1497
|
**Type here** is a plain text field (it was a password field: iOS showed the secure-entry keyboard and the
|
|
971
1498
|
strong-password bar, DEF-1734). A QuickType word arrives whole (composition input — CJK, GBoard — stays the client's
|
|
@@ -999,8 +1526,12 @@ a real iPhone in Safari and from the Home Screen —
|
|
|
999
1526
|
7. On a picker tab, type `ab` in the filter, backspace once, type `c`: the picker's footer shows `/ac` (not `/acac`,
|
|
1000
1527
|
the doubled query) and the list shows the matching lanes; clear the field: the footer is back to `Swipe to scroll ·
|
|
1001
1528
|
Tap to open`. Filter again and open a lane from the list: the lane receives nothing (its agent is not
|
|
1002
|
-
interrupted), and back on the picker the field is empty.
|
|
1003
|
-
|
|
1529
|
+
interrupted), and back on the picker the field is empty and the list whole. Type a filter, open the Workspaces
|
|
1530
|
+
sheet and tap another workspace: its bar is empty and its list unfiltered; go back: the first list is unfiltered too.
|
|
1531
|
+
8. (Cockpit v3 S5.) On a picker, tap `×` on an unregistered row: the `⏎ y Esc` row appears, `y` then `⏎` closes the
|
|
1532
|
+
session; with the keyboard open, `P` on an unregistered row shows no quick row until ` › register ‘sh’ — y⏎
|
|
1533
|
+
confirm`.
|
|
1534
|
+
9. (Plan Task 26 has landed; DEF-1531 waits on this.) The Workspaces sheet shows each picker's counts. A phone device
|
|
1004
1535
|
created before Task 26 keeps the old picker pane (`upgrade-session` does not re-lay-out a tab): run
|
|
1005
1536
|
`lanes rebuild <device>` first (`lanes doctor` must not list it under DEF-1531), or no counts appear and the check
|
|
1006
1537
|
says nothing about the title path.
|
|
@@ -1034,6 +1565,10 @@ Laptop and tablet browsers: see the [installation guide](INSTALL.md#laptop-and-t
|
|
|
1034
1565
|
- **`lanes web` printed a `127.0.0.1` URL**: the host has no Tailscale name. Connect Tailscale on the host;
|
|
1035
1566
|
there is no public fallback.
|
|
1036
1567
|
|
|
1568
|
+
The Docs page (`/lanes-docs/`: the phone's **More → Docs**, the laptop bar's **Docs**, the sidebar's ` ▤ docs N`) is
|
|
1569
|
+
a service of its own behind the same Serve and Caddy, with its own pinned Mermaid: section "Docs service" (its unit,
|
|
1570
|
+
pin gate, `docs.log`, routes, Mermaid pin, doctor lines and the operator's live checklist).
|
|
1571
|
+
|
|
1037
1572
|
### Tailscale Serve
|
|
1038
1573
|
|
|
1039
1574
|
`lanes web` (not `--print-only`) makes Tailscale Serve forward `https:8443` to `http://127.0.0.1:8083` after it
|
|
@@ -1171,12 +1706,17 @@ and every member keeps logging in, because sshd reads the block, not the service
|
|
|
1171
1706
|
- **Stores** under `~/.local/state/lanes/`: `invites/<id>.json` (minted by `lanes invite`, W8 A4; a redemption adds
|
|
1172
1707
|
`redeemed_by {device, login, pubkey_sha256}` and `redeemed_at`), `enrol-requests/<id>.json`, `members/<device>.json`
|
|
1173
1708
|
(via `invite`/`request`; `approved_by` = the approving tailnet login on `/enrol/pending`, `cli[:<device>]@<address>`
|
|
1174
|
-
from `lanes enrol approve`, null for an invite minted over SSH — its `minted_by.login` is null
|
|
1709
|
+
from `lanes enrol approve`, null for an invite minted over SSH — its `minted_by.login` is null; `client_version` =
|
|
1710
|
+
the release the installer sent with the redeem or the request, W9 K1, absent when it sent none or a value that is
|
|
1711
|
+
not 1-32 characters of `[A-Za-z0-9._+-]`). The invite is used
|
|
1175
1712
|
up before the key is written; when the key cannot be written and the block demonstrably lacks it, the invite and the
|
|
1176
1713
|
member file are put back (`503`).
|
|
1177
1714
|
- **Log:** `~/.local/state/lanes/enrol.log` (0600, append-only), one line per attempt —
|
|
1178
1715
|
`<UTC> <redeem|request|approve|deny|invite|invite-revoke|offboard> <id> <device> <login> fp=<SHA256:…> <result>`,
|
|
1179
|
-
`-` for an unknown field.
|
|
1716
|
+
`-` for an unknown field. A redeem attempt's lines end ` client=<release>` when its body named one (W9 K1:
|
|
1717
|
+
`redeem 7q4k anna-mbp anna@example.com fp=SHA256:… ok client=1.2.0`, a refusal's too); a line written before
|
|
1718
|
+
the body is read (`rate-limited`, and a body refused as `bad-request`, `too-large` or `length-required`) carries
|
|
1719
|
+
none. No code is ever written, not even the id half of a code that matched nothing. Service
|
|
1180
1720
|
errors go to the journal. A redeem or approval re-renders the whole block from the member files that load, so a
|
|
1181
1721
|
member file that no longer validates (a hand edit) loses its line — fail closed — and that is logged, one
|
|
1182
1722
|
`<redeem|approve> <id> <its device> <login> fp=- dropped-member` line per such file before the attempt's own, with
|
|
@@ -1185,8 +1725,9 @@ and every member keeps logging in, because sshd reads the block, not the service
|
|
|
1185
1725
|
|
|
1186
1726
|
**Who approves.** `/enrol/pending` lets in a tailnet login that some member record names as its owner (and, in
|
|
1187
1727
|
request mode, passes the domain gate). Keys added by hand — `lanes keys adopt` (the plan's bootstrap step) or
|
|
1188
|
-
`lanes keys add` — record **no owner
|
|
1189
|
-
one of your own devices, with the login exactly as `/site` echoes
|
|
1728
|
+
`lanes keys add` — record **no owner** (unless adopt is given `--login <login>`), so after a bootstrap without one
|
|
1729
|
+
nobody can approve on the web yet. Name yourself on one of your own devices, with the login exactly as `/site` echoes
|
|
1730
|
+
it as `caller`:
|
|
1190
1731
|
|
|
1191
1732
|
```sh
|
|
1192
1733
|
lanes keys list # find your device, e.g. my-mac
|
|
@@ -1309,6 +1850,340 @@ signed out in step 3; their views are named, not ended — offboard each by name
|
|
|
1309
1850
|
open with the key keeps running until it ends (step 1 says so). `enrol.log` gets one `offboard` line with the key's
|
|
1310
1851
|
fingerprint and `ok` or `incomplete`.
|
|
1311
1852
|
|
|
1853
|
+
## Docs service (`lanes-docsd`, W10 W3)
|
|
1854
|
+
|
|
1855
|
+
The docs cockpit's web half: what an agent publishes from a lane (W4, "Publish pick-up" below) and what a person
|
|
1856
|
+
uploads (W5: the Docs page's drop area and every `lanes upload`) is opened at
|
|
1857
|
+
`https://<host>.<tailnet>.ts.net:8443/lanes-docs/` from the phone or laptop shell, bound to the person's
|
|
1858
|
+
tailnet login. `lanes-docsd.service` runs `libexec/docsd.py` on **127.0.0.1:8185**; the `lanes-mobile` Caddy forwards
|
|
1859
|
+
`/lanes-docs/*` to it (`handle_path /lanes-docs/*` in `web/Caddyfile`, which strips the prefix and is the FIRST handle of
|
|
1860
|
+
the site, before the Zellij routes) and keeps Serve's `X-Forwarded-For` (`trusted_proxies 127.0.0.0/8`, as on
|
|
1861
|
+
`/enrol/*`). The site-wide Caddy header block is `header @notdocs` (`@notdocs not path /lanes-docs/*`), so a docs
|
|
1862
|
+
response carries docsd's headers only. Every server install enables and restarts it after `lanes-enrold`, then runs
|
|
1863
|
+
`install/enable-docs.sh` for the page's Mermaid ("Mermaid pin" below; never fatal); an update (`--update-only`)
|
|
1864
|
+
restarts it when it runs (`update.TRY_RESTART`), runs `enable-docs.sh` right after, and, like every update, enables
|
|
1865
|
+
nothing — a server set up before the docs service and only updated since has no `lanes-docsd` until one full `--server`
|
|
1866
|
+
install (or `systemctl --user enable --now lanes-docsd.service`); `lanes doctor` says so (DEF-1900, mitigated). In
|
|
1867
|
+
order below: the unit, the pin gate and who sees what, the routes, uploads, encryption, `docs.log`, the publish
|
|
1868
|
+
pick-up and the Caddy reload; then "Mermaid pin", "What `lanes doctor` says" and the operator's live checklist
|
|
1869
|
+
("Operator verification"). `lanes docs push <id>` prints one document's receipt and exits 2 `not configured`: the
|
|
1870
|
+
hook for the data service's publication surface, not built (operator gate 3 of the plan).
|
|
1871
|
+
|
|
1872
|
+
The unit, `lanes-docsd.service` (rendered from `systemd/lanes-docsd.service` by `install/services.py`), runs
|
|
1873
|
+
`python3 -S -E libexec/docsd.py` in `lanes.slice` with `Restart=on-failure` (10 s), `NoNewPrivileges=true`,
|
|
1874
|
+
`UMask=0077`, `MemoryHigh=128M` and `MemoryMax=192M`, and `WantedBy=default.target`; no `ProtectHome` (it writes
|
|
1875
|
+
`~/.local/state/lanes/docs/` and `docs.log`, its publish pick-up reads each workspace container through
|
|
1876
|
+
`/proc/<pid>/root`, as `lanes-statusd` does, and an upload lands in the workspace's `uploads/` through the container's
|
|
1877
|
+
home volume). MEASURED 2026-10-08 on the development host (`/usr/bin/python3`
|
|
1878
|
+
3.12.3 `-S -E`, load about 70; not the ceobox service): idle VmHWM 25 MB, one 32 MiB upload 56 MB, two at once (the
|
|
1879
|
+
most it parses: `UPLOAD_SLOTS`) 89 MB; the security review (2026-10-08) measured 94 MiB for two. A decryption now
|
|
1880
|
+
takes one of the same two slots (it holds up to 32 MiB of plaintext), so at most two 32 MiB buffers are held at once;
|
|
1881
|
+
the composed peak under `MemoryMax` (two buffers, two renders, the render cache, a pick-up survey) is not measured
|
|
1882
|
+
(DEF-1917, mitigated). `LANES_DOCS_PORT` (or `--port`) moves it off 8185 (the Caddyfile route must follow); `--bind`
|
|
1883
|
+
takes a loopback address only.
|
|
1884
|
+
|
|
1885
|
+
```sh
|
|
1886
|
+
systemctl --user status lanes-docsd.service
|
|
1887
|
+
journalctl --user -u lanes-docsd.service -n 30 # "listening on 127.0.0.1:8185; pinned request (…); domains …"
|
|
1888
|
+
tail ~/.local/state/lanes/docs.log # one line per read, upload refusal, publish, intake and denial
|
|
1889
|
+
lanes doctor # the five docs lines below
|
|
1890
|
+
```
|
|
1891
|
+
|
|
1892
|
+
**The pin gate.** docsd answers NOTHING until the operator has run the enrolment service's Serve header check (section
|
|
1893
|
+
"Enrolment service", Operator verification) and pinned its result with `lanes enrol mode request` (it reads
|
|
1894
|
+
`~/.local/state/lanes/enrol-mode` on every request: no restart). Unpinned (`auto`), pinned `invite`, or a pin that
|
|
1895
|
+
cannot be read: every request is `403 not-pinned`, and the start line says `NOT pinned`. `LANES_ENROL_REQUEST_MODE`
|
|
1896
|
+
in enrold's unit is enrold's alone and is not a pin here. After the pin, a request is answered only when ALL hold —
|
|
1897
|
+
else `403` with the word, a short page for a browser:
|
|
1898
|
+
|
|
1899
|
+
| Check | Refusal |
|
|
1900
|
+
|---|---|
|
|
1901
|
+
| it came through Tailscale Serve: its `X-Forwarded-For` (Serve sets it, Caddy keeps it) names a non-loopback address | `not-through-serve` (a probe of 127.0.0.1:8185 or of Caddy from this host) |
|
|
1902
|
+
| Serve's identity header set: `Tailscale-User-Login` and `Tailscale-User-Name` | `no-identity` (a tagged device) |
|
|
1903
|
+
| the login validates as a member login | `invalid-login` |
|
|
1904
|
+
| the pin above | `not-pinned` |
|
|
1905
|
+
| the login owns a valid `members/<device>.json` (casefolded) | `not-a-member` (`lanes keys owner <device> <login>`) |
|
|
1906
|
+
| its domain is in `LANES_DOCS_DOMAINS`, else `LANES_ENROL_DOMAINS` (default `beyondwork.ai`, `*` = any) — set it in this unit's drop-in | `domain-not-allowed` |
|
|
1907
|
+
| its `Sec-Fetch-Site` is `same-origin` (a page, script, stylesheet or image of the Docs pages, the phone/laptop shell's **Docs** link) or `none` (an address typed or bookmarked, a link opened from a terminal or a phone app); or it is a top-level navigation — a GET or HEAD with `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document` — from any site (a link followed in ntfy's web app, a chat, a mail); `POST /up` only `same-origin` or `none` — every route, since the security review of 2026-10-08 | `cross-site` (anything else from another site — `cross-site`, `same-site` (`*.ts.net` is a public suffix, so another tailnet host's page is same-site) — that is not such a navigation: a `fetch()`, an image, a script, a frame, a form posted to `/up`; or no `Sec-Fetch-Site` on a request that is not a navigation: a browser too old to send it, or `curl`) |
|
|
1908
|
+
|
|
1909
|
+
No Zellij cookie is consulted. The `cross-site` check is why a sandboxed artifact (an opaque origin) cannot spend the
|
|
1910
|
+
viewer's login here: its `fetch()` and subresource loads are not navigations. A link to a docs page followed from
|
|
1911
|
+
another website (a web mail, a chat in a browser, ntfy's web app) is a top-level navigation and is served. The
|
|
1912
|
+
artifact's sandbox (`sandbox allow-scripts` only) cannot open a window, submit a form or navigate another browsing
|
|
1913
|
+
context; the one navigation its script can start is of its own page, which replaces it unless the answer is a download
|
|
1914
|
+
the sandbox blocks, so what it can request that way is bounded by the 300-a-minute limit (the HTML standard's rules,
|
|
1915
|
+
not measured in a browser).
|
|
1916
|
+
**Trust boundary:** as for enrold (DEF-1645, open), a process on this host that reaches loopback can send the headers
|
|
1917
|
+
itself and read any member's documents; the pin and the Serve-only check are the controls, and the same `docker ps
|
|
1918
|
+
--filter network=host` audit applies. And a member's device key carries `restrict,pty` but no `command=`
|
|
1919
|
+
(`libexec/keys.py` `OPTIONS`): a member who can SSH in has a shell as the lanes account and can read every store
|
|
1920
|
+
directly, so the per-login separation of the docs service holds between people who only use the web, not against a
|
|
1921
|
+
member with a device key (DEF-1919, open). Never expose 8443 through Tailscale Funnel (Funnel traffic carries no
|
|
1922
|
+
identity headers).
|
|
1923
|
+
|
|
1924
|
+
**Who sees what.** A person sees the documents in their own store (their uploads), the documents whose `for` names
|
|
1925
|
+
them, and a document that names nobody when a member record they own grants its workspace. A record's `workspaces`
|
|
1926
|
+
(the board ids an invite recorded with `lanes invite --workspaces`) grants those boards; a record that lists none —
|
|
1927
|
+
a key added with `lanes keys add|adopt`, a device enrolled by request, an invite minted without `--workspaces` —
|
|
1928
|
+
grants every workspace the server serves (the registry's), because nothing else in Lanes narrows by that list
|
|
1929
|
+
(DEF-1852, closed). The same grant decides the lanes the upload form offers and `POST /up` accepts. A document a
|
|
1930
|
+
person cannot see answers `404`, like one that does not exist.
|
|
1931
|
+
|
|
1932
|
+
| Route (after Caddy strips `/lanes-docs`) | Answer (wire contract v1, DM-LANES-DOCS-WIRE) |
|
|
1933
|
+
|---|---|
|
|
1934
|
+
| `GET /` · `GET /?ws=<ws>&lane=<lane>` | the index page: workspace → lane → newest first, counts, the upload form; the filter (either key optional) must name a workspace/lane the person knows, else `404` |
|
|
1935
|
+
| `GET /d/<id>` | the document page: Markdown rendered (mdlite; over 1 MiB not rendered; from the render cache when its sha256 was rendered before; `503 busy` + `Retry-After` when two renders run and none ends within 1 s), an HTML/SVG artifact linked (never embedded), a file's facts and download |
|
|
1936
|
+
| `GET /d/<id>/raw` | the bytes, `Content-Disposition: attachment; filename="<name>"`, `X-Lanes-Sha256` |
|
|
1937
|
+
| `GET /d/<id>/artifact` | kind html/svg only: the bytes as a top-level document under `Content-Security-Policy: sandbox allow-scripts` |
|
|
1938
|
+
| `GET /receipt/<id>.json` | `receipt.json` (`{v, id, sha256, bytes, url, at}`) |
|
|
1939
|
+
| `GET /docs.css`, `GET /docs.js` | `web/docs.css` / `web/docs.js` of the running tree (W6), else `404` |
|
|
1940
|
+
| `GET /upload.css`, `GET /upload.js` | `web/upload.css` / `web/upload.js` (W5, the index's drop area), else `404` |
|
|
1941
|
+
| `POST /up` | multipart form: `lane` (+ optional `ws`), files; each lands in the workspace's `uploads/` AND the store, both or neither; `200 {receipts: [{id, name, sha256, bytes, url, at, landed}]}` or a page; `429` (30 per login per 10 min) + `Retry-After`, `503 busy` (two big buffers at once — uploads and decryptions —, one per login, or the store lock), `415`, `411`, `413` (32 MiB), `408 request-timeout` (the body did not arrive within 30 s + its length at 64 KiB/s), `400 bad-request\|no-file\|no-lane\|unknown-lane\|unknown-workspace`, `503 registry-unavailable`, `503 uploads-unavailable` (the container's folder: home volume unknown, a link on the way), `500 encrypt-failed` |
|
|
1942
|
+
| every route but `POST /up` | `429 rate-limited` + `Retry-After` past 300 requests per login per minute (one `docs.log` line per minute) |
|
|
1943
|
+
|
|
1944
|
+
**Limits** (per `lanes-docsd` process; security review 2026-10-08 — each pinned by a test in `tests/unit/test_docsd.py`
|
|
1945
|
+
or `test_docs_store.py`, values in `libexec/docsd.py` / `docs_store.py`):
|
|
1946
|
+
|
|
1947
|
+
| Limit | Value | Past it |
|
|
1948
|
+
|---|---|---|
|
|
1949
|
+
| requests per login, every route but `POST /up` (`VIEWS` / `VIEW_WINDOW`) | 300 per 60 s, a fixed window | `429 rate-limited` + `Retry-After`; one `view … rate-limited` line per window |
|
|
1950
|
+
| Markdown renders at once (`RENDER_SLOTS`, `RENDER_WAIT_S`) | 2; a view waits 1 s for one | `503 busy` + `Retry-After: 5` |
|
|
1951
|
+
| render cache (`RENDER_CACHE_BYTES`, `RENDER_CACHE_ENTRY`) | 8 MiB in all, by the document's sha256, least recently used out; a render over 2 MiB is not kept | a second view of a cached document renders nothing and needs no slot |
|
|
1952
|
+
| big buffers at once (`UPLOAD_SLOTS`), shared by uploads and decryptions | 2, at most 1 per login; a decryption also one at a time and waits 5 s for a slot | `503 busy` + `Retry-After: 5` |
|
|
1953
|
+
| uploads per login (`UPLOADS` / `UPLOAD_WINDOW`) | 30 per 10 min, counted at the start | `429 rate-limited` |
|
|
1954
|
+
| upload body deadline (`BODY_GRACE_S`, `BODY_MIN_RATE`) | 30 s + length / 64 KiB/s (32 MiB: 542 s) for the whole body; each read still times out after 30 s without data | `408 request-timeout`, connection closed, the slot free again |
|
|
1955
|
+
| refused body read away (`DRAIN_LIMIT`, `LINGER_S`) | 64 MiB before the answer, then 5 s | the client may see a reset |
|
|
1956
|
+
| pick-up survey (`MAX_ENTRIES`) | 4,096 directory entries per container per cycle (its publish directory and lane directories together) | the container is refused for the cycle: nothing taken; the journal says `pick-up <container>: refused: more than 4096 entries …` once an hour (`OVER_CAP_SAY_S`) |
|
|
1957
|
+
| pick-up per container (`MAX_PICKUPS`, `PUBLISHES`) | 16 per cycle, 30 per 10 min | the rest wait; past 30 refused `rate-limited` |
|
|
1958
|
+
| `docs.log` refusal lines (`DENIED_REPEAT_S`, `MAX_DENIED_SOURCES`, `MAX_DENIED_LOGIN`) | one `denied` line per source (the tailnet address Serve forwarded, else the peer) and word, then one `denied-repeat` line per minute with the count; 1,024 sources remembered; a login logged at most 64 characters | — |
|
|
1959
|
+
| `docs.log` size (`docs_store.LOG_ROTATE_BYTES`) | 8 MiB, then `docs.log.1` (one generation) | the older generation is replaced |
|
|
1960
|
+
|
|
1961
|
+
Every response has `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` and a
|
|
1962
|
+
CSP: pages `default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src
|
|
1963
|
+
'self'; form-action 'self'; base-uri 'none'` (`connect-src` for the drop area's own `POST up`), an artifact exactly
|
|
1964
|
+
`sandbox allow-scripts`, everything else `default-src 'none'; sandbox`.
|
|
1965
|
+
Links and assets in a page are relative (`docs.css`, `d/<id>`), so the pages work under the stripped prefix; open the
|
|
1966
|
+
index as `/lanes-docs/` with the slash (`/lanes-docs` alone is the page route a device of that name would have, which
|
|
1967
|
+
is why `lanes-docs`, `lanes-assets` and `enrol` are not device names). An upload goes through `uploads.receive` (W5):
|
|
1968
|
+
it lands in the lane's workspace's `uploads/` (`<folder>/uploads/<name>`, `-1` on a collision, exactly as `lanes
|
|
1969
|
+
upload` lands one) and `docs_store.intake` (`source` `web`, kind by extension, `for` and `by` the uploader) takes the
|
|
1970
|
+
same bytes into the person's store; the receipt's `landed` is the in-container path to tell the agent. The drop area is
|
|
1971
|
+
a plain form that works without a script; `web/upload.js` (loaded by the index only) adds the drop onto the whole area,
|
|
1972
|
+
a size check before sending, progress and the receipt list.
|
|
1973
|
+
The multipart body is split by docsd's own bytes scan, each part's headers parsed by `email.parser`: parsing a 32 MiB
|
|
1974
|
+
body whole with `email.parser` MEASURED about 430 MB peak RSS and 3.8 s, the scan 0.05 s and no copy.
|
|
1975
|
+
|
|
1976
|
+
**A refused upload is answered, not reset (DEF-1854, closed by W5).** A browser sends its whole body before it reads
|
|
1977
|
+
the answer, and a server that answers and closes while the body is still arriving resets the connection under its own
|
|
1978
|
+
answer. docsd now reads a refused body away up to its `Content-Length` — at most 64 MiB (`DRAIN_LIMIT`, twice the cap) —
|
|
1979
|
+
before it answers (with `Connection: close`); past that it answers, shuts its write side and reads the rest for at
|
|
1980
|
+
most 5 s (`LINGER_S`). MEASURED 2026-10-08 against a real `python3 -S -E docsd.py` (development host, load ~15–20 on
|
|
1981
|
+
64 cores), three concurrent 32 MiB uploads then a 48 MiB and a 100 MiB one: before (the W3 tree) 200, 200 and a
|
|
1982
|
+
`BrokenPipeError`, and both oversize uploads `BrokenPipeError`; after, three runs, 200, 200, `503 busy` and both `413`
|
|
1983
|
+
read by the client. Over a slow link a body past 64 MiB can still be reset; upload.js refuses a set over 32 MiB before
|
|
1984
|
+
it sends.
|
|
1985
|
+
|
|
1986
|
+
**Encryption at rest (`age`, W5; off by default).** `LANES_DOCS_AGE_RECIPIENT=<age recipient>` in lanes-docsd's
|
|
1987
|
+
drop-in makes the docs store keep each NEW document as `age -r <recipient>` output (`<name>.age`, meta.json `enc:
|
|
1988
|
+
{alg, file, bytes, recipient}`); the file in the workspace's `uploads/` stays plain (agents read it), and receipts stay
|
|
1989
|
+
the sha256 of the plaintext, computed before encryption (age output is randomised, so a hash of it would match nothing
|
|
1990
|
+
anyone holds). `lanes upload` runs over SSH, outside the unit, and reads the same drop-in files, so both paths follow
|
|
1991
|
+
one switch. For docsd to open encrypted documents, give it an identity file with `LANES_DOCS_AGE_IDENTITY=<path>`: raw
|
|
1992
|
+
downloads and artifacts are then decrypted on the fly (at most 32 MiB, one at a time) and checked against the receipt;
|
|
1993
|
+
without it they answer `409 encrypted` (a page that sends the person to `uploads/`) and a document page shows its facts
|
|
1994
|
+
only. Documents stored before the switch stay plain. Every server install refuses the setting where no `age` binary is
|
|
1995
|
+
on PATH (`host-install.sh` `check_docs_age` → `python3 libexec/docs_store.py check-age`, exit 1 with the reason; an
|
|
1996
|
+
update records it `refused`, nothing changed); install `age` first.
|
|
1997
|
+
|
|
1998
|
+
```sh
|
|
1999
|
+
systemctl --user edit lanes-docsd.service # [Service]
|
|
2000
|
+
# Environment=LANES_DOCS_AGE_RECIPIENT=age1...
|
|
2001
|
+
# Environment=LANES_DOCS_AGE_IDENTITY=/home/<you>/.config/lanes/docs-age.key
|
|
2002
|
+
python3 ~/.local/share/lanes/current/libexec/docs_store.py check-age && systemctl --user restart lanes-docsd.service
|
|
2003
|
+
```
|
|
2004
|
+
|
|
2005
|
+
**docs.log** (`~/.local/state/lanes/docs.log`, 0600, append-only, DM-LANES-DOCS-LOG): `<at> <login> <event> <id> <lane>
|
|
2006
|
+
<bytes> <sha256> <outcome>` — docsd adds `denied` (outcome = the refusal word; the first refusal of a source and word,
|
|
2007
|
+
the login cut to 64 characters) and `denied-repeat` (one a minute while that source keeps being refused: the `bytes`
|
|
2008
|
+
field is how many refusals the line folds, the login the latest), `view`/`raw`/`artifact`/`receipt` (`ok`,
|
|
2009
|
+
`not-found`, `encrypted`, `busy`, and `view … rate-limited` once a window), `upload` (a refusal before any file
|
|
2010
|
+
reaches intake, `request-timeout` included) and `publish` (one per request the pick-up takes: `ok` or the refusal
|
|
2011
|
+
reason — the precise word, also where the receipt says `not-delivered`); each stored file is intake's own `intake`
|
|
2012
|
+
line; `lanes upload` adds `upload … no-owner`/`no-lane`/`too-many` lines with a file's size and sha256 when it lands
|
|
2013
|
+
without a document. Never content, a title or a file name. At 8 MiB the file is renamed `docs.log.1` (replacing the
|
|
2014
|
+
one before) and a new one begun, by whichever writer finds it full first (an exclusive `flock`; the others append to
|
|
2015
|
+
the new file), so the two files never hold much more than 16 MiB. Before the security review (2026-10-08) refusals
|
|
2016
|
+
from any tailnet device appended 269 B each without bound (DEF-1913).
|
|
2017
|
+
|
|
2018
|
+
**Publish pick-up (W10 W4).** `lane publish <file>` and the agents' `lanes_publish` write a request in the container,
|
|
2019
|
+
`~/.lz/state/publish/<lane id>/<id>.json` (`{v: 1, path, title, kind, claimed_sha256, mime, bytes, for}`;
|
|
2020
|
+
DM-LANES-PUBLISH-REQUEST). A thread of `lanes-docsd` takes them every 5 s — for each container `containers.json` names
|
|
2021
|
+
whose init pid is alive (a pid whose `/proc/<pid>/root` is the host's own `/` is skipped, and so is one whose
|
|
2022
|
+
`/home/coder` is not the home volume `containers.json` records, when this account can see that volume), at most 16 per
|
|
2023
|
+
container per cycle and 30 per container per 10 minutes (more are refused `rate-limited`). It first surveys the
|
|
2024
|
+
container's publish directory and lane directories, at most 4,096 entries in all; a container that holds more is
|
|
2025
|
+
refused for the cycle (Limits above). It renames the request to `.<id>.taking` (a directory planted at that name is
|
|
2026
|
+
removed when empty, else moved aside to `.<name>.<random>.planted`; a request it still cannot rename is left alone, the
|
|
2027
|
+
next one is taken and the journal says why once), reads it, resolves the file inside
|
|
2028
|
+
the container to a regular file under `/home/coder` (never through a link, never a FIFO), streams it into the store and
|
|
2029
|
+
compares the sha256. The answer is written back beside the request as `<id>.receipt.json` (0644; DM-LANES-PUBLISH-RECEIPT):
|
|
2030
|
+
|
|
2031
|
+
```json
|
|
2032
|
+
{"v": 1, "id": "<document id>", "request": "<id>", "sha256": "…", "bytes": 1234, "url": "/lanes-docs/d/<document id>", "at": "2026-10-08T12:00:00Z", "outcome": "ok"}
|
|
2033
|
+
{"v": 1, "id": null, "request": "<id>", "sha256": null, "bytes": null, "url": null, "at": "…", "outcome": "refused", "reason": "outside-home"}
|
|
2034
|
+
```
|
|
2035
|
+
|
|
2036
|
+
then the claim is removed and the lane keeps its 16 newest receipts (a directory planted at the receipt's name is
|
|
2037
|
+
removed or moved aside first, so the answer is always written). A refused receipt carries the host's sha256 and size
|
|
2038
|
+
when it read the file (`sha256-mismatch`, `too-large`). Refusal reasons (the receipt's word):
|
|
2039
|
+
|
|
2040
|
+
| Reason | Meaning |
|
|
2041
|
+
|---|---|
|
|
2042
|
+
| `unreadable-request` | the request is not a regular JSON object file of at most 16 KiB (a link, a FIFO, a directory, not JSON) |
|
|
2043
|
+
| `bad-version` · `bad-request` · `bad-kind` · `bad-title` | `v` is not 1; a field of the wrong type (`path` not absolute, `claimed_sha256` not 64 hex, `bytes` not a count); `kind` not `md`/`html`/`svg`/`file`; a title that is not one printable line of at most 200 characters |
|
|
2044
|
+
| `too-large` | the claimed or the real size is over 32 MiB |
|
|
2045
|
+
| `unknown-lane` | the registry places no lane of that id in this container |
|
|
2046
|
+
| `rate-limited` | over 30 requests from this container in 10 minutes |
|
|
2047
|
+
| `not-delivered` | `for` names a login that owns no member record, or — no `for`, nobody opened the lane — no member record grants its workspace. One word for both (security review 2026-10-08, DEF-1915): the two words told a container whether a login it named is a member. `docs.log`'s `publish` line keeps the precise one, `for-not-a-member` or `no-recipient`; a `for` that names a member still answers `ok` |
|
|
2048
|
+
| `outside-home` | the path, every link resolved inside the container, is not under `/home/coder` |
|
|
2049
|
+
| `not-a-file` | missing, a directory, a FIFO or device, or a link appeared on the way after it was resolved |
|
|
2050
|
+
| `sha256-mismatch` | the file's bytes are not the ones the request hashed (it changed): nothing is stored |
|
|
2051
|
+
| `invalid` | the store refused it (a name or login it cannot hold) |
|
|
2052
|
+
| `error` | anything else went wrong after the claim (the journal names it): the request is answered and removed, never dropped silently |
|
|
2053
|
+
|
|
2054
|
+
Who it is for: `for` (stored in that member's store, for them alone); else the member whose profile last opened the lane
|
|
2055
|
+
(`users/<login>.json` `last_opened`); else every member whose record grants the lane's workspace (`for` empty). A busy
|
|
2056
|
+
store leaves the request for the next cycle, and without a `registry.json` nothing is taken (the requests wait). Each stored document sends one `published` push through notify (above;
|
|
2057
|
+
at most one per lane per minute). From inside a container the latest answer is `lanes_status`'s `published`, or:
|
|
2058
|
+
|
|
2059
|
+
```sh
|
|
2060
|
+
ls -t ~/.lz/state/publish/<lane id>/ | head; cat ~/.lz/state/publish/<lane id>/<id>.receipt.json
|
|
2061
|
+
```
|
|
2062
|
+
|
|
2063
|
+
A crash between the claim and the receipt leaves `.<id>.taking` without a receipt; publish again.
|
|
2064
|
+
|
|
2065
|
+
**Caddy picks up the route** as for `/enrol/*` (section "Enrolment service"): a server install restarts a running
|
|
2066
|
+
`lanes-mobile`; after editing the Caddyfile in place, `systemctl --user restart lanes-mobile.service`. If
|
|
2067
|
+
`/lanes-docs/` answers with the Zellij page, `lanes-mobile` runs an older Caddyfile. Settings go in a drop-in:
|
|
2068
|
+
|
|
2069
|
+
```sh
|
|
2070
|
+
systemctl --user edit lanes-docsd.service # [Service] Environment=LANES_DOCS_DOMAINS=beyondwork.ai,example.com
|
|
2071
|
+
systemctl --user restart lanes-docsd.service
|
|
2072
|
+
```
|
|
2073
|
+
|
|
2074
|
+
### Mermaid pin
|
|
2075
|
+
|
|
2076
|
+
The docs page (plan W10, `docs/superpowers/plans/2026-10-08-lanes-docs-cockpit.md`) draws ```` ```mermaid ```` fences
|
|
2077
|
+
with Mermaid 11.17.2, pinned in `holders.lock` as an opt-in line: the npm tarball's sha256 and the sha256 of its member
|
|
2078
|
+
`package/dist/mermaid.min.js`, the classic-script build (3 572 661 B). The server installer's pinned-binaries loop
|
|
2079
|
+
skips it; `install/enable-docs.sh` installs it. As the workspace account, `sh
|
|
2080
|
+
~/.local/share/lanes/current/install/enable-docs.sh` downloads the tarball into a
|
|
2081
|
+
temporary directory, refuses unless both digests match, and installs `~/.local/share/lanes/vendor/mermaid-11.17.2.min.js`
|
|
2082
|
+
(0644; `$LANES_SHARE/vendor`) and the link `mermaid.min.js`; run again, it finds the pinned digest and downloads
|
|
2083
|
+
nothing. Exit status: 0 installed or present, 1 a prerequisite or the download failed, 2 usage, 3 refused (no valid
|
|
2084
|
+
pin, a digest mismatch, the member missing or not a regular file) — a refusal installs nothing.
|
|
2085
|
+
|
|
2086
|
+
**The server install runs it (plan W8, LNS-109), never fatally.** The full `./install.sh --server` (or `npx
|
|
2087
|
+
@vibelord/lanes@<release> --server`) runs it right after `lanes-docsd`'s enable and restart, shows its own line, goes
|
|
2088
|
+
on whatever it exits, and ends its summary with one line: `Mermaid for the docs page: ok`, `… skipped (<its last
|
|
2089
|
+
line>) — diagrams show as source until sh <tree>/install/enable-docs.sh succeeds` (exit 1: a prerequisite or the
|
|
2090
|
+
network), `… refused (<its last line>) — nothing was installed; see RUNBOOK "Mermaid pin"` (exit 3), or `… failed (exit
|
|
2091
|
+
N: …)`; `--no-start` does not run it and says so. **An update (`--update-only`)** runs it as the post-commit step
|
|
2092
|
+
`enable-docs`, right after `try-restart lanes-docsd` (`libexec/update.py` `DOCS_STEP`; a release rolled back to that
|
|
2093
|
+
has no script gets no step): exit 0 is `ok`; exit 1, and a download stopped after the step's 300 s (`rc` 124), are
|
|
2094
|
+
recorded `"warning": true, "outcome": "skipped"`, exit 3 `"warning": true, "outcome": "refused"`, each with `reason`
|
|
2095
|
+
(the script's last line) — warnings, so the update stays `ok` and `lanes update` does not re-run the post-commit for
|
|
2096
|
+
them; any other status is a failed step (`"outcome": "failed"`, `partial`). The update's summary line names it:
|
|
2097
|
+
`…; every step ran; mermaid skipped: enable-docs: download failed: https://… — log …`. A re-run (every update, every
|
|
2098
|
+
install) finds the pinned file and downloads nothing. To install by hand after a skip, run the script; `lanes doctor`
|
|
2099
|
+
says which state the pin is in ("What `lanes doctor` says" below). Caddy serves the directory at `/lanes-assets/vendor/`
|
|
2100
|
+
(`lanes-mobile.service`; a new Caddyfile takes `systemctl --user try-restart lanes-mobile.service`, which the server
|
|
2101
|
+
installer does) as `immutable`, so `web/docs.js` names the versioned file and carries the member digest as its SRI
|
|
2102
|
+
(DEF-1870). Without the file, or on an integrity mismatch, every fence shows its source as a plain `<pre>`. Check:
|
|
2103
|
+
`curl -sI http://127.0.0.1:8083/lanes-assets/vendor/mermaid-11.17.2.min.js` answers 200 with `cache-control: public,
|
|
2104
|
+
max-age=31536000, immutable` (step 3 of "Operator verification" below; DEF-1871 stays open until that is seen on a
|
|
2105
|
+
server).
|
|
2106
|
+
|
|
2107
|
+
The pin needs no `'unsafe-eval'` (MEASURED 2026-10-08): the member has no `new Function(`; its four
|
|
2108
|
+
`Function("return this")` (lodash's global lookup) sit behind `self`, which every page has, and its one `(0,eval)`
|
|
2109
|
+
(chevrotain) follows a `return`. In headless Chromium 153 under `default-src 'none'; script-src 'self'; style-src
|
|
2110
|
+
'self' 'unsafe-inline'`, docs.js drew a flowchart and a sequence diagram with no violation, while a control page's
|
|
2111
|
+
`eval` was refused and reported. Mermaid's SVG needs `style-src 'unsafe-inline'` (without it: unstyled, 88 violations).
|
|
2112
|
+
|
|
2113
|
+
To bump the pin (12.x — npm's latest, 12.1.0 on 2026-10-08 — is a separate decision): download the tarball once
|
|
2114
|
+
(`curl -fsSLO https://registry.npmjs.org/mermaid/-/mermaid-<v>.tgz`) and compare its sha512 with `npm view
|
|
2115
|
+
mermaid@<v> dist.integrity`; take `sha256sum mermaid-<v>.tgz` and `tar -xzOf mermaid-<v>.tgz
|
|
2116
|
+
package/dist/mermaid.min.js | sha256sum`; check that the member is still a classic script (no `import`/`export`, ends
|
|
2117
|
+
by assigning `globalThis["mermaid"]`) and repeat the `'unsafe-eval'` check above. Then change, in one commit, the
|
|
2118
|
+
`mermaid` line of `holders.lock` (and its comment) and `SRC`/`SRI` in `web/docs.js` — the SRI is `sha256-` plus the
|
|
2119
|
+
base64 of the raw member digest: `python3 -c 'import base64,sys; print("sha256-" +
|
|
2120
|
+
base64.b64encode(bytes.fromhex(sys.argv[1])).decode())' <member-sha256>`. `test_docs_assets` and
|
|
2121
|
+
`tests/web/docs.test.mjs` are red while the lock and docs.js disagree. The install or update of that release runs
|
|
2122
|
+
`enable-docs.sh`, which downloads the new pin (a skip says why: run it again by hand); older `vendor/mermaid-*.min.js`
|
|
2123
|
+
files stay (harmless; remove them by hand).
|
|
2124
|
+
|
|
2125
|
+
### What `lanes doctor` says
|
|
2126
|
+
|
|
2127
|
+
Five lines (plan W8, LNS-109), after the browser/phone lines and before the release lines, read-only (one bounded
|
|
2128
|
+
`systemctl --user show`, one loopback connect that sends nothing — docsd answers nothing and writes no docs.log line
|
|
2129
|
+
for it — and file reads):
|
|
2130
|
+
|
|
2131
|
+
| Line | Marks |
|
|
2132
|
+
|---|---|
|
|
2133
|
+
| `lanes-docsd.service active, answering on 127.0.0.1:8185` | `✓` enabled, active and the port (its drop-in's `LANES_DOCS_PORT`, else 8185) accepts a connection; `✗` `enabled but failed` (or any state but active), `active, but 127.0.0.1:8185 does not answer`, a `LANES_DOCS_PORT` that is no port, or systemctl silent — each with `journalctl --user -u lanes-docsd.service; systemctl --user restart lanes-docsd.service`; `⚠` `not enabled, so there is no docs page` (the full server install enables it; `--update-only` never does) or active but not enabled (a reboot loses it) |
|
|
2134
|
+
| `docs: pinned — lanes-docsd answers members through Tailscale Serve` | `✓` `enrol-mode` pins `request`, read as docsd reads it on every request; `⚠ docs: refuses until the Serve header check is pinned (unpinned: auto \| pinned invite only \| <why the pin cannot be read>)` with the pointer to "Enrolment service" (Operator verification) and this section — an operator gate, never a `✗` |
|
|
2135
|
+
| `docs store: N documents in M lanes (counts.json), <size> under ~/.local/state/lanes/docs; docs.log <size>` | information: the documents and lanes `docs/counts.json` counts, the bytes of the regular files under `docs/` (never through a link), docs.log's size (`no docs.log yet`) |
|
|
2136
|
+
| `docs: Mermaid 11.17.2 pinned (~/.local/share/lanes/vendor/mermaid-11.17.2.min.js, sha256 581ed7d74bd9…)` | `✓` a regular file whose sha256 is `holders.lock`'s member digest; `⚠ … not installed …, so diagrams show as source — run sh …/install/enable-docs.sh`; `✗ … refused — … is not the pinned build (sha256 …, holders.lock …)` or `… is not a regular file` (a link or a FIFO, never followed or waited on) — browsers refuse it by its integrity; the script replaces it from a verified download; `✗ docs: Mermaid not checked — …/holders.lock: …` without a valid opt-in pin |
|
|
2137
|
+
| `docs: age encryption off (LANES_DOCS_AGE_RECIPIENT unset; age at /usr/bin/age)` | information while the switch is off (and whether an `age` is on PATH); `✓ … on — new store copies are age output (age at …); lanes-docsd opens them with LANES_DOCS_AGE_IDENTITY` (or `no LANES_DOCS_AGE_IDENTITY, so their downloads answer 409 encrypted`); `✗` set without an `age` binary (every upload and publish fails `encrypt-failed`) or a value that is no recipient. The recipient is never printed. The `age` looked for is the one on doctor's PATH, which may not be the unit's |
|
|
2138
|
+
|
|
2139
|
+
### Operator verification (docs plan gate 1, not yet run on ceobox)
|
|
2140
|
+
|
|
2141
|
+
Unit-proven only so far: `tests/unit/test_docsd.py` runs a real docsd on a loopback port, `test_install.DocsRoute`
|
|
2142
|
+
models the shipped Caddyfile's routes and headers in front of it, `test_docs_assets.VendorRoute` models Caddy's route
|
|
2143
|
+
sort for the Mermaid route, and `tests/web/docs.test.mjs` and `tests/web/upload.test.mjs` drive the page scripts
|
|
2144
|
+
against fakes. Nothing below has been run on a server, a phone or a real browser. After the server install of the
|
|
2145
|
+
first release with the docs cockpit (1.3.0), run it once, in order, and record the date, the devices and each step's
|
|
2146
|
+
result here:
|
|
2147
|
+
|
|
2148
|
+
1. **Install.** On the server, the full install: `npx @vibelord/lanes@1.3.0 --server` — it enables and restarts
|
|
2149
|
+
`lanes-docsd`, restarts `lanes-mobile` on the new Caddyfile and runs `enable-docs.sh`; its last lines say
|
|
2150
|
+
`Mermaid for the docs page: ok`. Then `lanes install-container <name>` for each container whose agents should
|
|
2151
|
+
publish (the `lane publish` verb and the `lanes_publish` tool; new agent sessions load the tool).
|
|
2152
|
+
2. **Pin.** From another tailnet device signed in as yourself, run the Serve header check (section "Enrolment
|
|
2153
|
+
service", Operator verification); on the server, `lanes enrol mode request`. `lanes doctor` then shows
|
|
2154
|
+
`✓ lanes-docsd.service active, answering on 127.0.0.1:8185`, `✓ docs: pinned …` and
|
|
2155
|
+
`✓ docs: Mermaid 11.17.2 pinned …`.
|
|
2156
|
+
3. **The Mermaid route (DEF-1871).** On the server, `curl -sI
|
|
2157
|
+
http://127.0.0.1:8083/lanes-assets/vendor/mermaid-11.17.2.min.js` answers `200` with `cache-control: public,
|
|
2158
|
+
max-age=31536000, immutable`. Seen once on the server, that closes DEF-1871 (the route proven on a running Caddy,
|
|
2159
|
+
not only on a model of its sort).
|
|
2160
|
+
4. **Publish.** In a lane of a container from step 1, `~/.lz/bin/lane publish README.md` (a README with a
|
|
2161
|
+
```` ```mermaid ```` fence; add a small flowchart if it has none). The JSON says `"picked_up_by": "lanes-docsd"`;
|
|
2162
|
+
within about 5 s `cat ~/.lz/state/publish/<lane id>/<id>.receipt.json` says `"outcome": "ok"` with a `url`.
|
|
2163
|
+
5. **Sidebar.** Within a few seconds that workspace's upload strip reads ` ↑ uploads · ▤ docs 1 (U)`. With the
|
|
2164
|
+
cursor on the lane, `d` shows `https://<name>:8443/lanes-docs/?ws=<workspace>&lane=<lane id>` in the footer; a
|
|
2165
|
+
⌘-click or Ctrl-click on it (or on the strip) opens the page.
|
|
2166
|
+
6. **Phone.** **More → Docs** opens the index in a new tab, signed in as you; the README opens rendered and its
|
|
2167
|
+
```` ```mermaid ```` fence is drawn as a diagram, not shown as source.
|
|
2168
|
+
7. **Artifact sandbox (DEF-1853).** Publish an HTML file whose script tries to reach this site and writes what it
|
|
2169
|
+
got into its own page: `fetch('/lanes-docs/')`, `fetch('/')`, `document.cookie`, and a WebSocket to the same
|
|
2170
|
+
server's `/ws/` path that the terminal page itself opens (the browser's network panel on a view shows it). **Open
|
|
2171
|
+
the HTML artifact**: the script runs (the page shows its writing), every one of those attempts fails or comes back
|
|
2172
|
+
empty, and no lane receives a keystroke. Record the browser and what each attempt answered: that is DEF-1853's
|
|
2173
|
+
measurement.
|
|
2174
|
+
8. **Upload.** On the Docs page pick a lane, drop a file on the dashed area, **Upload**: the receipt lists the name,
|
|
2175
|
+
size, sha256 and `/home/coder/<repo>/uploads/<name>`; in that lane's shell, `sha256sum
|
|
2176
|
+
/home/coder/<repo>/uploads/<name>` prints the receipt's sha256. The sidebar's count goes up by one.
|
|
2177
|
+
9. **Not a member.** From a tailnet login that owns no member record, `https://<name>:8443/lanes-docs/` answers 403
|
|
2178
|
+
`not-a-member`; on the server, `curl -s http://127.0.0.1:8185/` answers `{"error": "not-through-serve"}`.
|
|
2179
|
+
10. **Forged header: the known DEF-1645 case.** A process in the host's network namespace can send Serve's headers
|
|
2180
|
+
itself: list such containers with `docker ps --filter network=host` and record the answer. From one (or from the
|
|
2181
|
+
host as the workspace account), `curl -s -H 'Tailscale-User-Login: <a member login>' -H 'Tailscale-User-Name: x'
|
|
2182
|
+
-H 'X-Forwarded-For: 100.64.0.9' -H 'Sec-Fetch-Site: none' http://127.0.0.1:8083/lanes-docs/` is answered as that
|
|
2183
|
+
member. That is expected and stays DEF-1645 (open): the pin gate and the Serve-only check are the controls against
|
|
2184
|
+
every other client, not against a process on this host. If another account or a host-network container exists,
|
|
2185
|
+
weigh `lanes enrol mode invite` (which also stops the Docs page) against it.
|
|
2186
|
+
|
|
1312
2187
|
## Safety rules baked into the code (do not remove)
|
|
1313
2188
|
|
|
1314
2189
|
- tmux is always called with `-N` (`attach-session`, `new-session` and `start-server` on a missing socket start
|
|
@@ -1318,13 +2193,18 @@ fingerprint and `ok` or `incomplete`.
|
|
|
1318
2193
|
- pty-name collision guard: the lanes' `client-attached` hook detaches a client whose tty *name* equals the
|
|
1319
2194
|
lane's `pane_tty`; host and container pty numbers collide, so the holder re-wraps itself on a fresh pty.
|
|
1320
2195
|
- Views attach only from a pane zellij has really sized (unviewed tabs are 14x49) and only after a first client.
|
|
1321
|
-
-
|
|
1322
|
-
`MoveFocus "left"` once per possible
|
|
1323
|
-
viewport
|
|
2196
|
+
- No bind in `zellij/config.kdl` types into a pane (no `WriteChars`; the one `Write` is tmux mode's `Ctrl-b`
|
|
2197
|
+
prefix passthrough). `Ctrl-Alt-/`, `Ctrl-Alt-u` and `Ctrl-Alt-l` walk `MoveFocus "left"` once per possible
|
|
2198
|
+
viewport (with a single left, a key pressed in the second viewport reached a lane); the first two then run
|
|
2199
|
+
`lanes sidebar-key filter|upload --focused` (cockpit v3 S4) and `Ctrl-Alt-↓/↑` run `lanes next|prev --focused`
|
|
2200
|
+
(plan Task 19), each in an invisible floating pane that closes on exit (`tests/unit/test_keybinds.py`).
|
|
1324
2201
|
- Tests: `python3 -m unittest discover -s tests/unit` needs no server (runs on the Mac) and carries the harness's
|
|
1325
2202
|
pure-function pins — the viewer rule, the client-size bucketing, the status keying, the shared-socket
|
|
1326
2203
|
attribution and the zmx word (`tests/unit/test_alignment.py`). Live suites, on a QUIET host only:
|
|
1327
|
-
`tests/guards.py`, `tests/view.py` (incl. the wide-view key walk
|
|
2204
|
+
`tests/guards.py`, `tests/view.py` (incl. the wide-view key walk: three lefts without wrapping, then
|
|
2205
|
+
`Ctrl-Alt-/`, `Ctrl-Alt-u` and `Ctrl-Alt-l` pressed through the client's pty from each viewport — the verb's command
|
|
2206
|
+
file, the Run pane's focus hand-back to the sidebar, the first letters typed after it landing in the filter, the
|
|
2207
|
+
focus legend — and both chords doing nothing on the Host tab; then `Ctrl-Alt-↓/↑`), `tests/upload_box.py`,
|
|
1328
2208
|
`tests/smoke_real.py` (read-only, real lanes), `tests/latency.py` (from a client; `tests/latency.py --switch
|
|
1329
2209
|
[samples]` ON the server measures `switch` = SIGUSR1 to view.sh → first byte of the new lane, with a throwaway
|
|
1330
2210
|
view.sh on a temp `LANES_STATE` and two sessions on `-L lanes_test`, run ad hoc). All use disposable lanes except
|
|
@@ -1344,11 +2224,23 @@ background tab without spawning its panes. Names and ordering can still sync.
|
|
|
1344
2224
|
|
|
1345
2225
|
Reconciliation identifies managed tabs by the workspace IDs in their launched
|
|
1346
2226
|
sidebar/view commands, then adds or renames tabs and orders them using Zellij's
|
|
1347
|
-
stable tab IDs. It never
|
|
2227
|
+
stable tab IDs. It never restarts agents or changes client focus, and closes exactly one
|
|
2228
|
+
kind of tab (cockpit v3 R4): the tab of a workspace the device owner's profile hides
|
|
2229
|
+
(`users/<login>.json` `workspaces.hidden`), identified by its sidebar/view panes — viewers
|
|
2230
|
+
only, the lanes live in containers. The owner's `workspaces.order` orders the boards (an
|
|
2231
|
+
unlisted board follows in catalog order); a device nobody owns keeps the catalog. A
|
|
2232
|
+
profile that hides every board hides none. A hidden board's tab closes only while another
|
|
2233
|
+
workspace tab exists on that device, counted over the tabs it has rather than the boards it
|
|
2234
|
+
should show: when the shown boards have no tab yet (one added while no client was attached),
|
|
2235
|
+
nothing closes and `lanes upgrade-session` prints `<device>: kept <title> open although
|
|
2236
|
+
hidden: …`; the missing tab is added once a client is attached, and the next sync or ensure
|
|
2237
|
+
tick closes the hidden one. A reconcile that would show no board at all is refused before
|
|
2238
|
+
any change.
|
|
1348
2239
|
An unknown session/pane response or an ambiguous identity refuses changes for that
|
|
1349
2240
|
device and reports an error. Another device can still be updated. An unmanaged
|
|
1350
2241
|
name collision is preserved for explicit inspection; it is never adopted by title.
|
|
1351
|
-
Per-device file locks serialize a sync with the ensure timer.
|
|
2242
|
+
Per-device file locks serialize a sync with the ensure timer; the view-session lock (`locks/sessions.lock`) serializes
|
|
2243
|
+
`ensure`, `rebuild` and every create or end of a view session ("The view-session lock" under "Server").
|
|
1352
2244
|
SSH split tabs match the host, absolute helper and declared pane ID together.
|
|
1353
2245
|
Different pane IDs may share one helper; overlapping endpoint identities refuse
|
|
1354
2246
|
before tab mutations and require an unambiguous catalog assignment.
|