@vibelord/lanes 1.0.0 → 1.0.1

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 (34) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +56 -1
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +281 -104
  5. package/payload/lanes/client/lanes-launch +44 -2
  6. package/payload/lanes/container/env.sh +8 -0
  7. package/payload/lanes/container/lane +19 -2
  8. package/payload/lanes/docs/ARCHITECTURE.md +86 -1
  9. package/payload/lanes/docs/DESIGN.md +72 -0
  10. package/payload/lanes/docs/INSTALL.md +39 -0
  11. package/payload/lanes/docs/RUNBOOK.md +94 -1
  12. package/payload/lanes/holders/ssh.sh +18 -0
  13. package/payload/lanes/install/enable-phone.sh +15 -6
  14. package/payload/lanes/install/enable-web.sh +19 -6
  15. package/payload/lanes/libexec/agent_state.py +214 -0
  16. package/payload/lanes/libexec/connection.py +178 -0
  17. package/payload/lanes/libexec/cursor_sync.py +139 -6
  18. package/payload/lanes/libexec/device_sync.py +134 -0
  19. package/payload/lanes/libexec/lanes_lib.py +158 -5
  20. package/payload/lanes/libexec/registrations.py +148 -0
  21. package/payload/lanes/libexec/session_actions.py +47 -15
  22. package/payload/lanes/libexec/sidebar.py +372 -61
  23. package/payload/lanes/libexec/statusd.py +126 -33
  24. package/payload/lanes/libexec/tokens.py +279 -0
  25. package/payload/lanes/libexec/tokens.sh +11 -0
  26. package/payload/lanes/libexec/view.sh +9 -8
  27. package/payload/lanes/web/README.md +15 -0
  28. package/payload/lanes/web/browser.css +22 -0
  29. package/payload/lanes/web/browser.js +73 -0
  30. package/payload/lanes/web/mobile.css +65 -33
  31. package/payload/lanes/web/mobile.js +23 -5
  32. package/payload/lanes/workspaces.example/README.md +9 -3
  33. package/payload/lanes/zellij/config.kdl +61 -3
  34. package/payload/manifest.json +6 -3
@@ -13,6 +13,7 @@ or positively does not exist.
13
13
  """
14
14
  import json
15
15
  import os
16
+ import subprocess
16
17
  import sys
17
18
  import time
18
19
  from pathlib import Path
@@ -20,6 +21,7 @@ from pathlib import Path
20
21
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
22
  import lanes_lib as L # noqa: E402
22
23
  import session_actions as A # noqa: E402
24
+ import agent_state as AS # noqa: E402
23
25
  import legacy_sessions as Legacy # noqa: E402
24
26
 
25
27
  INTERVAL = float(os.environ.get('LANES_STATUS_INTERVAL', '3'))
@@ -35,7 +37,12 @@ FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_curr
35
37
  'session_id', 'session_created',
36
38
  # Alignment contract A4a: the group a session is attached onto (a Throttle Studio browser view is a
37
39
  # grouped session), and which pane of which window represents the session's state.
38
- 'session_group', 'pane_active', 'window_active']
40
+ 'session_group', 'pane_active', 'window_active',
41
+ # Task 5 (`agent{}`): the window bell, the ids a Claude session file names its pane by
42
+ # (`<session>:@<win>.%<pane>`), and the pane title Codex writes its status into. APPENDED, so every
43
+ # column above keeps its index; the title is the one free-text column and comes LAST, so a tab inside it
44
+ # stays in the title (the row is split at most len(FIELDS) - 1 times) instead of shifting the ids.
45
+ 'window_bell_flag', 'window_id', 'pane_id', 'pane_title']
39
46
  FORMAT = '\t'.join('#{' + f + '}' for f in FIELDS)
40
47
  VIEW_PREFIX = 'view-' # a Throttle Studio browser attach: a grouped session named view-<studio>-<ch>
41
48
 
@@ -196,10 +203,11 @@ def keep(previous, lane_id, outcome):
196
203
  return dict(old, state=state, stale=True, observed=outcome, stale_since=since)
197
204
 
198
205
 
199
- def lane_entry(pane, now, activity=None, viewing=()):
206
+ def lane_entry(pane, now, activity=None, viewing=(), agent=None):
200
207
  """One live session's published row. `activity` is the NEWEST `window_activity` across the session's panes
201
208
  (see `newest_activity`) and `viewing` the rows of the sessions that VIEW it (see `split_viewers`); both
202
- default to "this pane alone", which is what a caller with a single row has."""
209
+ default to "this pane alone", which is what a caller with a single row has. `agent` is `agent_state.merge`'s
210
+ verdict for the session's pane: published as the additive `agent` key, absent when no signal names an agent."""
203
211
  if activity is None:
204
212
  try:
205
213
  activity = int(pane['window_activity'])
@@ -224,9 +232,53 @@ def lane_entry(pane, now, activity=None, viewing=()):
224
232
  'tty': pane['pane_tty']}
225
233
  if viewing: # skipped when 0, so a lane nobody views publishes byte-identically to before A4a
226
234
  entry['viewers'] = len(viewing)
235
+ if agent: # absent = no agent: a plain shell's row is byte-identical to before Task 5
236
+ entry['agent'] = agent
227
237
  return entry
228
238
 
229
239
 
240
+ def agent_signals(containers, sessions_by_server):
241
+ """The per-container inputs `agent_state.merge` needs, read ONCE per container per cycle through the
242
+ container's root as the host sees it (`/proc/<init pid>/root`), so liveness is judged in the container's pid
243
+ namespace and nothing is spawned. -> {container: claude_by_pane}.
244
+
245
+ Also sets `pane_has_child` on the session rows that need it, in place: only a pane whose title Codex wrote
246
+ (`parse_codex_title`) over a bare-shell foreground can be a harness that ended into its shell, and only the
247
+ child read (`child_liveness`) tells that apart from a live harness launched under `bash -c` or a wrapper. A pane
248
+ 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
250
+ container whose files cannot be read costs only its own agents, never a lane its `state`."""
251
+ claude = {}
252
+ for container in sorted({container for container, _socket in sessions_by_server}):
253
+ info = containers.get(container) or {}
254
+ if not info.get('pid'):
255
+ continue
256
+ root = Path(L.proc_root()) / str(info['pid']) / 'root'
257
+ try:
258
+ claude[container] = AS.claude_sessions(root / L.CONTAINER_HOME.lstrip('/') / '.claude' / 'sessions',
259
+ root / 'proc')
260
+ except Exception as error:
261
+ log(f'agents {container}', error)
262
+ claude[container] = {}
263
+ probe = [pane for (owner, _socket), sessions in sessions_by_server.items() if owner == container
264
+ for pane in sessions.values()
265
+ if str(pane.get('pane_pid') or '').isdigit() and AS.parse_codex_title(pane.get('pane_title'))
266
+ and AS._shell_foreground(pane)
267
+ and f"{pane.get('session_name')}:{pane.get('window_id')}.{pane.get('pane_id')}" not in claude[container]]
268
+ if not probe:
269
+ continue
270
+ try:
271
+ live = AS.child_liveness(root / 'proc', [pane['pane_pid'] for pane in probe])
272
+ except Exception as error:
273
+ log(f'liveness {container}', error)
274
+ continue
275
+ for pane in probe:
276
+ known = live.get(int(pane['pane_pid']))
277
+ if known is not None:
278
+ pane['pane_has_child'] = known
279
+ return claude
280
+
281
+
230
282
  def probe_server(info, socket, live):
231
283
  if not info.get('pid'):
232
284
  # Only a docker inspect that ANSWERED "pid 0" says the container is down. No record at all, or one that
@@ -301,7 +353,8 @@ def observe(registry, containers, live, previous=None):
301
353
  # servers and servers with no registered lane. Never start a server to ask whether it exists.
302
354
  for key in discovered_sockets(containers, live):
303
355
  wanted.setdefault(key, [])
304
- for (container, socket), members in wanted.items():
356
+ probed = {}
357
+ for container, socket in wanted:
305
358
  info = containers.get(container) or {}
306
359
  rows_by_session = {}
307
360
  # One server (or one odd row) must not cost the other servers their cycle: whatever goes wrong in here
@@ -309,15 +362,24 @@ def observe(registry, containers, live, previous=None):
309
362
  try:
310
363
  outcome, text = probe_server(info, socket, live)
311
364
  for row in text.splitlines() if outcome == 'ok' else []:
312
- values = dict(zip(FIELDS, row.split('\t')))
365
+ values = dict(zip(FIELDS, row.split('\t', len(FIELDS) - 1)))
313
366
  rows_by_session.setdefault(values['session_name'], []).append(values)
314
367
  except Exception as error:
315
368
  log(f'observe {container}/{socket}', error)
316
369
  outcome, rows_by_session = 'error', {}
317
370
  # A4a: every session is read through its ACTIVE pane and its NEWEST window activity, the same two
318
371
  # readings the Host makes, so `dead`/`active` cannot mean one thing per surface on a multi-window lane.
319
- sessions = {name: session_row(rows) for name, rows in rows_by_session.items()}
320
- activities = {name: newest_activity(rows) for name, rows in rows_by_session.items()}
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()})
374
+ # Task 5: the agent signals of every container, read once per cycle over all of its servers' sessions.
375
+ try:
376
+ claude = agent_signals(containers, {key: sessions for key, (_o, sessions, _a) in probed.items()})
377
+ except Exception as error:
378
+ log('agents', error)
379
+ claude = {}
380
+ for (container, socket), members in wanted.items():
381
+ info = containers.get(container) or {}
382
+ outcome, sessions, activities = probed[(container, socket)]
321
383
  # ... and an UNDECLARED session grouped onto another live session is that session's viewer, not a lane.
322
384
  registered = {lane['tmux']['session'] for lane in members}
323
385
  own_sessions, viewers = split_viewers(sessions, registered)
@@ -330,7 +392,8 @@ def observe(registry, containers, live, previous=None):
330
392
  # The sessions nobody registered stay visible as well while their server does not answer.
331
393
  for workspace_id, entries in (previous.get('unregistered') or {}).items():
332
394
  for entry in entries:
333
- if entry.get('container') == container and (entry.get('tmux') or {}).get('socket') == socket:
395
+ if (entry.get('container') == container and (entry.get('tmux') or {}).get('socket') == socket
396
+ and not any(A.session_key(entry) == A.session_key(lane) for lane in members)):
334
397
  unregistered.setdefault(workspace_id, []).append(entry)
335
398
  lanes[entry['id']] = keep(previous, entry['id'], outcome)
336
399
  if own_sessions:
@@ -380,7 +443,8 @@ def observe(registry, containers, live, previous=None):
380
443
  # RUNBOOK nor the design doc may promise that it does.
381
444
  if not L.TMUX_SESSION.match(name):
382
445
  entry['note'] = 'session name outside the lane declaration grammar'
383
- found = lane_entry(pane, now, activities.get(name), viewers.get(name, []))
446
+ found = lane_entry(pane, now, activities.get(name), viewers.get(name, []),
447
+ AS.merge(pane, claude.get(container, {}), None))
384
448
  except Exception as error:
385
449
  log(f'observe {container}/{socket}/{name}', error)
386
450
  continue
@@ -398,7 +462,9 @@ def observe(registry, containers, live, previous=None):
398
462
  lanes[lane['id']] = {'state': 'absent' if info.get('running') else 'missing'}
399
463
  else:
400
464
  try:
401
- lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []))
465
+ # The hook sidecar (Task 8) is the third argument; until it lands nothing is passed.
466
+ lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []),
467
+ AS.merge(pane, claude.get(container, {}), None))
402
468
  except Exception as error: # a torn row: the session is there, its details are not readable
403
469
  log(f"observe {lane['id']}", error)
404
470
  lanes[lane['id']] = keep(previous, lane['id'], 'error')
@@ -591,7 +657,7 @@ def update_containers(ctx, registry, level):
591
657
  5-minute safety net on a host that is not busy — and at most every CONTAINER_RETRY seconds. NEVER because a
592
658
  probe failed: `docker inspect` (2 spawns, 20 s timeouts) every 3 s is a load generator on a host that is
593
659
  already too slow to answer it."""
594
- names = {lane['container'] for w in registry['workspaces'] for lane in w['lanes'] + w['extras']}
660
+ names = {lane['container'] for w in registry['workspaces'] for lane in w['lanes'] + w['extras'] if lane.get('container')}
595
661
  known = ctx['containers']
596
662
  age = time.time() - ctx['containers_at']
597
663
  unsettled = set(known) != names or any(not pid_alive(c.get('pid')) for c in known.values())
@@ -713,42 +779,69 @@ def pause(level, elapsed):
713
779
  return max(0.5, INTERVAL * SLOWDOWN.get(level, 1) - elapsed)
714
780
 
715
781
 
716
- def _registry_sources(registry):
717
- """All terminals.json paths referenced by the current registry (unique, extant)."""
718
- seen = set()
782
+ def registry_source_paths(registry, containers):
783
+ """(container path, HOST path) for every terminals.json the registry references. The daemon runs on the
784
+ host: a lane's `source` is spelled as the container sees it and only exists under the container's home
785
+ volume (`L.host_path`). A container with no record is skipped — nobody can stat its files anyway.
786
+
787
+ Deduplicated by the HOST path, never by `source` alone: the same spelling in two containers (one repository
788
+ cloned in two workspace boxes) is two distinct files, and each must be stat'ed on its own."""
789
+ seen, out = set(), []
719
790
  for w in (registry or {}).get('workspaces', []):
720
791
  for lane in w.get('lanes', []):
721
792
  src = lane.get('source')
722
- if src and src not in seen:
723
- seen.add(src)
724
- yield src
793
+ if not src:
794
+ continue
795
+ info = (containers or {}).get(lane.get('container') or '') or {}
796
+ try:
797
+ path = L.host_path(info, src)
798
+ except L.LanesError:
799
+ continue
800
+ if path in seen:
801
+ continue
802
+ seen.add(path)
803
+ out.append((src, path))
804
+ return out
805
+
806
+
807
+ def hot_sync(ctx, source_mtimes):
808
+ """One hot-reload check: start ONE `lanes sync` when a referenced terminals.json changed since the last
809
+ check (read through the host volume). Returns True when a sync was started. The new mtime is recorded
810
+ once the sync is started, so one edit costs one sync — not one every rate-limit window forever. Keyed by the
811
+ host path: a `source` spelling is shared by every container that clones the same repository."""
812
+ for _src, path in registry_source_paths(ctx['registry'], ctx.get('containers') or {}):
813
+ key = str(path)
814
+ try:
815
+ mtime = path.stat().st_mtime_ns
816
+ except OSError:
817
+ continue
818
+ if key in source_mtimes and mtime != source_mtimes[key]:
819
+ try:
820
+ proc = subprocess.Popen([str(L.REPO / 'bin' / 'lanes'), 'sync'],
821
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
822
+ except Exception as sync_err:
823
+ log('hot-sync', sync_err)
824
+ return False
825
+ ctx.setdefault('children', []).append(proc)
826
+ source_mtimes[key] = mtime
827
+ return True
828
+ source_mtimes[key] = mtime
829
+ return False
725
830
 
726
831
 
727
832
  def main():
728
833
  ctx = new_context()
729
834
  last_triggered_sync = 0.0
730
- source_mtimes = {} # path -> mtime_ns at last check
835
+ source_mtimes = {} # host path of a terminals.json -> its mtime_ns at last check
731
836
  while True:
732
837
  started = time.time()
733
838
  level, _payload = cycle(ctx)
734
839
  # Hot-reload: check if any terminals.json changed since we last synced.
735
840
  # Rate-limited to at most one triggered sync per 10 s to avoid thrashing.
736
841
  if time.time() - last_triggered_sync > 10 and ctx['registry']:
737
- for src in _registry_sources(ctx['registry']):
738
- try:
739
- mtime = os.stat(src).st_mtime_ns
740
- except OSError:
741
- continue
742
- if src in source_mtimes and mtime != source_mtimes[src]:
743
- try:
744
- import subprocess as _sp
745
- lanes_bin = str(L.REPO / 'bin' / 'lanes')
746
- _sp.Popen([lanes_bin, 'sync'], stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
747
- last_triggered_sync = time.time()
748
- except Exception as sync_err:
749
- log('hot-sync', sync_err)
750
- break
751
- source_mtimes[src] = mtime
842
+ if hot_sync(ctx, source_mtimes):
843
+ last_triggered_sync = time.time()
844
+ ctx['children'] = [p for p in ctx.get('children', []) if p.poll() is None] # reap finished syncs
752
845
  time.sleep(pause(level, time.time() - started))
753
846
 
754
847
 
@@ -0,0 +1,279 @@
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.
5
+ No third-party code; importable with no side effects."""
6
+ import difflib
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ ROLES = ('surface', 'surface-raised', 'line', 'accent-wash', 'accent-line', 'press', 'chip',
12
+ 'ink', 'ink-dim', 'ink-faint', 'accent', 'on-accent', 'on-accent-urgent',
13
+ 'tone-urgent', 'tone-attention', 'tone-working', 'tone-done', 'scrim', 'shadow')
14
+ TOKENS = {
15
+ 'dark': {'surface': '#101318', 'surface-raised': '#191e25', 'line': '#2c333d', 'accent-wash': '#1a2721',
16
+ 'accent-line': '#4b6955', 'press': '#26332c', 'chip': '#ffffff0f', 'ink': '#edf1f5', 'ink-dim': '#9ca7b5',
17
+ 'ink-faint': '#6b7683', 'accent': '#b4e3bf', 'on-accent': '#101318', 'on-accent-urgent': '#7a1a12',
18
+ 'tone-urgent': '#f28b7d', 'tone-attention': '#e5b84a', 'tone-working': '#7fb3f2', 'tone-done': '#6fd39a',
19
+ 'scrim': '#000000a6', 'shadow': '0 -16px 80px #0007'},
20
+ 'light': {'surface': '#f3f5f2', 'surface-raised': '#ffffff', 'line': '#d5ded6', 'accent-wash': '#e5f0e6',
21
+ 'accent-line': '#acc9b4', 'press': '#d8e9db', 'chip': '#edf3ec', 'ink': '#17241b', 'ink-dim': '#56616d',
22
+ 'ink-faint': '#7f8a96', 'accent': '#2f6b45', 'on-accent': '#ffffff', 'on-accent-urgent': '#ffd3cc',
23
+ 'tone-urgent': '#b7322a', 'tone-attention': '#8a5a00', 'tone-working': '#2456a8', 'tone-done': '#1b7142',
24
+ 'scrim': '#1a222b80', 'shadow': '0 -16px 80px #0003'},
25
+ }
26
+ # 16-colour fallback: the user's own palette resolves these. Selection is achromatic (bold) there, dim is bright-black.
27
+ ANSI16 = {'ink': '39', 'ink-dim': '90', 'ink-faint': '90', 'accent': '1;39',
28
+ 'tone-urgent': '31', 'tone-attention': '33', 'tone-working': '34', 'tone-done': '32'}
29
+ # Lane-identity palette for the two places that DEFINE a palette: Zellij's `web_client { theme {} }` (the phone's
30
+ # xterm.js; dark only, see _web_client) and the optional Ghostty file. A Zellij theme's legacy `black…white` keys are
31
+ # NOT one of them (DEF-1413). The TUI itself keeps 38;5;N. Slots 1-4 are the four tones. Every slot >= 4.5:1.
32
+ PALETTE16 = {
33
+ 'dark': ['#7d8896', '#f28b7d', '#6fd39a', '#e5b84a', '#7fb3f2', '#c9a6ff', '#6fd6d0', '#9ca7b5',
34
+ '#8a95a3', '#ff9d96', '#8fe0a3', '#f7cf7f', '#a5cbff', '#dcc6ff', '#9ae6e0', '#edf1f5'],
35
+ 'light': ['#3b4a42', '#b7322a', '#1b7142', '#8a5a00', '#2456a8', '#7a3fb8', '#0f7b7b', '#56616d',
36
+ '#5f6d66', '#c2332a', '#1b7a38', '#8f5a00', '#2860c4', '#8a4dc8', '#0f7272', '#17241b'],
37
+ }
38
+ # key -> (unicode, ascii twin, tone). Position disambiguates: agent glyphs in column 2, stream glyphs in column 33.
39
+ AGENT_GLYPHS = {'needs_approval': ('◆', '!', 'tone-urgent'), 'needs_input': ('◇', '?', 'tone-attention'),
40
+ 'working': ('▸', '>', 'tone-working'), 'done': ('✓', 'v', 'tone-done'),
41
+ 'errored': ('✗', 'X', 'tone-urgent'), 'shell': ('_', '_', 'ink-dim'), 'idle': ('·', '.', 'ink-dim')}
42
+ GLYPHS = {'active': ('●', '*', 'tone-done'), 'quiet': ('○', 'o', 'ink-dim'), 'missing': ('✗', 'x', 'tone-urgent'),
43
+ 'absent': ('–', '-', 'ink-dim'), 'dead': ('!', '!', 'tone-urgent'), 'helper': ('·', '.', 'ink-dim'),
44
+ 'unknown': ('?', '?', 'ink-dim')}
45
+ MARKS = {'cursor': ('▌', '|', 'accent'), 'viewing': ('›', '=', 'accent'), 'viewport-b': ('²', '2', 'accent'),
46
+ 'viewport-c': ('³', '3', 'accent'), 'viewer': ('·', '.', 'ink-dim'), 'truncation': ('…', '~', 'ink'),
47
+ 'close': ('×', 'x', 'tone-urgent'), 'new': ('+', '+', 'tone-done'), 'rule': ('─', '-', 'ink-faint'),
48
+ 'more': ('⋮', ':', 'ink-dim'), 'warning': ('⚠', '!', 'tone-attention'), 'upload': ('↑', '^', 'ink-dim'),
49
+ 'armed': ('↓', 'v', 'tone-done'), 'enter': ('⏎', 'Enter', 'ink-dim'), 'caret': ('█', '_', 'tone-working'),
50
+ 'stale': ('?', '?', 'ink-dim'), 'lead': ('›', '>', 'ink'),
51
+ # Tone-less punctuation (spec §3 completeness rule): every ` · ` and every quote the sidebar draws is a lookup, so
52
+ # LANES_GLYPHS=ascii yields a seven-bit frame. `sep` shares `-` with `rule` on purpose: neither carries state.
53
+ 'sep': ('·', '-', 'ink-dim'), 'quote-open': ('‘', "'", 'ink'), 'quote-close': ('’', "'", 'ink'),
54
+ 'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent')}
55
+ ECONOMY_TAIL = {'starving': ('⚠', '!', 'tone-attention'), 'downgraded': ('↓', 'v', 'tone-attention'), 'paid': ('$', '$', 'ink-dim')}
56
+ MOBILE_AGENT_LABELS = {'needs_approval': 'Needs approval', 'needs_input': 'Needs you', 'working': 'Working',
57
+ 'done': 'Done', 'errored': 'Error', 'shell': 'Shell', 'idle': 'Idle'}
58
+ PAINTED = tuple(ANSI16) # the roles the TUI ever emits
59
+
60
+
61
+ def luminance(hexval):
62
+ r, g, b = (int(hexval[i:i + 2], 16) / 255 for i in (1, 3, 5))
63
+ lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in (r, g, b)]
64
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
65
+
66
+
67
+ def contrast(fg, bg):
68
+ hi, lo = sorted((luminance(fg), luminance(bg)), reverse=True)
69
+ return (hi + 0.05) / (lo + 0.05)
70
+
71
+
72
+ def rgb(hexval):
73
+ return tuple(int(hexval[i:i + 2], 16) for i in (1, 3, 5))
74
+
75
+
76
+ def sgr_table(mode, variant):
77
+ """Complete escape per painted role. `mode` truecolor|16|none; `variant` dark|light. Resolved ONCE per process."""
78
+ table = {'bold': '\033[1m', 'reset': '\033[0m'}
79
+ dim16 = '\033[2m' if os.environ.get('LANES_DIM') == '2' else '\033[90m'
80
+ for role in PAINTED:
81
+ if mode == 'none':
82
+ table[role] = '\033[1m' if role == 'accent' else ''
83
+ elif mode == '16':
84
+ table[role] = dim16 if role in ('ink-dim', 'ink-faint') else f'\033[{ANSI16[role]}m'
85
+ else:
86
+ table[role] = ('\033[1m' if role == 'accent' else '') + '\033[38;2;%d;%d;%dm' % rgb(TOKENS[variant][role])
87
+ return table
88
+
89
+
90
+ def glyph_set(mode):
91
+ """mode unicode|ascii|emoji -> {table name: {key: glyph}}; emoji differs from unicode only in the icon column."""
92
+ index = 1 if mode == 'ascii' else 0
93
+ return {name: {k: v[index] for k, v in table.items()}
94
+ for name, table in (('agent', AGENT_GLYPHS), ('stream', GLYPHS), ('marks', MARKS), ('economy', ECONOMY_TAIL))}
95
+
96
+
97
+ def tone_of(table, key):
98
+ return table[key][2]
99
+
100
+
101
+ # ------------------------------------------------------------------ renderers
102
+ COMPONENTS = ('text_unselected', 'text_selected', 'ribbon_selected', 'ribbon_unselected', 'table_title', 'table_cell_selected',
103
+ 'table_cell_unselected', 'list_selected', 'list_unselected', 'frame_selected', 'frame_highlight',
104
+ 'exit_code_success', 'exit_code_error')
105
+
106
+
107
+ def _component(t, base, background, emphasis):
108
+ e = emphasis
109
+ return f'base "{t[base]}"; background "{t[background]}"; emphasis_0 "{t[e[0]]}"; emphasis_1 "{t[e[1]]}"; emphasis_2 "{t[e[2]]}"; emphasis_3 "{t[e[3]]}";'
110
+
111
+
112
+ def _theme(name, variant):
113
+ t, p = TOKENS[variant], PALETTE16[variant]
114
+ text = ('tone-attention', 'tone-done', 'accent', 'tone-working')
115
+ comps = {
116
+ 'text_unselected': ('ink', 'surface', text), 'text_selected': ('ink', 'accent-wash', text),
117
+ 'ribbon_selected': ('on-accent', 'accent', ('on-accent-urgent', 'on-accent', 'on-accent', 'on-accent')),
118
+ 'ribbon_unselected': ('ink-dim', 'surface-raised', ('tone-urgent', 'ink', 'tone-attention', 'tone-working')),
119
+ 'table_title': ('accent', 'surface', text), 'table_cell_selected': ('ink', 'accent-wash', text),
120
+ 'table_cell_unselected': ('ink', 'surface', text), 'list_selected': ('ink', 'accent-wash', text),
121
+ 'list_unselected': ('ink', 'surface', text),
122
+ 'frame_selected': ('accent', 'surface', ('tone-attention', 'tone-done', 'tone-working', 'tone-urgent')),
123
+ 'frame_highlight': ('tone-attention', 'surface', ('tone-urgent', 'tone-done', 'accent', 'tone-working')),
124
+ 'exit_code_success': ('tone-done', 'surface', ('tone-done',) * 4),
125
+ 'exit_code_error': ('tone-urgent', 'surface', ('tone-urgent',) * 4),
126
+ }
127
+ lines = [f' {name} {{']
128
+ for comp in COMPONENTS:
129
+ base, background, emphasis = comps[comp]
130
+ lines.append(f' {comp} {{ {_component(t, base, background, emphasis)} }}')
131
+ players = [t['tone-working'], t['tone-attention'], t['tone-done'], t['tone-urgent'], t['accent'], p[5], p[6], p[11], p[10], p[9]]
132
+ lines.append(' multiplayer_user_colors { ' + ' '.join(f'player_{i + 1} "{c}";' for i, c in enumerate(players)) + ' }')
133
+ # No legacy `fg bg black … orange` line: in a component-format theme Zellij 0.45.1 neither reads nor validates
134
+ # those keys, and none of a theme's colours reach the browser's xterm.js palette (DEF-1413; _web_client does).
135
+ lines.append(' }')
136
+ return lines
137
+
138
+
139
+ ANSI_NAMES = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
140
+
141
+
142
+ def _web_client():
143
+ """The phone's terminal palette. Zellij 0.45.1 sends `web_client.theme` to every browser as `SetConfig.theme`,
144
+ which the web client assigns to xterm.js `options.theme` (zellij-client `control_message.rs` / `app.js`
145
+ `terminalConfigOptions`). Without this block only background, foreground and selection fall back to a theme's
146
+ components and the sixteen ANSI slots stay xterm.js's built-in defaults. The block is server-wide and holds ONE
147
+ palette: the dark variant, the phone's default. It reaches the browser in the HTTP boot config at connect (the
148
+ xterm.js `Terminal` constructor) and in a `SetConfig` on every config-file reload, which the client assigns
149
+ wholesale before repainting `<body>`/`#terminal` inline, so the phone's light toggle needs a client-side
150
+ `term.options.theme` override applied on attach and re-applied after each `SetConfig`, plus a CSS background rule,
151
+ not set once (spec §6.3, Task 28; DEF-1413). Zellij validates every colour here (an unparseable one fails
152
+ `setup --check`), so `test_tokens` can prove the block is read."""
153
+ t, p = TOKENS['dark'], PALETTE16['dark']
154
+ lines = ['web_client {', ' font "monospace"', ' theme {',
155
+ f' background "{t["surface"]}"', f' foreground "{t["ink"]}"']
156
+ lines += [f' {n} "{p[i]}"' for i, n in enumerate(ANSI_NAMES)]
157
+ lines += [f' bright_{n} "{p[i + 8]}"' for i, n in enumerate(ANSI_NAMES)]
158
+ lines += [f' selection_background "{t["accent-wash"]}"', f' selection_foreground "{t["ink"]}"']
159
+ lines += [' }', '}']
160
+ return lines
161
+
162
+
163
+ def render_kdl():
164
+ lines = ['// BEGIN lanes tokens (generated by libexec/tokens.py --kdl; do not edit by hand)', 'themes {']
165
+ lines += _theme('lanes-dark', 'dark') + _theme('lanes-light', 'light')
166
+ lines += ['}', 'theme_dark "lanes-dark"', 'theme_light "lanes-light"']
167
+ lines += _web_client() + ['// END lanes tokens']
168
+ return '\n'.join(lines) + '\n'
169
+
170
+
171
+ ALIASES = {'bg': 'surface', 'panel': 'surface-raised', 'border': 'line', 'text': 'ink', 'muted': 'ink-dim',
172
+ 'current': 'accent-wash', 'current-border': 'accent-line'}
173
+ ZJ = {'bg': 'surface', 'surface': 'surface-raised', 'border': 'line', 'text': 'ink', 'text-dim': 'ink-dim',
174
+ 'green': 'tone-done', 'blue': 'tone-working', 'red': 'tone-urgent', 'yellow': 'tone-attention'}
175
+
176
+
177
+ def render_css():
178
+ """Values per variant; every `var()` indirection (legacy aliases, upstream `--zj-*`) ONCE. All three selectors
179
+ match the same <html> element and a custom property's var() resolves against that element's winning value, so a
180
+ single alias follows the light override exactly like a per-variant copy did — 289 B less of the phone's mobile.css
181
+ growth allowance (spec §8; DEF-1414)."""
182
+ out = ['/* BEGIN lanes tokens (generated by libexec/tokens.py --css; do not edit by hand) */']
183
+ for selector, variant, scheme in (('html.lanes-mobile', 'dark', 'dark'), ('html.lanes-mobile:root[data-zj-theme="light"]', 'light', 'light')):
184
+ out.append(selector + ' {')
185
+ out += [f' --lanes-{role}: {TOKENS[variant][role]};' for role in ROLES]
186
+ out.append(f' color-scheme: {scheme};')
187
+ out.append('}')
188
+ out.append('html.lanes-mobile:root {')
189
+ out += [f' --lanes-{old}: var(--lanes-{new});' for old, new in ALIASES.items()]
190
+ out += [f' --zj-{k}: var(--lanes-{v});' for k, v in ZJ.items()]
191
+ out.append('}')
192
+ out.append('/* END lanes tokens */')
193
+ return '\n'.join(out) + '\n'
194
+
195
+
196
+ def render_sh():
197
+ return ('# BEGIN lanes tokens (generated by libexec/tokens.py --sh; do not edit by hand)\n'
198
+ "LZ_BOLD='\\033[1m'\nLZ_DIM='\\033[90m'\n"
199
+ f"LZ_URGENT='\\033[{ANSI16['tone-urgent']}m'\nLZ_ATTENTION='\\033[{ANSI16['tone-attention']}m'\n"
200
+ f"LZ_WORKING='\\033[{ANSI16['tone-working']}m'\nLZ_DONE='\\033[{ANSI16['tone-done']}m'\nLZ_RESET='\\033[0m'\n"
201
+ '# END lanes tokens\n')
202
+
203
+
204
+ def render_md():
205
+ rows = ['<!-- BEGIN lanes tokens (generated by libexec/tokens.py --md; do not edit by hand) -->',
206
+ '| Role | Dark | Light | ANSI-16 | CSS variable |', '|---|---|---|---|---|']
207
+ rows += [f'| {r} | `{TOKENS["dark"][r]}` | `{TOKENS["light"][r]}` | `{ANSI16.get(r, "—")}` | `--lanes-{r}` |' for r in ROLES]
208
+ rows += ['', '| Glyph | Codepoint | ASCII | Tone | Meaning |', '|---|---|---|---|---|']
209
+ for name, table in (('agent', AGENT_GLYPHS), ('stream', GLYPHS), ('mark', MARKS), ('economy', ECONOMY_TAIL)):
210
+ for key, (glyph, ascii_twin, tone) in table.items():
211
+ cp = ' '.join(f'U+{ord(c):04X}' for c in glyph)
212
+ cell = ascii_twin.replace('|', '\\|') # a bare pipe splits a GFM table cell even inside backticks
213
+ rows.append(f'| `{glyph}` | {cp} | `{cell}` | {tone} | {name} `{key}` |')
214
+ rows.append('<!-- END lanes tokens -->')
215
+ return '\n'.join(rows) + '\n'
216
+
217
+
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'}
220
+ 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 -->')}
222
+
223
+
224
+ def render(kind):
225
+ return RENDERERS[kind]()
226
+
227
+
228
+ def _splice(text, kind):
229
+ """Replace the marker block in `text` with the renderer's output; append when no block exists yet."""
230
+ begin, end = MARKERS[kind]
231
+ block = render(kind)
232
+ start = text.find(begin)
233
+ if start < 0:
234
+ return text + ('' if text.endswith('\n') or not text else '\n') + block
235
+ stop = text.find(end, start)
236
+ stop = text.find('\n', stop) + 1 if stop >= 0 else len(text)
237
+ return text[:start] + block + text[stop:]
238
+
239
+
240
+ def check(repo):
241
+ """Paths (relative to `repo`) whose committed marker block is not what the renderer emits. A target that is
242
+ missing or has no block yet is reported too: an unadopted surface is drift, not a skip."""
243
+ drifted = []
244
+ for kind, rel in TARGETS.items():
245
+ path = Path(repo) / rel
246
+ try:
247
+ text = path.read_text()
248
+ except OSError:
249
+ drifted.append(rel)
250
+ continue
251
+ if _splice(text, kind) != text:
252
+ drifted.append(rel)
253
+ return drifted
254
+
255
+
256
+ def main(argv):
257
+ repo = Path(__file__).resolve().parents[1]
258
+ if argv[:1] == ['--check']:
259
+ drifted = check(repo)
260
+ for rel in drifted:
261
+ kind = next(k for k, r in TARGETS.items() if r == rel)
262
+ current = (repo / rel).read_text() if (repo / rel).exists() else ''
263
+ sys.stdout.writelines(difflib.unified_diff(current.splitlines(True), _splice(current, kind).splitlines(True), rel, rel + ' (generated)'))
264
+ return 1 if drifted else 0
265
+ if argv[:1] == ['--write']:
266
+ for kind, rel in TARGETS.items():
267
+ path = repo / rel
268
+ current = path.read_text() if path.exists() else ''
269
+ path.write_text(_splice(current, kind))
270
+ return 0
271
+ kind = argv[0].lstrip('-') if argv else ''
272
+ if kind not in RENDERERS:
273
+ sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--check|--write')
274
+ sys.stdout.write(render(kind))
275
+ return 0
276
+
277
+
278
+ if __name__ == '__main__':
279
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,11 @@
1
+ # Lanes 16-colour SGR literals for view.sh status screens. Sourced, never executed.
2
+ # The block below is generated: edit libexec/tokens.py and run `python3 libexec/tokens.py --write`.
3
+ # BEGIN lanes tokens (generated by libexec/tokens.py --sh; do not edit by hand)
4
+ LZ_BOLD='\033[1m'
5
+ LZ_DIM='\033[90m'
6
+ LZ_URGENT='\033[31m'
7
+ LZ_ATTENTION='\033[33m'
8
+ LZ_WORKING='\033[34m'
9
+ LZ_DONE='\033[32m'
10
+ LZ_RESET='\033[0m'
11
+ # END lanes tokens
@@ -103,7 +103,7 @@ while :; do
103
103
  # A background-created zellij session has no real size yet: idle until a client has attached once.
104
104
  while [ ! -e "$state/clients/$device.ready" ] || { [ "$mobile" = 1 ] && [ ! -e "$dir/$ws.$slot.shown" ]; }; do
105
105
  status_screen "$ws" 'waiting for the first client…' ''
106
- run sleep 2 || :
106
+ run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 2 || :
107
107
  done
108
108
 
109
109
  # zellij sizes a tab only once some client has looked at it; until then the pane has a made-up size
@@ -115,7 +115,7 @@ while :; do
115
115
  [ "$mobile" != 1 ] || { min_cols=20; min_rows=4; }
116
116
  if [ "${cols:-0}" -ge "$min_cols" ] 2>/dev/null && [ "${rows:-0}" -ge "$min_rows" ] 2>/dev/null; then break; fi
117
117
  status_screen "$ws" 'waiting to be shown…' ''
118
- sizing=1; run sleep 3 || :; sizing=0
118
+ sizing=1; run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 3 || :; sizing=0
119
119
  done
120
120
 
121
121
  lane=$(head -n 1 "$target_file" 2>/dev/null || :)
@@ -126,13 +126,13 @@ while :; do
126
126
  if [ "$slot" = main ]; then hint='Pick one in the sidebar (Ctrl-Alt-/ to find a lane).'
127
127
  else hint='In the sidebar, move to a lane and press 2 (or 3) to show it here.'; fi
128
128
  status_screen "$ws" 'No lane selected.' "$hint"
129
- switching=0; run sleep 5 || :; continue ;;
129
+ switching=0; run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 5 --preserve-screen || :; continue ;;
130
130
  esac
131
131
  resolve=$state/resolve/$lane
132
132
  if [ ! -r "$resolve" ]; then resolve=$state/resolve.dynamic/$lane; fi
133
133
  if [ ! -r "$resolve" ]; then
134
134
  status_screen "$lane" 'This lane is no longer in the registry.' 'Pick another lane in the sidebar.'
135
- switching=0; run sleep 5 || :; continue
135
+ switching=0; run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait 5 --preserve-screen || :; continue
136
136
  fi
137
137
  { IFS= read -r holder; IFS= read -r f2; IFS= read -r f3; IFS= read -r f4; IFS= read -r title; } < "$resolve"
138
138
 
@@ -140,9 +140,10 @@ while :; do
140
140
  switching=0
141
141
  started=$(date +%s)
142
142
  case "$holder" in
143
- tmux) run "$repo/holders/tmux.sh" "$f2" "$f3" "$f4" || : ;;
144
- zmx) run "$repo/holders/zmx.sh" "$f2" "$f3" "$f4" || : ;;
145
- exec) run "$repo/holders/exec.sh" "$f2" "$f3" "$f4" || : ;;
143
+ tmux) run python3 "$repo/libexec/connection.py" --title "${title:-$lane}" -- "$repo/holders/tmux.sh" "$f2" "$f3" "$f4" || : ;;
144
+ ssh) run "$repo/holders/ssh.sh" "$f2" "$f3" "$f4" || : ;;
145
+ zmx) run python3 "$repo/libexec/connection.py" --title "${title:-$lane}" -- "$repo/holders/zmx.sh" "$f2" "$f3" "$f4" || : ;;
146
+ exec) run python3 "$repo/libexec/connection.py" --title "${title:-$lane}" -- "$repo/holders/exec.sh" "$f2" "$f3" "$f4" || : ;;
146
147
  *) rc=64 ;;
147
148
  esac
148
149
  if [ "$switching" = 1 ]; then delay=1; continue; fi
@@ -173,7 +174,7 @@ while :; do
173
174
  health_hint "$ended"
174
175
  status_screen "${title:-$lane}" "${reason:-viewer detached (exit $rc)}${untouched}; retrying in ${delay}s.${host_hint:+
175
176
  $host_hint}" "$holder · $f2 · $f3 · $f4" "$lead"
176
- run sleep "$delay" || :
177
+ run python3 "$repo/libexec/connection.py" --title "${title:-$ws}" --wait "$delay" --preserve-screen || :
177
178
  delay=$((delay * 2))
178
179
  if [ "$delay" -gt 15 ]; then delay=15; fi
179
180
  done
@@ -5,6 +5,21 @@ The Lanes shell enhances the pinned Zellij 0.45.1 web client on phones. It uses
5
5
  There is no separate auth system or command API. Workspace names are rendered as text, and navigation
6
6
  uses Zellij's authenticated `FocusPane` messages. Desktop browsers keep the upstream interface.
7
7
 
8
+ `laneShellMode` (in `mobile.js`) chooses the mode once at load and records it on `<html data-lanes-shell>`:
9
+ phones get this shell; tablets (other touch screens) get the same shell with the dock in the top bar and the
10
+ workspace sheet as a left panel, and set `zellij:mobile-ui` to `on` when it is unset (iPad Safari reports a desktop
11
+ user agent). Desktop browsers (fine pointer) get only a **Lanes** chip and an overlay bar with workspace and lane
12
+ pickers (`laneBarModel`, from the `MobileState` sent to every web client). The laptop/tablet code and styles live in
13
+ `browser.js`/`browser.css`, which `mobile.js` appends only on a tablet or desktop (a phone never downloads them), so
14
+ the phone's `mobile.js`/`mobile.css` budget (spec §8) stays with the phone. The chip cancels `mousedown` and
15
+ refocuses the terminal, so the keys stay the terminal's. On a tablet, `browser.js` also undoes the client's per-load single-pane start once
16
+ (`laneFullRender`, the client's own `SetMobileRenderPreferences {single_pane: false, fit: true}`), so a desktop
17
+ view's sidebar shows beside its lanes. `tests/web/activation.test.mjs` (`node --test`) pins the activation matrix,
18
+ the picker model and that render preference; `tests/unit/test_web_view.py` pins the size budgets and runs that
19
+ suite when node is installed. After an upgrade,
20
+ check that `__zjMobileUi`, `__zjSendControl`, `__zjSoftKbdCapture`, `__zjSyncInactiveCursorStyle` and `window.term`
21
+ still exist in the served `app.js`.
22
+
8
23
  The phone keyboard opens through a native tap on an input field. The bottom **Type here** field contains
9
24
  Zellij's original capture input in its closed shadow root, preserving its keyboard/IME handling. The client
10
25
  blocks programmatic focus on text editors and keeps `__zjSoftKbdEnabled` false, so upstream touch handlers,