@vibelord/lanes 1.0.1 → 1.0.3

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 (52) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +37 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +664 -50
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +18 -3
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +1 -1
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +310 -26
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +87 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/INSTALL.md +37 -2
  19. package/payload/lanes/docs/RUNBOOK.md +458 -39
  20. package/payload/lanes/docs/SHARING.md +240 -5
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders.lock +13 -0
  23. package/payload/lanes/install/enable-studio-host.sh +14 -0
  24. package/payload/lanes/install/host-install.sh +15 -3
  25. package/payload/lanes/install/services.py +128 -4
  26. package/payload/lanes/install.sh +108 -3
  27. package/payload/lanes/libexec/agent_state.py +32 -0
  28. package/payload/lanes/libexec/cursor_sync.py +10 -2
  29. package/payload/lanes/libexec/economy_poll.py +685 -0
  30. package/payload/lanes/libexec/guard.py +27 -1
  31. package/payload/lanes/libexec/lanes_lib.py +285 -38
  32. package/payload/lanes/libexec/notify.py +317 -0
  33. package/payload/lanes/libexec/registrations.py +1 -1
  34. package/payload/lanes/libexec/session_actions.py +7 -8
  35. package/payload/lanes/libexec/sidebar.py +911 -218
  36. package/payload/lanes/libexec/statusd.py +258 -74
  37. package/payload/lanes/libexec/studio_host.py +1608 -0
  38. package/payload/lanes/libexec/tokens.py +38 -7
  39. package/payload/lanes/libexec/view.sh +13 -3
  40. package/payload/lanes/systemd/lanes-economy.service +20 -0
  41. package/payload/lanes/systemd/lanes-notify.service +19 -0
  42. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  43. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  44. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  45. package/payload/lanes/systemd/lanes-sync.service +3 -0
  46. package/payload/lanes/web/README.md +83 -4
  47. package/payload/lanes/web/index.html +2 -1
  48. package/payload/lanes/web/mobile.css +53 -198
  49. package/payload/lanes/web/mobile.js +87 -55
  50. package/payload/lanes/zellij/config.kdl +67 -16
  51. package/payload/manifest.json +5 -3
  52. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -14,7 +14,9 @@ allow-list — so that one of them is the victim instead. A build can be re-run;
14
14
  One pass per invocation (the 60 s timer is systemd's job; --once is accepted and means exactly that).
15
15
  --dry-run prints what it would do. Optional arguments: container names (default: every recorded container).
16
16
 
17
- This module is also where tmpscan.py gets the container's pids from (Env, container_pid, cgroup_pids).
17
+ This module is also where tmpscan.py gets the container's pids from (Env, container_pid, cgroup_pids), and where
18
+ statusd gets its live tmux servers from (tmux_servers_by_container). Importing it has no side effect: the pass runs
19
+ only under `__main__`.
18
20
  """
19
21
  import os
20
22
  import signal
@@ -137,6 +139,30 @@ def tmux_servers(env, pid):
137
139
  return found
138
140
 
139
141
 
142
+ def tmux_servers_by_container(env, containers, errors=None):
143
+ """{container: {socket name: host pid}} for statusd's live-server set (plan Task 16), from each container's OWN
144
+ cgroup — never a host-wide `ps`. `containers` is statusd's {name: info}; the init pid is `info['pid']` (statusd
145
+ keeps it current from /proc), not the pid file `container_pid` reads.
146
+
147
+ A container with no init pid (down, or never answered for) runs no servers: it is left out. A container whose
148
+ recorded pid cannot be walked — no such /proc entry, not a docker cgroup, not a namespace init (a recycled pid)
149
+ — maps to None: nobody knows which servers it runs, and the caller must not conclude that any of them is gone.
150
+ `errors`, when given, receives {container: why} for each such None, so the caller can NAME the container whose
151
+ pid broke the read (the None alone said only that some container did)."""
152
+ found = {}
153
+ for name, info in containers.items():
154
+ pid = (info or {}).get('pid')
155
+ if not pid:
156
+ continue
157
+ try:
158
+ found[name] = tmux_servers(env, int(pid))
159
+ except (L.LanesError, OSError, ValueError) as error:
160
+ found[name] = None
161
+ if errors is not None:
162
+ errors[name] = f'pid {pid}: {error}'
163
+ return found
164
+
165
+
140
166
  def sockets(env, names, repair=False, out=print, settle=2.0):
141
167
  """`lanes sockets [check|repair]`: tmux servers whose socket FILE is gone (someone emptied the container's /tmp:
142
168
  2026-09-20 every lane was alive and unreachable). repair = tmux's own remedy: recreate the directory, then
@@ -13,6 +13,7 @@ import json
13
13
  import os
14
14
  import re
15
15
  import shlex
16
+ import signal
16
17
  import subprocess
17
18
  import tempfile
18
19
  import time
@@ -118,6 +119,28 @@ def atomic_write(path, text, mode=0o600):
118
119
  raise
119
120
 
120
121
 
122
+ def atomic_write_if_changed(path, text, mode=0o600):
123
+ """atomic_write, skipped when the file already holds exactly `text` -> True when it wrote. Readers that reload
124
+ on mtime (statusd and notify on registry.json, every sidebar) then do not wake for a projection that did not
125
+ change, and the timer-driven sync stops rewriting the container volumes every two minutes."""
126
+ try:
127
+ if Path(path).read_text() == text:
128
+ return False
129
+ except (OSError, ValueError):
130
+ pass
131
+ atomic_write(path, text, mode)
132
+ return True
133
+
134
+
135
+ def _same_json(path, document, ignore=('generated_at',)):
136
+ """True when `path` holds `document` minus the `ignore` keys (a timestamp alone is not a change)."""
137
+ old = read_json(path)
138
+ if not isinstance(old, dict):
139
+ return False
140
+ return ({k: v for k, v in old.items() if k not in ignore} ==
141
+ {k: v for k, v in json.loads(json.dumps(document)).items() if k not in ignore})
142
+
143
+
121
144
  def read_json(path, default=None):
122
145
  try:
123
146
  return json.loads(Path(path).read_text())
@@ -125,6 +148,53 @@ def read_json(path, default=None):
125
148
  return default
126
149
 
127
150
 
151
+ def show_lane(view_dir, workspace, slot, lane_id):
152
+ """Point one viewport at a lane — the one implementation behind the sidebar's open_lane and `lanes next|prev`
153
+ (plan Task 19): write `<view_dir>/<workspace>.<slot>.target` atomically, then SIGUSR1 the process the viewport's
154
+ `.pid` file names, only when its /proc cmdline says it is a view.sh (a recycled pid is never signalled).
155
+ -> True when a view.sh was woken; False when none runs yet (the target is written either way: a view.sh that
156
+ starts later reads it)."""
157
+ target_file = os.path.join(view_dir, f'{workspace}.{slot}.target')
158
+ os.makedirs(view_dir, exist_ok=True)
159
+ tmp = target_file + f'.{os.getpid()}.tmp'
160
+ with open(tmp, 'w') as handle:
161
+ handle.write(lane_id + '\n')
162
+ os.replace(tmp, target_file)
163
+ try:
164
+ with open(os.path.join(view_dir, f'{workspace}.{slot}.pid')) as handle:
165
+ pid = int(handle.read().strip())
166
+ with open(os.path.join(proc_root(), str(pid), 'cmdline'), 'rb') as handle:
167
+ if b'view.sh' not in handle.read():
168
+ return False
169
+ os.kill(pid, signal.SIGUSR1)
170
+ return True
171
+ except (OSError, ValueError):
172
+ return False
173
+
174
+
175
+ def clean_seen(document):
176
+ """DM-LANES-CLIENT-SEEN as read: string keys with numeric values only (anything else is ignored)."""
177
+ return {k: v for k, v in (document.items() if isinstance(document, dict) else ())
178
+ if isinstance(k, str) and isinstance(v, (int, float))}
179
+
180
+
181
+ def record_seen(path, lane_id, since, known, mine=None):
182
+ """Mark `lane_id` opened now on this device (DM-LANES-CLIENT-SEEN): the file on disk merged with `mine` (the
183
+ caller's in-memory marks; the later mark per lane wins, so the per-workspace sidebars of one device and
184
+ `lanes next|prev` never clobber each other), bounded to the lane ids in `known` (what status.json lists), the
185
+ mark at least the agent's `since`. Written atomically; an unwritable file is not an error. -> the merged dict."""
186
+ seen = clean_seen(read_json(path))
187
+ for key, value in (mine or {}).items():
188
+ seen[key] = max(value, seen.get(key, value))
189
+ seen = {k: v for k, v in seen.items() if k in known}
190
+ seen[lane_id] = max(time.time(), since if isinstance(since, (int, float)) else 0)
191
+ try:
192
+ atomic_write(Path(path), json.dumps(seen, sort_keys=True) + '\n')
193
+ except OSError:
194
+ pass
195
+ return seen
196
+
197
+
128
198
  def slug(text):
129
199
  text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode()
130
200
  text = re.sub(r'[^A-Za-z0-9]+', '-', text).strip('-').lower()
@@ -177,36 +247,78 @@ class NoSuchContainer(LanesError):
177
247
  """docker ANSWERED that the container does not exist — an observation, unlike every other docker failure."""
178
248
 
179
249
 
180
- def docker_inspect(container, template):
181
- rc, out, err = run_bounded(['docker', 'inspect', '-f', template, container], 20)
250
+ INSPECT_FORMAT = ('{{.Name}}\t{{.State.Pid}}\t{{range .Mounts}}{{if eq .Destination "' + CONTAINER_HOME +
251
+ '"}}{{.Source}}{{end}}{{end}}')
252
+ _GONE = re.compile(r'no such (?:object|container): (\S+)', re.I)
253
+
254
+
255
+ def containers_info(names):
256
+ """{name: {pid, home, running}} for every container in ONE `docker inspect` (it was two spawns per container
257
+ per sync). A name docker ANSWERED does not exist maps to None; a name docker said nothing about is left out (the
258
+ caller must not conclude anything about it). Raises LanesError when docker gave no answer at all.
259
+
260
+ "Error: No such container/object: <name>" (after a `docker rm`) is an answer ABOUT that container. Any other
261
+ failure — daemon unreachable or busy, fork refused, permission — says nothing about it, whatever the exit code.
262
+
263
+ `--type container`: an image or volume that shares a name would otherwise be inspected, and its missing
264
+ `.State` would fail the template for the whole batch. Found containers are printed (one line each, keyed by
265
+ `.Name`) even when docker exits 1 because another name is gone."""
266
+ names = sorted(set(names))
267
+ for name in names:
268
+ if not IDENT.match(name):
269
+ raise LanesError(f'Invalid container name: {name}')
270
+ if not names:
271
+ return {}
272
+ label = 'docker inspect ' + ' '.join(names)
273
+ rc, out, err = run_bounded(['docker', 'inspect', '--type', 'container', '-f', INSPECT_FORMAT, *names], 20)
182
274
  if rc is None:
183
- raise LanesError(f'docker inspect {container}: no answer within 20 s')
184
- if rc:
185
- # "Error: No such object: <name>" (after a `docker rm`) is an answer ABOUT the container. Any other failure
186
- # — daemon unreachable or busy, fork refused, permission — says nothing about it, whatever the exit code.
187
- reason = err.lower()
188
- gone = 'no such object' in reason or 'no such container' in reason
189
- raise (NoSuchContainer if gone else LanesError)(f'docker inspect {container}: {err.strip()}')
190
- return out.strip()
275
+ raise LanesError(f'{label}: no answer within 20 s')
276
+ result = {}
277
+ for line in out.splitlines():
278
+ parts = line.split('\t')
279
+ if len(parts) != 3 or parts[0].lstrip('/') not in names:
280
+ continue
281
+ try:
282
+ pid = int(parts[1] or 0)
283
+ except ValueError:
284
+ continue
285
+ result[parts[0].lstrip('/')] = {'pid': pid, 'home': parts[2].strip(), 'running': pid > 0}
286
+ for match in _GONE.finditer(err):
287
+ if match.group(1) in names and match.group(1) not in result:
288
+ result[match.group(1)] = None
289
+ if rc and not result:
290
+ # Daemon unreachable or busy, fork refused, permission: nothing was said about any container.
291
+ raise LanesError(f'{label}: {err.strip()}')
292
+ return result
191
293
 
192
294
 
193
295
  def container_info(container):
194
- """Init pid and host path of the container's /home/coder volume."""
195
- if not IDENT.match(container):
196
- raise LanesError(f'Invalid container name: {container}')
197
- pid = int(docker_inspect(container, '{{.State.Pid}}') or 0)
198
- home = docker_inspect(
199
- container,
200
- '{{range .Mounts}}{{if eq .Destination "' + CONTAINER_HOME + '"}}{{.Source}}{{end}}{{end}}')
201
- return {'pid': pid, 'home': home, 'running': pid > 0}
296
+ """Init pid and host path of the container's /home/coder volume (one name of `containers_info`)."""
297
+ found = containers_info([container])
298
+ if container not in found:
299
+ raise LanesError(f'docker inspect {container}: no answer about it')
300
+ if found[container] is None:
301
+ raise NoSuchContainer(f'docker inspect {container}: no such container')
302
+ return found[container]
202
303
 
203
304
 
204
305
  def refresh_containers(names):
205
306
  previous = read_json(STATE / 'containers.json', {}) or {}
206
307
  info = {}
207
- for name in sorted(set(names)):
308
+ names = sorted(set(names))
309
+ try: # ONE docker spawn for every container; an invalid name stays that name's error, not the batch's
310
+ answers, failure = containers_info([n for n in names if IDENT.match(n)]), None
311
+ except (LanesError, subprocess.SubprocessError, ValueError) as error:
312
+ answers, failure = {}, error
313
+ for name in names:
208
314
  try:
209
- info[name] = container_info(name)
315
+ if not IDENT.match(name):
316
+ raise LanesError(f'Invalid container name: {name}')
317
+ if name not in answers:
318
+ raise failure or LanesError(f'docker inspect {name}: no answer about it')
319
+ if answers[name] is None:
320
+ raise NoSuchContainer(f'docker inspect {name}: no such container')
321
+ info[name] = answers[name]
210
322
  except NoSuchContainer:
211
323
  # An answer, like the pid 0 of a stopped container: NO 'error' key, so statusd publishes its lanes as
212
324
  # 'missing' instead of keeping their last state as if docker had been silent.
@@ -222,8 +334,8 @@ def refresh_containers(names):
222
334
  else:
223
335
  info[name] = {'pid': 0, 'home': '', 'running': False, 'error': str(error)}
224
336
  # One tiny file per container: the POSIX-sh holders read it without python or docker.
225
- atomic_write(STATE / 'containers' / f'{name}.pid', f"{info[name]['pid']}\n")
226
- atomic_write(STATE / 'containers.json', json.dumps(info, indent=2) + '\n')
337
+ atomic_write_if_changed(STATE / 'containers' / f'{name}.pid', f"{info[name]['pid']}\n")
338
+ atomic_write_if_changed(STATE / 'containers.json', json.dumps(info, indent=2) + '\n')
227
339
  return info
228
340
 
229
341
 
@@ -238,6 +350,36 @@ def host_path(info, container_path):
238
350
  return Path(info['home']) / container_path[len(CONTAINER_HOME) + 1:]
239
351
 
240
352
 
353
+ # A markdown path as a lane prints it: not glued to a word or a `/` on its left, `.md` ending at a word boundary
354
+ # (`foo.md5`, `c.mdx` are not markdown). `lanes preview` (plan Task 15) offers what this finds.
355
+ MARKDOWN_PATH = re.compile(r'(?<![\w/])(?:\.{0,2}/)?[\w./-]+\.md\b')
356
+
357
+
358
+ def markdown_candidates(text, cwd):
359
+ """The in-container paths of the markdown files `text` (a lane's scrollback) names, resolved against the lane's
360
+ `cwd`, de-duplicated with each path at its NEWEST position (oldest first, newest last). A match right after a `:`
361
+ is the path part of a URL (`https://host/a.md`) and is skipped; `~/x.md` is under the container home; a relative
362
+ path needs an absolute `cwd`. Pure: whether a path exists is the caller's question."""
363
+ found = []
364
+ for match in MARKDOWN_PATH.finditer(text):
365
+ raw, before = match.group(0), text[match.start() - 1:match.start()]
366
+ if before == ':' or raw.startswith('//'):
367
+ continue
368
+ if before == '~' and raw.startswith('/'):
369
+ raw = CONTAINER_HOME + raw
370
+ if not raw.startswith('/'):
371
+ if not cwd.startswith('/'):
372
+ continue
373
+ raw = cwd.rstrip('/') + '/' + raw
374
+ path = os.path.normpath(raw)
375
+ if not SAFE_PATH.match(path):
376
+ continue
377
+ if path in found:
378
+ found.remove(path)
379
+ found.append(path)
380
+ return found
381
+
382
+
241
383
  def tmux_socket(info, socket_name):
242
384
  return f"/proc/{info['pid']}/root/tmp/tmux-{CONTAINER_UID}/{socket_name}"
243
385
 
@@ -571,6 +713,100 @@ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
571
713
  f'-e THROTTLE_TRACE={trace} {cmd}')
572
714
 
573
715
 
716
+ # What `lane open` gets when it runs a lane's recorded `create` for a lane that does not exist (plan Task 18; the
717
+ # shapes measured in the boards' terminals.json on 2026-10-03). `lanes doctor` counts the lanes per class.
718
+ # idempotent a tmux new-session with -A: creates the lane, or attaches when it exists (the canonical recipe)
719
+ # attach-only tmux attach without any new-session: a lane that is gone stays gone
720
+ # null no recipe at all
721
+ # external anything else (a script, a new-session without -A, unparseable): run as written, unvouched for
722
+ # retired-path names the retired in-container coder-launch config, which no longer creates anything
723
+ # attach-only, null and retired-path are what `lanes cursor-sync --canonicalize` replaces (cursor_sync.needs_canonical
724
+ # reads them through this function, says why per lane, and also catches an external recipe whose script is missing in
725
+ # the container).
726
+ RECIPE_CLASSES = ('idempotent', 'attach-only', 'null', 'external', 'retired-path')
727
+ RETIRED_RECIPE_PATH = '.config/coder-launch'
728
+ _RECIPE_ATTACH = {'attach-session'[:n] for n in range(1, 15)} # tmux takes any unique prefix (+ `attach`)
729
+ _RECIPE_NEW = {'new-session'[:n] for n in range(3, 12)}
730
+ _TMUX_GLOBAL_ARGS = {'-L', '-S', '-f', '-T', '-c'}
731
+ _NEW_SESSION_ARGS = set('cefFnstxy') # new-session options that take a value
732
+
733
+
734
+ def _recipe_word_lists(text, depth=3):
735
+ """The shell words of a recipe as one list, then one list per quoted word that holds a command of its own
736
+ (`bash -lc 'tmux … new …'`), each kept apart so a nested string never lands among an outer command's options;
737
+ None = unparseable."""
738
+ try:
739
+ words = shlex.split(text)
740
+ except ValueError:
741
+ return None
742
+ lists = [words]
743
+ for word in words:
744
+ if depth and any(c.isspace() for c in word):
745
+ lists += _recipe_word_lists(word, depth - 1) or []
746
+ return lists
747
+
748
+
749
+ def _tmux_commands(lists):
750
+ """[(verb, the words after it)] of every tmux invocation in these word lists (its global options skipped)."""
751
+ found = []
752
+ for words in lists:
753
+ for i, word in enumerate(words):
754
+ if word == 'tmux' or word.endswith('/tmux'):
755
+ j = i + 1
756
+ while j < len(words) and words[j].startswith('-'):
757
+ j += 2 if words[j] in _TMUX_GLOBAL_ARGS else 1
758
+ if j < len(words):
759
+ found.append((words[j], words[j + 1:]))
760
+ return found
761
+
762
+
763
+ def _new_session_options(words):
764
+ """[(letter, value or None)] of a new-session's options, getopt-style: `-ADs x` gives A, D, (s, x); an option's
765
+ VALUE (the `A=1` of `-e A=1`, the `A` of `-s A`) is never read as flags. Stops at the first word that is no
766
+ option (the command)."""
767
+ options, i = [], 0
768
+ while i < len(words) and words[i].startswith('-') and len(words[i]) > 1 and words[i] != '--':
769
+ word, i = words[i], i + 1
770
+ for k, letter in enumerate(word[1:]):
771
+ if letter not in _NEW_SESSION_ARGS:
772
+ options.append((letter, None))
773
+ continue
774
+ value = word[k + 2:]
775
+ if not value: # the value is the next word
776
+ value = words[i] if i < len(words) else ''
777
+ i += 1
778
+ options.append((letter, value))
779
+ break
780
+ return options
781
+
782
+
783
+ def classify_recipe(create):
784
+ """One of RECIPE_CLASSES for a lane's recorded create recipe (None, a string, or anything a registry holds)."""
785
+ if not isinstance(create, str) or not create.strip():
786
+ return 'null'
787
+ if RETIRED_RECIPE_PATH in create:
788
+ return 'retired-path'
789
+ lists = _recipe_word_lists(create)
790
+ if lists is None:
791
+ return 'external'
792
+ commands = _tmux_commands(lists)
793
+ if any(verb in _RECIPE_NEW and ('A', None) in _new_session_options(rest) for verb, rest in commands):
794
+ return 'idempotent'
795
+ if any(verb in _RECIPE_ATTACH for verb, _ in commands) and not any(verb in _RECIPE_NEW for verb, _ in commands):
796
+ return 'attach-only'
797
+ return 'external'
798
+
799
+
800
+ def recipe_identity(create):
801
+ """Does the recipe give the lane it creates its identity — an `-e LANE_ID=…` on a tmux new-session (the
802
+ canonical recipe's flags, DEF-1559)? An env prefix (`LANE_ID=x tmux …`) does not count: it reaches the tmux
803
+ client, never the session."""
804
+ lists = _recipe_word_lists(create) if isinstance(create, str) else None
805
+ return any(verb in _RECIPE_NEW and any(letter == 'e' and value.startswith('LANE_ID=')
806
+ for letter, value in _new_session_options(rest))
807
+ for verb, rest in _tmux_commands(lists or []))
808
+
809
+
574
810
  def ssh_workspaces(catalog):
575
811
  """Native tabs for sessions on hosts outside the workspace containers; no network calls."""
576
812
  def require(pattern, value, label):
@@ -617,7 +853,7 @@ def ssh_workspaces(catalog):
617
853
  def ssh_workspace_board(workspace):
618
854
  """Use the standard sidebar/view layout for fixed, attach-only remote lanes."""
619
855
  return {'id': workspace['id'], 'title': workspace['title'], 'container': '',
620
- 'cwd': '', 'errors': [], 'extras': [], 'discovery_roots': [],
856
+ 'cwd': '', 'errors': [], 'discovery_roots': [],
621
857
  'attach_only': True, 'position': workspace.get('position', 'before-host'),
622
858
  'lanes': [{'id': f"{workspace['id']}--{pane['id']}", 'name': pane['name'],
623
859
  'holder': 'ssh', 'container': '', 'color': None, 'icon': '💻',
@@ -662,10 +898,8 @@ def build_registry(previous=None):
662
898
  lane_id, n = f'{base}-{n}', n + 1
663
899
  lane['id'] = lane_id
664
900
  used_ids.add(lane['id'])
665
- extras = []
666
901
  entry['discovery_roots'] = manifest.get('discoveryRoots') or []
667
902
  entry['lanes'] = lanes
668
- entry['extras'] = extras
669
903
  workspaces.append(entry)
670
904
  workspaces += [ssh_workspace_board(w) for w in remote_tabs if w['layout'] == 'lanes']
671
905
  remote_tabs = [w for w in remote_tabs if w['layout'] != 'lanes']
@@ -682,15 +916,27 @@ def build_registry(previous=None):
682
916
 
683
917
 
684
918
  def publish_registry(registry):
685
- """Publish a prepared registry while holding registry_lock()."""
919
+ """Publish a prepared registry while holding registry_lock(). Every projection is written only when its content
920
+ changed (plan Task 17): `lanes sync` runs every 2 minutes and a registry.json rewritten with nothing but a new
921
+ `generated_at` made statusd, notify and every sidebar reload it. An unchanged registry.json therefore keeps its
922
+ OLD `generated_at`; the sync itself is recorded in `registry.synced`, which only `lanes doctor` reads."""
686
923
  write_resolve_files(registry)
687
924
  export_container_registries(registry)
688
925
  for workspace in registry['workspaces']:
689
926
  focus = next((lane for lane in workspace['lanes'] if lane.get('focus')), None)
690
- candidates = workspace['lanes'] or workspace.get('extras', [])
691
- first = focus or (candidates[0] if candidates else None)
692
- atomic_write(STATE / 'defaults' / workspace['id'], (first['id'] if first else '!empty') + '\n')
693
- atomic_write(STATE / 'registry.json', json.dumps(registry, indent=1, ensure_ascii=False) + '\n')
927
+ first = focus or (workspace['lanes'][0] if workspace['lanes'] else None)
928
+ atomic_write_if_changed(STATE / 'defaults' / workspace['id'], (first['id'] if first else '!empty') + '\n')
929
+ if not _same_json(STATE / 'registry.json', registry):
930
+ atomic_write(STATE / 'registry.json', json.dumps(registry, indent=1, ensure_ascii=False) + '\n')
931
+ atomic_write(STATE / 'registry.synced', f"{registry['generated_at']}\n")
932
+
933
+
934
+ def registry_synced_at():
935
+ """When a sync last published (or confirmed unchanged) the registry; 0 when no sync has recorded it."""
936
+ try:
937
+ return float((STATE / 'registry.synced').read_text().strip())
938
+ except (OSError, ValueError):
939
+ return 0.0
694
940
 
695
941
 
696
942
  def write_resolve_files(registry):
@@ -700,9 +946,9 @@ def write_resolve_files(registry):
700
946
  wanted = set()
701
947
  for tool in registry.get('tools', []):
702
948
  # A tool tab is a view with a fixed target: views/<device>/<view>.main falls back to defaults/<view>.
703
- atomic_write(STATE / 'defaults' / tool['view'], tool['id'] + '\n')
704
- for workspace in registry['workspaces'] + [{'lanes': registry.get('tools', []), 'extras': []}]:
705
- for lane in workspace['lanes'] + workspace['extras']:
949
+ atomic_write_if_changed(STATE / 'defaults' / tool['view'], tool['id'] + '\n')
950
+ for workspace in registry['workspaces'] + [{'lanes': registry.get('tools', [])}]:
951
+ for lane in workspace['lanes']:
706
952
  wanted.add(lane['id'])
707
953
  if lane['holder'] == 'tmux':
708
954
  fields = ['tmux', lane['container'], lane['tmux']['socket'], lane['tmux']['session']]
@@ -712,7 +958,7 @@ def write_resolve_files(registry):
712
958
  fields = ['zmx', lane['container'], lane['zmx']['name'], lane['zmx'].get('version', '')]
713
959
  else:
714
960
  fields = ['exec', lane['container'], lane['exec'], lane.get('cwd', CONTAINER_HOME)]
715
- atomic_write(directory / lane['id'], '\n'.join(fields + [lane['name']]) + '\n')
961
+ atomic_write_if_changed(directory / lane['id'], '\n'.join(fields + [lane['name']]) + '\n')
716
962
  for stale in directory.iterdir():
717
963
  if stale.name not in wanted and not stale.name.startswith('.'):
718
964
  stale.unlink()
@@ -733,9 +979,10 @@ def export_container_registries(registry):
733
979
  for container, lanes in per_container.items():
734
980
  home = (registry['containers'].get(container) or {}).get('home')
735
981
  if home and (Path(home) / '.lz/bin/lane').exists():
736
- atomic_write(Path(home) / '.lz/registry.json',
737
- json.dumps({'version': 1, 'generated_at': registry['generated_at'], 'lanes': lanes},
738
- ensure_ascii=False, indent=1) + '\n', mode=0o644)
982
+ document = {'version': 1, 'generated_at': registry['generated_at'], 'lanes': lanes}
983
+ if not _same_json(Path(home) / '.lz/registry.json', document): # `generated_at` alone is no change
984
+ atomic_write(Path(home) / '.lz/registry.json',
985
+ json.dumps(document, ensure_ascii=False, indent=1) + '\n', mode=0o644)
739
986
  written.append(container)
740
987
  return written
741
988
 
@@ -752,7 +999,7 @@ def load_registry():
752
999
  def find_lane(registry, lane_id):
753
1000
  status = read_json(STATE / 'status.json', {}) or {}
754
1001
  for workspace in registry['workspaces']:
755
- for lane in workspace['lanes'] + workspace.get('extras', []):
1002
+ for lane in workspace['lanes']:
756
1003
  if lane['id'] == lane_id:
757
1004
  return workspace, lane
758
1005
  # Registration can move a session before the next observer cycle clears its previous workspace.