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