@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
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """A quiet connection screen with input custody until the viewer answers."""
3
+ import argparse
4
+ import errno
5
+ import fcntl
6
+ import os
7
+ import pty
8
+ import select
9
+ import signal
10
+ import sys
11
+ import termios
12
+ import time
13
+ import tty
14
+
15
+ RESET = b'\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l\x1b[?2004l\x1b[<u\x1b[0m'
16
+
17
+
18
+ def screen(title, frame):
19
+ title = ''.join(c for c in title if c.isprintable())[:100]
20
+ spinner = '|/-\\'[frame % 4]
21
+ os.write(1, b'\x1b[H\x1b[2J\x1b[?25l' + f'\n {spinner} Syncing {title}…\n\n Connecting to the workspace\n'.encode())
22
+
23
+
24
+ def drain():
25
+ termios.tcflush(0, termios.TCIFLUSH)
26
+
27
+
28
+ def wait_screen(title, seconds, preserve_screen=False):
29
+ saved = termios.tcgetattr(0)
30
+ handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGHUP)}
31
+ def stop(sig, _frame):
32
+ raise SystemExit(128 + sig)
33
+ for sig in handlers:
34
+ signal.signal(sig, stop)
35
+ try:
36
+ tty.setraw(0)
37
+ os.write(1, RESET)
38
+ drain()
39
+ end, frame = time.monotonic() + seconds, 0
40
+ while time.monotonic() < end:
41
+ if not preserve_screen:
42
+ screen(title, frame)
43
+ frame += 1
44
+ if select.select([0], [], [], min(.15, max(0, end - time.monotonic())))[0]:
45
+ if not os.read(0, 4096):
46
+ break # disconnected terminal
47
+ finally:
48
+ for sig, handler in handlers.items():
49
+ signal.signal(sig, handler)
50
+ drain()
51
+ termios.tcsetattr(0, termios.TCSANOW, saved)
52
+ os.write(1, RESET + b'\x1b[?25h')
53
+
54
+
55
+ def connect(title, command):
56
+ if not os.isatty(0):
57
+ raise SystemExit('The workspace viewer needs a terminal')
58
+ saved = termios.tcgetattr(0)
59
+ error_fd = os.dup(2)
60
+ child = None
61
+ stopping = False
62
+ master = None
63
+
64
+ def stop(_sig, _frame):
65
+ nonlocal stopping
66
+ stopping = True
67
+ if child:
68
+ try:
69
+ os.killpg(child, signal.SIGTERM)
70
+ except ProcessLookupError:
71
+ pass
72
+
73
+ def resize(_sig=None, _frame=None):
74
+ if master is not None:
75
+ size = fcntl.ioctl(0, termios.TIOCGWINSZ, b'\0' * 8)
76
+ fcntl.ioctl(master, termios.TIOCSWINSZ, size)
77
+
78
+ old_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGWINCH)}
79
+ try:
80
+ tty.setraw(0)
81
+ drain()
82
+ os.write(1, RESET)
83
+ screen(title, 0)
84
+ child, master = pty.fork()
85
+ if child == 0:
86
+ os.dup2(error_fd, 2) # diagnostics belong in the view's error file
87
+ os.close(error_fd)
88
+ try:
89
+ os.execvp(command[0], command)
90
+ except OSError as error:
91
+ print(error, file=sys.stderr)
92
+ os._exit(127)
93
+ os.close(error_fd)
94
+ error_fd = -1
95
+ resize()
96
+ signal.signal(signal.SIGTERM, stop)
97
+ signal.signal(signal.SIGHUP, stop)
98
+ signal.signal(signal.SIGWINCH, resize)
99
+ connected, frame = False, 0
100
+ while not stopping:
101
+ ready, _, _ = select.select([0, master], [], [], .15)
102
+ # Consume old mouse/keyboard input BEFORE accepting the first output.
103
+ if 0 in ready:
104
+ data = os.read(0, 4096)
105
+ if not data:
106
+ stop(None, None)
107
+ break
108
+ if connected:
109
+ os.write(master, data)
110
+ if master in ready:
111
+ try:
112
+ data = os.read(master, 65536)
113
+ except OSError as error:
114
+ if error.errno != errno.EIO:
115
+ raise
116
+ break
117
+ if not data:
118
+ break
119
+ if not connected:
120
+ drain()
121
+ os.write(1, RESET + b'\x1b[?25h\x1b[H\x1b[2J')
122
+ connected = True
123
+ os.write(1, data)
124
+ if not connected:
125
+ frame += 1
126
+ screen(title, frame)
127
+ if stopping:
128
+ # This kills only the attach client group, never the persistent lane.
129
+ deadline = time.monotonic() + 2
130
+ while time.monotonic() < deadline:
131
+ pid, status = os.waitpid(child, os.WNOHANG)
132
+ if pid:
133
+ return -signal.SIGTERM
134
+ time.sleep(.05)
135
+ try:
136
+ os.killpg(child, signal.SIGKILL)
137
+ except ProcessLookupError:
138
+ pass
139
+ _, status = os.waitpid(child, 0)
140
+ return -signal.SIGTERM if stopping else os.waitstatus_to_exitcode(status)
141
+ finally:
142
+ for sig, handler in old_handlers.items():
143
+ signal.signal(sig, handler)
144
+ if master is not None:
145
+ os.close(master)
146
+ if error_fd >= 0:
147
+ os.close(error_fd)
148
+ drain()
149
+ termios.tcsetattr(0, termios.TCSANOW, saved)
150
+ os.write(1, RESET + b'\x1b[?25h')
151
+
152
+
153
+ def main():
154
+ parser = argparse.ArgumentParser()
155
+ parser.add_argument('--title', default='workspace')
156
+ parser.add_argument('--wait', type=float)
157
+ parser.add_argument('--preserve-screen', action='store_true')
158
+ parser.add_argument('--retry', action='store_true')
159
+ parser.add_argument('command', nargs=argparse.REMAINDER)
160
+ args = parser.parse_args()
161
+ command = args.command[1:] if args.command[:1] == ['--'] else args.command
162
+ if args.wait is not None:
163
+ wait_screen(args.title, args.wait, args.preserve_screen)
164
+ return 0
165
+ if not command:
166
+ parser.error('viewer command required')
167
+ delay = 1
168
+ while True:
169
+ started = time.monotonic()
170
+ code = connect(args.title, command)
171
+ if not args.retry or code < 0:
172
+ return max(0, code) if code < 0 else code
173
+ delay = 1 if time.monotonic() - started >= 30 else min(delay * 2, 15)
174
+ wait_screen(args.title, delay)
175
+
176
+
177
+ if __name__ == '__main__':
178
+ sys.exit(main())
@@ -12,12 +12,23 @@ The repository's `.vscode/terminals.json` stays the source of truth for WHICH la
12
12
 
13
13
  Text is edited minimally (command strings replaced in place, the `lanes` block inserted before the final brace) so
14
14
  git diffs stay small. Dry-run by default: prints a unified diff. `--write` applies (with a timestamped backup).
15
+
16
+ `--canonicalize` additionally replaces every tmux lane's `create` that cannot create the lane — null, attach-only
17
+ (`attach-session` without `new`), naming the retired in-container coder-launch config (the three classes `lanes
18
+ doctor` warns about), or naming a script that is missing in the container — with
19
+ `lanes_lib.canonical_recipe()` (the recorded binding + the repository folder + `bash -l`), which is also what gives a
20
+ NEW lane its identity (LANE_ID, LANE_WS, the lanes/<ws>/<id> trace). `--identity` additionally gives a WORKING
21
+ recipe of the plain shape `[exec] tmux -L <socket> new -ADs <session> [-c <dir>] <command…>` the same identity
22
+ (`plain_tmux_new`: the command argv is kept, only the `-e` flags are added); any other working recipe is left
23
+ alone and its lane carries no identity. The old value is kept as `createPrevious` in both cases. Identity reaches a
24
+ tmux lane ONLY through these `-e` flags: `lane open` never exports it (DEF-1478, lanes/docs/ARCHITECTURE.md).
15
25
  Boards whose registry lives outside a repository are additionally PROJECTED into the file Cursor
16
26
  reads for that folder, named by `"cursor": "<path>"` on the repository entry of the workspace manifest.
17
27
  """
18
28
  import difflib
19
29
  import json
20
30
  import re
31
+ import shlex
21
32
  import time
22
33
  from pathlib import Path
23
34
 
@@ -31,8 +42,110 @@ def _indent_of(text):
31
42
  return match.group(1) if match else ' '
32
43
 
33
44
 
34
- def normalized_text(text, lanes_by_name):
35
- """Return the file text with uniform commands + the `lanes` block. Minimal textual edits when possible."""
45
+ ATTACH_VERBS = {'attach-session'[:n] for n in range(1, 15)} # tmux takes any unique prefix + `attach`
46
+ NEW_VERBS = {'new-session'[:n] for n in range(3, 12)}
47
+ TMUX_OPTION_ARGS = {'-L', '-S', '-f', '-T', '-c'}
48
+ SCRIPT_SUFFIXES = ('.sh', '.bash', '.py', '.js', '.mjs', '.cjs', '.rb', '.pl')
49
+ CLASS_REASONS = {'null': 'no create recipe', 'attach-only': 'attach-only',
50
+ 'retired-path': 'retired coder-launch path'}
51
+
52
+
53
+ def _words(text, depth=3):
54
+ """Shell words of a recipe, with quoted command strings split again (`new … 'script.sh arg'`)."""
55
+ try:
56
+ words = shlex.split(text)
57
+ except ValueError:
58
+ return None
59
+ out = []
60
+ for word in words:
61
+ out.append(word)
62
+ if depth and any(c.isspace() for c in word):
63
+ out += _words(word, depth - 1) or []
64
+ return out
65
+
66
+
67
+ def _tmux_verbs(words):
68
+ verbs = []
69
+ for i, word in enumerate(words):
70
+ if word == 'tmux' or word.endswith('/tmux'):
71
+ j = i + 1
72
+ while j < len(words) and words[j].startswith('-'):
73
+ j += 2 if words[j] in TMUX_OPTION_ARGS else 1
74
+ if j < len(words):
75
+ verbs.append(words[j])
76
+ return verbs
77
+
78
+
79
+ def needs_canonical(create, folder, exists):
80
+ """Why `create` cannot create its lane (None when it can, or when that is not knowable).
81
+ `exists(container_path)` answers True / False / None (unknown — never counted as missing)."""
82
+ if not isinstance(create, str) or not create.strip():
83
+ return 'no create recipe'
84
+ recipe = create.replace('${workspaceFolder}', folder).replace('[workspaceFolder]', folder)
85
+ # Every class `lanes doctor` warns about (lanes_lib.classify_recipe: null, attach-only, retired-path) is one this
86
+ # repairs, read by the same parser, so the doctor's "fix with --canonicalize" is never a no-op (DEF-1460).
87
+ reason = CLASS_REASONS.get(L.classify_recipe(recipe))
88
+ if reason:
89
+ return reason
90
+ words = _words(recipe)
91
+ if words is None:
92
+ return None
93
+ verbs = _tmux_verbs(words)
94
+ if any(v in ATTACH_VERBS for v in verbs) and not any(v in NEW_VERBS for v in verbs):
95
+ return 'attach-only'
96
+ for word in words:
97
+ if word.startswith('/') and word.endswith(SCRIPT_SUFFIXES) and exists(word) is False:
98
+ return f'missing script {word}'
99
+ return None
100
+
101
+
102
+ OPERATORS = set('();<>|&')
103
+ UNSAFE_TO_REQUOTE = set('$`*?[{\\')
104
+
105
+
106
+ def plain_tmux_new(create, folder, socket, session):
107
+ """`(folder, command)` of a WORKING recipe of exactly the shape `[exec] tmux -L <socket> new[-session] -A -D -s
108
+ <session> [-c <dir>] <command…>` — this lane's binding, no `-e`, no shell operators — else None. `--identity`
109
+ rewrites such a recipe to `canonical_recipe(…, folder, command)`: the command words are re-quoted one for one, so
110
+ tmux receives the same argv and the rewrite only ADDS the identity `-e` flags. Anything the re-quoting could change
111
+ (an expansion, a glob, a backslash, a leading `~`) or any other shape is left alone."""
112
+ if not isinstance(create, str):
113
+ return None
114
+ recipe = create.replace('${workspaceFolder}', folder).replace('[workspaceFolder]', folder)
115
+ try:
116
+ lexer = shlex.shlex(recipe, posix=True, punctuation_chars=True)
117
+ lexer.whitespace_split = True
118
+ words = list(lexer)
119
+ except ValueError:
120
+ return None
121
+ if any(set(w) <= OPERATORS or set(w) & UNSAFE_TO_REQUOTE or w.startswith('~') for w in words):
122
+ return None
123
+ if words[:1] == ['exec']:
124
+ words = words[1:]
125
+ if words[:3] != ['tmux', '-L', socket] or len(words) < 4 or words[3] not in NEW_VERBS:
126
+ return None
127
+ i, flags, sess, cdir = 4, set(), None, None
128
+ while i < len(words) and words[i].startswith('-'):
129
+ letters = words[i][1:]
130
+ if letters == 'c' and i + 1 < len(words):
131
+ cdir, i = words[i + 1], i + 2
132
+ elif letters and set(letters) <= {'A', 'D'}:
133
+ flags, i = flags | set(letters), i + 1
134
+ elif letters.endswith('s') and set(letters[:-1]) <= {'A', 'D'} and i + 1 < len(words):
135
+ flags, sess, i = flags | set(letters[:-1]), words[i + 1], i + 2
136
+ else:
137
+ return None # -e (already has an identity, or a partial one), -d, -n, -x, …: not the plain shape
138
+ command = words[i:]
139
+ cdir = cdir or folder
140
+ if sess != session or flags != {'A', 'D'} or not command or not cdir.startswith('/'):
141
+ return None
142
+ return cdir, ' '.join(shlex.quote(w) for w in command)
143
+
144
+
145
+ def normalized_text(text, lanes_by_name, canonicalize=None):
146
+ """Return the file text with uniform commands + the `lanes` block. Minimal textual edits when possible.
147
+ `canonicalize` = {'ws': workspace id, 'exists': container path -> True/False/None, 'repair': bool (default True,
148
+ `--canonicalize`), 'identity': bool (default False, `--identity`)}."""
36
149
  data = json.loads(text)
37
150
  block = {'version': 1, 'terminals': {}}
38
151
  commands = {}
@@ -47,6 +160,25 @@ def normalized_text(text, lanes_by_name):
47
160
  entry['tmux'] = {'socketName': lane['tmux']['socket'], 'sessionName': lane['tmux']['session']}
48
161
  if create:
49
162
  entry['create'] = create
163
+ if 'createPrevious' in previous:
164
+ entry['createPrevious'] = previous['createPrevious']
165
+ if canonicalize and lane.get('holder') == 'tmux' and lane.get('tmux'):
166
+ folder = lane.get('folder') or L.CONTAINER_HOME
167
+ socket, session = lane['tmux']['socket'], lane['tmux']['session']
168
+ kept = plain_tmux_new(create, folder, socket, session) if canonicalize.get('identity') else None
169
+ if canonicalize.get('repair', True) and needs_canonical(create, folder, canonicalize['exists']):
170
+ entry.pop('create', None)
171
+ entry['create'] = L.canonical_recipe(lane['id'], canonicalize['ws'], socket, session, folder)
172
+ entry['createPrevious'] = create
173
+ elif kept:
174
+ try:
175
+ recipe = L.canonical_recipe(lane['id'], canonicalize['ws'], socket, session, kept[0], cmd=kept[1])
176
+ except L.LanesError:
177
+ recipe = None # a trace the proxy would reject: no identity to give, the recipe stays
178
+ if recipe:
179
+ entry.pop('create', None)
180
+ entry['create'] = recipe
181
+ entry['createPrevious'] = create
50
182
  block['terminals'][term['name']] = entry
51
183
  commands[term['name']] = ENTRY + lane['id']
52
184
  # Nothing to do: leave the file byte-identical (never reformat somebody else's JSON).
@@ -104,7 +236,7 @@ def projected_text(source_text, source_path):
104
236
  return json.dumps({**note, **data}, ensure_ascii=False, indent=2) + '\n'
105
237
 
106
238
 
107
- def plan(registry, only=()):
239
+ def plan(registry, only=(), canonicalize=False, identity=False):
108
240
  """Yield (workspace id, host path, container path, old text, new text, note)."""
109
241
  catalog = L.read_site_json(L.workspaces_dir() / 'catalog.json')['workspaces']
110
242
  containers = registry['containers']
@@ -123,7 +255,14 @@ def plan(registry, only=()):
123
255
  lanes = {name[len(prefix):] if prefix and name.startswith(prefix) else name: lane for name, lane in lanes.items()}
124
256
  host_file = L.host_path(info, repo['registry'])
125
257
  old = host_file.read_text()
126
- new = normalized_text(old, lanes)
258
+
259
+ def exists(container_path, info=info):
260
+ try:
261
+ return L.host_path(info, container_path).exists()
262
+ except (L.LanesError, OSError):
263
+ return None # outside the home volume / container unknown: not knowable, never "missing"
264
+ mode = {'ws': item['id'], 'exists': exists, 'repair': canonicalize, 'identity': identity}
265
+ new = normalized_text(old, lanes, mode if canonicalize or identity else None)
127
266
  yield item['id'], host_file, repo['registry'], old, new, 'registry'
128
267
  if repo.get('cursor'):
129
268
  target = L.host_path(info, repo['cursor'])
@@ -136,7 +275,8 @@ def run(args):
136
275
  only = [a for a in args if not a.startswith('--')]
137
276
  registry = L.load_registry()
138
277
  changed = 0
139
- for workspace, host_file, container_path, old, new, note in plan(registry, only):
278
+ plans = plan(registry, only, '--canonicalize' in args, '--identity' in args)
279
+ for workspace, host_file, container_path, old, new, note in plans:
140
280
  if host_file is None:
141
281
  print(f'== {workspace}: {note}')
142
282
  continue
@@ -162,5 +302,6 @@ def run(args):
162
302
  L.atomic_write(host_file, new, mode=mode)
163
303
  print(f' written (backup in {backup_dir})')
164
304
  if not write and changed:
165
- print(f'\n{changed} file(s) would change. Apply with: lanes cursor-sync <workspace> --write')
305
+ again = ''.join(f' {flag}' for flag in ('--canonicalize', '--identity') if flag in args)
306
+ print(f'\n{changed} file(s) would change. Apply with: lanes cursor-sync <workspace>{again} --write')
166
307
  return 0
@@ -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