@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
package/payload/lanes/install.sh
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
#
|
|
5
5
|
# ./install.sh install or update the client on this machine
|
|
6
6
|
# ./install.sh --server install or update the Linux workspace server
|
|
7
|
+
# ./install.sh --server --update-only update it: every step after the switch runs whatever the one before it
|
|
8
|
+
# returned; the run is recorded in ~/.local/state/lanes/update/last.json (exit 0 · 4 · 3 · 1)
|
|
7
9
|
# ./install.sh --check verify terminal, SSH path, key and server (changes nothing)
|
|
8
10
|
# ./install.sh --uninstall remove what the installer added (your SSH key and ~/Lanes Uploads are kept)
|
|
9
11
|
#
|
|
@@ -41,9 +43,14 @@
|
|
|
41
43
|
# :8443/enrol/) authorizes it — at once for an invite code; without one, on a terminal, the installer offers to ask the
|
|
42
44
|
# site's members and waits up to 10 min (LANES_ENROL_WAIT) for an approval. The client never edits authorized_keys.
|
|
43
45
|
# No service, a refused code, or no code and no terminal: the public key is printed for the admin (exit 3), as before.
|
|
46
|
+
# Run by the npm wrapper with an invite, the installer first asks that service which release the server runs: a newer
|
|
47
|
+
# one than this installer ends the run before anything is installed or redeemed (exit 5), and the wrapper runs the
|
|
48
|
+
# server's release with the same options, which redeems the invite (LANES_NO_SELF_UPDATE=1: never asked).
|
|
44
49
|
#
|
|
45
50
|
# Exit status: 0 ready to use · 3 installed, but this machine's key is not authorized on the server yet ·
|
|
46
51
|
# 4 installed, but not in the tailnet, or the server is not reachable or did not answer in time (busy?) ·
|
|
52
|
+
# 5 nothing installed: the server runs a newer release (only when LANES_WRAPPER_REPLY names the npm
|
|
53
|
+
# wrapper's hand-over file; the wrapper then runs that release) ·
|
|
47
54
|
# 1 error
|
|
48
55
|
set -eu
|
|
49
56
|
here=$(cd "$(dirname "$0")" && pwd)
|
|
@@ -81,7 +88,7 @@ while [ $# -gt 0 ]; do
|
|
|
81
88
|
--palette) palette=1 ;;
|
|
82
89
|
--no-palette) palette=0 ;;
|
|
83
90
|
--yes|-y) assume_yes=1 ;;
|
|
84
|
-
-h|--help) sed -n '2,
|
|
91
|
+
-h|--help) sed -n '2,54p' "$0"; exit 0 ;;
|
|
85
92
|
*) echo "install.sh: unknown option $1 (see --help)" >&2; exit 1 ;;
|
|
86
93
|
esac
|
|
87
94
|
shift
|
|
@@ -1142,7 +1149,7 @@ report_key_taken() {
|
|
|
1142
1149
|
# and nobody at a terminal to ask: report_server's exit-3 text, unchanged — except a 409 (the device name or the key is
|
|
1143
1150
|
# already a member's), which names the re-run that joins instead (report_device_taken, report_key_taken). The code
|
|
1144
1151
|
# goes in a request body on stdin: never on a command line, in a file or in the output.
|
|
1145
|
-
enrol_file=$config/enrol-request enrol_caller= enrol_conflict= enrol_via=
|
|
1152
|
+
enrol_file=$config/enrol-request enrol_caller= enrol_conflict= enrol_via= enrol_base= enrol_resolve=
|
|
1146
1153
|
invite_problem() { # CODE -> why it is not an invite code for this profile, or nothing. Names the prefix at most.
|
|
1147
1154
|
ip_bad='not an invite code (letters, digits and dashes, like LN-xxxx-xxxxxxxxxxxx)'
|
|
1148
1155
|
case "$1" in ''|-*|*-|*--*|*[!A-Za-z0-9-]*) printf '%s' "$ip_bad"; return 0 ;; *-*) ;; *) printf '%s' "$ip_bad"; return 0 ;; esac
|
|
@@ -1169,14 +1176,18 @@ json_get() { # PATH -> the value at that path of $http_body (strings unquoted)
|
|
|
1169
1176
|
}
|
|
1170
1177
|
# -> enrol_base (scheme://host[:port]), enrol_host, enrol_port, enrol_ip (the tailnet address to use when this machine
|
|
1171
1178
|
# cannot resolve the name: an IPv4 LANES_SERVER_HOST); 1 + enrol_note when there is no service to ask.
|
|
1172
|
-
# LANES_ENROL_URL replaces the base (development and tests); the name and address logic stays the same.
|
|
1179
|
+
# LANES_ENROL_URL replaces the base (development and tests); the name and address logic stays the same. Called again in
|
|
1180
|
+
# the same run (the K3 question before the install, then enrol), it keeps the --resolve the first calls learned for the
|
|
1181
|
+
# same base: a name this machine cannot resolve is not tried again (each try costs up to curl's 5 s to connect).
|
|
1173
1182
|
enrol_target() {
|
|
1174
|
-
enrol_base
|
|
1183
|
+
et_known=$enrol_base
|
|
1184
|
+
enrol_base=${LANES_ENROL_URL:-} enrol_note=
|
|
1175
1185
|
if [ -z "$enrol_base" ]; then
|
|
1176
1186
|
if [ -z "$server_name" ]; then enrol_note='this site profile names no LANES_SERVER_NAME (its enrolment service answers under that name)'; return 1; fi
|
|
1177
1187
|
enrol_base=https://${server_name%.}:8443
|
|
1178
1188
|
fi
|
|
1179
1189
|
enrol_base=${enrol_base%/}
|
|
1190
|
+
[ "$enrol_base" = "$et_known" ] || enrol_resolve=
|
|
1180
1191
|
et_hostport=${enrol_base#*://}
|
|
1181
1192
|
case "$enrol_base" in http://*|https://*) ;; *) enrol_note='LANES_ENROL_URL is not an http(s) URL'; return 1 ;; esac
|
|
1182
1193
|
case "$et_hostport" in ''|*/*|*[!A-Za-z0-9.:-]*) enrol_note='LANES_ENROL_URL is not scheme://host[:port]'; return 1 ;; esac
|
|
@@ -1289,7 +1300,8 @@ enrol_site() { # GET /enrol/site -> 0, request_mode and enrol_caller; or 1 and
|
|
|
1289
1300
|
# (a server without the service hands /enrol/site to its web client: a page, not this document)
|
|
1290
1301
|
if ! enrol_json; then enrol_note="no enrolment service at $enrol_base/enrol/ (the answer is not JSON)"; return 1; fi
|
|
1291
1302
|
# lanes-enrold's /site always says request_mode, true or false: that is what tells it from any other JSON. Its
|
|
1292
|
-
# `version` is the server's Lanes release, not a wire number (wire v1 is additive only, and none is served)
|
|
1303
|
+
# `version` is the server's Lanes release, not a wire number (wire v1 is additive only, and none is served): only
|
|
1304
|
+
# enrol_preflight reads it, to hand an invite over to that release's installer; no admission ever depends on it.
|
|
1293
1305
|
request_mode=$(json_get /request_mode)
|
|
1294
1306
|
case "$request_mode" in
|
|
1295
1307
|
true|false) ;;
|
|
@@ -1298,6 +1310,55 @@ enrol_site() { # GET /enrol/site -> 0, request_mode and enrol_caller; or 1 and
|
|
|
1298
1310
|
# The tailnet login the service received (null outside request mode): the owner a request is filed under.
|
|
1299
1311
|
enrol_caller=$(enrol_clean "$(json_get /caller)")
|
|
1300
1312
|
}
|
|
1313
|
+
# ---- the server's release first (W9 K3, LNS-88) ---------------------------------------------------------------------
|
|
1314
|
+
# An invite is redeemed once, so the installer that redeems it should be the server's own release. Run by the npm
|
|
1315
|
+
# wrapper (which names a file in LANES_WRAPPER_REPLY) with an invite (--invite or LANES_INVITE), the installer asks the
|
|
1316
|
+
# enrolment service which release the server runs — /enrol/site's `version` — BEFORE anything is installed, sent or
|
|
1317
|
+
# redeemed. A newer one than this tree's VERSION ends the run: `update-to=<release>` into that file, one line, exit 5.
|
|
1318
|
+
# The wrapper then runs `npx --yes @vibelord/lanes@<release>` with the same options (the code moved to LANES_INVITE)
|
|
1319
|
+
# and LANES_NO_SELF_UPDATE=1, which skips this question: one hand-over at most. Anything else changes nothing and the
|
|
1320
|
+
# install goes on with this tree: no invite; LANES_NO_SELF_UPDATE (any value); a tailnet site this machine is not in
|
|
1321
|
+
# yet (one `tailscale status`, no login — the install's own Tailscale step follows); no service or no answer (one
|
|
1322
|
+
# bounded call, as enrol_call makes every call); an equal, older or unparseable release on either side. Without the
|
|
1323
|
+
# wrapper (a checkout's ./install.sh) a newer server is one line and the install goes on.
|
|
1324
|
+
release_key() { # RELEASE -> "MAJOR MINOR PATCH", nothing for what is no release
|
|
1325
|
+
# The rule `lanes hello` compares by (bin/lanes version_key): major.minor.patch, an optional -/+ suffix that is not
|
|
1326
|
+
# compared, at most 32 characters — and here each number at most 9 digits, so test(1) compares it exactly.
|
|
1327
|
+
case "$1" in ''|*[!A-Za-z0-9.+-]*) return 0 ;; esac
|
|
1328
|
+
[ "${#1}" -le 32 ] || return 0
|
|
1329
|
+
printf '%s\n' "$1" | sed -n 's/^\([0-9]\{1,9\}\)\.\([0-9]\{1,9\}\)\.\([0-9]\{1,9\}\)\([-+][A-Za-z0-9.]\{1,\}\)\{0,1\}$/\1 \2 \3/p'
|
|
1330
|
+
}
|
|
1331
|
+
release_newer() { # SERVER MINE -> 0 when both are releases and SERVER's major.minor.patch is the higher, as numbers
|
|
1332
|
+
rn_server=$(release_key "$1") rn_mine=$(release_key "$2")
|
|
1333
|
+
[ -n "$rn_server" ] && [ -n "$rn_mine" ] || return 1
|
|
1334
|
+
# shellcheck disable=SC2086
|
|
1335
|
+
set -- $rn_server $rn_mine # (digits only: no glob, no blank inside a number)
|
|
1336
|
+
if [ "$1" -ne "$4" ]; then [ "$1" -gt "$4" ]; return; fi
|
|
1337
|
+
if [ "$2" -ne "$5" ]; then [ "$2" -gt "$5" ]; return; fi
|
|
1338
|
+
[ "$3" -gt "$6" ]
|
|
1339
|
+
}
|
|
1340
|
+
enrol_preflight() {
|
|
1341
|
+
[ -n "$invite" ] && [ -z "${LANES_NO_SELF_UPDATE:-}" ] || return 0
|
|
1342
|
+
if tailnet_site; then
|
|
1343
|
+
ts=$(tailscale_bin) || return 0
|
|
1344
|
+
tailscale_look
|
|
1345
|
+
[ "$ts_state" = Running ] && [ "$ts_online" = true ] || return 0
|
|
1346
|
+
fi
|
|
1347
|
+
enrol_target && enrol_site || return 0
|
|
1348
|
+
pf_server=$(json_get /version)
|
|
1349
|
+
pf_mine=dev; if [ -r "$here/VERSION" ]; then pf_mine=$(tr -cd 'A-Za-z0-9._+-' < "$here/VERSION"); fi
|
|
1350
|
+
release_newer "$pf_server" "$pf_mine" || return 0
|
|
1351
|
+
if [ -z "${LANES_WRAPPER_REPLY:-}" ]; then
|
|
1352
|
+
say "server runs $pf_server; this installer is $pf_mine — going on with $pf_mine"
|
|
1353
|
+
return 0
|
|
1354
|
+
fi
|
|
1355
|
+
if ! { printf 'update-to=%s\n' "$pf_server" > "$LANES_WRAPPER_REPLY"; } 2>/dev/null; then
|
|
1356
|
+
warn "server runs $pf_server; this installer is $pf_mine, and the npm wrapper's hand-over file cannot be written — going on with $pf_mine"
|
|
1357
|
+
return 0
|
|
1358
|
+
fi
|
|
1359
|
+
say "server runs $pf_server: fetching that installer first (this one is $pf_mine; nothing is installed yet, and the invite is kept for it)"
|
|
1360
|
+
exit 5
|
|
1361
|
+
}
|
|
1301
1362
|
enrol_key() { # -> enrol_pubkey "ssh-ed25519 <base64>" (enrold writes its own comment); 1, said, for any other key
|
|
1302
1363
|
enrol_pubkey= kt= kb=
|
|
1303
1364
|
if [ -r "$key.pub" ]; then read -r kt kb _ < "$key.pub" || :; fi
|
|
@@ -1451,7 +1512,7 @@ enrol() {
|
|
|
1451
1512
|
do_install() {
|
|
1452
1513
|
command -v ssh >/dev/null 2>&1 || die 'OpenSSH (ssh) is required'
|
|
1453
1514
|
case "$os" in Darwin|Linux) ;; *) die "unsupported system: $os (macOS and Linux only)" ;; esac
|
|
1454
|
-
resolve_options; load_site; check_invite; src=$here/client
|
|
1515
|
+
resolve_options; load_site; check_invite; enrol_preflight; src=$here/client
|
|
1455
1516
|
head_ "lanes client $(cat "$here/VERSION" 2>/dev/null || echo dev) -> $server_user@$server_alias"
|
|
1456
1517
|
choose_device; choose_key; ensure_key
|
|
1457
1518
|
install_client; src=$target
|
|
@@ -1488,7 +1549,7 @@ do_install() {
|
|
|
1488
1549
|
say 'Open the window: lanes-window (Omarchy: Super+Shift+L · macOS: the Lanes app)'
|
|
1489
1550
|
say 'In any terminal: lanes-launch one lane only: lanes-launch lane <id> · list: lanes-launch ls'
|
|
1490
1551
|
say 'Phone or browser: lanes-launch web phone · lanes-launch web laptop (in your own terminal: a login token)'
|
|
1491
|
-
say 'Keys: Ctrl-Alt-←/→ board · Ctrl-Alt-↑/↓ lane · Ctrl-Alt-/ find · Ctrl-Alt-u upload box · close the window to detach'
|
|
1552
|
+
say 'Keys: Ctrl-Alt-←/→ board · Ctrl-Alt-↑/↓ lane · Ctrl-Alt-/ find · Ctrl-Alt-u upload box · Ctrl-Alt-l sidebar · close the window to detach'
|
|
1492
1553
|
exit 0 ;;
|
|
1493
1554
|
unauthorized) exit 3 ;;
|
|
1494
1555
|
*) exit 4 ;;
|
|
@@ -8,7 +8,8 @@ braille spinner while working, and rings BEL on turn completion. Claude's title
|
|
|
8
8
|
|
|
9
9
|
The agent{} status vocabulary is fixed here and reused by statusd, the sidebar, the hook sidecar and the phone:
|
|
10
10
|
working | needs_input | needs_approval | done | errored | shell | idle. It is a NEW key beside status.json's
|
|
11
|
-
six-word `state`, never a replacement for it.
|
|
11
|
+
six-word `state`, never a replacement for it. So are the attention SECTION each word belongs to and its RANK, and
|
|
12
|
+
`settle`, the debounce statusd applies between `merge`'s verdict and what it publishes (cockpit v3 S1).
|
|
12
13
|
|
|
13
14
|
Two stale-signal traps, both MEASURED 2026-10-04 on this host: (1) a Claude session file outlives its pid, and pids
|
|
14
15
|
wrap (pid_max 4194304, live pids already ~2.3M), so a recycled pid would resurrect a dead session — every file
|
|
@@ -37,6 +38,15 @@ SHELL_TITLES = {'', 'work', 'bash', 'sh', 'zsh', 'fish'}
|
|
|
37
38
|
STATUS_FROM_CLAUDE = {'busy': 'working', 'idle': 'needs_input', 'shell': 'shell', 'waiting': 'needs_input'}
|
|
38
39
|
STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle')
|
|
39
40
|
SHELL_COMMANDS = {'bash', 'sh', 'dash', 'zsh', 'fish', 'ksh', 'tcsh', 'csh'}
|
|
41
|
+
# The attention section each status belongs to (None = the workspace's other lanes) and how urgent a section is: the
|
|
42
|
+
# ONE table statusd's `settle` and the sidebar's sections (`sidebar.SECTION_OF`, its ATTENTION order) read.
|
|
43
|
+
SECTION = {'needs_approval': 'needs', 'errored': 'needs', 'needs_input': 'needs', 'working': 'working', 'done': 'done',
|
|
44
|
+
'shell': None, 'idle': None}
|
|
45
|
+
RANK = {'needs': 3, 'working': 2, 'done': 1, None: 0}
|
|
46
|
+
# Seconds a LOWER-ranked word must hold before the published status leaves a section, keyed by the section LEFT.
|
|
47
|
+
# Entering a higher section never waits. GONE_DWELL: seconds without any agent signal before agent{} is dropped.
|
|
48
|
+
DWELL = {'needs': 8, 'working': 5, 'done': 5, None: 0}
|
|
49
|
+
GONE_DWELL = 5
|
|
40
50
|
|
|
41
51
|
|
|
42
52
|
def pane_has_child(proc_root, pid):
|
|
@@ -244,3 +254,66 @@ def merge(pane_row, claude_by_pane, sidecar):
|
|
|
244
254
|
title['since'] = 0
|
|
245
255
|
return title
|
|
246
256
|
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _stamp(value):
|
|
260
|
+
"""A usable epoch second from a published or source field, or None (absent, bool, zero, negative, not a number)."""
|
|
261
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
262
|
+
return None
|
|
263
|
+
return int(value)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def settle(prev, raw, now):
|
|
267
|
+
"""The agent{} statusd publishes for one lane this cycle: `merge`'s verdict `raw` debounced against `prev`, the
|
|
268
|
+
agent{} it published last cycle (statusd's previous payload — status.json itself after a restart), at `now` on
|
|
269
|
+
statusd's clock. None = publish no agent key. Pure; neither input is mutated.
|
|
270
|
+
|
|
271
|
+
Until cockpit v3 (plan context item 4, read in the code 2026-10-06) statusd published `merge`'s word every 3 s
|
|
272
|
+
cycle with no hysteresis, so a lane whose signal flips (a turn ending and restarting, a dialog opened and closed, a
|
|
273
|
+
Codex title losing its spinner for a frame) moved between the sidebar's sections every cycle, and its `since`
|
|
274
|
+
restarted on every flip — for a Codex lane read from its title, on every output (`window_activity`). Rules (S1,
|
|
275
|
+
LNS-59), by SECTION and RANK:
|
|
276
|
+
|
|
277
|
+
* higher section than the one published: publish now (a question or an approval is never delayed);
|
|
278
|
+
* same section: publish now, `stable_since` kept (needs_input -> needs_approval changes only the urgency);
|
|
279
|
+
* lower section: publish only once the raw word has held for DWELL[section left] — `now - raw_since`;
|
|
280
|
+
* no agent at all: the published agent{} stays until GONE_DWELL has passed, then the key goes.
|
|
281
|
+
|
|
282
|
+
Published keys: `merge`'s own (harness, name, session_id, pid, source, event) from the verdict that was PUBLISHED —
|
|
283
|
+
a held status keeps the dict it was published with — plus `status` (the debounced word), `since` (when that status
|
|
284
|
+
began: the source's own start, never later than the cycle that first saw it, and kept while the status is
|
|
285
|
+
unchanged, so a moving source stamp is no longer a clock), `raw_status` (`merge`'s word this cycle, None while the
|
|
286
|
+
agent is gone), `raw_since` (statusd's clock when that word was first seen; reset only when it differs from the
|
|
287
|
+
previous `raw_status`, never a source timestamp) and `stable_since` (statusd's clock when the lane entered the
|
|
288
|
+
section it is published in). All stamps are integer seconds, so a steady lane publishes byte-identically and
|
|
289
|
+
status.json is not rewritten. A `prev` that is not an agent{} (a zmx row's lane-run record, an unknown word) is no
|
|
290
|
+
previous cycle; one written before these keys existed is adopted with `raw_since`/`stable_since` = `now`."""
|
|
291
|
+
now = int(now)
|
|
292
|
+
if not (isinstance(raw, dict) and raw.get('status') in STATUSES):
|
|
293
|
+
raw = None
|
|
294
|
+
if not (isinstance(prev, dict) and prev.get('status') in STATUSES):
|
|
295
|
+
prev = None
|
|
296
|
+
if prev is None:
|
|
297
|
+
return None if raw is None else _published(raw, now, now)
|
|
298
|
+
word = raw['status'] if raw else None
|
|
299
|
+
last = prev['raw_status'] if 'raw_status' in prev else prev['status'] # a pre-v3 entry: its status
|
|
300
|
+
raw_since = (_stamp(prev.get('raw_since')) if word == last else None) or now
|
|
301
|
+
stable = _stamp(prev.get('stable_since')) or now
|
|
302
|
+
held = dict(prev, raw_status=word, raw_since=raw_since, stable_since=stable)
|
|
303
|
+
if raw is None:
|
|
304
|
+
return None if now - raw_since >= GONE_DWELL else held
|
|
305
|
+
old, new = SECTION[prev['status']], SECTION[word]
|
|
306
|
+
if new == old:
|
|
307
|
+
kept = _stamp(prev.get('since')) if word == prev['status'] else None
|
|
308
|
+
return dict(_published(raw, raw_since, stable), since=kept) if kept else _published(raw, raw_since, stable)
|
|
309
|
+
if RANK[new] > RANK[old] or now - raw_since >= DWELL[old]:
|
|
310
|
+
return _published(raw, raw_since, now)
|
|
311
|
+
return held
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _published(raw, raw_since, stable_since):
|
|
315
|
+
"""`raw` as published: its `since` the start of the status (the source's, capped at `raw_since`, the cycle that
|
|
316
|
+
first saw the word), then the three settle keys."""
|
|
317
|
+
source = _stamp(raw.get('since'))
|
|
318
|
+
return dict(raw, since=min(source, raw_since) if source else raw_since, raw_status=raw['status'],
|
|
319
|
+
raw_since=raw_since, stable_since=stable_since)
|
|
@@ -9,6 +9,7 @@ The repository's `.vscode/terminals.json` stays the source of truth for WHICH la
|
|
|
9
9
|
view shows the lane);
|
|
10
10
|
* the ORIGINAL command and the exact binding are preserved next to it in a top-level `lanes` block, which the
|
|
11
11
|
extension and Throttle ignore: lanes.terminals[<name>] = {id, holder, tmux{socketName,sessionName}, create}.
|
|
12
|
+
An entry's optional `displayName` (the container's display layer for the lane, LNS-33) is carried as is.
|
|
12
13
|
|
|
13
14
|
Text is edited minimally (command strings replaced in place, the `lanes` block inserted before the final brace) so
|
|
14
15
|
git diffs stay small. Dry-run by default: prints a unified diff. `--write` applies (with a timestamped backup).
|
|
@@ -179,6 +180,11 @@ def normalized_text(text, lanes_by_name, canonicalize=None):
|
|
|
179
180
|
entry.pop('create', None)
|
|
180
181
|
entry['create'] = recipe
|
|
181
182
|
entry['createPrevious'] = create
|
|
183
|
+
if 'displayName' in previous:
|
|
184
|
+
# LNS-33: the container's display layer (lanes_lib._workspace_lanes reads it; the host manifest's
|
|
185
|
+
# displayNames win over it). Carried verbatim and last, never dropped: this tool rebuilds the entry from
|
|
186
|
+
# the registry and validates nothing it does not own — the reader ignores a value that is no lane name.
|
|
187
|
+
entry['displayName'] = previous['displayName']
|
|
182
188
|
block['terminals'][term['name']] = entry
|
|
183
189
|
commands[term['name']] = ENTRY + lane['id']
|
|
184
190
|
# Nothing to do: leave the file byte-identical (never reformat somebody else's JSON).
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
"""Reconcile catalog tabs without switching focus or touching persistent agents.
|
|
1
|
+
"""Reconcile catalog tabs without switching focus or touching persistent agents.
|
|
2
|
+
|
|
3
|
+
The one tab this ever closes (cockpit v3 R4, LNS-77) is one whose panes IDENTIFY a workspace the owner's profile hides
|
|
4
|
+
(`hidden`): its sidebar and view.sh panes are viewers — the lanes they show live in containers, so closing the tab loses
|
|
5
|
+
nothing, and the workspace comes back as a new tab when it is shown again. A tab that only carries such a title, an
|
|
6
|
+
unmanaged tab, and every other identity are left open. Such a close never leaves the device without a workspace tab:
|
|
7
|
+
it waits while the hidden boards' tabs are the only workspace tabs the device HAS — counted over the tabs that exist,
|
|
8
|
+
not the specs, because a board added while no client was attached has no tab yet (W9 review F2) — and a reconcile
|
|
9
|
+
whose specs want no workspace at all is refused. Closing the tab a client is looking at moves that client to a
|
|
10
|
+
neighbouring tab (the person hid it)."""
|
|
2
11
|
import fcntl
|
|
3
12
|
import json
|
|
4
13
|
import shlex
|
|
@@ -28,7 +37,9 @@ def observe(device, call):
|
|
|
28
37
|
|
|
29
38
|
|
|
30
39
|
def identities(device, tabs, panes, registry, mobile):
|
|
31
|
-
"""Read workspace IDs from the launched commands, independent of tab labels
|
|
40
|
+
"""Read workspace IDs from the launched commands, independent of tab labels — and of the tree path: a tree file
|
|
41
|
+
is matched by its suffix (lanes_lib.tree_file_at), so a pane started from a `pkg/<version>` layout written before
|
|
42
|
+
W9 U1, from one written through the `current` link since, or from a checkout keeps its identity across updates."""
|
|
32
43
|
by_tab = {}
|
|
33
44
|
for pane in panes:
|
|
34
45
|
if pane.get('is_plugin'):
|
|
@@ -38,10 +49,10 @@ def identities(device, tabs, panes, registry, mobile):
|
|
|
38
49
|
except ValueError:
|
|
39
50
|
continue
|
|
40
51
|
for script in ('sidebar', 'view.sh'):
|
|
41
|
-
|
|
42
|
-
if
|
|
52
|
+
at = L.tree_file_at(args, 'libexec/' + script)
|
|
53
|
+
if at is None:
|
|
43
54
|
continue
|
|
44
|
-
tail = args[
|
|
55
|
+
tail = args[at + 1:]
|
|
45
56
|
if len(tail) < 2 or tail[0] != device:
|
|
46
57
|
continue
|
|
47
58
|
workspace = tail[1]
|
|
@@ -51,9 +62,9 @@ def identities(device, tabs, panes, registry, mobile):
|
|
|
51
62
|
role = 'picker' if mobile and script == 'sidebar' else 'view'
|
|
52
63
|
key = f'workspace/{workspace}/{role}'
|
|
53
64
|
by_tab.setdefault(pane['tab_id'], set()).add(key)
|
|
54
|
-
|
|
55
|
-
if
|
|
56
|
-
tail = args[
|
|
65
|
+
at = L.tree_file_at(args, 'holders/ssh.sh')
|
|
66
|
+
if at is not None:
|
|
67
|
+
tail = args[at + 1:]
|
|
57
68
|
for workspace in registry.get('ssh_workspaces', []):
|
|
58
69
|
if (len(tail) == 3 and tail[:2] == [workspace['host'], workspace['command']]
|
|
59
70
|
and any(pane['id'] == tail[2] for pane in workspace['panes'])):
|
|
@@ -72,7 +83,16 @@ def identities(device, tabs, panes, registry, mobile):
|
|
|
72
83
|
return result
|
|
73
84
|
|
|
74
85
|
|
|
75
|
-
def reconcile(device, registry, specs, mobile, call):
|
|
86
|
+
def reconcile(device, registry, specs, mobile, call, hidden=()):
|
|
87
|
+
"""Make the device's tabs the `specs` (bin/lanes tab_specs): add each missing one (only while a client is attached),
|
|
88
|
+
rename a changed title, order them; close the tab of each workspace id in `hidden` (the boards the owner's profile
|
|
89
|
+
hides, cockpit v3 R4) whose identity is `workspace/<id>/…` and that no spec wants — an unknown id is ignored. ->
|
|
90
|
+
the changes made, as words. Two guards keep a workspace tab on the device:
|
|
91
|
+
* specs that want no workspace at all with such a tab to close: refused (L.LanesError, before any change);
|
|
92
|
+
* no workspace tab the device has would stay open — every EXISTING `workspace/…` tab is one to close, the
|
|
93
|
+
shown boards having none yet (one added while no client was attached, W9 review F2): nothing is closed and
|
|
94
|
+
the words say so (`kept <titles> open although hidden: …`); the rest runs — the missing tabs are added
|
|
95
|
+
once a client is attached — and a later reconcile, counting again, closes them."""
|
|
76
96
|
lock = L.STATE / 'locks' / f'device-sync.{device}.lock'
|
|
77
97
|
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
78
98
|
with lock.open('a') as handle:
|
|
@@ -88,6 +108,22 @@ def reconcile(device, registry, specs, mobile, call):
|
|
|
88
108
|
raise L.LanesError(f'{device}: {args[0]} failed: {error or "no answer"}')
|
|
89
109
|
return output
|
|
90
110
|
|
|
111
|
+
keys, hidden = {spec['key'] for spec in specs}, set(hidden)
|
|
112
|
+
doomed = [tab for key, tab in known.items()
|
|
113
|
+
if key.startswith('workspace/') and key.split('/')[1] in hidden and key not in keys]
|
|
114
|
+
if doomed and not any(key.startswith('workspace/') for key in keys):
|
|
115
|
+
raise L.LanesError(f'{device}: closing every workspace tab is refused (one workspace stays shown); '
|
|
116
|
+
'no tab was closed')
|
|
117
|
+
if doomed and len([key for key in known if key.startswith('workspace/')]) - len(doomed) == 0:
|
|
118
|
+
them, they = ('it', 'it closes') if len(doomed) == 1 else ('them', 'they close')
|
|
119
|
+
changes.append(f"kept {', '.join(tab['name'] for tab in doomed)} open although hidden: no other workspace "
|
|
120
|
+
f'has a tab yet (closing {them} would leave none); {they} once a shown workspace has one')
|
|
121
|
+
doomed = []
|
|
122
|
+
for tab in doomed:
|
|
123
|
+
action(['close-tab', '--tab-id', str(tab['tab_id'])])
|
|
124
|
+
tabs = [t for t in tabs if t['tab_id'] != tab['tab_id']]
|
|
125
|
+
changes.append('closed ' + tab['name'])
|
|
126
|
+
|
|
91
127
|
for spec in specs:
|
|
92
128
|
tab = known.get(spec['key'])
|
|
93
129
|
if tab is None:
|
|
@@ -115,7 +151,8 @@ def reconcile(device, registry, specs, mobile, call):
|
|
|
115
151
|
wanted.append(tab['tab_id'])
|
|
116
152
|
|
|
117
153
|
# Sort managed tabs by adjacent moves using stable IDs. Each observation
|
|
118
|
-
# must answer. Unmanaged tabs remain open; no
|
|
154
|
+
# must answer. Unmanaged tabs remain open; no rebuild/focus action exists, and the
|
|
155
|
+
# only close is a hidden workspace's identified tab above.
|
|
119
156
|
for position, tab_id in enumerate(wanted):
|
|
120
157
|
for _ in range(len(tabs) + 1):
|
|
121
158
|
rc, output, error = call(['-s', device, 'action', 'list-tabs', '--json'], 20)
|