@vibelord/lanes 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -3
@@ -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,47p' "$0"; exit 0 ;;
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=${LANES_ENROL_URL:-} enrol_resolve= enrol_note=
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
- path = str(L.REPO / 'libexec' / script)
42
- if path not in args:
52
+ at = L.tree_file_at(args, 'libexec/' + script)
53
+ if at is None:
43
54
  continue
44
- tail = args[args.index(path) + 1:]
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
- holder = str(L.REPO / 'holders/ssh.sh')
55
- if holder in args:
56
- tail = args[args.index(holder) + 1:]
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 close/rebuild/focus action exists.
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)