@vibelord/lanes 1.1.1 → 1.3.0

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