@vibelord/lanes 1.2.0 → 1.3.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 (35) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +4 -3
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +301 -45
  5. package/payload/lanes/container/lane +42 -1
  6. package/payload/lanes/container/lane-mcp +65 -6
  7. package/payload/lanes/container/lane_core.py +178 -0
  8. package/payload/lanes/docs/ARCHITECTURE.md +192 -20
  9. package/payload/lanes/docs/DESIGN.md +6 -3
  10. package/payload/lanes/docs/INSTALL.md +25 -8
  11. package/payload/lanes/docs/RUNBOOK.md +369 -8
  12. package/payload/lanes/docs/USERGUIDE.md +201 -25
  13. package/payload/lanes/holders.lock +5 -0
  14. package/payload/lanes/install/enable-docs.sh +78 -0
  15. package/payload/lanes/install/host-install.sh +39 -5
  16. package/payload/lanes/libexec/docs_store.py +995 -0
  17. package/payload/lanes/libexec/docsd.py +2010 -0
  18. package/payload/lanes/libexec/mdlite.py +575 -0
  19. package/payload/lanes/libexec/members.py +5 -0
  20. package/payload/lanes/libexec/notify.py +65 -5
  21. package/payload/lanes/libexec/sidebar.py +106 -13
  22. package/payload/lanes/libexec/statusd.py +97 -5
  23. package/payload/lanes/libexec/tokens.py +31 -9
  24. package/payload/lanes/libexec/update.py +94 -22
  25. package/payload/lanes/libexec/uploads.py +538 -0
  26. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  27. package/payload/lanes/web/Caddyfile +24 -1
  28. package/payload/lanes/web/README.md +86 -8
  29. package/payload/lanes/web/browser.js +2 -4
  30. package/payload/lanes/web/docs.css +52 -0
  31. package/payload/lanes/web/docs.js +36 -0
  32. package/payload/lanes/web/mobile.js +3 -2
  33. package/payload/lanes/web/upload.css +17 -0
  34. package/payload/lanes/web/upload.js +84 -0
  35. package/payload/manifest.json +4 -3
@@ -31,10 +31,17 @@ NEEDS_YOU set. The six-word `state` is never read: the agent status is the only
31
31
  device_active).
32
32
  * Payload: `<lane name>: <agent task> — <status>` and, on a second line, the client command that opens the lane
33
33
  (`lanes-launch lane <id>`). No client registers a URL scheme for it, so the push carries a tappable action only
34
- when `action_url` (a template over `{lane}`) is configured.
34
+ when `action_url` (a template over `{lane}`, and `{doc}` for a `published` event — `action_url()`) is configured.
35
35
  * A failed send is one journal line (lane id and the failure class — never the topic or the bearer) and is not
36
36
  retried; it never stops the service.
37
37
 
38
+ Published documents (plan W10 W4): the docs service's publish pick-up (`lanes-docsd`, docsd.Pickup), not this
39
+ service, calls `notify_published` once a document an agent published from a lane is stored — the same config, the
40
+ same desk-presence rule (device_active) and the same `send`, at most one push per lane per minute (docsd's
41
+ PUBLISHED_PUSH_S), best effort. Its payload is `{title: 'Published', message: '<lane name> published a document (<kind>,
42
+ <size>)\n/lanes-docs/d/<id>', priority: 2}` and, when `action_url` is set, the tap target with `{doc}` resolved —
43
+ never the document's title, name or content: the topic is the host's, and the document may be for one login only.
44
+
38
45
  Enrolment requests (W8 A5, LNS-44): the same service also watches `STATE/enrol-requests/` (lanes-enrold's store;
39
46
  read through `enrold.load_requests`, so a request announced is one `lanes enrol list` shows). A request that is
40
47
  pending — not approved, denied or past `expires_at` — and has not been announced yet yields ONE push:
@@ -55,7 +62,9 @@ Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` overrides the path), `
55
62
  `url` (ntfy server, http/https, required), `topic` (`[A-Za-z0-9_-]{1,64}`, required), `token_file` (a file holding
56
63
  an ntfy access token, optional — sent as a bearer, never logged; an unreadable one disables sending rather than
57
64
  sending unauthenticated), `quiet_when_device_active_s` (default 120; 0 never suppresses), `action_url` (optional
58
- template, e.g. `https://lanes.example/?lane={lane}`). Without a valid `url` and `topic` the service only tracks
65
+ template, e.g. `https://lanes.example/?lane={lane}`; plan W10 W7 adds `{doc}`, the page `/lanes-docs/d/<id>` of the
66
+ document a `published` push names — empty for a needs-you push; the docs service sends those, W4). Without a valid
67
+ `url` and `topic` the service only tracks
59
68
  transitions and sends nothing. The config is re-read when notify.conf OR its `token_file` changes (inode, size,
60
69
  mtime or ctime, so a token written, rotated, removed or chmod-ed after notify.conf counts), and on every tick while
61
70
  sending is disabled, so a token that becomes readable switches the pushes on within one interval.
@@ -76,6 +85,7 @@ import urllib.request
76
85
  from pathlib import Path
77
86
 
78
87
  sys.path.insert(0, str(Path(__file__).resolve().parent))
88
+ import docs_store # noqa: E402
79
89
  import enrold as E # noqa: E402
80
90
  import lanes_lib as L # noqa: E402
81
91
  import tokens # noqa: E402
@@ -95,6 +105,8 @@ PRESENCE = '*.pid' # clients/ files only
95
105
  SEEN = '.seen.json' # a sidebar's opened-lane record; desk devices only
96
106
  # (a .pid of any age, devices/<device>.json not mobile)
97
107
  # users/<login>.json written by a desk device counts too
108
+ PUBLISHED_TITLE = 'Published' # a document published from a lane (notify_published)
109
+ PUBLISHED_PRIORITY = 2
98
110
  ENROL_DIR = 'enrol-requests' # STATE/<this>/<id>.json: lanes-enrold's requests
99
111
  ENROL_SEEN = 'notify.enrol-seen.json' # STATE/<this>: the request ids already announced
100
112
  ENROL_TITLE = 'Enrol request'
@@ -275,6 +287,18 @@ def device_active(clients_dir, window, now, devices_dir=None, users_dir=None, me
275
287
  return any(within(at, window) for _login, at in marks)
276
288
 
277
289
 
290
+ def action_url(template, lane_id, doc_id=None):
291
+ """notify.conf's `action_url` template made a push's tap target -> the URL, or '' without a template. `{lane}` is
292
+ the lane id; `{doc}` (plan W10 W7) the page of the document a `published` event is about, `/lanes-docs/d/<id>`
293
+ (docs_store.URL; a docs_store id, else nothing), and nothing for an event without one (a needs-you push) — so
294
+ `https://<host>:8443{doc}` opens the document. Both are percent-quoted."""
295
+ if not template:
296
+ return ''
297
+ doc = docs_store.URL.format(doc_id) if isinstance(doc_id, str) and docs_store.ID_RE.match(doc_id) else ''
298
+ return (template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
299
+ .replace('{doc}', urllib.parse.quote(doc, safe='/')))
300
+
301
+
278
302
  def _note(lane_id, status, agent, names, config):
279
303
  label = LABELS.get(status, status)
280
304
  name = _text(names.get(lane_id) or lane_id)
@@ -282,14 +306,50 @@ def _note(lane_id, status, agent, names, config):
282
306
  first = _line(f'{name}: {task} — {label}' if task else f'{name} — {label}')
283
307
  note = {'lane': lane_id, 'status': status, 'title': label,
284
308
  'message': f'{first}\nlanes-launch lane {lane_id}', 'priority': PRIORITY.get(status, 3)}
285
- template = config.get('action_url') or ''
286
- if template:
287
- url = template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
309
+ url = action_url(config.get('action_url') or '', lane_id)
310
+ if url:
288
311
  note['click'] = url
289
312
  note['actions'] = [{'action': 'view', 'label': 'Open lane', 'url': url}]
290
313
  return note
291
314
 
292
315
 
316
+ def _size(size):
317
+ if size < 1024:
318
+ return f'{size} B'
319
+ return f'{size / 1024:.1f} KiB' if size < 1024 * 1024 else f'{size / (1024 * 1024):.1f} MiB'
320
+
321
+
322
+ def published_note(lane_id, meta, names, config):
323
+ """The push for a document stored from lane `lane_id` (`meta` its docs_store meta): the lane's name, the kind and
324
+ the size, and the document's page (module docstring) — never its title, name or content."""
325
+ name = _text(names.get(lane_id) or lane_id)
326
+ first = _line(f"{name} published a document ({_text(meta.get('kind'))}, {_size(int(meta.get('bytes') or 0))})")
327
+ note = {'lane': lane_id, 'status': 'published', 'doc': meta['id'], 'title': PUBLISHED_TITLE,
328
+ 'message': f"{first}\n{docs_store.URL.format(meta['id'])}", 'priority': PUBLISHED_PRIORITY}
329
+ url = action_url(config.get('action_url') or '', lane_id, meta['id'])
330
+ if url:
331
+ note['click'] = url
332
+ note['actions'] = [{'action': 'view', 'label': 'Open document', 'url': url}]
333
+ return note
334
+
335
+
336
+ def notify_published(lane_id, meta, names=None, conf_path=None, state=None, now=None, opener=None):
337
+ """Send the `published` push for a stored document (the docs service's pick-up calls it, W4) -> the note sent, or
338
+ None: sending disabled (no valid notify.conf at `conf_path`, else $LANES_NOTIFY_CONF, else the default), somebody
339
+ at a desk (device_active over `state`, default L.STATE), or the send failed (one journal line). Never raises for a
340
+ failed send; the caller rate-limits."""
341
+ config = read_config(conf_path or os.environ.get('LANES_NOTIFY_CONF') or DEFAULT_CONF)
342
+ if not config['enabled']:
343
+ return None
344
+ state = Path(state) if state is not None else L.STATE
345
+ now = time.time() if now is None else now
346
+ window = int(config.get('quiet_when_device_active_s', DEFAULT_QUIET) or 0)
347
+ if device_active(state / 'clients', window, now, state / 'devices'):
348
+ return None
349
+ note = published_note(lane_id, meta, names or {}, config)
350
+ return note if send(note, config, opener) else None
351
+
352
+
293
353
  def notify_changes(prev_status, new_status, config, names=None, now=None, clients_dir=None, devices_dir=None):
294
354
  """The notifications for lanes whose agent status ENTERED needs-you between two status.json documents.
295
355
  `prev_status` None = the first read: seed only, nothing is news. Empty when a desk device is present."""
@@ -7,6 +7,7 @@ Fully usable from the keyboard (touch/mouse is a bonus):
7
7
  ↑/↓ j/k move · Enter/→/Space open · J/K open next/previous lane · / filter · Esc clear · g/G · r reload
8
8
  2 / 3 open the lane under the cursor in the 2nd / 3rd viewport (wide view sessions) · x / × close (y, then Enter)
9
9
  u unregister a session added with a (z within 10 s undoes it; a declared lane says which file to edit)
10
+ d shows the cursor lane's docs link in the footer (plan W10 W7)
10
11
  ? shows every key in the body until the next key · Tab / Shift-Tab open the next / previous lane that needs you
11
12
  The keyboard goes back to the lane after every action (cockpit v3 S3): the sidebar and the lane share one keyboard, so
12
13
  an action that ends — a close, a rename, a register, a cancel, Esc, r, closing the key sheet, a click that opened
@@ -48,6 +49,9 @@ focus first; the file itself is then sent by the upload helper on your machine (
48
49
  there). Below it, when there is room, the strip row: the fleet economy line from the opt-in economy.json
49
50
  (`$8.5k/16k 53% · F2 S1 · spill 76%`), else which OTHER workspaces need you (`◆1 Worlds`). Column 34 of a lane row is
50
51
  one tail cell by priority: stale `?` > starving ⚠ > downgraded ↓ > mostly paid `$`.
52
+ Docs (plan W10 W7): while the workspace's lanes have published documents the idle strip says ` ↑ uploads · ▤ docs N
53
+ (U)`, one OSC 8 link to the docs page; the cursor row's name links to its lane's documents, and `d` shows that link in
54
+ the footer. The counts and the origin come in status.json (statusd): no stat or fork of the sidebar's own.
51
55
  Truthful under load: a lane statusd could not observe keeps its last glyph, dimmed, with a `?`; the footer line warns
52
56
  when the host is under pressure or statusd has fallen behind (age of $STATE/health, measured with our own clock).
53
57
  Never frozen by an action (cockpit v3 S9): a close, `P`'s write and a new shell run on one worker thread (Worker); the
@@ -66,6 +70,7 @@ import sys
66
70
  import termios
67
71
  import threading
68
72
  import time
73
+ import urllib.parse
69
74
  from operator import itemgetter
70
75
  from pathlib import Path
71
76
  import tty
@@ -184,6 +189,35 @@ PROFILE_LOCK_S = 0.2
184
189
  PROFILE_RETRY_S = 5.0
185
190
  # What `p`, `h` and `H` say on a device nobody owns (cockpit v3 R5): pins and hidden lanes live in the owner's profile.
186
191
  NO_PROFILE = 'no owner: lanes device claim'
192
+ # The docs cockpit (plan W10 W7, LNS-108). The docs page is lanes-docsd behind Caddy's `handle_path /lanes-docs/*`; a
193
+ # lane's documents are its index filtered `?ws=<ws>&lane=<lane>`. statusd publishes each lane's `docs: {n, at}` and the
194
+ # document's `web_origin` (`https://<name>:8443`) in status.json, which this sidebar already reads — no stat, no fork
195
+ # of its own. The links are OSC 8 hyperlinks (bytes, never cells): the upload strip's ` ↑ uploads · ▤ docs N (U)` to
196
+ # the page, the cursor row's name to its lane's documents, and the footer's `d` message to the same. Without an origin
197
+ # there is no link (a terminal needs an absolute URL) and `d` says the path alone. The origin is checked again here: it
198
+ # rides inside an OSC string, so only `https://<DNS host name>:8443` passes.
199
+ DOCS_PATH = '/lanes-docs/'
200
+ WEB_ORIGIN = re.compile(r'https://[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?:8443\Z', re.ASCII)
201
+
202
+
203
+ def docs_count(row):
204
+ """A status.json lane row's document count (`docs.n`, plan W10 W7): a positive int, else 0."""
205
+ docs = row.get('docs') if isinstance(row, dict) else None
206
+ n = docs.get('n') if isinstance(docs, dict) else None
207
+ return n if isinstance(n, int) and not isinstance(n, bool) and n > 0 else 0
208
+
209
+
210
+ def docs_url(origin, ws=None, lane=None):
211
+ """The docs page on `origin` ('' = the path alone), filtered to a workspace and a lane when given:
212
+ `/lanes-docs/?ws=<ws>&lane=<lane>`, each value percent-quoted (nothing in it can end an OSC string)."""
213
+ query = '&'.join(f'{key}={urllib.parse.quote(value, safe="")}' for key, value in (('ws', ws), ('lane', lane)) if value)
214
+ return f'{origin}{DOCS_PATH}' + (f'?{query}' if query else '')
215
+
216
+
217
+ def osc8(url, text):
218
+ """`text` as ONE OSC 8 hyperlink to `url` (ST-terminated, no id); `text` as it is when there is no url. Costs
219
+ len(url) + 14 bytes and no cell: the budget gate's strip and cell walk read through it."""
220
+ return f'\033]8;;{url}\033\\{text}\033]8;;\033\\' if url else text
187
221
 
188
222
 
189
223
  def shown_version(path):
@@ -455,13 +489,13 @@ BOX_ROWS = 1 # the upload strip (spec §4.6(d)); it was a four-row box
455
489
  # config.kdl's shared block; lane chords such as Ctrl-Alt-m are no sidebar keys and stay off). 18 lines below the
456
490
  # dynamic version row (cockpit v3 V2, LNS-84: ` lanes <current> · view <tree>`, key_sheet) — exactly the 19-row body
457
491
  # of a 24-row pane with the strip row; R5's `p h` / `H o` (LNS-78) took the two rows S10 left, and V2's row the line
458
- # `u z` now share.
492
+ # `u z` now share. Plan W10 W7's `d` (the cursor lane's docs link) joins the strip's own keys, `+ U d`, for the same reason.
459
493
  # test_sidebar.DocumentedKeys holds the sheet, the table and handle() to the same keys.
460
494
  KEY_SHEET = (('↑ ↓ j k', 'Up Dn j k', 'move'), ('⏎ → Space', 'Enter Spc', 'open the cursor lane'),
461
495
  ('J K', 'J K', 'open next / prev lane'), ('Tab S-Tab', 'Tab S-Tab', 'next / prev needing you'),
462
496
  ('2 3', '2 3', 'show in viewport 2 / 3'), ('/', '/', 'find a lane'),
463
497
  ('Esc', 'Esc', 'cancel, back to lane'), ('g G', 'g G', 'first / last'),
464
- ('+ U', '+ U', 'new shell / upload'), ('a', 'a', 'add to a workspace'),
498
+ ('+ U d', '+ U d', 'shell / upload / docs'), ('a', 'a', 'add to a workspace'),
465
499
  ('x', 'x', 'close (y, then Enter)'), ('u z', 'u z', 'unregister / undo 10 s'),
466
500
  ('R', 'R', 'rename'), ('P', 'P', 'register a session'), ('p h', 'p h', 'pin / hide lane'),
467
501
  ('H o', 'H o', 'hide tab / hidden lanes'), ('r ?', 'r ?', 'reload / this sheet'),
@@ -649,6 +683,10 @@ class Sidebar:
649
683
  # W9 U6: whether clients/<device>.update-ready existed at the last look (poll_update), when to look next, and until
650
684
  # when a rebuild `r` started counts as on its way (monotonic seconds)
651
685
  update_ready, update_poll_at, rebuild_until = False, 0.0, 0.0
686
+ # The docs cockpit (plan W10 W7): status.json's `web_origin` ('' = none known, so no link), this workspace's
687
+ # document count (reload), the link of the footer message `d` set ('' = the message is no link; the message
688
+ # setter clears it) and the link the footer row draws this frame (footer_text sets it).
689
+ web_origin, docs_total, _message_link, footer_link = '', 0, '', ''
652
690
 
653
691
  def __init__(self, device, workspace, slot='main', viewports='1'):
654
692
  self.device, self.workspace, self.slot = device, workspace, slot
@@ -775,9 +813,12 @@ class Sidebar:
775
813
  self.status = status.get('lanes', {})
776
814
  self.unregistered = (status.get('unregistered') or {}).get(self.workspace, [])
777
815
  self.zmx = (status.get('zmx') or {}).get(self.workspace, [])
816
+ origin = status.get('web_origin')
817
+ self.web_origin = origin if isinstance(origin, str) and WEB_ORIGIN.match(origin) else ''
778
818
  self.keep_moved_row()
779
819
  if stamp[0] != self.stamp[0] or stamp[1] != self.stamp[1] or force:
780
820
  self.fleet = self.fleet_counts()
821
+ self.docs_total = self.workspace_docs()
781
822
  look = stamp[3] != self.stamp[3] or force
782
823
  profile = stamp[6] != self.stamp[6] or force
783
824
  if look:
@@ -1447,6 +1488,22 @@ class Sidebar:
1447
1488
  lane_id = entry.get('id')
1448
1489
  return bool(lane_id) and not entry.get('divider') and any(e.get('id') == lane_id for e in self.entries)
1449
1490
 
1491
+ def workspace_docs(self):
1492
+ """This workspace's documents (plan W10 W7): the `docs.n` of every lane it lists — registry lanes (hidden ones
1493
+ too: their documents exist), zmx lanes and the sessions under Unregistered — each id once. reload() keeps it
1494
+ (`docs_total`) for the upload strip; counts are every login's (DM-LANES-DOCS-COUNTS), the page shows a login
1495
+ only what it may see."""
1496
+ ids = {entry.get('id') for rows in (self.entries, self.zmx, self.unregistered) for entry in rows or ()
1497
+ if isinstance(entry, dict)}
1498
+ return sum(docs_count(self.status.get(lane_id)) for lane_id in ids if lane_id)
1499
+
1500
+ def lane_docs_link(self, lane_id):
1501
+ """The OSC 8 target of a lane's name on the cursor row: its documents on the docs page, '' when the lane has
1502
+ none or no origin is known (plan W10 W7)."""
1503
+ if not self.web_origin or not docs_count(self.status.get(lane_id)):
1504
+ return ''
1505
+ return docs_url(self.web_origin, self.workspace, lane_id)
1506
+
1450
1507
  def header_tally(self):
1451
1508
  """[(agent status, 'glyph+count')] for the header, in `HEADER_COUNTS` order, zeros omitted; a seen `done`
1452
1509
  is not counted (it left the Done section)."""
@@ -1516,8 +1573,11 @@ class Sidebar:
1516
1573
  viewers = viewer_mark(observed).replace('·', self.glyphs['marks']['viewer'])
1517
1574
  closable = bool(entry.get('closable')) and not pending # a row being registered offers neither control
1518
1575
  can_register = closable and R.eligible(entry)
1576
+ # Plan W10 W7: the cursor row's name is one OSC 8 link to its lane's documents while it has any.
1577
+ link = self.lane_docs_link(entry['id']) if is_cursor else ''
1519
1578
  key = (entry.get('name'), entry.get('color'), entry.get('icon'), closable, can_register, key_state, stale,
1520
- viewers, status, age, tail, tail_role, is_cursor, is_viewing, mark, mark_role, cols, section_tone, hidden)
1579
+ viewers, status, age, tail, tail_role, is_cursor, is_viewing, mark, mark_role, cols, section_tone, hidden,
1580
+ link)
1521
1581
  cached = self.row_cache.get(entry['id'])
1522
1582
  if cached and cached[0] == key:
1523
1583
  self.row_hits += 1
@@ -1552,7 +1612,7 @@ class Sidebar:
1552
1612
  fg = (tone['ink-faint'] if hidden else f'\033[38;5;{color}m' if isinstance(color, int) and self.color != 'none'
1553
1613
  else tone['ink'] if self.vivid else '')
1554
1614
  lead = (tone['bold'] if is_viewing else '') + fg
1555
- name_cell = f'{lead}{clipped}{reset}' if lead else clipped
1615
+ name_cell = osc8(link, f'{lead}{clipped}{reset}' if lead else clipped)
1556
1616
  if viewers:
1557
1617
  name_cell += f'{tone["ink-dim"]}{viewers}{reset}'
1558
1618
  # The `+` register control (README "Daily use") has no column in spec §4.2: it takes the spacer cell 28
@@ -2493,6 +2553,27 @@ class Sidebar:
2493
2553
  self.message = f'Shown {name}' if hidden else f'Hid {name} {g["sep"]} o shows it'
2494
2554
  self.release_focus()
2495
2555
 
2556
+ def show_docs_link(self, entry):
2557
+ """`d` (plan W10 W7, LNS-108): the cursor lane's documents link in the footer — a message like every other
2558
+ (MESSAGE_S, or until the next action): the plain URL `https://<host>:8443/lanes-docs/?ws=<ws>&lane=<lane>`, as
2559
+ much of it as the row holds, the whole footer text one OSC 8 link to all of it (⌘/Ctrl-click, or the
2560
+ terminal's Copy Link, where 36 cells clip the text). Without an origin the path alone, no link. Then the
2561
+ keyboard goes back to the lane, as after `p`; a row with no documents, or no lane, says so and keeps it."""
2562
+ self.begin_action()
2563
+ self.dirty = True
2564
+ g = self.glyphs['marks']
2565
+ lane_id = (entry or {}).get('id')
2566
+ if not lane_id or entry.get('divider') or entry.get('action'):
2567
+ self.message = 'no docs here'
2568
+ return
2569
+ if not docs_count(self.status.get(lane_id)):
2570
+ self.message = f'no docs in {g["quote-open"]}{entry.get("name", lane_id)}{g["quote-close"]}'
2571
+ return
2572
+ url = docs_url(self.web_origin, self.workspace, lane_id)
2573
+ self.message = url
2574
+ self._message_link = url if self.web_origin else ''
2575
+ self.release_focus()
2576
+
2496
2577
  def toggle_hidden(self):
2497
2578
  """`o`: show the lanes the owner hid (faint, in their places; `h` there shows one for good) or hide them again.
2498
2579
  The footer says how many; then the keyboard goes back to the lane."""
@@ -2819,14 +2900,15 @@ class Sidebar:
2819
2900
 
2820
2901
  @message.setter
2821
2902
  def message(self, text):
2822
- # Every assignment is stamped (cockpit v3 S6): the message lasts MESSAGE_S from when it was SAID.
2823
- self._message, self.message_at = text, time.monotonic()
2903
+ # Every assignment is stamped (cockpit v3 S6): the message lasts MESSAGE_S from when it was SAID. A new message
2904
+ # is no link until `d` says it is one (plan W10 W7: show_docs_link sets `_message_link` after it).
2905
+ self._message, self.message_at, self._message_link = text, time.monotonic(), ''
2824
2906
 
2825
2907
  def expire_message(self, now=None):
2826
2908
  """run()'s tick (cockpit v3 S6): a message MESSAGE_S old goes -> True when it went (one repaint). Otherwise only
2827
2909
  the next action's begin_action() clears it — never a key, a report, the wheel or a release."""
2828
2910
  if self._message and (time.monotonic() if now is None else now) - self.message_at >= MESSAGE_S:
2829
- self._message = ''
2911
+ self._message, self._message_link = '', ''
2830
2912
  return True
2831
2913
  return False
2832
2914
 
@@ -2843,9 +2925,11 @@ class Sidebar:
2843
2925
 
2844
2926
  def strip_line(self, cols):
2845
2927
  """The upload strip (spec §4.6(d)): ONE row under the body, at most `cols − 1` cells — idle, armed, sending,
2846
- or the result for 8 s. Every glyph and separator is a mark, so the ASCII frame is seven-bit."""
2928
+ or the result for 8 s. Every glyph and separator is a mark, so the ASCII frame is seven-bit. Idle while this
2929
+ workspace has documents (plan W10 W7): ` ↑ uploads · ▤ docs N (U)`, the whole text one OSC 8 link to the docs
2930
+ page once statusd knows the origin (the page is where W5's browser uploads land too); with none, today's hint."""
2847
2931
  tone, g = self.tone, self.glyphs['marks']
2848
- sep = f' {g["sep"]} '
2932
+ sep, link = f' {g["sep"]} ', ''
2849
2933
  if self.upload:
2850
2934
  count = len(self.upload['names'])
2851
2935
  text, style = f' {g["upload"]} sending {count} file{"s" if count != 1 else ""}', tone['tone-working']
@@ -2856,10 +2940,13 @@ class Sidebar:
2856
2940
  elif self.upload_note and time.time() - self.upload_note_at < 8:
2857
2941
  text = ' ' + self.upload_note
2858
2942
  style = tone['tone-done'] if self.upload_note.startswith(g['ok']) else tone['tone-urgent']
2943
+ elif self.docs_total:
2944
+ text, style = f' {g["upload"]} uploads{sep}{g["docs"]} docs {self.docs_total} (U)', tone['ink-dim']
2945
+ link = docs_url(self.web_origin) if self.web_origin else ''
2859
2946
  else:
2860
2947
  text, style = f' {g["upload"]} uploads{sep}click, then drop (U)', tone['ink-dim']
2861
2948
  text, _used = clip(text, cols - 1)
2862
- return f'{style}{text}{tone["reset"]}'
2949
+ return f'{style}{osc8(link, text)}{tone["reset"]}'
2863
2950
 
2864
2951
  def footer_text(self, cols, entries=(), legend=False):
2865
2952
  """The footer row -> (text, style, caret): ONE ladder for the desktop and the phone (cockpit v3 S6; until S6 the
@@ -2872,9 +2959,11 @@ class Sidebar:
2872
2959
  (r)` (W9 U6: the installed release lays this view out differently; `r` rebuilds it — the desktop's rung and
2873
2960
  the phone's, in the accent) › the focus legend (desktop) › the idle hint. Only the picker's way out, the
2874
2961
  legend and the idle hint are a device's own. Every glyph, separator and quote is a mark, so the ASCII footer
2875
- is seven-bit."""
2962
+ is seven-bit. `footer_link` says which link footer_row draws this frame: the message's (`d`, plan W10 W7) while
2963
+ the message is the rung shown, else none."""
2876
2964
  tone, g = self.tone, self.glyphs['marks']
2877
2965
  sep = f' {g["sep"]} '
2966
+ self.footer_link = ''
2878
2967
  if self.prompt:
2879
2968
  return self.prompt_text(cols)
2880
2969
  if self.registration:
@@ -2892,6 +2981,7 @@ class Sidebar:
2892
2981
  text, style = f' {g["warning"]} registry: ' + self.errors[0], tone['tone-urgent']
2893
2982
  elif self.message:
2894
2983
  text, style = ' ' + self.message, tone['ink-dim']
2984
+ self.footer_link = self._message_link
2895
2985
  elif self.undo: # cockpit v3 S7: the `u` it undoes, until `z` or UNDO_S
2896
2986
  text, style = self.undo_text(cols), tone['ink-dim']
2897
2987
  elif self.update_ready: # W9 U6: the installed release lays this view out differently; `r` rebuilds it
@@ -2942,11 +3032,12 @@ class Sidebar:
2942
3032
 
2943
3033
  def footer_row(self, cols, entries=(), legend=False):
2944
3034
  """footer_text() painted: the words in their style, then the caret in tone-working when a field is open. The
2945
- caller ends the row (`\\033[K\\033[J`); the bytes are the ones render() wrote before cockpit v3 S6."""
3035
+ caller ends the row (`\\033[K\\033[J`); the bytes are the ones render() wrote before cockpit v3 S6. `d`'s
3036
+ message (plan W10 W7) is one OSC 8 link to the whole URL, which a 36-cell row clips."""
2946
3037
  text, style, caret = self.footer_text(cols, entries, legend)
2947
3038
  reset = self.tone['reset']
2948
3039
  painted = f'{reset}{self.tone["tone-working"]}{self.glyphs["marks"]["caret"]}{reset}' if caret else ''
2949
- return f'{style}{text}{painted}{reset}'
3040
+ return f'{style}{osc8(self.footer_link, text)}{painted}{reset}'
2950
3041
 
2951
3042
  def divider_line(self, entry, cols):
2952
3043
  """`── ◆ Needs you (3) ───` (spec §4.4), shared by the desktop and the phone (§4.10 "section headers as
@@ -3546,6 +3637,8 @@ class Sidebar:
3546
3637
  self.request_hide_workspace()
3547
3638
  elif ch == 'o':
3548
3639
  self.toggle_hidden()
3640
+ elif ch == 'd': # plan W10 W7: the cursor lane's docs link in the footer
3641
+ self.show_docs_link(entries[min(self.cursor, len(entries) - 1)] if entries else None)
3549
3642
  elif ch == 'P' and entries:
3550
3643
  entry = entries[min(self.cursor, len(entries) - 1)]
3551
3644
  self.start_promote(entry)
@@ -13,10 +13,15 @@ It never INVENTS a state. "No answer" (a tmux, cgroup, docker or zmx read that t
13
13
  host) is not an observation: the lane keeps its last published state, marked stale, for at most STALE_MAX seconds
14
14
  and is 'unknown' after that. 'absent' / 'missing' are only published when a server answered without the session,
15
15
  or positively does not exist.
16
+
17
+ The docs cockpit (plan W10 W7): a lane row carries `docs: {n, at}` while docs/counts.json counts documents for it
18
+ (one stat of that file per cycle), and the document `web_origin`, the https origin the docs page answers on, once
19
+ main() has learnt it between two cycles (refresh_origin).
16
20
  """
17
21
  import concurrent.futures
18
22
  import json
19
23
  import os
24
+ import re
20
25
  import subprocess
21
26
  import sys
22
27
  import time
@@ -27,6 +32,7 @@ import lanes_lib as L # noqa: E402
27
32
  import session_actions as A # noqa: E402
28
33
  import agent_state as AS # noqa: E402
29
34
  import guard as G # noqa: E402
35
+ import docs_store # noqa: E402
30
36
 
31
37
  INTERVAL = float(os.environ.get('LANES_STATUS_INTERVAL', '3'))
32
38
  SLOWDOWN = {'ok': 1, 'busy': 2, 'critical': 4} # a host under pressure gets FEWER spawns from us, not the same
@@ -56,6 +62,16 @@ FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_curr
56
62
  'window_bell_flag', 'window_id', 'pane_id', 'pane_title']
57
63
  FORMAT = '\t'.join('#{' + f + '}' for f in FIELDS)
58
64
  VIEW_PREFIX = 'view-' # a Throttle Studio browser attach: a grouped session named view-<studio>-<ch>
65
+ # The docs cockpit (plan W10 W7, LNS-108). A lane row carries `docs: {n, at}` while docs/counts.json
66
+ # (DM-LANES-DOCS-COUNTS) holds documents for it: ONE stat of that file per cycle, the file read only when it moved.
67
+ # `web_origin` is the https origin Tailscale Serve answers on, the one the enrolment service announces (enrold
68
+ # site_facts' host name: site.conf's LANES_SERVER_NAME, else this node's MagicDNS name), with Serve's port: the sidebar
69
+ # builds its OSC 8 links to /lanes-docs/ on it and never forks to learn it. Resolved outside the cycle (main), at most
70
+ # every ORIGIN_TTL seconds — enrold's own cache of the name — and not again on a host under pressure once known.
71
+ WEB_PORT = 8443 # bin/lanes SERVE_PORT: `tailscale serve --https=8443` -> Caddy :8083 (web/Caddyfile)
72
+ ORIGIN_TTL = 300 # enrold.TAILNET_TTL
73
+ HOST_NAME = re.compile(r'(?=.{1,253}\Z)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
74
+ r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\Z', re.ASCII)
59
75
 
60
76
 
61
77
  def log(where, error):
@@ -852,6 +868,68 @@ def update_containers(ctx, registry, level):
852
868
  info['running'] = pid_alive(info.get('pid'))
853
869
 
854
870
 
871
+ def docs_counts(ctx):
872
+ """docs/counts.json as the lane rows carry it -> {lane id: {n, at}} ({} when absent or not valid). ONE os.stat of
873
+ the file per cycle; it is read (docs_store.read_counts: no link followed, no FIFO opened, the shape checked) only
874
+ when its (mtime, inode, size) moved since the last read — docs_store replaces it on every change."""
875
+ try:
876
+ info = os.stat(docs_store.counts_path())
877
+ stamp = (info.st_mtime_ns, info.st_ino, info.st_size)
878
+ except OSError:
879
+ stamp = None
880
+ if stamp != ctx.get('docs_stamp') or 'docs_counts' not in ctx:
881
+ ctx['docs_counts'] = docs_store.read_counts() if stamp else {}
882
+ ctx['docs_stamp'] = stamp
883
+ return ctx['docs_counts']
884
+
885
+
886
+ def with_docs(lanes, counts):
887
+ """`lanes` with `docs: {n, at}` on every row counts.json gives documents (n > 0) and on no other row: absent at 0,
888
+ and dropped from a kept row whose lane has none any more. A row that changes is a NEW dict (DEF-1538: a kept row
889
+ may be the very object the published payload holds); the others are passed on as they are."""
890
+ out = {}
891
+ for lane_id, row in lanes.items():
892
+ entry = counts.get(lane_id) if isinstance(counts, dict) else None
893
+ want = {'n': entry['n'], 'at': entry['at']} if isinstance(entry, dict) and entry.get('n') else None
894
+ if not isinstance(row, dict) or row.get('docs') == want:
895
+ out[lane_id] = row
896
+ continue
897
+ row = {key: value for key, value in row.items() if key != 'docs'}
898
+ if want:
899
+ row['docs'] = want
900
+ out[lane_id] = row
901
+ return out
902
+
903
+
904
+ def resolve_origin():
905
+ """`https://<name>:8443` — the origin the docs page answers on (Tailscale Serve -> Caddy), by the name enrold
906
+ announces (site_facts: site.conf's LANES_SERVER_NAME, else enrold.tailnet_name(), one bounded `tailscale status
907
+ --json` cached for its TAILNET_TTL), or None when there is no name or it is no DNS host name: it is written into
908
+ terminal OSC strings and links, so nothing but `[A-Za-z0-9.-]` ever reaches them."""
909
+ import enrold # the enrolment service's own reader of the name; imported only when an origin is wanted
910
+ name = enrold.server_site().get('LANES_SERVER_NAME') or enrold.tailnet_name()
911
+ return f'https://{name}:{WEB_PORT}' if isinstance(name, str) and HOST_NAME.match(name) else None
912
+
913
+
914
+ def refresh_origin(ctx, level, now=None):
915
+ """main()'s look for `web_origin`, never inside a cycle: at most every ORIGIN_TTL seconds, and on a host that is
916
+ not `ok` only while none is known yet (one bounded spawn, then it waits). -> True when it looked. A failure keeps
917
+ the last origin: an unanswered name is no reason to drop the links."""
918
+ now = time.time() if now is None else now
919
+ known = ctx.get('origin_at') is not None
920
+ if known and (now - ctx['origin_at'] < ORIGIN_TTL or level != 'ok'):
921
+ return False
922
+ ctx['origin_at'] = now
923
+ try:
924
+ origin = resolve_origin()
925
+ except Exception as error:
926
+ log('web origin', error)
927
+ return True
928
+ if origin or not known:
929
+ ctx['web_origin'] = origin
930
+ return True
931
+
932
+
855
933
  def build_payload(ctx, level, reconcile=True):
856
934
  """One observation pass -> the payload of status.json (None = there is no registry to observe). Every phase
857
935
  fails alone: what it could not observe is republished as last seen (stale), the other phases still run.
@@ -915,8 +993,15 @@ def build_payload(ctx, level, reconcile=True):
915
993
  # Outside the payload: it changes every cycle, and status.json is rewritten only on change (plus the heartbeat).
916
994
  ctx['zmx_listed_at'] = listed
917
995
  lanes.update(zmx_lanes)
918
- return {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
919
- 'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
996
+ try: # plan W10 W7: the docs counts ride on the lane rows (one stat of docs/counts.json per cycle)
997
+ lanes = with_docs(lanes, docs_counts(ctx))
998
+ except Exception as error:
999
+ log('docs', error)
1000
+ payload = {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
1001
+ 'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
1002
+ if ctx.get('web_origin'): # additive, absent while no origin is known (refresh_origin, main())
1003
+ payload['web_origin'] = ctx['web_origin']
1004
+ return payload
920
1005
 
921
1006
 
922
1007
  def new_context():
@@ -927,16 +1012,21 @@ def new_context():
927
1012
  written = float(seed.get('updated_at') or 0)
928
1013
  for entry in (seed.get('lanes') or {}).values():
929
1014
  entry.setdefault('stale_since', int(written))
1015
+ # The docs links' origin survives a restart (re-checked by main()'s first refresh_origin); only a valid one.
1016
+ origin = seed.get('web_origin')
1017
+ valid = (isinstance(origin, str) and origin.startswith('https://') and origin.endswith(f':{WEB_PORT}')
1018
+ and HOST_NAME.match(origin[len('https://'):-len(f':{WEB_PORT}')]))
930
1019
  return {'registry': None, 'stamp': 0, 'containers': {}, 'containers_at': 0.0,
931
1020
  'payload': seed, 'published': None, 'status_at': written, 'cut': 0, 'unwalkable': [],
932
- 'container_status': {}, 'container_status_errors': {}}
1021
+ 'container_status': {}, 'container_status_errors': {}, 'web_origin': origin if valid else None}
933
1022
 
934
1023
 
935
1024
  # ------------------------------------------- the in-container projection (LNS-32)
936
1025
  # `~/.lz/status.json` in each workspace container: that container's slice of the payload, for the in-container Agent
937
1026
  # 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')
1027
+ # attached?, viewers?, activity?, size?, stale?, docs?}}, unregistered[{id, name, workspace, boards, tmux, identity}]}.
1028
+ # `docs` (plan W10 W7, additive) is the host row's {n, at} verbatim, absent like there.
1029
+ CONTAINER_STATUS_FIELDS = ('state', 'agent', 'attached', 'viewers', 'activity', 'size', 'stale', 'docs')
940
1030
  CONTAINER_STATUS_DIR = tuple(L.CONTAINER_HOME.strip('/').split('/')) + ('.lz',)
941
1031
  CONTAINER_STATUS_FILE = 'status.json'
942
1032
 
@@ -1147,6 +1237,8 @@ def main():
1147
1237
  while True:
1148
1238
  started = time.time()
1149
1239
  level, _payload = cycle(ctx)
1240
+ # The docs links' origin (plan W10 W7): a bounded look between two cycles, never inside one.
1241
+ refresh_origin(ctx, level)
1150
1242
  # Hot-reload: check if any terminals.json changed since we last synced.
1151
1243
  # Rate-limited to at most one triggered sync per HOT_SYNC_EVERY_S to avoid thrashing.
1152
1244
  if time.time() - last_triggered_sync > HOT_SYNC_EVERY_S and ctx['registry']:
@@ -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` (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:
2
+ """The Lanes design tokens: ONE table, rendered eight ways. The sidebar imports `sgr_table` (truecolor, 256, 16 or
3
+ none); `--kdl`, `--css`, `--sh`, `--md`, `--js`, `--ghostty-dark|--ghostty-light` and `--docs-css` write the Zellij
4
+ theme, the phone CSS, the viewer's shell colour sets, the docs tables, the phone's terminal palette, the optional
5
+ Ghostty palette files and the docs page's CSS (plan W10 W6) between markers; `--check` fails when any committed block
6
+ differs. Spec:
6
7
  docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md; palette v2 + the 256-colour set: cockpit v3 P1 (LNS-69).
7
8
  No third-party code; importable with no side effects."""
8
9
  import difflib
@@ -89,7 +90,10 @@ MARKS = {'cursor': ('▌', '|', 'accent'), 'viewing': ('›', '=', 'accent'), 'v
89
90
  # more`. The lowest of column 3's marks: pending, the viewports and the viewing mark win over it. Seven-bit
90
91
  # `^` is free in column 3 (`=` `2` `3` `~` are the others; the upload strip's `^` is no column-3 mark).
91
92
  'pinned': ('▲', '^', 'accent'),
92
- 'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent')}
93
+ 'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent'),
94
+ # The docs cockpit on the upload strip (` ↑ uploads · ▤ docs 3 (U)`, plan W10 W7): the strip's own tone.
95
+ # Seven-bit `#` is free on that row (`^` uploads, `v` armed, `-` sep are the others).
96
+ 'docs': ('▤', '#', 'ink-dim')}
93
97
  ECONOMY_TAIL = {'starving': ('⚠', '!', 'tone-attention'), 'downgraded': ('↓', 'v', 'tone-attention'), 'paid': ('$', '$', 'ink-dim')}
94
98
  MOBILE_AGENT_LABELS = {'needs_approval': 'Needs approval', 'needs_input': 'Needs you', 'working': 'Working',
95
99
  'done': 'Done', 'errored': 'Error', 'shell': 'Shell', 'idle': 'Idle'}
@@ -242,6 +246,22 @@ def render_css():
242
246
  return '\n'.join(out) + '\n'
243
247
 
244
248
 
249
+ # The docs page (plan W10 W6, LNS-107; web/docs.css) is its own HTML document, served by lanes-docsd, not the phone
250
+ # shell: no html.lanes-mobile, no Zellij data-zj-theme. Its block puts the roles it paints on :root, dark by default and
251
+ # light under the system's prefers-color-scheme, and only those roles (docs.css has a 2.5 KB cap; the full --css block
252
+ # alone is 2 KB). test_docs_assets pins that docs.css uses exactly these.
253
+ DOCS_ROLES = ('surface', 'surface-raised', 'line', 'ink', 'ink-dim', 'accent')
254
+
255
+
256
+ def render_docs_css():
257
+ out = ['/* BEGIN lanes tokens (generated by libexec/tokens.py --docs-css; do not edit by hand) */', ':root {']
258
+ out += [f' --lanes-{role}: {TOKENS["dark"][role]};' for role in DOCS_ROLES]
259
+ out += [' color-scheme: dark;', '}', '@media (prefers-color-scheme: light) {', ' :root {']
260
+ out += [f' --lanes-{role}: {TOKENS["light"][role]};' for role in DOCS_ROLES]
261
+ out += [' color-scheme: light;', ' }', '}', '/* END lanes tokens */']
262
+ return '\n'.join(out) + '\n'
263
+
264
+
245
265
  # The painted roles as the viewer's shell variables (`tokens.sh`), in PAINTED order. LZ_DIM kept its v1 name.
246
266
  SH_NAMES = {'ink': 'LZ_INK', 'ink-dim': 'LZ_DIM', 'ink-faint': 'LZ_FAINT', 'accent': 'LZ_ACCENT',
247
267
  'tone-urgent': 'LZ_URGENT', 'tone-attention': 'LZ_ATTENTION', 'tone-working': 'LZ_WORKING',
@@ -333,14 +353,16 @@ def render_ghostty(variant):
333
353
 
334
354
 
335
355
  RENDERERS = {'kdl': render_kdl, 'css': render_css, 'sh': render_sh, 'md': render_md, 'js': render_js,
336
- 'ghostty-dark': lambda: render_ghostty('dark'), 'ghostty-light': lambda: render_ghostty('light')}
356
+ 'ghostty-dark': lambda: render_ghostty('dark'), 'ghostty-light': lambda: render_ghostty('light'),
357
+ 'docs-css': render_docs_css}
337
358
  TARGETS = {'kdl': 'zellij/config.kdl', 'css': 'web/mobile.css', 'sh': 'libexec/tokens.sh', 'md': 'docs/DESIGN.md',
338
359
  'js': 'web/mobile.js', 'ghostty-dark': 'client/ghostty/lanes-palette.conf',
339
- 'ghostty-light': 'client/ghostty/lanes-palette-light.conf'}
360
+ 'ghostty-light': 'client/ghostty/lanes-palette-light.conf', 'docs-css': 'web/docs.css'}
340
361
  MARKERS = {'kdl': ('// BEGIN lanes tokens', '// END lanes tokens'), 'css': ('/* BEGIN lanes tokens', '/* END lanes tokens */'),
341
362
  'sh': ('# BEGIN lanes tokens', '# END lanes tokens'), 'md': ('<!-- BEGIN lanes tokens', '<!-- END lanes tokens -->'),
342
363
  'js': ('// BEGIN lanes tokens', '// END lanes tokens'),
343
- 'ghostty-dark': ('# BEGIN lanes tokens', '# END lanes tokens'), 'ghostty-light': ('# BEGIN lanes tokens', '# END lanes tokens')}
364
+ 'ghostty-dark': ('# BEGIN lanes tokens', '# END lanes tokens'), 'ghostty-light': ('# BEGIN lanes tokens', '# END lanes tokens'),
365
+ 'docs-css': ('/* BEGIN lanes tokens', '/* END lanes tokens */')}
344
366
 
345
367
 
346
368
  def render(kind):
@@ -392,7 +414,7 @@ def main(argv):
392
414
  return 0
393
415
  kind = argv[0].lstrip('-') if argv else ''
394
416
  if kind not in RENDERERS:
395
- sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--js|--ghostty-dark|--ghostty-light|--check|--write')
417
+ sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--js|--ghostty-dark|--ghostty-light|--docs-css|--check|--write')
396
418
  sys.stdout.write(render(kind))
397
419
  return 0
398
420