@vibelord/lanes 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +47 -23
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2753 -202
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +147 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1150 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1318 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +793 -16
- package/payload/lanes/docs/DESIGN.md +127 -46
- package/payload/lanes/docs/INSTALL.md +175 -18
- package/payload/lanes/docs/RUNBOOK.md +968 -76
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +967 -150
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +197 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +21 -2
- package/payload/lanes/libexec/notify.py +169 -29
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2232 -365
- package/payload/lanes/libexec/statusd.py +231 -12
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +148 -35
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +1044 -0
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +165 -28
- package/payload/lanes/web/browser.js +4 -8
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +45 -32
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- package/payload/manifest.json +6 -3
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
248
|
-
|
|
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-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
280
|
-
It replaces old unit symlinks atomically
|
|
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
|