@vibelord/lanes 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +45 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +618 -13
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +603 -72
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +777 -136
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +83 -24
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +44 -32
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
@@ -15,11 +15,40 @@ for operations, the [runbook](RUNBOOK.md).
15
15
  every board container in ONE `docker inspect` and rewrites a projection only when its content changed
16
16
  (`atomic_write_if_changed`; the two registry documents compare without `generated_at`), so a timer sync with
17
17
  nothing new wakes no reader that watches an mtime. The sync time itself is kept in `registry.synced`.
18
+ Each `.lz/registry.json` entry carries what `lane open` needs (`name`, `ws`, `holder`, `tmux`/`zmx`, `create`,
19
+ `folder`) and who declares the lane and where (LNS-32): `source` (the container's spelling of its
20
+ `terminals.json`), `repo` (the manifest's repository id), `registry_name` (prefix plus declared terminal name, the
21
+ name the id derives from whatever the display name), `container`, and `registered_session` — `source`, `repo` and
22
+ `registry_name` are null for a session adopted through `registered-sessions.json`. A repository lane's `name` is
23
+ the host manifest's `displayNames` entry, else the container's `lanes.terminals[<name>].displayName` (LNS-33; read
24
+ leniently, carried in the host registry as `declared_display`; dropped when another lane of the board answers to
25
+ the same name as a row reads it — title, override or its own declared name — so the container never makes two
26
+ rows read the same), else `registry_name`. The container owns `~/.lz`, so
27
+ the sync walks it without following a link, compares without blocking on a FIFO and replaces the file by rename; a
28
+ `~/.lz` that is no real directory is refused and reported (`! container <name>: …`, exit 1) and costs only that
29
+ container (DEF-1796). `publish_registry` returns those refusals; a register or rename that publishes
30
+ (`registrations.register`/`rename`, `lanes rename` through `sync_registry`) raises them as
31
+ `lanes_lib.ProjectionRefused` (`<what> saved; run lanes sync to finish publishing: container <name>: …`) once
32
+ its change is saved and published everywhere else.
18
33
  4. The status daemon observes tmux sessions and publishes status/health snapshots. Failed or timed-out
19
34
  probes mean unknown; they do not prove a missing session. A bad terminal registry retains the last good board.
20
35
 
36
+ Each workspace container that has the lane contract (`~/.lz/bin/lane`) also gets its own slice of the
37
+ observation as `~/.lz/status.json` (LNS-32, `statusd.write_container_status`), because nothing in a container
38
+ can read the host's `status.json`: `{v: 1, at, lanes{<id>: {state, agent?, attached?, viewers?, activity?, size?,
39
+ stale?}}, unregistered[{id, name, workspace, boards, tmux, identity}]}`. `lanes` holds the container's declared
40
+ and registered lanes (the ids its `.lz/registry.json` holds) and its unregistered sessions; a lane no cycle has
41
+ observed yet is `unknown`; the host-side details (cwd, pid, tty, command, probe outcome) stay on the host; a
42
+ session listed under several boards is one `unregistered` row naming them all. zmx lanes are not in it — `lane
43
+ ls`/`lane status` read them in the container. The file is rewritten only when that container's slice changed or
44
+ once per 15 s heartbeat (`at` = the last write, so its age tells a reader whether statusd is alive), written
45
+ through `/proc/<container init pid>/root` into a `~/.lz` walked with `O_NOFOLLOW` per component, the temp file
46
+ renamed over whatever the name holds — a symlink or FIFO a container plants there is replaced, never followed or
47
+ read (`lanes_lib.open_real_dir`, `atomic_write_if_changed_at`).
48
+
21
49
  Beside the six-word `state` (never changed by it), each live tmux lane, declared or unregistered, may carry
22
- an additive `agent{}` key: `{harness, status, name, session_id?, pid?, since, source}` with `status` one of
50
+ an additive `agent{}` key: `{harness, status, name, session_id?, pid?, since, source, raw_status, raw_since,
51
+ stable_since}` with `status` one of
23
52
  `working | needs_input | needs_approval | done | errored | shell | idle`. An absent key means no signal names
24
53
  an agent there. `statusd` builds it with `agent_state.merge` from signals the harnesses already emit, read once
25
54
  per container per cycle through `/proc/<container init pid>/root` without spawning anything. Precedence: a
@@ -33,11 +62,18 @@ for operations, the [runbook](RUNBOOK.md).
33
62
  (`window_bell_flag`, `window_id`, `pane_id`, `pane_title`) are appended to the listing format, the free-text
34
63
  title last so a tab inside it cannot shift another column.
35
64
 
65
+ What is published is not `merge`'s word itself but its debounced form, `agent_state.settle`, with three more
66
+ additive keys (`raw_status`, `raw_since`, `stable_since`): see [Attention queue](#attention-queue).
67
+
36
68
  The hook sidecar is `~/.lz/state/hooks/<lane id>.json` in the container, written by `~/.lz/bin/lane-signal`
37
69
  (Python 3 stdlib, installed by `lanes install-container`) when an opt-in Claude Code or Codex hook fires:
38
70
  `{v: 1, lane, harness, status, since, at, event, pid, start, session}` — `since` when the status began (kept
39
71
  while the same status is re-signalled), `at` the last event, `pid`/`start` the agent process and its
40
- starttime. The lane id is `$LANE_ID` (in a tmux lane only through a canonical recipe's `-e` flags; in a zmx lane
72
+ starttime — plus `name`, the agent's own note when it reported one (`lanes_signal`, LNS-35: kept while the status
73
+ repeats, dropped when it changes). A report (`event: lanes_signal`) stands against the same agent's hooks — the
74
+ same process (pid and starttime), else the same session when both name one — that only restate it: the PostToolUse of the
75
+ report's own tool call, and, unless it is `working`, the end of the turn (Stop, the idle Notification); those
76
+ advance `at` and nothing else. Any other hook replaces it (DEF-1801). The lane id is `$LANE_ID` (in a tmux lane only through a canonical recipe's `-e` flags; in a zmx lane
41
77
  `lane-run` exports it as the zmx session name) or the pane's tmux session looked up in `~/.lz/registry.json`; an unresolvable lane, a word outside the vocabulary or an
42
78
  unwritable directory is a silent exit 0, and nothing is ever printed (a Claude prompt/session hook's stdout
43
79
  becomes model context). `statusd` lists each container's `state/hooks/` once per cycle (`agent_sidecars`) and
@@ -98,6 +134,84 @@ hard cap"). Whether a lane should be one bucket is an open decision for the plan
98
134
  `tests/cursor_lane.py` exercises the installed `/home/coder/.lz/bin/lane`, so it proves this only after `lanes
99
135
  install-container`.
100
136
 
137
+ ## Attention queue
138
+
139
+ The sidebar's **Needs you**, **Working** and **Done** sections are a queue read by someone who is typing in a lane, so
140
+ it holds still: a lane changes section only when its agent's state has really changed, keeps its place inside a
141
+ section, and never moves the rows around it (cockpit v3 S1/S2, DEF-1810/DEF-1811). Two halves: `lanes-statusd`
142
+ debounces what it publishes, and the sidebar orders by when a lane ENTERED its section.
143
+
144
+ **Raw and debounced status.** Each cycle `statusd` derives `merge`'s word for a lane (the raw status, above) and
145
+ publishes `agent_state.settle(previous agent{}, merge's verdict, now)`, called at both merge sites (declared lanes and
146
+ undeclared sessions) with the agent{} the previous payload carried for the same lane id — status.json itself after a
147
+ restart, since `new_context()` seeds `previous` from it. Every status belongs to one section, ranked
148
+ (`agent_state.SECTION` / `RANK` / `DWELL`, the one table the sidebar's sections are drawn from too):
149
+
150
+ | Section (sidebar) | Statuses | Rank | Leaving it for a lower section waits |
151
+ | --- | --- | --- | --- |
152
+ | needs (**Needs you**) | `needs_approval`, `errored`, `needs_input` | 3 | 8 s |
153
+ | working (**Working**) | `working` | 2 | 5 s |
154
+ | done (**Done**) | `done` | 1 | 5 s |
155
+ | none (the workspace's other lanes) | `shell`, `idle` | 0 | — |
156
+
157
+ - a word in a HIGHER section is published at once — a question or an approval is never delayed;
158
+ - a word in the SAME section at once, keeping the section stamp (`needs_input` → `needs_approval` changes only the
159
+ urgency);
160
+ - a word in a LOWER section only once it has held for the `DWELL` of the section left (`now − raw_since`);
161
+ - no agent signal at all: the published agent{} stays for `GONE_DWELL` (5 s), then the key goes.
162
+
163
+ The dwell is measured on statusd's clock at its cycles (`LANES_STATUS_INTERVAL`, 3 s; longer while the host is under
164
+ pressure), so a demotion lands at the first cycle at or after it — 9 s out of Needs you and 6 s out of Working or Done
165
+ at the default interval. What `agent{}` then says: `status` (debounced) and `since` (the start of the published status
166
+ — the source's own start, never later than the cycle that first saw the word — kept while the status is unchanged, so
167
+ a Codex title's `window_activity` is no clock), `raw_status` (`merge`'s word this cycle, `null` while the agent is
168
+ gone), `raw_since` (statusd's clock when that word was first seen, reset only when it changes — never a source
169
+ timestamp) and `stable_since` (statusd's clock when the lane entered the section it is published in). All stamps are
170
+ integer seconds: a steady lane republishes byte-identically and status.json is not rewritten for it. The keys are
171
+ additive (DM-LANES-STATUS); a reader that knows only `status` reads the debounced word.
172
+
173
+ **Stable order.** The sidebar — desktop and phone, one `sidebar.Sidebar.sectioned` that `render_mobile` draws too —
174
+ orders Needs you by (urgency: `needs_approval`, `errored`, `needs_input`; `stable_since`; registry position), Working
175
+ by (`stable_since`, position) and Done by (newest `stable_since` first, position), Done holding only lanes not opened
176
+ since — from this device (`clients/<device>.seen.json`) or, on a device with an owner, from any device of that person
177
+ (the `seen` of their `users/<login>.json`, cockpit v3 R3); the rest follow in registry order under the workspace's own
178
+ header. `since` is never an order key — it restarts on every change inside a section — only the row's age and the
179
+ Done seen check. A status.json from a statusd older than S1 has no `stable_since`: the sidebar falls back to `since`
180
+ for the order and to the status for OSC 9 (cockpit v3 S2, DEF-1811).
181
+
182
+ **Stable geometry.** Once a workspace has an agent (a lane with any agent status but `shell`), the Needs you and Working
183
+ headers are always drawn (`ALWAYS_DRAWN`, `(0)` with a dimmed glyph), Done only with a member, so a lane moving
184
+ between sections adds or removes no row above the others. The cursor is a lane id (`cursor_id`), re-found after
185
+ every reload; `top` is pinned (`resolve_cursor` never shifts it) and render's clamps move it only to keep the cursor's
186
+ lane in the body or because the list got shorter.
187
+
188
+ **Announcements.** One OSC 9 per lane per entry into Needs you, keyed on `stable_since` (`osc_bytes`) — never for the
189
+ lane the pane shows, never on a phone, never when the device file's `notify` (else `LANES_NOTIFY`) says `0`; the pane
190
+ title carries the header counts. The push notifier (`notify.py`) reads the same debounced `agent.status`: a lane back
191
+ at work for less than 8 s never left Needs you, so its return is no news there either.
192
+
193
+ **Known limits.** A Codex `needs_approval` read from its pane title (a lane without hooks) keeps the 8 s demotion
194
+ dwell after the approval, like every demotion. A hooked Claude lane shows `needs_approval` until the approved call's `PostToolUse` (plus
195
+ the dwell): `PreToolUse → working` fires before the permission dialog, so it ends a stale approval at the next tool
196
+ start but cannot mark the approval itself (DEF-1571, open; RUNBOOK "Installing agent hooks").
197
+
198
+ **Keyboard.** The sidebar and the lane share one keyboard (the focused Zellij pane gets what is typed). Every sidebar
199
+ action that ends hands it back with one focus move (`Sidebar.release_focus` → `hand_off` → `focus_lane`; never on a
200
+ phone, whose picker is a whole tab) and drops the rest of the read it came in; a prompt, the filter and the armed
201
+ upload strip keep it (cockpit v3 S3). No Zellij bind types into a
202
+ pane: `Ctrl-Alt-/` and `Ctrl-Alt-u` reach the sidebar through `lanes sidebar-key filter|upload --focused`, which
203
+ writes `views/<device>/<ws>.sidebar.cmd` for the tab's sidebar to act on, and `Ctrl-Alt-l` only moves the focus there
204
+ (cockpit v3 S4). The sidebar asks for focus reports (`?1004h`) and says in its footer when the keyboard is on it. The
205
+ user guide's "Sidebar keys" table, the `?` sheet (`sidebar.KEY_SHEET`) and `handle()` are held to the same keys by
206
+ `tests/unit/test_sidebar.py` `DocumentedKeys` (cockpit v3 S10). The sheet's first row names the installed version and
207
+ the one the sidebar runs (`lanes <current> · view <tree>`, cockpit v3 V2); a sidebar keeps the tree it started from
208
+ when an update flips `current`, so while `current/VERSION` (re-read every 30 s in `reload()`) differs, its footer says
209
+ ` server <current> · view <tree> — press ?` below the health banner. Started through `current` (cockpit v3 U5), an idle
210
+ sidebar swaps itself onto the new tree instead: it hands its cursor lane, scroll, seen marks and filter to its successor
211
+ in `views/<device>/<ws>.<slot>.sidebar.state` and exits 75, and the wrapper re-executes itself from its logical
212
+ directory at once. `view.sh` traps `USR2` and re-executes itself at its child's next natural exit, so an attached lane
213
+ is never cut.
214
+
101
215
  ## Views and panes
102
216
 
103
217
  `lanes attach DEVICE` creates or reattaches the device's Zellij view. Layouts have one tab per workspace,
@@ -141,19 +255,33 @@ container, with the current workspace first. Registration verifies the observed
141
255
  saves its existing lane ID, name and exact binding in the version-1 `registered-sessions.json` site file.
142
256
  It publishes the registered lane immediately without signaling viewers or processes. Sync, registration and
143
257
  explicit close share a bounded registry lock; close also consults saved registrations if projection publication
144
- failed. Stale observations cannot put a registered session back in Unregistered.
258
+ failed. Stale observations cannot put a registered session back in Unregistered. The sidebar runs its slow verbs —
259
+ close, Promote's write and New + — on one worker thread (`sidebar.Worker`, cockpit v3 S9): the frame never waits for
260
+ a lock or a container, the row carries the `pending` mark and refuses every verb until the answer, which the 1 s
261
+ tick takes from a queue.
145
262
 
146
263
  These registrations have no create recipe and do not change repository terminal lists. Repository declarations
147
264
  win if they later declare the same binding. Unknown versions or malformed registration files refuse mutation;
148
265
  registrations for removed workspaces are retained on disk and ignored until the workspace returns. Removing a
149
- record and syncing unregisters it, including when a repository registry is temporarily invalid. Keep this site
266
+ record and syncing unregisters it, including when a repository registry is temporarily invalid; `registrations.unregister`
267
+ (sidebar `u`, `lanes unregister`) does exactly that under the registry lock and hands a running session back to
268
+ Unregistered under the same id (its name through `user-sessions/`, its views through `resolve.dynamic/`, the
269
+ workspace and id it was registered under through `handback/<binding digest>.json`: statusd's first listing of a
270
+ session with no listing of its own takes them when the binding and tmux identity match, rather than minting an id from
271
+ the workspace its directory names now), and
272
+ `registrations.reregister` (sidebar `z`, 10 s) is its undo, behind the same identity check as registration. Unregister
273
+ refuses while tmux gives no usable answer for the session (a timeout or connection error: proceeding would hand a
274
+ running session back with no identity), and a failure after the record is saved raises `registrations.Unpublished`
275
+ carrying the record, so the undo survives a full disk. A record `apply` leaves out (removed workspace, other container,
276
+ declared binding) is removed without asking tmux. Keep this site
150
277
  file in operator backups; deleting derived state does not delete registrations.
151
278
 
152
279
  Explicit **New +** starts a detached tmux shell inside the workspace container on its dedicated
153
280
  `lanes-shells-<workspace>` socket. Reopening only attaches. Explicit **×** closes an unregistered session after
154
281
  rechecking its session ID and creation time and refusing registered lanes; affected viewers become empty.
155
282
  The tmux close condition is checked inside the server, protecting a replacement created between observation
156
- and the action. `user-sessions/` stores display metadata; `status.json` and `resolve.dynamic/` are projections.
283
+ and the action. `user-sessions/` stores display metadata (and `handback/` the board and id of a session unregistered
284
+ while it ran); `status.json` and `resolve.dynamic/` are projections.
157
285
  `status.json` also carries `zmx_listed_at` — per container, the unix time statusd ISSUED the `zmx list` that last
158
286
  answered (a container whose listing timed out or failed is absent; its `zmx` groups are then the previous ones
159
287
  republished). It sits outside the change comparison, so the file is still rewritten only on change or the heartbeat;
@@ -168,7 +296,10 @@ The `lane` command lives in `/home/coder/.lz/bin`. `open` attaches or creates us
168
296
  These commands operate inside the container, independently of the Zellij viewer's lifetime.
169
297
 
170
298
  Every verb dispatches on the lane's holder. An id that `~/.lz/registry.json` holds as `holder: tmux` is driven
171
- through its exact socket and session, by a tmux client that carries no lane identity (`identity_free`, as `open`):
299
+ through its exact socket and session, by a tmux client that carries no lane identity (`identity_free`, as `open`),
300
+ runs with `-N` and is bounded in time (exit 124 when tmux does not answer); `lane` builds these clients through
301
+ `lane_core.py`, shipped beside it (DEF-1797: `attach-session` without `-N` on a socket whose server has just ended
302
+ starts an empty server that leaves its socket file behind, MEASURED on tmux 3.4):
172
303
  `send` types the text literally and then Enter in one client (`send-keys -t =<session>: -l -- TEXT ; send-keys
173
304
  Enter`; `--no-enter` omits it), `capture` is `capture-pane -p -J -S -N|- -t =<session>:` (`--vt` adds `-e`) trimmed
174
305
  to the last N non-blank lines, `status` prints `{id, holder, ws, title, tmux, alive, agent}` from `has-session` and
@@ -193,6 +324,228 @@ of a session is the `SessionEnd` hook's `lane-signal shell`, when hooks are inst
193
324
  `$EVAL_CLAUDE_REAL_BIN` when it is set (the binary eval-claude would run) and skips it when it names a `.bw-tools`
194
325
  shim, so `EVAL_CLAUDE_REAL_BIN=<checkout>/.bw-tools/bin/claude eval-claude` composes the two without a loop.
195
326
 
327
+ ## Agent API
328
+
329
+ `~/.lz/bin/lane-mcp` is the Agent API (the cockpit v3 plan's Track M, LNS-32…36). It is an MCP server inside each
330
+ workspace container: the agent in a lane uses it to see the lanes of its container and, within guardrails, to change
331
+ their declarations, type into a sibling lane and report its own status. It is the container's half of Lanes. It
332
+ reads the two projections the host writes into `~/.lz` and the container's own tmux, and it writes only inside the
333
+ container: a repository's `.vscode/terminals.json`, a tmux session, the lane's hook sidecar. What it changes reaches
334
+ the boards through the host's ordinary readers (`lanes sync`, `lanes-statusd`); nothing on the host listens to it.
335
+ **[OPERATOR-GATED]** in a live container: `lanes install-container <container>` ships it and adds it to Claude Code
336
+ and Codex (the plan's operator gate 3), and new agent sessions load it. How an agent should use it:
337
+ [`skills/lanes-agent-tools`](../../skills/lanes-agent-tools/SKILL.md). Every argument and refusal, for people:
338
+ [user guide](USERGUIDE.md#agent-tools-mcp). Install, uninstall, troubleshooting and the protocol smoke:
339
+ [runbook](RUNBOOK.md#install-check-uninstall).
340
+
341
+ ### What an agent may do
342
+
343
+ | Tool | Effect | Reads or changes | Picked up by | Seen within |
344
+ | --- | --- | --- | --- | --- |
345
+ | `lanes_whoami` | read | `$LANE_ID`, else the agent's own tmux pane, looked up in `~/.lz/registry.json` and `~/.lz/status.json` | — | now |
346
+ | `lanes_list` | read | `~/.lz/registry.json` and `~/.lz/status.json` only, no tmux | — | now; the observation is fresh while `status.fresh` (renewed within 60 s) |
347
+ | `lanes_status` | read | the two projections, one `has-session`, the lane's hook sidecar | — | now (`alive: null` when tmux does not answer in 5 s) |
348
+ | `lanes_capture` | read | one `capture-pane` of the lane | — | now |
349
+ | `lanes_register` | write | a repository's `terminals.json`: one terminal appended | `lanes sync`, started by `lanes-statusd`'s hot sync | 15 s |
350
+ | `lanes_unregister` | write | the same file: one terminal removed; its session keeps running | `lanes sync` | 15 s |
351
+ | `lanes_rename` | write | the same file: `lanes.terminals[<name>].displayName` | `lanes sync` | 15 s |
352
+ | `lanes_new` | exec | the same file: one terminal appended; and a new tmux session running `command` | `lanes sync` | 15 s (the session runs at once) |
353
+ | `lanes_send` | exec | keystrokes into a lane's active pane | the program in the lane's active pane | 0 s (its Enter half a second after the text) |
354
+ | `lanes_signal` | write | the agent's own hook sidecar, through `lane-signal` | `lanes-statusd` | 5 s |
355
+
356
+ "Seen within" is the `expected_within_s` each write answers (`test_docs.AgentApi` reads it from `lane-mcp` and holds
357
+ this table to it). A terminals.json edit: `lanes-statusd` checks every cycle (3 s, `LANES_STATUS_INTERVAL`) whether a
358
+ terminals.json its registry names has changed (`statusd.hot_sync`) and starts one `lanes sync` at most every 10 s;
359
+ the `lanes-sync.timer` sync every 2 minutes catches anything else. A sidecar is read on the next statusd cycle.
360
+ "Effect" is the tool gateway's classification (`read`, `write`, `exec`: arbitrary command execution). `lanes_new` runs
361
+ its `command`, and `lanes_send` types a line that a shell lane runs, so both are `exec`; the gateway recipe below
362
+ carries the same classes.
363
+
364
+ How it is built. The agents start `lane-mcp` (LNS-34) over stdio from
365
+ `mcpServers.lanes` in `~/.claude.json` and `[mcp_servers.lanes]` in `~/.codex/config.toml`. `lanes
366
+ install-container` writes both entries unless `--no-mcp`; `lane mcp install` makes the same change inside the
367
+ container. Claude Code hands the server its own environment; Codex clears it down to a short list of its own, so
368
+ the Codex table also carries `env_vars = ["LANE_ID", "LANE_WS", "TMUX", "TMUX_PANE"]` (`lane_core.MCP_ENV_VARS`),
369
+ the variables `lanes_whoami` names the lane by. It speaks newline-delimited JSON-RPC 2.0 (protocol versions
370
+ 2025-06-18, 2025-03-26 and 2024-11-05)
371
+ and keeps its stdout for protocol frames alone: fd 1 points at /dev/null before any tool runs, and a log line per
372
+ request goes to `~/.lz/s/logs/lane-mcp.log`. Its tools read the two host projections in `~/.lz`, `registry.json`
373
+ (`lanes sync`) and `status.json` (`lanes-statusd`, LNS-32), and tmux through the same `lane_core` runner. A newer
374
+ `version`/`v` there is refused, never half-read. `lanes_whoami`, `lanes_list`, `lanes_status` and `lanes_capture`
375
+ only read. `lane_core.py` also holds the minimal editor for a repository's `.vscode/terminals.json` that the write
376
+ tools build on. It edits strict JSON only, keeps every byte outside the edited span, re-reads the file before the
377
+ atomic write (one retry when another writer changed it), and refuses to touch the root `throttle` object, a
378
+ terminal's `name`, the terminals' order or the last terminal. The agents' entries are written the same careful
379
+ way: only when missing, `<file>.bak-<UTC>` first, re-read before the write, and an entry named `lanes` that starts
380
+ something else is never replaced.
381
+
382
+ The write tools (LNS-35) are `lanes_register`, `lanes_unregister`, `lanes_rename`, `lanes_new`, `lanes_send` and
383
+ `lanes_signal`. The first four change a lane's DECLARATION, a repository's terminals.json in the container, and
384
+ never the host: `lanes-statusd`'s hot-sync notices the changed file and starts one `lanes sync`, which publishes it
385
+ (about 15 s). To answer with the id that sync will give, the container carries ports of the host's rules —
386
+ `slug`, `shown_key`, `lane_trace`, `canonical_recipe`, the id grammar — each pinned equal to its `lanes_lib`
387
+ original by a test. The board's repository prefix, which the container never sees, is learned from the board's
388
+ existing lanes of that file (`registry_name` minus the declared name, `lane_core.source_prefix`), and a lane's
389
+ declared terminal is found by its tmux binding (`lane_core.declared_name`). A new declaration is refused when its
390
+ name reads the same as another lane's on the board — `_workspace_lanes` would refuse the board, or
391
+ `_settle_declared_displays` would silently drop another lane's display name — or when its id is one `build_registry`
392
+ would have to suffix (`-2`): the new lane would take an existing lane's id, the DEF-1555 class. `lanes_new` is the
393
+ one place lane-mcp may start a tmux server: its client runs without `-N`, in `lane_core.creation_env()` — an
394
+ allow-list (the account and locale names, `LC_*`, `TMUX_TMPDIR`, the host's TERM and COLORTERM), never the agent's
395
+ environment minus a deny-list, because lane-mcp runs with the agent's environment and a server hands its
396
+ environment to every later session on the board (DEF-1559, DEF-1802) — and the session gets the lane's identity
397
+ through the canonical recipe's four `-e` flags. `lanes_send` (and `lane send`) types printable text through the
398
+ runner's `send-keys -l` and presses Enter as a second client `ENTER_DELAY` (0.5 s) later: in one client the pane
399
+ reads the text and its CR as one chunk, which an agent's prompt takes as a paste (DEF-1803). It is refused while the
400
+ target's agent is `working` or `needs_approval` (the host's observation or the lane's own sidecar) unless forced.
401
+ `lanes_signal` runs `lane-signal` with `--agent-pid` (the server's parent, the agent), `--note` (the sidecar's
402
+ `name`, which `agent_state.read_sidecar_dict` already publishes as the agent's task line) and the agent's
403
+ `CLAUDE_CODE_SESSION_ID` when its environment carries one, so the report and the agent's hooks name the same agent. Each write answers what changed, what
404
+ picks it up and when; each refusal a machine code and what a person can do instead. On the host,
405
+ `session_actions.unregister_declared` runs the same `lane_core` editor (loaded once per process by
406
+ `lanes_lib.lane_core()`) through the container's home volume with `root=` set, so a symbolic link there is refused
407
+ instead of followed; `session_actions.unregister_lane(<id>)` finds the file and the terminal for a lane of the
408
+ registry, and it is what the sidebar's `u` and `lanes unregister` run on a declared lane (cockpit v3 S7's hook, wired
409
+ to this editor at the fan-in; a lane of an SSH workspace, declared in `catalog.json`, still says where to edit it). Ending, interrupting or attaching a lane, uploads, the host's sync/gc/ensure, tmux session
410
+ renames and adopted sessions have no tool.
411
+
412
+ ### What stays with a person
413
+
414
+ No tool exists for these. An agent that needs one asks a person; a refusal's `human` says what that person does.
415
+
416
+ - **Ending or closing a lane**, its session or its jobs (`x` in the sidebar for an unregistered session,
417
+ `lane kill <id> --yes` in the container). `lanes_unregister` removes a declaration and leaves the session running.
418
+ - **Interrupting** an agent. `lanes_send` types printable text only (no Ctrl-C, Esc, newline or tab) and refuses a
419
+ lane whose agent is working (`agent_working`) unless forced.
420
+ - **Answering an approval** prompt. `lanes_send` refuses a lane whose agent waits for an approval
421
+ (`agent_needs_approval`); `force` is for an answer the agent was asked to type.
422
+ - **Opening or attaching** a lane, or changing what a view shows.
423
+ - **Uploads**.
424
+ - The host's **`lanes sync`, `lanes gc`, `lanes ensure`** and every other host verb. The container cannot run them;
425
+ its changes reach the host only as files the host reads.
426
+ - **Renaming a tmux session** (`rename-session`). `lanes_rename` changes the name the boards show, never the id or the
427
+ session.
428
+ - Any change to a session added with **`lanes register`** on the host (an adopted `--x-` session, kept in
429
+ `registered-sessions.json`): `adopted_lane`.
430
+ - The board's host manifest (`workspaces.<board>.json`). A `displayNames` override there wins over `lanes_rename`, and
431
+ the answer says so (`host_override`).
432
+
433
+ ### Wire contract v1
434
+
435
+ The contract is frozen at wire v1, and any change within v1 is additive. It is what `tools/list` answers, pinned byte
436
+ for byte by
437
+ [`tests/unit/golden/lane-mcp-tools.json`](../tests/unit/golden/lane-mcp-tools.json) (`test_lane_mcp.ToolsList`), plus
438
+ the result and refusal shapes below. `lane-mcp` names it `WIRE = 1` (its `start` log line says `wire 1`). It speaks
439
+ MCP protocol versions 2025-06-18, 2025-03-26 and 2024-11-05: `initialize` answers the client's version when it is one
440
+ of these, else 2025-06-18. The data-model row is DM-LANES-LANE-MCP in `docs/master-datamodel.md`.
441
+
442
+ - **Frozen in v1:** the ten tool names and their order; every input property's name, type and meaning; which
443
+ arguments are required; the closed schemas (`additionalProperties: false`); the read-only annotations of the four
444
+ readers and the hints of the six writers; the result shapes below.
445
+ - **Additive only within v1:** a new tool appended after the others; a new optional argument whose default keeps
446
+ today's behaviour; a new key in a JSON result; a new refusal code. A reader ignores keys it does not know and treats
447
+ an unknown `refused` code as a refusal. A description may be clarified; the golden then changes visibly.
448
+ - **A breaking change** (a renamed or removed tool or argument, a changed type or meaning, an argument made required,
449
+ a reader that starts to write) is not made in v1. It ships as a new tool name beside the old one, and `WIRE` moves
450
+ only when an old name is removed, together with DM-LANES-LANE-MCP and this section.
451
+ - **Results** are one `text` content block. The readers answer JSON (`lanes_whoami`, `lanes_list`, `lanes_status`)
452
+ or plain text (`lanes_capture`); a reader that cannot answer is `isError: true` with a sentence. The writers always
453
+ answer one JSON object. On success it holds what changed (`changed`), what picks the change up (`picked_up_by`) and
454
+ how soon (`expected_within_s`); `lanes_signal` adds `stands_until`. On a refusal it is `isError: true` with the
455
+ machine code (`refused`), the reason (`why`) and what a person can do instead (`human`). A refusal changed nothing
456
+ unless it carries `changed`: `tmux_failed` after `lanes_new` declared its lane, or after `lanes_send` typed its text
457
+ but not its Enter. There is no `outputSchema` or `structuredContent`, so every protocol version reads the same thing.
458
+ - **Protocol errors** (JSON-RPC): -32700 a line that is not JSON, -32600 an invalid request (a batch), -32601 an
459
+ unknown method, -32602 an unknown tool, -32002 anything but `initialize` and `ping` before `initialize`, -32603 a
460
+ defect outside a tool. The session goes on after each.
461
+ - **What it reads** is versioned too: the registry projection (`version: 1`, DM-LANES-REGISTRY) and
462
+ `~/.lz/status.json` (`v: 1`, DM-LANES-CONTAINER-STATUS). A newer version is refused (`contract_outdated`), never
463
+ half-read; `status.fresh` is false when that file's `at` is older than 60 s (four statusd heartbeats).
464
+
465
+ The refusal codes (`refused`), each with the `human` sentence `lane-mcp`'s `HUMAN` table gives it:
466
+
467
+ - arguments and the contract: `invalid_arguments`, `contract_outdated`, `internal_error` (a defect; the trace is in
468
+ the log), `refused` (a refusal without a code of its own);
469
+ - which lane: `not_in_lane`, `not_in_container`, `adopted_lane`, `already_declared`, `unregistered`, `not_tmux`,
470
+ `session_gone`, `session_exists`;
471
+ - which file: `no_source`, `ambiguous_source`, `ambiguous_workspace`, `source_missing`, `source_invalid`,
472
+ `source_unreadable`, `source_link`, `no_lanes_block`, `not_declared_here`, `unknown_prefix`;
473
+ - the edit: `edit_conflict`, `edit_refused`, `last_terminal`, `duplicate_name`, `duplicate_binding`, `slug_collision`;
474
+ - a value: `invalid_name`, `invalid_session`, `invalid_cwd`, `invalid_command`, `invalid_text`, `invalid_note`;
475
+ - the target's agent: `agent_working`, `agent_needs_approval`;
476
+ - tmux and the sidecar: `tmux_unanswered`, `tmux_failed`, `signal_not_written`.
477
+
478
+ `test_docs.AgentApi` keeps this list equal to every code `lane-mcp` and `lane_core.py` can raise. Two JSON-RPC frames
479
+ through the installed server check a container without an agent: the
480
+ [protocol smoke](RUNBOOK.md#protocol-smoke).
481
+
482
+ ### Gateway adoption (documented, not registered)
483
+
484
+ Throttle's tool gateway ([its architecture](../../docs/architecture/throttle-tool-gateway.md)) launches reviewed MCP
485
+ packages from `catalog/workspace-tools.json`, and an operator selects them with `[[package]]` in
486
+ `tool-registry.toml`. `lane-mcp` is not registered there. Decided 2026-10-07 (LNS-36): agents reach it directly
487
+ through the entries `lanes install-container` writes. **[PLANNED]** adoption would use this catalog recipe. Its
488
+ fields are the gateway reader's own (`CatalogRecipe`, `CatalogTool` in `src/tool_gateway/catalog.rs`, both
489
+ `deny_unknown_fields`). `planned` keeps it invisible, and the digests come from `scripts/tool-harvest-catalog.mjs`
490
+ at a pinned commit, never by hand:
491
+
492
+ ```json
493
+ {
494
+ "id": "lanes",
495
+ "family": "lanes",
496
+ "description": "Lanes Agent API: the lanes of the workspace container this agent runs in (lane-mcp, wire v1).",
497
+ "version": "<lanes/VERSION at the pinned commit>",
498
+ "state": "planned",
499
+ "source": {"repository": "https://github.com/bwllaming/bw-tools.git", "commit": "<pinned>", "path": "lanes/container/lane-mcp"},
500
+ "source_descriptor_sha256": "<harvested>",
501
+ "transport": "stdio",
502
+ "command": "/home/coder/.lz/bin/lane-mcp",
503
+ "args": [],
504
+ "default_effect": "write",
505
+ "allow_exec": false,
506
+ "tools": [
507
+ {"name": "lanes_whoami", "description": "<harvested>", "effect": "read", "state": "planned", "description_sha256": "<harvested>"},
508
+ {"name": "lanes_list", "description": "<harvested>", "effect": "read", "state": "planned", "description_sha256": "<harvested>"},
509
+ {"name": "lanes_status", "description": "<harvested>", "effect": "read", "state": "planned", "description_sha256": "<harvested>"},
510
+ {"name": "lanes_capture", "description": "<harvested>", "effect": "read", "state": "planned", "description_sha256": "<harvested>"},
511
+ {"name": "lanes_register", "description": "<harvested>", "effect": "write", "state": "planned", "description_sha256": "<harvested>"},
512
+ {"name": "lanes_unregister", "description": "<harvested>", "effect": "write", "state": "planned", "description_sha256": "<harvested>"},
513
+ {"name": "lanes_rename", "description": "<harvested>", "effect": "write", "state": "planned", "description_sha256": "<harvested>"},
514
+ {"name": "lanes_new", "description": "<harvested>", "effect": "exec", "state": "planned", "description_sha256": "<harvested>"},
515
+ {"name": "lanes_send", "description": "<harvested>", "effect": "exec", "state": "planned", "description_sha256": "<harvested>"},
516
+ {"name": "lanes_signal", "description": "<harvested>", "effect": "write", "state": "planned", "description_sha256": "<harvested>"}
517
+ ]
518
+ }
519
+ ```
520
+
521
+ The operator's selection would then be (`PackageSelection`; the key is `package`):
522
+
523
+ ```toml
524
+ [[package]]
525
+ package = "lanes"
526
+ workspaces = ["<bound workspace>"]
527
+ allowed_tools = ["lanes_whoami", "lanes_list", "lanes_status", "lanes_capture"]
528
+ ```
529
+
530
+ Why it is not registered now:
531
+
532
+ 1. **Where it runs.** `lane-mcp` reads the `~/.lz` and the tmux sockets of ONE container. The gateway spawns a stdio
533
+ package where the gateway runs, from an argv, a `cwd` and literal `env` (`{workspace_root}` is the one
534
+ placeholder). From the host it would have to start through `docker exec -i -u coder <container>`, a launch the
535
+ catalog has no reviewed form for.
536
+ 2. **Who calls.** `lanes_whoami` and `lanes_signal` name the caller by the agent's own environment (`LANE_ID`, `TMUX`,
537
+ `TMUX_PANE`, `CLAUDE_CODE_SESSION_ID`) and by `lane-mcp`'s parent process, the agent (`--agent-pid`). A gateway
538
+ child serving several agents has the gateway as its parent and none of that environment. `lanes_whoami` would
539
+ answer `lane: null` and `lanes_signal` would refuse `not_in_lane`, or, through a pass-through the gateway does not
540
+ have, report for the wrong agent.
541
+ 3. **Effects.** `lanes_new` and `lanes_send` are `exec`. With `allow_exec: false`, as every catalog package ships, the
542
+ gateway hides them; the selection above names the four readers.
543
+
544
+ Adoption needs a per-attachment identity channel (the gateway passing the caller's lane, not the agent's
545
+ environment), a reviewed in-container launch, a harvest at a pinned commit (`source_descriptor_sha256` over the tool
546
+ surface the golden pins) and a Throttle catalog change with its own gates (DM-TOOL-CATALOG). Until then the catalog
547
+ holds no `lanes` package and no `lanes_*` tool; `test_docs.AgentApi` checks both.
548
+
196
549
  ## Desktop and uploads
197
550
 
198
551
  The client installer creates an SSH alias and dedicated terminal configuration, using the selected profile's
@@ -208,6 +561,21 @@ workspace's `uploads/` folder without overwriting files; an optional paste place
208
561
  selected lane. Active transfers have an idle timeout, not a short total-duration limit. Credentials, `.git`
209
562
  metadata and disallowed folders cannot be uploaded through the helper.
210
563
 
564
+ **Quick edit** (`Ctrl-Alt-e`, cockpit v3 E1–E3) is the opposite direction and moves no file at all. The floating
565
+ **Edit** pane runs `lanes edit --focused` on the server: it resolves the focused tmux lane as the preview does, finds
566
+ the lane's git checkout and, for a linked worktree, its main checkout (`lanes_lib.repo_roots`, walking up to `.git`, a
567
+ `gitdir:` link and its `commondir`, all read through `/proc/<container init>/root`), and offers `AGENTS.md`, `CLAUDE.md`
568
+ and the main checkout's `.env.local` when each is a regular file inside its checkout (no symbolic link, no control
569
+ character, nothing created). It only stats those paths; it never opens one. The picker paints after the terminal takes
570
+ raw input (TCSANOW, so a key typed ahead is read, never flushed: DEF-1836) and reads one byte; the terminal is restored
571
+ before the pick replaces the process (`execvp`, argv only, no shell, no `docker inspect`) with `docker exec -it -u coder
572
+ -w <checkout> <container> /home/coder/.lz/bin/lane-edit <file>`: the editor runs in the container as uid 1000, the
573
+ agents' own user, so it can reach no file the lane's agent could not, and saves as that user. `lane-edit` (shipped by
574
+ `lanes install-container`, with `~/.lz/vimrc`) runs `$LANES_EDITOR` — the device's `lanes attach --editor` choice — else
575
+ the first of micro, hx, nvim, vim and vi, under `umask 077`; vim and nvim write in place (mode and owner kept) and keep
576
+ swap files in `~/.lz/tmp/`. The contents leave the container only as the editor's screen output, on the view's terminal
577
+ stream; `.env.local` is named by its label, never its path, in anything quick edit prints or logs.
578
+
211
579
  ## Joining a site
212
580
 
213
581
  A machine whose key the server does not know (`lanes hello` refused with `Permission denied`) is authorized through
@@ -215,7 +583,8 @@ the site's enrolment service, `lanes-enrold` (W8 Track A, loopback, behind Tails
215
583
  never by the client editing `authorized_keys`. The client half is in `install.sh` (`enrol`). It asks
216
584
  `https://<LANES_SERVER_NAME>:8443` with curl, or with python3's `http.client` when curl is absent. When the machine
217
585
  cannot resolve the name (MagicDNS off), it repeats the request against the profile's IPv4 `LANES_SERVER_HOST`
218
- (`--resolve NAME:PORT:ADDRESS`), so TLS still checks the certificate against the name. Request bodies go through stdin;
586
+ (`--resolve NAME:PORT:ADDRESS`), so TLS still checks the certificate against the name, and keeps that for the rest of
587
+ the run. Request bodies go through stdin;
219
588
  curl runs with `-q` (no `~/.curlrc`) and `--noproxy '*'` (the service is on the tailnet only).
220
589
 
221
590
  Wire contract v1 is the one `lanes-enrold` serves (W8 A3; its RUNBOOK section "Enrolment service" and the data-model
@@ -225,7 +594,7 @@ reads JSON answers of at most 64 KiB; any status other than the success below is
225
594
 
226
595
  | Call | Body the client sends | Success, and what the client reads of it |
227
596
  | --- | --- | --- |
228
- | `GET /enrol/site` | — | `200 {alias, host_name, host_key, version, request_mode, caller, source}`: `request_mode` (`true`\|`false`, always present) is what tells the service from a web page or any other JSON; `false` = invite only; `caller` is the tailnet login the service received (`null` outside request mode). `version` is the server's Lanes release, not a wire number: the client does not gate on it |
597
+ | `GET /enrol/site` | — | `200 {alias, host_name, host_key, version, request_mode, caller, source}`: `request_mode` (`true`\|`false`, always present) is what tells the service from a web page or any other JSON; `false` = invite only; `caller` is the tailnet login the service received (`null` outside request mode). `version` is the server's Lanes release, not a wire number: no admission depends on it; the enrolment-first update (below) compares it with the installer's own release |
229
598
  | `POST /enrol/redeem` | `{"code", "pubkey": "ssh-ed25519 <base64>", "device", "client_version"}` | `200 {device, owner: {login, name, email} \| null, ssh_ready: true}`: the key is authorized now |
230
599
  | `POST /enrol/request` | `{"pubkey", "device", "client_version"}` | `202 {id, expires_at}` (the same device and key asked again: the same request); the client keeps `id` |
231
600
  | `GET /enrol/request/<id>` | — | `200 {id, state: pending\|approved\|denied\|expired, expires_at}`; `404` = the service does not know it (or it was filed by another login): the request is over |
@@ -240,6 +609,19 @@ refusal, or no code and no terminal: the exit-3 text with the public key, unchan
240
609
  and nothing is used up, so the exit-3 text names the re-run that joins instead: another `--device NAME` (the invite is
241
610
  still valid) or a key of its own, and where a member looks (`lanes offboard <device> --yes`, `lanes keys list`).
242
611
 
612
+ The enrolment-first update (W9 K3) makes the server's release the installer that redeems an invite. The npm wrapper
613
+ runs `install.sh` with `LANES_WRAPPER_REPLY=<a fresh file>`; with an invite and without `LANES_NO_SELF_UPDATE`,
614
+ `install.sh` `enrol_preflight` — after `check_invite`, before any key, file, greeting or redeem — makes one bounded
615
+ `GET /enrol/site` (on a tailnet site only when `tailscale status` already says Running and online) and compares
616
+ `version` with its own `VERSION` as `lanes hello` does (major.minor.patch as numbers; a `-`/`+` suffix not compared;
617
+ `dev`, a range or a tag is no release). Newer: it writes `update-to=<release>` to that file and exits 5. The wrapper
618
+ runs `npx --yes @vibelord/lanes@<release>` (npx from PATH, npm beside it, only after `npm view` printed that release)
619
+ with the same arguments minus `--invite CODE`, the code in `LANES_INVITE`, `LANES_NO_SELF_UPDATE=1`, and without
620
+ `npm_config_call`/`npm_config_package` (npx's own `-c`/`-p`, which break a nested npx), and ends with its status. No
621
+ npx, a release npm lacks, no answer, or a reply naming no newer release: `install.sh` runs again from the package with
622
+ `LANES_NO_SELF_UPDATE=1`. An exit 5 without a reply is `install.sh`'s own status (data model:
623
+ DM-LANES-INSTALL-HANDOVER).
624
+
243
625
  ## Web and phone
244
626
 
245
627
  Tailscale Serve terminates private HTTPS on 8443 and forwards to the dedicated Caddy user service on loopback
@@ -268,16 +650,178 @@ workspace and lane pickers. The bar reads the `MobileState` that Zellij sends to
268
650
  phone picker's `FocusPane`. It overlays the terminal, so the terminal's columns and rows do not change, and a
269
651
  click on the chip leaves the keyboard focus in the terminal. The tablet and desktop code and styles live in
270
652
  `web/browser.js` and `web/browser.css`, which `mobile.js` appends only in those two modes, so a phone never
271
- downloads them: a phone fetches only the two files spec §8 budgets, MEASURED 2026-10-04 after the fan-in with
272
- plan Tasks 13/28 at 21 964 B of 22 174 B (`mobile.js`, whose explanations live in `web/README.md`, Code notes)
273
- and 18 945 B of 18 982 B (`mobile.css`). `lanes web <device> [--desktop|--mobile]` prepares a browser view,
653
+ downloads them: a phone fetches only the two files spec §8 budgets, MEASURED 2026-10-07 after the cockpit v3 main
654
+ sync (phone shell input v2 composed with S5 and W9 V3) at 24 612 B (`mobile.js`, whose explanations live in
655
+ `web/README.md`, Code notes, as `browser.js`'s do; the cap is that measured size, see there) and 18 945 B of 18 982 B
656
+ (`mobile.css`); the laptop/tablet JS download is 27 946 B, the DEF-1569 ratchet. Every byte the phone shell sends
657
+ goes on the terminal WebSocket through the client's own `sendFn` (input v2, CAP-142). It writes only what a person
658
+ tapped or typed into a lane: the filter's clearing `Esc` reaches only the picker it was typed on (DEF-1752, DEF-1817,
659
+ DEF-1820), and
660
+ while the picker's sidebar asks for a key (its pane title ends ` · › <verb>`; never while a name is being typed,
661
+ DEF-1819) the quick row offers `⏎ y Esc` there. The phone and tablet shell makes one request: once per page load it
662
+ reads the enrolment service's `GET /site` on the same origin (`/enrol/site`) and shows its `version` atop the client's
663
+ **More** menu (`Lanes v1.2.0`); a dev tree, an error or no answer shows nothing.
664
+ `lanes web <device> [--desktop|--mobile]` prepares a browser view,
274
665
  enables the services through `install/enable-web.sh` and prints the private URL; without a flag a known device
275
666
  keeps its layout.
276
667
 
277
668
  ## Installed files and services
278
669
 
279
- `install/services.py` renders the service templates against the actual checkout and installed binary paths.
280
- It replaces old unit symlinks atomically and creates `~/.local/share/lanes/current` as a discovery shortcut.
670
+ `install/services.py` renders the service templates against the installed binary paths and the `current` link.
671
+ It replaces old unit symlinks atomically. An npm install stages each release at
672
+ `~/.local/share/lanes/pkg/<version>/`; a checkout install is the checkout itself.
673
+
674
+ **Stable paths (W9 U1).** `~/.local/share/lanes/current` names the installed tree, and it is the only tree path the
675
+ units, `LANES_REPO` (so the `lanes-mobile` Caddyfile root), `~/.local/bin/lanes` (→ `current/bin/lanes`) and the
676
+ generated view layouts embed. Moving the link is the update's commit point: `services.py` writes every unit first
677
+ (naming the link, a unit's text no longer changes with the release, so nothing runs differently yet), then records
678
+ the tree the link named as `~/.local/share/lanes/current.previous` (the rollback target; not when it is the tree being
679
+ installed, nor when that tree was pruned), then moves `current` with one `rename(2)` — a new link made beside it and
680
+ replaced over it, so there is never a moment without one — and then points `~/.local/bin/lanes` through it. A failure
681
+ before the flip leaves the installed release as it was. Only the server installer moves `current` off a tree it names
682
+ (`host-install.sh` runs `services.py --install`). The enable steps (`install/enable-web.sh`, step 6 of
683
+ `install/enable-studio-host.sh`) re-render: they run from whatever tree their shell found, which after an update can be
684
+ the old one, so a re-render renders the templates of the tree `current` names, moves no link, and says so on stderr; it
685
+ links `current` to its own tree only where the link names none. Installs and re-renders hold one exclusive `flock(2)`
686
+ on `~/.local/share/lanes/.install.lock` (kept, never removed; a 60 s bounded wait, then a refusal that changes nothing)
687
+ from the refusal check through the command link, and read `current` inside it, so neither acts on a link the other
688
+ has moved since (DEF-1835). The layout generator (`bin/lanes` `workspace_tab_kdl`,
689
+ `mobile_workspace_tab`, `ssh_workspace_tab_kdl`, `tool_tab_kdl`) names `libexec/sidebar`, `libexec/view.sh` and
690
+ `holders/ssh.sh` through `lanes_lib.CURRENT`, and so do the view server's zellij `--config-dir`/`--config` and the
691
+ `bin` entry of its `PATH` (`clean_env`), which a session keeps for its lifetime: the bare `lanes` of a keybinding
692
+ follows the link. Each process still runs one tree: Python puts the script's resolved directory first on `sys.path`,
693
+ so a daemon started through the link imports only from the tree it started in, and `view.sh` resolves its own
694
+ directory with `pwd -P`, so the holders it runs stay on its tree until the view starts again; the sidebar wrapper
695
+ re-runs `sidebar.py` through the link, so a restarted sidebar runs the release `current` names then. Readers of pane
696
+ commands — `device_sync.identities`, `focused_view`, `stale_picker_layouts` — match a tree file by its suffix
697
+ (`lanes_lib.tree_file_at`: an absolute path ending in `/libexec/sidebar`, `/libexec/view.sh` or `/holders/ssh.sh`), so
698
+ a session built before, whose panes name its `pkg/<version>` tree, keeps every tab identity across an update.
699
+
700
+ **Staged-tree pruning (W9 U4).** The npm wrapper deletes old staged trees only where `current` does not exist (a
701
+ client copies its commands out of the tree; `npm/lanes/lib/plan.mjs` `prunable`). On a server what still runs an old
702
+ tree is visible only there — `current.previous`, a layout built before W9 U1, a daemon or viewport started through the
703
+ link before a flip — so `lanes gc` prunes (`bin/lanes` `staged_tree_plan`, `gc_trees`): per folder of stamped trees
704
+ (`pkg_roots`) the 3 newest by mtime, plus every tree that `current`, `current.previous`, the running tree, a
705
+ `layouts/*.kdl`, a running daemon's `run/<name>.tree` or a process of this uid names (`process_paths`: `/proc/<pid>/`
706
+ `cmdline` words as written and resolved, `cwd`, `exe`, `fd/*` — the kernel's resolved paths, which is how a shell
707
+ script started through the link still shows its tree; a Python daemon closes its script, hence the run records). No
708
+ `/proc`, no pruning. A pruned tree is renamed to `.pruning-…` first, so the wrapper never reuses a half-removed one by
709
+ its stamp. Units are not read: every install that moves `current` renders them all through it.
710
+
711
+ **The update (W9 U2).** `install.sh --server --update-only` (`host-install.sh`) splits an update at that commit point.
712
+ Before it (preflight, the sha256-pinned binaries, terminfo, the catalog) any failure ends the run with the installed
713
+ release untouched; `services.py --install` is the commit, and its refusals (the install lock held, a link that is no
714
+ symlink, a path no unit can hold — each raised before its first write as a `services.Refusal`) exit 3. After it,
715
+ `libexec/update.py post-commit` runs from the new tree, with its own `bin/lanes`: daemon-reload, `lanes sync`, the
716
+ statusd restart, a `try-restart` of each optional unit (`update.TRY_RESTART`), `install-container` for each container
717
+ `containers.json` records with a home volume (read after the sync), `lanes reload-views`,
718
+ `lanes ensure --if-layout-changed` — each step in its own session, bounded (`STEP_TIMEOUT`, then its process group is
719
+ stopped), its output in `update/last.log`, its exit status in `update/last.json`, and run whatever the one before
720
+ returned — `lanes sync`'s exit 1 (a board with an error) being a warning that leaves the result `ok`
721
+ (`update.WARN_RC`). An update needs an installed release: with no `current` it is refused in the preflight, since the
722
+ post-commit only restarts what a full install enabled. One install or update runs at a time: each takes the run lock
723
+ `update/.lock` before writing anything — an flock on a descriptor host-install.sh's shell holds to its end (fd 9;
724
+ `update.py lock` takes it, the children that can leave a process behind run without it) — and a run that finds it held
725
+ exits 3 without touching the log or the record of the run that holds it. The update command of plan W9 U3 runs
726
+ host-install.sh, so it must not hold the run lock itself meanwhile; a rollback that calls `update.post_commit`
727
+ directly takes it with `update.take_run_lock`. The record says `partial` before the first step, so an update killed
728
+ half-way never leaves the record of the run before it; host-install.sh's EXIT trap writes `refused` or `failed` for a
729
+ run that ended before the commit, and `partial` when `services.py` failed after its flip (installed, nothing
730
+ restarted). Keys of the record the update does not own are kept for the tasks after it (the rollback mark of plan W9
731
+ U3). `lanes reload-views` sends SIGUSR2 only to a view.sh whose tree — the one it runs, read from the script file its
732
+ shell holds open (`/proc/<pid>/fd`), else its command line — is release 1.2.0 or later (`VIEW_RELOAD_SINCE`, the first
733
+ with the trap) AND whose SigCgt mask (`/proc/<pid>/status`) shows a handler — the signal's default action would end
734
+ any other, and with it the viewport — so a viewport of a release without the in-place reload keeps its tree until
735
+ `lanes rebuild`.
736
+
737
+ **Views after an update (W9 U6).** Panes re-exec into the new tree in place (U1's `current` paths, U5), so only a
738
+ layout change needs a rebuild. `lanes ensure --if-layout-changed` (`bin/lanes` `cmd_ensure` → `refresh_view`), the
739
+ post-commit's last step, compares each running view's `layouts/<device>.kdl` — what `create_session`, its only writer,
740
+ built the view from — with `layout_kdl` now (`layout_changed`): the tab template, and each tab whose title both still
741
+ have, line for line (the first tab's `focus=true` aside). A tab only one side has, and the tab order, are no change,
742
+ because `device_sync.reconcile` adds, renames and orders tabs live but never changes a tab's panes. A changed view is
743
+ rebuilt (`cmd_rebuild`) only when `zellij -s <device> action list-clients` answers with no client row; with a client,
744
+ or with no answer, it is left running and `clients/<device>.update-ready` is written (DM-LANES-UPDATE-READY), which
745
+ the sidebar looks for every `UPDATE_POLL` (30 s, outside the tick budget) and shows as the footer rung `update ready ·
746
+ reload (r)`, where `r` starts `lanes rebuild <device>` in a session of its own. The minute `lanes ensure` re-runs
747
+ the comparison for stamped views only, so a view is rebuilt once its last client has left; `create_session` removes
748
+ the stamp, and a layout that is current again (a rollback) drops it.
749
+
750
+ **The view-session lock (DEF-1792).** Every path that decides from `live_sessions()` (or from `devices/`) and then
751
+ creates, kills or deletes a view session — `cmd_ensure` (its whole pass, the tab sync included), `cmd_rebuild`, the
752
+ create of `cmd_attach` and `prepare_view` (`prepare`, `web`), offboard's `end_view` and `lanes device forget` — holds
753
+ `L.sessions_lock` (an exclusive flock(2) on `locks/sessions.lock`, `{pid, doing, since}` written in for a waiter to
754
+ name) from that list to its last zellij call. Without it the update's `ensure --if-layout-changed` and the minute
755
+ timer's plain `ensure` (or a sidebar's `r`) could both act on one device: one kills and deletes it, the other lists
756
+ in that window, finds it missing and runs a second `zellij attach --create-background`. The lock is re-entrant within
757
+ a process (`refresh_view` rebuilds through `cmd_rebuild` inside the pass), dies with its process, and is never
758
+ inherited (`O_CLOEXEC`; `cmd_attach` lets go before it forks its helpers and execs the client). Callers that must act
759
+ wait `SESSIONS_WAIT` (120 s, about one rebuild's bounded zellij calls) and then fail naming the holder; callers with a
760
+ fallback glance (`SESSIONS_GLANCE`, 10 s): the timer's plain `ensure` skips its tick, `attach` attaches without
761
+ creating. A lock in code, not unit ordering: `After=` orders only jobs queued in one transaction, `Conflicts=` would
762
+ stop a running update, and `lanes rebuild`, the sidebar and an update by hand are not units. Lock order: a pass that
763
+ holds it takes `device_sync`'s per-device `locks/device-sync.<device>.lock` (the tab sync) and nothing takes them the
764
+ other way round.
765
+
766
+ **The update command (W9 U3).** `lanes update` (`libexec/update.py` `update_main`, from `bin/lanes cmd_update` with its
767
+ `version_key`) is what `lanes-update.timer` runs (`--timer`: 15 min after a boot, every 30 min, a randomized 5 min; the
768
+ oneshot unit is stopped after 1 h). Two locks, by design: `locks/update.lock` (one `lanes update` at a time, held for
769
+ the whole run) and the installer's run lock `update/.lock`, which `lanes update` takes (`update.run_lock`, the same
770
+ `take_run_lock` on its own open file) only around what writes the record or runs a post-commit itself — the partial
771
+ re-run, the check, the rollback — and releases before npx runs host-install.sh, which takes it (the U2 contract). The run
772
+ acts only on an npm-installed server (`lanes_lib.staged_tree` of the tree `current` names); a checkout, no installed
773
+ release, or a lock held is a refusal (exit 3, 0 under `--timer`: the timer's unit fails only when an update did). In
774
+ order: a record whose `result` is `partial` gets `update.post_commit` of the installed tree again (once per release
775
+ from the timer: the `rerun` key); a check is skipped when the last one answered less than `INTERVAL` (20 min) ago;
776
+ `read_conf` maps `~/.config/lanes/update.conf` `channel=` to a dist-tag (`latest`, `stable` → `lanes-stable`; anything
777
+ else refuses); `ask_registry` runs `curl -fsS --max-time 20` for the registry's abbreviated document
778
+ (`Accept: application/vnd.npm.install-v1+json`) and reads `dist-tags[<tag>]`, which must be a release; the release is
779
+ installed only when `version_key` says it is newer than the installed `VERSION` and it is not the record's `bad`. The
780
+ install is `recorded_npx` (`update/npx`, written by the npm wrapper's `recordNpx` after a `--server` install that
781
+ installed something) `--yes @vibelord/lanes@<release> --server --update-only`, run in its own session (`run_npx`:
782
+ bounded by `NPX_TIMEOUT`, its group stopped on a timeout or an interrupt) with npx's folder first on `PATH` (npx is
783
+ `#!/usr/bin/env node`; a unit's `PATH` has no Node.js), without `npm_config_call`/`npm_config_package`, with the check
784
+ handed over in `LANES_UPDATE_LATEST`/`LANES_UPDATE_CHECKED_AT` (U2's `write_record` takes them), and with
785
+ `carried_settings`: the `LANES_WORKSPACES`, `LANES_THROTTLE_*` and `LANES_NOTIFY_CONF` values the installed units
786
+ carry (`Environment=` lines unescaped as `services.unit_value` escaped them) for each one the environment lacks —
787
+ `services.py` renders those from its caller's environment, which a timer's is not. Afterwards the record says whether
788
+ the installer ran: its `checked_at` and `latest` are this check's. The check itself is the record's `check` key
789
+ (`write_keys`, under the run lock; never one of `update.OWNED`), so the installer's record stays the installer's. A
790
+ rollback (`--rollback`, never from the timer) installs the tree `current.previous` names again with this release's
791
+ `install/services.py` (`register(previous, install=True)`: its templates, then `current.previous` → the tree left,
792
+ then the flip; the settings carried as above). The flip and its record are one step (`update.deferred_interrupts`:
793
+ SIGINT, SIGTERM and SIGHUP are noted while it runs and raised once it has ended): the moment `current` names the tree
794
+ rolled back to, one `write_record` says `partial` with the step `rollback`, sets `bad` to the release left and drops
795
+ `rerun` (that mark belongs to an earlier post-commit of the same release). Only then does `post_commit` of that tree
796
+ run. An interrupt during the post-commit or the flip — Ctrl-C, or the hangup of a dropped SSH session — therefore leaves
797
+ `partial` with the mark, so the next timer tick runs the post-commit again and does not install the release left
798
+ (fix cycle 1 of W9 U3: the mark used to be written after the post-commit). A `current.previous` that names the
799
+ release marked bad is refused, so two rollbacks never swap back. `update.doctor_line` is `lanes doctor`'s line about
800
+ all of this; it also reads whether `lanes-update.timer` is enabled — the `timers.target.wants/lanes-update.timer` link
801
+ `systemctl --user enable` makes, in each unit folder `bin/lanes unit_dirs` names — and warns at once when it is not:
802
+ only the full server install enables it, and an update (`--update-only`) enables nothing.
803
+
804
+ `lanes_lib.CURRENT` is that link for the tree it names, and the tree's own path for a checkout or for a staged tree
805
+ an update has moved past. A tree is staged (`lanes_lib.staged_tree`) when it sits in the default `…/pkg/<version>`
806
+ root or carries the npm wrapper's `.lanes-package.json` stamp, so a `$LANES_PKG_ROOT` of any name is followed through
807
+ the link too (DEF-1757); only the link under the home counts for such a root. `tree_version` and `tree_stamp` read a
808
+ tree's `VERSION` and that stamp. Every long-running process — statusd, enrold, the economy poller, the notifier, the
809
+ Studio Host enable step and each sidebar — writes the resolved tree it started from to
810
+ `~/.local/state/lanes/run/<name>.tree` (`lanes_lib.record_tree`), so what still runs an old tree is a file
811
+ comparison, not a process scan. `lanes version` makes it: each record against the resolved target of the `current`
812
+ link itself (`lanes_lib.current_link`), never against `lanes_lib.CURRENT`, which is the CLI's own tree whenever the
813
+ link names another; and, because a checkout updated in place by `git pull` keeps its path, each record's time against
814
+ the newest file of its tree's `VERSION`, `bin/` and `libexec/` (`tree_code_mtime`: a start before it ran the earlier
815
+ code); `--json` is the `lanes.version/1` document. `lanes doctor` judges the same document (`release_doctor`): only a
816
+ record whose process runs now is a fault — a process of doctor's one `ps -eo pid=,comm=,args=` sweep, owned by this
817
+ account, whose `/proc/<pid>/cmdline` is the argv Lanes starts it with (`run_name`: a `python3` interpreter, `-S -E`,
818
+ an absolute `<tree>/libexec/<script>`, and none of the one-shots that never record, `RUN_ONESHOTS`; DEF-1785), so an
819
+ editor or a grep on the file is no daemon — and a lanes process that runs with no record (started by a release before
820
+ the records) is one too; a record of a stopped process is information. It also checks that `current` names a tree
821
+ whose `VERSION` agrees with its staged `<version>` directory name, its npm stamp and the doctor's own tree, that each device
822
+ layout's commands (`libexec/sidebar`, `libexec/view.sh`, `holders/ssh.sh`) resolve to that tree through the path
823
+ `lanes rebuild` writes now, and that each installed unit's `ExecStart` (the first `@REPO@` file of its template,
824
+ unescaped as `services.unit_value` escaped it) resolves to it.
281
825
  No service requires the checkout to be named `~/lanes`. Re-run the server installer after moving it.
282
826
 
283
827
  Core units are the status observer and sync, ensure and garbage-collection timers. The web units are opt-in.
@@ -317,6 +861,67 @@ kept, like the base. Enrolment writes `members/` only, never `devices/`, because
317
861
  ensure` keeps alive. Every text field of a member is refused when it holds a control, format (bidi, zero-width),
318
862
  surrogate, private-use or unassigned character, since `device list` and `keys list` print them.
319
863
 
864
+ Preferences follow the person, not the device (cockpit v3): `libexec/users.py` keeps one profile per owner login in
865
+ `users/<login>.json` (the login percent-encoded): the look, the opened-lane marks that keep a Done lane until it is
866
+ opened, workspace order and hidden boards, pinned and hidden lanes, and the last lane opened. Every write is one
867
+ read-modify-write under that profile's lock, waited for at most a second — a sidebar that cannot get it drops the write
868
+ and its next tick retries — and is merged into the stored copy: a lane's opened mark and the last-opened lane only move
869
+ forward, every other field is last-writer-wins. `users.migrate` folds the devices a login owns (`members.owner_for`,
870
+ windows included) into its profile, then removes their look keys from `devices/<device>.json` so the profile wins.
871
+ `lanes attach` writes a device's theme, colour and glyphs to its owner's profile (`bin/lanes` `profile_for` is
872
+ `users.login_for`, the owner `device_owner` derives) and removes them from the device file; `--device-only` keeps them
873
+ there and names them in the file's `device_only` list — the only keys that win over the profile for that device, and
874
+ that no fold takes — and a device nobody owns records in its file as before. A look an owned device's file holds
875
+ without that mark was recorded before the profile (a device enrolled under W8 that set its look before R2): it sits
876
+ beneath the profile, `lanes user show` names it, and `lanes device claim` folds it (also when the member record
877
+ already names the claimed login). Nothing folds it implicitly — a device handed to another person keeps its previous
878
+ owner's look on that device instead of spreading it into the new owner's profile.
879
+ `lanes device claim` gives a view no member record speaks for an owner (in its file and its windows'; a member record
880
+ decides whenever one exists, so a claim never overrides one), and it, `lanes keys adopt --login` and `lanes keys
881
+ owner` fold the devices they name; `lanes user list|show` read the store. The sidebar of a device with an owner
882
+ (cockpit v3 R3) resolves that owner exactly as `lanes attach` does (`users.login_for`, at start and whenever the device
883
+ file changes — a claim, a hand-over) and adds the profile's mtime to its reload stamp, the one extra stat per tick an
884
+ owned device pays: on a change it re-reads the profile, merges its opened-lane marks into its own (the later per lane)
885
+ and paints its look over the device file's (`layered_look`: `--device-only` keys → profile → unmarked device keys →
886
+ environment → defaults).
887
+ Opening a lane there (`open_lane`, or `lanes next|prev`) writes the device's own `clients/<device>.seen.json` as before
888
+ — what the push notifier's desk presence and an older release read — and the mark with `last_opened` into the profile;
889
+ the sidebar waits at most 0.2 s for the profile's lock and retries a dropped write every 5 s from its tick. A device
890
+ nobody owns never stats, reads or writes a profile: its frames are byte-identical to the sidebar before R3.
891
+ The profile's workspace arrangement drives the layout (cockpit v3 R4): `users.arrange` drops the boards
892
+ `workspaces.hidden` names and orders the rest by `workspaces.order` (unlisted boards after, in catalog order; a list
893
+ that would hide every board hides none). `bin/lanes` `tab_specs` builds a view's tabs from it for the device's owner
894
+ (`device_profile`) — in `create_session` and in `lanes upgrade-session`, whose `device_sync.reconcile` closes a hidden
895
+ board's tab, identified by its sidebar/view panes, only while another workspace tab exists on the device (it counts
896
+ the tabs the device has, not the ones it should show: a board added while no client was attached has none yet, so the
897
+ close waits, says so, and lands on a later reconcile — DEF-1832); the phone's owner board (the sidebar that watches
898
+ the web client) is the first board shown.
899
+ Pins and hidden lanes (cockpit v3 R5) are the profile's `lanes{pins, hidden}`, read with the rest of it on every
900
+ profile change and written by the sidebar keys `p`, `h` and `H` (this workspace into `workspaces.hidden`, after `y` and
901
+ Enter, then a detached `lanes upgrade-session`) under the same 0.2 s lock wait. Only registry lanes hide — never a
902
+ divider, a zmx lane or a session under Unregistered — and a hidden one leaves the list, the header counts, OSC 9 and the
903
+ fleet strip until `o` shows the hidden lanes (per sidebar, faint). A pinned lane leads its section in registry order
904
+ with the `pinned` mark, and `Sidebar.place_body` reserves rows above `⋮ N more` for every pin the window would hide
905
+ below, so a click maps through the body as drawn (`body_map`).
906
+ The arranging verbs (cockpit v3 R6) write the rest of the profile through the same `users.update`: `lanes workspaces
907
+ hide|show|order` (board order and hidden boards; hiding the last visible board is refused under the lock) and `lanes
908
+ lane pin|unpin|hide|show`, for the owner of `--device D` (else of `$LANES_DEVICE`) or `--login L`; `lanes here
909
+ <device>` reads `last_opened` and shows that lane on the device, as Enter does — asked for, never a follow mode. The
910
+ push notifier (R7) reads every profile: one a desk device wrote within the quiet window is that person's presence, and
911
+ its `notify.quiet_s` is that person's own window when they alone were present. `updated_by_device` names the device
912
+ only for a change made AT it (its sidebar, `lanes attach`, `lanes next|prev`, `lanes here`); the arranging verbs stamp
913
+ `cli:<command>` and the claim/adopt fold `cli:migration` — writers no device name can be, so an admin's verb over SSH
914
+ never reads as somebody at a desk (`users.writer_device`, DEF-1833).
915
+
916
+ What lives where, per person and per device:
917
+
918
+ | File (state directory) | Holds | Shared by | Written by | Read by |
919
+ | --- | --- | --- | --- | --- |
920
+ | `members/<device>.json` | Who may log in with which key; the device's owner `{login, name, email}` | — (one per device) | enrolment, `lanes keys` | `keys.py` (the authorized_keys block), `members.owner_for` |
921
+ | `users/<login>.json` | Look (theme, colour, glyphs), opened-lane marks, `last_opened`, board order and hidden boards, pinned and hidden lanes, `notify.quiet_s` | every device the person owns | `users.update` only: `lanes attach` look flags, claim/adopt/owner folds, sidebars, `lanes next\|prev`, `lanes here`, the arranging verbs | sidebars, `lanes user`, `lanes here`, the push notifier |
922
+ | `devices/<device>.json` | The view: layout, viewports, `base`, recorded owner; `notify`, `dim`; `--device-only` look keys (named in `device_only`) and a look from before the profile | that device | `create_session`, `record_base`, `lanes attach`, `lanes device claim` | `lanes ensure`/`rebuild`, the device's sidebars, the push notifier (`mobile`) |
923
+ | `clients/<device>.seen.json` | What that device opened (its own marks) | that device | sidebars, `lanes next\|prev`, `lanes here` | the device's sidebars, `users.migrate`, the push notifier, an older release |
924
+
320
925
  Enrolment is a service, not an admin edit: `libexec/enrold.py` (`lanes-enrold.service`, loopback 8184, reached as
321
926
  `/enrol/*` through the same Tailscale Serve and Caddy as the browser view) adds a member either by redeeming a one-time
322
927
  invite code from `invites/` or by a request in `enrol-requests/` that a member approves on `/enrol/pending` (a member