@vibelord/lanes 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +37 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +651 -50
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +18 -3
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +1 -1
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +310 -26
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +80 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/RUNBOOK.md +429 -39
  19. package/payload/lanes/docs/SHARING.md +1 -1
  20. package/payload/lanes/holders/exec.sh +2 -5
  21. package/payload/lanes/holders.lock +3 -0
  22. package/payload/lanes/install/host-install.sh +10 -1
  23. package/payload/lanes/install/services.py +30 -3
  24. package/payload/lanes/install.sh +108 -3
  25. package/payload/lanes/libexec/agent_state.py +32 -0
  26. package/payload/lanes/libexec/cursor_sync.py +10 -2
  27. package/payload/lanes/libexec/economy_poll.py +685 -0
  28. package/payload/lanes/libexec/guard.py +27 -1
  29. package/payload/lanes/libexec/lanes_lib.py +285 -38
  30. package/payload/lanes/libexec/notify.py +317 -0
  31. package/payload/lanes/libexec/registrations.py +1 -1
  32. package/payload/lanes/libexec/session_actions.py +7 -8
  33. package/payload/lanes/libexec/sidebar.py +911 -218
  34. package/payload/lanes/libexec/statusd.py +238 -69
  35. package/payload/lanes/libexec/tokens.py +38 -7
  36. package/payload/lanes/libexec/view.sh +13 -3
  37. package/payload/lanes/systemd/lanes-economy.service +20 -0
  38. package/payload/lanes/systemd/lanes-notify.service +19 -0
  39. package/payload/lanes/systemd/lanes-sync.service +3 -0
  40. package/payload/lanes/web/README.md +83 -4
  41. package/payload/lanes/web/index.html +2 -1
  42. package/payload/lanes/web/mobile.css +53 -198
  43. package/payload/lanes/web/mobile.js +87 -55
  44. package/payload/lanes/zellij/config.kdl +67 -16
  45. package/payload/manifest.json +4 -3
  46. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -1,16 +1,19 @@
1
1
  #!/usr/bin/env python3
2
2
  """lanes status daemon: observes lanes, never touches them.
3
3
 
4
- Every few seconds: find LIVE tmux servers via `ps` (dead sockets are never probed), run one read-only
5
- `list-panes -a` per live server that the registry references, and publish ~/.local/state/lanes/status.json
4
+ Every few seconds: find LIVE tmux servers in each workspace container's own cgroup (`guard`, plain /proc reads;
5
+ dead sockets are never probed), run one read-only `list-panes -a` per live server that the registry references —
6
+ at most PROBE_WORKERS at once, the whole round cut at PROBE_TIMEOUT + PROBE_GRACE seconds, the servers the last
7
+ round cut started first — and publish ~/.local/state/lanes/status.json
6
8
  atomically, plus the one-line ~/.local/state/lanes/health. The sidebar, `lanes status`, `lanes doctor` and the
7
9
  future Throttle web harness all read those files.
8
10
 
9
- It never INVENTS a state. "No answer" (a tmux, ps, docker or zmx call that timed out or failed on an overloaded
11
+ It never INVENTS a state. "No answer" (a tmux, cgroup, docker or zmx read that timed out or failed on an overloaded
10
12
  host) is not an observation: the lane keeps its last published state, marked stale, for at most STALE_MAX seconds
11
13
  and is 'unknown' after that. 'absent' / 'missing' are only published when a server answered without the session,
12
14
  or positively does not exist.
13
15
  """
16
+ import concurrent.futures
14
17
  import json
15
18
  import os
16
19
  import subprocess
@@ -22,12 +25,18 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
25
  import lanes_lib as L # noqa: E402
23
26
  import session_actions as A # noqa: E402
24
27
  import agent_state as AS # noqa: E402
25
- import legacy_sessions as Legacy # noqa: E402
28
+ import guard as G # noqa: E402
26
29
 
27
30
  INTERVAL = float(os.environ.get('LANES_STATUS_INTERVAL', '3'))
28
31
  SLOWDOWN = {'ok': 1, 'busy': 2, 'critical': 4} # a host under pressure gets FEWER spawns from us, not the same
29
32
  HEARTBEAT = 15 # status.json is rewritten at least this often, so its age proves that statusd is alive
30
33
  PROBE_TIMEOUT = 5
34
+ # Plan Task 16: the servers of one cycle are probed concurrently, never more than PROBE_WORKERS tmux clients at once,
35
+ # and the cycle stops waiting PROBE_GRACE seconds after one PROBE_TIMEOUT: run_bounded's post-kill wait is 2 s, so a
36
+ # probe still running then is a child the kernel cannot reap (D state) or a fork that has not even started. Its socket
37
+ # is published as a 'timeout' (keep(): last state, stale) and the others' answers are not held back by it.
38
+ PROBE_WORKERS = 8
39
+ PROBE_GRACE = 2
31
40
  CONTAINER_REFRESH = 300 # docker is asked again this often as a safety net; liveness itself comes from /proc/<pid>
32
41
  CONTAINER_RETRY = 30 # ... and at most this often while a container is down or docker did not answer
33
42
  ACTIVE_WINDOW = 20 # seconds since last output for a lane to count as active
@@ -167,21 +176,40 @@ def board_named_by(name, boards):
167
176
  return None
168
177
 
169
178
 
170
- def live_tmux_sockets():
171
- """Socket names (-L) of running tmux servers, from the process table only.
172
- None = ps did not answer: the caller must then NOT conclude that any server is gone."""
173
- rc, out, _err = L.run_bounded(['ps', '-eo', 'comm=,args='], 10)
174
- if rc != 0:
179
+ def live_by_container(containers, errors=None):
180
+ """{container: socket names (-L) of the tmux servers running in it | None}, read from each container's own cgroup
181
+ (`guard.tmux_servers_by_container`: cgroup.procs + comm + cmdline, no subprocess). It replaced a host-wide
182
+ `ps -eo comm=,args=` per cycle — one fork that read every process of a 64-core host (plan Task 16).
183
+
184
+ This is statusd's own reading, PER CONTAINER: a container whose recorded init pid cannot be walked is None — only
185
+ ITS sockets are "nobody knows" (every socket file of that container is probed, none is concluded gone) — and the
186
+ other containers keep their real live sets (Task 16 review: one unwalkable pid used to collapse the WHOLE fleet's
187
+ reading to None). `errors` receives {container: why} for each None. A container without an init pid is absent
188
+ (it runs nothing; its lanes are 'missing' / kept by `probe_server`)."""
189
+ found = G.tmux_servers_by_container(G.Env(), containers or {}, errors)
190
+ return {name: (None if servers is None else set(servers)) for name, servers in found.items()}
191
+
192
+
193
+ def live_tmux_sockets(containers):
194
+ """All live socket names of these containers as ONE set, or None when any container that has an init pid could
195
+ not be walked. All-or-nothing on purpose: `lanes invariant` (bin/lanes `all_panes`) must never save or compare a
196
+ PARTIAL pane list. statusd itself reads `live_by_container`, where one unwalkable container costs only its own."""
197
+ found = live_by_container(containers)
198
+ if any(servers is None for servers in found.values()):
175
199
  return None
176
- names = set()
177
- for line in out.splitlines():
178
- if not line.startswith('tmux: server'):
179
- continue
180
- parts = line.split()
181
- for index, part in enumerate(parts):
182
- if part == '-L' and index + 1 < len(parts):
183
- names.add(parts[index + 1])
184
- return names
200
+ return {socket for servers in found.values() for socket in servers}
201
+
202
+
203
+ def live_in(live, container):
204
+ """The live socket names of ONE container from statusd's `live`, or None = nobody knows (probe its socket files,
205
+ conclude nothing gone). `live` is `live_by_container`'s {container: set | None} (a container missing from it is
206
+ unknown, never empty), None (no reading at all), or a flat set, which applies to every container — the shape
207
+ `live_tmux_sockets` returns and the unit fixtures and `tests/cursor_lane.py` pass."""
208
+ if live is None:
209
+ return None
210
+ if isinstance(live, dict):
211
+ return live.get(container)
212
+ return live
185
213
 
186
214
 
187
215
  def keep(previous, lane_id, outcome):
@@ -198,7 +226,9 @@ def keep(previous, lane_id, outcome):
198
226
  # 'active' is the one state that is a claim about NOW (output in the last 20 s): never true of a cached entry,
199
227
  # and the one that readers which know nothing about 'stale' count and draw green. 'idle' is the seventh word
200
228
  # a pre-DEF-823 statusd published for a zmx lane: a status.json written by that build seeds `new_context()`
201
- # across the upgrade, so it is normalised here rather than drawn as an unknown glyph by the sidebar.
229
+ # across the upgrade, so it is normalised here rather than drawn as an unknown glyph by the sidebar. (Plan
230
+ # Task 19 listed this line as dead weight; it stays because test_alignment's vocabulary gate pins it — removing
231
+ # it would mean weakening that assert.)
202
232
  state = 'quiet' if old['state'] in ('active', 'idle') else old['state']
203
233
  return dict(old, state=state, stale=True, observed=outcome, stale_since=since)
204
234
 
@@ -246,7 +276,7 @@ def agent_signals(containers, sessions_by_server):
246
276
  (`parse_codex_title`) over a bare-shell foreground can be a harness that ended into its shell, and only the
247
277
  child read (`child_liveness`) tells that apart from a live harness launched under `bash -c` or a wrapper. A pane
248
278
  a live Claude session file already claims is not probed (the file outranks the title). The key is set only for
249
- a known answer: unknown liveness leaves it absent, and an absent key never demotes a title (DEF-1420). A
279
+ a known answer: unknown liveness leaves it absent, and an absent key never demotes a title (DEF-1476). A
250
280
  container whose files cannot be read costs only its own agents, never a lane its `state`."""
251
281
  claude = {}
252
282
  for container in sorted({container for container, _socket in sessions_by_server}):
@@ -279,13 +309,63 @@ def agent_signals(containers, sessions_by_server):
279
309
  return claude
280
310
 
281
311
 
312
+ SIDECAR_MAX_AGE = 3600 # a hook sidecar whose last event is older than this loses to the live signals
313
+ # The hook sidecars' own directory under the container home. NOT `.lz/state` itself: lane-run writes each zmx lane's
314
+ # `{phase, ...}` record there as `<zmx name>.json` and exports that name as LANE_ID, so a shared name would let a hook
315
+ # overwrite the record `observe_zmx_container` and the container `lane ls` read (DEF-1436).
316
+ SIDECAR_DIR = ('.lz', 'state', 'hooks')
317
+
318
+
319
+ def sidecar_dir(info):
320
+ return Path(L.proc_root()) / str(info['pid']) / 'root' / L.CONTAINER_HOME.lstrip('/') / Path(*SIDECAR_DIR)
321
+
322
+
323
+ def sidecar_for(info, lane_id, now=None):
324
+ """The `lane-signal` hook sidecar of one lane (Task 8): `~/.lz/state/hooks/<lane id>.json` in the container, read
325
+ through `/proc/<container init pid>/root` like the Claude session files, judged by `agent_state.read_sidecar_dict`
326
+ in the container's pid namespace (a dead or recycled agent pid, or a last event older than SIDECAR_MAX_AGE, is
327
+ None)."""
328
+ if not info.get('pid'):
329
+ return None
330
+ root = Path(L.proc_root()) / str(info['pid']) / 'root'
331
+ return AS.read_sidecar_dict(L.read_json(sidecar_dir(info) / f'{lane_id}.json'),
332
+ time.time() if now is None else now, SIDECAR_MAX_AGE, proc_root=root / 'proc')
333
+
334
+
335
+ def agent_sidecars(containers, wanted, now=None):
336
+ """{container: {lane id: sidecar}} for the containers in `wanted`: ONE listing of each container's
337
+ ~/.lz/state/hooks per cycle, and a read only for the `.json` names in it — lane-run's phase records in the parent
338
+ directory are never listed or read here. A container whose directory cannot be listed simply has no sidecars —
339
+ absent is "no hook installed", never an error a lane pays for. A zmx lane's hook sidecar is named by its zmx
340
+ session (lane-run's LANE_ID), not by its published `<ws>--z-<name>` id, so it joins no lane yet (DEF-1437)."""
341
+ out = {}
342
+ for container in sorted(wanted):
343
+ info = containers.get(container) or {}
344
+ if not info.get('pid'):
345
+ continue
346
+ state = sidecar_dir(info)
347
+ try:
348
+ names = os.listdir(state)
349
+ except OSError:
350
+ continue
351
+ found = {}
352
+ for name in names:
353
+ if name.endswith('.json') and not name.startswith('.'):
354
+ side = sidecar_for(info, name[:-5], now)
355
+ if side:
356
+ found[name[:-5]] = side
357
+ out[container] = found
358
+ return out
359
+
360
+
282
361
  def probe_server(info, socket, live):
362
+ """`live` = the live socket names of THIS server's container (`live_in`), or None when nobody knows them."""
283
363
  if not info.get('pid'):
284
364
  # Only a docker inspect that ANSWERED "pid 0" says the container is down. No record at all, or one that
285
365
  # carries an error (docker did not answer) = nobody knows.
286
366
  return ('noserver' if info and not info.get('error') else 'error'), ''
287
367
  if live is not None and socket not in live:
288
- return 'noserver', '' # ps answered and lists no such server: a stale socket file is never probed
368
+ return 'noserver', '' # the cgroups were read and run no such server: a stale socket file is never probed
289
369
  if (live is not None and info.get('running', True) and os.path.exists(f"{L.proc_root()}/{info['pid']}")
290
370
  and not os.path.exists(L.tmux_socket(info, socket))):
291
371
  # (container init alive: a container that went DOWN takes its sockets along and is 'missing', not this)
@@ -309,17 +389,78 @@ def discovered_sockets(containers, live):
309
389
  if not info.get('pid'):
310
390
  continue
311
391
  directory = Path(L.tmux_socket(info, 'discovery')).parent
392
+ known = live_in(live, container)
312
393
  try:
313
394
  for path in sorted(directory.iterdir()):
314
- if L.IDENT.fullmatch(path.name) and path.is_socket() and (live is None or path.name in live):
395
+ if L.IDENT.fullmatch(path.name) and path.is_socket() and (known is None or path.name in known):
315
396
  found.append((container, path.name))
316
397
  except OSError:
317
398
  pass
318
399
  return found
319
400
 
320
401
 
321
- def observe(registry, containers, live, previous=None):
322
- """previous = the last published payload: what a lane keeps when its server gave no answer this time."""
402
+ def probe_one(container, info, socket, live):
403
+ """One server's answer -> (outcome, sessions, activities). Runs on the probe pool, one call per socket: one server
404
+ (or one odd row) must not cost the other servers their cycle, so whatever goes wrong in here ends as "no answer
405
+ from THIS server" and never reaches another socket's result."""
406
+ rows_by_session = {}
407
+ try:
408
+ outcome, text = probe_server(info, socket, live_in(live, container))
409
+ for row in text.splitlines() if outcome == 'ok' else []:
410
+ values = dict(zip(FIELDS, row.split('\t', len(FIELDS) - 1)))
411
+ rows_by_session.setdefault(values['session_name'], []).append(values)
412
+ except Exception as error:
413
+ log(f'observe {container}/{socket}', error)
414
+ outcome, rows_by_session = 'error', {}
415
+ # A4a: every session is read through its ACTIVE pane and its NEWEST window activity, the same two
416
+ # readings the Host makes, so `dead`/`active` cannot mean one thing per surface on a multi-window lane.
417
+ return (outcome, {name: session_row(rows) for name, rows in rows_by_session.items()},
418
+ {name: newest_activity(rows) for name, rows in rows_by_session.items()})
419
+
420
+
421
+ def probe_all(containers, keys, live, first=()):
422
+ """{(container, socket): probe_one's answer} for every key, at most PROBE_WORKERS probes at once, plus the keys
423
+ that were cut. A probe still running PROBE_TIMEOUT + PROBE_GRACE seconds after the round began answers 'timeout' —
424
+ keep() then republishes its lanes as last seen — and is abandoned, never waited for: `run_bounded` bounds it on
425
+ its own (PROBE_TIMEOUT + its 2 s post-kill wait), and `pause` lets the next round start only after that.
426
+ A probe that never started by then is cancelled. Both read at call time, so a test can shorten PROBE_TIMEOUT.
427
+
428
+ The keys in `first` — the ones the previous round's deadline cut — are submitted AHEAD of the others. The deadline
429
+ counts from the start of the round and the pool's queue is FIFO, so with more sockets than PROBE_WORKERS the
430
+ sockets at the end of the (deterministic, registry-ordered) key list start last: on a host where every probe
431
+ answers in, say, 0.4 x PROBE_TIMEOUT, the same tail was cut EVERY cycle and went 'unknown' after STALE_MAX while
432
+ the head stayed live (Task 16 review, MEASURED with 17 sockets at 1.5 s against a 4 s deadline). A socket cut once
433
+ now starts at t=0 next round, so no socket that answers inside PROBE_TIMEOUT is cut two rounds running unless more
434
+ sockets were cut than the pool can start at once."""
435
+ keys = list(keys)
436
+ first = set(first)
437
+ keys = [key for key in keys if key in first] + [key for key in keys if key not in first]
438
+ probed, cut = {}, []
439
+ if not keys:
440
+ return probed, cut
441
+ pool = concurrent.futures.ThreadPoolExecutor(max_workers=PROBE_WORKERS, thread_name_prefix='statusd-probe')
442
+ try:
443
+ futures = {pool.submit(probe_one, container, containers.get(container) or {}, socket, live): (container, socket)
444
+ for container, socket in keys}
445
+ done, _pending = concurrent.futures.wait(futures, timeout=PROBE_TIMEOUT + PROBE_GRACE)
446
+ for future, key in futures.items():
447
+ if future in done:
448
+ probed[key] = future.result() # probe_one never raises
449
+ else:
450
+ cut.append(key)
451
+ probed[key] = ('timeout', {}, {})
452
+ finally:
453
+ pool.shutdown(wait=False, cancel_futures=True)
454
+ if cut:
455
+ names = ' '.join(f'{container}/{socket}' for container, socket in cut[:8]) + (' …' if len(cut) > 8 else '')
456
+ log('observe', TimeoutError(f'{len(cut)} tmux probe(s) still running at the {PROBE_TIMEOUT + PROBE_GRACE} s '
457
+ f'cycle deadline (first next round): {names}'))
458
+ return probed, cut
459
+
460
+
461
+ def observe(registry, containers, live, previous=None, report=None):
462
+ """previous = the last published payload: what a lane keeps when its server gave no answer this time.
463
+ report, when given, receives {'cut': n}: how many probes were still running at the cycle deadline."""
323
464
  previous = previous or {}
324
465
  now = time.time()
325
466
  lanes, servers, unregistered = {}, {}, {}
@@ -353,30 +494,27 @@ def observe(registry, containers, live, previous=None):
353
494
  # servers and servers with no registered lane. Never start a server to ask whether it exists.
354
495
  for key in discovered_sockets(containers, live):
355
496
  wanted.setdefault(key, [])
356
- probed = {}
357
- for container, socket in wanted:
358
- info = containers.get(container) or {}
359
- rows_by_session = {}
360
- # One server (or one odd row) must not cost the other servers their cycle: whatever goes wrong in here
361
- # ends as "no answer from THIS server".
362
- try:
363
- outcome, text = probe_server(info, socket, live)
364
- for row in text.splitlines() if outcome == 'ok' else []:
365
- values = dict(zip(FIELDS, row.split('\t', len(FIELDS) - 1)))
366
- rows_by_session.setdefault(values['session_name'], []).append(values)
367
- except Exception as error:
368
- log(f'observe {container}/{socket}', error)
369
- outcome, rows_by_session = 'error', {}
370
- # A4a: every session is read through its ACTIVE pane and its NEWEST window activity, the same two
371
- # readings the Host makes, so `dead`/`active` cannot mean one thing per surface on a multi-window lane.
372
- probed[(container, socket)] = (outcome, {name: session_row(rows) for name, rows in rows_by_session.items()},
373
- {name: newest_activity(rows) for name, rows in rows_by_session.items()})
497
+ # The servers the previous round's deadline cut go first this round (`probe_all`). Read from the published
498
+ # `servers.*.cut` mark, so a statusd that restarts from status.json keeps the order too.
499
+ last_servers = previous.get('servers') or {}
500
+ first = {key for key in wanted if (last_servers.get(f'{key[0]}/{key[1]}') or {}).get('cut')}
501
+ probed, cut = probe_all(containers, wanted, live, first)
502
+ cut = set(cut)
503
+ if report is not None:
504
+ report['cut'] = len(cut)
374
505
  # Task 5: the agent signals of every container, read once per cycle over all of its servers' sessions.
375
506
  try:
376
507
  claude = agent_signals(containers, {key: sessions for key, (_o, sessions, _a) in probed.items()})
377
508
  except Exception as error:
378
509
  log('agents', error)
379
510
  claude = {}
511
+ # Task 8: the hook sidecars `lane-signal` writes, one directory listing per container per cycle. They outrank the
512
+ # session file and the title in `agent_state.merge`; a container without hooks costs one failed listdir.
513
+ try:
514
+ sidecars = agent_sidecars(containers, {container for container, _socket in probed}, now)
515
+ except Exception as error:
516
+ log('sidecars', error)
517
+ sidecars = {}
380
518
  for (container, socket), members in wanted.items():
381
519
  info = containers.get(container) or {}
382
520
  outcome, sessions, activities = probed[(container, socket)]
@@ -388,6 +526,10 @@ def observe(registry, containers, live, previous=None):
388
526
  servers[f'{container}/{socket}'] = {'live': outcome == 'ok' if answered else last.get('live'),
389
527
  'sessions': len(own_sessions) if answered else last.get('sessions', 0),
390
528
  'outcome': outcome}
529
+ if (container, socket) in cut:
530
+ # Additive key, present only on a server the cycle DEADLINE abandoned (a probe that ran into its own
531
+ # PROBE_TIMEOUT is not marked): the next round starts it first. Every other row is byte-identical.
532
+ servers[f'{container}/{socket}']['cut'] = True
391
533
  if not answered:
392
534
  # The sessions nobody registered stay visible as well while their server does not answer.
393
535
  for workspace_id, entries in (previous.get('unregistered') or {}).items():
@@ -444,7 +586,8 @@ def observe(registry, containers, live, previous=None):
444
586
  if not L.TMUX_SESSION.match(name):
445
587
  entry['note'] = 'session name outside the lane declaration grammar'
446
588
  found = lane_entry(pane, now, activities.get(name), viewers.get(name, []),
447
- AS.merge(pane, claude.get(container, {}), None))
589
+ AS.merge(pane, claude.get(container, {}),
590
+ sidecars.get(container, {}).get(entry['id'])))
448
591
  except Exception as error:
449
592
  log(f'observe {container}/{socket}/{name}', error)
450
593
  continue
@@ -462,9 +605,9 @@ def observe(registry, containers, live, previous=None):
462
605
  lanes[lane['id']] = {'state': 'absent' if info.get('running') else 'missing'}
463
606
  else:
464
607
  try:
465
- # The hook sidecar (Task 8) is the third argument; until it lands nothing is passed.
466
608
  lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []),
467
- AS.merge(pane, claude.get(container, {}), None))
609
+ AS.merge(pane, claude.get(container, {}),
610
+ sidecars.get(container, {}).get(lane['id'])))
468
611
  except Exception as error: # a torn row: the session is there, its details are not readable
469
612
  log(f"observe {lane['id']}", error)
470
613
  lanes[lane['id']] = keep(previous, lane['id'], 'error')
@@ -552,7 +695,8 @@ def reconcile_client_sizes(registry, containers, live):
552
695
  sockets.update(discovered_sockets(containers, live))
553
696
  for container, socket in sorted(sockets):
554
697
  info = containers.get(container) or {}
555
- if socket not in live or not info.get('pid'):
698
+ known = live_in(live, container) # cosmetics only on a server KNOWN to be live: unknown is never touched
699
+ if known is None or socket not in known or not info.get('pid'):
556
700
  continue
557
701
  output = L.tmux_query(info, socket, ['list-clients', '-F', CLIENT_FORMAT])
558
702
  rows = [row.split('|') for row in (output or '').splitlines() if row.count('|') == 4]
@@ -613,21 +757,28 @@ def observe_zmx_container(container):
613
757
  name = fields.get('name', '')
614
758
  if not L.IDENT.match(name):
615
759
  continue
616
- meta = L.read_json(link / 'meta' / f'{name}.json', {}) or {}
617
- agent = L.read_json(link / 'state' / f'{name}.json', {}) or {}
618
- workspace = meta.get('ws') or fields.get('ws') or '-'
760
+ # The meta and state sidecars are written INSIDE the container (DEF-1461): each one is read as an object or
761
+ # nothing, each field as one printable line or nothing, and the workspace must be a lanes identifier — it
762
+ # names a host file below. A malformed sidecar costs its own row's labels, never the container's other rows.
763
+ # `lane new` writes exactly these shapes (`ws` checked against IDENT or empty, `zmx_version` a string).
764
+ meta, agent = (L.read_json(link / sub / f'{name}.json', {}) for sub in ('meta', 'state'))
765
+ meta, agent = (d if isinstance(d, dict) else {} for d in (meta, agent))
766
+ text = {key: meta[key] if isinstance(meta.get(key), str) and meta[key].isprintable() else ''
767
+ for key in ('ws', 'title', 'zmx_version', 'color', 'icon', 'cwd')}
768
+ workspace = next((w for w in (text['ws'], fields.get('ws') or '') if L.IDENT.match(w)), '-')
619
769
  lane_id = f'{workspace}--z-{name}'
620
- version = str(meta.get('zmx_version') or binaries[-1].name.split('-', 1)[1])
770
+ version = text['zmx_version'] or binaries[-1].name.split('-', 1)[1]
771
+ title = text['title'] or name
621
772
  groups.setdefault(workspace, []).append({
622
- 'id': lane_id, 'name': meta.get('title') or name, 'holder': 'zmx', 'container': container,
623
- 'zmx': {'name': name, 'version': version}, 'color': L.ANSI_SLOTS.get(meta.get('color')),
624
- 'icon': L.LANE_ICONS.get(meta.get('icon'), '🧵')})
773
+ 'id': lane_id, 'name': title, 'holder': 'zmx', 'container': container,
774
+ 'zmx': {'name': name, 'version': version}, 'color': L.ANSI_SLOTS.get(text['color']),
775
+ 'icon': L.LANE_ICONS.get(text['icon'], '🧵')})
625
776
  L.atomic_write(L.STATE / 'resolve.dynamic' / lane_id,
626
- '\n'.join(['zmx', container, name, version, meta.get('title') or name]) + '\n')
777
+ '\n'.join(['zmx', container, name, version, title]) + '\n')
627
778
  state, evidence = zmx_state(agent)
628
779
  lanes[lane_id] = {'state': state, 'evidence': evidence,
629
780
  'clients': int(fields.get('clients', 0) or 0), 'pid': int(fields.get('pid', 0) or 0),
630
- 'agent': agent, 'cwd': meta.get('cwd', '')}
781
+ 'agent': agent, 'cwd': text['cwd']}
631
782
  return lanes, groups
632
783
 
633
784
 
@@ -657,7 +808,7 @@ def update_containers(ctx, registry, level):
657
808
  5-minute safety net on a host that is not busy — and at most every CONTAINER_RETRY seconds. NEVER because a
658
809
  probe failed: `docker inspect` (2 spawns, 20 s timeouts) every 3 s is a load generator on a host that is
659
810
  already too slow to answer it."""
660
- names = {lane['container'] for w in registry['workspaces'] for lane in w['lanes'] + w['extras'] if lane.get('container')}
811
+ names = {lane['container'] for w in registry['workspaces'] for lane in w['lanes'] if lane.get('container')}
661
812
  known = ctx['containers']
662
813
  age = time.time() - ctx['containers_at']
663
814
  unsettled = set(known) != names or any(not pid_alive(c.get('pid')) for c in known.values())
@@ -674,8 +825,11 @@ def update_containers(ctx, registry, level):
674
825
 
675
826
  def build_payload(ctx, level, reconcile=True):
676
827
  """One observation pass -> the payload of status.json (None = there is no registry to observe). Every phase
677
- fails alone: what it could not observe is republished as last seen (stale), the other phases still run."""
828
+ fails alone: what it could not observe is republished as last seen (stale), the other phases still run.
829
+ ctx['cut'] = how many tmux probes the cycle deadline abandoned (0 when nothing was probed): `pause` reads it."""
678
830
  previous = ctx['payload']
831
+ report = {'cut': 0}
832
+ ctx['cut'] = 0
679
833
  try:
680
834
  stamp = os.stat(L.STATE / 'registry.json').st_mtime_ns
681
835
  if stamp != ctx['stamp']:
@@ -690,17 +844,32 @@ def build_payload(ctx, level, reconcile=True):
690
844
  except Exception as error:
691
845
  log('containers', error)
692
846
  containers = ctx['containers']
847
+ unwalkable = {}
693
848
  try:
694
- live = live_tmux_sockets()
849
+ live = live_by_container(containers, unwalkable)
695
850
  except Exception as error:
696
- log('ps', error)
851
+ log('live servers', error)
697
852
  live = None
853
+ # Name the container whose init pid broke the cgroup read, once when it starts (not every INTERVAL): only ITS
854
+ # sockets are probed blind, and the operator needs to know which one that is.
855
+ for name in sorted(set(unwalkable) - set(ctx.get('unwalkable') or ())):
856
+ log(f'live servers {name}', L.LanesError(f'{unwalkable[name]} — its sockets are probed as unknown, none concluded gone'))
857
+ ctx['unwalkable'] = sorted(unwalkable)
698
858
  try:
699
- lanes, servers, unregistered = observe(registry, containers, live, previous)
859
+ lanes, servers, unregistered = observe(registry, containers, live, previous, report)
700
860
  except Exception as error:
701
861
  log('observe', error)
702
862
  lanes = {lane_id: keep(previous, lane_id, 'error') for lane_id in previous.get('lanes') or {}}
703
- servers, unregistered = previous.get('servers') or {}, previous.get('unregistered') or {}
863
+ # DEF-1457: COPIES of what was published, never the previous payload itself (`previous` is the very object
864
+ # `ctx['published']` holds, so a kept list extended in place would grow the published payload), and only
865
+ # observe's own rows (holder 'tmux', the rows `observe` carries over too).
866
+ servers = dict(previous.get('servers') or {})
867
+ unregistered = {}
868
+ for workspace, entries in (previous.get('unregistered') or {}).items():
869
+ kept = [entry for entry in entries if entry.get('holder') == 'tmux']
870
+ if kept:
871
+ unregistered[workspace] = kept
872
+ ctx['cut'] = report.get('cut', 0)
704
873
  # Half of all our spawns, and only cosmetics (who dictates a window size): not on a host under pressure.
705
874
  if reconcile and level == 'ok':
706
875
  try:
@@ -713,10 +882,6 @@ def build_payload(ctx, level, reconcile=True):
713
882
  log('zmx', error)
714
883
  zmx_lanes, zmx_groups = kept_zmx(previous, 'error')
715
884
  lanes.update(zmx_lanes)
716
- legacy_lanes, legacy_groups = Legacy.observe()
717
- lanes.update(legacy_lanes)
718
- for workspace, entries in legacy_groups.items():
719
- unregistered.setdefault(workspace, []).extend(entries)
720
885
  return {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
721
886
  'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
722
887
 
@@ -730,7 +895,7 @@ def new_context():
730
895
  for entry in (seed.get('lanes') or {}).values():
731
896
  entry.setdefault('stale_since', int(written))
732
897
  return {'registry': None, 'stamp': 0, 'containers': {}, 'containers_at': 0.0,
733
- 'payload': seed, 'published': None, 'status_at': written}
898
+ 'payload': seed, 'published': None, 'status_at': written, 'cut': 0, 'unwalkable': []}
734
899
 
735
900
 
736
901
  def cycle(ctx, reconcile=True):
@@ -775,8 +940,12 @@ def cycle(ctx, reconcile=True):
775
940
  return level, payload
776
941
 
777
942
 
778
- def pause(level, elapsed):
779
- return max(0.5, INTERVAL * SLOWDOWN.get(level, 1) - elapsed)
943
+ def pause(level, elapsed, cut=0):
944
+ """Seconds to sleep before the next cycle. After a cycle that abandoned probes at its deadline (`cut`), at least
945
+ PROBE_TIMEOUT + PROBE_GRACE: run_bounded ends each of those within that time, so the next round never runs
946
+ beside them and at most PROBE_WORKERS tmux clients of ours are ever in flight on a host that is already slow."""
947
+ rest = max(0.5, INTERVAL * SLOWDOWN.get(level, 1) - elapsed)
948
+ return max(rest, PROBE_TIMEOUT + PROBE_GRACE) if cut else rest
780
949
 
781
950
 
782
951
  def registry_source_paths(registry, containers):
@@ -842,7 +1011,7 @@ def main():
842
1011
  if hot_sync(ctx, source_mtimes):
843
1012
  last_triggered_sync = time.time()
844
1013
  ctx['children'] = [p for p in ctx.get('children', []) if p.poll() is None] # reap finished syncs
845
- time.sleep(pause(level, time.time() - started))
1014
+ time.sleep(pause(level, time.time() - started, ctx.get('cut', 0)))
846
1015
 
847
1016
 
848
1017
  if __name__ == '__main__':
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env python3
2
- """The Lanes design tokens: ONE table, rendered four ways. The sidebar imports `sgr_table`; `--kdl`, `--css`, `--sh`
3
- and `--md` write the Zellij theme, the phone CSS, the viewer's shell variables and the docs table between markers;
4
- `--check` fails when any committed block differs. Spec: docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md.
2
+ """The Lanes design tokens: ONE table, rendered seven ways. The sidebar imports `sgr_table`; `--kdl`, `--css`, `--sh`,
3
+ `--md`, `--js` and `--ghostty-dark|--ghostty-light` write the Zellij theme, the phone CSS, the viewer's shell variables,
4
+ the docs table, the phone's terminal palette and the optional Ghostty palette files between markers; `--check` fails
5
+ when any committed block differs. Spec: docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md.
5
6
  No third-party code; importable with no side effects."""
6
7
  import difflib
7
8
  import os
@@ -215,10 +216,40 @@ def render_md():
215
216
  return '\n'.join(rows) + '\n'
216
217
 
217
218
 
218
- RENDERERS = {'kdl': render_kdl, 'css': render_css, 'sh': render_sh, 'md': render_md}
219
- TARGETS = {'kdl': 'zellij/config.kdl', 'css': 'web/mobile.css', 'sh': 'libexec/tokens.sh', 'md': 'docs/DESIGN.md'}
219
+ def render_js():
220
+ """The phone's xterm.js slots for BOTH variants. Zellij's web_client.theme is one server-wide (dark) palette that
221
+ arrives in the HTTP boot config and that SetConfig re-sends on every config reload, assigned wholesale; mobile.js
222
+ applies these on attach and re-applies them after each SetConfig (spec §6.3; DEF-1413). 495 B against mobile.js's
223
+ 3072 B allowance (measured 2026-10-04)."""
224
+ body = ', '.join(f"{v}: [{', '.join(repr(c) for c in PALETTE16[v])}]" for v in ('dark', 'light'))
225
+ return ('// BEGIN lanes tokens (generated by libexec/tokens.py --js; do not edit by hand)\n'
226
+ f' const LANES_ANSI = {{{body}}};\n // END lanes tokens\n')
227
+
228
+
229
+ def render_ghostty(variant):
230
+ """The optional Ghostty palette (spec §7; `install.sh --client --palette`): colours only — never font, theme,
231
+ padding or cursor style, the everyday config keeps those. Valid both as an included config file and as a Ghostty
232
+ theme file, so install.sh can include the dark one directly or pair both through `theme = light:…,dark:…`."""
233
+ t, p = TOKENS[variant], PALETTE16[variant]
234
+ lines = [f'# BEGIN lanes tokens (generated by libexec/tokens.py --ghostty-{variant}; do not edit by hand)',
235
+ '# Optional: the cockpit palette regardless of your everyday Ghostty theme (install.sh --client --palette).',
236
+ '# Slots 1-4 are the four tones, so 16-colour mode here equals truecolor mode. No font, padding or cursor keys.',
237
+ f'background = {t["surface"]}', f'foreground = {t["ink"]}', f'cursor-color = {t["accent"]}',
238
+ f'selection-background = {t["line"]}', f'selection-foreground = {t["ink"]}']
239
+ lines += [f'palette = {i}={c}' for i, c in enumerate(p)]
240
+ lines.append('# END lanes tokens')
241
+ return '\n'.join(lines) + '\n'
242
+
243
+
244
+ RENDERERS = {'kdl': render_kdl, 'css': render_css, 'sh': render_sh, 'md': render_md, 'js': render_js,
245
+ 'ghostty-dark': lambda: render_ghostty('dark'), 'ghostty-light': lambda: render_ghostty('light')}
246
+ TARGETS = {'kdl': 'zellij/config.kdl', 'css': 'web/mobile.css', 'sh': 'libexec/tokens.sh', 'md': 'docs/DESIGN.md',
247
+ 'js': 'web/mobile.js', 'ghostty-dark': 'client/ghostty/lanes-palette.conf',
248
+ 'ghostty-light': 'client/ghostty/lanes-palette-light.conf'}
220
249
  MARKERS = {'kdl': ('// BEGIN lanes tokens', '// END lanes tokens'), 'css': ('/* BEGIN lanes tokens', '/* END lanes tokens */'),
221
- 'sh': ('# BEGIN lanes tokens', '# END lanes tokens'), 'md': ('<!-- BEGIN lanes tokens', '<!-- END lanes tokens -->')}
250
+ 'sh': ('# BEGIN lanes tokens', '# END lanes tokens'), 'md': ('<!-- BEGIN lanes tokens', '<!-- END lanes tokens -->'),
251
+ 'js': ('// BEGIN lanes tokens', '// END lanes tokens'),
252
+ 'ghostty-dark': ('# BEGIN lanes tokens', '# END lanes tokens'), 'ghostty-light': ('# BEGIN lanes tokens', '# END lanes tokens')}
222
253
 
223
254
 
224
255
  def render(kind):
@@ -270,7 +301,7 @@ def main(argv):
270
301
  return 0
271
302
  kind = argv[0].lstrip('-') if argv else ''
272
303
  if kind not in RENDERERS:
273
- sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--check|--write')
304
+ sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--js|--ghostty-dark|--ghostty-light|--check|--write')
274
305
  sys.stdout.write(render(kind))
275
306
  return 0
276
307
 
@@ -58,12 +58,22 @@ reset_terminal() {
58
58
  printf '\033[?1049l\033[?1000l\033[?1002l\033[?1003l\033[?1006l\033[?2004l\033[<u\033[0m\033[?25h\033[2J\033[3J\033[H'
59
59
  }
60
60
 
61
+ # The 16-colour SGR literals of the design tokens (libexec/tokens.py --sh): sourced once, printf only, no fork.
62
+ # They hold `\033[…m` as text, so they go into printf's FORMAT (which turns `\033` into ESC), never into a %s argument.
63
+ # shellcheck source=libexec/tokens.sh
64
+ . "$repo/libexec/tokens.sh"
65
+
61
66
  # status_screen <title> <text> <dim line> [<lead>]: a lead, when given, is the bold headline above the title.
67
+ # The lead glyph follows the sidebar's published glyph mode ($STATE/clients/<device>.glyphs, written by sidebar.py from
68
+ # devices/<device>.json) and is read on EVERY call with the builtin read, no fork: this runs exactly when the host may be
69
+ # too slow for one, and `lanes attach DEVICE --ascii` against a live session must reach the next screen without a restart.
62
70
  status_screen() {
63
71
  reset_terminal
64
- if [ -n "${4:-}" ]; then printf '\n \033[1m%s\033[0m\n\n %s\n' "$4" "$1"
65
- else printf '\n \033[1m%s\033[0m\n' "$1"; fi
66
- printf '\n %s\n\n \033[2m%s\033[0m\n' "$2" "$3"
72
+ glyphs=unicode; { IFS= read -r glyphs < "$state/clients/$device.glyphs"; } 2>/dev/null || :
73
+ lead_glyph='⚠'; [ "$glyphs" != ascii ] || lead_glyph='!'
74
+ if [ -n "${4:-}" ]; then printf "\n ${LZ_BOLD}%s %s${LZ_RESET}\n\n %s\n" "$lead_glyph" "$4" "$1"
75
+ else printf "\n ${LZ_BOLD}%s${LZ_RESET}\n" "$1"; fi
76
+ printf "\n %s\n\n ${LZ_DIM}%s${LZ_RESET}\n" "$2" "$3"
67
77
  }
68
78
 
69
79
  # Is the socket of the lane just shown still there? Then only this VIEWER ended: a lane lives in its holder's server
@@ -0,0 +1,20 @@
1
+ [Unit]
2
+ Description=lanes economy projection (opt-in: GETs the Throttle daemon's loopback /status and /sessions, writes economy.json)
3
+ After=lanes-statusd.service
4
+
5
+ # No installer enables this unit. It reads the production daemon's control port, so switching it on is an operator
6
+ # action: systemctl --user enable --now lanes-economy.service (RUNBOOK "Economy projection (opt-in)").
7
+ # It runs on the HOST, not in the daemon's container: point LANES_THROTTLE_CONTAINER at that container for the log,
8
+ # and use LANES_THROTTLE_CONTROL=none unless the control port is reachable from the host (RUNBOOK, DEF-1444).
9
+ [Service]
10
+ ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/economy_poll.py" --interval 10
11
+ Restart=on-failure
12
+ RestartSec=10
13
+ Nice=10
14
+ NoNewPrivileges=true
15
+ MemoryHigh=96M
16
+ MemoryMax=192M
17
+ Slice=lanes.slice
18
+
19
+ [Install]
20
+ WantedBy=default.target
@@ -0,0 +1,19 @@
1
+ [Unit]
2
+ Description=lanes push notifications (opt-in: POSTs needs-you transitions from status.json to an ntfy topic)
3
+ After=lanes-statusd.service
4
+
5
+ # No installer enables this unit. It sends lane names and agent tasks to the ntfy server in
6
+ # ~/.config/lanes/notify.conf (LANES_NOTIFY_CONF), so switching it on is an operator action:
7
+ # systemctl --user enable --now lanes-notify.service (RUNBOOK "Push notifications (opt-in)").
8
+ [Service]
9
+ ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/notify.py" --interval 3
10
+ Restart=on-failure
11
+ RestartSec=10
12
+ Nice=10
13
+ NoNewPrivileges=true
14
+ MemoryHigh=48M
15
+ MemoryMax=96M
16
+ Slice=lanes.slice
17
+
18
+ [Install]
19
+ WantedBy=default.target