@vibelord/lanes 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -3
@@ -6,10 +6,23 @@
6
6
  lanes resolve <lane-id> exact target of one lane as JSON (contract for the web harness)
7
7
  lanes status one-line-per-workspace health summary
8
8
  lanes attach <device> [--client ID] [--mobile] [--viewports N]
9
- [--ascii] [--glyphs unicode|ascii|emoji] [--theme dark|light|auto] [--color truecolor|16|none]
10
- [--default-device NAME] [--client-version V]
9
+ [--ascii] [--glyphs unicode|ascii|emoji] [--theme dark|light|auto] [--color truecolor|256|16|none]
10
+ [--notify 1|0] [--dim 90|2] [--editor micro|hx|nvim|vim|auto] [--device-only] [--default-device NAME]
11
+ [--client-version V]
11
12
  attach this terminal to the device's view session (creates it if needed); the look
12
- flags are remembered in devices/<device>.json and reach its running sidebars in a tick.
13
+ flags (--notify 0: no desktop notification; --dim 2: SGR 2 for 16-colour dim text)
14
+ are remembered and win over LANES_COLOR/THEME/GLYPHS/NOTIFY/DIM, which a session
15
+ created here inherits from this shell. On a device with an owner (a member record, its
16
+ machine's for a window like mac-2, or `lanes device claim`) --theme/--color/--glyphs/
17
+ --ascii go to that person's profile users/<login>.json, which every device they own
18
+ shares, and leave devices/<device>.json; --device-only keeps them in the device file,
19
+ for this device alone (marked as its own: only such a key wins over the profile
20
+ there, and the flag without --device-only hands it back). A look a device with an
21
+ owner recorded before profiles existed paints there only where the profile sets none
22
+ (`lanes user show` names it; `lanes device claim <device> --login <owner>` folds it
23
+ in). --notify/--dim/--editor, and every flag on a device without an owner, are
24
+ remembered in devices/<device>.json, which reaches its running sidebars in a tick.
25
+ --editor is the editor quick edit (Ctrl-Alt-e) starts on this device (auto: none chosen)
13
26
  lanes-launch passes its machine's device and its version: one dim line says when that
14
27
  client is older than this server (`lanes-launch update`)
15
28
  lanes prepare <device> [--mobile|--desktop]
@@ -36,6 +49,11 @@
36
49
  lanes open <lane-id> attach this terminal straight to one lane (no multiplexer in between)
37
50
  lanes register <lane-id> [ws] add an unregistered session to a workspace without restarting it
38
51
  use lanes ls --unregistered [--json] to find its ID
52
+ lanes unregister <lane-id> take a session added that way out of its workspace again (the sidebar's `u`): nothing
53
+ stops; a running one is listed under Unregistered with the same id and name (lanes
54
+ register undoes it); refused while tmux cannot check it. A record no workspace lists
55
+ any more is removed from registered-sessions.json. A lane declared in a repository's
56
+ terminals.json is refused with the file to edit
39
57
  lanes workspaces print the board ids (what the client installer reads)
40
58
  lanes workspaces add <id> --container <name> --repo <path> [--title T] [--session S] [--write]
41
59
  add a board to the site catalog (catalog.json + a new workspaces.<id>.json whose one
@@ -45,6 +63,24 @@
45
63
  a no-op, a difference is reported and exits 1. A repository belongs to one board: one
46
64
  another board already declares (same container and folder) is refused, as cursor-sync
47
65
  would rewrite its terminals.json for both. Then: install-container, sync, doctor
66
+ lanes workspaces hide|show|order <id…> [--device D | --login L]
67
+ arrange a person's boards on every device they own, in their profile users/<login>.json:
68
+ hide / show takes boards off their tabs / puts them back (a hide that would leave no
69
+ board is refused; show also takes off an id no board has any more), order puts the
70
+ boards named first, in that order, the rest following in catalog order (it replaces
71
+ the order). The person is the owner of --device D (a window like mac-2: its machine's),
72
+ else of $LANES_DEVICE, or --login L. Ids are the ones `lanes workspaces` prints. Exit 1
73
+ when the profile stayed busy (run it again)
74
+ lanes lane pin|unpin|hide|show <lane-id> [--device D | --login L]
75
+ the same person's pinned lanes (listed first in their section) and hidden lanes (left
76
+ out of their lists): pin takes a lane a board lists or a running unregistered session,
77
+ hide a registered lane only (an unregistered session that runs is always listed);
78
+ unpin and show also take a lane that left the boards
79
+ lanes here <device> show on <device> the lane its owner opened last on any of their devices (the profile's
80
+ last opened lane): its board's first viewport points at it and, when the view runs, the
81
+ board's tab comes to the front. Says why when it cannot (nothing opened yet, the lane
82
+ is gone, its board is hidden). Asked for, never automatic: `lanes-launch here` runs it
83
+ for the client's own device
48
84
  lanes onboard <login> --container <name> [--workspace <id> --repo <path> [--title T]]
49
85
  [--write] [--no-mcp]
50
86
  provision a user's container and agents in one verb. Dry-run by default (every diff and
@@ -62,18 +98,65 @@
62
98
  writing nothing: the agent could not read files this account writes. Prints a
63
99
  key: value table (a stopped run names every step it did not reach: not-run) and keeps
64
100
  ~/.local/state/lanes/onboard/<login>-<UTC>.json. Re-running changes nothing that is
65
- done. --no-mcp leaves out lane-mcp (plan W6, not shipped yet).
101
+ done. --no-mcp leaves out lane-mcp and the agents' MCP entries (install-container's).
66
102
  Exit 1 only when a step failed; an unknown container or a board that disagrees stops it
67
- lanes layout <device> [--mobile] print the generated zellij layout
68
- lanes rebuild <device> recreate a device's view session (agents are never touched)
69
- lanes upgrade-session [device…] sync catalog tabs and labels to live sessions without a rebuild
70
- lanes ensure recreate any known device session that is missing (never when zellij did not answer:
71
- an unknown session list is not an empty one)
103
+ lanes layout <device> [--mobile] print the generated zellij layout (its tree files named through
104
+ ~/.local/share/lanes/current while that link names this tree: a pane started from it
105
+ runs the release the link names then)
106
+ lanes rebuild <device> recreate a device's view session (agents are never touched). Waits up to 120 s while
107
+ another lanes process acts on the view sessions (the view-session lock,
108
+ ~/.local/state/lanes/locks/sessions.lock), then exits 1 naming it
109
+ lanes upgrade-session [device…] sync catalog tabs and labels to live sessions without a rebuild; the owner's profile
110
+ orders the workspace tabs and its hidden boards' tabs are closed (lanes untouched)
111
+ lanes ensure [--if-layout-changed]
112
+ recreate any known device session that is missing (never when zellij did not answer:
113
+ an unknown session list is not an empty one). --if-layout-changed (the server update's
114
+ last step) also compares every running view with the layout this release generates
115
+ for it: when a tab the view still has would get other panes, the view is rebuilt
116
+ (`lanes rebuild`) if `zellij action list-clients` shows no client attached; with a
117
+ client — or no answer — it keeps running and gets ~/.local/state/lanes/clients/
118
+ <device>.update-ready, which its sidebars show as `update ready · reload (r)` (r
119
+ rebuilds it). Tabs `lanes sync` adds, renames or orders in a running view are no
120
+ change. Without the flag only a device with that file is compared, so
121
+ lanes-ensure.timer rebuilds it once its last client has left. The whole run holds the
122
+ view-session lock (as rebuild, attach/prepare/web's create, offboard and device forget
123
+ do): with the flag it waits up to 120 s for another holder, without it 10 s, then
124
+ skips the run (exit 0: the timer runs again in a minute). Exit 1 when a view could
125
+ not be recreated (the run stops there) or rebuilt (the other views are still done), or
126
+ the flag's wait ran out; 2 for any other argument
72
127
  lanes reattach make every view re-attach its lane (after changing flags); agents untouched
128
+ lanes reload-views ask every running viewport to start again from the installed release at its next lane
129
+ switch or reconnect (SIGUSR2 to each view.sh that handles it; an attached lane client
130
+ is never dropped). A view.sh of an older release, which that signal would end, is left
131
+ alone and named: `lanes rebuild <device>` moves it. Sidebars swap by themselves. The
132
+ server update runs it after the switch (./install.sh --server --update-only)
133
+ lanes update [--now | --timer | --status | --rollback]
134
+ install the newest npm release on an npm-installed server (what lanes-update.timer runs
135
+ 15 min after boot and every 30 min, as --timer): ask the registry which release the
136
+ dist-tag `latest` names (`lanes-stable` with channel=stable in
137
+ ~/.config/lanes/update.conf) and, when it is newer than the installed one and not the one
138
+ a rollback left, run `npx --yes @vibelord/lanes@<release> --server --update-only` with the
139
+ npx the npm wrapper recorded at install (~/.local/state/lanes/update/npx). A last update
140
+ that ended partial gets its post-commit again first (the timer: once per release). A
141
+ check that answered less than 20 min ago is not repeated; --now asks anyway. One at a
142
+ time (~/.local/state/lanes/locks/update.lock). --rollback: install the release
143
+ ~/.local/share/lanes/current.previous names again, in place (no network), run its
144
+ post-commit and mark the release left `bad`: no update installs it again. --status:
145
+ print ~/.local/state/lanes/update/last.json, where every check is recorded (`check`).
146
+ A checkout server is left alone (git pull). Exit 0 nothing to do or ok, 4 partial,
147
+ 3 refused (another update running, a checkout, no installed release; 0 under --timer),
148
+ 1 failed (no release from the registry, no recorded npx, the install failed), 2 usage
73
149
  lanes upload <ws> | --active <device> [--paste]
74
150
  read a tar stream on stdin into that board's uploads/ folder (the same folder Cursor
75
151
  users drop files into); prints the in-container paths. --paste also types the path(s)
76
- into the lane that device is looking at. `lanes workspaces` lists board ids.
152
+ into the lane that device is looking at. `lanes workspaces` lists board ids. Each file
153
+ also goes to the docs store with a sha256 receipt (stderr `# docs:` lines), for the
154
+ login that owns the device; the Docs page's upload (POST /up) lands the same way.
155
+ lanes docs push <id> the hook for pushing one docs-store document to the data service's publication surface
156
+ — NOT BUILT: it prints the document's receipt (receipt.json: id, sha256, bytes, url,
157
+ at; looked up by id in every login's store, for the operator), says `not configured`
158
+ on stderr and exits 2. It sends nothing and writes nothing. Exit 1: no document with
159
+ that id, an id that is not one, or another argument (this usage)
77
160
  lanes cursor-sync [ws…] [--write] [--diff] [--canonicalize] [--identity]
78
161
  make Cursor/VS Code terminals open exactly the board's lanes through `lane open`
79
162
  (dry-run by default: prints the git-style diff; --write applies with a backup).
@@ -81,16 +164,39 @@
81
164
  attach-only, missing script) with the canonical one whose `tmux new -e` flags give the
82
165
  lane LANE_ID/LANE_WS and the lanes/<ws>/<id> trace; --identity adds those flags to a
83
166
  working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD. The old recipe is
84
- kept as createPrevious. Only these -e flags give a lane its identity (DEF-1559)
167
+ kept as createPrevious. Only these -e flags give a lane its identity (DEF-1559). An
168
+ entry's displayName (the repository's own lane name) is carried unchanged
85
169
  lanes cursor-commit [ws…] [--push]
86
170
  commit the normalized terminals.json on top of each repo's REMOTE branch tip and push it,
87
171
  with git plumbing only (no working tree or index is touched). Dry-run without --push.
88
- lanes hello [device] what the client installer calls to prove the path, the key and the server version
172
+ lanes hello [device] [--client-version V]
173
+ what the client installer calls to prove the path, the key and the server version:
174
+ `ok device=D version=S boards=N`; a client that passes its release V, older than S
175
+ (major.minor.patch as numbers), gets ` update=S` appended — the release to update to
176
+ lanes version [--json] which release runs here, in one line: `lanes 1.2.0 (abc1234) · tree pkg/1.2.0 ·
177
+ current ✓ · statusd runs 1.1.0 ✗ · enrold notify ✓` — this tree's VERSION and the npm
178
+ stamp's commit, the tree, whether ~/.local/share/lanes/current names it (✗ with the
179
+ tree it does name), then the tree each long-running process recorded at its last start
180
+ (~/.local/state/lanes/run/<name>.tree: statusd, enrold, docsd, notify, economy, studio_host,
181
+ the newest sidebar), ✗ for one that is not the current tree (`runs 1.1.0 ✗`) or that
182
+ started before its tree's code (VERSION, bin/, libexec/) last changed — a checkout
183
+ updated in place by `git pull` keeps its path (`started before its tree changed ✗`) —
184
+ restart that service. A stopped process keeps its last record. --json: {schema:
185
+ lanes.version/1, version, commit, tree, current, running{<name>: {tree, version,
186
+ started, current, changed}}, client_min (the release `lanes attach` stops telling
187
+ lanes-launch to update at)}. Exit 0
89
188
  lanes client-config site facts for clients (KEY=VALUE from ~/.config/lanes/site.conf on the server)
90
189
  lanes install-container <name> [--no-mcp]
91
- install the `lane` contract (lane, lane-run, lane-signal, hooks/) + pinned zmx into a
92
- container's home volume (additive; installs no agent hook). --no-mcp is accepted for
93
- lane-mcp (plan W6); no MCP server ships yet, so today it changes nothing
190
+ install the `lane` contract (lane, lane_core.py, lane-run, lane-signal, lane-edit,
191
+ lane-mcp, vimrc, VERSION, hooks/) + pinned zmx into a container's home volume
192
+ (additive; installs no agent hook and no editor: lane-edit runs $LANES_EDITOR or the
193
+ first of micro hx nvim vim vi there) and add the Lanes MCP server to the agents'
194
+ configs: mcpServers.lanes in ~/.claude.json and [mcp_servers.lanes] in
195
+ ~/.codex/config.toml, both starting /home/coder/.lz/bin/lane-mcp — only where missing,
196
+ the file kept as <file>.bak-<UTC> first, atomic (a `lanes` entry that starts something
197
+ else is left alone, exit 1; on a uid split nothing is written there, exit 1). --no-mcp
198
+ leaves out lane-mcp and the entries (lane-edit and its vimrc still ship) and removes
199
+ nothing an earlier install wrote. In the container: lane mcp install
94
200
  lanes hooks install --agent claude|codex (--container <name> | --home <dir>) [--dry-run | --write]
95
201
  merge the lane-signal hooks into that home's ~/.claude/settings.json or ~/.codex/hooks.json
96
202
  (+ a top-level Codex notify when none is set); prints the diff, writes only with --write
@@ -98,7 +204,10 @@
98
204
  --write into a --container whose home the agent could not read from this account (uid
99
205
  not 1000, or a home volume not owned by 1000) is refused with the reason
100
206
  lanes invariant save|check snapshot / verify every live pane's pid and window size
101
- lanes gc truncate oversized zmx logs, drop stale dynamic resolve files
207
+ lanes gc truncate oversized zmx logs, drop stale dynamic resolve files, prune old staged trees:
208
+ each pkg root keeps its 3 newest and every tree current, current.previous, this
209
+ lanes, a view layout, a running daemon's run record or a process of this account
210
+ uses (none pruned when /proc cannot be read); `pruned <tree>` / `kept <tree>: <why>`
102
211
  lanes sockets [check|repair] lanes that run but cannot be reached: tmux servers whose socket file was deleted
103
212
  (someone emptied the container's /tmp); repair = tmux's SIGUSR1, nothing restarts
104
213
  lanes pressure host memory / io pressure, swap, per-container memory and /tmp use — plain files and
@@ -123,11 +232,32 @@
123
232
  focused viewport (the first one when the sidebar has the focus) at the next / previous
124
233
  lane of its board in registry order, wrapping, and wake its view.sh (SIGUSR1) — the
125
234
  same write as Enter in the sidebar. Reads two zellij listings; moves no focus
235
+ lanes sidebar-key filter|upload --focused [--device D]
236
+ what Ctrl-Alt-/ and Ctrl-Alt-u run in a floating pane that closes on exit, after their
237
+ three lefts moved the focus to the sidebar: tell the sidebar of the active tab to start
238
+ its filter / arm its upload strip through views/<device>/<ws>.sidebar.cmd (it acts within
239
+ a tick, and before the next key it reads). Types nothing into any pane; on a tab without
240
+ a sidebar (Host, a tool tab), or when the sidebar does not have the focus, it does nothing
126
241
  lanes preview --focused [--device D]
127
242
  what Ctrl-Alt-m runs in a floating pane: the markdown files the focused lane named in
128
243
  its last 300 lines (guarded tmux capture, resolved against its cwd, existing ones only),
129
244
  newest first; one is shown at once, several give a numbered menu. glow (pinned in
130
245
  holders.lock) in the device's dark/light theme, else less -R
246
+ lanes edit --focused [--device D]
247
+ what Ctrl-Alt-e runs in a floating pane named Edit: quick edit of the focused lane's
248
+ AGENTS.md, CLAUDE.md or .env.local. One Edit pane per view: when another is open it
249
+ gets the focus (same tab) or is named (another tab) and nothing opens. Otherwise the
250
+ focused tmux lane's git checkout (its working directory, else its folder; a worktree's
251
+ .git file leads to the main checkout), read through /proc/<init>/root: AGENTS.md and
252
+ CLAUDE.md of the checkout, of the main checkout too when the lane is in a linked
253
+ worktree, and the main checkout's .env.local. A regular file inside the checkout only
254
+ (a symbolic link is never offered); a missing one is shown `not present` (no file is
255
+ created). One key picks (1-5; Enter when one file is there; q or Esc closes), then
256
+ `docker exec -it -u coder -w <checkout> <container> ~/.lz/bin/lane-edit <file>` replaces
257
+ it (no shell, no docker inspect) with LANES_EDITOR = the device's --editor, else this
258
+ process's LANES_EDITOR, else none (lane-edit's own list). Refused: a container that is
259
+ not running, one without lane-edit (install-container), no checkout. Exit: the
260
+ editor's status, 0 closed or nothing to edit, 1 refused, 2 without --focused
131
261
  lanes studio-host status [--json] [--check]
132
262
  the co-located Throttle Studio Host (opt-in: install/enable-studio-host.sh): binary
133
263
  digest, config mode, enrolment, units. Exit 0 only when it runs enrolled
@@ -141,17 +271,51 @@
141
271
  the lanes per create-recipe class (idempotent / attach-only / null / external /
142
272
  retired-path), lane identity, numeric session names, crowded `default` sockets and
143
273
  orphan tmux socket files (report only; in a container `lane gc --sockets` removes them),
144
- any record left in the retired legacy-sessions/ directory, and the browser/phone half:
274
+ any record left in the retired legacy-sessions/ directory, the devices without an owner
275
+ (each keeps its look to itself: `lanes device claim`), and the browser/phone half:
145
276
  lanes-web / lanes-mobile units (⚠ not enabled, ✗ enabled but not running), the login-token
146
277
  database the running lanes-web reads (✗ when it is not the one `lanes token` uses) and the
147
- Tailscale Serve https:8443 mapping (⚠ not checked on a host without a Tailscale name)
148
- lanes device list | forget <name>
278
+ Tailscale Serve https:8443 mapping (⚠ not checked on a host without a Tailscale name);
279
+ the docs service: lanes-docsd enabled, active and answering on its loopback port (⚠
280
+ not enabled, ✗ enabled but not running or not answering), the pin gate (⚠ until `lanes
281
+ enrol mode request`), the store (documents, bytes, docs.log), the Mermaid pin (⚠ not
282
+ installed: install/enable-docs.sh; ✗ not the pinned build) and the age switch (✗ set
283
+ without an age binary);
284
+ then the release that runs: ~/.local/share/lanes/current names a tree whose VERSION
285
+ agrees with its pkg/<version> name and npm stamp and that this lanes runs (⚠ without the
286
+ link: the rest compares with this tree); every lanes process that runs now has a run
287
+ record of the current code (✗ with its restart — a daemon or sidebar that runs with no
288
+ record started from an older release; a record whose process does not run is one line
289
+ of information); every device layout runs the current tree (✗ `lanes rebuild
290
+ <device>`; ⚠ when it names it by another path than `lanes rebuild` writes now); every
291
+ installed lanes unit's ExecStart runs it (✗ re-run the server installer); and the
292
+ updates (update/last.json): how long ago `lanes update` last checked and what came of
293
+ it, the last update's result (✗ partial or failed, ⚠ refused, a registry that gave no
294
+ release, lanes-update.timer not enabled — no timers.target.wants link: an update with
295
+ --update-only never enables it — or no check for 2 h: is it on?), a release a rollback
296
+ marked bad; a checkout server is one line of information
297
+ lanes device list | forget <name> | claim <device> --login L [--name N]
149
298
  the devices `lanes ensure` recreates view sessions for, each with its owner's login
150
299
  (from members/<device>.json, or the machine's for a window like mac-2, whenever that
151
300
  record exists, so it agrees with `lanes keys list`; after a revoke the last one is
152
301
  kept; blank for none); forget removes devices/<name>.json (a running view session is
153
- left alone and is not brought back once it ends)
154
- lanes keys list | add <device> <pubkey-file|-> | revoke <device> | adopt [--dry-run]
302
+ left alone and is not brought back once it ends); claim names the owner of a device no
303
+ member record speaks for (one from before enrolment): it writes `owner` into
304
+ devices/<device>.json and its windows' (mac-2, …), where every rewrite keeps it, then
305
+ folds their look (never a --device-only key) and seen marks into L's profile; a view L
306
+ owns already keeps the name it records unless --name replaces it. A member record
307
+ decides whenever one exists: a claim that disagrees is refused (`lanes keys owner`),
308
+ one that agrees only folds (a look the device recorded before profiles existed).
309
+ Exit 1 when the profile could not be written (the same claim finishes it)
310
+ lanes user list | show <login> [--json]
311
+ the people lanes knows — a profile under users/, or a device a member record or a claim
312
+ gives them — with their devices; show prints one profile (look, seen lanes, workspace
313
+ order and hidden boards, pinned and hidden lanes, last opened lane, quiet time, last
314
+ change; --json: the stored document and the devices) and every look one of the
315
+ person's devices keeps in its own file (--device-only, or from before the profile).
316
+ Read-only. Exit 1 when the profile file exists but this release does not read it (the
317
+ reason is printed)
318
+ lanes keys list | add <device> <pubkey-file|-> | revoke <device> | adopt [--dry-run] [--login LOGIN]
155
319
  | owner <device> <login> [--name NAME] [--email ADDR]
156
320
  who may log in, as a projection of ~/.local/state/lanes/members/<device>.json: one
157
321
  `restrict,pty ssh-ed25519 <key> lanes:<device>` line per member between the
@@ -163,8 +327,11 @@
163
327
  neither (exit 1 only when a line outside the block still lets it in); adopt moves
164
328
  hand-added `lanes:<device>` lines into members/
165
329
  and the block (first adopt keeps authorized_keys.bak-<UTC>) and re-renders the block;
330
+ --login names the owner of every key it moves in (owner null without it);
166
331
  owner names the person behind a member (add/adopt record none) — the tailnet login
167
- `/enrol/pending` must see to let that person approve enrolment requests
332
+ `/enrol/pending` must see to let that person approve enrolment requests. Whenever
333
+ adopt --login or owner names an owner, the views of those devices (and their windows)
334
+ are folded into that person's profile, as `lanes device claim` does
168
335
  lanes invite <name> [--email ADDR] [--expires 7d] [--workspaces a,b] [--tailscale-link URL] [--md]
169
336
  lanes invite list | revoke <id>
170
337
  mint a one-time code LN-<id>-<secret> a new machine redeems at lanes-enrold (libexec/
@@ -191,12 +358,17 @@
191
358
  views `lanes-launch web` opened for it), end its view session (no lane is touched).
192
359
  Without --yes or off a terminal: the plan, exit 2
193
360
  """
361
+ import contextlib
194
362
  import json
195
363
  import os
364
+ import posixpath
196
365
  import pwd
197
366
  import re
198
367
  import shutil
368
+ import signal
369
+ import stat
199
370
  import sys
371
+ import tempfile
200
372
  import time
201
373
  from pathlib import Path
202
374
 
@@ -206,17 +378,28 @@ import lanes_lib as L # noqa: E402
206
378
  SLOTS = ['main', 'b', 'c']
207
379
  ZELLIJ_VERSION = '0.45.1'
208
380
  ZELLIJ = L.SHARE / 'bin' / f'zellij-{ZELLIJ_VERSION}'
209
- ZELLIJ_BASE = [str(ZELLIJ), '--config-dir', str(L.REPO / 'zellij'), '--config', str(L.REPO / 'zellij/config.kdl')]
381
+ # A view session outlives the update that flips `current` (W9 U1): its zellij server keeps the argv and the environment
382
+ # it was started with, so the config it reads and the bare `lanes` its keybindings run (clean_env PATH) are named
383
+ # through L.CURRENT — the link for a staged tree it names — never this tree's resolved path.
384
+ ZELLIJ_BASE = [str(ZELLIJ), '--config-dir', str(L.CURRENT / 'zellij'), '--config', str(L.CURRENT / 'zellij/config.kdl')]
210
385
  PROC = Path(os.environ.get('LANES_PROC_ROOT') or '/proc') # injectable for the unit tests
211
386
 
212
387
 
388
+ # The sidebar's look and notification switches (cockpit v3 P4, LNS-72, DEF-1577): forwarded when set, so the shell that
389
+ # runs `lanes attach|ensure|rebuild` decides them for the view sessions it creates. A device's recorded LOOK_KEYS still
390
+ # win over them (sidebar.resolve_modes / resolve_dim_notify).
391
+ LOOK_ENV = ('LANES_COLOR', 'LANES_THEME', 'LANES_GLYPHS', 'LANES_DIM', 'LANES_NOTIFY')
392
+
393
+
213
394
  def clean_env(extra=None):
214
- """Environment for the view server: nothing from the ssh session leaks into panes."""
395
+ """Environment for the view server: nothing from the ssh session leaks into panes but the named keys. The Zellij
396
+ server create_session starts keeps it for the session's life, and every sidebar, view.sh and floating Run pane of
397
+ that session inherits it (zellij() and cmd_open use it too): LOOK_ENV is how LANES_NOTIFY=0 reaches a sidebar."""
215
398
  env = {'HOME': str(Path.home()), 'USER': os.environ.get('USER', 'dev'), 'SHELL': '/bin/bash',
216
- 'PATH': f"{L.SHARE / 'bin'}:{L.REPO / 'bin'}:/usr/local/bin:/usr/bin:/bin",
399
+ 'PATH': f"{L.SHARE / 'bin'}:{L.CURRENT / 'bin'}:/usr/local/bin:/usr/bin:/bin",
217
400
  'LANG': 'C.UTF-8', 'LANES_STATE': str(L.STATE), 'LANES_SHARE': str(L.SHARE), 'LANES_ZELLIJ': str(ZELLIJ)}
218
- for key in ('LANES_WORKSPACES', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'ZELLIJ_SOCKET_DIR'): # last one: test isolation
219
- if os.environ.get(key):
401
+ for key in ('LANES_WORKSPACES', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'ZELLIJ_SOCKET_DIR') + LOOK_ENV:
402
+ if os.environ.get(key): # ZELLIJ_SOCKET_DIR: test isolation
220
403
  env[key] = os.environ[key]
221
404
  if 'XDG_RUNTIME_DIR' not in env and Path(f'/run/user/{os.getuid()}').is_dir():
222
405
  env['XDG_RUNTIME_DIR'] = f'/run/user/{os.getuid()}'
@@ -230,11 +413,14 @@ def q(text):
230
413
 
231
414
  # ------------------------------------------------------------------- registry
232
415
 
233
- def cmd_sync(_args):
416
+ def sync_registry():
417
+ """Rebuild and publish the registry, print the per-board table -> (boards with errors, [(container, why)] whose
418
+ `~/.lz/registry.json` was refused). `lanes sync` turns either into exit 1; a rename that publishes through it
419
+ raises the refusal (DEF-1796: that container's `lane open` keeps its previous projection)."""
234
420
  with L.registry_lock():
235
421
  previous = L.read_json(L.STATE / 'registry.json')
236
422
  registry = L.build_registry(previous)
237
- L.publish_registry(registry)
423
+ refused = L.publish_registry(registry) or []
238
424
  total = sum(len(w['lanes']) for w in registry['workspaces'])
239
425
  failed = [w for w in registry['workspaces'] if w['errors']]
240
426
  for workspace in registry['workspaces']:
@@ -242,10 +428,18 @@ def cmd_sync(_args):
242
428
  print(f"{mark} {workspace['id']:<18} {len(workspace['lanes']):>3} lanes {workspace['container']}")
243
429
  for error in workspace['errors']:
244
430
  print(f' ⚠ {error}' + (' (kept previous lanes)' if workspace.get('stale') else ''))
245
- print(f"{total} lanes in {len(registry['workspaces'])} workspaces" + (f'; {len(failed)} with errors' if failed else ''))
431
+ for container, why in refused: # DEF-1796: that container's `lane open` keeps its previous projection
432
+ print(f'! container {container}: {why}')
433
+ print(f"{total} lanes in {len(registry['workspaces'])} workspaces" + (f'; {len(failed)} with errors' if failed else '')
434
+ + (f'; {len(refused)} container projection(s) not written' if refused else ''))
246
435
  if any((L.STATE / 'devices').glob('*.json')):
247
436
  cmd_upgrade_session([])
248
- return 1 if failed else 0
437
+ return failed, refused
438
+
439
+
440
+ def cmd_sync(_args):
441
+ failed, refused = sync_registry()
442
+ return 1 if failed or refused else 0
249
443
 
250
444
 
251
445
  def cmd_register(args):
@@ -257,6 +451,54 @@ def cmd_register(args):
257
451
  print(f"Registered {lane['name']} in {lane['workspace']}")
258
452
 
259
453
 
454
+ def cmd_unregister(args):
455
+ """lanes unregister <lane-id> — the sidebar's `u` (cockpit v3 S7): take a session added with Add to workspace /
456
+ `lanes register` out of registered-sessions.json. Nothing is stopped; a running session is listed under
457
+ Unregistered again with the same id and name. A lane a repository's terminals.json declares is taken out of that
458
+ file (session_actions.unregister_lane: the Agent API's editor, M4, wired at the fan-in), then synced; its refusals
459
+ (DeclaredRefused) are said, and a lane of an SSH workspace says which file declares it. A record no workspace
460
+ lists (its workspace left the catalog, uses another container, or a repository declares the session) is removed
461
+ from the file all the same (S7 fix cycle 1, DEF-1826)."""
462
+ if len(args) != 1:
463
+ raise L.LanesError('Usage: lanes unregister <lane-id>')
464
+ import registrations
465
+ import session_actions
466
+ try:
467
+ workspace, lane = L.find_lane(L.load_registry(), args[0])
468
+ except L.LanesError as error:
469
+ workspace, lane, unknown = None, None, error
470
+ published = lane is not None and bool(lane.get('registered_session'))
471
+ if lane is not None and not published and session_actions.declared_in(lane):
472
+ if not lane.get('source'): # an SSH workspace's lane: catalog.json, which no editor of ours writes
473
+ raise L.LanesError(f"{lane['id']} is {session_actions.declared_text(lane)}")
474
+ done = session_actions.unregister_lane(lane['id'])
475
+ if done['changed']:
476
+ cmd_sync([])
477
+ print(f"Unregistered {lane['name']} from {workspace['id']} ({done['name']} out of {done['source']})")
478
+ return 0
479
+ if published or any(item['id'] == args[0] for item in registrations.load()['sessions']):
480
+ try:
481
+ record = registrations.unregister(args[0])
482
+ except registrations.Unpublished as error:
483
+ undo = error.record
484
+ if undo.get('identity'): # the record is out of the file: say how to put it back once it is listed
485
+ raise L.LanesError(f"{error} (undo once it is listed again: lanes register {undo['id']} "
486
+ f"{undo['workspace']})") from None
487
+ raise
488
+ if record.get('identity'):
489
+ print(f"Unregistered {record['name']} from {record['workspace']}; it is listed under Unregistered again "
490
+ f"(undo: lanes register {record['id']} {record['workspace']})")
491
+ elif published:
492
+ print(f"Unregistered {record['name']} from {record['workspace']}; no session runs under it now")
493
+ else:
494
+ print(f"Removed the registration of {record['name']} ({record['id']}) from {registrations.FILENAME}; "
495
+ "no workspace listed it, so nothing else changed")
496
+ return 0
497
+ if lane is None:
498
+ raise unknown
499
+ raise L.LanesError(f"{lane['id']} is not registered; nothing to unregister")
500
+
501
+
260
502
  def cmd_ls(args):
261
503
  registry = L.load_registry()
262
504
  snapshot = L.read_json(L.STATE / 'status.json') or {}
@@ -321,7 +563,12 @@ def tab_layout(lines):
321
563
  ' }', ' }'] + lines + ['}']) + '\n'
322
564
 
323
565
 
324
- def tab_specs(device, registry, mobile=False, viewports=1):
566
+ def tab_specs(device, registry, mobile=False, viewports=1, profile=None):
567
+ """The tabs a view session has, in order: the workspace boards, then ssh workspaces and tools, then the boards whose
568
+ `position` is `before-host`, then Host. The boards are the ones the device's owner sees (cockpit v3 R4, LNS-77):
569
+ `profile` (users/<login>.json as users.read gives it, device_profile) hides `workspaces.hidden` and orders the rest
570
+ by `workspaces.order` (users.arrange: an unknown id is ignored, an unlisted board follows in catalog order, a hidden
571
+ list that would leave no board hides none); None — a device nobody owns — is the catalog order."""
325
572
  result = []
326
573
  def add(key, title, lines):
327
574
  result.append({'key': key, 'title': title, 'lines': lines, 'layout': tab_layout(lines)})
@@ -333,20 +580,25 @@ def tab_specs(device, registry, mobile=False, viewports=1):
333
580
  add(f"workspace/{workspace['id']}/{role}", title, mobile_workspace_tab(device, workspace, picker))
334
581
  else:
335
582
  add(f"workspace/{workspace['id']}/view", workspace['title'], workspace_tab_kdl(device, workspace, False, viewports))
336
- for workspace in registry['workspaces']:
583
+ users = users_module() if profile else None
584
+ boards = users.arrange(registry['workspaces'], profile) if users else registry['workspaces']
585
+ for workspace in boards:
337
586
  if workspace.get('position') != 'before-host': board(workspace)
338
587
  for workspace in registry.get('ssh_workspaces', []):
339
588
  add('ssh/' + workspace['id'], workspace['title'], ssh_workspace_tab_kdl(workspace))
340
589
  for tool in registry.get('tools', []):
341
590
  add('tool/' + tool['view'], tool['tab'], tool_tab_kdl(device, tool, mobile=mobile))
342
- for workspace in registry['workspaces']:
591
+ for workspace in boards:
343
592
  if workspace.get('position') == 'before-host': board(workspace)
344
593
  add('host', 'Host', [' tab name="Host" {', ' pane command="/bin/bash" {', ' args "-l"', ' }', ' }'])
345
594
  return result
346
595
 
347
596
 
348
- def layout_kdl(device, mobile=False, viewports=1):
349
- specs = tab_specs(device, L.load_registry(), mobile, viewports)
597
+ def layout_kdl(device, mobile=False, viewports=1, profile=None):
598
+ """The view session's layout (create_session, `lanes layout`): tab_specs for the device's owner's profile — passed
599
+ by the caller, else read here (device_profile) — with the focus on the first tab."""
600
+ specs = tab_specs(device, L.load_registry(), mobile, viewports,
601
+ device_profile(device) if profile is None else profile)
350
602
  lines = []
351
603
  for index, spec in enumerate(specs):
352
604
  tab = list(spec['lines'])
@@ -356,9 +608,58 @@ def layout_kdl(device, mobile=False, viewports=1):
356
608
  return tab_layout(lines)
357
609
 
358
610
 
611
+ # A tab of a layout layout_kdl wrote: tab_layout indents every tab by four, and only tabs start ` tab name=`.
612
+ LAYOUT_TAB = re.compile(r' tab name=("(?:[^"\\]|\\.)*")')
613
+
614
+
615
+ def layout_parts(text):
616
+ """(frame, tabs) of a layout layout_kdl wrote: `frame` the lines outside every tab (`layout {`, the
617
+ default_tab_template, the closing brace), `tabs` {title: [each tab of that title as text]}. The ` focus=true`
618
+ layout_kdl gives whichever tab comes first is left out of that tab's own line: which tab is first is the catalog's
619
+ order, which upgrade-session keeps in a running view without a rebuild."""
620
+ frame, tabs, block = [], {}, None
621
+ for line in text.splitlines():
622
+ match = LAYOUT_TAB.match(line)
623
+ if match:
624
+ try:
625
+ title = json.loads(match.group(1))
626
+ except ValueError:
627
+ title = match.group(1)
628
+ block = [line[:match.end()] + line[match.end():].replace(' focus=true', '', 1)]
629
+ tabs.setdefault(title, []).append(block)
630
+ elif block is not None and line.startswith(' '):
631
+ block.append(line)
632
+ else:
633
+ block = None
634
+ frame.append(line)
635
+ return frame, {title: ['\n'.join(block) for block in blocks] for title, blocks in tabs.items()}
636
+
637
+
638
+ def layout_changed(stored, generated):
639
+ """Whether a view built from the layout `stored` (layouts/<device>.kdl, what create_session wrote; None when there
640
+ is none) runs differently from `generated` (layout_kdl now) in a way only a rebuild changes (W9 U6, LNS-94): the
641
+ frame differs (the tab template), or a tab whose title both have is, line for line, none of `generated`'s tabs of
642
+ that title (another tree path, another pane, size or argument — what a release changes). A tab only one of them has
643
+ and the order of the tabs are no change: `lanes sync` (upgrade-session, device_sync.reconcile) adds, renames and
644
+ orders the tabs of a running view, and never changes a tab's panes, so after a board was added or renamed the file
645
+ no longer names every tab a view shows. A missing or unreadable layout is a change: nothing says what the view
646
+ runs."""
647
+ if stored is None:
648
+ return True
649
+ frame, tabs = layout_parts(stored)
650
+ frame_now, tabs_now = layout_parts(generated)
651
+ return frame != frame_now or any(tab not in tabs_now[title]
652
+ for title, kept in tabs.items() if title in tabs_now for tab in kept)
653
+
654
+
655
+ # Every tree file a layout runs is named through L.CURRENT (W9 U1, LNS-89): the `current` link while it names this
656
+ # tree, so an update that flips the link moves every view built through it; this tree's own path for a checkout and
657
+ # for a staged tree the link has moved past. Readers match those commands by suffix (lanes_lib.tree_file_at), so a
658
+ # layout written before — naming its pkg/<version> tree — still identifies.
659
+
359
660
  def mobile_workspace_tab(device, workspace, picker):
360
661
  title = workspace['title'] + (' ☰' if picker else '')
361
- command = str(L.REPO / 'libexec' / ('sidebar' if picker else 'view.sh'))
662
+ command = str(L.CURRENT / 'libexec' / ('sidebar' if picker else 'view.sh'))
362
663
  args = f'{q(device)} {q(workspace["id"])}' + ('' if picker else ' "main" "--mobile"')
363
664
  # The picker's sidebar pane has NO layout name: Zellij reports a non-empty pane name instead of the pane's own
364
665
  # OSC 2 title, and that title (the attention counts) is what the phone reads (DEF-1531).
@@ -374,8 +675,9 @@ def stale_picker_layouts():
374
675
  `upgrade-session` / `ensure` only add missing tabs and rename titles: a picker tab created from an older layout
375
676
  keeps its pane name, which Zellij reports instead of the OSC 2 counts, until `lanes rebuild <device>` (DEF-1531).
376
677
  `create_session` is the only writer of layouts/<device>.kdl, so the file is the layout the session was built from.
678
+ The sidebar is recognised by its tree file, whichever tree path the layout names (W9 U1: pkg/<version> before,
679
+ `current` since, a checkout's own path).
377
680
  """
378
- sidebar = str(L.REPO / 'libexec' / 'sidebar')
379
681
  stale = []
380
682
  for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
381
683
  if not (L.read_json(meta_file) or {}).get('mobile'):
@@ -384,13 +686,14 @@ def stale_picker_layouts():
384
686
  text = (L.STATE / 'layouts' / f'{meta_file.stem}.kdl').read_text()
385
687
  except OSError:
386
688
  continue
387
- if any('name="Lanes"' in line and f'command={q(sidebar)}' in line for line in text.splitlines()):
689
+ if any('name="Lanes"' in line and any(L.tree_file_at([command], 'libexec/sidebar') == 0
690
+ for command in layout_commands(line)) for line in text.splitlines()):
388
691
  stale.append(meta_file.stem)
389
692
  return stale
390
693
 
391
694
 
392
695
  def workspace_tab_kdl(device, workspace, mobile=False, viewports=1, focused=False):
393
- sidebar, view = str(L.REPO / 'libexec/sidebar'), str(L.REPO / 'libexec/view.sh')
696
+ sidebar, view = str(L.CURRENT / 'libexec/sidebar'), str(L.CURRENT / 'libexec/view.sh')
394
697
  focus = ' focus=true' if focused else ''
395
698
  lines = []
396
699
  if mobile:
@@ -413,7 +716,7 @@ def workspace_tab_kdl(device, workspace, mobile=False, viewports=1, focused=Fals
413
716
 
414
717
  def ssh_workspace_tab_kdl(workspace, indent=' '):
415
718
  """Main terminal on the left; up to two worker terminals stacked on the right."""
416
- holder = str(L.REPO / 'holders/ssh.sh')
719
+ holder = str(L.CURRENT / 'holders/ssh.sh')
417
720
  panes = workspace['panes']
418
721
  lines = [f'{indent}tab name={q(workspace["title"])} split_direction="vertical" {{']
419
722
 
@@ -435,7 +738,7 @@ def ssh_workspace_tab_kdl(workspace, indent=' '):
435
738
  def tool_tab_kdl(device, tool, indent=' ', mobile=False):
436
739
  """One full-size tab for a shared tool (the Throttle monitor). It is a view with a fixed target, so it starts
437
740
  only when the tab is first shown and restarts by itself if the tool exits."""
438
- view = str(L.REPO / 'libexec/view.sh')
741
+ view = str(L.CURRENT / 'libexec/view.sh')
439
742
  extra = ' "main" "--mobile"' if mobile else ''
440
743
  return [f'{indent}tab name={q(tool["tab"])} {{',
441
744
  f'{indent} pane borderless=true name={q(tool["name"])} command={q(view)} {{',
@@ -466,9 +769,38 @@ def live_sessions():
466
769
  return names
467
770
 
468
771
 
772
+ # The view-session lock (L.sessions_lock, DEF-1792). Every caller that decides from live_sessions() (or devices/) and then
773
+ # creates, kills or deletes a view session holds it from that list to the last zellij call: `ensure`, `rebuild`,
774
+ # attach/prepare/web's create, offboard's end_view and `device forget`. A caller that must act waits SESSIONS_WAIT for
775
+ # another to finish (a rebuild is at most kill 30 s + delete 30 s + create 60 s, plus a list); one with a fallback only
776
+ # glances (SESSIONS_GLANCE): the minute timer's plain `ensure` skips its tick, `attach` attaches without creating.
777
+ SESSIONS_WAIT = 120
778
+ SESSIONS_GLANCE = 10
779
+
780
+
781
+ def hold_sessions(stack, timeout, doing):
782
+ """Enter L.sessions_lock(timeout, doing) on `stack` (a contextlib.ExitStack) -> None when held, else the LanesError
783
+ that says why not (SessionsBusy: another lanes process held it for `timeout` s; a plain LanesError: the lock file
784
+ could not be opened), so a caller tells the lock it did not get from a failure of the work it does under it."""
785
+ try:
786
+ stack.enter_context(L.sessions_lock(timeout, doing))
787
+ except L.LanesError as error:
788
+ return error
789
+ return None
790
+
791
+
792
+ # The web front's own path roots (web/Caddyfile): a view session is served at /<device>, so a device of one of these
793
+ # names would sit on a route a service owns — the enrolment service (/enrol/*), the docs service (/lanes-docs/*) and
794
+ # the shell's assets (/lanes-assets/*). Compared casefolded: Caddy's path matcher ignores case.
795
+ RESERVED_DEVICES = ('enrol', 'lanes-assets', 'lanes-docs')
796
+
797
+
469
798
  def device_name(value):
470
799
  if not L.IDENT.match(value or '') or len(value) > 32:
471
800
  raise L.LanesError('Invalid device name')
801
+ if value.casefold() in RESERVED_DEVICES:
802
+ raise L.LanesError(f'Invalid device name: {value} is a path the web front reserves '
803
+ f'({", ".join(RESERVED_DEVICES)})')
472
804
  return value
473
805
 
474
806
 
@@ -491,12 +823,16 @@ def create_session(device, mobile, viewports=1):
491
823
  owner = device_owner(device, old)
492
824
  if owner: # who the device belongs to (W8 A2): the member record's, else the one recorded (device_owner)
493
825
  meta['owner'] = owner
494
- # The look `lanes attach --ascii|--glyphs|--theme|--color` recorded (record_look) is the operator's choice, not
495
- # session state: a recreation (ensure after a reboot, rebuild) keeps every valid one, or every sidebar of the
496
- # device would flip back to the environment defaults within one tick of this rewrite.
826
+ # The look `lanes attach --ascii|--glyphs|--theme|--color|--notify|--dim|--editor` recorded (record_look) is the
827
+ # operator's choice, not session state: a recreation (ensure after a reboot, rebuild) keeps every valid one, or every
828
+ # sidebar of the device would flip back to the environment defaults within one tick of this rewrite — and the
829
+ # `device_only` list that marks the device's own (--device-only) keys with them.
497
830
  for key, allowed in LOOK_KEYS.values():
498
831
  if old.get(key) in allowed:
499
832
  meta[key] = old[key]
833
+ users = users_module()
834
+ if users and users.device_only(old):
835
+ meta = users.mark_device_only(meta, users.device_only(old))
500
836
  L.atomic_write(meta_file, json.dumps(meta) + '\n')
501
837
  command = ZELLIJ_BASE + ['--layout', str(layout_file), 'attach', '--create-background', device]
502
838
  if shutil.which('systemd-run'):
@@ -510,6 +846,15 @@ def create_session(device, mobile, viewports=1):
510
846
  if rc != 0:
511
847
  raise L.LanesError(f'Could not create session {device}: '
512
848
  + ('no answer in 60 s' if rc is None else err.strip() or out.strip() or f'exit {rc}'))
849
+ # The session runs this release's layout now: nothing `lanes ensure --if-layout-changed` stamped is pending (W9 U6)
850
+ update_ready_path(device).unlink(missing_ok=True)
851
+
852
+
853
+ def update_ready_path(device):
854
+ """clients/<device>.update-ready (DM-LANES-UPDATE-READY, W9 U6): written by `lanes ensure --if-layout-changed` when
855
+ this release lays out the running view `device` differently while a client is attached (or zellij did not say);
856
+ its sidebars offer `update ready · reload (r)` while it exists; create_session removes it."""
857
+ return L.STATE / 'clients' / f'{device}.update-ready'
513
858
 
514
859
 
515
860
  def device_owner(device, meta):
@@ -527,6 +872,15 @@ def device_owner(device, meta):
527
872
  return members.owner_for((device, meta.get('base')), meta.get('owner'))
528
873
 
529
874
 
875
+ def users_module():
876
+ """libexec/users.py, or None when it cannot be loaded — for create_session, which must never raise (`lanes ensure`
877
+ recreates views through it every minute)."""
878
+ try:
879
+ return libexec_module('users')
880
+ except L.LanesError:
881
+ return None
882
+
883
+
530
884
  def base_device(device, default):
531
885
  """The machine a view belongs to: `mac-2` / `mac-w2`, opened by the client whose default device is `mac`, share
532
886
  mac's upload helper (one helper service per machine); every other device is its own base."""
@@ -567,12 +921,20 @@ def viewports_arg(args, default=1):
567
921
  return default
568
922
 
569
923
 
570
- LOOK_KEYS = {'--theme': ('theme', ('dark', 'light', 'auto')), '--color': ('color', ('truecolor', '16', 'none')),
571
- '--glyphs': ('glyphs', ('unicode', 'ascii', 'emoji'))}
924
+ # `256` since cockpit v3 P2 (LNS-70): the sidebar paints the xterm-256 set (tokens.XTERM256, indices 16-255). `notify`
925
+ # and `dim` since P4 (LNS-72, DEF-1577): LANES_NOTIFY's and LANES_DIM's per-device path, in the variables' own words
926
+ # (`dim` is the SGR of the 16-colour dim: 90 or the escape hatch 2).
927
+ LOOK_KEYS = {'--theme': ('theme', ('dark', 'light', 'auto')), '--color': ('color', ('truecolor', '256', '16', 'none')),
928
+ '--glyphs': ('glyphs', ('unicode', 'ascii', 'emoji')), '--notify': ('notify', ('1', '0')),
929
+ '--dim': ('dim', ('90', '2')),
930
+ # The editor quick edit (`lanes edit`, W9 E2) hands lane-edit as LANES_EDITOR; `auto` takes the choice back.
931
+ # Per device, like notify/dim: never a profile key (users.LOOK), never read by a sidebar.
932
+ '--editor': ('editor', ('micro', 'hx', 'nvim', 'vim', 'auto'))}
572
933
 
573
934
 
574
935
  def look_args(args):
575
- """The look `lanes attach` was asked for: {'glyphs'|'theme'|'color': value}, validated (spec §2.4)."""
936
+ """The look `lanes attach` was asked for: {'glyphs'|'theme'|'color'|'notify'|'dim'|'editor': value}, validated
937
+ (spec §2.4)."""
576
938
  look = {}
577
939
  if '--ascii' in args:
578
940
  look['glyphs'] = 'ascii'
@@ -586,20 +948,107 @@ def look_args(args):
586
948
 
587
949
 
588
950
  def record_look(device, args):
589
- """`lanes attach DEVICE --ascii|--glyphs M|--theme T|--color C`: remembered in devices/<device>.json, the file every
590
- sidebar of that device re-reads when it changes (its reload() stamp), so a RUNNING session follows within one tick.
591
- Returns what was recorded: empty when nothing was asked — or when the device has no file yet, because `ensure`
592
- builds a session for every devices/*.json and a look flag must never create that stub on its own."""
951
+ """`lanes attach DEVICE --ascii|--glyphs M|--theme T|--color C|--notify N|--dim D|--editor E [--device-only]`:
952
+ remembered where the device's sidebars read it (cockpit v3 R2, LNS-75).
953
+ * A device with an owner (profile_for) shares theme, color and glyphs with every device that person owns: they
954
+ are written to users/<login>.json (users.update, a 1 s lock) and leave devices/<device>.json — a key left
955
+ there would sit beneath the profile for this device. `--device-only` keeps them in the device file instead, for
956
+ this device alone, and names them in its `device_only` list (users.mark_device_only, DEF-1782): only a key that
957
+ list names wins over the profile there (sidebar.layered_look) and survives a fold (users.migrate). The same
958
+ flag without --device-only takes its key off the list again. A profile write that is dropped (its lock busy, or
959
+ a file this release does not write) records none of them anywhere — the device keeps what it had, its marks
960
+ included — and says so on stderr.
961
+ * notify and dim are per-device switches (P4), and so is editor (W9 E2: read by `lanes edit` at each Ctrl-Alt-e,
962
+ never by a sidebar): always the device file, never on the list. A device without an owner records every flag in
963
+ its file; a device that never used --device-only has no list, so its file is byte for byte what it was before
964
+ R2.
965
+ devices/<device>.json is the file every sidebar of the device re-reads when it changes (its reload() stamp), so a
966
+ RUNNING session follows a device-file change within one tick.
967
+ Returns what was recorded, in the flags' order: empty when nothing was asked — or when the device has no file yet,
968
+ because `ensure` builds a session for every devices/*.json and a look flag must never create that stub on its
969
+ own (nor, for an unknown device, a profile)."""
593
970
  look = look_args(args)
594
971
  meta_file = L.STATE / 'devices' / f'{device}.json'
595
972
  meta = L.read_json(meta_file) if meta_file.exists() else None
596
973
  if not look or not isinstance(meta, dict):
597
974
  return {}
598
- if any(meta.get(key) != value for key, value in look.items()):
599
- L.atomic_write(meta_file, json.dumps(dict(meta, **look)) + '\n')
975
+ alone = '--device-only' in args
976
+ login = None if alone else profile_for(device)
977
+ users = libexec_module('users')
978
+ shared = {}
979
+ if login:
980
+ shared = {key: value for key, value in look.items() if key in users.LOOK}
981
+ if shared and not users.update(login, device, lambda profile: profile['look'].update(shared)):
982
+ words = ', '.join(f'{key} {value}' for key, value in shared.items())
983
+ print(f"lanes: the look ({words}) was not saved: {login}'s profile was busy or cannot be written "
984
+ f'(`lanes user show {login}` says why); nothing changed — attach again, or add --device-only to keep '
985
+ f'it on {device} alone', file=sys.stderr)
986
+ look = {key: value for key, value in look.items() if key not in shared}
987
+ shared = {}
988
+ on_device = {key: value for key, value in look.items() if key not in shared}
989
+ updated = {key: value for key, value in meta.items() if key not in shared}
990
+ updated.update(on_device)
991
+ marked, asked = set(users.device_only(meta)), set(look) & set(users.LOOK)
992
+ marked = marked | asked if alone else marked - asked
993
+ if marked or 'device_only' in meta:
994
+ updated = users.mark_device_only(updated, marked)
995
+ if updated != meta:
996
+ L.atomic_write(meta_file, json.dumps(updated) + '\n')
600
997
  return look
601
998
 
602
999
 
1000
+ def profile_for(device):
1001
+ """The login whose users/<login>.json profile `device` follows (cockpit v3 R2, LNS-75): its owner exactly as
1002
+ device_owner derives it — the member record of the device or of the machine it is a window of whenever one exists,
1003
+ else the owner devices/<device>.json records (`lanes device claim`) — through users.login_for. None for a device
1004
+ nobody owns, or a name that is not a device: it keeps its look and seen marks to itself. Never raises."""
1005
+ try:
1006
+ return libexec_module('users').login_for(device)
1007
+ except L.LanesError:
1008
+ return None
1009
+
1010
+
1011
+ def device_profile(device):
1012
+ """The profile `device` follows (users.read of profile_for's login), or None: a device nobody owns, no profile yet,
1013
+ or a file this release does not read. What tab_specs arranges a view's workspace tabs by (cockpit v3 R4, LNS-77)
1014
+ for create_session and `lanes upgrade-session`. Never raises: `lanes ensure` runs both every minute."""
1015
+ login = profile_for(device)
1016
+ users = users_module() if login else None
1017
+ return users.read(login) if users else None
1018
+
1019
+
1020
+ def windows_of(devices):
1021
+ """{window: its devices/<window>.json} for each view whose recorded `base` is one of `devices` (mac-2 and mac-w2
1022
+ are mac's windows: base_device), the devices themselves excluded."""
1023
+ found = {}
1024
+ for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
1025
+ meta = L.read_json(meta_file)
1026
+ if (isinstance(meta, dict) and meta.get('base') in devices and meta_file.stem not in devices
1027
+ and L.IDENT.match(meta_file.stem) and len(meta_file.stem) <= 32):
1028
+ found[meta_file.stem] = meta
1029
+ return found
1030
+
1031
+
1032
+ def fold_profile(login, devices):
1033
+ """Fold the views of `devices` and their windows that `login` owns now into `login`'s profile (users.migrate over
1034
+ exactly those: look keys the profile lacks from the most recently attached, seen marks union-max, then their look
1035
+ keys leave the device files) -> 0, or 1 when the profile write was dropped (said on stderr, with the claim that
1036
+ finishes it; no device file was touched). Devices with no view and no seen marks have nothing to fold: 0, silent.
1037
+ What `lanes device claim`, `lanes keys adopt --login` and `lanes keys owner` end with."""
1038
+ users = libexec_module('users')
1039
+ wanted = set(devices) | set(windows_of(set(devices)))
1040
+ owned = [found['device'] for found in users.owned_devices(login) if found['device'] in wanted]
1041
+ if not owned:
1042
+ return 0
1043
+ if users.migrate(login, devices=owned) is None:
1044
+ print(f"⚠ {login}'s profile was not updated: its lock was busy, or its file under users/ is not one this "
1045
+ f'release writes (`lanes user show {login}` says why); {", ".join(owned)} keep their own look until '
1046
+ f'`lanes device claim {owned[0]} --login {login}` runs again', file=sys.stderr)
1047
+ return 1
1048
+ print(f'profile {login}: folded in {", ".join(owned)} — `lanes user show {login}`')
1049
+ return 0
1050
+
1051
+
603
1052
  def cmd_layout(args):
604
1053
  print(layout_kdl(device_name(args[0]), '--mobile' in args, viewports_arg(args)), end='')
605
1054
  return 0
@@ -607,7 +1056,7 @@ def cmd_layout(args):
607
1056
 
608
1057
  def cmd_attach(args):
609
1058
  device = device_name(args[0])
610
- look_args(args) # a bad --theme/--color/--glyphs refuses here, before any session is created
1059
+ look_args(args) # a bad --theme/--color/--glyphs/--editor refuses here, before any session is created
611
1060
  # Old muscle memory: `coder-launch worlds`. A workspace id is not a device: open the caller's normal
612
1061
  # device session and bring that workspace's tab to the front.
613
1062
  tab = None
@@ -632,15 +1081,22 @@ def cmd_attach(args):
632
1081
  raise L.LanesError(f'{ZELLIJ} is not installed: run install/host-install.sh')
633
1082
  if not (L.STATE / 'registry.json').exists():
634
1083
  cmd_sync([])
635
- try:
636
- missing = device not in live_sessions()
637
- except L.LanesError as error:
638
- # Unknown is not missing: create nothing and just try to attach. If the session really is gone, zellij says
639
- # so itself (nothing is resurrected: session_serialization is off) and lanes-launch retries.
640
- print(f'lanes: {error}', file=sys.stderr)
1084
+ with contextlib.ExitStack() as stack:
1085
+ # Only a glance at the view-session lock (DEF-1792): a person is waiting. Not taken in time is unknown, not
1086
+ # missing — the holder may be rebuilding this very view — so this creates nothing and just attaches.
1087
+ refused = hold_sessions(stack, SESSIONS_GLANCE, f'lanes attach {device}')
641
1088
  missing = False
642
- if missing:
643
- create_session(device, '--mobile' in args, viewports_arg(args))
1089
+ if refused:
1090
+ print(f'lanes: {refused}; attaching without creating {device}', file=sys.stderr)
1091
+ else:
1092
+ try:
1093
+ missing = device not in live_sessions()
1094
+ except L.LanesError as error:
1095
+ # Unknown is not missing: create nothing and just try to attach. If the session really is gone, zellij
1096
+ # says so itself (nothing is resurrected: session_serialization is off) and lanes-launch retries.
1097
+ print(f'lanes: {error}', file=sys.stderr)
1098
+ if missing:
1099
+ create_session(device, '--mobile' in args, viewports_arg(args))
644
1100
  # Record the base and bring the owner (W8 A2) in line, also for sessions that existed before either was known.
645
1101
  record_base(device, args)
646
1102
  record_look(device, args)
@@ -704,14 +1160,22 @@ ENV_HOOK = '[ -r "$HOME/.lz/env.sh" ] && . "$HOME/.lz/env.sh" # lanes-env: temp
704
1160
 
705
1161
  def hook_env(home):
706
1162
  """Make env.sh take effect for NEW shells: the disk temp dir it points at, and ONE marker-guarded line appended to
707
- the login files bash/zsh really read. Append only, idempotent, never rewrites a dotfile; remove the line to undo."""
1163
+ the login files bash/zsh really read. Append only, idempotent, never rewrites a dotfile; remove the line to undo.
1164
+ A dotfile (or ~/.tmp) that is a symbolic link is left alone and named: this runs on the host, where a link the
1165
+ container's home holds resolves outside the container (an absolute `/home/coder/dotfiles/.bashrc` is the HOST
1166
+ account's file; DEF-1798)."""
708
1167
  tmp = home / '.tmp'
709
- if not tmp.exists():
1168
+ if tmp.is_symlink():
1169
+ print(f'! {tmp} is a symbolic link — left alone')
1170
+ elif not tmp.exists():
710
1171
  tmp.mkdir(mode=0o700)
711
1172
  tmp.chmod(0o700)
712
- login = home / '.bash_profile' if (home / '.bash_profile').exists() else home / '.profile'
713
- targets = [login, home / '.bashrc'] + ([home / '.zshrc'] if (home / '.zshrc').exists() else [])
1173
+ login = home / '.bash_profile' if os.path.lexists(home / '.bash_profile') else home / '.profile'
1174
+ targets = [login, home / '.bashrc'] + ([home / '.zshrc'] if os.path.lexists(home / '.zshrc') else [])
714
1175
  for path in targets:
1176
+ if path.is_symlink():
1177
+ print(f'! {path} is a symbolic link — not changed; add this line to the file it points at by hand: {ENV_HOOK}')
1178
+ continue
715
1179
  text = path.read_text(errors='replace') if path.exists() else ''
716
1180
  if 'lanes-env' in text:
717
1181
  continue
@@ -720,44 +1184,130 @@ def hook_env(home):
720
1184
 
721
1185
 
722
1186
  INSTALL_CONTAINER_USAGE = 'usage: lanes install-container <name> [--no-mcp]'
1187
+ # lane_core.py first: `lane` imports it, so no moment has a new `lane` beside an old (or no) lane_core.py.
1188
+ CONTRACT_FILES = ('lane_core.py', 'lane-mcp', 'lane', 'lane-run', 'lane-signal', 'lane-edit')
1189
+
1190
+
1191
+ def lane_core():
1192
+ """container/lane_core.py, the in-container contract's shared helpers, as a module: the agents' MCP entries are
1193
+ written from the host through the same code `lane mcp install` runs in the container."""
1194
+ module = sys.modules.get('lane_core')
1195
+ if module is None:
1196
+ import importlib.util
1197
+ spec = importlib.util.spec_from_file_location('lane_core', str(L.REPO / 'container' / 'lane_core.py'))
1198
+ module = importlib.util.module_from_spec(spec)
1199
+ spec.loader.exec_module(module)
1200
+ sys.modules['lane_core'] = module
1201
+ return module
1202
+
1203
+
1204
+ def container_dir(home, *parts):
1205
+ """<home>/<parts…> as a real directory, created when missing; LanesError when it, or a component of it below the
1206
+ container home, is a symbolic link. The container owns its home and this runs on the HOST: a link planted there
1207
+ resolves outside the container (DEF-1798, the DEF-1796 class)."""
1208
+ path = Path(home).joinpath(*parts)
1209
+ core = lane_core()
1210
+ try:
1211
+ core.no_links(home, path)
1212
+ path.mkdir(parents=True, exist_ok=True)
1213
+ core.no_links(home, path)
1214
+ except core.LaneError as error:
1215
+ raise L.LanesError(f'{error}: nothing installed through it — remove the link in the container, then run again')
1216
+ return path
1217
+
1218
+
1219
+ def install_file(source, target):
1220
+ """Copy `source` (its bytes and mode) to `target` without following anything at the target: an O_EXCL temp file
1221
+ beside it, renamed over whatever the name holds — a planted symbolic link is replaced, never written through
1222
+ (DEF-1798), and a `lane open` starting mid-install runs the old file or the new one, never a half-written one.
1223
+ The directory must be a real one (container_dir)."""
1224
+ fd, tmp = tempfile.mkstemp(prefix=f'.{target.name}.', dir=str(target.parent))
1225
+ try:
1226
+ with os.fdopen(fd, 'wb') as handle, open(source, 'rb') as data:
1227
+ shutil.copyfileobj(data, handle)
1228
+ shutil.copystat(source, tmp)
1229
+ os.replace(tmp, target)
1230
+ except BaseException:
1231
+ try:
1232
+ os.unlink(tmp)
1233
+ except OSError:
1234
+ pass
1235
+ raise
1236
+
1237
+
1238
+ def install_mcp_entries(home):
1239
+ """`mcpServers.lanes` in <home>/.claude.json and `[mcp_servers.lanes]` in <home>/.codex/config.toml, both starting
1240
+ /home/coder/.lz/bin/lane-mcp, the Codex table also passing it LANE_ID, LANE_WS, TMUX and TMUX_PANE in `env_vars`
1241
+ (Codex clears an MCP server's environment) (lane_core.mcp_apply: written only where missing, `.bak-<UTC>` first,
1242
+ atomic, the file's own mode) -> exit code. Refused on a uid split (the agent could not read a file this account
1243
+ creates: DEF-1727's rule); a file left alone (not valid JSON/TOML, a `lanes` entry that starts something else)
1244
+ is said and makes the exit 1, while the other agent still gets its entry."""
1245
+ split = uid_split(home)
1246
+ if split:
1247
+ print(f'! MCP entries not written: {split} — the agent could not read what this account would write; run '
1248
+ f'`lanes install-container` from a host account with uid {L.CONTAINER_UID}, or `~/.lz/bin/lane mcp '
1249
+ f'install --write` inside the container')
1250
+ return 1
1251
+ code = 0
1252
+ for result in lane_core().mcp_apply(home):
1253
+ if result['status'] == 'refused':
1254
+ print(f"! {result['path']}: {result['why']}")
1255
+ code = 1
1256
+ continue
1257
+ if result['backup']:
1258
+ print(f"backup {result['backup']}")
1259
+ print(f"MCP ({result['agent']}): " + (f"wrote {result['path']}" if result['status'] == 'added'
1260
+ else f"{result['path']} already starts lane-mcp"))
1261
+ return code
723
1262
 
724
1263
 
725
1264
  def cmd_install_container(args):
726
1265
  names = [word for word in args if word != '--no-mcp']
727
1266
  if len(names) != 1 or names[0].startswith('-'):
728
1267
  raise L.LanesError(INSTALL_CONTAINER_USAGE)
729
- # --no-mcp leaves out lane-mcp, the in-container MCP server of plan W6. This checkout ships none, so there is
730
- # nothing to leave out yet: the flag is accepted so that `lanes onboard --no-mcp` keeps its meaning when it ships.
1268
+ no_mcp = '--no-mcp' in args # leaves out lane-mcp and the agents' MCP entries; removes nothing already there
731
1269
  container = names[0]
732
1270
  info = L.container_info(container)
733
1271
  if not info.get('home'):
734
1272
  raise L.LanesError(f'{container}: home volume not found')
735
- root = Path(info['home']) / '.lz'
736
- for sub in ('bin', 's', 'meta', 'state'):
737
- (root / sub).mkdir(parents=True, exist_ok=True)
738
- for source in (L.REPO / 'container/lane', L.REPO / 'container/lane-run', L.REPO / 'container/lane-signal'):
739
- shutil.copy2(source, root / 'bin' / source.name)
1273
+ home = Path(info['home'])
1274
+ root = home / '.lz'
1275
+ # Every directory is a real one and every file lands by rename (DEF-1798): nothing is written through a link.
1276
+ for sub in ('bin', 's', 'meta', 'state', 'hooks'):
1277
+ container_dir(home, '.lz', sub)
1278
+ for name in CONTRACT_FILES:
1279
+ if not (no_mcp and name == 'lane-mcp'):
1280
+ install_file(L.REPO / 'container' / name, root / 'bin' / name)
1281
+ install_file(L.REPO / 'VERSION', root / 'VERSION') # what lane-mcp reports as its version
1282
+ # lane-edit (a CONTRACT_FILES member) is what `lanes edit` (Ctrl-Alt-e) execs in the container; vim and nvim start
1283
+ # there with -u ~/.lz/vimrc (W9 E3). Both are ours and replaced on every install, by rename like the rest, whatever
1284
+ # --no-mcp says. ~/.lz/tmp/, where that vim keeps its swap files, is created by lane-edit as the container's own
1285
+ # user (0700), never here.
1286
+ install_file(L.REPO / 'container/vimrc', root / 'vimrc')
740
1287
  # The hook DEFINITIONS travel with the contract for reference; no hook is installed here — that is the opt-in,
741
1288
  # per-container `lanes hooks install --agent claude|codex --container <name> --write` (RUNBOOK).
742
- (root / 'hooks').mkdir(exist_ok=True)
743
1289
  for source in sorted((L.REPO / 'container/hooks').glob('*.json')):
744
- shutil.copy2(source, root / 'hooks' / source.name)
1290
+ install_file(source, root / 'hooks' / source.name)
745
1291
  env_file = L.REPO / 'container/env.sh' # TMPDIR off the RAM-backed /tmp; only NEW shells and agents read it
746
1292
  if env_file.exists():
747
- shutil.copy2(env_file, root / 'env.sh')
748
- hook_env(Path(info['home']))
1293
+ install_file(env_file, root / 'env.sh')
1294
+ hook_env(home)
749
1295
  for binary in sorted((L.SHARE / 'bin').glob('zmx-*')):
750
1296
  target = root / 'bin' / binary.name
751
- if not target.exists(): # a running lane's daemon keeps executing its own version: never overwrite
752
- shutil.copy2(binary, target)
1297
+ if not os.path.lexists(target): # a running lane's daemon keeps executing its own version: never overwrite
1298
+ install_file(binary, target)
753
1299
  link = Path.home() / '.lz' / container
754
1300
  link.parent.mkdir(parents=True, exist_ok=True)
755
1301
  if link.is_symlink() or link.exists():
756
1302
  link.unlink()
757
1303
  link.symlink_to(root)
758
- print(f'installed {root}/bin/{{lane,lane-run,lane-signal,zmx-*}}, {root}/hooks/'
759
- + (f' and {root}/env.sh' if env_file.exists() else '') + f'; host link {link} -> {root}')
760
- return 0
1304
+ print(f"installed {root}/bin/{{lane,lane_core.py,lane-run,lane-signal,lane-edit{'' if no_mcp else ',lane-mcp'},zmx-*}}, "
1305
+ f'{root}/VERSION, {root}/vimrc, {root}/hooks/' + (f' and {root}/env.sh' if env_file.exists() else '')
1306
+ + f'; host link {link} -> {root}')
1307
+ if no_mcp:
1308
+ print('--no-mcp: lane-mcp and the agents\' MCP entries left out (anything an earlier install wrote is kept)')
1309
+ return 0
1310
+ return install_mcp_entries(home)
761
1311
 
762
1312
 
763
1313
  HOOK_TARGETS = {'claude': ('claude-settings.json', '.claude/settings.json'),
@@ -914,20 +1464,31 @@ def cmd_hooks(args):
914
1464
 
915
1465
  def cmd_rebuild(args):
916
1466
  device = device_name(args[0])
917
- meta = L.read_json(L.STATE / 'devices' / f'{device}.json') or {}
918
- if device in live_sessions():
919
- # Attached clients see an ordinary exit (0) when their session is killed: leave a stamp so that
920
- # lanes-launch (`lanes rebuilt <device>`) reconnects instead of closing the window.
921
- L.atomic_write(L.STATE / 'clients' / f'{device}.rebuilt', f'{time.time()}\n')
922
- if zellij(['kill-session', device], 30)[0] is None:
923
- raise L.LanesError(f'zellij did not answer in 30 s: {device} was NOT rebuilt (no lane was touched)')
924
- zellij(['delete-session', '--force', device], 30)
925
- create_session(device, bool(meta.get('mobile')) or '--mobile' in args, viewports_arg(args, int(meta.get('viewports') or 1)))
1467
+ with contextlib.ExitStack() as stack:
1468
+ # From the list to the create, under the view-session lock (DEF-1792): between this kill and this create no
1469
+ # `lanes ensure` may find the device missing and create it as well. Re-entered when `ensure` rebuilds.
1470
+ refused = hold_sessions(stack, SESSIONS_WAIT, f'lanes rebuild {device}')
1471
+ if refused:
1472
+ raise L.LanesError(f'{refused}: {device} was NOT rebuilt (no lane was touched); run `lanes rebuild {device}` '
1473
+ 'again')
1474
+ meta = L.read_json(L.STATE / 'devices' / f'{device}.json') or {}
1475
+ if device in live_sessions():
1476
+ # Attached clients see an ordinary exit (0) when their session is killed: leave a stamp so that
1477
+ # lanes-launch (`lanes rebuilt <device>`) reconnects instead of closing the window.
1478
+ L.atomic_write(L.STATE / 'clients' / f'{device}.rebuilt', f'{time.time()}\n')
1479
+ if zellij(['kill-session', device], 30)[0] is None:
1480
+ raise L.LanesError(f'zellij did not answer in 30 s: {device} was NOT rebuilt (no lane was touched)')
1481
+ zellij(['delete-session', '--force', device], 30)
1482
+ create_session(device, bool(meta.get('mobile')) or '--mobile' in args,
1483
+ viewports_arg(args, int(meta.get('viewports') or 1)))
926
1484
  print(f'rebuilt view session {device}; no lane was touched')
927
1485
  return 0
928
1486
 
929
1487
 
930
1488
  def cmd_upgrade_session(args):
1489
+ """Bring each running view session (the named devices, else every device) in line with the catalog and with its
1490
+ owner's profile (device_sync.reconcile): add missing tabs, rename titles, order them, and close the tab of a
1491
+ workspace the profile hides (cockpit v3 R4, LNS-77) — only a tab whose panes identify that workspace."""
931
1492
  import device_sync
932
1493
  registry = L.load_registry()
933
1494
  live = live_sessions()
@@ -939,7 +1500,10 @@ def cmd_upgrade_session(args):
939
1500
  meta = L.read_json(L.STATE / 'devices' / (device + '.json')) or {}
940
1501
  mobile, viewports = bool(meta.get('mobile')), int(meta.get('viewports') or 1)
941
1502
  try:
942
- for change in device_sync.reconcile(device, registry, tab_specs(device, registry, mobile, viewports), mobile, zellij):
1503
+ specs = tab_specs(device, registry, mobile, viewports, device_profile(device))
1504
+ shown = {spec['key'].split('/')[1] for spec in specs if spec['key'].startswith('workspace/')}
1505
+ hidden = [workspace['id'] for workspace in registry['workspaces'] if workspace['id'] not in shown]
1506
+ for change in device_sync.reconcile(device, registry, specs, mobile, zellij, hidden=hidden):
943
1507
  print(f'{device}: {change}')
944
1508
  except (L.LanesError, ValueError, KeyError) as error:
945
1509
  failures.append(str(error))
@@ -962,26 +1526,198 @@ def cmd_reattach(_args):
962
1526
  return 0
963
1527
 
964
1528
 
965
- def cmd_ensure(_args):
1529
+ def catches_signal(pid, signum):
1530
+ """Whether process `pid` belongs to this account and has a handler installed for `signum` (the SigCgt mask of
1531
+ /proc/<pid>/status). A signal a process neither catches nor ignores takes its default action, which for SIGUSR2 is
1532
+ to end it: reload-views signals only a view.sh whose `trap … USR2` is in place."""
1533
+ try:
1534
+ fields = dict(line.split(':', 1) for line in (PROC / str(pid) / 'status').read_text().splitlines()
1535
+ if ':' in line)
1536
+ uid, mask = int(fields['Uid'].split()[0]), int(fields['SigCgt'].strip(), 16)
1537
+ except (OSError, KeyError, ValueError, IndexError):
1538
+ return False
1539
+ return uid == os.getuid() and bool(mask >> (signum - 1) & 1)
1540
+
1541
+
1542
+ # The first release whose view.sh traps SIGUSR2 (cockpit v3 U5, shipped in 1.2.0 — plan D3). A view.sh of an older
1543
+ # release has no such trap: the signal's default action ENDS it (RUNBOOK, U5), and with it the viewport.
1544
+ VIEW_RELOAD_SINCE = (1, 2, 0)
1545
+
1546
+
1547
+ def view_tree(pid, words):
1548
+ """The lanes tree the viewport process `pid` runs (`words`: its command line). First the tree of the view.sh file
1549
+ its shell holds open (/proc/<pid>/fd/*: dash and bash keep the script open while it runs, and the kernel names the
1550
+ file it RESOLVED at start, so a `…/current/libexec/view.sh` start names the tree `current` pointed at then, not the
1551
+ one an update has flipped it to since); else, with no readable fd, the resolved tree of the view.sh path in its
1552
+ command line. None when neither is a lanes tree file."""
1553
+ suffix = '/libexec/view.sh'
1554
+ try:
1555
+ fds = sorted((PROC / str(pid) / 'fd').iterdir())
1556
+ except OSError:
1557
+ fds = []
1558
+ for fd in fds:
1559
+ try:
1560
+ target = os.readlink(fd)
1561
+ except OSError:
1562
+ continue
1563
+ if target.startswith('/') and target.endswith(suffix): # a pruned tree reads `… (deleted)`: no match
1564
+ return Path(target[:-len(suffix)])
1565
+ at = L.tree_file_at(words, 'libexec/view.sh')
1566
+ return Path(os.path.realpath(words[at])).parent.parent if at is not None else None
1567
+
1568
+
1569
+ def view_reloads(tree):
1570
+ """Whether a view.sh of `tree` handles SIGUSR2: its release (`<tree>/VERSION`, lanes_lib.tree_version, compared as
1571
+ major.minor.patch by version_key) is VIEW_RELOAD_SINCE or later. No tree, no VERSION or one that is no release
1572
+ ('dev') is never assumed to: the signal would end a view that does not."""
1573
+ key = version_key(L.tree_version(tree) or '') if tree is not None else None
1574
+ return key is not None and key >= VIEW_RELOAD_SINCE
1575
+
1576
+
1577
+ def cmd_reload_views(args):
1578
+ """SIGUSR2 to every running viewport (`views/<device>/<ws>.<slot>.pid`) whose process is a view.sh of a lanes tree
1579
+ (`L.tree_file_at`) that handles it: such a view.sh starts again from the tree `current` names once its lane client
1580
+ next exits (W9 U5), never dropping an attached client. One that does not handle it — a view.sh of a release before
1581
+ that — would be ENDED by the signal (SIGUSR2's default action), so it is left alone and counted: it keeps its
1582
+ release until `lanes rebuild <device>`. Two checks, both required: the release of the tree the process runs
1583
+ (view_tree: the script file it holds open, else its command line's) is VIEW_RELOAD_SINCE or later (view_reloads),
1584
+ and the process has a SIGUSR2 handler installed now (catches_signal: the trap has run). Sidebars swap by themselves
1585
+ (W9 U5). A stale pid file, a recycled pid or another account's process is skipped. Exit 0; 2 for any argument."""
1586
+ if args:
1587
+ print('usage: lanes reload-views')
1588
+ return 2
1589
+ asked, older = 0, {}
1590
+ for pid_file in sorted((L.STATE / 'views').glob('*/*.pid')):
1591
+ try:
1592
+ pid = int(pid_file.read_text().strip())
1593
+ words = [word.decode(errors='replace') for word in (PROC / str(pid) / 'cmdline').read_bytes().split(b'\0')]
1594
+ except (OSError, ValueError):
1595
+ continue
1596
+ if pid <= 1 or L.tree_file_at(words, 'libexec/view.sh') is None:
1597
+ continue
1598
+ if not view_reloads(view_tree(pid, words)) or not catches_signal(pid, signal.SIGUSR2):
1599
+ older.setdefault(pid_file.parent.name, []).append(pid)
1600
+ continue
1601
+ try:
1602
+ os.kill(pid, signal.SIGUSR2)
1603
+ except OSError:
1604
+ continue
1605
+ asked += 1
1606
+ print(f"reload-views: asked {asked} view{'' if asked == 1 else 's'} to restart on the installed release "
1607
+ '(each at its next lane switch or reconnect)')
1608
+ if older:
1609
+ count = sum(len(pids) for pids in older.values())
1610
+ print(f"reload-views: {count} view{'' if count == 1 else 's'} of {', '.join(sorted(older))} "
1611
+ f"{'runs' if count == 1 else 'run'} a view.sh that cannot reload in place (an older release): "
1612
+ "`lanes rebuild <device>` moves "
1613
+ f"{'it' if count == 1 else 'them'}")
1614
+ return 0
1615
+
1616
+
1617
+ ENSURE_USAGE = 'usage: lanes ensure [--if-layout-changed]'
1618
+
1619
+
1620
+ def view_clients(device):
1621
+ """How many clients are attached to the view session `device`: the rows `zellij action list-clients` prints under
1622
+ its header (`CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND` on 0.45.1; a row starts with the client id — the sidebar's
1623
+ poll_mobile_view reads it the same way). None when zellij did not answer (no answer in 10 s, an error, a session
1624
+ gone since the list): an unknown list is never an empty one."""
1625
+ rc, out, _err = zellij(['-s', device, 'action', 'list-clients'], 10)
1626
+ if rc != 0:
1627
+ return None
1628
+ return sum(1 for line in out.splitlines() if re.match(r'^\s*\d+\s', line))
1629
+
1630
+
1631
+ def refresh_view(device, meta):
1632
+ """`lanes ensure --if-layout-changed` for the running view `device` (W9 U6, LNS-94): nothing while its layout file
1633
+ is what this release generates (a stamp left from before — a rollback — is dropped); otherwise rebuilt when zellij
1634
+ answers that no client is attached (cmd_rebuild, which writes the reconnect stamp and the new layout, and drops the
1635
+ stamp through create_session), else stamped once (update_ready_path; never rewritten, so its time is when the
1636
+ change was first found attached). Raises LanesError when the rebuild failed."""
1637
+ stamp = update_ready_path(device)
1638
+ try:
1639
+ stored = (L.STATE / 'layouts' / f'{device}.kdl').read_text()
1640
+ except (OSError, UnicodeDecodeError):
1641
+ stored = None
1642
+ if not layout_changed(stored, layout_kdl(device, bool(meta.get('mobile')), int(meta.get('viewports') or 1))):
1643
+ if stamp.exists():
1644
+ stamp.unlink(missing_ok=True)
1645
+ print(f'{device}: its layout is current again; dropped {stamp.name}')
1646
+ return
1647
+ clients = view_clients(device)
1648
+ if clients == 0:
1649
+ print(f'{device}: its layout changed and no client is attached: rebuilding it')
1650
+ cmd_rebuild([device])
1651
+ return
1652
+ if not stamp.exists():
1653
+ L.atomic_write(stamp, f'{time.time()}\n')
1654
+ attached = 'zellij did not say which clients are attached' if clients is None else (
1655
+ f"{clients} client{'' if clients == 1 else 's'} attached")
1656
+ print(f'{device}: its layout changed; {attached}: left running, its sidebars offer `update ready · reload (r)` '
1657
+ f'({stamp.name})')
1658
+
1659
+
1660
+ def cmd_ensure(args):
1661
+ if any(arg != '--if-layout-changed' for arg in args):
1662
+ print(ENSURE_USAGE)
1663
+ return 2
1664
+ every = '--if-layout-changed' in args
1665
+ with contextlib.ExitStack() as stack:
1666
+ # One pass under the view-session lock (DEF-1792), from the list to the last tab sync: no other ensure, rebuild
1667
+ # or create acts on a session between what this run listed and what it does. The update's run waits for one
1668
+ # that holds it; the minute timer's only glances, and skips its tick — the holder is acting on the views now.
1669
+ refused = hold_sessions(stack, SESSIONS_WAIT if every else SESSIONS_GLANCE,
1670
+ 'lanes ensure --if-layout-changed' if every else 'lanes ensure')
1671
+ if isinstance(refused, L.SessionsBusy) and not every:
1672
+ print(f'ensure: skipped: {refused}; nothing was recreated (lanes-ensure.timer runs again in a minute)')
1673
+ return 0
1674
+ if refused:
1675
+ raise L.LanesError(f'{refused}: no view was recreated, compared or rebuilt')
1676
+ return ensure_views(every)
1677
+
1678
+
1679
+ def ensure_views(every):
1680
+ """cmd_ensure's pass, under the view-session lock: recreate each device with no live session, compare the running
1681
+ ones (every one with `every`, else only a device the update stamped), then sync the tabs of every live view."""
966
1682
  # live_sessions() raises when zellij did not answer: then this run ends here and recreates nothing (the timer
967
1683
  # tries again in a minute). "Missing" is only ever concluded from a list zellij actually gave.
968
1684
  live = live_sessions()
1685
+ failures = []
969
1686
  for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
970
1687
  device = meta_file.stem
1688
+ meta = L.read_json(meta_file) or {}
1689
+ meta = meta if isinstance(meta, dict) else {}
971
1690
  if device not in live:
972
- meta = L.read_json(meta_file) or {}
973
1691
  create_session(device, bool(meta.get('mobile')), int(meta.get('viewports') or 1))
974
1692
  print(f'recreated view session {device}')
975
- return cmd_upgrade_session([])
1693
+ elif every or update_ready_path(device).exists():
1694
+ # The update asks for every view; the minute timer only finishes what an update left (W9 U6). Before the
1695
+ # tab sync, so a rebuilt view is reconciled like any other; one device's failure never stops the next.
1696
+ try:
1697
+ refresh_view(device, meta)
1698
+ except L.LanesError as error:
1699
+ failures.append(f'{device}: {error}')
1700
+ if not failures:
1701
+ return cmd_upgrade_session([])
1702
+ try:
1703
+ cmd_upgrade_session([])
1704
+ except L.LanesError as error:
1705
+ failures.append(str(error))
1706
+ raise L.LanesError('; '.join(failures))
976
1707
 
977
1708
 
978
- def prepare_view(device, mobile, viewports=1, explicit=False):
1709
+ def prepare_view(device, mobile, viewports=1, explicit=False, doing='lanes prepare'):
979
1710
  """Create the device's view session unless it runs -> 'created' | 'reused'. A running view is never rebuilt:
980
1711
  a mobile request refuses a desktop view, and an EXPLICIT desktop request (`--desktop`, `lanes web`) refuses a
981
- mobile one. Without a flag, `prepare` keeps reusing whatever layout runs."""
982
- if device not in live_sessions():
983
- create_session(device, mobile, viewports)
984
- return 'created'
1712
+ mobile one. Without a flag, `prepare` keeps reusing whatever layout runs. Under the view-session lock (DEF-1792),
1713
+ waited for like `lanes rebuild` waits; `doing` names the command to a process that waits for it."""
1714
+ with contextlib.ExitStack() as stack:
1715
+ refused = hold_sessions(stack, SESSIONS_WAIT, f'{doing} {device}')
1716
+ if refused:
1717
+ raise L.LanesError(f'{refused}: {device} was not prepared; try again shortly')
1718
+ if device not in live_sessions():
1719
+ create_session(device, mobile, viewports)
1720
+ return 'created'
985
1721
  running_mobile = bool((L.read_json(L.STATE / 'devices' / f'{device}.json') or {}).get('mobile'))
986
1722
  if mobile and not running_mobile:
987
1723
  raise L.LanesError(f'{device} already has a desktop layout; choose another device name')
@@ -1232,7 +1968,7 @@ def cmd_web(args):
1232
1968
  prepare = ['lanes', 'prepare', device, flag] + (['--viewports', str(viewports)] if viewports != 1 else [])
1233
1969
  state = 'planned'
1234
1970
  if not print_only:
1235
- state = prepare_view(device, mobile, viewports, explicit=explicit)
1971
+ state = prepare_view(device, mobile, viewports, explicit=explicit, doing='lanes web')
1236
1972
  record_base(device, args)
1237
1973
  rc, out, err = L.run_bounded(['sh', str(L.REPO / 'install/enable-web.sh'), device, flag], 180)
1238
1974
  if rc != 0:
@@ -1414,8 +2150,12 @@ class UploadReader:
1414
2150
 
1415
2151
 
1416
2152
  def cmd_upload(args):
2153
+ """`lanes upload <workspace> | --active <device> [--device D] [--paste] [--request R]`: a tar stream on stdin into
2154
+ the workspace's `uploads/` AND the docs store (W10 W5, libexec/uploads.py receive_tar): each file lands as it always
2155
+ did — same folder, `-1` collision suffix, `.git/info/exclude` line — and gets a sha256 receipt, `source` ssh, for
2156
+ the login that owns the asking device (users.login_for; none: a docs.log `no-owner` line), under the lane its view
2157
+ shows (uploads.upload_lane). stdout and `<request>.done` stay the landed in-container paths, one per line."""
1417
2158
  import shlex
1418
- import tarfile
1419
2159
  registry = L.load_registry()
1420
2160
  device = args[args.index('--active') + 1] if '--active' in args else None
1421
2161
  paste_device = args[args.index('--device') + 1] if '--device' in args else device
@@ -1436,34 +2176,20 @@ def cmd_upload(args):
1436
2176
  raise L.LanesError(f'unknown board: {names[0]} (see `lanes workspaces`)')
1437
2177
  else:
1438
2178
  raise L.LanesError('usage: lanes upload <workspace> | --active <device> [--paste] (tar stream on stdin)')
1439
- folder = next((lane.get('folder') for lane in workspace['lanes'] if lane.get('folder')), None) or workspace['cwd']
1440
- info = registry['containers'].get(workspace['container']) or {}
1441
- root = L.host_path(info, folder)
1442
- uploads = root / 'uploads'
1443
- uploads.mkdir(exist_ok=True)
1444
- # Keep uploads out of commits without touching a tracked .gitignore: the repository-local exclude file.
1445
- exclude = root / '.git' / 'info' / 'exclude'
1446
- # check-ignore: 0 = ignored, 1 = not ignored; an error or no answer (None) is not a reason to append.
1447
- if (root / '.git').is_dir() and L.run_bounded(['git', '-C', str(root), 'check-ignore', '-q', 'uploads/x'], 10)[0] == 1:
1448
- exclude.parent.mkdir(exist_ok=True)
1449
- with open(exclude, 'a') as handle:
1450
- handle.write('\n# lanes upload: local intake folder, never committed\nuploads/\n')
1451
- staging = uploads / f'.incoming-{os.getpid()}'
1452
- staging.mkdir()
1453
- landed = []
2179
+ uploads_lib, users = libexec_module('uploads'), libexec_module('users')
2180
+ folder, home, parts = uploads_lib.landing(registry, workspace)
2181
+ # The view that asked (a suffixed view's sidecar, else the device named): whose upload it is, and whose lane.
2182
+ view_device = paste_target(spool, request, paste_device) if spool else paste_device
2183
+ login = users.login_for(view_device) if view_device else None
2184
+ lane = uploads_lib.upload_lane(workspace, view_device)
2185
+ uploads = uploads_lib.open_uploads(home, parts)
1454
2186
  try:
1455
2187
  stream = UploadReader(sys.stdin.buffer, spool / f'{request}.progress' if spool else None)
1456
- with tarfile.open(fileobj=stream, mode='r|') as archive:
1457
- archive.extractall(staging, filter='data') # regular files/dirs only, no absolute paths, no links out
1458
- for entry in sorted(staging.iterdir()):
1459
- final, n = uploads / entry.name, 1
1460
- while final.exists(): # never overwrite an earlier upload
1461
- final = uploads / f'{entry.stem}-{n}{entry.suffix}'
1462
- n += 1
1463
- entry.rename(final)
1464
- landed.append(f"{folder}/uploads/{final.name}")
2188
+ landed = [f'{folder}/uploads/{name}' for name in uploads_lib.receive_tar(
2189
+ stream, uploads, login=login, ws=workspace['id'], lane=lane,
2190
+ say=lambda line: print(line, file=sys.stderr, flush=True))]
1465
2191
  finally:
1466
- shutil.rmtree(staging, ignore_errors=True)
2192
+ os.close(uploads)
1467
2193
  if not landed:
1468
2194
  # The helper reports the client-side cause (eg. macOS denied Downloads). Publishing a generic .err here
1469
2195
  # races that report: the sidebar consumes it and loses the useful error before the helper can send it.
@@ -1472,7 +2198,6 @@ def cmd_upload(args):
1472
2198
  print(path)
1473
2199
  if '--paste' in args and paste_device:
1474
2200
  # The result files (.done / .err) stay in the base spool, where the asking sidebar looks for them.
1475
- view_device = paste_target(spool, request, paste_device) if spool else paste_device
1476
2201
  shown = type_into_lane(registry, device_name(view_device), workspace, ' '.join(shlex.quote(p) for p in landed) + ' ')
1477
2202
  print(f'# typed into lane: {shown}' if shown else '# could not type into the lane (not a tmux lane?)', file=sys.stderr)
1478
2203
  if spool:
@@ -1480,6 +2205,32 @@ def cmd_upload(args):
1480
2205
  return 0
1481
2206
 
1482
2207
 
2208
+ DOCS_USAGE = 'usage: lanes docs push <id>'
2209
+ # What a publication target needs and does not have yet: the data service's token-authenticated publication surface
2210
+ # (plan W10 "Operator gates" 3 — decided once that surface is stable). Until then `lanes docs push` is the hook only.
2211
+ DOCS_PUSH_UNCONFIGURED = ('lanes docs push: not configured — no publication target exists yet (the data service\'s '
2212
+ 'publication surface is not built; plan W10, operator gate 3): nothing was sent')
2213
+
2214
+
2215
+ def cmd_docs(args):
2216
+ """`lanes docs push <id>` (docs cockpit W10 W9, LNS-110): the hook for the data service's publication surface — a
2217
+ STUB, not built. It looks the document up in every login's docs store (docs_store.operator_receipt: the operator is
2218
+ the account the store belongs to, so no login's visibility applies), prints its receipt.json on stdout
2219
+ (DM-LANES-DOCS-RECEIPT, as stored: indent 2), says `not configured` on stderr and returns 2. Nothing is sent,
2220
+ written or logged. No such document, an id that is not one (docs_store.ID_RE) or any other argument: LanesError,
2221
+ exit 1."""
2222
+ if len(args) != 2 or args[0] != 'push':
2223
+ raise L.LanesError(DOCS_USAGE)
2224
+ D = libexec_module('docs_store')
2225
+ found = D.operator_receipt(args[1])
2226
+ if found is None:
2227
+ raise L.LanesError(f'docs push: no document {args[1]} in the docs store ({D.root()})')
2228
+ _meta, receipt = found
2229
+ print(json.dumps(receipt, indent=2, ensure_ascii=False))
2230
+ print(DOCS_PUSH_UNCONFIGURED, file=sys.stderr)
2231
+ return 2
2232
+
2233
+
1483
2234
  def resolve_device(name, args):
1484
2235
  """`coder-launch worlds` style: a board id given where a device is expected means "my default device"."""
1485
2236
  try:
@@ -1544,18 +2295,22 @@ def cmd_upload_result(args):
1544
2295
 
1545
2296
 
1546
2297
  WORKSPACES_USAGE = ('usage: lanes workspaces | lanes workspaces add <id> --container <name> --repo <path> '
1547
- '[--title T] [--session S] [--write]')
2298
+ '[--title T] [--session S] [--write] | lanes workspaces hide|show|order <id…> '
2299
+ '[--device D | --login L]')
1548
2300
 
1549
2301
 
1550
2302
  def cmd_workspaces(args):
1551
2303
  """`lanes workspaces` prints the board ids (the client installer reads them); `lanes workspaces add` appends a
1552
2304
  board to the site catalog and writes its manifest (libexec/site_edit.py). Dry run (the printed diff) unless
1553
2305
  --write; an id already in the catalog is verified and never rewritten (exit 1 when it disagrees); a repository
1554
- another board already declares is refused (LanesError, before any write)."""
2306
+ another board already declares is refused (LanesError, before any write). `hide|show|order` arrange the boards in
2307
+ a person's profile (cmd_workspaces_profile, cockpit v3 R6)."""
1555
2308
  if not args:
1556
2309
  for workspace in L.load_registry()['workspaces']:
1557
2310
  print(workspace['id'])
1558
2311
  return 0
2312
+ if args[0] in ARRANGE_WORKSPACES:
2313
+ return cmd_workspaces_profile(args)
1559
2314
  if args[0] != 'add' or len(args) < 2 or args[1].startswith('-'):
1560
2315
  raise L.LanesError(WORKSPACES_USAGE)
1561
2316
  opts, rest = {}, list(args[2:])
@@ -1819,18 +2574,44 @@ def cmd_onboard(args):
1819
2574
  print(f'backup {backup}')
1820
2575
  return ('changed' if write else 'planned'), f"{board} in {result['catalog']}"
1821
2576
 
2577
+ mcp_before = {}
2578
+
2579
+ def mcp_files():
2580
+ return [home() / lane_core().MCP_FILES[agent] for agent in lane_core().MCP_AGENTS]
2581
+
1822
2582
  def contract_step():
1823
2583
  root = home() / '.lz'
1824
2584
  if not write:
1825
2585
  print(f'would install the lane contract into {root} and link {Path.home() / ".lz" / container}')
1826
2586
  return 'planned', str(root)
2587
+ for path in mcp_files(): # what the lane-mcp step compares against: install-container writes the entries
2588
+ mcp_before[path] = lane_core().read_regular(path, lane_core().EDIT_MAX) # never a link or a FIFO
1827
2589
  cmd_install_container([container] + (['--no-mcp'] if no_mcp else []))
1828
2590
  return 'ok', str(root)
1829
2591
 
1830
2592
  def mcp_step():
2593
+ """install-container (the contract step) wrote the agents' MCP entries; this step plans them again and says
2594
+ what that changed. A dry run prints the diffs install-container would apply."""
1831
2595
  if no_mcp:
1832
2596
  return 'skipped', '--no-mcp: left out'
1833
- return 'skipped', 'lane-mcp is not shipped yet (plan W6): install-container has nothing to add'
2597
+ plan = lane_core().mcp_plan(home())
2598
+ files = ', '.join(str(item['path']) for item in plan)
2599
+ refused = '; '.join(f"{item['path']}: {item['why']}" for item in plan if item['why'])
2600
+ pending = [str(item['path']) for item in plan if not item['why'] and item['after'] != item['before']]
2601
+ if not write:
2602
+ for item in plan:
2603
+ if item['why']:
2604
+ print(f"! {item['path']}: {item['why']}")
2605
+ elif item['after'] == item['before']:
2606
+ print(f"{item['path']}: the lanes MCP server is already there")
2607
+ else:
2608
+ sys.stdout.write(lane_core().mcp_diff(item))
2609
+ return ('warn', refused) if refused else (('planned', files) if pending else ('unchanged', files))
2610
+ if refused or pending:
2611
+ return 'warn', refused or f"not written: {', '.join(pending)}"
2612
+ changed = [str(path) for path in mcp_files()
2613
+ if lane_core().read_regular(path, lane_core().EDIT_MAX) != mcp_before.get(path)]
2614
+ return ('changed', ', '.join(changed)) if changed else ('unchanged', files)
1834
2615
 
1835
2616
  def hooks_step(agent):
1836
2617
  def step():
@@ -1851,7 +2632,7 @@ def cmd_onboard(args):
1851
2632
  return 'planned', 'lanes sync'
1852
2633
  if cmd_sync([]) == 0:
1853
2634
  return 'ok', 'registry published'
1854
- return 'warn', 'a board has registry errors (listed above)'
2635
+ return 'warn', 'a board has registry errors or a container refused its projection (listed above)'
1855
2636
 
1856
2637
  cursor = {}
1857
2638
 
@@ -1933,10 +2714,10 @@ def cmd_rebuilt(args):
1933
2714
  return 0 if time.time() - stamp < 60 else 1
1934
2715
 
1935
2716
 
1936
- def server_version():
1937
- """This server's release (lanes/VERSION), as `lanes hello` reports it; 'dev' without the file."""
1938
- path = L.REPO / 'VERSION'
1939
- return path.read_text().strip() if path.exists() else 'dev'
2717
+ def server_version(repo=None):
2718
+ """A tree's release (default this server's, L.REPO/VERSION), as `lanes hello` and `lanes version` report it; 'dev'
2719
+ when the file is absent, empty or unreadable."""
2720
+ return L.tree_version(repo if repo is not None else L.REPO) or 'dev'
1940
2721
 
1941
2722
 
1942
2723
  VERSION_RE = re.compile(r'(\d+)\.(\d+)\.(\d+)(?:[-+][A-Za-z0-9.]+)?')
@@ -1949,6 +2730,23 @@ def version_key(text):
1949
2730
  return tuple(int(part) for part in match.groups()) if match else None
1950
2731
 
1951
2732
 
2733
+ def client_older(client, server):
2734
+ """Whether the release `client` names is older than `server`: both parse (version_key) and the client's
2735
+ major.minor.patch is the lower, compared as numbers (1.9.3 is older than 1.10.0; 1.1.0-rc.1 is not older than
2736
+ 1.1.0). `dev`, a missing value or client text that is no version is never older. The one comparison behind both
2737
+ server answers to an older client: `lanes attach`'s notice (client_notice) and `lanes hello`'s update= (W9 K1)."""
2738
+ client_key, server_key = version_key(client), version_key(server)
2739
+ return client_key is not None and server_key is not None and client_key < server_key
2740
+
2741
+
2742
+ def client_min(server):
2743
+ """The release a lanes-launch must be at for this server not to tell it to update: the server's major.minor.patch
2744
+ (client_notice compares exactly that — a client below it gets the notice, one at it none); None for a server
2745
+ whose version is no release ('dev'), which tells no client to update."""
2746
+ key = version_key(server)
2747
+ return '.'.join(str(part) for part in key) if key else None
2748
+
2749
+
1952
2750
  # The newest published release whose lanes-launch passes `--client` but no `--client-version` (@vibelord/lanes 1.0.4).
1953
2751
  # A client without the flag is this release or an earlier one, so it is older only than a server past it. A fixed fact
1954
2752
  # about what was published: bumping lanes/VERSION never moves it.
@@ -1966,21 +2764,205 @@ def client_notice(args):
1966
2764
  return None
1967
2765
  if '--client-version' in args:
1968
2766
  client = args[args.index('--client-version') + 1] if '--client-version' in args[:-1] else ''
1969
- older = version_key(client) is not None and version_key(client) < version_key(server)
1970
- return f'lanes client {client} is older than the server ({server}) — run lanes-launch update' if older else None
2767
+ return (f'lanes client {client} is older than the server ({server}) — run lanes-launch update'
2768
+ if client_older(client, server) else None)
1971
2769
  if '--client' in args and version_key(server) > LAST_FLAGLESS_CLIENT:
1972
2770
  return f'lanes client is older than the server ({server}) — update it: npx @vibelord/lanes@latest'
1973
2771
  return None
1974
2772
 
1975
2773
 
1976
2774
  def cmd_hello(args):
1977
- device = resolve_device(args[0], args) if args else '-'
2775
+ """`lanes hello [device] [--client-version V]`: one line, exit 0 — `ok device=<device> version=<server>
2776
+ boards=<n>`, what the client installer reads to prove the SSH path, the key and the server's release. With
2777
+ `--client-version V` older than this server (client_older: major.minor.patch as numbers) the line ends
2778
+ ` update=<server>` (W9 K1, LNS-86): the exact release, as this tree's VERSION names it, that a client updates to
2779
+ (lanes-launch's self-update, W9 K2, installs exactly that and never @latest). An equal or newer client, `dev`
2780
+ on either side, text that is no version, or no flag at all: the line every earlier release printed. The hint is
2781
+ appended, so a reader of the first four fields (install.sh check_server, lanes-launch web) reads them as before.
2782
+ The flag and its value may stand anywhere; the device is the first other argument."""
2783
+ rest, client = list(args), None
2784
+ if '--client-version' in rest:
2785
+ at = rest.index('--client-version')
2786
+ client = rest[at + 1] if at + 1 < len(rest) else None
2787
+ del rest[at:at + 2]
2788
+ device = resolve_device(rest[0], args) if rest else '-'
1978
2789
  version = server_version()
1979
2790
  boards = len(L.load_registry()['workspaces']) if (L.STATE / 'registry.json').exists() else 0
1980
- print(f'ok device={device} version={version} boards={boards}')
2791
+ update = f' update={version}' if client_older(client, version) else ''
2792
+ print(f'ok device={device} version={version} boards={boards}{update}')
2793
+ return 0
2794
+
2795
+
2796
+ # ------------------------------------------------------------------ version (W9 V1, LNS-83)
2797
+ # `lanes version` answers "which release runs here, and what still runs an older one": this CLI's tree, the tree the
2798
+ # `current` link names (install/services.py writes it; the units run from it after W9 U1), and the tree each
2799
+ # long-running process recorded when it last started (L.record_tree, W9 U0). `current` is read from the link ITSELF:
2800
+ # L.CURRENT collapses to this tree's own path whenever the link names another tree, so realpath(L.CURRENT) is this tree
2801
+ # by construction and could never show a stale CLI. A checkout updated in place (`git pull`, USERGUIDE Update) keeps its
2802
+ # path, so a record names the current tree before and after the update: the time of the tree's code says which starts
2803
+ # came before it (tree_code_mtime).
2804
+
2805
+ # The record_tree names, in the order `lanes version` lists them: statusd first (every view's status comes from it),
2806
+ # then the services, then the newest sidebar. A name not listed (a newer tree's process) follows, sorted.
2807
+ RUN_ORDER = ('statusd', 'enrold', 'docsd', 'notify', 'economy', 'studio_host', 'sidebar')
2808
+ VERSION_SCHEMA = 'lanes.version/1'
2809
+ COMMIT_RE = re.compile(r'([0-9a-f]{7})[0-9a-f]{0,33}(-dirty)?') # scripts/build-payload.mjs: rev-parse HEAD [-dirty]
2810
+ # A tree's code, as far as a recorded process runs it: VERSION and the regular files directly in these folders (every
2811
+ # record_tree process starts from libexec/ or bin/lanes and imports libexec/*.py).
2812
+ TREE_CODE = ('bin', 'libexec')
2813
+
2814
+
2815
+ def utc(seconds):
2816
+ return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(seconds))
2817
+
2818
+
2819
+ def tree_code_mtime(tree):
2820
+ """The newest modification time (epoch seconds) of a tree's code: its VERSION and every regular file directly in
2821
+ bin/ and libexec/ — dotfiles (editor swap files), __pycache__ and every other folder (docs/, web/) are not code. None
2822
+ when there is none (a pruned tree). `git pull` writes each file it changes with the time of the pull, and the npm
2823
+ wrapper re-stages a version (other bytes) as a fresh copy, so a time later than a run record says that process
2824
+ started before its tree's code changed — a checkout updated in place keeps its path. A pull that only deletes files
2825
+ changes no file's time and is not seen."""
2826
+ tree = Path(tree)
2827
+ paths = [tree / 'VERSION']
2828
+ for folder in TREE_CODE:
2829
+ try:
2830
+ with os.scandir(tree / folder) as entries:
2831
+ paths.extend(Path(entry.path) for entry in entries if not entry.name.startswith('.'))
2832
+ except OSError:
2833
+ continue
2834
+ newest = None
2835
+ for path in paths:
2836
+ try:
2837
+ if path.is_file():
2838
+ mtime = path.stat().st_mtime
2839
+ newest = mtime if newest is None else max(newest, mtime)
2840
+ except OSError:
2841
+ continue
2842
+ return newest
2843
+
2844
+
2845
+ def run_records(state, reference):
2846
+ """{name: {tree, version, started, current, changed}} for each `<state>/run/<name>.tree`, RUN_ORDER first. `tree`
2847
+ is the record's one absolute path (None for a record that names none), `version` that tree's VERSION now (None once
2848
+ the tree is pruned), `started` the record's mtime in UTC (the process's last start), `changed` the time of the tree's
2849
+ newest code (tree_code_mtime) when it is LATER than that start — the process runs code from before it — else None,
2850
+ and `current` whether the process runs the current code: its tree resolves to `reference` and `changed` is None. A
2851
+ record outlives its process: it says what the last start ran, never that it still runs."""
2852
+ found, code_times = {}, {}
2853
+ try:
2854
+ paths = sorted((Path(state) / L.RUN_DIR).glob('*.tree'))
2855
+ except OSError:
2856
+ paths = []
2857
+ for path in paths:
2858
+ name = path.name[:-len('.tree')]
2859
+ if not L.IDENT.match(name): # never a name record_tree writes
2860
+ continue
2861
+ try:
2862
+ if not path.is_file():
2863
+ continue
2864
+ lines = path.read_text().splitlines()
2865
+ started = path.stat().st_mtime
2866
+ except (OSError, ValueError): # ValueError: undecodable bytes
2867
+ continue
2868
+ tree = lines[0] if len(lines) == 1 and lines[0].startswith('/') else None
2869
+ if tree is not None and tree not in code_times:
2870
+ code_times[tree] = tree_code_mtime(tree)
2871
+ code = code_times.get(tree)
2872
+ changed = code if code is not None and code > started else None # a start AT the code's time is after it
2873
+ found[name] = {'tree': tree, 'version': L.tree_version(tree) if tree else None, 'started': utc(started),
2874
+ 'current': tree is not None and os.path.realpath(tree) == reference and changed is None,
2875
+ 'changed': utc(changed) if changed is not None else None}
2876
+ order = [name for name in RUN_ORDER if name in found] + sorted(set(found) - set(RUN_ORDER))
2877
+ return {name: found[name] for name in order}
2878
+
2879
+
2880
+ def version_report(repo=None, state=None, home=None):
2881
+ """The `lanes version --json` document (schema lanes.version/1): {schema, version, commit, tree, current, running,
2882
+ client_min}. `version` is the tree's release ('dev' without one, as `lanes hello`), `commit` the npm wrapper's
2883
+ stamp (None for a checkout), `tree` the resolved tree this CLI runs, `current` the resolved target of
2884
+ ~/.local/share/lanes/current (None without the link), `running` run_records() compared with `current` — or with
2885
+ `tree` when there is no link — and with the time of each tree's code, and `client_min` the release attach stops
2886
+ telling lanes-launch to update at."""
2887
+ repo = Path(repo) if repo is not None else L.REPO
2888
+ try:
2889
+ link = L.current_link(home)
2890
+ current = os.path.realpath(link) if os.path.lexists(link) else None
2891
+ except (KeyError, RuntimeError): # no HOME and no passwd entry: no link to read
2892
+ current = None
2893
+ version = server_version(repo)
2894
+ return {'schema': VERSION_SCHEMA, 'version': version, 'commit': (L.tree_stamp(repo) or {}).get('commit'),
2895
+ 'tree': str(repo), 'current': current,
2896
+ 'running': run_records(state if state is not None else L.STATE,
2897
+ current if current is not None else os.path.realpath(repo)),
2898
+ 'client_min': client_min(version)}
2899
+
2900
+
2901
+ def plain(text):
2902
+ """Text for a terminal: every character that is not printable (escape sequences included) becomes `?`."""
2903
+ return ''.join(char if char.isprintable() else '?' for char in str(text))
2904
+
2905
+
2906
+ def tree_shown(path, home=None):
2907
+ """A tree as `lanes version` prints it: `pkg/<version>` for an npm-staged tree, `~/…` under the home, else the
2908
+ path."""
2909
+ path = Path(path)
2910
+ if path.parent.name == 'pkg':
2911
+ return plain(f'pkg/{path.name}')
2912
+ try:
2913
+ return plain(f'~/{path.relative_to(Path(home) if home is not None else Path.home())}')
2914
+ except (ValueError, KeyError, RuntimeError):
2915
+ return plain(path)
2916
+
2917
+
2918
+ def record_said(record, reference, home=None, past=False):
2919
+ """What a record that does not run the current code says, as `lanes version` and doctor print it after the name:
2920
+ `started before its tree changed` when its tree is `reference` (updated in place after that start), else `runs`
2921
+ (`ran` with `past`) its tree's release — or the tree once that is pruned, or `?` for a record that names none."""
2922
+ if record['tree'] is not None and os.path.realpath(record['tree']) == reference:
2923
+ return 'started before its tree changed'
2924
+ ran = (plain(record['version']) if record['version'] else
2925
+ tree_shown(record['tree'], home) if record['tree'] else '?')
2926
+ return f"{'ran' if past else 'runs'} {ran}"
2927
+
2928
+
2929
+ def version_text(report, home=None):
2930
+ """The one line `lanes version` prints, e.g. `lanes 1.2.0 (abc1234) · tree pkg/1.2.0 · current ✓ · statusd runs
2931
+ 1.1.0 ✗ · enrold notify ✓`: each record that does not run the current code on its own — `runs <version>` (or its
2932
+ tree once that is pruned, or `?` for a record that names no tree) when its tree is another one, `started before its
2933
+ tree changed` when it is the current tree, updated in place after that start — then the current ones together."""
2934
+ commit = COMMIT_RE.fullmatch(report['commit'] or '')
2935
+ parts = [f"lanes {plain(report['version'])}" + (f' ({commit.group(1)}{commit.group(2) or ""})' if commit else ''),
2936
+ f"tree {tree_shown(report['tree'], home)}"]
2937
+ current = report['current']
2938
+ parts.append('no current link' if current is None else 'current ✓' if current == os.path.realpath(report['tree'])
2939
+ else f'current {tree_shown(current, home)} ✗')
2940
+ reference = current if current is not None else os.path.realpath(report['tree'])
2941
+ running = report['running']
2942
+ for name, record in running.items():
2943
+ if not record['current']:
2944
+ parts.append(f'{plain(name)} {record_said(record, reference, home)} ✗')
2945
+ fresh = [plain(name) for name, record in running.items() if record['current']]
2946
+ if fresh:
2947
+ parts.append(' '.join(fresh) + ' ✓')
2948
+ return ' · '.join(parts)
2949
+
2950
+
2951
+ def cmd_version(args):
2952
+ if any(arg != '--json' for arg in args):
2953
+ print('usage: lanes version [--json]')
2954
+ return 2
2955
+ report = version_report()
2956
+ print(json.dumps(report, indent=1, ensure_ascii=False) if '--json' in args else version_text(report))
1981
2957
  return 0
1982
2958
 
1983
2959
 
2960
+ def cmd_update(args):
2961
+ """`lanes update [--now | --timer | --status | --rollback]` (W9 U3, LNS-91): libexec/update.py owns the flow, its
2962
+ refusals and its exit statuses; releases are compared as `lanes hello` compares them (version_key)."""
2963
+ return libexec_module('update').update_main(args, version_key)
2964
+
2965
+
1984
2966
  def cmd_client_config(_args):
1985
2967
  """Site facts a client may want after its first connection (e.g. the public fallback address), so that they
1986
2968
  need not be written into the distributed package. KEY=VALUE lines, LANES_* keys only."""
@@ -2030,9 +3012,227 @@ def cmd_gc(_args):
2030
3012
  if stale.name not in known and time.time() - stale.stat().st_mtime > 3600:
2031
3013
  stale.unlink()
2032
3014
  print(f'removed stale {stale.name}')
3015
+ gc_trees()
2033
3016
  return 0
2034
3017
 
2035
3018
 
3019
+ # ------------------------------------------------------------------ staged trees (plan W9 U4, LNS-92)
3020
+ # The npm wrapper stages each release at <pkg root>/<version>. Since W9 U4 it prunes only where the `current` link is
3021
+ # absent — a client, where nothing runs from an old tree (npm/lanes/lib/plan.mjs prunable; DEF-1768) — and a server's
3022
+ # trees are pruned here, by `lanes gc` (the daily lanes-gc.timer), where what still uses one can be seen.
3023
+ TREE_KEEP = 3 # the newest staged trees each pkg root keeps, as the wrapper keeps them on a client
3024
+ PRUNING = '.pruning-' # a tree being removed is first renamed to .pruning-<version>-<pid>-<ns> in its root
3025
+ DELETED = ' (deleted)' # what readlink(2) of a /proc link appends once the file it names is gone
3026
+
3027
+
3028
+ def home_shown(path, home):
3029
+ """A path as `lanes gc` prints it: `~/…` under the home, else whole (two roots may both be called pkg)."""
3030
+ for base in (Path(home), Path(home).resolve()):
3031
+ try:
3032
+ return plain(f'~/{Path(path).relative_to(base)}')
3033
+ except ValueError:
3034
+ continue
3035
+ return plain(path)
3036
+
3037
+
3038
+ def pkg_roots(home):
3039
+ """The folders a server's staged trees are in, resolved, each once and existing only, in this order: the folder of
3040
+ the tree `current` names, of the tree `current.previous` names and of the tree this lanes runs from, each when it is
3041
+ a staged tree (L.staged_tree); then the folder the npm wrapper stages in from this environment (lanes-install.mjs
3042
+ pkgRoot: an absolute $LANES_PKG_ROOT, else $XDG_DATA_HOME/lanes/pkg) and its default, ~/.local/share/lanes/pkg.
3043
+ A timer's environment holds neither variable: the first three find a tree staged elsewhere."""
3044
+ link = L.current_link(home)
3045
+ folders = []
3046
+ for tree in (link, link.with_name('current.previous'), L.REPO):
3047
+ try:
3048
+ resolved = Path(tree).resolve(strict=True)
3049
+ except (OSError, RuntimeError):
3050
+ continue
3051
+ if L.staged_tree(resolved):
3052
+ folders.append(resolved.parent)
3053
+ own, data = os.environ.get('LANES_PKG_ROOT', ''), os.environ.get('XDG_DATA_HOME', '')
3054
+ if own:
3055
+ folders += [Path(own)] if own.startswith('/') else []
3056
+ elif data.startswith('/'):
3057
+ folders.append(Path(data) / 'lanes/pkg')
3058
+ folders.append(Path(home) / '.local/share/lanes/pkg')
3059
+ roots = []
3060
+ for folder in folders:
3061
+ try:
3062
+ resolved = folder.resolve(strict=True)
3063
+ except (OSError, RuntimeError):
3064
+ continue
3065
+ if resolved.is_dir() and resolved not in roots:
3066
+ roots.append(resolved)
3067
+ return roots
3068
+
3069
+
3070
+ def staged_entries(root):
3071
+ """[(tree, mtime)] of the trees the npm wrapper staged directly in `root`, newest first (ties: the higher name
3072
+ first): a directory, never a link, whose name does not start with `.` (the wrapper's own `.staging-*`, a tree being
3073
+ pruned) and which holds the wrapper's stamp — a regular file that reads as a JSON object (L.tree_stamp), as the
3074
+ wrapper's own prune asks of it. A checkout, or anything else that is there, never is one."""
3075
+ found = []
3076
+ try:
3077
+ with os.scandir(root) as entries:
3078
+ for entry in entries:
3079
+ stamp = os.path.join(entry.path, L.PACKAGE_STAMP)
3080
+ try:
3081
+ if entry.name.startswith('.') or not entry.is_dir(follow_symlinks=False) \
3082
+ or os.path.islink(stamp) or L.tree_stamp(entry.path) is None:
3083
+ continue
3084
+ found.append((Path(root) / entry.name, entry.stat(follow_symlinks=False).st_mtime))
3085
+ except OSError:
3086
+ continue
3087
+ except OSError:
3088
+ return []
3089
+ return sorted(found, key=lambda each: (each[1], each[0].name), reverse=True)
3090
+
3091
+
3092
+ def proc_link(path):
3093
+ try:
3094
+ return os.readlink(path)
3095
+ except OSError:
3096
+ return None
3097
+
3098
+
3099
+ def process_paths(proc=None):
3100
+ """What this account's processes use now, read from /proc (`proc`, default PROC) — best effort: a process that
3101
+ ends or denies a read mid-scan is skipped -> ({path: pid}, {RUN_SCRIPTS name}), or None when /proc cannot be listed.
3102
+ The paths are every absolute path a process of this uid uses: its exe, its cwd and each file it holds open
3103
+ (/proc/<pid>/exe, cwd, fd/*), as the kernel resolved them when the process opened them — so a script started
3104
+ through `current` before a flip still names its tree: bash and dash read their script through an open descriptor —
3105
+ and each absolute word of its argv (or the value of an `--option=/path` word), as written and resolved now; the
3106
+ lowest pid that uses a path is the one named. The names are the daemons among the processes (run_name)."""
3107
+ proc = Path(proc) if proc is not None else PROC
3108
+ try:
3109
+ pids = sorted((name for name in os.listdir(proc) if name.isdigit()), key=int)
3110
+ except OSError:
3111
+ return None
3112
+ uid, paths, names = os.geteuid(), {}, set()
3113
+ for pid in pids:
3114
+ base = proc / pid
3115
+ try:
3116
+ if base.stat().st_uid != uid:
3117
+ continue
3118
+ fds = os.listdir(base / 'fd')
3119
+ except OSError:
3120
+ continue
3121
+ used = [proc_link(base / 'exe'), proc_link(base / 'cwd')] + [proc_link(base / 'fd' / fd) for fd in fds]
3122
+ try:
3123
+ raw = (base / 'cmdline').read_bytes()
3124
+ except OSError:
3125
+ raw = b''
3126
+ words = [os.fsdecode(word) for word in (raw[:-1] if raw.endswith(b'\0') else raw).split(b'\0')] if raw else []
3127
+ name = run_name(words)
3128
+ if name is not None:
3129
+ names.add(name)
3130
+ for word in words:
3131
+ for part in (word, word.partition('=')[2]):
3132
+ if part.startswith('/'):
3133
+ used += [part, os.path.realpath(part)]
3134
+ for path in used:
3135
+ path = path[:-len(DELETED)] if path and path.endswith(DELETED) else path
3136
+ if path and path.startswith('/'):
3137
+ paths.setdefault(os.path.normpath(path), int(pid))
3138
+ return paths, names
3139
+
3140
+
3141
+ def staged_tree_plan(home=None, proc=None):
3142
+ """[(tree, reason)] for every staged tree of pkg_roots, root by root and newest first: why `lanes gc` keeps it, or
3143
+ None when it is pruned. Kept: the TREE_KEEP newest of each root (by mtime, as the wrapper counts them on a client),
3144
+ and any older one that `current` or `current.previous` (the rollback target) names, that this lanes runs from,
3145
+ that a view layout (`layouts/<device>.kdl`) names — a release before W9 U1 wrote `pkg/<version>` into one, and the
3146
+ view keeps running it until it is rebuilt — that a running daemon's run record names (run/<name>.tree: one started
3147
+ through `current` before the last flip, whose argv now resolves to the new tree), or that a process of this account
3148
+ uses (process_paths, read only when an older tree has no other reason). When /proc cannot be listed no older tree
3149
+ is pruned. The units are not read: since W9 U1 each names `current`, and every install that moves the link renders
3150
+ them all."""
3151
+ home = Path(home) if home is not None else Path.home()
3152
+ link = L.current_link(home)
3153
+ named = {}
3154
+ for each, reason in ((link, 'current names it'),
3155
+ (link.with_name('current.previous'), 'current.previous names it (the rollback target)'),
3156
+ (L.REPO, 'this lanes runs from it')):
3157
+ if os.path.lexists(each):
3158
+ named.setdefault(os.path.realpath(each), reason)
3159
+ layouts = []
3160
+ for kdl in sorted((L.STATE / 'layouts').glob('*.kdl')):
3161
+ try:
3162
+ text = kdl.read_text()
3163
+ except (OSError, ValueError):
3164
+ continue
3165
+ said = f'the view layout of {plain(kdl.stem)} names it'
3166
+ layouts.append((said, text))
3167
+ for tree in layout_trees(text):
3168
+ named.setdefault(os.path.realpath(tree), said)
3169
+ plan = []
3170
+ for root in pkg_roots(home):
3171
+ for index, (tree, _mtime) in enumerate(staged_entries(root)):
3172
+ forms = {str(tree), os.path.realpath(tree)}
3173
+ plan.append([tree, forms, f'one of the {TREE_KEEP} newest' if index < TREE_KEEP else
3174
+ next((named[form] for form in forms if form in named), None)
3175
+ or next((said for said, text in layouts if any(form + '/' in text for form in forms)), None)])
3176
+ if not all(reason for _tree, _forms, reason in plan):
3177
+ scan = process_paths(proc)
3178
+ paths, live = scan if scan is not None else ({}, set())
3179
+ records = {}
3180
+ for name, record in run_records(L.STATE, '').items():
3181
+ if name in live and record['tree']:
3182
+ records.setdefault(os.path.realpath(record['tree']), f'{name} runs from it (run/{name}.tree)')
3183
+ for entry in plan:
3184
+ tree, forms, reason = entry
3185
+ if reason is None:
3186
+ entry[2] = ('the process table could not be read' if scan is None else
3187
+ next((records[form] for form in forms if form in records), None)
3188
+ or next((f'process {pid} uses it' for path, pid in paths.items()
3189
+ if any(path == form or path.startswith(form + '/') for form in forms)), None))
3190
+ return [(tree, reason) for tree, _forms, reason in plan]
3191
+
3192
+
3193
+ def gc_trees(home=None, proc=None):
3194
+ """`lanes gc`'s staged-tree sweep (plan W9 U4): what an earlier sweep left half-removed (`.pruning-*`) goes first;
3195
+ then each tree staged_tree_plan gives no reason to keep is renamed to `.pruning-<version>-…` — one rename(2), so no
3196
+ half-removed tree is ever left under its version name, which the wrapper would reuse by its stamp — and removed.
3197
+ One line for each tree past the newest: `pruned <tree>`, or `kept <tree>: <reason>`; `skip <tree>: <error>`."""
3198
+ try:
3199
+ home = Path(home) if home is not None else Path.home()
3200
+ except (KeyError, RuntimeError): # no HOME and no passwd entry: no link and no default root to read
3201
+ return
3202
+ for root in pkg_roots(home):
3203
+ try:
3204
+ with os.scandir(root) as entries:
3205
+ remains = sorted(entry.path for entry in entries
3206
+ if entry.name.startswith(PRUNING) and entry.is_dir(follow_symlinks=False))
3207
+ except OSError:
3208
+ remains = []
3209
+ for path in remains:
3210
+ try:
3211
+ shutil.rmtree(path)
3212
+ print(f'pruned {home_shown(path, home)} (left by an earlier lanes gc)')
3213
+ except OSError as error:
3214
+ print(f'skip {home_shown(path, home)}: {error.strerror or error}')
3215
+ for tree, reason in staged_tree_plan(home, proc):
3216
+ shown = home_shown(tree, home)
3217
+ if reason == f'one of the {TREE_KEEP} newest':
3218
+ continue
3219
+ if reason is not None:
3220
+ print(f'kept {shown}: {reason}')
3221
+ continue
3222
+ aside = tree.with_name(f'{PRUNING}{tree.name}-{os.getpid()}-{time.time_ns()}')
3223
+ try:
3224
+ os.rename(tree, aside)
3225
+ except OSError as error:
3226
+ print(f'skip {shown}: {error.strerror or error}')
3227
+ continue
3228
+ try:
3229
+ shutil.rmtree(aside)
3230
+ except OSError as error:
3231
+ print(f'skip {shown}: {error.strerror or error} (left as {aside.name}; the next lanes gc removes it)')
3232
+ continue
3233
+ print(f'pruned {shown}')
3234
+
3235
+
2036
3236
  def cmd_tmp(args):
2037
3237
  return libexec_module('tmpscan').run(args) # verbs, options and the usage text are tmpscan's
2038
3238
 
@@ -2046,7 +3246,364 @@ def cmd_studio_host(args):
2046
3246
 
2047
3247
 
2048
3248
  def cmd_keys(args):
2049
- return libexec_module('keys').main(args) # verbs, refusals and exit codes are keys.py's
3249
+ # verbs, refusals and exit codes are keys.py's; a verb that names an owner (adopt --login, owner) ends by folding
3250
+ # the views of the devices it named into that person's profile (cockpit v3 R2), and a dropped fold is its exit 1
3251
+ return libexec_module('keys').main(args, on_owned=fold_profile)
3252
+
3253
+
3254
+ USER_USAGE = 'usage: lanes user list | lanes user show <login> [--json]'
3255
+
3256
+
3257
+ def shown(text):
3258
+ """A file name for a terminal line: as it is when printable, else Python's escaped form (no control character from
3259
+ a directory listing reaches the terminal)."""
3260
+ return text if text.isprintable() else ascii(text)
3261
+
3262
+
3263
+ def shown_utc(epoch):
3264
+ """A time as `lanes user show` and `lanes here` print it: `2026-10-07 09:46:40Z` (utc() is the ISO form the JSON
3265
+ of `lanes version` carries)."""
3266
+ return time.strftime('%Y-%m-%d %H:%M:%SZ', time.gmtime(epoch))
3267
+
3268
+
3269
+ def look_words(look):
3270
+ """`theme dark · glyphs ascii`: a validated look (users.LOOK keys and values) in LOOK order."""
3271
+ return ' · '.join(f'{key} {look[key]}' for key in ('theme', 'color', 'glyphs') if key in look)
3272
+
3273
+
3274
+ def device_looks(users, devices):
3275
+ """{device: {'device_only': {…}, 'unfolded': {…}}} for each of `devices` whose devices/<device>.json keeps a
3276
+ look of its own (users.device_only / users.unfolded: validated values only), for `lanes user show` (DEF-1782):
3277
+ the keys that paint on that device whatever — or wherever — the profile says. Read-only."""
3278
+ looks = {}
3279
+ for device in devices:
3280
+ meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
3281
+ own, early = users.device_only(meta), users.unfolded(meta)
3282
+ if own or early:
3283
+ looks[device] = {'device_only': own, 'unfolded': early}
3284
+ return looks
3285
+
3286
+
3287
+ def profile_lines(profile):
3288
+ """[(label, value)] of one profile as `lanes user show` prints it (every value validated by users.normalize)."""
3289
+ look, seen = profile['look'], profile['seen']
3290
+ last, quiet = profile['last_opened'], profile['notify'].get('quiet_s')
3291
+ by = f" by {profile['updated_by_device']}" if profile['updated_by_device'] else ''
3292
+ return [('look', look_words(look) or 'nothing set (the device file, the environment and the defaults decide)'),
3293
+ ('seen', f"{len(seen)} lane{'s' if len(seen) != 1 else ''}" if seen else 'none'),
3294
+ ('workspaces', f"order {' '.join(profile['workspaces']['order']) or '-'} · "
3295
+ f"hidden {' '.join(profile['workspaces']['hidden']) or '-'}"),
3296
+ ('lanes', f"pinned {' '.join(profile['lanes']['pins']) or '-'} · "
3297
+ f"hidden {' '.join(profile['lanes']['hidden']) or '-'}"),
3298
+ ('last opened', f"{last['lane']} on {last['device']} at {shown_utc(last['at'])}" if last else 'none'),
3299
+ ('notify', f'quiet {quiet} s' if quiet is not None else 'default'),
3300
+ ('updated', f"{shown_utc(profile['updated_at'])}{by}" if profile['updated_at'] else 'never')]
3301
+
3302
+
3303
+ def cmd_user(args):
3304
+ """`lanes user list | show <login> [--json]` (cockpit v3 R2, LNS-75): the people lanes knows and one person's
3305
+ profile (users/<login>.json, libexec/users.py). A person is a login with a profile, or the owner of a device
3306
+ (users.device_logins: a view, a member record or seen marks, owned as device_owner derives it). show also names
3307
+ every look a device of the person keeps in its own devices/<device>.json (device_looks: --device-only keys, and
3308
+ keys from before the profile, with the claim that folds them in; DEF-1782) — why one device paints differently.
3309
+ Read-only: neither verb writes a file. Every value printed was validated (users.normalize, members.validate_owner,
3310
+ users.device_only/unfolded); a file name that is no profile is printed escaped."""
3311
+ users = libexec_module('users')
3312
+ if args == ['list']:
3313
+ profiles, problems = users.load_all()
3314
+ people = {profile['login']: {'profile': profile, 'devices': [], 'name': profile['name']}
3315
+ for profile in profiles}
3316
+ for device, login in users.device_logins().items():
3317
+ if login:
3318
+ person = people.setdefault(login, {'profile': None, 'devices': [], 'name': None})
3319
+ person['devices'].append(device)
3320
+ if not person['name']:
3321
+ person['name'] = (users.owner_of(device) or {}).get('name')
3322
+ for login, person in sorted(people.items()):
3323
+ profile = person['profile']
3324
+ if profile is None:
3325
+ state = 'no profile yet'
3326
+ else:
3327
+ by = f" by {profile['updated_by_device']}" if profile['updated_by_device'] else ''
3328
+ state = f"profile updated {shown_utc(profile['updated_at'])}{by}" if profile['updated_at'] else 'profile'
3329
+ print(f"{login:<32} {person['name'] or '-':<24} {', '.join(person['devices']) or '-':<32} {state}")
3330
+ if not people:
3331
+ print(f'no users: no profile in {users.users_dir()} and no device with an owner — '
3332
+ '`lanes device claim <device> --login <login>` gives a device one')
3333
+ for name, reason in problems:
3334
+ print(f'⚠ users/{shown(name)}: {reason} — not read and never overwritten (fix or remove the file)',
3335
+ file=sys.stderr)
3336
+ return 0
3337
+ rest = [word for word in args[1:] if word != '--json']
3338
+ if args[:1] != ['show'] or len(rest) != 1 or len(args) - len(rest) > 2:
3339
+ raise L.LanesError(USER_USAGE)
3340
+ login = rest[0]
3341
+ profile, problem = users.inspect(login) # refuses a login that can have no profile, never echoing it
3342
+ devices = [device for device, owner in users.device_logins().items() if owner == login]
3343
+ if profile is None and problem is None and not devices:
3344
+ raise L.LanesError(f'no user {login}: no profile and no device it owns (lanes user list)')
3345
+ looks = device_looks(users, devices)
3346
+ if '--json' in args:
3347
+ print(json.dumps({'login': login, 'devices': devices, 'device_looks': looks, 'profile': profile,
3348
+ 'problem': problem}, indent=2, ensure_ascii=False))
3349
+ return 1 if problem else 0
3350
+ name = profile['name'] if profile else next(
3351
+ (owner['name'] for owner in map(users.owner_of, devices) if owner and owner.get('name')), None)
3352
+ lines = [('login', login)] + ([('name', name)] if name else []) + [('devices', ', '.join(devices) or '-')]
3353
+ for device, look in looks.items():
3354
+ if look['unfolded']:
3355
+ lines.append(('device look', f"{device}: {look_words(look['unfolded'])} — from before the profile: "
3356
+ 'painted only where the profile sets none; '
3357
+ f'`lanes device claim {device} --login {login}` folds it in'))
3358
+ if look['device_only']:
3359
+ lines.append(('device look', f"{device}: {look_words(look['device_only'])} — --device-only: wins over "
3360
+ f'the profile on {device}; the same flag without --device-only hands it back'))
3361
+ if problem:
3362
+ lines.append(('profile', f'users/{users.stem(login)}.json: {problem} — not read and never overwritten (fix or '
3363
+ 'remove the file)'))
3364
+ elif profile is None:
3365
+ lines.append(('profile', f'none yet — `lanes device claim <device> --login {login}` or a look flag on '
3366
+ '`lanes attach` starts it'))
3367
+ else:
3368
+ lines += profile_lines(profile) + [('file', str(users.path(login)))]
3369
+ for label, value in lines:
3370
+ print(f'{label:<12} {value}')
3371
+ return 1 if problem else 0
3372
+
3373
+
3374
+ # ------------------------------------------------------------------ arrangement (cockpit v3 R6)
3375
+ # A person's boards and lanes are arranged once for every device they own: `lanes workspaces hide|show|order` and
3376
+ # `lanes lane pin|unpin|hide|show` write the `workspaces` and `lanes` lists of their profile users/<login>.json
3377
+ # (DM-LANES-USER-PROFILE) through users.update — its 1 s lock and merge, never a file this release does not read — and
3378
+ # `lanes here <device>` shows on that device the lane they opened last anywhere (`last_opened`). The person is the owner
3379
+ # of --device D (profile_for, as `lanes attach` derives it; else $LANES_DEVICE's) or --login L. Readers: the tabs of
3380
+ # every device of the person (workspace order and hidden boards, cockpit v3 R4) and their sidebars (pinned and hidden
3381
+ # lanes, R5). Nothing follows the person from device to device by itself: `lanes here` is asked for (v1).
3382
+
3383
+ ARRANGE_WORKSPACES = ('hide', 'show', 'order')
3384
+ LANE_VERBS = ('pin', 'unpin', 'hide', 'show')
3385
+ LANE_USAGE = 'usage: lanes lane pin|unpin|hide|show <lane-id> [--device D | --login L]'
3386
+ HERE_USAGE = 'usage: lanes here <device>'
3387
+
3388
+
3389
+ def target_args(args, usage):
3390
+ """The words of `args` and the person they arrange -> (words, device, login): `--device D` or `--login L`, at most
3391
+ one of them and each once, anywhere among the words; any other option is the usage."""
3392
+ words, options, rest = [], {}, list(args)
3393
+ while rest:
3394
+ word = rest.pop(0)
3395
+ if word in ('--device', '--login'):
3396
+ if not rest or options:
3397
+ raise L.LanesError(usage)
3398
+ options[word] = rest.pop(0)
3399
+ elif word.startswith('-'):
3400
+ raise L.LanesError(usage)
3401
+ else:
3402
+ words.append(word)
3403
+ return words, options.get('--device'), options.get('--login')
3404
+
3405
+
3406
+ def profile_target(users, device, login, usage):
3407
+ """The login whose profile an arranging verb writes. --login L: a person lanes knows (a profile, or a device they
3408
+ own — `lanes user list`). Else the device --device names, or $LANES_DEVICE: its owner (profile_for; a window such
3409
+ as mac-2 is its machine's owner's). The device only names the person: the write is the CLI's, stamped
3410
+ `cli:<command>` (arrange), never that device's — a verb run over SSH is nobody at the desk, and the push notifier
3411
+ must not mute that person's pushes for it (W9 review F3). Refused, never echoing an invalid value: no person
3412
+ named, a device nobody owns, an unknown login, or a profile file this release does not read (it is never
3413
+ overwritten)."""
3414
+ if login is None:
3415
+ device = device if device is not None else os.environ.get('LANES_DEVICE') or None
3416
+ if device is None:
3417
+ raise L.LanesError(f'{usage} — name the device (--device D, or LANES_DEVICE) or the person (--login L)')
3418
+ device = device_name(device)
3419
+ login = profile_for(device)
3420
+ if not login:
3421
+ raise L.LanesError(f'{device} has no owner, so it follows no profile: `lanes device claim {device} --login '
3422
+ '<login>` names one, or name the person with --login <login>')
3423
+ profile, problem = users.inspect(login) # refuses a login that can have no profile, never echoing it
3424
+ if problem:
3425
+ raise L.LanesError(f'users/{users.stem(login)}.json: {problem} — not read and never overwritten (fix or remove '
3426
+ 'the file); nothing changed')
3427
+ if device is None and profile is None and login not in users.device_logins().values():
3428
+ raise L.LanesError(f'no user {login}: no profile and no device it owns (lanes user list)')
3429
+ return login
3430
+
3431
+
3432
+ def arrange(users, login, command, change):
3433
+ """users.update(login, `cli:<command>`, change) -> 0, then the profile row it changed is printed by the caller; 1
3434
+ when the write was dropped (the lock stayed busy for users.LOCK_S, or the store cannot be written), said on
3435
+ stderr. A refusal `change` raises under the lock propagates, and nothing is written. The stamp is the CLI writer
3436
+ (users.cli_writer), never a device: see profile_target."""
3437
+ if users.update(login, users.cli_writer(command), change):
3438
+ return 0
3439
+ print(f"lanes: {login}'s profile was busy or cannot be written (`lanes user show {login}` says why); nothing "
3440
+ 'changed — run it again', file=sys.stderr)
3441
+ return 1
3442
+
3443
+
3444
+ def arranged(users, login, label, groups):
3445
+ """Print what an arranging verb did — `<words> for <login>: <ids>` per (words, ids) group with ids — and the
3446
+ profile's row `label` as `lanes user show` prints it."""
3447
+ for words, ids in groups:
3448
+ if ids:
3449
+ print(f"{words} for {login}: {' '.join(ids)}")
3450
+ profile = users.read(login) or users.empty(login)
3451
+ print(next(f'{name:<12} {value}' for name, value in profile_lines(profile) if name == label))
3452
+
3453
+
3454
+ def cmd_workspaces_profile(args):
3455
+ """`lanes workspaces hide|show|order <id…> [--device D | --login L]` (cockpit v3 R6, LNS-79): the person's board
3456
+ arrangement, on every device they own. hide adds boards to `workspaces.hidden` — refused when no board would stay
3457
+ visible (decided under the profile's lock); show takes ids off it, also one no board has any more (no registry is
3458
+ read); order REPLACES `workspaces.order` with the ids given (each once; the boards it leaves out follow in catalog
3459
+ order, as the reader places them). hide and order take the board ids `lanes workspaces` prints: an SSH workspace or
3460
+ a tool tab is the site's, not arranged per person. -> 0, 1 when the write was dropped (arrange)."""
3461
+ words, device, login = target_args(args, WORKSPACES_USAGE)
3462
+ verb, ids = words[0], words[1:]
3463
+ if not ids:
3464
+ raise L.LanesError(WORKSPACES_USAGE)
3465
+ users = libexec_module('users')
3466
+ for board in ids:
3467
+ users.check_workspace(board) # never echoes an invalid value
3468
+ if verb == 'order':
3469
+ twice = next((board for index, board in enumerate(ids) if board in ids[:index]), None)
3470
+ if twice:
3471
+ raise L.LanesError(f'{twice} is named twice; nothing changed')
3472
+ ids = list(dict.fromkeys(ids))
3473
+ boards = [] if verb == 'show' else [workspace['id'] for workspace in L.load_registry()['workspaces']]
3474
+ unknown = next((board for board in ids if verb != 'show' and board not in boards), None)
3475
+ if unknown:
3476
+ raise L.LanesError(f'no board {unknown} (lanes workspaces lists them); nothing changed')
3477
+ login = profile_target(users, device, login, WORKSPACES_USAGE)
3478
+ groups = []
3479
+
3480
+ def change(profile):
3481
+ hidden, order = profile['workspaces']['hidden'], profile['workspaces']['order']
3482
+ if verb == 'order':
3483
+ follow = '(the other boards follow in catalog order)'
3484
+ groups[:] = [('order', ids + [follow]) if order != ids else ('order was already', ids)]
3485
+ profile['workspaces']['order'] = list(ids)
3486
+ return
3487
+ if verb == 'hide':
3488
+ new = [board for board in ids if board not in hidden]
3489
+ if len(hidden) + len(new) > users.MAX_WORKSPACES:
3490
+ raise L.LanesError(f'a profile keeps at most {users.MAX_WORKSPACES} hidden boards; nothing changed')
3491
+ if not [board for board in boards if board not in hidden + new]:
3492
+ raise L.LanesError('that would hide every board: at least one stays visible (`lanes workspaces show '
3493
+ '<id>` shows one again); nothing changed')
3494
+ hidden.extend(new)
3495
+ groups[:] = [('hidden', new), ('already hidden', [board for board in ids if board not in new])]
3496
+ return
3497
+ shown = [board for board in ids if board in hidden]
3498
+ profile['workspaces']['hidden'] = [board for board in hidden if board not in shown]
3499
+ groups[:] = [('shown again', shown), ('not hidden', [board for board in ids if board not in shown])]
3500
+
3501
+ code = arrange(users, login, 'workspaces', change)
3502
+ if code == 0:
3503
+ arranged(users, login, 'workspaces', groups)
3504
+ return code
3505
+
3506
+
3507
+ def cmd_lane(args):
3508
+ """`lanes lane pin|unpin|hide|show <lane-id> [--device D | --login L]` (cockpit v3 R6, LNS-79): the person's
3509
+ `lanes.pins` / `lanes.hidden`, on every device they own. pin takes a lane the boards list (registered, or a live
3510
+ unregistered session); hide only a registered one — the sidebar always lists an unregistered session that runs, so
3511
+ hiding it would be a silent no-op; unpin and show take any lane id off the list, also one that left the boards. At
3512
+ most users.MAX_LANES per list (refused beyond, never truncated). -> 0, 1 when the write was dropped (arrange)."""
3513
+ words, device, login = target_args(args, LANE_USAGE)
3514
+ if len(words) != 2 or words[0] not in LANE_VERBS:
3515
+ raise L.LanesError(LANE_USAGE)
3516
+ verb, lane_id = words
3517
+ users = libexec_module('users')
3518
+ users.check_lane(lane_id) # never echoes an invalid value
3519
+ if verb in ('pin', 'hide'):
3520
+ registry = L.load_registry()
3521
+ L.find_lane(registry, lane_id) # Unknown lane: <id>
3522
+ registered = any(lane['id'] == lane_id for workspace in registry['workspaces'] for lane in workspace['lanes'])
3523
+ if verb == 'hide' and not registered:
3524
+ raise L.LanesError(f'{lane_id} is not registered in a workspace: an unregistered session that runs is always '
3525
+ f'listed — `lanes register {lane_id}` adds it to its workspace first; nothing changed')
3526
+ login = profile_target(users, device, login, LANE_USAGE)
3527
+ key = 'pins' if verb in ('pin', 'unpin') else 'hidden'
3528
+ adds = verb in ('pin', 'hide')
3529
+ said = {'pin': ('pinned', 'already pinned'), 'unpin': ('unpinned', 'not pinned'),
3530
+ 'hide': ('hidden', 'already hidden'), 'show': ('shown again', 'not hidden')}[verb]
3531
+ groups = []
3532
+
3533
+ def change(profile):
3534
+ listed = profile['lanes'][key]
3535
+ changed = (lane_id not in listed) if adds else (lane_id in listed)
3536
+ if changed and adds:
3537
+ if len(listed) >= users.MAX_LANES:
3538
+ raise L.LanesError(f"a profile keeps at most {users.MAX_LANES} {'pinned' if key == 'pins' else key} "
3539
+ f'lanes and {login}\'s has that many: {"unpin" if key == "pins" else "show"} one '
3540
+ 'first; nothing changed')
3541
+ listed.append(lane_id)
3542
+ elif changed:
3543
+ listed.remove(lane_id)
3544
+ groups[:] = [(said[0] if changed else said[1], [lane_id])]
3545
+
3546
+ code = arrange(users, login, 'lane', change)
3547
+ if code == 0:
3548
+ arranged(users, login, 'lanes', groups)
3549
+ return code
3550
+
3551
+
3552
+ def cmd_here(args):
3553
+ """`lanes here <device>` (cockpit v3 R6, LNS-79; `lanes-launch here` from a client): show on `device` the lane its
3554
+ owner opened last on any of their devices — the profile's `last_opened`, which a sidebar's Enter and `lanes
3555
+ next|prev` write when they open a lane with an agent — exactly as those do: its board's first viewport is pointed
3556
+ at it (lanes_lib.show_lane: the target file, SIGUSR1 to a running view.sh), and a lane with an agent is marked seen
3557
+ on the device and in the profile (mark_profile_seen: the mark and `last_opened` now name this device). When a view
3558
+ was woken, the board's tab is brought to the front (one bounded `zellij action go-to-tab-name`, as `lanes attach
3559
+ <workspace>` does). Says why it opens nothing (LanesError, exit 1): an unknown device, one nobody owns, no lane
3560
+ opened yet, a lane no board lists any more, or a board the person hides (cockpit v3 R4). Never follows by itself.
3561
+ -> 0."""
3562
+ if len(args) != 1 or args[0].startswith('-'):
3563
+ raise L.LanesError(HERE_USAGE)
3564
+ device = device_name(args[0])
3565
+ if not isinstance(L.read_json(L.STATE / 'devices' / f'{device}.json'), dict):
3566
+ raise L.LanesError(f'no device {device!r} is known (lanes device list)')
3567
+ login = profile_for(device)
3568
+ if not login:
3569
+ raise L.LanesError(f'{device} has no owner, so there is no profile to follow: `lanes device claim {device} '
3570
+ '--login <login>` names one')
3571
+ users = libexec_module('users')
3572
+ profile, problem = users.inspect(login)
3573
+ if problem:
3574
+ raise L.LanesError(f'users/{users.stem(login)}.json: {problem} — not read and never overwritten (fix or remove '
3575
+ 'the file)')
3576
+ last = (profile or {}).get('last_opened')
3577
+ if not last:
3578
+ raise L.LanesError(f'nothing to open: {login} has opened no lane with an agent yet (its profile records the last '
3579
+ 'one opened)')
3580
+ when = f"on {last['device']} at {shown_utc(last['at'])}"
3581
+ try:
3582
+ workspace, lane = L.find_lane(L.load_registry(), last['lane'])
3583
+ except L.LanesError:
3584
+ raise L.LanesError(f"{last['lane']}, the lane {login} opened last ({when}), is not listed any more; nothing to "
3585
+ 'open') from None
3586
+ if workspace['id'] in profile['workspaces']['hidden']:
3587
+ raise L.LanesError(f"{lane['name']} is on {workspace['title']}, which {login} hides on every device: `lanes "
3588
+ f"workspaces show {workspace['id']} --device {device}` shows it again")
3589
+ status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}
3590
+ agent = (status.get(lane['id']) or {}).get('agent') if isinstance(status, dict) else None
3591
+ if isinstance(agent, dict) and agent:
3592
+ seen = L.record_seen(L.STATE / 'clients' / f'{device}.seen.json', lane['id'], agent.get('since'), set(status))
3593
+ mark_profile_seen(device, lane['id'], seen[lane['id']], 'here')
3594
+ woken = L.show_lane(str(L.STATE / 'views' / device), workspace['id'], 'main', lane['id'])
3595
+ print(f"{device}: {lane['name']} ({workspace['title']}) — the lane {login} opened last, {when}")
3596
+ if lane['id'] in profile['lanes']['hidden']:
3597
+ print(f"{lane['name']} is hidden from your lists: `lanes lane show {lane['id']} --device {device}` lists it "
3598
+ 'again')
3599
+ if not woken:
3600
+ print(f"{device}'s view of {workspace['title']} is not running: it shows {lane['name']} when it starts")
3601
+ return 0
3602
+ rc, out, err = zellij(['-s', device, 'action', 'go-to-tab-name', workspace['title']], 10)
3603
+ if rc != 0:
3604
+ reason = 'no answer in 10 s' if rc is None else (err.strip() or out.strip() or f'exit {rc}')
3605
+ print(f"the {workspace['title']} tab was not brought to the front ({reason}); Ctrl-Alt-←/→ reaches it")
3606
+ return 0
2050
3607
 
2051
3608
 
2052
3609
  def cmd_invite(args):
@@ -2083,18 +3640,25 @@ def revoke_web_token(device, windows=()):
2083
3640
 
2084
3641
  def end_view(device):
2085
3642
  """Offboard step 4 -> (status, sentence): end the view session `device` the way `lanes rebuild` does (kill, then
2086
- delete the serialized session) without its reconnect stamp and without recreating it. No lane is touched."""
2087
- try:
2088
- live = live_sessions()
2089
- except L.LanesError as error:
2090
- return 'failed', f'whether the view session {device} runs is unknown ({error}); `lanes offboard {device} --yes` again'
2091
- if device not in live:
2092
- return 'none', f'no view session {device} is running'
2093
- rc = zellij(['kill-session', device], 30)[0]
2094
- if rc != 0:
2095
- return 'failed', (f'the view session {device} did not end ('
2096
- + ('no answer in 30 s' if rc is None else f'exit {rc}') + f'); `lanes offboard {device} --yes` again')
2097
- zellij(['delete-session', '--force', device], 30)
3643
+ delete the serialized session) without its reconnect stamp and without recreating it, under the view-session lock
3644
+ (DEF-1792). No lane is touched."""
3645
+ with contextlib.ExitStack() as stack:
3646
+ refused = hold_sessions(stack, SESSIONS_WAIT, f'lanes offboard {device}')
3647
+ if refused:
3648
+ return 'failed', f'the view session {device} was not ended ({refused}); `lanes offboard {device} --yes` again'
3649
+ try:
3650
+ live = live_sessions()
3651
+ except L.LanesError as error:
3652
+ return 'failed', (f'whether the view session {device} runs is unknown ({error}); '
3653
+ f'`lanes offboard {device} --yes` again')
3654
+ if device not in live:
3655
+ return 'none', f'no view session {device} is running'
3656
+ rc = zellij(['kill-session', device], 30)[0]
3657
+ if rc != 0:
3658
+ return 'failed', (f'the view session {device} did not end ('
3659
+ + ('no answer in 30 s' if rc is None else f'exit {rc}')
3660
+ + f'); `lanes offboard {device} --yes` again')
3661
+ zellij(['delete-session', '--force', device], 30)
2098
3662
  return 'done', f'ended view session {device} (no lane was touched)'
2099
3663
 
2100
3664
 
@@ -2232,14 +3796,118 @@ def socket_report(containers, live_by_container):
2232
3796
  return warnings
2233
3797
 
2234
3798
 
3799
+ DEVICE_USAGE = 'usage: lanes device list | lanes device forget <name> | lanes device claim <device> --login L [--name N]'
3800
+
3801
+
3802
+ def claim_args(rest):
3803
+ """[device, --login L (, --name N)] in any order of the two options, each once, --login required ->
3804
+ (device, login, name). Anything else is the usage."""
3805
+ if len(rest) not in (3, 5):
3806
+ raise L.LanesError(DEVICE_USAGE)
3807
+ options = {}
3808
+ for flag, value in zip(rest[1::2], rest[2::2]):
3809
+ if flag not in ('--login', '--name') or flag in options:
3810
+ raise L.LanesError(DEVICE_USAGE)
3811
+ options[flag] = value
3812
+ if '--login' not in options:
3813
+ raise L.LanesError(DEVICE_USAGE)
3814
+ return rest[0], options['--login'], options.get('--name')
3815
+
3816
+
3817
+ def deciding_member(members, device, meta):
3818
+ """(name, owner or None) of the member record that decides who owns `device` — members/<device>.json, else
3819
+ members/<base>.json (members.owner_for's order) — or None while neither exists. A record that exists but does not
3820
+ load is refused here (owner_for would keep the recorded owner beside it, but a claim must not be written while it
3821
+ is there)."""
3822
+ base = meta.get('base')
3823
+ for name in [device] + ([base] if isinstance(base, str) and base != device else []):
3824
+ if not L.IDENT.match(name) or len(name) > 32: # a corrupt base names no member file
3825
+ continue
3826
+ try:
3827
+ document = members.read(name)
3828
+ except L.LanesError as error:
3829
+ raise L.LanesError(f'{error} — while it exists it, not a claim, decides who owns {device}: fix or remove it '
3830
+ 'first; nothing changed') from None
3831
+ if document is not None:
3832
+ return name, members.valid_owner(document.get('owner'))
3833
+ return None
3834
+
3835
+
3836
+ def claimed_owner(before, owner, renamed):
3837
+ """The owner a claim records on a view whose valid recorded owner is `before` (or None): `owner` ({login, name,
3838
+ email: null}) for a view another login or nobody holds; for the login it records already, that record itself — its
3839
+ name and email kept — with only the name replaced when the claim gave --name (`renamed`). So a claim run again
3840
+ without --name, as fold_profile's repair hint words it, changes nothing that is done."""
3841
+ if before and before['login'] == owner['login']:
3842
+ return dict(before, name=owner['name']) if renamed else before
3843
+ return owner
3844
+
3845
+
3846
+ def owner_words(owner):
3847
+ """`login (name)` — or the login alone — of a validated owner, or `none`."""
3848
+ if not owner:
3849
+ return 'none'
3850
+ return owner['login'] + (f" ({owner['name']})" if owner['name'] else '')
3851
+
3852
+
3853
+ def cmd_device_claim(rest):
3854
+ """`lanes device claim <device> --login L [--name N]` (cockpit v3 R2, LNS-75): name the owner of a view no member
3855
+ record speaks for — a device from before enrolment (W8) — so it follows L's profile. Writes `owner` into
3856
+ devices/<device>.json, where device_owner keeps it across every rewrite (create_session, record_base) while no
3857
+ member record exists, and into each of its windows' files (views whose recorded base is the device) that have no
3858
+ member record of their own; then folds those views into L's profile (fold_profile). A member record decides
3859
+ ownership whenever one exists (DM-LANES-DEVICE-OWNER): a claim that disagrees with it is refused with the verb that
3860
+ changes it, one that agrees writes no owner and only folds. A view L already owns keeps its recorded name and email
3861
+ unless --name replaces the name (claimed_owner), so re-running a claim — with or without --name — changes nothing
3862
+ that is done and finishes a fold that was dropped -> 0, or 1 when the profile could not be written."""
3863
+ device, login, name = claim_args(rest)
3864
+ device = device_name(device)
3865
+ meta_file = L.STATE / 'devices' / f'{device}.json'
3866
+ meta = L.read_json(meta_file) if meta_file.exists() else None
3867
+ if not isinstance(meta, dict):
3868
+ raise L.LanesError(f'no device {device!r} is known (lanes device list): a claim names the owner of a view '
3869
+ 'lanes keeps')
3870
+ members = libexec_module('members')
3871
+ owner = members.validate_owner({'login': login, 'name': name})
3872
+ login = owner['login']
3873
+ decided = deciding_member(members, device, meta)
3874
+ if decided:
3875
+ record, current = decided
3876
+ if not current or current['login'] != login:
3877
+ who = current['login'] if current else 'nobody: a key added by hand'
3878
+ raise L.LanesError(f'members/{record}.json decides who owns {device} ({who}); a claim never overrides a '
3879
+ f'member record — `lanes keys owner {record} {login}` names its owner; nothing changed')
3880
+ print(f"{device} is already {login}'s: members/{record}.json names {login}")
3881
+ else:
3882
+ before = members.valid_owner(meta.get('owner'))
3883
+ after = claimed_owner(before, owner, name is not None)
3884
+ if before == after:
3885
+ print(f"{device} is already owned by {login} ("
3886
+ + (f"{before['name']}, " if before['name'] else '') + f'devices/{device}.json)')
3887
+ else:
3888
+ L.atomic_write(meta_file, json.dumps(dict(meta, owner=after)) + '\n')
3889
+ print(f'owner of {device}: {owner_words(before)} -> {owner_words(after)}')
3890
+ for window, window_meta in windows_of({device}).items():
3891
+ before = members.valid_owner(window_meta.get('owner'))
3892
+ after = claimed_owner(before, owner, name is not None)
3893
+ if (members.members_dir() / f'{window}.json').exists():
3894
+ print(f'kept {window} (a window of {device}): members/{window}.json decides who owns it')
3895
+ elif not decided and before != after:
3896
+ L.atomic_write(L.STATE / 'devices' / f'{window}.json', json.dumps(dict(window_meta, owner=after)) + '\n')
3897
+ print(f'owner of {window} (a window of {device}): {owner_words(before)} -> {owner_words(after)}')
3898
+ return fold_profile(login, [device])
3899
+
3900
+
2235
3901
  def cmd_device(args):
2236
- """lanes device list | lanes device forget <name>: the devices/<name>.json files `lanes ensure` (lanes-ensure.timer)
2237
- recreates a view session from, and `sync`/`upgrade-session` update. Forgetting removes that file only: a running
2238
- view session is never ended here. list prints the owner's login as the next rewrite records it (device_owner: the
2239
- member record's whenever one exists, so it agrees with `lanes keys list`; else the validated recorded one), blank
2240
- for none — a devices/*.json or members/*.json value never reaches the terminal unchecked."""
2241
- usage = 'usage: lanes device list | lanes device forget <name>'
3902
+ """lanes device list | forget <name> | claim <device> --login L [--name N]: the devices/<name>.json files `lanes
3903
+ ensure` (lanes-ensure.timer) recreates a view session from, and `sync`/`upgrade-session` update. Forgetting removes
3904
+ that file only: a running view session is never ended here. list prints the owner's login as the next rewrite
3905
+ records it (device_owner: the member record's whenever one exists, so it agrees with `lanes keys list`; else the
3906
+ validated recorded one), blank for none — a devices/*.json or members/*.json value never reaches the terminal
3907
+ unchecked. claim: cmd_device_claim."""
2242
3908
  devices = L.STATE / 'devices'
3909
+ if args[:1] == ['claim']:
3910
+ return cmd_device_claim(args[1:])
2243
3911
  if args == ['list']:
2244
3912
  try:
2245
3913
  live = live_sessions()
@@ -2257,17 +3925,23 @@ def cmd_device(args):
2257
3925
  + (f" base {base}" if base else ''))
2258
3926
  return 0
2259
3927
  if len(args) != 2 or args[0] != 'forget':
2260
- raise L.LanesError(usage)
3928
+ raise L.LanesError(DEVICE_USAGE)
2261
3929
  device = device_name(args[1])
2262
3930
  meta_file = devices / f'{device}.json'
2263
- if not meta_file.exists():
2264
- raise L.LanesError(f'no device {device!r} is known (lanes device list)')
2265
- meta = L.read_json(meta_file) or {}
2266
- try:
2267
- live = device in live_sessions()
2268
- except L.LanesError:
2269
- live = None
2270
- meta_file.unlink()
3931
+ with contextlib.ExitStack() as stack:
3932
+ # Under the view-session lock (DEF-1792): a `lanes ensure` pass that listed this device before the unlink and
3933
+ # recreated its view after it would write devices/<device>.json again (create_session), undoing the forget.
3934
+ refused = hold_sessions(stack, SESSIONS_WAIT, f'lanes device forget {device}')
3935
+ if refused:
3936
+ raise L.LanesError(f'{refused}: {device} was not forgotten')
3937
+ if not meta_file.exists():
3938
+ raise L.LanesError(f'no device {device!r} is known (lanes device list)')
3939
+ meta = L.read_json(meta_file) or {}
3940
+ try:
3941
+ live = device in live_sessions()
3942
+ except L.LanesError:
3943
+ live = None
3944
+ meta_file.unlink()
2271
3945
  kind = 'phone' if meta.get('mobile') else f"desktop, {int(meta.get('viewports') or 1)} viewports"
2272
3946
  print(f'forgot {device} ({kind}): `lanes ensure` (lanes-ensure.timer) no longer recreates its view session, and '
2273
3947
  '`lanes sync` / `upgrade-session` no longer update it')
@@ -2281,6 +3955,25 @@ def cmd_device(args):
2281
3955
  return 0
2282
3956
 
2283
3957
 
3958
+ def unit_properties(units):
3959
+ """One bounded `systemctl --user show` of `units` (Id, LoadState, ActiveState, UnitFileState, MainPID) ->
3960
+ ({unit: {property: value}}, None), or (None, why) when systemctl did not answer or failed. Read-only."""
3961
+ argv = ['systemctl', '--user', 'show', '-p', 'Id', '-p', 'LoadState', '-p', 'ActiveState', '-p', 'UnitFileState',
3962
+ '-p', 'MainPID', *units]
3963
+ rc, out, err = L.run_bounded(argv, 10, env=clean_env())
3964
+ if rc != 0:
3965
+ return None, 'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'
3966
+ found = {}
3967
+ for block in out.strip().split('\n\n'):
3968
+ props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
3969
+ found[props.get('Id', '')] = props
3970
+ return found, None
3971
+
3972
+
3973
+ def unit_enabled(props):
3974
+ return props.get('UnitFileState', '').startswith(('enabled', 'linked', 'alias'))
3975
+
3976
+
2284
3977
  def web_doctor():
2285
3978
  """`lanes doctor`'s browser/phone lines -> [(mark, text)], mark ✓ | ⚠ | ✗ (LNS-50). One bounded
2286
3979
  `systemctl --user show` for both web units, a read of the running lanes-web's environment, `tailscale status --json`
@@ -2294,20 +3987,14 @@ def web_doctor():
2294
3987
  if not shutil.which('systemctl'):
2295
3988
  lines.append(('⚠', 'web units not checked — no systemctl on this host (`lanes web` needs systemd user services)'))
2296
3989
  else:
2297
- argv = ['systemctl', '--user', 'show', '-p', 'Id', '-p', 'LoadState', '-p', 'ActiveState', '-p', 'UnitFileState',
2298
- '-p', 'MainPID', *WEB_UNITS]
2299
- rc, out, err = L.run_bounded(argv, 10, env=clean_env())
2300
- if rc != 0:
2301
- lines.append(('✗', f"web units NOT checked — systemctl --user {'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'}"))
3990
+ units, why = unit_properties(WEB_UNITS)
3991
+ if units is None:
3992
+ lines.append(('✗', f'web units NOT checked — systemctl --user {why}'))
2302
3993
  else:
2303
- units = {}
2304
- for block in out.strip().split('\n\n'):
2305
- props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
2306
- units[props.get('Id', '')] = props
2307
3994
  for unit in WEB_UNITS:
2308
3995
  props = units.get(unit, {})
2309
3996
  active = props.get('ActiveState') or 'unknown'
2310
- enabled = props.get('UnitFileState', '').startswith(('enabled', 'linked', 'alias'))
3997
+ enabled = unit_enabled(props)
2311
3998
  enabled_any = enabled_any or enabled
2312
3999
  if enabled and active == 'active':
2313
4000
  lines.append(('✓', f'{unit} active'))
@@ -2352,6 +4039,469 @@ def web_doctor():
2352
4039
  return lines
2353
4040
 
2354
4041
 
4042
+ # ------------------------------------------------------------------ doctor: the docs service (W10 W8, LNS-109)
4043
+ DOCS_UNIT = 'lanes-docsd.service'
4044
+ DOCS_PROBE_S = 2 # seconds the loopback connect to lanes-docsd may take
4045
+ ENABLE_DOCS = 'install/enable-docs.sh'
4046
+
4047
+
4048
+ def home_shown(path, home=None):
4049
+ """`path` as `~/…` under the home, else as it is (plain)."""
4050
+ try:
4051
+ return plain(f'~/{Path(path).relative_to(Path(home) if home is not None else Path.home())}')
4052
+ except (ValueError, KeyError, RuntimeError):
4053
+ return plain(path)
4054
+
4055
+
4056
+ def byte_size(count):
4057
+ """A size for a person: `812 B`, `3.4 KiB`, `17.0 MiB`, `1.2 GiB`."""
4058
+ if count < 1024:
4059
+ return f'{count} B'
4060
+ for unit in ('KiB', 'MiB', 'GiB'):
4061
+ count /= 1024
4062
+ if count < 1024 or unit == 'GiB':
4063
+ return f'{count:.1f} {unit}'
4064
+
4065
+
4066
+ def tree_bytes(root):
4067
+ """(regular files, their bytes) under the directory `root`, never through a link (a link at `root` itself counts
4068
+ nothing); (0, 0) when it does not exist. Unreadable entries are skipped."""
4069
+ if os.path.islink(root):
4070
+ return 0, 0
4071
+ files = total = 0
4072
+ stack = [os.fspath(root)]
4073
+ while stack:
4074
+ try:
4075
+ with os.scandir(stack.pop()) as entries:
4076
+ for entry in entries:
4077
+ try:
4078
+ if entry.is_dir(follow_symlinks=False):
4079
+ stack.append(entry.path)
4080
+ elif entry.is_file(follow_symlinks=False):
4081
+ files, total = files + 1, total + entry.stat(follow_symlinks=False).st_size
4082
+ except OSError:
4083
+ continue
4084
+ except OSError:
4085
+ continue
4086
+ return files, total
4087
+
4088
+
4089
+ def docs_port():
4090
+ """(port, None): the port lanes-docsd listens on — LANES_DOCS_PORT from its drop-in (docs_store.unit_environment,
4091
+ the files `lanes upload` reads too), else docsd.DEFAULT_PORT — or (None, why) for a setting that is not a port."""
4092
+ value = libexec_module('docs_store').unit_environment().get('LANES_DOCS_PORT') or ''
4093
+ if not value:
4094
+ return libexec_module('docsd').DEFAULT_PORT, None
4095
+ if value.isdigit() and 0 < int(value) <= 65535:
4096
+ return int(value), None
4097
+ return None, f'LANES_DOCS_PORT={plain(value)[:20]} in its drop-in is not a port number'
4098
+
4099
+
4100
+ def port_answers(port, timeout=DOCS_PROBE_S):
4101
+ """Whether 127.0.0.1:<port> accepts a TCP connection within `timeout` s. It connects and closes without sending a
4102
+ byte, so lanes-docsd answers nothing and writes no docs.log line (its handler reads an empty request line)."""
4103
+ import socket as sockets
4104
+ try:
4105
+ with sockets.create_connection(('127.0.0.1', port), timeout=timeout):
4106
+ return True
4107
+ except OSError:
4108
+ return False
4109
+
4110
+
4111
+ def docs_unit_line(probe):
4112
+ restart = f'journalctl --user -u {DOCS_UNIT}; systemctl --user restart {DOCS_UNIT}'
4113
+ if not shutil.which('systemctl'):
4114
+ return ('⚠', f'{DOCS_UNIT} not checked — no systemctl on this host')
4115
+ units, why = unit_properties([DOCS_UNIT])
4116
+ if units is None:
4117
+ return ('✗', f'{DOCS_UNIT} NOT checked — systemctl --user {why}')
4118
+ props = units.get(DOCS_UNIT, {})
4119
+ active, enabled = props.get('ActiveState') or 'unknown', unit_enabled(props)
4120
+ if active != 'active':
4121
+ if enabled:
4122
+ return ('✗', f'{DOCS_UNIT} enabled but {plain(active)} — {restart}')
4123
+ return ('⚠', f'{DOCS_UNIT} not enabled, so there is no docs page — the full server install enables it (an '
4124
+ f'update with --update-only enables nothing): systemctl --user enable --now {DOCS_UNIT}')
4125
+ port, why = docs_port()
4126
+ if port is None:
4127
+ return ('✗', f'{DOCS_UNIT} active, but {why} — fix it with systemctl --user edit {DOCS_UNIT}')
4128
+ where = f'127.0.0.1:{port}'
4129
+ if not probe(port):
4130
+ return ('✗', f'{DOCS_UNIT} active, but {where} does not answer — {restart}')
4131
+ if not enabled:
4132
+ return ('⚠', f'{DOCS_UNIT} active on {where} but not enabled: a reboot loses it — systemctl --user enable '
4133
+ f'{DOCS_UNIT}')
4134
+ return ('✓', f'{DOCS_UNIT} active, answering on {where}')
4135
+
4136
+
4137
+ def docs_pin_line():
4138
+ """The pin gate as docsd reads it on every request (enrold.read_mode): an operator gate, so never a ✗."""
4139
+ mode, problem = libexec_module('enrold').read_mode()
4140
+ if mode == 'request':
4141
+ return ('✓', 'docs: pinned — lanes-docsd answers members through Tailscale Serve (lanes enrol mode request)')
4142
+ said = problem or ('pinned invite only' if mode == 'invite' else 'unpinned: auto')
4143
+ return ('⚠', f'docs: refuses until the Serve header check is pinned ({plain(said)}): every request is 403 '
4144
+ 'not-pinned — run the check (RUNBOOK "Enrolment service", Operator verification), then lanes enrol '
4145
+ 'mode request (RUNBOOK "Docs service")')
4146
+
4147
+
4148
+ def docs_store_line(home=None):
4149
+ D = libexec_module('docs_store')
4150
+ counts = D.read_counts()
4151
+ documents = sum(entry['n'] for entry in counts.values())
4152
+ _files, size = tree_bytes(D.root())
4153
+ try:
4154
+ log = f'docs.log {byte_size(D.log_path().lstat().st_size)}'
4155
+ except FileNotFoundError:
4156
+ log = 'no docs.log yet'
4157
+ except OSError as error:
4158
+ log = f'docs.log not readable ({error.strerror or error})'
4159
+ return ('', f"docs store: {documents} document{'' if documents == 1 else 's'} in {len(counts)} "
4160
+ f"lane{'' if len(counts) == 1 else 's'} (counts.json), {byte_size(size)} under "
4161
+ f'{home_shown(D.root(), home)}; {log}')
4162
+
4163
+
4164
+ def docs_mermaid_line(home=None):
4165
+ """holders.lock's opt-in `mermaid` pin against the file install/enable-docs.sh installs: the member digest, read
4166
+ without following a link or blocking on a FIFO."""
4167
+ import hashlib
4168
+ SH = libexec_module('studio_host')
4169
+ script = f"sh {home_shown(L.CURRENT, home)}/{ENABLE_DOCS}"
4170
+ try:
4171
+ pin = SH.parse_lock((L.REPO / 'holders.lock').read_text()).get('mermaid')
4172
+ why = 'no mermaid line' if pin is None else '' if pin.opt_in else 'the mermaid line is not an opt-in pin'
4173
+ except (OSError, ValueError, SH.Refusal) as error:
4174
+ pin, why = None, str(error)
4175
+ if why:
4176
+ return ('✗', f'docs: Mermaid not checked — {L.REPO / "holders.lock"}: {plain(why)}')
4177
+ target = L.SHARE / 'vendor' / f'mermaid-{pin.version}.min.js'
4178
+ shown, version = home_shown(target, home), plain(pin.version)
4179
+ try:
4180
+ fd = os.open(target, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0))
4181
+ except FileNotFoundError:
4182
+ return ('⚠', f'docs: Mermaid {version} not installed ({shown}), so diagrams show as source — run {script} '
4183
+ '(every server install and update runs it)')
4184
+ except OSError as error:
4185
+ return ('✗', f'docs: Mermaid {version} refused — {shown} is not a regular file '
4186
+ f'({error.strerror or error}) — {script} replaces it from a verified download')
4187
+ try:
4188
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
4189
+ return ('✗', f'docs: Mermaid {version} refused — {shown} is not a regular file — {script} replaces it '
4190
+ 'from a verified download')
4191
+ digest = hashlib.sha256()
4192
+ for chunk in iter(lambda: os.read(fd, 1 << 20), b''):
4193
+ digest.update(chunk)
4194
+ finally:
4195
+ os.close(fd)
4196
+ actual = digest.hexdigest()
4197
+ if actual != pin.binary_sha256:
4198
+ return ('✗', f'docs: Mermaid {version} refused — {shown} is not the pinned build (sha256 {actual[:12]}…, '
4199
+ f'holders.lock {pin.binary_sha256[:12]}…): browsers refuse it by its integrity, so diagrams '
4200
+ f'show as source — {script} replaces it from a verified download')
4201
+ return ('✓', f'docs: Mermaid {version} pinned ({shown}, sha256 {actual[:12]}…)')
4202
+
4203
+
4204
+ def docs_age_line():
4205
+ """The store's encryption switch (docs_store.age_settings: this environment, else lanes-docsd's drop-in, as
4206
+ `lanes upload` reads it) and the `age` on this PATH. The recipient is never printed."""
4207
+ D = libexec_module('docs_store')
4208
+ binary = D.age_binary()
4209
+ has = f'age at {plain(binary)}' if binary else 'no age binary on PATH'
4210
+ try:
4211
+ recipient, identity = D.age_settings()
4212
+ except D.DocsError as error:
4213
+ return ('✗', f'docs: age encryption NOT usable — {error}: every upload and publish fails until it is fixed '
4214
+ f'({home_shown(D.dropin_dir())})')
4215
+ if recipient is None:
4216
+ return ('', f'docs: age encryption off (LANES_DOCS_AGE_RECIPIENT unset; {has})')
4217
+ if binary is None:
4218
+ return ('✗', 'docs: age encryption on (LANES_DOCS_AGE_RECIPIENT set) but there is no age binary on PATH: every '
4219
+ 'upload and publish fails (encrypt-failed) — install age or remove the setting (the server '
4220
+ 'install refuses it)')
4221
+ opens = ('lanes-docsd opens them with LANES_DOCS_AGE_IDENTITY' if identity else
4222
+ 'no LANES_DOCS_AGE_IDENTITY, so their downloads answer 409 encrypted')
4223
+ return ('✓', f'docs: age encryption on — new store copies are age output ({has}); {opens}')
4224
+
4225
+
4226
+ def docs_doctor(home=None, probe=None):
4227
+ """`lanes doctor`'s docs lines (W10 W8, LNS-109) -> [(mark, text)], mark ✓ | ✗ | ⚠ | '' (information), in this
4228
+ order; read-only (one bounded `systemctl --user show`, one loopback connect, file reads):
4229
+ lanes-docsd.service — enabled, active and its loopback port (docs_port) accepting a connection ✓ · enabled but not
4230
+ active, or active while its port refuses ✗ · active but not enabled ⚠ (a reboot loses it) · not enabled ⚠ (the
4231
+ full server install enables it; an update with --update-only enables nothing) · systemctl silent ✗ · no
4232
+ systemctl ⚠. `probe` (port -> bool) stands in for port_answers.
4233
+ the pin gate — `request` ✓ `docs: pinned` · else ⚠ `docs: refuses until the Serve header check is pinned`
4234
+ the store — information: documents and lanes from counts.json, the bytes under docs/, docs.log's size
4235
+ the Mermaid pin — the pinned member digest ✓ · absent ⚠ (run install/enable-docs.sh) · another digest or not a
4236
+ regular file ✗ (refused: browsers refuse it by its integrity) · no valid pin ✗
4237
+ the `age` switch — unset: information · set with an `age` on PATH ✓ · set without one, or no recipient ✗"""
4238
+ home = Path(home) if home is not None else Path.home()
4239
+ return [docs_unit_line(port_answers if probe is None else probe), docs_pin_line(), docs_store_line(home),
4240
+ docs_mermaid_line(home), docs_age_line()]
4241
+
4242
+
4243
+ # ------------------------------------------------------------------ doctor: the release that runs (W9 V3, LNS-85)
4244
+ # `lanes version` says which tree each process last started from; doctor judges it, with the reference `lanes version`
4245
+ # uses (the tree the `current` link names; this tree when there is none). A run record outlives its process, so only a
4246
+ # record whose process runs now is a fault (a stopped economy poller keeps its last one); a lanes process that runs
4247
+ # with NO record started from a release before the records (W9 U0) — what a server install that only `enable --now`s
4248
+ # statusd leaves running. Layouts and units are judged by the tree they resolve to; a layout that names the current tree
4249
+ # by another path than `lanes rebuild` writes now (a pinned pkg/<version> written before W9 U1, which writes through
4250
+ # `current`) is a warning: it runs the current tree, but the next update leaves its views behind.
4251
+
4252
+ # record_tree names by the script their process runs, in the argv Lanes starts it with (every release so far): the
4253
+ # units' `/usr/bin/python3 -S -E <tree>/libexec/<script> …` and the sidebar wrapper's `python3 -S -E
4254
+ # <tree>/libexec/sidebar.py …`. Anything else that names the file — an editor, a pager, a grep, the script run by
4255
+ # hand another way — and a one-shot that never records (RUN_ONESHOTS) is not that daemon (DEF-1785). studio_host
4256
+ # records a oneshot step (install/enable-studio-host.sh), never a running process.
4257
+ RUN_SCRIPTS = {'statusd': 'statusd.py', 'enrold': 'enrold.py', 'docsd': 'docsd.py', 'notify': 'notify.py',
4258
+ 'economy': 'economy_poll.py', 'sidebar': 'sidebar.py'}
4259
+ RUN_INTERPRETER = re.compile(r'python3(?:\.\d+)?') # argv[0]'s name, by path or not: python3, a versioned python3.N
4260
+ RUN_FLAGS = ['-S', '-E'] # the interpreter flags of every launch, in this order
4261
+ # The arguments that make a run a one-shot that never records, as each record_tree comment names them. statusd reads
4262
+ # its first argument itself (`sys.argv[1] == '--once'`); notify and economy_poll use argparse, which takes the option
4263
+ # anywhere and by any prefix it can tell apart (`--dry`; none of these prefixes another option of its script).
4264
+ RUN_ONESHOTS = {'statusd': ('--once',), 'notify': ('--test',), 'economy': ('--once', '--dry-run')}
4265
+ RUN_FIRST_ARGUMENT = {'statusd'}
4266
+ RUN_UNITS = {'statusd': 'lanes-statusd.service', 'enrold': 'lanes-enrold.service', 'docsd': 'lanes-docsd.service',
4267
+ 'notify': 'lanes-notify.service', 'economy': 'lanes-economy.service'} # what restarts each daemon; a sidebar restarts with its view
4268
+ LAYOUT_COMMAND = re.compile(r'command=("(?:[^"\\]|\\.)*")') # q(): a JSON string
4269
+ LAYOUT_FILES = L.LAYOUT_FILES # the tree files a generated layout runs
4270
+ UNIT_ENTRY = re.compile(r'"@REPO@/([^"]+)"') # a unit template's tree file (install/services.py)
4271
+ CURRENT_SHOWN = '~/' + L.CURRENT_LINK
4272
+
4273
+
4274
+ def run_name(argv):
4275
+ """The RUN_SCRIPTS name of the daemon a process with this `argv` is, or None. It is one only in the shape Lanes
4276
+ starts it: argv[0] a python3 interpreter, then exactly RUN_FLAGS, then an absolute `<tree>/libexec/<script>`, and
4277
+ no one-shot among the script's arguments, read as that script reads them."""
4278
+ if len(argv) < 4 or not RUN_INTERPRETER.fullmatch(os.path.basename(argv[0])) or argv[1:3] != RUN_FLAGS \
4279
+ or not os.path.isabs(argv[3]):
4280
+ return None
4281
+ name = next((name for name, script in RUN_SCRIPTS.items() if argv[3].endswith('/libexec/' + script)), None)
4282
+ args, oneshots = argv[4:], RUN_ONESHOTS.get(name, ())
4283
+ if name in RUN_FIRST_ARGUMENT:
4284
+ oneshot = bool(args) and args[0] in oneshots
4285
+ else:
4286
+ oneshot = any(len(word) > 2 and flag.startswith(word) for word in args for flag in oneshots)
4287
+ return None if oneshot else name
4288
+
4289
+
4290
+ def lanes_processes(table):
4291
+ """The RUN_SCRIPTS names of this account's daemons that run now, from a `ps -eo pid=,comm=,args=` table. A row that
4292
+ names a /libexec/<script> is only a candidate: its argv is read from /proc/<pid>/cmdline — the words as exec'd, a
4293
+ tree path with a space intact, where the table joins them with spaces — and judged by run_name, for a /proc/<pid>
4294
+ that is this account's (gone since the sweep: none)."""
4295
+ found = set()
4296
+ for line in table.splitlines():
4297
+ pid, _, rest = line.strip().partition(' ')
4298
+ if not pid.isdigit() or not any(f'/libexec/{script}' in rest for script in RUN_SCRIPTS.values()):
4299
+ continue
4300
+ try:
4301
+ if (PROC / pid).stat().st_uid != os.geteuid():
4302
+ continue
4303
+ cmdline = (PROC / pid / 'cmdline').read_bytes()
4304
+ except OSError:
4305
+ continue
4306
+ words = cmdline[:-1] if cmdline.endswith(b'\0') else cmdline # each word NUL-terminated
4307
+ name = run_name([os.fsdecode(word) for word in words.split(b'\0')])
4308
+ if name is not None:
4309
+ found.add(name)
4310
+ return found
4311
+
4312
+
4313
+ def current_doctor(report, home=None):
4314
+ """(mark, text): `current` names a tree that exists, whose VERSION agrees with its staged `<version>` directory name
4315
+ (an npm-staged tree, L.staged_tree: `pkg/<version>`, or `$LANES_PKG_ROOT/<version>` with the stamp) and its npm
4316
+ stamp, and that this lanes runs."""
4317
+ tree, current = os.path.realpath(report['tree']), report['current']
4318
+ if current is None:
4319
+ return ('⚠', f'no {CURRENT_SHOWN} link (the server installer writes it): layouts, units and run records are '
4320
+ f'compared with this tree, {tree_shown(tree, home)}')
4321
+ shown = tree_shown(current, home)
4322
+ if not os.path.isdir(current):
4323
+ return ('✗', f'current → {shown} does not exist — re-run the server installer')
4324
+ version = L.tree_version(current)
4325
+ if version is None:
4326
+ return ('✗', f'current → {shown} has no VERSION — re-run the server installer')
4327
+ named = Path(current).name if L.staged_tree(current) else version
4328
+ stamped = (L.tree_stamp(current) or {}).get('version') or version
4329
+ if (named, stamped) != (version, version):
4330
+ return ('✗', f'current → {shown}, but its VERSION says {plain(version)}'
4331
+ + (f' and its npm stamp {plain(stamped)}' if stamped != version else '')
4332
+ + ' — re-run the server installer')
4333
+ if current != tree:
4334
+ return ('✗', f'current → {shown} ({plain(version)}), but this lanes runs {tree_shown(tree, home)} '
4335
+ f'({plain(report["version"])}) — run {CURRENT_SHOWN}/bin/lanes; when ~/.local/bin/lanes is this '
4336
+ 'one, re-run the server installer')
4337
+ return ('✓', f'current → {shown} ({plain(version)}), the release this lanes runs')
4338
+
4339
+
4340
+ def records_doctor(report, table, reference, home=None):
4341
+ """[(mark, text)]: every lanes process that runs has a record of the current code. A record whose process is not
4342
+ running is information, never a fault; without a process table (`table` None) every daemon's counts."""
4343
+ running = report['running']
4344
+ live = lanes_processes(table) if table is not None else set(RUN_SCRIPTS)
4345
+ faults, stopped, restart = [], [], []
4346
+ for name, record in running.items():
4347
+ if record['current']:
4348
+ continue
4349
+ if name in live:
4350
+ faults.append(f'{plain(name)} {record_said(record, reference, home)}')
4351
+ restart.append(name)
4352
+ else:
4353
+ stopped.append(f'{plain(name)} {record_said(record, reference, home, past=True)}')
4354
+ if table is not None:
4355
+ for name in [name for name in RUN_ORDER if name in live and name not in running]:
4356
+ faults.append(f'{name} runs without a run record (started by an older release)')
4357
+ restart.append(name)
4358
+ lines = []
4359
+ if faults:
4360
+ units = [RUN_UNITS[name] for name in RUN_ORDER if name in restart and name in RUN_UNITS]
4361
+ hints = ([f"systemctl --user restart {' '.join(units)}"] if units else []) + \
4362
+ (['lanes rebuild <device>'] if 'sidebar' in restart else [])
4363
+ lines.append(('✗', 'processes run the current release — ' + '; '.join(faults)
4364
+ + ('' if table is not None else ' (the process table was not read: every older record counts)')
4365
+ + (' — ' + '; '.join(hints) if hints else '')))
4366
+ else:
4367
+ fresh = [plain(name) for name, record in running.items() if record['current']]
4368
+ lines.append(('✓', 'processes run the current release' + (': ' + ' '.join(fresh) if fresh else '')))
4369
+ if stopped:
4370
+ lines.append(('', 'not running now, so not judged (a record stays until its process starts again): '
4371
+ + '; '.join(stopped)))
4372
+ return lines
4373
+
4374
+
4375
+ def layout_commands(text):
4376
+ """The pane commands of a generated layout's text, in order, as q() wrote them (a JSON string after `command=`);
4377
+ one that does not decode is skipped."""
4378
+ commands = []
4379
+ for match in LAYOUT_COMMAND.finditer(text):
4380
+ try:
4381
+ commands.append(json.loads(match.group(1)))
4382
+ except ValueError:
4383
+ continue
4384
+ return commands
4385
+
4386
+
4387
+ def layout_trees(text):
4388
+ """The trees a generated layout runs: each pane command naming one of LAYOUT_FILES, without that file."""
4389
+ trees = []
4390
+ for command in layout_commands(text):
4391
+ for name in LAYOUT_FILES:
4392
+ if command.endswith('/' + name) and command[:-len(name) - 1] not in trees:
4393
+ trees.append(command[:-len(name) - 1])
4394
+ return trees
4395
+
4396
+
4397
+ def layout_tree_now():
4398
+ """The tree path `lanes rebuild` writes into a layout now, read from the generator itself: L.CURRENT (W9 U1) —
4399
+ the `current` link for a staged tree it names, else this tree's own path."""
4400
+ return layout_trees('\n'.join(workspace_tab_kdl('probe', {'id': 'probe', 'title': 'probe'})))[0]
4401
+
4402
+
4403
+ def layouts_doctor(reference, home=None):
4404
+ """(mark, text): each device's layout (devices/<device>.json with a layouts/<device>.kdl) runs the current tree
4405
+ (✗ with `lanes rebuild <device>`), through the path `lanes rebuild` writes now (⚠ when it names the current tree by
4406
+ another path). A layout that runs no tree file (or none at all) says nothing."""
4407
+ now, faults, pinned, fresh = layout_tree_now(), [], {}, []
4408
+ for meta in sorted((L.STATE / 'devices').glob('*.json')):
4409
+ device = plain(meta.stem)
4410
+ try:
4411
+ trees = layout_trees((L.STATE / 'layouts' / f'{meta.stem}.kdl').read_text())
4412
+ except (OSError, ValueError):
4413
+ continue
4414
+ if not trees:
4415
+ continue
4416
+ other = [tree for tree in trees if os.path.realpath(tree) != reference]
4417
+ if other:
4418
+ ran = [plain(L.tree_version(tree) or tree_shown(tree, home)) for tree in other]
4419
+ faults.append((device, f"{device} runs {', '.join(ran)}"))
4420
+ continue
4421
+ fresh.append(device)
4422
+ for tree in trees:
4423
+ if tree != now:
4424
+ pinned.setdefault(tree_shown(tree, home), []).append(device)
4425
+ if faults:
4426
+ return ('✗', 'view layouts run the current release — ' + '; '.join(said for _device, said in faults)
4427
+ + ' — ' + '; '.join(f'lanes rebuild {device}' for device, _said in faults))
4428
+ if pinned:
4429
+ devices = list(dict.fromkeys(device for named in pinned.values() for device in named))
4430
+ return ('⚠', '; '.join(f"view layouts of {' '.join(named)} name {shown} by its own path"
4431
+ for shown, named in pinned.items())
4432
+ + f', not as lanes rebuild writes them now ({tree_shown(now, home)}): the next update leaves those '
4433
+ 'views on it — ' + '; '.join(f'lanes rebuild {device}' for device in devices))
4434
+ return ('✓', 'view layouts run the current release' + (': ' + ' '.join(fresh) if fresh else ''))
4435
+
4436
+
4437
+ def unit_dirs(home=None):
4438
+ """Where install/services.py writes the units: the user manager's $XDG_CONFIG_HOME/systemd/user (this shell's
4439
+ XDG_CONFIG_HOME stands in for it: doctor never asks systemctl here) and ~/.config/systemd/user."""
4440
+ home = Path(home) if home is not None else Path.home()
4441
+ xdg = os.environ.get('XDG_CONFIG_HOME', '')
4442
+ folders = ([Path(xdg) / 'systemd/user'] if xdg.startswith('/') else []) + [home / '.config/systemd/user']
4443
+ return list(dict.fromkeys(folders))
4444
+
4445
+
4446
+ def unit_trees(home=None):
4447
+ """{unit: tree or None} for each lanes unit of this release whose template's ExecStart runs a tree file
4448
+ (`"@REPO@/<file>"`) and that is installed (the first of unit_dirs that has it): the tree its ExecStart names,
4449
+ unescaped as services.unit_value escaped it, or None when it names no `<tree>/<file>` (written by hand)."""
4450
+ found = {}
4451
+ for template in sorted((L.REPO / 'systemd').glob('lanes*.service')):
4452
+ entry = next((UNIT_ENTRY.search(line) for line in template.read_text().splitlines()
4453
+ if line.startswith('ExecStart=') and UNIT_ENTRY.search(line)), None)
4454
+ if entry is None:
4455
+ continue
4456
+ pattern = re.compile(r'"((?:[^"\\]|\\.)*)/' + re.escape(entry.group(1)) + '"')
4457
+ for folder in unit_dirs(home):
4458
+ try:
4459
+ text = (folder / template.name).read_text()
4460
+ except (OSError, ValueError):
4461
+ continue
4462
+ match = next((pattern.search(line) for line in text.splitlines()
4463
+ if line.startswith('ExecStart=') and pattern.search(line)), None)
4464
+ found[template.name] = (re.sub(r'\\(.)', r'\1', match.group(1).replace('$$', '$').replace('%%', '%'))
4465
+ if match else None)
4466
+ break
4467
+ return found
4468
+
4469
+
4470
+ def units_doctor(reference, home=None):
4471
+ """(mark, text): every installed lanes unit's ExecStart runs the current tree (✗: re-run the server installer)."""
4472
+ trees = unit_trees(home)
4473
+ if not trees:
4474
+ shown = ' or '.join(tree_shown(folder, home) for folder in unit_dirs(home))
4475
+ return ('⚠', f'lanes units not checked: none in {shown} (the server installer writes them)')
4476
+ other = {}
4477
+ for unit, tree in trees.items():
4478
+ if tree is None:
4479
+ other.setdefault(None, []).append(unit)
4480
+ elif os.path.realpath(tree) != reference:
4481
+ other.setdefault(plain(L.tree_version(tree) or tree_shown(tree, home)), []).append(unit)
4482
+ if not other:
4483
+ return ('✓', f'lanes units run the current release ({len(trees)} units)')
4484
+ said = []
4485
+ for ran, units in other.items():
4486
+ names, one = ' '.join(units[:3]) + (' …' if len(units) > 3 else ''), len(units) == 1
4487
+ said.append(f"{names} name{'s' if one else ''} no lanes tree" if ran is None else
4488
+ f"{len(units)} unit{'' if one else 's'} run{'s' if one else ''} {ran} ({names})")
4489
+ return ('✗', 'lanes units run the current release — ' + '; '.join(said) + ' — re-run the server installer')
4490
+
4491
+
4492
+ def release_doctor(table, home=None):
4493
+ """`lanes doctor`'s release lines -> [(mark, text)], mark ✓ | ✗ | ⚠ | '' (information) (W9 V3, LNS-85): `current`
4494
+ and its VERSION, the run records against the processes in `table` (`ps -eo pid=,comm=,args=`; None when ps did
4495
+ not answer), the device layouts, the units, then the updates (W9 U3: libexec/update.py doctor_line, with the unit
4496
+ folders it looks for lanes-update.timer's enable link in; no line without an installed release). Read-only: files
4497
+ and the one process table doctor already swept."""
4498
+ report = version_report(home=home)
4499
+ reference = report['current'] or os.path.realpath(report['tree'])
4500
+ updates = libexec_module('update').doctor_line(L.STATE, home, units=unit_dirs(home))
4501
+ return [current_doctor(report, home), *records_doctor(report, table, reference, home),
4502
+ layouts_doctor(reference, home), units_doctor(reference, home), *([updates] if updates else [])]
4503
+
4504
+
2355
4505
  def cmd_doctor(_args):
2356
4506
  problems = 0
2357
4507
 
@@ -2449,6 +4599,18 @@ def cmd_doctor(_args):
2449
4599
  print(' view sessions:', ', '.join(sorted(live_sessions())) or 'none')
2450
4600
  except (OSError, L.LanesError) as error:
2451
4601
  check(False, 'zellij reachable', str(error))
4602
+ # Cockpit v3 R2 (LNS-75): a device nobody owns follows no profile — its look and seen marks stay its own. Owner as
4603
+ # device_owner derives it (a member record, its machine's for a window, or a recorded claim); a warning, never a ✗.
4604
+ ownerless = []
4605
+ for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
4606
+ meta = L.read_json(meta_file)
4607
+ if (L.IDENT.match(meta_file.stem) and len(meta_file.stem) <= 32
4608
+ and not device_owner(meta_file.stem, meta if isinstance(meta, dict) else {})):
4609
+ ownerless.append(meta_file.stem)
4610
+ if ownerless:
4611
+ print(f'⚠ devices without an owner: {", ".join(ownerless[:8])}{" …" if len(ownerless) > 8 else ""} — each keeps '
4612
+ 'its look and seen marks to itself; `lanes device claim <device> --login <login>` names one (`lanes keys '
4613
+ 'owner <device> <login>` when members/ holds its key)')
2452
4614
  stale_pickers = stale_picker_layouts()
2453
4615
  check(not stale_pickers, 'phone picker panes report their own title (DEF-1531)',
2454
4616
  'built from a layout that names the picker pane, so the phone shows no counts; upgrade-session does not '
@@ -2458,6 +4620,19 @@ def cmd_doctor(_args):
2458
4620
  print(f'⚠ {text}')
2459
4621
  else:
2460
4622
  check(mark == '✓', text)
4623
+ # The docs service (W10 W8, LNS-109): lanes-docsd and its port, the pin gate, the store, the Mermaid pin, `age`.
4624
+ for mark, text in docs_doctor():
4625
+ if mark in ('⚠', ''):
4626
+ print(f'{mark or " "} {text}')
4627
+ else:
4628
+ check(mark == '✓', text)
4629
+ # The release that runs (W9 V3): `current`, the run records against the one process-table sweep above, the
4630
+ # device layouts, the units.
4631
+ for mark, text in release_doctor(table if rc == 0 else None):
4632
+ if mark in ('⚠', ''):
4633
+ print(f'{mark or " "} {text}')
4634
+ else:
4635
+ check(mark == '✓', text)
2461
4636
  print('PRESSURE')
2462
4637
  health = L.compute_health()
2463
4638
  print('\n'.join(pressure_lines(health, L.read_json(L.STATE / 'containers.json') or {})))
@@ -2472,7 +4647,11 @@ def cmd_rename(args):
2472
4647
  Rename a lane's display name.
2473
4648
  - Unregistered sessions: writes user-sessions metadata + updates resolve.dynamic.
2474
4649
  - Registered sessions (adopted): writes the record's name in registered-sessions.json and publishes.
2475
- - Repository lanes: writes a displayNames override to the workspace manifest and syncs the registry.
4650
+ - Repository lanes: writes a displayNames override to the workspace manifest and syncs the registry. Two layers
4651
+ name such a lane (LNS-33): this host override wins over the container's own `lanes.terminals[<name>]
4652
+ .displayName` in terminals.json, which rename never writes; renaming back to the lane's default (that
4653
+ container name when declared, else the terminal's title) removes the override. The id never changes. A
4654
+ container name another lane of the board answers to is not that lane's default: the reader drops it.
2476
4655
  """
2477
4656
  if len(args) < 2:
2478
4657
  raise L.LanesError('usage: lanes rename <lane-id> <new-name>')
@@ -2512,8 +4691,11 @@ def cmd_rename(args):
2512
4691
  if not lane.get('repo'):
2513
4692
  raise L.LanesError('No repository for this lane; set displayNames in the workspace manifest manually')
2514
4693
  registry_name = lane.get('registry_name') or lane.get('name', '')
2515
- SA.rename_registered(L.manifest_path(workspace_obj['id']), lane['repo'], registry_name, new_name)
2516
- cmd_sync([]) # rebuild registry so all consumers see the new name
4694
+ SA.rename_registered(L.manifest_path(workspace_obj['id']), lane['repo'], registry_name, new_name,
4695
+ declared_display=lane.get('declared_display'))
4696
+ _failed, refused = sync_registry() # rebuild registry so all consumers see the new name
4697
+ if refused: # saved and published, except where a container refused its own `~/.lz/registry.json` (DEF-1796)
4698
+ raise L.ProjectionRefused('Rename', refused)
2517
4699
  print(f"renamed registered lane {lane_id!r} ({registry_name!r}) → {new_name!r}")
2518
4700
  return 0
2519
4701
 
@@ -2555,11 +4737,9 @@ def preview_renderer():
2555
4737
  return 'less -R (no glow on the view server PATH; ./install.sh --server installs the pinned one)'
2556
4738
 
2557
4739
 
2558
- def focused_view(device):
2559
- """(view device, workspace, slot) behind the focused pane of `device`'s active tab. The floating preview pane holds
2560
- the focus while this runs, so the TILED view pane zellij marks focused decides; a focused sidebar (or none
2561
- marked) means the tab's first viewport. A tab without a board (Host, tools) is said, never guessed."""
2562
- import shlex
4740
+ def view_listings(device):
4741
+ """(tabs, panes): `zellij action list-tabs -j` and `list-panes -j -a` of `device`'s view session. Raises when either
4742
+ did not answer or is not JSON: an unknown listing is never an empty one."""
2563
4743
  listings = []
2564
4744
  for action in ('list-tabs', 'list-panes'):
2565
4745
  rc, out, err = zellij(['-s', device, 'action', action, '-j'] + (['-a'] if action == 'list-panes' else []), 10)
@@ -2569,10 +4749,26 @@ def focused_view(device):
2569
4749
  listings.append(json.loads(out))
2570
4750
  except ValueError:
2571
4751
  raise L.LanesError(f'zellij answered {action} for {device} with something that is not JSON')
2572
- tabs, panes = listings
2573
- active = next((t.get('tab_id') for t in tabs if isinstance(t, dict) and t.get('active')), None)
2574
- view_sh, sidebar = str(L.REPO / 'libexec/view.sh'), str(L.REPO / 'libexec/sidebar')
2575
- views, boards = [], []
4752
+ return listings[0], listings[1]
4753
+
4754
+
4755
+ def active_tab(tabs):
4756
+ """The tab_id list-tabs marks active, else None (a session no client is attached to marks none, measured)."""
4757
+ return next((t.get('tab_id') for t in tabs if isinstance(t, dict) and t.get('active')), None)
4758
+
4759
+
4760
+ def active_tab_panes(device, listings=None):
4761
+ """[(kind, (view device, workspace, slot), focused)] for the board panes of `device`'s active tab, TILED only (a
4762
+ floating pane — the Run pane asking — is never one): kind `view` (a viewport's view.sh; its slot) or `sidebar`
4763
+ (slot 'main'). Two bounded zellij listings (view_listings), or `listings` when the caller already holds them. While
4764
+ a floating pane holds the focus, zellij 0.45.1 still marks the tiled pane under it focused (measured 2026-10-06,
4765
+ cockpit v3 S4), which is what `focused` reports."""
4766
+ import shlex
4767
+ tabs, panes = listings or view_listings(device)
4768
+ active = active_tab(tabs)
4769
+ # By tree file, never by this CLI's tree path (W9 U1): the panes may run the pkg/<version> tree of a layout built
4770
+ # before, the `current` link of one built since, or a checkout (lanes_lib.tree_file_at).
4771
+ found = []
2576
4772
  for pane in panes:
2577
4773
  if not isinstance(pane, dict) or pane.get('is_plugin') or pane.get('is_floating') or pane.get('tab_id') != active:
2578
4774
  continue
@@ -2580,17 +4776,29 @@ def focused_view(device):
2580
4776
  args = shlex.split(pane.get('terminal_command') or pane.get('pane_command') or '')
2581
4777
  except ValueError:
2582
4778
  continue
2583
- for script in (view_sh, sidebar):
2584
- tail = args[args.index(script) + 1:] if script in args else []
4779
+ for script in ('libexec/view.sh', 'libexec/sidebar'):
4780
+ at = L.tree_file_at(args, script)
4781
+ tail = args[at + 1:] if at is not None else []
2585
4782
  if len(tail) < 2 or not all(L.IDENT.match(part) for part in tail[:2]):
2586
4783
  continue
2587
- if script == view_sh:
4784
+ if script == 'libexec/view.sh':
2588
4785
  slot = tail[2] if len(tail) > 2 and tail[2] in SLOTS else 'main'
2589
- views.append((not pane.get('is_focused'), SLOTS.index(slot), (tail[0], tail[1], slot)))
4786
+ found.append(('view', (tail[0], tail[1], slot), bool(pane.get('is_focused'))))
2590
4787
  else:
2591
- boards.append((tail[0], tail[1], 'main'))
4788
+ found.append(('sidebar', (tail[0], tail[1], 'main'), bool(pane.get('is_focused'))))
4789
+ return found
4790
+
4791
+
4792
+ def focused_view(device, listings=None):
4793
+ """(view device, workspace, slot) behind the focused pane of `device`'s active tab. The floating preview pane holds
4794
+ the focus while this runs, so the TILED view pane zellij marks focused decides; a focused sidebar (or none
4795
+ marked) means the tab's first viewport. A tab without a board (Host, tools) is said, never guessed. `listings`:
4796
+ view_listings(device) when the caller already holds them (`lanes edit` reads them once, for its guard first)."""
4797
+ panes = active_tab_panes(device, listings)
4798
+ views = [(not focused, SLOTS.index(where[2]), where) for kind, where, focused in panes if kind == 'view']
2592
4799
  if views:
2593
4800
  return min(views)[2]
4801
+ boards = [where for kind, where, _focused in panes if kind == 'sidebar']
2594
4802
  if boards:
2595
4803
  return boards[0]
2596
4804
  raise L.LanesError('the focused tab shows no board (Host or a tool tab): nothing to preview')
@@ -2707,7 +4915,8 @@ def cmd_preview(args):
2707
4915
  # closes when it exits. They used to walk the focus left three times and type J/K into whatever pane that reached
2708
4916
  # (the sidebar, or — on a tab without one — the lane itself). Now nothing is typed and no focus moves: the viewport
2709
4917
  # zellij marks focused is pointed at the neighbouring lane exactly as the sidebar's open_lane points it
2710
- # (lanes_lib.show_lane), and a lane with an agent is marked seen on the device as open_lane marks it.
4918
+ # (lanes_lib.show_lane), and a lane with an agent is marked seen on the device as open_lane marks it — and, on a device
4919
+ # with an owner, in that person's profile too (cockpit v3 R3, mark_profile_seen).
2711
4920
 
2712
4921
 
2713
4922
  def step_lane(args, step):
@@ -2738,12 +4947,34 @@ def step_lane(args, step):
2738
4947
  status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}
2739
4948
  agent = (status.get(lane_id) or {}).get('agent') if isinstance(status, dict) else None
2740
4949
  if isinstance(agent, dict) and agent:
2741
- L.record_seen(L.STATE / 'clients' / f'{view_device}.seen.json', lane_id, agent.get('since'), set(status))
4950
+ seen = L.record_seen(L.STATE / 'clients' / f'{view_device}.seen.json', lane_id, agent.get('since'), set(status))
4951
+ mark_profile_seen(view_device, lane_id, seen[lane_id], verb)
2742
4952
  if not L.show_lane(str(L.STATE / 'views' / view_device), workspace, slot, lane_id):
2743
4953
  print('view not running yet')
2744
4954
  return 0
2745
4955
 
2746
4956
 
4957
+ def mark_profile_seen(device, lane_id, at, verb):
4958
+ """`lanes next|prev` opened a lane with an agent on `device` (cockpit v3 R3, LNS-76): on a device with an owner the
4959
+ mark and `last_opened` {lane, device, at} also go to that person's profile (users.update as the device, its 1 s
4960
+ lock), as the sidebar's mark_seen writes them, so their other devices leave Done on the next tick. The device's own
4961
+ seen file was already written. A dropped write (a busy or unreadable profile) is said on stderr and changes nothing
4962
+ else: the lane is still shown, and seen on this device. -> False only for that drop."""
4963
+ login = profile_for(device)
4964
+ if not login:
4965
+ return True
4966
+ users = libexec_module('users')
4967
+
4968
+ def mark(profile):
4969
+ profile['seen'][lane_id] = max(at, profile['seen'].get(lane_id, at))
4970
+ profile['last_opened'] = {'lane': lane_id, 'device': device, 'at': at}
4971
+ if users.update(login, device, mark):
4972
+ return True
4973
+ print(f"lanes {verb}: {login}'s profile was busy or cannot be written (`lanes user show {login}` says why) — "
4974
+ f'{lane_id} is seen on {device} only', file=sys.stderr)
4975
+ return False
4976
+
4977
+
2747
4978
  def cmd_next(args):
2748
4979
  return step_lane(args, +1)
2749
4980
 
@@ -2752,13 +4983,333 @@ def cmd_prev(args):
2752
4983
  return step_lane(args, -1)
2753
4984
 
2754
4985
 
2755
- COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_client_config, 'cursor-commit': cmd_cursor_commit, 'upgrade-session': cmd_upgrade_session, 'upload-requests': cmd_upload_requests, 'upload-result': cmd_upload_result, 'upload': cmd_upload, 'workspaces': cmd_workspaces, 'cursor-sync': cmd_cursor_sync, 'reattach': cmd_reattach, 'gc': cmd_gc, 'install-container': cmd_install_container, 'open': cmd_open, 'sync': cmd_sync, 'ls': cmd_ls, 'resolve': cmd_resolve, 'status': cmd_status, 'layout': cmd_layout,
4986
+ # ------------------------------------------------------------------ sidebar-key
4987
+ # Ctrl-Alt-/ and Ctrl-Alt-u (zellij/config.kdl, cockpit v3 S4) walk the focus left to the sidebar, then run
4988
+ # `lanes sidebar-key filter|upload --focused` in a floating pane that closes when it exits. They used to end in
4989
+ # WriteChars "/" / "U", which typed into whatever pane held the focus by then — on a tab without a sidebar (Host,
4990
+ # Throttle, the phone's view tab) the pane itself, a shell or an agent's input. Now nothing is typed: the key is a
4991
+ # file, views/<device>/<ws>.sidebar.cmd (DM-LANES-SIDEBAR-CMD, lanes_lib.send_sidebar_key), written only for a sidebar
4992
+ # on the active tab that holds the focus; that sidebar acts on it within a tick and before the next key it reads.
4993
+
4994
+
4995
+ def cmd_sidebar_key(args):
4996
+ key = args[0] if args else ''
4997
+ if key not in L.SIDEBAR_KEYS or '--focused' not in args[1:]:
4998
+ print('usage: lanes sidebar-key filter|upload --focused [--device D] (Ctrl-Alt-/ and Ctrl-Alt-u run this)')
4999
+ return 2
5000
+ device = args[args.index('--device') + 1] if '--device' in args else os.environ.get('ZELLIJ_SESSION_NAME', '')
5001
+ try:
5002
+ device = device_name(device)
5003
+ sidebars = [(where, focused) for kind, where, focused in active_tab_panes(device) if kind == 'sidebar']
5004
+ if not sidebars:
5005
+ raise L.LanesError('this tab has no sidebar (Host or a tool tab): nothing to do')
5006
+ (view_device, workspace, _slot), focused = sidebars[0]
5007
+ if not focused:
5008
+ raise L.LanesError('the sidebar does not have the focus (it was in a floating pane): nothing to do')
5009
+ except L.LanesError as error:
5010
+ print(f'lanes sidebar-key: {error}')
5011
+ return 1
5012
+ L.send_sidebar_key(L.STATE / 'views' / view_device, workspace, key)
5013
+ return 0
5014
+
5015
+
5016
+ # ------------------------------------------------------------------ edit
5017
+ # Ctrl-Alt-e (zellij/config.kdl, W9 E1, LNS-95) runs `lanes edit --focused` in a floating pane named EDIT_PANE. Zellij
5018
+ # has no single-instance Run (LaunchOrFocus is for plugins only) and catches Ctrl-Alt-e in every unlocked mode,
5019
+ # whichever pane has the focus (the open editor's included), so each press opens one more pane. Two editors on one
5020
+ # file lose work: the one quit last writes its stale buffer over the other's save, and only vim and nvim warn (swap
5021
+ # files). So the verb is its own guard, before it resolves anything: one Edit pane per view session (DEF-1793).
5022
+
5023
+ EDIT_PANE = 'Edit' # the binding's `name`; test_server_cli EditWiring pins the two to one value
5024
+
5025
+
5026
+ def edit_pane(pane):
5027
+ """Whether a list-panes entry is an Edit pane: a live terminal pane zellij started as `lanes edit` (it keeps the
5028
+ command it started, renamed or embedded since), or a floating one named EDIT_PANE (zellij keeps a given name over
5029
+ the title its program sets: measured 0.45.1, an OSC 2 title left `title` "Edit"). A TILED pane titled Edit is
5030
+ not one: a pane without a given name shows whatever title its program set."""
5031
+ if not isinstance(pane, dict) or pane.get('is_plugin') or pane.get('exited'):
5032
+ return False
5033
+ words = str(pane.get('terminal_command') or '').split() # zellij joins the Run argv with spaces (measured)
5034
+ if len(words) >= 2 and Path(words[0]).name == 'lanes' and words[1] == 'edit':
5035
+ return True
5036
+ return bool(pane.get('is_floating')) and pane.get('title') == EDIT_PANE
5037
+
5038
+
5039
+ def edit_pane_open(device, tabs, panes):
5040
+ """The Edit pane already open in `device`'s view session that this process does not run in, or None; one in the
5041
+ active tab first. This process's own pane is ZELLIJ_PANE_ID (zellij gives each pane it starts its bare terminal
5042
+ id, measured 0.45.1) when it runs in that session; run by hand, or with --device from another session, every
5043
+ Edit pane counts. Every other one counts whichever is older: two presses at once both stand down (press again)
5044
+ rather than both open an editor."""
5045
+ own = os.environ.get('ZELLIJ_PANE_ID', '') if os.environ.get('ZELLIJ_SESSION_NAME') == device else ''
5046
+ own = own.removeprefix('terminal_')
5047
+ others = [pane for pane in (panes if isinstance(panes, list) else [])
5048
+ if edit_pane(pane) and (not own or str(pane.get('id')) != own)]
5049
+ active = active_tab(tabs)
5050
+ others.sort(key=lambda pane: active is None or pane.get('tab_id') != active)
5051
+ return others[0] if others else None
5052
+
5053
+
5054
+ def edit_defer(device, tabs, pane):
5055
+ """Hand this press to the Edit pane already open. In the active tab it gets the focus and this pane closes (exit
5056
+ 0, nothing said); in another tab, or when zellij will not focus it, it is named and the pane waits for Enter
5057
+ (exit 1). Either way nothing opens."""
5058
+ active = active_tab(tabs)
5059
+ if active is not None and pane.get('tab_id') == active:
5060
+ rc, _out, err = zellij(['-s', device, 'action', 'focus-pane-id', f"terminal_{pane.get('id')}"], 5)
5061
+ if rc == 0:
5062
+ return 0
5063
+ where = f'in this tab, and zellij did not focus it ({err.strip() or "no answer"})'
5064
+ else:
5065
+ name = next((t.get('name') for t in tabs if isinstance(t, dict) and t.get('tab_id') == pane.get('tab_id')), '')
5066
+ where = f'in tab "{name}"' if name else 'in another tab'
5067
+ print(f'An Edit pane is already open {where}. One per view: quit that editor, then press Ctrl-Alt-e again.')
5068
+ preview_hold()
5069
+ return 1
5070
+
5071
+
5072
+ # Past the guard (W9 E2, LNS-96): the focused lane as `lanes preview` finds it (tmux lanes; the container by the
5073
+ # registry's name and the init pid statusd recorded in containers.json: no docker inspect), the git checkout its
5074
+ # working directory is in (lanes_lib.repo_roots, through /proc/<init>/root), the files below offered in a one-key
5075
+ # picker painted with the sidebar's tokens, and the editor started INSIDE the container as uid 1000 (`coder`) by
5076
+ # execvp, argv only: `docker exec -it -u coder -w <checkout> ... <container> ~/.lz/bin/lane-edit <path>` (E3, LNS-97).
5077
+ # No file is created. `.env.local` is named by its label, never by its path, in anything printed.
5078
+
5079
+ EDIT_FILES = ('AGENTS.md', 'CLAUDE.md') # the checkout's own pair, then the main checkout's when it is another one
5080
+ EDIT_ENV_FILE = '.env.local' # the main checkout's only
5081
+ EDIT_MAIN = ' (main checkout)' # the label suffix of a main-checkout file when the lane is in a worktree
5082
+ LANE_EDIT = L.CONTAINER_HOME + '/.lz/bin/lane-edit' # where cmd_install_container copies container/lane-edit
5083
+ EDIT_QUIT = (b'', b'q', b'Q', b'\x1b', b'\x03', b'\x04') # end of input, q, Esc, Ctrl-C, Ctrl-D (raw mode)
5084
+
5085
+
5086
+ def edit_candidate(root, checkout, name, label):
5087
+ """{label, root, container_path, present} for the file `name` of the container checkout `checkout`. Present only
5088
+ as a regular file that is no symbolic link, inside the checkout once both are resolved in the container
5089
+ (lanes_lib.container_realpath), on a path without control characters. Any error reads as not present and is never
5090
+ printed (it would carry the path)."""
5091
+ path, base = posixpath.join(checkout, name), checkout
5092
+ present = False
5093
+ if not L.CONTROL_CHARS.search(path):
5094
+ try:
5095
+ base = L.container_realpath(root, checkout)
5096
+ path = posixpath.join(L.container_realpath(root, posixpath.dirname(path)), name)
5097
+ except L.LanesError:
5098
+ base = None
5099
+ found = L.container_lstat(root, path) if base else None
5100
+ present = bool(found and stat.S_ISREG(found.st_mode) and path.startswith(base.rstrip('/') + '/'))
5101
+ return {'label': label, 'root': base or checkout, 'container_path': path, 'present': present}
5102
+
5103
+
5104
+ def edit_candidates(roots, root):
5105
+ """The files quick edit offers for (worktree root, main checkout), in picker order: AGENTS.md and CLAUDE.md of the
5106
+ worktree, the main checkout's pair when it is another folder, then the main checkout's .env.local."""
5107
+ worktree, main = roots
5108
+ other = main != worktree
5109
+ rows = [(worktree, name, name) for name in EDIT_FILES]
5110
+ rows += [(main, name, name + EDIT_MAIN) for name in EDIT_FILES] if other else []
5111
+ rows.append((main, EDIT_ENV_FILE, EDIT_ENV_FILE + (EDIT_MAIN if other else '')))
5112
+ return [edit_candidate(root, checkout, name, label) for checkout, name, label in rows]
5113
+
5114
+
5115
+ def edit_roots(lane, board, root):
5116
+ """repo_roots of the lane's working directory (statusd's pane cwd), else of its repository folder, else of its
5117
+ board's cwd: an agent that cd'd out of the checkout still edits its lane's files. The first error when none is in
5118
+ a checkout."""
5119
+ status = ((L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}).get(lane['id']) or {}
5120
+ tried, first = [], None
5121
+ for cwd in (status.get('cwd'), lane.get('folder'), board.get('cwd')):
5122
+ if not cwd or cwd in tried:
5123
+ continue
5124
+ tried.append(cwd)
5125
+ try:
5126
+ return L.repo_roots(cwd, root)
5127
+ except L.LanesError as error:
5128
+ first = first or error
5129
+ raise first or L.LanesError('the lane has no usable working directory')
5130
+
5131
+
5132
+ def edit_look(device):
5133
+ """(SGR table, glyph set) the picker paints with: the device's sidebar look — sidebar.resolve_modes over
5134
+ devices/<device>.json and this pane's environment, then tokens.sgr_table / glyph_set — in the variant its theme
5135
+ names, else the one its sidebar published (clients/<device>.theme), else dark."""
5136
+ tokens = libexec_module('tokens')
5137
+ meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
5138
+ color, theme, glyphs = libexec_module('sidebar').resolve_modes(os.environ, meta if isinstance(meta, dict) else {})
5139
+ if theme not in ('dark', 'light'):
5140
+ try:
5141
+ theme = (L.STATE / 'clients' / f'{device}.theme').read_text().strip()
5142
+ except OSError:
5143
+ theme = ''
5144
+ return tokens.sgr_table(color, theme if theme in ('dark', 'light') else 'dark'), tokens.glyph_set(glyphs)
5145
+
5146
+
5147
+ def edit_picker(name, roots, candidates, tone, glyphs):
5148
+ """The picker: the lane and its checkout(s), one numbered row per candidate (a missing one dim, `not present`, its
5149
+ number never taken), and the keys. Nothing present: the rows and `Nothing to edit in …` instead of the keys."""
5150
+ reset, dim, sep = tone['reset'], tone['ink-dim'], f" {glyphs['marks']['sep']} "
5151
+ worktree, main = roots
5152
+ lines = [f"{tone['ink']}{tone['bold']}Edit{sep}{plain(name)}{reset}", f'{dim} {plain(worktree)}{reset}']
5153
+ if main != worktree:
5154
+ lines.append(f'{dim} main checkout {plain(main)}{reset}')
5155
+ lines.append('')
5156
+ for number, row in enumerate(candidates, 1):
5157
+ if row['present']:
5158
+ lines.append(f" {tone['accent']}{number}{reset} {tone['ink']}{plain(row['label'])}{reset}")
5159
+ else:
5160
+ lines.append(f"{dim} {number} {plain(row['label'])} not present{reset}")
5161
+ numbers = [str(number) for number, row in enumerate(candidates, 1) if row['present']]
5162
+ lines.append('')
5163
+ if not numbers:
5164
+ lines.append(f'{dim}Nothing to edit in {plain(worktree)}: lanes edit creates no file{reset}')
5165
+ else:
5166
+ keys = f"{numbers[0]} or {glyphs['marks']['enter']}" if len(numbers) == 1 else ' '.join(numbers)
5167
+ lines.append(f'{dim}{keys} edit{sep}q or Esc close{reset}')
5168
+ return '\n'.join(lines) + '\n'
5169
+
5170
+
5171
+ @contextlib.contextmanager
5172
+ def key_reader(stream=None):
5173
+ """A read() of one key byte (b'' at the end of input). A terminal takes raw input for the duration (one byte, no
5174
+ echo, no Enter needed, Ctrl-C a byte) and is restored after; anything else (a pipe, a test's StringIO) is read as it
5175
+ is. The switch is TCSANOW, never tty.setraw's TCSAFLUSH: a key already typed (while the pane started, or while the
5176
+ picker painted) is the first key read, not discarded (DEF-1836). Output processing stays on (setraw clears OPOST),
5177
+ so the caller paints the picker inside the block: once it shows, the terminal is raw."""
5178
+ stream = stream or sys.stdin
5179
+ try:
5180
+ fd = stream.fileno()
5181
+ terminal = os.isatty(fd)
5182
+ except (AttributeError, OSError, ValueError):
5183
+ terminal = False
5184
+ if not terminal:
5185
+ def read():
5186
+ key = stream.read(1)
5187
+ return key.encode() if isinstance(key, str) else (key or b'')
5188
+ yield read
5189
+ return
5190
+ import termios
5191
+ saved = termios.tcgetattr(fd)
5192
+ raw = termios.tcgetattr(fd) # tty.setraw's input, control and local flags; its oflag and `when` are not taken
5193
+ raw[0] &= ~(termios.BRKINT | termios.ICRNL | termios.INPCK | termios.ISTRIP | termios.IXON)
5194
+ raw[2] = raw[2] & ~(termios.CSIZE | termios.PARENB) | termios.CS8
5195
+ raw[3] &= ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG)
5196
+ raw[6][termios.VMIN], raw[6][termios.VTIME] = 1, 0
5197
+ try:
5198
+ termios.tcsetattr(fd, termios.TCSANOW, raw)
5199
+ yield lambda: os.read(fd, 1)
5200
+ finally:
5201
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
5202
+
5203
+
5204
+ def pick_key(candidates, read):
5205
+ """The index of the candidate one key picks, or None to close. `1`…`5` take that row when it is present; Enter takes
5206
+ the only present one (and nothing when several are); q, Esc, Ctrl-C, Ctrl-D and the end of input close; every
5207
+ other key (a missing row's number included) is ignored."""
5208
+ present = [index for index, row in enumerate(candidates) if row.get('present')]
5209
+ while True:
5210
+ key = read()
5211
+ if key in EDIT_QUIT:
5212
+ return None
5213
+ if key in (b'\r', b'\n') and len(present) == 1:
5214
+ return present[0]
5215
+ if len(key) == 1 and key.isdigit() and int(key) - 1 in present:
5216
+ return int(key) - 1
5217
+
5218
+
5219
+ def edit_editor(device):
5220
+ """The LANES_EDITOR lane-edit gets: the device's `lanes attach --editor` choice, else this process's LANES_EDITOR
5221
+ (without control characters), else empty (lane-edit then takes the first of micro hx nvim vim vi). The Ctrl-Alt-e
5222
+ pane runs with the view server's environment, which clean_env builds without LANES_EDITOR: there the device's
5223
+ choice is the one that counts; the variable serves a `lanes edit` run by hand."""
5224
+ meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
5225
+ chosen = meta.get('editor') if isinstance(meta, dict) else None
5226
+ if chosen in LOOK_KEYS['--editor'][1] and chosen != 'auto':
5227
+ return chosen
5228
+ value = os.environ.get('LANES_EDITOR', '')
5229
+ return '' if L.CONTROL_CHARS.search(value) else value
5230
+
5231
+
5232
+ def edit_argv(container, root, path, editor, label):
5233
+ """`docker exec` of lane-edit on `path` in `container`, as uid 1000 in `root`, argv only (no shell)."""
5234
+ return ['docker', 'exec', '-it', '-u', 'coder', '-w', root, '-e', 'TERM=xterm-256color', '-e', 'COLORTERM=truecolor',
5235
+ '-e', 'LANG=C.UTF-8', '-e', f'LANES_EDITOR={editor}', '-e', f'LANES_EDIT_LABEL={label}', container, LANE_EDIT,
5236
+ path]
5237
+
5238
+
5239
+ def edit_container(lane):
5240
+ """(container, /proc/<init>/root) of a running container, from the registry's name and containers.json's pid;
5241
+ refused when it is not running or lane-edit is not installed there."""
5242
+ container = lane.get('container') or ''
5243
+ info = (L.read_json(L.STATE / 'containers.json') or {}).get(container) or {}
5244
+ root = PROC / str(info.get('pid') or 0) / 'root'
5245
+ try:
5246
+ running = bool(info.get('pid')) and stat.S_ISDIR(os.stat(root).st_mode)
5247
+ except FileNotFoundError:
5248
+ running = False
5249
+ except OSError as error:
5250
+ raise L.LanesError(f'container {container}: its files cannot be read through /proc ({error.strerror})')
5251
+ if not running:
5252
+ raise L.LanesError(f'container {container} is not running')
5253
+ found = L.container_lstat(root, L.container_realpath(root, LANE_EDIT))
5254
+ if not (found and stat.S_ISREG(found.st_mode)):
5255
+ raise L.LanesError(f'lane-edit is not installed in {container}: run lanes install-container {container}')
5256
+ return container, root
5257
+
5258
+
5259
+ def cmd_edit(args):
5260
+ if '--focused' not in args:
5261
+ print('usage: lanes edit --focused [--device D] (the Ctrl-Alt-e floating pane runs this)')
5262
+ return 2
5263
+ device = args[args.index('--device') + 1] if '--device' in args else os.environ.get('ZELLIJ_SESSION_NAME', '')
5264
+ try:
5265
+ device = device_name(device)
5266
+ tabs, panes = view_listings(device)
5267
+ other = edit_pane_open(device, tabs, panes)
5268
+ if other is not None:
5269
+ return edit_defer(device, tabs, other)
5270
+ view_device, workspace, slot = focused_view(device, (tabs, panes))
5271
+ board, lane = L.find_lane(L.load_registry(), lane_shown(view_device, workspace, slot))
5272
+ if lane.get('holder') != 'tmux':
5273
+ raise L.LanesError(f"{lane['name']} is a {lane.get('holder')} lane: quick edit opens tmux lanes only")
5274
+ container, root = edit_container(lane)
5275
+ roots = edit_roots(lane, board, root)
5276
+ candidates = edit_candidates(roots, root)
5277
+ tone, glyphs = edit_look(device)
5278
+ except L.LanesError as error:
5279
+ print(f'lanes edit: {error}')
5280
+ preview_hold()
5281
+ return 1
5282
+ picker = edit_picker(lane['name'], roots, candidates, tone, glyphs)
5283
+ if not any(row['present'] for row in candidates):
5284
+ print(picker, end='', flush=True)
5285
+ preview_hold()
5286
+ return 0
5287
+ with key_reader() as read: # raw before the picker shows: a key pressed once it is on screen is never lost
5288
+ print(picker, end='', flush=True)
5289
+ choice = pick_key(candidates, read)
5290
+ if choice is None:
5291
+ return 0
5292
+ row = candidates[choice]
5293
+ argv = edit_argv(container, row['root'], row['container_path'], edit_editor(device), row['label'])
5294
+ try:
5295
+ os.execvp(argv[0], argv)
5296
+ except OSError as error: # its text names docker, never the file; the label says which file it was
5297
+ print(f"lanes edit: could not start docker for {row['label']}: {error.strerror or 'failed'}")
5298
+ preview_hold()
5299
+ return 1
5300
+ return 0 # only reached when execvp is a test double
5301
+
5302
+
5303
+ COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_client_config, 'cursor-commit': cmd_cursor_commit, 'upgrade-session': cmd_upgrade_session, 'upload-requests': cmd_upload_requests, 'upload-result': cmd_upload_result, 'upload': cmd_upload, 'workspaces': cmd_workspaces, 'cursor-sync': cmd_cursor_sync, 'reattach': cmd_reattach, 'reload-views': cmd_reload_views, 'gc': cmd_gc, 'install-container': cmd_install_container, 'open': cmd_open, 'sync': cmd_sync, 'ls': cmd_ls, 'resolve': cmd_resolve, 'status': cmd_status, 'layout': cmd_layout,
2756
5304
  'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
2757
5305
  'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
2758
- 'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, 'hooks': cmd_hooks, 'economy': cmd_economy,
2759
- 'preview': cmd_preview, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev, 'studio-host': cmd_studio_host,
5306
+ 'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, 'unregister': cmd_unregister,
5307
+ 'hooks': cmd_hooks, 'economy': cmd_economy,
5308
+ 'preview': cmd_preview, 'edit': cmd_edit, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev,
5309
+ 'sidebar-key': cmd_sidebar_key, 'studio-host': cmd_studio_host,
2760
5310
  'keys': cmd_keys, 'invite': cmd_invite, 'enrol': cmd_enrol, 'offboard': cmd_offboard,
2761
- 'token': cmd_token, 'onboard': cmd_onboard}
5311
+ 'token': cmd_token, 'onboard': cmd_onboard, 'user': cmd_user, 'lane': cmd_lane, 'here': cmd_here,
5312
+ 'version': cmd_version, 'update': cmd_update, 'docs': cmd_docs}
2762
5313
 
2763
5314
  if __name__ == '__main__':
2764
5315
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: