@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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/lanes-install.mjs +158 -0
  4. package/lib/plan.mjs +65 -0
  5. package/package.json +37 -0
  6. package/payload/lanes/README.md +152 -0
  7. package/payload/lanes/VERSION +1 -0
  8. package/payload/lanes/bin/lanes +1035 -0
  9. package/payload/lanes/client/ghostty/common.conf +22 -0
  10. package/payload/lanes/client/ghostty/macos.conf +6 -0
  11. package/payload/lanes/client/ghostty/omarchy.conf +19 -0
  12. package/payload/lanes/client/install.sh +16 -0
  13. package/payload/lanes/client/lanes-launch +315 -0
  14. package/payload/lanes/client/lanes-upload +147 -0
  15. package/payload/lanes/client/lanes-window +36 -0
  16. package/payload/lanes/client/macos-helper.applescript +14 -0
  17. package/payload/lanes/client/omarchy/discover.sh +56 -0
  18. package/payload/lanes/client/omarchy/lanes.conf +5 -0
  19. package/payload/lanes/client/omarchy/lanes.desktop +9 -0
  20. package/payload/lanes/client/omarchy/lanes.lua +14 -0
  21. package/payload/lanes/client/ssh.stanza.in +54 -0
  22. package/payload/lanes/container/env.sh +16 -0
  23. package/payload/lanes/container/lane +268 -0
  24. package/payload/lanes/container/lane-run +34 -0
  25. package/payload/lanes/docs/ARCHITECTURE.md +93 -0
  26. package/payload/lanes/docs/HOST-GUARD.md +108 -0
  27. package/payload/lanes/docs/INSTALL.md +192 -0
  28. package/payload/lanes/docs/RUNBOOK.md +385 -0
  29. package/payload/lanes/docs/SHARING.md +124 -0
  30. package/payload/lanes/docs/ZMX-RING0.md +51 -0
  31. package/payload/lanes/docs/images/desktop.png +0 -0
  32. package/payload/lanes/docs/images/iphone.png +0 -0
  33. package/payload/lanes/holders/exec.sh +21 -0
  34. package/payload/lanes/holders/tmux.sh +111 -0
  35. package/payload/lanes/holders/zmx.sh +22 -0
  36. package/payload/lanes/holders.lock +5 -0
  37. package/payload/lanes/install/enable-phone.sh +43 -0
  38. package/payload/lanes/install/enable-web.sh +16 -0
  39. package/payload/lanes/install/host-install.sh +72 -0
  40. package/payload/lanes/install/services.py +61 -0
  41. package/payload/lanes/install.sh +603 -0
  42. package/payload/lanes/libexec/cursor_commit.py +159 -0
  43. package/payload/lanes/libexec/cursor_sync.py +166 -0
  44. package/payload/lanes/libexec/guard.py +231 -0
  45. package/payload/lanes/libexec/lanes_lib.py +610 -0
  46. package/payload/lanes/libexec/legacy_sessions.py +130 -0
  47. package/payload/lanes/libexec/session_actions.py +276 -0
  48. package/payload/lanes/libexec/sidebar +8 -0
  49. package/payload/lanes/libexec/sidebar.py +872 -0
  50. package/payload/lanes/libexec/statusd.py +764 -0
  51. package/payload/lanes/libexec/tmpscan.py +921 -0
  52. package/payload/lanes/libexec/view.sh +179 -0
  53. package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
  54. package/payload/lanes/site/README.md +28 -0
  55. package/payload/lanes/site/beyondwork/known_hosts +1 -0
  56. package/payload/lanes/site/beyondwork/site.conf +11 -0
  57. package/payload/lanes/site/site.example.conf +10 -0
  58. package/payload/lanes/sudo/host-guard.sh +356 -0
  59. package/payload/lanes/sudo/one-time-root.sh +32 -0
  60. package/payload/lanes/systemd/lanes-ensure.service +7 -0
  61. package/payload/lanes/systemd/lanes-ensure.timer +10 -0
  62. package/payload/lanes/systemd/lanes-gc.service +7 -0
  63. package/payload/lanes/systemd/lanes-gc.timer +9 -0
  64. package/payload/lanes/systemd/lanes-guard.service +8 -0
  65. package/payload/lanes/systemd/lanes-guard.timer +10 -0
  66. package/payload/lanes/systemd/lanes-mobile.service +23 -0
  67. package/payload/lanes/systemd/lanes-statusd.service +15 -0
  68. package/payload/lanes/systemd/lanes-sync.service +10 -0
  69. package/payload/lanes/systemd/lanes-sync.timer +10 -0
  70. package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
  71. package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
  72. package/payload/lanes/systemd/lanes-web.service +13 -0
  73. package/payload/lanes/systemd/lanes.slice +11 -0
  74. package/payload/lanes/web/Caddyfile +37 -0
  75. package/payload/lanes/web/README.md +35 -0
  76. package/payload/lanes/web/icon-192.png +0 -0
  77. package/payload/lanes/web/icon.svg +4 -0
  78. package/payload/lanes/web/index.html +30 -0
  79. package/payload/lanes/web/manifest.webmanifest +15 -0
  80. package/payload/lanes/web/mobile.css +301 -0
  81. package/payload/lanes/web/mobile.js +367 -0
  82. package/payload/lanes/workspaces.example/README.md +27 -0
  83. package/payload/lanes/workspaces.example/catalog.json +7 -0
  84. package/payload/lanes/workspaces.example/commit-targets.json +8 -0
  85. package/payload/lanes/workspaces.example/terminals.example.json +19 -0
  86. package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
  87. package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
  88. package/payload/lanes/zellij/config.kdl +360 -0
  89. 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__)