@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.
- package/package.json +1 -1
- package/payload/lanes/README.md +92 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +928 -150
- package/payload/lanes/client/ghostty/common.conf +3 -0
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
- package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
- package/payload/lanes/client/ghostty/omarchy.conf +3 -2
- package/payload/lanes/client/lanes-launch +62 -5
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/hooks/claude-settings.json +10 -0
- package/payload/lanes/container/hooks/codex-hooks.json +22 -0
- package/payload/lanes/container/lane +326 -25
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +158 -6
- package/payload/lanes/docs/DESIGN.md +250 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +507 -24
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/holders.lock +3 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/install/host-install.sh +10 -1
- package/payload/lanes/install/services.py +30 -3
- package/payload/lanes/install.sh +108 -3
- package/payload/lanes/libexec/agent_state.py +246 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +147 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +436 -36
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +53 -22
- package/payload/lanes/libexec/sidebar.py +1190 -186
- package/payload/lanes/libexec/statusd.py +352 -90
- package/payload/lanes/libexec/tokens.py +310 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +22 -11
- package/payload/lanes/systemd/lanes-economy.service +20 -0
- package/payload/lanes/systemd/lanes-notify.service +19 -0
- package/payload/lanes/systemd/lanes-sync.service +3 -0
- package/payload/lanes/web/README.md +96 -2
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +108 -221
- package/payload/lanes/web/mobile.js +105 -55
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +128 -19
- package/payload/manifest.json +7 -3
- 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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|