@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,610 @@
1
+ """Shared helpers for the lanes host server. Python 3 stdlib only.
2
+
3
+ Safety rules encoded here (see docs/ARCHITECTURE.md):
4
+ * Observation and attachment never create or stop lanes. Explicit New/Close actions are separate.
5
+ * tmux is always invoked with -N (never start a server) and a scrubbed environment.
6
+ * In-container sockets are reached through /proc/<container-init-pid>/root, anchored on the
7
+ container's init pid (stable across tmux-server restarts), never through docker exec.
8
+ """
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import tempfile
15
+ import time
16
+ import unicodedata
17
+ from pathlib import Path
18
+
19
+ REPO = Path(__file__).resolve().parent.parent
20
+ STATE = Path(os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'))
21
+ SHARE = Path(os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'))
22
+
23
+
24
+ def workspaces_dir():
25
+ """SITE data — which boards exist and where their terminal registries live. Not part of the product:
26
+ $LANES_WORKSPACES, else ~/.config/lanes/workspaces (if it holds a catalog), else <repo>/workspaces."""
27
+ if os.environ.get('LANES_WORKSPACES'):
28
+ return Path(os.environ['LANES_WORKSPACES'])
29
+ site = Path.home() / '.config/lanes/workspaces'
30
+ return site if (site / 'catalog.json').exists() else REPO / 'workspaces'
31
+
32
+
33
+ CONTAINER_HOME = '/home/coder'
34
+ CONTAINER_UID = 1000
35
+
36
+ LANE_OPEN = '/.lz/bin/lane open ' # the uniform Cursor-facing entry point written by `lanes cursor-sync`
37
+ IDENT = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_.-]*$')
38
+ TMUX_SESSION = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_. -]*$')
39
+ SAFE_PATH = re.compile(r'^/[A-Za-z0-9_./-]+$')
40
+
41
+ # The Throttle Studio Host's OWN grouped session names, mirroring `studio_host::tmux::WebSession::channel_of`
42
+ # exactly: `view-<studio token>-<channel>` as `for_channel` mints it (the token is `[A-Za-z0-9_-]` and may
43
+ # itself carry `-`, so the channel is the segment after the LAST `-`), or the legacy `web-<channel>`. The
44
+ # channel is canonical decimal digits inside u32. A PREFIX test is deliberately NOT the rule: `view-notes`,
45
+ # `viewer-1`, `web-dev` and `web-007` are operator names the Host never mints — and therefore never un-flags
46
+ # and never sweeps (DEF-980).
47
+ HOST_VIEW_SESSION = re.compile(r'(?:view-[A-Za-z0-9_-]+|web)-(0|[1-9][0-9]*)')
48
+
49
+
50
+ def is_host_view(session):
51
+ """Whether `session` is a name the Studio Host mints for a browser attach. Pure, and the mirror of
52
+ `WebSession::channel_of` — the whole grammar, never the `view-`/`web-` prefix."""
53
+ found = HOST_VIEW_SESSION.fullmatch(session or '')
54
+ return bool(found) and int(found.group(1)) <= 0xFFFFFFFF
55
+
56
+
57
+ def grouped_members(listing, session):
58
+ """The sessions grouped onto `session` (sharing its windows), split into the ones the Studio Host MINTS
59
+ and the strangers — pure, so the rule is unit-testable without a tmux server. `listing` is
60
+ `list-sessions -F '#{session_name}\\t#{session_group}'` output.
61
+
62
+ tmux shares a group's windows between its sessions, so killing the group LEADER only unlinks the leader's
63
+ winlinks: every window and every process keeps running under the grouped member (MEASURED, tmux 3.4).
64
+ `studio_host::lane::stop` kills the Host's own view members first and refuses `not_stoppable` for a
65
+ stranger for exactly this reason; the sidebar's `×` did neither, said "Closed", and left the panes
66
+ running under a survivor that came back as a new 👻 on the next cycle (DEF-981)."""
67
+ views, strangers = [], []
68
+ for line in (listing or '').splitlines():
69
+ name, _, group = line.partition('\t')
70
+ if not name or name == session or group != session:
71
+ continue
72
+ (views if is_host_view(name) else strangers).append(name)
73
+ return views, strangers
74
+
75
+
76
+ ANSI_NAMES =['Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White']
77
+ ANSI_SLOTS = {}
78
+ for _i, _n in enumerate(ANSI_NAMES):
79
+ ANSI_SLOTS[f'terminal.ansi{_n}'] = _i
80
+ ANSI_SLOTS[f'terminal.ansiBright{_n}'] = _i + 8
81
+
82
+ # Default-emoji-presentation glyphs only (no VS16): column widths stay stable everywhere.
83
+ LANE_ICONS = {
84
+ 'pulse': '💓', 'repo': '📁', 'type-hierarchy': '🌳', 'layers': '🧩', 'lightbulb': '💡',
85
+ 'symbol-color': '🎨', 'person': '👤', 'game': '🎮', 'tools': '🔧', 'globe': '🌐',
86
+ 'organization': '👥', 'terminal': '💻', 'watch': '⌚', 'compass': '🧭', 'book': '📖',
87
+ 'package': '📦', 'map': '🌍', 'hubot': '🤖', 'files': '📄', 'layout': '▦',
88
+ 'symbol-structure': '🧱', 'beaker': '🧪', 'references': '🔗', 'edit': '📝', 'sparkle': '✨',
89
+ 'briefcase': '💼', 'comment': '💬', 'comment-discussion': '💬', 'search': '🔎',
90
+ 'rocket': '🚀', 'database': '💾', 'shield': '🔐', 'terminal-bash': '💻', 'warning': '🚧',
91
+ 'gear': '🔩', 'window': '🪟', 'table': '📊', 'dashboard': '📈', 'graph': '📈',
92
+ 'device-desktop': '💻', 'server': '📡', 'git-pull-request': '🔀', 'sync': '🔄',
93
+ 'extensions': '🧩', 'circuit-board': '🧠',
94
+ }
95
+
96
+
97
+ class LanesError(Exception):
98
+ pass
99
+
100
+
101
+ def atomic_write(path, text, mode=0o600):
102
+ path = Path(path)
103
+ path.parent.mkdir(parents=True, exist_ok=True)
104
+ fd, tmp = tempfile.mkstemp(prefix='.' + path.name + '.', dir=str(path.parent))
105
+ try:
106
+ with os.fdopen(fd, 'w') as handle:
107
+ handle.write(text)
108
+ os.chmod(tmp, mode)
109
+ os.replace(tmp, path)
110
+ except BaseException:
111
+ try:
112
+ os.unlink(tmp)
113
+ except OSError:
114
+ pass
115
+ raise
116
+
117
+
118
+ def read_json(path, default=None):
119
+ try:
120
+ return json.loads(Path(path).read_text())
121
+ except (OSError, ValueError):
122
+ return default
123
+
124
+
125
+ def slug(text):
126
+ text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode()
127
+ text = re.sub(r'[^A-Za-z0-9]+', '-', text).strip('-').lower()
128
+ return text or 'lane'
129
+
130
+
131
+ # ------------------------------------------------------------ bounded spawns
132
+
133
+ def run_bounded(argv, timeout, input=None, env=None):
134
+ """Run argv -> (rc, stdout, stderr), text mode. rc is None when the child did not finish in `timeout` seconds.
135
+
136
+ subprocess.run(timeout=) kills the child and then waits for it WITHOUT a bound. On a thrashing host that child
137
+ can sit in D state (uninterruptible) for minutes, or a grandchild keeps the pipes open — and the caller hangs
138
+ with it. Here: kill, ONE more bounded wait (2 s; it also collects what was already written), then abandon the
139
+ child. subprocess reaps an abandoned child on a later spawn, so no zombie is left behind.
140
+ A spawn that fails (no such binary, fork refused under memory pressure) is rc 127 with the reason on stderr.
141
+ Undecodable output bytes are replaced: a stray byte in somebody's argv (`ps`) must not raise in here."""
142
+ try:
143
+ child = subprocess.Popen(argv, stdin=subprocess.PIPE if input is not None else subprocess.DEVNULL,
144
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, errors='replace', env=env)
145
+ except OSError as error:
146
+ return 127, '', str(error)
147
+ try:
148
+ out, err = child.communicate(input, timeout=timeout)
149
+ return child.returncode, out, err
150
+ except subprocess.TimeoutExpired:
151
+ child.kill()
152
+ try:
153
+ out, err = child.communicate(timeout=2)
154
+ except subprocess.TimeoutExpired:
155
+ out, err = '', ''
156
+ for stream in (child.stdin, child.stdout, child.stderr): # abandoned: do not leak its pipes as well
157
+ try:
158
+ if stream:
159
+ stream.close()
160
+ except OSError:
161
+ pass
162
+ child.poll() # reaps a child that DID die (only its pipes are held open); never blocks on one that did not
163
+ return None, out or '', err or ''
164
+
165
+
166
+ def proc_root():
167
+ """/proc, or a directory of synthetic files in the unit tests (LANES_PROC_ROOT)."""
168
+ return os.environ.get('LANES_PROC_ROOT') or '/proc'
169
+
170
+
171
+ # ---------------------------------------------------------------- containers
172
+
173
+ class NoSuchContainer(LanesError):
174
+ """docker ANSWERED that the container does not exist — an observation, unlike every other docker failure."""
175
+
176
+
177
+ def docker_inspect(container, template):
178
+ rc, out, err = run_bounded(['docker', 'inspect', '-f', template, container], 20)
179
+ if rc is None:
180
+ raise LanesError(f'docker inspect {container}: no answer within 20 s')
181
+ if rc:
182
+ # "Error: No such object: <name>" (after a `docker rm`) is an answer ABOUT the container. Any other failure
183
+ # — daemon unreachable or busy, fork refused, permission — says nothing about it, whatever the exit code.
184
+ reason = err.lower()
185
+ gone = 'no such object' in reason or 'no such container' in reason
186
+ raise (NoSuchContainer if gone else LanesError)(f'docker inspect {container}: {err.strip()}')
187
+ return out.strip()
188
+
189
+
190
+ def container_info(container):
191
+ """Init pid and host path of the container's /home/coder volume."""
192
+ if not IDENT.match(container):
193
+ raise LanesError(f'Invalid container name: {container}')
194
+ pid = int(docker_inspect(container, '{{.State.Pid}}') or 0)
195
+ home = docker_inspect(
196
+ container,
197
+ '{{range .Mounts}}{{if eq .Destination "' + CONTAINER_HOME + '"}}{{.Source}}{{end}}{{end}}')
198
+ return {'pid': pid, 'home': home, 'running': pid > 0}
199
+
200
+
201
+ def refresh_containers(names):
202
+ previous = read_json(STATE / 'containers.json', {}) or {}
203
+ info = {}
204
+ for name in sorted(set(names)):
205
+ try:
206
+ info[name] = container_info(name)
207
+ except NoSuchContainer:
208
+ # An answer, like the pid 0 of a stopped container: NO 'error' key, so statusd publishes its lanes as
209
+ # 'missing' instead of keeping their last state as if docker had been silent.
210
+ info[name] = {'pid': 0, 'home': '', 'running': False}
211
+ except (LanesError, subprocess.SubprocessError, ValueError) as error:
212
+ # docker did not ANSWER (timeout, busy daemon): that says nothing about the container. While the init
213
+ # pid recorded last time is still alive, keep it — writing pid 0 here would tell every viewer and
214
+ # statusd "container down" because of a slow docker. A container that really went away takes its init
215
+ # pid with it, so this cannot keep a dead one alive.
216
+ old = previous.get(name) or {}
217
+ if old.get('pid') and os.path.exists(f"{proc_root()}/{old['pid']}"):
218
+ info[name] = dict(old, running=True, error=str(error))
219
+ else:
220
+ info[name] = {'pid': 0, 'home': '', 'running': False, 'error': str(error)}
221
+ # One tiny file per container: the POSIX-sh holders read it without python or docker.
222
+ atomic_write(STATE / 'containers' / f'{name}.pid', f"{info[name]['pid']}\n")
223
+ atomic_write(STATE / 'containers.json', json.dumps(info, indent=2) + '\n')
224
+ return info
225
+
226
+
227
+ def host_path(info, container_path):
228
+ """Map an absolute in-container path under /home/coder to the host volume path."""
229
+ if not SAFE_PATH.match(container_path) or '..' in container_path.split('/'):
230
+ raise LanesError(f'Unsafe registry path: {container_path}')
231
+ if not (container_path + '/').startswith(CONTAINER_HOME + '/'):
232
+ raise LanesError(f'Registry outside the home volume: {container_path}')
233
+ if not info.get('home'):
234
+ raise LanesError('Container home volume unknown')
235
+ return Path(info['home']) / container_path[len(CONTAINER_HOME) + 1:]
236
+
237
+
238
+ def tmux_socket(info, socket_name):
239
+ return f"/proc/{info['pid']}/root/tmp/tmux-{CONTAINER_UID}/{socket_name}"
240
+
241
+
242
+ def tmux_env():
243
+ return {'PATH': '/usr/bin:/bin', 'TERM': 'xterm-256color', 'LANG': 'C.UTF-8', 'HOME': str(Path.home())}
244
+
245
+
246
+ def tmux_probe(info, socket_name, args, timeout=5):
247
+ """tmux query against an in-container server -> (outcome, text). -N: never start a server.
248
+
249
+ 'ok' the server answered; text = its stdout
250
+ 'noserver' it positively does not exist: no container pid, no socket file, or tmux says nobody listens there
251
+ 'timeout' no answer in time — on a busy host this says NOTHING about the server
252
+ 'error' any other failure; text = tmux's stderr
253
+ Only ok/noserver are observations. A caller that turns timeout/error into "gone" reports an overloaded host
254
+ as lost lanes."""
255
+ if not info.get('pid'):
256
+ return 'noserver', ''
257
+ path = tmux_socket(info, socket_name)
258
+ if not os.path.exists(path):
259
+ return 'noserver', ''
260
+ rc, out, err = run_bounded(['tmux', '-N', '-u', '-S', path] + args, timeout, env=tmux_env())
261
+ if rc is None:
262
+ return 'timeout', ''
263
+ if rc == 0:
264
+ return 'ok', out
265
+ # tmux_env() pins LANG=C.UTF-8, so these are tmux's English texts. "error connecting" also covers EACCES,
266
+ # EAGAIN (listen backlog full) …: only the two reasons that mean "nobody listens there" count as no server.
267
+ reason = err.lower()
268
+ if 'no server running' in reason or ('error connecting' in reason and (
269
+ 'no such file' in reason or 'connection refused' in reason)):
270
+ return 'noserver', ''
271
+ if reason.strip() == 'no current target' and args and args[0].startswith('list-'):
272
+ # A LIVE server that holds ZERO sessions. MEASURED (tmux 3.4): `list-sessions` exits 0 with empty output
273
+ # while `list-panes -a` / `list-windows -a` / `list-clients` exit 1 with exactly this text. Under the
274
+ # operator's `set -g exit-empty off` that is the STEADY state of a board socket once its last lane ends,
275
+ # not a transient — so reading it as a failure published `unknown` for every declared lane of that socket
276
+ # while the Studio Host answered `absent` for the same server (`studio_host::tmux::probe_socket` has this
277
+ # exact arm, "which is not an error"). An empty listing IS the answer: there are no sessions (DEF-894).
278
+ #
279
+ # ONLY for a LISTING verb. tmux answers the same text to a TARGETED verb against an absent session on
280
+ # such a server (MEASURED, tmux 3.4: `send-keys -t '=gone:'` -> `no current target`, rc 1, where with
281
+ # another session present it says `can't find session: gone`), and reading that as 'ok' turned a write
282
+ # that reached no pane into a reported success: `lanes upload --paste` printed "# typed into lane: <name>"
283
+ # for text nobody received, and `has-session` through `tmux_query` answered "it exists" for a session
284
+ # that does not (DEF-967). A targeted verb's failure stays 'error', as it was before this arm.
285
+ return 'ok', ''
286
+ return 'error', err.strip()
287
+
288
+
289
+ def tmux_query(info, socket_name, args, timeout=5):
290
+ """tmux_probe for callers that only need "text or None" (None = no usable answer, for whatever reason)."""
291
+ outcome, text = tmux_probe(info, socket_name, args, timeout)
292
+ return text if outcome == 'ok' else None
293
+
294
+
295
+ # -------------------------------------------------------------------- health
296
+ # Host pressure from PLAIN FILES only — no subprocess: this is read exactly when spawning is what hurts.
297
+
298
+ HEALTH_LINE = ('at', 'level', 'mem_some10', 'mem_full10', 'io_some10', 'cpu_some10', 'swap_pct', 'load1', 'status_at')
299
+
300
+
301
+ def _text(path):
302
+ try:
303
+ return Path(path).read_text()
304
+ except (OSError, ValueError):
305
+ return ''
306
+
307
+
308
+ def _psi10(text, kind):
309
+ """'some avg10=12.34 avg60=… total=…' -> 12 (percent of the last 10 s that tasks were stalled)."""
310
+ match = re.search(r'^' + kind + r' avg10=([0-9.]+)', text, re.M)
311
+ try:
312
+ return int(round(float(match.group(1)))) if match else 0
313
+ except ValueError:
314
+ return 0
315
+
316
+
317
+ def _container_memory(proc, cgroup_root, pid):
318
+ """cgroup-v2 memory of the container whose init is `pid`; the cgroup is found the way the kernel names it."""
319
+ match = re.search(r'^0::(/\S*)$', _text(proc / str(pid) / 'cgroup'), re.M) if pid else None
320
+ if not match or '..' in match.group(1).split('/'):
321
+ return None
322
+ base = cgroup_root / match.group(1).lstrip('/')
323
+ current = _text(base / 'memory.current').strip()
324
+ if not current.isdigit():
325
+ return None
326
+ limit = _text(base / 'memory.max').strip()
327
+
328
+ def counter(name, key):
329
+ found = re.search(r'^' + key + r' (\d+)$', _text(base / name), re.M)
330
+ return int(found.group(1)) if found else 0
331
+
332
+ gib = 2 ** 30
333
+ return {'cur_gib': round(int(current) / gib, 1), 'max_gib': round(int(limit) / gib, 1) if limit.isdigit() else None,
334
+ 'shmem_gib': round(counter('memory.stat', 'shmem') / gib, 1), # shmem = RAM-backed /tmp and /dev/shm
335
+ # anon + shmem is what cannot be given back; `file` minus shmem is page cache, which always grows to the
336
+ # limit on a busy container ("207.9 of 208" is normal and says nothing about pressure)
337
+ 'anon_gib': round(counter('memory.stat', 'anon') / gib, 1),
338
+ 'oom_kill': counter('memory.events', 'oom_kill')}
339
+
340
+
341
+ def health_level(health):
342
+ if health['mem_some10'] >= 40 or health['mem_full10'] >= 15:
343
+ return 'critical'
344
+ # Load is a last resort only (4 x cores): on a host that runs ~170 agents the load average sits near 1.5 x cores
345
+ # all day (measured: 100-150 on 64 cpus with memory and io pressure at 0-2 %), and a permanent 'busy' would both
346
+ # wear the banner out and keep statusd's size reconciler switched off. Pressure (PSI) is the signal.
347
+ if health['mem_some10'] >= 10 or health['io_some10'] >= 25 or health['load1'] >= 4 * health['ncpu']:
348
+ return 'busy'
349
+ return 'ok'
350
+
351
+
352
+ def compute_health(paths=None):
353
+ """paths (tests): 'proc' (else $LANES_PROC_ROOT, else /proc), 'cgroup' (/sys/fs/cgroup), 'containers'
354
+ ($STATE/containers.json: whose init pids to look up), 'ncpu'. A file that is missing reads as 0."""
355
+ paths = paths or {}
356
+ proc = Path(paths.get('proc') or proc_root())
357
+ pressure = {name: _text(proc / 'pressure' / name) for name in ('memory', 'io', 'cpu')}
358
+ meminfo = {key: int(value) for key, value in re.findall(r'^(SwapTotal|SwapFree):\s+(\d+)', _text(proc / 'meminfo'), re.M)}
359
+ try:
360
+ load1 = float(_text(proc / 'loadavg').split()[0])
361
+ except (IndexError, ValueError):
362
+ load1 = 0.0
363
+ swap_total = meminfo.get('SwapTotal', 0)
364
+ health = {'at': time.time(), 'level': 'ok',
365
+ 'mem_some10': _psi10(pressure['memory'], 'some'), 'mem_full10': _psi10(pressure['memory'], 'full'),
366
+ 'io_some10': _psi10(pressure['io'], 'some'), 'cpu_some10': _psi10(pressure['cpu'], 'some'),
367
+ 'swap_pct': int(round(100 * (swap_total - meminfo.get('SwapFree', 0)) / swap_total)) if swap_total else 0,
368
+ 'load1': load1, 'ncpu': int(paths.get('ncpu') or os.cpu_count() or 1), 'containers': {}}
369
+ health['level'] = health_level(health)
370
+ recorded = read_json(paths.get('containers') or STATE / 'containers.json', {}) or {}
371
+ for name, info in recorded.items():
372
+ memory = _container_memory(proc, Path(paths.get('cgroup') or '/sys/fs/cgroup'), (info or {}).get('pid'))
373
+ if memory:
374
+ health['containers'][name] = memory
375
+ return health
376
+
377
+
378
+ def health_line(health, status_at):
379
+ """The one line of $STATE/health (sh and python read it without JSON). Key order is part of the format."""
380
+ values = dict(health, at=int(health['at']), load1=f"{health['load1']:.2f}", status_at=int(status_at))
381
+ return ' '.join(f'{key}={values[key]}' for key in HEALTH_LINE) + '\n'
382
+
383
+
384
+ def read_health(state_dir=None):
385
+ """$STATE/health as a dict ({} = no such file yet). The PRODUCER is what freezes on a bad host, so a reader
386
+ judges freshness with its own clock: now - at (or the file mtime); older than 30 s = statusd is not keeping up."""
387
+ health = {}
388
+ for token in _text(Path(state_dir or STATE) / 'health').split():
389
+ key, equals, value = token.partition('=')
390
+ if equals:
391
+ try:
392
+ health[key] = value if key == 'level' else float(value) if key == 'load1' else int(value)
393
+ except ValueError:
394
+ pass
395
+ return health
396
+
397
+
398
+ # ------------------------------------------------------------------ registry
399
+
400
+ def _require(pattern, value, label):
401
+ if not isinstance(value, str) or not pattern.match(value):
402
+ raise LanesError(f'Invalid {label}: {value!r}')
403
+ return value
404
+
405
+
406
+ def _title(value):
407
+ if not isinstance(value, str) or not value or re.search(r'[\x00-\x1f\x7f]', value):
408
+ raise LanesError('Invalid lane name')
409
+ return value
410
+
411
+
412
+ def _workspace_lanes(manifest, containers):
413
+ """Port of coder-launch/manage.mjs: explicit tmux bindings only; nothing is ever executed."""
414
+ lanes = []
415
+ names = set()
416
+ repo_ids = set()
417
+ for repo in manifest.get('repositories') or []:
418
+ repo_id = _require(IDENT, repo.get('id'), 'repository id')
419
+ container = _require(IDENT, repo.get('host'), 'container')
420
+ if repo_id in repo_ids:
421
+ raise LanesError(f'Duplicate repository id: {repo_id}')
422
+ repo_ids.add(repo_id)
423
+ info = containers.get(container) or {}
424
+ registry_file = host_path(info, repo.get('registry', ''))
425
+ registry = json.loads(registry_file.read_text())
426
+ terminals = registry.get('terminals')
427
+ if not isinstance(terminals, list) or not terminals:
428
+ raise LanesError(f'No terminals in {repo_id}')
429
+ for source in repo.get('bindingSources') or []:
430
+ digest = hashlib.sha256(host_path(info, source.get('path', '')).read_bytes()).hexdigest()
431
+ if digest != source.get('sha256'):
432
+ raise LanesError(f"Binding source changed: {source.get('path')}; inspect its tmux targets "
433
+ 'before updating the saved bindings.')
434
+ throttle = ((registry.get('throttle') or {}).get('terminals') or {})
435
+ declared = ((registry.get('lanes') or {}).get('terminals') or {})
436
+ folder = repo.get('registry', '').split('/.vscode/')[0] if '/.vscode/' in repo.get('registry', '') \
437
+ else (manifest.get('remoteShell') or {}).get('cwd') or CONTAINER_HOME
438
+ for entry in terminals:
439
+ name = entry.get('name')
440
+ own = declared.get(name) or {}
441
+ # `lanes cursor-sync` records the binding and the original command next to the terminal, so the
442
+ # Cursor-facing `command` can be the uniform `lane open <id>` entry point.
443
+ binding = own.get('tmux') or (throttle.get(name) or {}).get('tmux')
444
+ create = own.get('create') if 'create' in own else entry.get('command')
445
+ recovery = None if own.get('tmux') else (repo.get('bindingRecoveries') or {}).get(name)
446
+ if recovery:
447
+ if (not binding or entry.get('command') != recovery.get('expectedCommand')
448
+ or binding.get('socketName') != recovery.get('expectedSocketName')
449
+ or binding.get('sessionName') != recovery.get('expectedSessionName')):
450
+ raise LanesError(f'Registry changed since session recovery: {repo_id}/{name}')
451
+ binding = recovery
452
+ override = None if own.get('tmux') else (repo.get('bindingOverrides') or {}).get(name)
453
+ if not binding and override:
454
+ binding = override
455
+ if 'expectedCommand' in binding:
456
+ matches = entry.get('command') == binding['expectedCommand']
457
+ else:
458
+ prefix = f"TMUX= tmux -L {binding.get('socketName')} new -ADs {binding.get('sessionName')} "
459
+ matches = (entry.get('command') or '').startswith(prefix)
460
+ if not matches:
461
+ raise LanesError(f'Registry command no longer agrees with saved binding: {name}')
462
+ if not binding:
463
+ raise LanesError(f'Missing explicit tmux binding: {repo_id}/{name}')
464
+ title = _title(f"{repo.get('prefix') or ''}{name}")
465
+ if title in names:
466
+ raise LanesError('Duplicate lane names: configure repository prefixes')
467
+ names.add(title)
468
+ color = entry.get('color') if isinstance(entry.get('color'), str) else None
469
+ icon = entry.get('icon') if isinstance(entry.get('icon'), str) else None
470
+ display = (repo.get('displayNames') or {}).get(name)
471
+ lanes.append({
472
+ 'name': _title(display) if display else title, 'registry_name': title, 'repo': repo_id, 'container': container, 'holder': 'tmux',
473
+ 'tmux': {'socket': _require(IDENT, binding.get('socketName'), 'tmux socket'),
474
+ 'session': _require(TMUX_SESSION, binding.get('sessionName'), 'tmux session')},
475
+ 'focus': entry.get('focus') is True,
476
+ 'color': ANSI_SLOTS.get(color),
477
+ 'icon': LANE_ICONS.get(icon, '💻'),
478
+ 'create': create if isinstance(create, str) and LANE_OPEN not in create else None,
479
+ 'folder': folder, 'source': repo.get('registry'),
480
+ })
481
+ return lanes
482
+
483
+
484
+ def _workspace_cwd(manifest):
485
+ cwd = (manifest.get('remoteShell') or {}).get('cwd')
486
+ if cwd:
487
+ return cwd
488
+ registry = (manifest.get('repositories') or [{}])[0].get('registry', '')
489
+ return registry.split('/.vscode/')[0] if '/.vscode/' in registry else CONTAINER_HOME
490
+
491
+
492
+ SITE_SCHEMA = 1 # catalog.json / workspaces.<board>.json / commit-targets.json: optional "version" (absent = 1)
493
+
494
+
495
+ def read_site_json(path):
496
+ data = json.loads(Path(path).read_text())
497
+ if int(data.get('version', 1)) > SITE_SCHEMA:
498
+ raise LanesError(f'{path} is version {data["version"]}; this server understands {SITE_SCHEMA} — update the lanes checkout')
499
+ return data
500
+
501
+
502
+ def build_registry(previous=None):
503
+ catalog = read_site_json(workspaces_dir() / 'catalog.json')['workspaces']
504
+ manifests = {}
505
+ container_names = set()
506
+ for workspace in catalog:
507
+ manifest = read_site_json(workspaces_dir() / workspace['manifest'])
508
+ manifests[workspace['id']] = manifest
509
+ for repo in manifest.get('repositories') or []:
510
+ container_names.add(repo.get('host'))
511
+ containers = refresh_containers(name for name in container_names if name)
512
+ previous_by_id = {w['id']: w for w in (previous or {}).get('workspaces', [])}
513
+ workspaces = []
514
+ used_ids = set()
515
+ for workspace in catalog:
516
+ manifest = manifests[workspace['id']]
517
+ entry = {'id': _require(IDENT, workspace['id'], 'workspace id'), 'title': workspace['title'],
518
+ 'cwd': _workspace_cwd(manifest), 'errors': []}
519
+ try:
520
+ lanes = _workspace_lanes(manifest, containers)
521
+ except (LanesError, OSError, ValueError, KeyError) as error:
522
+ # Never lose a working board because one registry is momentarily invalid.
523
+ entry['errors'].append(str(error))
524
+ lanes = (previous_by_id.get(workspace['id']) or {}).get('lanes', [])
525
+ entry['stale'] = True
526
+ entry['container'] = (lanes[0]['container'] if lanes else
527
+ (manifest.get('repositories') or [{}])[0].get('host', ''))
528
+ for lane in lanes:
529
+ if 'id' not in lane: # lanes carried over from a previous registry keep their ids
530
+ base = f"{entry['id']}--{slug(lane['name'])}"
531
+ lane_id, n = base, 2
532
+ while lane_id in used_ids:
533
+ lane_id, n = f'{base}-{n}', n + 1
534
+ lane['id'] = lane_id
535
+ used_ids.add(lane['id'])
536
+ extras = []
537
+ entry['discovery_roots'] = manifest.get('discoveryRoots') or []
538
+ entry['lanes'] = lanes
539
+ entry['extras'] = extras
540
+ workspaces.append(entry)
541
+ # One shared tool tab: the Throttle monitor of the container that hosts most boards.
542
+ hosts = [(m.get('monitor') or {}).get('host') for m in manifests.values() if (m.get('monitor') or {}).get('host')]
543
+ tools = []
544
+ if hosts:
545
+ tools.append({'id': 'tools--throttle-monitor', 'view': '_throttle', 'tab': 'Throttle', 'name': 'Throttle Monitor',
546
+ 'holder': 'exec', 'exec': 'monitor', 'container': max(set(hosts), key=hosts.count),
547
+ 'cwd': CONTAINER_HOME, 'color': None, 'icon': '📊'})
548
+ return {'version': 1, 'generated_at': time.time(), 'containers': containers, 'workspaces': workspaces, 'tools': tools}
549
+
550
+
551
+ def write_resolve_files(registry):
552
+ """One small line-oriented file per lane so POSIX-sh viewers never parse JSON or eval data."""
553
+ directory = STATE / 'resolve'
554
+ directory.mkdir(parents=True, exist_ok=True)
555
+ wanted = set()
556
+ for tool in registry.get('tools', []):
557
+ # A tool tab is a view with a fixed target: views/<device>/<view>.main falls back to defaults/<view>.
558
+ atomic_write(STATE / 'defaults' / tool['view'], tool['id'] + '\n')
559
+ for workspace in registry['workspaces'] + [{'lanes': registry.get('tools', []), 'extras': []}]:
560
+ for lane in workspace['lanes'] + workspace['extras']:
561
+ wanted.add(lane['id'])
562
+ if lane['holder'] == 'tmux':
563
+ fields = ['tmux', lane['container'], lane['tmux']['socket'], lane['tmux']['session']]
564
+ elif lane['holder'] == 'zmx':
565
+ fields = ['zmx', lane['container'], lane['zmx']['name'], lane['zmx'].get('version', '')]
566
+ else:
567
+ fields = ['exec', lane['container'], lane['exec'], lane.get('cwd', CONTAINER_HOME)]
568
+ atomic_write(directory / lane['id'], '\n'.join(fields + [lane['name']]) + '\n')
569
+ for stale in directory.iterdir():
570
+ if stale.name not in wanted and not stale.name.startswith('.'):
571
+ stale.unlink()
572
+
573
+
574
+ def export_container_registries(registry):
575
+ """/home/coder/.lz/registry.json inside each container (written through the volume path): what `lane open`
576
+ needs — holder, exact target, create recipe, folder. Only where the lane contract is installed."""
577
+ per_container = {}
578
+ for workspace in registry['workspaces']:
579
+ for lane in workspace['lanes']:
580
+ per_container.setdefault(lane['container'], {})[lane['id']] = {
581
+ 'name': lane['name'], 'ws': workspace['id'], 'holder': lane['holder'], 'tmux': lane.get('tmux'),
582
+ 'zmx': lane.get('zmx'), 'create': lane.get('create'), 'folder': lane.get('folder')}
583
+ written = []
584
+ for container, lanes in per_container.items():
585
+ home = (registry['containers'].get(container) or {}).get('home')
586
+ if home and (Path(home) / '.lz/bin/lane').exists():
587
+ atomic_write(Path(home) / '.lz/registry.json',
588
+ json.dumps({'version': 1, 'generated_at': registry['generated_at'], 'lanes': lanes},
589
+ ensure_ascii=False, indent=1) + '\n', mode=0o644)
590
+ written.append(container)
591
+ return written
592
+
593
+
594
+ def load_registry():
595
+ registry = read_json(STATE / 'registry.json')
596
+ if not registry:
597
+ raise LanesError('No registry yet: run `lanes sync`')
598
+ if int(registry.get('version', 1)) > 1:
599
+ raise LanesError('registry.json was written by a newer lanes: update this checkout, then `lanes sync`')
600
+ return registry
601
+
602
+
603
+ def find_lane(registry, lane_id):
604
+ status = read_json(STATE / 'status.json', {}) or {}
605
+ for workspace in registry['workspaces']:
606
+ discovered = (status.get('unregistered') or {}).get(workspace['id'], [])
607
+ for lane in workspace['lanes'] + workspace['extras'] + discovered:
608
+ if lane['id'] == lane_id:
609
+ return workspace, lane
610
+ raise LanesError(f'Unknown lane: {lane_id}')