@vibelord/lanes 1.1.1 → 1.2.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 (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +45 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +618 -13
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +603 -72
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +777 -136
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +83 -24
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +44 -32
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
@@ -6,7 +6,8 @@ dead sockets are never probed), run one read-only `list-panes -a` per live serve
6
6
  at most PROBE_WORKERS at once, the whole round cut at PROBE_TIMEOUT + PROBE_GRACE seconds, the servers the last
7
7
  round cut started first — and publish ~/.local/state/lanes/status.json
8
8
  atomically, plus the one-line ~/.local/state/lanes/health. The sidebar, `lanes status`, `lanes doctor` and the
9
- future Throttle web harness all read those files.
9
+ future Throttle web harness all read those files. Each workspace container that has the lane contract also gets its
10
+ own slice as ~/.lz/status.json (LNS-32, `write_container_status`), for readers inside it.
10
11
 
11
12
  It never INVENTS a state. "No answer" (a tmux, cgroup, docker or zmx read that timed out or failed on an overloaded
12
13
  host) is not an observation: the lane keeps its last published state, marked stale, for at most STALE_MAX seconds
@@ -39,6 +40,7 @@ PROBE_WORKERS = 8
39
40
  PROBE_GRACE = 2
40
41
  CONTAINER_REFRESH = 300 # docker is asked again this often as a safety net; liveness itself comes from /proc/<pid>
41
42
  CONTAINER_RETRY = 30 # ... and at most this often while a container is down or docker did not answer
43
+ HOT_SYNC_EVERY_S = 10 # hot_sync starts at most one `lanes sync` per this many seconds (the sidebar's PENDING_S)
42
44
  ACTIVE_WINDOW = 20 # seconds since last output for a lane to count as active
43
45
  STALE_MAX = 300 # a lane nobody could observe for this long is published as 'unknown', no longer as last seen
44
46
  FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_current_command',
@@ -236,8 +238,9 @@ def keep(previous, lane_id, outcome):
236
238
  def lane_entry(pane, now, activity=None, viewing=(), agent=None):
237
239
  """One live session's published row. `activity` is the NEWEST `window_activity` across the session's panes
238
240
  (see `newest_activity`) and `viewing` the rows of the sessions that VIEW it (see `split_viewers`); both
239
- default to "this pane alone", which is what a caller with a single row has. `agent` is `agent_state.merge`'s
240
- verdict for the session's pane: published as the additive `agent` key, absent when no signal names an agent."""
241
+ default to "this pane alone", which is what a caller with a single row has. `agent` is the lane's settled
242
+ agent{} (`settled`: `agent_state.merge`'s verdict debounced by `agent_state.settle`): published as the additive
243
+ `agent` key, absent when no signal names an agent."""
241
244
  if activity is None:
242
245
  try:
243
246
  activity = int(pane['window_activity'])
@@ -267,6 +270,15 @@ def lane_entry(pane, now, activity=None, viewing=(), agent=None):
267
270
  return entry
268
271
 
269
272
 
273
+ def settled(previous, lane_id, now, raw):
274
+ """What a live lane publishes as its agent{} this cycle: `merge`'s verdict `raw` debounced against the agent{} the
275
+ PREVIOUS payload published for the same lane id (`agent_state.settle`, cockpit v3 S1). `previous` is the last
276
+ published payload — `new_context()` seeds it from status.json, so a restarted daemon keeps every lane's
277
+ `stable_since`. Called at both merge sites in `observe`: declared lanes and undeclared sessions alike."""
278
+ old = ((previous.get('lanes') or {}).get(lane_id) or {}) if isinstance(previous, dict) else {}
279
+ return AS.settle(old.get('agent') if isinstance(old, dict) else None, raw, now)
280
+
281
+
270
282
  def agent_signals(containers, sessions_by_server):
271
283
  """The per-container inputs `agent_state.merge` needs, read ONCE per container per cycle through the
272
284
  container's root as the host sees it (`/proc/<init pid>/root`), so liveness is judged in the container's pid
@@ -546,6 +558,11 @@ def observe(registry, containers, live, previous=None, report=None):
546
558
  try:
547
559
  pane = own_sessions[name]
548
560
  old_ws, old = prior.get((container, socket, name), (None, None))
561
+ if old is None:
562
+ # Listed for the first time: maybe a session `registrations.unregister` just released. It had
563
+ # no listing to carry its id over (it was a lane), and its directory may name another board
564
+ # now — the hand-back keeps the board and id it was registered under (S7 fix, DEF-1826).
565
+ old_ws, old = A.handed_back(container, socket, name, pane)
549
566
  board_ids = {w['id'] for w in registry['workspaces']}
550
567
  workspace_id = A.workspace_for(registry, container, socket, pane['pane_current_path'],
551
568
  owners.get((container, socket), set()),
@@ -586,8 +603,9 @@ def observe(registry, containers, live, previous=None, report=None):
586
603
  if not L.TMUX_SESSION.match(name):
587
604
  entry['note'] = 'session name outside the lane declaration grammar'
588
605
  found = lane_entry(pane, now, activities.get(name), viewers.get(name, []),
589
- AS.merge(pane, claude.get(container, {}),
590
- sidecars.get(container, {}).get(entry['id'])))
606
+ settled(previous, entry['id'], now,
607
+ AS.merge(pane, claude.get(container, {}),
608
+ sidecars.get(container, {}).get(entry['id']))))
591
609
  except Exception as error:
592
610
  log(f'observe {container}/{socket}/{name}', error)
593
611
  continue
@@ -606,8 +624,9 @@ def observe(registry, containers, live, previous=None, report=None):
606
624
  else:
607
625
  try:
608
626
  lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []),
609
- AS.merge(pane, claude.get(container, {}),
610
- sidecars.get(container, {}).get(lane['id'])))
627
+ settled(previous, lane['id'], now,
628
+ AS.merge(pane, claude.get(container, {}),
629
+ sidecars.get(container, {}).get(lane['id']))))
611
630
  except Exception as error: # a torn row: the session is there, its details are not readable
612
631
  log(f"observe {lane['id']}", error)
613
632
  lanes[lane['id']] = keep(previous, lane['id'], 'error')
@@ -909,7 +928,110 @@ def new_context():
909
928
  for entry in (seed.get('lanes') or {}).values():
910
929
  entry.setdefault('stale_since', int(written))
911
930
  return {'registry': None, 'stamp': 0, 'containers': {}, 'containers_at': 0.0,
912
- 'payload': seed, 'published': None, 'status_at': written, 'cut': 0, 'unwalkable': []}
931
+ 'payload': seed, 'published': None, 'status_at': written, 'cut': 0, 'unwalkable': [],
932
+ 'container_status': {}, 'container_status_errors': {}}
933
+
934
+
935
+ # ------------------------------------------- the in-container projection (LNS-32)
936
+ # `~/.lz/status.json` in each workspace container: that container's slice of the payload, for the in-container Agent
937
+ # API (`lane-mcp`, plan Track M), which cannot read the host's status.json. v1 = {v, at, lanes{<id>: {state, agent?,
938
+ # attached?, viewers?, activity?, size?, stale?}}, unregistered[{id, name, workspace, boards, tmux, identity}]}.
939
+ CONTAINER_STATUS_FIELDS = ('state', 'agent', 'attached', 'viewers', 'activity', 'size', 'stale')
940
+ CONTAINER_STATUS_DIR = tuple(L.CONTAINER_HOME.strip('/').split('/')) + ('.lz',)
941
+ CONTAINER_STATUS_FILE = 'status.json'
942
+
943
+
944
+ def container_status(registry, payload):
945
+ """{container: {'lanes': …, 'unregistered': […]}} — what each workspace container may read about ITS OWN lanes
946
+ (pure). `lanes` holds every lane the registry places in the container (declared and registered: the ids its
947
+ `~/.lz/registry.json` holds) and every unregistered session statusd found there, each reduced to the fields a
948
+ reader in the container needs; a row a cycle has not observed yet is `unknown`, never invented. The host-side
949
+ details (cwd, pid, tty, command, where an unanswered probe failed) stay on the host, and `stale` is carried so a
950
+ kept row is never read as a fresh one. A session listed under several boards is ONE `unregistered` row naming
951
+ them all in `boards`. zmx lanes are not projected: the container's own `lane ls`/`lane status` read them from
952
+ zmx and lane-run directly, and their `agent` is lane-run's phase record, not an `agent_state` verdict."""
953
+ rows = (payload or {}).get('lanes') or {}
954
+ ids = {}
955
+ for workspace in (registry or {}).get('workspaces') or []:
956
+ for lane in workspace.get('lanes') or []:
957
+ if lane.get('container') and lane.get('id'):
958
+ ids.setdefault(lane['container'], []).append(lane['id'])
959
+ ghosts = {}
960
+ for board, entries in sorted(((payload or {}).get('unregistered') or {}).items()):
961
+ for entry in entries or []:
962
+ if not isinstance(entry, dict) or not entry.get('container') or not entry.get('id'):
963
+ continue
964
+ ghost = ghosts.setdefault(entry['container'], {}).setdefault(entry['id'], {
965
+ 'id': entry['id'], 'name': entry.get('name'), 'workspace': entry.get('workspace') or board,
966
+ 'boards': [], 'tmux': entry.get('tmux'), 'identity': entry.get('identity')})
967
+ if board not in ghost['boards']:
968
+ ghost['boards'].append(board)
969
+ out = {}
970
+ for container in sorted(set(ids) | set(ghosts)):
971
+ found = ghosts.get(container, {})
972
+ lanes = {}
973
+ for lane_id in ids.get(container, []) + sorted(found):
974
+ row = rows.get(lane_id) if isinstance(rows.get(lane_id), dict) else {}
975
+ lanes[lane_id] = {key: row[key] for key in CONTAINER_STATUS_FIELDS if key in row}
976
+ lanes[lane_id].setdefault('state', 'unknown')
977
+ out[container] = {'lanes': lanes, 'unregistered': [found[lane_id] for lane_id in sorted(found)]}
978
+ return out
979
+
980
+
981
+ def write_container_status(ctx, payload, now=None):
982
+ """Write `container_status` into each running workspace container that has the lane contract (`~/.lz/bin/lane`,
983
+ the rule `lanes_lib.export_container_registries` follows) -> the containers whose file was written.
984
+
985
+ At most one small write per container per cycle, and none while that container's slice is unchanged: `at` is
986
+ the time of the last write, renewed on a change or once HEARTBEAT has passed, so an unchanged slice renders the
987
+ same bytes and `atomic_write_if_changed_at` skips it, while a reader can still tell a live statusd from a dead
988
+ one by the age of `at`. Written through /proc/<init pid>/root like every other read of the container here, into
989
+ a directory the container owns: `lanes_lib.open_real_dir` refuses a symlinked component and the write replaces
990
+ whatever is at the name without following or reading it (a planted link or FIFO). A container without the
991
+ contract is skipped silently; any other failure is logged once per distinct error and costs only that container."""
992
+ now = time.time() if now is None else now
993
+ last, errors = ctx.setdefault('container_status', {}), ctx.setdefault('container_status_errors', {})
994
+ written = []
995
+ slices = container_status(ctx.get('registry'), payload)
996
+ for gone in set(last) - set(slices):
997
+ del last[gone]
998
+ for container, content in slices.items():
999
+ info = (ctx.get('containers') or {}).get(container) or {}
1000
+ if not info.get('pid') or not info.get('running'):
1001
+ continue
1002
+ before = last.get(container)
1003
+ fresh = before and before['content'] == content and now - before['at'] <= HEARTBEAT
1004
+ at = before['at'] if fresh else now
1005
+ text = json.dumps({'v': 1, 'at': at, **content}, ensure_ascii=False, indent=1) + '\n'
1006
+ try:
1007
+ fd = L.open_real_dir(Path(L.proc_root()) / str(info['pid']) / 'root', CONTAINER_STATUS_DIR)
1008
+ except FileNotFoundError:
1009
+ continue # no ~/.lz: the lane contract is not installed there
1010
+ except (OSError, L.LanesError) as error:
1011
+ _container_status_error(errors, container, error)
1012
+ continue
1013
+ try:
1014
+ try:
1015
+ os.stat('bin/lane', dir_fd=fd)
1016
+ except FileNotFoundError:
1017
+ continue # a ~/.lz without `lane`: not installed either
1018
+ if L.atomic_write_if_changed_at(fd, CONTAINER_STATUS_FILE, text, mode=0o644):
1019
+ written.append(container)
1020
+ last[container] = {'content': content, 'at': at}
1021
+ errors.pop(container, None)
1022
+ except (OSError, L.LanesError) as error:
1023
+ _container_status_error(errors, container, error)
1024
+ finally:
1025
+ os.close(fd)
1026
+ return written
1027
+
1028
+
1029
+ def _container_status_error(errors, container, error):
1030
+ """Log a container's projection failure once per distinct error, not every INTERVAL."""
1031
+ message = f'{type(error).__name__}: {error}'
1032
+ if errors.get(container) != message:
1033
+ errors[container] = message
1034
+ log(f'container status {container}', error)
913
1035
 
914
1036
 
915
1037
  def cycle(ctx, reconcile=True):
@@ -938,6 +1060,10 @@ def cycle(ctx, reconcile=True):
938
1060
  ctx['published'], ctx['status_at'] = payload, written
939
1061
  except Exception as error:
940
1062
  log('status.json', error)
1063
+ try: # LNS-32: each container's own slice, into its ~/.lz (one small write per container at most)
1064
+ write_container_status(ctx, payload)
1065
+ except Exception as error:
1066
+ log('container status', error)
941
1067
  try: # "servers alive, sockets deleted" as a flag file: view.sh, the sidebar and doctor read it without parsing
942
1068
  lost = sorted(key for key, server in ((payload or {}).get('servers') or {}).items() if server.get('outcome') == 'nosocket')
943
1069
  flag = L.STATE / 'flags' / 'sockets-lost'
@@ -1014,6 +1140,7 @@ def hot_sync(ctx, source_mtimes):
1014
1140
 
1015
1141
 
1016
1142
  def main():
1143
+ L.record_tree('statusd') # W9 U0: the tree this daemon runs from (`lanes version`, doctor); --once never records
1017
1144
  ctx = new_context()
1018
1145
  last_triggered_sync = 0.0
1019
1146
  source_mtimes = {} # host path of a terminals.json -> its mtime_ns at last check
@@ -1021,8 +1148,8 @@ def main():
1021
1148
  started = time.time()
1022
1149
  level, _payload = cycle(ctx)
1023
1150
  # Hot-reload: check if any terminals.json changed since we last synced.
1024
- # Rate-limited to at most one triggered sync per 10 s to avoid thrashing.
1025
- if time.time() - last_triggered_sync > 10 and ctx['registry']:
1151
+ # Rate-limited to at most one triggered sync per HOT_SYNC_EVERY_S to avoid thrashing.
1152
+ if time.time() - last_triggered_sync > HOT_SYNC_EVERY_S and ctx['registry']:
1026
1153
  if hot_sync(ctx, source_mtimes):
1027
1154
  last_triggered_sync = time.time()
1028
1155
  ctx['children'] = [p for p in ctx.get('children', []) if p.poll() is None] # reap finished syncs
@@ -39,6 +39,9 @@ import tempfile
39
39
  import time
40
40
  from pathlib import Path
41
41
 
42
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
43
+ import lanes_lib as L # noqa: E402
44
+
42
45
  UNIT = 'lanes-studio-host.service'
43
46
  ENROL_SERVICE = 'lanes-studio-host-enrol.service'
44
47
  ENROL_TIMER = 'lanes-studio-host-enrol.timer'
@@ -1040,7 +1043,10 @@ def _enrol(layout, options, plan, record, receipt, key_file):
1040
1043
 
1041
1044
 
1042
1045
  def _units(layout, plan):
1043
- """Step 6: render every lanes unit for this checkout (services.py), then enable the Host and its retry timer."""
1046
+ """Step 6: render every lanes unit (services.py), then enable the Host and its retry timer. A re-render, never an
1047
+ install: services.register(install=False) renders the release `current` names and moves no link, so this step run
1048
+ from a tree an update has moved past cannot put the old release back (DEF-1835); it links `current` to this tree
1049
+ only where it names none yet."""
1044
1050
  repo = Path(__file__).resolve().parent.parent
1045
1051
 
1046
1052
  def render():
@@ -1169,6 +1175,7 @@ def enable(argv, layout=None):
1169
1175
  backup = _ensure_config(layout, options, plan)
1170
1176
  if not options['dry_run']:
1171
1177
  _remember_paths(layout, options, record)
1178
+ L.record_tree('studio_host', layout.state) # W9 U0: the enrol unit's tree, past every static refusal
1172
1179
  replaced = [] # set once the hand-built unit is saved, disabled and stopped
1173
1180
 
1174
1181
  def give_back():
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env python3
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.
2
+ """The Lanes design tokens: ONE table, rendered seven ways. The sidebar imports `sgr_table` (truecolor, 256, 16 or
3
+ none); `--kdl`, `--css`, `--sh`, `--md`, `--js` and `--ghostty-dark|--ghostty-light` write the Zellij theme, the phone
4
+ CSS, the viewer's shell colour sets, the docs tables, the phone's terminal palette and the optional Ghostty palette
5
+ files between markers; `--check` fails when any committed block differs. Spec:
6
+ docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md; palette v2 + the 256-colour set: cockpit v3 P1 (LNS-69).
6
7
  No third-party code; importable with no side effects."""
7
8
  import difflib
8
9
  import os
@@ -12,30 +13,54 @@ from pathlib import Path
12
13
  ROLES = ('surface', 'surface-raised', 'line', 'accent-wash', 'accent-line', 'press', 'chip',
13
14
  'ink', 'ink-dim', 'ink-faint', 'accent', 'on-accent', 'on-accent-urgent',
14
15
  'tone-urgent', 'tone-attention', 'tone-working', 'tone-done', 'scrim', 'shadow')
16
+ # Palette v2 (cockpit v3 P1, 2026-10-06): vivid, high-contrast roles in both variants. v1 had darkened the light tones
17
+ # until they passed (amber #8a5a00 read olive-brown). Surfaces are v1's but for the dark accent-wash, the cursor row.
15
18
  TOKENS = {
16
- 'dark': {'surface': '#101318', 'surface-raised': '#191e25', 'line': '#2c333d', 'accent-wash': '#1a2721',
17
- 'accent-line': '#4b6955', 'press': '#26332c', 'chip': '#ffffff0f', 'ink': '#edf1f5', 'ink-dim': '#9ca7b5',
18
- 'ink-faint': '#6b7683', 'accent': '#b4e3bf', 'on-accent': '#101318', 'on-accent-urgent': '#7a1a12',
19
- 'tone-urgent': '#f28b7d', 'tone-attention': '#e5b84a', 'tone-working': '#7fb3f2', 'tone-done': '#6fd39a',
19
+ 'dark': {'surface': '#101318', 'surface-raised': '#191e25', 'line': '#2c333d', 'accent-wash': '#1c3326',
20
+ 'accent-line': '#4b6955', 'press': '#26332c', 'chip': '#ffffff0f', 'ink': '#f2f5f8', 'ink-dim': '#a6b1be',
21
+ 'ink-faint': '#6f7a87', 'accent': '#8fe3a6', 'on-accent': '#101318', 'on-accent-urgent': '#7a1a12',
22
+ 'tone-urgent': '#ff7b6e', 'tone-attention': '#ffc233', 'tone-working': '#6fb6ff', 'tone-done': '#5fdc8a',
20
23
  'scrim': '#000000a6', 'shadow': '0 -16px 80px #0007'},
21
24
  'light': {'surface': '#f3f5f2', 'surface-raised': '#ffffff', 'line': '#d5ded6', 'accent-wash': '#e5f0e6',
22
- 'accent-line': '#acc9b4', 'press': '#d8e9db', 'chip': '#edf3ec', 'ink': '#17241b', 'ink-dim': '#56616d',
23
- 'ink-faint': '#7f8a96', 'accent': '#2f6b45', 'on-accent': '#ffffff', 'on-accent-urgent': '#ffd3cc',
24
- 'tone-urgent': '#b7322a', 'tone-attention': '#8a5a00', 'tone-working': '#2456a8', 'tone-done': '#1b7142',
25
+ 'accent-line': '#acc9b4', 'press': '#d8e9db', 'chip': '#edf3ec', 'ink': '#111a14', 'ink-dim': '#4a5661',
26
+ 'ink-faint': '#6f7b87', 'accent': '#1f6f3f', 'on-accent': '#ffffff', 'on-accent-urgent': '#ffd3cc',
27
+ 'tone-urgent': '#c41c1c', 'tone-attention': '#a04d00', 'tone-working': '#1a56c4', 'tone-done': '#0d7338',
25
28
  'scrim': '#1a222b80', 'shadow': '0 -16px 80px #0003'},
26
29
  }
27
30
  # 16-colour fallback: the user's own palette resolves these. Selection is achromatic (bold) there, dim is bright-black.
28
31
  ANSI16 = {'ink': '39', 'ink-dim': '90', 'ink-faint': '90', 'accent': '1;39',
29
32
  'tone-urgent': '31', 'tone-attention': '33', 'tone-working': '34', 'tone-done': '32'}
33
+ # Painted roles that are a BACKGROUND, by the token they take their colour from. `selected` is the cursor row (cockpit
34
+ # v3 P2 paints it): the accent-wash the Zellij themes' *_selected components and the phone's current row
35
+ # (--lanes-accent-wash) already use, with ink on it. Not a CSS variable of its own: the phone needs none, and a copy
36
+ # per variant would cost 58 B of the 37 B mobile.css still has under spec §8's cap.
37
+ BACKGROUNDS = {'selected': 'accent-wash'}
38
+ PAINTED = tuple(ANSI16) + tuple(BACKGROUNDS) # the roles the TUI ever emits
39
+ # The colour modes whose colours Lanes defines (cockpit v3 P2/P3): the sidebar spends the palette in them, and view.sh
40
+ # (`LZ_VIVID`) the warning tone on its lead. 16 is the user's own palette and none has no colour: both keep v1's bytes.
41
+ VIVID = ('truecolor', '256')
42
+ # The 256-colour set: one xterm index per painted role, always from 16-255 (fixed RGB; 0-15 are the user's palette).
43
+ # Each is the nearest entry (RGB distance) in its token's hue family — greys for the inks — that keeps every text role
44
+ # >= 4.2:1 on surface, surface-raised and the 256 `selected` band; `selected` is the nearest entry to the wash. The
45
+ # floor is 4.2, not 4.5, for the cube's six levels alone (test_tokens.Xterm256 re-derives every index). Light accent
46
+ # and done share 22 (#005f00): the nearest passing entry, 23, is a teal.
47
+ XTERM256 = {
48
+ 'dark': {'ink': 255, 'ink-dim': 249, 'ink-faint': 243, 'accent': 115, 'tone-urgent': 209, 'tone-attention': 215,
49
+ 'tone-working': 75, 'tone-done': 78, 'selected': 235},
50
+ 'light': {'ink': 233, 'ink-dim': 240, 'ink-faint': 243, 'accent': 22, 'tone-urgent': 160, 'tone-attention': 94,
51
+ 'tone-working': 26, 'tone-done': 22, 'selected': 254},
52
+ }
30
53
  # Lane-identity palette for the two places that DEFINE a palette: Zellij's `web_client { theme {} }` (the phone's
31
54
  # xterm.js; dark only, see _web_client) and the optional Ghostty file. A Zellij theme's legacy `black…white` keys are
32
- # 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.
55
+ # NOT one of them (DEF-1413). The TUI itself keeps 38;5;N. Slots 1-4 are the four tones and 7 / 15 the ink pair
56
+ # (PALETTE16_ROLES); v2 re-saturates the rest (more chroma than v1 in every chromatic slot). Every slot >= 4.5:1.
33
57
  PALETTE16 = {
34
- 'dark': ['#7d8896', '#f28b7d', '#6fd39a', '#e5b84a', '#7fb3f2', '#c9a6ff', '#6fd6d0', '#9ca7b5',
35
- '#8a95a3', '#ff9d96', '#8fe0a3', '#f7cf7f', '#a5cbff', '#dcc6ff', '#9ae6e0', '#edf1f5'],
36
- 'light': ['#3b4a42', '#b7322a', '#1b7142', '#8a5a00', '#2456a8', '#7a3fb8', '#0f7b7b', '#56616d',
37
- '#5f6d66', '#c2332a', '#1b7a38', '#8f5a00', '#2860c4', '#8a4dc8', '#0f7272', '#17241b'],
58
+ 'dark': ['#8792a0', '#ff7b6e', '#5fdc8a', '#ffc233', '#6fb6ff', '#c58bff', '#3fd9cf', '#a6b1be',
59
+ '#97a3b1', '#ff9a8e', '#86f0ad', '#ffd866', '#98cbff', '#dcb2ff', '#7deee6', '#f2f5f8'],
60
+ 'light': ['#2f3b34', '#c41c1c', '#0d7338', '#a04d00', '#1a56c4', '#8228c4', '#006f73', '#4a5661',
61
+ '#55625b', '#d0221a', '#0e7a3a', '#a85200', '#1f5fd8', '#9133d0', '#00797c', '#111a14'],
38
62
  }
63
+ PALETTE16_ROLES = {1: 'tone-urgent', 2: 'tone-done', 3: 'tone-attention', 4: 'tone-working', 7: 'ink-dim', 15: 'ink'}
39
64
  # key -> (unicode, ascii twin, tone). Position disambiguates: agent glyphs in column 2, stream glyphs in column 33.
40
65
  AGENT_GLYPHS = {'needs_approval': ('◆', '!', 'tone-urgent'), 'needs_input': ('◇', '?', 'tone-attention'),
41
66
  'working': ('▸', '>', 'tone-working'), 'done': ('✓', 'v', 'tone-done'),
@@ -52,11 +77,27 @@ MARKS = {'cursor': ('▌', '|', 'accent'), 'viewing': ('›', '=', 'accent'), 'v
52
77
  # Tone-less punctuation (spec §3 completeness rule): every ` · ` and every quote the sidebar draws is a lookup, so
53
78
  # LANES_GLYPHS=ascii yields a seven-bit frame. `sep` shares `-` with `rule` on purpose: neither carries state.
54
79
  'sep': ('·', '-', 'ink-dim'), 'quote-open': ('‘', "'", 'ink'), 'quote-close': ('’', "'", 'ink'),
80
+ # The confirmation's dash (` › close ‘sh’ — y⏎ confirm · Esc cancel`, cockpit v3 S3/S6); `-` like `sep`.
81
+ 'dash': ('—', '-', 'ink-dim'),
82
+ # The focus legend's arrow (` ▌ keys go to sidebar · Esc → lane`, cockpit v3 S4).
83
+ 'arrow': ('→', '->', 'accent'),
84
+ # Column 3 of a row whose registration is on its way (`P`, cockpit v3 S8): the session stays listed, dimmed
85
+ # mark, until the registry lists its lane. The truncation pair on purpose — position disambiguates (column 3,
86
+ # never the end of a name) — and seven-bit `~` is free in column 3 (`=` `2` `3` are the other marks there).
87
+ 'pending': ('…', '~', 'ink-dim'),
88
+ # Column 3 of a lane its owner pinned (`p`, cockpit v3 R5): it leads its section and never falls below `⋮ N
89
+ # more`. The lowest of column 3's marks: pending, the viewports and the viewing mark win over it. Seven-bit
90
+ # `^` is free in column 3 (`=` `2` `3` `~` are the others; the upload strip's `^` is no column-3 mark).
91
+ 'pinned': ('▲', '^', 'accent'),
55
92
  'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent')}
56
93
  ECONOMY_TAIL = {'starving': ('⚠', '!', 'tone-attention'), 'downgraded': ('↓', 'v', 'tone-attention'), 'paid': ('$', '$', 'ink-dim')}
57
94
  MOBILE_AGENT_LABELS = {'needs_approval': 'Needs approval', 'needs_input': 'Needs you', 'working': 'Working',
58
95
  'done': 'Done', 'errored': 'Error', 'shell': 'Shell', 'idle': 'Idle'}
59
- PAINTED = tuple(ANSI16) # the roles the TUI ever emits
96
+
97
+
98
+ def hex_of(variant, role):
99
+ """The colour a role paints: its own token, or for a background role the token it takes (`selected` -> wash)."""
100
+ return TOKENS[variant][BACKGROUNDS.get(role, role)]
60
101
 
61
102
 
62
103
  def luminance(hexval):
@@ -74,17 +115,24 @@ def rgb(hexval):
74
115
  return tuple(int(hexval[i:i + 2], 16) for i in (1, 3, 5))
75
116
 
76
117
 
77
- def sgr_table(mode, variant):
78
- """Complete escape per painted role. `mode` truecolor|16|none; `variant` dark|light. Resolved ONCE per process."""
118
+ def sgr_table(mode, variant, dim16=None):
119
+ """Complete escape per painted role. `mode` truecolor|256|16|none; `variant` dark|light. Resolved ONCE per process.
120
+ Foreground roles are `38;…`, `selected` (the cursor row) `48;…`; in 16/none it is empty — the palette there is the
121
+ user's, and the cursor stays the bold bar. `dim16` overrides the 16-colour dim (default: `LANES_DIM=2` -> SGR 2,
122
+ else 90); the shell renderer pins 90 so the committed block never depends on the generator's environment."""
79
123
  table = {'bold': '\033[1m', 'reset': '\033[0m'}
80
- dim16 = '\033[2m' if os.environ.get('LANES_DIM') == '2' else '\033[90m'
124
+ if dim16 is None:
125
+ dim16 = '\033[2m' if os.environ.get('LANES_DIM') == '2' else '\033[90m'
81
126
  for role in PAINTED:
127
+ bold, layer = '\033[1m' if role == 'accent' else '', 48 if role in BACKGROUNDS else 38
82
128
  if mode == 'none':
83
- table[role] = '\033[1m' if role == 'accent' else ''
129
+ table[role] = bold
84
130
  elif mode == '16':
85
- table[role] = dim16 if role in ('ink-dim', 'ink-faint') else f'\033[{ANSI16[role]}m'
131
+ table[role] = '' if role in BACKGROUNDS else dim16 if role in ('ink-dim', 'ink-faint') else f'\033[{ANSI16[role]}m'
132
+ elif mode == '256':
133
+ table[role] = bold + '\033[%d;5;%dm' % (layer, XTERM256[variant][role])
86
134
  else:
87
- table[role] = ('\033[1m' if role == 'accent' else '') + '\033[38;2;%d;%d;%dm' % rgb(TOKENS[variant][role])
135
+ table[role] = bold + '\033[%d;2;%d;%d;%dm' % ((layer,) + rgb(hex_of(variant, role)))
88
136
  return table
89
137
 
90
138
 
@@ -194,18 +242,61 @@ def render_css():
194
242
  return '\n'.join(out) + '\n'
195
243
 
196
244
 
245
+ # The painted roles as the viewer's shell variables (`tokens.sh`), in PAINTED order. LZ_DIM kept its v1 name.
246
+ SH_NAMES = {'ink': 'LZ_INK', 'ink-dim': 'LZ_DIM', 'ink-faint': 'LZ_FAINT', 'accent': 'LZ_ACCENT',
247
+ 'tone-urgent': 'LZ_URGENT', 'tone-attention': 'LZ_ATTENTION', 'tone-working': 'LZ_WORKING',
248
+ 'tone-done': 'LZ_DONE', 'selected': 'LZ_SELECTED'}
249
+ SH_SETS = (('256', 'dark'), ('256', 'light'), ('truecolor', 'dark'), ('truecolor', 'light'))
250
+
251
+
252
+ def _sh_set(mode, variant, indent):
253
+ """One set's assignments: every painted role, then `LZ_VIVID` (`1` in a VIVID mode, else empty) — what view.sh asks
254
+ instead of re-matching the mode words itself (cockpit v3 P3)."""
255
+ table = sgr_table(mode, variant, dim16='\033[90m')
256
+ lines = [indent + "%s='%s'" % (SH_NAMES[role], table[role].replace('\033', '\\033')) for role in PAINTED]
257
+ return lines + [indent + "LZ_VIVID='%s'" % ('1' if mode in VIVID else '')]
258
+
259
+
197
260
  def render_sh():
198
- return ('# BEGIN lanes tokens (generated by libexec/tokens.py --sh; do not edit by hand)\n'
199
- "LZ_BOLD='\\033[1m'\nLZ_DIM='\\033[90m'\n"
200
- f"LZ_URGENT='\\033[{ANSI16['tone-urgent']}m'\nLZ_ATTENTION='\\033[{ANSI16['tone-attention']}m'\n"
201
- f"LZ_WORKING='\\033[{ANSI16['tone-working']}m'\nLZ_DONE='\\033[{ANSI16['tone-done']}m'\nLZ_RESET='\\033[0m'\n"
202
- '# END lanes tokens\n')
261
+ """The viewer's colour sets as POSIX-shell literals holding `\\033` as text (they go into printf's FORMAT). A plain
262
+ `. tokens.sh` assigns the 16-colour set — the user's own palette; `lz_tones MODE VARIANT` re-points every LZ_* tone
263
+ variable to the 256 / truecolor set of dark / light, `lz_tones none` (one argument) to none, and anything else —
264
+ `lz_tones 16`, no argument, an unknown mode or variant — back to 16 colours. Every set also says `LZ_VIVID` (`1`
265
+ in truecolor and 256). The `case` word reads `${1:-}:${2:-}`: view.sh runs `set -u`, where a bare `$2` aborts the
266
+ shell on a one-argument call (DEF-1772). Assignments and one `case` only: sourcing or calling it forks nothing
267
+ (cockpit v3 P1). view.sh calls `lz_tones "$color" "$theme"` with its device's `.color`/`.theme` stamps on every
268
+ status screen (cockpit v3 P3)."""
269
+ lines = ['# BEGIN lanes tokens (generated by libexec/tokens.py --sh; do not edit by hand)',
270
+ "LZ_BOLD='\\033[1m'", "LZ_RESET='\\033[0m'"]
271
+ lines += _sh_set('16', 'dark', '')
272
+ lines += ['lz_tones() {', ' case "${1:-}:${2:-}" in']
273
+ for mode, variant in SH_SETS:
274
+ lines += [f' {mode}:{variant})'] + _sh_set(mode, variant, ' ') + [' ;;']
275
+ lines += [' none:*)'] + _sh_set('none', 'dark', ' ') + [' ;;']
276
+ lines += [' *)'] + _sh_set('16', 'dark', ' ') + [' ;;']
277
+ lines += [' esac', '}', '# END lanes tokens']
278
+ return '\n'.join(lines) + '\n'
279
+
280
+
281
+ def _md_role(role):
282
+ """One row of the role table: hex per variant, the 256 index per variant, the 16-colour SGR, the CSS variable."""
283
+ painted = role in PAINTED
284
+ x256 = [f'`{XTERM256[v][role]}`' if painted else '`—`' for v in ('dark', 'light')]
285
+ ansi = '—' if role in BACKGROUNDS else ANSI16.get(role, '—')
286
+ return (f'| {role} | `{hex_of("dark", role)}` | `{hex_of("light", role)}` | {x256[0]} | {x256[1]} | `{ansi}` | '
287
+ f'`--lanes-{BACKGROUNDS.get(role, role)}` |')
203
288
 
204
289
 
205
290
  def render_md():
206
291
  rows = ['<!-- BEGIN lanes tokens (generated by libexec/tokens.py --md; do not edit by hand) -->',
207
- '| Role | Dark | Light | ANSI-16 | CSS variable |', '|---|---|---|---|---|']
208
- rows += [f'| {r} | `{TOKENS["dark"][r]}` | `{TOKENS["light"][r]}` | `{ANSI16.get(r, "—")}` | `--lanes-{r}` |' for r in ROLES]
292
+ '| Role | Dark | Light | 256 dark | 256 light | ANSI-16 | CSS variable |', '|---|---|---|---|---|---|---|']
293
+ for role in ROLES:
294
+ rows.append(_md_role(role))
295
+ rows += [_md_role(bg) for bg, source in BACKGROUNDS.items() if source == role] # `selected` under its wash
296
+ rows += ['', '| Slot | ANSI | Dark | Light | Role |', '|---|---|---|---|---|']
297
+ names = ANSI_NAMES + tuple('bright ' + n for n in ANSI_NAMES)
298
+ rows += [f'| {i} | {names[i]} | `{PALETTE16["dark"][i]}` | `{PALETTE16["light"][i]}` | {PALETTE16_ROLES.get(i, "—")} |'
299
+ for i in range(16)]
209
300
  rows += ['', '| Glyph | Codepoint | ASCII | Tone | Meaning |', '|---|---|---|---|---|']
210
301
  for name, table in (('agent', AGENT_GLYPHS), ('stream', GLYPHS), ('mark', MARKS), ('economy', ECONOMY_TAIL)):
211
302
  for key, (glyph, ascii_twin, tone) in table.items():
@@ -1,11 +1,95 @@
1
- # Lanes 16-colour SGR literals for view.sh status screens. Sourced, never executed.
1
+ # Lanes SGR literals for view.sh status screens: the 16-colour set on sourcing, and `lz_tones MODE VARIANT` for the
2
+ # 256-colour, truecolor and none sets (`lz_tones none` takes one argument; safe under view.sh's `set -u`). view.sh calls
3
+ # `lz_tones "$color" "$theme"` with its device's clients/<device>.color and .theme stamps on every status screen; every
4
+ # set also says LZ_VIVID (`1` in truecolor and 256, the colours Lanes defines). Sourced, never executed.
2
5
  # The block below is generated: edit libexec/tokens.py and run `python3 libexec/tokens.py --write`.
3
6
  # BEGIN lanes tokens (generated by libexec/tokens.py --sh; do not edit by hand)
4
7
  LZ_BOLD='\033[1m'
8
+ LZ_RESET='\033[0m'
9
+ LZ_INK='\033[39m'
5
10
  LZ_DIM='\033[90m'
11
+ LZ_FAINT='\033[90m'
12
+ LZ_ACCENT='\033[1;39m'
6
13
  LZ_URGENT='\033[31m'
7
14
  LZ_ATTENTION='\033[33m'
8
15
  LZ_WORKING='\033[34m'
9
16
  LZ_DONE='\033[32m'
10
- LZ_RESET='\033[0m'
17
+ LZ_SELECTED=''
18
+ LZ_VIVID=''
19
+ lz_tones() {
20
+ case "${1:-}:${2:-}" in
21
+ 256:dark)
22
+ LZ_INK='\033[38;5;255m'
23
+ LZ_DIM='\033[38;5;249m'
24
+ LZ_FAINT='\033[38;5;243m'
25
+ LZ_ACCENT='\033[1m\033[38;5;115m'
26
+ LZ_URGENT='\033[38;5;209m'
27
+ LZ_ATTENTION='\033[38;5;215m'
28
+ LZ_WORKING='\033[38;5;75m'
29
+ LZ_DONE='\033[38;5;78m'
30
+ LZ_SELECTED='\033[48;5;235m'
31
+ LZ_VIVID='1'
32
+ ;;
33
+ 256:light)
34
+ LZ_INK='\033[38;5;233m'
35
+ LZ_DIM='\033[38;5;240m'
36
+ LZ_FAINT='\033[38;5;243m'
37
+ LZ_ACCENT='\033[1m\033[38;5;22m'
38
+ LZ_URGENT='\033[38;5;160m'
39
+ LZ_ATTENTION='\033[38;5;94m'
40
+ LZ_WORKING='\033[38;5;26m'
41
+ LZ_DONE='\033[38;5;22m'
42
+ LZ_SELECTED='\033[48;5;254m'
43
+ LZ_VIVID='1'
44
+ ;;
45
+ truecolor:dark)
46
+ LZ_INK='\033[38;2;242;245;248m'
47
+ LZ_DIM='\033[38;2;166;177;190m'
48
+ LZ_FAINT='\033[38;2;111;122;135m'
49
+ LZ_ACCENT='\033[1m\033[38;2;143;227;166m'
50
+ LZ_URGENT='\033[38;2;255;123;110m'
51
+ LZ_ATTENTION='\033[38;2;255;194;51m'
52
+ LZ_WORKING='\033[38;2;111;182;255m'
53
+ LZ_DONE='\033[38;2;95;220;138m'
54
+ LZ_SELECTED='\033[48;2;28;51;38m'
55
+ LZ_VIVID='1'
56
+ ;;
57
+ truecolor:light)
58
+ LZ_INK='\033[38;2;17;26;20m'
59
+ LZ_DIM='\033[38;2;74;86;97m'
60
+ LZ_FAINT='\033[38;2;111;123;135m'
61
+ LZ_ACCENT='\033[1m\033[38;2;31;111;63m'
62
+ LZ_URGENT='\033[38;2;196;28;28m'
63
+ LZ_ATTENTION='\033[38;2;160;77;0m'
64
+ LZ_WORKING='\033[38;2;26;86;196m'
65
+ LZ_DONE='\033[38;2;13;115;56m'
66
+ LZ_SELECTED='\033[48;2;229;240;230m'
67
+ LZ_VIVID='1'
68
+ ;;
69
+ none:*)
70
+ LZ_INK=''
71
+ LZ_DIM=''
72
+ LZ_FAINT=''
73
+ LZ_ACCENT='\033[1m'
74
+ LZ_URGENT=''
75
+ LZ_ATTENTION=''
76
+ LZ_WORKING=''
77
+ LZ_DONE=''
78
+ LZ_SELECTED=''
79
+ LZ_VIVID=''
80
+ ;;
81
+ *)
82
+ LZ_INK='\033[39m'
83
+ LZ_DIM='\033[90m'
84
+ LZ_FAINT='\033[90m'
85
+ LZ_ACCENT='\033[1;39m'
86
+ LZ_URGENT='\033[31m'
87
+ LZ_ATTENTION='\033[33m'
88
+ LZ_WORKING='\033[34m'
89
+ LZ_DONE='\033[32m'
90
+ LZ_SELECTED=''
91
+ LZ_VIVID=''
92
+ ;;
93
+ esac
94
+ }
11
95
  # END lanes tokens