@vibelord/lanes 1.0.0
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/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/lanes-install.mjs +158 -0
- package/lib/plan.mjs +65 -0
- package/package.json +37 -0
- package/payload/lanes/README.md +152 -0
- package/payload/lanes/VERSION +1 -0
- package/payload/lanes/bin/lanes +1035 -0
- package/payload/lanes/client/ghostty/common.conf +22 -0
- package/payload/lanes/client/ghostty/macos.conf +6 -0
- package/payload/lanes/client/ghostty/omarchy.conf +19 -0
- package/payload/lanes/client/install.sh +16 -0
- package/payload/lanes/client/lanes-launch +315 -0
- package/payload/lanes/client/lanes-upload +147 -0
- package/payload/lanes/client/lanes-window +36 -0
- package/payload/lanes/client/macos-helper.applescript +14 -0
- package/payload/lanes/client/omarchy/discover.sh +56 -0
- package/payload/lanes/client/omarchy/lanes.conf +5 -0
- package/payload/lanes/client/omarchy/lanes.desktop +9 -0
- package/payload/lanes/client/omarchy/lanes.lua +14 -0
- package/payload/lanes/client/ssh.stanza.in +54 -0
- package/payload/lanes/container/env.sh +16 -0
- package/payload/lanes/container/lane +268 -0
- package/payload/lanes/container/lane-run +34 -0
- package/payload/lanes/docs/ARCHITECTURE.md +93 -0
- package/payload/lanes/docs/HOST-GUARD.md +108 -0
- package/payload/lanes/docs/INSTALL.md +192 -0
- package/payload/lanes/docs/RUNBOOK.md +385 -0
- package/payload/lanes/docs/SHARING.md +124 -0
- package/payload/lanes/docs/ZMX-RING0.md +51 -0
- package/payload/lanes/docs/images/desktop.png +0 -0
- package/payload/lanes/docs/images/iphone.png +0 -0
- package/payload/lanes/holders/exec.sh +21 -0
- package/payload/lanes/holders/tmux.sh +111 -0
- package/payload/lanes/holders/zmx.sh +22 -0
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-phone.sh +43 -0
- package/payload/lanes/install/enable-web.sh +16 -0
- package/payload/lanes/install/host-install.sh +72 -0
- package/payload/lanes/install/services.py +61 -0
- package/payload/lanes/install.sh +603 -0
- package/payload/lanes/libexec/cursor_commit.py +159 -0
- package/payload/lanes/libexec/cursor_sync.py +166 -0
- package/payload/lanes/libexec/guard.py +231 -0
- package/payload/lanes/libexec/lanes_lib.py +610 -0
- package/payload/lanes/libexec/legacy_sessions.py +130 -0
- package/payload/lanes/libexec/session_actions.py +276 -0
- package/payload/lanes/libexec/sidebar +8 -0
- package/payload/lanes/libexec/sidebar.py +872 -0
- package/payload/lanes/libexec/statusd.py +764 -0
- package/payload/lanes/libexec/tmpscan.py +921 -0
- package/payload/lanes/libexec/view.sh +179 -0
- package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
- package/payload/lanes/site/README.md +28 -0
- package/payload/lanes/site/beyondwork/known_hosts +1 -0
- package/payload/lanes/site/beyondwork/site.conf +11 -0
- package/payload/lanes/site/site.example.conf +10 -0
- package/payload/lanes/sudo/host-guard.sh +356 -0
- package/payload/lanes/sudo/one-time-root.sh +32 -0
- package/payload/lanes/systemd/lanes-ensure.service +7 -0
- package/payload/lanes/systemd/lanes-ensure.timer +10 -0
- package/payload/lanes/systemd/lanes-gc.service +7 -0
- package/payload/lanes/systemd/lanes-gc.timer +9 -0
- package/payload/lanes/systemd/lanes-guard.service +8 -0
- package/payload/lanes/systemd/lanes-guard.timer +10 -0
- package/payload/lanes/systemd/lanes-mobile.service +23 -0
- package/payload/lanes/systemd/lanes-statusd.service +15 -0
- package/payload/lanes/systemd/lanes-sync.service +10 -0
- package/payload/lanes/systemd/lanes-sync.timer +10 -0
- package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
- package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
- package/payload/lanes/systemd/lanes-web.service +13 -0
- package/payload/lanes/systemd/lanes.slice +11 -0
- package/payload/lanes/web/Caddyfile +37 -0
- package/payload/lanes/web/README.md +35 -0
- package/payload/lanes/web/icon-192.png +0 -0
- package/payload/lanes/web/icon.svg +4 -0
- package/payload/lanes/web/index.html +30 -0
- package/payload/lanes/web/manifest.webmanifest +15 -0
- package/payload/lanes/web/mobile.css +301 -0
- package/payload/lanes/web/mobile.js +367 -0
- package/payload/lanes/workspaces.example/README.md +27 -0
- package/payload/lanes/workspaces.example/catalog.json +7 -0
- package/payload/lanes/workspaces.example/commit-targets.json +8 -0
- package/payload/lanes/workspaces.example/terminals.example.json +19 -0
- package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
- package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
- package/payload/lanes/zellij/config.kdl +360 -0
- package/payload/manifest.json +31 -0
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""lanes — host-level workspace server CLI. Python 3 stdlib only.
|
|
3
|
+
|
|
4
|
+
lanes sync rebuild registry.json from the repos' terminal registries (read-only)
|
|
5
|
+
lanes ls [--json] [workspace] list lanes with live status
|
|
6
|
+
lanes resolve <lane-id> exact target of one lane as JSON (contract for the web harness)
|
|
7
|
+
lanes status one-line-per-workspace health summary
|
|
8
|
+
lanes attach <device> [--client ID] [--mobile] [--viewports N]
|
|
9
|
+
attach this terminal to the device's view session (creates it if needed)
|
|
10
|
+
lanes prepare <device> [--mobile] create a device view without attaching a terminal (eg. Safari on a phone)
|
|
11
|
+
lanes open <lane-id> attach this terminal straight to one lane (no multiplexer in between)
|
|
12
|
+
lanes layout <device> [--mobile] print the generated zellij layout
|
|
13
|
+
lanes rebuild <device> recreate a device's view session (agents are never touched)
|
|
14
|
+
lanes upgrade-session [device…] add tabs a newer layout has (e.g. Throttle) to LIVE sessions, without a rebuild
|
|
15
|
+
lanes ensure recreate any known device session that is missing (never when zellij did not answer:
|
|
16
|
+
an unknown session list is not an empty one)
|
|
17
|
+
lanes reattach make every view re-attach its lane (after changing flags); agents untouched
|
|
18
|
+
lanes upload <ws> | --active <device> [--paste]
|
|
19
|
+
read a tar stream on stdin into that board's uploads/ folder (the same folder Cursor
|
|
20
|
+
users drop files into); prints the in-container paths. --paste also types the path(s)
|
|
21
|
+
into the lane that device is looking at. `lanes workspaces` lists board ids.
|
|
22
|
+
lanes cursor-sync [ws…] [--write] [--diff]
|
|
23
|
+
make Cursor/VS Code terminals open exactly the board's lanes through `lane open`
|
|
24
|
+
(dry-run by default: prints the git-style diff; --write applies with a backup)
|
|
25
|
+
lanes cursor-commit [ws…] [--push]
|
|
26
|
+
commit the normalized terminals.json on top of each repo's REMOTE branch tip and push it,
|
|
27
|
+
with git plumbing only (no working tree or index is touched). Dry-run without --push.
|
|
28
|
+
lanes hello [device] what the client installer calls to prove the path, the key and the server version
|
|
29
|
+
lanes client-config site facts for clients (KEY=VALUE from ~/.config/lanes/site.conf on the server)
|
|
30
|
+
lanes install-container <name> install the `lane` contract + pinned zmx into a container's home volume (additive)
|
|
31
|
+
lanes invariant save|check snapshot / verify every live pane's pid and window size
|
|
32
|
+
lanes verify-legacy compare the registry with the old in-container coder-launch layouts
|
|
33
|
+
lanes gc truncate oversized zmx logs, drop stale dynamic resolve files
|
|
34
|
+
lanes sockets [check|repair] lanes that run but cannot be reached: tmux servers whose socket file was deleted
|
|
35
|
+
(someone emptied the container's /tmp); repair = tmux's SIGUSR1, nothing restarts
|
|
36
|
+
lanes pressure host memory / io pressure, swap, per-container memory and /tmp use — plain files and
|
|
37
|
+
statvfs only (no docker exec, no du): safe on a thrashing host. Exit 1 unless the level is ok
|
|
38
|
+
lanes tmp scan|plan|apply|reap|caches [--home] what fills the containers' RAM-backed /tmp, and removing the dead part (libexec/tmpscan.py;
|
|
39
|
+
`reap` is what lanes-tmpreap.timer runs hourly once YOU enable it — the install does not;
|
|
40
|
+
dry-run until ~/.local/state/lanes/flags/tmpreap.armed exists)
|
|
41
|
+
lanes guard [--dry-run] [container…]
|
|
42
|
+
one pass, by hand, that makes the next in-container out-of-memory kill take a build, never
|
|
43
|
+
a lane: raises oom_score_adj of compilers / test runners by exact name (libexec/guard.py).
|
|
44
|
+
No timer runs it: look at --dry-run first. The recurring half is sudo/host-guard.sh
|
|
45
|
+
lanes doctor full health check (every external call is bounded; includes the pressure summary)
|
|
46
|
+
"""
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import re
|
|
50
|
+
import shutil
|
|
51
|
+
import sys
|
|
52
|
+
import time
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'libexec'))
|
|
56
|
+
import lanes_lib as L # noqa: E402
|
|
57
|
+
|
|
58
|
+
SLOTS = ['main', 'b', 'c']
|
|
59
|
+
ZELLIJ_VERSION = '0.45.1'
|
|
60
|
+
ZELLIJ = L.SHARE / 'bin' / f'zellij-{ZELLIJ_VERSION}'
|
|
61
|
+
ZELLIJ_BASE = [str(ZELLIJ), '--config-dir', str(L.REPO / 'zellij'), '--config', str(L.REPO / 'zellij/config.kdl')]
|
|
62
|
+
PROC = Path(os.environ.get('LANES_PROC_ROOT') or '/proc') # injectable for the unit tests
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def clean_env(extra=None):
|
|
66
|
+
"""Environment for the view server: nothing from the ssh session leaks into panes."""
|
|
67
|
+
env = {'HOME': str(Path.home()), 'USER': os.environ.get('USER', 'dev'), 'SHELL': '/bin/bash',
|
|
68
|
+
'PATH': f"{L.SHARE / 'bin'}:{L.REPO / 'bin'}:/usr/local/bin:/usr/bin:/bin",
|
|
69
|
+
'LANG': 'C.UTF-8', 'LANES_STATE': str(L.STATE), 'LANES_SHARE': str(L.SHARE), 'LANES_ZELLIJ': str(ZELLIJ)}
|
|
70
|
+
for key in ('LANES_WORKSPACES', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'ZELLIJ_SOCKET_DIR'): # last one: test isolation
|
|
71
|
+
if os.environ.get(key):
|
|
72
|
+
env[key] = os.environ[key]
|
|
73
|
+
if 'XDG_RUNTIME_DIR' not in env and Path(f'/run/user/{os.getuid()}').is_dir():
|
|
74
|
+
env['XDG_RUNTIME_DIR'] = f'/run/user/{os.getuid()}'
|
|
75
|
+
env.update(extra or {})
|
|
76
|
+
return env
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def q(text):
|
|
80
|
+
return json.dumps(text, ensure_ascii=False)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------- registry
|
|
84
|
+
|
|
85
|
+
def cmd_sync(_args):
|
|
86
|
+
previous = L.read_json(L.STATE / 'registry.json')
|
|
87
|
+
registry = L.build_registry(previous)
|
|
88
|
+
L.atomic_write(L.STATE / 'registry.json', json.dumps(registry, indent=1, ensure_ascii=False) + '\n')
|
|
89
|
+
L.write_resolve_files(registry)
|
|
90
|
+
L.export_container_registries(registry)
|
|
91
|
+
for workspace in registry['workspaces']:
|
|
92
|
+
focus = next((lane for lane in workspace['lanes'] if lane.get('focus')), None)
|
|
93
|
+
candidates = workspace['lanes'] or workspace['extras']
|
|
94
|
+
first = focus or (candidates[0] if candidates else None)
|
|
95
|
+
L.atomic_write(L.STATE / 'defaults' / workspace['id'], (first['id'] if first else '!empty') + '\n')
|
|
96
|
+
total = sum(len(w['lanes']) for w in registry['workspaces'])
|
|
97
|
+
failed = [w for w in registry['workspaces'] if w['errors']]
|
|
98
|
+
for workspace in registry['workspaces']:
|
|
99
|
+
mark = '!' if workspace['errors'] else ' '
|
|
100
|
+
print(f"{mark} {workspace['id']:<18} {len(workspace['lanes']):>3} lanes {workspace['container']}")
|
|
101
|
+
for error in workspace['errors']:
|
|
102
|
+
print(f' ⚠ {error}' + (' (kept previous lanes)' if workspace.get('stale') else ''))
|
|
103
|
+
print(f"{total} lanes in {len(registry['workspaces'])} workspaces" + (f'; {len(failed)} with errors' if failed else ''))
|
|
104
|
+
return 1 if failed else 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_ls(args):
|
|
108
|
+
registry = L.load_registry()
|
|
109
|
+
status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes', {})
|
|
110
|
+
wanted = [a for a in args if not a.startswith('-')]
|
|
111
|
+
rows = []
|
|
112
|
+
for workspace in registry['workspaces']:
|
|
113
|
+
if wanted and workspace['id'] not in wanted:
|
|
114
|
+
continue
|
|
115
|
+
for lane in workspace['lanes']:
|
|
116
|
+
rows.append(dict(lane, workspace=workspace['id'], status=status.get(lane['id'], {'state': 'unknown'})))
|
|
117
|
+
if '--json' in args:
|
|
118
|
+
print(json.dumps(rows, indent=1, ensure_ascii=False))
|
|
119
|
+
return 0
|
|
120
|
+
for row in rows:
|
|
121
|
+
target = (f"{row['tmux']['socket']} / {row['tmux']['session']}" if row['holder'] == 'tmux'
|
|
122
|
+
else row.get('zmx', {}).get('name', ''))
|
|
123
|
+
state = row['status'].get('state', 'unknown') + ('?' if row['status'].get('stale') else '') # ? = last known
|
|
124
|
+
print(f"{state:<8} {row['id']:<44} {row['holder']:<5} {target}")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_resolve(args):
|
|
129
|
+
registry = L.load_registry()
|
|
130
|
+
workspace, lane = L.find_lane(registry, args[0])
|
|
131
|
+
info = (L.read_json(L.STATE / 'containers.json') or {}).get(lane['container'], {})
|
|
132
|
+
out = dict(lane, workspace=workspace['id'])
|
|
133
|
+
if lane['holder'] == 'tmux' and info.get('pid'):
|
|
134
|
+
out['socket_path'] = L.tmux_socket(info, lane['tmux']['socket'])
|
|
135
|
+
out['attach'] = [str(L.REPO / 'holders/tmux.sh'), lane['container'], lane['tmux']['socket'], lane['tmux']['session']]
|
|
136
|
+
print(json.dumps(out, indent=1, ensure_ascii=False))
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_status(_args):
|
|
141
|
+
registry = L.load_registry()
|
|
142
|
+
status = L.read_json(L.STATE / 'status.json') or {}
|
|
143
|
+
age = time.time() - status.get('updated_at', 0)
|
|
144
|
+
print(f"status.json age: {age:.0f}s" + (' ⚠ statusd not running?' if age > 30 else ''))
|
|
145
|
+
for workspace in registry['workspaces']:
|
|
146
|
+
counts = {}
|
|
147
|
+
for lane in workspace['lanes']:
|
|
148
|
+
state = status.get('lanes', {}).get(lane['id'], {}).get('state', 'unknown')
|
|
149
|
+
counts[state] = counts.get(state, 0) + 1
|
|
150
|
+
summary = ' '.join(f'{k}={v}' for k, v in sorted(counts.items()))
|
|
151
|
+
print(f" {workspace['id']:<18} {len(workspace['lanes']):>3} {summary}")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------------- views
|
|
156
|
+
|
|
157
|
+
def layout_kdl(device, mobile=False, viewports=1):
|
|
158
|
+
registry = L.load_registry()
|
|
159
|
+
sidebar, view = str(L.REPO / 'libexec/sidebar'), str(L.REPO / 'libexec/view.sh')
|
|
160
|
+
lines = ['// Generated by `lanes layout`. Lanes are NOT part of this layout: adding, removing or',
|
|
161
|
+
'// reordering lanes only changes registry.json, so this layout never needs live surgery.',
|
|
162
|
+
'layout {',
|
|
163
|
+
' default_tab_template {',
|
|
164
|
+
' children',
|
|
165
|
+
' pane size=1 borderless=true {',
|
|
166
|
+
' plugin location="zellij:compact-bar"',
|
|
167
|
+
' }',
|
|
168
|
+
' }']
|
|
169
|
+
for index, workspace in enumerate(registry['workspaces']):
|
|
170
|
+
focus = ' focus=true' if index == 0 else ''
|
|
171
|
+
if mobile:
|
|
172
|
+
lines += [f' tab name={q(workspace["title"] + " ☰")}{focus} {{',
|
|
173
|
+
f' pane borderless=true name="Lanes" command={q(sidebar)} {{',
|
|
174
|
+
f' args {q(device)} {q(workspace["id"])}',
|
|
175
|
+
' }', ' }',
|
|
176
|
+
f' tab name={q(workspace["title"])} {{',
|
|
177
|
+
f' pane borderless=true name={q(workspace["title"])} command={q(view)} {{',
|
|
178
|
+
f' args {q(device)} {q(workspace["id"])} "main" "--mobile"',
|
|
179
|
+
' }', ' }']
|
|
180
|
+
continue
|
|
181
|
+
lines += [f' tab name={q(workspace["title"])}{focus} {{',
|
|
182
|
+
' pane split_direction="vertical" {',
|
|
183
|
+
f' pane size=36 borderless=true name="Lanes" command={q(sidebar)} {{',
|
|
184
|
+
f' args {q(device)} {q(workspace["id"])} "main" {q(str(viewports))}',
|
|
185
|
+
' }']
|
|
186
|
+
# Wide devices: several viewports side by side, each with its own target (slots main, b, c).
|
|
187
|
+
for index, slot in enumerate(SLOTS[:viewports]):
|
|
188
|
+
title = workspace['title'] if index == 0 else f"{workspace['title']} ·{index + 1}"
|
|
189
|
+
pane_focus = ' focus=true' if index == 0 else ''
|
|
190
|
+
lines += [f' pane borderless={"true" if viewports == 1 else "false"}{pane_focus} name={q(title)} command={q(view)} {{',
|
|
191
|
+
f' args {q(device)} {q(workspace["id"])} {q(slot)}',
|
|
192
|
+
' }']
|
|
193
|
+
lines += [' }', ' }']
|
|
194
|
+
for tool in registry.get('tools', []):
|
|
195
|
+
lines += tool_tab_kdl(device, tool, mobile=mobile)
|
|
196
|
+
lines += [' tab name="Host" {', ' pane command="/bin/bash" {', ' args "-l"', ' }', ' }', '}']
|
|
197
|
+
return '\n'.join(lines) + '\n'
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def tool_tab_kdl(device, tool, indent=' ', mobile=False):
|
|
201
|
+
"""One full-size tab for a shared tool (the Throttle monitor). It is a view with a fixed target, so it starts
|
|
202
|
+
only when the tab is first shown and restarts by itself if the tool exits."""
|
|
203
|
+
view = str(L.REPO / 'libexec/view.sh')
|
|
204
|
+
extra = ' "main" "--mobile"' if mobile else ''
|
|
205
|
+
return [f'{indent}tab name={q(tool["tab"])} {{',
|
|
206
|
+
f'{indent} pane borderless=true name={q(tool["name"])} command={q(view)} {{',
|
|
207
|
+
f'{indent} args {q(device)} {q(tool["view"])}{extra}',
|
|
208
|
+
f'{indent} }}', f'{indent}}}']
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def zellij(args, timeout):
|
|
212
|
+
"""One bounded zellij CLI call -> (rc, stdout, stderr); rc None = no answer in time. L.run_bounded, not
|
|
213
|
+
subprocess.run(timeout=): that one waits without a bound for the killed child, which never returns if the
|
|
214
|
+
child sits in D state on a thrashing host."""
|
|
215
|
+
return L.run_bounded(ZELLIJ_BASE + args, timeout, env=clean_env())
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def live_sessions():
|
|
219
|
+
"""Names of the live view sessions. Raises when zellij did not ANSWER (timeout, error): an unknown list is
|
|
220
|
+
not an empty one, and no caller may create or rebuild a session from it."""
|
|
221
|
+
rc, out, err = zellij(['list-sessions', '--no-formatting'], 20)
|
|
222
|
+
if rc == 1 and 'no active zellij sessions' in (out + err).lower():
|
|
223
|
+
return set() # zellij's way of saying "none" is exit 1 plus this sentence: a positive answer
|
|
224
|
+
if rc != 0:
|
|
225
|
+
reason = 'no answer in 20 s' if rc is None else (err.strip() or out.strip() or f'exit {rc}')
|
|
226
|
+
raise L.LanesError(f'cannot list the view sessions ({reason}); is the host under pressure? — see `lanes pressure`')
|
|
227
|
+
names = set()
|
|
228
|
+
for line in out.splitlines():
|
|
229
|
+
if line.strip() and '(EXITED' not in line:
|
|
230
|
+
names.add(line.split()[0])
|
|
231
|
+
return names
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def device_name(value):
|
|
235
|
+
if not L.IDENT.match(value or '') or len(value) > 32:
|
|
236
|
+
raise L.LanesError('Invalid device name')
|
|
237
|
+
return value
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def create_session(device, mobile, viewports=1):
|
|
241
|
+
# A headless session has a made-up size (50x50): its views must idle until a real client arrives.
|
|
242
|
+
try:
|
|
243
|
+
(L.STATE / 'clients' / f'{device}.ready').unlink()
|
|
244
|
+
except OSError:
|
|
245
|
+
pass
|
|
246
|
+
for shown in (L.STATE / 'views' / device).glob('*.shown'):
|
|
247
|
+
shown.unlink(missing_ok=True)
|
|
248
|
+
layout_file = L.STATE / 'layouts' / f'{device}.kdl'
|
|
249
|
+
L.atomic_write(layout_file, layout_kdl(device, mobile, viewports))
|
|
250
|
+
meta_file = L.STATE / 'devices' / f'{device}.json'
|
|
251
|
+
meta = {'version': 1, 'mobile': mobile, 'viewports': viewports, 'created_at': time.time()}
|
|
252
|
+
base = (L.read_json(meta_file) or {}).get('base')
|
|
253
|
+
if base: # learnt at attach (see base_device); `ensure` and `rebuild` must not forget it
|
|
254
|
+
meta['base'] = base
|
|
255
|
+
L.atomic_write(meta_file, json.dumps(meta) + '\n')
|
|
256
|
+
command = ZELLIJ_BASE + ['--layout', str(layout_file), 'attach', '--create-background', device]
|
|
257
|
+
if shutil.which('systemd-run'):
|
|
258
|
+
# Own cgroup scope: a zellij leak can never pressure the host or any lane. It lives in lanes.slice, whose
|
|
259
|
+
# CPUWeight (systemd/lanes.slice) keeps the views scheduled on a thrashing host; the weight is set on the
|
|
260
|
+
# slice only, never per scope.
|
|
261
|
+
command = ['systemd-run', '--user', '--scope', '--quiet', '--collect', '--slice=lanes.slice',
|
|
262
|
+
f'--unit=lanes-session-{device}-{int(time.time())}',
|
|
263
|
+
'-p', 'MemoryLow=512M', '-p', 'MemoryHigh=2G', '-p', 'MemoryMax=4G', '--'] + command
|
|
264
|
+
rc, out, err = L.run_bounded(command, 60, env=clean_env()) # stdin is /dev/null, never the caller's tty
|
|
265
|
+
if rc != 0:
|
|
266
|
+
raise L.LanesError(f'Could not create session {device}: '
|
|
267
|
+
+ ('no answer in 60 s' if rc is None else err.strip() or out.strip() or f'exit {rc}'))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def base_device(device, default):
|
|
271
|
+
"""The machine a view belongs to: `mac-2` / `mac-w2`, opened by the client whose default device is `mac`, share
|
|
272
|
+
mac's upload helper (one helper service per machine); every other device is its own base."""
|
|
273
|
+
return default if default and device.startswith(default + '-') else device
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def viewports_arg(args, default=1):
|
|
277
|
+
if '--viewports' in args:
|
|
278
|
+
try:
|
|
279
|
+
return max(1, min(len(SLOTS), int(args[args.index('--viewports') + 1])))
|
|
280
|
+
except (ValueError, IndexError):
|
|
281
|
+
raise L.LanesError('--viewports takes 1, 2 or 3')
|
|
282
|
+
return default
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def cmd_layout(args):
|
|
286
|
+
print(layout_kdl(device_name(args[0]), '--mobile' in args, viewports_arg(args)), end='')
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def cmd_attach(args):
|
|
291
|
+
device = device_name(args[0])
|
|
292
|
+
# Old muscle memory: `coder-launch worlds`. A workspace id is not a device: open the caller's normal
|
|
293
|
+
# device session and bring that workspace's tab to the front.
|
|
294
|
+
tab = None
|
|
295
|
+
default = args[args.index('--default-device') + 1] if '--default-device' in args[:-1] else None
|
|
296
|
+
try:
|
|
297
|
+
workspace = next((w for w in L.load_registry()['workspaces'] if w['id'] == device), None)
|
|
298
|
+
except L.LanesError:
|
|
299
|
+
workspace = None
|
|
300
|
+
if workspace:
|
|
301
|
+
if not default:
|
|
302
|
+
print(f"lanes: '{device}' is a workspace, not a device. Attach your device session and use Ctrl-Alt-←/→.", file=sys.stderr)
|
|
303
|
+
return 64
|
|
304
|
+
tab, device = workspace['title'], device_name(default)
|
|
305
|
+
client = args[args.index('--client') + 1] if '--client' in args else 'default'
|
|
306
|
+
if not re.match(r'^[A-Za-z0-9_.-]{1,48}$', client):
|
|
307
|
+
raise L.LanesError('Invalid client id')
|
|
308
|
+
if not ZELLIJ.exists():
|
|
309
|
+
raise L.LanesError(f'{ZELLIJ} is not installed: run install/host-install.sh')
|
|
310
|
+
if not (L.STATE / 'registry.json').exists():
|
|
311
|
+
cmd_sync([])
|
|
312
|
+
try:
|
|
313
|
+
missing = device not in live_sessions()
|
|
314
|
+
except L.LanesError as error:
|
|
315
|
+
# Unknown is not missing: create nothing and just try to attach. If the session really is gone, zellij says
|
|
316
|
+
# so itself (nothing is resurrected: session_serialization is off) and lanes-launch retries.
|
|
317
|
+
print(f'lanes: {error}', file=sys.stderr)
|
|
318
|
+
missing = False
|
|
319
|
+
if missing:
|
|
320
|
+
create_session(device, '--mobile' in args, viewports_arg(args))
|
|
321
|
+
# Record the base, also for sessions that existed before it was known. A client that names no default device
|
|
322
|
+
# teaches nothing: what is recorded stays. Readers treat a missing "base" as the device itself. (`default` is
|
|
323
|
+
# unvalidated client text, but only ever stored when it is a prefix of the validated device name.)
|
|
324
|
+
meta_file = L.STATE / 'devices' / f'{device}.json'
|
|
325
|
+
meta = L.read_json(meta_file) or {}
|
|
326
|
+
if default and meta and meta.get('base') != base_device(device, default):
|
|
327
|
+
L.atomic_write(meta_file, json.dumps(dict(meta, base=base_device(device, default))) + '\n')
|
|
328
|
+
# Views idle until a real client (with a real size) has attached once. We exec into the zellij
|
|
329
|
+
# client below, so a detached helper raises the flag shortly after the client has sized the session.
|
|
330
|
+
ready = L.STATE / 'clients' / f'{device}.ready'
|
|
331
|
+
if not ready.exists() and os.fork() == 0:
|
|
332
|
+
os.setsid()
|
|
333
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
334
|
+
for fd in (0, 1, 2):
|
|
335
|
+
os.dup2(devnull, fd)
|
|
336
|
+
time.sleep(1.5)
|
|
337
|
+
L.atomic_write(ready, f'{time.time()}\n')
|
|
338
|
+
os._exit(0)
|
|
339
|
+
# Reap our own stale predecessor (same device + client id) instead of waiting for sshd timeouts.
|
|
340
|
+
pid_file = L.STATE / 'clients' / f'{device}.{client}.pid'
|
|
341
|
+
try:
|
|
342
|
+
old = int(pid_file.read_text().strip())
|
|
343
|
+
cmdline = Path(f'/proc/{old}/cmdline').read_bytes()
|
|
344
|
+
if old != os.getpid() and b'zellij' in cmdline and b'attach' in cmdline:
|
|
345
|
+
os.kill(old, 15)
|
|
346
|
+
except (OSError, ValueError):
|
|
347
|
+
pass
|
|
348
|
+
L.atomic_write(pid_file, f'{os.getpid()}\n')
|
|
349
|
+
if tab and os.fork() == 0: # after the client is attached, show the requested workspace
|
|
350
|
+
os.setsid()
|
|
351
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
352
|
+
for fd in (0, 1, 2):
|
|
353
|
+
os.dup2(devnull, fd)
|
|
354
|
+
time.sleep(1.2)
|
|
355
|
+
zellij(['-s', device, 'action', 'go-to-tab-name', tab], 15)
|
|
356
|
+
os._exit(0)
|
|
357
|
+
env = clean_env({'TERM': os.environ.get('TERM', 'xterm-256color'),
|
|
358
|
+
'COLORTERM': os.environ.get('COLORTERM', 'truecolor')})
|
|
359
|
+
if L.run_bounded(['infocmp', env['TERM']], 5)[0] != 0: # unknown here — or no answer: the safe default
|
|
360
|
+
env['TERM'] = 'xterm-256color'
|
|
361
|
+
os.execve(str(ZELLIJ), ZELLIJ_BASE + ['attach', device], env)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def cmd_open(args):
|
|
365
|
+
lane_id = args[0]
|
|
366
|
+
if not re.match(r'^[A-Za-z0-9_.-]+$', lane_id):
|
|
367
|
+
raise L.LanesError('Invalid lane id')
|
|
368
|
+
resolve = L.STATE / 'resolve' / lane_id
|
|
369
|
+
if not resolve.exists():
|
|
370
|
+
resolve = L.STATE / 'resolve.dynamic' / lane_id
|
|
371
|
+
if not resolve.exists():
|
|
372
|
+
raise L.LanesError(f'Unknown lane: {lane_id}')
|
|
373
|
+
holder, a, b, c = resolve.read_text().split('\n')[:4]
|
|
374
|
+
if holder not in ('tmux', 'zmx', 'exec'):
|
|
375
|
+
raise L.LanesError(f'Unknown holder: {holder}')
|
|
376
|
+
env = clean_env({'TERM': 'xterm-256color', 'LANES_WS': lane_id.split('--')[0]})
|
|
377
|
+
os.execve(str(L.REPO / 'holders' / f'{holder}.sh'), [f'{holder}.sh', a, b, c], env)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
ENV_HOOK = '[ -r "$HOME/.lz/env.sh" ] && . "$HOME/.lz/env.sh" # lanes-env: temp files off the RAM-backed /tmp'
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def hook_env(home):
|
|
384
|
+
"""Make env.sh take effect for NEW shells: the disk temp dir it points at, and ONE marker-guarded line appended to
|
|
385
|
+
the login files bash/zsh really read. Append only, idempotent, never rewrites a dotfile; remove the line to undo."""
|
|
386
|
+
tmp = home / '.tmp'
|
|
387
|
+
if not tmp.exists():
|
|
388
|
+
tmp.mkdir(mode=0o700)
|
|
389
|
+
tmp.chmod(0o700)
|
|
390
|
+
login = home / '.bash_profile' if (home / '.bash_profile').exists() else home / '.profile'
|
|
391
|
+
targets = [login, home / '.bashrc'] + ([home / '.zshrc'] if (home / '.zshrc').exists() else [])
|
|
392
|
+
for path in targets:
|
|
393
|
+
text = path.read_text(errors='replace') if path.exists() else ''
|
|
394
|
+
if 'lanes-env' in text:
|
|
395
|
+
continue
|
|
396
|
+
with open(path, 'a') as handle:
|
|
397
|
+
handle.write(('' if text.endswith('\n') or not text else '\n') + ENV_HOOK + '\n')
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def cmd_install_container(args):
|
|
401
|
+
container = args[0]
|
|
402
|
+
info = L.container_info(container)
|
|
403
|
+
if not info.get('home'):
|
|
404
|
+
raise L.LanesError(f'{container}: home volume not found')
|
|
405
|
+
root = Path(info['home']) / '.lz'
|
|
406
|
+
for sub in ('bin', 's', 'meta', 'state'):
|
|
407
|
+
(root / sub).mkdir(parents=True, exist_ok=True)
|
|
408
|
+
for source in (L.REPO / 'container/lane', L.REPO / 'container/lane-run'):
|
|
409
|
+
shutil.copy2(source, root / 'bin' / source.name)
|
|
410
|
+
env_file = L.REPO / 'container/env.sh' # TMPDIR off the RAM-backed /tmp; only NEW shells and agents read it
|
|
411
|
+
if env_file.exists():
|
|
412
|
+
shutil.copy2(env_file, root / 'env.sh')
|
|
413
|
+
hook_env(Path(info['home']))
|
|
414
|
+
for binary in sorted((L.SHARE / 'bin').glob('zmx-*')):
|
|
415
|
+
target = root / 'bin' / binary.name
|
|
416
|
+
if not target.exists(): # a running lane's daemon keeps executing its own version: never overwrite
|
|
417
|
+
shutil.copy2(binary, target)
|
|
418
|
+
link = Path.home() / '.lz' / container
|
|
419
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
420
|
+
if link.is_symlink() or link.exists():
|
|
421
|
+
link.unlink()
|
|
422
|
+
link.symlink_to(root)
|
|
423
|
+
print(f'installed {root}/bin/{{lane,lane-run,zmx-*}}' + (f' and {root}/env.sh' if env_file.exists() else '')
|
|
424
|
+
+ f'; host link {link} -> {root}')
|
|
425
|
+
return 0
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def cmd_rebuild(args):
|
|
429
|
+
device = device_name(args[0])
|
|
430
|
+
meta = L.read_json(L.STATE / 'devices' / f'{device}.json') or {}
|
|
431
|
+
if device in live_sessions():
|
|
432
|
+
# Attached clients see an ordinary exit (0) when their session is killed: leave a stamp so that
|
|
433
|
+
# lanes-launch (`lanes rebuilt <device>`) reconnects instead of closing the window.
|
|
434
|
+
L.atomic_write(L.STATE / 'clients' / f'{device}.rebuilt', f'{time.time()}\n')
|
|
435
|
+
if zellij(['kill-session', device], 30)[0] is None:
|
|
436
|
+
raise L.LanesError(f'zellij did not answer in 30 s: {device} was NOT rebuilt (no lane was touched)')
|
|
437
|
+
zellij(['delete-session', '--force', device], 30)
|
|
438
|
+
create_session(device, bool(meta.get('mobile')) or '--mobile' in args, viewports_arg(args, int(meta.get('viewports') or 1)))
|
|
439
|
+
print(f'rebuilt view session {device}; no lane was touched')
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def cmd_upgrade_session(args):
|
|
444
|
+
registry = L.load_registry()
|
|
445
|
+
live = live_sessions()
|
|
446
|
+
devices = [device_name(a) for a in args] or [p.stem for p in sorted((L.STATE / 'devices').glob('*.json'))]
|
|
447
|
+
for device in devices:
|
|
448
|
+
if device not in live:
|
|
449
|
+
print(f'{device}: not running (it gets the current layout when it is next created)')
|
|
450
|
+
continue
|
|
451
|
+
rc, out, _err = zellij(['-s', device, 'action', 'list-tabs', '-j'], 20)
|
|
452
|
+
if rc != 0: # unknown tabs are not missing tabs: add nothing
|
|
453
|
+
print(f'{device}: zellij did not answer; left alone')
|
|
454
|
+
continue
|
|
455
|
+
tabs = json.loads(out or '[]')
|
|
456
|
+
names = {tab.get('name') for tab in tabs}
|
|
457
|
+
for tool in registry.get('tools', []):
|
|
458
|
+
if tool['tab'] in names:
|
|
459
|
+
print(f"{device}: tab '{tool['tab']}' already present")
|
|
460
|
+
continue
|
|
461
|
+
layout = '\n'.join(['layout {', ' default_tab_template {', ' children',
|
|
462
|
+
' pane size=1 borderless=true {', ' plugin location="zellij:compact-bar"',
|
|
463
|
+
' }', ' }'] + tool_tab_kdl(device, tool) + ['}'])
|
|
464
|
+
active = next((tab for tab in tabs if tab.get('active')), None)
|
|
465
|
+
zellij(['-s', device, 'action', 'new-tab', '--name', tool['tab'], '--layout-string', layout], 30)
|
|
466
|
+
if active: # new-tab takes the focus: hand it back to where the user was
|
|
467
|
+
zellij(['-s', device, 'action', 'go-to-tab-name', active['name']], 15)
|
|
468
|
+
print(f"{device}: added tab '{tool['tab']}'")
|
|
469
|
+
return 0
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def cmd_reattach(_args):
|
|
473
|
+
count = 0
|
|
474
|
+
for pid_file in (L.STATE / 'views').glob('*/*.pid'):
|
|
475
|
+
try:
|
|
476
|
+
pid = int(pid_file.read_text().strip())
|
|
477
|
+
if b'view.sh' in Path(f'/proc/{pid}/cmdline').read_bytes():
|
|
478
|
+
os.kill(pid, 10) # SIGUSR1
|
|
479
|
+
count += 1
|
|
480
|
+
except (OSError, ValueError):
|
|
481
|
+
pass
|
|
482
|
+
print(f're-attached {count} views')
|
|
483
|
+
return 0
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def cmd_ensure(_args):
|
|
487
|
+
# live_sessions() raises when zellij did not answer: then this run ends here and recreates nothing (the timer
|
|
488
|
+
# tries again in a minute). "Missing" is only ever concluded from a list zellij actually gave.
|
|
489
|
+
live = live_sessions()
|
|
490
|
+
for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
|
|
491
|
+
device = meta_file.stem
|
|
492
|
+
if device not in live:
|
|
493
|
+
meta = L.read_json(meta_file) or {}
|
|
494
|
+
create_session(device, bool(meta.get('mobile')), int(meta.get('viewports') or 1))
|
|
495
|
+
print(f'recreated view session {device}')
|
|
496
|
+
return 0
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def cmd_prepare(args):
|
|
500
|
+
device = device_name(args[0])
|
|
501
|
+
if device not in live_sessions():
|
|
502
|
+
create_session(device, '--mobile' in args, viewports_arg(args))
|
|
503
|
+
elif '--mobile' in args and not (L.read_json(L.STATE / 'devices' / f'{device}.json') or {}).get('mobile'):
|
|
504
|
+
raise L.LanesError(f'{device} already has a desktop layout; choose another device name')
|
|
505
|
+
print(f'ready: {device}')
|
|
506
|
+
return 0
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ------------------------------------------------------------------ safety
|
|
510
|
+
|
|
511
|
+
PANE_FORMAT = '#{session_name}\t#{window_index}.#{pane_index}\t#{pane_pid}\t#{window_width}x#{window_height}'
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def all_panes():
|
|
515
|
+
"""Every pane on every LIVE tmux server that the registry references (ps-discovered, read-only).
|
|
516
|
+
Ephemeral test-harness sockets come and go on their own and are not lanes.
|
|
517
|
+
Raises when ps or a live server did not ANSWER: a partial list would be saved as a weaker invariant, or
|
|
518
|
+
compared as panes that are "gone" — on a busy host both are wrong."""
|
|
519
|
+
sys.path.insert(0, str(L.REPO / 'libexec'))
|
|
520
|
+
import statusd
|
|
521
|
+
unknown = 'the panes are UNKNOWN, not gone — nothing saved or compared; try again'
|
|
522
|
+
live = statusd.live_tmux_sockets()
|
|
523
|
+
if live is None:
|
|
524
|
+
raise L.LanesError(f'ps did not answer: {unknown}')
|
|
525
|
+
containers = L.read_json(L.STATE / 'containers.json') or {}
|
|
526
|
+
referenced = {}
|
|
527
|
+
for workspace in L.load_registry()['workspaces']:
|
|
528
|
+
for lane in workspace['lanes']:
|
|
529
|
+
if lane['holder'] == 'tmux':
|
|
530
|
+
referenced.setdefault(lane['container'], set()).add(lane['tmux']['socket'])
|
|
531
|
+
panes = {}
|
|
532
|
+
for container, info in containers.items():
|
|
533
|
+
if not info.get('pid'):
|
|
534
|
+
continue
|
|
535
|
+
for socket in sorted(live & referenced.get(container, set())):
|
|
536
|
+
outcome, output = L.tmux_probe(info, socket, ['list-panes', '-a', '-F', PANE_FORMAT])
|
|
537
|
+
if outcome in ('timeout', 'error'):
|
|
538
|
+
raise L.LanesError(f'{container}/{socket}: tmux did not answer ({outcome}): {unknown}')
|
|
539
|
+
for row in (output if outcome == 'ok' else '').splitlines():
|
|
540
|
+
session, pane, pid, size = row.split('\t')
|
|
541
|
+
panes[f'{container}/{socket}/{session}/{pane}'] = {'pid': pid, 'size': size}
|
|
542
|
+
return panes
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def cmd_invariant(args):
|
|
546
|
+
path = L.STATE / 'invariant.json'
|
|
547
|
+
current = all_panes()
|
|
548
|
+
if args and args[0] == 'save':
|
|
549
|
+
L.atomic_write(path, json.dumps({'saved_at': time.time(), 'panes': current}, indent=1) + '\n')
|
|
550
|
+
print(f'saved {len(current)} panes')
|
|
551
|
+
return 0
|
|
552
|
+
saved = (L.read_json(path) or {}).get('panes')
|
|
553
|
+
if saved is None:
|
|
554
|
+
raise L.LanesError('No saved invariant: run `lanes invariant save` first')
|
|
555
|
+
gone = [k for k in saved if k not in current]
|
|
556
|
+
changed_pid = [k for k in saved if k in current and saved[k]['pid'] != current[k]['pid']]
|
|
557
|
+
resized = [k for k in saved if k in current and saved[k]['size'] != current[k]['size']]
|
|
558
|
+
new = [k for k in current if k not in saved]
|
|
559
|
+
print(f'panes: saved={len(saved)} now={len(current)} gone={len(gone)} pid-changed={len(changed_pid)} '
|
|
560
|
+
f'resized={len(resized)} new={len(new)}')
|
|
561
|
+
for label, keys in (('GONE', gone), ('PID', changed_pid), ('RESIZED', resized)):
|
|
562
|
+
for key in keys[:20]:
|
|
563
|
+
print(f' {label:<8} {key} {saved[key]} -> {current.get(key)}')
|
|
564
|
+
# Sizes are part of the invariant only while the old viewers own them (coexistence mode).
|
|
565
|
+
sizes_matter = (L.STATE / 'flags/coexist').exists()
|
|
566
|
+
return 1 if (gone or changed_pid or (resized and sizes_matter)) else 0
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def cmd_verify_legacy(_args):
|
|
570
|
+
registry = L.load_registry()
|
|
571
|
+
containers = registry['containers']
|
|
572
|
+
bad = 0
|
|
573
|
+
for workspace in registry['workspaces']:
|
|
574
|
+
info = containers.get(workspace['container']) or {}
|
|
575
|
+
layout = Path(info.get('home', '')) / '.config/coder-launch/layouts' / f"{workspace['id']}.kdl"
|
|
576
|
+
try:
|
|
577
|
+
text = layout.read_text()
|
|
578
|
+
except OSError:
|
|
579
|
+
print(f"? {workspace['id']:<18} no deployed legacy layout")
|
|
580
|
+
continue
|
|
581
|
+
deployed = {}
|
|
582
|
+
for match in re.finditer(r'tab name=("(?:[^"\\]|\\.)*")[^\n]*\{\s*pane [^\n]*coder-launch/connect"\s*\{\s*args ("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")', text):
|
|
583
|
+
name, socket, session = (json.loads(g) for g in match.groups())
|
|
584
|
+
deployed[name] = (socket, session)
|
|
585
|
+
ours = {lane['name']: (lane['tmux']['socket'], lane['tmux']['session'])
|
|
586
|
+
for lane in workspace['lanes'] if lane['holder'] == 'tmux'}
|
|
587
|
+
diffs = [n for n in sorted(set(ours) | set(deployed)) if ours.get(n) != deployed.get(n)]
|
|
588
|
+
bad += len(diffs)
|
|
589
|
+
print(f"{'✓' if not diffs else '✗'} {workspace['id']:<18} registry={len(ours):>3} deployed={len(deployed):>3}")
|
|
590
|
+
for name in diffs:
|
|
591
|
+
print(f' {name!r}: registry={ours.get(name)} deployed={deployed.get(name)}')
|
|
592
|
+
return 1 if bad else 0
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def active_workspace(registry, device):
|
|
596
|
+
"""The board the device's view is showing right now (its active zellij tab)."""
|
|
597
|
+
out = zellij(['-s', device, 'action', 'list-tabs', '-j'], 15)[1]
|
|
598
|
+
try:
|
|
599
|
+
active = next(t for t in json.loads(out) if t.get('active'))
|
|
600
|
+
except (ValueError, StopIteration):
|
|
601
|
+
raise L.LanesError(f'cannot tell which board {device} is showing; name one: lanes upload <workspace>')
|
|
602
|
+
title = active.get('name', '').replace(' ☰', '')
|
|
603
|
+
for workspace in registry['workspaces']:
|
|
604
|
+
if workspace['title'] == title:
|
|
605
|
+
return workspace
|
|
606
|
+
raise L.LanesError(f"the active tab '{title}' is not a board; name one: lanes upload <workspace>")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def type_into_lane(registry, device, workspace, text):
|
|
610
|
+
"""Type text into the lane shown in the device's first viewport (user-initiated input, like typing)."""
|
|
611
|
+
target = L.STATE / 'views' / device / f"{workspace['id']}.main.target"
|
|
612
|
+
lane_id = (target.read_text().split('\n')[0] if target.exists() else '') or \
|
|
613
|
+
(L.STATE / 'defaults' / workspace['id']).read_text().split('\n')[0]
|
|
614
|
+
_ws, lane = L.find_lane(registry, lane_id)
|
|
615
|
+
info = (L.read_json(L.STATE / 'containers.json') or {}).get(lane['container'], {})
|
|
616
|
+
if lane['holder'] == 'tmux':
|
|
617
|
+
ok = L.tmux_query(info, lane['tmux']['socket'], ['send-keys', '-t', f"={lane['tmux']['session']}:", '-l', '--', text])
|
|
618
|
+
return lane['name'] if ok is not None else None
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def paste_target(spool, request, fallback):
|
|
623
|
+
"""Requests of a suffixed view (mac-2) are spooled under its BASE device (mac), whose one helper serves the whole
|
|
624
|
+
machine with the unchanged line protocol; the sidecar <id>.dev names the view that asked — the device whose
|
|
625
|
+
lane gets the paste. No (or an unusable) sidecar = an older sidebar: the spool's own device, as before."""
|
|
626
|
+
try:
|
|
627
|
+
return device_name((spool / f'{request}.dev').read_text().strip())
|
|
628
|
+
except (OSError, L.LanesError):
|
|
629
|
+
return fallback
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class UploadReader:
|
|
633
|
+
"""Stream bytes without buffering the upload in RAM; record activity for the sidebar's idle timeout."""
|
|
634
|
+
def __init__(self, source, progress):
|
|
635
|
+
self.source, self.progress = source, progress
|
|
636
|
+
self.received, self.updated = 0, 0
|
|
637
|
+
|
|
638
|
+
def read(self, size):
|
|
639
|
+
data = self.source.read(size)
|
|
640
|
+
self.received += len(data)
|
|
641
|
+
now = time.monotonic()
|
|
642
|
+
if self.progress and data and now - self.updated >= 1:
|
|
643
|
+
L.atomic_write(self.progress, json.dumps({'bytes': self.received}) + '\n')
|
|
644
|
+
self.updated = now
|
|
645
|
+
return data
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def cmd_upload(args):
|
|
649
|
+
import shlex
|
|
650
|
+
import tarfile
|
|
651
|
+
registry = L.load_registry()
|
|
652
|
+
device = args[args.index('--active') + 1] if '--active' in args else None
|
|
653
|
+
paste_device = args[args.index('--device') + 1] if '--device' in args else device
|
|
654
|
+
default_device = args[args.index('--default-device') + 1] if '--default-device' in args else None
|
|
655
|
+
if paste_device:
|
|
656
|
+
paste_device = resolve_device(paste_device, args)
|
|
657
|
+
request = args[args.index('--request') + 1] if '--request' in args else None
|
|
658
|
+
if request and not re.match(r'^[0-9]+-[0-9]+$', request):
|
|
659
|
+
raise L.LanesError('invalid request id')
|
|
660
|
+
spool = L.STATE / 'upload-requests' / device_name(paste_device) if (request and paste_device) else None
|
|
661
|
+
skip = {device, paste_device, request, default_device, args[args.index('--device') + 1] if '--device' in args else None}
|
|
662
|
+
names = [a for a in args if not a.startswith('--') and a not in skip]
|
|
663
|
+
if device:
|
|
664
|
+
workspace = active_workspace(registry, device_name(device))
|
|
665
|
+
elif names:
|
|
666
|
+
workspace = next((w for w in registry['workspaces'] if w['id'] == names[0]), None)
|
|
667
|
+
if not workspace:
|
|
668
|
+
raise L.LanesError(f'unknown board: {names[0]} (see `lanes workspaces`)')
|
|
669
|
+
else:
|
|
670
|
+
raise L.LanesError('usage: lanes upload <workspace> | --active <device> [--paste] (tar stream on stdin)')
|
|
671
|
+
folder = next((lane.get('folder') for lane in workspace['lanes'] if lane.get('folder')), None) or workspace['cwd']
|
|
672
|
+
info = registry['containers'].get(workspace['container']) or {}
|
|
673
|
+
root = L.host_path(info, folder)
|
|
674
|
+
uploads = root / 'uploads'
|
|
675
|
+
uploads.mkdir(exist_ok=True)
|
|
676
|
+
# Keep uploads out of commits without touching a tracked .gitignore: the repository-local exclude file.
|
|
677
|
+
exclude = root / '.git' / 'info' / 'exclude'
|
|
678
|
+
# check-ignore: 0 = ignored, 1 = not ignored; an error or no answer (None) is not a reason to append.
|
|
679
|
+
if (root / '.git').is_dir() and L.run_bounded(['git', '-C', str(root), 'check-ignore', '-q', 'uploads/x'], 10)[0] == 1:
|
|
680
|
+
exclude.parent.mkdir(exist_ok=True)
|
|
681
|
+
with open(exclude, 'a') as handle:
|
|
682
|
+
handle.write('\n# lanes upload: local intake folder, never committed\nuploads/\n')
|
|
683
|
+
staging = uploads / f'.incoming-{os.getpid()}'
|
|
684
|
+
staging.mkdir()
|
|
685
|
+
landed = []
|
|
686
|
+
try:
|
|
687
|
+
stream = UploadReader(sys.stdin.buffer, spool / f'{request}.progress' if spool else None)
|
|
688
|
+
with tarfile.open(fileobj=stream, mode='r|') as archive:
|
|
689
|
+
archive.extractall(staging, filter='data') # regular files/dirs only, no absolute paths, no links out
|
|
690
|
+
for entry in sorted(staging.iterdir()):
|
|
691
|
+
final, n = uploads / entry.name, 1
|
|
692
|
+
while final.exists(): # never overwrite an earlier upload
|
|
693
|
+
final = uploads / f'{entry.stem}-{n}{entry.suffix}'
|
|
694
|
+
n += 1
|
|
695
|
+
entry.rename(final)
|
|
696
|
+
landed.append(f"{folder}/uploads/{final.name}")
|
|
697
|
+
finally:
|
|
698
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
699
|
+
if not landed:
|
|
700
|
+
# The helper reports the client-side cause (eg. macOS denied Downloads). Publishing a generic .err here
|
|
701
|
+
# races that report: the sidebar consumes it and loses the useful error before the helper can send it.
|
|
702
|
+
raise L.LanesError('nothing was uploaded (empty archive)')
|
|
703
|
+
for path in landed:
|
|
704
|
+
print(path)
|
|
705
|
+
if '--paste' in args and paste_device:
|
|
706
|
+
# The result files (.done / .err) stay in the base spool, where the asking sidebar looks for them.
|
|
707
|
+
view_device = paste_target(spool, request, paste_device) if spool else paste_device
|
|
708
|
+
shown = type_into_lane(registry, device_name(view_device), workspace, ' '.join(shlex.quote(p) for p in landed) + ' ')
|
|
709
|
+
print(f'# typed into lane: {shown}' if shown else '# could not type into the lane (not a tmux lane?)', file=sys.stderr)
|
|
710
|
+
if spool:
|
|
711
|
+
L.atomic_write(spool / f'{request}.done', '\n'.join(landed) + '\n')
|
|
712
|
+
return 0
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def resolve_device(name, args):
|
|
716
|
+
"""`coder-launch worlds` style: a board id given where a device is expected means "my default device"."""
|
|
717
|
+
try:
|
|
718
|
+
boards = {w['id'] for w in L.load_registry()['workspaces']}
|
|
719
|
+
except L.LanesError:
|
|
720
|
+
boards = set()
|
|
721
|
+
if name in boards and '--default-device' in args:
|
|
722
|
+
name = args[args.index('--default-device') + 1]
|
|
723
|
+
return device_name(name)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def cmd_upload_requests(args):
|
|
727
|
+
"""Long-running: stream this device's upload requests (made by the sidebar's upload box) to the client-side
|
|
728
|
+
helper as a simple line protocol, one request at a time, at most once (atomic rename):
|
|
729
|
+
BEGIN <request-id> <board> <paste:0|1> / P <local path> ... / END"""
|
|
730
|
+
device = resolve_device(args[0], args)
|
|
731
|
+
spool = L.STATE / 'upload-requests' / device
|
|
732
|
+
spool.mkdir(parents=True, exist_ok=True)
|
|
733
|
+
deadline = time.time() + 900 # the client reconnects; keeps stale helpers from piling up
|
|
734
|
+
print('READY', flush=True)
|
|
735
|
+
last_ping = time.time()
|
|
736
|
+
while time.time() < deadline:
|
|
737
|
+
L.atomic_write(spool / 'agent.alive', f'{time.time()}\n')
|
|
738
|
+
for request in sorted(spool.glob('*.req')):
|
|
739
|
+
taken = request.with_suffix('.sent')
|
|
740
|
+
try:
|
|
741
|
+
request.rename(taken)
|
|
742
|
+
except OSError:
|
|
743
|
+
continue # another helper of this device took it
|
|
744
|
+
lines = taken.read_text().split('\n')
|
|
745
|
+
board, paste, paths = lines[0], lines[1], [p for p in lines[2:] if p]
|
|
746
|
+
if not L.IDENT.match(board) or not paths or any('\n' in p for p in paths):
|
|
747
|
+
L.atomic_write(taken.with_suffix('.err'), 'malformed request\n')
|
|
748
|
+
continue
|
|
749
|
+
out = [f'BEGIN {taken.stem} {board} {1 if paste == "1" else 0}'] + [f'P {p}' for p in paths] + ['END']
|
|
750
|
+
try:
|
|
751
|
+
print('\n'.join(out), flush=True)
|
|
752
|
+
except BrokenPipeError:
|
|
753
|
+
taken.rename(request) # helper went away: leave the request for the next one
|
|
754
|
+
return 0
|
|
755
|
+
if time.time() - last_ping > 5:
|
|
756
|
+
try:
|
|
757
|
+
print('PING', flush=True) # notice a dead client
|
|
758
|
+
last_ping = time.time()
|
|
759
|
+
except BrokenPipeError:
|
|
760
|
+
return 0
|
|
761
|
+
time.sleep(0.4)
|
|
762
|
+
return 0
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def cmd_upload_result(args):
|
|
766
|
+
"""The client helper reports a request it refused (folder not allowed, file missing)."""
|
|
767
|
+
rest = list(args)
|
|
768
|
+
if '--default-device' in args:
|
|
769
|
+
index = args.index('--default-device')
|
|
770
|
+
del rest[index:index + 2]
|
|
771
|
+
device, request, message = resolve_device(args[0], args), rest[1], ' '.join(rest[2:])[:200]
|
|
772
|
+
if not re.match(r'^[0-9]+-[0-9]+$', request):
|
|
773
|
+
raise L.LanesError('invalid request id')
|
|
774
|
+
L.atomic_write(L.STATE / 'upload-requests' / device / f'{request}.err', message + '\n')
|
|
775
|
+
return 0
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def cmd_workspaces(_args):
|
|
779
|
+
for workspace in L.load_registry()['workspaces']:
|
|
780
|
+
print(workspace['id'])
|
|
781
|
+
return 0
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def cmd_rebuilt(args):
|
|
785
|
+
"""Exit 0 if this device's view session was rebuilt in the last minute (the client then reconnects)."""
|
|
786
|
+
try:
|
|
787
|
+
stamp = float((L.STATE / 'clients' / f'{resolve_device(args[0], args)}.rebuilt').read_text())
|
|
788
|
+
except (OSError, ValueError):
|
|
789
|
+
return 1
|
|
790
|
+
return 0 if time.time() - stamp < 60 else 1
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def cmd_hello(args):
|
|
794
|
+
device = resolve_device(args[0], args) if args else '-'
|
|
795
|
+
version = (L.REPO / 'VERSION').read_text().strip() if (L.REPO / 'VERSION').exists() else 'dev'
|
|
796
|
+
boards = len(L.load_registry()['workspaces']) if (L.STATE / 'registry.json').exists() else 0
|
|
797
|
+
print(f'ok device={device} version={version} boards={boards}')
|
|
798
|
+
return 0
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def cmd_client_config(_args):
|
|
802
|
+
"""Site facts a client may want after its first connection (e.g. the public fallback address), so that they
|
|
803
|
+
need not be written into the distributed package. KEY=VALUE lines, LANES_* keys only."""
|
|
804
|
+
path = Path.home() / '.config/lanes/site.conf'
|
|
805
|
+
for line in (path.read_text().splitlines() if path.exists() else []):
|
|
806
|
+
if re.match(r'^LANES_[A-Z_]+=[A-Za-z0-9_.:@/-]*$', line):
|
|
807
|
+
print(line)
|
|
808
|
+
return 0
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def cmd_cursor_sync(args):
|
|
812
|
+
import cursor_sync
|
|
813
|
+
return cursor_sync.run(args)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def cmd_cursor_commit(args):
|
|
817
|
+
import cursor_commit
|
|
818
|
+
return cursor_commit.run(args)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def cmd_gc(_args):
|
|
822
|
+
limit = 5 * 1024 * 1024
|
|
823
|
+
for link in sorted((Path.home() / '.lz').glob('*')):
|
|
824
|
+
for log in (link / 's' / 'logs').glob('*.log'):
|
|
825
|
+
try:
|
|
826
|
+
if log.stat().st_size > limit: # zmx logging cannot be disabled and never rotates
|
|
827
|
+
with open(log, 'r+b') as handle:
|
|
828
|
+
handle.seek(-1024 * 1024, 2)
|
|
829
|
+
tail = handle.read()
|
|
830
|
+
handle.seek(0)
|
|
831
|
+
handle.write(tail)
|
|
832
|
+
handle.truncate()
|
|
833
|
+
print(f'truncated {log}')
|
|
834
|
+
except OSError as error:
|
|
835
|
+
print(f'skip {log}: {error}')
|
|
836
|
+
status = L.read_json(L.STATE / 'status.json') or {}
|
|
837
|
+
known = set(status.get('lanes', {}))
|
|
838
|
+
for stale in (L.STATE / 'resolve.dynamic').glob('*'):
|
|
839
|
+
if stale.name not in known and time.time() - stale.stat().st_mtime > 3600:
|
|
840
|
+
stale.unlink()
|
|
841
|
+
print(f'removed stale {stale.name}')
|
|
842
|
+
return 0
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def cmd_tmp(args):
|
|
846
|
+
return libexec_module('tmpscan').run(args) # verbs, options and the usage text are tmpscan's
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def cmd_guard(args):
|
|
850
|
+
return libexec_module('guard').run(args)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def libexec_module(name):
|
|
854
|
+
try:
|
|
855
|
+
return __import__(name)
|
|
856
|
+
except ImportError as error: # lanes-tmpreap.timer runs this every hour: one line in the journal, no traceback
|
|
857
|
+
raise L.LanesError(f'libexec/{name}.py: {error}')
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def pressure_lines(health, containers):
|
|
861
|
+
"""Human lines for `lanes pressure` and the doctor. `health` is L.compute_health() (plain files, no subprocess);
|
|
862
|
+
/tmp use comes from statvfs through /proc/<init-pid>/root — an in-memory answer, never docker exec or du."""
|
|
863
|
+
def gib(value):
|
|
864
|
+
return '?' if value is None else f'{value:.1f}'
|
|
865
|
+
lines = [f" level {health.get('level', '?')}: memory some {health.get('mem_some10', '?')}% full {health.get('mem_full10', '?')}%"
|
|
866
|
+
f" · io some {health.get('io_some10', '?')}% · cpu some {health.get('cpu_some10', '?')}% (10 s averages)"
|
|
867
|
+
f" · swap {health.get('swap_pct', '?')}% used · load {health.get('load1', '?')} on {health.get('ncpu', '?')} cpus"]
|
|
868
|
+
memory = health.get('containers') or {}
|
|
869
|
+
for name in sorted(set(memory) | set(containers)):
|
|
870
|
+
row = memory.get(name) or {}
|
|
871
|
+
pinned = None if row.get('anon_gib') is None else row['anon_gib'] + (row.get('shmem_gib') or 0)
|
|
872
|
+
line = (f" {name}: {gib(pinned)} GiB not reclaimable (processes {gib(row.get('anon_gib'))} + RAM-backed files "
|
|
873
|
+
f"{gib(row.get('shmem_gib'))}) of a {gib(row.get('max_gib'))} GiB limit; with page cache {gib(row.get('cur_gib'))}"
|
|
874
|
+
f"; oom kills {row.get('oom_kill', '?')}")
|
|
875
|
+
try:
|
|
876
|
+
vfs = os.statvfs(PROC / str((containers.get(name) or {}).get('pid') or 0) / 'root/tmp')
|
|
877
|
+
line += f'; /tmp {gib((vfs.f_blocks - vfs.f_bfree) * vfs.f_frsize / 2 ** 30)} of {gib(vfs.f_blocks * vfs.f_frsize / 2 ** 30)} GiB'
|
|
878
|
+
except OSError:
|
|
879
|
+
line += '; /tmp not readable'
|
|
880
|
+
lines.append(line)
|
|
881
|
+
return lines
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def cmd_sockets(args):
|
|
885
|
+
"""lanes sockets [check|repair]: tmux servers that run but whose socket file was deleted (emptied /tmp)."""
|
|
886
|
+
import guard
|
|
887
|
+
verb = args[0] if args else 'check'
|
|
888
|
+
if verb not in ('check', 'repair'):
|
|
889
|
+
raise L.LanesError('usage: lanes sockets [check|repair]')
|
|
890
|
+
env = guard.Env()
|
|
891
|
+
return 1 if guard.sockets(env, guard.container_names(env), repair=verb == 'repair') else 0
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def cmd_pressure(_args):
|
|
895
|
+
health = L.compute_health()
|
|
896
|
+
print('\n'.join(pressure_lines(health, L.read_json(L.STATE / 'containers.json') or {})))
|
|
897
|
+
return 0 if health.get('level') == 'ok' else 1
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def cmd_doctor(_args):
|
|
901
|
+
problems = 0
|
|
902
|
+
|
|
903
|
+
def check(ok, label, detail=''):
|
|
904
|
+
nonlocal problems
|
|
905
|
+
problems += 0 if ok else 1
|
|
906
|
+
print(f"{'✓' if ok else '✗'} {label}" + (f' — {detail}' if detail else ''))
|
|
907
|
+
|
|
908
|
+
check(ZELLIJ.exists(), f'zellij {ZELLIJ_VERSION} installed', str(ZELLIJ))
|
|
909
|
+
registry = L.read_json(L.STATE / 'registry.json')
|
|
910
|
+
check(bool(registry), 'registry present')
|
|
911
|
+
if registry:
|
|
912
|
+
errors = [(w['id'], e) for w in registry['workspaces'] for e in w['errors']]
|
|
913
|
+
check(not errors, 'registry clean', '; '.join(f'{w}: {e}' for w, e in errors))
|
|
914
|
+
# Stale registry check: any terminals.json newer than the last sync?
|
|
915
|
+
generated_at = registry.get('generated_at', 0)
|
|
916
|
+
stale_sources = []
|
|
917
|
+
for w in registry['workspaces']:
|
|
918
|
+
for lane in w.get('lanes', []):
|
|
919
|
+
src = lane.get('source')
|
|
920
|
+
if src:
|
|
921
|
+
try:
|
|
922
|
+
if Path(src).stat().st_mtime > generated_at:
|
|
923
|
+
stale_sources.append(src)
|
|
924
|
+
except OSError:
|
|
925
|
+
pass
|
|
926
|
+
unique_stale = list(dict.fromkeys(stale_sources))
|
|
927
|
+
check(not unique_stale, 'registry up to date',
|
|
928
|
+
'run lanes sync — ' + '; '.join(unique_stale[:3]) + (' …' if len(unique_stale) > 3 else ''))
|
|
929
|
+
for name, info in (L.read_json(L.STATE / 'containers.json') or {}).items():
|
|
930
|
+
check(bool(info.get('running')), f'container {name} running', f"init pid {info.get('pid')}")
|
|
931
|
+
status = L.read_json(L.STATE / 'status.json') or {}
|
|
932
|
+
age = time.time() - status.get('updated_at', 0)
|
|
933
|
+
check(age < 30, 'statusd heartbeat', f'{age:.0f}s old')
|
|
934
|
+
states, stale = {}, 0
|
|
935
|
+
for value in status.get('lanes', {}).values():
|
|
936
|
+
states[value.get('state', 'unknown')] = states.get(value.get('state', 'unknown'), 0) + 1
|
|
937
|
+
stale += 1 if value.get('stale') else 0
|
|
938
|
+
# 'unknown' / stale = statusd could not OBSERVE the lane (its tmux server did not answer): not the same as gone.
|
|
939
|
+
check(not states.get('missing') and not states.get('unknown') and not stale, 'all registry lanes observed and present',
|
|
940
|
+
str(states) + (f'; {stale} shown with their last known state (tmux server did not answer)' if stale else ''))
|
|
941
|
+
# One bounded process-table sweep serves both checks; the cgroup of the few tmux servers is a plain file read.
|
|
942
|
+
rc, table, _err = L.run_bounded(['ps', '-eo', 'pid=,comm=,args='], 10)
|
|
943
|
+
coexist = (L.STATE / 'flags/coexist').exists()
|
|
944
|
+
if rc != 0:
|
|
945
|
+
check(False, 'process table readable', 'ps did not answer in 10 s: phantom tmux servers and coexistence mode NOT checked'
|
|
946
|
+
if rc is None else f'ps exit {rc}')
|
|
947
|
+
else:
|
|
948
|
+
phantom = []
|
|
949
|
+
for line in table.splitlines():
|
|
950
|
+
pid, _, rest = line.strip().partition(' ')
|
|
951
|
+
if rest.lstrip().startswith('tmux: server'):
|
|
952
|
+
try:
|
|
953
|
+
if 'docker' not in (PROC / pid / 'cgroup').read_text():
|
|
954
|
+
phantom.append(line.strip())
|
|
955
|
+
except OSError: # gone since the sweep
|
|
956
|
+
pass
|
|
957
|
+
check(not phantom, 'no host-side tmux servers (phantoms)', '; '.join(phantom)[:200])
|
|
958
|
+
old_viewers = 'coder-launch/connect ' in table
|
|
959
|
+
check(not coexist or old_viewers, 'coexistence mode',
|
|
960
|
+
('ON while old coder-launch viewers exist (ignore-size)' if old_viewers else
|
|
961
|
+
'flag is SET but no old viewers exist: lanes will not resize (dotted padding). Fix: rm ~/.local/state/lanes/flags/coexist && lanes reattach')
|
|
962
|
+
if coexist else 'OFF (viewers size the lane they show)')
|
|
963
|
+
zmx_lanes = sum(len(v) for v in (status.get('zmx') or {}).values())
|
|
964
|
+
installed = [link.name for link in sorted((Path.home() / '.lz').glob('*')) if (link / 'bin/lane').exists()]
|
|
965
|
+
print(f" lane contract installed in: {', '.join(installed) or 'no container yet'}; zmx-held lanes: {zmx_lanes}")
|
|
966
|
+
try:
|
|
967
|
+
print(' view sessions:', ', '.join(sorted(live_sessions())) or 'none')
|
|
968
|
+
except (OSError, L.LanesError) as error:
|
|
969
|
+
check(False, 'zellij reachable', str(error))
|
|
970
|
+
print('PRESSURE')
|
|
971
|
+
health = L.compute_health()
|
|
972
|
+
print('\n'.join(pressure_lines(health, L.read_json(L.STATE / 'containers.json') or {})))
|
|
973
|
+
check(health.get('level') != 'critical', 'host not under critical memory pressure',
|
|
974
|
+
'views and status may lag or time out; the lanes themselves keep running' if health.get('level') != 'ok' else '')
|
|
975
|
+
return 1 if problems else 0
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def cmd_rename(args):
|
|
979
|
+
"""lanes rename <lane-id> <new-name>
|
|
980
|
+
|
|
981
|
+
Rename a lane's display name.
|
|
982
|
+
- Unregistered sessions: writes user-sessions metadata + updates resolve.dynamic.
|
|
983
|
+
- Registered lanes: writes a displayNames override to the workspace manifest and syncs the registry.
|
|
984
|
+
"""
|
|
985
|
+
if len(args) < 2:
|
|
986
|
+
raise L.LanesError('usage: lanes rename <lane-id> <new-name>')
|
|
987
|
+
import session_actions as SA
|
|
988
|
+
lane_id, new_name = args[0], ' '.join(args[1:])
|
|
989
|
+
registry = L.load_registry()
|
|
990
|
+
# Locate the lane
|
|
991
|
+
workspace_obj = None
|
|
992
|
+
lane = None
|
|
993
|
+
for w in registry['workspaces']:
|
|
994
|
+
for l in w['lanes']:
|
|
995
|
+
if l['id'] == lane_id:
|
|
996
|
+
workspace_obj, lane = w, l
|
|
997
|
+
break
|
|
998
|
+
if lane:
|
|
999
|
+
break
|
|
1000
|
+
# Check unregistered (dynamic)
|
|
1001
|
+
if not lane:
|
|
1002
|
+
status = L.read_json(L.STATE / 'status.json') or {}
|
|
1003
|
+
for w_id, entries in (status.get('unregistered') or {}).items():
|
|
1004
|
+
for e in entries:
|
|
1005
|
+
if e['id'] == lane_id:
|
|
1006
|
+
updated = SA.rename_session(w_id, e, new_name)
|
|
1007
|
+
print(f"renamed unregistered lane {lane_id!r} → {new_name!r}")
|
|
1008
|
+
return 0
|
|
1009
|
+
if not lane:
|
|
1010
|
+
raise L.LanesError(f'Unknown lane: {lane_id}')
|
|
1011
|
+
# Registered lane: update displayNames in manifest
|
|
1012
|
+
source = lane.get('source')
|
|
1013
|
+
if not source:
|
|
1014
|
+
raise L.LanesError('No manifest source for this lane; set displayNames in the workspace manifest manually')
|
|
1015
|
+
registry_name = lane.get('registry_name') or lane.get('name', '')
|
|
1016
|
+
SA.rename_registered(source, registry_name, new_name)
|
|
1017
|
+
cmd_sync([]) # rebuild registry so all consumers see the new name
|
|
1018
|
+
print(f"renamed registered lane {lane_id!r} ({registry_name!r}) → {new_name!r}")
|
|
1019
|
+
return 0
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_client_config, 'cursor-commit': cmd_cursor_commit, 'upgrade-session': cmd_upgrade_session, 'upload-requests': cmd_upload_requests, 'upload-result': cmd_upload_result, 'upload': cmd_upload, 'workspaces': cmd_workspaces, 'cursor-sync': cmd_cursor_sync, 'reattach': cmd_reattach, 'gc': cmd_gc, 'install-container': cmd_install_container, 'open': cmd_open, 'sync': cmd_sync, 'ls': cmd_ls, 'resolve': cmd_resolve, 'status': cmd_status, 'layout': cmd_layout,
|
|
1023
|
+
'prepare': cmd_prepare, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
|
|
1024
|
+
'verify-legacy': cmd_verify_legacy, 'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
|
|
1025
|
+
'guard': cmd_guard, 'rename': cmd_rename}
|
|
1026
|
+
|
|
1027
|
+
if __name__ == '__main__':
|
|
1028
|
+
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|
|
1029
|
+
sys.exit(__doc__)
|
|
1030
|
+
try:
|
|
1031
|
+
sys.exit(COMMANDS[sys.argv[1]](sys.argv[2:]) or 0)
|
|
1032
|
+
except L.LanesError as error:
|
|
1033
|
+
sys.exit(f'lanes: {error}')
|
|
1034
|
+
except IndexError:
|
|
1035
|
+
sys.exit(__doc__)
|