@vibelord/lanes 1.0.0 → 1.0.1

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 (34) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +56 -1
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +281 -104
  5. package/payload/lanes/client/lanes-launch +44 -2
  6. package/payload/lanes/container/env.sh +8 -0
  7. package/payload/lanes/container/lane +19 -2
  8. package/payload/lanes/docs/ARCHITECTURE.md +86 -1
  9. package/payload/lanes/docs/DESIGN.md +72 -0
  10. package/payload/lanes/docs/INSTALL.md +39 -0
  11. package/payload/lanes/docs/RUNBOOK.md +94 -1
  12. package/payload/lanes/holders/ssh.sh +18 -0
  13. package/payload/lanes/install/enable-phone.sh +15 -6
  14. package/payload/lanes/install/enable-web.sh +19 -6
  15. package/payload/lanes/libexec/agent_state.py +214 -0
  16. package/payload/lanes/libexec/connection.py +178 -0
  17. package/payload/lanes/libexec/cursor_sync.py +139 -6
  18. package/payload/lanes/libexec/device_sync.py +134 -0
  19. package/payload/lanes/libexec/lanes_lib.py +158 -5
  20. package/payload/lanes/libexec/registrations.py +148 -0
  21. package/payload/lanes/libexec/session_actions.py +47 -15
  22. package/payload/lanes/libexec/sidebar.py +372 -61
  23. package/payload/lanes/libexec/statusd.py +126 -33
  24. package/payload/lanes/libexec/tokens.py +279 -0
  25. package/payload/lanes/libexec/tokens.sh +11 -0
  26. package/payload/lanes/libexec/view.sh +9 -8
  27. package/payload/lanes/web/README.md +15 -0
  28. package/payload/lanes/web/browser.css +22 -0
  29. package/payload/lanes/web/browser.js +73 -0
  30. package/payload/lanes/web/mobile.css +65 -33
  31. package/payload/lanes/web/mobile.js +23 -5
  32. package/payload/lanes/workspaces.example/README.md +9 -3
  33. package/payload/lanes/zellij/config.kdl +61 -3
  34. package/payload/manifest.json +6 -3
@@ -0,0 +1,134 @@
1
+ """Reconcile catalog tabs without switching focus or touching persistent agents."""
2
+ import fcntl
3
+ import json
4
+ import shlex
5
+
6
+ import lanes_lib as L
7
+
8
+
9
+ def observe(device, call):
10
+ values = []
11
+ for action in ('list-tabs', 'list-panes'):
12
+ rc, output, error = call(['-s', device, 'action', action, '--json'], 20)
13
+ if rc != 0:
14
+ raise L.LanesError(f'{device}: cannot read {action}: {error or "no answer"}')
15
+ try:
16
+ value = json.loads(output)
17
+ if not isinstance(value, list):
18
+ raise ValueError('expected a list')
19
+ except (ValueError, TypeError) as error:
20
+ raise L.LanesError(f'{device}: invalid {action} response') from error
21
+ values.append(value)
22
+ tabs, panes = values
23
+ if tabs and not panes:
24
+ raise L.LanesError(f"{device}: pane identities unavailable")
25
+ if any(type(t.get('tab_id')) is not int or type(t.get('position')) is not int for t in tabs):
26
+ raise L.LanesError(f'{device}: tab identities unavailable')
27
+ return tabs, panes
28
+
29
+
30
+ def identities(device, tabs, panes, registry, mobile):
31
+ """Read workspace IDs from the launched commands, independent of tab labels."""
32
+ by_tab = {}
33
+ for pane in panes:
34
+ if pane.get('is_plugin'):
35
+ continue
36
+ try:
37
+ args = shlex.split(pane.get('terminal_command') or pane.get('pane_command') or '')
38
+ except ValueError:
39
+ continue
40
+ for script in ('sidebar', 'view.sh'):
41
+ path = str(L.REPO / 'libexec' / script)
42
+ if path not in args:
43
+ continue
44
+ tail = args[args.index(path) + 1:]
45
+ if len(tail) < 2 or tail[0] != device:
46
+ continue
47
+ workspace = tail[1]
48
+ if workspace.startswith('_'):
49
+ key = 'tool/' + workspace
50
+ else:
51
+ role = 'picker' if mobile and script == 'sidebar' else 'view'
52
+ key = f'workspace/{workspace}/{role}'
53
+ 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:]
57
+ for workspace in registry.get('ssh_workspaces', []):
58
+ if (len(tail) == 3 and tail[:2] == [workspace['host'], workspace['command']]
59
+ and any(pane['id'] == tail[2] for pane in workspace['panes'])):
60
+ by_tab.setdefault(pane['tab_id'], set()).add('ssh/' + workspace['id'])
61
+ result = {}
62
+ for tab in tabs:
63
+ keys = by_tab.get(tab['tab_id'], set())
64
+ if not keys and tab['name'] == 'Host':
65
+ keys = {'host'}
66
+ if len(keys) > 1:
67
+ raise L.LanesError(f'{device}: ambiguous workspace tab {tab["tab_id"]}')
68
+ for key in keys:
69
+ if key in result:
70
+ raise L.LanesError(f'{device}: duplicate workspace identity {key}')
71
+ result[key] = tab
72
+ return result
73
+
74
+
75
+ def reconcile(device, registry, specs, mobile, call):
76
+ lock = L.STATE / 'locks' / f'device-sync.{device}.lock'
77
+ lock.parent.mkdir(parents=True, exist_ok=True)
78
+ with lock.open('a') as handle:
79
+ fcntl.flock(handle, fcntl.LOCK_EX)
80
+ tabs, panes = observe(device, call)
81
+ known = identities(device, tabs, panes, registry, mobile)
82
+ wanted = []
83
+ changes = []
84
+
85
+ def action(args):
86
+ rc, output, error = call(['-s', device, 'action'] + args, 30)
87
+ if rc != 0:
88
+ raise L.LanesError(f'{device}: {args[0]} failed: {error or "no answer"}')
89
+ return output
90
+
91
+ for spec in specs:
92
+ tab = known.get(spec['key'])
93
+ if tab is None:
94
+ if any(t['name'] == spec['title'] for t in tabs):
95
+ raise L.LanesError(f'{device}: tab {spec["title"]!r} has no matching workspace identity; left intact')
96
+ # Zellij 0.45.1 acknowledges background tabs without spawning their
97
+ # panes when no client is attached. Wait for a client; the ensure
98
+ # timer retries after attach without leaving empty tabs behind.
99
+ if not any(t.get('active') is True for t in tabs):
100
+ continue
101
+ output = action(['new-tab', '--no-focus', '--name', spec['title'],
102
+ '--layout-string', spec['layout']])
103
+ # Zellij 0.45.1 returns the stable ID. Do not guess after a timeout
104
+ # or repeat creation if the answer is missing: next sync observes it.
105
+ try:
106
+ tab_id = int(output.strip())
107
+ except ValueError as error:
108
+ raise L.LanesError(f'{device}: created tab ID unavailable; retry sync after observation') from error
109
+ tab = {'tab_id': tab_id, 'name': spec['title'], 'position': len(tabs)}
110
+ tabs.append(tab)
111
+ changes.append('added ' + spec['title'])
112
+ elif tab['name'] != spec['title']:
113
+ action(['rename-tab', spec['title'], '--tab-id', str(tab['tab_id'])])
114
+ changes.append('renamed ' + tab['name'] + ' to ' + spec['title'])
115
+ wanted.append(tab['tab_id'])
116
+
117
+ # Sort managed tabs by adjacent moves using stable IDs. Each observation
118
+ # must answer. Unmanaged tabs remain open; no close/rebuild/focus action exists.
119
+ for position, tab_id in enumerate(wanted):
120
+ for _ in range(len(tabs) + 1):
121
+ rc, output, error = call(['-s', device, 'action', 'list-tabs', '--json'], 20)
122
+ if rc != 0:
123
+ raise L.LanesError(f'{device}: cannot check tab order: {error or "no answer"}')
124
+ live = json.loads(output)
125
+ tab = next((t for t in live if t.get('tab_id') == tab_id), None)
126
+ if tab is None:
127
+ raise L.LanesError(f'{device}: workspace tab changed during sync')
128
+ if tab['position'] == position:
129
+ break
130
+ action(['move-tab', 'left' if tab['position'] > position else 'right',
131
+ '--tab-id', str(tab_id)])
132
+ else:
133
+ raise L.LanesError(f'{device}: tab order did not settle')
134
+ return changes
@@ -6,10 +6,13 @@ 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
13
16
  import subprocess
14
17
  import tempfile
15
18
  import time
@@ -397,6 +400,26 @@ def read_health(state_dir=None):
397
400
 
398
401
  # ------------------------------------------------------------------ registry
399
402
 
403
+ @contextlib.contextmanager
404
+ def registry_lock(timeout=5):
405
+ """Serialize sync, registration and close across device sidebars and the timer."""
406
+ STATE.mkdir(parents=True, exist_ok=True)
407
+ with (STATE / 'registry.lock').open('a') as handle:
408
+ deadline = time.monotonic() + timeout
409
+ while True:
410
+ try:
411
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
412
+ break
413
+ except BlockingIOError:
414
+ if time.monotonic() >= deadline:
415
+ raise LanesError('Workspace sync is busy; try again shortly')
416
+ time.sleep(0.05)
417
+ try:
418
+ yield
419
+ finally:
420
+ fcntl.flock(handle, fcntl.LOCK_UN)
421
+
422
+
400
423
  def _require(pattern, value, label):
401
424
  if not isinstance(value, str) or not pattern.match(value):
402
425
  raise LanesError(f'Invalid {label}: {value!r}')
@@ -467,6 +490,8 @@ def _workspace_lanes(manifest, containers):
467
490
  names.add(title)
468
491
  color = entry.get('color') if isinstance(entry.get('color'), str) else None
469
492
  icon = entry.get('icon') if isinstance(entry.get('icon'), str) else None
493
+ # Keyed by the DECLARED (unprefixed) terminal name; written by session_actions.rename_registered.
494
+ # The override changes `name` only: the id derives from `registry_name` (lane_id_for).
470
495
  display = (repo.get('displayNames') or {}).get(name)
471
496
  lanes.append({
472
497
  'name': _title(display) if display else title, 'registry_name': title, 'repo': repo_id, 'container': container, 'holder': 'tmux',
@@ -499,8 +524,112 @@ def read_site_json(path):
499
524
  return data
500
525
 
501
526
 
502
- def build_registry(previous=None):
527
+ def manifest_path(workspace_id):
528
+ """The `workspaces.<board>.json` that build_registry opens for this board (resolved through catalog.json)."""
503
529
  catalog = read_site_json(workspaces_dir() / 'catalog.json')['workspaces']
530
+ workspace = next((w for w in catalog if w.get('id') == workspace_id), None)
531
+ if workspace is None:
532
+ raise LanesError(f'Workspace {workspace_id!r} is not in catalog.json')
533
+ return workspaces_dir() / workspace['manifest']
534
+
535
+
536
+ def lane_id_for(workspace_id, lane):
537
+ """The stable id: workspace + slug of the DECLARED terminal name. A display override (`displayNames`)
538
+ changes `name`, never the id, or every resolve file, view target and `lane open <id>` would break."""
539
+ return f"{workspace_id}--{slug(lane.get('registry_name') or lane['name'])}"
540
+
541
+
542
+ # The proxy's `x-throttle-trace` charset and cap (src/proxy.rs): a lane trace outside it would be dropped there.
543
+ TRACE = re.compile(r'[A-Za-z0-9._/-]{1,256}')
544
+
545
+
546
+ def lane_trace(lane_id, ws):
547
+ """`lanes/<ws>/<id>` — the trace a lane's agents send as `x-throttle-trace`; None if the proxy would reject it."""
548
+ if not isinstance(ws, str) or not isinstance(lane_id, str):
549
+ return None
550
+ trace = f'lanes/{ws}/{lane_id}'
551
+ return trace if TRACE.fullmatch(trace) else None
552
+
553
+
554
+ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
555
+ """The one create recipe that gives a new tmux lane its identity: LANE_ID / LANE_WS for scripts and hooks, the
556
+ lanes/<ws>/<id> trace for Throttle (ANTHROPIC_CUSTOM_HEADERS for Claude Code, THROTTLE_TRACE for the rest).
557
+ `tmux new -A` attaches when the session already exists, so the env lands only on a real create — never through
558
+ `set-environment`, which would leak into unrelated windows. `cmd` is a shell command string, appended as is."""
559
+ trace = lane_trace(lane_id, ws)
560
+ if trace is None:
561
+ raise LanesError(f'Invalid lane trace: lanes/{ws}/{lane_id} (allowed: A-Z a-z 0-9 . _ - /, at most 256)')
562
+ _require(IDENT, socket, 'tmux socket')
563
+ _require(TMUX_SESSION, session, 'tmux session')
564
+ if not isinstance(folder, str) or not folder.startswith('/'):
565
+ raise LanesError(f'Invalid lane folder: {folder!r}')
566
+ if not isinstance(cmd, str) or not cmd.strip():
567
+ raise LanesError('Invalid lane command')
568
+ header = shlex.quote(f'x-throttle-trace: {trace}')
569
+ return (f'tmux -L {shlex.quote(socket)} new -ADs {shlex.quote(session)} -c {shlex.quote(folder)} '
570
+ f'-e LANE_ID={lane_id} -e LANE_WS={ws} -e ANTHROPIC_CUSTOM_HEADERS={header} '
571
+ f'-e THROTTLE_TRACE={trace} {cmd}')
572
+
573
+
574
+ def ssh_workspaces(catalog):
575
+ """Native tabs for sessions on hosts outside the workspace containers; no network calls."""
576
+ def require(pattern, value, label):
577
+ if not isinstance(value, str) or not pattern.fullmatch(value):
578
+ raise LanesError(f'Invalid {label}: {value!r}')
579
+ return value
580
+
581
+ result = []
582
+ ids = {w['id'] for w in catalog['workspaces']}
583
+ titles = {w['title'] for w in catalog['workspaces']} | {'Host', 'Throttle'}
584
+ host_pattern = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_.-]*(?:@[A-Za-z0-9_][A-Za-z0-9_.-]*)?$')
585
+ for source in catalog.get('sshWorkspaces', []):
586
+ ident = require(IDENT, source.get('id'), 'SSH workspace id')
587
+ title = _title(source.get('title'))
588
+ if ident in ids or title in titles:
589
+ raise LanesError('Duplicate SSH workspace id or title')
590
+ ids.add(ident)
591
+ titles.add(title)
592
+ host = require(host_pattern, source.get('host'), 'SSH host')
593
+ command = require(SAFE_PATH, source.get('command'), 'SSH command path')
594
+ if '..' in command.split('/'):
595
+ raise LanesError('Unsafe SSH command path')
596
+ panes, pane_ids = [], set()
597
+ for pane in source.get('panes', []):
598
+ pane_id = require(IDENT, pane.get('id'), 'SSH pane id')
599
+ if pane_id in pane_ids:
600
+ raise LanesError('Duplicate SSH pane id')
601
+ pane_ids.add(pane_id)
602
+ panes.append({'id': pane_id, 'name': _title(pane.get('name'))})
603
+ layout = source.get('layout', 'split')
604
+ if layout not in ('split', 'lanes'):
605
+ raise LanesError('SSH workspace layout must be split or lanes')
606
+ limit = 32 if layout == 'lanes' else 3
607
+ if not 1 <= len(panes) <= limit:
608
+ raise LanesError(f'An SSH workspace needs one to {limit} panes')
609
+ position = source.get('position', 'before-host')
610
+ if position not in ('workspace', 'before-host'):
611
+ raise LanesError('SSH workspace position must be workspace or before-host')
612
+ result.append({'id': ident, 'title': title, 'host': host, 'command': command,
613
+ 'panes': panes, 'layout': layout, 'position': position})
614
+ return result
615
+
616
+
617
+ def ssh_workspace_board(workspace):
618
+ """Use the standard sidebar/view layout for fixed, attach-only remote lanes."""
619
+ return {'id': workspace['id'], 'title': workspace['title'], 'container': '',
620
+ 'cwd': '', 'errors': [], 'extras': [], 'discovery_roots': [],
621
+ 'attach_only': True, 'position': workspace.get('position', 'before-host'),
622
+ 'lanes': [{'id': f"{workspace['id']}--{pane['id']}", 'name': pane['name'],
623
+ 'holder': 'ssh', 'container': '', 'color': None, 'icon': '💻',
624
+ 'focus': index == 0,
625
+ 'ssh': {'host': workspace['host'], 'command': workspace['command'], 'pane': pane['id']}}
626
+ for index, pane in enumerate(workspace['panes'])]}
627
+
628
+
629
+ def build_registry(previous=None):
630
+ site = read_site_json(workspaces_dir() / 'catalog.json')
631
+ remote_tabs = ssh_workspaces(site)
632
+ catalog = site['workspaces']
504
633
  manifests = {}
505
634
  container_names = set()
506
635
  for workspace in catalog:
@@ -527,7 +656,7 @@ def build_registry(previous=None):
527
656
  (manifest.get('repositories') or [{}])[0].get('host', ''))
528
657
  for lane in lanes:
529
658
  if 'id' not in lane: # lanes carried over from a previous registry keep their ids
530
- base = f"{entry['id']}--{slug(lane['name'])}"
659
+ base = lane_id_for(entry['id'], lane)
531
660
  lane_id, n = base, 2
532
661
  while lane_id in used_ids:
533
662
  lane_id, n = f'{base}-{n}', n + 1
@@ -538,6 +667,8 @@ def build_registry(previous=None):
538
667
  entry['lanes'] = lanes
539
668
  entry['extras'] = extras
540
669
  workspaces.append(entry)
670
+ workspaces += [ssh_workspace_board(w) for w in remote_tabs if w['layout'] == 'lanes']
671
+ remote_tabs = [w for w in remote_tabs if w['layout'] != 'lanes']
541
672
  # One shared tool tab: the Throttle monitor of the container that hosts most boards.
542
673
  hosts = [(m.get('monitor') or {}).get('host') for m in manifests.values() if (m.get('monitor') or {}).get('host')]
543
674
  tools = []
@@ -545,7 +676,21 @@ def build_registry(previous=None):
545
676
  tools.append({'id': 'tools--throttle-monitor', 'view': '_throttle', 'tab': 'Throttle', 'name': 'Throttle Monitor',
546
677
  'holder': 'exec', 'exec': 'monitor', 'container': max(set(hosts), key=hosts.count),
547
678
  'cwd': CONTAINER_HOME, 'color': None, 'icon': '📊'})
548
- return {'version': 1, 'generated_at': time.time(), 'containers': containers, 'workspaces': workspaces, 'tools': tools}
679
+ import registrations
680
+ return registrations.apply({'version': 1, 'generated_at': time.time(), 'containers': containers,
681
+ 'workspaces': workspaces, 'tools': tools, 'ssh_workspaces': remote_tabs})
682
+
683
+
684
+ def publish_registry(registry):
685
+ """Publish a prepared registry while holding registry_lock()."""
686
+ write_resolve_files(registry)
687
+ export_container_registries(registry)
688
+ for workspace in registry['workspaces']:
689
+ 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')
549
694
 
550
695
 
551
696
  def write_resolve_files(registry):
@@ -561,6 +706,8 @@ def write_resolve_files(registry):
561
706
  wanted.add(lane['id'])
562
707
  if lane['holder'] == 'tmux':
563
708
  fields = ['tmux', lane['container'], lane['tmux']['socket'], lane['tmux']['session']]
709
+ elif lane['holder'] == 'ssh':
710
+ fields = ['ssh', lane['ssh']['host'], lane['ssh']['command'], lane['ssh']['pane']]
564
711
  elif lane['holder'] == 'zmx':
565
712
  fields = ['zmx', lane['container'], lane['zmx']['name'], lane['zmx'].get('version', '')]
566
713
  else:
@@ -577,6 +724,8 @@ def export_container_registries(registry):
577
724
  per_container = {}
578
725
  for workspace in registry['workspaces']:
579
726
  for lane in workspace['lanes']:
727
+ if not lane.get('container'):
728
+ continue
580
729
  per_container.setdefault(lane['container'], {})[lane['id']] = {
581
730
  'name': lane['name'], 'ws': workspace['id'], 'holder': lane['holder'], 'tmux': lane.get('tmux'),
582
731
  'zmx': lane.get('zmx'), 'create': lane.get('create'), 'folder': lane.get('folder')}
@@ -603,8 +752,12 @@ def load_registry():
603
752
  def find_lane(registry, lane_id):
604
753
  status = read_json(STATE / 'status.json', {}) or {}
605
754
  for workspace in registry['workspaces']:
606
- discovered = (status.get('unregistered') or {}).get(workspace['id'], [])
607
- for lane in workspace['lanes'] + workspace['extras'] + discovered:
755
+ for lane in workspace['lanes'] + workspace.get('extras', []):
756
+ if lane['id'] == lane_id:
757
+ return workspace, lane
758
+ # Registration can move a session before the next observer cycle clears its previous workspace.
759
+ for workspace in registry['workspaces']:
760
+ for lane in (status.get('unregistered') or {}).get(workspace['id'], []):
608
761
  if lane['id'] == lane_id:
609
762
  return workspace, lane
610
763
  raise LanesError(f'Unknown lane: {lane_id}')
@@ -0,0 +1,148 @@
1
+ """Durable, attach-only registrations for sessions adopted through the sidebar or CLI."""
2
+ import json
3
+ import time
4
+
5
+ import lanes_lib as L
6
+
7
+ FILENAME = 'registered-sessions.json'
8
+ VERSION = 1
9
+
10
+
11
+ def key(entry):
12
+ binding = entry.get('tmux') or {}
13
+ return entry.get('container'), binding.get('socket'), binding.get('session')
14
+
15
+
16
+ def eligible(entry):
17
+ return entry.get('holder') == 'tmux' and bool(entry.get('tmux')) and not entry.get('legacy')
18
+
19
+
20
+ def destinations(registry, entry):
21
+ if not eligible(entry):
22
+ return []
23
+ return [w for w in registry['workspaces'] if w.get('container') == entry.get('container') or
24
+ any(lane.get('container') == entry.get('container') for lane in w.get('lanes', []))]
25
+
26
+
27
+ def load():
28
+ path = L.workspaces_dir() / FILENAME
29
+ try:
30
+ data = json.loads(path.read_text())
31
+ except FileNotFoundError:
32
+ return {'version': VERSION, 'sessions': []}
33
+ except (OSError, ValueError) as error:
34
+ raise L.LanesError(f'Cannot read {path}: {error}') from error
35
+ if not isinstance(data, dict) or type(data.get('version')) is not int or data['version'] != VERSION:
36
+ raise L.LanesError(f'Unsupported {FILENAME} version; this server reads version {VERSION}')
37
+ if not isinstance(data.get('sessions'), list):
38
+ raise L.LanesError(f'Invalid sessions in {FILENAME}')
39
+ ids, bindings = set(), set()
40
+ for record in data['sessions']:
41
+ if not isinstance(record, dict) or not isinstance(record.get('tmux'), dict):
42
+ raise L.LanesError(f'Invalid registration in {FILENAME}')
43
+ for field in ('id', 'workspace', 'container'):
44
+ L._require(L.IDENT, record.get(field), field)
45
+ L._title(record.get('name'))
46
+ L._require(L.IDENT, record['tmux'].get('socket'), 'tmux socket')
47
+ L._require(L.TMUX_SESSION, record['tmux'].get('session'), 'tmux session')
48
+ L._require(L.SAFE_PATH, record.get('folder'), 'working directory')
49
+ if record['id'] in ids or key(record) in bindings:
50
+ raise L.LanesError(f'Duplicate registration in {FILENAME}')
51
+ ids.add(record['id']); bindings.add(key(record))
52
+ return data
53
+
54
+
55
+ def apply(registry, data=None):
56
+ """Overlay local registrations. Repository declarations win if they adopt the same binding."""
57
+ data = load() if data is None else data
58
+ by_id = {w['id']: w for w in registry['workspaces']}
59
+ for workspace in by_id.values():
60
+ # A stale repository can retain a previous board. Rebuild its local registrations from authority too.
61
+ workspace['lanes'] = [lane for lane in workspace['lanes'] if not lane.get('registered_session')]
62
+ lanes = [lane for w in by_id.values() for lane in w['lanes']]
63
+ bindings = {key(lane) for lane in lanes if lane.get('holder') == 'tmux'}
64
+ ids = {lane['id'] for lane in lanes}
65
+ for record in data['sessions']:
66
+ workspace = by_id.get(record['workspace'])
67
+ if not workspace or key(record) in bindings:
68
+ continue
69
+ lane = dict(record, holder='tmux', registered_session=True, icon='💻', color=None, create=None)
70
+ if workspace not in destinations(registry, lane):
71
+ workspace.setdefault('errors', []).append('Registered session belongs to a different container: ' + record['name'])
72
+ continue
73
+ if lane['id'] in ids:
74
+ raise L.LanesError('Registered session ID collides with another lane: ' + lane['id'])
75
+ workspace['lanes'].append(lane)
76
+ bindings.add(key(lane)); ids.add(lane['id'])
77
+ return registry
78
+
79
+
80
+ def rename(lane_id, new_name):
81
+ """Display name of one registered session — this file is its only name authority (`apply` takes the lane's
82
+ name from the record). Only `name` changes: the id, binding, workspace and folder stay, so view targets,
83
+ resolve files and `lane open <id>` keep working. The `user-sessions/` and `resolve.dynamic/` files an
84
+ UNREGISTERED session is renamed through are never read for a registered one (DEF-1412)."""
85
+ name = L._title(new_name)
86
+ with L.registry_lock():
87
+ data = load()
88
+ record = next((item for item in data['sessions'] if item['id'] == lane_id), None)
89
+ if record is None:
90
+ raise L.LanesError(f'{lane_id} is not a registered session in {FILENAME}; reload')
91
+ record['name'] = name
92
+ registry = apply(L.load_registry(), data)
93
+ registry['generated_at'] = time.time()
94
+ L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
95
+ try:
96
+ L.publish_registry(registry)
97
+ except OSError as error:
98
+ raise L.LanesError('Rename saved; run lanes sync to finish publishing: ' + str(error)) from error
99
+ return next((lane for w in registry['workspaces'] for lane in w['lanes'] if lane['id'] == lane_id),
100
+ dict(record))
101
+
102
+
103
+ def register(workspace, entry, destination=None):
104
+ """Keep the same binding and ID; registration never starts, stops, renames or reattaches a process."""
105
+ destination = destination or workspace
106
+ with L.registry_lock():
107
+ data = load()
108
+ registry = apply(L.load_registry(), data)
109
+ if not eligible(entry):
110
+ raise L.LanesError('Only reconnectable tmux sessions can be registered')
111
+ for board in registry['workspaces']:
112
+ if any(key(lane) == key(entry) for lane in board['lanes'] if lane.get('holder') == 'tmux'):
113
+ raise L.LanesError('Already registered in ' + (board.get('title') or board['id']))
114
+ board = next((w for w in destinations(registry, entry) if w['id'] == destination), None)
115
+ if not board:
116
+ raise L.LanesError('Choose a workspace that uses this session’s container')
117
+ published = (L.read_json(L.STATE / 'status.json', {}) or {}).get('unregistered', {}).get(workspace, [])
118
+ actual = next((item for item in published if item.get('id') == entry.get('id')), None)
119
+ if not actual or key(actual) != key(entry) or actual.get('identity') != entry.get('identity'):
120
+ raise L.LanesError('Session changed; reload before registering')
121
+ container, socket, name = key(actual)
122
+ L._require(L.IDENT, actual['id'], 'lane id')
123
+ L._require(L.IDENT, container, 'container')
124
+ L._require(L.IDENT, socket, 'tmux socket')
125
+ L._require(L.TMUX_SESSION, name, 'tmux session')
126
+ identity = actual.get('identity') or {}
127
+ sid, created = identity.get('session_id', ''), identity.get('created', '')
128
+ if not isinstance(sid, str) or not isinstance(created, str) or not sid.startswith('$') or not sid[1:].isdigit() or not created.isdigit():
129
+ raise L.LanesError('Session identity is not ready; wait for the next status update')
130
+ info = (L.read_json(L.STATE / 'containers.json', {}) or {}).get(container) or {}
131
+ outcome, observed = L.tmux_probe(info, socket, ['display-message', '-p', '-t', '=' + name + ':',
132
+ '#{session_id}\t#{session_created}'], 5)
133
+ if outcome != 'ok' or observed.strip() != sid + '\t' + created:
134
+ raise L.LanesError('Session changed or could not be checked; nothing was registered')
135
+ record = {'id': actual['id'], 'workspace': destination, 'name': L._title(actual['name']),
136
+ 'container': container, 'tmux': dict(actual['tmux']),
137
+ 'folder': L._require(L.SAFE_PATH, board.get('cwd') or L.CONTAINER_HOME, 'working directory')}
138
+ if any(item['id'] == record['id'] or key(item) == key(record) for item in data['sessions']):
139
+ raise L.LanesError(f'Registration already saved; check {FILENAME} and run lanes sync')
140
+ data['sessions'].append(record)
141
+ registry = apply(registry, data)
142
+ registry['generated_at'] = time.time()
143
+ L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
144
+ try:
145
+ L.publish_registry(registry)
146
+ except OSError as error:
147
+ raise L.LanesError('Registration saved; run lanes sync to finish publishing: ' + str(error)) from error
148
+ return next(lane for lane in board['lanes'] if lane['id'] == record['id'])
@@ -82,6 +82,8 @@ def new_shell(workspace):
82
82
  board = next((w for w in registry['workspaces'] if w['id'] == workspace), None)
83
83
  if not board:
84
84
  raise L.LanesError('Workspace no longer exists; reload the sidebar')
85
+ if board.get('attach_only'):
86
+ raise L.LanesError('Select an existing remote terminal in this workspace')
85
87
  container = L._require(L.IDENT, board['container'], 'container')
86
88
  cwd = L._require(L.SAFE_PATH, board['cwd'], 'working directory')
87
89
  socket = shell_socket(workspace)
@@ -140,19 +142,30 @@ def rename_session(workspace, entry, new_name):
140
142
  return dict(entry, name=new_name)
141
143
 
142
144
 
143
- def rename_registered(manifest_path, registry_name, new_name):
144
- """Rename a registered lane by writing a displayNames override to the workspace manifest JSON.
145
- `manifest_path` is the path to workspaces.<board>.json; `registry_name` is the terminal's declared
146
- name; `new_name` is the display override. Callers must run `lanes sync` after this returns."""
145
+ def rename_registered(manifest_path, repo_id, registry_name, new_name):
146
+ """Display override for one declared terminal. The reader is `lanes_lib._workspace_lanes`, which looks at
147
+ `repositories[i].displayNames` keyed by the terminal's declared name — the manifest's top level (and the
148
+ lane's terminals.json `source`) has no such key. `registry_name` is the lane's title (repository prefix +
149
+ declared name); renaming back to it clears the override. The lane id never changes (lanes_lib.lane_id_for).
150
+ The first write keeps a `<manifest>.bak-<UTC timestamp>` copy beside it. Callers run `lanes sync`."""
147
151
  L._title(new_name)
148
- data = json.loads(Path(manifest_path).read_text())
149
- if 'displayNames' not in data:
150
- data['displayNames'] = {}
152
+ path = Path(manifest_path)
153
+ original = path.read_text()
154
+ data = json.loads(original)
155
+ repo = next((r for r in data.get('repositories') or [] if r.get('id') == repo_id), None)
156
+ if repo is None:
157
+ raise L.LanesError(f'Repository {repo_id!r} not in {path.name}')
158
+ prefix = repo.get('prefix') or ''
159
+ declared = registry_name[len(prefix):] if prefix and registry_name.startswith(prefix) else registry_name
160
+ names = repo.setdefault('displayNames', {})
151
161
  if new_name == registry_name:
152
- data['displayNames'].pop(registry_name, None) # removing the override restores the default
162
+ names.pop(declared, None) # removing the override restores the default title
153
163
  else:
154
- data['displayNames'][registry_name] = new_name
155
- L.atomic_write(manifest_path, json.dumps(data, indent=2, ensure_ascii=False) + '\n', mode=0o644)
164
+ names[declared] = new_name
165
+ if not any(path.parent.glob(path.name + '.bak-*')):
166
+ L.atomic_write(path.parent / (path.name + '.bak-' + time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())),
167
+ original, mode=0o644)
168
+ L.atomic_write(path, json.dumps(data, indent=2, ensure_ascii=False) + '\n', mode=0o644)
156
169
 
157
170
 
158
171
  def promote_to_registered(workspace_id, entry, registry):
@@ -163,19 +176,32 @@ def promote_to_registered(workspace_id, entry, registry):
163
176
  Raises LanesError for unsupported configurations or invalid session state."""
164
177
  if entry.get('holder') != 'tmux':
165
178
  raise L.LanesError('Only tmux sessions can be promoted (zmx and exec holders are not supported)')
179
+ # A binding any board already holds — a repository declaration or an adopted registered session — is
180
+ # registered. Appending it again would make the repository declaration win the binding on the next sync,
181
+ # re-keying an adopted lane's id and orphaning its view targets (DEF-1411). Nothing is written.
182
+ if entry.get('registered_session') or session_key(entry) in {
183
+ session_key(lane) for w in registry.get('workspaces', []) for lane in w.get('lanes', [])
184
+ if lane.get('holder') == 'tmux'}:
185
+ raise L.LanesError('Already registered; nothing was promoted')
166
186
  board = next((w for w in registry.get('workspaces', []) if w['id'] == workspace_id), None)
167
187
  if not board:
168
188
  raise L.LanesError('Workspace not found')
169
- # Find the terminals.json source — must be a single unambiguous path
170
- sources = [lane.get('source') for lane in board.get('lanes', []) if lane.get('source')]
171
- unique_sources = list(dict.fromkeys(s for s in sources if s))
189
+ # Find the terminals.json source — must be a single unambiguous file. A source is (container, spelling): the
190
+ # same spelling in two containers (one repository cloned in two boxes) is two files, so it is still ambiguous.
191
+ sources = [(lane.get('container') or board.get('container') or '', lane['source'])
192
+ for lane in board.get('lanes', []) if lane.get('source')]
193
+ unique_sources = list(dict.fromkeys(sources))
172
194
  if not unique_sources:
173
195
  raise L.LanesError(
174
196
  'No registry source found for this workspace; add the lane to terminals.json manually')
175
197
  if len(unique_sources) > 1:
176
198
  raise L.LanesError(
177
199
  'Multiple registry sources in this workspace; add the lane to terminals.json manually')
178
- terminals_path = Path(unique_sources[0])
200
+ # The source is the container's spelling of terminals.json; this process runs on the host, where the file only
201
+ # exists under the container's home volume. Writing Path(source) would miss it (or write a stray host file).
202
+ container, source = unique_sources[0]
203
+ info = (L.read_json(L.STATE / 'containers.json') or {}).get(container) or {}
204
+ terminals_path = L.host_path(info, source)
179
205
  if not terminals_path.exists():
180
206
  raise L.LanesError(f'Registry file not found: {terminals_path}')
181
207
  tmux = entry.get('tmux') or {}
@@ -206,7 +232,13 @@ def promote_to_registered(workspace_id, entry, registry):
206
232
 
207
233
  def close_session(workspace, entry):
208
234
  """Close the exact observed session, never an identically named replacement or a registered lane."""
209
- registry = L.load_registry()
235
+ with L.registry_lock():
236
+ return _close_session(workspace, entry)
237
+
238
+
239
+ def _close_session(workspace, entry):
240
+ import registrations
241
+ registry = registrations.apply(L.load_registry())
210
242
  current = (L.read_json(L.STATE / 'status.json', {}) or {}).get('unregistered', {}).get(workspace, [])
211
243
  published = next((x for x in current if x['id'] == entry['id']), None)
212
244
  if not published or session_key(published) != session_key(entry) or not entry.get('closable'):