@vibelord/lanes 1.0.0 → 1.0.2

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 (56) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +92 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +928 -150
  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 +62 -5
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +8 -0
  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 +326 -25
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +158 -6
  17. package/payload/lanes/docs/DESIGN.md +250 -0
  18. package/payload/lanes/docs/INSTALL.md +39 -0
  19. package/payload/lanes/docs/RUNBOOK.md +507 -24
  20. package/payload/lanes/docs/SHARING.md +1 -1
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders/ssh.sh +18 -0
  23. package/payload/lanes/holders.lock +3 -0
  24. package/payload/lanes/install/enable-phone.sh +15 -6
  25. package/payload/lanes/install/enable-web.sh +19 -6
  26. package/payload/lanes/install/host-install.sh +10 -1
  27. package/payload/lanes/install/services.py +30 -3
  28. package/payload/lanes/install.sh +108 -3
  29. package/payload/lanes/libexec/agent_state.py +246 -0
  30. package/payload/lanes/libexec/connection.py +178 -0
  31. package/payload/lanes/libexec/cursor_sync.py +147 -6
  32. package/payload/lanes/libexec/device_sync.py +134 -0
  33. package/payload/lanes/libexec/economy_poll.py +685 -0
  34. package/payload/lanes/libexec/guard.py +27 -1
  35. package/payload/lanes/libexec/lanes_lib.py +436 -36
  36. package/payload/lanes/libexec/notify.py +317 -0
  37. package/payload/lanes/libexec/registrations.py +148 -0
  38. package/payload/lanes/libexec/session_actions.py +53 -22
  39. package/payload/lanes/libexec/sidebar.py +1190 -186
  40. package/payload/lanes/libexec/statusd.py +352 -90
  41. package/payload/lanes/libexec/tokens.py +310 -0
  42. package/payload/lanes/libexec/tokens.sh +11 -0
  43. package/payload/lanes/libexec/view.sh +22 -11
  44. package/payload/lanes/systemd/lanes-economy.service +20 -0
  45. package/payload/lanes/systemd/lanes-notify.service +19 -0
  46. package/payload/lanes/systemd/lanes-sync.service +3 -0
  47. package/payload/lanes/web/README.md +96 -2
  48. package/payload/lanes/web/browser.css +22 -0
  49. package/payload/lanes/web/browser.js +73 -0
  50. package/payload/lanes/web/index.html +2 -1
  51. package/payload/lanes/web/mobile.css +108 -221
  52. package/payload/lanes/web/mobile.js +105 -55
  53. package/payload/lanes/workspaces.example/README.md +9 -3
  54. package/payload/lanes/zellij/config.kdl +128 -19
  55. package/payload/manifest.json +7 -3
  56. 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
@@ -6,10 +6,14 @@ Safety rules encoded here (see docs/ARCHITECTURE.md):
6
6
  * In-container sockets are reached through /proc/<container-init-pid>/root, anchored on the
7
7
  container's init pid (stable across tmux-server restarts), never through docker exec.
8
8
  """
9
+ import contextlib
10
+ import fcntl
9
11
  import hashlib
10
12
  import json
11
13
  import os
12
14
  import re
15
+ import shlex
16
+ import signal
13
17
  import subprocess
14
18
  import tempfile
15
19
  import time
@@ -115,6 +119,28 @@ def atomic_write(path, text, mode=0o600):
115
119
  raise
116
120
 
117
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
+
118
144
  def read_json(path, default=None):
119
145
  try:
120
146
  return json.loads(Path(path).read_text())
@@ -122,6 +148,53 @@ def read_json(path, default=None):
122
148
  return default
123
149
 
124
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
+
125
198
  def slug(text):
126
199
  text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode()
127
200
  text = re.sub(r'[^A-Za-z0-9]+', '-', text).strip('-').lower()
@@ -174,36 +247,78 @@ class NoSuchContainer(LanesError):
174
247
  """docker ANSWERED that the container does not exist — an observation, unlike every other docker failure."""
175
248
 
176
249
 
177
- def docker_inspect(container, template):
178
- 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)
179
274
  if rc is None:
180
- raise LanesError(f'docker inspect {container}: no answer within 20 s')
181
- if rc:
182
- # "Error: No such object: <name>" (after a `docker rm`) is an answer ABOUT the container. Any other failure
183
- # — daemon unreachable or busy, fork refused, permission — says nothing about it, whatever the exit code.
184
- reason = err.lower()
185
- gone = 'no such object' in reason or 'no such container' in reason
186
- raise (NoSuchContainer if gone else LanesError)(f'docker inspect {container}: {err.strip()}')
187
- 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
188
293
 
189
294
 
190
295
  def container_info(container):
191
- """Init pid and host path of the container's /home/coder volume."""
192
- if not IDENT.match(container):
193
- raise LanesError(f'Invalid container name: {container}')
194
- pid = int(docker_inspect(container, '{{.State.Pid}}') or 0)
195
- home = docker_inspect(
196
- container,
197
- '{{range .Mounts}}{{if eq .Destination "' + CONTAINER_HOME + '"}}{{.Source}}{{end}}{{end}}')
198
- 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]
199
303
 
200
304
 
201
305
  def refresh_containers(names):
202
306
  previous = read_json(STATE / 'containers.json', {}) or {}
203
307
  info = {}
204
- 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:
205
314
  try:
206
- 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]
207
322
  except NoSuchContainer:
208
323
  # An answer, like the pid 0 of a stopped container: NO 'error' key, so statusd publishes its lanes as
209
324
  # 'missing' instead of keeping their last state as if docker had been silent.
@@ -219,8 +334,8 @@ def refresh_containers(names):
219
334
  else:
220
335
  info[name] = {'pid': 0, 'home': '', 'running': False, 'error': str(error)}
221
336
  # One tiny file per container: the POSIX-sh holders read it without python or docker.
222
- atomic_write(STATE / 'containers' / f'{name}.pid', f"{info[name]['pid']}\n")
223
- 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')
224
339
  return info
225
340
 
226
341
 
@@ -235,6 +350,36 @@ def host_path(info, container_path):
235
350
  return Path(info['home']) / container_path[len(CONTAINER_HOME) + 1:]
236
351
 
237
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
+
238
383
  def tmux_socket(info, socket_name):
239
384
  return f"/proc/{info['pid']}/root/tmp/tmux-{CONTAINER_UID}/{socket_name}"
240
385
 
@@ -397,6 +542,26 @@ def read_health(state_dir=None):
397
542
 
398
543
  # ------------------------------------------------------------------ registry
399
544
 
545
+ @contextlib.contextmanager
546
+ def registry_lock(timeout=5):
547
+ """Serialize sync, registration and close across device sidebars and the timer."""
548
+ STATE.mkdir(parents=True, exist_ok=True)
549
+ with (STATE / 'registry.lock').open('a') as handle:
550
+ deadline = time.monotonic() + timeout
551
+ while True:
552
+ try:
553
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
554
+ break
555
+ except BlockingIOError:
556
+ if time.monotonic() >= deadline:
557
+ raise LanesError('Workspace sync is busy; try again shortly')
558
+ time.sleep(0.05)
559
+ try:
560
+ yield
561
+ finally:
562
+ fcntl.flock(handle, fcntl.LOCK_UN)
563
+
564
+
400
565
  def _require(pattern, value, label):
401
566
  if not isinstance(value, str) or not pattern.match(value):
402
567
  raise LanesError(f'Invalid {label}: {value!r}')
@@ -467,6 +632,8 @@ def _workspace_lanes(manifest, containers):
467
632
  names.add(title)
468
633
  color = entry.get('color') if isinstance(entry.get('color'), str) else None
469
634
  icon = entry.get('icon') if isinstance(entry.get('icon'), str) else None
635
+ # Keyed by the DECLARED (unprefixed) terminal name; written by session_actions.rename_registered.
636
+ # The override changes `name` only: the id derives from `registry_name` (lane_id_for).
470
637
  display = (repo.get('displayNames') or {}).get(name)
471
638
  lanes.append({
472
639
  'name': _title(display) if display else title, 'registry_name': title, 'repo': repo_id, 'container': container, 'holder': 'tmux',
@@ -499,8 +666,206 @@ def read_site_json(path):
499
666
  return data
500
667
 
501
668
 
502
- def build_registry(previous=None):
669
+ def manifest_path(workspace_id):
670
+ """The `workspaces.<board>.json` that build_registry opens for this board (resolved through catalog.json)."""
503
671
  catalog = read_site_json(workspaces_dir() / 'catalog.json')['workspaces']
672
+ workspace = next((w for w in catalog if w.get('id') == workspace_id), None)
673
+ if workspace is None:
674
+ raise LanesError(f'Workspace {workspace_id!r} is not in catalog.json')
675
+ return workspaces_dir() / workspace['manifest']
676
+
677
+
678
+ def lane_id_for(workspace_id, lane):
679
+ """The stable id: workspace + slug of the DECLARED terminal name. A display override (`displayNames`)
680
+ changes `name`, never the id, or every resolve file, view target and `lane open <id>` would break."""
681
+ return f"{workspace_id}--{slug(lane.get('registry_name') or lane['name'])}"
682
+
683
+
684
+ # The proxy's `x-throttle-trace` charset and cap (src/proxy.rs): a lane trace outside it would be dropped there.
685
+ TRACE = re.compile(r'[A-Za-z0-9._/-]{1,256}')
686
+
687
+
688
+ def lane_trace(lane_id, ws):
689
+ """`lanes/<ws>/<id>` — the trace a lane's agents send as `x-throttle-trace`; None if the proxy would reject it."""
690
+ if not isinstance(ws, str) or not isinstance(lane_id, str):
691
+ return None
692
+ trace = f'lanes/{ws}/{lane_id}'
693
+ return trace if TRACE.fullmatch(trace) else None
694
+
695
+
696
+ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
697
+ """The one create recipe that gives a new tmux lane its identity: LANE_ID / LANE_WS for scripts and hooks, the
698
+ lanes/<ws>/<id> trace for Throttle (ANTHROPIC_CUSTOM_HEADERS for Claude Code, THROTTLE_TRACE for the rest).
699
+ `tmux new -A` attaches when the session already exists, so the env lands only on a real create — never through
700
+ `set-environment`, which would leak into unrelated windows. `cmd` is a shell command string, appended as is."""
701
+ trace = lane_trace(lane_id, ws)
702
+ if trace is None:
703
+ raise LanesError(f'Invalid lane trace: lanes/{ws}/{lane_id} (allowed: A-Z a-z 0-9 . _ - /, at most 256)')
704
+ _require(IDENT, socket, 'tmux socket')
705
+ _require(TMUX_SESSION, session, 'tmux session')
706
+ if not isinstance(folder, str) or not folder.startswith('/'):
707
+ raise LanesError(f'Invalid lane folder: {folder!r}')
708
+ if not isinstance(cmd, str) or not cmd.strip():
709
+ raise LanesError('Invalid lane command')
710
+ header = shlex.quote(f'x-throttle-trace: {trace}')
711
+ return (f'tmux -L {shlex.quote(socket)} new -ADs {shlex.quote(session)} -c {shlex.quote(folder)} '
712
+ f'-e LANE_ID={lane_id} -e LANE_WS={ws} -e ANTHROPIC_CUSTOM_HEADERS={header} '
713
+ f'-e THROTTLE_TRACE={trace} {cmd}')
714
+
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-1478)? 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
+
810
+ def ssh_workspaces(catalog):
811
+ """Native tabs for sessions on hosts outside the workspace containers; no network calls."""
812
+ def require(pattern, value, label):
813
+ if not isinstance(value, str) or not pattern.fullmatch(value):
814
+ raise LanesError(f'Invalid {label}: {value!r}')
815
+ return value
816
+
817
+ result = []
818
+ ids = {w['id'] for w in catalog['workspaces']}
819
+ titles = {w['title'] for w in catalog['workspaces']} | {'Host', 'Throttle'}
820
+ host_pattern = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_.-]*(?:@[A-Za-z0-9_][A-Za-z0-9_.-]*)?$')
821
+ for source in catalog.get('sshWorkspaces', []):
822
+ ident = require(IDENT, source.get('id'), 'SSH workspace id')
823
+ title = _title(source.get('title'))
824
+ if ident in ids or title in titles:
825
+ raise LanesError('Duplicate SSH workspace id or title')
826
+ ids.add(ident)
827
+ titles.add(title)
828
+ host = require(host_pattern, source.get('host'), 'SSH host')
829
+ command = require(SAFE_PATH, source.get('command'), 'SSH command path')
830
+ if '..' in command.split('/'):
831
+ raise LanesError('Unsafe SSH command path')
832
+ panes, pane_ids = [], set()
833
+ for pane in source.get('panes', []):
834
+ pane_id = require(IDENT, pane.get('id'), 'SSH pane id')
835
+ if pane_id in pane_ids:
836
+ raise LanesError('Duplicate SSH pane id')
837
+ pane_ids.add(pane_id)
838
+ panes.append({'id': pane_id, 'name': _title(pane.get('name'))})
839
+ layout = source.get('layout', 'split')
840
+ if layout not in ('split', 'lanes'):
841
+ raise LanesError('SSH workspace layout must be split or lanes')
842
+ limit = 32 if layout == 'lanes' else 3
843
+ if not 1 <= len(panes) <= limit:
844
+ raise LanesError(f'An SSH workspace needs one to {limit} panes')
845
+ position = source.get('position', 'before-host')
846
+ if position not in ('workspace', 'before-host'):
847
+ raise LanesError('SSH workspace position must be workspace or before-host')
848
+ result.append({'id': ident, 'title': title, 'host': host, 'command': command,
849
+ 'panes': panes, 'layout': layout, 'position': position})
850
+ return result
851
+
852
+
853
+ def ssh_workspace_board(workspace):
854
+ """Use the standard sidebar/view layout for fixed, attach-only remote lanes."""
855
+ return {'id': workspace['id'], 'title': workspace['title'], 'container': '',
856
+ 'cwd': '', 'errors': [], 'discovery_roots': [],
857
+ 'attach_only': True, 'position': workspace.get('position', 'before-host'),
858
+ 'lanes': [{'id': f"{workspace['id']}--{pane['id']}", 'name': pane['name'],
859
+ 'holder': 'ssh', 'container': '', 'color': None, 'icon': '💻',
860
+ 'focus': index == 0,
861
+ 'ssh': {'host': workspace['host'], 'command': workspace['command'], 'pane': pane['id']}}
862
+ for index, pane in enumerate(workspace['panes'])]}
863
+
864
+
865
+ def build_registry(previous=None):
866
+ site = read_site_json(workspaces_dir() / 'catalog.json')
867
+ remote_tabs = ssh_workspaces(site)
868
+ catalog = site['workspaces']
504
869
  manifests = {}
505
870
  container_names = set()
506
871
  for workspace in catalog:
@@ -527,17 +892,17 @@ def build_registry(previous=None):
527
892
  (manifest.get('repositories') or [{}])[0].get('host', ''))
528
893
  for lane in lanes:
529
894
  if 'id' not in lane: # lanes carried over from a previous registry keep their ids
530
- base = f"{entry['id']}--{slug(lane['name'])}"
895
+ base = lane_id_for(entry['id'], lane)
531
896
  lane_id, n = base, 2
532
897
  while lane_id in used_ids:
533
898
  lane_id, n = f'{base}-{n}', n + 1
534
899
  lane['id'] = lane_id
535
900
  used_ids.add(lane['id'])
536
- extras = []
537
901
  entry['discovery_roots'] = manifest.get('discoveryRoots') or []
538
902
  entry['lanes'] = lanes
539
- entry['extras'] = extras
540
903
  workspaces.append(entry)
904
+ workspaces += [ssh_workspace_board(w) for w in remote_tabs if w['layout'] == 'lanes']
905
+ remote_tabs = [w for w in remote_tabs if w['layout'] != 'lanes']
541
906
  # One shared tool tab: the Throttle monitor of the container that hosts most boards.
542
907
  hosts = [(m.get('monitor') or {}).get('host') for m in manifests.values() if (m.get('monitor') or {}).get('host')]
543
908
  tools = []
@@ -545,7 +910,33 @@ def build_registry(previous=None):
545
910
  tools.append({'id': 'tools--throttle-monitor', 'view': '_throttle', 'tab': 'Throttle', 'name': 'Throttle Monitor',
546
911
  'holder': 'exec', 'exec': 'monitor', 'container': max(set(hosts), key=hosts.count),
547
912
  'cwd': CONTAINER_HOME, 'color': None, 'icon': '📊'})
548
- return {'version': 1, 'generated_at': time.time(), 'containers': containers, 'workspaces': workspaces, 'tools': tools}
913
+ import registrations
914
+ return registrations.apply({'version': 1, 'generated_at': time.time(), 'containers': containers,
915
+ 'workspaces': workspaces, 'tools': tools, 'ssh_workspaces': remote_tabs})
916
+
917
+
918
+ def publish_registry(registry):
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."""
923
+ write_resolve_files(registry)
924
+ export_container_registries(registry)
925
+ for workspace in registry['workspaces']:
926
+ focus = next((lane for lane in workspace['lanes'] if lane.get('focus')), None)
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
549
940
 
550
941
 
551
942
  def write_resolve_files(registry):
@@ -555,17 +946,19 @@ def write_resolve_files(registry):
555
946
  wanted = set()
556
947
  for tool in registry.get('tools', []):
557
948
  # A tool tab is a view with a fixed target: views/<device>/<view>.main falls back to defaults/<view>.
558
- atomic_write(STATE / 'defaults' / tool['view'], tool['id'] + '\n')
559
- for workspace in registry['workspaces'] + [{'lanes': registry.get('tools', []), 'extras': []}]:
560
- 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']:
561
952
  wanted.add(lane['id'])
562
953
  if lane['holder'] == 'tmux':
563
954
  fields = ['tmux', lane['container'], lane['tmux']['socket'], lane['tmux']['session']]
955
+ elif lane['holder'] == 'ssh':
956
+ fields = ['ssh', lane['ssh']['host'], lane['ssh']['command'], lane['ssh']['pane']]
564
957
  elif lane['holder'] == 'zmx':
565
958
  fields = ['zmx', lane['container'], lane['zmx']['name'], lane['zmx'].get('version', '')]
566
959
  else:
567
960
  fields = ['exec', lane['container'], lane['exec'], lane.get('cwd', CONTAINER_HOME)]
568
- 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')
569
962
  for stale in directory.iterdir():
570
963
  if stale.name not in wanted and not stale.name.startswith('.'):
571
964
  stale.unlink()
@@ -577,6 +970,8 @@ def export_container_registries(registry):
577
970
  per_container = {}
578
971
  for workspace in registry['workspaces']:
579
972
  for lane in workspace['lanes']:
973
+ if not lane.get('container'):
974
+ continue
580
975
  per_container.setdefault(lane['container'], {})[lane['id']] = {
581
976
  'name': lane['name'], 'ws': workspace['id'], 'holder': lane['holder'], 'tmux': lane.get('tmux'),
582
977
  'zmx': lane.get('zmx'), 'create': lane.get('create'), 'folder': lane.get('folder')}
@@ -584,9 +979,10 @@ def export_container_registries(registry):
584
979
  for container, lanes in per_container.items():
585
980
  home = (registry['containers'].get(container) or {}).get('home')
586
981
  if home and (Path(home) / '.lz/bin/lane').exists():
587
- atomic_write(Path(home) / '.lz/registry.json',
588
- json.dumps({'version': 1, 'generated_at': registry['generated_at'], 'lanes': lanes},
589
- 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)
590
986
  written.append(container)
591
987
  return written
592
988
 
@@ -603,8 +999,12 @@ def load_registry():
603
999
  def find_lane(registry, lane_id):
604
1000
  status = read_json(STATE / 'status.json', {}) or {}
605
1001
  for workspace in registry['workspaces']:
606
- discovered = (status.get('unregistered') or {}).get(workspace['id'], [])
607
- for lane in workspace['lanes'] + workspace['extras'] + discovered:
1002
+ for lane in workspace['lanes']:
1003
+ if lane['id'] == lane_id:
1004
+ return workspace, lane
1005
+ # Registration can move a session before the next observer cycle clears its previous workspace.
1006
+ for workspace in registry['workspaces']:
1007
+ for lane in (status.get('unregistered') or {}).get(workspace['id'], []):
608
1008
  if lane['id'] == lane_id:
609
1009
  return workspace, lane
610
1010
  raise LanesError(f'Unknown lane: {lane_id}')