@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
|
@@ -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
|
-
|
|
178
|
-
|
|
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'
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
container
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
for workspace in registry['workspaces'] + [{'lanes': registry.get('tools', [])
|
|
560
|
-
for lane in workspace['lanes']
|
|
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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
607
|
-
|
|
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}')
|